mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-02 07:43:42 +08:00
Defer island modules whose recorded load is deterministic (NI-7b/7d/7e/7f) (#1906)
This commit is contained in:
@@ -379,6 +379,17 @@ function deferIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether `node` runs while its module evaluates: no enclosing function,
|
||||
* method, accessor, constructor or class member initializer. */
|
||||
function runsAtModuleEvaluation(node: ts.Node): boolean {
|
||||
for (let n = node.parent; n !== undefined; n = n.parent) {
|
||||
if (ts.isSourceFile(n)) return true;
|
||||
if (ts.isFunctionLike(n) || ts.isClassStaticBlockDeclaration(n) || ts.isPropertyDeclaration(n))
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A static import (or re-export) of an island package: loads the module
|
||||
* in the importer's %init prelude. Returns false when `spec` is not an
|
||||
* island module. */
|
||||
@@ -1707,7 +1718,14 @@ function lowerIslandLoadCall(
|
||||
loc,
|
||||
};
|
||||
}
|
||||
return fromIsland(lowerer, loadIslandModule(lowerer, path, loc), expr);
|
||||
// A require() the module's own evaluation runs (not inside a function or
|
||||
// class body) loads the island exactly when a static import would: it is
|
||||
// deferred the same way (scope-B NI-7d), so a native package's top-level
|
||||
// `const x = require("<island>")` does not start Node at boot.
|
||||
const load = runsAtModuleEvaluation(expr)
|
||||
? deferIslandModule(lowerer, path, loc)
|
||||
: loadIslandModule(lowerer, path, loc);
|
||||
return fromIsland(lowerer, load, expr);
|
||||
}
|
||||
|
||||
/* ── native classes extending island classes (scope-B NI-5b) ───────────
|
||||
@@ -2046,7 +2064,8 @@ function requireSpecOf(lowerer: Lowerer, decl: ts.Node): string | null {
|
||||
/** Top-level `const x = require("pkg")` statements of island packages in a
|
||||
* compiled module (a native package requiring an island package): load the
|
||||
* island module in the module's %init prelude (shortcut: before the body,
|
||||
* not at the statement). */
|
||||
* not at the statement), deferred exactly like a static import (scope-B
|
||||
* NI-7d: the build decides from the probe, stubs stand in until first use). */
|
||||
export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void {
|
||||
const islands = lowerer.nodeIslands;
|
||||
if (islands === null) return;
|
||||
@@ -2065,7 +2084,7 @@ export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void
|
||||
actions.push({
|
||||
kind: "assign",
|
||||
localId: binding.global.id,
|
||||
value: loadIslandModule(lowerer, path, loc),
|
||||
value: deferIslandModule(lowerer, path, loc),
|
||||
loc,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ exports.sep = path.sep;
|
||||
exports.join = join;
|
||||
exports.cache = new Map();
|
||||
exports.mode = typeof window === "undefined" ? "node" : "browser";
|
||||
exports.size = LIMIT * 2 + VERSION.length;
|
||||
exports.label = "v" + VERSION;
|
||||
var __importDefault = (this && this.__importDefault) || function (m) { return m; };
|
||||
`,
|
||||
"lib.js": `exports.x = 1;\n`,
|
||||
});
|
||||
@@ -69,7 +72,7 @@ exports.mode = typeof window === "undefined" ? "node" : "browser";
|
||||
["an unknown global", `const w = window;`],
|
||||
["a static block", `class A { static { console.log(1); } }`],
|
||||
["a getter with effects", `Object.defineProperty(exports, "x", { get() { return 1; } });`],
|
||||
["a template substitution", "const s = `${{}}`;"],
|
||||
["an increment", "let i = 0; const j = i++;"],
|
||||
[
|
||||
"a read of a class's static getter",
|
||||
`class C { static get x() { return require("fs").readFileSync("/etc/hostname"); } }
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
* files), and no throw. The gate is the conservative half of that proof:
|
||||
* it admits a module only when the top-level code of every file it loads
|
||||
* (the closure over literal require()/import specifiers) is built from the
|
||||
* forms below, which cannot have effects whatever the environment. The
|
||||
* build-time probe (snapshot.ts) then runs the admitted modules in libnode
|
||||
* and confirms they load without throwing and without the effects it can
|
||||
* observe.
|
||||
* forms below, which cannot have effects whatever the environment.
|
||||
*
|
||||
* NI-7b: the gate is the static tier only. Whether a module defers is
|
||||
* decided by the build-time probe's load recording (probe-recorder.ts):
|
||||
* a load that reads no run-time input is deterministic, so one recorded
|
||||
* run without effects proves deferral unobservable whether or not the
|
||||
* gate admits the code (bundles such as esbuild output never pass the
|
||||
* gate). The gate's verdict is reported with the decision.
|
||||
*
|
||||
* Admitted top-level statements (per file, CommonJS or ES module):
|
||||
* - directives ("use strict"), empty statements;
|
||||
@@ -36,9 +40,9 @@
|
||||
* admitted class expressions; object and array literals of pure parts
|
||||
* (no spreads, no accessors); `require("<literal>")` of an admitted file or
|
||||
* an allowlisted builtin; member reads on required modules, allowlisted
|
||||
* globals and same-file bindings; `typeof`, `void`, `!`; `===`, `!==`,
|
||||
* `&&`, `||`, `??` and the conditional operator over pure operands;
|
||||
* arithmetic between literals; `Symbol(…)`, `Symbol.for(…)`,
|
||||
* globals and same-file bindings; unary and binary operators (no
|
||||
* assignments or increments) and the conditional operator over pure
|
||||
* operands; CommonJS's top-level `this`; `Symbol(…)`, `Symbol.for(…)`,
|
||||
* `Object.freeze(…)` and `Object.create(…)` over pure arguments; `new` of
|
||||
* Map, Set, WeakMap and WeakSet without arguments.
|
||||
*
|
||||
@@ -357,6 +361,7 @@ function checkFile(parser: typeof ts, file: string, load: (file: string) => void
|
||||
return PURE_GLOBALS.has(e.text) || (!esm && (e.text === "module" || e.text === "exports"));
|
||||
}
|
||||
if (parser.isPropertyAccessExpression(e)) return readable(e.expression);
|
||||
if (e.kind === SK.ThisKeyword) return !esm;
|
||||
if (parser.isCallExpression(e)) return isRequireCall(e);
|
||||
return false;
|
||||
};
|
||||
@@ -389,13 +394,14 @@ function checkFile(parser: typeof ts, file: string, load: (file: string) => void
|
||||
case SK.FunctionExpression:
|
||||
case SK.ArrowFunction:
|
||||
return;
|
||||
case SK.ThisKeyword:
|
||||
// CommonJS: `exports` (tsc's `this && this.__importDefault`).
|
||||
if (esm) refuse(e, "`this` in an ES module");
|
||||
return;
|
||||
case SK.ClassExpression:
|
||||
return checkClass(e as ts.ClassExpression);
|
||||
case SK.TemplateExpression:
|
||||
for (const span of (e as ts.TemplateExpression).templateSpans) {
|
||||
// A substitution converts its value to a string: literals only.
|
||||
if (!isLiteral(span.expression)) refuse(span.expression, "a template substitution");
|
||||
}
|
||||
for (const span of (e as ts.TemplateExpression).templateSpans) pure(span.expression);
|
||||
return;
|
||||
case SK.ObjectLiteralExpression:
|
||||
for (const p of (e as ts.ObjectLiteralExpression).properties) {
|
||||
@@ -437,9 +443,9 @@ function checkFile(parser: typeof ts, file: string, load: (file: string) => void
|
||||
return pure((e as ts.VoidExpression).expression);
|
||||
case SK.PrefixUnaryExpression: {
|
||||
const u = e as ts.PrefixUnaryExpression;
|
||||
if (u.operator === SK.ExclamationToken) return pure(u.operand);
|
||||
if (isLiteral(u.operand)) return;
|
||||
return refuse(e, "a unary operator on a non-literal");
|
||||
if (u.operator === SK.PlusPlusToken || u.operator === SK.MinusMinusToken)
|
||||
return refuse(e, "an increment");
|
||||
return pure(u.operand);
|
||||
}
|
||||
case SK.BinaryExpression: {
|
||||
const b = e as ts.BinaryExpression;
|
||||
@@ -456,8 +462,13 @@ function checkFile(parser: typeof ts, file: string, load: (file: string) => void
|
||||
pure(b.right);
|
||||
return;
|
||||
}
|
||||
if (isLiteral(b.left) && isLiteral(b.right) && op !== SK.EqualsToken) return;
|
||||
return refuse(e, "a binary operator on non-literals");
|
||||
if (op >= SK.FirstAssignment && op <= SK.LastAssignment)
|
||||
return refuse(e, "an assignment inside an expression");
|
||||
// Coercions of pure operands; a module-defined valueOf/toString
|
||||
// runs module code, whose effects the probe's recording sees.
|
||||
pure(b.left);
|
||||
pure(b.right);
|
||||
return;
|
||||
}
|
||||
case SK.ConditionalExpression: {
|
||||
const c = e as ts.ConditionalExpression;
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
/* The build-time load recorder of deferred Node islands (scope-B NI-7b).
|
||||
* It runs inside libnode in the probe host (snapshot.ts writes it next to
|
||||
* the host and the bootstrap requires it), loads every candidate island
|
||||
* module once and records, for each:
|
||||
*
|
||||
* - inputs: reads of values that can differ between the build and a run,
|
||||
* or between the import position and a later evaluation: process.env
|
||||
* keys (guarded at boot), process.env enumeration, stdio TTY state
|
||||
* (`isTTY` is guarded at boot; window size and colour depth are not),
|
||||
* argv, execPath, cwd, uid/gid, the file system, os information, and
|
||||
* the clock and random numbers (volatile: allowed, but then no
|
||||
* constants are snapshotted);
|
||||
* - effects: output, warnings, process listeners and exits, timers,
|
||||
* immediates, ticks, microtasks and any other async resource created
|
||||
* during the load (promises included), child processes, the network,
|
||||
* workers, vm, the inspector, native addons (process.dlopen), writes
|
||||
* to process.env;
|
||||
* - mutations of builtin state: globalThis, the builtin prototypes and
|
||||
* constructors, `process`, and the exports of common builtin modules
|
||||
* (island-local, since native code has its own builtins, but
|
||||
* observable by other island modules evaluated out of order);
|
||||
* - every file the load required (the module's closure), and a throw;
|
||||
*
|
||||
* and takes the interface snapshot of the module's value (snapshot.ts).
|
||||
* Calls made by Node's own internals (the module loader reading files,
|
||||
* for instance) are not the module's: a record is kept only when the
|
||||
* nearest caller outside this file is not `node:internal/`.
|
||||
*
|
||||
* Determinism argument: a load that read no input runs the same way at
|
||||
* any time in any run (with the same pack and the same files, which the
|
||||
* boot guards check), so one recorded run is the whole truth about its
|
||||
* effects, and evaluating it later is unobservable when that run had
|
||||
* none. Volatile values (pid, clock, random) do not change between the
|
||||
* import position and a later evaluation in the same process except the
|
||||
* clock and random numbers, which no run of Node reproduces either. */
|
||||
export const PROBE_RECORDER = String.raw`"use strict";
|
||||
const types = require("node:util").types;
|
||||
const Module = require("node:module");
|
||||
const { createRequire } = Module;
|
||||
const asyncHooks = require("node:async_hooks");
|
||||
const SELF = __filename;
|
||||
|
||||
let lastCaller = "";
|
||||
// The closure of the module being loaded (files its load resolved), and
|
||||
// its package directory: code in other files (an earlier module that
|
||||
// wrapped a builtin, as graceful-fs wraps fs.closeSync) acts for its caller.
|
||||
let currentFiles = null;
|
||||
let currentPackageDir = null;
|
||||
function frameFile(line) {
|
||||
const m = /\(([^()]+):\d+:\d+\)\s*$/.exec(line) || /at ([^ ()]+):\d+:\d+\s*$/.exec(line);
|
||||
if (m === null) return null;
|
||||
let f = m[1];
|
||||
if (f.startsWith("file://")) { try { f = require("node:url").fileURLToPath(f); } catch {} }
|
||||
return f;
|
||||
}
|
||||
// Whose call this is: walking out from the recorder's frames, the first
|
||||
// frame of the module loader (node:internal/modules/) makes it the
|
||||
// loader's (reading a module's files); the first frame of other code
|
||||
// (an island module, outside node:) makes it the module's. Other builtin
|
||||
// frames (console, streams, a public builtin calling its own exports) act
|
||||
// for their caller.
|
||||
function internalCaller() {
|
||||
const limit = Error.stackTraceLimit;
|
||||
Error.stackTraceLimit = 64;
|
||||
const lines = new Error().stack.split("\n");
|
||||
Error.stackTraceLimit = limit;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.includes(SELF)) continue;
|
||||
if (line.includes("node:internal/modules/")) return true;
|
||||
if (/[( ]node:/.test(line)) continue;
|
||||
const file = frameFile(line);
|
||||
if (currentFiles !== null && file !== null && !currentFiles.has(file)) continue;
|
||||
lastCaller = line.trim().replace(/^at /, "");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const fnToString = Function.prototype.toString;
|
||||
const hasOwn = Object.prototype.hasOwnProperty;
|
||||
// A getter that only reads a binding (esbuild's and tsc's export getters):
|
||||
// reading it again reads the same binding. A member read ('a.b', 'a[b]')
|
||||
// can invoke another accessor that runs code on every read, so only a bare
|
||||
// identifier qualifies. Any other getter can also run code on every read,
|
||||
// so its member is not snapshotted.
|
||||
const BINDING_GETTER = /^(?:\(\)\s*=>\s*[A-Za-z_$][\w$]*|function\s*[\w$]*\s*\(\)\s*\{\s*return\s+[A-Za-z_$][\w$]*\s*;?\s*\}|get\s+[\w$]+\s*\(\)\s*\{\s*return\s+[A-Za-z_$][\w$]*\s*;?\s*\})$/;
|
||||
function bindingGetter(fn) {
|
||||
let source = "";
|
||||
try { source = fnToString.call(fn); } catch { return false; }
|
||||
return BINDING_GETTER.test(source.trim());
|
||||
}
|
||||
function isConstructor(f) {
|
||||
try { Reflect.construct(String, [], f); return true; } catch { return false; }
|
||||
}
|
||||
function plainClass(f) {
|
||||
// instanceof reads the prototype: without an object there, it throws.
|
||||
const proto = Object.getOwnPropertyDescriptor(f, "prototype");
|
||||
if (proto === undefined || !("value" in proto)) return false;
|
||||
if (proto.value === null || (typeof proto.value !== "object" && typeof proto.value !== "function"))
|
||||
return false;
|
||||
let source = "";
|
||||
try { source = fnToString.call(f); } catch { return false; }
|
||||
if (/\[native code\]\s*\}$/.test(source)) return false;
|
||||
for (let o = f; o !== null && o !== Function.prototype; o = Object.getPrototypeOf(o))
|
||||
if (hasOwn.call(o, Symbol.hasInstance)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const builtinObjects = new Set();
|
||||
for (const name of ["assert", "buffer", "child_process", "constants", "crypto", "dns", "events", "fs",
|
||||
"fs/promises", "http", "https", "module", "net", "os", "path", "querystring", "readline", "stream",
|
||||
"stream/promises", "string_decoder", "timers", "tty", "url", "util", "util/types", "zlib"]) {
|
||||
try { builtinObjects.add(require(name)); } catch {}
|
||||
}
|
||||
|
||||
exports.probe = function probe(input) {
|
||||
const objects = {};
|
||||
const oids = new Map();
|
||||
let next = 1;
|
||||
const value = (v, depth) => {
|
||||
if (v === undefined) return { u: 1 };
|
||||
if (v === null) return { l: 1 };
|
||||
switch (typeof v) {
|
||||
case "boolean": return { b: v };
|
||||
case "number": return { n: Object.is(v, -0) ? "-0" : String(v) };
|
||||
case "string": return v.isWellFormed() ? { s: v } : { x: 1 };
|
||||
case "symbol": return object(v, 2, depth);
|
||||
case "function":
|
||||
if (types.isProxy(v)) return { x: 1 };
|
||||
return object(v, 1 | (isConstructor(v) ? 32 : 0), depth);
|
||||
case "object":
|
||||
if (types.isProxy(v) || types.isPromise(v) || types.isNativeError(v) ||
|
||||
ArrayBuffer.isView(v) || types.isExternal(v) || builtinObjects.has(v)) return { x: 1 };
|
||||
return object(v, Array.isArray(v) ? 8 : 0, depth);
|
||||
default: return { x: 1 };
|
||||
}
|
||||
};
|
||||
const object = (v, flags, depth) => {
|
||||
if (oids.has(v)) return { o: oids.get(v) };
|
||||
const id = next++;
|
||||
oids.set(v, id);
|
||||
const entry = { f: flags, p: (flags & 1) !== 0 && plainClass(v), m: [] };
|
||||
objects[id] = entry;
|
||||
if (typeof v === "symbol") return { o: id };
|
||||
const keys = depth === 0 || (depth === 1 && typeof v !== "function")
|
||||
? Object.getOwnPropertyNames(v)
|
||||
: typeof v === "function" ? ["name", "length"] : [];
|
||||
for (const key of keys) {
|
||||
let d;
|
||||
try { d = Object.getOwnPropertyDescriptor(v, key); } catch { continue; }
|
||||
if (d === undefined) continue;
|
||||
if (!("value" in d)) {
|
||||
// The module value's getters (esbuild's __export): read once, under
|
||||
// the recording, as Node reads CommonJS named exports after the
|
||||
// module evaluated.
|
||||
if (depth !== 0 || typeof d.get !== "function" || !bindingGetter(d.get)) continue;
|
||||
// A getter that reads inputs or has effects (esbuild's lazy __esm
|
||||
// inits) is left out: its member starts Node, and its work happens
|
||||
// when the program reads the member, eager or deferred alike (Node
|
||||
// reads every CommonJS named export at import; scriptc reads the
|
||||
// ones the program imports). It is not part of the load.
|
||||
const marks = [inputs.length, volatile.length, envKeys.size, tty.size, effects.length];
|
||||
const keysBefore = new Set(envKeys.keys());
|
||||
const ttyBefore = new Set(tty.keys());
|
||||
let got, ok = true;
|
||||
recording = true;
|
||||
try { got = d.get.call(v); } catch { ok = false; }
|
||||
recording = false;
|
||||
const readInput = inputs.length > marks[0] || volatile.length > marks[1] ||
|
||||
envKeys.size > marks[2] || tty.size > marks[3] || effects.length > marks[4];
|
||||
if (readInput) {
|
||||
inputs.length = marks[0];
|
||||
volatile.length = marks[1];
|
||||
effects.length = marks[4];
|
||||
for (const k of [...envKeys.keys()]) if (!keysBefore.has(k)) envKeys.delete(k);
|
||||
for (const k of [...tty.keys()]) if (!ttyBefore.has(k)) tty.delete(k);
|
||||
}
|
||||
if (ok && !readInput) entry.m.push([key, value(got, depth + 1)]);
|
||||
continue;
|
||||
}
|
||||
entry.m.push([key, value(d.value, depth + 1)]);
|
||||
}
|
||||
return { o: id };
|
||||
};
|
||||
|
||||
// ── the recorder ──
|
||||
const inputs = [], volatile = [], effects = [], tty = new Map(), envKeys = new Map();
|
||||
const files = new Set();
|
||||
let recording = false;
|
||||
const note = (list, what) => {
|
||||
if (recording && !internalCaller()) list.push(list === volatile ? what : what + " (" + lastCaller + ")");
|
||||
};
|
||||
const undo = [];
|
||||
const wrapFn = (owner, key, list, label) => {
|
||||
let original;
|
||||
try { original = owner[key]; } catch { return; }
|
||||
if (typeof original !== "function") return;
|
||||
const wrapped = function (...a) {
|
||||
note(list, label);
|
||||
return new.target ? Reflect.construct(original, a, new.target) : original.apply(this, a);
|
||||
};
|
||||
try {
|
||||
Object.defineProperty(wrapped, "name", { value: original.name });
|
||||
Object.defineProperty(wrapped, "length", { value: original.length });
|
||||
for (const k of Reflect.ownKeys(original))
|
||||
if (!hasOwn.call(wrapped, k)) Object.defineProperty(wrapped, k, Object.getOwnPropertyDescriptor(original, k));
|
||||
owner[key] = wrapped;
|
||||
} catch { return; }
|
||||
undo.push(() => { try { owner[key] = original; } catch {} });
|
||||
};
|
||||
const wrapGetter = (owner, key, label, onRead) => {
|
||||
const d = Object.getOwnPropertyDescriptor(owner, key);
|
||||
if (d !== undefined && !d.configurable) return;
|
||||
// Without an own property (a pipe's isTTY), reads go to the prototype.
|
||||
const own = d !== undefined;
|
||||
let shadow;
|
||||
Object.defineProperty(owner, key, {
|
||||
configurable: true,
|
||||
enumerable: own ? d.enumerable : false,
|
||||
get() {
|
||||
const v = own
|
||||
? (d.get ? d.get.call(this) : d.value)
|
||||
: shadow !== undefined ? shadow.v : Reflect.get(Object.getPrototypeOf(owner), key, this);
|
||||
if (recording && !internalCaller()) onRead(v);
|
||||
return v;
|
||||
},
|
||||
set(v) {
|
||||
note(effects, "a write of " + label);
|
||||
if (!own) shadow = { v };
|
||||
else if (d.set) d.set.call(this, v);
|
||||
else d.value = v;
|
||||
},
|
||||
});
|
||||
undo.push(() => {
|
||||
if (own) Object.defineProperty(owner, key, d);
|
||||
else delete owner[key];
|
||||
});
|
||||
};
|
||||
const realEnv = process.env;
|
||||
const readKey = (t, k) => {
|
||||
if (recording && typeof k === "string" && !envKeys.has(k) && !internalCaller())
|
||||
envKeys.set(k, t[k] === undefined ? null : String(t[k]));
|
||||
};
|
||||
process.env = new Proxy(realEnv, {
|
||||
get(t, k) { readKey(t, k); return t[k]; },
|
||||
has(t, k) { readKey(t, k); return k in t; },
|
||||
getOwnPropertyDescriptor(t, k) { readKey(t, k); return Reflect.getOwnPropertyDescriptor(t, k); },
|
||||
ownKeys(t) { note(inputs, "an enumeration of process.env"); return Reflect.ownKeys(t); },
|
||||
set(t, k, v) { note(effects, "a write of process.env." + String(k)); t[k] = v; return true; },
|
||||
defineProperty(t, k, d) { note(effects, "a write of process.env." + String(k)); return Reflect.defineProperty(t, k, d); },
|
||||
deleteProperty(t, k) { note(effects, "a delete of process.env." + String(k)); delete t[k]; return true; },
|
||||
});
|
||||
undo.push(() => { process.env = realEnv; });
|
||||
for (const k of ["argv", "execArgv", "execPath", "title", "argv0", "debugPort", "exitCode"])
|
||||
wrapGetter(process, k, "process." + k, () => note(inputs, "process." + k));
|
||||
for (const k of ["pid", "ppid"]) wrapGetter(process, k, "process." + k, () => note(volatile, "process." + k));
|
||||
for (const k of ["cwd", "getuid", "getgid", "geteuid", "getegid", "getgroups", "umask", "getActiveResourcesInfo", "resourceUsage", "availableMemory", "constrainedMemory"])
|
||||
wrapFn(process, k, inputs, "process." + k + "()");
|
||||
for (const k of ["uptime", "hrtime", "memoryUsage", "cpuUsage"]) wrapFn(process, k, volatile, "process." + k + "()");
|
||||
if (process.hrtime) wrapFn(process.hrtime, "bigint", volatile, "process.hrtime.bigint()");
|
||||
for (const k of ["exit", "reallyExit", "abort", "kill", "chdir", "setuid", "setgid", "seteuid", "setegid", "setgroups", "dlopen", "binding", "_linkedBinding", "emitWarning", "on", "addListener", "once", "prependListener", "prependOnceListener", "removeListener", "off", "removeAllListeners", "setMaxListeners", "nextTick", "setUncaughtExceptionCaptureCallback", "setSourceMapsEnabled", "send", "disconnect"])
|
||||
wrapFn(process, k, effects, "process." + k + "()");
|
||||
for (const [fd, s] of [[0, "stdin"], [1, "stdout"], [2, "stderr"]]) {
|
||||
let stream;
|
||||
try { stream = process[s]; } catch { continue; }
|
||||
for (const k of ["write", "on", "once", "addListener", "prependListener", "end", "setRawMode", "resume", "pause", "read", "cork", "uncork", "destroy", "pipe"])
|
||||
wrapFn(stream, k, effects, "process." + s + "." + k + "()");
|
||||
wrapGetter(stream, "isTTY", "process." + s + ".isTTY", (v) => { if (!tty.has(fd)) tty.set(fd, v === true); });
|
||||
for (const k of ["columns", "rows"]) wrapGetter(stream, k, "process." + s + "." + k, () => note(inputs, "process." + s + "." + k));
|
||||
for (const k of ["getColorDepth", "hasColors", "getWindowSize"]) wrapFn(stream, k, inputs, "process." + s + "." + k + "()");
|
||||
}
|
||||
for (const k of ["queueMicrotask", "fetch"]) wrapFn(globalThis, k, effects, k + "()");
|
||||
// Timers: one the load sets and clears again, and a clear of nothing
|
||||
// (undefined, null), leave nothing behind (NI-7d).
|
||||
const pendingTimers = new Map();
|
||||
for (const k of ["setTimeout", "setInterval", "setImmediate"]) {
|
||||
const original = globalThis[k];
|
||||
const wrapped = function (...a) {
|
||||
const handle = original.apply(this, a);
|
||||
if (recording && !internalCaller()) {
|
||||
effects.push(k + "() (" + lastCaller + ")");
|
||||
pendingTimers.set(handle, effects.length - 1);
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
Object.defineProperty(wrapped, "name", { value: original.name });
|
||||
globalThis[k] = wrapped;
|
||||
undo.push(() => { globalThis[k] = original; });
|
||||
}
|
||||
for (const k of ["clearTimeout", "clearInterval", "clearImmediate"]) {
|
||||
const original = globalThis[k];
|
||||
const wrapped = function (handle) {
|
||||
if (recording && !internalCaller()) {
|
||||
if (pendingTimers.has(handle)) {
|
||||
effects[pendingTimers.get(handle)] = null;
|
||||
pendingTimers.delete(handle);
|
||||
} else if (handle !== undefined && handle !== null) {
|
||||
effects.push(k + "() of a timer the load did not set (" + lastCaller + ")");
|
||||
}
|
||||
}
|
||||
return original.apply(this, arguments);
|
||||
};
|
||||
Object.defineProperty(wrapped, "name", { value: original.name });
|
||||
globalThis[k] = wrapped;
|
||||
undo.push(() => { globalThis[k] = original; });
|
||||
}
|
||||
wrapFn(Date, "now", volatile, "Date.now()");
|
||||
wrapFn(Math, "random", volatile, "Math.random()");
|
||||
wrapFn(globalThis, "Date", volatile, "new Date()");
|
||||
const whole = {
|
||||
fs: inputs, os: inputs, child_process: effects, net: effects, dgram: effects, dns: effects,
|
||||
http: effects, https: effects, http2: effects, tls: effects, worker_threads: effects,
|
||||
cluster: effects, vm: effects, inspector: effects, tty: inputs, readline: effects, timers: effects,
|
||||
perf_hooks: volatile, crypto: volatile, v8: effects, repl: effects, trace_events: effects,
|
||||
async_hooks: effects, diagnostics_channel: effects,
|
||||
};
|
||||
// os.homedir() and os.tmpdir() are functions of the environment on POSIX:
|
||||
// HOME (when set), and TMPDIR, TMP, TEMP. Their keys become env guards.
|
||||
const osModule = require("node:os");
|
||||
for (const [k, keys] of [["homedir", ["HOME"]], ["tmpdir", ["TMPDIR", "TMP", "TEMP"]]]) {
|
||||
const original = osModule[k];
|
||||
osModule[k] = function () {
|
||||
const result = original.apply(this, arguments);
|
||||
if (recording && !internalCaller()) {
|
||||
if (process.platform === "win32" || (k === "homedir" && realEnv.HOME === undefined))
|
||||
inputs.push("os." + k + "()");
|
||||
else for (const key of keys) if (!envKeys.has(key)) envKeys.set(key, realEnv[key] === undefined ? null : String(realEnv[key]));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
undo.push(() => { osModule[k] = original; });
|
||||
}
|
||||
const pureFns = new Set(["os.platform", "os.homedir", "os.tmpdir", "os.type", "os.arch", "os.machine", "os.endianness",
|
||||
"crypto.createHash", "crypto.createHmac", "crypto.getHashes", "crypto.getCiphers", "crypto.getCurves",
|
||||
"crypto.createCipheriv", "crypto.createDecipheriv", "vm.isContext", "fs.existsSync__never",
|
||||
"async_hooks.AsyncLocalStorage", "async_hooks.AsyncResource", "diagnostics_channel.channel",
|
||||
"diagnostics_channel.tracingChannel", "diagnostics_channel.hasSubscribers"]);
|
||||
// Synchronous reads of the module's own package (its files are boot
|
||||
// guards, by size and mtime) are guarded inputs, not inputs.
|
||||
const fsModule = require("node:fs");
|
||||
const guardedFds = new Set();
|
||||
const ownPath = (p) => {
|
||||
if (currentPackageDir === null) return null;
|
||||
let f = p;
|
||||
if (f instanceof URL) { try { f = require("node:url").fileURLToPath(f); } catch { return null; } }
|
||||
if (Buffer.isBuffer(f)) f = f.toString();
|
||||
if (typeof f !== "string") return null;
|
||||
const abs = require("node:path").resolve(f);
|
||||
return abs === currentPackageDir || abs.startsWith(currentPackageDir + "/") ? abs : null;
|
||||
};
|
||||
const readOnlyFlags = (flags) => flags === undefined || flags === "r" || flags === "rs" || flags === fsModule.constants.O_RDONLY;
|
||||
for (const key of ["readFileSync", "existsSync", "statSync", "lstatSync", "readdirSync", "realpathSync", "accessSync", "readlinkSync", "openSync"]) {
|
||||
const original = fsModule[key];
|
||||
if (typeof original !== "function") continue;
|
||||
const wrapped = function (target, flags) {
|
||||
let result;
|
||||
try {
|
||||
result = original.apply(this, arguments);
|
||||
} catch (e) {
|
||||
// A file that is not there has no boot guard: its absence is an input.
|
||||
if (recording && !internalCaller()) inputs.push("fs." + key + "() (" + lastCaller + ")");
|
||||
throw e;
|
||||
}
|
||||
if (recording && !internalCaller()) {
|
||||
const own = ownPath(target);
|
||||
if (key === "existsSync" && result === false) {
|
||||
inputs.push("fs." + key + "() (" + lastCaller + ")");
|
||||
} else if (own !== null && (key !== "openSync" || readOnlyFlags(flags))) {
|
||||
files.add(own);
|
||||
if (key === "openSync") guardedFds.add(result);
|
||||
} else {
|
||||
inputs.push("fs." + key + "() (" + lastCaller + ")");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const k of Reflect.ownKeys(original)) if (!hasOwn.call(wrapped, k)) { try { Object.defineProperty(wrapped, k, Object.getOwnPropertyDescriptor(original, k)); } catch {} }
|
||||
fsModule[key] = wrapped;
|
||||
undo.push(() => { fsModule[key] = original; });
|
||||
}
|
||||
for (const key of ["readSync", "fstatSync", "closeSync"]) {
|
||||
const original = fsModule[key];
|
||||
const wrapped = function (fd) {
|
||||
const result = original.apply(this, arguments);
|
||||
if (recording && !internalCaller()) {
|
||||
if (!guardedFds.has(fd)) inputs.push("fs." + key + "() (" + lastCaller + ")");
|
||||
else if (key === "closeSync") guardedFds.delete(fd);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
fsModule[key] = wrapped;
|
||||
undo.push(() => { fsModule[key] = original; });
|
||||
}
|
||||
const guardedFsFns = new Set(["readFileSync", "existsSync", "statSync", "lstatSync", "readdirSync", "realpathSync", "accessSync", "readlinkSync", "openSync", "readSync", "fstatSync", "closeSync"]);
|
||||
for (const [name, list] of Object.entries(whole)) {
|
||||
let m;
|
||||
try { m = require(name); } catch { continue; }
|
||||
for (const key of Object.keys(m)) {
|
||||
if (name === "fs" && guardedFsFns.has(key)) continue;
|
||||
if (pureFns.has(name + "." + key) || key === "promises" || /^[A-Z]/.test(key) && name !== "worker_threads" && name !== "child_process") continue;
|
||||
wrapFn(m, key, list, name + "." + key + "()");
|
||||
}
|
||||
if (name === "fs" && m.promises)
|
||||
for (const key of Object.keys(m.promises)) wrapFn(m.promises, key, inputs, "fs.promises." + key + "()");
|
||||
}
|
||||
wrapFn(require("worker_threads"), "Worker", effects, "new Worker()");
|
||||
// Async resources the load creates. A promise alone is no work (an async
|
||||
// function that returns at once creates one); a reaction to a promise
|
||||
// the load created (then/catch/finally, an await) is.
|
||||
const loadPromises = new Set();
|
||||
const hook = asyncHooks.createHook({
|
||||
init(id, type, trigger) {
|
||||
// Timers and immediates are the timer wrappers' to account for.
|
||||
if (type === "Timeout" || type === "Immediate" || !recording) return;
|
||||
if (type === "PROMISE") {
|
||||
if (loadPromises.has(trigger)) {
|
||||
internalCaller();
|
||||
effects.push("a promise reaction (" + lastCaller + ")");
|
||||
} else if (!internalCaller()) {
|
||||
loadPromises.add(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!internalCaller()) effects.push("async work (" + type + ", " + lastCaller + ")");
|
||||
},
|
||||
});
|
||||
hook.enable();
|
||||
undo.push(() => hook.disable());
|
||||
// The closure: a resolve hook (no frame of ours stays on the stack while
|
||||
// modules evaluate, so Node's node_modules checks see the real callers).
|
||||
const hooks = Module.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
const result = nextResolve(specifier, context);
|
||||
if (recording && typeof result.url === "string" && result.url.startsWith("file:"))
|
||||
files.add(require("node:url").fileURLToPath(result.url));
|
||||
return result;
|
||||
},
|
||||
});
|
||||
undo.push(() => hooks.deregister());
|
||||
const watched = [
|
||||
["globalThis", globalThis], ["Object", Object], ["Object.prototype", Object.prototype],
|
||||
["Function.prototype", Function.prototype], ["Array", Array], ["Array.prototype", Array.prototype],
|
||||
["String.prototype", String.prototype], ["Number.prototype", Number.prototype],
|
||||
["Boolean.prototype", Boolean.prototype], ["Symbol", Symbol], ["Symbol.prototype", Symbol.prototype],
|
||||
["Promise", Promise], ["Promise.prototype", Promise.prototype], ["Error", Error],
|
||||
["Error.prototype", Error.prototype], ["RegExp.prototype", RegExp.prototype],
|
||||
["Map.prototype", Map.prototype], ["Set.prototype", Set.prototype], ["JSON", JSON],
|
||||
["Math", Math], ["Reflect", Reflect], ["process", process], ["Buffer", Buffer],
|
||||
["Buffer.prototype", Buffer.prototype],
|
||||
];
|
||||
for (const name of ["fs", "path", "events", "stream", "util", "os", "child_process", "http", "https",
|
||||
"net", "url", "buffer", "crypto", "zlib", "module", "assert", "string_decoder", "timers", "tty",
|
||||
"fs/promises", "stream/promises", "util/types", "constants", "querystring", "readline"]) {
|
||||
try { watched.push([name, require(name)]); } catch {}
|
||||
}
|
||||
try { watched.push(["events.prototype", require("events").prototype]); } catch {}
|
||||
try { watched.push(["stream.prototype", require("stream").prototype]); } catch {}
|
||||
const shot = () => watched.map(([name, o]) => {
|
||||
const m = new Map();
|
||||
for (const k of Reflect.ownKeys(o)) {
|
||||
const d = Object.getOwnPropertyDescriptor(o, k);
|
||||
m.set(k, d === undefined ? undefined : "value" in d ? d.value : d.get);
|
||||
}
|
||||
return [name, m];
|
||||
});
|
||||
|
||||
// What a member of a module value reads now: a data property's value, or
|
||||
// a binding getter's (esbuild's and tsc's export getters). Any other
|
||||
// accessor can run code, so it is not part of a shape.
|
||||
const MISSING = {};
|
||||
const memberValue = (o, k) => {
|
||||
const d = Object.getOwnPropertyDescriptor(o, k);
|
||||
if (d === undefined) return MISSING;
|
||||
if ("value" in d) return d.value;
|
||||
if (typeof d.get !== "function" || !bindingGetter(d.get)) return MISSING;
|
||||
try { return d.get.call(o); } catch { return MISSING; }
|
||||
};
|
||||
|
||||
// A module's exported values right after its load, at the depth the
|
||||
// snapshot covers (the module value's members, and theirs for objects).
|
||||
const exportShape = (v) => {
|
||||
const shape = [];
|
||||
if (v === null || (typeof v !== "object" && typeof v !== "function")) return shape;
|
||||
for (const k of Reflect.ownKeys(v)) {
|
||||
const m = memberValue(v, k);
|
||||
if (m === MISSING) continue;
|
||||
shape.push([v, k, m]);
|
||||
if (m !== null && typeof m === "object")
|
||||
for (const k2 of Reflect.ownKeys(m)) {
|
||||
const m2 = memberValue(m, k2);
|
||||
if (m2 !== MISSING) shape.push([m, k2, m2]);
|
||||
}
|
||||
}
|
||||
return shape;
|
||||
};
|
||||
const shapes = new Map();
|
||||
const results = [];
|
||||
for (const path of input.modules) {
|
||||
inputs.length = volatile.length = effects.length = 0;
|
||||
pendingTimers.clear();
|
||||
loadPromises.clear();
|
||||
tty.clear(); envKeys.clear(); files.clear();
|
||||
files.add(path);
|
||||
currentFiles = files;
|
||||
currentPackageDir = null;
|
||||
for (let d = require("node:path").dirname(path); d !== require("node:path").dirname(d); d = require("node:path").dirname(d)) {
|
||||
if (require("node:fs").existsSync(d + "/package.json")) { currentPackageDir = d; break; }
|
||||
}
|
||||
const before = shot();
|
||||
let exportsValue, threw;
|
||||
recording = true;
|
||||
try { exportsValue = createRequire(path)(path); } catch (e) { threw = String((e && e.message) || e); }
|
||||
recording = false;
|
||||
const after = shot();
|
||||
const root = threw === undefined ? value(exportsValue, 0) : undefined;
|
||||
const mutations = [];
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
const [name, b] = before[i];
|
||||
const a = after[i][1];
|
||||
for (const [k, v] of a) if (!b.has(k) || !Object.is(b.get(k), v)) mutations.push(name + "." + String(k));
|
||||
for (const k of b.keys()) if (!a.has(k)) mutations.push("delete " + name + "." + String(k));
|
||||
}
|
||||
// Node's own undici creates its global-dispatcher symbol lazily, at
|
||||
// times unrelated to the module; only code that names it can set it.
|
||||
const names = (key) => {
|
||||
for (const f of files) {
|
||||
try { if (require("node:fs").readFileSync(f, "utf8").includes(key)) return true; } catch {}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (let i = mutations.length - 1; i >= 0; i--) {
|
||||
const m = /^globalThis\.Symbol\((undici\.globalDispatcher\.\d+)\)$/.exec(mutations[i]);
|
||||
if (m !== null && !names("undici.globalDispatcher")) mutations.splice(i, 1);
|
||||
}
|
||||
const record = {
|
||||
path,
|
||||
inputs: [...new Set(inputs)],
|
||||
volatile: [...new Set(volatile)],
|
||||
effects: [...new Set(effects.filter((x) => x !== null))],
|
||||
mutations,
|
||||
env: [...envKeys],
|
||||
tty: [...tty],
|
||||
files: [...files],
|
||||
};
|
||||
if (threw !== undefined) { record.throws = threw; results.push(record); continue; }
|
||||
if (!("o" in root)) { record.reason = "its value is not an object"; results.push(record); continue; }
|
||||
record.root = root.o;
|
||||
// This load changed what an earlier module exports: neither defers (the
|
||||
// earlier snapshot no longer holds, and this load's change has to happen
|
||||
// where the program imports it).
|
||||
for (const [earlier, shape] of shapes) {
|
||||
const changed = shape.some(([o, k, v]) => !Object.is(memberValue(o, k), v));
|
||||
if (!changed) continue;
|
||||
delete earlier.root;
|
||||
earlier.reason = "a later island module's load changes its exports";
|
||||
delete record.root;
|
||||
record.reason = "its load changes another island module's exports";
|
||||
shapes.delete(earlier);
|
||||
}
|
||||
if (record.root !== undefined) shapes.set(record, exportShape(exportsValue));
|
||||
results.push(record);
|
||||
}
|
||||
for (const u of undo.reverse()) u();
|
||||
return { results, objects };
|
||||
};
|
||||
`;
|
||||
@@ -1,8 +1,17 @@
|
||||
import { chmodSync, mkdtempSync, realpathSync, rmSync, statSync } from "node:fs";
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { probeHostDirectory } from "./snapshot.js";
|
||||
import type { IrModule } from "../ir/ir.js";
|
||||
import { finalizeDeferredIslands, probeHostDirectory, type ProbeOutput } from "./snapshot.js";
|
||||
|
||||
const saved = { ...process.env };
|
||||
const made: string[] = [];
|
||||
@@ -55,3 +64,76 @@ describe.skipIf(process.platform === "win32")("the probe host's directory", () =
|
||||
expect(statSync(dir).mode & 0o077).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/** A project whose entry imports the island module `dep` (one deferred
|
||||
* island load), and a stand-in for the probe whose recorded load of the
|
||||
* module read `env` and exported `{ url }`. */
|
||||
function deferredProject(scriptc: unknown, env: [string, string | null][], url: string) {
|
||||
const root = scratch();
|
||||
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "app", scriptc }));
|
||||
const file = join(root, "node_modules", "dep", "index.js");
|
||||
mkdirSync(join(root, "node_modules", "dep"), { recursive: true });
|
||||
writeFileSync(join(root, "node_modules", "dep", "package.json"), '{ "name": "dep" }');
|
||||
writeFileSync(file, "module.exports = { url: process.env.DB_URL };\n");
|
||||
const call = {
|
||||
kind: "libCall",
|
||||
fn: "nodeIsland.defer",
|
||||
args: [
|
||||
{ kind: "strLit", value: file },
|
||||
{ kind: "strLit", value: "" },
|
||||
],
|
||||
};
|
||||
const mod = { functions: [{ body: [call] }], globals: [] } as unknown as IrModule;
|
||||
const probe: ProbeOutput = {
|
||||
results: [
|
||||
{
|
||||
path: file,
|
||||
root: 1,
|
||||
inputs: [],
|
||||
volatile: [],
|
||||
effects: [],
|
||||
mutations: [],
|
||||
env,
|
||||
tty: [],
|
||||
files: [file],
|
||||
},
|
||||
],
|
||||
objects: { "1": { f: 0, p: false, m: [["url", { s: url }]] } },
|
||||
};
|
||||
const decisions = finalizeDeferredIslands(mod, join(root, "main.ts"), root, () => probe);
|
||||
return { decisions, call, text: JSON.stringify(mod) };
|
||||
}
|
||||
|
||||
describe("environment boot guards", () => {
|
||||
test("a load that read a variable outside publicEnv stays eager and records no value", () => {
|
||||
const secret = "postgres://admin:hunter2@db.internal/prod";
|
||||
const { decisions, call, text } = deferredProject({}, [["DB_URL", secret]], secret);
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]!.deferred).toBe(false);
|
||||
expect(decisions[0]!.reason).toContain("DB_URL");
|
||||
expect(call.fn).toBe("nodeIsland.require");
|
||||
expect(text).not.toContain("hunter2");
|
||||
});
|
||||
|
||||
test('"evaluate": "lazy" does not record a variable outside publicEnv', () => {
|
||||
const secret = "sk-live-0123456789";
|
||||
const { decisions, text } = deferredProject(
|
||||
{ packages: { dep: { evaluate: "lazy" } } },
|
||||
[["API_KEY", secret]],
|
||||
"https://api.example.com",
|
||||
);
|
||||
expect(decisions[0]!.deferred).toBe(false);
|
||||
expect(text).not.toContain(secret);
|
||||
});
|
||||
|
||||
test("a load that read only publicEnv variables defers, guarded by their values", () => {
|
||||
const { decisions, call, text } = deferredProject(
|
||||
{ nodeIslands: { publicEnv: ["NODE_ENV"] } },
|
||||
[["NODE_ENV", "production"]],
|
||||
"https://example.com",
|
||||
);
|
||||
expect(decisions[0]!.deferred).toBe(true);
|
||||
expect(call.fn).toBe("nodeIsland.defer");
|
||||
expect(text).toContain("E 8:NODE_ENV 1 10:production");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,16 +7,27 @@
|
||||
* decides each one:
|
||||
*
|
||||
* - the package's `evaluate` setting (`scriptc.packages.<name>`):
|
||||
* "eager" keeps the module eager; "lazy" skips the static effect gate
|
||||
* and the probe's effect log (the user judged the effects benign);
|
||||
* "auto" (the default) needs both;
|
||||
* - the static effect gate (effect-gate.ts) over the module's closure;
|
||||
* "eager" keeps the module eager; "lazy" defers it whatever else its
|
||||
* load does (the user judged the effects benign);
|
||||
* - environment reads: the boot guards record the build-time value of
|
||||
* every environment variable the load read, so a load that read one
|
||||
* not listed in `scriptc.nodeIslands.publicEnv` stays eager, "lazy"
|
||||
* included, and no value of it reaches the executable;
|
||||
* - the probe: one run of the build-time probe host
|
||||
* (runtime src/tools/scr_node_island_probe.c) in a libnode environment
|
||||
* from the same pack, which loads every candidate module, records what
|
||||
* it can observe of their evaluation (output, warnings, listeners,
|
||||
* timers, new globals, a throw) and takes the interface snapshot of
|
||||
* each module's value;
|
||||
* from the same pack, which loads every candidate module under the
|
||||
* load recorder (probe-recorder.ts) and takes the interface snapshot of
|
||||
* each module's value. "auto" (the default) defers a module whose
|
||||
* recorded load threw nothing, had no effects and read no run-time
|
||||
* input other than public env keys and stdio TTY state, which become
|
||||
* boot guards (the determinism argument in probe-recorder.ts). The static
|
||||
* effect gate (effect-gate.ts) is reported alongside, as the tier that
|
||||
* needs no recording;
|
||||
* - per-module mode (NI-7b): when every static island import defers,
|
||||
* none mutates builtin state and no island module loads at a call site,
|
||||
* Node starting evaluates nothing by itself; components of deferred
|
||||
* modules that share files evaluate on first use. Otherwise every
|
||||
* deferred module evaluates when Node starts (in import order);
|
||||
*
|
||||
* and rewrites the call: a deferred module keeps `nodeIsland.defer` with
|
||||
* its snapshot text (format: runtime scr_node_island_boot.c), any other
|
||||
@@ -42,6 +53,7 @@ import type { IrExpr, IrModule } from "../ir/ir.js";
|
||||
import { readProjectConfig } from "../project-config.js";
|
||||
import { effectGate } from "./effect-gate.js";
|
||||
import { packageDirOf } from "./origin.js";
|
||||
import { PROBE_RECORDER } from "./probe-recorder.js";
|
||||
|
||||
type DeferCall = Extract<IrExpr, { kind: "libCall" }>;
|
||||
|
||||
@@ -96,13 +108,39 @@ function traceDecision(d: DeferralDecision): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Island loads lowering emitted at call sites (`require()`, `import()`). */
|
||||
function hasDynamicLoads(mod: IrModule): boolean {
|
||||
let found = false;
|
||||
const seen = new Set<object>();
|
||||
const walk = (v: unknown): void => {
|
||||
if (found || v === null || typeof v !== "object" || seen.has(v)) return;
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) {
|
||||
for (const x of v) walk(x);
|
||||
return;
|
||||
}
|
||||
const o = v as Record<string, unknown>;
|
||||
if (
|
||||
o["kind"] === "libCall" &&
|
||||
(o["fn"] === "nodeIsland.require" || o["fn"] === "nodeIsland.import")
|
||||
)
|
||||
found = true;
|
||||
for (const k in o) if (k !== "loc" && k !== "type") walk(o[k]);
|
||||
};
|
||||
walk(mod.functions);
|
||||
walk(mod.globals);
|
||||
return found;
|
||||
}
|
||||
|
||||
export function finalizeDeferredIslands(
|
||||
mod: IrModule,
|
||||
entryPath: string,
|
||||
packDir: string | null,
|
||||
probeModules: (packDir: string, modules: string[]) => ProbeOutput | string = runProbe,
|
||||
): DeferralDecision[] {
|
||||
const calls = deferCalls(mod);
|
||||
if (calls.length === 0) return [];
|
||||
const dynamicLoads = hasDynamicLoads(mod);
|
||||
const decisions: DeferralDecision[] = [];
|
||||
const decide = (call: DeferCall, deferred: boolean, reason: string): void => {
|
||||
const d = { path: fileOf(mod, pathOf(call)), deferred, reason };
|
||||
@@ -115,7 +153,8 @@ export function finalizeDeferredIslands(
|
||||
return decisions;
|
||||
}
|
||||
const config = readProjectConfig(entryPath);
|
||||
const candidates: { call: DeferCall; path: string; lazy: boolean; files: string[] }[] = [];
|
||||
const publicEnv = new Set(config.nodeIslands.publicEnv);
|
||||
const candidates: { call: DeferCall; path: string; lazy: boolean; gate: string }[] = [];
|
||||
for (const call of calls) {
|
||||
const path = fileOf(mod, pathOf(call));
|
||||
const pkg = packageDirOf(path);
|
||||
@@ -125,19 +164,16 @@ export function finalizeDeferredIslands(
|
||||
continue;
|
||||
}
|
||||
const gate = effectGate(path);
|
||||
if (!gate.ok && evaluate !== "lazy") {
|
||||
decide(call, false, `${gate.reason} in ${gate.file}`);
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
call,
|
||||
path,
|
||||
lazy: evaluate === "lazy",
|
||||
files: gate.ok ? gate.files : [path],
|
||||
gate: gate.ok ? "" : `${gate.reason} in ${gate.file}`,
|
||||
});
|
||||
}
|
||||
let allDeferred = candidates.length === calls.length;
|
||||
if (candidates.length === 0) return decisions;
|
||||
const probe = runProbe(packDir, [...new Set(candidates.map((c) => c.path))]);
|
||||
const probe = probeModules(packDir, [...new Set(candidates.map((c) => c.path))]);
|
||||
if (typeof probe === "string") {
|
||||
for (const c of candidates) decide(c.call, false, probe);
|
||||
return decisions;
|
||||
@@ -147,33 +183,80 @@ export function finalizeDeferredIslands(
|
||||
// against its digests when it opens): only files outside it need guards.
|
||||
const tree = mod.nodeIsland?.modules;
|
||||
const shipped = new Set(tree?.files.map((f) => join(tree.root, f.path)) ?? []);
|
||||
const deferred: { call: DeferCall; result: ProbeResult; why: string }[] = [];
|
||||
for (const c of candidates) {
|
||||
const result = probe.results.find((r) => r.path === c.path);
|
||||
if (result?.throws !== undefined) {
|
||||
decide(c.call, false, `its evaluation throws: ${result.throws}`);
|
||||
const why = (() => {
|
||||
if (result === undefined) return "the probe did not load it";
|
||||
if (result.throws !== undefined) return `its evaluation throws: ${result.throws}`;
|
||||
if (result.root === undefined) return result.reason ?? "the probe did not load it";
|
||||
if (
|
||||
tree !== undefined &&
|
||||
tree.root.length > 1 &&
|
||||
snapshotNames(probe, result.root, tree.root)
|
||||
)
|
||||
return "its exports hold a path of the build machine";
|
||||
const privateEnv = result.env.map(([k]) => k).filter((k) => !publicEnv.has(k));
|
||||
if (privateEnv.length > 0)
|
||||
return `its load reads the environment variable${privateEnv.length === 1 ? "" : "s"} ${privateEnv.join(", ")}, whose build-time values its boot guards would record (scriptc.nodeIslands.publicEnv lists the ones that may be recorded)`;
|
||||
if (c.lazy) return null;
|
||||
if (result.effects.length > 0) return `its load has effects: ${result.effects.join(", ")}`;
|
||||
if (result.inputs.length > 0) return `its load reads ${result.inputs.join(", ")}`;
|
||||
return null;
|
||||
})();
|
||||
if (why !== null || result === undefined) {
|
||||
allDeferred = false;
|
||||
decide(c.call, false, why ?? "");
|
||||
continue;
|
||||
}
|
||||
if (result === undefined || result.root === undefined) {
|
||||
decide(c.call, false, result?.reason ?? "the probe did not load it");
|
||||
continue;
|
||||
}
|
||||
if (result.effects.length > 0 && !c.lazy) {
|
||||
decide(c.call, false, `the probe saw ${result.effects.join(", ")}`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
tree !== undefined &&
|
||||
tree.root.length > 1 &&
|
||||
snapshotNames(probe, result.root, tree.root)
|
||||
) {
|
||||
decide(c.call, false, "its exports hold a path of the build machine");
|
||||
continue;
|
||||
}
|
||||
const files = c.files.filter((f) => !shipped.has(f));
|
||||
const text = encodeSnapshot(probe, result.root, [...packGuards, ...fileGuardLines(files)]);
|
||||
const arg = c.call.args[1];
|
||||
const how = c.lazy
|
||||
? `"evaluate": "lazy"`
|
||||
: c.gate === ""
|
||||
? "static gate"
|
||||
: `recorded load is deterministic (static gate: ${c.gate})`;
|
||||
deferred.push({ call: c.call, result, why: how });
|
||||
}
|
||||
// Per-module evaluation once Node runs (no materialize-all at start):
|
||||
// every static island import is deferred, none mutates builtin state,
|
||||
// and the program loads no island module at a call site. Deferred
|
||||
// modules whose closures share files form one component, evaluated
|
||||
// together in import order.
|
||||
const lazyMode =
|
||||
allDeferred && !dynamicLoads && deferred.every((d) => d.result.mutations.length === 0);
|
||||
const component = new Map<string, number>();
|
||||
if (lazyMode) {
|
||||
const parent = deferred.map((_, i) => i);
|
||||
const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!)));
|
||||
const owner = new Map<string, number>();
|
||||
deferred.forEach((d, i) => {
|
||||
for (const f of d.result.files) {
|
||||
const j = owner.get(f);
|
||||
if (j === undefined) owner.set(f, i);
|
||||
else parent[find(i)] = find(j);
|
||||
}
|
||||
});
|
||||
deferred.forEach((d, i) => component.set(d.result.path, find(i) + 1));
|
||||
}
|
||||
for (const d of deferred) {
|
||||
const r = d.result;
|
||||
const guards = [
|
||||
...packGuards,
|
||||
...fileGuardLines(r.files.filter((f) => !shipped.has(f))),
|
||||
...r.env.map(([k, v]) => `E ${lstr(k)} ${v === null ? "0 0:" : `1 ${lstr(v)}`}`),
|
||||
...r.tty.map(([fd, isTty]) => `T ${fd} ${isTty ? 1 : 0}`),
|
||||
...(lazyMode ? [`M ${component.get(r.path)}`] : []),
|
||||
];
|
||||
const text = encodeSnapshot(probe, r.root!, guards, r.volatile.length === 0);
|
||||
const arg = d.call.args[1];
|
||||
if (arg !== undefined && arg.kind === "strLit") (arg as { value: string }).value = text;
|
||||
decide(c.call, true, "");
|
||||
const notes = [
|
||||
d.why,
|
||||
...(r.env.length > 0 ? [`env guards ${r.env.map(([k]) => k).join(",")}`] : []),
|
||||
...(r.volatile.length > 0 ? [`volatile ${r.volatile.join(",")}: no constants`] : []),
|
||||
...(r.mutations.length > 0 ? [`mutates ${r.mutations.join(",")}`] : []),
|
||||
lazyMode ? `per-module (component ${component.get(r.path)})` : "evaluated when Node starts",
|
||||
];
|
||||
decide(d.call, true, notes.join("; "));
|
||||
}
|
||||
return decisions;
|
||||
}
|
||||
@@ -220,7 +303,7 @@ function fileGuardLines(files: string[]): string[] {
|
||||
return lines;
|
||||
}
|
||||
|
||||
type ProbeValue =
|
||||
export type ProbeValue =
|
||||
| { o: number }
|
||||
| { u: 1 }
|
||||
| { l: 1 }
|
||||
@@ -229,14 +312,28 @@ type ProbeValue =
|
||||
| { s: string }
|
||||
| { x: 1 };
|
||||
|
||||
interface ProbeObject {
|
||||
export interface ProbeObject {
|
||||
f: number;
|
||||
p: boolean;
|
||||
m: [string, ProbeValue][];
|
||||
}
|
||||
|
||||
interface ProbeOutput {
|
||||
results: { path: string; root?: number; effects: string[]; throws?: string; reason?: string }[];
|
||||
export interface ProbeResult {
|
||||
path: string;
|
||||
root?: number;
|
||||
inputs: string[];
|
||||
volatile: string[];
|
||||
effects: string[];
|
||||
mutations: string[];
|
||||
env: [string, string | null][];
|
||||
tty: [number, boolean][];
|
||||
files: string[];
|
||||
throws?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ProbeOutput {
|
||||
results: ProbeResult[];
|
||||
objects: Record<string, ProbeObject>;
|
||||
}
|
||||
|
||||
@@ -268,7 +365,12 @@ function snapshotNames(probe: ProbeOutput, root: number, dir: string): boolean {
|
||||
return visit(root);
|
||||
}
|
||||
|
||||
function encodeSnapshot(probe: ProbeOutput, root: number, guards: string[]): string {
|
||||
function encodeSnapshot(
|
||||
probe: ProbeOutput,
|
||||
root: number,
|
||||
guards: string[],
|
||||
constants: boolean,
|
||||
): string {
|
||||
const lines = [...guards];
|
||||
const reached: number[] = [];
|
||||
const seen = new Set<number>();
|
||||
@@ -286,7 +388,7 @@ function encodeSnapshot(probe: ProbeOutput, root: number, guards: string[]): str
|
||||
for (const oid of reached) {
|
||||
for (const [key, v] of probe.objects[oid]!.m) {
|
||||
const enc = encodeValue(v);
|
||||
if (enc !== null) lines.push(`K ${oid} ${lstr(key)} ${enc}`);
|
||||
if (enc !== null && (constants || "o" in v)) lines.push(`K ${oid} ${lstr(key)} ${enc}`);
|
||||
}
|
||||
}
|
||||
lines.push(`R ${root}`);
|
||||
@@ -296,16 +398,16 @@ function encodeSnapshot(probe: ProbeOutput, root: number, guards: string[]): str
|
||||
/* ── the probe ────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The bootstrap the probe host starts Node with: the dispatcher the
|
||||
* bridge requires, and the probe as its `call`. */
|
||||
const PROBE_BOOTSTRAP = String.raw`
|
||||
"use strict";
|
||||
const types = require("node:util").types;
|
||||
const { createRequire } = require("node:module");
|
||||
* bridge requires, whose `call` runs the recorder (probe-recorder.ts)
|
||||
* from `recorder`. */
|
||||
function probeBootstrap(recorder: string): string {
|
||||
return `"use strict";
|
||||
const noop = () => {};
|
||||
globalThis.__scriptcNodeIsland = {
|
||||
call(id, sync, file, specifier, name, args) {
|
||||
try {
|
||||
return "R" + JSON.stringify(probe(JSON.parse(args)));
|
||||
const recorder = require("node:module").createRequire(${JSON.stringify(recorder)})(${JSON.stringify(recorder)});
|
||||
return "R" + JSON.stringify(recorder.probe(JSON.parse(args)));
|
||||
} catch (e) {
|
||||
return "T" + JSON.stringify(String((e && e.stack) || e));
|
||||
}
|
||||
@@ -314,107 +416,8 @@ globalThis.__scriptcNodeIsland = {
|
||||
setImmediate: noop, clearImmediate: noop, refImmediate: noop,
|
||||
nextTick: noop, emit: noop, load: noop, sharedPush: noop,
|
||||
};
|
||||
const fnToString = Function.prototype.toString;
|
||||
const hasOwn = Object.prototype.hasOwnProperty;
|
||||
function isConstructor(f) {
|
||||
try { Reflect.construct(String, [], f); return true; } catch { return false; }
|
||||
}
|
||||
function plainClass(f) {
|
||||
// instanceof reads the prototype: without an object there, it throws.
|
||||
const proto = Object.getOwnPropertyDescriptor(f, "prototype");
|
||||
if (proto === undefined || !("value" in proto)) return false;
|
||||
if (proto.value === null || (typeof proto.value !== "object" && typeof proto.value !== "function"))
|
||||
return false;
|
||||
let source = "";
|
||||
try { source = fnToString.call(f); } catch { return false; }
|
||||
if (/\[native code\]\s*\}$/.test(source)) return false;
|
||||
for (let o = f; o !== null && o !== Function.prototype; o = Object.getPrototypeOf(o))
|
||||
if (hasOwn.call(o, Symbol.hasInstance)) return false;
|
||||
return true;
|
||||
}
|
||||
function probe(input) {
|
||||
const objects = {};
|
||||
const oids = new Map();
|
||||
let next = 1;
|
||||
const value = (v, depth) => {
|
||||
if (v === undefined) return { u: 1 };
|
||||
if (v === null) return { l: 1 };
|
||||
switch (typeof v) {
|
||||
case "boolean": return { b: v };
|
||||
case "number": return { n: Object.is(v, -0) ? "-0" : String(v) };
|
||||
case "string": return v.isWellFormed() ? { s: v } : { x: 1 };
|
||||
case "symbol": return object(v, 2, depth);
|
||||
case "function":
|
||||
if (types.isProxy(v)) return { x: 1 };
|
||||
return object(v, 1 | (isConstructor(v) ? 32 : 0), depth);
|
||||
case "object":
|
||||
if (types.isProxy(v) || types.isPromise(v) || types.isNativeError(v) ||
|
||||
ArrayBuffer.isView(v) || types.isExternal(v)) return { x: 1 };
|
||||
return object(v, Array.isArray(v) ? 8 : 0, depth);
|
||||
default: return { x: 1 };
|
||||
}
|
||||
};
|
||||
const object = (v, flags, depth) => {
|
||||
if (oids.has(v)) return { o: oids.get(v) };
|
||||
const id = next++;
|
||||
oids.set(v, id);
|
||||
const entry = { f: flags, p: (flags & 1) !== 0 && plainClass(v), m: [] };
|
||||
objects[id] = entry;
|
||||
if (typeof v === "symbol") return { o: id };
|
||||
// The module's value and its direct members: every own data property
|
||||
// (namespace member lists); functions anywhere: name and length.
|
||||
const keys = depth === 0 || (depth === 1 && typeof v !== "function")
|
||||
? Object.getOwnPropertyNames(v)
|
||||
: typeof v === "function" ? ["name", "length"] : [];
|
||||
for (const key of keys) {
|
||||
let d;
|
||||
try { d = Object.getOwnPropertyDescriptor(v, key); } catch { continue; }
|
||||
if (d === undefined || !("value" in d)) continue;
|
||||
entry.m.push([key, value(d.value, depth + 1)]);
|
||||
}
|
||||
return { o: id };
|
||||
};
|
||||
const effects = [];
|
||||
const watch = (owner, key, label) => {
|
||||
const original = owner[key];
|
||||
if (typeof original !== "function") return () => {};
|
||||
owner[key] = function (...a) { effects.push(label); return original.apply(this, a); };
|
||||
return () => { owner[key] = original; };
|
||||
};
|
||||
const results = [];
|
||||
for (const path of input.modules) {
|
||||
effects.length = 0;
|
||||
const globals = new Set(Reflect.ownKeys(globalThis));
|
||||
const restore = [
|
||||
watch(process.stdout, "write", "stdout output"),
|
||||
watch(process.stderr, "write", "stderr output"),
|
||||
watch(process, "emitWarning", "a process warning"),
|
||||
watch(process, "on", "a process listener"),
|
||||
watch(process, "addListener", "a process listener"),
|
||||
watch(process, "once", "a process listener"),
|
||||
watch(process, "prependListener", "a process listener"),
|
||||
watch(process, "nextTick", "process.nextTick"),
|
||||
watch(globalThis, "setTimeout", "a timer"),
|
||||
watch(globalThis, "setInterval", "a timer"),
|
||||
watch(globalThis, "setImmediate", "an immediate"),
|
||||
watch(globalThis, "queueMicrotask", "a microtask"),
|
||||
];
|
||||
let exportsValue, threw;
|
||||
try { exportsValue = createRequire(path)(path); } catch (e) { threw = String((e && e.message) || e); }
|
||||
for (const r of restore) r();
|
||||
for (const k of Reflect.ownKeys(globalThis))
|
||||
if (!globals.has(k)) effects.push("a new global " + String(k));
|
||||
if (threw !== undefined) { results.push({ path, effects: [...effects], throws: threw }); continue; }
|
||||
const root = value(exportsValue, 0);
|
||||
if (!("o" in root)) {
|
||||
results.push({ path, effects: [...effects], reason: "its value is not an object" });
|
||||
continue;
|
||||
}
|
||||
results.push({ path, root: root.o, effects: [...new Set(effects)] });
|
||||
}
|
||||
return { results, objects };
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function runtimeSrc(): string {
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -447,8 +450,9 @@ export function probeHostDirectory(hash: string): { dir: string; kept: boolean }
|
||||
return { dir: mkdtempSync(join(tmpdir(), "scriptc-node-probe-")), kept: false };
|
||||
}
|
||||
|
||||
/** The probe host executable, built once per probe source and bridge
|
||||
* header (in the directory probeHostDirectory picks). */
|
||||
/** The probe host executable with its bootstrap and recorder, built once
|
||||
* per probe source, bridge header and recorder (in the directory
|
||||
* probeHostDirectory picks). */
|
||||
function probeHost(): { exe: string; bootstrap: string; kept: boolean } | string {
|
||||
const src = runtimeSrc();
|
||||
const source = join(src, "tools", "scr_node_island_probe.c");
|
||||
@@ -456,17 +460,20 @@ function probeHost(): { exe: string; bootstrap: string; kept: boolean } | string
|
||||
const hash = createHash("sha256")
|
||||
.update(readFileSync(source))
|
||||
.update(readFileSync(header))
|
||||
.update(PROBE_BOOTSTRAP)
|
||||
.update(PROBE_RECORDER)
|
||||
.update(probeBootstrap(""))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
const { dir, kept } = probeHostDirectory(hash);
|
||||
const exe = join(dir, "scr-node-probe");
|
||||
const bootstrap = join(dir, "bootstrap.js");
|
||||
const recorder = join(dir, "recorder.cjs");
|
||||
if (
|
||||
kept &&
|
||||
existsSync(exe) &&
|
||||
existsSync(bootstrap) &&
|
||||
[exe, bootstrap].every(writableByUserOnly)
|
||||
existsSync(recorder) &&
|
||||
[exe, bootstrap, recorder].every(writableByUserOnly)
|
||||
)
|
||||
return { exe, bootstrap, kept };
|
||||
const tmp = `${exe}.${process.pid}`;
|
||||
@@ -475,7 +482,9 @@ function probeHost(): { exe: string; bootstrap: string; kept: boolean } | string
|
||||
if (cc === undefined || cc === "") continue;
|
||||
const r = spawnSync(cc, ["-O1", "-I", src, source, "-o", tmp, "-ldl"], { encoding: "utf8" });
|
||||
if (r.status === 0) {
|
||||
writeFileSync(`${bootstrap}.${process.pid}`, PROBE_BOOTSTRAP, { mode: 0o600 });
|
||||
writeFileSync(`${recorder}.${process.pid}`, PROBE_RECORDER, { mode: 0o600 });
|
||||
renameSync(`${recorder}.${process.pid}`, recorder);
|
||||
writeFileSync(`${bootstrap}.${process.pid}`, probeBootstrap(recorder), { mode: 0o600 });
|
||||
renameSync(`${bootstrap}.${process.pid}`, bootstrap);
|
||||
renameSync(tmp, exe);
|
||||
return { exe, bootstrap, kept };
|
||||
@@ -490,24 +499,31 @@ function runProbe(packDir: string, modules: string[]): ProbeOutput | string {
|
||||
const host = probeHost();
|
||||
if (typeof host === "string") return host;
|
||||
const { exe, bootstrap, kept } = host;
|
||||
const input = join(dirname(exe), `input.${process.pid}.${Date.now()}.json`);
|
||||
const stem = join(dirname(exe), `run.${process.pid}.${Date.now()}`);
|
||||
const input = `${stem}.in.json`;
|
||||
const output = `${stem}.out.json`;
|
||||
writeFileSync(input, JSON.stringify({ modules }), { mode: 0o600 });
|
||||
let out: string;
|
||||
try {
|
||||
out = execFileSync(exe, [packDir, bootstrap, input], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
execFileSync(exe, [packDir, bootstrap, input, output], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
timeout: 60_000,
|
||||
});
|
||||
out = readFileSync(output, "utf8");
|
||||
} catch (e) {
|
||||
const err = e as { stderr?: string; message: string };
|
||||
return `the probe failed: ${(err.stderr ?? "").trim().split("\n")[0] || err.message}`;
|
||||
const err = e as { stderr?: Buffer | string; message: string };
|
||||
return `the probe failed: ${
|
||||
String(err.stderr ?? "")
|
||||
.trim()
|
||||
.split("\n")[0] || err.message
|
||||
}`;
|
||||
} finally {
|
||||
try {
|
||||
rmSync(kept ? input : dirname(exe), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
for (const f of kept ? [input, output] : [dirname(exe)]) {
|
||||
try {
|
||||
rmSync(f, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!out.startsWith("R")) return `the probe threw: ${out.slice(1, 400)}`;
|
||||
|
||||
@@ -21,6 +21,7 @@ test("a package.json without a scriptc key has the default configuration", () =>
|
||||
nodeVersion: null,
|
||||
packaging: "embedded",
|
||||
speculativeStart: false,
|
||||
publicEnv: [],
|
||||
});
|
||||
expect(parseProjectConfig(manifest, "{ not json").diagnostics).toEqual([]);
|
||||
});
|
||||
@@ -106,6 +107,7 @@ test("nodeIslands settings", () => {
|
||||
nodeVersion: ">=24.13.1",
|
||||
packaging: "beside",
|
||||
speculativeStart: true,
|
||||
publicEnv: ["NODE_ENV", "DEBUG"],
|
||||
},
|
||||
wasm: ["build/a.wasm", 3],
|
||||
});
|
||||
@@ -117,11 +119,20 @@ test("nodeIslands settings", () => {
|
||||
nodeVersion: ">=24.13.1",
|
||||
packaging: "beside",
|
||||
speculativeStart: true,
|
||||
publicEnv: ["NODE_ENV", "DEBUG"],
|
||||
});
|
||||
// scriptc.wasm keeps its lenient reading.
|
||||
expect(c.wasm).toEqual(["/project/build/a.wasm"]);
|
||||
});
|
||||
|
||||
test("publicEnv must be an array of variable names", () => {
|
||||
const c = config({ nodeIslands: { publicEnv: "NODE_ENV" } });
|
||||
expect(c.diagnostics.map((d) => d.message)).toEqual([
|
||||
`package.json 'scriptc.nodeIslands.publicEnv' must be an array of environment variable names, not "NODE_ENV"`,
|
||||
]);
|
||||
expect(c.nodeIslands.publicEnv).toEqual([]);
|
||||
});
|
||||
|
||||
test("unknown keys and values are SC8004, located at the key", () => {
|
||||
const text = JSON.stringify(
|
||||
{
|
||||
|
||||
@@ -66,6 +66,10 @@ export interface NodeIslandsSetting {
|
||||
nodeVersion: string | null;
|
||||
packaging: "embedded" | "beside";
|
||||
speculativeStart: boolean;
|
||||
/** Environment variables whose build-time values a deferred island
|
||||
* module's boot guards may record in the executable (snapshot.ts). A
|
||||
* deferred load that reads any other variable stays eager. */
|
||||
publicEnv: string[];
|
||||
}
|
||||
|
||||
/** One declaration patch: a selector and the type text it maps to. */
|
||||
@@ -131,6 +135,7 @@ const NODE_ISLANDS_KEYS = [
|
||||
"nodeVersion",
|
||||
"packaging",
|
||||
"speculativeStart",
|
||||
"publicEnv",
|
||||
];
|
||||
|
||||
function defaultPackageSetting(): PackageSetting {
|
||||
@@ -153,6 +158,7 @@ function defaultNodeIslands(): NodeIslandsSetting {
|
||||
nodeVersion: null,
|
||||
packaging: "embedded",
|
||||
speculativeStart: false,
|
||||
publicEnv: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -338,6 +344,22 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi
|
||||
`write "modules": ["src/plugins/loader.ts"]`,
|
||||
);
|
||||
} else setting.modules = list;
|
||||
} else if (key === "publicEnv") {
|
||||
const list: string[] = [];
|
||||
let ok = Array.isArray(v);
|
||||
if (Array.isArray(v)) {
|
||||
for (const entry of v as unknown[]) {
|
||||
if (typeof entry === "string" && entry !== "") list.push(entry);
|
||||
else ok = false;
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
problem(
|
||||
["nodeIslands", key],
|
||||
`must be an array of environment variable names, not ${shown(v)}`,
|
||||
`write "publicEnv": ["NODE_ENV"]`,
|
||||
);
|
||||
} else setting.publicEnv = list;
|
||||
} else if (key === "packaging") {
|
||||
if (v !== "embedded" && v !== "beside") {
|
||||
problem(
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6843-node-island-deferred-per-module/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6844-node-island-deferred-env/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6845-node-island-deferred-require/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6846-node-island-deferred-own-files/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6847-node-island-punycode-warning/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -1397,10 +1397,11 @@ static void ni_start(void) {
|
||||
ni_node_version, bridge->node_version, bridge->module_version, dir);
|
||||
ni_fatal(message);
|
||||
}
|
||||
ScrStr *self = scr_process_exec_path();
|
||||
char argv0[4096];
|
||||
snprintf(argv0, sizeof argv0, "%.*s", (int)self->len, self->data);
|
||||
scr_str_release(self);
|
||||
/* Node's argv[0] is "node": island process.argv0 reads it, and Node's
|
||||
* warning hint is built from it ("(Use `node --trace-deprecation ...`"),
|
||||
* which the runtime's own warnings print too. process.execPath stays the
|
||||
* executable (libuv's exepath). */
|
||||
char argv0[] = "node";
|
||||
/* Flush first: Node writes to the same stdout. */
|
||||
fflush(stdout);
|
||||
ni_bridge = bridge;
|
||||
|
||||
@@ -200,8 +200,10 @@ void *scr_ni_stub_of(const ScrDyn *value); /* the stub entry of an unmaterialize
|
||||
bool scr_ni_stub_reconcile(ScrDyn *stub, ScrDyn *real);
|
||||
ScrDyn *scr_ni_boot_member(void *stub, const char *key, size_t len); /* +1 or NULL: unknown */
|
||||
bool scr_ni_boot_plain_class(void *stub);
|
||||
bool scr_ni_boot_snapshot_valid(void *stub); /* its module has not evaluated */
|
||||
const char *scr_ni_pack_dir(void); /* the libnode pack directory this run uses */
|
||||
void scr_ni_boot_stub_failed(void *stub); /* raises the module's load error */
|
||||
void scr_ni_boot_materialize(void *stub); /* evaluates the stub's module (component) */
|
||||
/* Called at the end of Node's start (scr_node_island.c), set by the boot unit. */
|
||||
extern void (*scr_ni_started_hook)(void);
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
* O <oid> <flags> <plain-class> an object (SCR_NVF_* flags)
|
||||
* K <oid> <len>:<key> <value> a data member of object <oid>
|
||||
* R <oid> the module's value
|
||||
* E <len>:<key> <0|1> <len>:<value> an env key the load read (absent: 0)
|
||||
* T <fd> <0|1> the isTTY state the load read
|
||||
* M <component> per-module mode (NI-7b): see below
|
||||
* where <value> is o<oid>, u, l, t, f, n<number> or s<len>:<bytes>. */
|
||||
#define _XOPEN_SOURCE 700
|
||||
#define _DARWIN_C_SOURCE 1
|
||||
@@ -45,6 +48,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#if !defined(_WIN32) && !defined(__wasi__)
|
||||
|
||||
@@ -76,8 +80,18 @@ typedef struct NbStub {
|
||||
ScrCaught *error; /* why it could not be reconciled */
|
||||
} NbStub;
|
||||
|
||||
typedef struct {
|
||||
const char *key, *value;
|
||||
size_t key_len, value_len;
|
||||
bool present;
|
||||
} NbEnv;
|
||||
|
||||
typedef struct NbModule {
|
||||
char *path;
|
||||
uint32_t component; /* per-module mode: modules evaluated together */
|
||||
bool done;
|
||||
NbEnv *env;
|
||||
size_t nenv;
|
||||
char *snapshot; /* owns the strings the stubs point into */
|
||||
uint32_t root;
|
||||
uint32_t *order; /* stubs to reconcile after the root, parents first */
|
||||
@@ -90,6 +104,12 @@ static size_t nb_stubs_cap = 0;
|
||||
static NbModule **nb_modules = NULL;
|
||||
static size_t nb_nmodules = 0, nb_modules_cap = 0;
|
||||
static bool nb_installed = false;
|
||||
/* Per-module mode (NI-7b, `M` records): Node starting does not evaluate
|
||||
* every deferred module; each component evaluates on the first use of one
|
||||
* of its stubs. The build chooses it only when every static island import
|
||||
* is deferred, none mutates builtin state and no island module loads at a
|
||||
* call site, so nothing but these stubs can reach the modules' objects. */
|
||||
static bool nb_lazy = false;
|
||||
static int nb_trace = -1;
|
||||
|
||||
static bool nb_tracing(void) {
|
||||
@@ -195,10 +215,63 @@ static ScrCaught *nb_changed(const NbModule *m) {
|
||||
return scr_exc_take();
|
||||
}
|
||||
|
||||
/* A NUL-terminated copy (the snapshot's strings are length-delimited). */
|
||||
static char *nb_cstr(const char *s, size_t n) {
|
||||
char *c = malloc(n + 1);
|
||||
if (c == NULL) nb_oom();
|
||||
memcpy(c, s, n);
|
||||
c[n] = '\0';
|
||||
return c;
|
||||
}
|
||||
|
||||
static bool nb_env_matches(const NbEnv *e) {
|
||||
char *key = nb_cstr(e->key, e->key_len);
|
||||
const char *now = getenv(key);
|
||||
free(key);
|
||||
if (!e->present) return now == NULL;
|
||||
return now != NULL && strlen(now) == e->value_len && memcmp(now, e->value, e->value_len) == 0;
|
||||
}
|
||||
|
||||
/* The module evaluates against the environment it saw at its import
|
||||
* position (the boot guard checked those values then): keys the program
|
||||
* changed since are set back for the evaluation and restored after it. */
|
||||
static void nb_env_swap(NbModule *m, char **saved, bool restore) {
|
||||
for (size_t i = 0; i < m->nenv; i++) {
|
||||
NbEnv *e = &m->env[i];
|
||||
char *key = nb_cstr(e->key, e->key_len);
|
||||
if (!restore) {
|
||||
const char *now = getenv(key);
|
||||
saved[i] = now != NULL ? strdup(now) : NULL;
|
||||
if (!nb_env_matches(e)) {
|
||||
if (e->present) {
|
||||
char *v = nb_cstr(e->value, e->value_len);
|
||||
setenv(key, v, 1);
|
||||
free(v);
|
||||
} else {
|
||||
unsetenv(key);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (saved[i] != NULL) setenv(key, saved[i], 1);
|
||||
else unsetenv(key);
|
||||
free(saved[i]);
|
||||
}
|
||||
free(key);
|
||||
}
|
||||
}
|
||||
|
||||
static void nb_materialize(NbModule *m) {
|
||||
if (m->done) return;
|
||||
m->done = true;
|
||||
if (nb_tracing()) fprintf(stderr, "[node-islands] evaluating deferred %s\n", m->path);
|
||||
char **saved = m->nenv > 0 ? calloc(m->nenv, sizeof *saved) : NULL;
|
||||
if (m->nenv > 0 && saved == NULL) nb_oom();
|
||||
nb_env_swap(m, saved, false);
|
||||
ScrStr *path = scr_str_new(m->path, strlen(m->path));
|
||||
ScrDyn *real = scr_ni_require(path);
|
||||
scr_str_release(path);
|
||||
nb_env_swap(m, saved, true);
|
||||
free(saved);
|
||||
if (real == NULL) {
|
||||
m->error = scr_exc_take();
|
||||
return;
|
||||
@@ -210,9 +283,23 @@ static void nb_materialize(NbModule *m) {
|
||||
m->error = nb_changed(m);
|
||||
return;
|
||||
}
|
||||
/* Only stubs the program holds (their box has a reference besides this
|
||||
* table's), and their parents, need their real values: a stub nobody
|
||||
* took has no identity to keep, and reading its value would run getters
|
||||
* the eager program never runs. Parents precede children in `order`. */
|
||||
bool *needed = m->norder > 0 ? calloc(m->norder, sizeof *needed) : NULL;
|
||||
if (m->norder > 0 && needed == NULL) nb_oom();
|
||||
for (size_t i = m->norder; i-- > 0;) {
|
||||
NbStub *s = nb_stub(m->order[i]);
|
||||
if (s->box->rc > 1) needed[i] = true;
|
||||
if (!needed[i]) continue;
|
||||
for (size_t j = 0; j < i; j++)
|
||||
if (m->order[j] == s->parent) needed[j] = true;
|
||||
}
|
||||
for (size_t i = 0; i < m->norder; i++) {
|
||||
NbStub *s = nb_stub(m->order[i]);
|
||||
NbStub *parent = nb_stub(s->parent);
|
||||
if (!needed[i]) continue;
|
||||
if (scr_ni_stub_of(s->box) == NULL) continue; /* reached through an earlier module */
|
||||
if (parent->error != NULL || scr_ni_stub_of(parent->box) != NULL) {
|
||||
s->error = scr_caught_retain(parent->error != NULL ? parent->error : m->error);
|
||||
@@ -226,14 +313,25 @@ static void nb_materialize(NbModule *m) {
|
||||
if (!scr_ni_stub_reconcile(s->box, value)) s->error = nb_changed(m);
|
||||
scr_dyn_release(value);
|
||||
}
|
||||
free(needed);
|
||||
}
|
||||
|
||||
static void nb_started(void) {
|
||||
if (nb_lazy) return; /* each component evaluates on first use */
|
||||
if (nb_tracing() && nb_nmodules > 0)
|
||||
fprintf(stderr, "[node-islands] Node started: evaluating %zu deferred module(s)\n", nb_nmodules);
|
||||
for (size_t i = 0; i < nb_nmodules; i++) nb_materialize(nb_modules[i]);
|
||||
}
|
||||
|
||||
void scr_ni_boot_materialize(void *stub) {
|
||||
NbModule *owner = ((NbStub *)stub)->module;
|
||||
for (size_t i = 0; i < nb_nmodules; i++) {
|
||||
NbModule *m = nb_modules[i];
|
||||
if (m == owner || (owner->component != 0 && m->component == owner->component))
|
||||
nb_materialize(m);
|
||||
}
|
||||
}
|
||||
|
||||
void scr_ni_boot_stub_failed(void *stub) {
|
||||
NbStub *s = stub;
|
||||
ScrCaught *error = s->error != NULL ? s->error : s->module->error;
|
||||
@@ -277,6 +375,14 @@ ScrDyn *scr_ni_boot_member(void *stub, const char *key, size_t len) {
|
||||
|
||||
bool scr_ni_boot_plain_class(void *stub) { return ((NbStub *)stub)->plain; }
|
||||
|
||||
/* Whether the snapshot still describes the stub's value: its module has
|
||||
* not evaluated. Before Node starts nothing has; in per-module mode, a
|
||||
* component's objects are unreachable to any code until it evaluates. */
|
||||
bool scr_ni_boot_snapshot_valid(void *stub) {
|
||||
NbStub *s = stub;
|
||||
return !s->module->done && (nb_lazy || scr_ni_bridge_loaded() == NULL);
|
||||
}
|
||||
|
||||
/* ── nodeIsland.defer ─────────────────────────────────────────────────── */
|
||||
|
||||
static void nb_cleanup(void) {
|
||||
@@ -295,6 +401,7 @@ static void nb_cleanup(void) {
|
||||
NbModule *m = nb_modules[i];
|
||||
if (m->error != NULL) scr_caught_release(m->error);
|
||||
free(m->order);
|
||||
free(m->env);
|
||||
free(m->snapshot);
|
||||
free(m->path);
|
||||
free(m);
|
||||
@@ -340,13 +447,40 @@ static NbStub *nb_new_stub(uint32_t oid, uint32_t flags, bool plain, NbModule *m
|
||||
}
|
||||
|
||||
ScrDyn *scr_node_island_defer(ScrStr *path, ScrStr *snapshot) {
|
||||
if (scr_ni_bridge_loaded() != NULL) return nb_eager(path, "Node is already running");
|
||||
nb_install();
|
||||
/* Pass 1: the guards. */
|
||||
/* Pass 1: the guards, and the mode. */
|
||||
NbCursor c = {snapshot->data, snapshot->data + snapshot->len, false};
|
||||
bool lazy = false;
|
||||
while (c.p < c.end && !c.bad) {
|
||||
char tag = *c.p++;
|
||||
if (tag == 'G') {
|
||||
if (tag == 'M') lazy = true;
|
||||
nb_line_end(&c);
|
||||
}
|
||||
/* In per-module mode a module can stay deferred while Node runs. */
|
||||
if (!lazy && scr_ni_bridge_loaded() != NULL) return nb_eager(path, "Node is already running");
|
||||
nb_install();
|
||||
c = (NbCursor){snapshot->data, snapshot->data + snapshot->len, false};
|
||||
while (c.p < c.end && !c.bad) {
|
||||
char tag = *c.p++;
|
||||
if (tag == 'E') {
|
||||
NbEnv e = {0};
|
||||
e.key = nb_str(&c, &e.key_len);
|
||||
e.present = nb_uint(&c) != 0;
|
||||
e.value = nb_str(&c, &e.value_len);
|
||||
if (c.bad) break;
|
||||
if (!nb_env_matches(&e)) {
|
||||
if (nb_tracing())
|
||||
fprintf(stderr, "[node-islands] boot guard failed: env %.*s\n", (int)e.key_len, e.key);
|
||||
return nb_eager(path, "a boot guard failed");
|
||||
}
|
||||
} else if (tag == 'T') {
|
||||
int fd = (int)nb_uint(&c);
|
||||
bool was = nb_uint(&c) != 0;
|
||||
if (c.bad) break;
|
||||
if ((isatty(fd) == 1) != was) {
|
||||
if (nb_tracing()) fprintf(stderr, "[node-islands] boot guard failed: isatty(%d)\n", fd);
|
||||
return nb_eager(path, "a boot guard failed");
|
||||
}
|
||||
} else if (tag == 'G') {
|
||||
size_t len;
|
||||
const char *file = nb_str(&c, &len);
|
||||
uint64_t size = nb_uint(&c);
|
||||
@@ -409,11 +543,26 @@ ScrDyn *scr_node_island_defer(ScrStr *path, ScrStr *snapshot) {
|
||||
}
|
||||
} else if (tag == 'R') {
|
||||
m->root = (uint32_t)nb_uint(&c);
|
||||
} else if (tag == 'M') {
|
||||
m->component = (uint32_t)nb_uint(&c);
|
||||
nb_lazy = true;
|
||||
} else if (tag == 'E') {
|
||||
NbEnv e = {0};
|
||||
e.key = nb_str(&c, &e.key_len);
|
||||
e.present = nb_uint(&c) != 0;
|
||||
e.value = nb_str(&c, &e.value_len);
|
||||
if (!c.bad) {
|
||||
NbEnv *grown = realloc(m->env, (m->nenv + 1) * sizeof *grown);
|
||||
if (grown == NULL) nb_oom();
|
||||
m->env = grown;
|
||||
m->env[m->nenv++] = e;
|
||||
}
|
||||
}
|
||||
nb_line_end(&c);
|
||||
}
|
||||
NbStub *root = nb_stub(m->root);
|
||||
if (c.bad || root == NULL) {
|
||||
free(m->env);
|
||||
free(m->snapshot);
|
||||
free(m->path);
|
||||
free(m);
|
||||
|
||||
@@ -290,7 +290,8 @@ bool scr_ni_stub_reconcile(ScrDyn *stub, ScrDyn *real) {
|
||||
}
|
||||
|
||||
/* A stub whose Node value is needed: starting Node materializes every
|
||||
* deferred module (scr_node_island_boot.c), after which the ref is real.
|
||||
* deferred module (scr_node_island_boot.c), or in per-module mode the
|
||||
* boot unit evaluates the stub's component; then the ref is real.
|
||||
* False with the module's load error pending. */
|
||||
ScrDyn *scr_ni_lazy_subclass_new(ScrDyn *const *args) {
|
||||
ScrNodeRef *ref = calloc(1, sizeof *ref);
|
||||
@@ -343,6 +344,9 @@ static bool nh_live(ScrNodeRef *ref) {
|
||||
if (ref->stub == NULL) return true;
|
||||
void *stub = ref->stub;
|
||||
scr_ni_bridge();
|
||||
/* Per-module mode (NI-7b): Node starting evaluated nothing; the stub's
|
||||
* own component evaluates now. */
|
||||
if (ref->stub != NULL) scr_ni_boot_materialize(stub);
|
||||
if (ref->stub == NULL) return true;
|
||||
scr_ni_boot_stub_failed(stub);
|
||||
return false;
|
||||
@@ -906,8 +910,9 @@ ScrDyn *scr_ni_get_str(ScrDyn *obj, const char *key, size_t len) {
|
||||
if (nh_lazy_is(obj) && len == 4 && memcmp(key, "name", 4) == 0)
|
||||
return scr_dyn_retain(((ScrNodeRef *)obj->v.handle.ptr)->lazy[1]);
|
||||
void *stub = scr_ni_stub_of(obj);
|
||||
if (stub != NULL && scr_ni_bridge_loaded() == NULL) {
|
||||
/* Before Node starts, the interface snapshot answers what it holds. */
|
||||
if (stub != NULL && scr_ni_boot_snapshot_valid(stub)) {
|
||||
/* Until its module evaluates, the interface snapshot answers what it
|
||||
* holds. */
|
||||
ScrDyn *known = scr_ni_boot_member(stub, key, len);
|
||||
if (known != NULL) return known;
|
||||
}
|
||||
@@ -1063,7 +1068,7 @@ int scr_ni_instanceof(ScrDyn *value, ScrDyn *ctor) {
|
||||
* native value crossing into Node is never an instance of an ordinary
|
||||
* island class (the snapshot says whether the class is one). */
|
||||
void *stub = scr_ni_stub_of(ctor);
|
||||
if (stub != NULL && scr_ni_bridge_loaded() == NULL && !scr_ni_is_handle(value) &&
|
||||
if (stub != NULL && scr_ni_boot_snapshot_valid(stub) && !scr_ni_is_handle(value) &&
|
||||
scr_ni_boot_plain_class(stub))
|
||||
return 0;
|
||||
/* A lazy subclass has no instances yet, and a native value is never one
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* once per bridge ABI and runs it against the libnode pack the program
|
||||
* will load, so the interface snapshot comes from that exact Node.
|
||||
*
|
||||
* scr-node-probe <pack directory> <bootstrap.js> <input.json>
|
||||
* scr-node-probe <pack directory> <bootstrap.js> <input.json> <output.json>
|
||||
*
|
||||
* It starts Node from the pack's bridge with the bootstrap (which defines
|
||||
* the dispatcher and the probe), makes one synchronous call with the input
|
||||
* JSON, prints the outcome ("R<json>" or "T<json>") on stdout and stops
|
||||
* Node. Never includes a Node or V8 header. */
|
||||
* JSON, writes the outcome ("R<json>" or "T<json>") to the output file
|
||||
* (the probed modules may print) and exits. Never includes a Node or V8 header. */
|
||||
#include "scr_node_bridge.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
@@ -142,8 +142,8 @@ static const ScrNodeBridgeHost kHost = {
|
||||
};
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 4) {
|
||||
fprintf(stderr, "usage: scr-node-probe <pack directory> <bootstrap.js> <input.json>\n");
|
||||
if (argc != 5) {
|
||||
fprintf(stderr, "usage: scr-node-probe <pack directory> <bootstrap.js> <input.json> <output.json>\n");
|
||||
return 2;
|
||||
}
|
||||
char path[4096];
|
||||
@@ -174,8 +174,12 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
size_t len = 0;
|
||||
char *outcome = bridge->call(0, 1, "", "", "probe", input, ilen, &len);
|
||||
fwrite(outcome, 1, len, stdout);
|
||||
fflush(stdout);
|
||||
FILE *out = fopen(argv[4], "wb");
|
||||
if (out == NULL || fwrite(outcome, 1, len, out) != len || fclose(out) != 0) {
|
||||
fprintf(stderr, "scr-node-probe: cannot write %s\n", argv[4]);
|
||||
_Exit(1);
|
||||
}
|
||||
bridge->release(outcome);
|
||||
/* No orderly stop: the probe's modules may hold handles; the process
|
||||
* exits here. */
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// @requires-libnode
|
||||
// Two deferred island packages that share no files (scope-B NI-7b): every
|
||||
// static island import is deferred and none changes builtin state, so
|
||||
// each package evaluates on its own first use, not when Node starts.
|
||||
import { hello, NAME } from "pkg-a";
|
||||
import { Counter, make } from "pkg-b";
|
||||
|
||||
console.log("start", NAME, typeof hello, typeof Counter, Counter.name);
|
||||
console.log(hello("native"), hello("again"));
|
||||
console.log("pkg-b untouched so far:", typeof make);
|
||||
const c = make();
|
||||
console.log(c.next(), c.next(), c instanceof Counter);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export declare function hello(who: string): string;
|
||||
export declare const NAME: string;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
let greeted = 0;
|
||||
function hello(who) {
|
||||
greeted += 1;
|
||||
return `hello ${who} (${greeted})`;
|
||||
}
|
||||
const NAME = "pkg-a";
|
||||
module.exports = { hello, NAME };
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "pkg-a", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export declare class Counter {
|
||||
n: number;
|
||||
next(): number;
|
||||
}
|
||||
export declare function make(): Counter;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
class Counter {
|
||||
constructor() {
|
||||
this.n = 0;
|
||||
}
|
||||
next() {
|
||||
this.n += 1;
|
||||
return this.n;
|
||||
}
|
||||
}
|
||||
function make() {
|
||||
return new Counter();
|
||||
}
|
||||
module.exports = { Counter, make };
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "pkg-b", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "island-deferred-per-module", "private": true, "type": "module", "scriptc": { "packages": { "pkg-a": "node", "pkg-b": "node" } } }
|
||||
@@ -0,0 +1,11 @@
|
||||
// @requires-libnode
|
||||
// A deferred island package that reads an environment variable when it
|
||||
// evaluates (scope-B NI-7b): the build records the key as a boot guard,
|
||||
// and the late evaluation sees the value the variable had at the import
|
||||
// position, even though the program changed it before the first use.
|
||||
import { currentMode } from "env-pkg";
|
||||
|
||||
console.log("before", process.env.SCRIPTC_NI7B_MODE ?? "(unset)");
|
||||
process.env.SCRIPTC_NI7B_MODE = "changed";
|
||||
console.log("mode", currentMode());
|
||||
console.log("after", process.env.SCRIPTC_NI7B_MODE);
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function currentMode(): string;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use strict";
|
||||
// Read once, when the module evaluates.
|
||||
const mode = process.env.SCRIPTC_NI7B_MODE || "default";
|
||||
function currentMode() {
|
||||
return mode;
|
||||
}
|
||||
module.exports = { currentMode };
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "env-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "island-deferred-env", "private": true, "type": "module", "scriptc": { "packages": { "env-pkg": "node" } } }
|
||||
@@ -0,0 +1,16 @@
|
||||
// @requires-libnode
|
||||
// A native package's top-level require() of a Node island package is
|
||||
// deferred like a static import (scope-B NI-7d): the island's load (which
|
||||
// sets and clears a timer, leaving nothing behind) waits for the first
|
||||
// call, and Node does not start at boot.
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { calls, quadruple } from "native-wrapper";
|
||||
|
||||
function libnodeMapped(): boolean {
|
||||
if (!existsSync("/proc/self/maps")) return false;
|
||||
return readFileSync("/proc/self/maps", "utf8").includes("libnode");
|
||||
}
|
||||
|
||||
console.log("before first use, libnode mapped:", libnodeMapped());
|
||||
console.log("quadruple", quadruple(3), quadruple(5));
|
||||
console.log("calls", calls());
|
||||
Generated
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export declare function quadruple(n: number): number;
|
||||
export declare function calls(): number;
|
||||
Generated
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
// A native package that requires an island at its top level, as
|
||||
// async-retry requires retry.
|
||||
const timers = require("timer-pkg");
|
||||
|
||||
function quadruple(n) {
|
||||
return timers.twice(timers.twice(n));
|
||||
}
|
||||
|
||||
function calls() {
|
||||
return timers.count();
|
||||
}
|
||||
|
||||
module.exports = { quadruple, calls };
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "native-wrapper", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export declare function twice(n: number): number;
|
||||
export declare function count(): number;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
// Load-time timer housekeeping that leaves nothing behind: a clear of
|
||||
// nothing, and a timer set and cleared again (smol-toml-like).
|
||||
clearTimeout(undefined);
|
||||
const probe = setTimeout(() => {
|
||||
throw new Error("never runs");
|
||||
}, 1000);
|
||||
clearTimeout(probe);
|
||||
|
||||
let calls = 0;
|
||||
function twice(n) {
|
||||
calls += 1;
|
||||
return n * 2;
|
||||
}
|
||||
function count() {
|
||||
return calls;
|
||||
}
|
||||
module.exports = { twice, count };
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "timer-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "island-deferred-require", "private": true, "type": "module", "scriptc": { "packages": { "timer-pkg": "node" } } }
|
||||
@@ -0,0 +1,14 @@
|
||||
// @requires-libnode
|
||||
// A Node island package that reads its own files when it loads (scope-B
|
||||
// NI-7e): reads inside its package are guarded inputs, so the load defers
|
||||
// and the first call sees the same contents.
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { greet } from "data-pkg";
|
||||
|
||||
function libnodeMapped(): boolean {
|
||||
if (!existsSync("/proc/self/maps")) return false;
|
||||
return readFileSync("/proc/self/maps", "utf8").includes("libnode");
|
||||
}
|
||||
|
||||
console.log("before first use, libnode mapped:", libnodeMapped());
|
||||
console.log(greet("native"));
|
||||
+1
@@ -0,0 +1 @@
|
||||
hello from data
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export declare function greet(who: string): string;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
// Reads its own package's files at load: guarded inputs (the build records
|
||||
// their size and mtime as boot guards), so the load still defers.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const dataFile = path.join(__dirname, "data.txt");
|
||||
const greeting = fs.existsSync(dataFile) ? fs.readFileSync(dataFile, "utf8").trim() : "missing";
|
||||
const version = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8")).version;
|
||||
|
||||
function greet(who) {
|
||||
return `${greeting}, ${who} (v${version})`;
|
||||
}
|
||||
module.exports = { greet };
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "data-pkg", "version": "2.3.4", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "island-deferred-own-files", "private": true, "type": "module", "scriptc": { "packages": { "data-pkg": "node" } } }
|
||||
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
// Island code outside node_modules that loads punycode on first use: Node
|
||||
// prints its DEP0040 deprecation warning at that call.
|
||||
exports.toAscii = (domain) => require("punycode").toASCII(domain);
|
||||
@@ -0,0 +1,18 @@
|
||||
// @requires-libnode
|
||||
// @normalize-pid
|
||||
// Node's deprecation warnings from island code (scope-B NI-7f):
|
||||
// - punycode required first by island code outside node_modules: DEP0040
|
||||
// on stderr, with Node's text, at that call;
|
||||
// - punycode required by an island package (inside node_modules, as
|
||||
// node-fetch's whatwg-url): no warning, in Node as here;
|
||||
// - a package that warns when it loads: not deferred (the probe records
|
||||
// the warning), so it warns at its import position.
|
||||
import { requireModule } from "@scriptc/node";
|
||||
import { toAscii } from "puny-pkg";
|
||||
import { ok } from "warn-pkg";
|
||||
|
||||
console.log("start", ok());
|
||||
const legacy = requireModule("./legacy.cjs");
|
||||
const ascii: string = legacy.toAscii("bücher.de");
|
||||
console.log("legacy", ascii);
|
||||
console.log("package", toAscii("mañana.com"));
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function toAscii(domain: string): string;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
// An island package that uses the deprecated punycode builtin, as
|
||||
// node-fetch's whatwg-url does (here on first use). A require from inside
|
||||
// node_modules does not warn.
|
||||
function toAscii(domain) {
|
||||
return require("punycode").toASCII(domain);
|
||||
}
|
||||
module.exports = { toAscii };
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "puny-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function ok(): string;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
"use strict";
|
||||
// An island package that emits a deprecation warning when it loads: the
|
||||
// build's probe records the warning, so the package is not deferred and the
|
||||
// warning appears where Node prints it.
|
||||
process.emitWarning("warn-pkg's default export is deprecated", "DeprecationWarning", "DEP_WARN_PKG");
|
||||
exports.ok = () => "warn-pkg ok";
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "warn-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "island-punycode-warning", "private": true, "type": "module", "scriptc": { "packages": { "puny-pkg": "node", "warn-pkg": "node" } } }
|
||||
@@ -0,0 +1,3 @@
|
||||
import counter from "counter-pkg";
|
||||
|
||||
console.log(counter.label, counter.count, counter.count);
|
||||
Generated
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare const counter: { readonly count: number; readonly label: string };
|
||||
export default counter;
|
||||
Generated
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
"use strict";
|
||||
let reads = 0;
|
||||
// The export getter reads a member, not a binding: that member's getter
|
||||
// counts the read, so it is not a plain export getter.
|
||||
const state = { get count() { return ++reads; } };
|
||||
exports.label = "counter";
|
||||
Object.defineProperty(exports, "count", { enumerable: true, get: () => state.count });
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "counter-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "deferral-getter-member", "private": true, "type": "module", "scriptc": { "packages": { "counter-pkg": "node" } } }
|
||||
@@ -0,0 +1,3 @@
|
||||
import counter from "counter-pkg";
|
||||
|
||||
console.log(counter.label, counter.count, counter.count);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
declare const counter: { readonly count: number; readonly label: string };
|
||||
export default counter;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
"use strict";
|
||||
let reads = 0;
|
||||
// Each read counts: a getter that is not a plain binding read.
|
||||
Object.defineProperty(exports, "count", { enumerable: true, get: () => ++reads });
|
||||
exports.label = "counter";
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "counter-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "deferral-getter", "private": true, "type": "module", "scriptc": { "packages": { "counter-pkg": "node" } } }
|
||||
@@ -0,0 +1,3 @@
|
||||
import { hasExtra } from "probe-pkg";
|
||||
|
||||
console.log("extra", hasExtra);
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export declare const hasExtra: boolean;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
// A file of the package that is not there when the build probes it.
|
||||
exports.hasExtra = fs.existsSync(path.join(__dirname, "extra.json"));
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "probe-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "deferral-missing-file", "private": true, "type": "module", "scriptc": { "packages": { "probe-pkg": "node" } } }
|
||||
@@ -0,0 +1,4 @@
|
||||
import { config } from "pkg-a";
|
||||
import { ok } from "pkg-b";
|
||||
|
||||
console.log(config.mode, ok);
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare const config: { mode: string };
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
// pkg-a exports through a binding getter over its own object.
|
||||
const config = { mode: "initial" };
|
||||
Object.defineProperty(exports, "config", { enumerable: true, get: () => config });
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "pkg-a", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare const ok: boolean;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
// Its load changes what pkg-a exports, through pkg-a's binding getter.
|
||||
require("pkg-a").config.mode = "set by pkg-b";
|
||||
exports.ok = true;
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "name": "pkg-b", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "pkg-a": "1.0.0" } }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "deferral-mutate-getter", "private": true, "type": "module", "scriptc": { "packages": { "pkg-a": "node", "pkg-b": { "run": "node", "evaluate": "lazy" } } } }
|
||||
@@ -0,0 +1,4 @@
|
||||
import { config } from "pkg-a";
|
||||
import { ok } from "pkg-b";
|
||||
|
||||
console.log(config.mode, ok);
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare const config: { mode: string };
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
exports.config = { mode: "initial" };
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "pkg-a", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare const ok: boolean;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
// Its load changes what pkg-a exports.
|
||||
require("pkg-a").config.mode = "set by pkg-b";
|
||||
exports.ok = true;
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "pkg-b", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "pkg-a": "1.0.0" } }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "deferral-mutate", "private": true, "type": "module", "scriptc": { "packages": { "pkg-a": "node", "pkg-b": { "run": "node", "evaluate": "lazy" } } } }
|
||||
@@ -0,0 +1,3 @@
|
||||
import { color } from "tty-pkg";
|
||||
|
||||
console.log("color", color);
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare const color: boolean;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
// Decided by whether standard output is a terminal when the module loads.
|
||||
exports.color = Boolean(process.stdout.isTTY);
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "tty-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "deferral-tty", "private": true, "type": "module", "scriptc": { "packages": { "tty-pkg": "node" } } }
|
||||
@@ -156,6 +156,18 @@ function wantsNoDeprecation(file: string): boolean {
|
||||
return directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l));
|
||||
}
|
||||
|
||||
/** `// @normalize-pid` in the directive head: Node's process warnings
|
||||
* ("(node:<pid>) [DEP0040] ...") are compared with the pid replaced on both
|
||||
* sides — for island programs, where Node itself prints the warning in the
|
||||
* compiled program's process. */
|
||||
function wantsPidNormalized(file: string): boolean {
|
||||
return directiveHead(file).some((l) => /^\/\/ @normalize-pid\s*$/.test(l));
|
||||
}
|
||||
|
||||
function withoutWarningPid(stderr: Buffer): Buffer {
|
||||
return Buffer.from(stderr.toString("utf8").replace(/^\(node:\d+\) /gm, "(node:PID) "), "utf8");
|
||||
}
|
||||
|
||||
/** The Node oracle's argv for a corpus entry (shims + the per-program
|
||||
* transform flag). */
|
||||
/** `// @disallow-code-generation` in the directive head: the Node oracle
|
||||
@@ -426,9 +438,13 @@ describe(`differential corpus (${files.length} programs${sanitize ? ", sanitized
|
||||
// — their stderr carries the uncaught report, whose format is a
|
||||
// documented divergence.
|
||||
if (expectedExit === 0) {
|
||||
const nativeErr = comparableStderr(nativeRes.stderr);
|
||||
if (!nodeRes.stderr.equals(nativeErr)) {
|
||||
expect(nativeErr.toString("utf8")).toBe(nodeRes.stderr.toString("utf8"));
|
||||
const pidFree = wantsPidNormalized(file);
|
||||
const nativeErr = pidFree
|
||||
? withoutWarningPid(comparableStderr(nativeRes.stderr))
|
||||
: comparableStderr(nativeRes.stderr);
|
||||
const nodeErr = pidFree ? withoutWarningPid(nodeRes.stderr) : nodeRes.stderr;
|
||||
if (!nodeErr.equals(nativeErr)) {
|
||||
expect(nativeErr.toString("utf8")).toBe(nodeErr.toString("utf8"));
|
||||
expect.unreachable("stderr differed at byte level but not after utf8 decode");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,13 @@ function nodeOracleFile(file: string): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
/** `// @normalize-pid` (differential.test.ts): Node's process warnings
|
||||
* compare with the pid replaced on both sides. */
|
||||
function pidFree(file: string, stderr: Buffer): Buffer {
|
||||
if (!directiveHead(file).some((l) => /^\/\/ @normalize-pid\s*$/.test(l))) return stderr;
|
||||
return Buffer.from(stderr.toString("utf8").replace(/^\(node:\d+\) /gm, "(node:PID) "), "utf8");
|
||||
}
|
||||
|
||||
function wantsNoDeprecation(file: string): boolean {
|
||||
return directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l));
|
||||
}
|
||||
@@ -224,7 +231,9 @@ describe(`llvm differential corpus (${files.length} programs, ${optimizationMode
|
||||
const lane = level === undefined ? optimization : `${optimization} level ${level}`;
|
||||
expect(actual.stdout, `${lane} stdout`).toEqual(oracle.stdout);
|
||||
if (expectedExit === 0)
|
||||
expect(comparableStderr(actual.stderr), `${lane} stderr`).toEqual(oracle.stderr);
|
||||
expect(pidFree(file, comparableStderr(actual.stderr)), `${lane} stderr`).toEqual(
|
||||
pidFree(file, oracle.stderr),
|
||||
);
|
||||
expect(actual.exitCode, `${lane} exit status`).toBe(expectedExit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* Deferred Node island modules (scope-B NI-7) where what the build-time
|
||||
* probe records decides the program's behaviour: a load that changes
|
||||
* another island module's exports, an export getter that runs code, a
|
||||
* file of the package that is missing when the build probes it, and stdio
|
||||
* that is a pipe at build time and a terminal when the program runs. The
|
||||
* fixtures are in tests/fixtures/node-island/deferral. Needs a libnode pack
|
||||
* in SCRIPTC_LIBNODE (libnode.ts). */
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { libnodePack } from "./libnode.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** stdout of `binary` run under a pseudo-terminal by script(1). util-linux's
|
||||
* takes the command with -c. The BSD one on macOS takes it after the
|
||||
* transcript file, needs a stdin that is not a socket, and echoes the EOF
|
||||
* it forwards from /dev/null as ^D and two backspaces. */
|
||||
async function underTerminal(binary: string, env: NodeJS.ProcessEnv): Promise<string> {
|
||||
if (process.platform !== "darwin")
|
||||
return (await execFileAsync("script", ["-qec", binary, "/dev/null"], { env, encoding: "utf8" }))
|
||||
.stdout;
|
||||
const child = spawn("script", ["-q", "/dev/null", binary], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
let stdout = "";
|
||||
child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk));
|
||||
const code = await new Promise<number | null>((resolve) => child.on("close", resolve));
|
||||
expect(code).toBe(0);
|
||||
return stdout.replaceAll("^D\b\b", "");
|
||||
}
|
||||
const repoRoot = join(import.meta.dirname, "../..");
|
||||
const cli = join(repoRoot, "packages/cli/dist/main.js");
|
||||
const fixtures = join(repoRoot, "tests/fixtures/node-island/deferral");
|
||||
const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island-deferral");
|
||||
const skip = libnodePack() === null;
|
||||
|
||||
/** Builds a fixture with the deferral trace on; answers the binary and the
|
||||
* build's trace lines. */
|
||||
async function build(name: string): Promise<{ binary: string; trace: string[] }> {
|
||||
const dir = join(outDir, name);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const binary = join(dir, "main");
|
||||
const env = {
|
||||
...process.env,
|
||||
SCRIPTC_NODE_ISLANDS_TRACE: "defer",
|
||||
SCRIPTC_CACHE_DIR: join(dir, "build-cache"),
|
||||
};
|
||||
const { stderr } = await execFileAsync(
|
||||
process.execPath,
|
||||
[cli, "build", join(fixtures, name, "main.ts"), "-o", binary],
|
||||
{ env, encoding: "utf8" },
|
||||
);
|
||||
return { binary, trace: stderr.split("\n").filter((l) => l.startsWith("[node-islands] ")) };
|
||||
}
|
||||
|
||||
async function run(binary: string): Promise<string> {
|
||||
const env = { ...process.env, XDG_CACHE_HOME: join(outDir, "cache") };
|
||||
return (await execFileAsync(binary, [], { env, encoding: "utf8" })).stdout;
|
||||
}
|
||||
|
||||
describe.skipIf(skip)("deferred Node island modules", () => {
|
||||
test("a load that changes another island module's exports defers neither", async () => {
|
||||
const { binary, trace } = await build("mutate");
|
||||
expect(trace.some((l) => /eager .*pkg-a.*a later island module's load changes/.test(l))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
trace.some((l) => /eager .*pkg-b.*changes another island module's exports/.test(l)),
|
||||
).toBe(true);
|
||||
expect(await run(binary)).toBe("set by pkg-b true\n");
|
||||
}, 300_000);
|
||||
|
||||
test("a load that changes an object exported through a getter defers neither", async () => {
|
||||
const { binary, trace } = await build("mutate-getter");
|
||||
expect(trace.some((l) => /eager .*pkg-a.*a later island module's load changes/.test(l))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
trace.some((l) => /eager .*pkg-b.*changes another island module's exports/.test(l)),
|
||||
).toBe(true);
|
||||
expect(await run(binary)).toBe("set by pkg-b true\n");
|
||||
}, 300_000);
|
||||
|
||||
test("an export getter that runs code runs on every read", async () => {
|
||||
const { binary } = await build("getter");
|
||||
expect(await run(binary)).toBe("counter 1 2\n");
|
||||
}, 300_000);
|
||||
|
||||
test("an export getter that reads a member runs on every read", async () => {
|
||||
const { binary } = await build("getter-member");
|
||||
expect(await run(binary)).toBe("counter 1 2\n");
|
||||
}, 300_000);
|
||||
|
||||
test("a package file missing when the build probes keeps the module eager", async () => {
|
||||
const { binary, trace } = await build("missing-file");
|
||||
expect(trace.some((l) => /eager .*probe-pkg.*existsSync/.test(l))).toBe(true);
|
||||
expect(await run(binary)).toBe("extra false\n");
|
||||
}, 300_000);
|
||||
|
||||
test("stdio's terminal state at run time is the program's", async () => {
|
||||
const { binary } = await build("tty");
|
||||
expect(await run(binary)).toBe("color false\n");
|
||||
// Under a pseudo-terminal the module sees a terminal, as under Node.
|
||||
const env = { ...process.env, XDG_CACHE_HOME: join(outDir, "cache") };
|
||||
expect((await underTerminal(binary, env)).replace(/\r/g, "")).toBe("color true\n");
|
||||
}, 300_000);
|
||||
});
|
||||
Reference in New Issue
Block a user