@genroc/eval-node 0.0.0-edge.24a07d0 → 0.0.0-edge.2646018

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/eval.ts CHANGED
@@ -1,11 +1,6 @@
1
- // Evaluation core: import a script module in its OWN realm, call its default export, and
2
- // classify every outcome into one of the failure kinds in README.md. Nothing here knows about
3
- // genroc — worker.ts is the only thing that talks to the queue — so this stays testable and
4
- // the containment stays swappable.
5
- //
6
- // The containment is a Worker per execution (realm.ts). It is what makes the budget real:
7
- // a synchronous busy loop never yields, so no in-process timer can interrupt it, and only a
8
- // thread the host can kill bounds it.
1
+ // Imports a script module in its own realm (a Worker per execution, realm.ts), calls its default
2
+ // export, and classifies every outcome (README.md). Only a killable thread bounds a synchronous
3
+ // busy loop, which is what makes the budget real. Knows nothing of genroc; worker.ts does.
9
4
 
10
5
  import { Worker } from "node:worker_threads";
11
6
 
@@ -16,9 +11,8 @@ export type EvalRequest = {
16
11
  timeout_ms?: number;
17
12
  };
18
13
 
19
- /** Every kind here is PERMANENT: a retry re-runs the same code on the same input and fails
20
- * identically. The retryable class has no kind because it is not an outcome — a runner that
21
- * faults releases its claim and lets another worker take the task. */
14
+ /** Every kind is PERMANENT: a retry fails identically. A faulting runner is not an outcome; it
15
+ * releases its claim. */
22
16
  export type FailureKind = "compile_error" | "threw" | "timeout" | "nonserializable" | "exited";
23
17
 
24
18
  export type EvalFailure = {
@@ -28,9 +22,8 @@ export type EvalFailure = {
28
22
  stack?: string;
29
23
  };
30
24
 
31
- /** `body` is JSON TEXT, not a value: serialising in the realm is what makes a nonserializable
32
- * return a script fault rather than a 500 thrown out of the response path. It is also what
33
- * crosses the worker boundary — structured clone would refuse a different set of values. */
25
+ /** JSON TEXT serialised in the realm, so a nonserializable return is a script fault, not a 500,
26
+ * and structured clone (which refuses a different set of values) never sees the value. */
34
27
  export type EvalResult =
35
28
  | { ok: true; body: string }
36
29
  | { ok: false; failure: EvalFailure };
@@ -47,9 +40,8 @@ const REALM_URL = new URL(
47
40
  import.meta.url,
48
41
  );
49
42
 
50
- /** Thrown, not returned: a realm that fails to start is the RUNNER faulting, which worker.ts
51
- * answers by releasing the claim rather than by reporting an outcome. A script fault is a
52
- * return value. */
43
+ /** Thrown, not returned: a realm that fails to start is the runner faulting, which worker.ts
44
+ * answers by releasing the claim. */
53
45
  class RealmFault extends Error {
54
46
  constructor(message: string) {
55
47
  super(message);
@@ -57,24 +49,41 @@ class RealmFault extends Error {
57
49
  }
58
50
  }
59
51
 
60
- export async function evaluate(req: EvalRequest): Promise<EvalResult> {
52
+ /** Thrown when `signal` aborts: the answer is no longer wanted, so worker.ts releases rather
53
+ * than answers. Not a RealmFault: nothing is wrong. */
54
+ export class Cancelled extends Error {
55
+ constructor() {
56
+ super("cancelled");
57
+ this.name = "Cancelled";
58
+ }
59
+ }
60
+
61
+ export async function evaluate(req: EvalRequest, signal?: AbortSignal): Promise<EvalResult> {
61
62
  const budget = typeof req.timeout_ms === "number" ? req.timeout_ms : DEFAULT_TIMEOUT_MS;
62
63
 
63
64
  const worker = new Worker(REALM_URL);
64
65
  let timer: ReturnType<typeof setTimeout> | undefined;
66
+ let onAbort: (() => void) | undefined;
65
67
  try {
66
68
  return await new Promise<EvalResult>((resolve, reject) => {
67
69
  timer = setTimeout(() => resolve(timedOut(budget)), budget);
70
+ // The abort is wired to the same promise as the budget, so both settle through the one
71
+ // finally below -- which is what guarantees the thread is gone before either returns.
72
+ if (signal) {
73
+ if (signal.aborted) reject(new Cancelled());
74
+ onAbort = () => reject(new Cancelled());
75
+ signal.addEventListener("abort", onAbort, { once: true });
76
+ }
68
77
  worker.once("message", (reply: WorkerReply) => resolve(reply));
69
- // A script may end its own realm (`process.exit()`), which is not a throw and would
70
- // otherwise present as a hang until the budget expired. Our own terminate() raises
71
- // this too, by which time the promise has settled and the first result stands.
78
+ // A script may `process.exit()`, which would otherwise look like a hang until the budget.
79
+ // Our own terminate() fires this too, after the promise has settled.
72
80
  worker.once("exit", (code: number) => resolve(exited(code)));
73
81
  worker.once("error", (err: Error) => reject(new RealmFault(errorText(err))));
74
82
  worker.postMessage({ code: req.code, input: req.input } satisfies WorkerRequest);
75
83
  });
76
84
  } finally {
77
85
  clearTimeout(timer);
86
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
78
87
  // Awaited, and the whole point: on the timeout path a thread is still burning a core, and
79
88
  // resolving before it is gone would report an evaluation the machine is still running.
80
89
  await worker.terminate();
package/import.ts CHANGED
@@ -1,18 +1,12 @@
1
1
  #!/usr/bin/env node
2
- // The code-phase resolver: manifest on stdin, `{"code": [...]}` on stdout, non-zero exit
3
- // with the diagnostic on stderr. genctl never parses TypeScript and this never parses YAML
4
- // — the manifest is the whole contract. See specs/source-resolution.md.
5
- //
6
- // Two modes, one binary: "types" writes the declarations an editor needs and returns no
7
- // code; "build" typechecks and bundles. A separate types hook would mean a second `tsc`
8
- // over the same project.
9
-
10
- import { spawn } from "node:child_process";
2
+ // The typed-phase resolver: manifest on stdin, `{"values": [...]}` on stdout, diagnostic on stderr
3
+ // with a non-zero exit (specs/source-resolution.md). Two modes share one `tsc` pass: "generate"
4
+ // writes declarations only, "resolve" typechecks and bundles.
5
+
11
6
  import { existsSync } from "node:fs";
12
- import { access, mkdir, writeFile } from "node:fs/promises";
7
+ import { access, writeFile } from "node:fs/promises";
13
8
  import { builtinModules } from "node:module";
14
- import { dirname, join, relative, resolve } from "node:path";
15
- import { fileURLToPath } from "node:url";
9
+ import { dirname, join, resolve } from "node:path";
16
10
 
17
11
  import commonjs from "@rollup/plugin-commonjs";
18
12
  import json from "@rollup/plugin-json";
@@ -23,15 +17,17 @@ import ts from "typescript";
23
17
  type Schema = Record<string, any>;
24
18
 
25
19
  type Site = {
20
+ // Which namespace the directive sits in: `process`, `task` or `action`.
21
+ level: "process" | "task" | "action";
26
22
  task?: string;
27
- // What the site IS: the task's action type, and the process a child action calls.
23
+ // What the site IS, at action level: the action's type, and the process a child call names.
28
24
  action?: string;
29
25
  child?: string;
30
26
  // The slot as a path of keys and indices, so nothing has to unescape a JSON Pointer.
31
27
  pointer: (string | number)[];
32
- // Everything after `$<resolver>:`, verbatim. genctl does not read it as a path — that is
33
- // this resolver's reading of it.
34
- argument: string;
28
+ // The argument's words, split as sh splits single quotes. genctl does not read them as paths —
29
+ // that is this resolver's reading of them.
30
+ args: string[];
35
31
  // What .genroc asked genctl to type, keyed by the name it chose. This resolver reads Input
36
32
  // and Output; which ADDRESS each came from is the config's business, not ours.
37
33
  types?: Record<string, Schema>;
@@ -49,8 +45,7 @@ type ManifestProcess = {
49
45
  };
50
46
 
51
47
  type Manifest = {
52
- mode: "types" | "build";
53
- root: string;
48
+ mode: "generate" | "resolve";
54
49
  processes: ManifestProcess[];
55
50
  };
56
51
 
@@ -68,12 +63,6 @@ async function exists(path: string): Promise<boolean> {
68
63
  }
69
64
  }
70
65
 
71
- /** Creates the parent directory, which `.genroc-cache/` relies on: nothing else makes it. */
72
- async function write(path: string, content: string): Promise<void> {
73
- await mkdir(dirname(path), { recursive: true });
74
- await writeFile(path, content);
75
- }
76
-
77
66
  // ── JSON Schema → TypeScript ───────────────────────────────────────────────────
78
67
 
79
68
  /** Only genroc's keyword set is handled; anything else its strict decoder would have
@@ -135,16 +124,21 @@ function objectType(s: Schema, used: Set<string>): string {
135
124
  ? ` /** ${sub.description} */\n`
136
125
  : "";
137
126
  lines.push(
138
- `${doc} ${propKey(key)}${required.has(key) ? "" : "?"}: ${tsType(sub, used)};`,
127
+ `${doc} ${propKey(key)}${required.has(key) ? "" : "?"}: ${nest(tsType(sub, used))};`,
139
128
  );
140
129
  }
141
130
  if (s.additionalProperties && typeof s.additionalProperties === "object") {
142
- lines.push(` [key: string]: ${tsType(s.additionalProperties, used)};`);
131
+ lines.push(
132
+ ` [key: string]: ${nest(tsType(s.additionalProperties, used))};`,
133
+ );
143
134
  }
144
135
  if (lines.length === 0) return "Record<string, unknown>";
145
136
  return `{\n${lines.join("\n")}\n}`;
146
137
  }
147
138
 
139
+ /** Every line but the first, which is already placed by the property that opens it. */
140
+ const nest = (t: string) => t.replace(/\n/g, "\n ");
141
+
148
142
  function union(parts: string[]): string {
149
143
  const seen = [...new Set(parts)];
150
144
  return seen.length === 0 ? "unknown" : seen.join(" | ");
@@ -155,17 +149,6 @@ const propKey = (k: string) => (IDENT.test(k) ? k : JSON.stringify(k));
155
149
  const identifier = (n: string) =>
156
150
  IDENT.test(n) ? n : `Def_${n.replace(/[^A-Za-z0-9_$]/g, "_")}`;
157
151
 
158
- function deref(
159
- s: Schema | undefined,
160
- defs: Record<string, Schema>,
161
- ): Schema | undefined {
162
- let cur = s;
163
- for (let i = 0; cur && typeof cur.$ref === "string" && i < 16; i++) {
164
- cur = defs[cur.$ref.replace(/^#\/\$defs\//, "")];
165
- }
166
- return cur;
167
- }
168
-
169
152
  /** Emits one named type per reachable $def rather than inlining: a task output may
170
153
  * reference itself (specs/recursive-type-inference.md) and inlining would not terminate. */
171
154
  function declarations(at: Located): string {
@@ -186,7 +169,7 @@ function declarations(at: Located): string {
186
169
  }
187
170
 
188
171
  return [
189
- "// Generated by genroc. Do not edit - regenerate with `genctl types`.",
172
+ "// Generated by genroc. Do not edit - regenerate with `genctl generate`.",
190
173
  `// ${where.name} (${address(site.pointer)})`,
191
174
  "",
192
175
  ...emitted,
@@ -206,9 +189,8 @@ function typesPathFor(scriptPath: string): string {
206
189
 
207
190
  // ── typecheck ──────────────────────────────────────────────────────────────────
208
191
 
209
- /** The nearest tsconfig above the script — the one the author's editor already reads. Two
210
- * different configs mean a red editor over a clean apply, or the reverse. The walk stops at
211
- * the project root: above it is not this project. */
192
+ /** The tsconfig the author's editor reads, so editor and apply agree. Stops at the project
193
+ * root. */
212
194
  async function nearestTsconfig(
213
195
  from: string,
214
196
  root: string,
@@ -221,12 +203,7 @@ async function nearestTsconfig(
221
203
  }
222
204
 
223
205
  async function typecheck(sites: Located[]): Promise<void> {
224
- // NOT `.genroc`: that is the project config FILE, and a directory of the same name cannot
225
- // coexist with it. The suffix is what keeps the scratch area out of its way.
226
- const dir = join(root, ".genroc-cache");
227
- await write(join(dir, ".gitignore"), "*\n");
228
-
229
- // One tsc per distinct base config: `extends` takes a single base, so merging two would
206
+ // One program per distinct base config: `extends` takes a single base, so merging two would
230
207
  // check each script under the other author's options.
231
208
  const groups = new Map<string, Located[]>();
232
209
  for (const at of sites) {
@@ -236,10 +213,10 @@ async function typecheck(sites: Located[]): Promise<void> {
236
213
  else groups.set(base, [at]);
237
214
  }
238
215
 
239
- let n = 0;
216
+ const diagnostics: ts.Diagnostic[] = [];
240
217
  for (const [base, group] of groups) {
241
218
  const config: Record<string, unknown> = {
242
- ...(base ? { extends: relative(dir, base) } : {}),
219
+ ...(base ? { extends: base } : {}),
243
220
  compilerOptions: {
244
221
  noEmit: true,
245
222
  strict: true,
@@ -250,46 +227,45 @@ async function typecheck(sites: Located[]): Promise<void> {
250
227
  // `lib` DESCRIBES the realm and is written after `extends` so a base cannot widen it:
251
228
  // a worker thread has no document, whatever an author's config claims.
252
229
  lib: ["esnext", "webworker"],
253
- // `types` is the author's, and it is how a script opts into the node globals —
254
- // the worker realm has them, so refusing the declarations would only lie. With no
255
- // base config there is nothing to opt in with, so the default stays none.
230
+ // `types` is the author's opt-in to node globals, which the realm has. With no base
231
+ // config there is nothing to opt in with, so it stays none.
256
232
  ...(base ? {} : { types: [] }),
257
233
  },
258
- files: group.flatMap((s) => [
259
- relative(dir, s.file),
260
- relative(dir, typesPathFor(s.file)),
261
- ]),
234
+ files: group.flatMap((s) => [s.file, typesPathFor(s.file)]),
262
235
  // `files` overrides the base's, but a base `include` survives beside it and would
263
236
  // drag the author's whole tree in, to be checked under the worker lib.
264
237
  include: [],
265
238
  };
266
- const configPath = join(
267
- dir,
268
- groups.size === 1 ? "tsconfig.json" : `tsconfig.${n++}.json`,
239
+ // Never written; anchors `@types` lookup at the root. Not `tsconfig.json`: that is often the
240
+ // base it extends, and tsc refuses a circular extends.
241
+ const parsed = ts.parseJsonConfigFileContent(
242
+ config,
243
+ ts.sys,
244
+ root,
245
+ undefined,
246
+ join(root, "tsconfig.genroc.json"),
269
247
  );
270
- await write(configPath, JSON.stringify(config, null, 2));
271
- await runTsc(root, configPath);
248
+ const program = ts.createProgram({
249
+ rootNames: parsed.fileNames,
250
+ options: parsed.options,
251
+ projectReferences: parsed.projectReferences,
252
+ configFileParsingDiagnostics: parsed.errors,
253
+ });
254
+ diagnostics.push(...parsed.errors, ...ts.getPreEmitDiagnostics(program));
272
255
  }
273
- }
274
256
 
275
- async function runTsc(root: string, configPath: string): Promise<void> {
276
- const tsc = fileURLToPath(import.meta.resolve("typescript/bin/tsc"));
277
- const proc = spawn(process.execPath, [tsc, "--noEmit", "-p", configPath], {
278
- cwd: root,
279
- stdio: ["ignore", "pipe", "pipe"],
280
- });
281
- let out = "";
282
- let err = "";
283
- proc.stdout.on("data", (c: Buffer) => (out += c));
284
- proc.stderr.on("data", (c: Buffer) => (err += c));
285
- const code = await new Promise<number>((resolve, reject) => {
286
- proc.on("error", reject);
287
- proc.on("close", (c) => resolve(c ?? 1));
288
- });
289
- if (code !== 0) {
290
- // tsc reports on stdout; the exit code IS the type check, so this is the diagnostic
291
- // genctl surfaces and the reason a failed import never produces a string.
292
- die([out, err].filter(Boolean).join("\n").trimEnd());
257
+ if (diagnostics.length > 0) {
258
+ // The diagnostics are the whole type check: this is what genctl surfaces, and the reason a
259
+ // failed import never produces a string.
260
+ die(
261
+ ts
262
+ .formatDiagnostics(diagnostics, {
263
+ getCanonicalFileName: (f) => f,
264
+ getCurrentDirectory: () => root,
265
+ getNewLine: () => "\n",
266
+ })
267
+ .trimEnd(),
268
+ );
293
269
  }
294
270
  }
295
271
 
@@ -319,11 +295,9 @@ const BUILTIN = new Set([
319
295
  ...builtinModules.map((m) => `node:${m}`),
320
296
  ]);
321
297
 
322
- /** Resolves imports through TYPESCRIPT, using the same config the typecheck ran under, so a
323
- * `paths` alias that compiles also bundles. Reimplementing `paths` here would be a second
324
- * resolver to keep in agreement with tsc; this one cannot disagree.
325
- * A package resolving to a `.d.ts` is declined — that is a type, not the implementation —
326
- * which leaves node_modules to nodeResolve. */
298
+ /** Resolves through TYPESCRIPT under the typecheck's config, so a `paths` alias that compiles
299
+ * also bundles. A `.d.ts` resolution is declined (a type, not code), leaving node_modules to
300
+ * nodeResolve. */
327
301
  function tsResolve(configPath: string | null): Plugin {
328
302
  let options: ts.CompilerOptions = {};
329
303
  if (configPath) {
@@ -353,14 +327,12 @@ function tsResolve(configPath: string | null): Plugin {
353
327
  };
354
328
  }
355
329
 
356
- /** Bundles to a self-contained ES module, which is what the evaluator imports: the default
357
- * export it calls is the author's own, so nothing wraps or rewrites the code between the two.
358
- * Bundling is entirely the importer's job, so a definition version pins its code forever. */
330
+ /** A self-contained ES module whose default export is the author's own, unwrapped. Bundling here
331
+ * means a definition version pins its code forever. */
359
332
  async function bundle(at: Located): Promise<string> {
360
333
  const site = at.site;
361
- // Builtins are EXTERNALISED as imports the realm resolves natively. Anything else
362
- // unresolved is a REFUSAL, not an external: rollup's default is to leave it as an import
363
- // of a module that will not be there, which bundles clean and fails at runtime.
334
+ // Builtins are externalised; anything else unresolved is a REFUSAL, since rollup's default
335
+ // leaves an import that bundles clean and fails at runtime.
364
336
  const built = await rollup({
365
337
  input: at.file,
366
338
  external: (id) => BUILTIN.has(id),
@@ -368,8 +340,7 @@ async function bundle(at: Located): Promise<string> {
368
340
  tsResolve(await nearestTsconfig(dirname(at.file), root)),
369
341
  nodeResolve({ extensions: [".ts", ".tsx", ".mjs", ".js", ".json"] }),
370
342
  commonjs(),
371
- // A `.json` import is a data file inlined at build time, which the previous bundler
372
- // did natively; without it rollup hands the JSON to the JS parser.
343
+ // Inlines `.json` imports; without it rollup hands JSON to the JS parser.
373
344
  json(),
374
345
  transpile,
375
346
  ],
@@ -412,10 +383,8 @@ const root = process.cwd();
412
383
  if (!manifest || !Array.isArray(manifest.processes))
413
384
  die("stdin is not a genroc resolver manifest");
414
385
 
415
- /** Every site with the process it sits in and the file its argument names, in the manifest's own
416
- * order — which is the order `code` must answer in. genctl passes the argument verbatim, so
417
- * joining it to the definition's directory is this resolver's business: only it knows the
418
- * argument is a path at all. */
386
+ /** Sites in manifest order, the order `code` must answer in. The argument is joined to the
387
+ * definition's directory here: only this resolver knows it is a path. */
419
388
  type Located = { site: Site; where: ManifestProcess; file: string };
420
389
 
421
390
  /** A pointer as the address it is, so a generated comment or an error can be pasted into
@@ -431,23 +400,23 @@ function address(pointer: (string | number)[]): string {
431
400
  .replace(/^\./, "");
432
401
  }
433
402
  const located: Located[] = manifest.processes.flatMap((where) =>
434
- where.sites.map((site) => ({
435
- site,
436
- where,
437
- file: resolve(where.dir, site.argument),
438
- })),
403
+ where.sites.map((site) => {
404
+ const [script, ...rest] = site.args;
405
+ if (script === undefined || rest.length > 0) {
406
+ die(
407
+ `${join(where.dir, where.file)}: ${address(site.pointer)}: $import takes one script, ` +
408
+ `and is given ${site.args.length} arguments`,
409
+ );
410
+ }
411
+ return { site, where, file: resolve(where.dir, script) };
412
+ }),
439
413
  );
440
414
 
441
- // genctl is agnostic about what a script is for; the contract that an evaluation request carries
442
- // its module in `code` is THIS resolver's, so it is the one that checks a directive landed there.
443
- // `child` is the shape the scaffold uses — a call to a process that forwards to the evaluator —
444
- // and `external` is the same request made directly.
415
+ // The `code`-field contract is this resolver's, not genctl's, so it checks the directive landed
416
+ // there: in a child call (the scaffold's shape) or an external task's input.
445
417
  for (const at of located) {
446
- // Two halves, from the two places that carry them: the action's KIND is a field, and WHERE in
447
- // it the directive sits is the pointer. A child call is the shape the scaffold generates — to a
448
- // process that forwards to the evaluator — and an external task is the same request made
449
- // directly.
450
- const kind = at.site.action ?? "";
418
+ // The action's kind is a field; where in it the directive sits is the pointer.
419
+ const kind = at.site.level === "action" ? (at.site.action ?? "") : "";
451
420
  const slot = at.site.pointer.slice(-2).join(".");
452
421
  if ((kind !== "child" && kind !== "external") || slot !== "input.code") {
453
422
  die(
@@ -458,13 +427,12 @@ for (const at of located) {
458
427
  }
459
428
  }
460
429
 
461
- // genctl no longer stats the argument — it does not know it is a file — so the resolver that
462
- // does is the one that must say when it is not there.
430
+ // genctl does not know the argument is a file, so this resolver reports a missing one.
463
431
  for (const at of located) {
464
432
  if (!existsSync(at.file)) {
465
433
  die(
466
434
  `${join(at.where.dir, at.where.file)}: ${address(at.site.pointer)}: ` +
467
- `"$import: ${at.site.argument}" names no file (looked at ${at.file})`,
435
+ `"$import: ${at.site.args[0]}" names no file (looked at ${at.file})`,
468
436
  );
469
437
  }
470
438
  }
@@ -488,18 +456,17 @@ for (const at of located) {
488
456
  }
489
457
 
490
458
  for (const at of byPath.values()) {
491
- await write(typesPathFor(at.file), declarations(at));
459
+ await writeFile(typesPathFor(at.file), declarations(at));
492
460
  }
493
461
 
494
- if (manifest.mode === "types") {
495
- console.log(JSON.stringify(manifest, null, 2));
462
+ if (manifest.mode === "generate") {
496
463
  process.exit(0);
497
464
  }
498
465
 
499
466
  await typecheck([...byPath.values()]);
500
467
 
501
- const code: string[] = [];
468
+ const values: string[] = [];
502
469
  for (const at of located) {
503
- code.push(await bundle(at));
470
+ values.push(await bundle(at));
504
471
  }
505
- process.stdout.write(JSON.stringify({ code }));
472
+ process.stdout.write(JSON.stringify({ values }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genroc/eval-node",
3
- "version": "0.0.0-edge.24a07d0",
3
+ "version": "0.0.0-edge.2646018",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
package/realm.ts CHANGED
@@ -1,18 +1,13 @@
1
- // The evaluation realm. One Worker per execution: a fresh global object per script, and a
2
- // thread the host can kill mid-loop — the only thing that bounds a synchronous busy loop.
3
- // eval.ts owns the budget and does the killing; nothing here knows about time.
4
- //
5
- // Everything that touches the script's VALUE lives on this side of the boundary — loading,
6
- // classifying, serialising — because this is the only realm the value exists in.
1
+ // The evaluation realm: one Worker per execution, killable mid-loop by eval.ts, which owns the
2
+ // budget. Everything touching the script's VALUE lives here, the only realm it exists in.
7
3
 
8
4
  import { registerHooks } from "node:module";
9
5
  import { parentPort } from "node:worker_threads";
10
6
 
11
7
  import type { EvalFailure, FailureKind, WorkerReply, WorkerRequest } from "./eval.ts";
12
8
 
13
- // The script is IMPORTED as a module under a URL of our own, not compiled from a string: its
14
- // frames then carry the author's own line numbers, and an `import` of a node builtin resolves
15
- // the way it does everywhere else.
9
+ // IMPORTED under our own URL rather than compiled from a string, so frames carry the author's
10
+ // line numbers and builtin imports resolve normally.
16
11
  const SCRIPT_URL = "script:main";
17
12
  const STACK_BYTES = 2_048;
18
13
 
@@ -40,7 +35,10 @@ function scriptStack(err: unknown): string | undefined {
40
35
 
41
36
  function describe(err: unknown, kind: FailureKind): EvalFailure {
42
37
  if (err instanceof Error) {
43
- return { kind, name: err.name, message: err.message, stack: scriptStack(err) };
38
+ // A subclass inherits `name` from Error.prototype, so `class LimitExceeded extends Error {}`
39
+ // would report "Error"; a name the script set still wins.
40
+ const name = err.name === "Error" && err.constructor.name ? err.constructor.name : err.name;
41
+ return { kind, name, message: err.message, stack: scriptStack(err) };
44
42
  }
45
43
  // A script may throw a non-Error (`throw {code: "x"}`), so name/message must not assume one.
46
44
  return { kind, name: "Thrown", message: safeText(err) };
@@ -54,9 +52,8 @@ function safeText(v: unknown): string {
54
52
  }
55
53
  }
56
54
 
57
- /** A module that will not parse, or names an import nothing resolves, is broken code — only
58
- * editing it helps. Anything else thrown by the import is the module's top level running,
59
- * which is the script throwing. */
55
+ /** Unparseable code or an unresolvable import is broken code; anything else the import throws is
56
+ * the module's top level running, i.e. the script throwing. */
60
57
  function unloadable(err: unknown): boolean {
61
58
  const code = (err as { code?: unknown } | null)?.code;
62
59
  return err instanceof SyntaxError || (typeof code === "string" && code.startsWith("ERR_MODULE"));
@@ -99,10 +96,8 @@ async function run(req: WorkerRequest): Promise<WorkerReply> {
99
96
  }
100
97
  }
101
98
 
102
- // The realm's stdio is a pipe to the host thread, and eval.ts terminates this thread the moment
103
- // the reply lands — so whatever a script wrote last is still in the pipe when it dies. An empty
104
- // write's callback fires once the queue ahead of it has drained, which makes the reply a barrier
105
- // for `console` and a direct process.stdout.write alike: both are this stream.
99
+ // eval.ts terminates this thread the moment the reply lands, while stdio may still be in the
100
+ // pipe. An empty write's callback fires once the queue drains, making the reply a barrier.
106
101
  function flush(stream: NodeJS.WriteStream): Promise<void> {
107
102
  return new Promise((resolve) => stream.write("", () => resolve()));
108
103
  }