frontend/dashboard: heartbeat + watchdog so a dead SharedWorker self-heals (#515)
Firefox kills "idle" SharedWorkers under memory pressure with no native signal to the client. The page's port silently becomes a no-op and events stop flowing — observable symptom: mara's "dashboard never refreshes; F5 fixes it" (because F5 creates a fresh page that creates a fresh worker). The worker now pings every connected port every 30s. The client tracks last-activity-from-worker on every message arrival (incl. pings, since those carry no URL — bumped before the URL filter in the route handler). A visibility-gated watchdog polls every 15s; if the page is visible AND has active subs AND hasn't heard from the worker in >90s, it presumes the worker dead, logs a console warning, and re-subscribes on a fresh port. Three pings missed before we act, so a normal tab-throttle blip doesn't false-positive. The fresh-port re-subscribe re-uses the bfcache-restore code path (same shape: drop stale listeners, getSharedPort → new SharedWorker, re-attach each route + repost subscribe). Recovery is per-tab — when one tab's watchdog fires and brings up a new worker, other tabs that share the named worker pick it up on their own watchdog cycle. Falls back gracefully on environments without SharedWorker (the existing direct-EventSource path is untouched) and is invisible on the healthy path — pings are 30s apart, no UI surface.
This commit is contained in:
parent
c786b9ec37
commit
b9df538940
2 changed files with 100 additions and 1 deletions
|
|
@ -99,6 +99,71 @@ function getSharedPort() {
|
||||||
return _sharedPort;
|
return _sharedPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #515: detect when the SharedWorker has been killed. Firefox aggressively
|
||||||
|
// reclaims "idle" SharedWorkers under memory pressure (or just on tab
|
||||||
|
// lifecycle quirks we don't fully understand), and there's no native
|
||||||
|
// signal to the client when that happens — `postMessage` on a dead
|
||||||
|
// port silently no-ops, and the page just stops receiving events. The
|
||||||
|
// observable symptom is "dashboard never refreshes; F5 fixes it" (a
|
||||||
|
// fresh page creates a fresh worker), which is mara's report on #515.
|
||||||
|
//
|
||||||
|
// Pipeline:
|
||||||
|
// - The worker pings every connected port every WORKER_PING_INTERVAL_MS.
|
||||||
|
// - `route` (every message arrival, including pings) bumps
|
||||||
|
// `_lastWorkerActivityAt`.
|
||||||
|
// - `startWorkerWatchdog` polls every WORKER_WATCHDOG_INTERVAL_MS.
|
||||||
|
// If the page is visible AND we have active subs AND we haven't
|
||||||
|
// heard from the worker in > WORKER_DEAD_THRESHOLD_MS, we presume
|
||||||
|
// the worker is dead, log a warning, and re-subscribe on a fresh
|
||||||
|
// port.
|
||||||
|
//
|
||||||
|
// Numbers picked so a real Firefox tab-suspend / unsuspend cycle (which
|
||||||
|
// can pause the watchdog itself) doesn't false-positive: 90s without a
|
||||||
|
// 30s ping means at least three pings missed. Visibility-gated so a
|
||||||
|
// backgrounded tab — where Firefox throttles setInterval to 1Hz min and
|
||||||
|
// our healthcheck wouldn't trigger reliably anyway — doesn't try to
|
||||||
|
// reconnect uselessly.
|
||||||
|
const WORKER_DEAD_THRESHOLD_MS = 90_000;
|
||||||
|
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
|
||||||
|
let _lastWorkerActivityAt = 0;
|
||||||
|
function noteWorkerActivity() { _lastWorkerActivityAt = Date.now(); }
|
||||||
|
let _watchdogTimer = null;
|
||||||
|
function startWorkerWatchdog() {
|
||||||
|
if (_watchdogTimer != null) return;
|
||||||
|
_watchdogTimer = setInterval(() => {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
||||||
|
if (!_activeSubs.size) return;
|
||||||
|
if (!_sharedPort) return;
|
||||||
|
const sinceLast = Date.now() - _lastWorkerActivityAt;
|
||||||
|
if (sinceLast < WORKER_DEAD_THRESHOLD_MS) return;
|
||||||
|
console.warn(
|
||||||
|
'hyperhive-stream worker silent for ' + Math.round(sinceLast / 1000)
|
||||||
|
+ 's, presumed dead — re-subscribing on a fresh port');
|
||||||
|
rebindOnFreshPort();
|
||||||
|
}, WORKER_WATCHDOG_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
function rebindOnFreshPort() {
|
||||||
|
// Drop any listeners attached to the dead port (best-effort; calls
|
||||||
|
// on a dead port throw, which we swallow). The active-subs registry
|
||||||
|
// is the source of truth for what we need to re-attach.
|
||||||
|
if (_sharedPort) {
|
||||||
|
for (const sub of _activeSubs.values()) {
|
||||||
|
try { _sharedPort.removeEventListener('message', sub.route); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_sharedPort = null;
|
||||||
|
const port = getSharedPort();
|
||||||
|
if (!port) return; // SharedWorker unsupported / unavailable — nothing to do
|
||||||
|
for (const [url, sub] of _activeSubs) {
|
||||||
|
sub.target.readyState = 0; // CONNECTING — worker will (re-)fire 'open'
|
||||||
|
port.addEventListener('message', sub.route);
|
||||||
|
try { port.postMessage({ kind: 'subscribe', url }); } catch {}
|
||||||
|
}
|
||||||
|
// Reset the activity clock so the watchdog gives the fresh worker
|
||||||
|
// a full window to settle before re-triggering.
|
||||||
|
noteWorkerActivity();
|
||||||
|
}
|
||||||
|
|
||||||
// Registry of live subscriptions on this page. Keyed by url so a
|
// Registry of live subscriptions on this page. Keyed by url so a
|
||||||
// second openStream call for the same URL (would only happen on a
|
// second openStream call for the same URL (would only happen on a
|
||||||
// hypothetical multi-consumer page) attaches to the existing route
|
// hypothetical multi-consumer page) attaches to the existing route
|
||||||
|
|
@ -122,6 +187,7 @@ let _lifecycleBound = false;
|
||||||
function bindLifecycleOnce() {
|
function bindLifecycleOnce() {
|
||||||
if (_lifecycleBound) return;
|
if (_lifecycleBound) return;
|
||||||
_lifecycleBound = true;
|
_lifecycleBound = true;
|
||||||
|
startWorkerWatchdog();
|
||||||
window.addEventListener('pagehide', () => {
|
window.addEventListener('pagehide', () => {
|
||||||
if (!_sharedPort) return;
|
if (!_sharedPort) return;
|
||||||
for (const url of _activeSubs.keys()) {
|
for (const url of _activeSubs.keys()) {
|
||||||
|
|
@ -179,8 +245,12 @@ export function openStream(url) {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const route = (e) => {
|
const route = (e) => {
|
||||||
|
// #515: any message from the worker is proof of life — note it
|
||||||
|
// before the URL filter, since heartbeat pings carry no URL.
|
||||||
|
noteWorkerActivity();
|
||||||
const m = e.data;
|
const m = e.data;
|
||||||
if (!m || m.url !== url) return;
|
if (!m || m.kind === 'ping') return;
|
||||||
|
if (m.url !== url) return;
|
||||||
if (m.kind === 'open') {
|
if (m.kind === 'open') {
|
||||||
target.readyState = 1; // OPEN
|
target.readyState = 1; // OPEN
|
||||||
if (target.onopen) {
|
if (target.onopen) {
|
||||||
|
|
@ -202,6 +272,9 @@ export function openStream(url) {
|
||||||
_activeSubs.set(url, { target, route });
|
_activeSubs.set(url, { target, route });
|
||||||
port.addEventListener('message', route);
|
port.addEventListener('message', route);
|
||||||
port.postMessage({ kind: 'subscribe', url });
|
port.postMessage({ kind: 'subscribe', url });
|
||||||
|
// #515: seed the activity clock so the watchdog has a baseline; it
|
||||||
|
// would otherwise compare against 0 (epoch) and trigger immediately.
|
||||||
|
noteWorkerActivity();
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,18 @@
|
||||||
// re-sync after a reconnect gap.
|
// re-sync after a reconnect gap.
|
||||||
// { kind: 'message', url: '...', data: '<raw SSE data string>' }
|
// { kind: 'message', url: '...', data: '<raw SSE data string>' }
|
||||||
// { kind: 'error', url: '...' } relayed from EventSource.onerror.
|
// { kind: 'error', url: '...' } relayed from EventSource.onerror.
|
||||||
|
// { kind: 'ping' } #515: heartbeat — fired every
|
||||||
|
// PING_INTERVAL_MS to every
|
||||||
|
// connected port. The client's
|
||||||
|
// watchdog uses these as
|
||||||
|
// proof-of-life; silence past
|
||||||
|
// ~3× the interval triggers a
|
||||||
|
// re-subscribe on a fresh port
|
||||||
|
// (recovers from Firefox killing
|
||||||
|
// the SharedWorker out from
|
||||||
|
// under us, which it does under
|
||||||
|
// memory pressure with no native
|
||||||
|
// signal to the client).
|
||||||
//
|
//
|
||||||
// Subscriptions are tracked per (port, url): a single port can
|
// Subscriptions are tracked per (port, url): a single port can
|
||||||
// subscribe to multiple URLs (today only one is in use but the shape
|
// subscribe to multiple URLs (today only one is in use but the shape
|
||||||
|
|
@ -37,6 +49,19 @@
|
||||||
// don't keep idle streams open.
|
// don't keep idle streams open.
|
||||||
|
|
||||||
const streams = new Map();
|
const streams = new Map();
|
||||||
|
// All currently-connected ports. Used by the heartbeat tick to fan
|
||||||
|
// pings out across every tab regardless of which URLs each port is
|
||||||
|
// subscribed to. Stays disjoint from per-stream `entry.ports` (which
|
||||||
|
// is URL-scoped); a port may be in `allPorts` without any active
|
||||||
|
// subscription (e.g. between a tab loading and its first subscribe).
|
||||||
|
const allPorts = new Set();
|
||||||
|
const PING_INTERVAL_MS = 30_000;
|
||||||
|
setInterval(() => {
|
||||||
|
for (const port of allPorts) {
|
||||||
|
try { port.postMessage({ kind: 'ping' }); }
|
||||||
|
catch { /* port dead — cleanup happens lazily on next subscribe */ }
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
|
||||||
function getOrCreateStream(url) {
|
function getOrCreateStream(url) {
|
||||||
let entry = streams.get(url);
|
let entry = streams.get(url);
|
||||||
|
|
@ -77,6 +102,7 @@ function unsubscribe(port, url) {
|
||||||
|
|
||||||
self.onconnect = (connectEvent) => {
|
self.onconnect = (connectEvent) => {
|
||||||
const port = connectEvent.ports[0];
|
const port = connectEvent.ports[0];
|
||||||
|
allPorts.add(port);
|
||||||
const subscribedUrls = new Set();
|
const subscribedUrls = new Set();
|
||||||
port.onmessage = (e) => {
|
port.onmessage = (e) => {
|
||||||
const msg = e.data;
|
const msg = e.data;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue