| """ |
| 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 |
|
|
| |
| OP_TIMEOUT = 50 |
|
|
|
|
| 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) |
| |
| |
| 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. |
| """ |
| |
| os.makedirs(BPY_IPC_DIR, exist_ok=True) |
|
|
| |
| _write_pid() |
|
|
| |
| 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) |
|
|
| |
| while True: |
| |
| ready_files = sorted(glob.glob(os.path.join(BPY_IPC_DIR, "req_*.ready"))) |
| if not ready_files: |
| time.sleep(0.05) |
| continue |
|
|
| ready_file = ready_files[0] |
| base = ready_file[:-len(".ready")] |
| req_id = os.path.basename(base) |
|
|
| 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") |
|
|
| |
| if not os.path.exists(req_torch): |
| |
| _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 |
|
|
| |
| fatal = False |
| result = None |
| try: |
| |
| 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)}" |
|
|
| |
| signal.alarm(0) |
|
|
| except Exception as exc: |
| signal.alarm(0) |
| |
| 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) |
| |
| |
| fatal = True |
|
|
| |
| 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) |
|
|
| |
| _safe_remove(ready_file) |
| _safe_remove(req_torch) |
|
|
| |
| if fatal: |
| print("[bpy_server] exiting after error — restart for clean state", flush=True) |
| _cleanup_ipc_state() |
| sys.exit(1) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| |
| 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 |
|
|