diff --git a/app/static/error-handling-enhanced.js b/app/static/error-handling-enhanced.js index b43fb33c..b2ce2924 100644 --- a/app/static/error-handling-enhanced.js +++ b/app/static/error-handling-enhanced.js @@ -56,14 +56,10 @@ class EnhancedErrorHandler { // Re-probe when the tab becomes active again (background timers may have gone stale) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { + this.beginResumeGrace(); this.startHealthProbeInterval(); - this.checkOnlineStatus({ quiet: true, onResume: true }); - // Second probe to sweep up errors from resuming background requests - setTimeout(() => { - if (document.visibilityState === 'visible') { - this.checkOnlineStatus({ quiet: true, onResume: true }); - } - }, 1500); + this.probeAfterResume(); + this.dispatchTabResumed(); } else { this.stopHealthProbeInterval(); } @@ -81,6 +77,56 @@ class EnhancedErrorHandler { } } + // When the tab wakes up, requests race a burst of reconnects and stale + // keep-alive sockets. Treat failures inside this window as transient: + // no error toasts, and a later probe clears the state automatically. + static RESUME_GRACE_MS = 8000; + static TAB_RESUMED_DEBOUNCE_MS = 1500; + + beginResumeGrace() { + this._resumeGraceUntil = Date.now() + EnhancedErrorHandler.RESUME_GRACE_MS; + } + + inResumeGraceWindow() { + return Boolean(this._resumeGraceUntil) && Date.now() < this._resumeGraceUntil; + } + + // Probe /api/health several times with short backoff before declaring the + // server offline — the first probe right after resume often fails on a + // dead keep-alive connection even when the server is healthy. + probeAfterResume(attempts = [0, 2000, 5000]) { + if (this._resumeProbeToken === undefined) this._resumeProbeToken = 0; + const token = ++this._resumeProbeToken; + + const attemptAt = (delays) => { + if (token !== this._resumeProbeToken) return; + const [delay, ...rest] = delays; + setTimeout(() => { + if (token !== this._resumeProbeToken || document.visibilityState !== 'visible') return; + this.checkOnlineStatus({ quiet: true, onResume: true }).then((healthy) => { + if (token !== this._resumeProbeToken) return; + if (healthy || rest.length === 0) return; + attemptAt(rest); + }); + }, delay); + }; + attemptAt(attempts); + } + + // Single debounced signal so widgets (timer bar, notifications) can + // refetch authoritative data once the reconnect burst has settled. + dispatchTabResumed() { + if (this._tabResumedTimer) { + clearTimeout(this._tabResumedTimer); + } + this._tabResumedTimer = setTimeout(() => { + this._tabResumedTimer = null; + if (document.visibilityState === 'visible') { + document.dispatchEvent(new CustomEvent('tt:tab-resumed')); + } + }, EnhancedErrorHandler.TAB_RESUMED_DEBOUNCE_MS); + } + startHealthProbeInterval() { if (this._healthProbeTimer) return; this._healthProbeTimer = window.setInterval(() => { @@ -371,6 +417,12 @@ class EnhancedErrorHandler { } async handleFetchError(response, url, options) { + // Transient proxy errors right after tab resume: the server is usually + // fine again within seconds, so don't alarm the user. + if (this.inResumeGraceWindow() && [502, 503, 504].includes(response.status)) { + console.debug('Suppressed transient error during tab-resume grace window:', response.status, url); + return response; + } // Clone the response before reading it, so the caller can still read it const clonedResponse = response.clone(); const errorData = await clonedResponse.json().catch(() => ({ error: 'Unknown error' })); @@ -403,6 +455,17 @@ class EnhancedErrorHandler { }); } + // Network error on a stale keep-alive connection during the resume + // window: callers still get a non-ok response, but no toast — the + // resume probes will declare offline if the server is really gone. + if (this.inResumeGraceWindow()) { + console.debug('Suppressed network error during tab-resume grace window:', url); + return new Response(JSON.stringify({ error: 'Reconnecting' }), { + status: 503, + statusText: 'Reconnecting' + }); + } + const userFriendlyMessage = this.getUserFriendlyMessage(0, error); const errorId = this.showErrorWithRetry(userFriendlyMessage, 0, () => { return this.retryFetch(url, options); diff --git a/app/static/floating-timer-bar.js b/app/static/floating-timer-bar.js index cf0a5490..e4843bdd 100644 --- a/app/static/floating-timer-bar.js +++ b/app/static/floating-timer-bar.js @@ -54,6 +54,7 @@ this.lastFocusFetch = now; this.fetchStatus(); }); + document.addEventListener('tt:tab-resumed', () => this.fetchStatus()); document.addEventListener('click', (evt) => { if (!this.switchPopover || this.switchPopover.classList.contains('hidden')) return; if (this.switchPopover.contains(evt.target)) return; @@ -177,6 +178,13 @@ return; } if (!res.ok) { + // The fetch interceptor converts network errors (stale + // keep-alive connection after tab resume) into synthetic + // 503 responses, so retry those the same as real failures. + if ([502, 503, 504].includes(res.status) && !this._statusRetried) { + this.retryStatusFetchOnce(); + return; + } console.warn('FloatingTimerBar: fetch status failed', res.status); return; } @@ -195,10 +203,24 @@ } syncFabDesktopHide(this.timerData); } catch (e) { + if (!this._statusRetried) { + this.retryStatusFetchOnce(); + return; + } console.warn('FloatingTimerBar: fetch status failed', e); } } + retryStatusFetchOnce() { + // Right after tab resume the first fetch can land on a dead + // keep-alive connection; one delayed retry recovers transparently. + this._statusRetried = true; + setTimeout(() => { + this._statusRetried = false; + this.fetchStatus(); + }, 1500); + } + startElapsedUpdater() { this.stopElapsedUpdater(); const update = () => { diff --git a/app/static/smart-notifications.js b/app/static/smart-notifications.js index 12fc9321..a79773a6 100644 --- a/app/static/smart-notifications.js +++ b/app/static/smart-notifications.js @@ -314,6 +314,7 @@ class SmartNotificationManager { try { setTimeout(() => this.pollServerSmartNotifications(), 12000); setInterval(() => this.pollServerSmartNotifications(), this._serverSmartPollMs); + document.addEventListener('tt:tab-resumed', () => this.pollServerSmartNotifications()); } catch (e) { console.error('[SmartNotifications] server poll init:', e); } diff --git a/tests/test_reconnect_on_resume.py b/tests/test_reconnect_on_resume.py index c0707f2c..8a1cfc9d 100644 --- a/tests/test_reconnect_on_resume.py +++ b/tests/test_reconnect_on_resume.py @@ -27,11 +27,13 @@ def test_error_handler_quiet_health_and_visibility_reconnect(): assert "startHealthProbeInterval" in content assert "/api/health" in content assert "if (quiet)" in content - # Delayed probes on resume sweep errors from background fetches (#703 / #746) - assert "setTimeout" in content - assert "1500" in content - assert "5000" in content - assert content.count("checkOnlineStatus({ quiet: true, onResume: true })") >= 3 + # Resume probes retry with backoff before declaring the server offline, + # and transient failures during the grace window are toast-suppressed + assert "probeAfterResume" in content + assert "beginResumeGrace" in content + assert "inResumeGraceWindow" in content + # Debounced signal for widgets to refetch after the reconnect burst settles + assert "tt:tab-resumed" in content def test_idle_background_fetches_are_quiet():