fix: replace setInterval with self-scheduling setTimeout for async polls
setInterval fires regardless of whether the previous async call has completed. When federation requests time out (5s) during 2s poll cycles, multiple requests stack up. Chrome's 6-connection-per-origin limit is quickly exhausted → ERR_INSUFFICIENT_RESOURCES → death spiral. Fix: self-scheduling setTimeout pattern — poll completes → wait → next poll. At most one in-flight request at a time. Applied to both pollSessions and sendHeartbeat loops. A sentinel (true) is set on _pollingTimer/_heartbeatTimer immediately in startPolling/startHeartbeat so the double-start guard works even during the first async call before the real timer ID is assigned.
This commit is contained in:
+20
-8
@@ -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() {
|
function startPolling() {
|
||||||
if (_pollingTimer) return;
|
if (_pollingTimer) return;
|
||||||
pollSessions();
|
_pollingTimer = true; // sentinel: prevents double-start before first setTimeout fires
|
||||||
_pollingTimer = setInterval(pollSessions, POLL_MS);
|
async function pollLoop() {
|
||||||
|
await pollSessions();
|
||||||
|
_pollingTimer = setTimeout(pollLoop, POLL_MS);
|
||||||
|
}
|
||||||
|
pollLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Grid rendering ──────────────────────────────────────────────────────────
|
// ─── Grid rendering ──────────────────────────────────────────────────────────
|
||||||
@@ -989,18 +996,23 @@ async function sendHeartbeat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the heartbeat interval. Guards against double-start.
|
* Start the heartbeat loop. Guards against double-start.
|
||||||
* Calls sendHeartbeat() immediately, then every HEARTBEAT_MS milliseconds.
|
* 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() {
|
function startHeartbeat() {
|
||||||
if (_heartbeatTimer) return;
|
if (_heartbeatTimer) return;
|
||||||
sendHeartbeat();
|
_heartbeatTimer = true; // sentinel: prevents double-start before first setTimeout fires
|
||||||
_heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_MS);
|
async function heartbeatLoop() {
|
||||||
|
await sendHeartbeat();
|
||||||
|
_heartbeatTimer = setTimeout(heartbeatLoop, HEARTBEAT_MS);
|
||||||
|
}
|
||||||
|
heartbeatLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-only helper: reset heartbeat timer state so tests can exercise startHeartbeat cleanly. */
|
/** Test-only helper: reset heartbeat timer state so tests can exercise startHeartbeat cleanly. */
|
||||||
function _resetHeartbeatTimer() {
|
function _resetHeartbeatTimer() {
|
||||||
if (_heartbeatTimer) clearInterval(_heartbeatTimer);
|
if (_heartbeatTimer) clearTimeout(_heartbeatTimer);
|
||||||
_heartbeatTimer = undefined;
|
_heartbeatTimer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -441,24 +441,28 @@ test('pollSessions uses /api/sessions when multi_device_enabled is false', async
|
|||||||
|
|
||||||
// --- startPolling ---
|
// --- startPolling ---
|
||||||
|
|
||||||
test('startPolling guards against double-start (only creates one interval)', () => {
|
test('startPolling guards against double-start (only starts one poll loop)', async () => {
|
||||||
const intervals = [];
|
const timeouts = [];
|
||||||
const origSetInterval = globalThis.setInterval;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
globalThis.setInterval = (fn, ms) => {
|
// Capture scheduling calls and prevent real timers from firing
|
||||||
intervals.push(ms);
|
globalThis.setTimeout = (fn, ms) => {
|
||||||
|
timeouts.push(ms);
|
||||||
return Symbol('timer');
|
return Symbol('timer');
|
||||||
};
|
};
|
||||||
const origFetch = globalThis.fetch;
|
const origFetch = globalThis.fetch;
|
||||||
globalThis.fetch = async () => ({ ok: true, json: async () => [] });
|
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();
|
||||||
app.startPolling();
|
app.startPolling();
|
||||||
|
|
||||||
assert.strictEqual(intervals.length, 1, 'startPolling should create exactly one interval');
|
// Yield microtasks so the async pollLoop can complete and schedule the next timeout
|
||||||
assert.strictEqual(intervals[0], 2000, 'interval should be POLL_MS (2000ms)');
|
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;
|
globalThis.fetch = origFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -857,11 +861,12 @@ test('startHeartbeat is exported', () => {
|
|||||||
assert.strictEqual(typeof app.startHeartbeat, 'function');
|
assert.strictEqual(typeof app.startHeartbeat, 'function');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('startHeartbeat guards against double-start (only creates one interval)', () => {
|
test('startHeartbeat guards against double-start (only starts one heartbeat loop)', async () => {
|
||||||
const intervals = [];
|
const timeouts = [];
|
||||||
const origSetInterval = globalThis.setInterval;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
globalThis.setInterval = (fn, ms) => {
|
// Capture scheduling calls and prevent real timers from firing
|
||||||
intervals.push(ms);
|
globalThis.setTimeout = (fn, ms) => {
|
||||||
|
timeouts.push(ms);
|
||||||
return Symbol('heartbeat-timer');
|
return Symbol('heartbeat-timer');
|
||||||
};
|
};
|
||||||
const origFetch = globalThis.fetch;
|
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._resetHeartbeatTimer(); // ensure clean state regardless of test order
|
||||||
app.startHeartbeat();
|
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');
|
// Yield microtasks so the async heartbeatLoop can complete and schedule the next timeout
|
||||||
assert.strictEqual(intervals[0], 5000, 'interval should be HEARTBEAT_MS (5000ms)');
|
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;
|
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 () => {
|
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;
|
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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user