diff --git a/packages/runtime/DESIGN.md b/packages/runtime/DESIGN.md index e0534a3..b4707bb 100644 --- a/packages/runtime/DESIGN.md +++ b/packages/runtime/DESIGN.md @@ -401,7 +401,11 @@ Kernel PCB: `Process{ pid, ppid, threads: Map, fds, cwd, env, handl a Thread is one Worker, via a `WorkerFactory` interface (nested workers default, host-proxied fallback). The kernel keeps one runtime worker spawned ahead of need, idle until `init`, so the next process or thread starts with runtime.js already evaluated; only a child process or thread -taking it refills it, so a lone top-level process leaves no idle worker. `spawn/fork/exec*` target `node` or a VFS file with a `#!/usr/bin/env node` shebang; +taking it refills it, so a lone top-level process leaves no idle worker. A process has a +runtime (`exec({ runtime })`, VISION D2): `node`, the default, installs Node's globals +and loads the entry as Node does; `web` installs none of them, gives number-returning timers, +and runs only an ES module entry (`ERR_NIB_WEB_ENTRY_NOT_ESM` otherwise). The kernel stamps the +Proc's runtime on every thread's `init`, and a spawned child is always `node`. `spawn/fork/exec*` target `node` or a VFS file with a `#!/usr/bin/env node` shebang; stdio is `pipe` (kernel ring) / `inherit` (shared OpenFile, shared offset) / `ignore`; `fork` adds a transferred IPC MessageChannel (`serialization: 'advanced'` = structuredClone). `worker_threads` spawns a Thread in the same Pid sharing fds and cwd. `process.exit` runs exit listeners, flushes diff --git a/packages/runtime/src/runtime/errors.ts b/packages/runtime/src/runtime/errors.ts index c815bb2..f915b63 100644 --- a/packages/runtime/src/runtime/errors.ts +++ b/packages/runtime/src/runtime/errors.ts @@ -20,6 +20,13 @@ export function requireEsm(path: string, parent?: string): Error { return Object.assign(e, { code: "ERR_REQUIRE_ESM" }); } +/** A web process runs only an ES module entry (VISION D2). */ +export function webEntryNotEsm(entry: string, format: string): Error { + const e = new Error(`A web process's entry must be an ES module; ${entry} is ${format}.`); + if (e.stack?.startsWith("Error: ")) e.stack = `Error [ERR_NIB_WEB_ENTRY_NOT_ESM]: ${e.stack.slice(7)}`; + return Object.assign(e, { code: "ERR_NIB_WEB_ENTRY_NOT_ESM" }); +} + /** Node's ERR_INVALID_ARG_TYPE, with its description of the received value. */ export function invalidArgType(name: string, expected: string, value: unknown): TypeError { const got = value === null || value === undefined ? ` Received ${value}` diff --git a/packages/runtime/src/runtime/globals.ts b/packages/runtime/src/runtime/globals.ts index 2bb54e8..e30ae2f 100644 --- a/packages/runtime/src/runtime/globals.ts +++ b/packages/runtime/src/runtime/globals.ts @@ -1,10 +1,11 @@ -// The Node globals a browser worker lacks, installed in one place before the entry -// runs (CJS and ESM alike). queueMicrotask stays the browser's own. +// The globals each process runtime installs, in one place, before the entry runs (CJS and +// ESM alike). queueMicrotask stays the browser's own. import { Buffer } from "./buffer"; import type { GuestProcess } from "./process"; -import type { Timers } from "./timers"; +import type { Timeout, Timers } from "./timers"; -export function installGlobals(timers: Timers, process: GuestProcess): void { +/** The Node globals a browser worker lacks. */ +export function installNodeGlobals(timers: Timers, process: GuestProcess): void { Object.assign(globalThis, { global: globalThis, process, @@ -17,3 +18,16 @@ export function installGlobals(timers: Timers, process: GuestProcess): void { clearImmediate: timers.clearImmediate, }); } + +/** Web-shaped timers that still hold the loop, so a pending timer keeps the process + * alive: they return the numeric id, which clearTimeout already accepts. */ +export function installWebGlobals(timers: Timers): void { + const byId = (start: (fn: () => void, ms?: unknown, ...args: unknown[]) => Timeout) => + (fn: () => void, ms?: unknown, ...args: unknown[]): number => Number(start(fn, ms, ...args)); + Object.assign(globalThis, { + setTimeout: byId(timers.setTimeout), + clearTimeout: timers.clearTimeout, + setInterval: byId(timers.setInterval), + clearInterval: timers.clearInterval, + }); +} diff --git a/packages/runtime/src/runtime/main.ts b/packages/runtime/src/runtime/main.ts index df2f750..74a51a2 100644 --- a/packages/runtime/src/runtime/main.ts +++ b/packages/runtime/src/runtime/main.ts @@ -1,10 +1,10 @@ // Runtime worker entry: installs the syscall client, event loop, and process object, // then evaluates the entry script. Runs inside a (nested) worker spawned by the kernel. // The kernel bumps the mailbox heartbeat; the sync watchdog here only reads it. -import type { FsResult, KernelToRuntime, KernelToRuntimeInit, Res, ResolvedModule, RuntimeToKernel, SyscallSpec } from "../shared/protocol"; +import type { FsResult, KernelToRuntime, KernelToRuntimeInit, ProcessRuntime, Res, ResolvedModule, RuntimeToKernel, SyscallSpec } from "../shared/protocol"; import { awaitSyncReply, takeSignals, writeSyncRequest, SAB_BYTES } from "../shared/mailbox"; import { builtinUrl, cjsUrl, vfsUrl, type HostToken } from "../shared/vurls"; -import { toNodeError, loaderError, requireNotFound } from "./errors"; +import { toNodeError, loaderError, requireNotFound, webEntryNotEsm } from "./errors"; import { compileCjs, makeRequire, loadCjsByPath, newModule, type RequireContext, type ModuleCacheEntry } from "./modules"; import { loadBuiltin } from "./builtins"; import { deliverWatchEvents } from "./fs"; @@ -17,7 +17,7 @@ import { ReadStream, WriteStream, installTty, shouldColorize } from "./tty"; import { join } from "./resolver"; import { useNextTick } from "./ticks"; import { createTimers, type Timers } from "./timers"; -import { installGlobals } from "./globals"; +import { installNodeGlobals, installWebGlobals } from "./globals"; import { formatWithOptions } from "./inspect"; import { installPorts, type MessagePort as NodePort } from "./ports"; import { makeWorkerThreads, type WorkerThreads } from "./worker_threads"; @@ -259,7 +259,8 @@ self.onmessage = (ev: MessageEvent): void => { proc.on("SIGWINCH", function refreshStderrOnSigWinch() { stderr._refreshSize(); }); } timers = createTimers(loop, (message, type) => proc.emitWarning(message, type)); - installGlobals(timers, proc); + const setup = processRuntimes[msg.runtime]; + setup.installGlobals(timers, proc); // ---- guest ESM hooks (installed before the entry runs) ---- // Facade modules served from the SW virtual origin reach back into the runtime @@ -327,7 +328,20 @@ self.onmessage = (ev: MessageEvent): void => { // Node emits 'online' when the thread starts running its entry. parentPort?.send({ k: "online" }); - void runEntry(msg); + void runEntry(msg, setup.runEntry); +}; + +/** What differs between process runtimes (VISION D2). Everything else (the loop, stdio, + * console capture, node: builtins, the exit path) is the same for both. */ +interface ProcessRuntimeSetup { + installGlobals(timers: Timers, proc: GuestProcess): void; + /** Load and evaluate the entry, then mark the main script done. */ + runEntry(msg: KernelToRuntimeInit): Promise; +} + +const processRuntimes: Record = { + node: { installGlobals: installNodeGlobals, runEntry: runNodeEntry }, + web: { installGlobals: installWebGlobals, runEntry: runWebEntry }, }; /** Map a resolved module record to its virtual-origin URL — the same mapping the @@ -342,41 +356,9 @@ function moduleUrlFor(resolved: ResolvedModule, specifier: string, from: string) return vfsUrl(host, resolved.path, st); } -async function runEntry(msg: KernelToRuntimeInit): Promise { +async function runEntry(msg: KernelToRuntimeInit, run: ProcessRuntimeSetup["runEntry"]): Promise { try { - if (msg.thread?.eval != null) { - // An eval worker runs its source as a CJS script whose require() starts at cwd. - const fn = compileCjs(msg.thread.eval, msg.entry, ["exports", "require", "module", "__filename", "__dirname", "process"]); - const moduleObj = { exports: {}, id: msg.entry, filename: msg.entry, loaded: false, children: [] as unknown[] }; - loop.runMain(() => fn.call(moduleObj.exports, moduleObj.exports, makeRequire(ctx, `${msg.cwd}/${msg.entry}`), moduleObj, msg.entry, ".", proc)); - loop.setMainDone(); - return; - } - const resolved = await call("resolve", { specifier: msg.entry, parent: msg.cwd, kind: "require" }); - // Node resolves the entry through its CJS loader, whatever the entry's format. - if (resolved.kind === "missing" && resolved.code === "ERR_MODULE_NOT_FOUND") throw requireNotFound(msg.entry, []); - if (resolved.kind === "missing") throw loaderError(resolved.code, msg.entry, msg.cwd); - if (resolved.kind !== "file") throw requireNotFound(msg.entry, []); - const { source, format } = await call("loadModule", { path: resolved.path }); - if (format === "esm") { - // Guest ESM executes through the browser's native module linker: the SW - // virtual origin serves the kernel-transformed source, so cycles, live - // bindings and TLA come from the engine (DESIGN §Module system). - const st = callSync("stat", { path: resolved.path, follow: true }); - await import(vfsUrl(host, resolved.path, st)); - loop.setMainDone(); - return; - } - if (format !== "cjs") throw loaderError("ERR_REQUIRE_ESM", msg.entry, msg.cwd); - - const mod = newModule(resolved.path); - ctx.cache.set(resolved.path, mod); - const text = new TextDecoder().decode(source); - const fn = compileCjs(text, resolved.path, ["exports", "require", "module", "__filename", "__dirname", "process"]); - loop.runMain(() => fn.call(mod.exports, mod.exports, makeRequire(ctx, resolved.path), mod, - resolved.path, ctx.dirnameOf(resolved.path), proc)); - mod.loaded = true; - loop.setMainDone(); + await run(msg); } catch (e) { // A throw from the entry is an uncaught exception like any other: listeners it // registered before throwing keep the process running. @@ -384,6 +366,52 @@ async function runEntry(msg: KernelToRuntimeInit): Promise { } } +async function runWebEntry(msg: KernelToRuntimeInit): Promise { + if (msg.thread?.eval != null) throw webEntryNotEsm("the eval source", "a CommonJS script"); + const resolved = await call("resolve", { specifier: msg.entry, parent: msg.cwd, kind: "import" }); + if (resolved.kind === "file" && resolved.format !== "esm") { + throw webEntryNotEsm(resolved.path, resolved.format === "cjs" ? "CommonJS" : "JSON"); + } + await import(moduleUrlFor(resolved, msg.entry, msg.cwd)); + loop.setMainDone(); +} + +async function runNodeEntry(msg: KernelToRuntimeInit): Promise { + if (msg.thread?.eval != null) { + // An eval worker runs its source as a CJS script whose require() starts at cwd. + const fn = compileCjs(msg.thread.eval, msg.entry, ["exports", "require", "module", "__filename", "__dirname", "process"]); + const moduleObj = { exports: {}, id: msg.entry, filename: msg.entry, loaded: false, children: [] as unknown[] }; + loop.runMain(() => fn.call(moduleObj.exports, moduleObj.exports, makeRequire(ctx, `${msg.cwd}/${msg.entry}`), moduleObj, msg.entry, ".", proc)); + loop.setMainDone(); + return; + } + const resolved = await call("resolve", { specifier: msg.entry, parent: msg.cwd, kind: "require" }); + // Node resolves the entry through its CJS loader, whatever the entry's format. + if (resolved.kind === "missing" && resolved.code === "ERR_MODULE_NOT_FOUND") throw requireNotFound(msg.entry, []); + if (resolved.kind === "missing") throw loaderError(resolved.code, msg.entry, msg.cwd); + if (resolved.kind !== "file") throw requireNotFound(msg.entry, []); + const { source, format } = await call("loadModule", { path: resolved.path }); + if (format === "esm") { + // Guest ESM executes through the browser's native module linker: the SW + // virtual origin serves the kernel-transformed source, so cycles, live + // bindings and TLA come from the engine (DESIGN §Module system). + const st = callSync("stat", { path: resolved.path, follow: true }); + await import(vfsUrl(host, resolved.path, st)); + loop.setMainDone(); + return; + } + if (format !== "cjs") throw loaderError("ERR_REQUIRE_ESM", msg.entry, msg.cwd); + + const mod = newModule(resolved.path); + ctx.cache.set(resolved.path, mod); + const text = new TextDecoder().decode(source); + const fn = compileCjs(text, resolved.path, ["exports", "require", "module", "__filename", "__dirname", "process"]); + loop.runMain(() => fn.call(mod.exports, mod.exports, makeRequire(ctx, resolved.path), mod, + resolved.path, ctx.dirnameOf(resolved.path), proc)); + mod.loaded = true; + loop.setMainDone(); +} + /** Route an uncaught error. True when an uncaughtException listener took it; * otherwise the process dies and this does not return. */ function uncaught(e: unknown, origin: "uncaughtException" | "unhandledRejection" = "uncaughtException"): boolean { diff --git a/packages/runtime/tests/guest.ts b/packages/runtime/tests/guest.ts index 9dc3de7..49c6844 100644 --- a/packages/runtime/tests/guest.ts +++ b/packages/runtime/tests/guest.ts @@ -1,6 +1,6 @@ // Run a guest program to exit on the fs fixture page and collect its stdio. import { test, expect, type Page } from "@playwright/test"; -import type { Runtime } from "../src/host/index"; +import type { ExecOptions, Runtime } from "../src/host/index"; declare global { interface Window { __runtime?: Runtime } @@ -14,13 +14,15 @@ export interface GuestResult { } /** Like spawnSync, the guest's stdin gets `stdin` (or nothing) and is then closed. */ -export async function runGuest(page: Page, files: Record, entry: string, stdin = ""): Promise { +export async function runGuest( + page: Page, files: Record, entry: string, stdin = "", options: ExecOptions = {}, +): Promise { await page.goto("/demo/fixture.html"); await page.waitForFunction(() => window.__runtime !== undefined, undefined, { timeout: 20_000 }); - return await page.evaluate(async ({ files, entry, stdin }) => { + return await page.evaluate(async ({ files, entry, stdin, options }) => { const runtime = window.__runtime!; runtime.writeTree(files); - const proc = runtime.exec(entry); + const proc = runtime.exec(entry, options); const writer = proc.stdin.getWriter(); if (stdin) void writer.write(new TextEncoder().encode(stdin)); void writer.close(); @@ -35,7 +37,7 @@ export async function runGuest(page: Page, files: Record, entry: const signal = await proc.signal; await piped; return { code, signal, stdout, stderr }; - }, { files, entry, stdin }); + }, { files, entry, stdin, options }); } /** Run a guest kept under tests/fixtures/guests/ (also runnable by real Node, which is diff --git a/packages/runtime/tests/process-runtime.spec.ts b/packages/runtime/tests/process-runtime.spec.ts new file mode 100644 index 0000000..17d93d0 --- /dev/null +++ b/packages/runtime/tests/process-runtime.spec.ts @@ -0,0 +1,111 @@ +// Process runtime (VISION D2): a `web` process has web globals only and an ESM +// entry; `node` (the default) keeps today's Node globals. +import { test, expect, type Page } from "@playwright/test"; +import { runGuest, type GuestResult } from "./guest"; + +const web = (page: Page, files: Record, entry = "/app/main.mjs"): Promise => + runGuest(page, files, entry, "", { runtime: "web" }); + +test("web: no Node globals", async ({ page }) => { + const out = await web(page, { + "/app/main.mjs": "console.log(typeof process, typeof Buffer, typeof global, typeof setImmediate, typeof clearImmediate);", + }); + expect(out).toEqual({ code: 0, signal: null, stdout: "undefined undefined undefined undefined undefined\n", stderr: "" }); +}); + +test("web: timers return numbers, keep the loop alive, and clear by number", async ({ page }) => { + const out = await web(page, { + "/app/main.mjs": [ + "const cancelled = setTimeout(() => console.log('cancelled timeout ran'), 10);", + "clearTimeout(cancelled);", + "const iv = setInterval(() => console.log('cancelled interval ran'), 10);", + "clearInterval(iv);", + "console.log(typeof cancelled, typeof iv);", + "setTimeout(() => console.log('fired'), 50);", + "console.log('sync done');", + ].join("\n"), + }); + expect(out).toEqual({ code: 0, signal: null, stdout: "number number\nsync done\nfired\n", stderr: "" }); +}); + +test("web: node: builtins import from ESM", async ({ page }) => { + const out = await web(page, { + "/app/main.mjs": [ + "import { readFileSync, writeFileSync } from 'node:fs';", + "import fs from 'node:fs';", + "import { join } from 'node:path';", + "import { EventEmitter } from 'node:events';", + "writeFileSync(join('/app', 'out.txt'), 'written');", + "console.log(readFileSync('/app/out.txt', 'utf8'), fs.existsSync('/app/out.txt'));", + "const e = new EventEmitter();", + "e.on('ping', (v) => console.log('event', v));", + "e.emit('ping', 7);", + ].join("\n"), + }); + expect(out).toEqual({ code: 0, signal: null, stdout: "written true\nevent 7\n", stderr: "" }); +}); + +test("web: a CJS entry is refused", async ({ page }) => { + const out = await web(page, { "/app/main.cjs": "console.log('ran');" }, "/app/main.cjs"); + expect(out.code).toBe(1); + expect(out.stdout).toBe(""); + expect(out.stderr).toContain("ERR_NIB_WEB_ENTRY_NOT_ESM"); + expect(out.stderr).toContain("/app/main.cjs"); +}); + +test("web: an uncaught throw prints its stack and exits 1", async ({ page }) => { + const out = await web(page, { "/app/main.mjs": "console.log('before');\nthrow new Error('boom');" }); + expect(out.code).toBe(1); + expect(out.stdout).toBe("before\n"); + expect(out.stderr).toMatch(/^Error: boom\n {4}at /); +}); + +test("web: worker_threads threads inherit the runtime", async ({ page }) => { + const out = await web(page, { + "/app/main.mjs": [ + "import { Worker } from 'node:worker_threads';", + "const w = new Worker('/app/thread.mjs');", + "w.on('message', (m) => console.log('thread sees', m));", + "w.on('exit', (code) => console.log('thread exit', code));", + ].join("\n"), + "/app/thread.mjs": "import { parentPort } from 'node:worker_threads';\nparentPort.postMessage(typeof process);", + }); + expect(out).toEqual({ code: 0, signal: null, stdout: "thread sees undefined\nthread exit 0\n", stderr: "" }); +}); + +test("web: an eval worker is refused, since its source is a CommonJS script", async ({ page }) => { + const out = await web(page, { + "/app/main.mjs": [ + "import { Worker } from 'node:worker_threads';", + "const w = new Worker('console.log(\"ran\")', { eval: true });", + "w.on('error', (e) => console.log('thread error', e.message));", + "w.on('exit', (code) => console.log('thread exit', code));", + ].join("\n"), + }); + expect(out).toEqual({ + code: 0, + signal: null, + stdout: "thread error A web process's entry must be an ES module; the eval source is a CommonJS script.\nthread exit 1\n", + stderr: "", + }); +}); + +test("web: a node child is a node process", async ({ page }) => { + const out = await web(page, { + "/app/main.mjs": [ + "import { spawn } from 'node:child_process';", + "const child = spawn('node', ['/app/child.cjs']);", + "child.stdout.on('data', (d) => console.log('child says', String(d).trim()));", + "child.on('close', (code) => console.log('child exit', code));", + ].join("\n"), + "/app/child.cjs": "console.log(typeof process, typeof Buffer);", + }); + expect(out).toEqual({ code: 0, signal: null, stdout: "child says object function\nchild exit 0\n", stderr: "" }); +}); + +test("node stays the default runtime", async ({ page }) => { + const out = await runGuest(page, { + "/app/main.mjs": "console.log(typeof process, typeof Buffer, typeof setImmediate, typeof setTimeout(() => {}, 0));", + }, "/app/main.mjs"); + expect(out).toEqual({ code: 0, signal: null, stdout: "object function function object\n", stderr: "" }); +});