Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 70 additions & 7 deletions app/static/error-handling-enhanced.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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' }));
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions app/static/floating-timer-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 = () => {
Expand Down
1 change: 1 addition & 0 deletions app/static/smart-notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
12 changes: 7 additions & 5 deletions tests/test_reconnect_on_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading