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:
parent
44a69e665e
commit
aa02473d0d
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue