pi-supernova 0.0.15 → 0.2.0

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/runtime.js CHANGED
@@ -1,276 +1,270 @@
1
1
  import { Worker } from "node:worker_threads";
2
+ import { parse } from "acorn";
2
3
  import { performance } from "node:perf_hooks";
3
4
  import { packageFinalReturn } from "./bottleneck.js";
4
- import { isFunction, isObject } from "./decode.js";
5
-
6
- // Guest code runs in a worker thread (see guest-worker.js). The host thread
7
- // owns the bridge and answers nova.* RPCs; a hard timeout or abort terminates
8
- // the worker, which is the only way to stop a synchronous loop.
5
+ import { isFunction, isObject, isString } from "./decode.js";
9
6
 
10
7
  const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
11
8
  const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
12
- // Bun ignores worker resourceLimits, so a process-RSS watchdog backs up the V8 heap cap.
13
9
  const MEMORY_POLL_MS = 50;
14
10
  const MEMORY_SLACK = 1.5;
15
-
16
11
  const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
17
-
18
12
  let idleWorker = null;
19
13
  let runSeq = 0;
20
14
 
15
+ const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true };
16
+ const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
17
+
18
+ function hasReturn(node) {
19
+ if (!isObject(node)) return false;
20
+ if (node.type === "ReturnStatement") return true;
21
+ if (FUNCTION_TYPES.has(node.type)) return false;
22
+ return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
23
+ }
24
+
25
+ function prepareProgram(code) {
26
+ let program;
27
+ let expression;
28
+ let expressionSource;
29
+ try {
30
+ program = parse(code, PARSE_OPTIONS);
31
+ const statements = program.body.filter(node => node.type !== "EmptyStatement");
32
+ const statement = statements.length === 1 ? statements[0] : undefined;
33
+ const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
34
+ if (candidate && FUNCTION_TYPES.has(candidate.type)) {
35
+ expression = candidate;
36
+ expressionSource = code.slice(0, statement.end).replace(/;\s*$/, "");
37
+ }
38
+ } catch (bodyError) {
39
+ expressionSource = code.trimEnd().replace(/;+\s*$/, "");
40
+ try {
41
+ const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
42
+ expression = wrapped.body[0]?.expression;
43
+ if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
44
+ } catch { throw bodyError; }
45
+ }
46
+ const body = expression ? "return await (" + expressionSource + "\n)();" : code;
47
+ const returns = expression
48
+ ? expression.type === "ArrowFunctionExpression" && expression.body.type !== "BlockStatement" || hasReturn(expression.body)
49
+ : hasReturn(program);
50
+ return { body, hasReturn: returns };
51
+ }
52
+
21
53
  function spawnWorker(config) {
22
- const { maxHeapMb = 512 } = config;
23
- const worker = new Worker(WORKER_URL, {
24
- resourceLimits: { maxOldGenerationSizeMb: maxHeapMb },
54
+ const maxHeapMb = config.maxHeapMb ?? 512;
55
+ const worker = new Worker(WORKER_URL, { resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
56
+ const handle = { worker, maxHeapMb, dead: false, ready: null };
57
+ // This listener also owns errors between readiness and a run's listeners.
58
+ worker.on("error", () => { handle.dead = true; });
59
+ worker.on("exit", () => {
60
+ handle.dead = true;
61
+ if (idleWorker === handle) idleWorker = null;
25
62
  });
26
- const handle = { worker, dead: false, ready: null };
27
63
  handle.ready = new Promise((resolve, reject) => {
64
+ const cleanup = () => {
65
+ worker.off("message", onMessage);
66
+ worker.off("error", onFail);
67
+ worker.off("exit", onFail);
68
+ };
28
69
  const onMessage = (msg) => {
29
- if (msg?.op === "ready") {
30
- cleanup();
31
- resolve();
32
- }
70
+ if (msg?.op !== "ready") return;
71
+ cleanup();
72
+ resolve();
33
73
  };
34
74
  const onFail = (err) => {
35
75
  cleanup();
36
- handle.dead = true;
37
76
  reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
38
77
  };
39
- const cleanup = () => {
40
- worker.off("message", onMessage);
41
- worker.off("error", onFail);
42
- worker.off("exit", onFail);
43
- };
44
78
  worker.on("message", onMessage);
45
79
  worker.on("error", onFail);
46
80
  worker.on("exit", onFail);
47
81
  });
48
82
  handle.ready.catch(() => {});
49
- worker.on("exit", () => {
50
- handle.dead = true;
51
- if (idleWorker === handle) idleWorker = null;
52
- });
53
83
  return handle;
54
84
  }
55
85
 
56
- function setIdleRef(handle, idle) {
57
- const fn = idle ? handle.worker.unref : handle.worker.ref;
58
- if (isFunction(fn)) fn.call(handle.worker);
86
+ function killWorker(handle) {
87
+ if (!handle) return;
88
+ if (idleWorker === handle) idleWorker = null;
89
+ handle.dead = true;
90
+ return handle.worker.terminate();
59
91
  }
60
92
 
61
93
  function acquireWorker(config) {
62
- const handle = idleWorker && !idleWorker.dead ? idleWorker : spawnWorker(config);
94
+ const candidate = idleWorker;
63
95
  idleWorker = null;
64
- setIdleRef(handle, false);
96
+ const reusable = candidate && !candidate.dead && candidate.maxHeapMb === (config.maxHeapMb ?? 512);
97
+ if (candidate && !reusable) void killWorker(candidate);
98
+ const handle = reusable ? candidate : spawnWorker(config);
99
+ handle.worker.ref?.();
65
100
  return handle;
66
101
  }
67
102
 
68
- function releaseWorker(handle) {
69
- if (handle.dead) return;
70
- if (idleWorker && idleWorker !== handle) {
71
- void handle.worker.terminate();
72
- return;
73
- }
74
- idleWorker = handle;
75
- setIdleRef(handle, true);
76
- }
77
-
78
- function killWorker(handle) {
79
- handle.dead = true;
80
- if (idleWorker === handle) idleWorker = null;
81
- void handle.worker.terminate();
82
- }
83
-
84
- /** Pre-spawn the guest worker so the first program does not pay startup cost. */
85
- export function warmGuestWorker(config) {
103
+ /** Only pristine workers may be prewarmed. A used worker is never pooled. */
104
+ export function warmGuestWorker(config = {}) {
86
105
  if (idleWorker && !idleWorker.dead) return idleWorker.ready;
87
- const handle = spawnWorker(config || {});
106
+ const handle = spawnWorker(config);
88
107
  idleWorker = handle;
89
- // Keep the loop alive only until the worker reports ready; an idle worker must not pin the process.
90
- handle.ready.then(
91
- () => { if (idleWorker === handle) setIdleRef(handle, true); },
92
- () => {},
93
- );
108
+ handle.ready.then(() => {
109
+ if (idleWorker === handle) handle.worker.unref?.();
110
+ }, () => {});
94
111
  return handle.ready;
95
112
  }
96
113
 
97
114
  const RPC_METHODS = {
98
- call: (nova, args) => {
99
- if (!isFunction(nova?.call)) throw new Error("nova.call unavailable");
100
- return nova.call(args[0], args[1]);
101
- },
115
+ call: (nova, args) => nova.call(args[0], args[1]),
102
116
  callMany: async (nova, args) => {
103
- if (!isFunction(nova?.callMany)) throw new Error("nova.callMany unavailable");
104
117
  const wave = await nova.callMany(args[0]);
105
- if (Array.isArray(wave)) return { results: [...wave], mode: wave.mode, reason: wave.reason };
106
- return wave;
107
- },
108
- search: (nova, args) => {
109
- if (!isFunction(nova?.search)) throw new Error("nova.search unavailable");
110
- return nova.search(args[0], args[1]);
111
- },
112
- describe: (nova, args) => {
113
- if (!isFunction(nova?.describe)) throw new Error("nova.describe unavailable");
114
- return nova.describe(args[0]);
115
- },
116
- speculateBegin: (nova) => nova?.speculateBegin?.(),
117
- speculateCommit: (nova) => nova?.speculateCommit?.(),
118
- speculateRollback: (nova) => nova?.speculateRollback?.(),
119
- };
120
-
121
- async function dispatchRpc(nova, method, args) {
122
- const fn = RPC_METHODS[method];
123
- if (!fn) throw new Error("unknown nova method: " + method);
124
- return fn(nova, args);
125
- }
126
-
127
- async function loadAvailable(nova) {
128
- if (!isFunction(nova?.names)) return [];
129
- try {
130
- return await nova.names();
131
- } catch {
132
- return [];
133
- }
134
- }
135
-
136
- async function prepareRun(options, fail) {
137
- const { code, config, signal, nova } = options;
138
- if (!String(code || "").trim()) return { failed: fail("code must be a non-empty string") };
139
- const maxCode = config.maxCodeChars ?? 48000;
140
- if (code.length > maxCode) return { failed: fail("code exceeds " + maxCode + " characters") };
141
- if (signal?.aborted) return { failed: fail(ABORT_MESSAGE) };
142
- const handle = acquireWorker(config);
143
- try {
144
- await handle.ready;
145
- } catch (err) {
146
- return { failed: fail("guest worker failed to start: " + err?.message) };
147
- }
148
- const available = await loadAvailable(nova);
149
- return { handle, worker: handle.worker, available };
150
- }
151
-
152
- function startWatchdogs({ timeoutMs, rssLimit, signal, onAbort, onMemoryExceeded }) {
153
- const timer = setTimeout(onAbort, timeoutMs);
154
- if (timer.unref) timer.unref();
155
- const memTimer = setInterval(() => {
156
- if (rssBytes() <= rssLimit) return;
157
- onMemoryExceeded(rssBytes());
158
- }, MEMORY_POLL_MS);
159
- if (memTimer.unref) memTimer.unref();
160
- if (signal) signal.addEventListener("abort", onAbort, { once: true });
161
- return () => {
162
- clearTimeout(timer);
163
- clearInterval(memTimer);
164
- if (signal) signal.removeEventListener("abort", onAbort);
165
- };
166
- }
167
-
168
- const MESSAGE_HANDLERS = {
169
- log: (msg, ctx) => {
170
- ctx.logs.push(msg.line);
171
- },
172
- rpc: (msg, ctx) => {
173
- dispatchRpc(ctx.nova, msg.method, msg.args).then(
174
- (value) => ctx.postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
175
- (err) => ctx.postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) }),
176
- );
177
- },
178
- done: (msg, ctx) => {
179
- const packaged = packageFinalReturn(msg.value, ctx.logs, ctx.config);
180
- ctx.finish(
181
- {
182
- ok: true,
183
- result: packaged.returnValue,
184
- resultText: packaged.returnText,
185
- returnTruncated: packaged.returnTruncated,
186
- undefinedReturn: msg.undefinedReturn === true && msg.hasReturn === false,
187
- logs: packaged.logs,
188
- logTruncated: packaged.logTruncated,
189
- wallMs: ctx.wall(),
190
- },
191
- true,
192
- );
193
- },
194
- error: (msg, ctx) => {
195
- const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
196
- ctx.finish(ctx.fail(msg.message + where, ctx.logs), true);
118
+ return Array.isArray(wave) ? { results: [...wave], mode: wave.mode, reason: wave.reason } : wave;
197
119
  },
120
+ search: (nova, args) => nova.search(args[0], args[1]),
121
+ describe: (nova, args) => nova.describe(args[0]),
122
+ speculateBegin: (nova) => nova.speculateBegin(),
123
+ speculateCommit: (nova) => nova.speculateCommit(),
124
+ speculateRollback: (nova) => nova.speculateRollback(),
198
125
  };
199
126
 
200
- export async function runGuestProgram(options) {
201
- const { code, nova, config, signal, onTimeout } = options;
127
+ export async function runGuestProgram({ code, nova = {}, config = {}, signal, onTimeout }) {
202
128
  const started = performance.now();
203
129
  const wall = () => Math.round(performance.now() - started);
204
- const fail = (error, logs = []) => ({ ok: false, error, logs, wallMs: wall() });
205
- const prepared = await prepareRun(options, fail);
206
- if (prepared.failed) return prepared.failed;
207
- const { handle, worker, available } = prepared;
208
130
  const logs = [];
131
+ const fail = (error) => ({ ok: false, error, logs, logTruncated, wallMs: wall() });
132
+ let logTruncated = false;
133
+ if (!isString(code) || !code.trim()) return fail("code must be a non-empty string");
134
+ if (code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters");
135
+ if (signal?.aborted) return fail(ABORT_MESSAGE);
209
136
  const runId = ++runSeq;
210
- const { timeoutMs = 60000, maxHeapMb = 512 } = config;
211
- const rssLimit = rssBytes() + maxHeapMb * MEMORY_SLACK * 1048576;
212
- return await new Promise((resolve) => {
137
+ const timeoutMs = config.timeoutMs ?? 60000;
138
+ const rssLimit = rssBytes() + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576;
139
+
140
+ return new Promise((resolve) => {
141
+ let handle;
213
142
  let finished = false;
214
- let stop;
215
- const finish = (outcome, keepWorker) => {
143
+ let accepting = true;
144
+ let completing = false;
145
+ let hostError;
146
+ let notifyingHost = false;
147
+ const pending = new Set();
148
+ const cleanup = () => {
149
+ clearTimeout(timer);
150
+ clearInterval(memTimer);
151
+ signal?.removeEventListener("abort", signalAbort);
152
+ handle?.worker.off("message", onMessage);
153
+ handle?.worker.off("error", onError);
154
+ handle?.worker.off("exit", onExit);
155
+ };
156
+ const finish = (outcome) => {
216
157
  if (finished) return;
217
158
  finished = true;
218
- stop();
219
- worker.off("message", onMessage);
220
- worker.off("error", onError);
221
- worker.off("exit", onExit);
222
- if (keepWorker) releaseWorker(handle);
223
- else killWorker(handle);
224
- resolve(outcome);
159
+ accepting = false;
160
+ cleanup();
161
+ void killWorker(handle);
162
+ resolve({ ...outcome, wallMs: wall() });
225
163
  };
226
- const abort = () => {
227
- try {
228
- onTimeout?.();
229
- } catch {}
230
- finish(fail(ABORT_MESSAGE, logs), false);
164
+ const cancelHost = () => {
165
+ notifyingHost = true;
166
+ try { nova.cancel?.(); } catch {} finally { notifyingHost = false; }
231
167
  };
232
- const postResult = (msg) => {
168
+ const abort = () => {
233
169
  if (finished) return;
170
+ cancelHost();
171
+ try { onTimeout?.(); } catch {}
172
+ finish(fail(ABORT_MESSAGE));
173
+ };
174
+ const signalAbort = () => { if (!notifyingHost) abort(); };
175
+ const timer = setTimeout(abort, Math.min(timeoutMs, 2147483647));
176
+ const memTimer = setInterval(() => {
177
+ if (rssBytes() <= rssLimit) return;
178
+ cancelHost();
179
+ finish(fail("guest exceeded memory limit (maxHeapMb=" + (config.maxHeapMb ?? 512) + ")"));
180
+ }, MEMORY_POLL_MS);
181
+ signal?.addEventListener("abort", signalAbort, { once: true });
182
+
183
+ const postResult = (message) => {
184
+ if (!accepting || finished) return;
234
185
  try {
235
- worker.postMessage(msg);
186
+ handle.worker.postMessage({ ...message, runId });
236
187
  } catch (err) {
237
- worker.postMessage({ op: "rpc:result", id: msg.id, ok: false, error: "result not transferable: " + err?.message });
188
+ try {
189
+ handle.worker.postMessage({ op: "rpc:result", id: message.id, runId, ok: false, error: "result not transferable: " + err.message });
190
+ } catch (error) {
191
+ onError(error);
192
+ }
238
193
  }
239
194
  };
240
- const ctx = { logs, nova, config, wall, fail, finish, postResult };
195
+ const complete = async (outcome) => {
196
+ if (finished || completing) return;
197
+ completing = true;
198
+ accepting = false;
199
+ // Stop timers and detached guest continuations before settling host work.
200
+ handle?.worker.off("error", onError);
201
+ handle?.worker.off("exit", onExit);
202
+ void killWorker(handle);
203
+ if (!outcome.ok) cancelHost();
204
+ await Promise.allSettled(pending);
205
+ if (finished) return;
206
+ finish(outcome.ok && hostError ? fail(hostError) : outcome);
207
+ };
208
+ const onError = (err) => { void complete(fail("guest crashed: " + err.message)); };
209
+ const onExit = (exitCode) => { void complete(fail("guest exited (code " + exitCode + ")")); };
241
210
  const onMessage = (msg) => {
242
- if (!isObject(msg)) return;
243
- if (msg.runId !== runId) {
244
- if (msg.op === "rpc") postResult({ op: "rpc:result", id: msg.id, ok: false, error: "stale run" });
245
- return;
211
+ if (finished || !accepting || !isObject(msg) || msg.runId !== runId) return;
212
+ if (msg.op === "log") {
213
+ if (logs.length < (config.maxLogLines ?? 100)) logs.push(msg.line);
214
+ else logTruncated = true;
215
+ logTruncated ||= msg.truncated === true;
216
+ } else if (msg.op === "logTruncated") {
217
+ logTruncated = true;
218
+ } else if (msg.op === "rpc") {
219
+ const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
220
+ const work = Promise.resolve().then(() => {
221
+ if (!method) throw new Error("unknown nova method: " + msg.method);
222
+ return method(nova, msg.args);
223
+ });
224
+ pending.add(work);
225
+ work.then(
226
+ value => postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
227
+ err => {
228
+ // An awaited, handled host error must not poison the whole program.
229
+ if (!accepting) hostError ??= err instanceof Error ? err.message : String(err);
230
+ postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
231
+ },
232
+ ).finally(() => pending.delete(work));
233
+ } else if (msg.op === "done") {
234
+ try {
235
+ const packed = packageFinalReturn(msg.value, logs, config);
236
+ void complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
237
+ returnTruncated: packed.returnTruncated, undefinedReturn: msg.undefinedReturn === true,
238
+ logs: packed.logs, logTruncated: logTruncated || packed.logTruncated });
239
+ } catch (err) {
240
+ void complete(fail(err.message));
241
+ }
242
+ } else if (msg.op === "error") {
243
+ const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
244
+ void complete(fail(msg.message + where));
246
245
  }
247
- const handler = MESSAGE_HANDLERS[msg.op];
248
- if (handler) handler(msg, ctx);
249
246
  };
250
- const onError = (err) => finish(fail("guest crashed: " + err?.message, logs), false);
251
- const onExit = (runCode) => finish(fail("guest exited (code " + runCode + ")", logs), false);
252
- worker.on("message", onMessage);
253
- worker.on("error", onError);
254
- worker.on("exit", onExit);
255
- stop = startWatchdogs({
256
- timeoutMs,
257
- rssLimit,
258
- signal,
259
- onAbort: abort,
260
- onMemoryExceeded: (rss) => {
261
- finish(fail(`guest exceeded memory limit (maxHeapMb=${maxHeapMb}, process rss grew to ${Math.round(rss / 1048576)} MB)`, logs), false);
262
- try {
263
- onTimeout?.();
264
- } catch {}
265
- },
266
- });
267
- const { maxLogLines = 100, maxLogLineChars = 4096 } = config;
268
- worker.postMessage({
269
- op: "run",
270
- runId,
271
- code,
272
- limits: { maxLogLines, maxLogLineChars },
273
- available,
274
- });
247
+
248
+ void (async () => {
249
+ try {
250
+ if (signal?.aborted) return abort();
251
+ handle = acquireWorker(config);
252
+ await handle.ready;
253
+ if (finished || signal?.aborted) return abort();
254
+ const available = isFunction(nova.names) ? await nova.names() : [];
255
+ if (finished || signal?.aborted) return abort();
256
+ handle.worker.on("message", onMessage);
257
+ handle.worker.on("error", onError);
258
+ handle.worker.on("exit", onExit);
259
+ const prepared = prepareProgram(code);
260
+ if (wall() >= timeoutMs) return abort();
261
+ handle.worker.postMessage({ op: "run", runId, prepared, available,
262
+ batchRead: nova.batchRead !== false,
263
+ limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
264
+ } catch (err) {
265
+ cancelHost();
266
+ finish(fail("guest worker failed to start: " + err.message));
267
+ }
268
+ })();
275
269
  });
276
270
  }