Detect a dead local pipeline worker after API restart, and flush SSE.

A docker rebuild left HubAudio status=running with no process, so the UI sat on a silent event stream. Persist the worker pid and disable proxy buffering on the SSE response.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 08:50:33 +02:00
co-authored by Cursor
parent ea43b550ee
commit 96e2590a3c
3 changed files with 63 additions and 12 deletions
+9 -1
View File
@@ -471,7 +471,15 @@ async def events(project_id: str, request: Request):
except (asyncio.CancelledError, Exception): except (asyncio.CancelledError, Exception):
pass pass
return EventSourceResponse(event_generator()) return EventSourceResponse(
event_generator(),
ping=15,
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@router.get("/pipeline/{project_id}/status") @router.get("/pipeline/{project_id}/status")
+35 -3
View File
@@ -16,6 +16,7 @@ import os
import subprocess import subprocess
import sys import sys
import threading import threading
from pathlib import Path
from typing import Literal from typing import Literal
from backend.config import settings from backend.config import settings
@@ -71,9 +72,9 @@ def _spawn_local_subprocess(
proc = subprocess.Popen( proc = subprocess.Popen(
[sys.executable, "-m", "backend.pipeline_worker"], [sys.executable, "-m", "backend.pipeline_worker"],
env=env, env=env,
# Inherit stdout/stderr so logs appear in the dev terminal
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
) )
_write_pid(project_id, proc.pid)
with _local_procs_lock: with _local_procs_lock:
# Reap any old proc for the same project before tracking the new one. # Reap any old proc for the same project before tracking the new one.
prior = _local_procs.pop(project_id, None) prior = _local_procs.pop(project_id, None)
@@ -87,11 +88,36 @@ def _spawn_local_subprocess(
return name return name
def _pid_path(project_id: str) -> Path:
return settings.data_dir / "workers" / f"{project_id}.pid"
def _write_pid(project_id: str, pid: int) -> None:
path = _pid_path(project_id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(pid))
def _pid_alive(project_id: str) -> bool | None:
"""True/False if a pid file exists; None if there is no file."""
path = _pid_path(project_id)
if not path.is_file():
return None
try:
pid = int(path.read_text().strip())
except ValueError:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True
def _local_state(project_id: str) -> ExecutionState: def _local_state(project_id: str) -> ExecutionState:
with _local_procs_lock: with _local_procs_lock:
proc = _local_procs.get(project_id) proc = _local_procs.get(project_id)
if proc is None: if proc is not None:
return "unknown"
rc = proc.poll() rc = proc.poll()
if rc is None: if rc is None:
return "running" return "running"
@@ -101,6 +127,12 @@ def _local_state(project_id: str) -> ExecutionState:
# Negative return = terminated by signal # Negative return = terminated by signal
return "cancelled" return "cancelled"
return "failed" return "failed"
alive = _pid_alive(project_id)
if alive is True:
return "running"
if alive is False:
return "failed"
return "unknown"
def _local_cancel(project_id: str) -> None: def _local_cancel(project_id: str) -> None:
+11
View File
@@ -0,0 +1,11 @@
"""Local worker pid file so a restarted API can see a dead subprocess."""
from backend.services import job_runner
def test_local_state_failed_when_pid_file_is_stale(tmp_path, monkeypatch):
monkeypatch.setattr(job_runner.settings, "data_dir", tmp_path)
pid_path = tmp_path / "workers" / "proj.pid"
pid_path.parent.mkdir(parents=True)
pid_path.write_text("99999999")
assert job_runner.get_execution_state("local/projects/proj") == "failed"