import torch import numpy as np from PIL import Image import folder_paths import os import json import asyncio import aiohttp from aiohttp import web import io import server import subprocess import platform import time import atexit import signal import sys import shlex import uuid from multiprocessing import Queue from comfy.utils import ProgressBar # Import shared utilities from .utils.logging import debug_log, log from .utils.config import CONFIG_FILE, get_default_config, load_config, save_config, ensure_config_exists from .utils.image import tensor_to_pil, pil_to_tensor, ensure_contiguous from .utils.process import is_process_alive, terminate_process, get_python_executable from .utils.network import handle_api_error, get_server_port, get_server_loop, get_client_session, cleanup_client_session from .utils.async_helpers import run_async_in_server_loop from .utils.constants import ( WORKER_JOB_TIMEOUT, PROCESS_TERMINATION_TIMEOUT, WORKER_CHECK_INTERVAL, STATUS_CHECK_INTERVAL, CHUNK_SIZE, LOG_TAIL_BYTES, WORKER_LOG_PATTERN, WORKER_STARTUP_DELAY, PROCESS_WAIT_TIMEOUT, MEMORY_CLEAR_DELAY, MAX_BATCH ) # Try to import psutil for better process management try: import psutil PSUTIL_AVAILABLE = True except ImportError: log("psutil not available, using fallback process management") PSUTIL_AVAILABLE = False # Register cleanup for aiohttp session def cleanup(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(cleanup_client_session()) loop.close() atexit.register(cleanup) # --- API Endpoints --- @server.PromptServer.instance.routes.get("/distributed/config") async def get_config_endpoint(request): config = load_config() return web.json_response(config) @server.PromptServer.instance.routes.get("/distributed/queue_status/{job_id}") async def queue_status_endpoint(request): """Check if a job queue is initialized.""" try: job_id = request.match_info['job_id'] # Import to ensure initialization from .distributed_upscale import ensure_tile_jobs_initialized prompt_server = ensure_tile_jobs_initialized() async with prompt_server.distributed_tile_jobs_lock: exists = job_id in prompt_server.distributed_pending_tile_jobs debug_log(f"Queue status check for job {job_id}: {'exists' if exists else 'not found'}") return web.json_response({"exists": exists, "job_id": job_id}) except Exception as e: return await handle_api_error(request, e, 500) @server.PromptServer.instance.routes.post("/distributed/worker/clear_launching") async def clear_launching_state(request): """Clear the launching flag when worker is confirmed running.""" try: data = await request.json() worker_id = str(data.get('worker_id')) if not worker_id: return await handle_api_error(request, "worker_id is required", 400) # Clear launching flag in managed processes if worker_id in worker_manager.processes: if 'launching' in worker_manager.processes[worker_id]: del worker_manager.processes[worker_id]['launching'] worker_manager.save_processes() debug_log(f"Cleared launching state for worker {worker_id}") return web.json_response({"status": "success"}) except Exception as e: return await handle_api_error(request, e, 500) @server.PromptServer.instance.routes.get("/distributed/network_info") async def get_network_info_endpoint(request): """Get network interfaces and recommend best IP for master.""" import socket # Get CUDA device if available cuda_device = None cuda_device_count = 0 physical_device_count = 0 if torch.cuda.is_available(): try: import os import subprocess # Get visible device count (what PyTorch sees) cuda_device_count = torch.cuda.device_count() # Try to get actual physical device info cuda_visible = os.environ.get('CUDA_VISIBLE_DEVICES', '') # Method 1: Parse CUDA_VISIBLE_DEVICES if cuda_visible and cuda_visible.strip(): visible_devices = [int(d.strip()) for d in cuda_visible.split(',') if d.strip().isdigit()] if visible_devices: # Get the first visible device as the actual physical device cuda_device = visible_devices[0] # Try to get total physical device count using nvidia-smi try: result = subprocess.run(['nvidia-smi', '--query-gpu=name', '--format=csv,noheader'], capture_output=True, text=True, timeout=5) if result.returncode == 0: physical_device_count = len(result.stdout.strip().split('\n')) else: physical_device_count = max(visible_devices) + 1 # Best guess except: physical_device_count = max(visible_devices) + 1 # Best guess else: cuda_device = 0 physical_device_count = cuda_device_count else: # No CUDA_VISIBLE_DEVICES set, current device is actual device cuda_device = torch.cuda.current_device() physical_device_count = cuda_device_count except Exception as e: debug_log(f"CUDA detection error: {e}") cuda_device = None cuda_device_count = 0 physical_device_count = 0 def get_network_ips(): """Get all network IPs, trying multiple methods.""" ips = [] hostname = socket.gethostname() # Method 1: Try socket.getaddrinfo try: addr_info = socket.getaddrinfo(hostname, None) for info in addr_info: ip = info[4][0] if ip and ip not in ips and not ip.startswith('::'): # Skip IPv6 for now ips.append(ip) except: pass # Method 2: Try to connect to external server and get local IP try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) # Google DNS local_ip = s.getsockname()[0] s.close() if local_ip not in ips: ips.append(local_ip) except: pass # Method 3: Platform-specific commands try: if platform.system() == "Windows": # Windows ipconfig result = subprocess.run(["ipconfig"], capture_output=True, text=True) lines = result.stdout.split('\n') for i, line in enumerate(lines): if 'IPv4' in line and i + 1 < len(lines): ip = lines[i].split(':')[-1].strip() if ip and ip not in ips: ips.append(ip) else: # Unix/Linux/Mac ifconfig or ip addr try: result = subprocess.run(["ip", "addr"], capture_output=True, text=True) except: result = subprocess.run(["ifconfig"], capture_output=True, text=True) import re ip_pattern = re.compile(r'inet\s+(\d+\.\d+\.\d+\.\d+)') for match in ip_pattern.finditer(result.stdout): ip = match.group(1) if ip and ip not in ips: ips.append(ip) except: pass return ips def get_recommended_ip(ips): """Choose the best IP for master-worker communication.""" # Priority order: # 1. Private network ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x) # 2. Other non-localhost IPs # 3. Localhost as last resort private_ips = [] public_ips = [] for ip in ips: if ip.startswith('127.') or ip == 'localhost': continue elif (ip.startswith('192.168.') or ip.startswith('10.') or (ip.startswith('172.') and 16 <= int(ip.split('.')[1]) <= 31)): private_ips.append(ip) else: public_ips.append(ip) # Prefer private IPs if private_ips: # Prefer 192.168 range as it's most common for ip in private_ips: if ip.startswith('192.168.'): return ip return private_ips[0] elif public_ips: return public_ips[0] elif ips: return ips[0] else: return None try: hostname = socket.gethostname() all_ips = get_network_ips() recommended_ip = get_recommended_ip(all_ips) return web.json_response({ "status": "success", "hostname": hostname, "all_ips": all_ips, "recommended_ip": recommended_ip, "cuda_device": cuda_device, "cuda_device_count": physical_device_count if physical_device_count > 0 else cuda_device_count, "message": "Auto-detected network configuration" }) except Exception as e: return web.json_response({ "status": "error", "message": str(e), "hostname": "unknown", "all_ips": [], "recommended_ip": None }) @server.PromptServer.instance.routes.get("/distributed/system_info") async def get_system_info_endpoint(request): """Get system information including machine ID for local worker detection.""" try: import socket return web.json_response({ "status": "success", "hostname": socket.gethostname(), "machine_id": get_machine_id(), "platform": { "system": platform.system(), "machine": platform.machine(), "node": platform.node(), "path_separator": os.sep, # Add path separator "os_name": os.name # Add OS name (posix, nt, etc.) }, "is_docker": is_docker_environment(), "is_runpod": is_runpod_environment(), "runpod_pod_id": os.environ.get('RUNPOD_POD_ID') }) except Exception as e: return web.json_response({ "status": "error", "message": str(e) }, status=500) @server.PromptServer.instance.routes.post("/distributed/config/update_worker") async def update_worker_endpoint(request): try: data = await request.json() worker_id = data.get("worker_id") if worker_id is None: return await handle_api_error(request, "Missing worker_id", 400) config = load_config() worker_found = False for worker in config.get("workers", []): if worker["id"] == worker_id: # Update all provided fields if "enabled" in data: worker["enabled"] = data["enabled"] if "name" in data: worker["name"] = data["name"] if "port" in data: worker["port"] = data["port"] # Handle host field - remove it if None if "host" in data: if data["host"] is None: worker.pop("host", None) else: worker["host"] = data["host"] # Handle cuda_device field - remove it if None if "cuda_device" in data: if data["cuda_device"] is None: worker.pop("cuda_device", None) else: worker["cuda_device"] = data["cuda_device"] # Handle extra_args field - remove it if None if "extra_args" in data: if data["extra_args"] is None: worker.pop("extra_args", None) else: worker["extra_args"] = data["extra_args"] # Handle type field if "type" in data: worker["type"] = data["type"] worker_found = True break if not worker_found: # If worker not found and all required fields are provided, create new worker if all(key in data for key in ["name", "port", "cuda_device"]): new_worker = { "id": worker_id, "name": data["name"], "host": data.get("host", "localhost"), "port": data["port"], "cuda_device": data["cuda_device"], "enabled": data.get("enabled", False), "extra_args": data.get("extra_args", ""), "type": data.get("type", "local") } if "workers" not in config: config["workers"] = [] config["workers"].append(new_worker) worker_found = True else: return await handle_api_error(request, f"Worker {worker_id} not found and missing required fields for creation", 404) if save_config(config): return web.json_response({"status": "success"}) else: return await handle_api_error(request, "Failed to save config") except Exception as e: return await handle_api_error(request, e, 400) @server.PromptServer.instance.routes.post("/distributed/config/delete_worker") async def delete_worker_endpoint(request): try: data = await request.json() worker_id = data.get("worker_id") if worker_id is None: return await handle_api_error(request, "Missing worker_id", 400) config = load_config() workers = config.get("workers", []) # Find and remove the worker worker_index = -1 for i, worker in enumerate(workers): if worker["id"] == worker_id: worker_index = i break if worker_index == -1: return await handle_api_error(request, f"Worker {worker_id} not found", 404) # Remove the worker removed_worker = workers.pop(worker_index) if save_config(config): return web.json_response({ "status": "success", "message": f"Worker {removed_worker.get('name', worker_id)} deleted" }) else: return await handle_api_error(request, "Failed to save config") except Exception as e: return await handle_api_error(request, e, 400) @server.PromptServer.instance.routes.post("/distributed/config/update_setting") async def update_setting_endpoint(request): """Updates a specific key in the settings object.""" try: data = await request.json() key = data.get("key") value = data.get("value") if not key or value is None: return await handle_api_error(request, "Missing 'key' or 'value' in request", 400) config = load_config() if 'settings' not in config: config['settings'] = {} config['settings'][key] = value if save_config(config): return web.json_response({"status": "success", "message": f"Setting '{key}' updated."}) else: return await handle_api_error(request, "Failed to save config") except Exception as e: return await handle_api_error(request, e, 400) @server.PromptServer.instance.routes.post("/distributed/config/update_master") async def update_master_endpoint(request): """Updates master configuration.""" try: data = await request.json() config = load_config() if 'master' not in config: config['master'] = {} # Update all provided fields if "name" in data: config['master']['name'] = data['name'] if "host" in data: config['master']['host'] = data['host'] if "port" in data: config['master']['port'] = data['port'] if "cuda_device" in data: config['master']['cuda_device'] = data['cuda_device'] if "extra_args" in data: config['master']['extra_args'] = data['extra_args'] if save_config(config): return web.json_response({"status": "success", "message": "Master configuration updated."}) else: return await handle_api_error(request, "Failed to save config") except Exception as e: return await handle_api_error(request, e, 400) @server.PromptServer.instance.routes.post("/distributed/prepare_job") async def prepare_job_endpoint(request): try: data = await request.json() multi_job_id = data.get('multi_job_id') if not multi_job_id: return await handle_api_error(request, "Missing multi_job_id", 400) async with prompt_server.distributed_jobs_lock: if multi_job_id not in prompt_server.distributed_pending_jobs: prompt_server.distributed_pending_jobs[multi_job_id] = asyncio.Queue() debug_log(f"Prepared queue for job {multi_job_id}") return web.json_response({"status": "success"}) except Exception as e: return await handle_api_error(request, e) @server.PromptServer.instance.routes.post("/distributed/clear_memory") async def clear_memory_endpoint(request): debug_log("Received request to clear VRAM.") try: # Use ComfyUI's prompt server queue system like the /free endpoint does if hasattr(server.PromptServer.instance, 'prompt_queue'): server.PromptServer.instance.prompt_queue.set_flag("unload_models", True) server.PromptServer.instance.prompt_queue.set_flag("free_memory", True) debug_log("Set queue flags for memory clearing.") # Wait a bit for the queue to process await asyncio.sleep(MEMORY_CLEAR_DELAY) # Also do direct cleanup as backup, but with error handling import gc import comfy.model_management as mm try: mm.unload_all_models() except AttributeError as e: debug_log(f"Warning during model unload: {e}") try: mm.soft_empty_cache() except Exception as e: debug_log(f"Warning during cache clear: {e}") for _ in range(3): gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache() torch.cuda.ipc_collect() debug_log("VRAM cleared successfully.") return web.json_response({"status": "success", "message": "GPU memory cleared."}) except Exception as e: # Even if there's an error, try to do basic cleanup import gc gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache() debug_log(f"Partial VRAM clear completed with warning: {e}") return web.json_response({"status": "success", "message": "GPU memory cleared (with warnings)"}) @server.PromptServer.instance.routes.post("/distributed/launch_worker") async def launch_worker_endpoint(request): """Launch a worker process from the UI.""" try: data = await request.json() worker_id = data.get("worker_id") if not worker_id: return await handle_api_error(request, "Missing worker_id", 400) # Find worker config config = load_config() worker = next((w for w in config.get("workers", []) if w["id"] == worker_id), None) if not worker: return await handle_api_error(request, f"Worker {worker_id} not found", 404) # Ensure consistent string ID worker_id_str = str(worker_id) # Check if already running (managed by this instance) if worker_id_str in worker_manager.processes: proc_info = worker_manager.processes[worker_id_str] process = proc_info.get('process') # Check if still running is_running = False if process: is_running = process.poll() is None else: # Restored process without subprocess object is_running = worker_manager._is_process_running(proc_info['pid']) if is_running: return web.json_response({ "status": "error", "message": "Worker already running (managed by UI)", "pid": proc_info['pid'], "log_file": proc_info.get('log_file') }, status=409) else: # Process is dead, remove it del worker_manager.processes[worker_id_str] worker_manager.save_processes() # Launch the worker try: pid = worker_manager.launch_worker(worker) log_file = worker_manager.processes[worker_id_str].get('log_file') return web.json_response({ "status": "success", "pid": pid, "message": f"Worker {worker['name']} launched", "log_file": log_file }) except Exception as e: return await handle_api_error(request, f"Failed to launch worker: {str(e)}", 500) except Exception as e: return await handle_api_error(request, e, 400) @server.PromptServer.instance.routes.post("/distributed/stop_worker") async def stop_worker_endpoint(request): """Stop a worker process that was launched from the UI.""" try: data = await request.json() worker_id = data.get("worker_id") if not worker_id: return await handle_api_error(request, "Missing worker_id", 400) success, message = worker_manager.stop_worker(worker_id) if success: return web.json_response({"status": "success", "message": message}) else: return web.json_response({"status": "error", "message": message}, status=404 if "not managed" in message else 409) except Exception as e: return await handle_api_error(request, e, 400) @server.PromptServer.instance.routes.get("/distributed/managed_workers") async def get_managed_workers_endpoint(request): """Get list of workers managed by this UI instance.""" try: managed = worker_manager.get_managed_workers() return web.json_response({ "status": "success", "managed_workers": managed }) except Exception as e: return await handle_api_error(request, e, 500) @server.PromptServer.instance.routes.get("/distributed/local-worker-status") async def get_local_worker_status_endpoint(request): """Check status of all local workers (localhost/no host specified).""" try: config = load_config() worker_statuses = {} for worker in config.get("workers", []): # Only check local workers if not worker.get("host") or worker.get("host") in ["localhost", "127.0.0.1"]: worker_id = worker["id"] port = worker["port"] # Check if worker is enabled if not worker.get("enabled", False): worker_statuses[worker_id] = { "online": False, "enabled": False, "processing": False, "queue_count": 0 } continue # Try to connect to worker try: session = await get_client_session() async with session.get( f"http://localhost:{port}/prompt", timeout=aiohttp.ClientTimeout(total=2) ) as resp: if resp.status == 200: data = await resp.json() queue_remaining = data.get("exec_info", {}).get("queue_remaining", 0) worker_statuses[worker_id] = { "online": True, "enabled": True, "processing": queue_remaining > 0, "queue_count": queue_remaining } else: worker_statuses[worker_id] = { "online": False, "enabled": True, "processing": False, "queue_count": 0, "error": f"HTTP {resp.status}" } except asyncio.TimeoutError: worker_statuses[worker_id] = { "online": False, "enabled": True, "processing": False, "queue_count": 0, "error": "Timeout" } except Exception as e: worker_statuses[worker_id] = { "online": False, "enabled": True, "processing": False, "queue_count": 0, "error": str(e) } return web.json_response({ "status": "success", "worker_statuses": worker_statuses }) except Exception as e: debug_log(f"Error checking local worker status: {e}") return await handle_api_error(request, e, 500) @server.PromptServer.instance.routes.get("/distributed/worker_log/{worker_id}") async def get_worker_log_endpoint(request): """Get log content for a specific worker.""" try: worker_id = request.match_info['worker_id'] # Ensure worker_id is string worker_id = str(worker_id) # Check if we manage this worker if worker_id not in worker_manager.processes: return await handle_api_error(request, f"Worker {worker_id} not managed by UI", 404) proc_info = worker_manager.processes[worker_id] log_file = proc_info.get('log_file') if not log_file or not os.path.exists(log_file): return await handle_api_error(request, "Log file not found", 404) # Read last N lines (or full file if small) lines_to_read = int(request.query.get('lines', 1000)) try: # Get file size file_size = os.path.getsize(log_file) with open(log_file, 'r', encoding='utf-8', errors='replace') as f: if lines_to_read > 0 and file_size > 1024 * 1024: # If file > 1MB and limited lines requested # Read last N lines efficiently lines = [] # Start from end and work backwards f.seek(0, 2) # Go to end file_length = f.tell() # Read chunks from end chunk_size = min(CHUNK_SIZE, file_length) while len(lines) < lines_to_read and f.tell() > 0: # Move back and read chunk current_pos = max(0, f.tell() - chunk_size) f.seek(current_pos) chunk = f.read(chunk_size) # Process chunk chunk_lines = chunk.splitlines() if current_pos > 0: # Partial line at beginning, combine with next chunk chunk_lines = chunk_lines[1:] lines = chunk_lines + lines # Move back for next chunk f.seek(current_pos) # Take only last N lines content = '\n'.join(lines[-lines_to_read:]) truncated = len(lines) > lines_to_read else: # Read entire file content = f.read() truncated = False return web.json_response({ "status": "success", "content": content, "log_file": log_file, "file_size": file_size, "truncated": truncated, "lines_shown": lines_to_read if truncated else content.count('\n') + 1 }) except Exception as e: return await handle_api_error(request, f"Error reading log file: {str(e)}", 500) except Exception as e: return await handle_api_error(request, e, 500) # --- Worker Process Management --- class WorkerProcessManager: def __init__(self): self.processes = {} # worker_id -> process info self.load_processes() def find_comfy_root(self): """Find the ComfyUI root directory.""" # Start from current file location current_dir = os.path.dirname(os.path.abspath(__file__)) # Method 1: Check for environment variable override env_root = os.environ.get('COMFYUI_ROOT') if env_root and os.path.exists(os.path.join(env_root, "main.py")): debug_log(f"Found ComfyUI root via COMFYUI_ROOT environment variable: {env_root}") return env_root # Method 2: Try going up from custom_nodes directory # This file should be in ComfyUI/custom_nodes/ComfyUI-Distributed/ potential_root = os.path.dirname(os.path.dirname(current_dir)) if os.path.exists(os.path.join(potential_root, "main.py")): debug_log(f"Found ComfyUI root via directory traversal: {potential_root}") return potential_root # Method 3: Look for common Docker paths docker_paths = ["/basedir", "/ComfyUI", "/app", "/workspace/ComfyUI", "/comfyui", "/opt/ComfyUI", "/workspace"] for path in docker_paths: if os.path.exists(path) and os.path.exists(os.path.join(path, "main.py")): debug_log(f"Found ComfyUI root in Docker path: {path}") return path # Method 4: Search upwards for main.py search_dir = current_dir for _ in range(5): # Limit search depth if os.path.exists(os.path.join(search_dir, "main.py")): debug_log(f"Found ComfyUI root via upward search: {search_dir}") return search_dir parent = os.path.dirname(search_dir) if parent == search_dir: # Reached root break search_dir = parent # Method 5: Try to import and use folder_paths try: import folder_paths # folder_paths.base_path should point to ComfyUI root if hasattr(folder_paths, 'base_path') and os.path.exists(os.path.join(folder_paths.base_path, "main.py")): debug_log(f"Found ComfyUI root via folder_paths: {folder_paths.base_path}") return folder_paths.base_path except: pass # If all methods fail, log detailed information and return the best guess log(f"Warning: Could not reliably determine ComfyUI root directory") log(f"Current directory: {current_dir}") log(f"Initial guess was: {potential_root}") # Return the initial guess as fallback return potential_root def _find_windows_terminal(self): """Find Windows Terminal executable.""" # Common locations for Windows Terminal possible_paths = [ os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WindowsApps\wt.exe"), os.path.expandvars(r"%PROGRAMFILES%\WindowsApps\Microsoft.WindowsTerminal_*\wt.exe"), "wt.exe" # Try PATH ] for path in possible_paths: if os.path.exists(path): return path # Handle wildcard for WindowsApps if '*' in path: import glob matches = glob.glob(path) if matches: return matches[0] # Try to find it in PATH import shutil wt_path = shutil.which("wt") if wt_path: return wt_path return None def build_launch_command(self, worker_config, comfy_root): """Build the command to launch a worker.""" # Use main.py directly - it's the most reliable method main_py = os.path.join(comfy_root, "main.py") if os.path.exists(main_py): cmd = [ get_python_executable(), main_py, "--port", str(worker_config['port']), "--enable-cors-header" ] debug_log(f"Using main.py: {main_py}") else: # Provide detailed error message error_msg = f"Could not find main.py in {comfy_root}\n" error_msg += f"Searched for: {main_py}\n" error_msg += f"Directory contents of {comfy_root}:\n" try: if os.path.exists(comfy_root): files = os.listdir(comfy_root)[:20] # List first 20 files error_msg += " " + "\n ".join(files) if len(os.listdir(comfy_root)) > 20: error_msg += f"\n ... and {len(os.listdir(comfy_root)) - 20} more files" else: error_msg += f" Directory {comfy_root} does not exist!" except Exception as e: error_msg += f" Error listing directory: {e}" # Try to suggest the correct path error_msg += "\n\nPossible solutions:\n" error_msg += "1. Check if ComfyUI is installed in a different location\n" error_msg += "2. For Docker: ComfyUI might be in /ComfyUI or /app\n" error_msg += "3. Ensure the custom node is installed in the correct location\n" raise RuntimeError(error_msg) # Add any extra arguments safely if worker_config.get('extra_args'): raw_args = worker_config['extra_args'].strip() if raw_args: # Safely split using shlex to handle quotes and spaces extra_args_list = shlex.split(raw_args) # Validate: Block dangerous shell meta-characters (e.g., ; | & > < ` $) forbidden_chars = set(';|>&<`$()[]{}*!?') for arg in extra_args_list: if any(c in forbidden_chars for c in arg): raise ValueError(f"Invalid characters in extra_args: {arg}. Forbidden: {''.join(forbidden_chars)}") cmd.extend(extra_args_list) return cmd def launch_worker(self, worker_config, show_window=False): """Launch a worker process with logging.""" comfy_root = self.find_comfy_root() # Set up environment env = os.environ.copy() env['CUDA_VISIBLE_DEVICES'] = str(worker_config.get('cuda_device', 0)) env['PYTORCH_CUDA_ALLOC_CONF'] = 'expandable_segments:True' # Pass master PID to worker so it can monitor if master is still alive env['COMFYUI_MASTER_PID'] = str(os.getpid()) cmd = self.build_launch_command(worker_config, comfy_root) # Change to ComfyUI root directory for the process cwd = comfy_root # Create log directory and file log_dir = os.path.join(comfy_root, "logs", "workers") os.makedirs(log_dir, exist_ok=True) # Use daily log files instead of timestamp date_stamp = time.strftime("%Y%m%d") worker_name = worker_config.get('name', f'Worker{worker_config["id"]}') # Clean worker name for filename safe_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in worker_name) log_file = os.path.join(log_dir, f"{safe_name}_{date_stamp}.log") # Launch process with logging (append mode for daily logs) with open(log_file, 'a') as log_handle: # Write startup info to log with timestamp log_handle.write(f"\n\n{'='*50}\n") log_handle.write(f"=== ComfyUI Worker Session Started ===\n") log_handle.write(f"Worker: {worker_name}\n") log_handle.write(f"Port: {worker_config['port']}\n") log_handle.write(f"CUDA Device: {worker_config.get('cuda_device', 0)}\n") log_handle.write(f"Started: {time.strftime('%Y-%m-%d %H:%M:%S')}\n") log_handle.write(f"Command: {' '.join(cmd)}\n") # Note about worker behavior config = load_config() stop_on_master_exit = config.get('settings', {}).get('stop_workers_on_master_exit', True) if stop_on_master_exit: log_handle.write("Note: Worker will stop when master shuts down\n") else: log_handle.write("Note: Worker will continue running after master shuts down\n") log_handle.write("=" * 30 + "\n\n") log_handle.flush() # Wrap command with monitor if needed if stop_on_master_exit and env.get('COMFYUI_MASTER_PID'): # Use the monitor wrapper monitor_script = os.path.join(os.path.dirname(__file__), 'worker_monitor.py') monitored_cmd = [get_python_executable(), monitor_script] + cmd log_handle.write(f"[Worker Monitor] Monitoring master PID: {env['COMFYUI_MASTER_PID']}\n") log_handle.flush() else: monitored_cmd = cmd # Platform-specific process creation - always hidden with logging if platform.system() == "Windows": CREATE_NO_WINDOW = 0x08000000 process = subprocess.Popen( monitored_cmd, env=env, cwd=cwd, stdout=log_handle, stderr=subprocess.STDOUT, creationflags=CREATE_NO_WINDOW ) else: # Unix-like systems process = subprocess.Popen( monitored_cmd, env=env, cwd=cwd, stdout=log_handle, stderr=subprocess.STDOUT, start_new_session=True # Detach from parent ) # Track the process with log file info - use string ID for consistency worker_id = str(worker_config['id']) self.processes[worker_id] = { 'pid': process.pid, 'process': process, 'started_at': time.time(), 'config': worker_config, 'log_file': log_file, 'is_monitor': stop_on_master_exit and env.get('COMFYUI_MASTER_PID'), # Track if using monitor 'launching': True # Mark as launching until confirmed running } # Save process info for persistence self.save_processes() if stop_on_master_exit and env.get('COMFYUI_MASTER_PID'): debug_log(f"Launched worker {worker_name} via monitor (Monitor PID: {process.pid})") else: log(f"Launched worker {worker_name} directly (PID: {process.pid})") debug_log(f"Log file: {log_file}") return process.pid def stop_worker(self, worker_id): """Stop a worker process.""" # Ensure worker_id is string worker_id = str(worker_id) if worker_id not in self.processes: return False, "Worker not managed by UI" proc_info = self.processes[worker_id] process = proc_info.get('process') pid = proc_info['pid'] debug_log(f"Attempting to stop worker {worker_id} (PID: {pid})") # For restored processes without subprocess object if not process: try: print(f"[Distributed] Stopping restored process (no subprocess object)") if self._kill_process_tree(pid): del self.processes[worker_id] self.save_processes() debug_log(f"Successfully stopped worker {worker_id} and all child processes") return True, "Worker stopped" else: return False, "Failed to stop worker process" except Exception as e: print(f"[MultiGPU] Exception during stop: {e}") return False, f"Error stopping worker: {str(e)}" # Normal case with subprocess object # Check if still running if process.poll() is not None: # Already stopped print(f"[Distributed] Worker {worker_id} already stopped") del self.processes[worker_id] self.save_processes() return False, "Worker already stopped" # Try to kill the entire process tree try: debug_log(f"Using process tree kill for worker {worker_id}") if self._kill_process_tree(pid): # Clean up tracking del self.processes[worker_id] self.save_processes() debug_log(f"Successfully stopped worker {worker_id} and all child processes") return True, "Worker stopped" else: # Fallback to normal termination print(f"[Distributed] Process tree kill failed, trying normal termination") if process: terminate_process(process, timeout=PROCESS_TERMINATION_TIMEOUT) del self.processes[worker_id] self.save_processes() return True, "Worker stopped (fallback)" except Exception as e: print(f"[Distributed] Exception during stop: {e}") return False, f"Error stopping worker: {str(e)}" def get_managed_workers(self): """Get list of workers managed by this process.""" managed = {} for worker_id, proc_info in list(self.processes.items()): # Check if process is still running is_running, _ = self._check_worker_process(worker_id, proc_info) if is_running: managed[worker_id] = { 'pid': proc_info['pid'], 'started_at': proc_info['started_at'], 'log_file': proc_info.get('log_file'), 'launching': proc_info.get('launching', False) } else: # Process has stopped, remove from tracking del self.processes[worker_id] return managed def cleanup_all(self): """Stop all managed workers (called on shutdown).""" for worker_id in list(self.processes.keys()): try: self.stop_worker(worker_id) except Exception as e: print(f"[Distributed] Error stopping worker {worker_id}: {e}") # Clear all managed processes from config config = load_config() config['managed_processes'] = {} save_config(config) def load_processes(self): """Load persisted process information from config.""" config = load_config() managed_processes = config.get('managed_processes', {}) # Verify each saved process is still running for worker_id, proc_info in managed_processes.items(): pid = proc_info.get('pid') if pid and self._is_process_running(pid): # Reconstruct process info self.processes[worker_id] = { 'pid': pid, 'process': None, # Can't reconstruct subprocess object 'started_at': proc_info.get('started_at'), 'config': proc_info.get('config'), 'log_file': proc_info.get('log_file') } print(f"[Distributed] Restored worker {worker_id} (PID: {pid})") else: if pid: print(f"[Distributed] Worker {worker_id} (PID: {pid}) is no longer running") def save_processes(self): """Save process information to config.""" config = load_config() # Create serializable version of process info managed_processes = {} for worker_id, proc_info in self.processes.items(): # Only save if process is running is_running, _ = self._check_worker_process(worker_id, proc_info) if is_running: managed_processes[worker_id] = { 'pid': proc_info['pid'], 'started_at': proc_info['started_at'], 'config': proc_info['config'], 'log_file': proc_info.get('log_file'), 'launching': proc_info.get('launching', False) } # Update config with managed processes config['managed_processes'] = managed_processes save_config(config) def _is_process_running(self, pid): """Check if a process with given PID is running.""" return is_process_alive(pid) def _check_worker_process(self, worker_id, proc_info): """Check if a worker process is still running and return status. Returns: tuple: (is_running, has_subprocess_object) """ process = proc_info.get('process') pid = proc_info.get('pid') if process: # Normal case with subprocess object return process.poll() is None, True elif pid: # Restored process without subprocess object return self._is_process_running(pid), False else: # No process or PID return False, False def _kill_process_tree(self, pid): """Kill a process and all its children.""" if PSUTIL_AVAILABLE: try: parent = psutil.Process(pid) children = parent.children(recursive=True) # Log what we're about to kill debug_log(f"Killing process tree for PID {pid} ({parent.name()})") for child in children: debug_log(f" - Child PID {child.pid} ({child.name()})") # Kill children first for child in children: try: debug_log(f"Terminating child {child.pid}") child.terminate() except psutil.NoSuchProcess: pass # Wait a bit for graceful termination gone, alive = psutil.wait_procs(children, timeout=PROCESS_WAIT_TIMEOUT) # Force kill any remaining for child in alive: try: debug_log(f"Force killing child {child.pid}") child.kill() except psutil.NoSuchProcess: pass # Finally kill the parent try: debug_log(f"Terminating parent {pid}") parent.terminate() parent.wait(timeout=PROCESS_WAIT_TIMEOUT) except psutil.TimeoutExpired: debug_log(f"Force killing parent {pid}") parent.kill() except psutil.NoSuchProcess: debug_log(f"Parent process {pid} already gone") return True except psutil.NoSuchProcess: debug_log(f"Process {pid} does not exist") return False except Exception as e: debug_log(f"Error killing process tree: {e}") # Fall through to OS commands # Fallback to OS-specific commands print(f"[Distributed] Using OS commands to kill process tree") if platform.system() == "Windows": try: # Use wmic to find child processes result = subprocess.run(['wmic', 'process', 'where', f'ParentProcessId={pid}', 'get', 'ProcessId'], capture_output=True, text=True) if result.returncode == 0: lines = result.stdout.strip().split('\n')[1:] # Skip header child_pids = [line.strip() for line in lines if line.strip() and line.strip().isdigit()] print(f"[Distributed] Found child processes: {child_pids}") # Kill each child for child_pid in child_pids: try: subprocess.run(['taskkill', '/F', '/PID', child_pid], capture_output=True, check=False) except: pass # Kill the parent with tree flag result = subprocess.run(['taskkill', '/F', '/PID', str(pid), '/T'], capture_output=True, text=True) print(f"[Distributed] Taskkill result: {result.stdout.strip()}") return result.returncode == 0 except Exception as e: print(f"[Distributed] Error with taskkill: {e}") return False else: # Unix: use pkill try: subprocess.run(['pkill', '-TERM', '-P', str(pid)], check=False) time.sleep(WORKER_CHECK_INTERVAL) subprocess.run(['pkill', '-KILL', '-P', str(pid)], check=False) os.kill(pid, signal.SIGKILL) return True except: return False # Create global instance worker_manager = WorkerProcessManager() # Add IPC queue storage worker_manager.queues = {} # Helper to get aiohttp session async def get_client_session(): """Get or create aiohttp client session.""" if not hasattr(server.PromptServer.instance, '_distributed_session'): server.PromptServer.instance._distributed_session = aiohttp.ClientSession() return server.PromptServer.instance._distributed_session # Local worker detection functions async def is_local_worker(worker_config): """Check if a worker is running on the same machine as the master.""" host = worker_config.get('host', 'localhost') if host in ['localhost', '127.0.0.1', '0.0.0.0', ''] or worker_config.get('type') == 'local': return True # For cloud workers, check if on same physical host if worker_config.get('type') == 'cloud': return await is_same_physical_host(worker_config) return False async def is_same_physical_host(worker_config): """Compare machine IDs to determine if worker is on same physical host.""" try: # Get master machine ID master_machine_id = get_machine_id() # Fetch worker's machine ID via API host = worker_config.get('host', 'localhost') port = worker_config.get('port', 8188) session = await get_client_session() async with session.get( f"http://{host}:{port}/distributed/system_info", timeout=aiohttp.ClientTimeout(total=5) ) as resp: if resp.status == 200: data = await resp.json() worker_machine_id = data.get('machine_id') return worker_machine_id == master_machine_id else: debug_log(f"Failed to get system info from worker: HTTP {resp.status}") return False except Exception as e: debug_log(f"Error checking same physical host: {e}") return False def get_machine_id(): """Get a unique identifier for this machine.""" # Try multiple methods to get a stable machine ID try: # Method 1: MAC address-based UUID return str(uuid.getnode()) except: try: # Method 2: Platform + hostname import socket return f"{platform.machine()}_{socket.gethostname()}" except: # Fallback return platform.machine() def is_docker_environment(): """Check if running inside Docker container.""" return (os.path.exists('/.dockerenv') or os.environ.get('DOCKER_CONTAINER', False) or 'docker' in platform.node().lower()) def is_runpod_environment(): """Check if running in Runpod environment.""" return (os.environ.get('RUNPOD_POD_ID') is not None or os.environ.get('RUNPOD_API_KEY') is not None) async def get_comms_channel(worker_id, worker_config): """Get communication channel for a worker (HTTP URL or IPC Queue).""" if await is_local_worker(worker_config): comm_mode = worker_config.get('communicationMode', 'http') if comm_mode == 'ipc': # Use multiprocessing Queue for IPC if worker_id not in worker_manager.queues: worker_manager.queues[worker_id] = Queue() return worker_manager.queues[worker_id] elif is_docker_environment(): # Docker: use host.docker.internal return f"http://host.docker.internal:{worker_config['port']}" else: # Local: use loopback return f"http://127.0.0.1:{worker_config['port']}" elif worker_config.get('type') == 'cloud' and is_runpod_environment(): # Runpod same-host optimization (if detected) host = worker_config.get('host', 'localhost') return f"http://{host}:{worker_config['port']}" else: # Remote worker: use configured endpoint host = worker_config.get('host', 'localhost') return f"http://{host}:{worker_config['port']}" # Auto-launch workers if enabled def auto_launch_workers(): """Launch enabled workers if auto_launch_workers is set to true.""" try: config = load_config() if config.get('settings', {}).get('auto_launch_workers', False): log("Auto-launch workers is enabled, checking for workers to start...") # Clear managed_processes before launching new workers # This handles cases where the master was killed without proper cleanup if config.get('managed_processes'): log("Clearing old managed_processes before auto-launch...") config['managed_processes'] = {} save_config(config) workers = config.get('workers', []) launched_count = 0 for worker in workers: if worker.get('enabled', False): worker_id = worker.get('id') worker_name = worker.get('name', f'Worker {worker_id}') # Skip remote workers host = worker.get('host', 'localhost').lower() if host not in ['localhost', '127.0.0.1', '', None]: debug_log(f"Skipping remote worker {worker_name} (host: {host})") continue # Check if already running if str(worker_id) in worker_manager.processes: proc_info = worker_manager.processes[str(worker_id)] if worker_manager._is_process_running(proc_info['pid']): debug_log(f"Worker {worker_name} already running, skipping") continue # Launch the worker try: pid = worker_manager.launch_worker(worker) log(f"Auto-launched worker {worker_name} (PID: {pid})") # Mark as launching in managed processes if str(worker_id) in worker_manager.processes: worker_manager.processes[str(worker_id)]['launching'] = True worker_manager.save_processes() launched_count += 1 except Exception as e: log(f"Failed to auto-launch worker {worker_name}: {e}") if launched_count > 0: log(f"Auto-launched {launched_count} worker(s)") else: debug_log("No workers to auto-launch") else: debug_log("Auto-launch workers is disabled") except Exception as e: log(f"Error during auto-launch: {e}") # Schedule auto-launch after a short delay to ensure server is ready def delayed_auto_launch(): """Delay auto-launch to ensure server is fully initialized.""" import threading timer = threading.Timer(WORKER_STARTUP_DELAY, auto_launch_workers) timer.daemon = True timer.start() # Async cleanup function for proper shutdown async def async_cleanup_and_exit(signum=None): """Async-friendly cleanup and exit.""" try: config = load_config() if config.get('settings', {}).get('stop_workers_on_master_exit', True): print("\n[Distributed] Master shutting down, stopping all managed workers...") worker_manager.cleanup_all() else: print("\n[Distributed] Master shutting down, workers will continue running") worker_manager.save_processes() except Exception as e: print(f"[Distributed] Error during cleanup: {e}") # On Windows, we need to exit differently if platform.system() == "Windows": # Force exit on Windows sys.exit(0) else: # On Unix, stop the event loop gracefully loop = asyncio.get_running_loop() loop.stop() def register_async_signals(): """Register async signal handlers for graceful shutdown.""" if platform.system() == "Windows": # Windows doesn't support add_signal_handler, use traditional signal handling def signal_handler(signum, frame): # Schedule the async cleanup in the event loop loop = server.PromptServer.instance.loop if loop and loop.is_running(): asyncio.run_coroutine_threadsafe(async_cleanup_and_exit(signum), loop) else: # Fallback to sync cleanup if loop isn't running try: config = load_config() if config.get('settings', {}).get('stop_workers_on_master_exit', True): print("\n[Distributed] Master shutting down, stopping all managed workers...") worker_manager.cleanup_all() else: print("\n[Distributed] Master shutting down, workers will continue running") worker_manager.save_processes() except Exception as e: print(f"[Distributed] Error during cleanup: {e}") sys.exit(0) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) else: # Unix-like systems support add_signal_handler loop = server.PromptServer.instance.loop for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(async_cleanup_and_exit(s))) # SIGHUP is Unix-only loop.add_signal_handler(signal.SIGHUP, lambda: asyncio.create_task(async_cleanup_and_exit(signal.SIGHUP))) def sync_cleanup(): """Synchronous wrapper for atexit.""" try: # For atexit, we don't want to stop the loop or exit config = load_config() if config.get('settings', {}).get('stop_workers_on_master_exit', True): print("\n[Distributed] Master shutting down, stopping all managed workers...") worker_manager.cleanup_all() else: print("\n[Distributed] Master shutting down, workers will continue running") worker_manager.save_processes() except Exception as e: print(f"[Distributed] Error during cleanup: {e}") # Register atexit handler for normal exits atexit.register(sync_cleanup) # Call delayed auto-launch only if we're the master (not a worker) if not os.environ.get('COMFYUI_MASTER_PID'): delayed_auto_launch() try: register_async_signals() # Register async signal handlers except Exception as e: print(f"[Distributed] Warning: Could not register async signal handlers: {e}") # Fall back to basic signal handling def basic_signal_handler(signum, frame): print("\n[Distributed] Received signal, shutting down...") try: config = load_config() if config.get('settings', {}).get('stop_workers_on_master_exit', True): worker_manager.cleanup_all() else: worker_manager.save_processes() except Exception as cleanup_error: print(f"[Distributed] Error during cleanup: {cleanup_error}") sys.exit(0) signal.signal(signal.SIGINT, basic_signal_handler) signal.signal(signal.SIGTERM, basic_signal_handler) else: debug_log("Running as worker, skipping auto-launch") # --- Persistent State Storage --- # Store job queue on the persistent server instance to survive script reloads prompt_server = server.PromptServer.instance # Initialize persistent state if not already present if not hasattr(prompt_server, 'distributed_pending_jobs'): debug_log("Initializing persistent job queue on server instance.") prompt_server.distributed_pending_jobs = {} prompt_server.distributed_jobs_lock = asyncio.Lock() @server.PromptServer.instance.routes.post("/distributed/load_image") async def load_image_endpoint(request): """Load an image or video file and return it as base64 data with hash.""" try: data = await request.json() image_path = data.get("image_path") if not image_path: return await handle_api_error(request, "Missing image_path", 400) import folder_paths import base64 import io import hashlib # Use ComfyUI's folder paths to find the file full_path = folder_paths.get_annotated_filepath(image_path) if not os.path.exists(full_path): return await handle_api_error(request, f"File not found: {image_path}", 404) # Calculate file hash hash_md5 = hashlib.md5() with open(full_path, 'rb') as f: for chunk in iter(lambda: f.read(4096), b""): hash_md5.update(chunk) file_hash = hash_md5.hexdigest() # Check if it's a video file video_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.webm'} file_ext = os.path.splitext(full_path)[1].lower() if file_ext in video_extensions: # For video files, read the raw bytes with open(full_path, 'rb') as f: file_data = f.read() # Determine MIME type mime_types = { '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska', '.webm': 'video/webm' } mime_type = mime_types.get(file_ext, 'video/mp4') # Return base64 encoded video with data URL video_base64 = base64.b64encode(file_data).decode('utf-8') return web.json_response({ "status": "success", "image_data": f"data:{mime_type};base64,{video_base64}", "hash": file_hash }) else: # For images, use PIL from PIL import Image # Load and convert to base64 with Image.open(full_path) as img: # Convert to RGB if needed if img.mode not in ('RGB', 'RGBA'): img = img.convert('RGB') # Save to bytes buffer = io.BytesIO() img.save(buffer, format='PNG', compress_level=1) # Fast compression img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') return web.json_response({ "status": "success", "image_data": f"data:image/png;base64,{img_base64}", "hash": file_hash }) except Exception as e: return await handle_api_error(request, e, 500) @server.PromptServer.instance.routes.post("/distributed/check_file") async def check_file_endpoint(request): """Check if a file exists and matches the given hash.""" try: data = await request.json() filename = data.get("filename") expected_hash = data.get("hash") if not filename or not expected_hash: return await handle_api_error(request, "Missing filename or hash", 400) import folder_paths import hashlib # Use ComfyUI's folder paths to find the file full_path = folder_paths.get_annotated_filepath(filename) if not os.path.exists(full_path): return web.json_response({ "status": "success", "exists": False }) # Calculate file hash hash_md5 = hashlib.md5() with open(full_path, 'rb') as f: for chunk in iter(lambda: f.read(4096), b""): hash_md5.update(chunk) file_hash = hash_md5.hexdigest() # Check if hash matches hash_matches = file_hash == expected_hash return web.json_response({ "status": "success", "exists": True, "hash_matches": hash_matches }) except Exception as e: return await handle_api_error(request, e, 500) @server.PromptServer.instance.routes.post("/distributed/job_complete") async def job_complete_endpoint(request): try: data = await request.post() multi_job_id = data.get('multi_job_id') worker_id = data.get('worker_id') is_last = data.get('is_last', 'False').lower() == 'true' if multi_job_id is None or worker_id is None: return await handle_api_error(request, "Missing multi_job_id or worker_id", 400) # Check for batch mode batch_size = int(data.get('batch_size', 0)) tensors = [] if batch_size > 0: # Batch mode: Extract multiple images debug_log(f"job_complete received batch - job_id: {multi_job_id}, worker: {worker_id}, batch_size: {batch_size}") # Check for JSON metadata metadata_field = data.get('images_metadata') metadata = None if metadata_field: # New JSON metadata format try: # Handle different types of metadata field if hasattr(metadata_field, 'file'): # File-like object metadata_str = metadata_field.file.read().decode('utf-8') elif isinstance(metadata_field, (bytes, bytearray)): # Direct bytes/bytearray metadata_str = metadata_field.decode('utf-8') else: # String metadata_str = str(metadata_field) metadata = json.loads(metadata_str) if len(metadata) != batch_size: return await handle_api_error(request, "Metadata length mismatch", 400) debug_log(f"Using JSON metadata for batch from worker {worker_id}") except Exception as e: log(f"Error parsing JSON metadata from worker {worker_id}: {e}") return await handle_api_error(request, f"Metadata parsing error: {e}", 400) else: # Legacy format - log deprecation warning debug_log(f"WARNING: Worker {worker_id} using legacy field format. Please update to use JSON metadata.") # Process images with per-item error handling image_data_list = [] # List of tuples: (index, tensor) for i in range(batch_size): image_field = data.get(f'image_{i}') if image_field is None: log(f"Missing image_{i} from worker {worker_id}, skipping") continue try: img_data = image_field.file.read() img = Image.open(io.BytesIO(img_data)).convert("RGB") tensor = pil_to_tensor(img) tensor = ensure_contiguous(tensor) # Get expected index from metadata or use position if metadata and i < len(metadata): expected_idx = metadata[i].get('index', i) if i != expected_idx: debug_log(f"Warning: Image order mismatch at position {i}, expected index {expected_idx}") else: expected_idx = i image_data_list.append((expected_idx, tensor)) except Exception as e: log(f"Error processing image {i} from worker {worker_id}: {e}, skipping this image") # Continue processing other images instead of failing entire batch continue # Check if we got any valid images if not image_data_list: return await handle_api_error(request, "No valid images in batch", 400) # Sort by index to ensure correct order image_data_list.sort(key=lambda x: x[0]) tensors = [tensor for _, tensor in image_data_list] # Keep the indices for later use indices = [idx for idx, _ in image_data_list] # Validate final order if metadata: debug_log(f"Reordered {len(tensors)} images based on metadata indices") else: # Fallback for single-image mode (backward compatibility) image_file = data.get('image') image_index = data.get('image_index') debug_log(f"job_complete received single - job_id: {multi_job_id}, worker: {worker_id}, index: {image_index}, is_last: {is_last}") if not image_file: return await handle_api_error(request, "Missing image data", 400) try: img_data = image_file.file.read() img = Image.open(io.BytesIO(img_data)).convert("RGB") tensor = pil_to_tensor(img) tensor = ensure_contiguous(tensor) tensors = [tensor] except Exception as e: log(f"Error processing image from worker {worker_id}: {e}") return await handle_api_error(request, f"Image processing error: {e}", 400) # Put batch into queue async with prompt_server.distributed_jobs_lock: debug_log(f"Current pending jobs: {list(prompt_server.distributed_pending_jobs.keys())}") if multi_job_id in prompt_server.distributed_pending_jobs: if batch_size > 0: # Put batch as single item with indices await prompt_server.distributed_pending_jobs[multi_job_id].put({ 'worker_id': worker_id, 'tensors': tensors, 'indices': indices, 'is_last': is_last }) debug_log(f"Received batch result for job {multi_job_id} from worker {worker_id}, size={len(tensors)}") else: # Put single image (backward compat) await prompt_server.distributed_pending_jobs[multi_job_id].put({ 'tensor': tensors[0], 'worker_id': worker_id, 'image_index': int(image_index) if image_index else 0, 'is_last': is_last }) debug_log(f"Received single result for job {multi_job_id} from worker {worker_id}") debug_log(f"Queue size after put: {prompt_server.distributed_pending_jobs[multi_job_id].qsize()}") return web.json_response({"status": "success"}) else: log(f"ERROR: Job {multi_job_id} not found in distributed_pending_jobs") return await handle_api_error(request, "Job not found or already complete", 404) except Exception as e: return await handle_api_error(request, e) # --- Collector Node --- class DistributedCollectorNode: @classmethod def INPUT_TYPES(s): return { "required": { "images": ("IMAGE",) }, "hidden": { "multi_job_id": ("STRING", {"default": ""}), "is_worker": ("BOOLEAN", {"default": False}), "master_url": ("STRING", {"default": ""}), "enabled_worker_ids": ("STRING", {"default": "[]"}), "worker_batch_size": ("INT", {"default": 1, "min": 1, "max": 1024}), "worker_id": ("STRING", {"default": ""}), "pass_through": ("BOOLEAN", {"default": False}), }, } RETURN_TYPES = ("IMAGE",) FUNCTION = "run" CATEGORY = "image" def run(self, images, multi_job_id="", is_worker=False, master_url="", enabled_worker_ids="[]", worker_batch_size=1, worker_id="", pass_through=False): if not multi_job_id or pass_through: if pass_through: print(f"[Distributed Collector] Pass-through mode enabled, returning images unchanged") return (images,) # Use async helper to run in server loop result = run_async_in_server_loop( self.execute(images, multi_job_id, is_worker, master_url, enabled_worker_ids, worker_batch_size, worker_id) ) return result async def send_batch_to_master(self, image_batch, multi_job_id, master_url, worker_id): """Send image batch to master, chunked if large.""" batch_size = image_batch.shape[0] if batch_size == 0: return for start in range(0, batch_size, MAX_BATCH): chunk = image_batch[start:start + MAX_BATCH] chunk_size = chunk.shape[0] is_chunk_last = (start + chunk_size == batch_size) # True only for final chunk data = aiohttp.FormData() data.add_field('multi_job_id', multi_job_id) data.add_field('worker_id', str(worker_id)) data.add_field('is_last', str(is_chunk_last)) data.add_field('batch_size', str(chunk_size)) # Chunk metadata: Absolute index from full batch metadata = [{'index': start + j} for j in range(chunk_size)] data.add_field('images_metadata', json.dumps(metadata), content_type='application/json') # Add chunk images for j in range(chunk_size): # Convert tensor slice to PIL img = tensor_to_pil(chunk[j:j+1], 0) byte_io = io.BytesIO() img.save(byte_io, format='PNG', compress_level=0) byte_io.seek(0) data.add_field(f'image_{j}', byte_io, filename=f'image_{j}.png', content_type='image/png') try: session = await get_client_session() url = f"{master_url}/distributed/job_complete" async with session.post(url, data=data) as response: response.raise_for_status() except Exception as e: log(f"Worker - Failed to send chunk to master: {e}") debug_log(f"Worker - Full error details: URL={url}") raise # Re-raise to handle at caller level async def execute(self, images, multi_job_id="", is_worker=False, master_url="", enabled_worker_ids="[]", worker_batch_size=1, worker_id=""): if is_worker: # Worker mode: send images to master in a single batch debug_log(f"Worker - Job {multi_job_id} complete. Sending {images.shape[0]} image(s) to master") await self.send_batch_to_master(images, multi_job_id, master_url, worker_id) return (images,) else: # Master mode: collect images from workers enabled_workers = json.loads(enabled_worker_ids) num_workers = len(enabled_workers) if num_workers == 0: return (images,) images_on_cpu = images.cpu() master_batch_size = images.shape[0] debug_log(f"Master - Job {multi_job_id}: Master has {master_batch_size} images, collecting from {num_workers} workers...") # Ensure master images are contiguous images_on_cpu = ensure_contiguous(images_on_cpu) # Initialize storage for collected images worker_images = {} # Dict to store images by worker_id and index # Get the existing queue - it should already exist from prepare_job async with prompt_server.distributed_jobs_lock: if multi_job_id not in prompt_server.distributed_pending_jobs: log(f"Master - WARNING: Queue doesn't exist for job {multi_job_id}, creating one") prompt_server.distributed_pending_jobs[multi_job_id] = asyncio.Queue() else: existing_size = prompt_server.distributed_pending_jobs[multi_job_id].qsize() debug_log(f"Master - Using existing queue for job {multi_job_id} (current size: {existing_size})") # Collect images until all workers report they're done collected_count = 0 workers_done = set() # Use a reasonable timeout for the first image timeout = WORKER_JOB_TIMEOUT # Get queue size before starting async with prompt_server.distributed_jobs_lock: q = prompt_server.distributed_pending_jobs[multi_job_id] initial_size = q.qsize() # NEW: Initialize progress bar for workers (total = num_workers) p = ProgressBar(num_workers) while len(workers_done) < num_workers: try: # Get the queue again each time to ensure we have the right reference async with prompt_server.distributed_jobs_lock: q = prompt_server.distributed_pending_jobs[multi_job_id] current_size = q.qsize() result = await asyncio.wait_for(q.get(), timeout=timeout) worker_id = result['worker_id'] is_last = result.get('is_last', False) # Check if batch mode tensors = result.get('tensors', []) indices = result.get('indices', []) # Get the indices if tensors: # Batch mode debug_log(f"Master - Got batch from worker {worker_id}, size={len(tensors)}, is_last={is_last}") if worker_id not in worker_images: worker_images[worker_id] = {} # Use actual indices if available, otherwise fall back to sequential if indices: for i, tensor in enumerate(tensors): actual_idx = indices[i] worker_images[worker_id][actual_idx] = tensor else: # Fallback for backward compatibility for idx, tensor in enumerate(tensors): worker_images[worker_id][idx] = tensor collected_count += len(tensors) else: # Single image mode (backward compat) image_index = result['image_index'] tensor = result['tensor'] debug_log(f"Master - Got single result from worker {worker_id}, image {image_index}, is_last={is_last}") if worker_id not in worker_images: worker_images[worker_id] = {} worker_images[worker_id][image_index] = tensor collected_count += 1 # Once we start receiving images, use shorter timeout timeout = WORKER_JOB_TIMEOUT if is_last: workers_done.add(worker_id) p.update(1) # +1 per completed worker else: pass # Continue waiting for more results except asyncio.TimeoutError: missing_workers = set(str(w) for w in enabled_workers) - workers_done log(f"Master - Timeout. Still waiting for workers: {list(missing_workers)}") # Check queue size again with lock async with prompt_server.distributed_jobs_lock: if multi_job_id in prompt_server.distributed_pending_jobs: final_q = prompt_server.distributed_pending_jobs[multi_job_id] final_size = final_q.qsize() # Try to drain any remaining items remaining_items = [] while not final_q.empty(): try: item = final_q.get_nowait() remaining_items.append(item) except asyncio.QueueEmpty: break if remaining_items: # Process them for item in remaining_items: worker_id = item['worker_id'] is_last = item.get('is_last', False) # Check if batch mode tensors = item.get('tensors', []) if tensors: # Batch mode if worker_id not in worker_images: worker_images[worker_id] = {} for idx, tensor in enumerate(tensors): worker_images[worker_id][idx] = tensor collected_count += len(tensors) else: # Single image mode image_index = item['image_index'] tensor = item['tensor'] if worker_id not in worker_images: worker_images[worker_id] = {} worker_images[worker_id][image_index] = tensor collected_count += 1 if is_last: workers_done.add(worker_id) p.update(1) # +1 here too else: log(f"Master - Queue {multi_job_id} no longer exists!") break total_collected = sum(len(imgs) for imgs in worker_images.values()) # Clean up job queue async with prompt_server.distributed_jobs_lock: if multi_job_id in prompt_server.distributed_pending_jobs: del prompt_server.distributed_pending_jobs[multi_job_id] # Reorder images according to seed distribution pattern # Pattern: master img 1, master img 2, worker 1 img 1, worker 1 img 2, worker 2 img 1, worker 2 img 2, etc. ordered_tensors = [] # Add master images first for i in range(master_batch_size): ordered_tensors.append(images_on_cpu[i:i+1]) # Add worker images in order # The worker IDs in worker_images are already strings (e.g., "1", "2") # Just iterate through what we actually received for worker_id_str in sorted(worker_images.keys()): # Sort by image index for each worker for idx in sorted(worker_images[worker_id_str].keys()): ordered_tensors.append(worker_images[worker_id_str][idx]) # Ensure all tensors are on CPU and properly formatted before concatenation cpu_tensors = [] for t in ordered_tensors: if t.is_cuda: t = t.cpu() # Ensure tensor is contiguous in memory t = ensure_contiguous(t) cpu_tensors.append(t) try: combined = torch.cat(cpu_tensors, dim=0) # Ensure the combined tensor is contiguous and properly formatted combined = ensure_contiguous(combined) debug_log(f"Master - Job {multi_job_id} complete. Combined {combined.shape[0]} images total (master: {master_batch_size}, workers: {combined.shape[0] - master_batch_size})") return (combined,) except Exception as e: log(f"Master - Error combining images: {e}") # Return just the master images as fallback return (images,) # --- Distributor Node --- class DistributedSeed: """ Distributes seed values across multiple GPUs. On master: passes through the original seed. On workers: adds offset based on worker ID. """ @classmethod def INPUT_TYPES(cls): return { "required": { "seed": ("INT", { "default": 1125899906842, "min": 0, "max": 1125899906842624, "forceInput": False # Widget by default, can be converted to input }), }, "hidden": { "is_worker": ("BOOLEAN", {"default": False}), "worker_id": ("STRING", {"default": ""}), }, } RETURN_TYPES = ("INT",) RETURN_NAMES = ("seed",) FUNCTION = "distribute" CATEGORY = "utils" def distribute(self, seed, is_worker=False, worker_id=""): if not is_worker: # Master node: pass through original values debug_log(f"Distributor - Master: seed={seed}") return (seed,) else: # Worker node: apply offset based on worker index # Find worker index from enabled_worker_ids try: # Worker IDs are passed as "worker_0", "worker_1", etc. if worker_id.startswith("worker_"): worker_index = int(worker_id.split("_")[1]) else: # Fallback: try to parse as direct index worker_index = int(worker_id) offset = worker_index + 1 new_seed = seed + offset debug_log(f"Distributor - Worker {worker_index}: seed={seed} → {new_seed}") return (new_seed,) except (ValueError, IndexError) as e: debug_log(f"Distributor - Error parsing worker_id '{worker_id}': {e}") # Fallback: return original seed return (seed,) # Define ByPassTypeTuple for flexible return types class AnyType(str): def __ne__(self, __value: object) -> bool: return False any_type = AnyType("*") class ByPassTypeTuple(tuple): def __getitem__(self, index): if index > 0: index = 0 item = super().__getitem__(index) if isinstance(item, str): return any_type return item class ImageBatchDivider: @classmethod def INPUT_TYPES(s): return { "required": { "images": ("IMAGE",), "divide_by": ("INT", { "default": 2, "min": 1, "max": 10, "step": 1, "display": "number", "tooltip": "Number of parts to divide the batch into" }), } } RETURN_TYPES = ByPassTypeTuple(("IMAGE", )) # Flexible for variable outputs RETURN_NAMES = ByPassTypeTuple(tuple([f"batch_{i+1}" for i in range(10)])) FUNCTION = "divide_batch" OUTPUT_NODE = True CATEGORY = "image" def divide_batch(self, images, divide_by): import torch # Use divide_by directly total_splits = divide_by if total_splits > 10: total_splits = 10 # Cap to max # Get total number of frames total_frames = images.shape[0] frames_per_split = total_frames // total_splits remainder = total_frames % total_splits outputs = [] start_idx = 0 for i in range(total_splits): current_frames = frames_per_split + (1 if i < remainder else 0) end_idx = start_idx + current_frames split_frames = images[start_idx:end_idx] outputs.append(split_frames) start_idx = end_idx # Pad with empty tensors up to max (10) to match potential RETURN_TYPES len empty_shape = (1, images.shape[1], images.shape[2], images.shape[3]) if total_frames > 0 else (1, 512, 512, 3) empty_tensor = torch.zeros(empty_shape, dtype=images.dtype if total_frames > 0 else torch.float32, device=images.device if total_frames > 0 else 'cpu') while len(outputs) < 10: outputs.append(empty_tensor) return tuple(outputs) NODE_CLASS_MAPPINGS = { "DistributedCollector": DistributedCollectorNode, "DistributedSeed": DistributedSeed, "ImageBatchDivider": ImageBatchDivider } NODE_DISPLAY_NAME_MAPPINGS = { "DistributedCollector": "Distributed Collector", "DistributedSeed": "Distributed Seed", "ImageBatchDivider": "Image Batch Divider" }