Make keepalive registration self-healing
| @@ -55,6 +55,8 @@ let jobRegistered = false; | |||
| 55 | let refreshPromise = null; | 55 | let refreshPromise = null; |
| 56 | let refreshRequested = false; | 56 | let refreshRequested = false; |
| 57 | let refreshTimer = null; | 57 | let refreshTimer = null; |
| 58 | let generationRefreshTimer = null; | ||
| 59 | let retryTimer = null; | ||
| 58 | let preparingJob = false; | 60 | let preparingJob = false; |
| 59 | let lifecycleVersion = 0; | 61 | let lifecycleVersion = 0; |
| 60 | // Whether keepalive is "armed" for the CURRENT chat. It stays dormant until the user sends a new | 62 | // Whether keepalive is "armed" for the CURRENT chat. It stays dormant until the user sends a new |
| @@ -111,6 +113,7 @@ async function postKeepaliveBackend(path, body) { | |||
| 111 | async function stopBackendJob() { | 113 | async function stopBackendJob() { |
| 112 | lifecycleVersion++; | 114 | lifecycleVersion++; |
| 113 | jobRegistered = false; | 115 | jobRegistered = false; |
| 116 | clearTimeout(retryTimer); | ||
| 114 | try { | 117 | try { |
| 115 | await postKeepaliveBackend('stop', {}); | 118 | await postKeepaliveBackend('stop', {}); |
| 116 | } catch (error) { | 119 | } catch (error) { |
| @@ -119,7 +122,14 @@ async function stopBackendJob() { | |||
| 119 | } | 122 | } |
| 120 | 123 | ||
| 121 | async function sendBackendHeartbeat(activity = false) { | 124 | async function sendBackendHeartbeat(activity = false) { |
| 122 | if (!jobRegistered || !armed || !isKeepaliveEnabledForActive()) { | 125 | if (!armed || !isKeepaliveEnabledForActive()) { |
| 126 | return; | ||
| 127 | } | ||
| 128 | // Registration is normally created as soon as a response finishes. If that one-shot event | ||
| 129 | // was missed (or registration failed), the regular heartbeat must self-heal instead of doing | ||
| 130 | // nothing forever. | ||
| 131 | if (!jobRegistered) { | ||
| 132 | queueJobRefresh(); | ||
| 123 | return; | 133 | return; |
| 124 | } | 134 | } |
| 125 | try { | 135 | try { |
| @@ -278,11 +288,13 @@ async function registerBackendJob() { | |||
| 278 | await stopBackendJob(); | 288 | await stopBackendJob(); |
| 279 | return; | 289 | return; |
| 280 | } | 290 | } |
| 291 | clearTimeout(retryTimer); | ||
| 281 | jobRegistered = true; | 292 | jobRegistered = true; |
| 282 | } catch (error) { | 293 | } catch (error) { |
| 283 | jobRegistered = false; | 294 | jobRegistered = false; |
| 284 | console.debug('[Keepalive] Could not register backend job:', error); | 295 | console.debug('[Keepalive] Could not register backend job:', error); |
| 285 | setTimeout(queueJobRefresh, RETRY_INTERVAL_MS); | 296 | clearTimeout(retryTimer); |
| 297 | retryTimer = setTimeout(queueJobRefresh, RETRY_INTERVAL_MS); | ||
| 286 | } | 298 | } |
| 287 | } | 299 | } |
| 288 | 300 | ||
| @@ -306,6 +318,15 @@ function scheduleJobRefresh() { | |||
| 306 | refreshTimer = setTimeout(queueJobRefresh, SETTINGS_REFRESH_DELAY_MS); | 318 | refreshTimer = setTimeout(queueJobRefresh, SETTINGS_REFRESH_DELAY_MS); |
| 307 | } | 319 | } |
| 308 | 320 | ||
| 321 | function scheduleGenerationJobRefresh() { | ||
| 322 | clearTimeout(generationRefreshTimer); | ||
| 323 | generationRefreshTimer = setTimeout(() => { | ||
| 324 | if (armed && isKeepaliveEnabledForActive()) { | ||
| 325 | queueJobRefresh(); | ||
| 326 | } | ||
| 327 | }, SETTINGS_REFRESH_DELAY_MS); | ||
| 328 | } | ||
| 329 | |||
| 309 | function setupHeartbeatLoop() { | 330 | function setupHeartbeatLoop() { |
| 310 | clearInterval(heartbeatTimer); | 331 | clearInterval(heartbeatTimer); |
| 311 | heartbeatTimer = setInterval(() => void sendBackendHeartbeat(), HEARTBEAT_INTERVAL_MS); | 332 | heartbeatTimer = setInterval(() => void sendBackendHeartbeat(), HEARTBEAT_INTERVAL_MS); |
| @@ -441,9 +462,12 @@ function onGenerationStarted(_type, _options, dryRun) { | |||
| 441 | 462 | ||
| 442 | function onGenerationEnded() { | 463 | function onGenerationEnded() { |
| 443 | markActivity(); | 464 | markActivity(); |
| 444 | if (armed && isKeepaliveEnabledForActive()) { | 465 | scheduleGenerationJobRefresh(); |
| 445 | queueJobRefresh(); | ||
| 446 | } | 466 | } |
| 467 | |||
| 468 | function onMessageReceived() { | ||
| 469 | markActivity(); | ||
| 470 | scheduleGenerationJobRefresh(); | ||
| 447 | } | 471 | } |
| 448 | 472 | ||
| 449 | function onChatContentChanged() { | 473 | function onChatContentChanged() { |
| @@ -475,7 +499,7 @@ async function init() { | |||
| 475 | // Activity resets the backend deadline but never arms keepalive on its own. A completed | 499 | // Activity resets the backend deadline but never arms keepalive on its own. A completed |
| 476 | // generation or a chat edit also refreshes the stored request so the server always replays | 500 | // generation or a chat edit also refreshes the stored request so the server always replays |
| 477 | // the latest prompt assembled by the UI. | 501 | // the latest prompt assembled by the UI. |
| 478 | eventSource.on(event_types.MESSAGE_RECEIVED, markActivity); | 502 | eventSource.on(event_types.MESSAGE_RECEIVED, onMessageReceived); |
| 479 | eventSource.on(event_types.GENERATION_STARTED, onGenerationStarted); | 503 | eventSource.on(event_types.GENERATION_STARTED, onGenerationStarted); |
| 480 | eventSource.on(event_types.GENERATION_ENDED, onGenerationEnded); | 504 | eventSource.on(event_types.GENERATION_ENDED, onGenerationEnded); |
| 481 | eventSource.on(event_types.MESSAGE_EDITED, onChatContentChanged); | 505 | eventSource.on(event_types.MESSAGE_EDITED, onChatContentChanged); |
| @@ -152,6 +152,7 @@ async function dispatchKeepaliveRequest(job) { | |||
| 152 | const timeout = setTimeout(() => controller.abort(new Error('Keepalive request timed out')), 120000); | 152 | const timeout = setTimeout(() => controller.abort(new Error('Keepalive request timed out')), 120000); |
| 153 | timeout.unref?.(); | 153 | timeout.unref?.(); |
| 154 | try { | 154 | try { |
| 155 | console.info(`[Keepalive] Dispatching job ${job.id} to ${job.endpoint}`); | ||
| 155 | const response = await fetch(`${job.request.origin}${job.endpoint}`, { | 156 | const response = await fetch(`${job.request.origin}${job.endpoint}`, { |
| 156 | method: 'POST', | 157 | method: 'POST', |
| 157 | headers: job.request.headers, | 158 | headers: job.request.headers, |
| @@ -163,6 +164,7 @@ async function dispatchKeepaliveRequest(job) { | |||
| 163 | if (!response.ok) { | 164 | if (!response.ok) { |
| 164 | throw new Error(`HTTP ${response.status}`); | 165 | throw new Error(`HTTP ${response.status}`); |
| 165 | } | 166 | } |
| 167 | console.info(`[Keepalive] Job ${job.id} completed successfully`); | ||
| 166 | } finally { | 168 | } finally { |
| 167 | clearTimeout(timeout); | 169 | clearTimeout(timeout); |
| 168 | } | 170 | } |
| @@ -234,6 +236,7 @@ router.post('/register', (request, response) => { | |||
| 234 | delayMs: request.body.delayMs, | 236 | delayMs: request.body.delayMs, |
| 235 | request: getReplayRequest(request), | 237 | request: getReplayRequest(request), |
| 236 | }); | 238 | }); |
| 239 | console.info(`[Keepalive] Registered job ${id}; first run in ${job.nextRun - Date.now()}ms, interval ${job.intervalMs}ms`); | ||
| 237 | return response.send({ ok: true, nextRun: job.nextRun, leaseMs: FRONTEND_LEASE_MS }); | 240 | return response.send({ ok: true, nextRun: job.nextRun, leaseMs: FRONTEND_LEASE_MS }); |
| 238 | } catch (error) { | 241 | } catch (error) { |
| 239 | return response.status(400).send({ error: String(error.message || error) }); | 242 | return response.status(400).send({ error: String(error.message || error) }); |