@deepblend/dsh-blender-provider-local 0.2.7 → 0.2.9

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
@@ -225,6 +225,28 @@ async function connect(socketPath, timeoutMs) {
225
225
  }
226
226
  }
227
227
 
228
+ /**
229
+ * The actions this provider can serve from a kept session, and the ONLY ones it can.
230
+ *
231
+ * DERIVED, NOT CHOSEN: these are exactly the actions whose calls go through `runBootstrap`, which is
232
+ * the one place the session path is decided. Everything else spawns its own process on purpose —
233
+ * `render_frames` most of all, because cancelling a render has to kill exactly that render, and a
234
+ * request inside a kept session cannot be killed without ending the session (and, attached, the
235
+ * user's own Blender).
236
+ *
237
+ * WHY THE LIST IS CHECKED RATHER THAN TRUSTED. MEASURED: naming `render_frames` in `sessionActions`
238
+ * used to do NOTHING AT ALL — the configuration was inert, the render ran in its own process as
239
+ * before, and nothing anywhere said so. A key that silently does nothing is worse than a key that
240
+ * fails: the operator believes they changed something. `contract/session-routing.test.mjs` derives
241
+ * this set from the source, so adding a `runBootstrap` call site without adding it here goes red.
242
+ */
243
+ export const SESSION_ROUTABLE_ACTIONS = Object.freeze([
244
+ 'get_capabilities',
245
+ 'compile_scene',
246
+ 'render_preview',
247
+ 'render_views',
248
+ ])
249
+
228
250
  /**
229
251
  * A live Blender, presented as one shape whatever carries the bytes.
230
252
  *
@@ -574,6 +596,20 @@ export default class LocalBlenderRuntime extends Service {
574
596
  // See the host's constructor: a key this row does not read is a startup error, because the schema
575
597
  // accepts it silently (SPEC §17's nested groups versus this package's flat keys).
576
598
  assertKnownConfigKeys(ProviderConfig, config, 'deepblend-blender-runtime')
599
+ // A CONFIGURED ACTION THAT CANNOT BE ROUTED IS REFUSED AT STARTUP, not ignored. Naming a render
600
+ // here would leave the operator believing their renders now run in their own Blender, while the
601
+ // render quietly kept its own process — the same shape as a key this row does not read, which the
602
+ // line above already refuses.
603
+ const unroutable = (config.sessionActions ?? []).filter(action => !SESSION_ROUTABLE_ACTIONS.includes(action))
604
+ if (unroutable.length > 0) {
605
+ throw new Error(
606
+ `sessionActions names ${unroutable.map(action => `"${action}"`).join(', ')}, and this provider can only ` +
607
+ `serve ${SESSION_ROUTABLE_ACTIONS.join(', ')} from a kept session. The renders that must be cancellable ` +
608
+ '(`render_frames`) spawn their own process on purpose: cancelling one kills exactly that render, and a ' +
609
+ 'request inside a kept session cannot be killed without ending the session — which, attached, is the ' +
610
+ 'user\'s own Blender.',
611
+ )
612
+ }
577
613
  this.config = config
578
614
  this.bootstrapPath = this._resolveBootstrapPath(config.bootstrapPath)
579
615
  /** Resolved once: the host must be configured with the same value. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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.7"
14
+ "@deepblend/dsh-blender-contracts": "0.2.9"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -107,10 +107,23 @@ class _Bridge:
107
107
  # -- lifecycle ---------------------------------------------------------
108
108
 
109
109
  def start(self):
110
+ # THREE STATES, NOT TWO. A socket path that exists is either a LIVE bridge or the file a dead
111
+ # one left behind, and the difference decides everything.
112
+ #
113
+ # MEASURED, and this is why: two workspaces on one machine both enabled the add-on, and the
114
+ # SECOND bridge unlinked the first's socket file, bound its own, and announced `ready` — so the
115
+ # first Blender became unreachable while still believing it was listening, and the second
116
+ # reported success. That is the failure this repository keeps paying for: something other than
117
+ # what was asked, reported as fine.
110
118
  if os.path.exists(self.socket_path):
111
- # A socket file left by a process that died. Refusing to start would leave the user with
112
- # no way forward except deleting a file they cannot see, so it is replaced — the same
113
- # decision the render job's recovery path makes about a stale pid file.
119
+ if self._someone_is_listening():
120
+ raise RuntimeError(
121
+ "another Blender is already serving %s. Give this one its own path with "
122
+ "DEEPBLEND_BRIDGE_SOCKET (or the add-on's preferences), or stop the other bridge."
123
+ % (self.socket_path,)
124
+ )
125
+ # Nothing answers, so it is the file a process that died left behind. Refusing here would
126
+ # leave the user with no way forward except deleting a file they cannot see.
114
127
  os.unlink(self.socket_path)
115
128
  self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
116
129
  self.server.bind(self.socket_path)
@@ -118,6 +131,21 @@ class _Bridge:
118
131
  self.thread = threading.Thread(target=self._serve, name="deepblend-bridge", daemon=True)
119
132
  self.thread.start()
120
133
 
134
+ def _someone_is_listening(self):
135
+ """Whether a live bridge is behind this path, rather than a file left by a dead one."""
136
+ probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
137
+ probe.settimeout(1.0)
138
+ try:
139
+ probe.connect(self.socket_path)
140
+ return True
141
+ except Exception:
142
+ return False
143
+ finally:
144
+ try:
145
+ probe.close()
146
+ except Exception:
147
+ pass
148
+
121
149
  def stop(self):
122
150
  try:
123
151
  if self.server is not None:
@@ -289,7 +317,15 @@ def main():
289
317
 
290
318
  global _BRIDGE
291
319
  _BRIDGE = _Bridge(socket_path, {"proc": None})
292
- _BRIDGE.start()
320
+ try:
321
+ _BRIDGE.start()
322
+ except Exception as exc:
323
+ # A REFUSAL IS A RESULT, NOT A TRACEBACK. The one thing that can go wrong here is a named,
324
+ # actionable condition — another bridge on the same path — and a caller parsing stdout should
325
+ # get a document it can read rather than a stack it has to scrape. The add-on path already
326
+ # reports this in its panel; this is the same sentence for the headless one.
327
+ print(json.dumps({"kind": "error", "message": bootstrap.error_text(exc)}), flush=True)
328
+ return 2
293
329
  print(json.dumps({"kind": "ready", "pid": os.getpid(), "socket": socket_path}), flush=True)
294
330
  try:
295
331
  # The serve thread is a daemon, so this loop is what keeps the process alive. A SIGTERM (or