fix(shell): evict tab pane from cache on script load failure

When a module script (e.g. canvas-*.js) fails to load (502 during
deploy, network error), the pane stayed in cache with a blank canvas.
Subsequent tab switches showed the broken cached pane instead of
re-fetching. Now script onerror removes the failed tag and evicts
the pane, so the next switchTo does a fresh fetch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 17:10:23 -07:00
parent 44a69e665e
commit aa02473d0d
5 changed files with 182 additions and 25 deletions

View File

@ -282,6 +282,16 @@ services:
networks:
- rspace-internal
# ── Blender headless render worker ──
blender-worker:
build: ./docker/blender-worker
container_name: blender-worker
restart: unless-stopped
volumes:
- rspace-files:/data/files
networks:
- rspace-internal
# ── Scribus noVNC (rDesign DTP workspace) ──
scribus-novnc:
build:

View File

@ -0,0 +1,20 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
blender \
python3 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV QT_QPA_PLATFORM=offscreen
ENV DISPLAY=""
WORKDIR /app
COPY server.py .
RUN mkdir -p /data/files/generated
EXPOSE 8810
CMD ["python3", "server.py"]

View File

@ -0,0 +1,104 @@
"""Headless Blender render worker — accepts scripts via HTTP, returns rendered images."""
import json
import os
import random
import string
import subprocess
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
GENERATED_DIR = "/data/files/generated"
BLENDER_TIMEOUT = 90 # seconds (fits within CF 100s limit)
class RenderHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self._json_response(200, {"ok": True, "service": "blender-worker"})
else:
self._json_response(404, {"error": "not found"})
def do_POST(self):
if self.path != "/render":
self._json_response(404, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
except (json.JSONDecodeError, ValueError):
self._json_response(400, {"error": "invalid JSON"})
return
script = body.get("script", "").strip()
if not script:
self._json_response(400, {"error": "script required"})
return
# Write script to temp file
script_path = "/tmp/scene.py"
render_path = "/tmp/render.png"
with open(script_path, "w") as f:
f.write(script)
# Clean any previous render
if os.path.exists(render_path):
os.remove(render_path)
# Run Blender headless
try:
result = subprocess.run(
["blender", "--background", "--python", script_path],
capture_output=True,
text=True,
timeout=BLENDER_TIMEOUT,
)
except subprocess.TimeoutExpired:
self._json_response(504, {
"success": False,
"error": f"Blender timed out after {BLENDER_TIMEOUT}s",
})
return
# Check if render was produced
if not os.path.exists(render_path):
self._json_response(422, {
"success": False,
"error": "Blender finished but no render output at /tmp/render.png",
"stdout": result.stdout[-2000:] if result.stdout else "",
"stderr": result.stderr[-2000:] if result.stderr else "",
})
return
# Move render to shared volume with unique name
rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
filename = f"blender-{int(time.time())}-{rand}.png"
dest = os.path.join(GENERATED_DIR, filename)
os.makedirs(GENERATED_DIR, exist_ok=True)
os.rename(render_path, dest)
self._json_response(200, {
"success": True,
"render_url": f"/data/files/generated/{filename}",
"filename": filename,
})
def _json_response(self, status, data):
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print(f"[blender-worker] {fmt % args}")
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 8810), RenderHandler)
print("[blender-worker] listening on :8810")
server.serve_forever()

View File

@ -1550,14 +1550,22 @@ app.get("/api/3d-gen/:jobId", async (c) => {
return c.json(response);
});
// Blender 3D generation via LLM + RunPod
const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || "";
// Blender 3D generation via LLM + headless worker sidecar
const BLENDER_WORKER_URL = process.env.BLENDER_WORKER_URL || "http://blender-worker:8810";
app.get("/api/blender-gen/health", async (c) => {
const issues: string[] = [];
const warnings: string[] = [];
if (!GEMINI_API_KEY) issues.push("GEMINI_API_KEY not configured");
if (!RUNPOD_API_KEY) warnings.push("RunPod not configured — script-only mode");
// Check blender-worker health
try {
const res = await fetch(`${BLENDER_WORKER_URL}/health`, { signal: AbortSignal.timeout(3000) });
if (!res.ok) warnings.push("blender-worker unhealthy");
} catch {
warnings.push("blender-worker unreachable — script-only mode");
}
return c.json({ available: issues.length === 0, issues, warnings });
});
@ -1596,38 +1604,37 @@ Output ONLY the Python code, no explanations or comments outside the code.`);
return c.json({ error: "Failed to generate Blender script" }, 502);
}
// Step 2: Execute on RunPod (headless Blender) — optional
if (!RUNPOD_API_KEY) {
return c.json({ script, render_url: null, blend_url: null });
}
// Step 2: Execute on blender-worker sidecar (headless Blender)
try {
const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", {
const workerRes = await fetch(`${BLENDER_WORKER_URL}/render`, {
method: "POST",
headers: {
Authorization: `Bearer ${RUNPOD_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
input: {
script,
render: true,
},
}),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ script }),
signal: AbortSignal.timeout(95_000), // 95s — worker has 90s internal timeout
});
if (!runpodRes.ok) {
return c.json({ script, error_detail: "RunPod execution failed" });
const data = await workerRes.json() as {
success?: boolean;
render_url?: string;
error?: string;
stdout?: string;
stderr?: string;
};
if (data.success && data.render_url) {
return c.json({ script, render_url: data.render_url });
}
const runpodData = await runpodRes.json();
// Worker ran but render failed — return script + error details
return c.json({
render_url: runpodData.output?.render_url || null,
script,
blend_url: runpodData.output?.blend_url || null,
render_url: null,
error_detail: data.error || "Render failed",
});
} catch (e) {
return c.json({ script, error_detail: "RunPod unavailable" });
console.error("[blender-gen] worker error:", e);
// Worker unreachable — return script only
return c.json({ script, render_url: null, error_detail: "blender-worker unavailable" });
}
});

View File

@ -344,6 +344,22 @@ export class TabCache {
const el = document.createElement("script");
el.type = "module";
el.src = src;
// If a script fails (502, network error), evict the pane from cache
// so the next switchTo re-fetches everything instead of showing a blank pane.
el.onerror = () => {
console.warn("[TabCache] script load failed:", src, "— evicting pane", moduleId);
el.remove();
if (moduleId) {
// moduleId here is "space-module" format — find and remove the pane
for (const [key, pane] of this.panes) {
if (pane.dataset.moduleId && moduleId.endsWith(pane.dataset.moduleId)) {
pane.remove();
this.panes.delete(key);
break;
}
}
}
};
document.body.appendChild(el);
}