Keep expired keepalives dormant on wake

0aa62e9004c1517fe3801ca9fef82ecf60d6bc29

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

5 files changed, +130 -16Ignore whitespace
public/scripts/extensions/keepalive/index.js+55 -8
@@ -37,11 +37,6 @@ const LEGACY_DEFAULT_MESSAGE = 'Keep the cache warm.';
3737// Idle-tracking key used when no connection profile is active (manual API panel).
3838const NONE_KEY = '__none__';
3939
40-// When the active profile is already past its timeout (e.g. you just switched back
41-// to a profile that went cold), wait this short grace before pinging so an imminent
42-// user message can pre-empt the keepalive.
43-const EXPIRED_GRACE_MS = 3000;
44-
4540const defaultSettings = {
4641 enabled: false,
4742 timeoutSeconds: 295,
@@ -66,6 +61,9 @@ let armed = false;
6661// Per-profile "time since last message", keyed by connection profile id (or NONE_KEY).
6762// In-memory only: on reload every profile is treated as freshly active.
6863const lastActivity = {};
64+// Last successful backend keepalive, keyed by connection profile. The backend returns this after a
65+// sleeping tab wakes so we can distinguish a still-warm prompt from an expired one.
66+const lastKeepalive = {};
6967
7068// Per-document ids keep duplicated tabs independent. Jobs orphaned by a closed or reloaded
7169// document are deliberately cleaned up by the backend lease.
@@ -101,6 +99,32 @@ function getTimeoutMs() {
10199 return Math.max(60, Number(extension_settings[MODULE].timeoutSeconds) || 295) * 1000;
102100}
103101
102+function updateLastKeepalive(profileKey, value) {
103+ const timestamp = Number(value);
104+ if (Number.isFinite(timestamp) && timestamp > 0) {
105+ lastKeepalive[profileKey] = Math.max(lastKeepalive[profileKey] || 0, timestamp);
106+ }
107+}
108+
109+function getLastWarmTime(profileKey = getActiveProfileKey()) {
110+ return Math.max(lastActivity[profileKey] || 0, lastKeepalive[profileKey] || 0);
111+}
112+
113+function isWarmPromptExpired(profileKey = getActiveProfileKey()) {
114+ const lastWarmTime = getLastWarmTime(profileKey);
115+ return lastWarmTime > 0 && (Date.now() - lastWarmTime) >= getTimeoutMs();
116+}
117+
118+function disarmExpiredJob() {
119+ armed = false;
120+ jobRegistered = false;
121+ refreshRequested = false;
122+ lifecycleVersion++;
123+ clearTimeout(retryTimer);
124+ clearTimeout(refreshTimer);
125+ clearTimeout(generationRefreshTimer);
126+}
127+
104128async function postKeepaliveBackend(path, body) {
105129 return fetch(`/api/keepalive/${path}`, {
106130 method: 'POST',
@@ -128,13 +152,28 @@ async function sendBackendHeartbeat(activity = false) {
128152 // was missed (or registration failed), the regular heartbeat must self-heal instead of doing
129153 // nothing forever.
130154 if (!jobRegistered) {
155+ if (isWarmPromptExpired()) {
156+ disarmExpiredJob();
157+ return;
158+ }
131159 queueJobRefresh();
132160 return;
133161 }
162+ const version = lifecycleVersion;
163+ const profileKey = getActiveProfileKey();
134164 try {
135165 const response = await postKeepaliveBackend('heartbeat', { activity });
166+ const state = await response.json().catch(() => ({}));
167+ if (version !== lifecycleVersion) {
168+ return;
169+ }
170+ updateLastKeepalive(profileKey, state.lastRun);
136171 if (response.status === 404) {
137172 jobRegistered = false;
173+ if (isWarmPromptExpired(profileKey)) {
174+ disarmExpiredJob();
175+ return;
176+ }
138177 queueJobRefresh();
139178 }
140179 } catch (error) {
@@ -261,6 +300,11 @@ async function registerBackendJob() {
261300 await stopBackendJob();
262301 return;
263302 }
303+ const profileKey = getActiveProfileKey();
304+ if (isWarmPromptExpired(profileKey)) {
305+ disarmExpiredJob();
306+ return;
307+ }
264308 const version = lifecycleVersion;
265309 try {
266310 if (isGenerating()) {
@@ -271,13 +315,16 @@ async function registerBackendJob() {
271315 return;
272316 }
273317 const timeoutMs = getTimeoutMs();
274318 const last = lastActivity[getActiveProfileKeygetLastWarmTime(profileKey)] ??|| Date.now();
275319 const remaining = timeoutMs - (Date.now() - last);
276- const delayMs = remaining > 0 ? remaining : EXPIRED_GRACE_MS;
320+ if (remaining <= 0) {
321+ disarmExpiredJob();
322+ return;
323+ }
277324 const response = await postKeepaliveBackend('register', {
278325 ...request,
279326 intervalMs: timeoutMs,
280327 delayMs: remaining,
281328 });
282329 if (!response.ok) {
283330 throw new Error(`HTTP ${response.status}`);
public/scripts/extensions/keepalive/manifest.json+1 -1
@@ -6,7 +6,7 @@
66 "js": "index.js",
77 "css": "",
88 "author": "SillyTavern",
99 "version": "1.1.23",
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. The server owns the keepalive timer, while this tab 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 theWaking profilea you'retab currentlyafter usingboth isits pinged.lease Keepaliveand stayscache offwindow untilhave youexpired senddoes anot messagerestart orkeepalives startuntil a foreground generationnew (includingmessage, regenerate, swipe, or continue);. merelyOnly the profile you're currently using is pinged. Merely opening, switching to, or reloading a chat is not proof its prompt is cached.</small>
2020
2121 <hr>
2222
src/endpoints/keepalive.js+51 -6
@@ -7,6 +7,8 @@ export const router = express.Router();
77export const FRONTEND_LEASE_MS = 5 * 60 * 1000;
88const MIN_INTERVAL_MS = 60 * 1000;
99const MAX_INTERVAL_MS = 24 * 60 * 60 * 1000;
10+const EXPIRED_JOB_RETENTION_MS = MAX_INTERVAL_MS;
11+const MAX_EXPIRED_JOBS = 1000;
1012const MAX_JOB_ID_LENGTH = 128;
1113const ALLOWED_ENDPOINTS = new Set([
1214 '/api/backends/chat-completions/generate',
@@ -30,6 +32,7 @@ function advanceDeadline(deadline, interval, now) {
3032 */
3133export class KeepaliveScheduler {
3234 #jobs = new Map();
35+ #expiredJobs = new Map();
3336 #timer = null;
3437 #now;
3538 #dispatch;
@@ -50,6 +53,8 @@ export class KeepaliveScheduler {
5053
5154 register(key, job) {
5255 const now = this.#now();
56+ this.#pruneExpiredJobs(now);
57+ this.#expiredJobs.delete(key);
5358 const intervalMs = Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Number(job.intervalMs) || MIN_INTERVAL_MS));
5459 const requestedDelay = Number(job.delayMs);
5560 const delayMs = Number.isFinite(requestedDelay)
@@ -61,6 +66,7 @@ export class KeepaliveScheduler {
6166 lastSeen: now,
6267 nextRun: now + delayMs,
6368 inFlight: false,
69+ lastRun: null,
6470 });
6571 this.#schedule();
6672 return this.#jobs.get(key);
@@ -73,7 +79,7 @@ export class KeepaliveScheduler {
7379 }
7480 const now = this.#now();
7581 if ((now - job.lastSeen) >= FRONTEND_LEASE_MS) {
7682 this.#jobs.deleteexpire(key, job, now);
7783 this.#schedule();
7884 return null;
7985 }
@@ -90,6 +96,7 @@ export class KeepaliveScheduler {
9096
9197 stop(key) {
9298 const removed = this.#jobs.delete(key);
99+ this.#expiredJobs.delete(key);
93100 this.#schedule();
94101 return removed;
95102 }
@@ -98,11 +105,16 @@ export class KeepaliveScheduler {
98105 return this.#jobs.get(key) ?? null;
99106 }
100107
108+ getExpired(key) {
109+ this.#pruneExpiredJobs(this.#now());
110+ return this.#expiredJobs.get(key) ?? null;
111+ }
112+
101113 async tick(now = this.#now()) {
102114 const pending = [];
103115 for (const [key, job] of this.#jobs) {
104116 if ((now - job.lastSeen) >= FRONTEND_LEASE_MS) {
105117 this.#jobs.deleteexpire(key, job, now);
106118 continue;
107119 }
108120 if (job.nextRun > now) {
@@ -114,7 +126,14 @@ export class KeepaliveScheduler {
114126 continue;
115127 }
116128 job.inFlight = true;
117129 pending.push(Promise.resolve(this.#dispatch(job)).catchthen(error() => {
130+ const completedAt = this.#now();
131+ job.lastRun = completedAt;
132+ const expiredJob = this.#expiredJobs.get(key);
133+ if (expiredJob) {
134+ expiredJob.lastRun = completedAt;
135+ }
136+ }).catch(error => {
118137 console.debug(`[Keepalive] Backend request failed for ${job.id}:`, error);
119138 }).finally(() => {
120139 job.inFlight = false;
@@ -125,6 +144,27 @@ export class KeepaliveScheduler {
125144 await Promise.all(pending);
126145 }
127146
147+ #expire(key, job, now) {
148+ this.#jobs.delete(key);
149+ this.#expiredJobs.set(key, {
150+ expiredAt: now,
151+ intervalMs: job.intervalMs,
152+ lastRun: job.lastRun,
153+ });
154+ this.#pruneExpiredJobs(now);
155+ }
156+
157+ #pruneExpiredJobs(now) {
158+ for (const [key, job] of this.#expiredJobs) {
159+ if ((now - job.expiredAt) >= EXPIRED_JOB_RETENTION_MS) {
160+ this.#expiredJobs.delete(key);
161+ }
162+ }
163+ while (this.#expiredJobs.size > MAX_EXPIRED_JOBS) {
164+ this.#expiredJobs.delete(this.#expiredJobs.keys().next().value);
165+ }
166+ }
167+
128168 #schedule() {
129169 if (this.#timer) {
130170 this.#clearTimer(this.#timer);
@@ -246,11 +286,16 @@ router.post('/register', (request, response) => {
246286router.post('/heartbeat', (request, response) => {
247287 try {
248288 const id = getJobId(request);
249289 const jobkey = scheduler.heartbeat(getJobKey(request, id), !!request.body?.activity, getReplayRequest(request));
290+ const job = scheduler.heartbeat(key, !!request.body?.activity, getReplayRequest(request));
250291 if (!job) {
251292 returnconst responseexpiredJob = scheduler.sendStatusgetExpired(404key);
293+ return response.status(404).send({
294+ expired: !!expiredJob,
295+ lastRun: expiredJob?.lastRun ?? null,
296+ });
252297 }
253298 return response.send({ ok: true, nextRun: job.nextRun, lastRun: job.lastRun, leaseMs: FRONTEND_LEASE_MS });
254299 } catch (error) {
255300 return response.status(400).send({ error: String(error.message || error) });
256301 }
tests/keepalive-scheduler.test.js+22 -0
@@ -33,6 +33,11 @@ describe('KeepaliveScheduler', () => {
3333
3434 expect(scheduler.heartbeat('user:tab')).toBeNull();
3535 expect(scheduler.get('user:tab')).toBeNull();
36+ expect(scheduler.getExpired('user:tab')).toEqual({
37+ expiredAt: FRONTEND_LEASE_MS,
38+ intervalMs: 60000,
39+ lastRun: null,
40+ });
3641 });
3742
3843 test('accepts an immediate first deadline', () => {
@@ -55,6 +60,7 @@ describe('KeepaliveScheduler', () => {
5560 await scheduler.tick();
5661 expect(dispatch).toHaveBeenCalledTimes(2);
5762 expect(scheduler.get('user:tab').nextRun).toBe(240000);
63+ expect(scheduler.get('user:tab').lastRun).toBe(190000);
5864 });
5965
6066 test('activity resets the next keepalive deadline', () => {
@@ -77,4 +83,20 @@ describe('KeepaliveScheduler', () => {
7783 expect(scheduler.get('user:tab')).toBeNull();
7884 expect(dispatch).not.toHaveBeenCalled();
7985 });
86+
87+ test('retains the last successful keepalive after the frontend lease expires', async () => {
88+ const { scheduler, setNow } = createScheduler();
89+ scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 });
90+
91+ setNow(240000);
92+ await scheduler.tick();
93+ setNow(FRONTEND_LEASE_MS);
94+ await scheduler.tick();
95+
96+ expect(scheduler.getExpired('user:tab')).toEqual({
97+ expiredAt: FRONTEND_LEASE_MS,
98+ intervalMs: 60000,
99+ lastRun: 240000,
100+ });
101+ });
80102});