@deepblend/dsh-blender-provider-local 0.2.8 → 0.2.10

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
@@ -363,6 +363,8 @@ class BlenderSession {
363
363
  // itself, in the `ready` line bootstrap.py opens with — which is better evidence anyway, because
364
364
  // it is the pid the script believes it is rather than the one the spawn returned.
365
365
  this.pid = input.transport.pid ?? null
366
+ /** The workspace the other end says it serves, or null when it did not say. */
367
+ this.workspace = null
366
368
  /** @type {Map<string, {resolve: Function, reject: Function, timer: object}>} */
367
369
  this.waiting = new Map()
368
370
  this.progress = []
@@ -456,6 +458,9 @@ class BlenderSession {
456
458
  }
457
459
  if (document?.kind === 'ready') {
458
460
  if (typeof document.pid === 'number') this.pid = document.pid
461
+ // What the other end says it serves, when it says anything. Empty means "not stated", which the
462
+ // attach path treats as "serves anything" — every deployment before this key existed.
463
+ if (typeof document.workspace === 'string') this.workspace = document.workspace
459
464
  this.readyResolve?.(this)
460
465
  return
461
466
  }
@@ -986,6 +991,24 @@ export default class LocalBlenderRuntime extends Service {
986
991
  // connected and said nothing took 180 s to report — long enough that a user would conclude the
987
992
  // product had hung.
988
993
  await session.ready(ATTACH_READY_MS)
994
+
995
+ // THE WRONG-BLENDER CHECK. A socket path has a global default, so two workspaces on one machine
996
+ // can point the product at the SAME Blender — and without this, workspace B would drive the
997
+ // Blender workspace A's user is looking at, silently, because both sides behave correctly. A
998
+ // bridge that states a workspace and states a DIFFERENT one is refused; a bridge that states none
999
+ // is served, because that is what every deployment did before the key existed.
1000
+ const stated = session.workspace
1001
+ if (typeof stated === 'string' && stated.length > 0 && resolve(stated) !== resolve(this.workspaceRoot)) {
1002
+ await session.close()
1003
+ throw new BlenderError(
1004
+ BlenderErrorCode.RUNTIME_UNAVAILABLE,
1005
+ `The Blender at ${socketPath} serves the workspace ${stated}, and this deployment is ${this.workspaceRoot}. ` +
1006
+ 'Point this deployment at its own bridge (DEEPBLEND_BRIDGE_SOCKET / sessionSocket), or start that Blender ' +
1007
+ 'with DEEPBLEND_BRIDGE_WORKSPACE set to this workspace.',
1008
+ { detail: { socketPath, bridgeWorkspace: stated, workspaceRoot: this.workspaceRoot } },
1009
+ )
1010
+ }
1011
+
989
1012
  return session
990
1013
  }
991
1014
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
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.8"
14
+ "@deepblend/dsh-blender-contracts": "0.2.10"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -97,6 +97,15 @@ class _Bridge:
97
97
  def __init__(self, socket_path, options=None):
98
98
  self.socket_path = socket_path
99
99
  self.options = options or {}
100
+ # WHICH WORKSPACE THIS BLENDER SERVES, when it was told. Empty means "not stated", which is
101
+ # what every deployment did before this existed — and the product treats "not stated" as
102
+ # "serves anything" rather than as a mismatch, so nothing that worked before stops working.
103
+ #
104
+ # WHY IT MATTERS: the socket path has a global default, so two workspaces on one machine can
105
+ # point the product at the SAME Blender. Without this, the product in workspace B would drive
106
+ # the Blender that workspace A's user is looking at, and the operations would land in a scene
107
+ # nobody intended — silently, because both sides are behaving correctly.
108
+ self.workspace = self.options.get("workspace") or ""
100
109
  self.server = None
101
110
  self.thread = None
102
111
  self.requests = 0
@@ -107,10 +116,23 @@ class _Bridge:
107
116
  # -- lifecycle ---------------------------------------------------------
108
117
 
109
118
  def start(self):
119
+ # THREE STATES, NOT TWO. A socket path that exists is either a LIVE bridge or the file a dead
120
+ # one left behind, and the difference decides everything.
121
+ #
122
+ # MEASURED, and this is why: two workspaces on one machine both enabled the add-on, and the
123
+ # SECOND bridge unlinked the first's socket file, bound its own, and announced `ready` — so the
124
+ # first Blender became unreachable while still believing it was listening, and the second
125
+ # reported success. That is the failure this repository keeps paying for: something other than
126
+ # what was asked, reported as fine.
110
127
  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.
128
+ if self._someone_is_listening():
129
+ raise RuntimeError(
130
+ "another Blender is already serving %s. Give this one its own path with "
131
+ "DEEPBLEND_BRIDGE_SOCKET (or the add-on's preferences), or stop the other bridge."
132
+ % (self.socket_path,)
133
+ )
134
+ # Nothing answers, so it is the file a process that died left behind. Refusing here would
135
+ # leave the user with no way forward except deleting a file they cannot see.
114
136
  os.unlink(self.socket_path)
115
137
  self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
116
138
  self.server.bind(self.socket_path)
@@ -118,6 +140,21 @@ class _Bridge:
118
140
  self.thread = threading.Thread(target=self._serve, name="deepblend-bridge", daemon=True)
119
141
  self.thread.start()
120
142
 
143
+ def _someone_is_listening(self):
144
+ """Whether a live bridge is behind this path, rather than a file left by a dead one."""
145
+ probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
146
+ probe.settimeout(1.0)
147
+ try:
148
+ probe.connect(self.socket_path)
149
+ return True
150
+ except Exception:
151
+ return False
152
+ finally:
153
+ try:
154
+ probe.close()
155
+ except Exception:
156
+ pass
157
+
121
158
  def stop(self):
122
159
  try:
123
160
  if self.server is not None:
@@ -159,7 +196,12 @@ class _Bridge:
159
196
  nothing — so the product's `BlenderSession.ready()` waited forever on a peer that was already
160
197
  serving. A transport that answers the same conversation has to open it the same way.
161
198
  """
162
- connection.sendall((json.dumps({"kind": "ready", "pid": os.getpid(), "socket": self.socket_path}) + "\n").encode("utf-8"))
199
+ connection.sendall((json.dumps({
200
+ "kind": "ready",
201
+ "pid": os.getpid(),
202
+ "socket": self.socket_path,
203
+ "workspace": self.workspace,
204
+ }) + "\n").encode("utf-8"))
163
205
  buffer = b""
164
206
  while True:
165
207
  chunk = connection.recv(65536)
@@ -239,6 +281,8 @@ class DEEPBLEND_PT_bridge(bpy.types.Panel):
239
281
  layout.label(text="Status: %s" % (_BRIDGE.status(),))
240
282
  layout.label(text="Socket: %s" % (os.path.basename(_BRIDGE.socket_path),))
241
283
  layout.label(text="Operations served: %d" % (_BRIDGE.requests,))
284
+ if _BRIDGE.workspace:
285
+ layout.label(text="Workspace: %s" % (os.path.basename(_BRIDGE.workspace),))
242
286
  if _BRIDGE.last_action is not None:
243
287
  layout.label(text="Last: %s" % (_BRIDGE.last_action,))
244
288
  if _BRIDGE.last_error is not None:
@@ -248,7 +292,7 @@ class DEEPBLEND_PT_bridge(bpy.types.Panel):
248
292
  def register():
249
293
  global _BRIDGE
250
294
  socket_path = os.environ.get("DEEPBLEND_BRIDGE_SOCKET", DEFAULT_SOCKET)
251
- _BRIDGE = _Bridge(socket_path)
295
+ _BRIDGE = _Bridge(socket_path, {"workspace": os.environ.get("DEEPBLEND_BRIDGE_WORKSPACE", "")})
252
296
  try:
253
297
  _BRIDGE.start()
254
298
  except Exception as exc:
@@ -272,6 +316,7 @@ def main():
272
316
  """Headless entry point: serve until the process is asked to stop."""
273
317
  argv = sys.argv
274
318
  options = {}
319
+ workspace = os.environ.get("DEEPBLEND_BRIDGE_WORKSPACE", "")
275
320
  if "--" in argv:
276
321
  tokens = argv[argv.index("--") + 1:]
277
322
  index = 0
@@ -280,6 +325,10 @@ def main():
280
325
  options["socket"] = tokens[index + 1]
281
326
  index += 2
282
327
  continue
328
+ if tokens[index] == "--workspace" and index + 1 < len(tokens):
329
+ workspace = tokens[index + 1]
330
+ index += 2
331
+ continue
283
332
  index += 1
284
333
  socket_path = options.get("socket", DEFAULT_SOCKET)
285
334
 
@@ -288,9 +337,17 @@ def main():
288
337
  return 2
289
338
 
290
339
  global _BRIDGE
291
- _BRIDGE = _Bridge(socket_path, {"proc": None})
292
- _BRIDGE.start()
293
- print(json.dumps({"kind": "ready", "pid": os.getpid(), "socket": socket_path}), flush=True)
340
+ _BRIDGE = _Bridge(socket_path, {"proc": None, "workspace": workspace})
341
+ try:
342
+ _BRIDGE.start()
343
+ except Exception as exc:
344
+ # A REFUSAL IS A RESULT, NOT A TRACEBACK. The one thing that can go wrong here is a named,
345
+ # actionable condition — another bridge on the same path — and a caller parsing stdout should
346
+ # get a document it can read rather than a stack it has to scrape. The add-on path already
347
+ # reports this in its panel; this is the same sentence for the headless one.
348
+ print(json.dumps({"kind": "error", "message": bootstrap.error_text(exc)}), flush=True)
349
+ return 2
350
+ print(json.dumps({"kind": "ready", "pid": os.getpid(), "socket": socket_path, "workspace": workspace}), flush=True)
294
351
  try:
295
352
  # The serve thread is a daemon, so this loop is what keeps the process alive. A SIGTERM (or
296
353
  # the product closing the socket and asking for shutdown) ends it.