Spaces:
Paused
Paused
File size: 7,695 Bytes
efd4665 1f4445b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | """
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
|