mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
Merge branch 'fix/sse-stale-watchdog'
Heal a stalled SSE stream: the server's :keepalive comment becomes a named sse:heartbeat event (comments are invisible to EventSource by spec), and the client gains a staleness watchdog that forces a reconnect after three missed beats. Also applies a confirmed rename locally instead of waiting on SSE. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+86
-3
@@ -684,6 +684,17 @@ class CodemanApp {
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.isOnline = navigator.onLine;
|
||||
|
||||
// SSE staleness watchdog. An EventSource that stops delivering does not
|
||||
// always error (a proxy that idle-closed it, a resumed laptop), so
|
||||
// `onerror` never fires and every SSE-driven surface freezes silently.
|
||||
// The server heartbeats every 15s; going quiet for three of them means the
|
||||
// stream is a zombie and has to be rebuilt. The decision is pure
|
||||
// (computeSseStale in constants.js); these are its inputs. The threshold
|
||||
// is an instance field so a browser test can shrink it.
|
||||
this._sseLastMessageAt = 0;
|
||||
this._sseStaleTimeoutMs = window.CodemanSseStale?.TIMEOUT_MS ?? 45000;
|
||||
this._sseStaleWatchdog = null;
|
||||
|
||||
// Connection-loss UI (banner + full-screen overlay). The decision itself is
|
||||
// pure and lives in constants.js (computeConnectionLossUi); these are just
|
||||
// its inputs. `_connDownSince` is the timestamp the transport LEFT the
|
||||
@@ -719,6 +730,11 @@ class CodemanApp {
|
||||
window.addEventListener('pagehide', () => this._persistReliableNow());
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') this._persistReliableNow();
|
||||
// A background tab's timers are throttled, so the 5s watchdog may not
|
||||
// have run for minutes, and a wake/unlock is exactly when a stream
|
||||
// comes back zombie. Checking here is what makes recovery feel instant
|
||||
// instead of up to a full timeout late.
|
||||
else this._checkSseStale();
|
||||
});
|
||||
|
||||
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
|
||||
@@ -1421,6 +1437,14 @@ class CodemanApp {
|
||||
// Clear any pending reconnect timeout to prevent duplicate connections
|
||||
this._clearTimer('sseReconnectTimeout');
|
||||
|
||||
// Same discipline for the staleness watchdog: connectSSE() runs on every
|
||||
// reconnect and is the only teardown path this page-lifetime interval has,
|
||||
// so clearing it anywhere else (or not at all) stacks intervals.
|
||||
if (this._sseStaleWatchdog) {
|
||||
clearInterval(this._sseStaleWatchdog);
|
||||
this._sseStaleWatchdog = null;
|
||||
}
|
||||
|
||||
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
|
||||
if (this._sseListenerCleanup) {
|
||||
this._sseListenerCleanup();
|
||||
@@ -1448,11 +1472,20 @@ class CodemanApp {
|
||||
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
|
||||
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
|
||||
|
||||
// Store all event listeners for cleanup on reconnect
|
||||
// Store all event listeners for cleanup on reconnect.
|
||||
//
|
||||
// Every handler is wrapped so ANY frame that arrives stamps the liveness
|
||||
// clock the staleness watchdog reads. Doing it here (rather than at the
|
||||
// three separate registration sites below) is what keeps a future
|
||||
// addListener() call from silently opting out of it.
|
||||
const listeners = [];
|
||||
const addListener = (event, handler) => {
|
||||
this.eventSource.addEventListener(event, handler);
|
||||
listeners.push({ event, handler });
|
||||
const stamped = (e) => {
|
||||
this._sseLastMessageAt = Date.now();
|
||||
handler(e);
|
||||
};
|
||||
this.eventSource.addEventListener(event, stamped);
|
||||
listeners.push({ event, handler: stamped });
|
||||
};
|
||||
|
||||
// Create cleanup function to remove all listeners
|
||||
@@ -1467,6 +1500,10 @@ class CodemanApp {
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
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
|
||||
// moment that becomes true.
|
||||
this._sseLastMessageAt = Date.now();
|
||||
this.setConnectionStatus('connected');
|
||||
};
|
||||
this.eventSource.onerror = () => {
|
||||
@@ -1614,6 +1651,52 @@ class CodemanApp {
|
||||
}
|
||||
this._onSessionListMaybeChanged();
|
||||
});
|
||||
|
||||
// Liveness heartbeat. The handler is deliberately empty: the whole point
|
||||
// is the stamp inherited from addListener's wrapper. It still has to be
|
||||
// REGISTERED: EventSource only dispatches named events that have a
|
||||
// listener, so without this the frame arrives on the wire and is dropped
|
||||
// before it can prove the stream is alive.
|
||||
addListener(SSE_EVENTS.HEARTBEAT, () => {});
|
||||
|
||||
// Watchdog: a stream that goes quiet without erroring is invisible to
|
||||
// onerror, so poll the pure staleness policy and rebuild the connection
|
||||
// ourselves. 5s granularity against a 45s threshold: cheap, and it keeps
|
||||
// the worst-case detection lag well under a heartbeat interval.
|
||||
this._sseStaleWatchdog = setInterval(() => this._checkSseStale(), 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Recovery needs no new sync path: the reconnect re-runs `handleInit`, which
|
||||
* already calls `_resetAllAppState()` and rebuilds everything from the
|
||||
* server. The connection-loss UI needs nothing either: `connectSSE()` sets
|
||||
* status 'connecting' (reconnectAttempts was zeroed by onopen), and the 2.5s
|
||||
* grace in computeConnectionLossUi means a stream that heals in 200ms shows
|
||||
* nothing at all.
|
||||
*/
|
||||
_checkSseStale() {
|
||||
const policy = window.CodemanSseStale;
|
||||
if (!policy) return;
|
||||
const now = Date.now();
|
||||
const stale = policy.compute({
|
||||
lastMessageAt: this._sseLastMessageAt,
|
||||
now,
|
||||
status: this._connectionStatus,
|
||||
isOnline: this.isOnline,
|
||||
timeoutMs: this._sseStaleTimeoutMs,
|
||||
});
|
||||
if (!stale) return;
|
||||
// If a middlebox ever strips or delays heartbeats, the failure mode is
|
||||
// "silently reconnects every 45s", and a field report of that would be
|
||||
// undebuggable without this line.
|
||||
console.log(
|
||||
`[SSE] stream stale: no frame for ${now - this._sseLastMessageAt}ms ` +
|
||||
`(threshold ${this._sseStaleTimeoutMs}ms), forcing reconnect`
|
||||
);
|
||||
this.connectSSE();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -379,6 +379,41 @@ function computeConnectionLossUi(input) {
|
||||
};
|
||||
}
|
||||
|
||||
// SSE staleness policy: is this stream a zombie?
|
||||
//
|
||||
// An EventSource that stops delivering does not always error. A proxy that
|
||||
// idle-closed the connection, a laptop resumed from sleep, a tailnet
|
||||
// reconnect: `onerror` never fires, the header dot stays green, and every
|
||||
// SSE-driven surface (tab status dots, sessions created on another device,
|
||||
// renames) freezes until the user reloads. The server writes a
|
||||
// `sse:heartbeat` frame every 15s, so silence longer than three of them means
|
||||
// the stream is dead even though the transport still claims otherwise.
|
||||
//
|
||||
// Stale ONLY when the transport believes it is 'connected': the other states
|
||||
// already have the reconnect/backoff machinery running, and re-firing on top
|
||||
// of them would stack reconnects. That guard is also the loop breaker: a
|
||||
// forced reconnect leaves 'connected' immediately, so the watchdog cannot
|
||||
// fire again while one is in flight. `navigator.onLine === false` is not
|
||||
// staleness either; there is nothing to reconnect to yet.
|
||||
//
|
||||
// Pure: no DOM, no timers, no side effects. `now` is passed in.
|
||||
const SSE_STALE_TIMEOUT_MS = 45000; // three missed 15s heartbeats
|
||||
|
||||
function computeSseStale(input) {
|
||||
const {
|
||||
lastMessageAt = null,
|
||||
now = 0,
|
||||
status = 'connected',
|
||||
isOnline = true,
|
||||
timeoutMs = SSE_STALE_TIMEOUT_MS,
|
||||
} = input || {};
|
||||
if (!isOnline || status !== 'connected') return false;
|
||||
// No frame has ever arrived: `init` lands on connect, so this is a stream
|
||||
// that has not opened yet rather than one that went quiet.
|
||||
if (typeof lastMessageAt !== 'number' || !(lastMessageAt > 0)) return false;
|
||||
return now - lastMessageAt >= timeoutMs;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
@@ -401,6 +436,10 @@ if (typeof window !== 'undefined') {
|
||||
compute: computeConnectionLossUi,
|
||||
GRACE_MS: CONNECTION_LOSS_GRACE_MS,
|
||||
};
|
||||
window.CodemanSseStale = {
|
||||
compute: computeSseStale,
|
||||
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
@@ -514,6 +553,9 @@ const SSE_EVENTS = {
|
||||
// Core
|
||||
INIT: 'init',
|
||||
|
||||
// Transport
|
||||
HEARTBEAT: 'sse:heartbeat',
|
||||
|
||||
// Session lifecycle
|
||||
SESSION_CREATED: 'session:created',
|
||||
SESSION_UPDATED: 'session:updated',
|
||||
|
||||
@@ -1415,9 +1415,54 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.activeFocusTrap.activate();
|
||||
},
|
||||
|
||||
/**
|
||||
* Write a name the server has just confirmed into the local session map.
|
||||
*
|
||||
* Both rename surfaces re-render the tab strip from `this.sessions` right
|
||||
* after their PUT, so without this they depended on the `session:updated` SSE
|
||||
* frame to carry their own write back. On a page whose SSE stream has gone
|
||||
* quiet without erroring (a proxy that idle-closed it, a laptop resumed from
|
||||
* sleep) that frame never lands: the PUT stores the new name, the re-render
|
||||
* repaints the stale one, and the rename looks like it did nothing until a
|
||||
* full page reload. The response body is authoritative, so apply it directly.
|
||||
* The SSE frame, when it does arrive, replaces the object with the same name.
|
||||
*/
|
||||
_applyLocalSessionName(sessionId, name) {
|
||||
if (typeof name !== 'string') return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.name = name;
|
||||
this.sessions.set(sessionId, session);
|
||||
// Mirrors _onSessionUpdated: subagent windows cache their parent's name.
|
||||
this.updateSubagentParentNames?.(sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT a session name and return the name the server stored, or null if the
|
||||
* request failed. `_apiPut` swallows network errors into a null Response and
|
||||
* an API-level failure arrives as a non-ok status or `{success:false}`, so a
|
||||
* rename that silently did nothing has to be detected here, not thrown.
|
||||
*/
|
||||
async _putSessionName(sessionId, name) {
|
||||
const res = await this._apiPut(`/api/sessions/${sessionId}/name`, { name });
|
||||
if (!res || !res.ok) return null;
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (payload && payload.success === false) return null;
|
||||
const confirmed = payload?.data?.name;
|
||||
return typeof confirmed === 'string' ? confirmed : name;
|
||||
},
|
||||
|
||||
async saveSessionName() {
|
||||
if (!this.editingSessionId) return;
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
// Captured: the modal can be closed (or switched to another session) while
|
||||
// the PUT is in flight, and the name belongs to the session that was open.
|
||||
const sessionId = this.editingSessionId;
|
||||
const session = this.sessions.get(sessionId);
|
||||
const parsed = session ? parseSessionPrefix(session.name) : null;
|
||||
const inputVal = document.getElementById('modalSessionName').value.trim();
|
||||
let name;
|
||||
@@ -1426,11 +1471,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else {
|
||||
name = inputVal;
|
||||
}
|
||||
try {
|
||||
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
|
||||
} catch (err) {
|
||||
this.showToast('Failed to save session name: ' + err.message, 'error');
|
||||
const confirmed = await this._putSessionName(sessionId, name);
|
||||
if (confirmed === null) {
|
||||
this.showToast('Failed to save session name', 'error');
|
||||
return;
|
||||
}
|
||||
this._applyLocalSessionName(sessionId, confirmed);
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
async autoSaveAutoCompact() {
|
||||
@@ -1740,15 +1787,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Skip the API call if the session vanished between focus and blur.
|
||||
const stillExists = this.sessions.has(sessionId);
|
||||
if (stillExists && fullName !== session.name) {
|
||||
try {
|
||||
await fetch(`/api/sessions/${sessionId}/name`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: fullName })
|
||||
});
|
||||
} catch (err) {
|
||||
const confirmed = await this._putSessionName(sessionId, fullName);
|
||||
if (confirmed === null) {
|
||||
tabName.textContent = originalContent;
|
||||
this.showToast('Failed to rename', 'error');
|
||||
} else {
|
||||
// The re-render below repaints from this.sessions, so the new name has
|
||||
// to be in the map before it runs (see _applyLocalSessionName()).
|
||||
this._applyLocalSessionName(sessionId, confirmed);
|
||||
}
|
||||
}
|
||||
// Re-render tabs to restore full tab structure
|
||||
|
||||
+21
-1
@@ -5,8 +5,9 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* 154 event constants organized by category:
|
||||
* 155 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Transport** (1): sse:heartbeat
|
||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
|
||||
@@ -52,6 +53,22 @@
|
||||
/** Sent to each SSE client on initial connection with full app state. */
|
||||
export const Init = 'init' as const;
|
||||
|
||||
// ─── Transport ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liveness frame written to every SSE client every `SSE_HEARTBEAT_INTERVAL`.
|
||||
* Payload: `{ t: <epoch ms> }`.
|
||||
*
|
||||
* Carries no application data; its only job is to be *observable*. This was a
|
||||
* `:keepalive` SSE **comment**, and comments are invisible to `EventSource` by
|
||||
* spec, so a stream that stopped delivering without erroring (a proxy that
|
||||
* idle-closed it, a laptop resumed from sleep, a tailnet reconnect) was
|
||||
* undetectable to the client: `onerror` never fires and the UI freezes until a
|
||||
* reload. A named event reaches a listener, which is what lets the client's
|
||||
* staleness watchdog notice the silence and force a reconnect.
|
||||
*/
|
||||
export const Heartbeat = 'sse:heartbeat' as const;
|
||||
|
||||
// ─── Session Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
/** New session spawned. */
|
||||
@@ -443,6 +460,9 @@ export const SseEvent = {
|
||||
// Core
|
||||
Init,
|
||||
|
||||
// Transport
|
||||
Heartbeat,
|
||||
|
||||
// Session lifecycle
|
||||
SessionCreated,
|
||||
SessionUpdated,
|
||||
|
||||
@@ -470,12 +470,20 @@ export class SseStreamManager {
|
||||
// ========== Client Health ==========
|
||||
|
||||
/**
|
||||
* Clean up dead SSE clients and send keep-alive comments.
|
||||
* Clean up dead SSE clients and send the liveness heartbeat.
|
||||
* Keep-alive prevents proxy/load-balancer timeouts on idle connections.
|
||||
* Dead client cleanup prevents memory leaks from abruptly terminated connections.
|
||||
*
|
||||
* The heartbeat is a NAMED event, not the `:keepalive` comment it used to be:
|
||||
* comments are invisible to `EventSource` by spec, so a stream that stopped
|
||||
* delivering without erroring was undetectable to the client (see
|
||||
* `SseEvent.Heartbeat`). Written per-client rather than through `broadcast()`
|
||||
* deliberately: the frame carries no session data, so it needs no owner
|
||||
* routing, and this loop is already walking every client to check its socket.
|
||||
*/
|
||||
cleanupDeadClients(): void {
|
||||
const deadClients: FastifyReply[] = [];
|
||||
const heartbeat = `event: ${SseEvent.Heartbeat}\ndata: ${JSON.stringify({ t: Date.now() })}\n\n`;
|
||||
|
||||
for (const [client] of this.sseClients) {
|
||||
try {
|
||||
@@ -484,11 +492,9 @@ export class SseStreamManager {
|
||||
if (!socket || socket.destroyed || !socket.writable) {
|
||||
deadClients.push(client);
|
||||
} else {
|
||||
// Send SSE comment as keep-alive. Only add padding when tunnel is
|
||||
// active — it flushes Cloudflare proxy buffers but wastes bandwidth
|
||||
// for direct/Tailscale connections.
|
||||
const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n';
|
||||
client.raw.write(ka);
|
||||
// Only add padding when tunnel is active: it flushes Cloudflare
|
||||
// proxy buffers but wastes bandwidth for direct/Tailscale connections.
|
||||
client.raw.write(this._isTunnelActive ? heartbeat + SSE_PADDING : heartbeat);
|
||||
}
|
||||
} catch {
|
||||
// Error accessing socket means client is dead
|
||||
|
||||
Reference in New Issue
Block a user