Fix OBS display and Start button not working
All checks were successful
Release / Bump version and tag (push) Successful in 12s
Sidecar Release / Bump sidecar version and tag (push) Successful in 6s

Three issues fixed:

1. Port mismatch: The sidecar reported the OBS port (8080) in the
   ready event but the frontend needs the API port (8081). Now reports
   the API port so WebSocket/REST connects to the right place.

2. Broadcast from wrong thread: Engine init fires state_changed from
   a background thread, but _broadcast_control used get_event_loop()
   which returns the wrong loop. Now captures the uvicorn event loop
   at startup via on_event("startup").

3. Missed ready state: If the engine finishes before the WebSocket
   client connects, the "ready" state_changed was never received.
   Added status polling (GET /api/status) on WebSocket connect that
   retries every 2s while appState is "initializing".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Developer
2026-04-07 07:35:40 -07:00
parent bc82584dff
commit a8de39de84
3 changed files with 59 additions and 6 deletions

View File

@@ -99,11 +99,19 @@ class APIServer:
self.controller.on_credits_low = on_credits_low self.controller.on_credits_low = on_credits_low
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
"""Set the event loop used for broadcasting (call from uvicorn startup)."""
self._event_loop = loop
def _broadcast_control(self, data: dict): def _broadcast_control(self, data: dict):
"""Send a message to all connected /ws/control clients.""" """Send a message to all connected /ws/control clients."""
if not self.control_connections: if not self.control_connections:
return return
loop = getattr(self, '_event_loop', None)
if loop is None:
return
message = json.dumps(data) message = json.dumps(data)
disconnected = [] disconnected = []
@@ -111,7 +119,7 @@ class APIServer:
try: try:
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
ws.send_text(message), ws.send_text(message),
asyncio.get_event_loop(), loop,
) )
except Exception: except Exception:
disconnected.append(ws) disconnected.append(ws)
@@ -124,6 +132,10 @@ class APIServer:
app = self.app app = self.app
ctrl = self.controller ctrl = self.controller
@app.on_event("startup")
async def on_startup():
self.set_event_loop(asyncio.get_event_loop())
# ── Status ───────────────────────────────────────────── # ── Status ─────────────────────────────────────────────
@app.get("/api/status") @app.get("/api/status")

View File

@@ -88,11 +88,16 @@ def main():
# Create API server wrapping the controller # Create API server wrapping the controller
api_server = APIServer(controller) api_server = APIServer(controller)
# Determine actual port (web server may have shifted if port was in use) # OBS display runs on the configured port, API server on port+1
actual_port = controller.actual_web_port or args.port obs_port = controller.actual_web_port or args.port
api_port = obs_port + 1
# Print ready event so Tauri can discover the port # Print ready event so Tauri can discover the API port
print(json.dumps({"event": "ready", "port": actual_port}), flush=True) print(json.dumps({
"event": "ready",
"port": api_port,
"obs_port": obs_port,
}), flush=True)
# Run the API server (blocks) # Run the API server (blocks)
import uvicorn import uvicorn
@@ -104,7 +109,7 @@ def main():
uvicorn.run( uvicorn.run(
api_server.app, api_server.app,
host=args.host, host=args.host,
port=actual_port + 1, # API on port+1, OBS display on the main port port=api_port,
log_level="error", log_level="error",
access_log=False, access_log=False,
) )

View File

@@ -54,6 +54,35 @@ async function apiFetch(path: string, options?: RequestInit): Promise<Response>
return fetch(url, { ...options, headers }); return fetch(url, { ...options, headers });
} }
// ── Status polling ──────────────────────────────────────────────────
let statusPollTimer: ReturnType<typeof setTimeout> | null = null;
async function pollStatus() {
try {
const resp = await fetch(apiUrl("/api/status"));
if (resp.ok) {
const data = await resp.json();
if (data.state) {
state.appState = data.state as AppState;
}
if (data.engine_device) {
state.deviceInfo = data.engine_device;
}
if (data.version) {
state.version = data.version;
}
}
} catch {
// API not ready yet, will retry
}
// Keep polling every 2s while still initializing
if (state.appState === "initializing" && state.connectionState === "connected") {
statusPollTimer = setTimeout(pollStatus, 2000);
}
}
// ── WebSocket management ───────────────────────────────────────────── // ── WebSocket management ─────────────────────────────────────────────
function connectWebSocket() { function connectWebSocket() {
@@ -80,6 +109,9 @@ function _openSocket() {
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);
reconnectTimer = null; reconnectTimer = null;
} }
// Poll status to catch engine ready state that may have been
// missed (engine can finish before WebSocket connects)
pollStatus();
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -132,6 +164,10 @@ function _scheduleReconnect() {
} }
function disconnect() { function disconnect() {
if (statusPollTimer) {
clearTimeout(statusPollTimer);
statusPollTimer = null;
}
if (reconnectTimer) { if (reconnectTimer) {
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);
reconnectTimer = null; reconnectTimer = null;