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
11531156async 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.
13371350function 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+ } ) ;
13651407const sleep = ( ms ) => new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
13661408
13671409// ---- mock response editor ----
@@ -1565,7 +1607,7 @@ <h2 id="confirmDialogTitle">Confirm</h2>
15651607} ) ;
15661608els . refreshBtn . addEventListener ( 'click' , ( ) => refresh ( ) ) ;
15671609els . liveToggle . addEventListener ( 'click' , ( ) => {
1568- if ( liveRunning ) { liveEnabled = false ; stopLive ( ) ; }
1610+ if ( liveEnabled ) stopLive ( ) ;
15691611 else startLive ( ) ;
15701612} ) ;
15711613els . 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