diff --git a/backend/app/main.py b/backend/app/main.py index 05ac0a1..e558fc5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -65,6 +65,12 @@ SDK_DOCS_DIST = Path(__file__).resolve().parents[2] / "site" +@app.on_event("shutdown") +def stop_active_runner_processes() -> None: + """Prevent browsers from outliving the local API process on reload.""" + store.shutdown() + + def safely(action): try: return action() diff --git a/backend/app/store.py b/backend/app/store.py index a03c277..473cf8d 100644 --- a/backend/app/store.py +++ b/backend/app/store.py @@ -717,6 +717,23 @@ def __init__(self) -> None: self._recover_interrupted_runs() self.tracer = configure_telemetry(TELEMETRY) + @staticmethod + def _stop_process_group(process: subprocess.Popen[str], *, force: bool = False) -> None: + """Stop a runner and every subprocess it spawned, including browsers.""" + if process.poll() is not None: + return + if os.name == "nt": + (process.kill if force else process.terminate)() + return + os.killpg(process.pid, signal.SIGKILL if force else signal.SIGTERM) + + def shutdown(self) -> None: + """Release child process groups before an API reload or shutdown.""" + with self._lock: + processes = list(self._processes.values()) + for process in processes: + self._stop_process_group(process) + def _recover_interrupted_runs(self) -> None: """Do not present orphaned in-memory pipelines as still running. @@ -4757,7 +4774,7 @@ def capture_output() -> None: self._fail(run, step.id, f"exit code {process.returncode}") return except subprocess.TimeoutExpired: - process.kill() + self._stop_process_group(process, force=True) span.add_event("process.timeout", {"timeout.seconds": step.timeout_seconds}) if self._load(run_id).status == "cancelled": return @@ -4780,11 +4797,8 @@ def cancel(self, run_id: str) -> Run: run = self._load(run_id) with self._lock: process = self._processes.get(run_id) - if process and process.poll() is None: - if os.name == "nt": - process.terminate() - else: - os.killpg(process.pid, signal.SIGTERM) + if process: + self._stop_process_group(process) run.status, run.pid, run.current_step, run.current_phase, run.updated_at, run.finished_at = ( "cancelled", None,