@alisio/core 0.5.2 → 0.6.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/dist/analysis/janitor.d.ts +48 -0
- package/dist/analysis/janitor.js +170 -18
- package/dist/analysis/jobs.d.ts +11 -2
- package/dist/analysis/jobs.js +13 -0
- package/dist/analysis/oci.d.ts +11 -2
- package/dist/analysis/oci.js +20 -4
- package/dist/analysis/python/sources.js +2 -1
- package/dist/analysis/rerun.d.ts +1 -1
- package/dist/analysis/status.d.ts +3 -1
- package/dist/analysis/status.js +3 -0
- package/dist/application.d.ts +28 -2
- package/dist/application.js +189 -4
- package/dist/changelog/data.js +26 -0
- package/dist/config.d.ts +110 -2
- package/dist/config.js +188 -6
- package/dist/core/call-internals.d.ts +22 -0
- package/dist/core/call-internals.js +9 -0
- package/dist/core/contracts.d.ts +2 -2
- package/dist/core/runner.js +111 -38
- package/dist/forge/broker.d.ts +51 -0
- package/dist/forge/broker.js +244 -0
- package/dist/forge/budget.d.ts +27 -0
- package/dist/forge/budget.js +60 -0
- package/dist/forge/catalog-view.d.ts +24 -0
- package/dist/forge/catalog-view.js +43 -0
- package/dist/forge/errors.d.ts +26 -0
- package/dist/forge/errors.js +49 -0
- package/dist/forge/guides/javascript.d.ts +25 -0
- package/dist/forge/guides/javascript.js +90 -0
- package/dist/forge/guides/provider.d.ts +42 -0
- package/dist/forge/guides/provider.js +96 -0
- package/dist/forge/guides/python-environment.d.ts +15 -0
- package/dist/forge/guides/python-environment.js +81 -0
- package/dist/forge/guides/python.d.ts +38 -0
- package/dist/forge/guides/python.js +170 -0
- package/dist/forge/javascript/engine.d.ts +44 -0
- package/dist/forge/javascript/engine.js +229 -0
- package/dist/forge/javascript/hosts/factory.d.ts +25 -0
- package/dist/forge/javascript/hosts/factory.js +21 -0
- package/dist/forge/javascript/hosts/framing.d.ts +17 -0
- package/dist/forge/javascript/hosts/framing.js +49 -0
- package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
- package/dist/forge/javascript/hosts/isolate-main.js +63 -0
- package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/process-entry.js +3 -0
- package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
- package/dist/forge/javascript/hosts/process-host.js +160 -0
- package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/worker-entry.js +3 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +18 -0
- package/dist/forge/javascript/hosts/worker-host.js +101 -0
- package/dist/forge/javascript/prelude.d.ts +2 -0
- package/dist/forge/javascript/prelude.js +333 -0
- package/dist/forge/javascript/probe.d.ts +2 -0
- package/dist/forge/javascript/probe.js +53 -0
- package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
- package/dist/forge/javascript/runtime-manifest.js +14 -0
- package/dist/forge/javascript/vm-runner.d.ts +21 -0
- package/dist/forge/javascript/vm-runner.js +301 -0
- package/dist/forge/javascript/wasm-asset.d.ts +8 -0
- package/dist/forge/javascript/wasm-asset.js +8 -0
- package/dist/forge/javascript/wasm.d.ts +15 -0
- package/dist/forge/javascript/wasm.js +54 -0
- package/dist/forge/journal.d.ts +105 -0
- package/dist/forge/journal.js +174 -0
- package/dist/forge/kernel.d.ts +76 -0
- package/dist/forge/kernel.js +338 -0
- package/dist/forge/limits.d.ts +38 -0
- package/dist/forge/limits.js +73 -0
- package/dist/forge/nested.d.ts +96 -0
- package/dist/forge/nested.js +214 -0
- package/dist/forge/ports.d.ts +120 -0
- package/dist/forge/ports.js +1 -0
- package/dist/forge/progress.d.ts +24 -0
- package/dist/forge/progress.js +76 -0
- package/dist/forge/protocol.d.ts +110 -0
- package/dist/forge/protocol.js +171 -0
- package/dist/forge/python/backend-selector.d.ts +38 -0
- package/dist/forge/python/backend-selector.js +111 -0
- package/dist/forge/python/backend.d.ts +89 -0
- package/dist/forge/python/backend.js +1 -0
- package/dist/forge/python/engine.d.ts +84 -0
- package/dist/forge/python/engine.js +279 -0
- package/dist/forge/python/frame-parser.d.ts +24 -0
- package/dist/forge/python/frame-parser.js +107 -0
- package/dist/forge/python/frame-transport.d.ts +40 -0
- package/dist/forge/python/frame-transport.js +101 -0
- package/dist/forge/python/import-scan.d.ts +22 -0
- package/dist/forge/python/import-scan.js +133 -0
- package/dist/forge/python/managed-backend.d.ts +30 -0
- package/dist/forge/python/managed-backend.js +111 -0
- package/dist/forge/python/oci-backend.d.ts +27 -0
- package/dist/forge/python/oci-backend.js +84 -0
- package/dist/forge/python/pyodide-backend.d.ts +40 -0
- package/dist/forge/python/pyodide-backend.js +289 -0
- package/dist/forge/python/pyodide-install.d.ts +41 -0
- package/dist/forge/python/pyodide-install.js +167 -0
- package/dist/forge/python/pyodide-manifest.d.ts +42 -0
- package/dist/forge/python/pyodide-manifest.js +376 -0
- package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
- package/dist/forge/python/pyodide-runner-source.js +3 -0
- package/dist/forge/python/rss.d.ts +2 -0
- package/dist/forge/python/rss.js +33 -0
- package/dist/forge/python/scratch-copy.d.ts +6 -0
- package/dist/forge/python/scratch-copy.js +61 -0
- package/dist/forge/python/settle.d.ts +6 -0
- package/dist/forge/python/settle.js +21 -0
- package/dist/forge/python/status.d.ts +8 -0
- package/dist/forge/python/status.js +15 -0
- package/dist/forge/python/wasm-node.d.ts +59 -0
- package/dist/forge/python/wasm-node.js +220 -0
- package/dist/forge/python/wiring.d.ts +39 -0
- package/dist/forge/python/wiring.js +47 -0
- package/dist/forge/registry.d.ts +26 -0
- package/dist/forge/registry.js +83 -0
- package/dist/forge/result.d.ts +58 -0
- package/dist/forge/result.js +86 -0
- package/dist/forge/routing-rule.d.ts +19 -0
- package/dist/forge/routing-rule.js +31 -0
- package/dist/forge/runtime.d.ts +64 -0
- package/dist/forge/runtime.js +88 -0
- package/dist/forge/scratch-files.d.ts +36 -0
- package/dist/forge/scratch-files.js +94 -0
- package/dist/forge/scratch-provider.d.ts +41 -0
- package/dist/forge/scratch-provider.js +79 -0
- package/dist/forge/scratch-quota.d.ts +8 -0
- package/dist/forge/scratch-quota.js +25 -0
- package/dist/forge/selector.d.ts +35 -0
- package/dist/forge/selector.js +51 -0
- package/dist/forge/session-dir.d.ts +59 -0
- package/dist/forge/session-dir.js +239 -0
- package/dist/forge/session-path.d.ts +31 -0
- package/dist/forge/session-path.js +61 -0
- package/dist/forge/slots.d.ts +16 -0
- package/dist/forge/slots.js +47 -0
- package/dist/forge/state-delta.d.ts +54 -0
- package/dist/forge/state-delta.js +99 -0
- package/dist/forge/state-machine.d.ts +20 -0
- package/dist/forge/state-machine.js +40 -0
- package/dist/forge/state-store.d.ts +25 -0
- package/dist/forge/state-store.js +50 -0
- package/dist/forge/task-log.d.ts +9 -0
- package/dist/forge/task-log.js +42 -0
- package/dist/forge/verdict.d.ts +20 -0
- package/dist/forge/verdict.js +28 -0
- package/dist/forge-isolate.d.ts +6 -0
- package/dist/forge-isolate.js +6 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/permissions/decide.d.ts +42 -0
- package/dist/permissions/decide.js +24 -0
- package/dist/resources/context.d.ts +2 -1
- package/dist/resources/context.js +2 -1
- package/dist/runtime/access.d.ts +34 -0
- package/dist/runtime/access.js +80 -0
- package/dist/runtime/process.d.ts +2 -1
- package/dist/runtime/process.js +5 -5
- package/dist/runtime/store.d.ts +11 -0
- package/dist/runtime/store.js +103 -0
- package/dist/tools/analysis.d.ts +25 -2
- package/dist/tools/analysis.js +315 -267
- package/dist/tools/execute.js +6 -7
- package/dist/tools/forge-guide.d.ts +6 -0
- package/dist/tools/forge-guide.js +60 -0
- package/dist/tools/js-run.d.ts +19 -0
- package/dist/tools/js-run.js +126 -0
- package/dist/tools/standard.d.ts +7 -1
- package/dist/tools/standard.js +44 -43
- package/package.json +7 -2
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `JavaScriptEngine` (spec §6, D1-D5): the `ExecutionEngine` strategy that runs a script in a
|
|
3
|
+
* QuickJS-WASM VM inside an isolate host. It owns the transport and the lifecycle of the isolate;
|
|
4
|
+
* what the guest's messages MEAN (tool calls, state, output, budget) is the kernel's broker.
|
|
5
|
+
*
|
|
6
|
+
* Host-side checks, whatever the guest says: every message passes `GuestMessageValidator`
|
|
7
|
+
* (schema, sequence, correlation, size, depth, forbidden keys, capability gate); a violation or a
|
|
8
|
+
* handler error terminates the isolate and fails the execution with the matching code.
|
|
9
|
+
*/
|
|
10
|
+
import { access } from "node:fs/promises";
|
|
11
|
+
import { isForgeError } from "../errors.js";
|
|
12
|
+
import { GuestMessageValidator } from "../protocol.js";
|
|
13
|
+
import { resolveIsolateCommand } from "./hosts/process-host.js";
|
|
14
|
+
import { workerEntryUrl } from "./hosts/worker-host.js";
|
|
15
|
+
import { PRELUDE_HASH } from "./prelude.js";
|
|
16
|
+
import { FORGE_PROTOCOL_VERSION, JAVASCRIPT_ENGINE_VERSION } from "./runtime-manifest.js";
|
|
17
|
+
import { loadQuickJsModule } from "./wasm.js";
|
|
18
|
+
const GUEST_LIMIT_KEYS = [
|
|
19
|
+
"timeoutMs",
|
|
20
|
+
"memoryBytes",
|
|
21
|
+
"stackBytes",
|
|
22
|
+
"maxToolCalls",
|
|
23
|
+
"maxConcurrency",
|
|
24
|
+
"maxCallInputBytes",
|
|
25
|
+
"maxCallResponseBytes",
|
|
26
|
+
"maxRetainedOutputBytes",
|
|
27
|
+
];
|
|
28
|
+
const guestLimits = (limits) => Object.fromEntries(GUEST_LIMIT_KEYS.map((key) => [key, limits[key]]));
|
|
29
|
+
export class JavaScriptEngine {
|
|
30
|
+
id = "javascript-quickjs";
|
|
31
|
+
#options;
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.#options = options;
|
|
34
|
+
}
|
|
35
|
+
#module() {
|
|
36
|
+
return (this.#options.loadModule ?? loadQuickJsModule)();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Cheap self-check (runs at startup): the WASM matches its pinned hash and compiles, and the
|
|
40
|
+
* files the chosen host starts exist. The full spin-up of an isolate is `probe()`.
|
|
41
|
+
*/
|
|
42
|
+
async describe() {
|
|
43
|
+
const base = {
|
|
44
|
+
id: this.id,
|
|
45
|
+
language: "javascript",
|
|
46
|
+
version: JAVASCRIPT_ENGINE_VERSION,
|
|
47
|
+
isolation: "wasm-isolate",
|
|
48
|
+
ready: true,
|
|
49
|
+
capabilities: {
|
|
50
|
+
cooperativeInterrupt: true,
|
|
51
|
+
hardTermination: true,
|
|
52
|
+
hardMemoryLimit: false,
|
|
53
|
+
networkDeniedByOS: false,
|
|
54
|
+
toolCalls: true,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const unavailable = (reason) => ({ ...base, ready: false, reason });
|
|
58
|
+
try {
|
|
59
|
+
await this.#module();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
return unavailable(`the QuickJS runtime failed verification (${message}); reinstall @alisio/core`);
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
if (this.#options.hosts.kind === "worker")
|
|
67
|
+
await access(workerEntryUrl());
|
|
68
|
+
else {
|
|
69
|
+
const command = resolveIsolateCommand();
|
|
70
|
+
await access(command.execPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
75
|
+
return unavailable(`the ${this.#options.hosts.kind} isolate host cannot start (${message})`);
|
|
76
|
+
}
|
|
77
|
+
return base;
|
|
78
|
+
}
|
|
79
|
+
/** The kind of host that runs the isolates (`forge status`). */
|
|
80
|
+
get hostKind() {
|
|
81
|
+
return this.#options.hosts.kind;
|
|
82
|
+
}
|
|
83
|
+
async prepare(request, ctx) {
|
|
84
|
+
await this.#module();
|
|
85
|
+
const payload = request.payload ?? {};
|
|
86
|
+
return {
|
|
87
|
+
executionId: ctx.executionId,
|
|
88
|
+
engine: this.id,
|
|
89
|
+
limits: ctx.limits,
|
|
90
|
+
source: request.source,
|
|
91
|
+
state: payload.state ?? {},
|
|
92
|
+
catalogSize: typeof payload.catalogSize === "number" ? payload.catalogSize : 0,
|
|
93
|
+
host: await this.#options.hosts.create(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async run(prepared, channel) {
|
|
97
|
+
const job = prepared;
|
|
98
|
+
const session = job.host.start();
|
|
99
|
+
job.session = session;
|
|
100
|
+
const validator = new GuestMessageValidator({ toolCalls: true });
|
|
101
|
+
let hostSeq = 0;
|
|
102
|
+
let settled = false;
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
const send = (reply) => {
|
|
105
|
+
if (settled)
|
|
106
|
+
return;
|
|
107
|
+
// Answering a call or a lookup closes its correlation (a second answer is a bug).
|
|
108
|
+
if (reply.type === "result")
|
|
109
|
+
validator.resolveCall(reply.payload.callId);
|
|
110
|
+
else if (reply.type === "catalog.result")
|
|
111
|
+
validator.resolveCatalog(reply.payload.requestId);
|
|
112
|
+
session.send({ protocol: FORGE_PROTOCOL_VERSION, seq: hostSeq++, ...reply });
|
|
113
|
+
};
|
|
114
|
+
const finish = (outcome, stop) => {
|
|
115
|
+
if (settled)
|
|
116
|
+
return;
|
|
117
|
+
settled = true;
|
|
118
|
+
validator.close();
|
|
119
|
+
// The guest ends itself after its terminal message; a failure stops it from here.
|
|
120
|
+
if (stop)
|
|
121
|
+
void session.stop(stop);
|
|
122
|
+
resolve(outcome);
|
|
123
|
+
};
|
|
124
|
+
const fail = (code, message, stop) => finish({ kind: "failed", code, message }, stop);
|
|
125
|
+
// The kernel stopped the run (deadline or cancellation) and the isolate never confirmed it
|
|
126
|
+
// is gone: no terminal message and no exit will come, so the host ends the run itself. The
|
|
127
|
+
// kernel's own verdict decides the status; the unconfirmed report is journaled from there.
|
|
128
|
+
job.forceEnd = () => finish({
|
|
129
|
+
kind: "failed",
|
|
130
|
+
code: "ISOLATE_FAILURE",
|
|
131
|
+
message: "the isolate did not confirm that it stopped",
|
|
132
|
+
});
|
|
133
|
+
session.onStreamError((reason) => fail("PROTOCOL_VIOLATION", reason, "protocol_violation"));
|
|
134
|
+
session.exited.then((exit) => {
|
|
135
|
+
if (settled)
|
|
136
|
+
return;
|
|
137
|
+
const detail = exit.error ?? `code ${String(exit.code)}${exit.signal ? `, signal ${exit.signal}` : ""}`;
|
|
138
|
+
finish({
|
|
139
|
+
kind: "failed",
|
|
140
|
+
code: "ISOLATE_FAILURE",
|
|
141
|
+
message: `the isolate exited unexpectedly (${detail})`,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
session.onMessage((raw) => {
|
|
145
|
+
if (settled)
|
|
146
|
+
return;
|
|
147
|
+
const checked = validator.accept(raw);
|
|
148
|
+
if (!checked.ok)
|
|
149
|
+
return fail("PROTOCOL_VIOLATION", checked.error.message, "protocol_violation");
|
|
150
|
+
const message = checked.message;
|
|
151
|
+
if (message.type === "complete") {
|
|
152
|
+
const value = message.payload.value;
|
|
153
|
+
return finish({ kind: "completed", ...(value !== undefined ? { value } : {}) });
|
|
154
|
+
}
|
|
155
|
+
if (message.type === "fatal") {
|
|
156
|
+
return finish({
|
|
157
|
+
kind: "failed",
|
|
158
|
+
code: message.payload.code,
|
|
159
|
+
message: message.payload.message,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (message.type === "ready") {
|
|
163
|
+
if (message.payload.engine !== this.id || message.payload.preludeHash !== PRELUDE_HASH)
|
|
164
|
+
return fail("PROTOCOL_VIOLATION", "the isolate runs an unexpected prelude", "protocol_violation");
|
|
165
|
+
send({
|
|
166
|
+
type: "start",
|
|
167
|
+
payload: {
|
|
168
|
+
source: job.source,
|
|
169
|
+
state: job.state,
|
|
170
|
+
limits: guestLimits(job.limits),
|
|
171
|
+
catalogSize: job.catalogSize,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
Promise.resolve()
|
|
176
|
+
.then(() => channel.receive(message))
|
|
177
|
+
.then((reply) => {
|
|
178
|
+
if (reply)
|
|
179
|
+
send(reply);
|
|
180
|
+
})
|
|
181
|
+
.catch((error) => {
|
|
182
|
+
if (isForgeError(error))
|
|
183
|
+
return fail(error.code, error.message, "limit_exceeded");
|
|
184
|
+
fail("ISOLATE_FAILURE", error instanceof Error ? error.message : String(error), "limit_exceeded");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
channel.bind(send);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async terminate(prepared, reason) {
|
|
191
|
+
const job = prepared;
|
|
192
|
+
const session = job.session;
|
|
193
|
+
if (!session)
|
|
194
|
+
return { confirmed: true, method: "interrupt", elapsedMs: 0 };
|
|
195
|
+
const report = await session.stop(reason);
|
|
196
|
+
if (!report.confirmed)
|
|
197
|
+
job.forceEnd?.();
|
|
198
|
+
return report;
|
|
199
|
+
}
|
|
200
|
+
async dispose(prepared) {
|
|
201
|
+
const session = prepared.session;
|
|
202
|
+
if (!session)
|
|
203
|
+
return;
|
|
204
|
+
// A normal run ends with the guest leaving by itself; anything still alive is stopped now.
|
|
205
|
+
const gone = await Promise.race([
|
|
206
|
+
session.exited.then(() => true),
|
|
207
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 250).unref?.()),
|
|
208
|
+
]);
|
|
209
|
+
if (!gone)
|
|
210
|
+
await session.stop("limit_exceeded");
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Full self-test (`alisio forge status`): starts a real isolate, runs `return 1` and reports how
|
|
214
|
+
* long it took. Nothing here is on the hot path of a normal execution.
|
|
215
|
+
*/
|
|
216
|
+
async probe() {
|
|
217
|
+
const host = this.#options.hosts.kind;
|
|
218
|
+
const started = performance.now();
|
|
219
|
+
try {
|
|
220
|
+
const { probeIsolate } = await import("./probe.js");
|
|
221
|
+
const module = await this.#module();
|
|
222
|
+
await probeIsolate(await this.#options.hosts.create(), module);
|
|
223
|
+
return { ok: true, host, bootMs: Math.round(performance.now() - started) };
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
return { ok: false, host, reason: error instanceof Error ? error.message : String(error) };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chooses the isolate host (spec §6.3, D-20). Node always gets the worker; Bun (directly or as a
|
|
3
|
+
* compiled binary) gets the child process, because `worker.terminate()` does not reliably stop a
|
|
4
|
+
* WASM loop there. `forge.javascript.host` can force either one for diagnosis. The only runtime
|
|
5
|
+
* detection in Forja lives here and reads `process.versions.bun`, not a Bun global.
|
|
6
|
+
*/
|
|
7
|
+
import type { IsolateHost } from "../../ports.ts";
|
|
8
|
+
import { type ProcessHostOptions } from "./process-host.ts";
|
|
9
|
+
import { type WorkerHostOptions } from "./worker-host.ts";
|
|
10
|
+
export type HostChoice = "auto" | "worker" | "process";
|
|
11
|
+
export declare function chooseHostKind(choice: HostChoice, bun?: boolean): "worker" | "process";
|
|
12
|
+
export interface IsolateHostFactoryOptions {
|
|
13
|
+
choice: HostChoice;
|
|
14
|
+
/** The verified, compiled module (the worker receives it; the child compiles its own copy). */
|
|
15
|
+
module: () => Promise<WebAssembly.Module>;
|
|
16
|
+
worker?: Omit<WorkerHostOptions, "module">;
|
|
17
|
+
process?: ProcessHostOptions;
|
|
18
|
+
bun?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare class IsolateHostFactory {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(options: IsolateHostFactoryOptions);
|
|
23
|
+
get kind(): "worker" | "process";
|
|
24
|
+
create(): Promise<IsolateHost>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ProcessIsolateHost } from "./process-host.js";
|
|
2
|
+
import { WorkerIsolateHost } from "./worker-host.js";
|
|
3
|
+
export function chooseHostKind(choice, bun = !!process.versions.bun) {
|
|
4
|
+
if (choice !== "auto")
|
|
5
|
+
return choice;
|
|
6
|
+
return bun ? "process" : "worker";
|
|
7
|
+
}
|
|
8
|
+
export class IsolateHostFactory {
|
|
9
|
+
#options;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.#options = options;
|
|
12
|
+
}
|
|
13
|
+
get kind() {
|
|
14
|
+
return chooseHostKind(this.#options.choice, this.#options.bun);
|
|
15
|
+
}
|
|
16
|
+
async create() {
|
|
17
|
+
if (this.kind === "process")
|
|
18
|
+
return new ProcessIsolateHost(this.#options.process);
|
|
19
|
+
return new WorkerIsolateHost({ ...this.#options.worker, module: await this.#options.module() });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Length-prefixed JSON framing of the process host (spec §6.3, D10): a big-endian u32 byte count
|
|
3
|
+
* followed by that many bytes of UTF-8 JSON. Pure. A frame over the limit or a body that is not
|
|
4
|
+
* JSON breaks the stream: the caller treats it as a protocol violation and kills the child.
|
|
5
|
+
*
|
|
6
|
+
* Erasable TypeScript only (D-33): the isolate process loads this file under type stripping.
|
|
7
|
+
*/
|
|
8
|
+
export declare function encodeFrame(message: unknown): Buffer;
|
|
9
|
+
export declare class FrameStreamError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export declare class FrameDecoder {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(maxFrameBytes: number);
|
|
15
|
+
/** Adds bytes and returns every message completed by them. Throws `FrameStreamError`. */
|
|
16
|
+
push(chunk: Uint8Array): unknown[];
|
|
17
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Length-prefixed JSON framing of the process host (spec §6.3, D10): a big-endian u32 byte count
|
|
3
|
+
* followed by that many bytes of UTF-8 JSON. Pure. A frame over the limit or a body that is not
|
|
4
|
+
* JSON breaks the stream: the caller treats it as a protocol violation and kills the child.
|
|
5
|
+
*
|
|
6
|
+
* Erasable TypeScript only (D-33): the isolate process loads this file under type stripping.
|
|
7
|
+
*/
|
|
8
|
+
export function encodeFrame(message) {
|
|
9
|
+
const body = Buffer.from(JSON.stringify(message), "utf8");
|
|
10
|
+
const header = Buffer.allocUnsafe(4);
|
|
11
|
+
header.writeUInt32BE(body.length, 0);
|
|
12
|
+
return Buffer.concat([header, body]);
|
|
13
|
+
}
|
|
14
|
+
export class FrameStreamError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "FrameStreamError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class FrameDecoder {
|
|
21
|
+
#buffer = Buffer.alloc(0);
|
|
22
|
+
#maxFrameBytes;
|
|
23
|
+
constructor(maxFrameBytes) {
|
|
24
|
+
this.#maxFrameBytes = maxFrameBytes;
|
|
25
|
+
}
|
|
26
|
+
/** Adds bytes and returns every message completed by them. Throws `FrameStreamError`. */
|
|
27
|
+
push(chunk) {
|
|
28
|
+
this.#buffer =
|
|
29
|
+
this.#buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.#buffer, chunk]);
|
|
30
|
+
const messages = [];
|
|
31
|
+
while (this.#buffer.length >= 4) {
|
|
32
|
+
const size = this.#buffer.readUInt32BE(0);
|
|
33
|
+
// The size is checked before the body is buffered: a hostile length never allocates.
|
|
34
|
+
if (size > this.#maxFrameBytes)
|
|
35
|
+
throw new FrameStreamError(`frame of ${size} bytes exceeds the ${this.#maxFrameBytes} limit`);
|
|
36
|
+
if (this.#buffer.length < 4 + size)
|
|
37
|
+
break;
|
|
38
|
+
const body = this.#buffer.subarray(4, 4 + size).toString("utf8");
|
|
39
|
+
this.#buffer = this.#buffer.subarray(4 + size);
|
|
40
|
+
try {
|
|
41
|
+
messages.push(JSON.parse(body));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new FrameStreamError("frame is not valid JSON");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return messages;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface WorkerIsolateData {
|
|
2
|
+
forgeIsolate: 1;
|
|
3
|
+
module: WebAssembly.Module;
|
|
4
|
+
flag: SharedArrayBuffer;
|
|
5
|
+
}
|
|
6
|
+
/** Body of a worker thread started by `WorkerIsolateHost`. */
|
|
7
|
+
export declare function runWorkerIsolate(): Promise<void>;
|
|
8
|
+
/** Body of the child process started by `ProcessIsolateHost` (or by `alisio __forge-isolate`). */
|
|
9
|
+
export declare function runProcessIsolate(env?: Record<string, string | undefined>): Promise<void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What runs inside an isolate (spec §6.3, D-20, D-21): the worker thread body and the child
|
|
3
|
+
* process body. Both build a `GuestTransport` over their channel and hand it to `runGuest`.
|
|
4
|
+
*
|
|
5
|
+
* The process variant refuses to run unless `ALISIO_FORGE_ISOLATE=1` is set (the host sets it
|
|
6
|
+
* with an otherwise empty environment), so the hidden `__forge-isolate` command cannot be
|
|
7
|
+
* mistaken for something a user runs. It talks only through stdin/stdout frames and exits when
|
|
8
|
+
* the execution reaches its terminal message.
|
|
9
|
+
*
|
|
10
|
+
* Erasable TypeScript only (D-33).
|
|
11
|
+
*/
|
|
12
|
+
import { isMainThread, parentPort, workerData } from "node:worker_threads";
|
|
13
|
+
import { runGuest } from "../vm-runner.js";
|
|
14
|
+
import { loadQuickJsModule } from "../wasm.js";
|
|
15
|
+
import { encodeFrame, FrameDecoder } from "./framing.js";
|
|
16
|
+
/** Body of a worker thread started by `WorkerIsolateHost`. */
|
|
17
|
+
export async function runWorkerIsolate() {
|
|
18
|
+
const data = workerData;
|
|
19
|
+
if (isMainThread || !parentPort || data?.forgeIsolate !== 1)
|
|
20
|
+
return;
|
|
21
|
+
const port = parentPort;
|
|
22
|
+
await runGuest({
|
|
23
|
+
module: data.module,
|
|
24
|
+
flag: new Int32Array(data.flag),
|
|
25
|
+
transport: {
|
|
26
|
+
send: (message) => port.postMessage(message),
|
|
27
|
+
onMessage: (handler) => port.on("message", handler),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
// The terminal message is posted; leaving the thread lets the parent observe a clean `exit`.
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
/** Body of the child process started by `ProcessIsolateHost` (or by `alisio __forge-isolate`). */
|
|
34
|
+
export async function runProcessIsolate(env = process.env) {
|
|
35
|
+
if (env.ALISIO_FORGE_ISOLATE !== "1") {
|
|
36
|
+
process.stderr.write("__forge-isolate is internal and is started by the Forja process host\n");
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
const module = await loadQuickJsModule();
|
|
40
|
+
const decoder = new FrameDecoder(8 * 1024 * 1024);
|
|
41
|
+
let handler = () => { };
|
|
42
|
+
process.stdin.on("data", (chunk) => {
|
|
43
|
+
try {
|
|
44
|
+
for (const message of decoder.push(chunk))
|
|
45
|
+
handler(message);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
process.exit(3);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
process.stdin.on("end", () => process.exit(0));
|
|
52
|
+
await runGuest({
|
|
53
|
+
module,
|
|
54
|
+
transport: {
|
|
55
|
+
send: (message) => process.stdout.write(encodeFrame(message)),
|
|
56
|
+
onMessage: (next) => {
|
|
57
|
+
handler = next;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
// Exit once the last frame is flushed.
|
|
62
|
+
process.stdout.write("", () => process.exit(0));
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { IsolateHost, IsolateSession } from "../../ports.ts";
|
|
2
|
+
export declare const PROCESS_KILL_BACKSTOP_MS = 500;
|
|
3
|
+
/**
|
|
4
|
+
* The isolate's whole environment: the marker, and on Windows `SystemRoot` and nothing else (the
|
|
5
|
+
* runtime cannot start without it). No other variable of the parent ever reaches the guest process.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isolateEnv(platform?: NodeJS.Platform, parent?: NodeJS.ProcessEnv): Record<string, string>;
|
|
8
|
+
export interface IsolateCommand {
|
|
9
|
+
execPath: string;
|
|
10
|
+
args: string[];
|
|
11
|
+
}
|
|
12
|
+
/** Whether this process is a binary compiled by `bun build --compile`. */
|
|
13
|
+
export declare function isBunCompiled(argv1?: string | undefined): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* How to start an isolate process. A compiled binary re-runs itself with the hidden
|
|
16
|
+
* `__forge-isolate` command (its virtual files cannot be spawned); otherwise the process entry file
|
|
17
|
+
* next to this one runs directly (`.ts` through type stripping, `.js` from `dist`).
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveIsolateCommand(options?: {
|
|
20
|
+
compiled?: boolean;
|
|
21
|
+
base?: string;
|
|
22
|
+
execPath?: string;
|
|
23
|
+
}): IsolateCommand;
|
|
24
|
+
export interface ProcessHostOptions {
|
|
25
|
+
command?: IsolateCommand;
|
|
26
|
+
backstopMs?: number;
|
|
27
|
+
}
|
|
28
|
+
export declare class ProcessIsolateHost implements IsolateHost {
|
|
29
|
+
#private;
|
|
30
|
+
readonly kind: "process";
|
|
31
|
+
constructor(options?: ProcessHostOptions);
|
|
32
|
+
start(): IsolateSession;
|
|
33
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ProcessIsolateHost` (spec §6.3, D-20, D-21): the VM runs in a child process of the same
|
|
3
|
+
* executable (Bun, where `worker.terminate()` cannot be trusted to stop WASM).
|
|
4
|
+
*
|
|
5
|
+
* The child gets an EMPTY environment but `ALISIO_FORGE_ISOLATE=1` (see `isolateEnv`), no inherited descriptors
|
|
6
|
+
* (stdin and stdout are the frame channel, stderr is dropped) and a private working directory.
|
|
7
|
+
* There is no shared memory with it, so:
|
|
8
|
+
* - a user cancellation is `SIGKILL` at once;
|
|
9
|
+
* - a deadline relies on the guest's own interrupt handler, with `SIGKILL` as a backstop 500 ms
|
|
10
|
+
* after the deadline passed.
|
|
11
|
+
* The stop is only "confirmed" when the child's `exit` is seen.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { MAX_MESSAGE_BYTES } from "../../protocol.js";
|
|
19
|
+
import { encodeFrame, FrameDecoder, FrameStreamError } from "./framing.js";
|
|
20
|
+
export const PROCESS_KILL_BACKSTOP_MS = 500;
|
|
21
|
+
const EXIT_CONFIRM_MS = 2_000;
|
|
22
|
+
/**
|
|
23
|
+
* The isolate's whole environment: the marker, and on Windows `SystemRoot` and nothing else (the
|
|
24
|
+
* runtime cannot start without it). No other variable of the parent ever reaches the guest process.
|
|
25
|
+
*/
|
|
26
|
+
export function isolateEnv(platform = process.platform, parent = process.env) {
|
|
27
|
+
const env = { ALISIO_FORGE_ISOLATE: "1" };
|
|
28
|
+
if (platform === "win32" && parent.SystemRoot)
|
|
29
|
+
env.SystemRoot = parent.SystemRoot;
|
|
30
|
+
return env;
|
|
31
|
+
}
|
|
32
|
+
/** Whether this process is a binary compiled by `bun build --compile`. */
|
|
33
|
+
export function isBunCompiled(argv1 = process.argv[1]) {
|
|
34
|
+
return !!process.versions.bun && /^(\/\$bunfs\/|[A-Za-z]:[\\/]+~BUN[\\/])/.test(argv1 ?? "");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* How to start an isolate process. A compiled binary re-runs itself with the hidden
|
|
38
|
+
* `__forge-isolate` command (its virtual files cannot be spawned); otherwise the process entry file
|
|
39
|
+
* next to this one runs directly (`.ts` through type stripping, `.js` from `dist`).
|
|
40
|
+
*/
|
|
41
|
+
export function resolveIsolateCommand(options = {}) {
|
|
42
|
+
const execPath = options.execPath ?? process.execPath;
|
|
43
|
+
if (options.compiled ?? isBunCompiled())
|
|
44
|
+
return { execPath, args: ["__forge-isolate"] };
|
|
45
|
+
const base = options.base ?? import.meta.url;
|
|
46
|
+
const typescript = base.endsWith(".ts");
|
|
47
|
+
const entry = fileURLToPath(new URL(`./process-entry.${typescript ? "ts" : "js"}`, base));
|
|
48
|
+
const stripFlags = typescript && !process.versions.bun
|
|
49
|
+
? ["--experimental-strip-types", "--disable-warning=ExperimentalWarning"]
|
|
50
|
+
: [];
|
|
51
|
+
return { execPath, args: [...stripFlags, entry] };
|
|
52
|
+
}
|
|
53
|
+
export class ProcessIsolateHost {
|
|
54
|
+
kind = "process";
|
|
55
|
+
#options;
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
this.#options = options;
|
|
58
|
+
}
|
|
59
|
+
start() {
|
|
60
|
+
const command = this.#options.command ?? resolveIsolateCommand();
|
|
61
|
+
const backstopMs = this.#options.backstopMs ?? PROCESS_KILL_BACKSTOP_MS;
|
|
62
|
+
const cwd = mkdtempSync(join(tmpdir(), "alisio-forge-"));
|
|
63
|
+
const child = spawn(command.execPath, command.args, {
|
|
64
|
+
cwd,
|
|
65
|
+
env: isolateEnv(),
|
|
66
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
});
|
|
69
|
+
let messageHandler = () => { };
|
|
70
|
+
let streamErrorHandler = () => { };
|
|
71
|
+
let exitState;
|
|
72
|
+
let spawnError;
|
|
73
|
+
const exited = new Promise((resolve) => {
|
|
74
|
+
let settled = false;
|
|
75
|
+
const finish = (exit) => {
|
|
76
|
+
if (settled)
|
|
77
|
+
return;
|
|
78
|
+
settled = true;
|
|
79
|
+
exitState = exit;
|
|
80
|
+
try {
|
|
81
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* A leftover empty directory in the temporary folder is harmless. */
|
|
85
|
+
}
|
|
86
|
+
resolve(exit);
|
|
87
|
+
};
|
|
88
|
+
child.once("error", (error) => {
|
|
89
|
+
spawnError = error.message;
|
|
90
|
+
finish({ code: null, signal: null, error: error.message });
|
|
91
|
+
});
|
|
92
|
+
child.once("exit", (code, signal) => finish({ code, signal, ...(spawnError ? { error: spawnError } : {}) }));
|
|
93
|
+
});
|
|
94
|
+
const decoder = new FrameDecoder(MAX_MESSAGE_BYTES + 4096);
|
|
95
|
+
child.stdout?.on("data", (chunk) => {
|
|
96
|
+
try {
|
|
97
|
+
for (const message of decoder.push(chunk))
|
|
98
|
+
messageHandler(message);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error instanceof FrameStreamError) {
|
|
102
|
+
streamErrorHandler(error.message);
|
|
103
|
+
child.kill("SIGKILL");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
// The child may be killed while a frame is being written.
|
|
110
|
+
child.stdin?.on("error", () => { });
|
|
111
|
+
child.stdout?.on("error", () => { });
|
|
112
|
+
const waitExit = async () => {
|
|
113
|
+
let timer;
|
|
114
|
+
const confirmed = await Promise.race([
|
|
115
|
+
exited.then(() => true),
|
|
116
|
+
new Promise((resolve) => {
|
|
117
|
+
timer = setTimeout(() => resolve(false), EXIT_CONFIRM_MS);
|
|
118
|
+
}),
|
|
119
|
+
]);
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
return confirmed;
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
kind: "process",
|
|
125
|
+
onMessage(handler) {
|
|
126
|
+
messageHandler = handler;
|
|
127
|
+
},
|
|
128
|
+
onStreamError(handler) {
|
|
129
|
+
streamErrorHandler = handler;
|
|
130
|
+
},
|
|
131
|
+
send(message) {
|
|
132
|
+
if (!exitState && child.stdin?.writable)
|
|
133
|
+
child.stdin.write(encodeFrame(message));
|
|
134
|
+
},
|
|
135
|
+
exited,
|
|
136
|
+
async stop(reason) {
|
|
137
|
+
const started = performance.now();
|
|
138
|
+
if (exitState)
|
|
139
|
+
return { confirmed: true, method: "kill", elapsedMs: 0 };
|
|
140
|
+
let backstop;
|
|
141
|
+
if (reason === "timeout") {
|
|
142
|
+
// The guest's own deadline should end it first; this is the backstop.
|
|
143
|
+
backstop = setTimeout(() => child.kill("SIGKILL"), backstopMs);
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
child.kill("SIGKILL");
|
|
147
|
+
const confirmed = await waitExit();
|
|
148
|
+
clearTimeout(backstop);
|
|
149
|
+
if (!confirmed)
|
|
150
|
+
child.kill("SIGKILL");
|
|
151
|
+
return {
|
|
152
|
+
confirmed,
|
|
153
|
+
method: "kill",
|
|
154
|
+
elapsedMs: Math.round(performance.now() - started),
|
|
155
|
+
...(confirmed ? {} : { settled: exited.then(() => undefined) }),
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IsolateHost, IsolateSession } from "../../ports.ts";
|
|
2
|
+
export declare const WORKER_TERMINATE_AFTER_MS = 50;
|
|
3
|
+
/** The worker entry next to this file: `.ts` when running from sources, `.js` from `dist`. */
|
|
4
|
+
export declare function workerEntryUrl(base?: string): URL;
|
|
5
|
+
export interface WorkerHostOptions {
|
|
6
|
+
module: WebAssembly.Module;
|
|
7
|
+
/** Overrides the entry (tests of failure paths). */
|
|
8
|
+
entry?: URL;
|
|
9
|
+
terminateAfterMs?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare class WorkerIsolateHost implements IsolateHost {
|
|
12
|
+
#private;
|
|
13
|
+
readonly kind: "worker";
|
|
14
|
+
constructor(options: WorkerHostOptions);
|
|
15
|
+
start(): IsolateSession;
|
|
16
|
+
}
|
|
17
|
+
/** Path of the worker entry, for diagnostics. */
|
|
18
|
+
export declare const workerEntryPath: () => string;
|