From 1380b023e2a9e77b8e60a0d0eb690049f495cfeb Mon Sep 17 00:00:00 2001 From: Randalix Date: Tue, 15 Sep 2026 15:11:36 +0200 Subject: [PATCH] fix(remote): make the wake banner's poller page-wide and independent of tab switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/web/public/app.js | 3 ++ src/web/public/host-wake-ui.js | 67 ++++++++++++++++++++++----------- test/sse-dispatch-table.test.ts | 60 +++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 21 deletions(-) create mode 100644 test/sse-dispatch-table.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index d03d6853..1e1c9c63 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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) { diff --git a/src/web/public/host-wake-ui.js b/src/web/public/host-wake-ui.js index 03611285..8f222e77 100644 --- a/src/web/public/host-wake-ui.js +++ b/src/web/public/host-wake-ui.js @@ -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. */ diff --git a/test/sse-dispatch-table.test.ts b/test/sse-dispatch-table.test.ts new file mode 100644 index 00000000..b42b8213 --- /dev/null +++ b/test/sse-dispatch-table.test.ts @@ -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([]); + }); +});