mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(sse): retry an EventSource connect attempt that never opens
An EventSource whose request never gets response headers stays in CONNECTING without firing open or error, so the backoff never runs and the staleness watchdog (which only judges a 'connected' stream) cannot see it. Bound each attempt with a 15s connect timeout: if open has not fired, close the attempt and retry through the same path onerror uses. The timer is cleared on open, on failure and at the top of every connectSSE(), and both the timeout and the failure handler ignore an EventSource that has since been replaced.
This commit is contained in:
1 parent
39077281d1
commit
536eca8f3d
3 files changed
+293
-26
No files matched your search
+59
-26
@@ -988,6 +988,12 @@ class CodemanApp {
|
||||
this._sseLastMessageAt = 0;
|
||||
this._sseStaleTimeoutMs = window.CodemanSseStale?.TIMEOUT_MS ?? 45000;
|
||||
this._sseStaleWatchdog = null;
|
||||
// Connect timeout: the watchdog only judges a 'connected' stream, and an
|
||||
// attempt whose request hangs never opens AND never errors. Each attempt
|
||||
// is bounded by this timer (armed in connectSSE, cleared on open, on
|
||||
// failure and at the top of every connectSSE so timers never stack).
|
||||
this._sseConnectTimeout = null;
|
||||
this._sseConnectTimeoutMs = window.CodemanSseStale?.CONNECT_TIMEOUT_MS ?? 15000;
|
||||
|
||||
// Connection-loss UI (banner + full-screen overlay). The decision itself is
|
||||
// pure and lives in constants.js (computeConnectionLossUi); these are just
|
||||
@@ -2057,6 +2063,10 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
connectSSE() {
|
||||
// First, before the offline early return: any path into connectSSE
|
||||
// supersedes the previous attempt, and its deadline must go with it.
|
||||
this._clearTimer('_sseConnectTimeout');
|
||||
|
||||
// Check if browser is offline
|
||||
if (!navigator.onLine) {
|
||||
this.setConnectionStatus('offline');
|
||||
@@ -2100,7 +2110,21 @@ class CodemanApp {
|
||||
const _sseParams = new URLSearchParams({ clientId: this._clientId });
|
||||
const _sseFilterId = this._sseFilterSessionId(this.activeSessionId);
|
||||
if (_sseFilterId) _sseParams.set('sessions', _sseFilterId);
|
||||
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
|
||||
const eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
|
||||
this.eventSource = eventSource;
|
||||
|
||||
// Bound the attempt. A request that never gets response headers leaves
|
||||
// the EventSource in CONNECTING with no `open` and no `error`, so without
|
||||
// this the page waits forever. readyState OPEN covers the narrow race
|
||||
// where the connection opened but its `open` callback has not run yet.
|
||||
const connectTimeout = setTimeout(() => {
|
||||
if (this._sseConnectTimeout !== connectTimeout) return;
|
||||
this._sseConnectTimeout = null;
|
||||
if (this.eventSource !== eventSource || eventSource.readyState === EventSource.OPEN) return;
|
||||
console.warn(`[SSE] connection did not open within ${this._sseConnectTimeoutMs}ms, retrying`);
|
||||
this._handleSseConnectionFailure(eventSource);
|
||||
}, this._sseConnectTimeoutMs);
|
||||
this._sseConnectTimeout = connectTimeout;
|
||||
|
||||
// Store all event listeners for cleanup on reconnect.
|
||||
//
|
||||
@@ -2129,6 +2153,7 @@ class CodemanApp {
|
||||
};
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
this._clearTimer('_sseConnectTimeout');
|
||||
this.reconnectAttempts = 0;
|
||||
// Start the liveness clock here, not at the first frame: the watchdog
|
||||
// only ever fires while the status is 'connected', and this is the
|
||||
@@ -2136,31 +2161,7 @@ class CodemanApp {
|
||||
this._sseLastMessageAt = Date.now();
|
||||
this.setConnectionStatus('connected');
|
||||
};
|
||||
this.eventSource.onerror = () => {
|
||||
this.reconnectAttempts++;
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this.setConnectionStatus('disconnected');
|
||||
} else {
|
||||
this.setConnectionStatus('reconnecting');
|
||||
}
|
||||
// Close the failed connection before scheduling reconnect
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
|
||||
this._clearTimer('sseReconnectTimeout');
|
||||
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
|
||||
// Fast first retry (200ms) for server-restart case (COM deploy),
|
||||
// then ramp up for real network issues.
|
||||
const delay = this.reconnectAttempts <= 1 ? 200
|
||||
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
|
||||
// Feeds the "Retrying in Ns" countdown. With a 30s cap on the backoff, a
|
||||
// silent wait that long is indistinguishable from a hung app.
|
||||
this._nextSseRetryAt = Date.now() + delay;
|
||||
this._updateConnectionLossUi();
|
||||
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
|
||||
};
|
||||
this.eventSource.onerror = () => this._handleSseConnectionFailure(eventSource);
|
||||
|
||||
// Create stable handler wrappers once (reused across reconnects so
|
||||
// removeEventListener always matches the original reference)
|
||||
@@ -2323,6 +2324,38 @@ class CodemanApp {
|
||||
this._sseStaleWatchdog = setInterval(() => this._checkSseStale(), 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a failed SSE attempt (onerror, or the connect timeout) and schedule
|
||||
* the normal exponential-backoff retry.
|
||||
*/
|
||||
_handleSseConnectionFailure(eventSource) {
|
||||
// A late error or timeout from an EventSource that has since been
|
||||
// replaced must not close or reschedule the connection that replaced it.
|
||||
if (this.eventSource !== eventSource) return;
|
||||
this._clearTimer('_sseConnectTimeout');
|
||||
this.reconnectAttempts++;
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this.setConnectionStatus('disconnected');
|
||||
} else {
|
||||
this.setConnectionStatus('reconnecting');
|
||||
}
|
||||
// Close the failed connection before scheduling reconnect
|
||||
this.eventSource = null;
|
||||
eventSource.close();
|
||||
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
|
||||
this._clearTimer('sseReconnectTimeout');
|
||||
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
|
||||
// Fast first retry (200ms) for server-restart case (COM deploy),
|
||||
// then ramp up for real network issues.
|
||||
const delay = this.reconnectAttempts <= 1 ? 200
|
||||
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
|
||||
// Feeds the "Retrying in Ns" countdown. With a 30s cap on the backoff, a
|
||||
// silent wait that long is indistinguishable from a hung app.
|
||||
this._nextSseRetryAt = Date.now() + delay;
|
||||
this._updateConnectionLossUi();
|
||||
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a reconnect if the SSE stream has gone quiet while still claiming to
|
||||
* be connected. Called by the 5s watchdog and on tab-visible.
|
||||
|
||||
@@ -783,6 +783,15 @@ function formatPromptCacheStatus(cache, now, statusLine) {
|
||||
// Pure: no DOM, no timers, no side effects. `now` is passed in.
|
||||
const SSE_STALE_TIMEOUT_MS = 45000; // three missed 15s heartbeats
|
||||
|
||||
// The watchdog above cannot see a connect attempt that never opens: an
|
||||
// EventSource whose request gets no response headers (a resumed phone on a
|
||||
// VPN, a proxy that accepted the socket and went silent) stays CONNECTING
|
||||
// without firing `open` or `error`, and its status is never 'connected'.
|
||||
// connectSSE() bounds each attempt by this much and then retries it through
|
||||
// the normal backoff. Generous next to a healthy open (well under a second)
|
||||
// so a slow link is not cut off mid-handshake.
|
||||
const SSE_CONNECT_TIMEOUT_MS = 15000;
|
||||
|
||||
function computeSseStale(input) {
|
||||
const {
|
||||
lastMessageAt = null,
|
||||
@@ -1500,6 +1509,7 @@ if (typeof window !== 'undefined') {
|
||||
window.CodemanSseStale = {
|
||||
compute: computeSseStale,
|
||||
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
||||
CONNECT_TIMEOUT_MS: SSE_CONNECT_TIMEOUT_MS,
|
||||
};
|
||||
window.CodemanPromptCache = {
|
||||
format: formatPromptCacheStatus,
|
||||
|
||||
Reference in new issue
Block a user