Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
7d15fa2a22 Merge claude/2 2026-05-31 15:27:29 +01:00
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
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
// task doesn't repaint a torn-down board.
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.systemPage = null;
window.sshPage = null;

View File

@ -7,6 +7,7 @@ class OverviewPage {
this.rootId = rootId;
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this._bound = false;
this._ac = new AbortController();
}
root() { return document.getElementById(this.rootId); }
@ -52,7 +53,7 @@ class OverviewPage {
if (go) { this.go(go.dataset.adminGo); return; }
if (e.target.closest('[data-admin-update]')) { this.runUpdate(); 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
// and re-render so the badge reflects reality without a manual reload.
// 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) {
if (where === 'backup') {
window.spaClean?.navigate('/backup', true);

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@ class AppTabbedManager {
this.tasksManager = new TasksManager();
this.appsManager = new AppsManager();
this.initialized = false;
this._ac = new AbortController(); // backs teardown of the watchdog + popstate listeners (dispose())
// Button state management
this.disabledButtons = new Set();
@ -614,11 +615,11 @@ class AppTabbedManager {
this._reconcileTimer = setTimeout(tick, next);
};
this._reconcileTimer = setTimeout(tick, 1500);
window.addEventListener('taskBusReady', reconcile);
window.addEventListener('focus', reconcile);
window.addEventListener('taskBusReady', reconcile, { signal: this._ac.signal });
window.addEventListener('focus', reconcile, { signal: this._ac.signal });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') reconcile();
});
}, { signal: this._ac.signal });
}
// Set current app from URL BEFORE setting up URL monitoring
@ -687,7 +688,20 @@ class AppTabbedManager {
if (this.currentApp && newAppName !== this.currentApp) {
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

View File

@ -76,7 +76,11 @@ LP.features.register({
},
async unmount() {
// No-op: appsManager / appTabbedManager are shared system-loader singletons.
// The dirty-config nav guard fires in navigate() before unmount.
// appsManager / appTabbedManager are shared singletons (never null them), but
// 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();
}, 100);
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() {
// Nothing this view owns is destroyable from here: the dashboard has no
// handler-newed controller and no system-loader singleton to tear down.
// The pending reload timer is cancelled via the ctx.sub() above (ctx.teardown).
// The 1 Hz LiveSystem SSE sub (attachDashboardLive) self-releases on its
// 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.
// This view's teardown is all registered via ctx.sub() in mount() and runs
// in ctx.teardown(): the pending reload timer, the 1 Hz update-countdown
// interval (stopUpdateCountdown), and this view's LiveSystem subscription
// (detachDashboardLive). No handler-newed controller or singleton to tear down.
},
});

View File

@ -16,6 +16,10 @@ LP.features.register({
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.
@ -31,6 +35,17 @@ LP.features.register({
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.

View File

@ -200,9 +200,11 @@ Object.assign(TasksManager.prototype, {
},
// Start global live log updater - simple 2-second updates for all running tasks
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
setInterval(async () => {
this.globalLiveLogInterval = setInterval(async () => {
// Find all running tasks
const runningTasks = this.tasks.filter(task => task.status === 'running');

View File

@ -92,10 +92,19 @@ Object.assign(TasksManager.prototype, {
this.stopLogStreaming(taskId);
window.removeEventListener('taskUpdated', onUpdate);
window.removeEventListener('taskCompleted', onComplete);
if (this.taskMonitors) this.taskMonitors.delete(taskId);
};
window.addEventListener('taskUpdated', onUpdate);
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
async autoExpandTask(taskId) {

View File

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