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:
iris 2026-05-27 23:37:21 +02:00
commit b9df538940
2 changed files with 100 additions and 1 deletions

View file

@ -29,6 +29,18 @@
// re-sync after a reconnect gap.
// { kind: 'message', url: '...', data: '<raw SSE data string>' }
// { 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
// subscribe to multiple URLs (today only one is in use but the shape
@ -37,6 +49,19 @@
// don't keep idle streams open.
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) {
let entry = streams.get(url);
@ -77,6 +102,7 @@ function unsubscribe(port, url) {
self.onconnect = (connectEvent) => {
const port = connectEvent.ports[0];
allPorts.add(port);
const subscribedUrls = new Set();
port.onmessage = (e) => {
const msg = e.data;