@genroc/eval-node 0.0.0-edge.0477fb0 → 0.0.0-edge.04ed705c

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/realm.ts CHANGED
@@ -1,89 +1,41 @@
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 — compiling,
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
- import { createRequire } from "node:module";
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
- const AsyncFunction = async function () {}.constructor as new (
14
- ...args: string[]
15
- ) => (...args: unknown[]) => Promise<unknown>;
16
-
17
- const STRICT = '"use strict";\n';
18
-
19
- // Bundled `node:*` imports survive as `require` calls — the importer externalises builtins and
20
- // inlines everything else — and a function built by the AsyncFunction constructor has no
21
- // `require` in scope. Passing one in is what makes an import of a builtin work at runtime rather than at
22
- // typecheck only. Resolution is anchored here, which is right: only builtins reach it.
23
- const scriptRequire = createRequire(import.meta.url);
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.
11
+ const SCRIPT_URL = "script:main";
24
12
  const STACK_BYTES = 2_048;
25
13
 
26
- /**
27
- * Line offset the AsyncFunction preamble adds, measured rather than assumed: the generated
28
- * wrapper's shape is engine-specific, and a hardcoded number silently misreports every
29
- * script's error location the day it changes.
30
- */
31
- const lineOffset: Promise<number> = (async () => {
32
- // Same parameter list as a real compile: the preamble is what is being measured.
33
- const probe = new AsyncFunction("input", "require", STRICT + "throw new Error('probe');");
34
- try {
35
- await probe();
36
- return 0;
37
- } catch (err) {
38
- return reportedLine(err) - 1;
39
- }
40
- })();
41
-
42
- // V8 marks a frame compiled by the AsyncFunction constructor with the site that CALLED the
43
- // constructor, then the script's OWN position:
44
- // at inner (eval at run (file:///…/worker.ts:107:10), <anonymous>:6:9)
45
- // `eval at` is therefore what separates script frames from runner plumbing — matched without
46
- // the function name, which is whatever encloses the `new AsyncFunction` below. The LAST such
47
- // frame is the body's top level: frames interleave, since a script can throw inside a native
48
- // callback.
49
- const SCRIPT_FRAME = /\(eval at /;
50
- // The trailing `<anonymous>:LINE:COL` — the script's position, after the host file's own.
51
- const POSITION = /<anonymous>:(\d+):(\d+)\)?\s*$/;
52
- // ` at name (` — absent on the top-level frame, which V8 names `eval`.
53
- const FRAME_NAME = /^\s*at\s+(?:async\s+)?([^\s(]+)\s*\(/;
14
+ // The only channel a load hook has to the source. Written per request and read once — a realm
15
+ // evaluates one script and is then discarded, so no second execution can observe it.
16
+ let source = "";
54
17
 
55
- /** Line number of the throw as the engine reported it, or 1 if the stack is unreadable. */
56
- function reportedLine(err: unknown): number {
57
- const stack = err instanceof Error && typeof err.stack === "string" ? err.stack : "";
58
- const frame = stack.split("\n").find((l) => SCRIPT_FRAME.test(l)) ?? "";
59
- const m = frame.match(POSITION);
60
- return m ? Number(m[1]) : 1;
61
- }
18
+ registerHooks({
19
+ resolve: (specifier, context, next) =>
20
+ specifier === SCRIPT_URL ? { url: SCRIPT_URL, shortCircuit: true } : next(specifier, context),
21
+ load: (url, context, next) =>
22
+ url === SCRIPT_URL ? { format: "module", source, shortCircuit: true } : next(url, context),
23
+ });
62
24
 
63
- /** Renumbers each script frame to the line the AUTHOR wrote and drops the runner's own.
64
- * Rewriting the whole location is also what keeps the runner's path out of a script's
65
- * stack — V8 puts it inside every compiled frame. */
66
- function scriptStack(err: unknown, offset: number): string | undefined {
25
+ /** Keeps the frames that are the script's own. Everything below the last of them is runner
26
+ * plumbing the author cannot act on, and V8 puts this file's path in it. */
27
+ function scriptStack(err: unknown): string | undefined {
67
28
  if (!(err instanceof Error) || typeof err.stack !== "string") return undefined;
68
29
  const lines = err.stack.split("\n");
69
- let boundary = -1;
70
- for (let i = 0; i < lines.length; i++) if (SCRIPT_FRAME.test(lines[i]!)) boundary = i;
71
- const frames = (boundary >= 0 ? lines.slice(0, boundary + 1) : lines.slice(0, 1))
72
- .map((line) => {
73
- const pos = line.match(POSITION);
74
- if (!pos) return line; // the `Error: message` header and native frames, kept as-is
75
- const name = line.match(FRAME_NAME)?.[1];
76
- const at = name && name !== "eval" && name !== "anonymous" ? `at ${name} ` : "at ";
77
- const indent = line.match(/^\s*/)![0];
78
- return `${indent}${at}(script:${Math.max(1, Number(pos[1]) - offset)}:${pos[2]})`;
79
- })
80
- .join("\n");
81
- return frames.length > STACK_BYTES ? frames.slice(0, STACK_BYTES) : frames;
30
+ let last = 0;
31
+ for (let i = 0; i < lines.length; i++) if (lines[i]!.includes(SCRIPT_URL)) last = i;
32
+ const stack = lines.slice(0, last + 1).join("\n");
33
+ return stack.length > STACK_BYTES ? stack.slice(0, STACK_BYTES) : stack;
82
34
  }
83
35
 
84
- function describe(err: unknown, kind: FailureKind, offset: number): EvalFailure {
36
+ function describe(err: unknown, kind: FailureKind): EvalFailure {
85
37
  if (err instanceof Error) {
86
- return { kind, name: err.name, message: err.message, stack: scriptStack(err, offset) };
38
+ return { kind, name: err.name, message: err.message, stack: scriptStack(err) };
87
39
  }
88
40
  // A script may throw a non-Error (`throw {code: "x"}`), so name/message must not assume one.
89
41
  return { kind, name: "Thrown", message: safeText(err) };
@@ -97,23 +49,39 @@ function safeText(v: unknown): string {
97
49
  }
98
50
  }
99
51
 
52
+ /** Unparseable code or an unresolvable import is broken code; anything else the import throws is
53
+ * the module's top level running, i.e. the script throwing. */
54
+ function unloadable(err: unknown): boolean {
55
+ const code = (err as { code?: unknown } | null)?.code;
56
+ return err instanceof SyntaxError || (typeof code === "string" && code.startsWith("ERR_MODULE"));
57
+ }
58
+
100
59
  async function run(req: WorkerRequest): Promise<WorkerReply> {
101
- const offset = await lineOffset;
60
+ source = req.code;
102
61
 
103
- let fn: (...args: unknown[]) => Promise<unknown>;
62
+ let main: unknown;
104
63
  try {
105
- // No compile cache: the realm is discarded after this execution, so a cache in it could
106
- // never be hit. Repeated compilation is the price of the fresh global object.
107
- fn = new AsyncFunction("input", "require", STRICT + req.code);
64
+ main = (await import(SCRIPT_URL)).default;
108
65
  } catch (err) {
109
- return { ok: false, failure: describe(err, "compile_error", offset) };
66
+ return { ok: false, failure: describe(err, unloadable(err) ? "compile_error" : "threw") };
67
+ }
68
+ if (typeof main !== "function") {
69
+ const got = main === undefined ? "no default export" : `a ${typeof main}`;
70
+ return {
71
+ ok: false,
72
+ failure: {
73
+ kind: "compile_error",
74
+ name: "NoDefaultExport",
75
+ message: `a script must export default a function; this one has ${got}`,
76
+ },
77
+ };
110
78
  }
111
79
 
112
80
  let value: unknown;
113
81
  try {
114
- value = await fn(req.input, scriptRequire);
82
+ value = await (main as (input: unknown) => unknown)(req.input);
115
83
  } catch (err) {
116
- return { ok: false, failure: describe(err, "threw", offset) };
84
+ return { ok: false, failure: describe(err, "threw") };
117
85
  }
118
86
 
119
87
  try {
@@ -121,12 +89,20 @@ async function run(req: WorkerRequest): Promise<WorkerReply> {
121
89
  // spells null, which is the right reading of a script that returned nothing.
122
90
  return { ok: true, body: value === undefined ? "" : JSON.stringify(value) ?? "" };
123
91
  } catch (err) {
124
- return { ok: false, failure: describe(err, "nonserializable", offset) };
92
+ return { ok: false, failure: describe(err, "nonserializable") };
125
93
  }
126
94
  }
127
95
 
96
+ // eval.ts terminates this thread the moment the reply lands, while stdio may still be in the
97
+ // pipe. An empty write's callback fires once the queue drains, making the reply a barrier.
98
+ function flush(stream: NodeJS.WriteStream): Promise<void> {
99
+ return new Promise((resolve) => stream.write("", () => resolve()));
100
+ }
101
+
128
102
  // Non-null because this module only ever runs as a worker entry point; a null port here
129
103
  // would mean eval.ts loaded it as a plain module, which nothing does.
130
104
  parentPort!.on("message", async (req: WorkerRequest) => {
131
- parentPort!.postMessage(await run(req));
105
+ const reply = await run(req);
106
+ await Promise.all([flush(process.stdout), flush(process.stderr)]);
107
+ parentPort!.postMessage(reply);
132
108
  });
package/worker.ts CHANGED
@@ -1,27 +1,21 @@
1
- // The queue worker: claims parked `external` script tasks from genroc, evaluates each in its
2
- // own realm, and answers. This is the whole genroc-facing half — eval.ts and realm.ts know
3
- // nothing about the queue, which is what keeps the containment strategy swappable.
4
- //
5
- // See README.md for the contract, and specs/external-task-queue.md for the queue itself.
1
+ #!/usr/bin/env node
2
+ // The queue worker: claims parked `external` script tasks, evaluates each in its own realm, and
3
+ // answers. The only genroc-facing half (README.md; specs/external-task-queue.md).
6
4
 
7
- import { evaluate, type EvalRequest, type FailureKind } from "./eval.ts";
5
+ import { readFileSync } from "node:fs";
6
+ import { Cancelled, evaluate, type EvalRequest, type FailureKind } from "./eval.ts";
8
7
 
9
8
  const SERVER = (process.env.GENROC_SERVER ?? "http://localhost:8448").replace(/\/$/, "");
10
9
  const WORKER_ID = process.env.WORKER_ID ?? `evaluator-${process.pid}`;
11
- // The credential, when the server runs with --auth token. A worker needs exactly the `worker`
12
- // permission — the four queue verbs plus GET /api/objects — so mint it scoped rather than
13
- // handing a worker an admin token: this is the credential most likely to sit on a machine you
14
- // trust least. specs/api-auth.md §5.
15
- //
16
- // Sent as a header rather than in the URL because Node's fetch REFUSES a URL carrying
17
- // credentials ("Request cannot be constructed from a URL that includes credentials"), so the
18
- // basic-auth-in-the-URL trick that works for genctl is not available here.
19
- const TOKEN = process.env.GENROC_TOKEN ?? "";
10
+ // Needs only the `worker` permission (specs/api-auth.md §5): this credential sits on the machine
11
+ // you trust least. A header, because Node's fetch refuses credentials in a URL. The file form
12
+ // keeps it out of `docker inspect` and child environments; the inline variable wins.
13
+ const TOKEN =
14
+ process.env.GENROC_TOKEN ??
15
+ (process.env.GENROC_TOKEN_FILE ? readFileSync(process.env.GENROC_TOKEN_FILE, "utf8").trim() : "");
20
16
  const authHeaders: Record<string, string> = TOKEN ? { authorization: `Bearer ${TOKEN}` } : {};
21
- // Concurrency is the worker's to set, and that is the point of pulling: under the old fetch
22
- // shape genroc decided how many scripts ran at once (--max-concurrent, default 200) and the
23
- // evaluator accepted every one of them. Here it claims what it can run and no more, so a
24
- // backlog is a queue rather than 200 threads fighting over a core.
17
+ // The worker claims only what it can run, so a backlog is a queue rather than threads fighting
18
+ // over a core.
25
19
  const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
26
20
  const POLL_MS = Number(process.env.POLL_MS ?? 250);
27
21
  // The visibility timeout. Short, and renewed while work is in flight: a worker that dies
@@ -36,23 +30,21 @@ type ObjectEntry = { path: (string | number)[]; ref: string; size: number };
36
30
  type QueueTask = {
37
31
  token: string;
38
32
  process: string;
39
- task_id: string;
40
- input: unknown;
33
+ task: string;
34
+ external_input: unknown;
41
35
  objects?: ObjectEntry[];
42
36
  raises?: Record<string, unknown>;
43
37
  };
44
38
 
45
- // Values too large to ship inline are listed rather than carried, and a bundle is exactly that:
46
- // one object shared by every instance of a definition version, fetched once instead of copied
47
- // into each task. A ref is a content hash, so it is immutable — the cache never invalidates.
39
+ // Large values arrive as refs; a bundle is one object per definition version, fetched once.
40
+ // Refs are content hashes, so the cache never invalidates.
48
41
  const objectCache = new Map<string, unknown>();
49
42
 
50
43
  async function fetchObject(ref: string): Promise<unknown> {
51
44
  const cached = objectCache.get(ref);
52
45
  if (cached !== undefined) return cached;
53
- // Left to throw on purpose: this runs while a task is IN FLIGHT, and a task whose input
54
- // cannot be fetched must fail rather than silently run against a missing value. The caller
55
- // releases the claim, so the task returns to the queue.
46
+ // Left to throw: a task whose input cannot be fetched must not run against a missing value.
47
+ // The caller releases the claim.
56
48
  const res = await fetch(`${SERVER}/api/objects/${encodeURIComponent(ref)}`, { headers: authHeaders });
57
49
  if (!res.ok) throw new Error(`fetch object ${ref}: HTTP ${res.status}`);
58
50
  const { data } = (await res.json()) as { data: string };
@@ -69,15 +61,15 @@ async function fetchObject(ref: string): Promise<unknown> {
69
61
  /** Put each listed value back where its path says it belongs. Paths are arrays of keys, so this
70
62
  * needs no parser: the whole reason they are not JSON Pointer strings. */
71
63
  async function resolveObjects(job: QueueTask): Promise<unknown> {
72
- let input: any = job.input;
64
+ let input: any = job.external_input;
73
65
  for (const e of job.objects ?? []) {
74
66
  const value = await fetchObject(e.ref);
75
67
  if (e.path.length === 0) {
76
68
  input = value;
77
69
  continue;
78
70
  }
79
- // The path is rooted at the entry and starts with "input", which is the value being rebuilt.
80
- const rest = e.path[0] === "input" ? e.path.slice(1) : e.path;
71
+ // The path is rooted at the entry and starts with "external_input", the value being rebuilt.
72
+ const rest = e.path[0] === "external_input" ? e.path.slice(1) : e.path;
81
73
  if (rest.length === 0) {
82
74
  input = value;
83
75
  continue;
@@ -90,10 +82,8 @@ async function resolveObjects(job: QueueTask): Promise<unknown> {
90
82
  }
91
83
 
92
84
  async function call(path: string, body: unknown): Promise<{ ok: boolean; status: number; data: any }> {
93
- // A network error is a REPLY, not a throw. A worker outlives the server it polls — a
94
- // restart, a rolling deploy, a container coming up before genroc is listening — and an
95
- // unhandled rejection here kills it for a condition the next poll would clear. Status 0
96
- // says "never reached the server", which is distinct from anything genroc answers.
85
+ // A network error is a REPLY (status 0, "never reached the server"), not a throw: a worker
86
+ // outlives restarts and deploys, and an unhandled rejection would kill it.
97
87
  let res: Response;
98
88
  try {
99
89
  res = await fetch(SERVER + path, {
@@ -114,9 +104,8 @@ async function call(path: string, body: unknown): Promise<{ ok: boolean; status:
114
104
  return { ok: res.ok, status: res.status, data };
115
105
  }
116
106
 
117
- /** The task input IS an EvalRequest: `code` required, `input` and `timeout_ms` optional. A task
118
- * whose input is not that shape is the definition's fault, not the script's, and is reported
119
- * as a compile_error — the nearest permanent kind, since no retry can fix the definition. */
107
+ /** A task input that is not an EvalRequest is the definition's fault, reported as compile_error:
108
+ * the nearest permanent kind, since no retry fixes the definition. */
120
109
  function asEvalRequest(input: unknown): EvalRequest | string {
121
110
  if (typeof input !== "object" || input === null) return "the task input is not an object";
122
111
  const r = input as Record<string, unknown>;
@@ -128,13 +117,15 @@ function asEvalRequest(input: unknown): EvalRequest | string {
128
117
  };
129
118
  }
130
119
 
131
- const inFlight = new Map<string, QueueTask>();
120
+ /** genroc cannot call us, so a cancellation arrives on our own heartbeat and reaches the
121
+ * evaluation through the controller. */
122
+ type Running = { job: QueueTask; abort: AbortController };
123
+
124
+ const inFlight = new Map<string, Running>();
132
125
  let running = true;
133
126
 
134
- // Whether the last claim reached genroc. A worker polls several times a second, so an
135
- // unreachable server would otherwise emit a line per poll — thousands during a restart, which
136
- // buries the one line that mattered. Announce the TRANSITIONS instead: going away, and coming
137
- // back. Silence in between is the report that nothing changed.
127
+ // Announce reachability TRANSITIONS only: polling several times a second would otherwise emit
128
+ // thousands of lines during a restart.
138
129
  let serverReachable = true;
139
130
 
140
131
  async function claim(n: number): Promise<QueueTask[]> {
@@ -146,9 +137,8 @@ async function claim(n: number): Promise<QueueTask[]> {
146
137
  ...(TASK_FILTER ? { task: TASK_FILTER } : {}),
147
138
  });
148
139
  if (!ok) {
149
- // A credential problem is not transient, and polling through it looks like a healthy
150
- // worker that never picks anything up — the worst shape for an operator to debug. Exit
151
- // instead, so a supervisor restarts it and the failure is visible where it happened.
140
+ // A credential problem is not transient, and polling through it looks like a healthy idle
141
+ // worker. Exit, so a supervisor restarts it visibly.
152
142
  if (status === 401 || status === 403) {
153
143
  console.error(
154
144
  `claim rejected (${status}): ${JSON.stringify(data)}\n` +
@@ -157,9 +147,7 @@ async function claim(n: number): Promise<QueueTask[]> {
157
147
  );
158
148
  process.exit(1);
159
149
  }
160
- // status 0 is "never reached the server" (see call): a restart, a rolling deploy, a
161
- // network blip. Not an error to act on — the next poll clears it — so it is reported once
162
- // and then waited out.
150
+ // status 0 (see call): a restart or blip the next poll clears, so it is reported once.
163
151
  if (status === 0) {
164
152
  if (serverReachable) {
165
153
  serverReachable = false;
@@ -185,10 +173,8 @@ async function release(token: string): Promise<void> {
185
173
  if (!ok) console.error(`release failed: ${JSON.stringify(data)}`);
186
174
  }
187
175
 
188
- /** answer submits the outcome. A refusal is NOT retried with a different one: the definition
189
- * declared a contract this worker does not satisfy (an undeclared code, a payload that does
190
- * not fit `raises`), and guessing again would only pick a second wrong answer. Release it, so
191
- * the task returns to the queue and an operator sees it waiting rather than silently gone. */
176
+ /** A refused outcome is not retried with another: the worker does not satisfy the declared
177
+ * contract. Released, so an operator sees the task waiting rather than gone. */
192
178
  async function answer(token: string, outcome: Record<string, unknown>): Promise<void> {
193
179
  const { ok, data } = await call("/api/external-tasks/resolve", { token, ...outcome });
194
180
  if (ok) return;
@@ -196,7 +182,7 @@ async function answer(token: string, outcome: Record<string, unknown>): Promise<
196
182
  await release(token);
197
183
  }
198
184
 
199
- async function run(job: QueueTask): Promise<void> {
185
+ async function run(job: QueueTask, signal: AbortSignal): Promise<void> {
200
186
  let resolved: unknown;
201
187
  try {
202
188
  resolved = await resolveObjects(job);
@@ -217,12 +203,17 @@ async function run(job: QueueTask): Promise<void> {
217
203
 
218
204
  let result;
219
205
  try {
220
- result = await evaluate(req);
206
+ result = await evaluate(req, signal);
221
207
  } catch (err) {
222
- // The RUNNER faulted, not the script — the one class where a retry can help. There is no
223
- // error code for it on purpose: releasing the claim is how a queue spells "retryable", and
224
- // it puts the task in front of a different worker instead of burning the definition's
225
- // on_error budget on this one's bad day.
208
+ // Cancelled is not a fault. Still released: the row is terminal so nothing re-claims it,
209
+ // but the release stops it waiting out a lease nobody serves.
210
+ if (err instanceof Cancelled) {
211
+ console.log(`cancelled: ${job.token}`);
212
+ await release(job.token);
213
+ return;
214
+ }
215
+ // The RUNNER faulted, the one retryable class. Releasing is how a queue spells "retryable",
216
+ // and it reaches another worker without spending the definition's on_error budget.
226
217
  console.error(`evaluator fault on ${job.token}: ${err instanceof Error ? err.message : String(err)}`);
227
218
  await release(job.token);
228
219
  return;
@@ -256,11 +247,18 @@ async function renewLoop(): Promise<void> {
256
247
  tokens,
257
248
  lease_ms: LEASE_MS,
258
249
  });
259
- // A short count means a claim lapsed and was taken over. Nothing to do about it — the work
260
- // continues and its answer will be refused — but say so, because it is the signal that
261
- // LEASE_MS is too short for what these scripts actually take.
262
- if (ok && data?.renewed < tokens.length) {
263
- console.error(`renewed ${data.renewed}/${tokens.length} claims; a lease lapsed under load`);
250
+ if (!ok) continue;
251
+
252
+ // Abort first rather than let a stopped script burn a core; the release rides run()'s
253
+ // catch, so nothing is released twice.
254
+ for (const token of (data?.cancelled ?? []) as string[]) {
255
+ inFlight.get(token)?.abort.abort();
256
+ }
257
+ // Lost: another worker holds it now and our answer will be refused; logged as the sign that
258
+ // LEASE_MS is too short. NOT released: that would bump the epoch out from under the holder.
259
+ const lost = (data?.lost ?? []) as string[];
260
+ if (lost.length) {
261
+ console.error(`lost ${lost.length}/${tokens.length} claims; a lease lapsed under load`);
264
262
  }
265
263
  }
266
264
  }
@@ -270,8 +268,9 @@ async function pollLoop(): Promise<void> {
270
268
  const free = CONCURRENCY - inFlight.size;
271
269
  const jobs = free > 0 ? await claim(free) : [];
272
270
  for (const job of jobs) {
273
- inFlight.set(job.token, job);
274
- void run(job).finally(() => inFlight.delete(job.token));
271
+ const abort = new AbortController();
272
+ inFlight.set(job.token, { job, abort });
273
+ void run(job, abort.signal).finally(() => inFlight.delete(job.token));
275
274
  }
276
275
  // Only idle when there was nothing to take: a full queue should be drained at the speed the
277
276
  // realms allow, not at the poll interval.
package/bin/import.mjs DELETED
@@ -1,4 +0,0 @@
1
- #!/usr/bin/env node
2
- // npm `bin` entries need a shebang and a stable extension; Node's type stripping handles the
3
- // .ts behind it. genroc.yaml points here: command: [npx, genroc-import]
4
- import "../import.ts";
package/bin/worker.mjs DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- import "../worker.ts";