@deepblend/dsh-blender-provider-local 0.2.5 → 0.2.6

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/lib/index.js CHANGED
@@ -143,6 +143,30 @@ export const ProviderConfig = z.object({
143
143
  capabilitiesCacheMs: z.number().default(60_000),
144
144
  /** Retain per-invocation working directories for post-mortem inspection. */
145
145
  keepWorkingDirectory: z.boolean().default(false),
146
+ /**
147
+ * Actions served from a KEPT Blender process instead of one process each (SPEC §20 M6).
148
+ *
149
+ * EMPTY BY DEFAULT, AND THAT IS THE HONEST DEFAULT — MEASURED, not cautious. Turning it on for
150
+ * `get_capabilities` and `compile_scene` made three acceptance suites fail, and they were right:
151
+ * a kept session has a NARROWER CONTRACT than a batch invocation. It does not capture per-request
152
+ * stdout (the capture cap and the flood test have nothing to read), a per-request deadline does not
153
+ * kill the process (the deadline test found a Blender still in the process table), and it holds its
154
+ * working directory for as long as it lives (the M0 probe found temp directories left behind).
155
+ *
156
+ * None of that is a bug in the session; it is a different shape with different promises. But a
157
+ * default that silently swaps one set of promises for another is exactly the kind of change this
158
+ * repository's suites exist to catch, so the fast path is OPT IN: an operator who wants it names the
159
+ * actions, and the actions that are safe for it are the pure, short ones — `get_capabilities` and
160
+ * `compile_scene`. MEASURED with it on: three operations cost 832 ms instead of 2594 ms, in one
161
+ * process instead of three (`milestone-status.md` §212.6).
162
+ *
163
+ * The RENDERS are not safe for it in any configuration: cancelling one must kill exactly that
164
+ * render, and the only way this provider can do that is to end the process — which, on an ATTACHED
165
+ * Blender, is the user's own session.
166
+ */
167
+ sessionActions: z.array(z.string()).default([]),
168
+ /** How long a kept session may sit idle before it is closed. */
169
+ sessionIdleMs: z.number().default(60_000),
146
170
  })
147
171
 
148
172
  /**
@@ -434,7 +458,7 @@ class BlenderSession {
434
458
  /**
435
459
  * Run one action in the live process.
436
460
  *
437
- * @param {{ action: string, payload?: object, jobId?: string }} request
461
+ * @param {{ action: string, payload?: object, args?: string[], jobId?: string }} request
438
462
  * @param {{ signal?: AbortSignal }} [options]
439
463
  * @returns {Promise<object>} the envelope bootstrap.py built for it.
440
464
  */
@@ -451,6 +475,11 @@ class BlenderSession {
451
475
  jobId,
452
476
  action: request.action,
453
477
  ...(request.payload !== undefined ? { payload: request.payload } : {}),
478
+ // PER-REQUEST ARGUMENTS. A session's argv is fixed when the process starts, and the actions that
479
+ // matter need flags (`--scene-spec`, `--output`, …), so the request carries its own and
480
+ // bootstrap.py parses them with the same parser the process used. MEASURED: without this, every
481
+ // real action failed inside a session with "requires --scene-spec".
482
+ ...(Array.isArray(request.args) ? { args: request.args } : {}),
454
483
  }
455
484
 
456
485
  const answered = new Promise((resolve, reject) => {
@@ -518,6 +547,10 @@ export default class LocalBlenderRuntime extends Service {
518
547
  /** @type {Map<string, import('@deepblend/dsh-blender-contracts').BlenderCapabilities>} */
519
548
  _capabilitiesCache = new Map()
520
549
 
550
+ /** The kept Blender for `sessionActions`, and the timer that closes it when it goes quiet. */
551
+ _session = null
552
+ _sessionTimer = null
553
+
521
554
  /**
522
555
  * @param {import('@deepseek-ai/cordis').Context} ctx
523
556
  * @param {import('z').infer<typeof ProviderConfig>} config - validated by schemastery before construction.
@@ -531,6 +564,11 @@ export default class LocalBlenderRuntime extends Service {
531
564
  this.bootstrapPath = this._resolveBootstrapPath(config.bootstrapPath)
532
565
  /** Resolved once: the host must be configured with the same value. */
533
566
  this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot)
567
+ // A KEPT SESSION IS A PROCESS THIS ROW OWNS, so it is disposed with the row rather than left for
568
+ // the host's exit path to find: a plugin that is unloaded must not leave a Blender behind.
569
+ ctx.effect(() => () => {
570
+ this.closeSession().catch(() => {})
571
+ }, 'deepblend kept session teardown')
534
572
  }
535
573
 
536
574
  /**
@@ -901,6 +939,46 @@ export default class LocalBlenderRuntime extends Service {
901
939
  return session
902
940
  }
903
941
 
942
+ /**
943
+ * The kept session, opened on first use and closed when it has been idle.
944
+ *
945
+ * A SESSION IS NOT A CACHE. It is a Blender process holding a loaded scene, so it is closed on a
946
+ * timer rather than kept forever: an operator who runs one compile and walks away should not find a
947
+ * Blender still resident an hour later. The timer is refreshed by use and cleared on disposal.
948
+ */
949
+ async _keptSession() {
950
+ if (this._session !== null && !this._session.closed) return this._session
951
+ this._session = await this.openSession()
952
+ return this._session
953
+ }
954
+
955
+ _touchSession() {
956
+ if (this._sessionTimer !== null) clearTimeout(this._sessionTimer)
957
+ this._sessionTimer = setTimeout(() => {
958
+ // NEVER CLOSE UNDER A REQUEST. MEASURED: the first version closed on the timer alone, and a
959
+ // compile that was still running when the idle window expired was rejected with "closed by its
960
+ // caller" — the session ended a request that had done nothing wrong. Idle means idle.
961
+ if (this._session !== null && this._session.waiting.size > 0) {
962
+ this._touchSession()
963
+ return
964
+ }
965
+ const session = this._session
966
+ this._session = null
967
+ this._sessionTimer = null
968
+ session?.close?.().catch(() => {})
969
+ }, this.config.sessionIdleMs)
970
+ if (typeof this._sessionTimer.unref === 'function') this._sessionTimer.unref()
971
+ }
972
+
973
+ /** Close the kept session now, if there is one. */
974
+ async closeSession() {
975
+ if (this._sessionTimer !== null) clearTimeout(this._sessionTimer)
976
+ this._sessionTimer = null
977
+ const session = this._session
978
+ this._session = null
979
+ if (session !== null) await session.close().catch(() => {})
980
+ }
981
+
904
982
  async runBootstrap(request, options = {}) {
905
983
  const action = request?.action
906
984
  if (typeof action !== 'string' || action.length === 0) {
@@ -914,6 +992,28 @@ export default class LocalBlenderRuntime extends Service {
914
992
  )
915
993
  }
916
994
 
995
+ // THE SESSION PATH, for the actions configured to use one. It returns the SAME outcome shape the
996
+ // batch path returns — a caller must not be able to tell which one served it, or the two paths
997
+ // would drift into two behaviours with one name.
998
+ if (this.config.sessionActions.includes(action) && options.session !== false) {
999
+ const session = await this._keptSession()
1000
+ this._touchSession()
1001
+ const startedAt = Date.now()
1002
+ const envelope = await session.run({
1003
+ action,
1004
+ ...(request.payload !== undefined ? { payload: request.payload } : {}),
1005
+ ...(Array.isArray(options.args) ? { args: options.args } : {}),
1006
+ }, { signal: options.signal })
1007
+ return {
1008
+ envelope,
1009
+ stdout: '',
1010
+ stderr: '',
1011
+ exitCode: envelope.status === 'error' ? 1 : 0,
1012
+ durationMs: Date.now() - startedAt,
1013
+ workingDirectory: session.directory ?? null,
1014
+ }
1015
+ }
1016
+
917
1017
  const resolvedExecutable = await this.resolveBlenderExecutable({ signal: options.signal })
918
1018
  if (resolvedExecutable.error !== null || resolvedExecutable.resolved === null) {
919
1019
  throw resolvedExecutable.error ?? new BlenderError(
@@ -1264,7 +1364,14 @@ export default class LocalBlenderRuntime extends Service {
1264
1364
  * @returns {Promise<{report: object, envelope: object, durationMs: number, stdout: string, stderr: string}>}
1265
1365
  */
1266
1366
  async compileScene(request) {
1267
- const outputBlend = 'result.blend'
1367
+ // AN ABSOLUTE, PER-REQUEST OUTPUT PATH, and it is what lets the same action run in either
1368
+ // transport. MEASURED: this used to be the relative `'result.blend'`, which was safe only because
1369
+ // the batch path gives every invocation its own working directory — a session has ONE directory
1370
+ // for every request, so two compiles would write the same file and the caller would be handed a
1371
+ // checkpoint that belonged to a different one. Passing the path explicitly removes the dependency
1372
+ // on a working directory that a kept process does not have per request.
1373
+ const { directory: requestDirectory } = this._createWorkingDirectory()
1374
+ const outputBlend = join(requestDirectory, 'result.blend')
1268
1375
  if (typeof request?.sceneSpecPath !== 'string' || request.sceneSpecPath.length === 0) {
1269
1376
  throw new BlenderError(
1270
1377
  BlenderErrorCode.SCENE_SPEC_INVALID,
@@ -1282,10 +1389,18 @@ export default class LocalBlenderRuntime extends Service {
1282
1389
  signal: request.signal,
1283
1390
  args,
1284
1391
  projectRoot: request.projectRoot,
1285
- onWorkingDirectory: request.onWorkingDirectory,
1392
+ // NOT `onWorkingDirectory` here: the callback has to name the directory that HOLDS the
1393
+ // checkpoint, and that is this method's own (`runBootstrap` would name its invocation
1394
+ // directory, which in a session is a different one). One owner for one path.
1286
1395
  },
1287
1396
  )
1288
1397
 
1398
+ // The caller moves the checkpoint somewhere durable before this directory goes away, which is what
1399
+ // lets a compile failure leave no trace in the project.
1400
+ if (typeof request.onWorkingDirectory === 'function') {
1401
+ await request.onWorkingDirectory({ directory: requestDirectory, envelope: run.envelope, jobId: request.jobId })
1402
+ }
1403
+
1289
1404
  const report = run.envelope.result ?? {}
1290
1405
  return {
1291
1406
  report,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "Local Blender Provider: the BlenderRuntime Service over ctx.subprocess (batch execution).",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "dependencies": {
14
- "@deepblend/dsh-blender-contracts": "0.2.5"
14
+ "@deepblend/dsh-blender-contracts": "0.2.6"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -429,6 +429,15 @@ def _as_int(value):
429
429
  raise ActionError("BLENDER_SCRIPT_ERROR", '"%s" is not an integer' % (value,))
430
430
 
431
431
 
432
+ # The options a REQUEST may carry, which is the batch path's own list: a session request that names a
433
+ # flag the batch path knows must work, and one it does not must fail the same way.
434
+ KNOWN_SESSION_OPTIONS = (
435
+ "--request", "--result", "--scene-spec", "--blend", "--output-blend",
436
+ "--output", "--camera", "--engine", "--width", "--height", "--samples",
437
+ "--frame", "--profile", "--project-root", "--save-on-failure", "--views",
438
+ "--frames", "--events", "--proc",
439
+ )
440
+
432
441
  ACTIONS = {
433
442
  "get_capabilities": action_get_capabilities,
434
443
  "compile_scene": action_compile_scene,
@@ -552,6 +561,27 @@ def _session_dispatch(request, options):
552
561
  "detail": {"action": action, "supported": list(SUPPORTED_ACTIONS)},
553
562
  }, [], [])
554
563
 
564
+ # PER-REQUEST ARGUMENTS, merged over the process's own.
565
+ #
566
+ # MEASURED, and it made sessions useless for the actions that matter: `compile_scene` needs
567
+ # `--scene-spec`, `render_preview` needs `--output`, and the batch path passes those as extra
568
+ # argv — but a session's argv is fixed when the process starts, so the first attempt at reusing a
569
+ # process dropped every one of them and every real action failed with "requires --scene-spec".
570
+ #
571
+ # The fix is not to restart the process with new flags (that is the batch path) but to let a
572
+ # request carry its own, parsed by the SAME parser the process used: one parser, so a flag that
573
+ # works in batch mode works here, including the failure for an unknown one.
574
+ options = options
575
+ if isinstance(request.get("args"), list):
576
+ extra, unknown = parse_args(["--"] + [str(token) for token in request["args"]], KNOWN_SESSION_OPTIONS)
577
+ if unknown:
578
+ return build_envelope(job_id, action, None, {
579
+ "code": "BLENDER_SCRIPT_ERROR",
580
+ "message": "unrecognised arguments: %s" % (", ".join(unknown),),
581
+ }, [], [])
582
+ options = dict(options)
583
+ options.update(extra)
584
+
555
585
  eprint("session action=%s job=%s" % (action, job_id))
556
586
  # Same claim as the batch path: every request is attributable to this pid, so a session that
557
587
  # dies mid-action leaves the same evidence an interrupted render does.