@genroc/eval-node 0.0.0-edge.d1df786 → 0.0.0-edge.d46a8ebf
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/LICENSE +202 -0
- package/README.md +58 -34
- package/dist/eval.js +27 -14
- package/dist/import.js +133 -126
- package/dist/realm.js +63 -80
- package/dist/worker.js +52 -64
- package/eval.ts +31 -20
- package/import.ts +219 -148
- package/package.json +1 -1
- package/realm.ts +58 -82
- package/worker.ts +59 -67
package/realm.ts
CHANGED
|
@@ -1,89 +1,41 @@
|
|
|
1
|
-
// The evaluation realm
|
|
2
|
-
//
|
|
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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
/**
|
|
64
|
-
*
|
|
65
|
-
|
|
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
|
|
70
|
-
for (let i = 0; i < lines.length; i++) if (
|
|
71
|
-
const
|
|
72
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
60
|
+
source = req.code;
|
|
102
61
|
|
|
103
|
-
let
|
|
62
|
+
let main: unknown;
|
|
104
63
|
try {
|
|
105
|
-
|
|
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"
|
|
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
|
|
82
|
+
value = await (main as (input: unknown) => unknown)(req.input);
|
|
115
83
|
} catch (err) {
|
|
116
|
-
return { ok: false, failure: describe(err, "threw"
|
|
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"
|
|
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
|
-
|
|
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,34 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The queue worker: claims parked `external` script tasks
|
|
3
|
-
//
|
|
4
|
-
// nothing about the queue, which is what keeps the containment strategy swappable.
|
|
5
|
-
//
|
|
6
|
-
// See README.md for the contract, and specs/external-task-queue.md for the queue itself.
|
|
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).
|
|
7
4
|
|
|
8
5
|
import { readFileSync } from "node:fs";
|
|
9
|
-
import { evaluate, type EvalRequest, type FailureKind } from "./eval.ts";
|
|
6
|
+
import { Cancelled, evaluate, type EvalRequest, type FailureKind } from "./eval.ts";
|
|
10
7
|
|
|
11
8
|
const SERVER = (process.env.GENROC_SERVER ?? "http://localhost:8448").replace(/\/$/, "");
|
|
12
9
|
const WORKER_ID = process.env.WORKER_ID ?? `evaluator-${process.pid}`;
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// trust least. specs/api-auth.md §5.
|
|
17
|
-
//
|
|
18
|
-
// Sent as a header rather than in the URL because Node's fetch REFUSES a URL carrying
|
|
19
|
-
// credentials ("Request cannot be constructed from a URL that includes credentials"), so the
|
|
20
|
-
// basic-auth-in-the-URL trick that works for genctl is not available here.
|
|
21
|
-
// GENROC_TOKEN_FILE is the mounted-secret shape: a credential in a file rather than an
|
|
22
|
-
// environment variable, so it stays out of `docker inspect` and out of the process environment
|
|
23
|
-
// any child inherits. The inline variable wins when both are set.
|
|
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.
|
|
24
13
|
const TOKEN =
|
|
25
14
|
process.env.GENROC_TOKEN ??
|
|
26
15
|
(process.env.GENROC_TOKEN_FILE ? readFileSync(process.env.GENROC_TOKEN_FILE, "utf8").trim() : "");
|
|
27
16
|
const authHeaders: Record<string, string> = TOKEN ? { authorization: `Bearer ${TOKEN}` } : {};
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// evaluator accepted every one of them. Here it claims what it can run and no more, so a
|
|
31
|
-
// 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.
|
|
32
19
|
const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
|
|
33
20
|
const POLL_MS = Number(process.env.POLL_MS ?? 250);
|
|
34
21
|
// The visibility timeout. Short, and renewed while work is in flight: a worker that dies
|
|
@@ -43,23 +30,21 @@ type ObjectEntry = { path: (string | number)[]; ref: string; size: number };
|
|
|
43
30
|
type QueueTask = {
|
|
44
31
|
token: string;
|
|
45
32
|
process: string;
|
|
46
|
-
|
|
47
|
-
|
|
33
|
+
task: string;
|
|
34
|
+
external_input: unknown;
|
|
48
35
|
objects?: ObjectEntry[];
|
|
49
36
|
raises?: Record<string, unknown>;
|
|
50
37
|
};
|
|
51
38
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// 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.
|
|
55
41
|
const objectCache = new Map<string, unknown>();
|
|
56
42
|
|
|
57
43
|
async function fetchObject(ref: string): Promise<unknown> {
|
|
58
44
|
const cached = objectCache.get(ref);
|
|
59
45
|
if (cached !== undefined) return cached;
|
|
60
|
-
// Left to throw
|
|
61
|
-
//
|
|
62
|
-
// 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.
|
|
63
48
|
const res = await fetch(`${SERVER}/api/objects/${encodeURIComponent(ref)}`, { headers: authHeaders });
|
|
64
49
|
if (!res.ok) throw new Error(`fetch object ${ref}: HTTP ${res.status}`);
|
|
65
50
|
const { data } = (await res.json()) as { data: string };
|
|
@@ -76,15 +61,15 @@ async function fetchObject(ref: string): Promise<unknown> {
|
|
|
76
61
|
/** Put each listed value back where its path says it belongs. Paths are arrays of keys, so this
|
|
77
62
|
* needs no parser: the whole reason they are not JSON Pointer strings. */
|
|
78
63
|
async function resolveObjects(job: QueueTask): Promise<unknown> {
|
|
79
|
-
let input: any = job.
|
|
64
|
+
let input: any = job.external_input;
|
|
80
65
|
for (const e of job.objects ?? []) {
|
|
81
66
|
const value = await fetchObject(e.ref);
|
|
82
67
|
if (e.path.length === 0) {
|
|
83
68
|
input = value;
|
|
84
69
|
continue;
|
|
85
70
|
}
|
|
86
|
-
// The path is rooted at the entry and starts with "
|
|
87
|
-
const rest = e.path[0] === "
|
|
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;
|
|
88
73
|
if (rest.length === 0) {
|
|
89
74
|
input = value;
|
|
90
75
|
continue;
|
|
@@ -97,10 +82,8 @@ async function resolveObjects(job: QueueTask): Promise<unknown> {
|
|
|
97
82
|
}
|
|
98
83
|
|
|
99
84
|
async function call(path: string, body: unknown): Promise<{ ok: boolean; status: number; data: any }> {
|
|
100
|
-
// A network error is a REPLY
|
|
101
|
-
//
|
|
102
|
-
// unhandled rejection here kills it for a condition the next poll would clear. Status 0
|
|
103
|
-
// 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.
|
|
104
87
|
let res: Response;
|
|
105
88
|
try {
|
|
106
89
|
res = await fetch(SERVER + path, {
|
|
@@ -121,9 +104,8 @@ async function call(path: string, body: unknown): Promise<{ ok: boolean; status:
|
|
|
121
104
|
return { ok: res.ok, status: res.status, data };
|
|
122
105
|
}
|
|
123
106
|
|
|
124
|
-
/**
|
|
125
|
-
*
|
|
126
|
-
* 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. */
|
|
127
109
|
function asEvalRequest(input: unknown): EvalRequest | string {
|
|
128
110
|
if (typeof input !== "object" || input === null) return "the task input is not an object";
|
|
129
111
|
const r = input as Record<string, unknown>;
|
|
@@ -135,13 +117,15 @@ function asEvalRequest(input: unknown): EvalRequest | string {
|
|
|
135
117
|
};
|
|
136
118
|
}
|
|
137
119
|
|
|
138
|
-
|
|
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>();
|
|
139
125
|
let running = true;
|
|
140
126
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// buries the one line that mattered. Announce the TRANSITIONS instead: going away, and coming
|
|
144
|
-
// 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.
|
|
145
129
|
let serverReachable = true;
|
|
146
130
|
|
|
147
131
|
async function claim(n: number): Promise<QueueTask[]> {
|
|
@@ -153,9 +137,8 @@ async function claim(n: number): Promise<QueueTask[]> {
|
|
|
153
137
|
...(TASK_FILTER ? { task: TASK_FILTER } : {}),
|
|
154
138
|
});
|
|
155
139
|
if (!ok) {
|
|
156
|
-
// A credential problem is not transient, and polling through it looks like a healthy
|
|
157
|
-
// worker
|
|
158
|
-
// 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.
|
|
159
142
|
if (status === 401 || status === 403) {
|
|
160
143
|
console.error(
|
|
161
144
|
`claim rejected (${status}): ${JSON.stringify(data)}\n` +
|
|
@@ -164,9 +147,7 @@ async function claim(n: number): Promise<QueueTask[]> {
|
|
|
164
147
|
);
|
|
165
148
|
process.exit(1);
|
|
166
149
|
}
|
|
167
|
-
// status 0
|
|
168
|
-
// network blip. Not an error to act on — the next poll clears it — so it is reported once
|
|
169
|
-
// and then waited out.
|
|
150
|
+
// status 0 (see call): a restart or blip the next poll clears, so it is reported once.
|
|
170
151
|
if (status === 0) {
|
|
171
152
|
if (serverReachable) {
|
|
172
153
|
serverReachable = false;
|
|
@@ -192,10 +173,8 @@ async function release(token: string): Promise<void> {
|
|
|
192
173
|
if (!ok) console.error(`release failed: ${JSON.stringify(data)}`);
|
|
193
174
|
}
|
|
194
175
|
|
|
195
|
-
/**
|
|
196
|
-
*
|
|
197
|
-
* not fit `raises`), and guessing again would only pick a second wrong answer. Release it, so
|
|
198
|
-
* 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. */
|
|
199
178
|
async function answer(token: string, outcome: Record<string, unknown>): Promise<void> {
|
|
200
179
|
const { ok, data } = await call("/api/external-tasks/resolve", { token, ...outcome });
|
|
201
180
|
if (ok) return;
|
|
@@ -203,7 +182,7 @@ async function answer(token: string, outcome: Record<string, unknown>): Promise<
|
|
|
203
182
|
await release(token);
|
|
204
183
|
}
|
|
205
184
|
|
|
206
|
-
async function run(job: QueueTask): Promise<void> {
|
|
185
|
+
async function run(job: QueueTask, signal: AbortSignal): Promise<void> {
|
|
207
186
|
let resolved: unknown;
|
|
208
187
|
try {
|
|
209
188
|
resolved = await resolveObjects(job);
|
|
@@ -224,12 +203,17 @@ async function run(job: QueueTask): Promise<void> {
|
|
|
224
203
|
|
|
225
204
|
let result;
|
|
226
205
|
try {
|
|
227
|
-
result = await evaluate(req);
|
|
206
|
+
result = await evaluate(req, signal);
|
|
228
207
|
} catch (err) {
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
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.
|
|
233
217
|
console.error(`evaluator fault on ${job.token}: ${err instanceof Error ? err.message : String(err)}`);
|
|
234
218
|
await release(job.token);
|
|
235
219
|
return;
|
|
@@ -263,11 +247,18 @@ async function renewLoop(): Promise<void> {
|
|
|
263
247
|
tokens,
|
|
264
248
|
lease_ms: LEASE_MS,
|
|
265
249
|
});
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
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`);
|
|
271
262
|
}
|
|
272
263
|
}
|
|
273
264
|
}
|
|
@@ -277,8 +268,9 @@ async function pollLoop(): Promise<void> {
|
|
|
277
268
|
const free = CONCURRENCY - inFlight.size;
|
|
278
269
|
const jobs = free > 0 ? await claim(free) : [];
|
|
279
270
|
for (const job of jobs) {
|
|
280
|
-
|
|
281
|
-
|
|
271
|
+
const abort = new AbortController();
|
|
272
|
+
inFlight.set(job.token, { job, abort });
|
|
273
|
+
void run(job, abort.signal).finally(() => inFlight.delete(job.token));
|
|
282
274
|
}
|
|
283
275
|
// Only idle when there was nothing to take: a full queue should be drained at the speed the
|
|
284
276
|
// realms allow, not at the poll interval.
|