fix(remote): make the wake banner's poller page-wide and independent of tab switches

Reported as 'the tab shows no banner' while the host was verifiably unreachable: the
banner only started polling from selectSession, which RETURNS EARLY for the tab you
are already on (so a page loaded with the remote tab active never polled), and a
long-lived tab keeps running the JS it loaded — the feature was invisible to anyone
who did not switch tabs after the deploy.

The poller is now page-wide: one interval (created on init and on the first session
switch), re-targeted whenever the active session changes, plus a visibilitychange
wake-up. It no longer depends on any single selection path running.

Also adds test/sse-dispatch-table.test.ts: a static guard that every
[SSE_EVENTS.X, '_onFoo'] entry names an event constants.js defines AND a handler some
module defines. Both halves fail silently (a typo'd constant is an undefined table
key; a renamed handler just never runs), which is exactly how a new banner can never
appear with no error anywhere.
This commit is contained in:
Randalix
2026-09-15 15:11:36 +02:00
parent e8f7772320
commit 1380b023e2
3 changed files with 109 additions and 21 deletions
+3
View File
@@ -1815,6 +1815,9 @@ class CodemanApp {
_onInit(data) {
_crashDiag.log(`INIT: ${data.sessions?.length || 0} sessions`);
this.handleInit(data);
// Start the remote-host reachability poller even if no session switch follows
// (a page loaded with the remote tab already active) — see host-wake-ui.js.
this._ensureHostWakePoller?.();
}
_onSessionCreated(data) {
+46 -21
View File
@@ -30,12 +30,13 @@ const HOST_WAKE_POLL_MS = 30_000;
Object.assign(CodemanApp.prototype, {
/** Per-tab banner state (single active session at a time). */
_hostWake: null,
/** The page-wide poller interval (created once, see `_ensureHostWakePoller`). */
_hostWakeTimer: null,
/** Fresh state for a session we just switched to. */
_hostWakeState() {
return {
sessionId: null,
timer: null,
/** Last reachability answer, or null before the first poll. */
reachable: null,
/** 'command' | 'mac' | 'none' — what the banner action should do. */
@@ -52,33 +53,57 @@ Object.assign(CodemanApp.prototype, {
/**
* Entry point from the session switcher — called for every active session, remote or
* not, so it must be cheap and must clear the banner for local sessions.
*
* ⚠️ The POLLER is page-wide and independent of this call on purpose: a session
* switch is not the only way the active tab changes (boot restore, a page loaded with
* the tab already active, and `selectSession`'s own early return for the tab you are
* already on), and the banner must not depend on any single one of those paths
* running — that is exactly how it could silently never appear.
*/
refreshHostWakeBanner(sessionId) {
const state = (this._hostWake = this._hostWakeState());
if (state.timer) clearInterval(state.timer);
state.sessionId = sessionId || null;
this._ensureHostWakePoller();
const state = this._hostWake;
if (state && state.sessionId && state.sessionId !== sessionId) this._hostWake = null;
this._hostWakeTick();
},
/** Create the page-wide poller once (interval + a visibility wake-up). */
_ensureHostWakePoller() {
if (this._hostWakeTimer) return;
this._hostWakeTimer = setInterval(() => this._hostWakeTick(), HOST_WAKE_POLL_MS);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this._hostWakeTick();
});
},
/**
* One poller tick: resolve the ACTIVE session, reset the banner when it changed, and
* ask the server. No-op while the page is hidden (a background tab must not poll).
*/
_hostWakeTick() {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
const sessionId = this.activeSessionId;
const session = sessionId && this.sessions ? this.sessions.get(sessionId) : null;
if (!session || !session.remote) {
this._renderHostWakeBanner();
if (!sessionId || !session || !session.remote) {
if (this._hostWake) {
this._hostWake = null;
this._renderHostWakeBanner();
}
return;
}
state.host = session.remote.host || '';
state.label = session.remote.label || 'Remote host';
// Text from the session payload first (instant, no round trip), corrected by the
// poll — a session whose wake config was added after launch only knows it after
// the server resolves host config.
state.wakeConfigured = session.remote.wakeMac || session.remote.wakeCommand ? 'mac' : 'none';
this._renderHostWakeBanner();
let state = this._hostWake;
if (!state || state.sessionId !== sessionId) {
state = this._hostWake = this._hostWakeState();
state.sessionId = sessionId;
state.host = session.remote.host || '';
state.label = session.remote.label || 'Remote host';
// Text from the session payload first (instant, no round trip), corrected by the
// poll — a session whose wake config was added after launch only knows it after
// the server resolves host config.
state.wakeConfigured = session.remote.wakeMac || session.remote.wakeCommand ? 'mac' : 'none';
this._renderHostWakeBanner();
}
this._pollHostReachability();
if (state.timer) clearInterval(state.timer);
state.timer = setInterval(() => {
if (document.visibilityState === 'hidden') return;
if (this.activeSessionId !== state.sessionId) return;
this._pollHostReachability();
}, HOST_WAKE_POLL_MS);
},
/** One reachability check for the active remote session. */