pi-supernova 0.0.7 → 0.0.8

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,268 +1,293 @@
1
-
2
- import { packageFinalReturn } from "./bottleneck.js";
3
- import { parallel as runParallel, pipeline as runPipeline } from "./parallel.js";
1
+ import { Worker } from "node:worker_threads";
4
2
  import { performance } from "node:perf_hooks";
5
- import { isString, isFunction, isObject } from "./decode.js";
3
+ import { packageFinalReturn } from "./bottleneck.js";
4
+ import { isFunction, isObject } from "./decode.js";
6
5
 
7
- const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
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.
8
9
 
9
- const compiledCache = new Map();
10
- const COMPILED_CACHE_MAX = 256;
10
+ const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
11
+ 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
+ const MEMORY_POLL_MS = 50;
14
+ const MEMORY_SLACK = 1.5;
11
15
 
12
- function wrapBody(code) {
13
- const trimmed = String(code || "").trim();
14
- if (!trimmed) throw new Error("code must be a non-empty string");
16
+ const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
15
17
 
16
- if (/^(async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/.test(trimmed)) {
17
- return `const __fn = (${trimmed});\nreturn await __fn();`;
18
- }
19
- if (/^async\s+function\b/.test(trimmed) || /^function\b/.test(trimmed)) {
20
- return `const __fn = (${trimmed});\nreturn await __fn();`;
21
- }
22
- return trimmed;
23
- }
18
+ let idleWorker = null;
19
+ let runSeq = 0;
24
20
 
25
- export async function runGuestProgram(options) {
26
- const { code, nova, config, signal, onTimeout } = options;
27
- const maxCode = config.maxCodeChars ?? 48000;
28
- if (code.length > maxCode) {
29
- return {
30
- ok: false,
31
- error: `code exceeds ${maxCode} characters`,
32
- logs: [],
33
- wallMs: 0,
21
+ function spawnWorker(config) {
22
+ const { maxHeapMb = 512 } = config;
23
+ const worker = new Worker(WORKER_URL, {
24
+ resourceLimits: { maxOldGenerationSizeMb: maxHeapMb },
25
+ });
26
+ const handle = { worker, dead: false, ready: null };
27
+ handle.ready = new Promise((resolve, reject) => {
28
+ const onMessage = (msg) => {
29
+ if (msg?.op === "ready") {
30
+ cleanup();
31
+ resolve();
32
+ }
34
33
  };
35
- }
34
+ const onFail = (err) => {
35
+ cleanup();
36
+ handle.dead = true;
37
+ reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
38
+ };
39
+ const cleanup = () => {
40
+ worker.off("message", onMessage);
41
+ worker.off("error", onFail);
42
+ worker.off("exit", onFail);
43
+ };
44
+ worker.on("message", onMessage);
45
+ worker.on("error", onFail);
46
+ worker.on("exit", onFail);
47
+ });
48
+ handle.ready.catch(() => {});
49
+ worker.on("exit", () => {
50
+ handle.dead = true;
51
+ if (idleWorker === handle) idleWorker = null;
52
+ });
53
+ return handle;
54
+ }
36
55
 
37
- const logs = [];
38
- const started = performance.now();
39
- const timeoutMs = config.timeoutMs ?? 60000;
56
+ function setIdleRef(handle, idle) {
57
+ const fn = idle ? handle.worker.unref : handle.worker.ref;
58
+ if (isFunction(fn)) fn.call(handle.worker);
59
+ }
40
60
 
41
- const scopedConsole = {
42
- log: (...args) => pushLog(logs, args, config),
43
- warn: (...args) => pushLog(logs, args, config),
44
- error: (...args) => pushLog(logs, args, config),
45
- info: (...args) => pushLog(logs, args, config),
46
- };
61
+ function acquireWorker(config) {
62
+ const handle = idleWorker && !idleWorker.dead ? idleWorker : spawnWorker(config);
63
+ idleWorker = null;
64
+ setIdleRef(handle, false);
65
+ return handle;
66
+ }
47
67
 
48
- let body;
49
- try {
50
- body = wrapBody(code);
51
- } catch (err) {
52
- return {
53
- ok: false,
54
- error: err instanceof Error ? err.message : String(err),
55
- logs,
56
- wallMs: Math.round(performance.now() - started),
57
- };
68
+ function releaseWorker(handle) {
69
+ if (handle.dead) return;
70
+ if (idleWorker && idleWorker !== handle) {
71
+ void handle.worker.terminate();
72
+ return;
58
73
  }
74
+ idleWorker = handle;
75
+ setIdleRef(handle, true);
76
+ }
59
77
 
60
- let compiled = compiledCache.get(body);
61
- if (!compiled) {
62
- try {
63
- compiled = new AsyncFunction(
64
- "nova",
65
- "tools",
66
- "console",
67
- "parallel",
68
- "pipeline",
69
- "read",
70
- "write",
71
- "edit",
72
- "patch",
73
- "surface",
74
- "snap",
75
- "bash",
76
- "exec",
77
- "speculate",
78
- body,
79
- );
80
- } catch (err) {
81
- return {
82
- ok: false,
83
- error: err instanceof Error ? err.message : String(err),
84
- logs,
85
- wallMs: Math.round(performance.now() - started),
86
- };
87
- }
88
- if (compiledCache.size >= COMPILED_CACHE_MAX) {
89
- const first = compiledCache.keys().next().value;
90
- if (first !== undefined) compiledCache.delete(first);
91
- }
92
- compiledCache.set(body, compiled);
93
- }
78
+ function killWorker(handle) {
79
+ handle.dead = true;
80
+ if (idleWorker === handle) idleWorker = null;
81
+ void handle.worker.terminate();
82
+ }
94
83
 
95
- const abortError = new Error("supernova timed out or aborted");
96
- const timeoutPromise = sleepReject(timeoutMs, abortError, signal, () => {
97
- try {
98
- onTimeout?.();
99
- } catch {
100
- }
101
- });
84
+ /** Pre-spawn the guest worker so the first program does not pay startup cost. */
85
+ export function warmGuestWorker(config) {
86
+ if (idleWorker && !idleWorker.dead) return idleWorker.ready;
87
+ const handle = spawnWorker(config || {});
88
+ 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
+ );
94
+ return handle.ready;
95
+ }
102
96
 
103
- const unwrapValue = (res) => {
104
- if (res && isObject(res) && "value" in res) {
105
- if (res.details?.isSnap && isString(res.value)) {
106
- try {
107
- return JSON.parse(res.value);
108
- } catch {
109
- return res.value;
110
- }
111
- }
112
- if (res.details?.batch && Array.isArray(res.details?.items)) {
113
- return res.details.items;
114
- }
115
- return res.value;
116
- }
117
- return res;
118
- };
97
+ /** Terminate every guest worker (tests, shutdown). */
98
+ export async function shutdownGuestWorkers() {
99
+ if (!idleWorker) return;
100
+ const handle = idleWorker;
101
+ idleWorker = null;
102
+ handle.dead = true;
103
+ await handle.worker.terminate();
104
+ }
119
105
 
120
- const unwrapJsonValue = (res) => {
121
- const value = unwrapValue(res);
122
- if (!isString(value)) return value;
123
- try {
124
- return JSON.parse(value);
125
- } catch {
126
- return value;
127
- }
128
- };
106
+ const RPC_METHODS = {
107
+ call: (nova, args) => {
108
+ if (!isFunction(nova?.call)) throw new Error("nova.call unavailable");
109
+ return nova.call(args[0], args[1]);
110
+ },
111
+ callMany: async (nova, args) => {
112
+ if (!isFunction(nova?.callMany)) throw new Error("nova.callMany unavailable");
113
+ const wave = await nova.callMany(args[0]);
114
+ if (Array.isArray(wave)) return { results: [...wave], mode: wave.mode, reason: wave.reason };
115
+ return wave;
116
+ },
117
+ search: (nova, args) => {
118
+ if (!isFunction(nova?.search)) throw new Error("nova.search unavailable");
119
+ return nova.search(args[0], args[1]);
120
+ },
121
+ describe: (nova, args) => {
122
+ if (!isFunction(nova?.describe)) throw new Error("nova.describe unavailable");
123
+ return nova.describe(args[0]);
124
+ },
125
+ surface: (nova, args) => {
126
+ if (isFunction(nova?.surface)) return nova.surface(args[0]);
127
+ return nova.call("surface", { path: args[0] });
128
+ },
129
+ snap: (nova, args) => {
130
+ if (isFunction(nova?.snap)) return nova.snap(args[0], args[1]);
131
+ return nova.call("snap", { query: args[0], path: args[1] });
132
+ },
133
+ speculateBegin: (nova) => (isFunction(nova?.speculateBegin) ? nova.speculateBegin() : undefined),
134
+ speculateCommit: (nova) => (isFunction(nova?.speculateCommit) ? nova.speculateCommit() : undefined),
135
+ speculateRollback: (nova) => (isFunction(nova?.speculateRollback) ? nova.speculateRollback() : undefined),
136
+ };
129
137
 
130
- const guestRead = async (p, off, lim) => {
131
- if (Array.isArray(p)) {
132
- return await Promise.all(p.map((item) => guestRead(item, off, lim)));
133
- }
134
- const res = await nova.call("read", { path: p, offset: off, limit: lim });
135
- return unwrapValue(res);
136
- };
137
- const guestWrite = async (p, c) => unwrapValue(await nova.call("write", { path: p, content: c }));
138
- const guestEdit = async (p, oldOrDiff, newText) => {
139
- const res = await nova.call("edit", { path: p, oldText: oldOrDiff, newText });
140
- return unwrapValue(res);
141
- };
142
- const guestPatch = async (p, d) => unwrapValue(await nova.call("apply_patch", { path: p, patch: d }));
143
- const guestSurface = async (p) => {
144
- const res = await (isFunction(nova.surface) ? nova.surface(p) : nova.call("surface", { path: p }));
145
- return unwrapJsonValue(res);
146
- };
147
- const guestSnap = async (q, p) => {
148
- const res = await (isFunction(nova.snap) ? nova.snap(q, p) : nova.call("snap", { query: q, path: p }));
149
- return unwrapJsonValue(res);
150
- };
151
- const guestBash = async (cmd, opts) => {
152
- const res = await nova.call("bash", { command: cmd, ...opts });
153
- if (res?.ok === false) {
154
- const detail = isString(res?.details) ? res.details : "";
155
- throw new Error(res?.value || detail || `command failed: ${cmd}`);
156
- }
157
- return unwrapValue(res);
158
- };
159
- const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
160
- const guestExec = async (cmd, args, opts) => {
161
- const command = String(cmd ?? "").trim();
162
- if (!command) throw new Error("exec requires command");
163
- // exec("git status") is a shell line; exec("git", ["status"]) is argv.
164
- if (!Array.isArray(args) || args.length === 0) {
165
- return guestBash(command, opts);
166
- }
167
- const argv = [command, ...args].map(quoteShellArg).join(" ");
168
- return guestBash(argv, opts);
169
- };
170
- const guestSpeculate = async (fn) => (isFunction(nova.speculate) ? nova.speculate(fn) : fn());
138
+ async function dispatchRpc(nova, method, args) {
139
+ const fn = RPC_METHODS[method];
140
+ if (!fn) throw new Error("unknown nova method: " + method);
141
+ return fn(nova, args);
142
+ }
171
143
 
172
- let settled = false;
173
- const runPromise = Promise.resolve(
174
- compiled(
175
- nova,
176
- nova,
177
- scopedConsole,
178
- runParallel,
179
- runPipeline,
180
- guestRead,
181
- guestWrite,
182
- guestEdit,
183
- guestPatch,
184
- guestSurface,
185
- guestSnap,
186
- guestBash,
187
- guestExec,
188
- guestSpeculate,
189
- ),
190
- );
191
- runPromise.catch((err) => {
192
- if (!settled) return;
193
- pushLog(logs, [`[late guest error] ${err instanceof Error ? err.message : String(err)}`], config);
194
- });
195
- timeoutPromise.catch(() => {
196
- });
144
+ async function loadAvailable(nova) {
145
+ if (!isFunction(nova?.names)) return [];
146
+ try {
147
+ return await nova.names();
148
+ } catch {
149
+ return [];
150
+ }
151
+ }
197
152
 
153
+ async function prepareRun(options, fail) {
154
+ const { code, config, signal, nova } = options;
155
+ if (!String(code || "").trim()) return { failed: fail("code must be a non-empty string") };
156
+ const maxCode = config.maxCodeChars ?? 48000;
157
+ if (code.length > maxCode) return { failed: fail("code exceeds " + maxCode + " characters") };
158
+ if (signal?.aborted) return { failed: fail(ABORT_MESSAGE) };
159
+ const handle = acquireWorker(config);
198
160
  try {
199
- const resultValue = await Promise.race([runPromise, timeoutPromise]);
200
- settled = true;
201
- const packaged = packageFinalReturn(resultValue, logs, config);
202
- return {
203
- ok: true,
204
- result: packaged.returnValue,
205
- resultText: packaged.returnText,
206
- returnTruncated: packaged.returnTruncated,
207
- logs: packaged.logs,
208
- logTruncated: packaged.logTruncated,
209
- wallMs: Math.round(performance.now() - started),
210
- };
161
+ await handle.ready;
211
162
  } catch (err) {
212
- settled = true;
213
- return {
214
- ok: false,
215
- error: err instanceof Error ? err.message : String(err),
216
- logs,
217
- wallMs: Math.round(performance.now() - started),
218
- };
219
- } finally {
220
- settled = true;
221
- timeoutPromise.clear();
163
+ return { failed: fail("guest worker failed to start: " + err?.message) };
222
164
  }
165
+ const available = await loadAvailable(nova);
166
+ return { handle, worker: handle.worker, available };
223
167
  }
224
168
 
225
- function pushLog(logs, args, config) {
226
- const maxLines = config.maxLogLines ?? 100;
227
- if (logs.length >= maxLines) return;
228
- const line = args
229
- .map((a) => {
230
- if (isString(a)) return a;
231
- try {
232
- return JSON.stringify(a);
233
- } catch {
234
- return String(a);
235
- }
236
- })
237
- .join(" ");
238
- logs.push(line);
169
+ function startWatchdogs({ timeoutMs, rssLimit, signal, onAbort, onMemoryExceeded }) {
170
+ const timer = setTimeout(onAbort, timeoutMs);
171
+ if (timer.unref) timer.unref();
172
+ const memTimer = setInterval(() => {
173
+ if (rssBytes() <= rssLimit) return;
174
+ onMemoryExceeded(rssBytes());
175
+ }, MEMORY_POLL_MS);
176
+ if (memTimer.unref) memTimer.unref();
177
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
178
+ return () => {
179
+ clearTimeout(timer);
180
+ clearInterval(memTimer);
181
+ if (signal) signal.removeEventListener("abort", onAbort);
182
+ };
239
183
  }
240
184
 
241
- function sleepReject(ms, error, signal, onFire) {
242
- let timer;
243
- let onAbort;
244
- const fire = (reject) => {
245
- try {
246
- onFire?.();
247
- } catch {
248
- }
249
- reject(error);
250
- };
251
- const promise = new Promise((_, reject) => {
252
- timer = setTimeout(() => fire(reject), ms);
253
- if (timer.unref) timer.unref();
254
- if (signal) {
255
- onAbort = () => {
256
- clearTimeout(timer);
257
- fire(reject);
258
- };
259
- if (signal.aborted) onAbort();
260
- else signal.addEventListener("abort", onAbort, { once: true });
261
- }
185
+ const MESSAGE_HANDLERS = {
186
+ log: (msg, ctx) => {
187
+ ctx.logs.push(msg.line);
188
+ },
189
+ rpc: (msg, ctx) => {
190
+ dispatchRpc(ctx.nova, msg.method, msg.args).then(
191
+ (value) => ctx.postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
192
+ (err) => ctx.postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) }),
193
+ );
194
+ },
195
+ done: (msg, ctx) => {
196
+ const packaged = packageFinalReturn(msg.value, ctx.logs, ctx.config);
197
+ ctx.finish(
198
+ {
199
+ ok: true,
200
+ result: packaged.returnValue,
201
+ resultText: packaged.returnText,
202
+ returnTruncated: packaged.returnTruncated,
203
+ undefinedReturn: msg.undefinedReturn === true && msg.hasReturn === false,
204
+ logs: packaged.logs,
205
+ logTruncated: packaged.logTruncated,
206
+ wallMs: ctx.wall(),
207
+ },
208
+ true,
209
+ );
210
+ },
211
+ error: (msg, ctx) => {
212
+ const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
213
+ ctx.finish(ctx.fail(msg.message + where, ctx.logs), true);
214
+ },
215
+ };
216
+
217
+ export async function runGuestProgram(options) {
218
+ const { code, nova, config, signal, onTimeout } = options;
219
+ const started = performance.now();
220
+ const wall = () => Math.round(performance.now() - started);
221
+ const fail = (error, logs = []) => ({ ok: false, error, logs, wallMs: wall() });
222
+ const prepared = await prepareRun(options, fail);
223
+ if (prepared.failed) return prepared.failed;
224
+ const { handle, worker, available } = prepared;
225
+ const logs = [];
226
+ const runId = ++runSeq;
227
+ const { timeoutMs = 60000, maxHeapMb = 512 } = config;
228
+ const rssLimit = rssBytes() + maxHeapMb * MEMORY_SLACK * 1048576;
229
+ return await new Promise((resolve) => {
230
+ let finished = false;
231
+ let stop;
232
+ const finish = (outcome, keepWorker) => {
233
+ if (finished) return;
234
+ finished = true;
235
+ stop();
236
+ worker.off("message", onMessage);
237
+ worker.off("error", onError);
238
+ worker.off("exit", onExit);
239
+ if (keepWorker) releaseWorker(handle);
240
+ else killWorker(handle);
241
+ resolve(outcome);
242
+ };
243
+ const abort = () => {
244
+ try {
245
+ onTimeout?.();
246
+ } catch {}
247
+ finish(fail(ABORT_MESSAGE, logs), false);
248
+ };
249
+ const postResult = (msg) => {
250
+ if (finished) return;
251
+ try {
252
+ worker.postMessage(msg);
253
+ } catch (err) {
254
+ worker.postMessage({ op: "rpc:result", id: msg.id, ok: false, error: "result not transferable: " + err?.message });
255
+ }
256
+ };
257
+ const ctx = { logs, nova, config, wall, fail, finish, postResult };
258
+ const onMessage = (msg) => {
259
+ if (!isObject(msg)) return;
260
+ if (msg.runId !== runId) {
261
+ if (msg.op === "rpc") postResult({ op: "rpc:result", id: msg.id, ok: false, error: "stale run" });
262
+ return;
263
+ }
264
+ const handler = MESSAGE_HANDLERS[msg.op];
265
+ if (handler) handler(msg, ctx);
266
+ };
267
+ const onError = (err) => finish(fail("guest crashed: " + err?.message, logs), false);
268
+ const onExit = (runCode) => finish(fail("guest exited (code " + runCode + ")", logs), false);
269
+ worker.on("message", onMessage);
270
+ worker.on("error", onError);
271
+ worker.on("exit", onExit);
272
+ stop = startWatchdogs({
273
+ timeoutMs,
274
+ rssLimit,
275
+ signal,
276
+ onAbort: abort,
277
+ onMemoryExceeded: (rss) => {
278
+ finish(fail(`guest exceeded memory limit (maxHeapMb=${maxHeapMb}, process rss grew to ${Math.round(rss / 1048576)} MB)`, logs), false);
279
+ try {
280
+ onTimeout?.();
281
+ } catch {}
282
+ },
283
+ });
284
+ const { maxLogLines = 100, maxLogLineChars = 4096 } = config;
285
+ worker.postMessage({
286
+ op: "run",
287
+ runId,
288
+ code,
289
+ limits: { maxLogLines, maxLogLineChars },
290
+ available,
291
+ });
262
292
  });
263
- promise.clear = () => {
264
- clearTimeout(timer);
265
- if (signal && onAbort) signal.removeEventListener("abort", onAbort);
266
- };
267
- return promise;
268
293
  }