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>
This commit is contained in:
librelad 2026-05-31 15:27:29 +01:00
parent 429ec419cf
commit 306e6223c0
12 changed files with 91 additions and 20 deletions

View File

@ -49,6 +49,12 @@ LP.features.register({
// Drop OverviewPage's task-refresh registration so a finished verify/update // Drop OverviewPage's task-refresh registration so a finished verify/update
// task doesn't repaint a torn-down board. // task doesn't repaint a torn-down board.
try { ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview-page'); } catch (_) {} try { ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview-page'); } catch (_) {}
// Each sub-page binds a document-level click listener; nulling the global
// alone leaks it (the backup-stacking bug class), so abort each first.
try { window.overviewPage && window.overviewPage.dispose && window.overviewPage.dispose(); } catch (_) {}
try { window.systemPage && window.systemPage.dispose && window.systemPage.dispose(); } catch (_) {}
try { window.sshPage && window.sshPage.dispose && window.sshPage.dispose(); } catch (_) {}
try { window.peersPage && window.peersPage.dispose && window.peersPage.dispose(); } catch (_) {}
window.overviewPage = null; window.overviewPage = null;
window.systemPage = null; window.systemPage = null;
window.sshPage = null; window.sshPage = null;

View File

@ -7,6 +7,7 @@ class OverviewPage {
this.rootId = rootId; this.rootId = rootId;
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this._bound = false; this._bound = false;
this._ac = new AbortController();
} }
root() { return document.getElementById(this.rootId); } root() { return document.getElementById(this.rootId); }
@ -52,7 +53,7 @@ class OverviewPage {
if (go) { this.go(go.dataset.adminGo); return; } if (go) { this.go(go.dataset.adminGo); return; }
if (e.target.closest('[data-admin-update]')) { this.runUpdate(); return; } if (e.target.closest('[data-admin-update]')) { this.runUpdate(); return; }
if (e.target.closest('[data-admin-verify]')) { this.runVerify(); return; } if (e.target.closest('[data-admin-verify]')) { this.runVerify(); return; }
}); }, { signal: this._ac.signal });
// When a verify or update task finishes, re-read the integrity status // When a verify or update task finishes, re-read the integrity status
// and re-render so the badge reflects reality without a manual reload. // and re-render so the badge reflects reality without a manual reload.
// Registered with the task-refresh coordinator (single source of truth). // Registered with the task-refresh coordinator (single source of truth).
@ -65,6 +66,8 @@ class OverviewPage {
}); });
} }
dispose() { try { this._ac && this._ac.abort(); } catch (_) {} }
go(where) { go(where) {
if (where === 'backup') { if (where === 'backup') {
window.spaClean?.navigate('/backup', true); window.spaClean?.navigate('/backup', true);

View File

@ -14,6 +14,7 @@ class PeersPage {
this.backupLocations = []; // populated for the loc_idx dropdown this.backupLocations = []; // populated for the loc_idx dropdown
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this.eventBound = false; this.eventBound = false;
this._ac = new AbortController();
} }
async init() { async init() {
@ -71,9 +72,11 @@ class PeersPage {
this.closeAllModals(); this.closeAllModals();
return; return;
} }
}); }, { signal: this._ac.signal });
} }
dispose() { try { this._ac && this._ac.abort(); } catch (_) {} }
render() { render() {
const list = document.getElementById('peers-list'); const list = document.getElementById('peers-list');
const empty = document.getElementById('peers-empty'); const empty = document.getElementById('peers-empty');

View File

@ -10,6 +10,7 @@ class SshPage {
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this.data = null; this.data = null;
this._bound = false; this._bound = false;
this._ac = new AbortController();
} }
root() { return document.getElementById(this.rootId); } root() { return document.getElementById(this.rootId); }
@ -51,9 +52,11 @@ class SshPage {
if (rm) { this.removeKey(rm.dataset.fp); return; } if (rm) { this.removeKey(rm.dataset.fp); return; }
const tog = e.target.closest('[data-action="ssh-toggle-password"]'); const tog = e.target.closest('[data-action="ssh-toggle-password"]');
if (tog) { this.togglePassword(tog.dataset.next); return; } if (tog) { this.togglePassword(tog.dataset.next); return; }
}); }, { signal: this._ac.signal });
} }
dispose() { try { this._ac && this._ac.abort(); } catch (_) {} }
render() { render() {
const root = this.root(); const root = this.root();
if (!root) return; if (!root) return;

View File

@ -28,6 +28,7 @@ class SystemPage {
this.d = {}; this.d = {};
// Active sub-view renderer. Disposed on each init(). // Active sub-view renderer. Disposed on each init().
this._subview = null; this._subview = null;
this._ac = new AbortController();
} }
root() { return document.getElementById(this.rootId); } root() { return document.getElementById(this.rootId); }
@ -157,9 +158,11 @@ class SystemPage {
if (window.navigateToRoute) window.navigateToRoute('/admin/system/storage'); if (window.navigateToRoute) window.navigateToRoute('/admin/system/storage');
return; return;
} }
}); }, { signal: this._ac.signal });
} }
dispose() { try { this._ac && this._ac.abort(); } catch (_) {} }
/* ---- formatting helpers (used by sub-pages via window.SystemFmt) ---- */ /* ---- formatting helpers (used by sub-pages via window.SystemFmt) ---- */
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); } escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
bytes(n) { bytes(n) {

View File

@ -30,6 +30,7 @@ class AppTabbedManager {
this.tasksManager = new TasksManager(); this.tasksManager = new TasksManager();
this.appsManager = new AppsManager(); this.appsManager = new AppsManager();
this.initialized = false; this.initialized = false;
this._ac = new AbortController(); // backs teardown of the watchdog + popstate listeners (dispose())
// Button state management // Button state management
this.disabledButtons = new Set(); this.disabledButtons = new Set();
@ -614,11 +615,11 @@ class AppTabbedManager {
this._reconcileTimer = setTimeout(tick, next); this._reconcileTimer = setTimeout(tick, next);
}; };
this._reconcileTimer = setTimeout(tick, 1500); this._reconcileTimer = setTimeout(tick, 1500);
window.addEventListener('taskBusReady', reconcile); window.addEventListener('taskBusReady', reconcile, { signal: this._ac.signal });
window.addEventListener('focus', reconcile); window.addEventListener('focus', reconcile, { signal: this._ac.signal });
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') reconcile(); if (document.visibilityState === 'visible') reconcile();
}); }, { signal: this._ac.signal });
} }
// Set current app from URL BEFORE setting up URL monitoring // Set current app from URL BEFORE setting up URL monitoring
@ -687,7 +688,20 @@ class AppTabbedManager {
if (this.currentApp && newAppName !== this.currentApp) { if (this.currentApp && newAppName !== this.currentApp) {
this.updateApp(newAppName); this.updateApp(newAppName);
} }
}); }, { signal: this._ac.signal });
}
// Release everything bound for this mount so navigating away doesn't leave the
// reconcile loop + window/document listeners firing on the stale singleton (the
// backup-stacking bug class). Re-armed on the next initialize().
dispose() {
try { this._ac && this._ac.abort(); } catch (_) {}
this._ac = new AbortController();
if (this._reconcileTimer) { clearTimeout(this._reconcileTimer); this._reconcileTimer = null; }
this._watchdogStarted = false;
this._listenersWired = false;
try { window.servicesManager && window.servicesManager.unload && window.servicesManager.unload(); } catch (_) {}
try { window.toolsManager && window.toolsManager.unload && window.toolsManager.unload(); } catch (_) {}
} }
// Watch for new tasks and switch to logs tab // Watch for new tasks and switch to logs tab

View File

@ -76,7 +76,11 @@ LP.features.register({
}, },
async unmount() { async unmount() {
// No-op: appsManager / appTabbedManager are shared system-loader singletons. // appsManager / appTabbedManager are shared singletons (never null them), but
// The dirty-config nav guard fires in navigate() before unmount. // the detail view's per-mount resources DO need releasing: the reconcile loop,
// the watchdog window/document listeners, and the active tab's Services
// intervals + log SSE. dispose() handles all of it (re-armed on next mount).
// The dirty-config nav guard still fires in navigate() before unmount.
try { window.appTabbedManager && window.appTabbedManager.dispose && window.appTabbedManager.dispose(); } catch (_) {}
}, },
}); });

View File

@ -27,16 +27,18 @@ LP.features.register({
if (typeof loadDashboardData === 'function') loadDashboardData(); if (typeof loadDashboardData === 'function') loadDashboardData();
}, 100); }, 100);
ctx.sub(() => clearTimeout(reloadTimer)); ctx.sub(() => clearTimeout(reloadTimer));
// Stop the 1 Hz update-countdown interval + drop this view's LiveSystem
// subscription on navigation away — both are module-private in data-loader.js
// and otherwise outlive the page (the leak class). ctx.teardown runs these.
ctx.sub(() => { if (typeof stopUpdateCountdown === 'function') stopUpdateCountdown(); });
ctx.sub(() => { if (typeof detachDashboardLive === 'function') detachDashboardLive(); });
}, },
async unmount() { async unmount() {
// Nothing this view owns is destroyable from here: the dashboard has no // This view's teardown is all registered via ctx.sub() in mount() and runs
// handler-newed controller and no system-loader singleton to tear down. // in ctx.teardown(): the pending reload timer, the 1 Hz update-countdown
// The pending reload timer is cancelled via the ctx.sub() above (ctx.teardown). // interval (stopUpdateCountdown), and this view's LiveSystem subscription
// The 1 Hz LiveSystem SSE sub (attachDashboardLive) self-releases on its // (detachDashboardLive). No handler-newed controller or singleton to tear down.
// next sample once the dashboard DOM is gone, and the 1 s update countdown
// is a module-private interval with no exported stopper (pre-existing; it
// self-clears on the next dashboard mount). Both are noted for the Phase 5
// dashboard cleanup; neither is reachable here.
}, },
}); });

View File

@ -16,6 +16,10 @@ LP.features.register({
if (window.tasksManager) { if (window.tasksManager) {
await window.tasksManager.init(); 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 { } else {
// Don't throw — matches handleTasks: the page still renders, task // Don't throw — matches handleTasks: the page still renders, task
// functionality is just limited until the task-system component is ready. // functionality is just limited until the task-system component is ready.
@ -31,6 +35,17 @@ LP.features.register({
clearInterval(tm.refreshInterval); clearInterval(tm.refreshInterval);
tm.refreshInterval = null; 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 // Stop any open per-task log streams this view started (each removes its own
// SSE listeners + map entry). Snapshot keys first — stopLogStreaming mutates // SSE listeners + map entry). Snapshot keys first — stopLogStreaming mutates
// the map. Does NOT touch the shared bus. // the map. Does NOT touch the shared bus.

View File

@ -200,9 +200,11 @@ Object.assign(TasksManager.prototype, {
}, },
// Start global live log updater - simple 2-second updates for all running tasks // Start global live log updater - simple 2-second updates for all running tasks
startGlobalLiveLogUpdater() { startGlobalLiveLogUpdater() {
// Idempotent + keep the handle so unmount can clear it (it was discarded
// before — a permanent 2 s poll that outlived the page).
if (this.globalLiveLogInterval) return;
// Update every 2 seconds // Update every 2 seconds
setInterval(async () => { this.globalLiveLogInterval = setInterval(async () => {
// Find all running tasks // Find all running tasks
const runningTasks = this.tasks.filter(task => task.status === 'running'); const runningTasks = this.tasks.filter(task => task.status === 'running');

View File

@ -92,10 +92,19 @@ Object.assign(TasksManager.prototype, {
this.stopLogStreaming(taskId); this.stopLogStreaming(taskId);
window.removeEventListener('taskUpdated', onUpdate); window.removeEventListener('taskUpdated', onUpdate);
window.removeEventListener('taskCompleted', onComplete); window.removeEventListener('taskCompleted', onComplete);
if (this.taskMonitors) this.taskMonitors.delete(taskId);
}; };
window.addEventListener('taskUpdated', onUpdate); window.addEventListener('taskUpdated', onUpdate);
window.addEventListener('taskCompleted', onComplete); window.addEventListener('taskCompleted', onComplete);
// Track this monitor so the tasks module's unmount() can release it if the
// user navigates away while the task is still running (these window
// listeners + interval otherwise outlive the page until the task completes).
(this.taskMonitors = this.taskMonitors || new Map()).set(taskId, () => {
if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; }
window.removeEventListener('taskUpdated', onUpdate);
window.removeEventListener('taskCompleted', onComplete);
});
}, },
// Auto-expand a task when it's created // Auto-expand a task when it's created
async autoExpandTask(taskId) { async autoExpandTask(taskId) {

View File

@ -215,6 +215,10 @@ async function loadDashboardData() {
// Countdown timer for next automatic update // Countdown timer for next automatic update
let updateCountdownInterval = null; let updateCountdownInterval = null;
function stopUpdateCountdown() {
if (updateCountdownInterval) { clearInterval(updateCountdownInterval); updateCountdownInterval = null; }
}
function startUpdateCountdown() { function startUpdateCountdown() {
// Clear any existing countdown // Clear any existing countdown
if (updateCountdownInterval) { if (updateCountdownInterval) {
@ -467,6 +471,9 @@ async function loadSystemInfo() {
// ticker feeds both. Cleanup hangs off a route-change check: if the dashboard // ticker feeds both. Cleanup hangs off a route-change check: if the dashboard
// DOM goes away we drop the sub on the next sample. // DOM goes away we drop the sub on the next sample.
let _dashboardLiveUnsub = null; let _dashboardLiveUnsub = null;
function detachDashboardLive() {
if (typeof _dashboardLiveUnsub === 'function') { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; }
}
function attachDashboardLive() { function attachDashboardLive() {
if (!window.LiveSystem) return; if (!window.LiveSystem) return;
if (_dashboardLiveUnsub) { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; } if (_dashboardLiveUnsub) { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; }