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. */
+60
View File
@@ -0,0 +1,60 @@
/**
* @fileoverview Static guard: every SSE dispatch entry must actually resolve.
*
* `app.js` dispatches server events through a table of `[SSE_EVENTS.X, '_onFoo']`
* pairs. Both halves fail SILENTLY when they are wrong:
*
* - a handler name that exists in no module (renamed method, typo) → the event is
* received and nothing happens, with no error anywhere;
* - an `SSE_EVENTS.X` key that `constants.js` does not define → the table key is
* `undefined`, so the entry can never match an incoming event.
*
* Both have happened in this codebase's feature areas (a new banner/toast that simply
* never appears), and neither is visible to a test that only checks the modules compile.
* Pure static analysis — no server, no browser.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const PUBLIC_DIR = fileURLToPath(new URL('../src/web/public', import.meta.url));
const appJs = readFileSync(join(PUBLIC_DIR, 'app.js'), 'utf-8');
const constantsJs = readFileSync(join(PUBLIC_DIR, 'constants.js'), 'utf-8');
const allModules = readdirSync(PUBLIC_DIR)
.filter((name) => name.endsWith('.js'))
.map((name) => readFileSync(join(PUBLIC_DIR, name), 'utf-8'))
.join('\n');
/** `[SSE_EVENTS.FOO, '_onFoo'],` entries of the dispatch table. */
function dispatchEntries(): { constant: string; handler: string }[] {
const entries: { constant: string; handler: string }[] = [];
const re = /\[SSE_EVENTS\.([A-Z0-9_]+),\s*'(_[A-Za-z0-9_]+)'\]/g;
for (const match of appJs.matchAll(re)) {
entries.push({ constant: match[1], handler: match[2] });
}
return entries;
}
describe('SSE dispatch table', () => {
it('has entries to check (the table is what this guard exists for)', () => {
expect(dispatchEntries().length).toBeGreaterThan(20);
});
it('names only events that constants.js defines', () => {
const defined = new Set([...constantsJs.matchAll(/^\s{2}([A-Z0-9_]+):\s*'/gm)].map((m) => m[1]));
const missing = dispatchEntries()
.map((entry) => entry.constant)
.filter((name) => !defined.has(name));
expect(missing).toEqual([]);
});
it('names only handlers that some frontend module actually defines', () => {
const missing = dispatchEntries()
.map((entry) => entry.handler)
.filter((handler) => !new RegExp(`(^|\\s)${handler}\\s*\\(`, 'm').test(allModules));
expect(missing).toEqual([]);
});
});