comfy-env 0.0.65__py3-none-any.whl → 0.0.66__py3-none-any.whl

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.
Files changed (55) hide show
  1. comfy_env/__init__.py +70 -122
  2. comfy_env/cli.py +78 -7
  3. comfy_env/config/__init__.py +19 -0
  4. comfy_env/config/parser.py +151 -0
  5. comfy_env/config/types.py +64 -0
  6. comfy_env/install.py +83 -361
  7. comfy_env/isolation/__init__.py +9 -0
  8. comfy_env/isolation/wrap.py +351 -0
  9. comfy_env/nodes.py +2 -2
  10. comfy_env/pixi/__init__.py +48 -0
  11. comfy_env/pixi/core.py +356 -0
  12. comfy_env/{resolver.py → pixi/resolver.py} +1 -14
  13. comfy_env/prestartup.py +60 -0
  14. comfy_env/templates/comfy-env-instructions.txt +30 -87
  15. comfy_env/templates/comfy-env.toml +69 -128
  16. comfy_env/workers/__init__.py +21 -32
  17. comfy_env/workers/base.py +1 -1
  18. comfy_env/workers/{torch_mp.py → mp.py} +47 -14
  19. comfy_env/workers/{venv.py → subprocess.py} +397 -443
  20. {comfy_env-0.0.65.dist-info → comfy_env-0.0.66.dist-info}/METADATA +2 -1
  21. comfy_env-0.0.66.dist-info/RECORD +34 -0
  22. comfy_env/decorator.py +0 -700
  23. comfy_env/env/__init__.py +0 -46
  24. comfy_env/env/config.py +0 -191
  25. comfy_env/env/config_file.py +0 -706
  26. comfy_env/env/manager.py +0 -636
  27. comfy_env/env/security.py +0 -267
  28. comfy_env/ipc/__init__.py +0 -55
  29. comfy_env/ipc/bridge.py +0 -476
  30. comfy_env/ipc/protocol.py +0 -265
  31. comfy_env/ipc/tensor.py +0 -371
  32. comfy_env/ipc/torch_bridge.py +0 -401
  33. comfy_env/ipc/transport.py +0 -318
  34. comfy_env/ipc/worker.py +0 -221
  35. comfy_env/isolation.py +0 -310
  36. comfy_env/pixi.py +0 -760
  37. comfy_env/stub_imports.py +0 -270
  38. comfy_env/stubs/__init__.py +0 -1
  39. comfy_env/stubs/comfy/__init__.py +0 -6
  40. comfy_env/stubs/comfy/model_management.py +0 -58
  41. comfy_env/stubs/comfy/utils.py +0 -29
  42. comfy_env/stubs/folder_paths.py +0 -71
  43. comfy_env/workers/pool.py +0 -241
  44. comfy_env-0.0.65.dist-info/RECORD +0 -48
  45. /comfy_env/{env/cuda_gpu_detection.py → pixi/cuda_detection.py} +0 -0
  46. /comfy_env/{env → pixi}/platform/__init__.py +0 -0
  47. /comfy_env/{env → pixi}/platform/base.py +0 -0
  48. /comfy_env/{env → pixi}/platform/darwin.py +0 -0
  49. /comfy_env/{env → pixi}/platform/linux.py +0 -0
  50. /comfy_env/{env → pixi}/platform/windows.py +0 -0
  51. /comfy_env/{registry.py → pixi/registry.py} +0 -0
  52. /comfy_env/{wheel_sources.yml → pixi/wheel_sources.yml} +0 -0
  53. {comfy_env-0.0.65.dist-info → comfy_env-0.0.66.dist-info}/WHEEL +0 -0
  54. {comfy_env-0.0.65.dist-info → comfy_env-0.0.66.dist-info}/entry_points.txt +0 -0
  55. {comfy_env-0.0.65.dist-info → comfy_env-0.0.66.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,11 @@
1
1
  """
2
- VenvWorker - Cross-venv isolation using subprocess + shared memory.
2
+ SubprocessWorker - Cross-venv isolation using persistent subprocess + socket IPC.
3
3
 
4
4
  This worker supports calling functions in a different Python environment:
5
- - Uses subprocess.Popen to run in different venv
6
- - Transfers tensors via torch.save/load through /dev/shm (RAM-backed)
7
- - One memcpy per tensor per direction
8
- - ~100-500ms overhead per call (subprocess spawn + tensor I/O)
5
+ - Uses a persistent subprocess to avoid spawn overhead
6
+ - Socket-based IPC for commands/responses
7
+ - Transfers tensors via torch.save/load over socket
8
+ - ~50-100ms overhead per call
9
9
 
10
10
  Use this when you need:
11
11
  - Different PyTorch version
@@ -13,16 +13,17 @@ Use this when you need:
13
13
  - Different Python version
14
14
 
15
15
  Example:
16
- worker = VenvWorker(
16
+ worker = SubprocessWorker(
17
17
  python="/path/to/other/venv/bin/python",
18
18
  working_dir="/path/to/code",
19
19
  )
20
20
 
21
- # Call a function by module path
22
- result = worker.call_module(
23
- module="my_module",
24
- func="my_function",
25
- image=my_tensor,
21
+ # Call a method by module path
22
+ result = worker.call_method(
23
+ module_name="my_module",
24
+ class_name="MyClass",
25
+ method_name="process",
26
+ kwargs={"image": my_tensor},
26
27
  )
27
28
  """
28
29
 
@@ -185,7 +186,142 @@ class SocketTransport:
185
186
 
186
187
 
187
188
  # =============================================================================
188
- # Serialization helpers
189
+ # Shared Memory Serialization
190
+ # =============================================================================
191
+
192
+ from multiprocessing import shared_memory as shm
193
+ import numpy as np
194
+
195
+
196
+ def _to_shm(obj, registry, visited=None):
197
+ """
198
+ Serialize object to shared memory. Returns JSON-safe metadata.
199
+
200
+ Args:
201
+ obj: Object to serialize
202
+ registry: List to track SharedMemory objects for cleanup
203
+ visited: Dict tracking already-serialized objects (cycle detection)
204
+ """
205
+ if visited is None:
206
+ visited = {}
207
+
208
+ obj_id = id(obj)
209
+ if obj_id in visited:
210
+ return visited[obj_id]
211
+
212
+ t = type(obj).__name__
213
+
214
+ # numpy array → direct shared memory
215
+ if t == 'ndarray':
216
+ arr = np.ascontiguousarray(obj)
217
+ block = shm.SharedMemory(create=True, size=arr.nbytes)
218
+ np.ndarray(arr.shape, arr.dtype, buffer=block.buf)[:] = arr
219
+ registry.append(block)
220
+ result = {"__shm_np__": block.name, "shape": list(arr.shape), "dtype": str(arr.dtype)}
221
+ visited[obj_id] = result
222
+ return result
223
+
224
+ # torch.Tensor → convert to numpy → shared memory
225
+ if t == 'Tensor':
226
+ arr = obj.detach().cpu().numpy()
227
+ return _to_shm(arr, registry, visited)
228
+
229
+ # trimesh.Trimesh → vertices + faces arrays → shared memory
230
+ if t == 'Trimesh':
231
+ verts = np.ascontiguousarray(obj.vertices, dtype=np.float64)
232
+ faces = np.ascontiguousarray(obj.faces, dtype=np.int64)
233
+
234
+ v_block = shm.SharedMemory(create=True, size=verts.nbytes)
235
+ np.ndarray(verts.shape, verts.dtype, buffer=v_block.buf)[:] = verts
236
+ registry.append(v_block)
237
+
238
+ f_block = shm.SharedMemory(create=True, size=faces.nbytes)
239
+ np.ndarray(faces.shape, faces.dtype, buffer=f_block.buf)[:] = faces
240
+ registry.append(f_block)
241
+
242
+ result = {
243
+ "__shm_trimesh__": True,
244
+ "v_name": v_block.name, "v_shape": list(verts.shape),
245
+ "f_name": f_block.name, "f_shape": list(faces.shape),
246
+ }
247
+ visited[obj_id] = result
248
+ return result
249
+
250
+ # Path → string
251
+ from pathlib import PurePath
252
+ if isinstance(obj, PurePath):
253
+ return str(obj)
254
+
255
+ # dict
256
+ if isinstance(obj, dict):
257
+ result = {k: _to_shm(v, registry, visited) for k, v in obj.items()}
258
+ visited[obj_id] = result
259
+ return result
260
+
261
+ # list/tuple
262
+ if isinstance(obj, list):
263
+ result = [_to_shm(v, registry, visited) for v in obj]
264
+ visited[obj_id] = result
265
+ return result
266
+ if isinstance(obj, tuple):
267
+ result = [_to_shm(v, registry, visited) for v in obj] # JSON doesn't have tuples
268
+ visited[obj_id] = result
269
+ return result
270
+
271
+ # primitives pass through
272
+ return obj
273
+
274
+
275
+ def _from_shm(obj, unlink=True):
276
+ """Reconstruct object from shared memory metadata."""
277
+ if not isinstance(obj, dict):
278
+ if isinstance(obj, list):
279
+ return [_from_shm(v, unlink) for v in obj]
280
+ return obj
281
+
282
+ # numpy array
283
+ if "__shm_np__" in obj:
284
+ block = shm.SharedMemory(name=obj["__shm_np__"])
285
+ arr = np.ndarray(tuple(obj["shape"]), dtype=np.dtype(obj["dtype"]), buffer=block.buf).copy()
286
+ block.close()
287
+ if unlink:
288
+ block.unlink()
289
+ return arr
290
+
291
+ # trimesh
292
+ if "__shm_trimesh__" in obj:
293
+ import trimesh
294
+ v_block = shm.SharedMemory(name=obj["v_name"])
295
+ verts = np.ndarray(tuple(obj["v_shape"]), dtype=np.float64, buffer=v_block.buf).copy()
296
+ v_block.close()
297
+ if unlink:
298
+ v_block.unlink()
299
+
300
+ f_block = shm.SharedMemory(name=obj["f_name"])
301
+ faces = np.ndarray(tuple(obj["f_shape"]), dtype=np.int64, buffer=f_block.buf).copy()
302
+ f_block.close()
303
+ if unlink:
304
+ f_block.unlink()
305
+
306
+ return trimesh.Trimesh(vertices=verts, faces=faces, process=False)
307
+
308
+ # regular dict - recurse
309
+ return {k: _from_shm(v, unlink) for k, v in obj.items()}
310
+
311
+
312
+ def _cleanup_shm(registry):
313
+ """Unlink all shared memory blocks in registry."""
314
+ for block in registry:
315
+ try:
316
+ block.close()
317
+ block.unlink()
318
+ except Exception:
319
+ pass
320
+ registry.clear()
321
+
322
+
323
+ # =============================================================================
324
+ # Legacy Serialization helpers (for isolated objects)
189
325
  # =============================================================================
190
326
 
191
327
 
@@ -257,104 +393,6 @@ def _serialize_for_ipc(obj, visited=None):
257
393
  return obj
258
394
 
259
395
 
260
- # Worker script template - minimal, runs in target venv
261
- _WORKER_SCRIPT = '''
262
- import sys
263
- import os
264
- import json
265
- import traceback
266
- from types import SimpleNamespace
267
-
268
- # On Windows, add DLL directories for proper library loading
269
- if sys.platform == "win32" and hasattr(os, "add_dll_directory"):
270
- _host_python_dir = os.environ.get("COMFYUI_HOST_PYTHON_DIR")
271
- if _host_python_dir:
272
- try:
273
- os.add_dll_directory(_host_python_dir)
274
- _dlls_dir = os.path.join(_host_python_dir, "DLLs")
275
- if os.path.isdir(_dlls_dir):
276
- os.add_dll_directory(_dlls_dir)
277
- except Exception:
278
- pass
279
- _pixi_library_bin = os.environ.get("COMFYUI_PIXI_LIBRARY_BIN")
280
- if _pixi_library_bin:
281
- try:
282
- os.add_dll_directory(_pixi_library_bin)
283
- except Exception:
284
- pass
285
-
286
- def _deserialize_isolated_objects(obj):
287
- """Reconstruct objects serialized with __isolated_object__ marker."""
288
- if isinstance(obj, dict):
289
- if obj.get("__isolated_object__"):
290
- # Reconstruct as SimpleNamespace (supports .attr access)
291
- attrs = {k: _deserialize_isolated_objects(v) for k, v in obj.get("__attrs__", {}).items()}
292
- ns = SimpleNamespace(**attrs)
293
- ns.__class_name__ = obj.get("__class_name__", "Unknown")
294
- return ns
295
- return {k: _deserialize_isolated_objects(v) for k, v in obj.items()}
296
- elif isinstance(obj, list):
297
- return [_deserialize_isolated_objects(v) for v in obj]
298
- elif isinstance(obj, tuple):
299
- return tuple(_deserialize_isolated_objects(v) for v in obj)
300
- return obj
301
-
302
- def main():
303
- # Read request from file
304
- request_path = sys.argv[1]
305
- response_path = sys.argv[2]
306
-
307
- with open(request_path, 'r') as f:
308
- request = json.load(f)
309
-
310
- try:
311
- # Setup paths
312
- for p in request.get("sys_path", []):
313
- if p not in sys.path:
314
- sys.path.insert(0, p)
315
-
316
- # Import torch for tensor I/O
317
- import torch
318
-
319
- # Load inputs
320
- inputs_path = request.get("inputs_path")
321
- if inputs_path:
322
- inputs = torch.load(inputs_path, weights_only=False)
323
- inputs = _deserialize_isolated_objects(inputs)
324
- else:
325
- inputs = {}
326
-
327
- # Import and call function
328
- module_name = request["module"]
329
- func_name = request["func"]
330
-
331
- module = __import__(module_name, fromlist=[func_name])
332
- func = getattr(module, func_name)
333
-
334
- result = func(**inputs)
335
-
336
- # Save outputs
337
- outputs_path = request.get("outputs_path")
338
- if outputs_path:
339
- torch.save(result, outputs_path)
340
-
341
- response = {"status": "ok"}
342
-
343
- except Exception as e:
344
- response = {
345
- "status": "error",
346
- "error": str(e),
347
- "traceback": traceback.format_exc(),
348
- }
349
-
350
- with open(response_path, 'w') as f:
351
- json.dump(response, f)
352
-
353
- if __name__ == "__main__":
354
- main()
355
- '''
356
-
357
-
358
396
  def _get_shm_dir() -> Path:
359
397
  """Get shared memory directory for efficient tensor transfer."""
360
398
  # Linux: /dev/shm is RAM-backed tmpfs
@@ -364,249 +402,6 @@ def _get_shm_dir() -> Path:
364
402
  return Path(tempfile.gettempdir())
365
403
 
366
404
 
367
- class VenvWorker(Worker):
368
- """
369
- Worker using subprocess for cross-venv isolation.
370
-
371
- This worker spawns a new Python process for each call, using
372
- a different Python interpreter (from another venv). Tensors are
373
- transferred via torch.save/load through shared memory.
374
-
375
- For long-running workloads, consider using persistent mode which
376
- keeps the subprocess alive between calls.
377
- """
378
-
379
- def __init__(
380
- self,
381
- python: Union[str, Path],
382
- working_dir: Optional[Union[str, Path]] = None,
383
- sys_path: Optional[List[str]] = None,
384
- env: Optional[Dict[str, str]] = None,
385
- name: Optional[str] = None,
386
- persistent: bool = True,
387
- ):
388
- """
389
- Initialize the worker.
390
-
391
- Args:
392
- python: Path to Python executable in target venv.
393
- working_dir: Working directory for subprocess.
394
- sys_path: Additional paths to add to sys.path in subprocess.
395
- env: Additional environment variables.
396
- name: Optional name for logging.
397
- persistent: If True, keep subprocess alive between calls (faster).
398
- """
399
- self.python = Path(python)
400
- self.working_dir = Path(working_dir) if working_dir else Path.cwd()
401
- self.sys_path = sys_path or []
402
- self.extra_env = env or {}
403
- self.name = name or f"VenvWorker({self.python.parent.parent.name})"
404
- self.persistent = persistent
405
-
406
- # Verify Python exists
407
- if not self.python.exists():
408
- raise FileNotFoundError(f"Python not found: {self.python}")
409
-
410
- # Create temp directory for IPC files
411
- self._temp_dir = Path(tempfile.mkdtemp(prefix='comfyui_venv_'))
412
- self._shm_dir = _get_shm_dir()
413
-
414
- # Persistent process state
415
- self._process: Optional[subprocess.Popen] = None
416
- self._shutdown = False
417
-
418
- # Write worker script
419
- self._worker_script = self._temp_dir / "worker.py"
420
- self._worker_script.write_text(_WORKER_SCRIPT)
421
-
422
- def call(
423
- self,
424
- func: Callable,
425
- *args,
426
- timeout: Optional[float] = None,
427
- **kwargs
428
- ) -> Any:
429
- """
430
- Execute a function - NOT SUPPORTED for VenvWorker.
431
-
432
- VenvWorker cannot pickle arbitrary functions across venv boundaries.
433
- Use call_module() instead to call functions by module path.
434
-
435
- Raises:
436
- NotImplementedError: Always.
437
- """
438
- raise NotImplementedError(
439
- f"{self.name}: VenvWorker cannot call arbitrary functions. "
440
- f"Use call_module(module='...', func='...', **kwargs) instead."
441
- )
442
-
443
- def call_module(
444
- self,
445
- module: str,
446
- func: str,
447
- timeout: Optional[float] = None,
448
- **kwargs
449
- ) -> Any:
450
- """
451
- Call a function by module path in the isolated venv.
452
-
453
- Args:
454
- module: Module name (e.g., "my_package.my_module").
455
- func: Function name within the module.
456
- timeout: Timeout in seconds (None = 600s default).
457
- **kwargs: Keyword arguments passed to the function.
458
- Must be torch.save-compatible (tensors, dicts, etc.).
459
-
460
- Returns:
461
- Return value of module.func(**kwargs).
462
-
463
- Raises:
464
- WorkerError: If function raises an exception.
465
- TimeoutError: If execution exceeds timeout.
466
- """
467
- if self._shutdown:
468
- raise RuntimeError(f"{self.name}: Worker has been shut down")
469
-
470
- timeout = timeout or 600.0 # 10 minute default
471
-
472
- # Create unique ID for this call
473
- call_id = str(uuid.uuid4())[:8]
474
-
475
- # Paths for IPC (use shm for tensors, temp for json)
476
- inputs_path = self._shm_dir / f"comfyui_venv_{call_id}_in.pt"
477
- outputs_path = self._shm_dir / f"comfyui_venv_{call_id}_out.pt"
478
- request_path = self._temp_dir / f"request_{call_id}.json"
479
- response_path = self._temp_dir / f"response_{call_id}.json"
480
-
481
- try:
482
- # Save inputs via torch.save (handles tensors natively)
483
- # Serialize custom objects with broken __module__ paths first
484
- import torch
485
- if kwargs:
486
- serialized_kwargs = _serialize_for_ipc(kwargs)
487
- torch.save(serialized_kwargs, str(inputs_path))
488
-
489
- # Build request
490
- request = {
491
- "module": module,
492
- "func": func,
493
- "sys_path": [str(self.working_dir)] + self.sys_path,
494
- "inputs_path": str(inputs_path) if kwargs else None,
495
- "outputs_path": str(outputs_path),
496
- }
497
-
498
- request_path.write_text(json.dumps(request))
499
-
500
- # Build environment
501
- env = os.environ.copy()
502
- env.update(self.extra_env)
503
- env["COMFYUI_ISOLATION_WORKER"] = "1"
504
-
505
- # For conda/pixi environments, add lib dir to LD_LIBRARY_PATH (Linux)
506
- lib_dir = self.python.parent.parent / "lib"
507
- if lib_dir.is_dir():
508
- existing = env.get("LD_LIBRARY_PATH", "")
509
- env["LD_LIBRARY_PATH"] = f"{lib_dir}:{existing}" if existing else str(lib_dir)
510
-
511
- # On Windows, pass host Python directory and pixi Library/bin for DLL loading
512
- if sys.platform == "win32":
513
- env["COMFYUI_HOST_PYTHON_DIR"] = str(Path(sys.executable).parent)
514
-
515
- # For pixi environments with MKL, add Library/bin to PATH for DLL loading
516
- # Pixi has python.exe directly in env dir, not in Scripts/
517
- env_dir = self.python.parent
518
- library_bin = env_dir / "Library" / "bin"
519
- if library_bin.is_dir():
520
- existing_path = env.get("PATH", "")
521
- env["PATH"] = f"{env_dir};{library_bin};{existing_path}"
522
- env["COMFYUI_PIXI_LIBRARY_BIN"] = str(library_bin)
523
- # Allow duplicate OpenMP libraries (MKL's libiomp5md.dll + PyTorch's libomp.dll)
524
- env["KMP_DUPLICATE_LIB_OK"] = "TRUE"
525
- # Use UTF-8 encoding for stdout/stderr to handle Unicode symbols
526
- env["PYTHONIOENCODING"] = "utf-8"
527
-
528
- # Run subprocess
529
- cmd = [
530
- str(self.python),
531
- str(self._worker_script),
532
- str(request_path),
533
- str(response_path),
534
- ]
535
-
536
- process = subprocess.Popen(
537
- cmd,
538
- cwd=str(self.working_dir),
539
- env=env,
540
- stdout=subprocess.PIPE,
541
- stderr=subprocess.PIPE,
542
- )
543
-
544
- try:
545
- stdout, stderr = process.communicate(timeout=timeout)
546
- except subprocess.TimeoutExpired:
547
- process.kill()
548
- process.wait()
549
- raise TimeoutError(f"{self.name}: Call timed out after {timeout}s")
550
-
551
- # Check for process error
552
- if process.returncode != 0:
553
- raise WorkerError(
554
- f"Subprocess failed with code {process.returncode}",
555
- traceback=stderr.decode('utf-8', errors='replace'),
556
- )
557
-
558
- # Read response
559
- if not response_path.exists():
560
- raise WorkerError(
561
- f"No response file",
562
- traceback=stderr.decode('utf-8', errors='replace'),
563
- )
564
-
565
- response = json.loads(response_path.read_text())
566
-
567
- if response["status"] == "error":
568
- raise WorkerError(
569
- response.get("error", "Unknown error"),
570
- traceback=response.get("traceback"),
571
- )
572
-
573
- # Load result
574
- if outputs_path.exists():
575
- result = torch.load(str(outputs_path), weights_only=False)
576
- return result
577
- else:
578
- return None
579
-
580
- finally:
581
- # Cleanup IPC files
582
- for path in [inputs_path, outputs_path, request_path, response_path]:
583
- try:
584
- if path.exists():
585
- path.unlink()
586
- except:
587
- pass
588
-
589
- def shutdown(self) -> None:
590
- """Shut down the worker and clean up resources."""
591
- if self._shutdown:
592
- return
593
-
594
- self._shutdown = True
595
-
596
- # Clean up temp directory
597
- try:
598
- shutil.rmtree(self._temp_dir, ignore_errors=True)
599
- except:
600
- pass
601
-
602
- def is_alive(self) -> bool:
603
- """VenvWorker spawns fresh process per call, so always 'alive' if not shutdown."""
604
- return not self._shutdown
605
-
606
- def __repr__(self):
607
- return f"<VenvWorker name={self.name!r} python={self.python}>"
608
-
609
-
610
405
  # Persistent worker script - runs as __main__ in the venv Python subprocess
611
406
  # Uses Unix socket (or TCP localhost) for IPC - completely separate from stdout/stderr
612
407
  _PERSISTENT_WORKER_SCRIPT = '''
@@ -722,6 +517,99 @@ if sys.platform == "win32":
722
517
  except Exception as e:
723
518
  wlog(f"[worker] Failed to add pixi Library/bin: {e}")
724
519
 
520
+ # =============================================================================
521
+ # Shared Memory Serialization
522
+ # =============================================================================
523
+
524
+ from multiprocessing import shared_memory as shm
525
+ import numpy as np
526
+
527
+ def _to_shm(obj, registry, visited=None):
528
+ """Serialize to shared memory. Returns JSON-safe metadata."""
529
+ if visited is None:
530
+ visited = {}
531
+ obj_id = id(obj)
532
+ if obj_id in visited:
533
+ return visited[obj_id]
534
+ t = type(obj).__name__
535
+
536
+ if t == 'ndarray':
537
+ arr = np.ascontiguousarray(obj)
538
+ block = shm.SharedMemory(create=True, size=arr.nbytes)
539
+ np.ndarray(arr.shape, arr.dtype, buffer=block.buf)[:] = arr
540
+ registry.append(block)
541
+ result = {"__shm_np__": block.name, "shape": list(arr.shape), "dtype": str(arr.dtype)}
542
+ visited[obj_id] = result
543
+ return result
544
+
545
+ if t == 'Tensor':
546
+ arr = obj.detach().cpu().numpy()
547
+ return _to_shm(arr, registry, visited)
548
+
549
+ if t == 'Trimesh':
550
+ verts = np.ascontiguousarray(obj.vertices, dtype=np.float64)
551
+ faces = np.ascontiguousarray(obj.faces, dtype=np.int64)
552
+
553
+ v_block = shm.SharedMemory(create=True, size=verts.nbytes)
554
+ np.ndarray(verts.shape, verts.dtype, buffer=v_block.buf)[:] = verts
555
+ registry.append(v_block)
556
+
557
+ f_block = shm.SharedMemory(create=True, size=faces.nbytes)
558
+ np.ndarray(faces.shape, faces.dtype, buffer=f_block.buf)[:] = faces
559
+ registry.append(f_block)
560
+
561
+ result = {
562
+ "__shm_trimesh__": True,
563
+ "v_name": v_block.name, "v_shape": list(verts.shape),
564
+ "f_name": f_block.name, "f_shape": list(faces.shape),
565
+ }
566
+ visited[obj_id] = result
567
+ return result
568
+
569
+ if isinstance(obj, dict):
570
+ result = {k: _to_shm(v, registry, visited) for k, v in obj.items()}
571
+ visited[obj_id] = result
572
+ return result
573
+ if isinstance(obj, (list, tuple)):
574
+ result = [_to_shm(v, registry, visited) for v in obj]
575
+ visited[obj_id] = result
576
+ return result
577
+
578
+ return obj
579
+
580
+ def _from_shm(obj):
581
+ """Reconstruct from shared memory metadata. Does NOT unlink - caller handles that."""
582
+ if not isinstance(obj, dict):
583
+ if isinstance(obj, list):
584
+ return [_from_shm(v) for v in obj]
585
+ return obj
586
+ if "__shm_np__" in obj:
587
+ block = shm.SharedMemory(name=obj["__shm_np__"])
588
+ arr = np.ndarray(tuple(obj["shape"]), dtype=np.dtype(obj["dtype"]), buffer=block.buf).copy()
589
+ block.close()
590
+ return arr
591
+ if "__shm_trimesh__" in obj:
592
+ import trimesh
593
+ v_block = shm.SharedMemory(name=obj["v_name"])
594
+ verts = np.ndarray(tuple(obj["v_shape"]), dtype=np.float64, buffer=v_block.buf).copy()
595
+ v_block.close()
596
+
597
+ f_block = shm.SharedMemory(name=obj["f_name"])
598
+ faces = np.ndarray(tuple(obj["f_shape"]), dtype=np.int64, buffer=f_block.buf).copy()
599
+ f_block.close()
600
+
601
+ return trimesh.Trimesh(vertices=verts, faces=faces, process=False)
602
+ return {k: _from_shm(v) for k, v in obj.items()}
603
+
604
+ def _cleanup_shm(registry):
605
+ for block in registry:
606
+ try:
607
+ block.close()
608
+ block.unlink()
609
+ except Exception:
610
+ pass
611
+ registry.clear()
612
+
725
613
  # =============================================================================
726
614
  # Object Reference System - keep complex objects in worker, pass refs to host
727
615
  # =============================================================================
@@ -767,12 +655,38 @@ def _should_use_reference(obj):
767
655
  # Dicts, lists, tuples - recurse into contents (don't ref the container)
768
656
  if isinstance(obj, (dict, list, tuple)):
769
657
  return False
770
- # Trimesh - pass by value (pickle handles it well across Python versions)
658
+ # Trimesh - pass by value but needs special handling (see _prepare_trimesh_for_pickle)
771
659
  if obj_type == 'Trimesh':
772
660
  return False
773
661
  # Everything else (custom classes) - pass by reference
774
662
  return True
775
663
 
664
+ def _prepare_trimesh_for_pickle(mesh):
665
+ """
666
+ Prepare a trimesh object for cross-Python-version pickling.
667
+
668
+ Trimesh attaches helper objects (ray tracer, proximity query) that may use
669
+ native extensions like embreex. These cause import errors when unpickling
670
+ on a system without those extensions. We strip them - they'll be recreated
671
+ lazily when needed.
672
+
673
+ Note: Do NOT strip _cache - trimesh needs it to function properly.
674
+ """
675
+ # Make a copy to avoid modifying the original
676
+ mesh = mesh.copy()
677
+
678
+ # Remove helper objects that may have unpickleable native code references
679
+ # These are lazily recreated on first access anyway
680
+ # Do NOT remove _cache - it's needed for trimesh to work
681
+ for attr in ('ray', '_ray', 'permutate', 'nearest'):
682
+ try:
683
+ delattr(mesh, attr)
684
+ except AttributeError:
685
+ pass
686
+
687
+ return mesh
688
+
689
+
776
690
  def _serialize_result(obj, visited=None):
777
691
  """Convert result for IPC - complex objects become references."""
778
692
  if visited is None:
@@ -791,6 +705,11 @@ def _serialize_result(obj, visited=None):
791
705
 
792
706
  visited.add(obj_id)
793
707
 
708
+ # Handle trimesh objects specially - strip unpickleable native extensions
709
+ obj_type = type(obj).__name__
710
+ if obj_type == 'Trimesh':
711
+ return _prepare_trimesh_for_pickle(obj)
712
+
794
713
  if isinstance(obj, dict):
795
714
  return {k: _serialize_result(v, visited) for k, v in obj.items()}
796
715
  if isinstance(obj, list):
@@ -799,7 +718,6 @@ def _serialize_result(obj, visited=None):
799
718
  return tuple(_serialize_result(v, visited) for v in obj)
800
719
 
801
720
  # Convert numpy scalars to Python primitives for JSON serialization
802
- obj_type = type(obj).__name__
803
721
  if obj_type in ('float16', 'float32', 'float64'):
804
722
  return float(obj)
805
723
  if obj_type in ('int8', 'int16', 'int32', 'int64', 'uint8', 'uint16', 'uint32', 'uint64'):
@@ -918,10 +836,52 @@ def main():
918
836
  if p not in sys.path:
919
837
  sys.path.insert(0, p)
920
838
 
921
- # Import torch after path setup
922
- wlog("[worker] Importing torch...")
923
- import torch
924
- wlog(f"[worker] Torch imported: {torch.__version__}")
839
+ # Try to import torch (optional - not all isolated envs need it)
840
+ _HAS_TORCH = False
841
+ try:
842
+ import torch
843
+ _HAS_TORCH = True
844
+ wlog(f"[worker] Torch imported: {torch.__version__}")
845
+ except ImportError:
846
+ wlog("[worker] Torch not available, using pickle for serialization")
847
+
848
+ # Setup log forwarding to host
849
+ # This makes print() and logging statements in node code visible to the user
850
+ import builtins
851
+ import logging
852
+ _original_print = builtins.print
853
+
854
+ def _forwarded_print(*args, **kwargs):
855
+ """Forward print() calls to host via socket."""
856
+ # Build message from args
857
+ sep = kwargs.get('sep', ' ')
858
+ message = sep.join(str(a) for a in args)
859
+ # Send to host
860
+ try:
861
+ transport.send({"type": "log", "message": message})
862
+ except Exception:
863
+ pass # Don't fail if transport is closed
864
+ # Also log locally for debugging
865
+ wlog(f"[print] {message}")
866
+
867
+ builtins.print = _forwarded_print
868
+
869
+ # Also forward logging module output
870
+ class SocketLogHandler(logging.Handler):
871
+ def emit(self, record):
872
+ try:
873
+ msg = self.format(record)
874
+ transport.send({"type": "log", "message": msg})
875
+ wlog(f"[log] {msg}")
876
+ except Exception:
877
+ pass
878
+
879
+ # Add our handler to the root logger
880
+ _socket_handler = SocketLogHandler()
881
+ _socket_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
882
+ logging.root.addHandler(_socket_handler)
883
+
884
+ wlog("[worker] Print and logging forwarding enabled")
925
885
 
926
886
  # Signal ready
927
887
  transport.send({"status": "ready"})
@@ -950,23 +910,20 @@ def main():
950
910
  transport.send({"status": "pong"})
951
911
  continue
952
912
 
913
+ shm_registry = []
953
914
  try:
954
915
  request_type = request.get("type", "call_module")
955
916
  module_name = request["module"]
956
- inputs_path = request.get("inputs_path")
957
- outputs_path = request.get("outputs_path")
958
917
  wlog(f"[worker] Request: {request_type} {module_name}")
959
918
 
960
- # Load inputs
961
- if inputs_path:
962
- wlog(f"[worker] Loading inputs from {inputs_path}...")
963
- inputs = torch.load(inputs_path, weights_only=False)
964
- wlog(f"[worker] Deserializing isolated objects...")
919
+ # Load inputs from shared memory
920
+ kwargs_meta = request.get("kwargs")
921
+ if kwargs_meta:
922
+ wlog(f"[worker] Reconstructing inputs from shm...")
923
+ inputs = _from_shm(kwargs_meta)
965
924
  inputs = _deserialize_isolated_objects(inputs)
966
- # Resolve any object references from previous node calls
967
- wlog(f"[worker] Resolving object references...")
968
925
  inputs = _deserialize_input(inputs)
969
- wlog(f"[worker] Inputs ready: {list(inputs.keys())}")
926
+ wlog(f"[worker] Inputs ready: {list(inputs.keys()) if isinstance(inputs, dict) else type(inputs)}")
970
927
  else:
971
928
  inputs = {}
972
929
 
@@ -995,14 +952,17 @@ def main():
995
952
  func = getattr(module, func_name)
996
953
  result = func(**inputs)
997
954
 
998
- # Save result - convert complex objects to references
999
- if outputs_path:
1000
- serialized_result = _serialize_result(result)
1001
- torch.save(serialized_result, outputs_path)
955
+ # Serialize result to shared memory
956
+ wlog(f"[worker] Serializing result to shm...")
957
+ result_meta = _to_shm(result, shm_registry)
958
+ wlog(f"[worker] Created {len(shm_registry)} shm blocks for result")
1002
959
 
1003
- transport.send({"status": "ok"})
960
+ transport.send({"status": "ok", "result": result_meta})
961
+ # Note: don't cleanup shm_registry here - host needs to read it
1004
962
 
1005
963
  except Exception as e:
964
+ # Cleanup shm on error since host won't read it
965
+ _cleanup_shm(shm_registry)
1006
966
  transport.send({
1007
967
  "status": "error",
1008
968
  "error": str(e),
@@ -1016,9 +976,9 @@ if __name__ == "__main__":
1016
976
  '''
1017
977
 
1018
978
 
1019
- class PersistentVenvWorker(Worker):
979
+ class SubprocessWorker(Worker):
1020
980
  """
1021
- Persistent version of VenvWorker that keeps subprocess alive.
981
+ Cross-venv worker using persistent subprocess + socket IPC.
1022
982
 
1023
983
  Uses Unix domain sockets (or TCP localhost on older Windows) for IPC.
1024
984
  This completely separates IPC from stdout/stderr, so C libraries
@@ -1027,11 +987,11 @@ class PersistentVenvWorker(Worker):
1027
987
  Benefits:
1028
988
  - Works on Windows with different venv Python (full isolation)
1029
989
  - Compiled CUDA extensions load correctly in the venv
1030
- - ~50-100ms per call (vs ~300-500ms for VenvWorker per-call spawn)
990
+ - ~50-100ms per call (persistent subprocess avoids spawn overhead)
1031
991
  - Tensor transfer via shared memory files
1032
992
  - Immune to stdout pollution from C libraries
1033
993
 
1034
- Use this for high-frequency calls to isolated venvs.
994
+ Use this for calls to isolated venvs with different Python/dependencies.
1035
995
  """
1036
996
 
1037
997
  def __init__(
@@ -1058,7 +1018,7 @@ class PersistentVenvWorker(Worker):
1058
1018
  self.working_dir = Path(working_dir) if working_dir else Path.cwd()
1059
1019
  self.sys_path = sys_path or []
1060
1020
  self.extra_env = env or {}
1061
- self.name = name or f"PersistentVenvWorker({self.python.parent.parent.name})"
1021
+ self.name = name or f"SubprocessWorker({self.python.parent.parent.name})"
1062
1022
 
1063
1023
  if not self.python.exists():
1064
1024
  raise FileNotFoundError(f"Python not found: {self.python}")
@@ -1204,21 +1164,26 @@ class PersistentVenvWorker(Worker):
1204
1164
  # Use UTF-8 encoding for stdout/stderr to handle Unicode symbols
1205
1165
  env["PYTHONIOENCODING"] = "utf-8"
1206
1166
 
1207
- # Find ComfyUI base and set env var for folder_paths stub
1167
+ # Find ComfyUI base and add to sys_path for real folder_paths/comfy modules
1168
+ # This works because comfy.options.args_parsing=False by default, so folder_paths
1169
+ # auto-detects its base directory from __file__ location
1208
1170
  comfyui_base = self._find_comfyui_base()
1209
1171
  if comfyui_base:
1210
- env["COMFYUI_BASE"] = str(comfyui_base)
1172
+ env["COMFYUI_BASE"] = str(comfyui_base) # Keep for fallback/debugging
1211
1173
 
1212
- # Add stubs directory to sys_path for folder_paths etc.
1213
- stubs_dir = Path(__file__).parent.parent / "stubs"
1214
- all_sys_path = [str(stubs_dir), str(self.working_dir)] + self.sys_path
1174
+ # Build sys_path: ComfyUI first (for real modules), then working_dir, then extras
1175
+ all_sys_path = []
1176
+ if comfyui_base:
1177
+ all_sys_path.append(str(comfyui_base))
1178
+ all_sys_path.append(str(self.working_dir))
1179
+ all_sys_path.extend(self.sys_path)
1215
1180
 
1216
1181
  # Launch subprocess with the venv Python, passing socket address
1217
1182
  # For pixi environments, use "pixi run python" to get proper environment activation
1218
1183
  # (CONDA_PREFIX, Library paths, etc.) which fixes DLL loading issues with bpy
1219
1184
  is_pixi = '.pixi' in str(self.python)
1220
1185
  if _DEBUG:
1221
- print(f"[PersistentVenvWorker] is_pixi={is_pixi}, python={self.python}", flush=True)
1186
+ print(f"[SubprocessWorker] is_pixi={is_pixi}, python={self.python}", flush=True)
1222
1187
  if is_pixi:
1223
1188
  # Find pixi project root (parent of .pixi directory)
1224
1189
  pixi_project = self.python
@@ -1227,7 +1192,7 @@ class PersistentVenvWorker(Worker):
1227
1192
  pixi_project = pixi_project.parent # Go up from .pixi to project root
1228
1193
  pixi_toml = pixi_project / "pixi.toml"
1229
1194
  if _DEBUG:
1230
- print(f"[PersistentVenvWorker] pixi_toml={pixi_toml}, exists={pixi_toml.exists()}", flush=True)
1195
+ print(f"[SubprocessWorker] pixi_toml={pixi_toml}, exists={pixi_toml.exists()}", flush=True)
1231
1196
 
1232
1197
  if pixi_toml.exists():
1233
1198
  pixi_exe = get_pixi_path()
@@ -1254,15 +1219,15 @@ class PersistentVenvWorker(Worker):
1254
1219
  launch_env = env
1255
1220
 
1256
1221
  if _DEBUG:
1257
- print(f"[PersistentVenvWorker] launching cmd={cmd[:3]}...", flush=True)
1222
+ print(f"[SubprocessWorker] launching cmd={cmd[:3]}...", flush=True)
1258
1223
  if launch_env:
1259
1224
  path_sep = ";" if sys.platform == "win32" else ":"
1260
1225
  path_parts = launch_env.get("PATH", "").split(path_sep)
1261
- print(f"[PersistentVenvWorker] PATH has {len(path_parts)} entries:", flush=True)
1226
+ print(f"[SubprocessWorker] PATH has {len(path_parts)} entries:", flush=True)
1262
1227
  for i, p in enumerate(path_parts[:10]): # Show first 10
1263
- print(f"[PersistentVenvWorker] [{i}] {p}", flush=True)
1228
+ print(f"[SubprocessWorker] [{i}] {p}", flush=True)
1264
1229
  if len(path_parts) > 10:
1265
- print(f"[PersistentVenvWorker] ... and {len(path_parts) - 10} more", flush=True)
1230
+ print(f"[SubprocessWorker] ... and {len(path_parts) - 10} more", flush=True)
1266
1231
  self._process = subprocess.Popen(
1267
1232
  cmd,
1268
1233
  stdin=subprocess.DEVNULL,
@@ -1349,9 +1314,21 @@ class PersistentVenvWorker(Worker):
1349
1314
  # Send request
1350
1315
  self._transport.send(request)
1351
1316
 
1352
- # Read response with timeout
1317
+ # Read response with timeout, handling log messages along the way
1353
1318
  try:
1354
- response = self._transport.recv(timeout=timeout)
1319
+ while True:
1320
+ response = self._transport.recv(timeout=timeout)
1321
+ if response is None:
1322
+ break # Timeout
1323
+
1324
+ # Handle log messages from worker
1325
+ if response.get("type") == "log":
1326
+ msg = response.get("message", "")
1327
+ print(f"[worker:{self.name}] {msg}", file=sys.stderr, flush=True)
1328
+ continue # Keep waiting for actual response
1329
+
1330
+ # Got a real response
1331
+ break
1355
1332
  except ConnectionError as e:
1356
1333
  # Socket closed - check if worker process died
1357
1334
  self._shutdown = True
@@ -1414,51 +1391,43 @@ class PersistentVenvWorker(Worker):
1414
1391
  """
1415
1392
  import sys
1416
1393
  if _DEBUG:
1417
- print(f"[PersistentVenvWorker] call_method: {module_name}.{class_name}.{method_name}", file=sys.stderr, flush=True)
1394
+ print(f"[SubprocessWorker] call_method: {module_name}.{class_name}.{method_name}", file=sys.stderr, flush=True)
1418
1395
 
1419
1396
  with self._lock:
1420
1397
  if _DEBUG:
1421
- print(f"[PersistentVenvWorker] acquired lock, ensuring started...", file=sys.stderr, flush=True)
1398
+ print(f"[SubprocessWorker] acquired lock, ensuring started...", file=sys.stderr, flush=True)
1422
1399
  self._ensure_started()
1423
1400
  if _DEBUG:
1424
- print(f"[PersistentVenvWorker] worker started/confirmed", file=sys.stderr, flush=True)
1401
+ print(f"[SubprocessWorker] worker started/confirmed", file=sys.stderr, flush=True)
1425
1402
 
1426
1403
  timeout = timeout or 600.0
1427
- call_id = str(uuid.uuid4())[:8]
1428
-
1429
- import torch
1430
- inputs_path = self._shm_dir / f"comfyui_pvenv_{call_id}_in.pt"
1431
- outputs_path = self._shm_dir / f"comfyui_pvenv_{call_id}_out.pt"
1404
+ shm_registry = []
1432
1405
 
1433
1406
  try:
1434
- # Serialize kwargs
1407
+ # Serialize kwargs to shared memory
1435
1408
  if kwargs:
1436
1409
  if _DEBUG:
1437
- print(f"[PersistentVenvWorker] serializing kwargs...", file=sys.stderr, flush=True)
1438
- serialized_kwargs = _serialize_for_ipc(kwargs)
1439
- if _DEBUG:
1440
- print(f"[PersistentVenvWorker] saving to {inputs_path}...", file=sys.stderr, flush=True)
1441
- torch.save(serialized_kwargs, str(inputs_path))
1410
+ print(f"[SubprocessWorker] serializing kwargs to shm...", file=sys.stderr, flush=True)
1411
+ kwargs_meta = _to_shm(kwargs, shm_registry)
1442
1412
  if _DEBUG:
1443
- print(f"[PersistentVenvWorker] saved inputs", file=sys.stderr, flush=True)
1413
+ print(f"[SubprocessWorker] created {len(shm_registry)} shm blocks", file=sys.stderr, flush=True)
1414
+ else:
1415
+ kwargs_meta = None
1444
1416
 
1445
- # Send request with class info
1446
- # Serialize self_state to handle Path objects and other non-JSON types
1447
- serialized_self_state = _serialize_for_ipc(self_state) if self_state else None
1417
+ # Send request with shared memory metadata
1448
1418
  request = {
1449
1419
  "type": "call_method",
1450
1420
  "module": module_name,
1451
1421
  "class_name": class_name,
1452
1422
  "method_name": method_name,
1453
- "self_state": serialized_self_state,
1454
- "inputs_path": str(inputs_path) if kwargs else None,
1455
- "outputs_path": str(outputs_path),
1423
+ "self_state": _serialize_for_ipc(self_state) if self_state else None,
1424
+ "kwargs": kwargs_meta,
1456
1425
  }
1457
1426
  if _DEBUG:
1458
- print(f"[PersistentVenvWorker] sending request via socket...", file=sys.stderr, flush=True)
1427
+ print(f"[SubprocessWorker] sending request via socket...", file=sys.stderr, flush=True)
1459
1428
  response = self._send_request(request, timeout)
1460
1429
  if _DEBUG:
1461
- print(f"[PersistentVenvWorker] got response: {response.get('status')}", file=sys.stderr, flush=True)
1430
+ print(f"[SubprocessWorker] got response: {response.get('status')}", file=sys.stderr, flush=True)
1462
1431
 
1463
1432
  if response.get("status") == "error":
1464
1433
  raise WorkerError(
@@ -1466,16 +1435,14 @@ class PersistentVenvWorker(Worker):
1466
1435
  traceback=response.get("traceback"),
1467
1436
  )
1468
1437
 
1469
- if outputs_path.exists():
1470
- return torch.load(str(outputs_path), weights_only=False)
1438
+ # Reconstruct result from shared memory
1439
+ result_meta = response.get("result")
1440
+ if result_meta is not None:
1441
+ return _from_shm(result_meta)
1471
1442
  return None
1472
1443
 
1473
1444
  finally:
1474
- for p in [inputs_path, outputs_path]:
1475
- try:
1476
- p.unlink()
1477
- except:
1478
- pass
1445
+ _cleanup_shm(shm_registry)
1479
1446
 
1480
1447
  def call_module(
1481
1448
  self,
@@ -1489,25 +1456,16 @@ class PersistentVenvWorker(Worker):
1489
1456
  self._ensure_started()
1490
1457
 
1491
1458
  timeout = timeout or 600.0
1492
- call_id = str(uuid.uuid4())[:8]
1493
-
1494
- # Save inputs
1495
- import torch
1496
- inputs_path = self._shm_dir / f"comfyui_pvenv_{call_id}_in.pt"
1497
- outputs_path = self._shm_dir / f"comfyui_pvenv_{call_id}_out.pt"
1459
+ shm_registry = []
1498
1460
 
1499
1461
  try:
1500
- if kwargs:
1501
- serialized_kwargs = _serialize_for_ipc(kwargs)
1502
- torch.save(serialized_kwargs, str(inputs_path))
1462
+ kwargs_meta = _to_shm(kwargs, shm_registry) if kwargs else None
1503
1463
 
1504
- # Send request
1505
1464
  request = {
1506
1465
  "type": "call_module",
1507
1466
  "module": module,
1508
1467
  "func": func,
1509
- "inputs_path": str(inputs_path) if kwargs else None,
1510
- "outputs_path": str(outputs_path),
1468
+ "kwargs": kwargs_meta,
1511
1469
  }
1512
1470
  response = self._send_request(request, timeout)
1513
1471
 
@@ -1517,17 +1475,13 @@ class PersistentVenvWorker(Worker):
1517
1475
  traceback=response.get("traceback"),
1518
1476
  )
1519
1477
 
1520
- # Load result
1521
- if outputs_path.exists():
1522
- return torch.load(str(outputs_path), weights_only=False)
1478
+ result_meta = response.get("result")
1479
+ if result_meta is not None:
1480
+ return _from_shm(result_meta)
1523
1481
  return None
1524
1482
 
1525
1483
  finally:
1526
- for p in [inputs_path, outputs_path]:
1527
- try:
1528
- p.unlink()
1529
- except:
1530
- pass
1484
+ _cleanup_shm(shm_registry)
1531
1485
 
1532
1486
  def shutdown(self) -> None:
1533
1487
  """Shut down the persistent worker."""
@@ -1579,4 +1533,4 @@ class PersistentVenvWorker(Worker):
1579
1533
 
1580
1534
  def __repr__(self):
1581
1535
  status = "alive" if self.is_alive() else "stopped"
1582
- return f"<PersistentVenvWorker name={self.name!r} status={status}>"
1536
+ return f"<SubprocessWorker name={self.name!r} status={status}>"