diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 89aa9be..6d10a00 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -279,12 +279,19 @@ async function pollSessions() { } /** - * Start the session polling interval. Guards against double-start. + * Start the session polling loop. Guards against double-start. + * Uses self-scheduling setTimeout so at most one poll is in-flight at a time. + * If a poll takes longer than POLL_MS, the next poll starts POLL_MS after it + * finishes — never while it is still running. */ function startPolling() { if (_pollingTimer) return; - pollSessions(); - _pollingTimer = setInterval(pollSessions, POLL_MS); + _pollingTimer = true; // sentinel: prevents double-start before first setTimeout fires + async function pollLoop() { + await pollSessions(); + _pollingTimer = setTimeout(pollLoop, POLL_MS); + } + pollLoop(); } // ─── Grid rendering ────────────────────────────────────────────────────────── @@ -989,18 +996,23 @@ async function sendHeartbeat() { } /** - * Start the heartbeat interval. Guards against double-start. - * Calls sendHeartbeat() immediately, then every HEARTBEAT_MS milliseconds. + * Start the heartbeat loop. Guards against double-start. + * Uses self-scheduling setTimeout so at most one heartbeat is in-flight at a time. + * Calls sendHeartbeat() immediately, then HEARTBEAT_MS after each completion. */ function startHeartbeat() { if (_heartbeatTimer) return; - sendHeartbeat(); - _heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_MS); + _heartbeatTimer = true; // sentinel: prevents double-start before first setTimeout fires + async function heartbeatLoop() { + await sendHeartbeat(); + _heartbeatTimer = setTimeout(heartbeatLoop, HEARTBEAT_MS); + } + heartbeatLoop(); } /** Test-only helper: reset heartbeat timer state so tests can exercise startHeartbeat cleanly. */ function _resetHeartbeatTimer() { - if (_heartbeatTimer) clearInterval(_heartbeatTimer); + if (_heartbeatTimer) clearTimeout(_heartbeatTimer); _heartbeatTimer = undefined; } diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 6a3e6ba..2d1708e 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -441,24 +441,28 @@ test('pollSessions uses /api/sessions when multi_device_enabled is false', async // --- startPolling --- -test('startPolling guards against double-start (only creates one interval)', () => { - const intervals = []; - const origSetInterval = globalThis.setInterval; - globalThis.setInterval = (fn, ms) => { - intervals.push(ms); +test('startPolling guards against double-start (only starts one poll loop)', async () => { + const timeouts = []; + const origSetTimeout = globalThis.setTimeout; + // Capture scheduling calls and prevent real timers from firing + globalThis.setTimeout = (fn, ms) => { + timeouts.push(ms); return Symbol('timer'); }; const origFetch = globalThis.fetch; globalThis.fetch = async () => ({ ok: true, json: async () => [] }); - // First call should create the interval; second should be a no-op + // First call should start one pollLoop; second should be a no-op (sentinel guard) app.startPolling(); app.startPolling(); - assert.strictEqual(intervals.length, 1, 'startPolling should create exactly one interval'); - assert.strictEqual(intervals[0], 2000, 'interval should be POLL_MS (2000ms)'); + // Yield microtasks so the async pollLoop can complete and schedule the next timeout + await new Promise((r) => origSetTimeout(r, 0)); - globalThis.setInterval = origSetInterval; + assert.strictEqual(timeouts.length, 1, 'startPolling should schedule exactly one setTimeout for rescheduling'); + assert.strictEqual(timeouts[0], 2000, 'setTimeout delay should be POLL_MS (2000ms)'); + + globalThis.setTimeout = origSetTimeout; globalThis.fetch = origFetch; }); @@ -857,11 +861,12 @@ test('startHeartbeat is exported', () => { assert.strictEqual(typeof app.startHeartbeat, 'function'); }); -test('startHeartbeat guards against double-start (only creates one interval)', () => { - const intervals = []; - const origSetInterval = globalThis.setInterval; - globalThis.setInterval = (fn, ms) => { - intervals.push(ms); +test('startHeartbeat guards against double-start (only starts one heartbeat loop)', async () => { + const timeouts = []; + const origSetTimeout = globalThis.setTimeout; + // Capture scheduling calls and prevent real timers from firing + globalThis.setTimeout = (fn, ms) => { + timeouts.push(ms); return Symbol('heartbeat-timer'); }; const origFetch = globalThis.fetch; @@ -869,14 +874,17 @@ test('startHeartbeat guards against double-start (only creates one interval)', ( app._resetHeartbeatTimer(); // ensure clean state regardless of test order app.startHeartbeat(); - app.startHeartbeat(); // second call should be a no-op + app.startHeartbeat(); // second call should be a no-op (sentinel guard) - assert.strictEqual(intervals.length, 1, 'startHeartbeat should create exactly one interval'); - assert.strictEqual(intervals[0], 5000, 'interval should be HEARTBEAT_MS (5000ms)'); + // Yield microtasks so the async heartbeatLoop can complete and schedule the next timeout + await new Promise((r) => origSetTimeout(r, 0)); - globalThis.setInterval = origSetInterval; + assert.strictEqual(timeouts.length, 1, 'startHeartbeat should schedule exactly one setTimeout for rescheduling'); + assert.strictEqual(timeouts[0], 5000, 'setTimeout delay should be HEARTBEAT_MS (5000ms)'); + + globalThis.setTimeout = origSetTimeout; globalThis.fetch = origFetch; - // _heartbeatTimer is now set; next test using startHeartbeat must call _resetHeartbeatTimer() + // _heartbeatTimer is now set to Symbol; next test using startHeartbeat must call _resetHeartbeatTimer() }); test('startHeartbeat calls sendHeartbeat immediately (calls fetch for heartbeat)', async () => { @@ -4804,3 +4812,27 @@ test('renderGrid shows "No sessions" status tile for status=empty devices', () = globalThis.document.getElementById = origGetById; }); + +// --- async-safe polling: setTimeout not setInterval --- + +test('startPolling uses setTimeout not setInterval for async-safe polling', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function startPolling'); + const fnEnd = source.indexOf('\n}\n', fnStart + 20); + const fnBody = source.substring(fnStart, fnEnd + 3); + assert.ok(!fnBody.includes('setInterval'), + 'startPolling must NOT use setInterval — causes overlapping async requests'); + assert.ok(fnBody.includes('setTimeout'), + 'startPolling must use setTimeout for self-scheduling async poll loop'); +}); + +test('heartbeat uses setTimeout not setInterval for async-safe scheduling', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function startHeartbeat'); + const fnEnd = source.indexOf('\n}\n', fnStart + 20); + const fnBody = source.substring(fnStart, fnEnd + 3); + assert.ok(!fnBody.includes('setInterval'), + 'startHeartbeat must NOT use setInterval — causes overlapping async requests'); + assert.ok(fnBody.includes('setTimeout'), + 'startHeartbeat must use setTimeout for self-scheduling async loop'); +});