@volter/blender-engine 0.1.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/LICENSE +724 -0
- package/README.md +48 -0
- package/browser/blender-emscripten-engine.mts +289 -0
- package/browser/blender-engine.mts +412 -0
- package/browser/blender-wali-engine.mts +362 -0
- package/browser/index.ts +7 -0
- package/browser/protocol.ts +202 -0
- package/browser/rna.ts +697 -0
- package/browser/runtime.ts +511 -0
- package/browser/session-frame.mts +169 -0
- package/browser/session.py +4166 -0
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/attach-presenter.ts +140 -0
- package/browser/three/blender-agx.ts +235 -0
- package/browser/three/blender-base64.ts +42 -0
- package/browser/three/blender-corner-normals.ts +432 -0
- package/browser/three/blender-display-lut.ts +145 -0
- package/browser/three/blender-filmic.ts +49 -0
- package/browser/three/blender-frame-columns.ts +100 -0
- package/browser/three/blender-gradient-texture.ts +57 -0
- package/browser/three/blender-runtime-armature.ts +528 -0
- package/browser/three/blender-runtime-frame.ts +39 -0
- package/browser/three/blender-runtime-geometry.ts +342 -0
- package/browser/three/blender-runtime-lighting.ts +829 -0
- package/browser/three/blender-runtime-shadows.ts +107 -0
- package/browser/three/blender-runtime-view.ts +1481 -0
- package/browser/three/blender-runtime-volume.ts +128 -0
- package/browser/three/blender-runtime-weights.ts +306 -0
- package/browser/three/blender-sky.ts +461 -0
- package/browser/three/blender-standard.ts +68 -0
- package/browser/three/blender-triangulate.ts +181 -0
- package/browser/three/filmic-srgb.lut +0 -0
- package/browser/three/presenter.ts +265 -0
- package/browser/three/release.ts +27 -0
- package/browser/three/sky-precompute-worker.ts +45 -0
- package/browser/three/sky-worker.ts +79 -0
- package/browser/three/world-field-sampler.ts +358 -0
- package/browser/three/world-math.ts +59 -0
- package/browser/vgai_three.py +554 -0
- package/browser/worker.ts +648 -0
- package/package.json +48 -0
- package/wasm/BUNDLE.json +65 -0
- package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +2 -0
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless Blender, running in the editor tab's worker -- THE SEAM, and the
|
|
3
|
+
* choice of which Blender answers it.
|
|
4
|
+
*
|
|
5
|
+
* THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
|
|
6
|
+
* 2026-09-17), and since the 2026-09-18 amendment there are TWO BUILDS of it
|
|
7
|
+
* and neither retires the other:
|
|
8
|
+
*
|
|
9
|
+
* emscripten -- the STANDALONE skew. Blender in this worker's own module,
|
|
10
|
+
* `@volter/editor-blender` with zero in-house dependencies. What the
|
|
11
|
+
* package ships under `wasm/`.
|
|
12
|
+
* wali -- the SUBSTRATE skew. The same Blender linked for
|
|
13
|
+
* `wasm32-wali-linux-musl` and run as an ordinary Linux
|
|
14
|
+
* program through `@volter/browser-wali`: argv, stdio, an
|
|
15
|
+
* exit code, syscalls, a filesystem.
|
|
16
|
+
*
|
|
17
|
+
* ONE GATE, BOTH SKEWS: the scene battery passes on each at the same counts,
|
|
18
|
+
* from the same `session.py`. A skew may REFUSE a capability by name; it may
|
|
19
|
+
* not answer the same question differently. So this file is where the two
|
|
20
|
+
* meet and everything above it -- `worker.ts`, `session-frame.mts`,
|
|
21
|
+
* `blender-runtime-host.ts`, the presenter, the battery, the document -- is
|
|
22
|
+
* written once.
|
|
23
|
+
*
|
|
24
|
+
* HOW THE SKEW IS CHOSEN: IT IS NOT. It is READ off the artifact this editor
|
|
25
|
+
* serves, through the door that already exists for "is there a Blender here at
|
|
26
|
+
* all" (`/__editor/blender-wasm/status`, `blender-wasm-artifact.ts`). One
|
|
27
|
+
* editor serves one Blender; the served directory holds either a
|
|
28
|
+
* `blender_browser.js` bundle or a `blender.wasm` beside its `runtime/` tree,
|
|
29
|
+
* and that IS the answer. There is no manifest field, no setting and no flag,
|
|
30
|
+
* because nothing a user does should depend on the skew -- if it did, the
|
|
31
|
+
* skews would be answering differently, which the ruling forbids.
|
|
32
|
+
* `VGAI_BLENDER_WASM_DIR` is the existing door for pointing the editor at a
|
|
33
|
+
* different build and is how the substrate skew is reached in development.
|
|
34
|
+
*
|
|
35
|
+
* WHAT DIFFERS, and it is four things, all below this line:
|
|
36
|
+
* 1. how the module is booted (a `--post-js` glue factory / a WALI program);
|
|
37
|
+
* 2. where the session's `in`/`out`/`ask` files live (`Module.FS` / the
|
|
38
|
+
* program's filesystem);
|
|
39
|
+
* 3. how the export door's arena is read (`HEAPU8` / the file
|
|
40
|
+
* `export_frame`'s `buffer_path` names -- see `readArena`);
|
|
41
|
+
* 4. cross-origin isolation, which only the Emscripten build asks of the page.
|
|
42
|
+
* Everything else, including the request/ask protocol below, is one
|
|
43
|
+
* implementation over {@link BlenderFiles}.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export const SESSION_ROOT = '/work/.vgai-session';
|
|
47
|
+
export const SESSION_SCRIPT = `${SESSION_ROOT}/session.py`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* THE TWO CHANNEL DIRECTORIES THIS PAGE OWNS, and the reason they are a
|
|
51
|
+
* constant instead of two literals per engine.
|
|
52
|
+
*
|
|
53
|
+
* ONE WRITER PER DIRECTORY is the channel's ownership rule (see
|
|
54
|
+
* {@link openSessionChannel}), and a directory's owner is also the side that
|
|
55
|
+
* CREATES it: `in/` and `reply/` are made here, before the program starts;
|
|
56
|
+
* `out/` and `ask/` are made by `session.py`, which owns them. An engine that
|
|
57
|
+
* pre-created all four put a guest `mkdir`/`chmod` on a path the page had
|
|
58
|
+
* already touched, which is the same crossing edit at a smaller scale.
|
|
59
|
+
*/
|
|
60
|
+
export const PAGE_OWNED_DIRECTORIES = [`${SESSION_ROOT}/in`, `${SESSION_ROOT}/reply`] as const;
|
|
61
|
+
|
|
62
|
+
/** Which build of Blender this editor serves. */
|
|
63
|
+
export type BlenderSkew = 'emscripten' | 'wali';
|
|
64
|
+
|
|
65
|
+
export interface BlenderFileStat {
|
|
66
|
+
size: number;
|
|
67
|
+
/** The raw mode, S_IFDIR and all: `listModuleFiles` reads the type bits. */
|
|
68
|
+
mode: number;
|
|
69
|
+
mtimeMs: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The session's filesystem, as the one shape everything above the seam uses.
|
|
74
|
+
*
|
|
75
|
+
* ASYNCHRONOUS BECAUSE ONE SKEW CANNOT BE OTHERWISE: on WALI the tree lives in
|
|
76
|
+
* a `BrowserFileSystem` whose bytes are a promise by contract. The Emscripten
|
|
77
|
+
* side answers from `Module.FS` and resolves immediately.
|
|
78
|
+
*
|
|
79
|
+
* `stat` ANSWERS NULL FOR AN ABSENT PATH rather than throwing. The poll loop
|
|
80
|
+
* asks "is the answer there yet" thousands of times per call, and the version
|
|
81
|
+
* of this that threw sorted a genuine failure from an expected absence by
|
|
82
|
+
* matching `/ENOENT|no such|FS error|errno/i` against the message -- a filter
|
|
83
|
+
* that swallows any real error whose text happens to say "errno".
|
|
84
|
+
*/
|
|
85
|
+
export interface BlenderFiles {
|
|
86
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
87
|
+
writeFile(path: string, data: string | Uint8Array): Promise<void>;
|
|
88
|
+
mkdirTree(path: string): Promise<void>;
|
|
89
|
+
readdir(path: string): Promise<string[]>;
|
|
90
|
+
/** Null when the path is not there. */
|
|
91
|
+
stat(path: string): Promise<BlenderFileStat | null>;
|
|
92
|
+
unlink(path: string): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface BlenderEngineOptions {
|
|
96
|
+
/** The project's absolute path; the engine's filesystem mirrors it there. */
|
|
97
|
+
project: string;
|
|
98
|
+
log(level: 'log' | 'error', text: string): void;
|
|
99
|
+
/** What the session asks the TAB for, mid-call: a frame, and sometimes a
|
|
100
|
+
* photograph of it. Whatever this resolves to is what Python receives.
|
|
101
|
+
*
|
|
102
|
+
* `saveDue` rides BESIDE the frame (the frame's own schema is the
|
|
103
|
+
* presenter's): this present left the session's `.blend` behind the model,
|
|
104
|
+
* and whoever answers owes it a save once the session goes idle. */
|
|
105
|
+
ask(payload: { frame: unknown; capture?: unknown; saveDue?: boolean }): Promise<unknown>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface BlenderEngine {
|
|
109
|
+
/** Which build answered. Reported out of `blender-start`, because a number
|
|
110
|
+
* that differs between the skews is a defect in one of them and the board
|
|
111
|
+
* has to be able to say which one it measured. */
|
|
112
|
+
readonly skew: BlenderSkew;
|
|
113
|
+
readonly banner: { blender: string; engines: string[] };
|
|
114
|
+
/** The session's filesystem. */
|
|
115
|
+
readonly files: BlenderFiles;
|
|
116
|
+
/** One request, one answer. Rejects with the session's own error text. */
|
|
117
|
+
request(payload: Record<string, unknown>): Promise<unknown>;
|
|
118
|
+
/**
|
|
119
|
+
* THE LAST FRAME'S EXPORT ARENA, whole, offset zero at the arena's base.
|
|
120
|
+
*
|
|
121
|
+
* The one call whose implementation the skews do not share, and the reason
|
|
122
|
+
* is worth the paragraph. `bpy_web_export.cc` writes every column of a frame
|
|
123
|
+
* into a side arena and the JSON frame names each one `{offset, length,
|
|
124
|
+
* dtype, count, stride}` -- no descriptor of the arena itself, because
|
|
125
|
+
* "natively the path is the caller's own string". On Emscripten the arena is
|
|
126
|
+
* in the module's linear memory and the C exports name it, so this is a view
|
|
127
|
+
* on `HEAPU8` and costs nothing. Under WALI the module's memory is in a
|
|
128
|
+
* worker the host cannot reach, and does not need to: `export_frame`'s
|
|
129
|
+
* `buffer_path` option already writes the same bytes to a file, so this
|
|
130
|
+
* reads that file. `session-frame.mts` is handed the bytes and never learns
|
|
131
|
+
* which it got.
|
|
132
|
+
*
|
|
133
|
+
* VALID UNTIL THE NEXT `export_frame`, on both skews.
|
|
134
|
+
*/
|
|
135
|
+
readArena(): Promise<Uint8Array>;
|
|
136
|
+
/** Milliseconds from the first byte of the artifact to the session's ready line. */
|
|
137
|
+
readonly bootMs: number;
|
|
138
|
+
/** The engine's memory in bytes, now. Linear memory on the standalone skew;
|
|
139
|
+
* null where the skew cannot see the module's memory from the host, which
|
|
140
|
+
* is the WALI case and is why `vgai status` prints "unreported" rather than
|
|
141
|
+
* a zero. */
|
|
142
|
+
memoryBytes(): number | null;
|
|
143
|
+
/** Bytes of packed `.data` payload handed back after boot, or null when the
|
|
144
|
+
* build has no packed payload to release (every WALI boot) or no release
|
|
145
|
+
* door. Reported out of `blender-start` because an action whose success is
|
|
146
|
+
* otherwise invisible has to say so somewhere a caller reads. */
|
|
147
|
+
readonly releasedPayloadBytes: number | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const ARTIFACT_BASE = '/__editor/blender-wasm';
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* An artifact URL, resolved against THE MODULE — never against `self.location`.
|
|
154
|
+
*
|
|
155
|
+
* A worker's `location` is where its SCRIPT was loaded from, and that is not
|
|
156
|
+
* always an http url of this editor: when the page is a frame this editor does
|
|
157
|
+
* not own (the Code-OSS workbench, docs/CODE-OSS.md §Boot, DESKTOP), a
|
|
158
|
+
* cross-origin worker can only be constructed through a same-origin `blob:`
|
|
159
|
+
* doorway, so `self.location.href` reads `blob:vscode-file://vscode-app/<uuid>`
|
|
160
|
+
* — an opaque base against which `new URL('/__editor/...', base)` THROWS
|
|
161
|
+
* `TypeError: Invalid URL`. Measured 2026-09-19: that is what `blender-start`
|
|
162
|
+
* answered on the desktop shape once its message reached the worker at all.
|
|
163
|
+
*
|
|
164
|
+
* `import.meta.url` is this module's own url and is always the http one the
|
|
165
|
+
* editor served, whatever loaded the worker. Every artifact url is built from
|
|
166
|
+
* it, and nothing here reads `self.location`.
|
|
167
|
+
*/
|
|
168
|
+
export function artifactUrl(file: string): string {
|
|
169
|
+
return new URL(`${ARTIFACT_BASE}/${file}`, import.meta.url).href;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** What the editor's artifact door answers. `blender-wasm-artifact.ts` owns it. */
|
|
173
|
+
export interface BlenderArtifactStatus {
|
|
174
|
+
available: boolean;
|
|
175
|
+
/** Which build the served directory holds; null when nothing is served. */
|
|
176
|
+
skew: BlenderSkew | null;
|
|
177
|
+
dir: string | null;
|
|
178
|
+
sizes: Record<string, number>;
|
|
179
|
+
encoded: Record<string, 'br'>;
|
|
180
|
+
/** Why it is unavailable, named. Empty when it is available. */
|
|
181
|
+
missing: string[];
|
|
182
|
+
/** WALI only: the `sha256-` SRI of `blender.wasm`, which the program loader
|
|
183
|
+
* requires for any non-blob URL. */
|
|
184
|
+
integrity?: string;
|
|
185
|
+
/** WALI only: how many workers the program's host pool gets, and how many
|
|
186
|
+
* Blender itself is told to use (`blender -t N`). */
|
|
187
|
+
workers?: { pool: number; blender: number };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function artifactStatus(): Promise<BlenderArtifactStatus> {
|
|
191
|
+
const answer = await fetch(artifactUrl('status'));
|
|
192
|
+
if (!answer.ok)
|
|
193
|
+
return {
|
|
194
|
+
available: false,
|
|
195
|
+
skew: null,
|
|
196
|
+
dir: null,
|
|
197
|
+
sizes: {},
|
|
198
|
+
encoded: {},
|
|
199
|
+
missing: [`${ARTIFACT_BASE}/status answered ${answer.status}`],
|
|
200
|
+
};
|
|
201
|
+
return (await answer.json()) as BlenderArtifactStatus;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Boot the Blender this editor serves and hold it as a long-lived session.
|
|
206
|
+
*
|
|
207
|
+
* The two implementations are reached by DYNAMIC IMPORT and that is load
|
|
208
|
+
* bearing, not style: the WALI engine reaches `@volter/browser-wali` and
|
|
209
|
+
* `@volter/browser-runtime`, and the standalone skew must not pay for them.
|
|
210
|
+
* A module never imported is never fetched, and `blender-wali-engine.mts`
|
|
211
|
+
* names no package at all -- it imports the substrate from URLs this same
|
|
212
|
+
* editor serves, exactly as the Emscripten engine fetches its glue. So the
|
|
213
|
+
* standalone skew's dependency on browser-substrate is zero in the package
|
|
214
|
+
* manifest, zero in the module graph, and zero on the wire.
|
|
215
|
+
*/
|
|
216
|
+
export async function startBlenderEngine(options: BlenderEngineOptions): Promise<BlenderEngine> {
|
|
217
|
+
const status = await artifactStatus();
|
|
218
|
+
if (!status.available)
|
|
219
|
+
throw new Error(
|
|
220
|
+
`The headless Blender WebAssembly build is not served by this editor: ${status.missing.join('; ')}`,
|
|
221
|
+
);
|
|
222
|
+
if (status.skew === 'wali') {
|
|
223
|
+
const { startWaliBlenderEngine } = await import('./blender-wali-engine.mts');
|
|
224
|
+
return startWaliBlenderEngine(options, status);
|
|
225
|
+
}
|
|
226
|
+
const { startEmscriptenBlenderEngine } = await import('./blender-emscripten-engine.mts');
|
|
227
|
+
return startEmscriptenBlenderEngine(options, status);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The poll cadence: tight while a call is young, then backing off so a
|
|
231
|
+
* ten-minute bake does not spend ten minutes of main-thread wakeups. */
|
|
232
|
+
function pollDelay(elapsedMs: number): number {
|
|
233
|
+
if (elapsedMs < 200) return 1;
|
|
234
|
+
if (elapsedMs < 5_000) return 5;
|
|
235
|
+
return 25;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
239
|
+
|
|
240
|
+
const decoder = new TextDecoder();
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* THE DIRECTORY CHANNEL, written once for both skews.
|
|
244
|
+
*
|
|
245
|
+
* `session.py`'s header states the other half and says why it is a directory
|
|
246
|
+
* rather than stdin. Nothing here is toolchain-specific: a request is
|
|
247
|
+
* `in/<id>.json` then `in/<id>.done`, an answer is `out/<id>.json` +
|
|
248
|
+
* `out/<id>.done`, and an `ask` the session raises mid-call is served from
|
|
249
|
+
* inside this poll loop -- which is why `saveDocument` cannot be raised from
|
|
250
|
+
* Python's idle loop (`worker.ts` holds that clock).
|
|
251
|
+
*
|
|
252
|
+
* ONE WRITER PER DIRECTORY. This is the channel's single structural rule and
|
|
253
|
+
* it is what keeps the WALI skew alive for a whole session:
|
|
254
|
+
*
|
|
255
|
+
* in/ the PAGE writes and the PAGE unlinks; the guest only reads.
|
|
256
|
+
* out/ the GUEST writes and the GUEST unlinks; the page only reads.
|
|
257
|
+
* ask/ the GUEST writes and the GUEST unlinks; the page only reads.
|
|
258
|
+
* reply/ the PAGE writes and the PAGE unlinks; the guest only reads.
|
|
259
|
+
*
|
|
260
|
+
* WHY IT IS STRUCTURAL AND NOT A TUNING. Under WALI the two sides are two
|
|
261
|
+
* snapshot-backed views of one tree, reconciled by patches: the guest sends
|
|
262
|
+
* the paths IT changed, and `applyProcessFilesystemPatch` refuses the patch
|
|
263
|
+
* (`assertPatchBase`) when a path in it no longer holds what the guest's base
|
|
264
|
+
* said -- after which the host TERMINATES the program's worker. A path can
|
|
265
|
+
* only disagree if somebody other than the patch's author moved it. So with
|
|
266
|
+
* the sets of paths the two sides mutate DISJOINT, the assertion has nothing
|
|
267
|
+
* left to fire on. MEASURED 2026-09-19 (WS-X): with this page unlinking four
|
|
268
|
+
* files in the guest's own `out/` after every answer, a render loop died at
|
|
269
|
+
* call 37 (`base`: a file, `current`: null) and at call 14 (a different
|
|
270
|
+
* mtime), and narrowing the window moved the death 1 -> 13 -> 37 without
|
|
271
|
+
* closing it.
|
|
272
|
+
*
|
|
273
|
+
* THE ACK IS THE REQUEST FILE'S DISAPPEARANCE, so it costs no file and no
|
|
274
|
+
* extra write. Each side needs to know when the other has taken what it left:
|
|
275
|
+
*
|
|
276
|
+
* - this page unlinks `in/<id>.json` and then `in/<id>.done` ONLY after it
|
|
277
|
+
* has read `out/<id>.json`. So `in/<id>.done` being gone IS "the page
|
|
278
|
+
* took the answer" -- a fact the guest reads out of a directory it never
|
|
279
|
+
* writes to, from the `listdir` its loop already does. It then removes
|
|
280
|
+
* its own `out/<id>.*`.
|
|
281
|
+
* - the session unlinks `ask/<id>.json` and then `ask/<id>.done` only after
|
|
282
|
+
* it has read `reply/<id>.json`, so `ask/<id>.done` being gone is "the
|
|
283
|
+
* guest took the reply" -- read here out of the `readdir` `serveAsks`
|
|
284
|
+
* already does, after which this page removes its own `reply/<id>.*`.
|
|
285
|
+
*
|
|
286
|
+
* `.done` IS ALWAYS UNLINKED LAST, on both sides, because `.done` is the
|
|
287
|
+
* token the other side watches: a `.done` without its `.json` would be a
|
|
288
|
+
* half-visible message.
|
|
289
|
+
*
|
|
290
|
+
* NOTHING HERE CATCHES ITS OWN UNLINK. These are files this page wrote into a
|
|
291
|
+
* directory nothing else writes to; an ENOENT would mean the ownership rule
|
|
292
|
+
* had been broken, and the loud failure of the call that found it is the
|
|
293
|
+
* proof the rule holds.
|
|
294
|
+
*/
|
|
295
|
+
export function openSessionChannel(
|
|
296
|
+
files: BlenderFiles,
|
|
297
|
+
options: BlenderEngineOptions,
|
|
298
|
+
/**
|
|
299
|
+
* WHY THE POLL LOOP NEEDS A LIVENESS PREDICATE, and it is the difference
|
|
300
|
+
* between a board and a wasted afternoon.
|
|
301
|
+
*
|
|
302
|
+
* The loop below waits for `out/<id>.done` and nothing else. When the
|
|
303
|
+
* program behind the directory DIES, that file is never written, so the
|
|
304
|
+
* call waits forever: `vgai status` reports it IN FLIGHT for as long as
|
|
305
|
+
* anyone looks, `blender-start` keeps answering from the host's cached
|
|
306
|
+
* banner, and the harness has no answer to time out against. MEASURED
|
|
307
|
+
* 2026-09-19 three times on the WALI skew -- 507 s, 621 s and 964 s of a
|
|
308
|
+
* battery model spent waiting on a Blender that had already ended with
|
|
309
|
+
* `[object WebAssembly.Exception]`, each ending in a killed process and no
|
|
310
|
+
* result record at all.
|
|
311
|
+
*
|
|
312
|
+
* This returns null while the program is alive and the reason it ended
|
|
313
|
+
* otherwise. A caller that supplies one turns that infinite wait into a
|
|
314
|
+
* named failure naming the recovery door. The standalone skew supplies
|
|
315
|
+
* none: its module is the worker, so a death there ends the worker rather
|
|
316
|
+
* than stranding a reader, and inventing a predicate for a failure nobody
|
|
317
|
+
* has measured would be a fiction.
|
|
318
|
+
*/
|
|
319
|
+
programEnded?: () => string | null,
|
|
320
|
+
): {
|
|
321
|
+
request(payload: Record<string, unknown>): Promise<unknown>;
|
|
322
|
+
} {
|
|
323
|
+
let sequence = 0;
|
|
324
|
+
let asking = 0;
|
|
325
|
+
/** Asks this page has answered whose `reply/<id>.*` it still owes a cleanup
|
|
326
|
+
* -- cleared as each `ask/<id>.done` disappears. */
|
|
327
|
+
const replied = new Set<string>();
|
|
328
|
+
|
|
329
|
+
/** THE GUEST'S ACK: its own `ask/<id>.done` is gone, so it has read the
|
|
330
|
+
* reply and this page may retire the two files IT wrote. `.done` last. */
|
|
331
|
+
async function retireReplies(raised: ReadonlySet<string>): Promise<void> {
|
|
332
|
+
for (const id of replied) {
|
|
333
|
+
if (raised.has(`${id}.done`)) continue;
|
|
334
|
+
await files.unlink(`${SESSION_ROOT}/reply/${id}.json`);
|
|
335
|
+
await files.unlink(`${SESSION_ROOT}/reply/${id}.done`);
|
|
336
|
+
replied.delete(id);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Answer any `ask` the session has raised, and retire the replies it has
|
|
341
|
+
* taken. Called from the poll loop, so a present raised inside a call is
|
|
342
|
+
* served while that call is outstanding. */
|
|
343
|
+
async function serveAsks(): Promise<void> {
|
|
344
|
+
let names: string[];
|
|
345
|
+
try {
|
|
346
|
+
names = await files.readdir(`${SESSION_ROOT}/ask`);
|
|
347
|
+
} catch {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const raised = new Set(names.filter((name) => name.endsWith('.done')));
|
|
351
|
+
await retireReplies(raised);
|
|
352
|
+
for (const name of raised) {
|
|
353
|
+
const id = name.slice(0, -'.done'.length);
|
|
354
|
+
if (Number(id) <= asking) continue;
|
|
355
|
+
asking = Number(id);
|
|
356
|
+
const payload = JSON.parse(
|
|
357
|
+
decoder.decode(await files.readFile(`${SESSION_ROOT}/ask/${id}.json`)),
|
|
358
|
+
) as { frame: unknown; capture?: unknown; saveDue?: boolean };
|
|
359
|
+
let answer: unknown;
|
|
360
|
+
try {
|
|
361
|
+
answer = await options.ask(payload);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
answer = { error: error instanceof Error ? error.message : String(error) };
|
|
364
|
+
}
|
|
365
|
+
await files.writeFile(`${SESSION_ROOT}/reply/${id}.json`, JSON.stringify(answer ?? null));
|
|
366
|
+
await files.writeFile(`${SESSION_ROOT}/reply/${id}.done`, '1');
|
|
367
|
+
replied.add(id);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function request(payload: Record<string, unknown>): Promise<unknown> {
|
|
372
|
+
const id = String(++sequence);
|
|
373
|
+
await files.writeFile(`${SESSION_ROOT}/in/${id}.json`, JSON.stringify(payload));
|
|
374
|
+
await files.writeFile(`${SESSION_ROOT}/in/${id}.done`, '1');
|
|
375
|
+
const began = performance.now();
|
|
376
|
+
for (;;) {
|
|
377
|
+
await serveAsks();
|
|
378
|
+
if (await files.stat(`${SESSION_ROOT}/out/${id}.done`)) {
|
|
379
|
+
const body = JSON.parse(
|
|
380
|
+
decoder.decode(await files.readFile(`${SESSION_ROOT}/out/${id}.json`)),
|
|
381
|
+
) as { result?: unknown; error?: string };
|
|
382
|
+
// THE PAGE UNLINKS WHAT THE PAGE WROTE, AND ONLY THAT. `out/<id>.*`
|
|
383
|
+
// is the session's to remove; this loop does not touch it. Doing so
|
|
384
|
+
// is what killed a WALI session mid-run (see the ownership rule
|
|
385
|
+
// above): the guest's patch for `out/<id>.done` crossed this page's
|
|
386
|
+
// unlink of it, `assertPatchBase` refused the patch, and the host
|
|
387
|
+
// terminated the program's worker -- no guest exit path ran, which is
|
|
388
|
+
// why every instrument on `SYS_exit` was silent through it.
|
|
389
|
+
//
|
|
390
|
+
// These two unlinks are also the ACK. The session watches for
|
|
391
|
+
// `in/<id>.done` to vanish and takes that as "the page has the
|
|
392
|
+
// answer", so `.done` goes last and neither is attempted before
|
|
393
|
+
// `out/<id>.json` has been read.
|
|
394
|
+
await files.unlink(`${SESSION_ROOT}/in/${id}.json`);
|
|
395
|
+
await files.unlink(`${SESSION_ROOT}/in/${id}.done`);
|
|
396
|
+
if (body.error) throw new Error(body.error);
|
|
397
|
+
return body.result;
|
|
398
|
+
}
|
|
399
|
+
const ended = programEnded?.();
|
|
400
|
+
if (ended)
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Blender is gone, so this call will never be answered: ${ended}. ` +
|
|
403
|
+
'The session keeps its directory and its cached banner, so every later call would ' +
|
|
404
|
+
'wait on the same missing answer; start a new program with `blender-start {fresh: true}` ' +
|
|
405
|
+
'(`VGAI_BLENDER_FRESH_SESSION=1` for the battery harness).',
|
|
406
|
+
);
|
|
407
|
+
await sleep(pollDelay(performance.now() - began));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return { request };
|
|
412
|
+
}
|