Defer island modules whose recorded load is deterministic (NI-7b/7d/7e/7f) (#1906)

This commit is contained in:
Chris Tate
2026-10-29 15:18:28 -05:00
committed by GitHub
93 changed files with 1539 additions and 209 deletions
@@ -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");
});
});
+181 -165
View File
@@ -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(
{
+22
View File
@@ -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(
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6843-node-island-deferred-per-module/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6844-node-island-deferred-env/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6845-node-island-deferred-require/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6846-node-island-deferred-own-files/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6847-node-island-punycode-warning/main.ts"
],
"diags": []
}
+5 -4
View File
@@ -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;
+2
View File
@@ -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);
+153 -4
View File
@@ -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);
@@ -0,0 +1,2 @@
export declare function hello(who: string): string;
export declare const NAME: string;
@@ -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 };
@@ -0,0 +1 @@
{ "name": "pkg-a", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1,5 @@
export declare class Counter {
n: number;
next(): number;
}
export declare function make(): Counter;
@@ -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 };
@@ -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);
@@ -0,0 +1 @@
export declare function currentMode(): string;
@@ -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 };
@@ -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());
@@ -0,0 +1,2 @@
export declare function quadruple(n: number): number;
export declare function calls(): number;
@@ -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 };
@@ -0,0 +1 @@
{ "name": "native-wrapper", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1,2 @@
export declare function twice(n: number): number;
export declare function count(): number;
@@ -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 };
@@ -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"));
@@ -0,0 +1 @@
hello from data
@@ -0,0 +1 @@
export declare function greet(who: string): string;
@@ -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 };
@@ -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"));
@@ -0,0 +1 @@
export declare function toAscii(domain: string): string;
@@ -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 };
@@ -0,0 +1 @@
{ "name": "puny-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
export declare function ok(): string;
@@ -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";
@@ -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);
@@ -0,0 +1,2 @@
declare const counter: { readonly count: number; readonly label: string };
export default counter;
@@ -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 });
@@ -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" } } }
+3
View File
@@ -0,0 +1,3 @@
import counter from "counter-pkg";
console.log(counter.label, counter.count, counter.count);
@@ -0,0 +1,2 @@
declare const counter: { readonly count: number; readonly label: string };
export default counter;
@@ -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";
@@ -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);
@@ -0,0 +1 @@
export declare const hasExtra: boolean;
@@ -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"));
@@ -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);
@@ -0,0 +1 @@
export declare const config: { mode: string };
@@ -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 });
@@ -0,0 +1 @@
{ "name": "pkg-a", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
export declare const ok: boolean;
@@ -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;
@@ -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" } } } }
+4
View File
@@ -0,0 +1,4 @@
import { config } from "pkg-a";
import { ok } from "pkg-b";
console.log(config.mode, ok);
@@ -0,0 +1 @@
export declare const config: { mode: string };
@@ -0,0 +1,2 @@
"use strict";
exports.config = { mode: "initial" };
@@ -0,0 +1 @@
{ "name": "pkg-a", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
export declare const ok: boolean;
@@ -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;
@@ -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" } } } }
+3
View File
@@ -0,0 +1,3 @@
import { color } from "tty-pkg";
console.log("color", color);
@@ -0,0 +1 @@
export declare const color: boolean;
@@ -0,0 +1,3 @@
"use strict";
// Decided by whether standard output is a terminal when the module loads.
exports.color = Boolean(process.stdout.isTTY);
@@ -0,0 +1 @@
{ "name": "tty-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
+1
View File
@@ -0,0 +1 @@
{ "name": "deferral-tty", "private": true, "type": "module", "scriptc": { "packages": { "tty-pkg": "node" } } }
+19 -3
View File
@@ -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");
}
}
+10 -1
View File
@@ -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);
}
}
+112
View File
@@ -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);
});