librelad 306e6223c0 fix(webui): release leaked listeners/intervals/streams on unmount (all modules)
The teardown audit found the backup-stacking leak class across 4 more feature
modules (12 confirmed leaks); unmount() left document/window listeners, intervals,
and SSE subscriptions firing on stale controllers after navigation:

- admin: overview/ssh/peers/system each leaked a document click listener ->
  AbortController + dispose() per page; admin unmount() aborts each.
- dashboard: the 1 Hz update-countdown interval + the LiveSystem view sub ->
  stopUpdateCountdown()/detachDashboardLive(), registered via ctx.sub().
- tasks: constructor-started global live-log poller (discarded handle) -> stored
  + idempotent + cleared on unmount + re-armed on mount; per-task monitorTask
  window listeners + interval -> tracked in a map, released on unmount.
- apps: app-tabbed reconcile setTimeout loop + watchdog window/document listeners
  + popstate -> per-instance AbortController + dispose() that clears the timer,
  resets the guards, and unloads the active tab's Services intervals + log SSE.

All mirror the kernel's MountContext teardown discipline. 12 files, all pass
node --check. Backup (fixed earlier) re-confirmed clean by the audit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-31 15:27:29 +01:00

61 lines
2.8 KiB
JavaScript

// components/tasks/index.js — the Tasks page as a feature module.
//
// window.tasksManager is a system-loader singleton (its "task-system" component
// loads all task scripts, starts the shared SSE bus, then news TasksManager).
// So mount() does NOT new it or load scripts — it renders the fragment and
// re-inits the view, exactly like the old handleTasks(). unmount() releases
// only this view's per-mount leaks (the 30s auto-refresh interval + open log
// streams); it must never stop the shared taskEventBus or null the singleton.
LP.features.register({
id: 'tasks',
routes: ['/tasks', '/tasks*'],
async mount(ctx) {
const html = await ctx.loadFragment('/components/tasks/html/tasks-content.html');
ctx.setContent(html, 'Tasks');
if (window.tasksManager) {
await window.tasksManager.init();
// Re-arm the global live-log poller (idempotent) — it's first started in
// the constructor, which doesn't re-run, so a revisit after unmount cleared
// it would otherwise have no poller. unmount() clears it again.
if (typeof window.tasksManager.startGlobalLiveLogUpdater === 'function') window.tasksManager.startGlobalLiveLogUpdater();
} else {
// Don't throw — matches handleTasks: the page still renders, task
// functionality is just limited until the task-system component is ready.
console.warn('TasksManager not available yet, task functionality will be limited');
}
},
async unmount() {
const tm = window.tasksManager;
// The one per-view leak init() opens: the 30s auto-refresh interval stored
// on the singleton. Each init() recreates it, so clearing here is idempotent.
if (tm && tm.refreshInterval) {
clearInterval(tm.refreshInterval);
tm.refreshInterval = null;
}
// The global live-log poller (constructor-started, handle was discarded) and
// any still-running per-task monitors — release them so they don't keep
// firing on a torn-down page.
if (tm && tm.globalLiveLogInterval) {
clearInterval(tm.globalLiveLogInterval);
tm.globalLiveLogInterval = null;
}
if (tm && tm.taskMonitors) {
for (const stop of Array.from(tm.taskMonitors.values())) { try { stop(); } catch (_) {} }
tm.taskMonitors.clear();
}
// Stop any open per-task log streams this view started (each removes its own
// SSE listeners + map entry). Snapshot keys first — stopLogStreaming mutates
// the map. Does NOT touch the shared bus.
if (tm && tm.activeLogStreams && typeof tm.stopLogStreaming === 'function') {
for (const id of Array.from(tm.activeLogStreams.keys())) {
try { tm.stopLogStreaming(id); } catch (_) {}
}
}
// DO NOT: stop window.taskEventBus (shared SSE singleton), remove the
// singleton's once-bound task listeners, or null window.tasksManager.
},
});