@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,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PyodideBackend` (Python spec §4.3, §8): Pyodide in a child `node` started with the Permission
|
|
3
|
+
* Model. `isolation: "wasm-process"`: the file system is limited to the Pyodide folder and the job (the scratchpad as a link-free copy),
|
|
4
|
+
* there are no child processes, workers or addons; the network is NOT restricted on Node 22/24
|
|
5
|
+
* (the self-probe says what this machine does) and Python reaches the runner's JavaScript through
|
|
6
|
+
* the FFI. Memory is bounded by an RSS watchdog (soft), time by the kernel's deadline plus a startup
|
|
7
|
+
* margin, and termination is SIGKILL of the child, confirmed by its exit (PD13).
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { killProcessTree, runProcess } from "../../runtime/process.js";
|
|
13
|
+
import { classifyModules, scanImports } from "./import-scan.js";
|
|
14
|
+
import { inspectPyodide, pyodideDir, verifyPyodideFiles, } from "./pyodide-install.js";
|
|
15
|
+
import { PYODIDE_MANIFEST, PYODIDE_PROFILE_ORDER, } from "./pyodide-manifest.js";
|
|
16
|
+
import { PYODIDE_RUNNER_SOURCE } from "./pyodide-runner-source.js";
|
|
17
|
+
import { readRssMb } from "./rss.js";
|
|
18
|
+
import { copyScratchNoFollow } from "./scratch-copy.js";
|
|
19
|
+
import { settle } from "./settle.js";
|
|
20
|
+
import { childEnv, currentNodeEnvironment, permissionArgs, probeNetwork, resolveNode, } from "./wasm-node.js";
|
|
21
|
+
/** How often the watchdog samples the child (measured in P0: up to one tick of overshoot). */
|
|
22
|
+
const SAMPLE_MS = 250;
|
|
23
|
+
/** How often the parent looks for the runner's ready file. */
|
|
24
|
+
const READY_POLL_MS = 50;
|
|
25
|
+
const PROFILE_FIX = (profile) => `alisio analysis setup --wasm${profile === "science" ? " --profile science" : ""}`;
|
|
26
|
+
/** Keys needed to load `wanted` (and what they depend on), in the manifest's load order. */
|
|
27
|
+
export function loadPlan(manifest, profile, wanted) {
|
|
28
|
+
const needed = new Set();
|
|
29
|
+
const add = (key) => {
|
|
30
|
+
if (needed.has(key))
|
|
31
|
+
return;
|
|
32
|
+
const pkg = manifest.packages[key];
|
|
33
|
+
if (!pkg)
|
|
34
|
+
return;
|
|
35
|
+
needed.add(key);
|
|
36
|
+
for (const dep of pkg.depends)
|
|
37
|
+
add(dep);
|
|
38
|
+
};
|
|
39
|
+
for (const key of wanted)
|
|
40
|
+
add(key);
|
|
41
|
+
const ordered = manifest.profiles[profile].filter((key) => needed.has(key));
|
|
42
|
+
const packages = [];
|
|
43
|
+
const wheels = [];
|
|
44
|
+
const files = [];
|
|
45
|
+
for (const key of ordered) {
|
|
46
|
+
const pkg = manifest.packages[key];
|
|
47
|
+
files.push(pkg);
|
|
48
|
+
if (pkg.source === "pyodide")
|
|
49
|
+
packages.push(key);
|
|
50
|
+
else
|
|
51
|
+
wheels.push(pkg.name);
|
|
52
|
+
}
|
|
53
|
+
return { packages, wheels, files };
|
|
54
|
+
}
|
|
55
|
+
export class PyodideBackend {
|
|
56
|
+
id = "wasm";
|
|
57
|
+
#deps;
|
|
58
|
+
#manifest;
|
|
59
|
+
/** The profile found installed by the last `check()`; `supports` is synchronous and uses it. */
|
|
60
|
+
#installed;
|
|
61
|
+
constructor(deps) {
|
|
62
|
+
this.#deps = deps;
|
|
63
|
+
this.#manifest = deps.manifest ?? PYODIDE_MANIFEST;
|
|
64
|
+
}
|
|
65
|
+
get #dir() {
|
|
66
|
+
return pyodideDir(this.#deps.stateRoot, this.#manifest);
|
|
67
|
+
}
|
|
68
|
+
#node() {
|
|
69
|
+
return resolveNode(this.#deps.settings().nodePath, (this.#deps.environment ?? currentNodeEnvironment)());
|
|
70
|
+
}
|
|
71
|
+
async check(_signal) {
|
|
72
|
+
const node = await this.#node();
|
|
73
|
+
if (!node.ok) {
|
|
74
|
+
this.#installed = undefined;
|
|
75
|
+
return { ready: false, reason: node.reason, fix: "" };
|
|
76
|
+
}
|
|
77
|
+
const installation = await inspectPyodide(this.#dir, this.#manifest);
|
|
78
|
+
this.#installed = installation.ok ? installation.profile : undefined;
|
|
79
|
+
return installation.ok ? { ready: true } : { ready: false, reason: installation.reason };
|
|
80
|
+
}
|
|
81
|
+
/** Static facts plus the observed network answer; `ready` is the live state of `check()`. */
|
|
82
|
+
async describe() {
|
|
83
|
+
const state = await this.check();
|
|
84
|
+
let networkDeniedByOS = false;
|
|
85
|
+
if (state.ready) {
|
|
86
|
+
const node = await this.#node();
|
|
87
|
+
if (node.ok) {
|
|
88
|
+
const dir = this.#dir;
|
|
89
|
+
networkDeniedByOS = (await probeNetwork(node, permissionArgs({ read: [dir], write: [] })))
|
|
90
|
+
.denied;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
id: "python-wasm",
|
|
95
|
+
language: "python",
|
|
96
|
+
version: `pyodide-${this.#manifest.version} (python ${this.#manifest.python})`,
|
|
97
|
+
isolation: "wasm-process",
|
|
98
|
+
// Registered once: readiness is checked on every call (like the other backends).
|
|
99
|
+
ready: true,
|
|
100
|
+
...(state.ready ? {} : { reason: state.reason }),
|
|
101
|
+
capabilities: {
|
|
102
|
+
cooperativeInterrupt: false,
|
|
103
|
+
hardTermination: true,
|
|
104
|
+
hardMemoryLimit: false,
|
|
105
|
+
networkDeniedByOS,
|
|
106
|
+
toolCalls: false,
|
|
107
|
+
},
|
|
108
|
+
profiles: this.#installed
|
|
109
|
+
? PYODIDE_PROFILE_ORDER.slice(0, PYODIDE_PROFILE_ORDER.indexOf(this.#installed) + 1).map(String)
|
|
110
|
+
: [],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
supports(requirements) {
|
|
114
|
+
const { maxInputBytes } = this.#deps.settings();
|
|
115
|
+
if (requirements.inputBytes > maxInputBytes)
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
code: "INPUT_TOO_LARGE",
|
|
119
|
+
reason: `the inputs are ${Math.ceil(requirements.inputBytes / (1024 * 1024))} MB, over analysis.wasm.maxInputBytes (${Math.floor(maxInputBytes / (1024 * 1024))} MB)`,
|
|
120
|
+
};
|
|
121
|
+
const found = classifyModules(requirements.importedModules);
|
|
122
|
+
if (found.unknown.length)
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
code: "PACKAGE_UNAVAILABLE",
|
|
126
|
+
reason: `module${found.unknown.length === 1 ? "" : "s"} ${found.unknown.join(", ")} ${found.unknown.length === 1 ? "is" : "are"} not in the wasm profiles`,
|
|
127
|
+
};
|
|
128
|
+
const wanted = [found.profile, ...requirements.extras].reduce((top, p) => (PYODIDE_PROFILE_ORDER.indexOf(p) > PYODIDE_PROFILE_ORDER.indexOf(top) ? p : top), "base");
|
|
129
|
+
if (this.#installed === undefined)
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
code: "NOT_READY",
|
|
133
|
+
reason: "Pyodide is not installed",
|
|
134
|
+
fix: PROFILE_FIX("analysis"),
|
|
135
|
+
};
|
|
136
|
+
if (PYODIDE_PROFILE_ORDER.indexOf(wanted) > PYODIDE_PROFILE_ORDER.indexOf(this.#installed))
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
code: "PROFILE_UNSUPPORTED",
|
|
140
|
+
reason: `this script needs the ${wanted} profile and ${this.#installed} is installed`,
|
|
141
|
+
fix: PROFILE_FIX(wanted),
|
|
142
|
+
};
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
async launch(job, sink) {
|
|
146
|
+
const settings = this.#deps.settings();
|
|
147
|
+
const node = await this.#node();
|
|
148
|
+
if (!node.ok)
|
|
149
|
+
throw new Error(`runtime_unavailable: ${node.reason}`);
|
|
150
|
+
const installation = await inspectPyodide(this.#dir, this.#manifest);
|
|
151
|
+
if (!installation.ok)
|
|
152
|
+
throw new Error(`runtime_unavailable: ${installation.reason}`);
|
|
153
|
+
// What this script loads: its imports, nothing of the profile it does not use.
|
|
154
|
+
const source = await readFile(job.paths.main, "utf8");
|
|
155
|
+
const wantedPackages = classifyModules(scanImports(source)).packages;
|
|
156
|
+
const plan = loadPlan(this.#manifest, installation.profile, wantedPackages);
|
|
157
|
+
const mismatch = await verifyPyodideFiles(installation.dir, [
|
|
158
|
+
...this.#manifest.core,
|
|
159
|
+
...plan.files,
|
|
160
|
+
]);
|
|
161
|
+
if (mismatch)
|
|
162
|
+
throw new Error(`runtime_unavailable: Pyodide file ${mismatch} does not match its pinned SHA-256; run "alisio analysis setup --wasm" again`);
|
|
163
|
+
// The runner is written next to the distribution so the restricted child may read it.
|
|
164
|
+
const runnerName = `runner-${createHash("sha256").update(PYODIDE_RUNNER_SOURCE).digest("hex").slice(0, 12)}.mjs`;
|
|
165
|
+
const runner = join(installation.dir, runnerName);
|
|
166
|
+
if ((await readFile(runner, "utf8").catch(() => undefined)) !== PYODIDE_RUNNER_SOURCE) {
|
|
167
|
+
const temporary = `${runner}.${process.pid}.tmp`;
|
|
168
|
+
await writeFile(temporary, PYODIDE_RUNNER_SOURCE);
|
|
169
|
+
await rename(temporary, runner);
|
|
170
|
+
}
|
|
171
|
+
const real = (path) => realpath(path);
|
|
172
|
+
// The Permission Model follows links that already exist, so the real scratchpad is never
|
|
173
|
+
// granted: the child gets a copy made here without following any link (M1).
|
|
174
|
+
const scratchCopy = join(job.paths.root, "scratch-copy");
|
|
175
|
+
let scratch;
|
|
176
|
+
if (job.scratch) {
|
|
177
|
+
const source = await real(job.scratch).catch(() => undefined);
|
|
178
|
+
if (source) {
|
|
179
|
+
await rm(scratchCopy, { recursive: true, force: true });
|
|
180
|
+
await copyScratchNoFollow(source, scratchCopy);
|
|
181
|
+
scratch = await real(scratchCopy);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const [dist, script, input, work, staging] = await Promise.all([
|
|
185
|
+
real(installation.dir),
|
|
186
|
+
real(job.paths.script),
|
|
187
|
+
real(job.paths.input),
|
|
188
|
+
real(job.paths.work),
|
|
189
|
+
real(job.paths.staging),
|
|
190
|
+
]);
|
|
191
|
+
await mkdir(staging, { recursive: true });
|
|
192
|
+
const spec = {
|
|
193
|
+
dist,
|
|
194
|
+
packages: plan.packages,
|
|
195
|
+
wheels: plan.wheels,
|
|
196
|
+
job: { script, input, work, out: staging, ...(scratch ? { scratch } : {}) },
|
|
197
|
+
};
|
|
198
|
+
const args = [
|
|
199
|
+
...permissionArgs({
|
|
200
|
+
read: [dist, script, input, work, staging, ...(scratch ? [scratch] : [])],
|
|
201
|
+
write: [work, staging],
|
|
202
|
+
}),
|
|
203
|
+
join(dist, runnerName),
|
|
204
|
+
JSON.stringify(spec),
|
|
205
|
+
];
|
|
206
|
+
const stop = new AbortController();
|
|
207
|
+
const memory = {
|
|
208
|
+
peakMb: 0,
|
|
209
|
+
};
|
|
210
|
+
const runtime = {
|
|
211
|
+
mode: "wasm",
|
|
212
|
+
pyodide: this.#manifest.version,
|
|
213
|
+
python: this.#manifest.python,
|
|
214
|
+
node: node.version,
|
|
215
|
+
profile: installation.profile,
|
|
216
|
+
extras: [],
|
|
217
|
+
packages: [...plan.packages, ...plan.wheels],
|
|
218
|
+
memoryMb: settings.memoryMb,
|
|
219
|
+
};
|
|
220
|
+
const spawnedAt = Date.now();
|
|
221
|
+
// The runner leaves a file when Pyodide and the packages are loaded: its modification time
|
|
222
|
+
// ends the startup, whenever the parent happens to look.
|
|
223
|
+
const readyFile = join(work, ".forge", "wasm-ready");
|
|
224
|
+
const readStartup = async () => {
|
|
225
|
+
try {
|
|
226
|
+
const info = await stat(readyFile);
|
|
227
|
+
runtime.startupMs ??= Math.max(0, Math.round(info.mtimeMs - spawnedAt));
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const watchReady = setInterval(() => {
|
|
235
|
+
void readStartup().then((found) => found && clearInterval(watchReady));
|
|
236
|
+
}, READY_POLL_MS);
|
|
237
|
+
watchReady.unref?.();
|
|
238
|
+
let sampler;
|
|
239
|
+
const read = this.#deps.rss ?? readRssMb;
|
|
240
|
+
const sample = async (pid) => {
|
|
241
|
+
const rss = await read(pid);
|
|
242
|
+
if (rss === undefined)
|
|
243
|
+
return;
|
|
244
|
+
memory.peakMb = Math.max(memory.peakMb, rss);
|
|
245
|
+
runtime.peakRssMb = Math.round(memory.peakMb);
|
|
246
|
+
if (rss > settings.memoryMb && !memory.hit) {
|
|
247
|
+
memory.hit = {
|
|
248
|
+
code: "MEMORY_LIMIT",
|
|
249
|
+
message: `the wasm runtime used ${Math.round(rss)} MB, over analysis.wasm.memoryMb (${settings.memoryMb} MB)`,
|
|
250
|
+
};
|
|
251
|
+
killProcessTree(pid);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const result = runProcess(node.path, args, {
|
|
255
|
+
cwd: work,
|
|
256
|
+
signal: AbortSignal.any([job.signal, stop.signal]),
|
|
257
|
+
timeoutMs: job.backstopMs,
|
|
258
|
+
maxBytes: job.maxBytes,
|
|
259
|
+
onOverflow: "truncate",
|
|
260
|
+
logFiles: job.logFiles,
|
|
261
|
+
maxLogBytes: job.maxLogBytes,
|
|
262
|
+
// Kill, don't ask: Pyodide has no cooperative interrupt (PD13).
|
|
263
|
+
killGraceMs: 1,
|
|
264
|
+
env: childEnv({ ...job.env, ALISIO_EXECUTION_ID: job.executionId }),
|
|
265
|
+
onSpawn: (pid) => {
|
|
266
|
+
sampler = setInterval(() => void sample(pid), this.#deps.sampleMs ?? SAMPLE_MS);
|
|
267
|
+
sampler.unref?.();
|
|
268
|
+
},
|
|
269
|
+
onData: sink.onData,
|
|
270
|
+
}).finally(async () => {
|
|
271
|
+
clearInterval(sampler);
|
|
272
|
+
clearInterval(watchReady);
|
|
273
|
+
await readStartup();
|
|
274
|
+
if (scratch)
|
|
275
|
+
await rm(scratchCopy, { recursive: true, force: true }).catch(() => undefined);
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
result,
|
|
279
|
+
stop,
|
|
280
|
+
runtime,
|
|
281
|
+
limit: () => memory.hit,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
async terminate(process, _reason) {
|
|
285
|
+
const started = Date.now();
|
|
286
|
+
process.stop.abort();
|
|
287
|
+
return settle(process, "kill", started);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type PyodideFile, type PyodideManifest, type PyodideProfile } from "./pyodide-manifest.ts";
|
|
2
|
+
/** `<state root>/analysis/pyodide/<version>`. */
|
|
3
|
+
export declare const pyodideDir: (stateRoot: string, manifest?: PyodideManifest) => string;
|
|
4
|
+
/** The files of a profile: the runtime plus the wheels of its closure, in load order. */
|
|
5
|
+
export declare function profileFiles(manifest: PyodideManifest, profile: PyodideProfile): Array<PyodideFile & {
|
|
6
|
+
key?: string;
|
|
7
|
+
}>;
|
|
8
|
+
export declare const downloadSize: (manifest: PyodideManifest, profile: PyodideProfile) => number;
|
|
9
|
+
export type PyodideInspection = {
|
|
10
|
+
ok: true;
|
|
11
|
+
dir: string;
|
|
12
|
+
profile: PyodideProfile;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
reason: string;
|
|
16
|
+
};
|
|
17
|
+
/** Is the distribution there, complete for what it claims, and of this manifest's version? */
|
|
18
|
+
export declare function inspectPyodide(dir: string, manifest?: PyodideManifest): Promise<PyodideInspection>;
|
|
19
|
+
/** Hashes the given files of an installation; returns the first that does not match its pin. */
|
|
20
|
+
export declare function verifyPyodideFiles(dir: string, files: readonly PyodideFile[]): Promise<string | undefined>;
|
|
21
|
+
export interface InstallPyodideOptions {
|
|
22
|
+
/** `<state root>/analysis/pyodide/<version>`. */
|
|
23
|
+
dir: string;
|
|
24
|
+
profile: PyodideProfile;
|
|
25
|
+
manifest?: PyodideManifest;
|
|
26
|
+
/** Replaceable in tests; the global `fetch` otherwise. */
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
onProgress?: (event: {
|
|
30
|
+
file: string;
|
|
31
|
+
index: number;
|
|
32
|
+
total: number;
|
|
33
|
+
}) => void;
|
|
34
|
+
}
|
|
35
|
+
/** A download whose bytes are not the pinned ones. Nothing from it is kept. */
|
|
36
|
+
export declare class PyodideIntegrityError extends Error {
|
|
37
|
+
readonly file: string;
|
|
38
|
+
constructor(file: string, message: string);
|
|
39
|
+
}
|
|
40
|
+
/** Downloads, verifies and installs the profile. Throws on any failure, leaving no partial files. */
|
|
41
|
+
export declare function installPyodide(options: InstallPyodideOptions): Promise<PyodideInspection>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local Pyodide distribution (Python spec §8.1, PD7). `installPyodide` is the ONLY code that
|
|
3
|
+
* touches the network for the `wasm` backend (`alisio analysis setup --wasm`): it downloads the
|
|
4
|
+
* files of a profile into a staging folder, verifies the SHA-256 and the size of every one and only
|
|
5
|
+
* then moves them into `<analysis dir>/pyodide/<version>/` and writes the readiness marker. Any
|
|
6
|
+
* mismatch deletes what this run downloaded and throws; a distribution that was complete before
|
|
7
|
+
* stays as it was. `inspectPyodide` is the cheap check the backend does before every run.
|
|
8
|
+
* It never downloads and never reads the hash of a file (`verifyPyodideFiles` does that).
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
import { createReadStream } from "node:fs";
|
|
12
|
+
import { mkdir, open, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { PYODIDE_MANIFEST, PYODIDE_PROFILE_ORDER, } from "./pyodide-manifest.js";
|
|
15
|
+
const MARKER = "ready.json";
|
|
16
|
+
/** Staging folders are `.download-<random>`: every run has its own, so runs never touch each other. */
|
|
17
|
+
const STAGING = ".download";
|
|
18
|
+
const STALE_STAGING_MS = 60 * 60 * 1000;
|
|
19
|
+
/** `<state root>/analysis/pyodide/<version>`. */
|
|
20
|
+
export const pyodideDir = (stateRoot, manifest = PYODIDE_MANIFEST) => join(stateRoot, "analysis", "pyodide", manifest.version);
|
|
21
|
+
/** The files of a profile: the runtime plus the wheels of its closure, in load order. */
|
|
22
|
+
export function profileFiles(manifest, profile) {
|
|
23
|
+
return [
|
|
24
|
+
...manifest.core,
|
|
25
|
+
...manifest.profiles[profile].map((key) => {
|
|
26
|
+
const pkg = manifest.packages[key];
|
|
27
|
+
if (!pkg)
|
|
28
|
+
throw new Error(`manifest lists ${key} in a profile but has no file for it`);
|
|
29
|
+
return { ...pkg, key };
|
|
30
|
+
}),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
export const downloadSize = (manifest, profile) => profileFiles(manifest, profile).reduce((sum, f) => sum + f.size, 0);
|
|
34
|
+
const rank = (profile) => PYODIDE_PROFILE_ORDER.indexOf(profile);
|
|
35
|
+
/** Is the distribution there, complete for what it claims, and of this manifest's version? */
|
|
36
|
+
export async function inspectPyodide(dir, manifest = PYODIDE_MANIFEST) {
|
|
37
|
+
let marker;
|
|
38
|
+
try {
|
|
39
|
+
marker = JSON.parse(await readFile(join(dir, MARKER), "utf8"));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { ok: false, reason: "Pyodide is not installed" };
|
|
43
|
+
}
|
|
44
|
+
if (marker.version !== manifest.version || !(marker.profile in manifest.profiles))
|
|
45
|
+
return { ok: false, reason: `the installed Pyodide is not ${manifest.version}` };
|
|
46
|
+
for (const file of profileFiles(manifest, marker.profile)) {
|
|
47
|
+
const info = await stat(join(dir, file.name)).catch(() => undefined);
|
|
48
|
+
if (!info?.isFile() || info.size !== file.size)
|
|
49
|
+
return { ok: false, reason: `Pyodide file ${file.name} is missing or damaged` };
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, dir, profile: marker.profile };
|
|
52
|
+
}
|
|
53
|
+
async function sha256OfFile(path) {
|
|
54
|
+
const hash = createHash("sha256");
|
|
55
|
+
for await (const chunk of createReadStream(path))
|
|
56
|
+
hash.update(chunk);
|
|
57
|
+
return hash.digest("hex");
|
|
58
|
+
}
|
|
59
|
+
/** Hashes the given files of an installation; returns the first that does not match its pin. */
|
|
60
|
+
export async function verifyPyodideFiles(dir, files) {
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
const actual = await sha256OfFile(join(dir, file.name)).catch(() => undefined);
|
|
63
|
+
if (actual !== file.sha256)
|
|
64
|
+
return file.name;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/** A download whose bytes are not the pinned ones. Nothing from it is kept. */
|
|
69
|
+
export class PyodideIntegrityError extends Error {
|
|
70
|
+
file;
|
|
71
|
+
constructor(file, message) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "PyodideIntegrityError";
|
|
74
|
+
this.file = file;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function download(file, target, fetcher, signal) {
|
|
78
|
+
const response = await fetcher(file.url, { ...(signal ? { signal } : {}) });
|
|
79
|
+
if (!response.ok || !response.body)
|
|
80
|
+
throw new Error(`download of ${file.name} failed: HTTP ${response.status} from ${file.url}`);
|
|
81
|
+
const hash = createHash("sha256");
|
|
82
|
+
let size = 0;
|
|
83
|
+
const handle = await open(target, "w", 0o600);
|
|
84
|
+
try {
|
|
85
|
+
for await (const chunk of response.body) {
|
|
86
|
+
size += chunk.byteLength;
|
|
87
|
+
// A server that sends more than was pinned is stopped before it fills the disk.
|
|
88
|
+
if (size > file.size)
|
|
89
|
+
throw new PyodideIntegrityError(file.name, `${file.name} is larger than the pinned ${file.size} bytes`);
|
|
90
|
+
hash.update(chunk);
|
|
91
|
+
await handle.write(chunk);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await handle.close();
|
|
96
|
+
}
|
|
97
|
+
if (size !== file.size)
|
|
98
|
+
throw new PyodideIntegrityError(file.name, `${file.name} is ${size} bytes, the pin says ${file.size}`);
|
|
99
|
+
if (hash.digest("hex") !== file.sha256)
|
|
100
|
+
throw new PyodideIntegrityError(file.name, `${file.name} does not match its pinned SHA-256`);
|
|
101
|
+
}
|
|
102
|
+
/** Downloads, verifies and installs the profile. Throws on any failure, leaving no partial files. */
|
|
103
|
+
export async function installPyodide(options) {
|
|
104
|
+
const manifest = options.manifest ?? PYODIDE_MANIFEST;
|
|
105
|
+
const fetcher = options.fetch ?? fetch;
|
|
106
|
+
const { dir } = options;
|
|
107
|
+
const readMarker = () => readFile(join(dir, MARKER), "utf8")
|
|
108
|
+
.then((text) => JSON.parse(text))
|
|
109
|
+
.catch(() => undefined);
|
|
110
|
+
const previous = await readMarker();
|
|
111
|
+
await mkdir(dir, { recursive: true });
|
|
112
|
+
// Leftovers of a run that was killed, long gone by now; a live run's folder is far younger.
|
|
113
|
+
for (const name of await readdir(dir).catch(() => [])) {
|
|
114
|
+
if (!name.startsWith(`${STAGING}-`))
|
|
115
|
+
continue;
|
|
116
|
+
const info = await stat(join(dir, name)).catch(() => undefined);
|
|
117
|
+
if (info && Date.now() - info.mtimeMs > STALE_STAGING_MS)
|
|
118
|
+
await rm(join(dir, name), { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
const staging = join(dir, `${STAGING}-${randomBytes(6).toString("hex")}`);
|
|
121
|
+
await mkdir(staging, { recursive: true, mode: 0o700 });
|
|
122
|
+
const files = profileFiles(manifest, options.profile);
|
|
123
|
+
try {
|
|
124
|
+
let index = 0;
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
options.signal?.throwIfAborted();
|
|
127
|
+
index++;
|
|
128
|
+
// A file that an earlier install put there and still hashes right is kept.
|
|
129
|
+
const present = await sha256OfFile(join(dir, file.name)).catch(() => undefined);
|
|
130
|
+
if (present === file.sha256)
|
|
131
|
+
continue;
|
|
132
|
+
options.onProgress?.({ file: file.name, index, total: files.length });
|
|
133
|
+
await download(file, join(staging, file.name), fetcher, options.signal);
|
|
134
|
+
}
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
const staged = join(staging, file.name);
|
|
137
|
+
if (await stat(staged).catch(() => undefined))
|
|
138
|
+
await rename(staged, join(dir, file.name));
|
|
139
|
+
}
|
|
140
|
+
// Never downgrade a larger installation that is still there (read again: another run may
|
|
141
|
+
// have finished a larger one while this one downloaded).
|
|
142
|
+
const current = await readMarker();
|
|
143
|
+
const keep = current &&
|
|
144
|
+
current.version === manifest.version &&
|
|
145
|
+
rank(current.profile) > rank(options.profile)
|
|
146
|
+
? current.profile
|
|
147
|
+
: options.profile;
|
|
148
|
+
const marker = {
|
|
149
|
+
version: manifest.version,
|
|
150
|
+
profile: keep,
|
|
151
|
+
files: Object.fromEntries(profileFiles(manifest, keep).map((f) => [f.name, f.size])),
|
|
152
|
+
};
|
|
153
|
+
const temporaryMarker = join(dir, `${MARKER}.${randomBytes(6).toString("hex")}.tmp`);
|
|
154
|
+
await writeFile(temporaryMarker, `${JSON.stringify(marker)}\n`);
|
|
155
|
+
await rename(temporaryMarker, join(dir, MARKER));
|
|
156
|
+
return { ok: true, dir, profile: keep };
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
// Nothing of this run stays; a distribution that was complete before is untouched.
|
|
160
|
+
if (!previous && !(await readMarker()))
|
|
161
|
+
await rm(dir, { recursive: true, force: true });
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await rm(staging, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned Pyodide distribution for the `wasm` Python backend (Python spec §8.1, PD7). A TypeScript
|
|
3
|
+
* constant, not JSON, because `tsc` does not copy JSON into `dist`. Every file carries its
|
|
4
|
+
* SHA-256 and size; `alisio analysis setup --wasm` is the only code that fetches them.
|
|
5
|
+
*
|
|
6
|
+
* - `pyodide` files come from the official jsDelivr mirror of the 314.0.7 release; the hashes of
|
|
7
|
+
* the wheels are the ones of `pyodide-lock.json`, and the core files were checked against the
|
|
8
|
+
* mirror (Phase P0, 2026-10-12).
|
|
9
|
+
* - `pypi` files are pure wheels that the Pyodide lock does not ship; their hashes are the ones
|
|
10
|
+
* PyPI publishes. A raw wheel URL does not resolve dependencies, so `depends` lists them.
|
|
11
|
+
* - `sqlite3` is part of the standard library build and is deliberately not listed.
|
|
12
|
+
*/
|
|
13
|
+
export declare const PYODIDE_VERSION = "314.0.7";
|
|
14
|
+
export declare const PYTHON_VERSION = "3.14.2";
|
|
15
|
+
export type PyodideProfile = "base" | "analysis" | "science";
|
|
16
|
+
export interface PyodideFile {
|
|
17
|
+
name: string;
|
|
18
|
+
url: string;
|
|
19
|
+
sha256: string;
|
|
20
|
+
size: number;
|
|
21
|
+
source: "pyodide" | "pypi";
|
|
22
|
+
}
|
|
23
|
+
export interface PyodidePackage extends PyodideFile {
|
|
24
|
+
/** Direct dependencies by manifest key; they load first. */
|
|
25
|
+
depends: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
/** Files every profile needs (the runtime itself). */
|
|
28
|
+
export declare const PYODIDE_CORE_FILES: readonly PyodideFile[];
|
|
29
|
+
/** Wheels by manifest key (the distribution name, normalized with dashes). */
|
|
30
|
+
export declare const PYODIDE_PACKAGES: Readonly<Record<string, PyodidePackage>>;
|
|
31
|
+
/** Explicit dependency closure per profile, in load order. `science` includes `analysis`. */
|
|
32
|
+
export declare const PYODIDE_PROFILES: Readonly<Record<PyodideProfile, readonly string[]>>;
|
|
33
|
+
/** The shape `setup --wasm` and the backend work with; tests hand in a tiny one of their own. */
|
|
34
|
+
export interface PyodideManifest {
|
|
35
|
+
version: string;
|
|
36
|
+
python: string;
|
|
37
|
+
core: readonly PyodideFile[];
|
|
38
|
+
packages: Readonly<Record<string, PyodidePackage>>;
|
|
39
|
+
profiles: Readonly<Record<PyodideProfile, readonly string[]>>;
|
|
40
|
+
}
|
|
41
|
+
export declare const PYODIDE_MANIFEST: PyodideManifest;
|
|
42
|
+
export declare const PYODIDE_PROFILE_ORDER: readonly PyodideProfile[];
|