@volter/blender-engine 0.1.1 → 0.1.3

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.
@@ -2,6 +2,7 @@
2
2
  * worker; the model lives in the worker's Python and nowhere else. */
3
3
 
4
4
  export type WorkerRequest =
5
+ | { id: number; op: 'flush-document' }
5
6
  | { id: number; op: 'history-begin' | 'history-end' }
6
7
  | { id: number; op: 'history-step'; token: string; direction: 'undo' | 'redo' }
7
8
  /** `document` is the session's `.blend`, PROJECT-RELATIVE (`models/model.blend`
@@ -93,6 +94,7 @@ export type WorkerRequest =
93
94
  };
94
95
 
95
96
  export type WorkerReply =
97
+ | { op: 'document-dirty'; dirty: boolean }
96
98
  | { op: 'history'; entries: NativeHistoryEntry[] }
97
99
  | { id: number; result: unknown }
98
100
  | { id: number; error: string }
@@ -139,6 +139,14 @@ export class BlenderRuntime {
139
139
  >();
140
140
  #nextId = 0;
141
141
  #started: Promise<RuntimeStart> | null = null;
142
+ #stopping: Promise<void> | null = null;
143
+ #terminated = false;
144
+ #dirty = false;
145
+ readonly #beforeUnload = (event: BeforeUnloadEvent): void => {
146
+ if (!this.#dirty && this.#pending.size === 0) return;
147
+ event.preventDefault();
148
+ event.returnValue = '';
149
+ };
142
150
  /** `performance.now()` at the `postMessage` of every outstanding call. */
143
151
  readonly #callStarts = new Map<number, number>();
144
152
  #lastCallMs: number | null = null;
@@ -157,6 +165,7 @@ export class BlenderRuntime {
157
165
  type: 'module',
158
166
  name: 'blender',
159
167
  });
168
+ globalThis.addEventListener?.('beforeunload', this.#beforeUnload);
160
169
  this.#worker.onmessage = (event: MessageEvent<WorkerReply>) => void this.#receive(event.data);
161
170
  this.#worker.onerror = (event) => {
162
171
  // A module worker that fails to LOAD reports an ErrorEvent with an empty
@@ -410,7 +419,29 @@ export class BlenderRuntime {
410
419
  return this.#presented;
411
420
  }
412
421
 
422
+ /** Drain accepted calls and persist the document before destroying its only
423
+ * copy. A failed save keeps the worker alive so the caller can retry. */
424
+ stop(): Promise<void> {
425
+ if (this.#terminated) return Promise.resolve();
426
+ if (this.#stopping) return this.#stopping;
427
+ this.#stopping = (async () => {
428
+ if (this.#started) {
429
+ await this.#started;
430
+ await this.#request({ op: 'flush-document' }, true);
431
+ }
432
+ this.terminate();
433
+ })().catch(error => {
434
+ this.#stopping = null;
435
+ throw error;
436
+ });
437
+ return this.#stopping;
438
+ }
439
+
440
+ /** Forced teardown for a lost session; explicit user stops must use stop(). */
413
441
  terminate(): void {
442
+ if (this.#terminated) return;
443
+ this.#terminated = true;
444
+ globalThis.removeEventListener?.('beforeunload', this.#beforeUnload);
414
445
  this.#worker.terminate();
415
446
  const error = new Error('The Blender session was terminated');
416
447
  for (const id of [...this.#pending.keys()]) this.#settled(id);
@@ -470,7 +501,9 @@ export class BlenderRuntime {
470
501
  this.#lastCallWindow = { start: started, end: started + elapsed };
471
502
  }
472
503
 
473
- #request(request: Request): Promise<unknown> {
504
+ #request(request: Request, shutdown = false): Promise<unknown> {
505
+ if (this.#terminated || (this.#stopping && !shutdown))
506
+ return Promise.reject(new Error('The Blender session is stopping or terminated'));
474
507
  const id = ++this.#nextId;
475
508
  return new Promise((resolve, reject) => {
476
509
  this.#pending.set(id, { resolve, reject });
@@ -483,6 +516,10 @@ export class BlenderRuntime {
483
516
 
484
517
  async #receive(reply: WorkerReply): Promise<void> {
485
518
  if ('op' in reply) {
519
+ if (reply.op === 'document-dirty') {
520
+ this.#dirty = reply.dirty;
521
+ return;
522
+ }
486
523
  if (reply.op === 'history') {
487
524
  this.#options.history?.(reply.entries);
488
525
  return;
package/browser/worker.ts CHANGED
@@ -19,18 +19,17 @@
19
19
  * before every call, and what the session writes is mirrored back out by the
20
20
  * transport (`list-files`/`read-file`).
21
21
  *
22
- * THE DOCUMENT'S DEBOUNCE LIVES HERE, because this is the only side of the
23
- * session that has a clock. Python's loop cannot ask the tab for anything
22
+ * THE DOCUMENT'S SAVE BARRIER LIVES HERE. Python's loop cannot ask the tab for anything
24
23
  * while it is idle — `serveAsks` only runs inside a request's poll loop, so an
25
24
  * `ask` raised between calls is never answered and wedges the Blender pthread.
26
- * So the session marks a present `saveDue`, this file waits out one idle
27
- * second, calls `save-document` as an ordinary request (which Python's loop
25
+ * So the session marks a present `saveDue`, this file finishes the command,
26
+ * calls `save-document` as an ordinary request (which Python's loop
28
27
  * picks up BETWEEN calls, never mid-call), and carries the bytes to the
29
- * project through `/__editor/blender-document`.
28
+ * project through `/__editor/blender-document` BEFORE acknowledging the edit.
30
29
  *
31
30
  * WHY THE CARRY IS NOT THE MIRROR'S JOB (`vgai blender-mcp`, class Mirror):
32
31
  * the Mirror is pull-based and runs only after an `execute_blender_code`, so
33
- * a document saved one idle second after the LAST call of a modeling session
32
+ * a document saved after the LAST call of a modeling session
34
33
  * would never leave the worker — which is exactly the state this closes
35
34
  * ("closing the tab loses the model"). The Mirror still lists and mirrors the
36
35
  * same file in the MCP lane; it just is not what persistence depends on.
@@ -114,36 +113,29 @@ let engine: BlenderEngine | null = null;
114
113
  // the only one that crosses to the server, which joins it to its own root so
115
114
  // no host path is ever on the wire.
116
115
  let documentPath: string | null = null;
117
- let saveTimer: ReturnType<typeof setTimeout> | null = null;
118
- /** Calls the tab is waiting on. A save waits for zero. */
119
- let callsInFlight = 0;
120
-
121
- /** The idle second. One save per second of quiet, however many presents
122
- * arrived during it: the timer is RESET by each, so a script presenting in a
123
- * tight loop writes the document once, when it stops. */
124
- const DOCUMENT_SAVE_IDLE_MS = 1_000;
116
+ let documentDirty = false;
117
+ // Commands and saves share one lane. In particular a flush cannot overtake
118
+ // an accepted edit, and a later edit cannot race an upload of older bytes.
119
+ let workTail: Promise<unknown> = Promise.resolve();
120
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
121
+ const result = workTail.then(work);
122
+ workTail = result.catch(() => undefined);
123
+ return result;
124
+ }
125
125
 
126
- function armDocumentSave(): void {
127
- if (documentPath === null) return;
128
- if (saveTimer !== null) clearTimeout(saveTimer);
129
- saveTimer = setTimeout(() => {
130
- saveTimer = null;
131
- void saveDocument();
132
- }, DOCUMENT_SAVE_IDLE_MS);
126
+ function setDocumentDirty(dirty: boolean): void {
127
+ documentDirty = dirty;
128
+ post({ op: 'document-dirty', dirty });
133
129
  }
134
130
 
135
131
  /**
136
132
  * Save the document and land it in the project.
137
133
  *
138
- * NEVER MID-CALL: a call still outstanding means a script is running, and its
139
- * half-built model is not the document. The timer re-arms instead of writing.
134
+ * Only called inside the command lane, after previous calls have finished.
135
+ * Failure is a rejection: explicit shutdown must retain the live model.
140
136
  */
141
137
  async function saveDocument(): Promise<void> {
142
138
  if (!engine || documentPath === null) return;
143
- if (callsInFlight > 0) {
144
- armDocumentSave();
145
- return;
146
- }
147
139
  const relative = documentPath;
148
140
  let answer: { saved?: boolean; path?: string; size?: number };
149
141
  try {
@@ -151,22 +143,15 @@ async function saveDocument(): Promise<void> {
151
143
  } catch (error) {
152
144
  // A document that cannot be written is the session's work at risk, so it
153
145
  // is a named condition in the editor's console, not a debug line.
154
- log(
155
- 'error',
156
- `@@VGAI-ERROR the Blender document ${relative} could not be saved: ${describeThrown(error)}`,
157
- );
158
- return;
146
+ throw new Error(`The Blender document ${relative} could not be saved: ${describeThrown(error)}`);
159
147
  }
160
- if (!answer?.saved || typeof answer.path !== 'string') return;
148
+ if (!answer?.saved || typeof answer.path !== 'string')
149
+ throw new Error(`Blender did not save the document ${relative}`);
161
150
  let bytes: Uint8Array;
162
151
  try {
163
152
  bytes = await engine.files.readFile(answer.path);
164
153
  } catch (error) {
165
- log(
166
- 'error',
167
- `@@VGAI-ERROR the Blender document ${relative} was saved but could not be read back out of the engine: ${describeThrown(error)}`,
168
- );
169
- return;
154
+ throw new Error(`The Blender document ${relative} could not be read back out of the engine: ${describeThrown(error)}`);
170
155
  }
171
156
  // The engine's copy is now the newer one, so the stager must stop treating
172
157
  // this path as the host's: an entry left in `staged` would make the next
@@ -181,19 +166,12 @@ async function saveDocument(): Promise<void> {
181
166
  });
182
167
  if (!posted.ok) {
183
168
  const said = await posted.text().catch(() => '');
184
- log(
185
- 'error',
186
- `@@VGAI-ERROR the Blender document ${relative} was not written to the project: HTTP ${posted.status} ${said}`,
187
- );
188
- return;
169
+ throw new Error(`HTTP ${posted.status} ${said}`);
189
170
  }
190
171
  } catch (error) {
191
- log(
192
- 'error',
193
- `@@VGAI-ERROR the Blender document ${relative} was not written to the project: ${describeThrown(error)}`,
194
- );
195
- return;
172
+ throw new Error(`The Blender document ${relative} was not written to the project: ${describeThrown(error)}`);
196
173
  }
174
+ setDocumentDirty(false);
197
175
  log('log', `@@VGAI-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length })}`);
198
176
  }
199
177
 
@@ -206,9 +184,8 @@ async function startBlender(project: string, document?: string): Promise<unknown
206
184
  log,
207
185
  ask: async ({ frame, capture, saveDue }) => {
208
186
  if (!holder.engine) throw new Error('The Blender session presented before it started');
209
- // The session says this present left the document behind the model. The
210
- // clock is here; the write is one idle second away.
211
- if (saveDue) armDocumentSave();
187
+ // Save once after the whole command, never during a partial frame.
188
+ if (saveDue && documentPath !== null) setDocumentDirty(true);
212
189
  // THE ARENA IS READ ONCE, HERE, and both readers share those bytes: the
213
190
  // typed arrays the tab draws from, and the record of what was sent
214
191
  // (`describeFrame`). After the post the buffers are detached and the
@@ -480,6 +457,9 @@ async function handle(request: WorkerRequest): Promise<unknown> {
480
457
  * shape the caller wants (the RNA door). */
481
458
  const ask = engine.request.bind(engine);
482
459
  switch (request.op) {
460
+ case 'flush-document':
461
+ await saveDocument();
462
+ return { saved: true };
483
463
  case 'history-begin':
484
464
  case 'history-end':
485
465
  return ask({ op: request.op });
@@ -638,17 +618,19 @@ function reportMemory(): void {
638
618
  if (bytes !== null) post({ op: 'memory', bytes });
639
619
  }
640
620
 
641
- self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
621
+ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
642
622
  const request = event.data;
643
623
  if (request.op === 'present-result') {
644
- await handle(request);
624
+ void handle(request);
645
625
  return;
646
626
  }
647
- // Held across the WHOLE call, so the document's save timer can tell "the
648
- // session is quiet" from "a script is still running".
649
- callsInFlight += 1;
627
+ void enqueue(() => answerRequest(request));
628
+ };
629
+
630
+ async function answerRequest(request: WorkerRequest): Promise<void> {
650
631
  try {
651
632
  const result = await handle(request);
633
+ if (documentDirty) await saveDocument();
652
634
  await reportHistory();
653
635
  post({ id: request.id, result });
654
636
  } catch (error) {
@@ -661,13 +643,11 @@ self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
661
643
  message += `\nUnable to deliver Blender history: ${describeThrown(historyError)}`;
662
644
  }
663
645
  post({ id: request.id, error: message });
664
- } finally {
665
- callsInFlight -= 1;
666
646
  }
667
647
  // AFTER the answer, never before it: the reading is a passenger and must not
668
648
  // sit between a finished call and the reply the caller is waiting on.
669
649
  reportMemory();
670
- };
650
+ }
671
651
 
672
652
  async function reportHistory(): Promise<void> {
673
653
  if (!engine || !session) return;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.1",
5
+ "version": "0.1.3",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },