@genex-ai/cli-demo 1.30.0-dev.645 → 1.31.1

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.
Files changed (47) hide show
  1. package/README.md +0 -18
  2. package/dist/index.js +2839 -4768
  3. package/package.json +3 -3
  4. package/templates/controllers/character/follow-camera.ts +1 -16
  5. package/templates/controllers/character/meshy/meshy-loader.ts +2 -3
  6. package/templates/controllers/quality/pick-asset.ts +16 -49
  7. package/templates/controllers/shared/physics-world.ts +4 -6
  8. package/templates/skills/genex-ai-character/SKILL.md +15 -77
  9. package/templates/skills/genex-ai-menu/SKILL.md +11 -15
  10. package/templates/skills/genex-ai-model/SKILL.md +7 -45
  11. package/templates/skills/genex-ai-texture/SKILL.md +1 -1
  12. package/templates/skills/genex-ai-video/SKILL.md +17 -75
  13. package/templates/skills/genex-game-director/SKILL.md +46 -112
  14. package/templates/skills/genex-game-director/references/design-contract.md +5 -13
  15. package/templates/skills/genex-game-director/references/routing-map.md +45 -30
  16. package/templates/skills/genex-getting-started/SKILL.md +2 -2
  17. package/templates/skills/genex-monetization/SKILL.md +177 -0
  18. package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +0 -21
  19. package/templates/skills/genex-threejs-character-controller/SKILL.md +5 -17
  20. package/templates/skills/genex-threejs-creatures/SKILL.md +1 -8
  21. package/templates/skills/genex-threejs-embed-auth/SKILL.md +57 -8
  22. package/templates/skills/genex-threejs-game-ui/SKILL.md +6 -55
  23. package/templates/skills/genex-threejs-procedural-assets/SKILL.md +10 -17
  24. package/templates/skills/genex-threejs-visual-validation/SKILL.md +2 -12
  25. package/templates/skills/genex-tool-audio/SKILL.md +2 -3
  26. package/templates/skills/genex-tool-character/SKILL.md +5 -36
  27. package/templates/skills/genex-tool-image/SKILL.md +2 -4
  28. package/templates/skills/genex-tool-model/SKILL.md +6 -32
  29. package/templates/skills/genex-tool-texture/SKILL.md +1 -1
  30. package/templates/skills/genex-tool-video/SKILL.md +5 -28
  31. package/templates/skills/genex-tool-workflow/SKILL.md +1 -4
  32. package/templates/skills/genex-updates/SKILL.md +1 -1
  33. package/dist/blender-mcp-Q6PSFYSE.js +0 -241
  34. package/dist/blender-serve-BF4FZ55Z.js +0 -244
  35. package/dist/chunk-2COG4P3T.js +0 -968
  36. package/dist/chunk-HYCSNWYX.js +0 -126
  37. package/templates/blender-service/demo/castle.py +0 -117
  38. package/templates/blender-service/gpu_witness.py +0 -245
  39. package/templates/blender-service/ops.py +0 -225
  40. package/templates/blender-service/pool.py +0 -910
  41. package/templates/blender-service/server.py +0 -611
  42. package/templates/blender-service/supervisor.py +0 -221
  43. package/templates/blender-service/views.py +0 -281
  44. package/templates/controllers/quality/deadline.ts +0 -117
  45. package/templates/skills/genex-blender-scene/SKILL.md +0 -243
  46. package/templates/skills/genex-lane-card/SKILL.md +0 -78
  47. package/templates/skills/genex-tool-publish/SKILL.md +0 -100
@@ -1,910 +0,0 @@
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())