Skip to content

Commit 389711a

Browse files
committed
feat(dev,html-app): pause long-polling when Webhooks HTML app tab is hidden
1 parent 9dbaae9 commit 389711a

2 files changed

Lines changed: 121 additions & 8 deletions

File tree

dev/tools/AGENTS.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2400,6 +2400,78 @@ exception still releases the lock. The Parse-button restoration in the
24002400
`finally` is separate (`els.parseBtn.disabled = !pdfFile`) because their
24012401
disabled state mirrors file-loaded state, not the snapshot.
24022402
2403+
## Live updates / polling (visibility-aware)
2404+
2405+
Any tool that polls or long-polls a backend (repeated `fetch`, `EventSource`, or a
2406+
long-poll loop like the webhook inspector's `secutils.kv.watch`) **MUST**:
2407+
2408+
- **Abort the in-flight request and stop scheduling new ones when the tab is hidden**
2409+
(`visibilitychange` -> `document.hidden`) and on `pagehide` (navigation / close / bfcache).
2410+
- **Re-establish from the last cursor when the tab becomes visible again.**
2411+
2412+
Rationale: a backgrounded or closed tab that keeps polling wastes server work for output
2413+
nobody is looking at. For a long-poll it is worse - each open request parks a **server-side
2414+
V8 isolate (+ worker thread)** for the whole window, and with the safe actix default
2415+
(`h1_allow_half_closed = true`) the server **cannot** detect the client disconnect on its own
2416+
(it only notices on its next socket write, which a parked long-poll never does). So the client
2417+
is the only party that can promptly close the connection - it must do so itself.
2418+
2419+
Model the loop as two pieces of state: **intent** (the user toggled it on; drives the toggle
2420+
pill) and **running** (a request loop is actually open). Pausing for visibility clears
2421+
*running* but preserves *intent*, so it resumes transparently.
2422+
2423+
Canonical shape (reference implementation: [`dev/tools/webhook.html`](webhook.html)):
2424+
2425+
```js
2426+
let abort = null; // AbortController for the in-flight request
2427+
let running = false; // a request loop is actually open
2428+
let enabled = false; // user intent (drives the toggle UI)
2429+
2430+
// Record intent + reflect it in the UI, but only open a connection while visible.
2431+
function start() {
2432+
enabled = true;
2433+
// ...reflect enabled in the toggle UI here...
2434+
if (!document.hidden) runLoop();
2435+
}
2436+
function runLoop() {
2437+
if (running) return; // idempotent
2438+
running = true;
2439+
abort = new AbortController();
2440+
(async () => {
2441+
while (running /* && still-current */) {
2442+
try {
2443+
const res = await fetch(url, { signal: abort.signal });
2444+
/* ...handle response, advance cursor... */
2445+
} catch (e) {
2446+
if (e.name === 'AbortError') break;
2447+
await sleep(2000);
2448+
}
2449+
}
2450+
})();
2451+
}
2452+
// Pause WITHOUT clearing intent, so visibility can resume it from the last cursor.
2453+
function pauseLoop() {
2454+
running = false;
2455+
if (abort) { try { abort.abort(); } catch {} abort = null; }
2456+
}
2457+
// Explicit user-off: clears intent + UI, then pauses.
2458+
function stop() {
2459+
enabled = false;
2460+
// ...reflect disabled in the toggle UI here...
2461+
pauseLoop();
2462+
}
2463+
2464+
document.addEventListener('visibilitychange', () => {
2465+
if (document.hidden) { if (running) pauseLoop(); }
2466+
else if (enabled && !running) runLoop();
2467+
});
2468+
window.addEventListener('pagehide', () => { if (running) pauseLoop(); });
2469+
```
2470+
2471+
**Agent instruction:** when building a new tool, or adding any polling / long-polling to an
2472+
existing one, implement this pattern and **explicitly confirm with the developer** that the
2473+
visibility-abort + resume wiring is in place before considering the tool done.
2474+
24032475
## Pre-deploy verification
24042476
24052477
After editing any tool, run these checks before `make deploy-tools`. They take seconds,

dev/tools/webhook.html

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@
6363
// *is* enabled it only clamps the deadline shorter; it can never extend it.
6464
const LIFESPAN_FALLBACK_SEC = 604800; // 7 days.
6565
const PAGE_LIMIT = 200;
66-
const WATCH_TIMEOUT_MS = 25000;
66+
// Long-poll window. Kept short so a watch that ever outlives its client (e.g. an abrupt
67+
// kill the client can't intercept) parks a server-side V8 isolate for at most this long
68+
// before the worker is reclaimed. The client also aborts proactively on hide/close.
69+
const WATCH_TIMEOUT_MS = 15000;
6770
// Remaining seconds until the webhook's absolute deadline (`exp`, unix seconds),
6871
// so dependent writes never outlive the key. Floors at 1s so a near-deadline
6972
// write still lands rather than being rejected as a zero/negative TTL.
@@ -1151,7 +1154,9 @@ <h2 id="confirmDialogTitle">Confirm</h2>
11511154
}
11521155

11531156
async function activateSession(session, { register }) {
1154-
stopLive();
1157+
// Pause (not stop) so the user's live intent carries over and resumes for the new
1158+
// session via the `if (liveEnabled) startLive()` below.
1159+
pauseLoop();
11551160
current = session;
11561161
setActive(session.t);
11571162
upsertSession(session);
@@ -1334,11 +1339,24 @@ <h2 id="confirmDialogTitle">Confirm</h2>
13341339
}
13351340

13361341
// ---- live watch (long-poll loop) ----
1342+
// Three-state model: `liveEnabled` is the user's intent (pill state), `liveRunning` is
1343+
// whether a fetch loop is actually open. We keep these separate so the loop can be paused
1344+
// (e.g. while the tab is hidden) without losing the user's intent, and transparently
1345+
// resumed when the tab becomes visible again.
1346+
1347+
// startLive() records intent and reflects it in the pill, but only opens a connection when
1348+
// the tab is visible - a backgrounded tab would otherwise park a server-side V8 isolate
1349+
// (one per long-poll) for nothing.
13371350
function startLive() {
1338-
if (liveRunning || !current) return;
1339-
liveRunning = true;
1351+
if (!current) return;
13401352
liveEnabled = true;
13411353
els.liveToggle.setAttribute('aria-checked', 'true');
1354+
if (!document.hidden) runLiveLoop();
1355+
}
1356+
// The actual long-poll loop. Idempotent: a no-op if already running.
1357+
function runLiveLoop() {
1358+
if (liveRunning || !current) return;
1359+
liveRunning = true;
13421360
watchAbort = new AbortController();
13431361
(async () => {
13441362
const token = current.t;
@@ -1357,11 +1375,35 @@ <h2 id="confirmDialogTitle">Confirm</h2>
13571375
}
13581376
})();
13591377
}
1360-
function stopLive() {
1378+
// Pause the loop and FIN the in-flight connection WITHOUT clearing user intent, so it can
1379+
// be resumed (e.g. on visibility change) from the last `cursor`.
1380+
function pauseLoop() {
13611381
liveRunning = false;
1362-
els.liveToggle.setAttribute('aria-checked', 'false');
13631382
if (watchAbort) { try { watchAbort.abort(); } catch {} watchAbort = null; }
13641383
}
1384+
// Explicit user-off: clears intent, unchecks the pill, and pauses the loop.
1385+
function stopLive() {
1386+
liveEnabled = false;
1387+
els.liveToggle.setAttribute('aria-checked', 'false');
1388+
pauseLoop();
1389+
}
1390+
1391+
// Don't hold a server-side long-poll open for a tab nobody is looking at: pause when hidden,
1392+
// resume from the last cursor when visible. User intent (`liveEnabled` + pill) is preserved
1393+
// across hide/show. With the safe actix default the server can't detect the disconnect on
1394+
// its own, so the client must close the connection itself.
1395+
document.addEventListener('visibilitychange', () => {
1396+
if (document.hidden) {
1397+
if (liveRunning) pauseLoop();
1398+
} else if (liveEnabled && !liveRunning) {
1399+
runLiveLoop();
1400+
}
1401+
});
1402+
// On navigation / close / bfcache, drop the in-flight watch so the connection FINs promptly
1403+
// instead of lingering server-side until the watch window elapses.
1404+
window.addEventListener('pagehide', () => {
1405+
if (liveRunning) pauseLoop();
1406+
});
13651407
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13661408

13671409
// ---- mock response editor ----
@@ -1565,7 +1607,7 @@ <h2 id="confirmDialogTitle">Confirm</h2>
15651607
});
15661608
els.refreshBtn.addEventListener('click', () => refresh());
15671609
els.liveToggle.addEventListener('click', () => {
1568-
if (liveRunning) { liveEnabled = false; stopLive(); }
1610+
if (liveEnabled) stopLive();
15691611
else startLive();
15701612
});
15711613
els.clearBtn.addEventListener('click', async () => {
@@ -1617,7 +1659,6 @@ <h2 id="confirmDialogTitle">Confirm</h2>
16171659
confirmLabel: 'Delete webhook',
16181660
});
16191661
if (!ok) return;
1620-
liveEnabled = false;
16211662
stopLive();
16221663
els.deleteBtn.disabled = true;
16231664
const token = current.t;

0 commit comments

Comments
 (0)