fix(rdesign): run Scribus runner as standalone supervisor process

The Scribus --python-script flag requires GUI initialization which
blocks in headless environments. Instead, run the runner as a separate
supervisor-managed Python process (always-on socket server). The bridge
server now simply verifies the socket exists rather than launching
Scribus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 12:05:51 -07:00
parent 3a443a0d09
commit 4ed940d75c
3 changed files with 17 additions and 57 deletions

View File

@ -14,7 +14,6 @@ server translates HTTP requests into socket commands.
import json import json
import os import os
import socket import socket
import subprocess
import time import time
from pathlib import Path from pathlib import Path
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
@ -23,11 +22,7 @@ app = Flask(__name__)
BRIDGE_SECRET = os.environ.get("BRIDGE_SECRET", "") BRIDGE_SECRET = os.environ.get("BRIDGE_SECRET", "")
SOCKET_PATH = "/tmp/scribus_bridge.sock" SOCKET_PATH = "/tmp/scribus_bridge.sock"
SCRIBUS_RUNNER = "/opt/bridge/scribus_runner.py"
DESIGNS_DIR = Path("/data/designs") DESIGNS_DIR = Path("/data/designs")
DISPLAY = os.environ.get("DISPLAY", ":1")
_scribus_proc = None
def _check_auth(): def _check_auth():
@ -81,50 +76,24 @@ def before_request():
@app.route("/health", methods=["GET"]) @app.route("/health", methods=["GET"])
def health(): def health():
runner_alive = os.path.exists(SOCKET_PATH) runner_alive = os.path.exists(SOCKET_PATH)
scribus_alive = _scribus_proc is not None and _scribus_proc.poll() is None
return jsonify({ return jsonify({
"ok": True, "ok": True,
"service": "scribus-bridge", "service": "scribus-bridge",
"runner_connected": runner_alive, "runner_connected": runner_alive,
"scribus_alive": scribus_alive,
}) })
@app.route("/api/scribus/start", methods=["POST"]) @app.route("/api/scribus/start", methods=["POST"])
def start_scribus(): def start_scribus():
"""Launch Scribus with the bridge runner script.""" """Verify runner socket is available. Optionally launch Scribus GUI for real rendering."""
global _scribus_proc # The runner process is managed by supervisor and should already be listening.
# Wait briefly for socket if it's still starting up.
if _scribus_proc and _scribus_proc.poll() is None: for _ in range(10):
if os.path.exists(SOCKET_PATH): if os.path.exists(SOCKET_PATH):
return jsonify({"ok": True, "message": "Scribus already running", "pid": _scribus_proc.pid}) return jsonify({"ok": True, "message": "Runner connected", "runner_connected": True})
# Process alive but runner socket missing — kill and restart
_scribus_proc.kill()
_scribus_proc.wait(timeout=5)
_scribus_proc = None
# Clean up stale socket
if os.path.exists(SOCKET_PATH):
os.remove(SOCKET_PATH)
env = os.environ.copy()
env["DISPLAY"] = DISPLAY
env["XDG_RUNTIME_DIR"] = "/tmp/runtime-root"
_scribus_proc = subprocess.Popen(
["scribus", "--python-script", SCRIBUS_RUNNER],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Wait for runner socket to appear (up to 15s)
for _ in range(30):
if os.path.exists(SOCKET_PATH):
return jsonify({"ok": True, "message": "Scribus started", "pid": _scribus_proc.pid})
time.sleep(0.5) time.sleep(0.5)
return jsonify({"ok": False, "error": "Scribus runner did not connect in time"}), 500 return jsonify({"ok": False, "error": "Runner socket not available. Check supervisor logs."}), 500
@app.route("/api/scribus/command", methods=["POST"]) @app.route("/api/scribus/command", methods=["POST"])

View File

@ -20,6 +20,15 @@ autorestart=true
priority=30 priority=30
startsecs=5 startsecs=5
[program:runner]
command=python3 /opt/bridge/scribus_runner.py
autorestart=true
priority=35
environment=DISPLAY=":1"
stdout_logfile=/var/log/supervisor/runner.log
stderr_logfile=/var/log/supervisor/runner_err.log
startsecs=2
[program:bridge] [program:bridge]
command=python3 /opt/bridge/server.py command=python3 /opt/bridge/server.py
autorestart=true autorestart=true

View File

@ -61,7 +61,7 @@ async function bridgeState(): Promise<any> {
} }
} }
/** Start Scribus if not running, verify runner is connected. */ /** Verify the bridge runner is connected and ready. */
async function ensureScribusRunning(): Promise<any> { async function ensureScribusRunning(): Promise<any> {
const headers: Record<string, string> = { "Content-Type": "application/json" }; const headers: Record<string, string> = { "Content-Type": "application/json" };
if (BRIDGE_SECRET) headers["X-Bridge-Secret"] = BRIDGE_SECRET; if (BRIDGE_SECRET) headers["X-Bridge-Secret"] = BRIDGE_SECRET;
@ -69,28 +69,10 @@ async function ensureScribusRunning(): Promise<any> {
try { try {
const res = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/start`, { const res = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/start`, {
method: "POST", method: "POST",
headers,
signal: AbortSignal.timeout(20_000),
});
const result = await res.json();
if (result.error) return result;
// Verify runner is actually connected by checking state
const stateRes = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/state`, {
headers, headers,
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
}); });
const state = await stateRes.json(); return await res.json();
if (!state.error) return result;
// Runner not connected — force restart by calling start again
// (server.py now kills zombie Scribus when socket is missing)
const retryRes = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/start`, {
method: "POST",
headers,
signal: AbortSignal.timeout(20_000),
});
return await retryRes.json();
} catch (e: any) { } catch (e: any) {
return { error: `Bridge unreachable: ${e.message}` }; return { error: `Bridge unreachable: ${e.message}` };
} }