Reliably restore last session on refresh after a server restart

The page-load restore was one-shot and bailed when a completed scan's
replayed scan_phase left a running flag set; sse_replay_done (the other
retry) only fires for a non-empty replay buffer, which is empty after a
restart — so refreshing post-update showed a blank grid despite the
results being in the DB. The watchdog now retries the restore on each
4s poll while nothing is shown and no scan runs, clearing stale flags
first. /api/scan/status also reports google_running separately so a
refresh during a live Google scan is no longer treated as idle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
StyxX65 2026-06-16 11:53:07 +02:00
parent 9fd1aa1f8a
commit f84c8516df
5 changed files with 46 additions and 13 deletions

View File

@ -9,6 +9,10 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
## [Unreleased] ## [Unreleased]
### Fixed
- **Blank results grid after a browser refresh (especially after a server restart)** — restoring the last scan session on page load was one-shot: `_sseWatchdog()` called `loadHistorySession(null)` a single time, guarded by `_initialStatusChecked`. If that attempt was blocked — a completed scan's replayed `scan_phase` event leaves a `_*ScanRunning` flag set, and the `loadHistorySession` guard then bails — nothing retried, because `sse_replay_done` (the other retry path) only fires when the SSE replay buffer is non-empty, and the buffer is empty after a server restart (so refreshing after the in-app self-update reliably showed an empty grid even though the results were in the database). The watchdog now re-attempts the restore on every 4-second poll while nothing is shown and no scan is running, clearing stale running flags first (both scan locks are confirmed free at that point). Additionally, `/api/scan/status` now reports `google_running` separately from `running` (which only ever reflected the M365 + file lock), so a refresh during a live Google scan is detected instead of being treated as idle.
--- ---
## [1.7.7] — 2026-06-15 ## [1.7.7] — 2026-06-15

View File

@ -76,9 +76,13 @@ def scan_status():
acquired = state._scan_lock.acquire(blocking=False) acquired = state._scan_lock.acquire(blocking=False)
if acquired: if acquired:
state._scan_lock.release() state._scan_lock.release()
g_acquired = state._google_scan_lock.acquire(blocking=False)
if g_acquired:
state._google_scan_lock.release()
return jsonify({ return jsonify({
"running": not acquired, "running": not acquired, # M365 + file scan lock
"scan_id": _sse_mod._current_scan_id or None, "google_running": not g_acquired, # Google scan lock (separate)
"scan_id": _sse_mod._current_scan_id or None,
}) })

View File

@ -41,7 +41,7 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
## Scan history browser — history.js + results.js ## Scan history browser — history.js + results.js
- **`S._historyRefScanId`** — `null` = live/SSE mode; positive int = viewing a past session. Set by `loadHistorySession()`; cleared by `exitHistoryMode()`. - **`S._historyRefScanId`** — `null` = live/SSE mode; positive int = viewing a past session. Set by `loadHistorySession()`; cleared by `exitHistoryMode()`.
- **Auto-load on page load**`results.js` calls `window.loadHistorySession?.(null)` once when the SSE watchdog confirms `!status.running`. `_initialStatusChecked` guard ensures this fires at most once per page load. The `sse_replay_done` handler in `scan.js` retries if `loadHistorySession` bailed due to stale running flags set during replay. - **Auto-load on page load**`_sseWatchdog()` in `results.js` calls `window.loadHistorySession?.(null)` whenever `/api/scan/status` reports neither `running` (M365 + file lock) nor `google_running` (Google lock) **and** nothing is shown yet (`!S._historyRefScanId && !S.flaggedData.length`). This is **not one-shot** — it retries on every 4s poll until a session is restored, because (a) the replay buffer is empty after a server restart so `sse_replay_done` never fires, and (b) a completed scan's replayed `scan_phase` can leave a running flag set that would otherwise block the load forever. Because both locks are confirmed free, the watchdog clears the stale `_m365/_google/_fileScanRunning` flags before calling. Do not revert to a one-shot `_initialStatusChecked` gate — that reintroduces the "blank grid after refresh/restart" bug. `/api/scan/status` **must** report `google_running` separately; `running` alone misses live Google scans. The `sse_replay_done` handler in `scan.js` still retries for the non-empty-buffer (no-restart) case.
- **History banner** (`#historyBanner`) — shown when `S._historyRefScanId` is set. Do not hide/show from outside `history.js`. - **History banner** (`#historyBanner`) — shown when `S._historyRefScanId` is set. Do not hide/show from outside `history.js`.
- **Session picker** (`#historyDropdown`) — rendered inside `[data-history-wrap]` so the outside-click handler works correctly. Do not move the picker outside this wrapper. - **Session picker** (`#historyDropdown`) — rendered inside `[data-history-wrap]` so the outside-click handler works correctly. Do not move the picker outside this wrapper.
- **Cache invalidation**`invalidateHistoryCache()` clears `_sessions` and `_latestRefScanId`. All three `*_done` SSE handlers call `window.invalidateHistoryCache?.()`. - **Cache invalidation**`invalidateHistoryCache()` clears `_sessions` and `_latestRefScanId`. All three `*_done` SSE handlers call `window.invalidateHistoryCache?.()`.

View File

@ -736,25 +736,34 @@ function _ensureSSE() {
function _sseWatchdog() { function _sseWatchdog() {
fetch('/api/scan/status').then(function(r) { return r.json(); }).then(function(status) { fetch('/api/scan/status').then(function(r) { return r.json(); }).then(function(status) {
if (status.running) { var anyRunning = status.running || status.google_running;
if (anyRunning) {
// A scan is in progress — make sure SSE is connected and progress UI is visible // A scan is in progress — make sure SSE is connected and progress UI is visible
_ensureSSE(); _ensureSSE();
if (!S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) { if (status.running && !S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) {
document.getElementById('scanBtn').disabled = true; document.getElementById('scanBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-block'; document.getElementById('stopBtn').style.display = 'inline-block';
// /api/scan/status checks the M365 lock — if running=true it's an M365 scan // status.running reflects the M365 + file lock; treat as an M365 reconnect
S._m365ScanRunning = true; _renderProgressSegments(); S._m365ScanRunning = true; _renderProgressSegments();
document.getElementById('progressFile').textContent = t('m365_sse_reconnecting', 'Reconnecting to running scan…'); document.getElementById('progressFile').textContent = t('m365_sse_reconnecting', 'Reconnecting to running scan…');
log(t('m365_sse_reconnecting', 'Reconnecting to running scan…')); log(t('m365_sse_reconnecting', 'Reconnecting to running scan…'));
} }
} else if (!S._historyRefScanId && !(S.flaggedData && S.flaggedData.length)) {
// No scan of any kind is running (authoritative, both locks free) and
// nothing is shown yet — restore the last saved session from the DB.
// Retried on every poll, not one-shot: the initial attempt can be blocked
// by running flags that SSE replay of a *completed* scan set but never
// cleared, and sse_replay_done only fires for a non-empty buffer (so it
// never retries after a server restart clears the replay buffer).
// Both locks are confirmed free, so clear any stale flags first.
S._m365ScanRunning = false;
S._googleScanRunning = false;
S._fileScanRunning = false;
window.loadHistorySession?.(null);
} }
if (!_initialStatusChecked) { _initialStatusChecked = true;
_initialStatusChecked = true; // Keep polling even when idle — the SSE connection may have died and we
if (!status.running) window.loadHistorySession?.(null); // need to detect the next scheduled scan (SSE is only opened on demand).
}
// When no scan is running, we still keep polling — the SSE connection
// may have died and we need to detect the *next* scheduled scan.
// The SSE itself is only opened/reopened when a scan is detected.
}).catch(function(err) { }).catch(function(err) {
// Status endpoint unavailable — server might be restarting // Status endpoint unavailable — server might be restarting
console.warn('[SSE] status poll failed:', err); console.warn('[SSE] status poll failed:', err);

View File

@ -97,6 +97,22 @@ class TestScanStatus:
assert "scan_id" in data assert "scan_id" in data
assert data["scan_id"] is None assert data["scan_id"] is None
def test_idle_reports_google_not_running(self, client):
# The refresh/restore path relies on google_running being reported
# separately — running alone misses live Google scans.
data = client.get("/api/scan/status").get_json()
assert data["google_running"] is False
def test_google_lock_held_reports_google_running(self, client):
from routes import state
assert state._google_scan_lock.acquire(blocking=False)
try:
data = client.get("/api/scan/status").get_json()
assert data["google_running"] is True
assert data["running"] is False # M365/file lock still free
finally:
state._google_scan_lock.release()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /api/scan/start # /api/scan/start