@genex-ai/cli-demo 1.31.1 → 1.32.0-dev.650
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/blender-mcp-Q6PSFYSE.js +241 -0
- package/dist/blender-serve-BF4FZ55Z.js +244 -0
- package/dist/chunk-2COG4P3T.js +968 -0
- package/dist/chunk-HYCSNWYX.js +126 -0
- package/dist/index.js +4759 -2830
- package/package.json +3 -3
- package/templates/blender-service/demo/castle.py +117 -0
- package/templates/blender-service/gpu_witness.py +245 -0
- package/templates/blender-service/ops.py +225 -0
- package/templates/blender-service/pool.py +910 -0
- package/templates/blender-service/server.py +611 -0
- package/templates/blender-service/supervisor.py +221 -0
- package/templates/blender-service/views.py +281 -0
- package/templates/controllers/character/follow-camera.ts +16 -1
- package/templates/controllers/character/meshy/meshy-loader.ts +3 -2
- package/templates/controllers/quality/deadline.ts +117 -0
- package/templates/controllers/quality/pick-asset.ts +49 -16
- package/templates/controllers/shared/physics-world.ts +6 -4
- package/templates/skills/genex-ai-character/SKILL.md +77 -15
- package/templates/skills/genex-ai-menu/SKILL.md +15 -11
- package/templates/skills/genex-ai-model/SKILL.md +45 -7
- package/templates/skills/genex-ai-texture/SKILL.md +1 -1
- package/templates/skills/genex-ai-video/SKILL.md +75 -17
- package/templates/skills/genex-blender-scene/SKILL.md +243 -0
- package/templates/skills/genex-game-director/SKILL.md +112 -46
- package/templates/skills/genex-game-director/references/design-contract.md +13 -5
- package/templates/skills/genex-game-director/references/routing-map.md +30 -45
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-lane-card/SKILL.md +78 -0
- package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +21 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +17 -5
- package/templates/skills/genex-threejs-creatures/SKILL.md +8 -1
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +8 -0
- package/templates/skills/genex-threejs-game-ui/SKILL.md +55 -6
- package/templates/skills/genex-threejs-procedural-assets/SKILL.md +17 -10
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +12 -2
- package/templates/skills/genex-tool-audio/SKILL.md +3 -2
- package/templates/skills/genex-tool-character/SKILL.md +36 -5
- package/templates/skills/genex-tool-image/SKILL.md +4 -2
- package/templates/skills/genex-tool-model/SKILL.md +32 -6
- package/templates/skills/genex-tool-publish/SKILL.md +100 -0
- package/templates/skills/genex-tool-texture/SKILL.md +1 -1
- package/templates/skills/genex-tool-video/SKILL.md +28 -5
- package/templates/skills/genex-tool-workflow/SKILL.md +4 -1
- package/templates/skills/genex-updates/SKILL.md +1 -1
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
"""Multi-seat pod: one Blender process per session, behind one router.
|
|
2
|
+
|
|
3
|
+
:8080 router (this process, threaded) -> 127.0.0.1:8101+i seat children
|
|
4
|
+
each its own Blender, own scene
|
|
5
|
+
|
|
6
|
+
WHY THREADS ARE SAFE HERE AND NOT IN server.py. bpy is main-thread-only, which is
|
|
7
|
+
why `server.py` is a deliberately non-threading HTTPServer. This process contains
|
|
8
|
+
NO bpy — it is pure stdlib that spawns Blender as children and relays HTTP to
|
|
9
|
+
them. So a ThreadingHTTPServer is correct here, and it is what stops one slow
|
|
10
|
+
seat from blocking every other tenant. Never import bpy into this file.
|
|
11
|
+
|
|
12
|
+
ISOLATION is a process boundary, not a scene boundary. Two sessions never share
|
|
13
|
+
`bpy.data`, a NAMESPACE dict, a checkpoint, or a deadline file — the collisions
|
|
14
|
+
that made one-process-many-scenes unworkable. What they do share is the pod's
|
|
15
|
+
filesystem, network namespace and GPU, so this is a density mechanism, not a
|
|
16
|
+
security boundary against hostile code (see ops.py on why /exec cannot be
|
|
17
|
+
sandboxed in-process).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import secrets
|
|
25
|
+
import shutil
|
|
26
|
+
import signal
|
|
27
|
+
import socket
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
35
|
+
|
|
36
|
+
BLENDER = os.environ.get("GENEX_BLENDER_BIN", "blender")
|
|
37
|
+
SERVER = os.environ.get("GENEX_BLENDER_SERVER", "/app/server.py")
|
|
38
|
+
POD_SECRET = os.environ.get("GENEX_BLENDER_SECRET", "")
|
|
39
|
+
STATE_ROOT = os.environ.get("GENEX_BLENDER_STATE", "/run/genex/seats")
|
|
40
|
+
SEAT_BASE_PORT = int(os.environ.get("GENEX_BLENDER_SEAT_PORT", "8101"))
|
|
41
|
+
SEAT_UID_BASE = int(os.environ.get("GENEX_BLENDER_SEAT_UID", "10000"))
|
|
42
|
+
|
|
43
|
+
# MEASURED 2026-09-02 on an RTX 4090 pod, VmHWM (true peak RSS) per child:
|
|
44
|
+
# booted, empty scene 1,103 MB
|
|
45
|
+
# castle (46 obj, 1,440 tris) 1,313 MB
|
|
46
|
+
# heavy (12 obj, 612k tris, 8 x 4096^2 tex) 2,204 MB
|
|
47
|
+
# Four seats each holding the heavy scene summed to 8,640 MB; the router is
|
|
48
|
+
# 27 MB. The old guess of 2048 was ALREADY EXCEEDED by one moderately heavy
|
|
49
|
+
# scene, and the shape is the lesson: ~1.1 GB is Blender's own floor before any
|
|
50
|
+
# content, so 2048 left ~950 MB for the actual scene. 3072 keeps a heavy scene
|
|
51
|
+
# plus a 40 MB sheet transient inside the budget with margin -- and this is an
|
|
52
|
+
# OOM guard, where over-reserving is the safe direction.
|
|
53
|
+
PER_SEAT_MB = int(os.environ.get("GENEX_BLENDER_PER_SEAT_MB", "3072"))
|
|
54
|
+
# TWO, and the reason changed once it was measured. Memory was never the bind:
|
|
55
|
+
# four heavy seats used 29% of a 29 GB pod and 22% of VRAM. What binds is GPU
|
|
56
|
+
# COMPUTE. Four seats rendering at once ran 2.41x slower each (869 -> 2,094 ms
|
|
57
|
+
# lit), and four concurrent renders finished in 3,100 ms wall against 3,476 ms
|
|
58
|
+
# serial -- concurrency bought 11%, not 4x. So packing seats onto one card is
|
|
59
|
+
# not a render-throughput win; it pays only by multiplexing the idle time
|
|
60
|
+
# BETWEEN an agent's /exec calls. Seat count is therefore a DUTY-CYCLE choice,
|
|
61
|
+
# not a memory one, and raising it needs an observed duty cycle, not a bigger
|
|
62
|
+
# pod.
|
|
63
|
+
HARD_CAP = int(os.environ.get("GENEX_BLENDER_SEATS", "2"))
|
|
64
|
+
|
|
65
|
+
# A seat is BOOTING until its child answers /health. The GPU probe plus device
|
|
66
|
+
# enumeration costs seconds, and on a cold pod the EEVEE shader compile can cost
|
|
67
|
+
# 30-44s -- so the router NEVER waits for this. It answers 503 and the API turns
|
|
68
|
+
# that into a 202 the caller retries. A blocking wait would share one thread
|
|
69
|
+
# budget with live relays, which is the cross-tenant head-of-line blocking that
|
|
70
|
+
# process-per-seat exists to prevent.
|
|
71
|
+
SEAT_BOOT_BUDGET_S = float(os.environ.get("GENEX_BLENDER_SEAT_BOOT_S", "240"))
|
|
72
|
+
RELAY_TIMEOUT_S = float(os.environ.get("GENEX_BLENDER_RELAY_S", "930"))
|
|
73
|
+
MAX_BODY = 8 * 1024 * 1024
|
|
74
|
+
|
|
75
|
+
# --- the R2 checkpoint push ---------------------------------------------------
|
|
76
|
+
# THE ROUTER PUSHES, NOT THE CHILD, and that is a security choice before it is a
|
|
77
|
+
# threading one: `/exec` runs arbitrary Python inside the child as a uid-switched
|
|
78
|
+
# user, so an API-facing credential in that process is readable by the code it
|
|
79
|
+
# runs. The router holds the pod secret already and is the only process here
|
|
80
|
+
# that never imports bpy.
|
|
81
|
+
#
|
|
82
|
+
# The child's local checkpoint stays exactly as it was — synchronous, after every
|
|
83
|
+
# mutating call. This lane only ships that finished file, off the request path.
|
|
84
|
+
API_URL = os.environ.get("GENEX_API_URL", "").rstrip("/")
|
|
85
|
+
POD_NAME = os.environ.get("GENEX_BLENDER_POD_NAME", "")
|
|
86
|
+
# All three or nothing. On the local lane (`genex blender serve`) none of them is
|
|
87
|
+
# set and this whole feature is inert without a flag to forget.
|
|
88
|
+
PUSH_ENABLED = bool(API_URL and POD_NAME and POD_SECRET)
|
|
89
|
+
# Quiet-time before a push: the agent's think time is where these seconds hide.
|
|
90
|
+
CHECKPOINT_DEBOUNCE_S = float(os.environ.get("GENEX_BLENDER_PUSH_DEBOUNCE_S", "10"))
|
|
91
|
+
# ...but never let a busy seat go unsaved forever chasing quiet.
|
|
92
|
+
CHECKPOINT_MAX_DIRTY_S = float(os.environ.get("GENEX_BLENDER_PUSH_MAX_DIRTY_S", "60"))
|
|
93
|
+
PUSH_RETRY_S = 30.0
|
|
94
|
+
PUSH_TIMEOUT_S = 600.0
|
|
95
|
+
RESTORE_DEADLINE_S = float(os.environ.get("GENEX_BLENDER_RESTORE_S", "120"))
|
|
96
|
+
CLOSE_PUSH_TIMEOUT_S = 20.0
|
|
97
|
+
# Cloudflare's error 1010 refuses the default Python UA (measured on the pod
|
|
98
|
+
# proxy); the API host is behind Cloudflare too, so never rely on the default.
|
|
99
|
+
USER_AGENT = "genex-blender-pool"
|
|
100
|
+
# Bodies up to this size are read before a 503 so the poller keeps its socket.
|
|
101
|
+
READ_AHEAD_MAX = 256 * 1024
|
|
102
|
+
TERM_GRACE_S = 5.0
|
|
103
|
+
FAST_FAIL_S = 20.0
|
|
104
|
+
MAX_CONSECUTIVE_FAILURES = 5
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def log(msg):
|
|
108
|
+
sys.stderr.write("[pool] %s\n" % msg)
|
|
109
|
+
sys.stderr.flush()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cgroup_limit_mb():
|
|
113
|
+
"""The container's real memory ceiling.
|
|
114
|
+
|
|
115
|
+
/proc/meminfo reports the HOST's memory, not the container's, so sizing from
|
|
116
|
+
it over-admits by however much bigger the host is -- and the failure is an
|
|
117
|
+
OOM kill of somebody else's Blender. cgroup v2 first, then v1, then a
|
|
118
|
+
configured fallback; 'max' means unlimited, which in a pod means "we cannot
|
|
119
|
+
tell", so treat it as the fallback rather than as infinity.
|
|
120
|
+
"""
|
|
121
|
+
for path, div in (("/sys/fs/cgroup/memory.max", 1),
|
|
122
|
+
("/sys/fs/cgroup/memory/memory.limit_in_bytes", 1)):
|
|
123
|
+
try:
|
|
124
|
+
with open(path) as f:
|
|
125
|
+
raw = f.read().strip()
|
|
126
|
+
if raw == "max":
|
|
127
|
+
continue
|
|
128
|
+
n = int(raw) // (1024 * 1024)
|
|
129
|
+
# A v1 "unlimited" is a huge sentinel, not a real limit.
|
|
130
|
+
if 0 < n < 1024 * 1024:
|
|
131
|
+
return n
|
|
132
|
+
except (OSError, ValueError):
|
|
133
|
+
continue
|
|
134
|
+
return int(os.environ.get("GENEX_BLENDER_POD_MB", "0")) or None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Seat:
|
|
138
|
+
__slots__ = ("sid", "port", "uid", "proc", "token_sha", "child_secret", "run_dir",
|
|
139
|
+
"workspace", "checkpoint", "opened_at", "last_request_at", "failures",
|
|
140
|
+
"closing", "spawning", "_finish_close",
|
|
141
|
+
# the R2 push/restore half
|
|
142
|
+
"restore", "restoring", "seen_sig", "seen_at", "dirty_since",
|
|
143
|
+
"push_sig", "pushing", "push_next_at", "push_state")
|
|
144
|
+
|
|
145
|
+
def __init__(self, sid, port, uid):
|
|
146
|
+
self.sid = sid
|
|
147
|
+
self.port = port
|
|
148
|
+
self.uid = uid
|
|
149
|
+
self.proc = None
|
|
150
|
+
self.token_sha = None
|
|
151
|
+
# The secret the CHILD checks. Distinct from the seat token the caller
|
|
152
|
+
# presents: the router translates one into the other, so a tenant never
|
|
153
|
+
# holds a credential its own Blender would accept directly.
|
|
154
|
+
self.child_secret = None
|
|
155
|
+
self.run_dir = os.path.join(STATE_ROOT, sid, "run")
|
|
156
|
+
self.workspace = os.path.join(STATE_ROOT, sid, "workspace")
|
|
157
|
+
self.checkpoint = os.path.join(STATE_ROOT, sid, "checkpoint.blend")
|
|
158
|
+
self.opened_at = time.time()
|
|
159
|
+
self.last_request_at = time.time()
|
|
160
|
+
self.failures = 0
|
|
161
|
+
self.closing = False
|
|
162
|
+
# True from the moment the seat is committed to the table until _spawn
|
|
163
|
+
# has set `proc`. The watcher reads `proc is None` as "dead child", and
|
|
164
|
+
# between `self.seats[sid] = seat` (under the lock) and Popen returning
|
|
165
|
+
# (outside it -- makedirs, chown, a fork) that is exactly what it sees.
|
|
166
|
+
# Its 1 s scan landing in that window spawned a SECOND Blender on the
|
|
167
|
+
# same port. Found by code-read on the first real pod run.
|
|
168
|
+
self.spawning = False
|
|
169
|
+
self._finish_close = None
|
|
170
|
+
# The chain's saved scene, handed over by the API at open (it probes R2;
|
|
171
|
+
# the pod is never allowed to ask for an arbitrary chain's bytes).
|
|
172
|
+
self.restore = None
|
|
173
|
+
self.restoring = False
|
|
174
|
+
# (st_mtime_ns, st_size) of the checkpoint as last SEEN and as last
|
|
175
|
+
# PUSHED. A signature rather than a timestamp: a scene can be rewritten
|
|
176
|
+
# to the same size within one clock tick.
|
|
177
|
+
self.seen_sig = None
|
|
178
|
+
self.seen_at = 0.0
|
|
179
|
+
self.dirty_since = None
|
|
180
|
+
self.push_sig = None
|
|
181
|
+
self.pushing = False
|
|
182
|
+
self.push_next_at = 0.0
|
|
183
|
+
self.push_state = None
|
|
184
|
+
|
|
185
|
+
def alive(self):
|
|
186
|
+
return self.proc is not None and self.proc.poll() is None
|
|
187
|
+
|
|
188
|
+
def ready(self):
|
|
189
|
+
"""Answering, not merely running. The child binds its port only after
|
|
190
|
+
the GPU assertion and the checkpoint restore, so 'process exists' is not
|
|
191
|
+
the same question."""
|
|
192
|
+
if not self.alive():
|
|
193
|
+
return False
|
|
194
|
+
try:
|
|
195
|
+
with socket.create_connection(("127.0.0.1", self.port), timeout=0.4):
|
|
196
|
+
return True
|
|
197
|
+
except OSError:
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _sig(path):
|
|
202
|
+
"""(st_mtime_ns, st_size), or None when the file is not there yet."""
|
|
203
|
+
try:
|
|
204
|
+
st = os.stat(path)
|
|
205
|
+
except OSError:
|
|
206
|
+
return None
|
|
207
|
+
return (st.st_mtime_ns, st.st_size)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def restore_is_pointless(seat):
|
|
211
|
+
"""True when there is nothing to wipe or restore — the seat has no saved
|
|
212
|
+
scene to fetch, so an existing local checkpoint (a restart in place) is the
|
|
213
|
+
better source and must not be deleted."""
|
|
214
|
+
return seat.restore is None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class Pool:
|
|
218
|
+
def __init__(self):
|
|
219
|
+
self.seats = {}
|
|
220
|
+
self.lock = threading.Lock()
|
|
221
|
+
self.pod_mb = cgroup_limit_mb()
|
|
222
|
+
os.makedirs(STATE_ROOT, exist_ok=True)
|
|
223
|
+
|
|
224
|
+
def capacity(self):
|
|
225
|
+
"""Seats this pod will admit. The lower of the hard cap and what memory
|
|
226
|
+
allows, never just the cap."""
|
|
227
|
+
if not self.pod_mb:
|
|
228
|
+
return HARD_CAP
|
|
229
|
+
# Leave one seat's worth of headroom for the router, the shared shader
|
|
230
|
+
# cache and page cache. Without it the last admitted seat is the one
|
|
231
|
+
# that OOMs, and it takes a stranger's session with it.
|
|
232
|
+
by_mem = max(0, (self.pod_mb - PER_SEAT_MB) // PER_SEAT_MB)
|
|
233
|
+
return max(1, min(HARD_CAP, by_mem))
|
|
234
|
+
|
|
235
|
+
def open(self, sid, token, restore=None):
|
|
236
|
+
with self.lock:
|
|
237
|
+
existing = self.seats.get(sid)
|
|
238
|
+
if existing and not existing.closing:
|
|
239
|
+
# Idempotent: a retried openSeat must not spawn a second child.
|
|
240
|
+
existing.token_sha = hashlib.sha256(token.encode()).hexdigest()
|
|
241
|
+
return existing, False
|
|
242
|
+
if len(self.seats) >= self.capacity():
|
|
243
|
+
return None, False
|
|
244
|
+
used = {s.port for s in self.seats.values()}
|
|
245
|
+
port = next(p for p in range(SEAT_BASE_PORT, SEAT_BASE_PORT + HARD_CAP + 8)
|
|
246
|
+
if p not in used)
|
|
247
|
+
uid = SEAT_UID_BASE + (port - SEAT_BASE_PORT)
|
|
248
|
+
seat = Seat(sid, port, uid)
|
|
249
|
+
seat.restore = restore
|
|
250
|
+
seat.token_sha = hashlib.sha256(token.encode()).hexdigest()
|
|
251
|
+
# Flagged BEFORE it is visible in the table, so no scan can find it
|
|
252
|
+
# proc-less and unflagged.
|
|
253
|
+
seat.spawning = True
|
|
254
|
+
self.seats[sid] = seat
|
|
255
|
+
# A leftover directory from a wipe that never finished would make the
|
|
256
|
+
# restore below skip ("there is already a checkpoint") and the child boot
|
|
257
|
+
# from a stranger's scene — or from a stale one it then overwrites.
|
|
258
|
+
if not restore_is_pointless(seat):
|
|
259
|
+
shutil.rmtree(os.path.join(STATE_ROOT, sid), ignore_errors=True)
|
|
260
|
+
os.makedirs(seat.run_dir, exist_ok=True)
|
|
261
|
+
# THREADED, because _spawn may now download a checkpoint first and
|
|
262
|
+
# /pool/seats must still answer 201 `ready: false` immediately — the
|
|
263
|
+
# router never blocks, which is the property process-per-seat buys.
|
|
264
|
+
# `spawning` is already set under the lock, so the watcher skips the seat
|
|
265
|
+
# and a concurrent close hands the teardown to _spawn's finally.
|
|
266
|
+
threading.Thread(target=self._spawn, args=(seat,), daemon=True,
|
|
267
|
+
name="spawn-%s" % sid).start()
|
|
268
|
+
return seat, True
|
|
269
|
+
|
|
270
|
+
def _spawn(self, seat):
|
|
271
|
+
with self.lock:
|
|
272
|
+
# A close that arrived first wins; a seat no longer in the table is
|
|
273
|
+
# not respawned. Both checks under the lock, so the watcher's restart
|
|
274
|
+
# and a concurrent DELETE cannot interleave into an orphan.
|
|
275
|
+
if seat.closing or self.seats.get(seat.sid) is not seat:
|
|
276
|
+
return
|
|
277
|
+
seat.spawning = True
|
|
278
|
+
try:
|
|
279
|
+
if seat.restore:
|
|
280
|
+
self._restore(seat)
|
|
281
|
+
tmpdir = os.path.join(seat.workspace, "tmp")
|
|
282
|
+
for d in (seat.run_dir, seat.workspace, tmpdir):
|
|
283
|
+
os.makedirs(d, exist_ok=True)
|
|
284
|
+
# Per-seat ownership so one tenant cannot read another's checkpoint or
|
|
285
|
+
# scripts through the shared filesystem. Best-effort: a pod that is not
|
|
286
|
+
# running as root still isolates by process, just not by uid. TMPDIR is
|
|
287
|
+
# created ABOVE, before this pass — created after it (the first cut) it
|
|
288
|
+
# belonged to root, the child could not write it, and tempfile fell
|
|
289
|
+
# back silently to the shared /tmp, which is the isolation this exists
|
|
290
|
+
# to provide (review finding).
|
|
291
|
+
try:
|
|
292
|
+
os.chown(os.path.dirname(seat.run_dir), seat.uid, seat.uid)
|
|
293
|
+
for d in (seat.run_dir, seat.workspace, tmpdir):
|
|
294
|
+
os.chown(d, seat.uid, seat.uid)
|
|
295
|
+
except (OSError, AttributeError):
|
|
296
|
+
pass
|
|
297
|
+
env = dict(os.environ)
|
|
298
|
+
env.update({
|
|
299
|
+
"PORT": str(seat.port),
|
|
300
|
+
"GENEX_BLENDER_RUN_DIR": seat.run_dir,
|
|
301
|
+
"GENEX_BLENDER_CHECKPOINT": seat.checkpoint,
|
|
302
|
+
"HOME": seat.workspace,
|
|
303
|
+
"TMPDIR": os.path.join(seat.workspace, "tmp"),
|
|
304
|
+
# The seat's own secret. The pod secret authenticates the control
|
|
305
|
+
# plane; a seat token authenticates one session's routes, so seat A
|
|
306
|
+
# cannot drive seat B even though both are on this pod's loopback.
|
|
307
|
+
"GENEX_BLENDER_SECRET": secrets.token_hex(16),
|
|
308
|
+
# Children bind loopback only -- the router is the sole way in.
|
|
309
|
+
"GENEX_BLENDER_BIND": "127.0.0.1",
|
|
310
|
+
})
|
|
311
|
+
argv = [BLENDER, "--background", "--factory-startup", "-noaudio",
|
|
312
|
+
"--python-exit-code", "1", "--python", SERVER,
|
|
313
|
+
"--", "--port", str(seat.port), "--host", "127.0.0.1"]
|
|
314
|
+
extra = os.environ.get("GENEX_BLENDER_ARGS", "").split()
|
|
315
|
+
if extra:
|
|
316
|
+
# Same lever supervisor.spawn honours (e.g. --gpu-backend vulkan);
|
|
317
|
+
# the pooled path silently dropped it (review finding).
|
|
318
|
+
argv[1:1] = extra
|
|
319
|
+
kwargs = {}
|
|
320
|
+
if os.getuid() == 0:
|
|
321
|
+
kwargs["user"] = seat.uid
|
|
322
|
+
kwargs["group"] = seat.uid
|
|
323
|
+
seat.child_secret = env["GENEX_BLENDER_SECRET"]
|
|
324
|
+
seat.proc = subprocess.Popen(argv, env=env, **kwargs)
|
|
325
|
+
log("seat %s -> pid %s port %s uid %s" % (seat.sid, seat.proc.pid, seat.port, seat.uid))
|
|
326
|
+
finally:
|
|
327
|
+
# Cleared on EVERY exit. A raise here (Popen failing) must not
|
|
328
|
+
# leave a seat the watcher will skip forever.
|
|
329
|
+
seat.spawning = False
|
|
330
|
+
finish = seat._finish_close
|
|
331
|
+
if seat.closing and finish is not None:
|
|
332
|
+
# A DELETE landed while Popen was in flight: close() left the
|
|
333
|
+
# stop-and-wipe for us, because it could not see the child yet.
|
|
334
|
+
seat._finish_close = None
|
|
335
|
+
threading.Thread(target=finish, daemon=True, name="close-%s" % seat.sid).start()
|
|
336
|
+
|
|
337
|
+
def _restore(self, seat):
|
|
338
|
+
"""Download the chain's saved scene, then arm the CHILD's own restore.
|
|
339
|
+
|
|
340
|
+
Zero changes to server.py: `_restore_if_recovering()` already loads
|
|
341
|
+
`GENEX_BLENDER_CHECKPOINT` at boot when `run_dir/restart-reason` exists,
|
|
342
|
+
and reports it as `sceneRestored` on the first response. Writing that
|
|
343
|
+
file is the whole integration.
|
|
344
|
+
|
|
345
|
+
Runs in the router BEFORE Popen, so the child's own BOOT_BUDGET never
|
|
346
|
+
pays for the download.
|
|
347
|
+
"""
|
|
348
|
+
url = seat.restore.get("url")
|
|
349
|
+
want = int(seat.restore.get("bytes") or 0)
|
|
350
|
+
if not url or want <= 0:
|
|
351
|
+
return
|
|
352
|
+
if os.path.exists(seat.checkpoint):
|
|
353
|
+
# A restart in place: the local file is at least as new as R2's.
|
|
354
|
+
return
|
|
355
|
+
part = seat.checkpoint + ".part"
|
|
356
|
+
seat.restoring = True
|
|
357
|
+
started = time.time()
|
|
358
|
+
try:
|
|
359
|
+
os.makedirs(os.path.dirname(seat.checkpoint), exist_ok=True)
|
|
360
|
+
req = urllib.request.Request(url, method="GET")
|
|
361
|
+
req.add_header("User-Agent", USER_AGENT)
|
|
362
|
+
got = 0
|
|
363
|
+
with urllib.request.urlopen(req, timeout=RESTORE_DEADLINE_S) as r, open(part, "wb") as f:
|
|
364
|
+
while True:
|
|
365
|
+
if seat.closing:
|
|
366
|
+
raise OSError("seat closed during restore")
|
|
367
|
+
if time.time() - started > RESTORE_DEADLINE_S:
|
|
368
|
+
raise OSError("restore exceeded %.0fs" % RESTORE_DEADLINE_S)
|
|
369
|
+
chunk = r.read(1024 * 1024)
|
|
370
|
+
if not chunk:
|
|
371
|
+
break
|
|
372
|
+
f.write(chunk)
|
|
373
|
+
got += len(chunk)
|
|
374
|
+
if got != want:
|
|
375
|
+
raise OSError("restore truncated: %d of %d bytes" % (got, want))
|
|
376
|
+
os.replace(part, seat.checkpoint)
|
|
377
|
+
try:
|
|
378
|
+
os.chown(seat.checkpoint, seat.uid, seat.uid)
|
|
379
|
+
except (OSError, AttributeError):
|
|
380
|
+
pass
|
|
381
|
+
with open(os.path.join(seat.run_dir, "restart-reason"), "w") as f:
|
|
382
|
+
json.dump({"at": time.time(), "reason": "restored_from_r2", "bytes": got}, f)
|
|
383
|
+
# The bytes we just wrote are R2's own — pushing them straight back
|
|
384
|
+
# would be a round trip for nothing.
|
|
385
|
+
seat.push_sig = _sig(seat.checkpoint)
|
|
386
|
+
log("seat %s restored %d bytes from R2" % (seat.sid, got))
|
|
387
|
+
except Exception as e: # noqa: BLE001 - a failed restore must still boot
|
|
388
|
+
log("seat %s restore failed (%s) — booting empty" % (seat.sid, e))
|
|
389
|
+
for path in (part, seat.checkpoint):
|
|
390
|
+
try:
|
|
391
|
+
os.unlink(path)
|
|
392
|
+
except OSError:
|
|
393
|
+
pass
|
|
394
|
+
try:
|
|
395
|
+
with open(os.path.join(seat.run_dir, "restart-reason"), "w") as f:
|
|
396
|
+
json.dump({"at": time.time(), "reason": "restore_failed", "note": str(e)[:200]}, f)
|
|
397
|
+
except OSError:
|
|
398
|
+
pass
|
|
399
|
+
finally:
|
|
400
|
+
seat.restoring = False
|
|
401
|
+
|
|
402
|
+
def _maybe_push(self, seat, now):
|
|
403
|
+
"""One seat's turn in the watcher: has its checkpoint changed, settled,
|
|
404
|
+
and not yet been pushed?"""
|
|
405
|
+
if not PUSH_ENABLED or seat.closing or seat.spawning or seat.pushing:
|
|
406
|
+
return
|
|
407
|
+
sig = _sig(seat.checkpoint)
|
|
408
|
+
if sig is None or sig == seat.push_sig:
|
|
409
|
+
return
|
|
410
|
+
if sig != seat.seen_sig:
|
|
411
|
+
seat.seen_sig = sig
|
|
412
|
+
seat.seen_at = now
|
|
413
|
+
if seat.dirty_since is None:
|
|
414
|
+
seat.dirty_since = now
|
|
415
|
+
return
|
|
416
|
+
if now < seat.push_next_at:
|
|
417
|
+
return
|
|
418
|
+
quiet = now - seat.seen_at >= CHECKPOINT_DEBOUNCE_S
|
|
419
|
+
stale = seat.dirty_since is not None and now - seat.dirty_since >= CHECKPOINT_MAX_DIRTY_S
|
|
420
|
+
if not (quiet or stale):
|
|
421
|
+
return
|
|
422
|
+
seat.pushing = True
|
|
423
|
+
threading.Thread(target=self._push, args=(seat, sig), daemon=True,
|
|
424
|
+
name="push-%s" % seat.sid).start()
|
|
425
|
+
|
|
426
|
+
def _push(self, seat, sig, timeout=PUSH_TIMEOUT_S):
|
|
427
|
+
"""Ask the API for a URL, then PUT the file.
|
|
428
|
+
|
|
429
|
+
The API mints per push because the presigned PUT signs the exact byte
|
|
430
|
+
length — a URL handed over at seat-open would be wrong the moment the
|
|
431
|
+
scene changed size.
|
|
432
|
+
"""
|
|
433
|
+
try:
|
|
434
|
+
# Blender saves via write-then-rename, so a file whose signature is
|
|
435
|
+
# unchanged after the open cannot be a half-written one.
|
|
436
|
+
with open(seat.checkpoint, "rb") as f:
|
|
437
|
+
st = os.fstat(f.fileno())
|
|
438
|
+
if (st.st_mtime_ns, st.st_size) != sig:
|
|
439
|
+
return
|
|
440
|
+
body = f.read()
|
|
441
|
+
payload = json.dumps({"sessionId": seat.sid, "bytes": len(body)}).encode()
|
|
442
|
+
req = urllib.request.Request(
|
|
443
|
+
API_URL + "/api/internal/blender/checkpoint-put", data=payload, method="POST")
|
|
444
|
+
req.add_header("Content-Type", "application/json")
|
|
445
|
+
req.add_header("User-Agent", USER_AGENT)
|
|
446
|
+
req.add_header("x-genex-pod", POD_NAME)
|
|
447
|
+
req.add_header("x-genex-pod-secret", POD_SECRET)
|
|
448
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
449
|
+
grant = json.load(r)
|
|
450
|
+
put = urllib.request.Request(grant["url"], data=body, method="PUT")
|
|
451
|
+
for k, v in (grant.get("headers") or {}).items():
|
|
452
|
+
put.add_header(k, v)
|
|
453
|
+
put.add_header("User-Agent", USER_AGENT)
|
|
454
|
+
with urllib.request.urlopen(put, timeout=timeout) as r:
|
|
455
|
+
if r.status not in (200, 201, 204):
|
|
456
|
+
raise OSError("R2 answered %s" % r.status)
|
|
457
|
+
seat.push_sig = sig
|
|
458
|
+
seat.dirty_since = None
|
|
459
|
+
seat.push_state = {"pushedAt": time.time() * 1000.0, "bytes": len(body), "error": None}
|
|
460
|
+
except urllib.error.HTTPError as e:
|
|
461
|
+
# 409 superseded is not a failure: a newer episode of this chain owns
|
|
462
|
+
# the scene now, and this seat is a zombie that must stop writing.
|
|
463
|
+
detail = "%s %s" % (e.code, e.reason)
|
|
464
|
+
if e.code == 409:
|
|
465
|
+
seat.push_sig = sig
|
|
466
|
+
log("seat %s push superseded — a newer episode owns the chain" % seat.sid)
|
|
467
|
+
seat.push_state = {"pushedAt": (seat.push_state or {}).get("pushedAt"),
|
|
468
|
+
"bytes": (seat.push_state or {}).get("bytes"), "error": detail}
|
|
469
|
+
seat.push_next_at = time.time() + PUSH_RETRY_S
|
|
470
|
+
except Exception as e: # noqa: BLE001 - a failed push must never kill a seat
|
|
471
|
+
seat.push_state = {"pushedAt": (seat.push_state or {}).get("pushedAt"),
|
|
472
|
+
"bytes": (seat.push_state or {}).get("bytes"), "error": str(e)[:200]}
|
|
473
|
+
seat.push_next_at = time.time() + PUSH_RETRY_S
|
|
474
|
+
log("seat %s push failed: %s" % (seat.sid, e))
|
|
475
|
+
finally:
|
|
476
|
+
seat.pushing = False
|
|
477
|
+
|
|
478
|
+
def close(self, sid, wipe=True, push=True):
|
|
479
|
+
with self.lock:
|
|
480
|
+
seat = self.seats.get(sid)
|
|
481
|
+
if not seat:
|
|
482
|
+
return False
|
|
483
|
+
# `closing` is set UNDER the lock and BEFORE the seat leaves the
|
|
484
|
+
# table. The first cut popped first and flagged second, and the
|
|
485
|
+
# watcher's scan — which reads the flag once off a snapshot — could
|
|
486
|
+
# land in between, see a dead child, and respawn a seat that was no
|
|
487
|
+
# longer in the table: an orphan Blender on a port the next tenant
|
|
488
|
+
# then could not bind (review finding).
|
|
489
|
+
seat.closing = True
|
|
490
|
+
self.seats.pop(sid, None)
|
|
491
|
+
spawning = seat.spawning
|
|
492
|
+
|
|
493
|
+
def finish():
|
|
494
|
+
_stop(seat.proc, "seat %s closed" % sid)
|
|
495
|
+
# THE LAST SAVE, before the local copy is deleted. Bounded, because
|
|
496
|
+
# the API is waiting on nothing here but the row is already closing;
|
|
497
|
+
# the route allows a short grace after close for exactly this.
|
|
498
|
+
if push and PUSH_ENABLED:
|
|
499
|
+
sig = _sig(seat.checkpoint)
|
|
500
|
+
if sig is not None and sig != seat.push_sig:
|
|
501
|
+
seat.pushing = True
|
|
502
|
+
self._push(seat, sig, timeout=CLOSE_PUSH_TIMEOUT_S)
|
|
503
|
+
if wipe:
|
|
504
|
+
# The push above is what keeps the scene; the pod's copy is
|
|
505
|
+
# scratch and must not outlive the seat, or the next tenant to
|
|
506
|
+
# land on this port inherits it.
|
|
507
|
+
shutil.rmtree(os.path.join(STATE_ROOT, sid), ignore_errors=True)
|
|
508
|
+
|
|
509
|
+
# A Popen in flight finishes the close itself (see _spawn's finally):
|
|
510
|
+
# stopping now would find proc None and leave the child it is about to
|
|
511
|
+
# create running with nobody to stop it.
|
|
512
|
+
if not spawning:
|
|
513
|
+
# Asynchronous, and the reason is the router's own thread budget: a
|
|
514
|
+
# close during a running /exec cannot interrupt bpy, so _stop burns
|
|
515
|
+
# the whole TERM_GRACE — five seconds a router thread must not spend
|
|
516
|
+
# holding a control-plane request open (review finding).
|
|
517
|
+
threading.Thread(target=finish, daemon=True, name="close-%s" % sid).start()
|
|
518
|
+
seat._finish_close = finish if spawning else None
|
|
519
|
+
return True
|
|
520
|
+
|
|
521
|
+
def get(self, sid):
|
|
522
|
+
with self.lock:
|
|
523
|
+
return self.seats.get(sid)
|
|
524
|
+
|
|
525
|
+
def status(self, with_seats=False):
|
|
526
|
+
with self.lock:
|
|
527
|
+
seats = list(self.seats.values())
|
|
528
|
+
# ready() opens a loopback socket per call: once per seat, not twice.
|
|
529
|
+
probed = [(s, s.alive(), s.ready()) for s in seats]
|
|
530
|
+
out = {
|
|
531
|
+
"capacity": self.capacity(),
|
|
532
|
+
"hardCap": HARD_CAP,
|
|
533
|
+
"podMemoryMb": self.pod_mb,
|
|
534
|
+
"perSeatMb": PER_SEAT_MB,
|
|
535
|
+
"open": len(seats),
|
|
536
|
+
# Counts only on the unauthenticated /health. Session ids appear ONLY
|
|
537
|
+
# on the authenticated /pool/status (with_seats), where the API reads
|
|
538
|
+
# per-seat readiness — what lets it bill a seat from the moment its
|
|
539
|
+
# Blender answers and never for a child that only crash-looped.
|
|
540
|
+
"ready": sum(1 for _, _, r in probed if r),
|
|
541
|
+
"booting": sum(1 for _, a, r in probed if a and not r),
|
|
542
|
+
}
|
|
543
|
+
if with_seats:
|
|
544
|
+
seats = {}
|
|
545
|
+
for st, a, r in probed:
|
|
546
|
+
entry = {"ready": r, "alive": a}
|
|
547
|
+
# Only when this pod actually has a durability half — an older
|
|
548
|
+
# API reads an absent key as "no report", which is the truth.
|
|
549
|
+
if PUSH_ENABLED or st.push_state or st.restoring:
|
|
550
|
+
cp = dict(st.push_state or {"pushedAt": None, "bytes": None, "error": None})
|
|
551
|
+
if st.restoring:
|
|
552
|
+
cp["restoring"] = True
|
|
553
|
+
entry["checkpoint"] = cp
|
|
554
|
+
seats[st.sid] = entry
|
|
555
|
+
out["seats"] = seats
|
|
556
|
+
return out
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _stop(proc, why):
|
|
560
|
+
if proc is None:
|
|
561
|
+
return
|
|
562
|
+
log("stopping: %s" % why)
|
|
563
|
+
try:
|
|
564
|
+
proc.terminate()
|
|
565
|
+
except OSError:
|
|
566
|
+
pass
|
|
567
|
+
deadline = time.time() + TERM_GRACE_S
|
|
568
|
+
while time.time() < deadline:
|
|
569
|
+
if proc.poll() is not None:
|
|
570
|
+
return
|
|
571
|
+
time.sleep(0.1)
|
|
572
|
+
try:
|
|
573
|
+
proc.kill()
|
|
574
|
+
except OSError:
|
|
575
|
+
pass
|
|
576
|
+
proc.wait()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
POOL = Pool()
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _watch_seats(stop_evt):
|
|
583
|
+
"""One thread for the whole pod: enforce per-seat deadlines and restart dead
|
|
584
|
+
children. Per-seat threads would be tidier and would also mean N threads
|
|
585
|
+
blocked in waitpid; one scanner is enough at these seat counts."""
|
|
586
|
+
while not stop_evt.is_set():
|
|
587
|
+
now = time.time()
|
|
588
|
+
for sid, seat in list(POOL.seats.items()):
|
|
589
|
+
if seat.closing or seat.spawning:
|
|
590
|
+
continue
|
|
591
|
+
# Ship the finished checkpoint, off the request path. Cheap: one
|
|
592
|
+
# stat per seat per second, and a thread only when it settled.
|
|
593
|
+
try:
|
|
594
|
+
POOL._maybe_push(seat, now)
|
|
595
|
+
except Exception as e: # noqa: BLE001 - never let the watcher die
|
|
596
|
+
log("seat %s push scheduling failed: %s" % (sid, e))
|
|
597
|
+
# A runaway request: the child writes a deadline file before every
|
|
598
|
+
# request and clears it after. Same protocol supervisor.py uses --
|
|
599
|
+
# the reason is unchanged, bpy cannot be interrupted in-process.
|
|
600
|
+
try:
|
|
601
|
+
with open(os.path.join(seat.run_dir, "deadline")) as f:
|
|
602
|
+
d = json.load(f)
|
|
603
|
+
if time.time() > float(d["deadline"]):
|
|
604
|
+
log("seat %s exceeded its %ss budget on %s"
|
|
605
|
+
% (sid, d.get("budget"), d.get("route")))
|
|
606
|
+
with open(os.path.join(seat.run_dir, "restart-reason"), "w") as f:
|
|
607
|
+
json.dump({"at": time.time(), "reason": "deadline_exceeded",
|
|
608
|
+
"route": d.get("route")}, f)
|
|
609
|
+
_stop(seat.proc, "runaway in seat %s" % sid)
|
|
610
|
+
try:
|
|
611
|
+
os.unlink(os.path.join(seat.run_dir, "deadline"))
|
|
612
|
+
except OSError:
|
|
613
|
+
pass
|
|
614
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
615
|
+
pass
|
|
616
|
+
# A dead child is restarted in place: the seat, its port and its
|
|
617
|
+
# checkpoint survive, so the session keeps its scene. Only a crash
|
|
618
|
+
# LOOP gives up, and it closes one seat rather than the pod --
|
|
619
|
+
# the whole reason this is not supervisor.py.
|
|
620
|
+
if not seat.alive():
|
|
621
|
+
ran = time.time() - seat.opened_at
|
|
622
|
+
seat.failures = seat.failures + 1 if ran < FAST_FAIL_S else 1
|
|
623
|
+
if seat.failures >= MAX_CONSECUTIVE_FAILURES:
|
|
624
|
+
log("seat %s failed %d times fast — closing it, pod stays up"
|
|
625
|
+
% (sid, seat.failures))
|
|
626
|
+
# push=False: this seat's checkpoint may be the very file
|
|
627
|
+
# that kills the child at load, and R2 already holds the
|
|
628
|
+
# last version that worked.
|
|
629
|
+
POOL.close(sid, push=False)
|
|
630
|
+
continue
|
|
631
|
+
rc = seat.proc.poll() if seat.proc else None
|
|
632
|
+
log("seat %s died (rc=%s), restarting (%d)" % (sid, rc, seat.failures))
|
|
633
|
+
# THE RESTORE KEYS ON THIS FILE. server.py restores the checkpoint
|
|
634
|
+
# only when `restart-reason` exists at boot; without it the child
|
|
635
|
+
# comes up EMPTY and the next /exec checkpoints that emptiness over
|
|
636
|
+
# the good scene. The first cut wrote it only for deadline kills, so
|
|
637
|
+
# every crash — OOM, a segfault in bpy, a script's sys.exit() —
|
|
638
|
+
# silently lost the session's work (review finding). supervisor.py
|
|
639
|
+
# has always written it on every exit path; this now matches.
|
|
640
|
+
try:
|
|
641
|
+
with open(os.path.join(seat.run_dir, "restart-reason"), "w") as f:
|
|
642
|
+
json.dump({"at": time.time(), "code": rc,
|
|
643
|
+
"reason": "unexpected_clean_exit" if rc == 0 else "crashed"}, f)
|
|
644
|
+
except OSError:
|
|
645
|
+
pass
|
|
646
|
+
seat.opened_at = time.time()
|
|
647
|
+
POOL._spawn(seat)
|
|
648
|
+
stop_evt.wait(1.0)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
class Router(BaseHTTPRequestHandler):
|
|
652
|
+
protocol_version = "HTTP/1.1"
|
|
653
|
+
server_version = "genex-blender-pool/0"
|
|
654
|
+
timeout = 30.0
|
|
655
|
+
|
|
656
|
+
def log_message(self, fmt, *args):
|
|
657
|
+
sys.stderr.write("[router] %s\n" % (fmt % args))
|
|
658
|
+
|
|
659
|
+
def handle_one_request(self):
|
|
660
|
+
# One handler instance serves EVERY request on a kept-alive socket, so
|
|
661
|
+
# per-request state is reset here, before parse_request runs.
|
|
662
|
+
self._body_consumed = False
|
|
663
|
+
try:
|
|
664
|
+
super().handle_one_request()
|
|
665
|
+
except (TimeoutError, OSError):
|
|
666
|
+
self.close_connection = True
|
|
667
|
+
|
|
668
|
+
def _send(self, code, payload):
|
|
669
|
+
self._respond(code, json.dumps(payload).encode("utf-8"))
|
|
670
|
+
|
|
671
|
+
def _respond(self, code, body, content_type="application/json"):
|
|
672
|
+
"""THE response writer. Both _send and the relay's success path come
|
|
673
|
+
through here, so the keep-alive guard below lives in the writer rather
|
|
674
|
+
than in the order of calls that precede it (review finding)."""
|
|
675
|
+
self.send_response(code)
|
|
676
|
+
self.send_header("Content-Type", content_type)
|
|
677
|
+
self.send_header("Content-Length", str(len(body)))
|
|
678
|
+
if not self._body_consumed and self._announced_body():
|
|
679
|
+
# THE KEEP-ALIVE DESYNC, closed by construction rather than per branch.
|
|
680
|
+
#
|
|
681
|
+
# An early answer that never read the request body leaves those bytes
|
|
682
|
+
# in the socket, and on a kept-alive connection they are parsed as the
|
|
683
|
+
# NEXT request line -- measured on the first real pod: a 404 or 401
|
|
684
|
+
# followed by "400 Bad request syntax" on the same socket. The 503
|
|
685
|
+
# seat_not_ready branch had the same shape, and it is precisely the
|
|
686
|
+
# one the API retries on. Every pooled client (undici keep-alive,
|
|
687
|
+
# requests.Session, Go's default transport) hits it; urllib hid it
|
|
688
|
+
# only by never reusing a connection.
|
|
689
|
+
#
|
|
690
|
+
# So: any response written while an announced body is still unread
|
|
691
|
+
# closes the connection. send_header("Connection","close") also sets
|
|
692
|
+
# close_connection, which is what stops the handler loop. Reading the
|
|
693
|
+
# body instead would be worse here -- most of these branches answer an
|
|
694
|
+
# UNAUTHENTICATED caller, who does not get to make us read MAX_BODY.
|
|
695
|
+
self.send_header("Connection", "close")
|
|
696
|
+
self.end_headers()
|
|
697
|
+
try:
|
|
698
|
+
self.wfile.write(body)
|
|
699
|
+
except OSError:
|
|
700
|
+
pass
|
|
701
|
+
|
|
702
|
+
def _content_length(self):
|
|
703
|
+
"""Parsed and sign-checked. int("-1") passes a `> MAX_BODY` test and turns
|
|
704
|
+
rfile.read(n) into a read-to-EOF that parks a router thread for the whole
|
|
705
|
+
socket timeout (review finding)."""
|
|
706
|
+
raw = self.headers.get("Content-Length")
|
|
707
|
+
if not raw:
|
|
708
|
+
return 0
|
|
709
|
+
try:
|
|
710
|
+
n = int(raw)
|
|
711
|
+
except ValueError:
|
|
712
|
+
raise ValueError("bad content-length")
|
|
713
|
+
if n < 0:
|
|
714
|
+
raise ValueError("negative content-length")
|
|
715
|
+
return n
|
|
716
|
+
|
|
717
|
+
def _announced_body(self):
|
|
718
|
+
"""Did the request declare bytes we have not taken off the socket?"""
|
|
719
|
+
if self.headers.get("Transfer-Encoding"):
|
|
720
|
+
return True
|
|
721
|
+
try:
|
|
722
|
+
return self._content_length() != 0
|
|
723
|
+
except ValueError:
|
|
724
|
+
return True
|
|
725
|
+
|
|
726
|
+
def _pod_authed(self):
|
|
727
|
+
if not POD_SECRET:
|
|
728
|
+
return True
|
|
729
|
+
return hmac.compare_digest(self.headers.get("x-genex-internal", ""), POD_SECRET)
|
|
730
|
+
|
|
731
|
+
def _read_body(self):
|
|
732
|
+
n = self._content_length()
|
|
733
|
+
if n > MAX_BODY:
|
|
734
|
+
raise ValueError("body too large: %d" % n)
|
|
735
|
+
if self.headers.get("Transfer-Encoding"):
|
|
736
|
+
# Refused at the door rather than relayed: without a length we
|
|
737
|
+
# cannot enforce MAX_BODY, and the child would be the one to find out.
|
|
738
|
+
# NOT marked consumed: the 413 that follows must close the socket.
|
|
739
|
+
raise ValueError("chunked bodies are not accepted")
|
|
740
|
+
data = self.rfile.read(n) if n else b""
|
|
741
|
+
self._body_consumed = True
|
|
742
|
+
return data
|
|
743
|
+
|
|
744
|
+
def do_GET(self):
|
|
745
|
+
self._dispatch("GET")
|
|
746
|
+
|
|
747
|
+
def do_DELETE(self):
|
|
748
|
+
self._dispatch("DELETE")
|
|
749
|
+
|
|
750
|
+
def do_POST(self):
|
|
751
|
+
self._dispatch("POST")
|
|
752
|
+
|
|
753
|
+
def _dispatch(self, method):
|
|
754
|
+
route = (self.path or "").split("?")[0]
|
|
755
|
+
if route == "/health":
|
|
756
|
+
# Unauthenticated, and counts only — never session ids. This is what
|
|
757
|
+
# the container healthcheck and the API's liveness probe read.
|
|
758
|
+
return self._send(200, {"ok": True, "pool": POOL.status()})
|
|
759
|
+
if route.startswith("/pool/"):
|
|
760
|
+
if not self._pod_authed():
|
|
761
|
+
return self._send(401, {"error": "unauthorized"})
|
|
762
|
+
return self._control(method, route)
|
|
763
|
+
if route.startswith("/s/"):
|
|
764
|
+
return self._relay(method, route)
|
|
765
|
+
self._send(404, {"error": "not_found"})
|
|
766
|
+
|
|
767
|
+
def _control(self, method, route):
|
|
768
|
+
try:
|
|
769
|
+
raw = self._read_body()
|
|
770
|
+
except ValueError as e:
|
|
771
|
+
return self._send(413, {"error": "bad_body", "detail": str(e)})
|
|
772
|
+
body = json.loads(raw) if raw else {}
|
|
773
|
+
if route == "/pool/status":
|
|
774
|
+
return self._send(200, POOL.status(with_seats=True))
|
|
775
|
+
if route == "/pool/seats" and method == "POST":
|
|
776
|
+
sid = str(body.get("sessionId") or "").strip()
|
|
777
|
+
token = str(body.get("token") or "")
|
|
778
|
+
if not sid or not token:
|
|
779
|
+
return self._send(400, {"error": "sessionId and token required"})
|
|
780
|
+
# The chain's saved scene, resolved by the API (which knows the
|
|
781
|
+
# chain) and handed over here. Validated, not trusted: a url and a
|
|
782
|
+
# positive size, nothing else, and an unusable value is simply
|
|
783
|
+
# ignored — a seat that boots empty beats a seat that fails to open.
|
|
784
|
+
restore = body.get("restore")
|
|
785
|
+
if isinstance(restore, dict):
|
|
786
|
+
url = restore.get("url")
|
|
787
|
+
nbytes = restore.get("bytes")
|
|
788
|
+
ok = (isinstance(url, str) and url.startswith(("https://", "http://"))
|
|
789
|
+
and isinstance(nbytes, int) and nbytes > 0)
|
|
790
|
+
restore = {"url": url, "bytes": nbytes} if ok else None
|
|
791
|
+
else:
|
|
792
|
+
restore = None
|
|
793
|
+
seat, created = POOL.open(sid, token, restore=restore)
|
|
794
|
+
if seat is None:
|
|
795
|
+
# The pod is full. The API allocates across pods and creates
|
|
796
|
+
# another; this is not the place to queue.
|
|
797
|
+
return self._send(409, {"error": "pool_full", **POOL.status()})
|
|
798
|
+
return self._send(201 if created else 200,
|
|
799
|
+
{"sessionId": sid, "ready": seat.ready(), "created": created,
|
|
800
|
+
# Which child, so the API's seat row can name the
|
|
801
|
+
# process a VmHWM reading belongs to.
|
|
802
|
+
"index": seat.port - SEAT_BASE_PORT})
|
|
803
|
+
if route.startswith("/pool/seats/") and method == "DELETE":
|
|
804
|
+
sid = route[len("/pool/seats/"):]
|
|
805
|
+
return self._send(200, {"closed": POOL.close(sid)})
|
|
806
|
+
self._send(404, {"error": "not_found"})
|
|
807
|
+
|
|
808
|
+
def _relay(self, method, route):
|
|
809
|
+
rest = route[len("/s/"):]
|
|
810
|
+
sid, _, child_route = rest.partition("/")
|
|
811
|
+
seat = POOL.get(sid)
|
|
812
|
+
if seat is None:
|
|
813
|
+
return self._send(404, {"error": "no_such_seat"})
|
|
814
|
+
token = self.headers.get("x-genex-seat", "")
|
|
815
|
+
if not seat.token_sha or not hmac.compare_digest(
|
|
816
|
+
hashlib.sha256(token.encode()).hexdigest(), seat.token_sha):
|
|
817
|
+
# Per-seat, so one tenant cannot drive another's Blender even though
|
|
818
|
+
# both live on this pod's loopback.
|
|
819
|
+
return self._send(401, {"error": "unauthorized"})
|
|
820
|
+
# The body is read HERE, before the readiness check, and the order is
|
|
821
|
+
# load-bearing: the caller has just proved it holds the seat token, so
|
|
822
|
+
# reading its body is its own budget -- and the 503 below is the answer
|
|
823
|
+
# the API polls every two seconds during a 21 s seat boot. Answering it
|
|
824
|
+
# with the body unread would close the socket on every poll (see _send),
|
|
825
|
+
# which is a reconnect through the vendor proxy per retry.
|
|
826
|
+
try:
|
|
827
|
+
announced = self._content_length()
|
|
828
|
+
except ValueError as e:
|
|
829
|
+
return self._send(413, {"error": "bad_body", "detail": str(e)})
|
|
830
|
+
if announced > READ_AHEAD_MAX and not seat.ready():
|
|
831
|
+
# Too big to swallow just to keep a socket: an inlined asset would be
|
|
832
|
+
# uploaded through the vendor proxy on every 2 s poll. Answer without
|
|
833
|
+
# reading; _respond closes the connection, one reconnect is cheaper.
|
|
834
|
+
return self._send(503, {"error": "seat_not_ready", "retryAfterMs": 2000})
|
|
835
|
+
try:
|
|
836
|
+
payload = self._read_body()
|
|
837
|
+
except ValueError as e:
|
|
838
|
+
return self._send(413, {"error": "bad_body", "detail": str(e)})
|
|
839
|
+
if not seat.ready():
|
|
840
|
+
# `spawning` covers the restore download too: before this, a seat
|
|
841
|
+
# fetching its scene read as `seat_down` and the API's retry gave up
|
|
842
|
+
# on a seat that was working perfectly.
|
|
843
|
+
booting = seat.spawning or (seat.alive() and (time.time() - seat.opened_at) < SEAT_BOOT_BUDGET_S)
|
|
844
|
+
# NEVER block waiting. The API turns this into a 202 the caller
|
|
845
|
+
# retries; holding the connection would tie up a router thread for
|
|
846
|
+
# the length of a cold shader compile.
|
|
847
|
+
return self._send(503, {"error": "seat_not_ready" if booting else "seat_down",
|
|
848
|
+
"retryAfterMs": 2000})
|
|
849
|
+
url = "http://127.0.0.1:%d/%s" % (seat.port, child_route)
|
|
850
|
+
req = urllib.request.Request(url, data=payload if method != "GET" else None,
|
|
851
|
+
method=method)
|
|
852
|
+
req.add_header("Content-Type", "application/json")
|
|
853
|
+
req.add_header("x-genex-internal", seat.child_secret)
|
|
854
|
+
# Connection: close on the loopback leg. The child is a single-threaded
|
|
855
|
+
# HTTPServer, so a kept-alive idle socket from the router would own it
|
|
856
|
+
# until the idle timeout and block this seat's own next request.
|
|
857
|
+
req.add_header("Connection", "close")
|
|
858
|
+
seat.last_request_at = time.time()
|
|
859
|
+
try:
|
|
860
|
+
with urllib.request.urlopen(req, timeout=RELAY_TIMEOUT_S) as r:
|
|
861
|
+
data = r.read()
|
|
862
|
+
code = r.status
|
|
863
|
+
except urllib.error.HTTPError as e:
|
|
864
|
+
data, code = e.read(), e.code
|
|
865
|
+
except (urllib.error.URLError, OSError, TimeoutError) as e:
|
|
866
|
+
# The child died mid-request; the watcher will restart it and the
|
|
867
|
+
# scene comes back from its checkpoint.
|
|
868
|
+
return self._send(502, {"error": "seat_unreachable", "detail": str(e)})
|
|
869
|
+
self._respond(code, data)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def main():
|
|
873
|
+
if os.environ.get("GENEX_BLENDER_REQUIRE_SECRET") == "1" and not POD_SECRET:
|
|
874
|
+
sys.stderr.write("[pool] FATAL no_shared_secret: refusing to serve a "
|
|
875
|
+
"multi-tenant pod unauthenticated.\n")
|
|
876
|
+
return 5
|
|
877
|
+
port = int(os.environ.get("PORT", "8080"))
|
|
878
|
+
stop_evt = threading.Event()
|
|
879
|
+
watcher = threading.Thread(target=_watch_seats, args=(stop_evt,), daemon=True)
|
|
880
|
+
watcher.start()
|
|
881
|
+
srv = ThreadingHTTPServer(("0.0.0.0", port), Router)
|
|
882
|
+
srv.daemon_threads = True
|
|
883
|
+
log("router on 0.0.0.0:%d | capacity %d (pod %s MB, %d MB/seat)"
|
|
884
|
+
% (port, POOL.capacity(), POOL.pod_mb, PER_SEAT_MB))
|
|
885
|
+
|
|
886
|
+
def bye(signum, _f):
|
|
887
|
+
log("signal %s — draining" % signum)
|
|
888
|
+
stop_evt.set()
|
|
889
|
+
threading.Thread(target=srv.shutdown, daemon=True).start()
|
|
890
|
+
|
|
891
|
+
signal.signal(signal.SIGTERM, bye)
|
|
892
|
+
signal.signal(signal.SIGINT, bye)
|
|
893
|
+
try:
|
|
894
|
+
srv.serve_forever(poll_interval=0.2)
|
|
895
|
+
finally:
|
|
896
|
+
stop_evt.set()
|
|
897
|
+
# wipe=False (the disk is going anyway) but push=True: a pod being torn
|
|
898
|
+
# down is exactly when the last minutes of work would otherwise be lost.
|
|
899
|
+
# Bounded by CLOSE_PUSH_TIMEOUT_S per seat inside close().
|
|
900
|
+
for sid in list(POOL.seats):
|
|
901
|
+
POOL.close(sid, wipe=False)
|
|
902
|
+
deadline = time.time() + CLOSE_PUSH_TIMEOUT_S
|
|
903
|
+
while time.time() < deadline and any(t.name.startswith(("close-", "push-"))
|
|
904
|
+
for t in threading.enumerate()):
|
|
905
|
+
time.sleep(0.2)
|
|
906
|
+
return 0
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
if __name__ == "__main__":
|
|
910
|
+
sys.exit(main())
|