comfy-env 0.0.8__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.
@@ -0,0 +1,512 @@
1
+ """
2
+ WorkerBridge - Main IPC class for communicating with isolated workers.
3
+
4
+ This is the primary interface that ComfyUI node developers use.
5
+ """
6
+
7
+ import json
8
+ import subprocess
9
+ import threading
10
+ import uuid
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Dict, Optional
13
+
14
+ from ..env.config import IsolatedEnv
15
+ from ..env.manager import IsolatedEnvManager
16
+ from .protocol import encode_object, decode_object
17
+
18
+
19
+ class WorkerBridge:
20
+ """
21
+ Bridge for communicating with a worker process in an isolated environment.
22
+
23
+ This class manages the worker process lifecycle and handles IPC.
24
+
25
+ Features:
26
+ - Lazy worker startup (starts on first call)
27
+ - Singleton pattern (one worker per environment)
28
+ - Auto-restart on crash
29
+ - Graceful shutdown
30
+ - Timeout support
31
+
32
+ Example:
33
+ from comfy_env import IsolatedEnv, WorkerBridge
34
+
35
+ env = IsolatedEnv(
36
+ name="my-node",
37
+ python="3.10",
38
+ cuda="12.8",
39
+ requirements=["torch==2.8.0"],
40
+ )
41
+
42
+ bridge = WorkerBridge(env, worker_script=Path("worker.py"))
43
+
44
+ # Direct method calls (preferred)
45
+ result = bridge.process(image=my_image)
46
+
47
+ # Or use explicit call() method
48
+ result = bridge.call("process", image=my_image)
49
+ """
50
+
51
+ # Singleton instances by environment hash
52
+ _instances: Dict[str, "WorkerBridge"] = {}
53
+ _instances_lock = threading.Lock()
54
+
55
+ def __init__(
56
+ self,
57
+ env: IsolatedEnv,
58
+ worker_script: Path,
59
+ base_dir: Optional[Path] = None,
60
+ log_callback: Optional[Callable[[str], None]] = None,
61
+ auto_start: bool = True,
62
+ ):
63
+ """
64
+ Initialize the bridge.
65
+
66
+ Args:
67
+ env: Isolated environment configuration
68
+ worker_script: Path to the worker Python script
69
+ base_dir: Base directory for environments (default: worker_script's parent)
70
+ log_callback: Optional callback for logging (default: print)
71
+ auto_start: Whether to auto-start worker on first call (default: True)
72
+ """
73
+ self.env = env
74
+ self.worker_script = Path(worker_script)
75
+ self.base_dir = base_dir or self.worker_script.parent
76
+ self.log = log_callback or print
77
+ self.auto_start = auto_start
78
+
79
+ self._manager = IsolatedEnvManager(self.base_dir, log_callback=log_callback)
80
+ self._process: Optional[subprocess.Popen] = None
81
+ self._process_lock = threading.Lock()
82
+ self._stderr_thread: Optional[threading.Thread] = None
83
+
84
+ @classmethod
85
+ def get_instance(
86
+ cls,
87
+ env: IsolatedEnv,
88
+ worker_script: Path,
89
+ base_dir: Optional[Path] = None,
90
+ log_callback: Optional[Callable[[str], None]] = None,
91
+ ) -> "WorkerBridge":
92
+ """
93
+ Get or create a singleton bridge instance for an environment.
94
+
95
+ Args:
96
+ env: Isolated environment configuration
97
+ worker_script: Path to the worker Python script
98
+ base_dir: Base directory for environments
99
+ log_callback: Optional callback for logging
100
+
101
+ Returns:
102
+ WorkerBridge instance (reused if same env hash)
103
+ """
104
+ env_hash = env.get_env_hash()
105
+
106
+ with cls._instances_lock:
107
+ if env_hash not in cls._instances:
108
+ cls._instances[env_hash] = cls(
109
+ env=env,
110
+ worker_script=worker_script,
111
+ base_dir=base_dir,
112
+ log_callback=log_callback,
113
+ )
114
+ return cls._instances[env_hash]
115
+
116
+ @classmethod
117
+ def from_config_file(
118
+ cls,
119
+ node_dir: Path,
120
+ worker_script: Optional[Path] = None,
121
+ config_file: Optional[str] = None,
122
+ log_callback: Optional[Callable[[str], None]] = None,
123
+ auto_start: bool = True,
124
+ ) -> "WorkerBridge":
125
+ """
126
+ Create WorkerBridge from a config file.
127
+
128
+ This is a convenience method for loading environment configuration
129
+ from a TOML file instead of defining it programmatically.
130
+
131
+ Args:
132
+ node_dir: Directory containing the config file (and base_dir for env)
133
+ worker_script: Path to worker script (optional - auto-discovered from config or convention)
134
+ config_file: Specific config file name (default: auto-discover)
135
+ log_callback: Optional callback for logging
136
+ auto_start: Whether to auto-start worker on first call (default: True)
137
+
138
+ Returns:
139
+ Configured WorkerBridge instance
140
+
141
+ Raises:
142
+ FileNotFoundError: If no config file found or worker script not found
143
+ ImportError: If tomli not installed (Python < 3.11)
144
+
145
+ Example:
146
+ # Auto-discover everything (recommended)
147
+ bridge = WorkerBridge.from_config_file(node_dir=Path(__file__).parent)
148
+
149
+ # Explicit worker script (backward compatible)
150
+ bridge = WorkerBridge.from_config_file(
151
+ node_dir=Path(__file__).parent,
152
+ worker_script=Path(__file__).parent / "worker.py",
153
+ )
154
+ """
155
+ from ..env.config_file import load_env_from_file, discover_env_config, CONFIG_FILE_NAMES
156
+
157
+ node_dir = Path(node_dir)
158
+
159
+ if config_file:
160
+ env = load_env_from_file(node_dir / config_file, node_dir)
161
+ else:
162
+ env = discover_env_config(node_dir)
163
+ if env is None:
164
+ raise FileNotFoundError(
165
+ f"No isolation config found in {node_dir}. "
166
+ f"Create one of: {', '.join(CONFIG_FILE_NAMES)}"
167
+ )
168
+
169
+ # Resolve worker script from explicit param, config, or convention
170
+ if worker_script is None:
171
+ worker_script = cls._resolve_worker_script(node_dir, env)
172
+
173
+ return cls(
174
+ env=env,
175
+ worker_script=worker_script,
176
+ base_dir=node_dir,
177
+ log_callback=log_callback,
178
+ auto_start=auto_start,
179
+ )
180
+
181
+ @staticmethod
182
+ def _resolve_worker_script(node_dir: Path, env: "IsolatedEnv") -> Path:
183
+ """
184
+ Resolve worker script from config or convention.
185
+
186
+ Discovery order:
187
+ 1. Explicit package in config ([worker] package = "worker")
188
+ 2. Explicit script in config ([worker] script = "worker.py")
189
+ 3. Convention: worker/ directory with __main__.py
190
+ 4. Convention: worker.py file
191
+
192
+ Args:
193
+ node_dir: Node directory
194
+ env: IsolatedEnv with optional worker_package/worker_script
195
+
196
+ Returns:
197
+ Path to worker script
198
+
199
+ Raises:
200
+ FileNotFoundError: If no worker found
201
+ """
202
+ # 1. Explicit package in config
203
+ if env.worker_package:
204
+ pkg_dir = node_dir / env.worker_package
205
+ main_py = pkg_dir / "__main__.py"
206
+ if main_py.exists():
207
+ return main_py
208
+ raise FileNotFoundError(
209
+ f"Worker package '{env.worker_package}' not found. "
210
+ f"Expected: {main_py}"
211
+ )
212
+
213
+ # 2. Explicit script in config
214
+ if env.worker_script:
215
+ script = node_dir / env.worker_script
216
+ if script.exists():
217
+ return script
218
+ raise FileNotFoundError(
219
+ f"Worker script '{env.worker_script}' not found. "
220
+ f"Expected: {script}"
221
+ )
222
+
223
+ # 3. Convention: worker/ directory
224
+ worker_pkg = node_dir / "worker" / "__main__.py"
225
+ if worker_pkg.exists():
226
+ return worker_pkg
227
+
228
+ # 4. Convention: worker.py file
229
+ worker_file = node_dir / "worker.py"
230
+ if worker_file.exists():
231
+ return worker_file
232
+
233
+ raise FileNotFoundError(
234
+ f"No worker found in {node_dir}. "
235
+ f"Create worker/__main__.py, worker.py, or specify in config:\n"
236
+ f" [worker]\n"
237
+ f" package = \"worker\" # or\n"
238
+ f" script = \"worker.py\""
239
+ )
240
+
241
+ @property
242
+ def python_exe(self) -> Path:
243
+ """Get the Python executable path for the isolated environment."""
244
+ return self._manager.get_python(self.env)
245
+
246
+ @property
247
+ def is_running(self) -> bool:
248
+ """Check if worker process is currently running."""
249
+ with self._process_lock:
250
+ return self._process is not None and self._process.poll() is None
251
+
252
+ def ensure_environment(self, verify_packages: Optional[list] = None) -> None:
253
+ """
254
+ Ensure the isolated environment exists and is ready.
255
+
256
+ Args:
257
+ verify_packages: Optional list of packages to verify
258
+ """
259
+ self._manager.setup(self.env, verify_packages=verify_packages)
260
+
261
+ def start(self) -> None:
262
+ """
263
+ Start the worker process.
264
+
265
+ Does nothing if worker is already running.
266
+ """
267
+ with self._process_lock:
268
+ if self._process is not None and self._process.poll() is None:
269
+ return # Already running
270
+
271
+ python_exe = self.python_exe
272
+ if not python_exe.exists():
273
+ raise RuntimeError(
274
+ f"Python executable not found: {python_exe}\n"
275
+ f"Run ensure_environment() first or check your env configuration."
276
+ )
277
+
278
+ if not self.worker_script.exists():
279
+ raise RuntimeError(f"Worker script not found: {self.worker_script}")
280
+
281
+ self.log(f"Starting worker process...")
282
+ self.log(f" Python: {python_exe}")
283
+ self.log(f" Script: {self.worker_script}")
284
+
285
+ self._process = subprocess.Popen(
286
+ [str(python_exe), str(self.worker_script)],
287
+ stdin=subprocess.PIPE,
288
+ stdout=subprocess.PIPE,
289
+ stderr=subprocess.PIPE,
290
+ text=True,
291
+ bufsize=1, # Line buffered
292
+ )
293
+
294
+ # Collect stderr for error reporting
295
+ self._stderr_lines = []
296
+
297
+ # Start stderr reader thread
298
+ self._stderr_thread = threading.Thread(
299
+ target=self._read_stderr,
300
+ daemon=True,
301
+ name=f"worker-stderr-{self.env.name}",
302
+ )
303
+ self._stderr_thread.start()
304
+
305
+ # Give the process a moment to crash if it's going to (e.g., import error)
306
+ import time
307
+ time.sleep(0.5)
308
+
309
+ # Check if process died immediately
310
+ if self._process.poll() is not None:
311
+ exit_code = self._process.returncode
312
+ time.sleep(0.2) # Let stderr thread collect output
313
+ stderr_output = "\n".join(self._stderr_lines[-20:]) # Last 20 lines
314
+ raise RuntimeError(
315
+ f"Worker crashed on startup (exit code {exit_code}).\n"
316
+ f"Stderr output:\n{stderr_output}"
317
+ )
318
+
319
+ # Test connection
320
+ try:
321
+ response = self._send_raw({"method": "ping"}, timeout=30.0)
322
+ if response.get("result") != "pong":
323
+ raise RuntimeError(f"Worker ping failed: {response}")
324
+ self.log("Worker started successfully")
325
+ except Exception as e:
326
+ # Collect any stderr output for debugging
327
+ time.sleep(0.2)
328
+ stderr_output = "\n".join(self._stderr_lines[-20:])
329
+ self.stop()
330
+ raise RuntimeError(f"Worker failed to start: {e}\nStderr:\n{stderr_output}")
331
+
332
+ def _read_stderr(self) -> None:
333
+ """Read stderr from worker and forward to log callback."""
334
+ if not self._process or not self._process.stderr:
335
+ return
336
+
337
+ for line in self._process.stderr:
338
+ line = line.rstrip()
339
+ if line:
340
+ self.log(line)
341
+ # Also collect for error reporting
342
+ if hasattr(self, '_stderr_lines'):
343
+ self._stderr_lines.append(line)
344
+
345
+ def stop(self) -> None:
346
+ """
347
+ Stop the worker process gracefully.
348
+ """
349
+ with self._process_lock:
350
+ if self._process is None or self._process.poll() is not None:
351
+ return
352
+
353
+ self.log("Stopping worker...")
354
+
355
+ # Send shutdown command
356
+ try:
357
+ self._send_raw({"method": "shutdown"}, timeout=5.0)
358
+ except Exception:
359
+ pass
360
+
361
+ # Wait for graceful shutdown
362
+ try:
363
+ self._process.wait(timeout=5.0)
364
+ except subprocess.TimeoutExpired:
365
+ self.log("Worker didn't stop gracefully, terminating...")
366
+ self._process.terminate()
367
+ try:
368
+ self._process.wait(timeout=2.0)
369
+ except subprocess.TimeoutExpired:
370
+ self._process.kill()
371
+
372
+ self._process = None
373
+ self.log("Worker stopped")
374
+
375
+ def _send_raw(self, request: dict, timeout: float = 300.0) -> dict:
376
+ """
377
+ Send a raw request and wait for response.
378
+
379
+ Args:
380
+ request: Request dict
381
+ timeout: Timeout in seconds
382
+
383
+ Returns:
384
+ Response dict
385
+ """
386
+ if self._process is None or self._process.poll() is not None:
387
+ raise RuntimeError("Worker process is not running")
388
+
389
+ # Add request ID
390
+ if "id" not in request:
391
+ request["id"] = str(uuid.uuid4())[:8]
392
+
393
+ # Send request
394
+ request_json = json.dumps(request) + "\n"
395
+ self._process.stdin.write(request_json)
396
+ self._process.stdin.flush()
397
+
398
+ # Read response
399
+ import time
400
+ start_time = time.time()
401
+
402
+ while True:
403
+ if time.time() - start_time > timeout:
404
+ raise TimeoutError(f"Timeout waiting for response after {timeout}s")
405
+
406
+ response_line = self._process.stdout.readline()
407
+ if not response_line:
408
+ raise RuntimeError("Worker process closed unexpectedly")
409
+
410
+ response_line = response_line.strip()
411
+ if not response_line:
412
+ continue
413
+
414
+ # Skip non-JSON lines (library output that escaped)
415
+ if response_line.startswith('[') and not response_line.startswith('[{'):
416
+ continue
417
+
418
+ try:
419
+ return json.loads(response_line)
420
+ except json.JSONDecodeError:
421
+ continue
422
+
423
+ def call(
424
+ self,
425
+ method: str,
426
+ timeout: float = 300.0,
427
+ **kwargs,
428
+ ) -> Any:
429
+ """
430
+ Call a method on the worker.
431
+
432
+ Args:
433
+ method: Method name to call
434
+ timeout: Timeout in seconds (default: 5 minutes)
435
+ **kwargs: Arguments to pass to the method
436
+
437
+ Returns:
438
+ The method's return value
439
+
440
+ Raises:
441
+ RuntimeError: If worker returns an error
442
+ TimeoutError: If call times out
443
+ """
444
+ # Auto-start if needed
445
+ if self.auto_start and not self.is_running:
446
+ self.start()
447
+
448
+ # Encode arguments
449
+ encoded_args = encode_object(kwargs)
450
+
451
+ # Send request
452
+ request = {
453
+ "method": method,
454
+ "args": encoded_args,
455
+ }
456
+ response = self._send_raw(request, timeout=timeout)
457
+
458
+ # Check for error
459
+ if "error" in response and response["error"]:
460
+ error_msg = response["error"]
461
+ tb = response.get("traceback", "")
462
+ raise RuntimeError(f"Worker error: {error_msg}\n{tb}")
463
+
464
+ # Decode and return result
465
+ return decode_object(response.get("result"))
466
+
467
+ def __enter__(self) -> "WorkerBridge":
468
+ """Context manager entry."""
469
+ return self
470
+
471
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
472
+ """Context manager exit - stops worker."""
473
+ self.stop()
474
+
475
+ def __del__(self) -> None:
476
+ """Destructor - stops worker."""
477
+ try:
478
+ self.stop()
479
+ except Exception:
480
+ pass
481
+
482
+ def __getattr__(self, name: str) -> Callable[..., Any]:
483
+ """
484
+ Enable direct method calls on the bridge.
485
+
486
+ Instead of:
487
+ result = bridge.call("inference", image=img)
488
+
489
+ You can do:
490
+ result = bridge.inference(image=img)
491
+
492
+ Args:
493
+ name: Method name to call on the worker
494
+
495
+ Returns:
496
+ A callable that forwards to self.call()
497
+ """
498
+ # Don't intercept private/magic attributes
499
+ if name.startswith("_"):
500
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
501
+
502
+ # Return a wrapper that calls the method on the worker
503
+ def method_wrapper(*args, timeout: float = 300.0, **kwargs) -> Any:
504
+ # If positional args provided, raise helpful error
505
+ if args:
506
+ raise TypeError(
507
+ f"bridge.{name}() doesn't accept positional arguments. "
508
+ f"Use keyword arguments: bridge.{name}(arg1=val1, arg2=val2)"
509
+ )
510
+ return self.call(name, timeout=timeout, **kwargs)
511
+
512
+ return method_wrapper