SkinTokens / src /server /bpy_server.py
czpcf
remove proxy
1f4445b
Raw
History Blame Contribute Delete
7.7 kB
"""
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