Files
workinf_Blender_Wasm/tools/web/server-operator-job.py
mes123456 e2e81c7c00
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
feat(web): classify and route complete Blender inventory
2026-09-06 21:10:31 +08:00

121 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""Bounded SERVER_NATIVE Blender operator runner.
The runner owns a temporary workspace, invokes Blender in background mode, and
binds the result to the request/source/property hashes. UI, window and
file-browser contexts are intentionally unavailable.
"""
import hashlib
import json
import os
import re
import resource
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
SCHEMA = 1
ROUTE = "SERVER_NATIVE"
SHA256 = re.compile(r"^[a-f0-9]{64}$")
JOB_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$")
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def digest(value):
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def validate_request(request):
if not isinstance(request, dict) or request.get("schemaVersion") != SCHEMA or request.get("route") != ROUTE:
raise ValueError("SERVER_OPERATOR_JOB_INVALID: schema or route mismatch")
required = {"schemaVersion", "route", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "operator", "properties", "propertiesSha256", "timeoutMs", "memoryBytes", "resultBinding", "requestSha256"}
if set(request) - required - {"context"}:
raise ValueError("SERVER_OPERATOR_JOB_INVALID: undeclared request field")
if not JOB_ID.fullmatch(str(request.get("jobId", ""))):
raise ValueError("SERVER_OPERATOR_JOB_INVALID: jobId is invalid")
if not all(isinstance(request.get(name), str) and SHA256.fullmatch(request[name]) for name in ("sourceBlendSha256", "propertiesSha256", "requestSha256")):
raise ValueError("SERVER_OPERATOR_JOB_INVALID: hash binding is invalid")
if not isinstance(request.get("sourceBlendByteLength"), int) or not 1 <= request["sourceBlendByteLength"] <= 512 * 1024 * 1024:
raise ValueError("SERVER_OPERATOR_JOB_INVALID: source blend exceeds the budget")
if not isinstance(request.get("sourceRevision"), int) or request["sourceRevision"] < 0:
raise ValueError("SERVER_OPERATOR_JOB_INVALID: sourceRevision is invalid")
if not isinstance(request.get("operator"), str) or not 1 <= len(request["operator"].encode()) <= 128 or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", request["operator"]):
raise ValueError("SERVER_OPERATOR_JOB_INVALID: operator is invalid")
props = request.get("properties")
if not isinstance(props, dict) or len(props) > 128:
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties exceed the budget")
for key, value in props.items():
if not KEY.fullmatch(key) or not ((isinstance(value, str) and len(value.encode()) <= 4096) or isinstance(value, (bool, int, float)) and not isinstance(value, bool) or isinstance(value, bool)):
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties must be typed scalar values")
if len(canonical(props).encode()) > 64 * 1024:
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties exceed the byte budget")
if not isinstance(request.get("blenderBuild"), dict) or not SHA256.fullmatch(str(request["blenderBuild"].get("buildSha256", ""))):
raise ValueError("SERVER_OPERATOR_JOB_INVALID: Blender build binding is invalid")
binding = request.get("resultBinding")
if not isinstance(binding, dict) or binding.get("kind") not in ("json", "snapshot", "scene-delta") or not KEY.fullmatch(str(binding.get("key", ""))):
raise ValueError("SERVER_OPERATOR_JOB_INVALID: result binding is invalid")
if request.get("context", "BACKGROUND") != "BACKGROUND":
raise ValueError("SERVER_OPERATOR_CONTEXT_BLOCKED: UI/window/file-browser contexts are unavailable")
if not isinstance(request.get("timeoutMs"), int) or not 1000 <= request["timeoutMs"] <= 600000:
raise ValueError("SERVER_OPERATOR_JOB_INVALID: timeout budget is invalid")
if not isinstance(request.get("memoryBytes"), int) or not 64 * 1024 * 1024 <= request["memoryBytes"] <= 4 * 1024 * 1024 * 1024:
raise ValueError("SERVER_OPERATOR_JOB_INVALID: memory budget is invalid")
return request
def _limit_memory(memory_bytes):
try:
resource.setrlimit(resource.RLIMIT_AS, (memory_bytes, memory_bytes))
except (OSError, ValueError):
pass
def _blender_script(request, source_path, result_path):
return """import bpy, json, os, traceback\nrequest=json.load(open(%r, encoding='utf-8'))\nresult={'schemaVersion':1,'route':'SERVER_NATIVE','jobId':request['jobId'],'status':'FAILED','sourceBlendSha256':request['sourceBlendSha256'],'sourceRevision':request['sourceRevision'],'propertiesSha256':request['propertiesSha256'],'requestSha256':request['requestSha256']}\ntry:\n if bpy.context.window is not None or bpy.context.area is not None or bpy.context.region is not None:\n raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED')\n source=%r\n if source:\n bpy.ops.wm.open_mainfile(filepath=source, load_ui=False)\n target=bpy.ops\n for part in request['operator'].split('.'):\n target=getattr(target, part)\n if not target.poll():\n raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED: operator poll failed in background context')\n result['result']=target(**request['properties'])\n result['status']='SUCCEEDED'\nexcept Exception as error:\n result['errorMessage']=str(error)\n result['traceback']=traceback.format_exc(limit=3)\nresult['resultSha256']='%s' % __import__('hashlib').sha256(json.dumps(result.get('result'), sort_keys=True, separators=(',',':')).encode()).hexdigest()\njson.dump(result, open(%r, 'w', encoding='utf-8'), sort_keys=True, separators=(',',':'))\n""" % (str(request.get("_requestPath", "")), source_path or "", digest("result"), str(result_path))
def run_server_operator_job(request, blender_binary="blender", source_blend_path=None, workspace_root=None):
validate_request(request)
with tempfile.TemporaryDirectory(prefix=f"server-operator-{request['jobId']}-", dir=workspace_root) as workspace:
root = Path(workspace)
request_path = root / "request.json"
result_path = root / "result.json"
request_with_path = dict(request, _requestPath=str(request_path))
request_path.write_text(canonical(request_with_path), encoding="utf-8")
script_path = root / "job.py"
script_path.write_text(_blender_script(request_with_path, source_blend_path, result_path), encoding="utf-8")
completed = subprocess.run([blender_binary, "--background", "--factory-startup", "--python", str(script_path)], cwd=str(root), timeout=request["timeoutMs"] / 1000, preexec_fn=lambda: _limit_memory(request["memoryBytes"]), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False)
if completed.returncode != 0 and not result_path.exists():
return dict(request, status="FAILED", resultSha256=digest(""), errorMessage=completed.stderr.decode(errors="replace")[-4096:])
result = json.loads(result_path.read_text(encoding="utf-8"))
result["resultSha256"] = digest(canonical(result.get("result")))
return result
def main():
envelope = json.load(sys.stdin)
action = envelope.get("action", "submit")
if action == "submit":
result = run_server_operator_job(envelope["request"], envelope.get("blenderBinary", "blender"), envelope.get("sourceBlendPath"), envelope.get("workspaceRoot"))
elif action == "cancel":
result = {"schemaVersion": SCHEMA, "route": ROUTE, "jobId": envelope["jobId"], "status": "CANCELLED", "requestSha256": envelope["requestSha256"], "resultSha256": digest("CANCELLED")}
elif action == "read":
result = json.loads(Path(envelope["resultPath"]).read_text(encoding="utf-8"))
else:
raise ValueError("SERVER_OPERATOR_JOB_INVALID: unknown action")
print(canonical(result))
if __name__ == "__main__":
try:
main()
except Exception as error:
print(json.dumps({"status": "FAILED", "errorMessage": str(error)}, separators=(",", ":")))
sys.exit(1)