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

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.9",
3
+ "version": "0.2.11",
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.9"
14
+ "@deepblend/dsh-blender-contracts": "0.2.11"
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
@@ -131,6 +140,29 @@ class _Bridge:
131
140
  self.thread = threading.Thread(target=self._serve, name="deepblend-bridge", daemon=True)
132
141
  self.thread.start()
133
142
 
143
+ @staticmethod
144
+ def derive_workspace(open_file):
145
+ """The workspace a Blender belongs to, read from the file it has open — or nothing at all.
146
+
147
+ WHY THIS IS A DERIVATION AND NOT A GUESS. A checkpoint the product wrote lives at
148
+ `<workspace>/.deepblend/projects/<id>/revisions/<rev>/scene.blend`, so a Blender that has one
149
+ of those open IS working in that workspace — the path says so, in the product's own layout.
150
+ Anything else (an empty path, a file the user made themselves, a project laid out differently)
151
+ returns nothing, and nothing is what the product treats as "serves anything".
152
+
153
+ THAT ASYMMETRY IS THE POINT: a bridge that states a workspace it cannot be sure of would be
154
+ refused by the product for a mismatch the user did not cause, and the fix would look like a
155
+ configuration problem when it is a wrong guess. Silence is the honest answer when the path does
156
+ not say.
157
+ """
158
+ if not isinstance(open_file, str) or open_file == "":
159
+ return ""
160
+ marker = os.sep + ".deepblend" + os.sep
161
+ head, separator, _ = open_file.partition(marker)
162
+ if separator == "" or head == "":
163
+ return ""
164
+ return head
165
+
134
166
  def _someone_is_listening(self):
135
167
  """Whether a live bridge is behind this path, rather than a file left by a dead one."""
136
168
  probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
@@ -187,7 +219,12 @@ class _Bridge:
187
219
  nothing — so the product's `BlenderSession.ready()` waited forever on a peer that was already
188
220
  serving. A transport that answers the same conversation has to open it the same way.
189
221
  """
190
- connection.sendall((json.dumps({"kind": "ready", "pid": os.getpid(), "socket": self.socket_path}) + "\n").encode("utf-8"))
222
+ connection.sendall((json.dumps({
223
+ "kind": "ready",
224
+ "pid": os.getpid(),
225
+ "socket": self.socket_path,
226
+ "workspace": self.workspace,
227
+ }) + "\n").encode("utf-8"))
191
228
  buffer = b""
192
229
  while True:
193
230
  chunk = connection.recv(65536)
@@ -267,6 +304,8 @@ class DEEPBLEND_PT_bridge(bpy.types.Panel):
267
304
  layout.label(text="Status: %s" % (_BRIDGE.status(),))
268
305
  layout.label(text="Socket: %s" % (os.path.basename(_BRIDGE.socket_path),))
269
306
  layout.label(text="Operations served: %d" % (_BRIDGE.requests,))
307
+ if _BRIDGE.workspace:
308
+ layout.label(text="Workspace: %s" % (os.path.basename(_BRIDGE.workspace),))
270
309
  if _BRIDGE.last_action is not None:
271
310
  layout.label(text="Last: %s" % (_BRIDGE.last_action,))
272
311
  if _BRIDGE.last_error is not None:
@@ -276,7 +315,11 @@ class DEEPBLEND_PT_bridge(bpy.types.Panel):
276
315
  def register():
277
316
  global _BRIDGE
278
317
  socket_path = os.environ.get("DEEPBLEND_BRIDGE_SOCKET", DEFAULT_SOCKET)
279
- _BRIDGE = _Bridge(socket_path)
318
+ workspace = os.environ.get("DEEPBLEND_BRIDGE_WORKSPACE", "")
319
+ if workspace == "":
320
+ # In a GUI the user has a file open, and if the product wrote it, it names the workspace.
321
+ workspace = _Bridge.derive_workspace(getattr(bpy.data, "filepath", ""))
322
+ _BRIDGE = _Bridge(socket_path, {"workspace": workspace})
280
323
  try:
281
324
  _BRIDGE.start()
282
325
  except Exception as exc:
@@ -300,6 +343,7 @@ def main():
300
343
  """Headless entry point: serve until the process is asked to stop."""
301
344
  argv = sys.argv
302
345
  options = {}
346
+ workspace = os.environ.get("DEEPBLEND_BRIDGE_WORKSPACE", "")
303
347
  if "--" in argv:
304
348
  tokens = argv[argv.index("--") + 1:]
305
349
  index = 0
@@ -308,15 +352,23 @@ def main():
308
352
  options["socket"] = tokens[index + 1]
309
353
  index += 2
310
354
  continue
355
+ if tokens[index] == "--workspace" and index + 1 < len(tokens):
356
+ workspace = tokens[index + 1]
357
+ index += 2
358
+ continue
311
359
  index += 1
312
360
  socket_path = options.get("socket", DEFAULT_SOCKET)
361
+ if workspace == "":
362
+ # WHAT THE OPEN FILE SAYS, when it says anything. An operator's explicit setting always wins;
363
+ # this only fills a silence, and only from a path that follows the product's own layout.
364
+ workspace = _Bridge.derive_workspace(getattr(bpy.data, "filepath", ""))
313
365
 
314
366
  if bootstrap is None:
315
367
  print(json.dumps({"kind": "error", "message": BOOTSTRAP_PROBLEM}), flush=True)
316
368
  return 2
317
369
 
318
370
  global _BRIDGE
319
- _BRIDGE = _Bridge(socket_path, {"proc": None})
371
+ _BRIDGE = _Bridge(socket_path, {"proc": None, "workspace": workspace})
320
372
  try:
321
373
  _BRIDGE.start()
322
374
  except Exception as exc:
@@ -326,7 +378,7 @@ def main():
326
378
  # reports this in its panel; this is the same sentence for the headless one.
327
379
  print(json.dumps({"kind": "error", "message": bootstrap.error_text(exc)}), flush=True)
328
380
  return 2
329
- print(json.dumps({"kind": "ready", "pid": os.getpid(), "socket": socket_path}), flush=True)
381
+ print(json.dumps({"kind": "ready", "pid": os.getpid(), "socket": socket_path, "workspace": workspace}), flush=True)
330
382
  try:
331
383
  # The serve thread is a daemon, so this loop is what keeps the process alive. A SIGTERM (or
332
384
  # the product closing the socket and asking for shutdown) ends it.