Move keepalive scheduling to backend

18bf41c8bdd4f16a5745803d1dc7f4779c15387b

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

12 files changed, +721 -141Ignore whitespace
homelab/README.md+1 -1
@@ -7,7 +7,7 @@ changing one, update both (scp to the NAS path + commit here).
77| File | Deployed at | Purpose |
88|---|---|---|
99| `whisper-lazy-proxy.py` | `/mnt/user/appdata/whisper-lazy/proxy.py` (container `whisper-lazy`, :10300) | Wyoming lazy proxy: answers HA availability checks from cache, starts `faster-whisper` on demand, stops it after 15 min idle (frees GPU VRAM). |
1010| `runpod-lazy-proxy.py` | `/mnt/user/appdata/runpod-lazy/proxy.py` (container `runpod-lazy`, :8189) | ComfyUI-compatible on-demand proxy for a volumeless RunPod GPU pod: only `/lazy/warmup` starts or provisions, while cold ComfyUI requests return 503 for provider failover. `/lazy/status|warmup|shutdown|ping|catalog` providerecords SillyTavern20-second controlsfrontend heartbeats; the proxy owns the 60-second upstream keepalive cadence and continues it through browser timer pauses up to a 5-minute lease. A NAS-side idle timer terminates the pod 15-min idleminutes terminateafter keepalives and other activity stop. Model-set changes requested by Warm up download in place via the worker image's model-manager (port 8189, LRU disk eviction); pods are only recreated when none exists or the image predates the manager. |
1111| `runpod-pod.sh` | `/boot/config/runpod-pod.sh` | CLI for the runpod-lazy control API (`status|warmup|shutdown|ping|watch`). |
1212
1313Related: the RunPod worker image (ComfyUI + custom nodes + boot-time model
homelab/runpod-lazy-proxy.py+62 -6
@@ -14,15 +14,16 @@ Control API (CORS-enabled; ST warmup UI + runpod-pod.sh CLI):
1414 GET /lazy/status -> {"state": red|orange|green, "model": ..., ...}
1515 POST /lazy/warmup -> start pod for the active catalog entry
1616 POST /lazy/shutdown -> terminate pod now
1717 POST /lazy/ping -> keepalive forrecord a readyfrontend podheartbeat (never starts a pod)
1818 POST /lazy/catalog -> {"models": [...], "active": "<value>"} store catalog;
1919 never starts or provisions a pod
2020
2121Start semantics: only POST /lazy/warmup may provision or change models. ComfyUI
2222requests return 503 unless a pod is already ready, allowing SillyTavern's image
2323target fallback chain to continue without waking this target. The idleBrowser timerheartbeats
24-lives here (NAS side): IDLE_SECONDS after the last activity signal the pod is
24+grant a short lease during which this proxy sends keepalive probes on a stable
25-terminated, frontend or no frontend.
25+backend cadence. The idle timer lives here (NAS side): IDLE_SECONDS after the
26+last activity signal the pod is terminated, frontend or no frontend.
2627"""
2728import json
2829import os
@@ -35,6 +36,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
3536RUNPOD_KEY = os.environ['RUNPOD_KEY']
3637LISTEN_PORT = int(os.environ.get('LISTEN_PORT', '8189'))
3738IDLE_SECONDS = int(os.environ.get('IDLE_SECONDS', '900'))
39+KEEPALIVE_SECONDS = max(1, int(os.environ.get('KEEPALIVE_SECONDS', '60')))
40+FRONTEND_LEASE_SECONDS = max(1, int(os.environ.get('FRONTEND_LEASE_SECONDS', '300')))
3841DATACENTERS = [d for d in os.environ.get('DATACENTERS', '').split(',') if d]
3942CLOUD_TYPE = os.environ.get('CLOUD_TYPE', 'SECURE')
4043IMAGE = os.environ.get('IMAGE', 'ghcr.io/permissionbrick/comfyui-runpod-worker:latest')
@@ -55,6 +58,8 @@ state = {
5558 'gpu': None,
5659 'since': time.time(),
5760 'last': time.monotonic(),
61+ 'frontend_last_seen': None,
62+ 'keepalive_last': None,
5863 'ensuring': 0, # active ensure_pod waiters (status skips probing then)
5964 'control_epoch': 0, # incremented by shutdown to cancel in-flight warmups
6065 'lock': threading.Lock(),
@@ -364,6 +369,11 @@ def idle_reaper():
364369 time.sleep(30)
365370 idle = time.monotonic() - state['last']
366371 if idle > IDLE_SECONDS:
372+ # A heartbeat may arrive just before the next scheduled backend
373+ # probe. Its live lease protects the pod until that probe catches
374+ # up, without relying on the browser to refresh the idle clock.
375+ if frontend_lease_seconds_left() > 0:
376+ continue
367377 pod_id = state['pod_id'] or find_pod()[0]
368378 if pod_id:
369379 log(f'idle {int(idle)}s - terminating pod')
@@ -372,6 +382,49 @@ def idle_reaper():
372382 state.update({'pod_id': None, 'model': None, 'gpu': None, 'phase': 'red', 'since': time.time()})
373383
374384
385+def frontend_lease_seconds_left(now=None):
386+ """Returns the remaining frontend heartbeat lease in whole seconds."""
387+ now = time.monotonic() if now is None else now
388+ last_seen = state['frontend_last_seen']
389+ if last_seen is None:
390+ return 0
391+ return max(0, FRONTEND_LEASE_SECONDS - int(now - last_seen))
392+
393+
394+def maintain_frontend_keepalive(now=None):
395+ """Probes and refreshes a ready pod while a frontend heartbeat lease is live."""
396+ now = time.monotonic() if now is None else now
397+ with state['lock']:
398+ pod_id = state['pod_id']
399+ lease_active = frontend_lease_seconds_left(now) > 0
400+ ready = state['phase'] == 'green' and state['ensuring'] == 0
401+ if not pod_id or not lease_active or not ready or not upstream_ready(pod_id):
402+ return False
403+ with state['lock']:
404+ # The network probe runs outside the lock. Re-check that its result still
405+ # belongs to the current pod and a still-live frontend lease.
406+ if state['pod_id'] != pod_id or frontend_lease_seconds_left(now) <= 0:
407+ return False
408+ state['last'] = now
409+ state['keepalive_last'] = now
410+ return True
411+
412+
413+def keepalive_scheduler():
414+ """Runs keepalive probes on a monotonic backend cadence without timer drift."""
415+ next_tick = time.monotonic() + KEEPALIVE_SECONDS
416+ while True:
417+ time.sleep(max(0, next_tick - time.monotonic()))
418+ maintain_frontend_keepalive()
419+ next_tick += KEEPALIVE_SECONDS
420+ now = time.monotonic()
421+ if next_tick <= now:
422+ # Skip missed ticks after a long process pause instead of issuing a
423+ # burst of stale keepalives, while keeping future ticks aligned.
424+ missed = int((now - next_tick) // KEEPALIVE_SECONDS) + 1
425+ next_tick += missed * KEEPALIVE_SECONDS
426+
427+
375428def status_body():
376429 pod_id = state['pod_id']
377430 previous_phase = state['phase']
@@ -406,6 +459,8 @@ def status_body():
406459 'since': state['since'],
407460 'url': pod_url(state['pod_id']) if state['pod_id'] else None,
408461 'idle_seconds_left': max(0, IDLE_SECONDS - int(time.monotonic() - state['last'])) if state['pod_id'] else 0,
462+ 'frontend_lease_seconds_left': frontend_lease_seconds_left(),
463+ 'keepalive_active': bool(state['pod_id'] and state['phase'] == 'green' and frontend_lease_seconds_left() > 0),
409464 }).encode()
410465
411466
@@ -441,8 +496,8 @@ class Proxy(BaseHTTPRequestHandler):
441496 if path in ('/lazy/ping', '/lazy/warmup', '/lazy/shutdown', '/lazy/catalog') and self.command != 'POST':
442497 return self._reply(405, b'{"error": "method not allowed"}')
443498 if path == '/lazy/ping':
444- if state['pod_id'] and state['phase'] == 'green':
499+ with state['lock']:
445500 state['lastfrontend_last_seen'] = time.monotonic()
446501 return self._reply(200, b'{"ok": true}')
447502 if path == '/lazy/warmup':
448503 with state['lock']:
@@ -516,6 +571,7 @@ class Proxy(BaseHTTPRequestHandler):
516571
517572def main():
518573 threading.Thread(target=idle_reaper, daemon=True).start()
574+ threading.Thread(target=keepalive_scheduler, daemon=True).start()
519575 # Adopt a pre-existing pod after a proxy restart without provisioning or
520576 # changing it. Read-only status polls will update its readiness phase.
521577 pod_id, have, gpu = find_pod()
homelab/runpod-pod.sh+1 -1
@@ -1,5 +1,5 @@
11#!/bin/sh
22# CLI for the runpod-lazy proxy: status | warmup | shutdown | ping (renew frontend lease)
33# Usable by anyone (or any AI) without SillyTavern. Proxy owns the pod
44# lifecycle; this only talks to its control API.
55CMD="${1:-status}"
homelab/tests/test_runpod_lazy_proxy.py+37 -1
@@ -27,6 +27,8 @@ class RunpodLazyProxyTest(unittest.TestCase):
2727 'gpu': None,
2828 'since': 0,
2929 'last': 100,
30+ 'frontend_last_seen': None,
31+ 'keepalive_last': None,
3032 'ensuring': 0,
3133 'control_epoch': 0,
3234 })
@@ -73,9 +75,11 @@ class RunpodLazyProxyTest(unittest.TestCase):
7375 self.assertEqual(status, 200)
7476 self.assertEqual(body['state'], 'red')
7577
76- status, _ = self.request('POST', '/lazy/ping')
78+ with mock.patch.object(proxy.time, 'monotonic', return_value=120):
79+ status, _ = self.request('POST', '/lazy/ping')
7780 self.assertEqual(status, 200)
7881 self.assertEqual(proxy.state['last'], 100)
82+ self.assertEqual(proxy.state['frontend_last_seen'], 120)
7983
8084 catalog = {
8185 'models': [{'name': 'Test', 'value': 'test.safetensors', 'files': []}],
@@ -100,6 +104,38 @@ class RunpodLazyProxyTest(unittest.TestCase):
100104 self.assertEqual(status, 200)
101105 self.assertEqual(proxy.state['last'], 100)
102106
107+ def test_backend_maintains_keepalive_during_frontend_lease(self):
108+ proxy.state.update({
109+ 'pod_id': 'ready-pod',
110+ 'phase': 'green',
111+ 'last': 100,
112+ 'frontend_last_seen': 100,
113+ })
114+
115+ with mock.patch.object(proxy, 'upstream_ready', return_value=True) as upstream_ready:
116+ maintained = proxy.maintain_frontend_keepalive(now=399)
117+
118+ self.assertTrue(maintained)
119+ self.assertEqual(proxy.state['last'], 399)
120+ self.assertEqual(proxy.state['keepalive_last'], 399)
121+ upstream_ready.assert_called_once_with('ready-pod')
122+
123+ def test_backend_stops_keepalive_after_frontend_lease_expires(self):
124+ proxy.state.update({
125+ 'pod_id': 'ready-pod',
126+ 'phase': 'green',
127+ 'last': 100,
128+ 'frontend_last_seen': 100,
129+ })
130+
131+ with mock.patch.object(proxy, 'upstream_ready') as upstream_ready:
132+ maintained = proxy.maintain_frontend_keepalive(now=401)
133+
134+ self.assertFalse(maintained)
135+ self.assertEqual(proxy.state['last'], 100)
136+ self.assertIsNone(proxy.state['keepalive_last'])
137+ upstream_ready.assert_not_called()
138+
103139 def test_get_warmup_is_rejected_without_provisioning(self):
104140 with mock.patch.object(proxy, 'ensure_pod') as ensure_pod:
105141 status, body = self.request('GET', '/lazy/warmup')
public/scripts/extensions/keepalive/index.js+259 -119
@@ -1,18 +1,20 @@
11import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
22import {
33 activateSendButtonsGenerate,
4- deactivateSendButtons,
54 eventSource,
65 event_types,
76 extension_prompt_roles,
87 extension_prompt_types,
98 generateQuietPromptgetGenerateUrl,
9+ getRequestHeaders,
1010 isGenerating,
11+ main_api,
1112 saveSettingsDebounced,
1213 setExtensionPrompt,
13- setSendButtonState,
14- streamingProcessor,
1514} from '../../../script.js';
15+import { createGenerationParameters, getChatCompletionModel, oai_settings } from '../../openai.js';
16+import { horde_settings } from '../../horde.js';
17+import { uuidv4 } from '../../utils.js';
1618
1719export { init };
1820
@@ -22,6 +24,9 @@ const MODULE = 'keepalive';
2224// still processed by the backend (which is what refreshes the server-side
2325// cache), but we never pay for a full completion.
2426const KEEPALIVE_RESPONSE_LENGTH = 1;
27+const HEARTBEAT_INTERVAL_MS = 20000;
28+const RETRY_INTERVAL_MS = 5000;
29+const SETTINGS_REFRESH_DELAY_MS = 300;
2530
2631// Extension-prompt key used to inject the keepalive message as a USER message at depth 0.
2732const KEEPALIVE_INJECT_ID = 'keepalive_ping';
@@ -37,11 +42,6 @@ const NONE_KEY = '__none__';
3742// user message can pre-empt the keepalive.
3843const EXPIRED_GRACE_MS = 3000;
3944
40-// If the idle timer fires more than this many ms later than it was scheduled to, assume the
41-// machine slept / the tab was throttled — far more wall-clock time passed than the interval,
42-// so the cache almost certainly lapsed. Skip the ping and go dormant rather than guess.
43-const MAX_TIMER_DRIFT_MS = 5000;
44-
4545const defaultSettings = {
4646 enabled: false,
4747 timeoutSeconds: 295,
@@ -50,20 +50,26 @@ const defaultSettings = {
5050 profiles: {},
5151};
5252
5353let idleTimerheartbeatTimer = null;
5454let keepaliveActivejobRegistered = false;
55+let refreshPromise = null;
56+let refreshRequested = false;
57+let refreshTimer = null;
58+let preparingJob = false;
59+let lifecycleVersion = 0;
5560// Whether keepalive is "armed" for the CURRENT chat. It stays dormant until the user sends a new
5661// message in the open chat — on a freshly loaded or switched-to chat (even with history already
5762// present) we have no way to know whether its prompt is still cached server-side, so pinging would
5863// be pointless. Reset on page reload and whenever the chat changes.
5964let armed = false;
60-// Wall-clock time (Date.now() + delay) the pending idle timer is expected to fire, or null when
61-// none is scheduled. Used to detect a timer that fired far too late (sleep/throttle).
62-let scheduledFireTime = null;
6365// Per-profile "time since last message", keyed by connection profile id (or NONE_KEY).
6466// In-memory only: on reload every profile is treated as freshly active.
6567const lastActivity = {};
6668
69+// Per-document ids keep duplicated tabs independent. Jobs orphaned by a closed or reloaded
70+// document are deliberately cleaned up by the backend lease.
71+const keepaliveJobId = uuidv4().replaceAll('-', '');
72+
6773/**
6874 * @returns {string} The active connection profile id, or NONE_KEY for a manual connection.
6975 */
@@ -90,43 +96,49 @@ function isKeepaliveEnabledForActive() {
9096 return perProfile === undefined ? true : !!perProfile;
9197}
9298
93-/**
99+function getTimeoutMs() {
94- * Clears the pending idle timer, if any.
100+ return Math.max(60, Number(extension_settings[MODULE].timeoutSeconds) || 295) * 1000;
95- */
101+}
96-function clearIdleTimer() {
102+
97- if (idleTimer) {
103+async function postKeepaliveBackend(path, body) {
98- clearTimeout(idleTimer);
104+ return fetch(`/api/keepalive/${path}`, {
99- idleTimer = null;
105+ method: 'POST',
106+ headers: getRequestHeaders(),
107+ body: JSON.stringify({ id: keepaliveJobId, ...body }),
108+ });
109+}
110+
111+async function stopBackendJob() {
112+ lifecycleVersion++;
113+ jobRegistered = false;
114+ try {
115+ await postKeepaliveBackend('stop', {});
116+ } catch (error) {
117+ console.debug('[Keepalive] Could not stop backend job:', error);
100118 }
101119}
102120
103-/**
121+async function sendBackendHeartbeat(activity = false) {
104- * (Re)schedules the keepalive for the ACTIVE profile based on how long it has been since
122+ if (!jobRegistered || !armed || !isKeepaliveEnabledForActive()) {
105- * that profile's last message. Does nothing (and leaves the timer cleared) until keepalive
106- * has been armed for the current chat, or when it is disabled for the active connection.
107- * Called on activity AND whenever the active profile changes, so each profile keeps its own
108- * independent idle clock.
109- */
110-function scheduleIdleTimer() {
111- clearIdleTimer();
112- if (!armed || !isKeepaliveEnabledForActive()) {
113- scheduledFireTime = null;
114123 return;
115124 }
116- const timeoutMs = Math.max(60, Number(extension_settings[MODULE].timeoutSeconds) || 295) * 1000;
125+ try {
117- const last = lastActivity[getActiveProfileKey()] ?? Date.now();
126+ const response = await postKeepaliveBackend('heartbeat', { activity });
118- const remaining = timeoutMs - (Date.now() - last);
127+ if (response.status === 404) {
119- const delay = remaining > 0 ? remaining : EXPIRED_GRACE_MS;
128+ jobRegistered = false;
120- scheduledFireTime = Date.now() + delay;
129+ queueJobRefresh();
121- idleTimer = setTimeout(fireKeepalive, delay);
130+ }
131+ } catch (error) {
132+ console.debug('[Keepalive] Backend heartbeat failed:', error);
133+ }
122134}
123135
124-/**
125- * Marks the active profile as just-used (resets its idle clock) and reschedules its keepalive.
126- */
127136function markActivity() {
137+ if (preparingJob) {
138+ return;
139+ }
128140 lastActivity[getActiveProfileKey()] = Date.now();
129141 scheduleIdleTimervoid sendBackendHeartbeat(true);
130142}
131143
132144/**
@@ -146,78 +158,164 @@ function onMessageSent() {
146158 */
147159function onChatChanged() {
148160 armed = false;
149161 scheduleIdleTimervoid stopBackendJob();
150162}
151163
152164/**
153- * Fires a background ("quiet") generation against the active model to keep the
165+ * Caps a prepared request to a single, non-streaming completion token.
154- * server-side prompt cache warm. The full prompt prefix (current chat context +
166+ * @param {string} endpoint Backend generation endpoint.
155- * the keepalive message) is sent — which is what refreshes the cache — while the
167+ * @param {object} payload Prepared generation payload.
156- * completion is capped to a single token (KEEPALIVE_RESPONSE_LENGTH) so we never
168+ * @returns {object} Cloned and capped payload.
157- * pay for a real response.
158- *
159- * Only the CURRENTLY ACTIVE profile is ever pinged — keepalive never switches the
160- * connection in the background. Each profile's idle clock is tracked separately, so
161- * switching profiles re-targets the timer at whichever profile is now active.
162- *
163- * Quiet generations are forced non-streaming by the core (Generate excludes
164- * type === 'quiet' from the StreamingProcessor) and are never saved to chat, so the
165- * output is simply discarded. generateQuietPrompt's responseLength applies a *global*
166- * temporary response-length override for the duration of the request, so we hold the
167- * same generation lock the blocking summary path uses — setSendButtonState(true) flips
168- * `is_send_press` (which every user send entry point checks and bails on) and
169- * deactivateSendButtons() reflects the busy state — and release both in `finally`.
170169 */
171170async function fireKeepalivecapKeepalivePayload(endpoint, payload) {
172- if (!armed || !isKeepaliveEnabledForActive()) {
171+ const capped = structuredClone(payload);
173- return;
172+ capped.stream = false;
173+ for (const key of ['max_tokens', 'max_completion_tokens', 'max_length', 'max_new_tokens', 'n_predict']) {
174+ if (Object.hasOwn(capped, key)) {
175+ capped[key] = KEEPALIVE_RESPONSE_LENGTH;
176+ }
177+ }
178+ if (endpoint === '/api/backends/chat-completions/generate'
179+ && !Object.hasOwn(capped, 'max_tokens')
180+ && !Object.hasOwn(capped, 'max_completion_tokens')) {
181+ capped.max_tokens = KEEPALIVE_RESPONSE_LENGTH;
174182 }
183+ if (Object.hasOwn(capped, 'n')) {
184+ capped.n = 1;
185+ }
186+ return capped;
187+}
175188
176- // If the timer fired much later than scheduled (machine sleep, background-tab throttling,
189+/**
177- // etc.), far more wall-clock time has elapsed than the idle interval, so the cache has
190+ * Dry-runs prompt assembly and returns a request the backend can replay later.
178- // almost certainly expired. Don't ping on a stale assumption — go dormant until the next
191+ * @returns {Promise<{endpoint: string, payload: object}>}
179- // sent message re-arms keepalive, exactly like a freshly opened chat.
192+ */
180- if (scheduledFireTime !== null && (Date.now() - scheduledFireTime) > MAX_TIMER_DRIFT_MS) {
193+async function prepareKeepaliveRequest() {
181- console.debug('[Keepalive] Idle timer fired', Date.now() - scheduledFireTime, 'ms late; skipping and disarming until next activity.');
194+ const api = main_api;
182195 armed let capturedData = falsenull;
183- clearIdleTimer();
196+ const captureData = (data, dryRun) => {
184- scheduledFireTime = null;
197+ if (dryRun && preparingJob) {
185- return;
198+ capturedData = structuredClone(data);
199+ }
200+ };
201+
202+ preparingJob = true;
203+ eventSource.on(event_types.GENERATE_AFTER_DATA, captureData);
204+ setExtensionPrompt(KEEPALIVE_INJECT_ID, extension_settings[MODULE].message, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
205+ try {
206+ await Generate('quiet', { quiet_prompt: '', force_name2: true }, true);
207+ } finally {
208+ setExtensionPrompt(KEEPALIVE_INJECT_ID, '', extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
209+ eventSource.removeListener(event_types.GENERATE_AFTER_DATA, captureData);
210+ preparingJob = false;
186211 }
187212
188- // Never start while any generation is in progress, or while a previous
213+ if (!capturedData || api !== main_api) {
189- // keepalive is still running. Reschedule and bail.
214+ throw new Error('Active connection changed while preparing the keepalive request');
190- if (keepaliveActive || isGenerating() || streamingProcessor) {
191- scheduleIdleTimer();
192- return;
193215 }
194216
195217 keepaliveActive =let trueendpoint;
196- setSendButtonState(true);
218+ let payload;
197- deactivateSendButtons();
219+ if (api === 'openai') {
220+ endpoint = '/api/backends/chat-completions/generate';
221+ const model = getChatCompletionModel(oai_settings);
222+ ({ generate_data: payload } = await createGenerationParameters(oai_settings, model, 'quiet', capturedData.prompt));
223+ await eventSource.emit(event_types.CHAT_COMPLETION_SETTINGS_READY, payload);
224+ } else if (api === 'koboldhorde') {
225+ endpoint = '/api/horde/generate-text';
226+ const params = structuredClone(capturedData);
227+ const prompt = params.prompt;
228+ delete params.prompt;
229+ Object.assign(params, {
230+ n: 1,
231+ max_length: KEEPALIVE_RESPONSE_LENGTH,
232+ frmtadsnsp: false,
233+ frmtrmblln: false,
234+ frmtrmspch: false,
235+ frmttriminc: false,
236+ });
237+ payload = {
238+ prompt,
239+ params,
240+ trusted_workers: horde_settings.trusted_workers_only,
241+ models: horde_settings.models,
242+ };
243+ } else {
244+ endpoint = getGenerateUrl(api);
245+ payload = capturedData;
246+ }
198247
248+ return { endpoint, payload: capKeepalivePayload(endpoint, payload) };
249+}
250+
251+async function registerBackendJob() {
252+ if (!armed || !isKeepaliveEnabledForActive()) {
253+ await stopBackendJob();
254+ return;
255+ }
256+ const version = lifecycleVersion;
199257 try {
200- // Deliver the keepalive as a USER message at depth 0 (the quiet-prompt path forces a
258+ if (isGenerating()) {
201- // system role — openai.js hardcodes role:'system' for it), using the same depth+role
259+ throw new Error('Waiting for the active generation to finish');
202- // injection Author's Note uses. The quiet prompt itself stays empty so the full chat
260+ }
203- // context is still assembled (which is what keeps the cached prefix warm).
261+ const request = await prepareKeepaliveRequest();
204- setExtensionPrompt(KEEPALIVE_INJECT_ID, extension_settings[MODULE].message, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
262+ if (version !== lifecycleVersion || !armed || !isKeepaliveEnabledForActive()) {
205- await generateQuietPrompt({ quietPrompt: '', responseLength: KEEPALIVE_RESPONSE_LENGTH });
263+ return;
206- // The ping kept this profile warm; treat it as fresh activity so the next
264+ }
207- // keepalive for it is a full timeout away.
265+ const timeoutMs = getTimeoutMs();
208266 const last = lastActivity[getActiveProfileKey()] =?? Date.now();
267+ const remaining = timeoutMs - (Date.now() - last);
268+ const delayMs = remaining > 0 ? remaining : EXPIRED_GRACE_MS;
269+ const response = await postKeepaliveBackend('register', {
270+ ...request,
271+ intervalMs: timeoutMs,
272+ delayMs,
273+ });
274+ if (!response.ok) {
275+ throw new Error(`HTTP ${response.status}`);
276+ }
277+ if (version !== lifecycleVersion || !armed || !isKeepaliveEnabledForActive()) {
278+ await stopBackendJob();
279+ return;
280+ }
281+ jobRegistered = true;
209282 } catch (error) {
210- // Best-effort: any failure here is harmless and intentionally swallowed.
283+ jobRegistered = false;
211284 console.debug('[Keepalive] BackgroundCould generationnot endedregister backend job:', error);
212- } finally {
285+ setTimeout(queueJobRefresh, RETRY_INTERVAL_MS);
213- setExtensionPrompt(KEEPALIVE_INJECT_ID, '', extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
214- setSendButtonState(false);
215- activateSendButtons();
216- keepaliveActive = false;
217- scheduleIdleTimer();
218286 }
219287}
220288
289+function queueJobRefresh() {
290+ refreshRequested = true;
291+ if (refreshPromise) {
292+ return;
293+ }
294+ refreshPromise = (async () => {
295+ while (refreshRequested) {
296+ refreshRequested = false;
297+ await registerBackendJob();
298+ }
299+ })().finally(() => {
300+ refreshPromise = null;
301+ });
302+}
303+
304+function scheduleJobRefresh() {
305+ clearTimeout(refreshTimer);
306+ refreshTimer = setTimeout(queueJobRefresh, SETTINGS_REFRESH_DELAY_MS);
307+}
308+
309+function setupHeartbeatLoop() {
310+ clearInterval(heartbeatTimer);
311+ heartbeatTimer = setInterval(() => void sendBackendHeartbeat(), HEARTBEAT_INTERVAL_MS);
312+ document.addEventListener('visibilitychange', () => {
313+ if (document.visibilityState === 'visible') {
314+ void sendBackendHeartbeat();
315+ }
316+ });
317+}
318+
221319/**
222320 * Renders the per-profile enable checkboxes from the current Connection Manager profiles.
223321 */
@@ -293,18 +391,27 @@ function setupListeners() {
293391 $('#keepalive_enabled').on('change', function () {
294392 extension_settings[MODULE].enabled = !!$(this).prop('checked');
295393 saveSettingsDebounced();
296- scheduleIdleTimer();
394+ if (extension_settings[MODULE].enabled && armed) {
395+ queueJobRefresh();
396+ } else {
397+ void stopBackendJob();
398+ }
297399 });
298400
299401 $('#keepalive_timeout').on('input', function () {
300402 extension_settings[MODULE].timeoutSeconds = Number($(this).val()) || 295;
301403 saveSettingsDebounced();
302- scheduleIdleTimer();
404+ if (armed && isKeepaliveEnabledForActive()) {
405+ scheduleJobRefresh();
406+ }
303407 });
304408
305409 $('#keepalive_message').on('input', function () {
306410 extension_settings[MODULE].message = String($(this).val());
307411 saveSettingsDebounced();
412+ if (armed && isKeepaliveEnabledForActive()) {
413+ scheduleJobRefresh();
414+ }
308415 });
309416
310417 // Per-profile toggles are rendered dynamically; use a delegated handler.
@@ -315,46 +422,79 @@ function setupListeners() {
315422 }
316423 extension_settings[MODULE].profiles[profileId] = !!$(this).prop('checked');
317424 saveSettingsDebounced();
318425 // If the toggled profile is the one currently active, rescheduleupdate its backend job immediately.
319426 if (profileId === extension_settings.connectionManager?.selectedProfile) {
320- scheduleIdleTimer();
427+ if (isKeepaliveEnabledForActive() && armed) {
428+ queueJobRefresh();
429+ } else {
430+ void stopBackendJob();
431+ }
321432 }
322433 });
323434}
324435
436+function onGenerationStarted(_type, _options, dryRun) {
437+ if (!dryRun) {
438+ markActivity();
439+ }
440+}
441+
442+function onGenerationEnded() {
443+ markActivity();
444+ if (armed && isKeepaliveEnabledForActive()) {
445+ queueJobRefresh();
446+ }
447+}
448+
449+function onChatContentChanged() {
450+ markActivity();
451+ if (armed && isKeepaliveEnabledForActive()) {
452+ queueJobRefresh();
453+ }
454+}
455+
456+async function onConnectionChanged() {
457+ // Remove the previous connection's request before assembling one with the new settings.
458+ await stopBackendJob();
459+ if (armed && isKeepaliveEnabledForActive()) {
460+ queueJobRefresh();
461+ }
462+}
463+
325464async function init() {
326465 const settingsHtml = await renderExtensionTemplateAsync(MODULE, 'settings');
327466 $('#extensions_settings2').append(settingsHtml);
328467
329468 loadSettings();
330469 setupListeners();
470+ setupHeartbeatLoop();
331471
332472 // Only the user sending a new message arms keepalive (for the current chat).
333473 eventSource.on(event_types.MESSAGE_SENT, onMessageSent);
334474
335475 // TheseActivity resetresets the active profile'sbackend idledeadline clockbut (sonever generationarms timekeepalive isn'ton countedits asown. idle)A butcompleted
336476 // nevergeneration armor keepalivea onchat theiredit ownalso refreshes theythe alsostored firerequest forso background/quietthe generationsserver andalways whilereplays
337- // a chat is loading. They only re-arm the timer if a sent message already armed it.
477+ // the latest prompt assembled by the UI.
338- const activityEvents = [
478+ eventSource.on(event_types.MESSAGE_RECEIVED, markActivity);
339- event_types.MESSAGE_RECEIVED,
479+ eventSource.on(event_types.GENERATION_STARTED, onGenerationStarted);
340- event_types.GENERATION_STARTED,
480+ eventSource.on(event_types.GENERATION_ENDED, onGenerationEnded);
341- event_types.GENERATION_ENDED,
481+ eventSource.on(event_types.MESSAGE_EDITED, onChatContentChanged);
342- ];
482+ eventSource.on(event_types.MESSAGE_DELETED, onChatContentChanged);
343- for (const event of activityEvents) {
483+ eventSource.on(event_types.MESSAGE_UPDATED, onChatContentChanged);
344484 eventSource.on(eventevent_types.MESSAGE_SWIPED, markActivityonChatContentChanged);
345- }
346485
347486 // Opening/switching a chat disarms keepalive — it must be re-armed by a new sent message.
348487 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
349488
350489 // When the active profile changes, re-targetreplace the idlebackend timerjob atwithout themarking newit profileas usingactivity.
351- // ITS own last-activity time (do NOT mark activity — switching is not using it).
490+ eventSource.on(event_types.CONNECTION_PROFILE_LOADED, onConnectionChanged);
352491 eventSource.on(event_types.CONNECTION_PROFILE_LOADEDMAIN_API_CHANGED, scheduleIdleTimeronConnectionChanged);
492+ eventSource.on(event_types.CHATCOMPLETION_SOURCE_CHANGED, onConnectionChanged);
493+ eventSource.on(event_types.CHATCOMPLETION_MODEL_CHANGED, onConnectionChanged);
494+ eventSource.on(event_types.PRESET_CHANGED, onConnectionChanged);
353495
354496 // Keep the per-profile toggle list in sync with Connection Manager.
355497 eventSource.on(event_types.CONNECTION_PROFILE_CREATED, renderProfileToggles);
356498 eventSource.on(event_types.CONNECTION_PROFILE_UPDATED, renderProfileToggles);
357499 eventSource.on(event_types.CONNECTION_PROFILE_DELETED, renderProfileToggles);
358-
359- scheduleIdleTimer();
360500}
public/scripts/extensions/keepalive/manifest.json+1 -1
@@ -6,7 +6,7 @@
66 "js": "index.js",
77 "css": "",
88 "author": "SillyTavern",
99 "version": "1.01.0",
1010 "homePage": "https://github.com/SillyTavern/SillyTavern",
1111 "hooks": {
1212 "activate": "init"
public/scripts/extensions/keepalive/settings.html+1 -1
@@ -16,7 +16,7 @@
1616 <label for="keepalive_message" data-i18n="ext_keepalive_message">Keepalive message</label>
1717 <input id="keepalive_message" class="text_pole" type="text" />
1818
1919 <small data-i18n="ext_keepalive_help">After this many seconds without activity on the active connection profile, the current chat plus this message (sent as a hidden user message) is submitted to that profile to keep its server-side prompt cache warm. The completion is capped to a single token and is never saved to the chat. SendingThe isserver brieflyowns lockedthe keepalive timer, while itthis runstab renews its lease every 20 seconds; if the tab sleeps or closes, the server continues on schedule for up to five minutes after the last heartbeat. Only the profile you're currently using is pinged; each profile has its own idle timer. Keepalive stays off until you send a new message in the current chat (opening, switching to, or reloading a chat isn't proof its prompt is still cached), and it also skips a ping if its timer fired far later than scheduled (e.g. after the machine slept).</small>
2020
2121 <hr>
2222
public/scripts/extensions/stable-diffusion/index.js+12 -10
@@ -1541,11 +1541,11 @@ async function fetchReferenceImageBase64(refImage) {
15411541/** Poll cadence for the pod status indicator (faster while it is starting). */
15421542const RUNPOD_POLL_IDLE_MS = 30000;
15431543const RUNPOD_POLL_BUSY_MS = 5000;
15441544/** Keepalive cadenceFrontend forheartbeat acadence. podThe thatproxy thetolerates statusbrowser APItimer haspauses confirmedfor isfive readyminutes. */
15451545const RUNPOD_PING_MSRUNPOD_HEARTBEAT_MS = 6000020000;
15461546
15471547let runpodStatusTimer = null;
15481548let runpodPingTimerrunpodHeartbeatTimer = null;
15491549let runpodLastPhase = 'red';
15501550
15511551function getRunpodLazyUrl() {
@@ -1690,20 +1690,21 @@ async function runpodControl(action) {
16901690 runpodStatusTimer = setTimeout(pollRunpodStatus, RUNPOD_POLL_BUSY_MS);
16911691}
16921692
16931693function runpodKeepaliverunpodHeartbeat() {
16941694 const url = getRunpodLazyUrl();
16951695 if (!url || runpodLastPhase !== 'green') {
16961696 return;
16971697 }
1698- // The proxy also rejects wake-on-ping; this phase guard avoids unnecessary
1698+ // This only renews the proxy's frontend lease and never starts a pod. The
16991699 // requestsproxy wheneverowns the last read-only statusstable pollupstream sayskeepalive thecadence podso isbrowser down.timer
1700+ // throttling cannot directly skew it.
17001701 fetch(`${url}/lazy/ping`, { method: 'POST', signal: AbortSignal.timeout(5000) }).catch(() => { });
17011702}
17021703
17031704/** (Re)starts the polling/keepalive loops basedstatus onpolling theand configuredfrontend-heartbeat URLloops. */
17041705function setupRunpodLoops() {
17051706 clearTimeout(runpodStatusTimer);
17061707 clearInterval(runpodPingTimerrunpodHeartbeatTimer);
17071708 ensureRunpodBarDot();
17081709 const barDot = document.getElementById('sd_runpod_bar_dot');
17091710 if (barDot) {
@@ -1714,7 +1715,8 @@ function setupRunpodLoops() {
17141715 return;
17151716 }
17161717 pollRunpodStatus();
1717- runpodPingTimer = setInterval(runpodKeepalive, RUNPOD_PING_MS);
1718+ runpodHeartbeat();
1719+ runpodHeartbeatTimer = setInterval(runpodHeartbeat, RUNPOD_HEARTBEAT_MS);
17181720}
17191721
17201722function onRunpodLazyUrlInput() {
public/scripts/extensions/stable-diffusion/settings.html+1 -1
@@ -100,7 +100,7 @@
100100 <span data-i18n="Copy ComfyUI URL">Copy ComfyUI URL</span>
101101 </div>
102102 </div>
103103 <small data-i18n="sd_runpod_small">The pod starts only when you press Warm up. A SillyTavern tab sends a heartbeat every 20 seconds; the proxy keeps an already-ready pod alive; iton its own stable timer and tolerates browser sleep for up to 5 minutes. The pod shuts down 15 minutes after thekeepalives lastand other activity stop. Image requests fail over instead of starting a stopped pod. Red = off, orange = starting/downloading models, green = ready.</small>
104104 <h5 data-i18n="Model catalog">Model catalog</h5>
105105 <small data-i18n="sd_runpod_models_small">When the ComfyUI URL points at the proxy, the Model dropdown lists these entries instead of asking the backend. Press Warm up to apply the selected model and download its files; changing the selection or generating an image never starts or provisions a stopped pod. Filename = what &quot;%model%&quot; resolves to; downloads = one &quot;subpath url&quot; per line, including companion files (text encoder, VAE, LoRA).</small>
106106 <div id="sd_runpod_models_list" class="flex-container flexFlowColumn marginTopBot5"></div>
src/endpoints/keepalive.js+264 -0
@@ -0,0 +1,264 @@
1+import express from 'express';
2+import https from 'node:https';
3+import fetch from 'node-fetch';
4+
5+export const router = express.Router();
6+
7+export const FRONTEND_LEASE_MS = 5 * 60 * 1000;
8+const MIN_INTERVAL_MS = 60 * 1000;
9+const MAX_INTERVAL_MS = 24 * 60 * 60 * 1000;
10+const MAX_JOB_ID_LENGTH = 128;
11+const ALLOWED_ENDPOINTS = new Set([
12+ '/api/backends/chat-completions/generate',
13+ '/api/backends/text-completions/generate',
14+ '/api/backends/kobold/generate',
15+ '/api/novelai/generate',
16+ '/api/horde/generate-text',
17+]);
18+const localHttpsAgent = new https.Agent({ rejectUnauthorized: false });
19+
20+function advanceDeadline(deadline, interval, now) {
21+ if (deadline > now) {
22+ return deadline;
23+ }
24+ const missedIntervals = Math.floor((now - deadline) / interval) + 1;
25+ return deadline + missedIntervals * interval;
26+}
27+
28+/**
29+ * Backend-owned scheduler for prompt-cache keepalive requests.
30+ */
31+export class KeepaliveScheduler {
32+ #jobs = new Map();
33+ #timer = null;
34+ #now;
35+ #dispatch;
36+ #setTimer;
37+ #clearTimer;
38+
39+ constructor({
40+ now = Date.now,
41+ dispatch = dispatchKeepaliveRequest,
42+ setTimer = setTimeout,
43+ clearTimer = clearTimeout,
44+ } = {}) {
45+ this.#now = now;
46+ this.#dispatch = dispatch;
47+ this.#setTimer = setTimer;
48+ this.#clearTimer = clearTimer;
49+ }
50+
51+ register(key, job) {
52+ const now = this.#now();
53+ const intervalMs = Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Number(job.intervalMs) || MIN_INTERVAL_MS));
54+ const requestedDelay = Number(job.delayMs);
55+ const delayMs = Number.isFinite(requestedDelay)
56+ ? Math.min(intervalMs, Math.max(0, requestedDelay))
57+ : intervalMs;
58+ this.#jobs.set(key, {
59+ ...job,
60+ intervalMs,
61+ lastSeen: now,
62+ nextRun: now + delayMs,
63+ inFlight: false,
64+ });
65+ this.#schedule();
66+ return this.#jobs.get(key);
67+ }
68+
69+ heartbeat(key, activity = false, request = null) {
70+ const job = this.#jobs.get(key);
71+ if (!job) {
72+ return null;
73+ }
74+ const now = this.#now();
75+ if ((now - job.lastSeen) >= FRONTEND_LEASE_MS) {
76+ this.#jobs.delete(key);
77+ this.#schedule();
78+ return null;
79+ }
80+ job.lastSeen = now;
81+ if (activity) {
82+ job.nextRun = now + job.intervalMs;
83+ }
84+ if (request) {
85+ job.request = request;
86+ }
87+ this.#schedule();
88+ return job;
89+ }
90+
91+ stop(key) {
92+ const removed = this.#jobs.delete(key);
93+ this.#schedule();
94+ return removed;
95+ }
96+
97+ get(key) {
98+ return this.#jobs.get(key) ?? null;
99+ }
100+
101+ async tick(now = this.#now()) {
102+ const pending = [];
103+ for (const [key, job] of this.#jobs) {
104+ if ((now - job.lastSeen) >= FRONTEND_LEASE_MS) {
105+ this.#jobs.delete(key);
106+ continue;
107+ }
108+ if (job.nextRun > now) {
109+ continue;
110+ }
111+
112+ job.nextRun = advanceDeadline(job.nextRun, job.intervalMs, now);
113+ if (job.inFlight) {
114+ continue;
115+ }
116+ job.inFlight = true;
117+ pending.push(Promise.resolve(this.#dispatch(job)).catch(error => {
118+ console.debug(`[Keepalive] Backend request failed for ${job.id}:`, error);
119+ }).finally(() => {
120+ job.inFlight = false;
121+ this.#schedule();
122+ }));
123+ }
124+ this.#schedule();
125+ await Promise.all(pending);
126+ }
127+
128+ #schedule() {
129+ if (this.#timer) {
130+ this.#clearTimer(this.#timer);
131+ this.#timer = null;
132+ }
133+ if (!this.#jobs.size) {
134+ return;
135+ }
136+
137+ let nextWake = Infinity;
138+ for (const job of this.#jobs.values()) {
139+ nextWake = Math.min(nextWake, job.nextRun, job.lastSeen + FRONTEND_LEASE_MS);
140+ }
141+ const delay = Math.max(0, nextWake - this.#now());
142+ this.#timer = this.#setTimer(() => {
143+ this.#timer = null;
144+ void this.tick();
145+ }, delay);
146+ this.#timer?.unref?.();
147+ }
148+}
149+
150+async function dispatchKeepaliveRequest(job) {
151+ const controller = new AbortController();
152+ const timeout = setTimeout(() => controller.abort(new Error('Keepalive request timed out')), 120000);
153+ timeout.unref?.();
154+ try {
155+ const response = await fetch(`${job.request.origin}${job.endpoint}`, {
156+ method: 'POST',
157+ headers: job.request.headers,
158+ body: JSON.stringify(job.payload),
159+ signal: controller.signal,
160+ agent: job.request.secure ? localHttpsAgent : undefined,
161+ });
162+ await response.arrayBuffer();
163+ if (!response.ok) {
164+ throw new Error(`HTTP ${response.status}`);
165+ }
166+ } finally {
167+ clearTimeout(timeout);
168+ }
169+}
170+
171+const scheduler = new KeepaliveScheduler();
172+
173+function getJobKey(request, id) {
174+ return `${request.user.profile.handle}:${id}`;
175+}
176+
177+function getJobId(request) {
178+ const id = String(request.body?.id ?? '');
179+ if (!id || id.length > MAX_JOB_ID_LENGTH || !/^[a-zA-Z0-9_-]+$/.test(id)) {
180+ throw new Error('Invalid keepalive job id');
181+ }
182+ return id;
183+}
184+
185+function getReplayRequest(request) {
186+ const localPort = Number(request.socket.localPort);
187+ if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
188+ throw new Error('Unable to determine local server port');
189+ }
190+ let localAddress = request.socket.localAddress || '127.0.0.1';
191+ if (localAddress === '::' || localAddress === '0:0:0:0:0:0:0:0') {
192+ localAddress = '::1';
193+ } else if (localAddress === '0.0.0.0') {
194+ localAddress = '127.0.0.1';
195+ } else if (localAddress.startsWith('::ffff:')) {
196+ localAddress = localAddress.slice('::ffff:'.length);
197+ }
198+ const loopbackHost = localAddress.includes(':') ? `[${localAddress}]` : localAddress;
199+ const headers = {
200+ 'Content-Type': 'application/json',
201+ 'Cookie': request.get('cookie') || '',
202+ 'X-CSRF-Token': request.get('x-csrf-token') || '',
203+ 'Host': request.get('host') || '',
204+ };
205+ for (const name of ['authorization', 'origin', 'referer', 'user-agent']) {
206+ const value = request.get(name);
207+ if (value) {
208+ headers[name] = value;
209+ }
210+ }
211+ const secure = !!request.socket.encrypted;
212+ return {
213+ origin: `${secure ? 'https' : 'http'}://${loopbackHost}:${localPort}`,
214+ headers,
215+ secure,
216+ };
217+}
218+
219+router.post('/register', (request, response) => {
220+ try {
221+ const id = getJobId(request);
222+ const endpoint = String(request.body?.endpoint ?? '');
223+ if (!ALLOWED_ENDPOINTS.has(endpoint)) {
224+ return response.status(400).send({ error: 'Unsupported keepalive endpoint' });
225+ }
226+ if (!request.body?.payload || typeof request.body.payload !== 'object' || Array.isArray(request.body.payload)) {
227+ return response.status(400).send({ error: 'Missing keepalive payload' });
228+ }
229+ const job = scheduler.register(getJobKey(request, id), {
230+ id,
231+ endpoint,
232+ payload: request.body.payload,
233+ intervalMs: request.body.intervalMs,
234+ delayMs: request.body.delayMs,
235+ request: getReplayRequest(request),
236+ });
237+ return response.send({ ok: true, nextRun: job.nextRun, leaseMs: FRONTEND_LEASE_MS });
238+ } catch (error) {
239+ return response.status(400).send({ error: String(error.message || error) });
240+ }
241+});
242+
243+router.post('/heartbeat', (request, response) => {
244+ try {
245+ const id = getJobId(request);
246+ const job = scheduler.heartbeat(getJobKey(request, id), !!request.body?.activity, getReplayRequest(request));
247+ if (!job) {
248+ return response.sendStatus(404);
249+ }
250+ return response.send({ ok: true, nextRun: job.nextRun, leaseMs: FRONTEND_LEASE_MS });
251+ } catch (error) {
252+ return response.status(400).send({ error: String(error.message || error) });
253+ }
254+});
255+
256+router.post('/stop', (request, response) => {
257+ try {
258+ const id = getJobId(request);
259+ scheduler.stop(getJobKey(request, id));
260+ return response.send({ ok: true });
261+ } catch (error) {
262+ return response.status(400).send({ error: String(error.message || error) });
263+ }
264+});
src/server-startup.js+2 -0
@@ -51,6 +51,7 @@ import { router as dataMaidRouter } from './endpoints/data-maid.js';
5151import { router as backupsRouter } from './endpoints/backups.js';
5252import { router as imageMetadataRouter } from './endpoints/image-metadata.js';
5353import { router as volcengineRouter } from './endpoints/volcengine.js';
54+import { router as keepaliveRouter } from './endpoints/keepalive.js';
5455
5556/**
5657 * @typedef {object} ServerStartupResult
@@ -185,6 +186,7 @@ export function setupPrivateEndpoints(app) {
185186 app.use('/api/data-maid', dataMaidRouter);
186187 app.use('/api/backups', backupsRouter);
187188 app.use('/api/image-metadata', imageMetadataRouter);
189+ app.use('/api/keepalive', keepaliveRouter);
188190}
189191
190192/**
tests/keepalive-scheduler.test.js+80 -0
@@ -0,0 +1,80 @@
1+import { describe, expect, jest, test } from '@jest/globals';
2+import { FRONTEND_LEASE_MS, KeepaliveScheduler } from '../src/endpoints/keepalive.js';
3+
4+function createScheduler() {
5+ let now = 0;
6+ const dispatch = jest.fn().mockResolvedValue(undefined);
7+ const scheduler = new KeepaliveScheduler({
8+ now: () => now,
9+ dispatch,
10+ setTimer: () => ({ unref() { } }),
11+ clearTimer: () => { },
12+ });
13+ return { scheduler, dispatch, setNow: value => { now = value; } };
14+}
15+
16+describe('KeepaliveScheduler', () => {
17+ test('heartbeats renew the lease without moving the backend deadline', () => {
18+ const { scheduler, setNow } = createScheduler();
19+ scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 });
20+
21+ setNow(20000);
22+ scheduler.heartbeat('user:tab');
23+
24+ expect(scheduler.get('user:tab').lastSeen).toBe(20000);
25+ expect(scheduler.get('user:tab').nextRun).toBe(60000);
26+ });
27+
28+ test('a heartbeat cannot revive an already expired lease', () => {
29+ const { scheduler, setNow } = createScheduler();
30+ scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 });
31+
32+ setNow(FRONTEND_LEASE_MS);
33+
34+ expect(scheduler.heartbeat('user:tab')).toBeNull();
35+ expect(scheduler.get('user:tab')).toBeNull();
36+ });
37+
38+ test('accepts an immediate first deadline', () => {
39+ const { scheduler } = createScheduler();
40+ scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 0 });
41+
42+ expect(scheduler.get('user:tab').nextRun).toBe(0);
43+ });
44+
45+ test('dispatches on the backend deadline and stays aligned after a late tick', async () => {
46+ const { scheduler, dispatch, setNow } = createScheduler();
47+ scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 });
48+
49+ setNow(60000);
50+ await scheduler.tick();
51+ expect(dispatch).toHaveBeenCalledTimes(1);
52+ expect(scheduler.get('user:tab').nextRun).toBe(120000);
53+
54+ setNow(190000);
55+ await scheduler.tick();
56+ expect(dispatch).toHaveBeenCalledTimes(2);
57+ expect(scheduler.get('user:tab').nextRun).toBe(240000);
58+ });
59+
60+ test('activity resets the next keepalive deadline', () => {
61+ const { scheduler, setNow } = createScheduler();
62+ scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 });
63+
64+ setNow(50000);
65+ scheduler.heartbeat('user:tab', true);
66+
67+ expect(scheduler.get('user:tab').nextRun).toBe(110000);
68+ });
69+
70+ test('removes a job after five minutes without a frontend heartbeat', async () => {
71+ const { scheduler, dispatch, setNow } = createScheduler();
72+ scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 });
73+
74+ setNow(FRONTEND_LEASE_MS);
75+ await scheduler.tick();
76+
77+ expect(scheduler.get('user:tab')).toBeNull();
78+ expect(dispatch).not.toHaveBeenCalled();
79+ });
80+});