diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 325328ec8..589fd8b00 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -966,6 +966,9 @@ export const LIB_FN_SYMS: Record = { "nodeIsland.construct": "scr_node_island_construct", "nodeIsland.instanceof": "scr_node_island_instanceof", "nodeIsland.classMethods": "scr_node_island_class_methods", + "nodeIsland.share": "scr_node_island_share", + "nodeIsland.sharedTdz": "scr_node_island_shared_tdz", + "nodeIsland.classMethodsOf": "scr_node_island_class_methods_of", "dyn.keySet": "scr_dyn_key_set", "dyn.keySetComputed": "scr_dyn_key_set_computed", "dyn.keyDelete": "scr_dyn_key_delete", diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 71db0c492..bdbe2724e 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -258,6 +258,44 @@ export function nativeSubclassOfIslandDiag(loc: SrcLoc): ScrDiagnostic { }; } +/** SC8020 — a stateful module the program compiles is duplicated across + * the Node island boundary (an explicit "duplicate", or a form NI-6 cannot + * share yet): each side has its own state. */ +export function sharedModuleDuplicatedDiag( + module: string, + why: string, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8020", + message: `module '${module}' is duplicated across the Node island boundary although it is stateful: ${why}; the program and its islands each see their own copy`, + loc, + hint: 'remove "share": "duplicate" from its scriptc.packages entry to share one instance', + }; +} + +/** SC8022 — island code extends a class of a module the program compiles. + * A native instance cannot take an island subclass's overrides, so the + * module is not owned by the program: a package runs in Node for both + * sides (the build retries with it as a Node island); a program module is + * refused. */ +export function sharedClassExtendedDiag( + pkg: string | null, + file: string, + base: string, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8022", + message: + pkg !== null + ? `package '${pkg}' is shared with a Node island that extends its class '${base}': it runs in Node for both sides` + : `module '${file}' cannot be shared with a Node island that extends its class '${base}' (island subclasses of native classes)`, + loc, + hint: "move the subclass into the program, or mark the module as a Node island", + }; +} + /** SC8002 — Node islands are off (or limited to marked islands) and an * npm package is outside the native subset. */ export function nodeIslandsOffDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic { diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 5952f15ce..ed4ca9f27 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -14,6 +14,11 @@ import * as ts from "../ts7/adapter.js"; import { collectRebindableFunctions, isRebindableFunction } from "./rebindable-functions.js"; import { bindingSource } from "../binding-source.js"; import type { Lowerer } from "./lowerer.js"; +import { + sharedInitFlagStores, + sharedModulePlans, + sharedModuleRegistrations, +} from "./lower-shared-modules.js"; import { dirname as dirnamePath, resolve as resolvePath } from "node:path"; import { packageNameOfPath, probeNodeImportRefusal } from "../npm.js"; import { isNpmStaticPackage } from "../npm-static.js"; @@ -2973,6 +2978,10 @@ export function lowerFileInit( }); } if (sf === lowerer.entry) header.push(...lowerer.punycodeLinkWarning()); + // Modules shared with Node islands register before any module runs + // (NI-6); the plan also creates their declarations' TDZ flags. + if (sharedModulePlans(lowerer).length > 0 && sf === lowerer.entry) + header.push(...sharedModuleRegistrations(lowerer)); // Top-level `this` of a CommonJS module: its object exists before the // module's first statement (lowerCjsTopLevelThis). const exportsThis = lowerer.cjsExportsThis.get(sf); @@ -3209,6 +3218,7 @@ export function lowerFileInit( stmt, deferred ? [deferred] : lowerer.lowerStmts([stmt]), ); + if (lowerer.sharedInitFlags.size > 0) stmtIr.push(...sharedInitFlagStores(lowerer, stmt)); // Class EXPRESSIONS inside this statement queued their static // inits while it lowered: they land immediately before it — JS's // order for the supported whole-initializer positions. diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 24c3c91cc..823ca1384 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -590,8 +590,27 @@ function withClassMethods( className: string, blame: ts.Node, ): IrExpr { + const builder = classMethodsBuilderOf(lowerer, className, blame, value.loc); + if (builder === null) return value; + return { + kind: "libCall", + fn: "nodeIsland.classMethods", + args: [value, builder], + type: DYN, + loc: value.loc, + }; +} + +/** The builder of a class's method table (a dyn closure answering + * {name: unbound method value}), or null for a class without methods. */ +function classMethodsBuilderOf( + lowerer: Lowerer, + className: string, + blame: ts.Node, + loc: SrcLoc, +): IrExpr | null { const info = lowerer.classes.get(className); - if (info === undefined || info.def.runtime) return value; + if (info === undefined || info.def.runtime) return null; const names: string[] = []; for ( let c: typeof info | undefined = info; @@ -601,8 +620,7 @@ function withClassMethods( for (const m of c.def.methods ?? []) if (!names.includes(m) && m !== "constructor") names.push(m); } - if (names.length === 0) return value; - const loc = value.loc; + if (names.length === 0) return null; const builder = `%island.methods:${className}`; if (!lowerer.classMethodValueHelpers.has(builder)) { lowerer.classMethodValueHelpers.add(builder); @@ -631,13 +649,75 @@ function withClassMethods( type: funcTypeFromParamShapes([], DYN), loc, }; - return { - kind: "libCall", - fn: "nodeIsland.classMethods", - args: [value, lowerer.coerceToExpected(closure, DYN)], - type: DYN, + return lowerer.coerceToExpected(closure, DYN); +} + +/** NI-6: the method-table builder of the class a shared module exports + * (`ident` is its declaration's name), or null. */ +export function classMethodsBuilder( + lowerer: Lowerer, + ident: ts.Identifier, + loc: SrcLoc, +): IrExpr | null { + const decl = ident.parent; + if (decl === undefined || !ts.isClassDeclaration(decl)) return null; + return classMethodsBuilderOf(lowerer, lowerer.classNamer(decl), ident, loc); +} + +/** NI-6: `new C(...)` from island code for the class a shared module + * exports: a function with the constructor's parameters (its dyn thunk + * checks the island's arguments) that constructs an instance, as a dyn + * closure, or null when the constructor's shape is not a plain list of + * parameters (the island's `new` then throws). */ +export function classConstructHelper( + lowerer: Lowerer, + ident: ts.Identifier, + loc: SrcLoc, +): IrExpr | null { + const decl = ident.parent; + if (decl === undefined || !ts.isClassDeclaration(decl)) return null; + const className = lowerer.classNamer(decl); + const info = lowerer.classes.get(className); + if (info === undefined || info.def.runtime || info.localClass || info.generic) return null; + for (let c: typeof info | null = info; c; c = c.base) if (c.def.baseValueGlobal) return null; + if (lowerer.inheritsBuiltinErrorCtor(info)) return null; + const shapes = info.ctorParams; + if (shapes.some((s) => s.mode !== "required" && s.mode !== "omittable")) return null; + const name = `%island.new:${className}`; + if (!lowerer.classMethodValueHelpers.has(name)) { + lowerer.classMethodValueHelpers.add(name); + lowerer.noteEdge(`%${className}.constructor`); + const params = shapes.map((s, i) => ({ localId: `p${i}`, name: `p${i}`, type: s.type })); + const objectType: IrType = { kind: "object", className }; + lowerer.liftedFns.push({ + name, + params, + returnType: objectType, + locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + body: [ + { + kind: "return", + value: { + kind: "new", + className, + args: params.map((p) => varRef(p.localId, p.type, loc)), + type: objectType, + loc, + }, + loc, + }, + ], + loc, + }); + } + const closure: IrExpr = { + kind: "closure", + fnName: name, + captures: [], + type: funcTypeFromParamShapes(shapes, { kind: "object", className }), loc, }; + return lowerer.coerceToExpected(closure, DYN); } /** Native values NI-5 exports into the island by reference: functions, diff --git a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts new file mode 100644 index 000000000..4a63beb91 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts @@ -0,0 +1,529 @@ +/* Shared modules between the program and its Node islands (scope-B NI-6, + * DESIGN.md §2.6). A module the program compiles that island code also + * imports would otherwise exist twice, with two states. Rule: each module + * has exactly one owner. + * + * - Program modules (user code): always shared, owned by the program. + * - npm packages compiled natively and imported by island code: shared + * when stateful (module-level state written after evaluation, exported + * classes, evaluation effects), duplicated when pure (each side has its + * own copy, which nobody can observe). `scriptc.packages..share` + * ("shared" | "duplicate") overrides; duplicating a stateful package is + * SC8020, a warning, because the two copies diverge. + * + * A shared module is registered at program start (nodeIsland.share, never + * loads Node) with its accessor: a generated function that answers the + * current values of its exports as a dyn array, the TDZ sentinel for a let + * or const whose declaration has not run yet (a flag the module's %init + * sets right after the declaration). The bootstrap's loader hooks + * (runtime scr_node_island_hooks.cjs) resolve island imports of the module + * to a generated ES module over those values. + * + * Island code is found syntactically: the files requireModule() loads and + * every JavaScript file of an island package, then the files they import + * that the program does not compile (Node's side of the closure). An + * import that resolves to a compiled module makes it a candidate. A + * specifier inside a comment or string can only add a candidate (an extra + * shared module), never lose one. */ +import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { resolveFrom } from "../../node-islands/resolve.js"; +import { join } from "node:path"; +import * as ts from "../ts7/adapter.js"; +import { locOf, isCjsJsFile } from "../program.js"; +import { PoisonError, newFnCtx, type Lowerer } from "./lowerer.js"; +import { BOOL, DYN, STRING, VOID, type IrExpr, type IrStmt, type SrcLoc } from "../../ir/ir.js"; +import { resolveBareModule } from "../resolve.js"; +import { packageDirOf } from "../../node-islands/origin.js"; +import { readProjectConfig } from "../../project-config.js"; +import { + sharedClassExtendedDiag, + sharedModuleDuplicatedDiag, +} from "../../diagnostics/diagnostic.js"; +import { classConstructHelper, classMethodsBuilder } from "./lower-node-island.js"; +import { dynUndefinedExpr } from "../../ir/build.js"; +import { funcTypeFromParamShapes } from "./call-signatures.js"; + +type ExportKind = "function" | "class" | "let" | "const" | "var"; + +interface SharedExport { + name: string; + kind: ExportKind; + /** The declaration's name: its reference is the export's value. */ + ident: ts.Identifier; + /** let/const: the flag global set after the declaration ran. */ + flag?: string; +} + +export interface SharedModulePlan { + sf: ts.SourceFile; + /** The real path Node resolves the module to. */ + path: string; + exports: SharedExport[]; +} + +const MAX_ISLAND_FILES = 4000; +const SPECIFIERS = [ + /\bfrom\s*["']([^"'\n]+)["']/g, + /\bimport\s*["']([^"'\n]+)["']/g, + /\brequire\s*\(\s*["']([^"'\n]+)["']\s*\)/g, + /\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/g, +]; +const REQUIRE_MODULE = /\brequireModule\s*\(\s*["']([^"'\n]+)["']\s*\)/g; + +function real(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +function isJsFile(path: string): boolean { + return /\.(?:[cm]?js)$/.test(path); +} + +/** The files `spec` may name from `from` (require and import conditions). */ +function resolveAll(from: string, spec: string): string[] { + if (spec.startsWith("node:")) return []; + const out = new Set(); + try { + out.add(real(resolveFrom(from, spec))); + } catch { + /* not resolvable for require */ + } + if (!spec.startsWith(".") && !spec.startsWith("/")) { + for (const kind of ["import", "require"] as const) { + const js = resolveBareModule(from, spec, "js-only", kind); + if (js !== null) out.add(real(js.typesFile)); + } + } + return [...out]; +} + +function packageJsFiles(dir: string, out: string[]): void { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return; + } + for (const name of names) { + if (out.length >= MAX_ISLAND_FILES) return; + if (name === "node_modules" || name.startsWith(".")) continue; + const path = join(dir, name); + let isDirectory: boolean; + try { + isDirectory = statSync(path).isDirectory(); + } catch { + continue; + } + if (isDirectory) packageJsFiles(path, out); + else if (isJsFile(name)) out.push(real(path)); + } +} + +function isExported(stmt: ts.Node): boolean { + const mods = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined; + return mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true; +} + +function isDefaultExport(stmt: ts.Node): boolean { + const mods = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined; + return mods?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) === true; +} + +/** The top-level declarations of a module by name. */ +function localDeclarations( + sf: ts.SourceFile, +): Map { + const out = new Map(); + for (const stmt of sf.statements.nodes) { + if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body) + out.set(stmt.name.text, { kind: "function", ident: stmt.name }); + else if (ts.isClassDeclaration(stmt) && stmt.name) + out.set(stmt.name.text, { kind: "class", ident: stmt.name }); + else if (ts.isVariableStatement(stmt)) { + const flags = stmt.declarationList.flags; + const kind: ExportKind = + (flags & ts.NodeFlags.Const) !== 0 + ? "const" + : (flags & ts.NodeFlags.Let) !== 0 + ? "let" + : "var"; + for (const d of stmt.declarationList.declarations.nodes) + if (ts.isIdentifier(d.name)) out.set(d.name.text, { kind, ident: d.name }); + } + } + return out; +} + +/** The module's value exports (re-exports and default exports of + * expressions are not shared: NOTES.md shortcut). */ +function moduleExports(sf: ts.SourceFile): SharedExport[] { + const locals = localDeclarations(sf); + const out: SharedExport[] = []; + for (const stmt of sf.statements.nodes) { + if (ts.isExportDeclaration(stmt)) { + if (stmt.moduleSpecifier || stmt.isTypeOnly || !stmt.exportClause) continue; + if (!ts.isNamedExports(stmt.exportClause)) continue; + for (const el of stmt.exportClause.elements.nodes) { + if (el.isTypeOnly) continue; + const local = locals.get((el.propertyName ?? el.name).text); + if (local !== undefined) out.push({ name: el.name.text, ...local }); + } + continue; + } + if (!isExported(stmt)) continue; + const def = isDefaultExport(stmt); + if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body) + out.push({ name: def ? "default" : stmt.name.text, kind: "function", ident: stmt.name }); + else if (ts.isClassDeclaration(stmt) && stmt.name) + out.push({ name: def ? "default" : stmt.name.text, kind: "class", ident: stmt.name }); + else if (ts.isVariableStatement(stmt)) { + const flags = stmt.declarationList.flags; + const kind: ExportKind = + (flags & ts.NodeFlags.Const) !== 0 + ? "const" + : (flags & ts.NodeFlags.Let) !== 0 + ? "let" + : "var"; + for (const d of stmt.declarationList.declarations.nodes) + if (ts.isIdentifier(d.name)) out.push({ name: d.name.text, kind, ident: d.name }); + } + } + return out; +} + +/** The name of a class imported from `spec` that the island file extends + * (`class X extends Base` or `extends ns.Base`), or null. */ +function extendedImport(text: string, spec: string): string | null { + const quoted = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const names: string[] = []; + const named = new RegExp( + `import\\s*(?:(\\w+)\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*["']${quoted}["']`, + "g", + ); + for (const m of text.matchAll(named)) { + if (m[1] !== undefined) names.push(m[1]); + for (const part of m[2]!.split(",")) { + const local = part + .trim() + .split(/\s+as\s+/) + .at(-1) + ?.trim(); + if (local) names.push(local); + } + } + const star = new RegExp(`import\\s*\\*\\s*as\\s+(\\w+)\\s*from\\s*["']${quoted}["']`, "g"); + for (const m of text.matchAll(star)) names.push(`${m[1]!}\\.\\w+`); + const def = new RegExp(`import\\s+(\\w+)\\s+from\\s*["']${quoted}["']`, "g"); + for (const m of text.matchAll(def)) names.push(m[1]!); + for (const name of names) { + const m = new RegExp(`\\bclass\\s+[\\w$]*\\s*extends\\s+(${name})\\b`).exec(text); + if (m !== null) return m[1]!; + } + return null; +} + +const MUTATING_METHODS = new Set([ + "set", + "add", + "delete", + "clear", + "push", + "pop", + "shift", + "unshift", + "splice", + "sort", + "reverse", + "fill", + "copyWithin", +]); + +/** Why a module is stateful (one reason), or null when it is pure: no + * module-level binding reachable from its exports is written after + * evaluation, no exported class (its instances may cross or meet + * instanceof in island code), and no top-level effect beyond declarations. */ +export function statefulReason(sf: ts.SourceFile): string | null { + const moduleLevel = new Map(); // name -> kind + for (const stmt of sf.statements.nodes) { + if (ts.isClassDeclaration(stmt) && isExported(stmt)) + return `it exports the class '${stmt.name?.text ?? "default"}'`; + if (ts.isVariableStatement(stmt)) { + const flags = stmt.declarationList.flags; + for (const d of stmt.declarationList.declarations.nodes) { + if (!ts.isIdentifier(d.name)) continue; + if ((flags & ts.NodeFlags.Const) === 0) { + if (isExported(stmt)) return `it exports the mutable binding '${d.name.text}'`; + moduleLevel.set(d.name.text, "let"); + } else { + moduleLevel.set(d.name.text, "const"); + } + } + continue; + } + if ( + ts.isFunctionDeclaration(stmt) || + ts.isClassDeclaration(stmt) || + ts.isImportDeclaration(stmt) || + ts.isExportDeclaration(stmt) || + ts.isInterfaceDeclaration(stmt) || + ts.isTypeAliasDeclaration(stmt) || + ts.isExportAssignment(stmt) || + ts.isEmptyStatement(stmt) + ) + continue; + if (ts.isExpressionStatement(stmt) && ts.isStringLiteral(stmt.expression)) continue; // directives + return "its evaluation has effects beyond declarations"; + } + // Writes from function bodies to module-level bindings. + let reason: string | null = null; + const visit = (node: ts.Node, inFunction: boolean): void => { + if (reason !== null) return; + const fn = ts.isFunctionLike(node); + if (inFunction) { + if (ts.isBinaryExpression(node) && ts.isAssignmentOperator(node.operatorToken.kind)) { + let target: ts.Node = node.left; + while (ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) + target = target.expression; + if (ts.isIdentifier(target) && moduleLevel.has(target.text)) + reason = `its function bodies write the module-level binding '${target.text}'`; + } else if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + ts.isIdentifier(node.operand) && + moduleLevel.has(node.operand.text) + ) { + reason = `its function bodies write the module-level binding '${node.operand.text}'`; + } else if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + moduleLevel.has(node.expression.expression.text) && + MUTATING_METHODS.has(node.expression.name.text) + ) { + reason = `its function bodies mutate the module-level '${node.expression.expression.text}'`; + } + } + ts.forEachChild(node, (child) => visit(child, inFunction || fn)); + }; + visit(sf, false); + return reason; +} + +/** The modules the program shares with its islands (computed once). */ +export function sharedModulePlans(lowerer: Lowerer): SharedModulePlan[] { + if (lowerer.sharedModules !== null) return lowerer.sharedModules; + const plans: SharedModulePlan[] = []; + lowerer.sharedModules = plans; + const compiled = new Map(); + for (const sf of lowerer.moduleOrder) { + if (sf.isDeclarationFile) continue; + compiled.set(real(sf.fileName), sf); + } + // Island roots: requireModule() targets and island package files. + const queue: string[] = []; + for (const sf of lowerer.moduleOrder) { + const text = sf.text; + if (!text.includes("requireModule")) continue; + const from = real(sf.fileName); + for (const m of text.matchAll(REQUIRE_MODULE)) { + try { + queue.push(real(resolveFrom(from, m[1]!))); + } catch { + /* lowering reports it */ + } + } + } + for (const island of lowerer.nodeIslands?.packages.values() ?? []) + packageJsFiles(island.dir, queue); + if (queue.length === 0) return plans; + const seen = new Set(); + const candidates = new Set(); + // Classes island code extends, by the compiled module that exports them. + const extended = new Map(); + while (queue.length > 0 && seen.size < MAX_ISLAND_FILES) { + const file = queue.pop()!; + if (seen.has(file) || compiled.has(file) || !isJsFile(file)) continue; + seen.add(file); + let text: string; + try { + text = readFileSync(file, "utf8"); + } catch { + continue; + } + for (const re of SPECIFIERS) { + for (const m of text.matchAll(re)) { + for (const target of resolveAll(file, m[1]!)) { + const sf = compiled.get(target); + if (sf !== undefined) { + candidates.add(sf); + const base = extendedImport(text, m[1]!); + if (base !== null && !extended.has(sf)) extended.set(sf, base); + } else if (!seen.has(target)) queue.push(target); + } + } + } + } + if (candidates.size === 0) return plans; + const config = readProjectConfig(lowerer.entry.fileName); + for (const sf of lowerer.moduleOrder) { + if (!candidates.has(sf)) continue; + const path = real(sf.fileName); + const pkg = packageDirOf(path); + const loc: SrcLoc = { file: sf.fileName, start: 0, end: 0 }; + const share = pkg === null ? "shared" : (config.packages.get(pkg.name)?.share ?? "auto"); + const stateful = pkg === null ? "it is a program module" : statefulReason(sf); + const base = extended.get(sf); + if (base !== undefined) { + // Island code extends one of its classes: a native instance cannot + // take an island subclass's overrides, so the module cannot be owned + // by the program. A package then runs in Node for both sides + // (Node-owned, through handles; the build retries with it as an + // island); a program module is refused. + lowerer.pushDiag(sharedClassExtendedDiag(pkg?.name ?? null, sf.fileName, base, loc)); + continue; + } + if (isCjsJsFile(sf, lowerer.program)) { + // Shortcut: CommonJS modules are not shared yet; each side keeps its + // copy, so a stateful one is reported. + if (stateful !== null) + lowerer.sharedModuleWarnings.push( + sharedModuleDuplicatedDiag( + pkg?.name ?? sf.fileName, + `${stateful}, and CommonJS modules cannot be shared yet`, + loc, + ), + ); + continue; + } + if (share === "duplicate" || (share === "auto" && stateful === null)) { + if (stateful !== null) + lowerer.sharedModuleWarnings.push( + sharedModuleDuplicatedDiag( + pkg?.name ?? sf.fileName, + `${stateful}, and it is marked "share": "duplicate"`, + loc, + ), + ); + continue; + } + const exports = moduleExports(sf); + for (const e of exports) { + if (e.kind !== "let" && e.kind !== "const") continue; + const flag = `%g.shared.${plans.length}.${e.name}%initialized`; + lowerer.globalsList.push({ id: flag, name: "%initialized", type: BOOL, mutable: true }); + e.flag = flag; + let stmt: ts.Node | undefined = e.ident.parent; + while (stmt !== undefined && !ts.isVariableStatement(stmt)) stmt = stmt.parent; + if (stmt !== undefined) { + const list = lowerer.sharedInitFlags.get(stmt as ts.Statement) ?? []; + list.push(flag); + lowerer.sharedInitFlags.set(stmt as ts.Statement, list); + } + } + plans.push({ sf, path, exports }); + } + if (plans.length > 0) lowerer.usesNodeIsland = true; + return plans; +} + +/** The flag stores after a shared module's declaration statement. */ +export function sharedInitFlagStores(lowerer: Lowerer, stmt: ts.Statement): IrStmt[] { + const flags = lowerer.sharedInitFlags.get(stmt); + if (flags === undefined) return []; + const loc = locOf(stmt); + return flags.map((flag) => ({ + kind: "assign", + localId: flag, + value: { kind: "boolLit", value: true, type: BOOL, loc }, + loc, + })); +} + +/** The registrations the entry's %init runs first: one nodeIsland.share + * per shared module, with its accessor. */ +export function sharedModuleRegistrations(lowerer: Lowerer): IrStmt[] { + const plans = sharedModulePlans(lowerer); + const out: IrStmt[] = []; + plans.forEach((plan, index) => { + const loc: SrcLoc = { file: plan.sf.fileName, start: 0, end: 0 }; + const name = `%island.shared:${index}`; + const ctx = newFnCtx(false, null, null, DYN); + lowerer.fnStack.push(ctx); + const elems: IrExpr[] = []; + const kept: SharedExport[] = []; + try { + for (const e of plan.exports) { + let value: IrExpr; + try { + value = lowerer.coerceToExpected(lowerer.lowerExpr(e.ident), DYN); + } catch (error) { + if (!(error instanceof PoisonError)) throw error; + continue; + } + if (e.kind === "class") { + const builder = classMethodsBuilder(lowerer, e.ident, loc) ?? dynUndefinedExpr(loc); + const construct = classConstructHelper(lowerer, e.ident, loc) ?? dynUndefinedExpr(loc); + value = { + kind: "libCall", + fn: "nodeIsland.classMethodsOf", + args: [value, builder, construct], + type: DYN, + loc, + }; + } + if (e.flag !== undefined) { + value = { + kind: "ternary", + cond: { kind: "varRef", localId: e.flag, type: BOOL, loc }, + then: value, + else_: { kind: "libCall", fn: "nodeIsland.sharedTdz", args: [], type: DYN, loc }, + type: DYN, + loc, + }; + } + elems.push(value); + kept.push(e); + } + } finally { + lowerer.fnStack.pop(); + } + lowerer.liftedFns.push({ + name, + params: [], + returnType: DYN, + locals: ctx.locals, + body: [{ kind: "return", value: { kind: "dynArrLit", elems, type: DYN, loc }, loc }], + loc, + }); + const exportsJson = JSON.stringify(kept.map((e) => ({ name: e.name, kind: e.kind }))); + out.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "nodeIsland.share", + args: [ + { kind: "strLit", value: plan.path, type: STRING, loc }, + { kind: "strLit", value: exportsJson, type: STRING, loc }, + lowerer.coerceToExpected( + { + kind: "closure", + fnName: name, + captures: [], + type: funcTypeFromParamShapes([], DYN), + loc, + }, + DYN, + ), + ], + type: VOID, + loc, + }, + loc, + }); + }); + return out; +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 77cc10cc0..7c6f5b7c6 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1224,7 +1224,10 @@ function lowerToIrWithIslands( } // Divergence warnings over the program's own modules: computed once, // after lowering, from the same checker (never part of a failure). - const divergences = scanDivergences(resultLowerer, moduleOrder); + const divergences = [ + ...scanDivergences(resultLowerer, moduleOrder), + ...resultLowerer.sharedModuleWarnings, + ]; timing("divergence-scan", { found: divergences.length }); if (divergences.length > 0) result = { ...result, divergences }; // The performance warning (SC6101): likely-hot functions of the program @@ -3106,6 +3109,11 @@ export class Lowerer { /** Island module loads, by the importing file and specifier: the module * global holding what the import form sees (lower-node-island.ts). */ readonly islandModules = new Map(); + /** NI-6: the modules shared with Node islands (lower-shared-modules.ts), + * their SC8020 warnings, and the TDZ flags their declarations set. */ + sharedModules: import("./lower-shared-modules.js").SharedModulePlan[] | null = null; + readonly sharedModuleWarnings: ScrDiagnostic[] = []; + readonly sharedInitFlags = new Map(); /** Per-directory answers of experimental-import.ts's declaration-file test. */ readonly experimentalFiles = new Map(); /** Interned LifetimeRegion.run helpers, by callback type key. */ diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index e22569d11..26e4cc081 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -22,6 +22,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "nodeIsland.construct", "nodeIsland.instanceof", "nodeIsland.classMethods", + "nodeIsland.classMethodsOf", // Node's argument, cipher-name, IV, tag-length and key-length errors. "crypto.cipherNew", // Node's curve, key, point, encoding and format errors. diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index f619a1f1b..ecdb7e8a1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -204,6 +204,14 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** NI-5: a class instance crossing into an island, with the builder of * its class's method table (run once per class). Answers the value. */ "nodeIsland.classMethods": { argTypes: [DYN, DYN], result: DYN }, + /** NI-6 shared modules: registers a module the program shares with its + * islands (real path, exports JSON, accessor); the TDZ sentinel an + * accessor answers; the method table and constructor helper of an + * exported class by its class value (either may be undefined; answers + * the class value). */ + "nodeIsland.share": { argTypes: [STRING, STRING, DYN], result: VOID }, + "nodeIsland.sharedTdz": { argTypes: [], result: DYN }, + "nodeIsland.classMethodsOf": { argTypes: [DYN, DYN, DYN], result: DYN }, /** Keyed WRITE on a dyn value — `h.onDone = cb` / `h["k"] = v` on a * checked-dynamic object (args: receiver, key string, value — all * borrowed; the runtime copies the key and retains the value in). An diff --git a/packages/compiler/src/node-islands/libnode.ts b/packages/compiler/src/node-islands/libnode.ts index 22642015f..c12ecd4e5 100644 --- a/packages/compiler/src/node-islands/libnode.ts +++ b/packages/compiler/src/node-islands/libnode.ts @@ -23,7 +23,7 @@ import { findInstalledLibnodePack, hostLibnodeTarget, libnodePackPackage } from export const NODE_ISLAND_MIN_NODE = "24.13.1"; /** The bridge ABI this compiler's runtime speaks (scr_node_bridge.h). */ -export const NODE_BRIDGE_ABI = 6; +export const NODE_BRIDGE_ABI = 7; /** The prefix of a recorded path relative to the executable's directory. */ export const ORIGIN = "$ORIGIN/"; diff --git a/packages/compiler/src/node-islands/snapshot.ts b/packages/compiler/src/node-islands/snapshot.ts index 650c5d5f3..bde0f9674 100644 --- a/packages/compiler/src/node-islands/snapshot.ts +++ b/packages/compiler/src/node-islands/snapshot.ts @@ -272,7 +272,7 @@ globalThis.__scriptcNodeIsland = { }, setTimer: noop, clearTimer: noop, refTimer: noop, refreshTimer: noop, setImmediate: noop, clearImmediate: noop, refImmediate: noop, - nextTick: noop, emit: noop, load: noop, + nextTick: noop, emit: noop, load: noop, sharedPush: noop, }; const fnToString = Function.prototype.toString; const hasOwn = Object.prototype.hasOwnProperty; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6850-node-island-shared-singleton/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6850-node-island-shared-singleton/main.mjs.json new file mode 100644 index 000000000..0e4388f5a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6850-node-island-shared-singleton/main.mjs.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6850-node-island-shared-singleton/state.mjs", + "/tests/corpus/6850-node-island-shared-singleton/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6851-node-island-shared-class/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6851-node-island-shared-class/main.mjs.json new file mode 100644 index 000000000..d8980e1f1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6851-node-island-shared-class/main.mjs.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6851-node-island-shared-class/shapes.mjs", + "/tests/corpus/6851-node-island-shared-class/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6852-node-island-shared-cycle/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6852-node-island-shared-cycle/main.mjs.json new file mode 100644 index 000000000..bcb9ef07d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6852-node-island-shared-cycle/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6852-node-island-shared-cycle/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6853-node-island-shared-registry/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6853-node-island-shared-registry/main.mjs.json new file mode 100644 index 000000000..4aac1857f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6853-node-island-shared-registry/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6853-node-island-shared-registry/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6854-node-island-shared-registry-native/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6854-node-island-shared-registry-native/main.mjs.json new file mode 100644 index 000000000..5f3f514d3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6854-node-island-shared-registry-native/main.mjs.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6854-node-island-shared-registry-native/registry.mjs", + "/tests/corpus/6854-node-island-shared-registry-native/main.mjs" + ], + "diags": [] +} diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index 362c6bf0c..0571e75ac 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -70,6 +70,10 @@ Global g_set_timer, g_clear_timer, g_ref_timer, g_refresh_timer; Global g_set_immediate, g_clear_immediate, g_ref_immediate; Global g_next_tick, g_emit; Global g_load; // the bootstrap's module loader (NI-4) +Global g_shared_push; // the loader hooks' binding updates (NI-6) +void SharedTable(const FunctionCallbackInfo &info); +void SharedGet(const FunctionCallbackInfo &info); +void SharedTdz(const FunctionCallbackInfo &info); char *Copy(const std::string &text, size_t *len) { char *out = static_cast(std::malloc(text.size() + 1)); @@ -237,6 +241,9 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar SetMethod(context, native, "settle", Settle); SetMethod(context, native, "run", Run); SetMethod(context, native, "listen", Listen); + SetMethod(context, native, "sharedTable", SharedTable); + SetMethod(context, native, "sharedGet", SharedGet); + SetMethod(context, native, "sharedTdz", SharedTdz); global->Set(context, Utf8(isolate, "__scriptcNodeIslandHost"), native).Check(); TryCatch try_catch(isolate); if (node::LoadEnvironment(g_setup->env(), bootstrap).IsEmpty()) { @@ -269,7 +276,8 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar !TakeFunction(isolate, context, d, "refImmediate", &g_ref_immediate) || !TakeFunction(isolate, context, d, "nextTick", &g_next_tick) || !TakeFunction(isolate, context, d, "emit", &g_emit) || - !TakeFunction(isolate, context, d, "load", &g_load)) + !TakeFunction(isolate, context, d, "load", &g_load) || + !TakeFunction(isolate, context, d, "sharedPush", &g_shared_push)) return Copy("the Node island bootstrap left an incomplete dispatcher", nullptr); global->Delete(context, Utf8(isolate, "__scriptcNodeIsland")).FromMaybe(false); global->Delete(context, Utf8(isolate, "__scriptcNodeIslandHost")).FromMaybe(false); @@ -596,7 +604,7 @@ void Stop() { DropAllExports(); for (Global *fn : {&g_set_timer, &g_clear_timer, &g_ref_timer, &g_refresh_timer, &g_set_immediate, - &g_clear_immediate, &g_ref_immediate, &g_next_tick, &g_emit}) + &g_clear_immediate, &g_ref_immediate, &g_next_tick, &g_emit, &g_shared_push}) fn->Reset(); isolate->Exit(); delete g_locker; @@ -865,6 +873,14 @@ void ExportCall(const FunctionCallbackInfo &info) { std::memset(&out, 0, sizeof out); int status; if (info.IsConstructCall()) { + // `class Sub extends NativeClass` in island code: the native instance + // cannot take the island prototype's overrides (NI-6 shortcut). + auto self = g_exports.find(id); + if (self != g_exports.end() && !info.NewTarget()->StrictEquals(self->second.Get(isolate))) { + isolate->ThrowException(v8::Exception::TypeError(Utf8( + isolate, "scriptc: island code cannot extend a native class of a shared module yet"))); + return; + } status = g_host->export_construct(id, args.data(), args.size(), &out); } else { ScrNodeValue self; @@ -876,6 +892,15 @@ void ExportCall(const FunctionCallbackInfo &info) { if (!result.IsEmpty()) info.GetReturnValue().Set(result); } +void ExportHasInstance(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + HostCall scope; + ScrNodeValue v; + FromV8(isolate, context, info[0], &v); + info.GetReturnValue().Set(g_host->export_instance_of(ExportIdArg(info.Data()), &v) == 1); +} + uint32_t HolderId(Local holder) { if (holder->InternalFieldCount() < 1) return 0; return ExportIdArg(holder->GetInternalField(0).As()); @@ -1055,6 +1080,14 @@ Local ExportValue(Isolate *isolate, Local context, const ScrNode fn->SetName(in->u.exp.name != nullptr ? FromWtf8(isolate, in->u.exp.name, in->u.exp.name_len) : String::Empty(isolate)); + if (in->flags & SCR_NVF_CONSTRUCTOR) { + // A native class: instanceof asks the runtime (NI-6). + fn->DefineOwnProperty(context, v8::Symbol::GetHasInstance(isolate), + Function::New(context, ExportHasInstance, data, 1).ToLocalChecked(), + static_cast(v8::DontEnum | v8::DontDelete | + v8::ReadOnly)) + .Check(); + } obj = fn; } else if (in->flags & SCR_NVF_PROMISE) { Local resolver = v8::Promise::Resolver::New(context).ToLocalChecked(); @@ -1515,6 +1548,56 @@ void DropHandle(uint32_t h) { Drop(h); } +// ── shared modules (NI-6) ────────────────────────────────────────────── +// Natives of the loader hooks (scr_node_island_hooks.cjs): the table of +// modules the program shares, the current value of one export, and the +// exports still in their temporal dead zone. + +void SharedTable(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + HostCall scope; + ScrNodeValue out; + std::memset(&out, 0, sizeof out); + g_host->shared_table(&out); + info.GetReturnValue().Set(FromWtf8(isolate, out.u.str.data, out.u.str.len)); +} + +void SharedTdz(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + HostCall scope; + ScrNodeValue out; + std::memset(&out, 0, sizeof out); + g_host->shared_tdz(&out); + info.GetReturnValue().Set(FromWtf8(isolate, out.u.str.data, out.u.str.len)); +} + +// sharedGet(mod, slot, mode, tdz): the value, or `tdz` for a binding in its +// temporal dead zone. +void SharedGet(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + HostCall scope; + ScrNodeValue out; + std::memset(&out, 0, sizeof out); + int status = g_host->shared_get(Uint32Arg(info, 0), Uint32Arg(info, 1), + static_cast(Uint32Arg(info, 2)), &out); + if (status == 3) { + info.GetReturnValue().Set(info[3]); + return; + } + Local result; + ThrowOrReturn(isolate, context, status, &out, &result); + if (!result.IsEmpty()) info.GetReturnValue().Set(result); +} + +void SharedPush(uint32_t mod, uint32_t slot, const ScrNodeValue *value) { + Enter e; + Local argv[] = {Integer::NewFromUnsigned(e.isolate, mod), + Integer::NewFromUnsigned(e.isolate, slot), + ToV8(e.isolate, e.context, value)}; + Helper(e, g_shared_push, 3, argv); +} + const ScrNodeBridge kBridge = { SCR_NODE_BRIDGE_ABI, NODE_VERSION_STRING, @@ -1551,6 +1634,7 @@ const ScrNodeBridge kBridge = { DropHandle, LiveHandles, ExportSettle, + SharedPush, }; } // namespace diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 452ccbe29..9922a09ba 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2130,6 +2130,18 @@ ScrDyn *scr_dyn_get_prototype(ScrDyn *object) { return scr_dyn_object_prototype(); } if (object == scr_builtin_array_prototype) return scr_dyn_object_prototype(); +#ifdef SCR_NODE_ISLANDS + /* An island object: its V8 prototype, through the __proto__ accessor + * (undefined for an object without Object.prototype: null). */ + if (scr_ni_is_handle(object)) { + ScrDyn *proto = scr_ni_get_str(object, "__proto__", 9); + if (proto != NULL && proto->kind == SCR_DYN_UNDEF) { + scr_dyn_release(proto); + return scr_dyn_new_null(); + } + return proto; + } +#endif if (object->kind == SCR_DYN_NULL || object->kind == SCR_DYN_UNDEF) { static const char message[] = "Cannot convert undefined or null to object"; scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); diff --git a/packages/runtime/src/scr_node_bridge.h b/packages/runtime/src/scr_node_bridge.h index ca73792bb..674a9ff68 100644 --- a/packages/runtime/src/scr_node_bridge.h +++ b/packages/runtime/src/scr_node_bridge.h @@ -17,7 +17,7 @@ #include #include -#define SCR_NODE_BRIDGE_ABI 6u +#define SCR_NODE_BRIDGE_ABI 7u /* The symbol a bridge library exports: `const ScrNodeBridge *(void)`. */ #define SCR_NODE_BRIDGE_ENTRY "scr_node_bridge_v1" @@ -200,6 +200,24 @@ typedef struct ScrNodeBridgeHost { void (*export_watch)(uint32_t id); /* V8 collected the representative of `id`. */ void (*export_release)(uint32_t id); + /* `value instanceof ` for a native class representative + * (its Symbol.hasInstance): 1 or 0. */ + int (*export_instance_of)(uint32_t id, const ScrNodeValue *value); + /* ── shared modules (NI-6): modules the program compiled that island + * code imports resolve, through the loader hooks of the bootstrap, to + * generated modules over the live native exports. ── */ + /* The table, as SCR_NV_STRING JSON: [{"path","exports":[{"name","kind"}]}] + * (index = module id, export index = slot). */ + void (*shared_table)(ScrNodeValue *out); + /* The current value of a shared export. mode 0: read; 1: read and bind + * (the generated module holds it: later native changes are pushed with + * bridge->shared_push); 2: bind late (the binding is in its temporal + * dead zone: it is pushed once the native declaration ran). 0: *out is + * the value; 1: threw (*out thrown); 3: in its temporal dead zone. */ + int (*shared_get)(uint32_t mod, uint32_t slot, int mode, ScrNodeValue *out); + /* The exports in their temporal dead zone now, as SCR_NV_STRING JSON + * [[mod, slot], ...]. */ + void (*shared_tdz)(ScrNodeValue *out); } ScrNodeBridgeHost; typedef struct ScrNodeBridge { @@ -290,6 +308,9 @@ typedef struct ScrNodeBridge { uint32_t (*live_handles)(void); /* Settles the V8 promise representing native promise `id` (NI-5). */ void (*export_settle)(uint32_t id, int rejected, const ScrNodeValue *value); + /* NI-6: the native value of shared export (mod, slot) changed: the + * generated module's binding takes `value`. */ + void (*shared_push)(uint32_t mod, uint32_t slot, const ScrNodeValue *value); } ScrNodeBridge; #endif /* SCR_NODE_BRIDGE_H */ diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c index fe27acd0c..1a6ce307f 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -347,6 +347,9 @@ static void ni_on_node_stack(void (*job)(void *), void *arg) { job(arg); return; } + /* Island code is about to run: it sees the current native values of + * shared exports (NI-6). */ + scr_ni_shared_refresh(); if (ni_stack == NULL) { ni_stack = mmap(NULL, NI_STACK, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); if (ni_stack == MAP_FAILED) ni_oom(); @@ -446,6 +449,7 @@ static void ni_host_exit(int code) { static void ni_host_run(uint32_t kind, uint32_t token) { NI_MARK_BEGIN; scr_loop_hosted_run(kind, token); + scr_ni_shared_refresh(); NI_MARK_END; } @@ -489,6 +493,10 @@ static const ScrNodeBridgeHost ni_host = { &scr_ni_host_export_keys, &scr_ni_host_export_watch, &scr_ni_host_export_release, + &scr_ni_host_export_instance_of, + &scr_ni_host_shared_table, + &scr_ni_host_shared_get, + &scr_ni_host_shared_tdz, }; void scr_node_island_emit(uint32_t event, uint32_t listener, ScrStr *name, int arg) { diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 5b12ef07f..9e818c3c3 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -119,7 +119,18 @@ int scr_ni_host_export_delete(uint32_t id, const struct ScrNodeValue *key, struc int scr_ni_host_export_keys(uint32_t id, struct ScrNodeValue *out); void scr_ni_host_export_watch(uint32_t id); void scr_ni_host_export_release(uint32_t id); +/* ── shared modules (scope-B NI-6, scr_node_island_export.c) ── */ +void scr_node_island_share(ScrStr *path, ScrStr *exports, ScrDyn *accessor); +ScrDyn *scr_node_island_shared_tdz(void); +ScrDyn *scr_node_island_class_methods_of(ScrDyn *cls, ScrDyn *builder, ScrDyn *ctor); +void scr_ni_shared_refresh(void); void scr_ni_exports_release_all(void); /* at exit, after Node stopped */ +void *scr_ni_value_out_begin(ScrDyn *v, struct ScrNodeValue *out, bool *ok); +void scr_ni_value_out_end(void *keep); +int scr_ni_host_export_instance_of(uint32_t id, const struct ScrNodeValue *value); +void scr_ni_host_shared_table(struct ScrNodeValue *out); +int scr_ni_host_shared_get(uint32_t mod, uint32_t slot, int mode, struct ScrNodeValue *out); +void scr_ni_host_shared_tdz(struct ScrNodeValue *out); /* Promise settlement callback (SCR_NODE_ISLANDS build of scr_async.c). */ void scr_promise_on_settle_sync(ScrPromise *in, void (*cb)(ScrPromise *dst, ScrPromise *src)); diff --git a/packages/runtime/src/scr_node_island_export.c b/packages/runtime/src/scr_node_island_export.c index 9a44e5110..3bbf52c0f 100644 --- a/packages/runtime/src/scr_node_island_export.c +++ b/packages/runtime/src/scr_node_island_export.c @@ -128,6 +128,8 @@ static void ni_ids_del(const void *key) { static const void *ni_identity(const ScrDyn *v) { switch (v->kind) { case SCR_DYN_FUNC: + /* A class value is boxed afresh per read: its identity is the class. */ + if (v->v.fn.class_obj != NULL) return v->v.fn.class_obj; return v->v.fn.clo != NULL ? (const void *)v->v.fn.clo : (const void *)v; case SCR_DYN_PROMISE: return v->v.promise; @@ -141,7 +143,7 @@ static const void *ni_identity(const ScrDyn *v) { uint32_t scr_ni_export_flags(const ScrDyn *v) { switch (v->kind) { case SCR_DYN_FUNC: - return SCR_NVF_FUNCTION; + return v->v.fn.class_obj != NULL ? SCR_NVF_FUNCTION | SCR_NVF_CONSTRUCTOR : SCR_NVF_FUNCTION; case SCR_DYN_PROMISE: return SCR_NVF_PROMISE; case SCR_DYN_ARR: @@ -296,6 +298,7 @@ int scr_ni_host_export_call(uint32_t id, const ScrNodeValue *this_, const ScrNod scr_dyn_release(self); ni_args_free(in, argc, small); scr_dyn_release(fn); + scr_ni_shared_refresh(); if (result == NULL || scr_exc_pending()) { scr_dyn_release(result); return ni_threw(out); @@ -303,6 +306,8 @@ int scr_ni_host_export_call(uint32_t id, const ScrNodeValue *this_, const ScrNod return ni_returned(result, out); } +static ScrDyn *ni_class_ctor(const ScrDyn *cls); + int scr_ni_host_export_construct(uint32_t id, const ScrNodeValue *args, size_t argc, ScrNodeValue *out) { ni_keep_set(NULL); @@ -312,13 +317,25 @@ int scr_ni_host_export_construct(uint32_t id, const ScrNodeValue *args, size_t a return ni_threw(out); } scr_dyn_retain(ctor); - ScrDyn *list = scr_dyn_new_arr(); - for (size_t i = 0; i < argc; i++) scr_dyn_arr_push(list, scr_ni_value_in(&args[i])); - ScrStr *what = scr_str_new("constructor", 11); - ScrDyn *result = scr_dyn_construct(ctor, list, what); - scr_str_release(what); - scr_dyn_release(list); + ScrDyn *result; + ScrDyn *helper = ni_class_ctor(ctor); + if (helper != NULL) { + /* A native class of a shared module (NI-6): its helper's thunk checks + * the arguments against the constructor's parameters. */ + ScrDyn *small[8]; + ScrDyn **in = ni_args_in(args, argc, small, 8); + result = scr_dyn_call(helper, in, argc, "constructor"); + ni_args_free(in, argc, small); + } else { + ScrDyn *list = scr_dyn_new_arr(); + for (size_t i = 0; i < argc; i++) scr_dyn_arr_push(list, scr_ni_value_in(&args[i])); + ScrStr *what = scr_str_new("constructor", 11); + result = scr_dyn_construct(ctor, list, what); + scr_str_release(what); + scr_dyn_release(list); + } scr_dyn_release(ctor); + scr_ni_shared_refresh(); if (result == NULL || scr_exc_pending()) { scr_dyn_release(result); return ni_threw(out); @@ -335,6 +352,7 @@ typedef struct { const char *key; size_t key_len; ScrDyn *table; /* +1 dyn object */ + ScrDyn *ctor; /* +1 dyn function constructing an instance (NI-6), or NULL */ } NiMethods; static NiMethods *ni_methods = NULL; static size_t ni_nmethods = 0, ni_methods_cap = 0; @@ -363,11 +381,68 @@ ScrDyn *scr_node_island_class_methods(ScrDyn *value, ScrDyn *builder) { ni_methods[ni_nmethods].key = value->v.typed_ref.type_key; /* static */ ni_methods[ni_nmethods].key_len = value->v.typed_ref.type_key_len; ni_methods[ni_nmethods].table = table; + ni_methods[ni_nmethods].ctor = NULL; ni_nmethods++; } return scr_dyn_retain(value); } +/* NI-6: the method table of a class registered through its class value + * (a shared module's exported class), keyed by the same type key its + * instances carry. Answers the class value. */ +/* The type key a class value's instances carry: "classval:C" -> "object:C". + * Answers whether `key`/`len` is it. */ +static bool ni_instance_key_is(const char *sig, const char *key, size_t len) { + if (sig == NULL || key == NULL || strncmp(sig, "classval:", 9) != 0) return false; + size_t rest = strlen(sig + 9); + return len == 7 + rest && memcmp(key, "object:", 7) == 0 && memcmp(key + 7, sig + 9, rest) == 0; +} + +static NiMethods *ni_methods_of_class(const ScrDyn *cls) { + if (cls->kind != SCR_DYN_FUNC || cls->v.fn.class_obj == NULL) return NULL; + for (size_t i = 0; i < ni_nmethods; i++) + if (ni_instance_key_is(cls->v.fn.sig, ni_methods[i].key, ni_methods[i].key_len)) + return &ni_methods[i]; + return NULL; +} + +ScrDyn *scr_node_island_class_methods_of(ScrDyn *cls, ScrDyn *builder, ScrDyn *ctor) { + if (cls->kind != SCR_DYN_FUNC || cls->v.fn.class_obj == NULL || cls->v.fn.sig == NULL || + strncmp(cls->v.fn.sig, "classval:", 9) != 0) + return scr_dyn_retain(cls); + NiMethods *known = ni_methods_of_class(cls); + if (known != NULL) { + if (known->ctor == NULL && ctor->kind == SCR_DYN_FUNC) known->ctor = scr_dyn_retain(ctor); + return scr_dyn_retain(cls); + } + ScrDyn *table = builder->kind == SCR_DYN_FUNC ? scr_dyn_call(builder, NULL, 0, "class methods") + : scr_dyn_new_obj(); + if (table == NULL) return NULL; + if (ni_nmethods == ni_methods_cap) { + ni_methods_cap = ni_methods_cap ? ni_methods_cap * 2 : 8; + NiMethods *grown = realloc(ni_methods, ni_methods_cap * sizeof *grown); + if (grown == NULL) ni_export_oom(); + ni_methods = grown; + } + size_t rest = strlen(cls->v.fn.sig + 9); + char *key = malloc(7 + rest + 1); /* lives as long as the program */ + if (key == NULL) ni_export_oom(); + memcpy(key, "object:", 7); + memcpy(key + 7, cls->v.fn.sig + 9, rest + 1); + ni_methods[ni_nmethods].key = key; + ni_methods[ni_nmethods].key_len = 7 + rest; + ni_methods[ni_nmethods].table = table; + ni_methods[ni_nmethods].ctor = ctor->kind == SCR_DYN_FUNC ? scr_dyn_retain(ctor) : NULL; + ni_nmethods++; + return scr_dyn_retain(cls); +} + +/* The constructor helper of a native class value (borrowed), or NULL. */ +static ScrDyn *ni_class_ctor(const ScrDyn *cls) { + NiMethods *m = ni_methods_of_class(cls); + return m != NULL ? m->ctor : NULL; +} + /* A prototype method of a class capsule (+1), or NULL. */ static ScrDyn *ni_class_method(const ScrDyn *target, const ScrDyn *key) { if (key->kind != SCR_DYN_STR) return NULL; @@ -390,8 +465,11 @@ int scr_ni_host_export_get(uint32_t id, const ScrNodeValue *key, ScrNodeValue *o int present = 0; if (pk->kind == SCR_DYN_STR) present = scr_dyn_in(target, pk->v.str); if (!present && !scr_exc_pending()) { + /* A prototype method of a class instance (its table). */ + ScrDyn *method = ni_class_method(target, pk); scr_dyn_release(pk); scr_dyn_release(k); + if (method != NULL) return ni_returned(method, out); return 2; } ScrDyn *result = scr_exc_pending() ? NULL : scr_dyn_reflect_get(target, pk, target); @@ -421,6 +499,7 @@ int scr_ni_host_export_set(uint32_t id, const ScrNodeValue *key, const ScrNodeVa bool ok = scr_dyn_reflect_set(target, k, v, target); scr_dyn_release(v); scr_dyn_release(k); + scr_ni_shared_refresh(); if (scr_exc_pending()) return ni_threw(out); (void)ok; /* a refused write is silent outside strict mode; Node's setter * interceptor reports the same */ @@ -436,6 +515,11 @@ int scr_ni_host_export_has(uint32_t id, const ScrNodeValue *key) { int present = 0; if (pk != NULL && pk->kind == SCR_DYN_STR) present = scr_dyn_in(target, pk->v.str); if (scr_exc_pending()) scr_exc_clear(); + if (!present && pk != NULL) { + ScrDyn *method = ni_class_method(target, pk); + present = method != NULL; + scr_dyn_release(method); + } scr_dyn_release(pk); scr_dyn_release(k); return present; @@ -470,7 +554,9 @@ int scr_ni_host_export_keys(uint32_t id, ScrNodeValue *out) { if (json == NULL || json->kind != SCR_DYN_STR) { scr_dyn_release(json); if (scr_exc_pending()) return ni_threw(out); - json = scr_dyn_new_str(scr_str_new("[]", 2)); + ScrStr *empty = scr_str_new("[]", 2); + json = scr_dyn_new_str(empty); + scr_str_release(empty); } memset(out, 0, sizeof *out); out->tag = SCR_NV_JSON; @@ -497,6 +583,19 @@ void scr_ni_host_export_release(uint32_t id) { scr_dyn_release(v); } +/* `value instanceof C` for the representative of native class C: the + * value is an instance of C's own type key (NI-6 shortcut: instances of + * native subclasses of C answer false; NOTES.md). */ +int scr_ni_host_export_instance_of(uint32_t id, const ScrNodeValue *value) { + ScrDyn *ctor = scr_ni_export_value(id); + if (ctor == NULL || ctor->kind != SCR_DYN_FUNC || ctor->v.fn.class_obj == NULL) return 0; + ScrDyn *v = scr_ni_value_in(value); + int is = v->kind == SCR_DYN_TYPED_REF && + ni_instance_key_is(ctor->v.fn.sig, v->v.typed_ref.type_key, v->v.typed_ref.type_key_len); + scr_dyn_release(v); + return is; +} + /* ── promises into Node ───────────────────────────────────────────────── */ typedef struct { @@ -567,8 +666,245 @@ void scr_ni_host_export_watch(uint32_t id) { scr_promise_on_settle_sync(p, ni_promise_settled); } -/* At exit, once Node stopped: everything the export table and the class - * tables hold (the RC audit counts them). */ +/* ── shared modules (scope-B NI-6) ──────────────────────────────────── + * A module the program compiled and island code also imports exists once: + * the program registers it (nodeIsland.share) with its accessor, a + * compiler-generated function answering the current values of its exports + * as a dyn array (the TDZ sentinel for a binding whose declaration has not + * run). The bootstrap's loader hooks (scr_node_island_hooks.cjs) resolve + * island imports of the module to a generated ES module whose bindings + * read those values; the bindings that can change (exported lets, and + * bindings still in their temporal dead zone when the generated module + * linked) are kept current by pushes: at each entry into Node and before + * each return from a native callback, the runtime compares the bound + * values with the last pushed ones and pushes the changes. Island code only + * runs inside those entries, so it never observes a stale binding. */ + +typedef struct { + ScrStr *path; /* +1 */ + ScrStr *exports; /* +1 JSON [{"name","kind"}] */ + ScrDyn *accessor; /* +1 */ + uint32_t nslots; + ScrDyn **last; /* +1 each, NULL when unbound */ + uint8_t *bound; /* 0 unbound, 1 bound, 2 bound late (not yet pushed) */ + uint32_t nbound; +} NiShared; + +static NiShared *ni_shared = NULL; +static uint32_t ni_nshared = 0, ni_shared_cap = 0; +static uint32_t ni_shared_bound = 0; /* bound slots over all modules */ +static bool ni_refreshing = false; +static ScrDyn *ni_tdz = NULL; + +static ScrDyn *ni_tdz_value(void) { + if (ni_tdz == NULL) ni_tdz = scr_dyn_new_obj(); + return ni_tdz; +} + +/* nodeIsland.sharedTdz: the sentinel an accessor answers for a binding in + * its temporal dead zone (+1). */ +ScrDyn *scr_node_island_shared_tdz(void) { return scr_dyn_retain(ni_tdz_value()); } + +/* nodeIsland.share(path, exports JSON, accessor): registers a shared + * module before any island code runs. Never loads Node. */ +void scr_node_island_share(ScrStr *path, ScrStr *exports, ScrDyn *accessor) { + for (uint32_t i = 0; i < ni_nshared; i++) + if (ni_shared[i].path->len == path->len && + memcmp(ni_shared[i].path->data, path->data, path->len) == 0) + return; + /* Released at exit (after Node stopped, when it started: atexit order). */ + if (ni_nshared == 0 && ni_shared_cap == 0) scr_atexit(scr_ni_exports_release_all); + if (ni_nshared == ni_shared_cap) { + ni_shared_cap = ni_shared_cap ? ni_shared_cap * 2 : 8; + NiShared *grown = realloc(ni_shared, ni_shared_cap * sizeof *grown); + if (grown == NULL) ni_export_oom(); + ni_shared = grown; + } + NiShared *m = &ni_shared[ni_nshared++]; + memset(m, 0, sizeof *m); + m->path = scr_str_retain(path); + m->exports = scr_str_retain(exports); + m->accessor = scr_dyn_retain(accessor); + /* The slot count: the accessor's array length is the export count. */ + uint32_t n = 0; + for (size_t i = 0; i < exports->len; i++) n += exports->data[i] == '{'; + m->nslots = n; + m->last = calloc(n > 0 ? n : 1, sizeof *m->last); + m->bound = calloc(n > 0 ? n : 1, 1); + if (m->last == NULL || m->bound == NULL) ni_export_oom(); +} + +/* The current export values of module `m` (+1 dyn array), or NULL. */ +static ScrDyn *ni_shared_values(NiShared *m) { + ScrDyn *values = scr_dyn_call(m->accessor, NULL, 0, "shared module"); + if (values == NULL) scr_exc_clear(); + return values; +} + +void scr_ni_host_shared_table(ScrNodeValue *out) { + ni_keep_set(NULL); + size_t len = 2; + for (uint32_t i = 0; i < ni_nshared; i++) + len += ni_shared[i].path->len * 2 + ni_shared[i].exports->len + 32; + char *buf = malloc(len); + if (buf == NULL) ni_export_oom(); + size_t at = 0; + buf[at++] = '['; + for (uint32_t i = 0; i < ni_nshared; i++) { + NiShared *m = &ni_shared[i]; + if (i > 0) buf[at++] = ','; + at += (size_t)sprintf(buf + at, "{\"path\":\""); + for (size_t j = 0; j < m->path->len; j++) { + char c = m->path->data[j]; + if (c == '"' || c == '\\') buf[at++] = '\\'; + buf[at++] = c; + } + at += (size_t)sprintf(buf + at, "\",\"exports\":"); + memcpy(buf + at, m->exports->data, m->exports->len); + at += m->exports->len; + buf[at++] = '}'; + } + buf[at++] = ']'; + ScrStr *text = scr_str_new(buf, at); + ScrDyn *json = scr_dyn_new_str(text); + scr_str_release(text); + free(buf); + memset(out, 0, sizeof *out); + out->tag = SCR_NV_STRING; + out->u.str.data = json->v.str->data; + out->u.str.len = json->v.str->len; + ni_keep_set(json); +} + +static void ni_shared_bind(NiShared *m, uint32_t slot, ScrDyn *value, uint8_t how) { + if (m->bound[slot] == 0) { + m->nbound++; + ni_shared_bound++; + } + m->bound[slot] = how; + scr_dyn_release(m->last[slot]); + m->last[slot] = value != NULL ? scr_dyn_retain(value) : NULL; +} + +int scr_ni_host_shared_get(uint32_t mod, uint32_t slot, int mode, ScrNodeValue *out) { + ni_keep_set(NULL); + memset(out, 0, sizeof *out); + if (mod >= ni_nshared || slot >= ni_shared[mod].nslots) return 3; + NiShared *m = &ni_shared[mod]; + ScrDyn *values = ni_shared_values(m); + if (values == NULL) return 3; + ScrDyn *v = scr_dyn_arr_at(values, slot); + scr_dyn_release(values); + if (v == ni_tdz) { + scr_dyn_release(v); + if (mode == 2) ni_shared_bind(m, slot, NULL, 2); + return 3; + } + /* Arrays still cross as copies (NI-5): a copy of shared state would + * diverge silently, so the island read throws instead. */ + if (v->kind == SCR_DYN_ARR || + (v->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(v) != NULL)) { + scr_dyn_release(v); + static const char message[] = + "scriptc: an array exported by a shared module cannot cross into a Node island yet"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return ni_threw(out); + } + if (mode == 1 || mode == 2) ni_shared_bind(m, slot, v, 1); + return ni_returned(v, out); +} + +void scr_ni_host_shared_tdz(ScrNodeValue *out) { + ni_keep_set(NULL); + size_t cap = 64, at = 0; + char *buf = malloc(cap); + if (buf == NULL) ni_export_oom(); + buf[at++] = '['; + for (uint32_t i = 0; i < ni_nshared; i++) { + NiShared *m = &ni_shared[i]; + ScrDyn *values = ni_shared_values(m); + if (values == NULL) continue; + for (uint32_t j = 0; j < m->nslots; j++) { + ScrDyn *v = scr_dyn_arr_at(values, j); + if (v == ni_tdz) { + if (at + 32 > cap) { + cap *= 2; + char *grown = realloc(buf, cap); + if (grown == NULL) ni_export_oom(); + buf = grown; + } + at += (size_t)sprintf(buf + at, "%s[%u,%u]", at > 1 ? "," : "", i, j); + } + scr_dyn_release(v); + } + scr_dyn_release(values); + } + buf[at++] = ']'; + ScrStr *text = scr_str_new(buf, at); + ScrDyn *json = scr_dyn_new_str(text); + scr_str_release(text); + free(buf); + memset(out, 0, sizeof *out); + out->tag = SCR_NV_STRING; + out->u.str.data = json->v.str->data; + out->u.str.len = json->v.str->len; + ni_keep_set(json); +} + +typedef struct { + uint32_t mod, slot; + const ScrNodeValue *value; +} NiPush; + +static void ni_push_job(void *p) { + NiPush *a = p; + scr_ni_bridge_loaded()->shared_push(a->mod, a->slot, a->value); +} + +/* Pushes the bound exports whose native values changed since the last + * push. Cheap when nothing is bound. */ +void scr_ni_shared_refresh(void) { + if (ni_shared_bound == 0 || ni_refreshing || scr_ni_bridge_loaded() == NULL) return; + ni_refreshing = true; + bool pending = scr_exc_pending(); + ScrCaught *saved = pending ? scr_exc_take() : NULL; + for (uint32_t i = 0; i < ni_nshared; i++) { + NiShared *m = &ni_shared[i]; + if (m->nbound == 0) continue; + ScrDyn *values = ni_shared_values(m); + if (values == NULL) continue; + for (uint32_t j = 0; j < m->nslots; j++) { + if (m->bound[j] == 0) continue; + ScrDyn *v = scr_dyn_arr_at(values, j); + if (v == ni_tdz || (m->bound[j] == 1 && m->last[j] != NULL && scr_dyn_strict_eq(v, m->last[j]))) { + scr_dyn_release(v); + continue; + } + ScrNodeValue nv; + bool ok = false; + void *keep = scr_ni_value_out_begin(v, &nv, &ok); + if (ok) { + NiPush a = {i, j, &nv}; + scr_ni_on_node_stack(ni_push_job, &a); + } else { + scr_exc_clear(); + } + scr_ni_value_out_end(keep); + scr_dyn_release(m->last[j]); + m->last[j] = v; + m->bound[j] = 1; + } + scr_dyn_release(values); + } + if (saved != NULL) { + scr_rethrow(saved); + scr_caught_release(saved); + } + ni_refreshing = false; +} + +/* At exit, once Node stopped: everything the export table, the class + * tables and the shared modules hold (the RC audit counts them). */ void scr_ni_exports_release_all(void) { ni_keep_set(NULL); for (uint32_t i = 1; i < ni_exports_len; i++) { @@ -581,8 +917,24 @@ void scr_ni_exports_release_all(void) { free(ni_ids); ni_ids = NULL; ni_ids_cap = ni_ids_len = ni_ids_dead = 0; - for (size_t i = 0; i < ni_nmethods; i++) scr_dyn_release(ni_methods[i].table); + for (size_t i = 0; i < ni_nmethods; i++) { + scr_dyn_release(ni_methods[i].table); + scr_dyn_release(ni_methods[i].ctor); + } ni_nmethods = 0; + for (uint32_t i = 0; i < ni_nshared; i++) { + NiShared *m = &ni_shared[i]; + for (uint32_t j = 0; j < m->nslots; j++) scr_dyn_release(m->last[j]); + free(m->last); + free(m->bound); + scr_str_release(m->path); + scr_str_release(m->exports); + scr_dyn_release(m->accessor); + } + ni_nshared = 0; + ni_shared_bound = 0; + scr_dyn_release(ni_tdz); + ni_tdz = NULL; } #endif diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 70c5db4c9..6a113c6ca 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -572,7 +572,10 @@ static bool nh_export_value(ScrDyn *v, ScrNodeValue *out, NhKeep *keep) { scr_ni_export_out(v, out); return true; } - if (v->kind == SCR_DYN_OBJ || v->kind == SCR_DYN_ARR) { + /* Arrays (dyn, and typed array capsules: NI-6 shortcut, as for dyn + * arrays) keep NI-4's JSON copy. */ + if (v->kind == SCR_DYN_OBJ || v->kind == SCR_DYN_ARR || + (v->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(v) != NULL)) { ScrStr *gap = scr_str_new("", 0); ScrDyn *json = scr_json_stringify_replacer(v, scr_dyn_undefined(), gap); scr_str_release(gap); @@ -752,6 +755,7 @@ static void nh_call_job(void *p) { } static int nh_run(NhCall *c) { + scr_ni_shared_refresh(); scr_ni_on_node_stack(nh_call_job, c); return c->threw; } @@ -1136,6 +1140,20 @@ bool scr_ni_value_out(ScrDyn *v, ScrNodeValue *out) { return nh_to_node(v, out, &nh_out_keep); } +/* NI-6: a conversion whose keep belongs to the caller, for values the + * runtime pushes into Node between other conversions (shared modules). */ +void *scr_ni_value_out_begin(ScrDyn *v, ScrNodeValue *out, bool *ok) { + NhKeep *k = calloc(1, sizeof *k); + if (k == NULL) nh_oom(); + *ok = nh_to_node(v, out, k); + return k; +} + +void scr_ni_value_out_end(void *keep) { + nh_keep_done(keep); + free(keep); +} + ScrDyn *scr_ni_value_in(const ScrNodeValue *v) { ScrDyn *d = nh_from_node(v); return d != NULL ? d : scr_dyn_retain(scr_dyn_undefined()); diff --git a/packages/runtime/src/scr_node_island_hooks.cjs b/packages/runtime/src/scr_node_island_hooks.cjs new file mode 100644 index 000000000..b2d7b7496 --- /dev/null +++ b/packages/runtime/src/scr_node_island_hooks.cjs @@ -0,0 +1,146 @@ +// ── shared modules (scope-B NI-6) ────────────────────────────────────── +// The design's hooks.mjs. scripts/node-island-host.mjs appends this file to +// scr_node_island_host.cjs, so it runs in the bootstrap's scope (CommonJS: +// the bootstrap is Node's main script). +// +// A module the program compiled that island code imports must exist once. +// Synchronous, in-thread loader hooks (module.registerHooks) resolve every +// island import or require() of such a module to a generated ES module +// (served at the module's own file URL) whose bindings hold the live native exports: the +// natives sharedGet/sharedTable/sharedTdz ask the runtime directly. An +// export that can change (an exported let, or a binding still in its +// temporal dead zone when the module linked) is bound: the runtime pushes +// each new native value (sharedPush) before island code can observe it. +// +// Temporal dead zone across a cycle: when an island module loads while a +// shared module's bindings are not yet initialized (the native module is +// still evaluating, as in `core` -> island `ext` -> `core`), the load goes +// through an entry module that imports the island module and declares +// those bindings after the import. The generated module re-exports them +// from the entry, so while the island module evaluates they are in their +// temporal dead zone exactly as in Node (V8 throws its own ReferenceError +// with the importer's binding name), and they are pushed once the native +// declarations ran. +// oxlint-disable-next-line no-unused-vars -- called by scr_node_island_host.cjs, which this file is appended to +function scriptcSharedModules(nativeHost) { + const { registerHooks } = require("node:module"); + const { fileURLToPath, pathToFileURL } = require("node:url"); + const { dirname, join } = require("node:path"); + const table = JSON.parse(nativeHost.sharedTable()); + const none = { entry: (path, load) => load(null), push() {} }; + if (table.length === 0) return none; + const byUrl = new Map(); + table.forEach((m, id) => byUrl.set(pathToFileURL(m.path).href, id)); + const TDZ = Symbol("scriptc.tdz"); + const setters = new Map(); // `${mod}:${slot}` -> [(value) => void] + const entries = new Map(); // entry URL -> { target, late: [[mod, slot]] } + let entrySeq = 0; + let current = null; // the entry being loaded + + function addSetter(mod, slot, set) { + const key = `${mod}:${slot}`; + const list = setters.get(key); + if (list === undefined) setters.set(key, [set]); + else list.push(set); + } + + const shared = { + get(mod, slot) { + return nativeHost.sharedGet(mod, slot, 0, TDZ); + }, + bind(mod, slot, set) { + addSetter(mod, slot, set); + return nativeHost.sharedGet(mod, slot, 1, TDZ); + }, + late(mod, slot, set) { + addSetter(mod, slot, set); + const v = nativeHost.sharedGet(mod, slot, 2, TDZ); + return v === TDZ ? undefined : v; + }, + }; + Object.defineProperty(globalThis, Symbol.for("scriptc.shared"), { value: shared }); + + const lateName = (mod, slot) => `__scriptc_${mod}_${slot}`; + + function sharedSource(mod) { + const m = table[mod]; + const lines = ['const __s = globalThis[Symbol.for("scriptc.shared")];']; + const lateIn = current === null ? null : entries.get(current); + m.exports.forEach((e, slot) => { + const name = JSON.stringify(e.name); + const local = `__e${slot}`; + const tdz = nativeHost.sharedGet(mod, slot, 0, TDZ) === TDZ; + if (tdz && lateIn !== null && lateIn.late.some(([a, b]) => a === mod && b === slot)) { + lines.push(`export { ${lateName(mod, slot)} as ${name} } from ${JSON.stringify(current)};`); + } else if (tdz) { + // Reached outside an island load (a lazy require): no entry can hold + // the dead zone; the binding reads undefined until its declaration + // runs (NOTES.md shortcut). + lines.push(`let ${local} = __s.late(${mod}, ${slot}, (v) => { ${local} = v; });`); + lines.push(`export { ${local} as ${name} };`); + } else if (e.kind === "let" || e.kind === "var") { + lines.push(`let ${local} = __s.bind(${mod}, ${slot}, (v) => { ${local} = v; });`); + lines.push(`export { ${local} as ${name} };`); + } else { + lines.push(`const ${local} = __s.get(${mod}, ${slot});`); + lines.push(`export { ${local} as ${name} };`); + } + }); + return lines.join("\n"); + } + + function entrySource(url) { + const e = entries.get(url); + const lines = [`import ${JSON.stringify(e.target)};`]; + lines.push('const __s = globalThis[Symbol.for("scriptc.shared")];'); + for (const [mod, slot] of e.late) { + const n = lateName(mod, slot); + lines.push(`export let ${n} = __s.late(${mod}, ${slot}, (v) => { ${n} = v; });`); + } + return lines.join("\n"); + } + + registerHooks({ + resolve(specifier, context, nextResolve) { + const asUrl = specifier.startsWith("/") ? pathToFileURL(specifier).href : specifier; + if (entries.has(asUrl)) return { url: asUrl, shortCircuit: true }; + const resolved = nextResolve(specifier, context); + // A shared module keeps its own file URL (require() and import key + // their module records alike only for file URLs); load answers it. + if (resolved.url.startsWith("file:") && byUrl.has(resolved.url)) + return { url: resolved.url, format: "module", shortCircuit: true }; + return resolved; + }, + load(url, context, nextLoad) { + const mod = byUrl.get(url); + if (mod !== undefined) + return { format: "module", source: sharedSource(mod), shortCircuit: true }; + if (entries.has(url)) + return { format: "module", source: entrySource(url), shortCircuit: true }; + return nextLoad(url, context); + }, + }); + + return { + /** The entry to load the island module at `path` through, or null when + * no shared binding is in its temporal dead zone. Run `load` with it. */ + entry(path, load) { + const late = JSON.parse(nativeHost.sharedTdz()); + if (late.length === 0) return load(null); + // A file URL beside the island module (never on disk: the load hook + // answers it), so require() and import key it alike. + const url = pathToFileURL(join(dirname(path), `__scriptc_entry_${++entrySeq}.mjs`)).href; + entries.set(url, { target: pathToFileURL(path).href, late }); + const outer = current; + current = url; + try { + return load(fileURLToPath(url)); + } finally { + current = outer; + } + }, + push(mod, slot, value) { + for (const set of setters.get(`${mod}:${slot}`) ?? []) set(value); + }, + }; +} diff --git a/packages/runtime/src/scr_node_island_host.cjs b/packages/runtime/src/scr_node_island_host.cjs index 9d30627e7..8cb89efe7 100644 --- a/packages/runtime/src/scr_node_island_host.cjs +++ b/packages/runtime/src/scr_node_island_host.cjs @@ -127,10 +127,23 @@ function call(id, sync, file, specifier, name, argsJson) { // answers a promise of the namespace. const { pathToFileURL } = require("node:url"); +// Island imports of modules the program shares resolve to their native +// exports (NI-6, scr_node_island_hooks.cjs, appended to this file). +let sharedModules = null; + function load(path, esm) { if (path.startsWith("node:")) return require(path); + if (sharedModules === null) sharedModules = scriptcSharedModules(nativeHost); if (esm) return import(pathToFileURL(path).href); - return createRequire(path)(path); + const load = createRequire(path); + return sharedModules.entry(path, (entry) => { + if (entry !== null) load(entry); + return load(path); + }); +} + +function sharedPush(mod, slot, value) { + if (sharedModules !== null) sharedModules.push(mod, slot, value); } // ── the hosted loop (scope-B NI-3) ───────────────────────────────────── @@ -307,4 +320,5 @@ globalThis.__scriptcNodeIsland = { nextTick, emit, load, + sharedPush, }; diff --git a/packages/runtime/src/scr_node_island_host.h b/packages/runtime/src/scr_node_island_host.h index 12cf217a2..05e47a909 100644 --- a/packages/runtime/src/scr_node_island_host.h +++ b/packages/runtime/src/scr_node_island_host.h @@ -1,4 +1,5 @@ -/* Generated by scripts/node-island-host.mjs from scr_node_island_host.cjs. +/* Generated by scripts/node-island-host.mjs from scr_node_island_host.cjs + * and scr_node_island_hooks.cjs. * Do not edit: change the .cjs file and rerun the script. */ #ifndef SCR_NODE_ISLAND_HOST_H #define SCR_NODE_ISLAND_HOST_H @@ -133,10 +134,23 @@ static const char scr_node_island_host_js[] = "// answers a promise of the namespace.\012" "const { pathToFileURL } = require(\"node:url\");\012" "\012" + "// Island imports of modules the program shares resolve to their native\012" + "// exports (NI-6, scr_node_island_hooks.cjs, appended to this file).\012" + "let sharedModules = null;\012" + "\012" "function load(path, esm) {\012" " if (path.startsWith(\"node:\")) return require(path);\012" + " if (sharedModules === null) sharedModules = scriptcSharedModules(nativeHost);\012" " if (esm) return import(pathToFileURL(path).href);\012" - " return createRequire(path)(path);\012" + " const load = createRequire(path);\012" + " return sharedModules.entry(path, (entry) => {\012" + " if (entry !== null) load(entry);\012" + " return load(path);\012" + " });\012" + "}\012" + "\012" + "function sharedPush(mod, slot, value) {\012" + " if (sharedModules !== null) sharedModules.push(mod, slot, value);\012" "}\012" "\012" "// \342\224\200\342\224\200 the hosted loop (scope-B NI-3) \342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\012" @@ -313,6 +327,154 @@ static const char scr_node_island_host_js[] = " nextTick,\012" " emit,\012" " load,\012" - "};\012"; + " sharedPush,\012" + "};\012" + "\012" + "// \342\224\200\342\224\200 shared modules (scope-B NI-6) \342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\012" + "// The design's hooks.mjs. scripts/node-island-host.mjs appends this file to\012" + "// scr_node_island_host.cjs, so it runs in the bootstrap's scope (CommonJS:\012" + "// the bootstrap is Node's main script).\012" + "//\012" + "// A module the program compiled that island code imports must exist once.\012" + "// Synchronous, in-thread loader hooks (module.registerHooks) resolve every\012" + "// island import or require() of such a module to a generated ES module\012" + "// (served at the module's own file URL) whose bindings hold the live native exports: the\012" + "// natives sharedGet/sharedTable/sharedTdz ask the runtime directly. An\012" + "// export that can change (an exported let, or a binding still in its\012" + "// temporal dead zone when the module linked) is bound: the runtime pushes\012" + "// each new native value (sharedPush) before island code can observe it.\012" + "//\012" + "// Temporal dead zone across a cycle: when an island module loads while a\012" + "// shared module's bindings are not yet initialized (the native module is\012" + "// still evaluating, as in `core` -> island `ext` -> `core`), the load goes\012" + "// through an entry module that imports the island module and declares\012" + "// those bindings after the import. The generated module re-exports them\012" + "// from the entry, so while the island module evaluates they are in their\012" + "// temporal dead zone exactly as in Node (V8 throws its own ReferenceError\012" + "// with the importer's binding name), and they are pushed once the native\012" + "// declarations ran.\012" + "// oxlint-disable-next-line no-unused-vars -- called by scr_node_island_host.cjs, which this file is appended to\012" + "function scriptcSharedModules(nativeHost) {\012" + " const { registerHooks } = require(\"node:module\");\012" + " const { fileURLToPath, pathToFileURL } = require(\"node:url\");\012" + " const { dirname, join } = require(\"node:path\");\012" + " const table = JSON.parse(nativeHost.sharedTable());\012" + " const none = { entry: (path, load) => load(null), push() {} };\012" + " if (table.length === 0) return none;\012" + " const byUrl = new Map();\012" + " table.forEach((m, id) => byUrl.set(pathToFileURL(m.path).href, id));\012" + " const TDZ = Symbol(\"scriptc.tdz\");\012" + " const setters = new Map(); // `${mod}:${slot}` -> [(value) => void]\012" + " const entries = new Map(); // entry URL -> { target, late: [[mod, slot]] }\012" + " let entrySeq = 0;\012" + " let current = null; // the entry being loaded\012" + "\012" + " function addSetter(mod, slot, set) {\012" + " const key = `${mod}:${slot}`;\012" + " const list = setters.get(key);\012" + " if (list === undefined) setters.set(key, [set]);\012" + " else list.push(set);\012" + " }\012" + "\012" + " const shared = {\012" + " get(mod, slot) {\012" + " return nativeHost.sharedGet(mod, slot, 0, TDZ);\012" + " },\012" + " bind(mod, slot, set) {\012" + " addSetter(mod, slot, set);\012" + " return nativeHost.sharedGet(mod, slot, 1, TDZ);\012" + " },\012" + " late(mod, slot, set) {\012" + " addSetter(mod, slot, set);\012" + " const v = nativeHost.sharedGet(mod, slot, 2, TDZ);\012" + " return v === TDZ \077 undefined : v;\012" + " },\012" + " };\012" + " Object.defineProperty(globalThis, Symbol.for(\"scriptc.shared\"), { value: shared });\012" + "\012" + " const lateName = (mod, slot) => `__scriptc_${mod}_${slot}`;\012" + "\012" + " function sharedSource(mod) {\012" + " const m = table[mod];\012" + " const lines = ['const __s = globalThis[Symbol.for(\"scriptc.shared\")];'];\012" + " const lateIn = current === null \077 null : entries.get(current);\012" + " m.exports.forEach((e, slot) => {\012" + " const name = JSON.stringify(e.name);\012" + " const local = `__e${slot}`;\012" + " const tdz = nativeHost.sharedGet(mod, slot, 0, TDZ) === TDZ;\012" + " if (tdz && lateIn !== null && lateIn.late.some(([a, b]) => a === mod && b === slot)) {\012" + " lines.push(`export { ${lateName(mod, slot)} as ${name} } from ${JSON.stringify(current)};`);\012" + " } else if (tdz) {\012" + " // Reached outside an island load (a lazy require): no entry can hold\012" + " // the dead zone; the binding reads undefined until its declaration\012" + " // runs (NOTES.md shortcut).\012" + " lines.push(`let ${local} = __s.late(${mod}, ${slot}, (v) => { ${local} = v; });`);\012" + " lines.push(`export { ${local} as ${name} };`);\012" + " } else if (e.kind === \"let\" || e.kind === \"var\") {\012" + " lines.push(`let ${local} = __s.bind(${mod}, ${slot}, (v) => { ${local} = v; });`);\012" + " lines.push(`export { ${local} as ${name} };`);\012" + " } else {\012" + " lines.push(`const ${local} = __s.get(${mod}, ${slot});`);\012" + " lines.push(`export { ${local} as ${name} };`);\012" + " }\012" + " });\012" + " return lines.join(\"\\n\");\012" + " }\012" + "\012" + " function entrySource(url) {\012" + " const e = entries.get(url);\012" + " const lines = [`import ${JSON.stringify(e.target)};`];\012" + " lines.push('const __s = globalThis[Symbol.for(\"scriptc.shared\")];');\012" + " for (const [mod, slot] of e.late) {\012" + " const n = lateName(mod, slot);\012" + " lines.push(`export let ${n} = __s.late(${mod}, ${slot}, (v) => { ${n} = v; });`);\012" + " }\012" + " return lines.join(\"\\n\");\012" + " }\012" + "\012" + " registerHooks({\012" + " resolve(specifier, context, nextResolve) {\012" + " const asUrl = specifier.startsWith(\"/\") \077 pathToFileURL(specifier).href : specifier;\012" + " if (entries.has(asUrl)) return { url: asUrl, shortCircuit: true };\012" + " const resolved = nextResolve(specifier, context);\012" + " // A shared module keeps its own file URL (require() and import key\012" + " // their module records alike only for file URLs); load answers it.\012" + " if (resolved.url.startsWith(\"file:\") && byUrl.has(resolved.url))\012" + " return { url: resolved.url, format: \"module\", shortCircuit: true };\012" + " return resolved;\012" + " },\012" + " load(url, context, nextLoad) {\012" + " const mod = byUrl.get(url);\012" + " if (mod !== undefined)\012" + " return { format: \"module\", source: sharedSource(mod), shortCircuit: true };\012" + " if (entries.has(url))\012" + " return { format: \"module\", source: entrySource(url), shortCircuit: true };\012" + " return nextLoad(url, context);\012" + " },\012" + " });\012" + "\012" + " return {\012" + " /** The entry to load the island module at `path` through, or null when\012" + " * no shared binding is in its temporal dead zone. Run `load` with it. */\012" + " entry(path, load) {\012" + " const late = JSON.parse(nativeHost.sharedTdz());\012" + " if (late.length === 0) return load(null);\012" + " // A file URL beside the island module (never on disk: the load hook\012" + " // answers it), so require() and import key it alike.\012" + " const url = pathToFileURL(join(dirname(path), `__scriptc_entry_${++entrySeq}.mjs`)).href;\012" + " entries.set(url, { target: pathToFileURL(path).href, late });\012" + " const outer = current;\012" + " current = url;\012" + " try {\012" + " return load(fileURLToPath(url));\012" + " } finally {\012" + " current = outer;\012" + " }\012" + " },\012" + " push(mod, slot, value) {\012" + " for (const set of setters.get(`${mod}:${slot}`) \077\077 []) set(value);\012" + " },\012" + " };\012" + "}\012"; #endif /* SCR_NODE_ISLAND_HOST_H */ diff --git a/packages/runtime/test/node-island-host.test.ts b/packages/runtime/test/node-island-host.test.ts index 3fe28697f..8867a161f 100644 --- a/packages/runtime/test/node-island-host.test.ts +++ b/packages/runtime/test/node-island-host.test.ts @@ -8,6 +8,6 @@ const srcDir = join(import.meta.dirname, "../src"); // scr_node_island.c starts `node -e` with the host embedded in this header; // a host edit without regenerating it would ship the old host. test("the embedded Node island host matches its source", () => { - const source = readFileSync(join(srcDir, "scr_node_island_host.cjs"), "utf8"); + const source = `${readFileSync(join(srcDir, "scr_node_island_host.cjs"), "utf8")}\n${readFileSync(join(srcDir, "scr_node_island_hooks.cjs"), "utf8")}`; expect(readFileSync(join(srcDir, "scr_node_island_host.h"), "utf8")).toBe(renderHeader(source)); }); diff --git a/scripts/node-island-host.mjs b/scripts/node-island-host.mjs index 61b93c411..3dadaf26d 100644 --- a/scripts/node-island-host.mjs +++ b/scripts/node-island-host.mjs @@ -10,6 +10,8 @@ import { fileURLToPath } from "node:url"; const srcDir = fileURLToPath(new URL("../packages/runtime/src/", import.meta.url)); const SOURCE = `${srcDir}scr_node_island_host.cjs`; +// The loader hooks of shared modules (NI-6) run in the bootstrap's scope. +const HOOKS = `${srcDir}scr_node_island_hooks.cjs`; const HEADER = `${srcDir}scr_node_island_host.h`; /** One C string literal per source line, every byte outside printable @@ -28,7 +30,8 @@ export function renderHeader(source) { const lines = source.split("\n"); if (lines.at(-1) === "") lines.pop(); const body = lines.map((line) => ` ${cLiteral(`${line}\n`)}`).join("\n"); - return `/* Generated by scripts/node-island-host.mjs from scr_node_island_host.cjs. + return `/* Generated by scripts/node-island-host.mjs from scr_node_island_host.cjs + * and scr_node_island_hooks.cjs. * Do not edit: change the .cjs file and rerun the script. */ #ifndef SCR_NODE_ISLAND_HOST_H #define SCR_NODE_ISLAND_HOST_H @@ -41,7 +44,7 @@ ${body}; } if (process.argv[1] === fileURLToPath(import.meta.url)) { - const expected = renderHeader(readFileSync(SOURCE, "utf8")); + const expected = renderHeader(`${readFileSync(SOURCE, "utf8")}\n${readFileSync(HOOKS, "utf8")}`); if (process.argv.includes("--check")) { if (readFileSync(HEADER, "utf8") !== expected) { console.error(`${HEADER} is stale: run node scripts/node-island-host.mjs`); diff --git a/tests/corpus/6850-node-island-shared-singleton/main.mjs b/tests/corpus/6850-node-island-shared-singleton/main.mjs new file mode 100644 index 000000000..97cbe0618 --- /dev/null +++ b/tests/corpus/6850-node-island-shared-singleton/main.mjs @@ -0,0 +1,28 @@ +// @requires-libnode +// A module both the program and a Node island import exists once (scope-B +// NI-6): island imports of the compiled `state.mjs` resolve to its live +// native exports, so a write on either side is seen by the other, and an +// exported `let` is a live binding in the island too. +import { requireModule } from "@scriptc/node"; +import { config, count, entries, record, reset } from "./state.mjs"; + +record("main-1"); +console.log("main before island", count, entries()); + +const plugin = requireModule("./plugin.mjs"); +console.log("island start:", plugin.startup); +console.log("main after island load", count, entries()); + +console.log("island report:", plugin.report()); +record("main-2"); +console.log("island report after main write:", plugin.report()); +console.log("island bump:", plugin.bump("island-2"), "main sees", count); + +plugin.configure("island-mode"); +console.log("main sees config", config.mode, config.owner); +config.owner = "main"; +console.log("island sees config", plugin.describeConfig()); + +console.log("same functions", plugin.sameRecord(record), plugin.requireSees()); +reset(); +console.log("after reset", count, entries(), plugin.report()); diff --git a/tests/corpus/6850-node-island-shared-singleton/plugin.mjs b/tests/corpus/6850-node-island-shared-singleton/plugin.mjs new file mode 100644 index 000000000..7078c7097 --- /dev/null +++ b/tests/corpus/6850-node-island-shared-singleton/plugin.mjs @@ -0,0 +1,37 @@ +// Island code (loaded with requireModule): imports the shared module by +// named imports, by namespace, and through require(). +import { createRequire } from "node:module"; +import * as ns from "./state.mjs"; +import { config, count, entries, record } from "./state.mjs"; + +const require = createRequire(import.meta.url); + +record("island-1"); +export const startup = `count=${count} ns=${ns.count} entries=${entries()}`; + +export function report() { + return `count=${count} ns=${ns.count} entries=${entries()}`; +} + +export function bump(name) { + const before = count; + const after = record(name); + return `${before}->${after} live=${count}`; +} + +export function configure(mode) { + config.mode = mode; +} + +export function describeConfig() { + return `${config.mode}/${config.owner}`; +} + +export function sameRecord(fn) { + return fn === record && fn === ns.record; +} + +export function requireSees() { + const viaRequire = require("./state.mjs"); + return viaRequire === ns && viaRequire.entries() === entries(); +} diff --git a/tests/corpus/6850-node-island-shared-singleton/state.mjs b/tests/corpus/6850-node-island-shared-singleton/state.mjs new file mode 100644 index 000000000..580f522bf --- /dev/null +++ b/tests/corpus/6850-node-island-shared-singleton/state.mjs @@ -0,0 +1,19 @@ +// A stateful singleton: a module-level log and an exported counter. +export let count = 0; +const log = []; +export const config = { mode: "default", owner: "state" }; + +export function record(entry) { + log.push(entry); + count++; + return count; +} + +export function entries() { + return log.join(","); +} + +export function reset() { + log.length = 0; + count = 0; +} diff --git a/tests/corpus/6851-node-island-shared-class/island.mjs b/tests/corpus/6851-node-island-shared-class/island.mjs new file mode 100644 index 000000000..6b36626b6 --- /dev/null +++ b/tests/corpus/6851-node-island-shared-class/island.mjs @@ -0,0 +1,35 @@ +import { Point, isPoint, make, remember, remembered } from "./shapes.mjs"; + +export function check(p) { + return `${p instanceof Point} ${isPoint(p)} ${p.describe()} norm=${p.norm()}`; +} + +export function create(x, y) { + return new Point(x, y); +} + +export function selfCheck() { + const p = new Point(1, 1); + return `${p instanceof Point} ${isPoint(p)} ${make(2, 2) instanceof Point}`; +} + +export function plainIsPoint() { + return `${{ x: 1 } instanceof Point} ${isPoint({ x: 1 })}`; +} + +export function keep(p) { + return remember(p); +} + +export function firstIsNative(p) { + return remembered(0) === p; +} + +export function viaFactory() { + const p = make(5, 12); + return `${p.describe()} ${p.norm()} ${p instanceof Point}`; +} + +export function sameClass(c) { + return c === Point; +} diff --git a/tests/corpus/6851-node-island-shared-class/main.mjs b/tests/corpus/6851-node-island-shared-class/main.mjs new file mode 100644 index 000000000..600b107f9 --- /dev/null +++ b/tests/corpus/6851-node-island-shared-class/main.mjs @@ -0,0 +1,23 @@ +// @requires-libnode +// A class exported by a module both sides import is one class (scope-B +// NI-6): instances made on either side answer instanceof on both, keep +// their identity, and their methods run natively. +import { requireModule } from "@scriptc/node"; +import { Point, describeAll, isPoint, make, remember, remembered } from "./shapes.mjs"; + +const island = requireModule("./island.mjs"); + +const native = new Point(3, 4); +console.log("island sees native instance:", island.check(native)); +const fromIsland = island.create(6, 8); +console.log("native instanceof:", fromIsland instanceof Point, isPoint(fromIsland)); +console.log("native reads:", fromIsland.x, fromIsland.y, fromIsland.norm()); +console.log("island instanceof:", island.selfCheck()); +console.log("plain object (island):", island.plainIsPoint()); +console.log("plain object (native):", isPoint({ x: 1, y: 2 })); + +remember(native); +console.log("kept:", island.keep(fromIsland), describeAll()); +console.log("identity:", remembered(1) === fromIsland, island.firstIsNative(native)); +console.log("made by native factory in island:", island.viaFactory()); +console.log("class identity:", island.sameClass(Point), island.sameClass(make)); diff --git a/tests/corpus/6851-node-island-shared-class/shapes.mjs b/tests/corpus/6851-node-island-shared-class/shapes.mjs new file mode 100644 index 000000000..cdc5dc77d --- /dev/null +++ b/tests/corpus/6851-node-island-shared-class/shapes.mjs @@ -0,0 +1,35 @@ +export class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + norm() { + return Math.hypot(this.x, this.y); + } + describe() { + return `(${this.x},${this.y})`; + } +} + +const kept = []; + +export function remember(p) { + kept.push(p); + return kept.length; +} + +export function remembered(i) { + return kept[i]; +} + +export function describeAll() { + return kept.map((p) => p.describe()).join(" "); +} + +export function make(x, y) { + return new Point(x, y); +} + +export function isPoint(p) { + return p instanceof Point; +} diff --git a/tests/corpus/6852-node-island-shared-cycle/main.mjs b/tests/corpus/6852-node-island-shared-cycle/main.mjs new file mode 100644 index 000000000..adf916dd6 --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/main.mjs @@ -0,0 +1,10 @@ +// @requires-libnode +// An import cycle through a Node island (scope-B NI-6): the compiled package +// `core` imports the island package `ext`, which imports `core` back. As in +// Node, `ext` evaluates first and sees core's functions (hoisted) but its +// let and const bindings in their temporal dead zone; once core's body ran, +// the same bindings read core's live values. +import { check, x } from "core"; + +console.log("main:", check()); +console.log("main x:", x); diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.d.ts b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.d.ts new file mode 100644 index 000000000..4bd83c41b --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.d.ts @@ -0,0 +1,5 @@ +export let x: number; +export const y: string; +export function f(): string; +export function bump(): number; +export function check(): string; diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.js b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.js new file mode 100644 index 000000000..6c6a56662 --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/index.js @@ -0,0 +1,16 @@ +import { early, later, bumpFromIsland } from "ext"; + +console.log("core body starts; ext saw:", early); +export let x = 1; +export const y = "y"; +export function f() { + return "f"; +} +export function bump() { + x++; + return x; +} +export function check() { + return `${later()} ${bumpFromIsland()} ${later()}`; +} + diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/core/package.json b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/package.json new file mode 100644 index 000000000..5bbd96010 --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/core/package.json @@ -0,0 +1 @@ +{ "name": "core", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.d.ts b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.d.ts new file mode 100644 index 000000000..c081053df --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.d.ts @@ -0,0 +1,3 @@ +export const early: string; +export function later(): string; +export function bumpFromIsland(): string; diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.js b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.js new file mode 100644 index 000000000..efff5b9ab --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/index.js @@ -0,0 +1,19 @@ +import { bump, f, x, y } from "core"; + +console.log("ext evaluates; f() =", f()); +function probe(read) { + try { + return String(read()); + } catch (e) { + return `${e.name}: ${e.message}`; + } +} +export const early = `${probe(() => x)} | ${probe(() => y)}`; + +export function later() { + return `x=${x} y=${y}`; +} + +export function bumpFromIsland() { + return `bump=${bump()} x=${x}`; +} diff --git a/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/package.json b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/package.json new file mode 100644 index 000000000..1e44518e2 --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/node_modules/ext/package.json @@ -0,0 +1 @@ +{ "name": "ext", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6852-node-island-shared-cycle/package.json b/tests/corpus/6852-node-island-shared-cycle/package.json new file mode 100644 index 000000000..a026108b2 --- /dev/null +++ b/tests/corpus/6852-node-island-shared-cycle/package.json @@ -0,0 +1 @@ +{ "name": "shared-cycle", "private": true, "type": "module", "scriptc": { "packages": { "ext": "node" } } } diff --git a/tests/corpus/6853-node-island-shared-registry/main.mjs b/tests/corpus/6853-node-island-shared-registry/main.mjs new file mode 100644 index 000000000..4db2e7100 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/main.mjs @@ -0,0 +1,19 @@ +// @requires-libnode +// The registry scenario of the Node delegation design (§13): a stateful +// package — a module-level Map, an exported `let counter`, a class — that +// the program and a Node island both import. Island code subclasses the +// package's class, so the package is shared Node-owned (scope-B NI-6): one +// instance, its state visible on both sides, live bindings, `require` and +// `import` answering the same module, instanceof both ways, and an island +// subclass whose override and `super` call cross the boundary. +import fs from "node:fs"; +import { requireModule } from "@scriptc/node"; +import * as reg from "registry"; + +const { run } = requireModule("./scenario.mjs"); +const log = (s) => fs.writeSync(1, `${s}\n`); +reg.register(new reg.Plugin("owner-a")); +run(log); +log(`owner list: ${reg.list().join(",")}`); +log(`owner counter: ${reg.counter}`); +log(`owner describeAll: ${reg.describeAll()}`); diff --git a/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.d.ts b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.d.ts new file mode 100644 index 000000000..5c8096332 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.d.ts @@ -0,0 +1,13 @@ +export let counter: number; +export function bump(): number; +export class Plugin { + name: string; + constructor(name: string); + describe(): string; +} +export function register(p: { name: string; describe(): string }): number; +// A handle (NI-4 copies no island arrays into typed native arrays). +export function list(): any; +export function find(name: string): Plugin | undefined; +export function describeAll(): string; +export function noop(x: T): T; diff --git a/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.js b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.js new file mode 100644 index 000000000..e66af40bc --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/index.js @@ -0,0 +1,13 @@ +// A stateful singleton: a plugin registry with a mutable exported binding. +export let counter = 0; +export function bump() { return ++counter; } +const items = new Map(); +export class Plugin { + constructor(name) { this.name = name; } + describe() { return `plugin:${this.name}`; } +} +export function register(p) { items.set(p.name, p); bump(); return items.size; } +export function list() { return [...items.keys()]; } +export function find(name) { return items.get(name); } +export function describeAll() { return [...items.values()].map((p) => p.describe()).join(' | '); } +export function noop(x) { return x; } diff --git a/tests/corpus/6853-node-island-shared-registry/node_modules/registry/package.json b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/package.json new file mode 100644 index 000000000..c47a4f064 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/node_modules/registry/package.json @@ -0,0 +1 @@ +{ "name": "registry", "version": "1.0.0", "type": "module", "exports": { "types": "./index.d.ts", "default": "./index.js" } } diff --git a/tests/corpus/6853-node-island-shared-registry/scenario.mjs b/tests/corpus/6853-node-island-shared-registry/scenario.mjs new file mode 100644 index 000000000..5f503fe61 --- /dev/null +++ b/tests/corpus/6853-node-island-shared-registry/scenario.mjs @@ -0,0 +1,26 @@ +// Island code. Imports the shared module both as ESM (named + namespace) and via require(). +import { createRequire } from 'node:module'; +import * as ns from 'registry'; +import { counter, bump, Plugin, register, list, find, describeAll, noop } from 'registry'; +const require = createRequire(import.meta.url); + +export function run(log) { + log(`island list: ${list().join(',')}`); + log(`island counter: named=${counter} ns=${ns.counter}`); + bump(); + log(`after island bump: named=${counter} ns=${ns.counter}`); + log(`require() sees same instance: ${require('registry').list().join(',')}`); + const p = new Plugin('island-b'); + register(p); + log(`instanceof (island-constructed): ${p instanceof Plugin}`); + log(`instanceof (owner-constructed): ${find('owner-a') instanceof Plugin}`); + log(`find returns same object twice: ${find('owner-a') === find('owner-a')}`); + class Sub extends Plugin { describe() { return `sub(${super.describe()})`; } } + const sub = new Sub('island-sub'); + log(`subclass: proto ok=${Object.getPrototypeOf(sub) === Sub.prototype} describe=${sub.describe()} instanceof Plugin=${sub instanceof Plugin}`); + register(sub); + register({ name: 'island-plain', describe() { return 'plain-from-island'; } }); + log(`describeAll from island: ${describeAll()}`); + log(`Object.keys(owner object): ${Object.keys(find('owner-a') ?? {}).join(',')}`); +} + diff --git a/tests/corpus/6854-node-island-shared-registry-native/main.mjs b/tests/corpus/6854-node-island-shared-registry-native/main.mjs new file mode 100644 index 000000000..9937676d7 --- /dev/null +++ b/tests/corpus/6854-node-island-shared-registry-native/main.mjs @@ -0,0 +1,18 @@ +// @requires-libnode +// The registry scenario of the Node delegation design (§13) with the +// registry as a program module, shared through the loader hooks over its +// native exports (scope-B NI-6): one instance, its state visible on both +// sides, live bindings, `require` and `import` answering the same module, +// instanceof both ways, and island objects in the native registry. (The +// design's island subclass is in 6843, where the registry is Node-owned.) +import fs from "node:fs"; +import { requireModule } from "@scriptc/node"; +import * as reg from "./registry.mjs"; + +const { run } = requireModule("./scenario.mjs"); +const log = (s) => fs.writeSync(1, `${s}\n`); +reg.register(new reg.Plugin("owner-a")); +run(log); +log(`owner list: ${reg.list().join(",")}`); +log(`owner counter: ${reg.counter}`); +log(`owner describeAll: ${reg.describeAll()}`); diff --git a/tests/corpus/6854-node-island-shared-registry-native/registry.mjs b/tests/corpus/6854-node-island-shared-registry-native/registry.mjs new file mode 100644 index 000000000..e66af40bc --- /dev/null +++ b/tests/corpus/6854-node-island-shared-registry-native/registry.mjs @@ -0,0 +1,13 @@ +// A stateful singleton: a plugin registry with a mutable exported binding. +export let counter = 0; +export function bump() { return ++counter; } +const items = new Map(); +export class Plugin { + constructor(name) { this.name = name; } + describe() { return `plugin:${this.name}`; } +} +export function register(p) { items.set(p.name, p); bump(); return items.size; } +export function list() { return [...items.keys()]; } +export function find(name) { return items.get(name); } +export function describeAll() { return [...items.values()].map((p) => p.describe()).join(' | '); } +export function noop(x) { return x; } diff --git a/tests/corpus/6854-node-island-shared-registry-native/scenario.mjs b/tests/corpus/6854-node-island-shared-registry-native/scenario.mjs new file mode 100644 index 000000000..da85c3ec1 --- /dev/null +++ b/tests/corpus/6854-node-island-shared-registry-native/scenario.mjs @@ -0,0 +1,22 @@ +// Island code. Imports the shared module both as ESM (named + namespace) and via require(). +import { createRequire } from 'node:module'; +import * as ns from './registry.mjs'; +import { counter, bump, Plugin, register, list, find, describeAll, noop } from './registry.mjs'; +const require = createRequire(import.meta.url); + +export function run(log) { + log(`island list: ${list().join(',')}`); + log(`island counter: named=${counter} ns=${ns.counter}`); + bump(); + log(`after island bump: named=${counter} ns=${ns.counter}`); + log(`require() sees same instance: ${require('./registry.mjs').list().join(',')}`); + const p = new Plugin('island-b'); + register(p); + log(`instanceof (island-constructed): ${p instanceof Plugin}`); + log(`instanceof (owner-constructed): ${find('owner-a') instanceof Plugin}`); + log(`find returns same object twice: ${find('owner-a') === find('owner-a')}`); + register({ name: 'island-plain', describe() { return 'plain-from-island'; } }); + log(`describeAll from island: ${describeAll()}`); + log(`Object.keys(owner object): ${Object.keys(find('owner-a') ?? {}).join(',')}`); +} + diff --git a/tests/harness/corpus-costs/6850-node-island-shared-singleton/main.mjs.json b/tests/harness/corpus-costs/6850-node-island-shared-singleton/main.mjs.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6850-node-island-shared-singleton/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/corpus-costs/6851-node-island-shared-class/main.mjs.json b/tests/harness/corpus-costs/6851-node-island-shared-class/main.mjs.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6851-node-island-shared-class/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/corpus-costs/6852-node-island-shared-cycle/main.mjs.json b/tests/harness/corpus-costs/6852-node-island-shared-cycle/main.mjs.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6852-node-island-shared-cycle/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/corpus-costs/6853-node-island-shared-registry/main.mjs.json b/tests/harness/corpus-costs/6853-node-island-shared-registry/main.mjs.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6853-node-island-shared-registry/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/corpus-costs/6854-node-island-shared-registry-native/main.mjs.json b/tests/harness/corpus-costs/6854-node-island-shared-registry-native/main.mjs.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6854-node-island-shared-registry-native/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 15de758ca..8d3abcee2 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -160,7 +160,7 @@ describe.skipIf(skip)("Node islands in-process", () => { const result = await run(binary, [], { ...process.env, SCRIPTC_LIBNODE: dir }); expect(result.code).toBe(1); expect(result.stderr).toBe( - `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 6)\n`, + `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 7)\n`, ); });