From 39dfed791e7d2fc4f1980010db6db3c20841dbbf Mon Sep 17 00:00:00 2001 From: wind Date: Mon, 2 Mar 2026 14:09:26 +0100 Subject: [PATCH] fix: mark stale running scans as failed on restart; stop unconditional polling Backend (main.py): - _mark_interrupted_scans() runs in lifespan after init_db() - Finds all scans with status='running', sets status='failed', error_message='Scan interrupted by server restart', current_stage=None - Wrapped in try/except so a pre-migration DB never crashes startup Frontend (useScanStatus.ts): - Replaces unconditional 3s polling with adaptive intervals: - 3s fast-poll only while a scan is running - 30s slow-poll when idle (was: always 3s, even at rest) - Interval is rescheduled whenever running state changes Fixes: stale '102m 0s running scan' after restart + dozens of /api/scans requests per minute when nothing is happening Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/app/main.py | 32 +++++++++++++++++++++++++++++ frontend/src/hooks/useScanStatus.ts | 29 ++++++++++++++++++-------- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 61337af..bc0cffa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -43,9 +43,41 @@ def _read_version() -> str: _VERSION = _read_version() +def _mark_interrupted_scans() -> None: + """Mark any scan still in 'running' status as failed. + + Scans left in 'running' after a container restart were killed mid-flight + and will never complete. Leaving them in that state causes the frontend to + poll /api/scans indefinitely. Errors are caught so a stale or pre-migration + database never prevents the server from starting. + """ + from datetime import UTC, datetime + + from app.db import SessionLocal + from app.models.scan import Scan + + db = SessionLocal() + try: + stale = db.query(Scan).filter(Scan.status == "running").all() + if stale: + now = datetime.now(tz=UTC) + for scan in stale: + scan.status = "failed" + scan.finished_at = now + scan.error_message = "Scan interrupted by server restart" + scan.current_stage = None + db.commit() + logger.info("Marked %d interrupted scan(s) as failed on startup", len(stale)) + except Exception: # noqa: BLE001 — startup cleanup; never prevent the server from starting + logger.warning("Could not clean up interrupted scans on startup (schema mismatch?)") + finally: + db.close() + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: init_db() + _mark_interrupted_scans() from apscheduler.schedulers.background import BackgroundScheduler diff --git a/frontend/src/hooks/useScanStatus.ts b/frontend/src/hooks/useScanStatus.ts index cf6635d..c01aaf9 100644 --- a/frontend/src/hooks/useScanStatus.ts +++ b/frontend/src/hooks/useScanStatus.ts @@ -1,7 +1,8 @@ /** - * useScanStatus — polls GET /api/scans every `intervalMs` ms while a scan - * is running (status === 'running'). Returns the latest scan and whether - * a scan is currently in progress. + * useScanStatus — fetches GET /api/scans on mount, then fast-polls every + * `intervalMs` ms ONLY while a scan is running (status === 'running'). + * When no scan is running it falls back to a slow refresh every 30 s so + * the UI stays eventually-consistent without hammering the API. */ import { useEffect, useRef, useState } from "react"; import type { Scan } from "../types/api"; @@ -13,11 +14,13 @@ export interface UseScanStatusResult { } const RUNNING_STATUS = new Set(["running", "pending", "in_progress"]); +const IDLE_INTERVAL_MS = 30_000; export function useScanStatus(intervalMs = 3000): UseScanStatusResult { const [latestScan, setLatestScan] = useState(null); const [loading, setLoading] = useState(true); const intervalRef = useRef | null>(null); + const isRunningRef = useRef(false); const fetchScans = (silent = false) => { if (!silent) setLoading(true); @@ -29,6 +32,17 @@ export function useScanStatus(intervalMs = 3000): UseScanStatusResult { const latest = data[0] ?? null; setLatestScan(latest); setLoading(false); + + const nowRunning = latest !== null && RUNNING_STATUS.has(latest.status); + // Reschedule interval only when running-state changes + if (nowRunning !== isRunningRef.current) { + isRunningRef.current = nowRunning; + if (intervalRef.current !== null) clearInterval(intervalRef.current); + intervalRef.current = setInterval( + () => fetchScans(true), + nowRunning ? intervalMs : IDLE_INTERVAL_MS, + ); + } }) .catch(() => { setLoading(false); @@ -37,16 +51,13 @@ export function useScanStatus(intervalMs = 3000): UseScanStatusResult { useEffect(() => { fetchScans(); - - intervalRef.current = setInterval(() => { - // Always poll; only matters visually when running - fetchScans(true); - }, intervalMs); + // Start with idle interval; fetchScans will upgrade to fast-poll if running + intervalRef.current = setInterval(() => fetchScans(true), IDLE_INTERVAL_MS); return () => { if (intervalRef.current !== null) clearInterval(intervalRef.current); }; - }, [intervalMs]); + }, [intervalMs]); // eslint-disable-line react-hooks/exhaustive-deps -- fetchScans is stable const isRunning = latestScan !== null && RUNNING_STATUS.has(latestScan.status);