""" File-based IPC bpy server. Instead of listening on a TCP port (which triggers HF Space proxy issues), this server communicates with the main process via the filesystem: - Requests are written to {tmpdir}/req_{uuid}.torch + .ready flag - Responses are written to {tmpdir}/resp_{uuid}.torch + .done flag All data (potentially large meshes / assets) lives on disk, avoiding HTTP body-size limits and proxy interference entirely. bpy MUST run in its own *process* (not thread) — this server is launched as a standalone subprocess via `bpy_server.py` at the repo root. **Crash recovery**: if any load/export/transfer operation raises an exception (or hangs beyond ``OP_TIMEOUT`` seconds), the server writes an error response to the client and then **exits immediately**. The client detects the dead server via the PID file and launches a fresh instance. This guarantees a clean bpy state for every request. """ import glob import os import signal import sys import time from .spec import bytes_to_object, object_to_bytes, BPY_IPC_DIR from ..rig_package.parser.bpy import BpyParser, transfer_rigging # --- per-operation watchdog (seconds) --------------------------------------- OP_TIMEOUT = 50 # max time for a single load / export / transfer (must be < client timeout of 60s) def _on_op_timeout(signum, frame): """Called by SIGALRM when an operation exceeds OP_TIMEOUT.""" print(f"[bpy_server] FATAL: operation timed out after {OP_TIMEOUT}s", flush=True) # _exit bypasses Python cleanup (bpy may be stuck); the parent will # detect the dead server via the missing PID and restart. os._exit(1) def run(): """ Main loop of the bpy worker process. It polls ``{BPY_IPC_DIR}/*.ready`` files, reads the companion ``.torch`` request, dispatches to the appropriate handler, writes the result to a ``resp_{uuid}.torch`` file, and signals completion with a ``resp_{uuid}.done`` flag. A ``.ready`` file inside ``BPY_IPC_DIR`` signals that the server has finished importing bpy and is ready to accept work. **On any dispatch error the process exits** so the next request always starts with a fresh bpy interpreter. """ # --- ensure the IPC directory exists ----------------------------------- os.makedirs(BPY_IPC_DIR, exist_ok=True) # --- write PID so other processes can quickly detect us ---------------- _write_pid() # --- signal readiness -------------------------------------------------- ready_flag = os.path.join(BPY_IPC_DIR, ".ready") with open(ready_flag, "w") as f: f.write("ready") print(f"[bpy_server] ready (pid={os.getpid()}, ipc_dir={BPY_IPC_DIR})", flush=True) # --- main loop --------------------------------------------------------- while True: # Look for any pending request (a `.ready` flag file). ready_files = sorted(glob.glob(os.path.join(BPY_IPC_DIR, "req_*.ready"))) if not ready_files: time.sleep(0.05) # 50 ms poll – negligible overhead vs bpy ops continue ready_file = ready_files[0] base = ready_file[:-len(".ready")] # strip .ready suffix req_id = os.path.basename(base) # e.g. "req_abc123" req_torch = base + ".torch" resp_torch = os.path.join(BPY_IPC_DIR, req_id.replace("req_", "resp_", 1) + ".torch") resp_done = os.path.join(BPY_IPC_DIR, req_id.replace("req_", "resp_", 1) + ".done") # --- read request -------------------------------------------------- if not os.path.exists(req_torch): # Stale / partial – clean up and move on. _safe_remove(ready_file) _safe_remove(req_torch) continue try: with open(req_torch, "rb") as f: raw = f.read() op, data = bytes_to_object(raw) except Exception as exc: print(f"[bpy_server] ERROR reading request {req_id}: {exc}", flush=True) _safe_remove(ready_file) _safe_remove(req_torch) continue # --- dispatch ------------------------------------------------------ fatal = False result = None # type: ignore try: # Arm watchdog – SIGALRM fires if the op hangs (e.g. bpy deadlock). signal.signal(signal.SIGALRM, _on_op_timeout) signal.alarm(OP_TIMEOUT) if op == "ping": result = "pong" print("[bpy_server] ping", flush=True) elif op == "load": print(f"[bpy_server] load: {data}", flush=True) asset = BpyParser.load(data) result = asset elif op == "export": print(f"[bpy_server] export: {data.get('filepath', '?')}", flush=True) BpyParser.export(**data) result = "ok" elif op == "transfer": print(f"[bpy_server] transfer: {data.get('target_path', '?')}", flush=True) transfer_rigging(**data) result = "ok" else: result = f"unsupported op: {str(op)}" # Disarm watchdog signal.alarm(0) except Exception as exc: signal.alarm(0) # disarm # Include context (file path for load, export path for export) ctx = "" if op == "load": ctx = f" (file: {data})" elif op == "export": ctx = f" (file: {data.get('filepath', '?') if isinstance(data, dict) else data})" elif op == "transfer": ctx = f" (target: {data.get('target_path', '?') if isinstance(data, dict) else data})" result = f"error: [{op}]{ctx} — {exc}" print(f"[bpy_server] ERROR {result}", flush=True) # bpy state may be corrupted — exit after writing response so # the next request gets a clean interpreter. fatal = True # --- write response ------------------------------------------------ try: with open(resp_torch, "wb") as f: print(f"[bpy_server] writing response to {resp_torch}", flush=True) f.write(object_to_bytes(result)) with open(resp_done, "w") as f: print(f"[bpy_server] writing done to {resp_done}", flush=True) f.write("done") except Exception as exc: print(f"[bpy_server] ERROR writing response {req_id}: {exc}", flush=True) # --- clean up request ---------------------------------------------- _safe_remove(ready_file) _safe_remove(req_torch) # --- exit on fatal error so client restarts us --------------------- if fatal: print("[bpy_server] exiting after error — restart for clean state", flush=True) _cleanup_ipc_state() sys.exit(1) # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- def _write_pid(): pid_file = os.path.join(BPY_IPC_DIR, ".pid") with open(pid_file, "w") as f: f.write(str(os.getpid())) def _cleanup_ipc_state(): """Remove our PID file so clients don't think we're still alive.""" pid_file = os.path.join(BPY_IPC_DIR, ".pid") _safe_remove(pid_file) # Also remove .ready since it's no longer valid ready_flag = os.path.join(BPY_IPC_DIR, ".ready") _safe_remove(ready_flag) def _safe_remove(path: str): """Remove a file if it exists; never raise.""" try: os.remove(path) except OSError: pass