Skip to content

Commit a147706

Browse files
ericdalloeca
andcommitted
Add graceful auto-reconnection on connection loss
When the SSE connection drops (network instability, Tailscale VPN reconnect, server restart), the bridge now automatically attempts to re-establish the connection with exponential backoff (1s → 2s → 4s → up to 15s). The webview stays mounted during reconnection so chat history is preserved. UI changes: - Non-intrusive reconnection banner slides in at the top of the session showing attempt count and retry countdown - Banner turns green and slides away on successful reconnect - Connection tab dot pulses orange during reconnection - No user interaction required — fully automatic recovery 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca <noreply@eca.dev>
1 parent 5af84c0 commit a147706

File tree

7 files changed

+416
-26
lines changed

7 files changed

+416
-26
lines changed

src/bridge/sse.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,14 @@ export class SSEClient {
5353
this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
5454
}
5555

56-
/** Open the SSE stream. Throws if the initial HTTP request fails. */
56+
/**
57+
* Open the SSE stream. Throws if the initial HTTP request fails.
58+
* Safe to call again after a `disconnect()` — resets internal state first.
59+
*/
5760
async connect(): Promise<void> {
61+
// Clean up any leftover state from a previous connection
62+
this.cleanUp();
63+
5864
this.running = true;
5965
this.abortController = new AbortController();
6066

@@ -79,10 +85,16 @@ export class SSEClient {
7985
/** Cleanly close the SSE stream. Safe to call multiple times. */
8086
disconnect(): void {
8187
this.running = false;
88+
this.cleanUp();
89+
}
90+
91+
/** Release resources without changing the `running` flag. */
92+
private cleanUp(): void {
8293
this.clearHeartbeatTimer();
8394
this.abortController?.abort();
8495
this.reader?.cancel().catch(() => {});
8596
this.reader = null;
97+
this.abortController = null;
8698
}
8799

88100
// ---------------------------------------------------------------------------

src/bridge/transport.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import type {
2727
ChatListChangeCallback,
2828
ChatSummary,
2929
MCPServerUpdatedParams,
30+
ReconnectionCallback,
31+
ReconnectionState,
3032
SessionConfig,
3133
SessionState,
3234
SSEChatStatusPayload,
@@ -38,6 +40,11 @@ import type {
3840
/** Timeout for the initial SSE handshake (session:connected). */
3941
const SSE_CONNECT_TIMEOUT_MS = 15_000;
4042

43+
/** Base delay between reconnection attempts (exponential backoff). */
44+
const RECONNECT_BASE_DELAY_MS = 1_000;
45+
/** Maximum delay between reconnection attempts. */
46+
const RECONNECT_MAX_DELAY_MS = 15_000;
47+
4148
export class WebBridge {
4249
private api: EcaRemoteApi;
4350
private sse: SSEClient | null = null;
@@ -47,6 +54,14 @@ export class WebBridge {
4754
private outboundListener: ((e: Event) => void) | null = null;
4855
private mcpServers: MCPServerUpdatedParams[] = [];
4956

57+
// --- Reconnection state ---
58+
private reconnecting = false;
59+
private reconnectAttempt = 0;
60+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
61+
private onReconnectionChange: ReconnectionCallback | null = null;
62+
/** Set to true once the bridge has successfully connected at least once. */
63+
private hasConnectedOnce = false;
64+
5065
/**
5166
* Lightweight chat index exposed to the React shell for the sidebar.
5267
* Kept in sync as chat events flow through the bridge.
@@ -106,13 +121,15 @@ export class WebBridge {
106121
await this.connectSSE();
107122
if (this.disposed) return;
108123

124+
this.hasConnectedOnce = true;
109125
this.registerOutboundHandler();
110126
this.registerTransport();
111127
}
112128

113129
disconnect(): void {
114130
this.disposed = true;
115131
this.connected = false;
132+
this.cleanUpReconnect();
116133
this.sse?.disconnect();
117134
this.sse = null;
118135
window.__ecaWebTransport = undefined;
@@ -127,6 +144,181 @@ export class WebBridge {
127144
return this.connected;
128145
}
129146

147+
/** Whether the bridge is currently attempting to reconnect. */
148+
isReconnecting(): boolean {
149+
return this.reconnecting;
150+
}
151+
152+
/** Register a callback for reconnection state changes. */
153+
onReconnection(cb: ReconnectionCallback): void {
154+
this.onReconnectionChange = cb;
155+
}
156+
157+
// ---------------------------------------------------------------------------
158+
// Auto-reconnection
159+
// ---------------------------------------------------------------------------
160+
161+
/**
162+
* Attempt to re-establish the SSE connection with exponential backoff.
163+
*
164+
* Called automatically when an established SSE connection drops
165+
* (heartbeat timeout, stream end, network error). Does NOT fire for
166+
* initial connection failures — those are surfaced to the caller of
167+
* `connect()` directly.
168+
*
169+
* During reconnection the webview stays mounted with its full chat
170+
* history; only the live SSE stream is re-opened.
171+
*/
172+
private scheduleReconnect(): void {
173+
if (this.disposed || this.reconnecting) return;
174+
this.reconnecting = true;
175+
this.reconnectAttempt = 0;
176+
this.attemptReconnect();
177+
}
178+
179+
private attemptReconnect(): void {
180+
if (this.disposed) {
181+
this.cleanUpReconnect();
182+
return;
183+
}
184+
185+
this.reconnectAttempt++;
186+
const delay = Math.min(
187+
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempt - 1),
188+
RECONNECT_MAX_DELAY_MS,
189+
);
190+
191+
console.log(
192+
`[Bridge] Reconnect attempt #${this.reconnectAttempt} in ${delay}ms`,
193+
);
194+
195+
this.notifyReconnection({
196+
status: 'reconnecting',
197+
attempt: this.reconnectAttempt,
198+
nextRetryMs: delay,
199+
});
200+
201+
this.reconnectTimer = setTimeout(async () => {
202+
if (this.disposed) {
203+
this.cleanUpReconnect();
204+
return;
205+
}
206+
207+
try {
208+
// Quick health check first — fail fast if server is unreachable
209+
await this.api.health();
210+
if (this.disposed) return;
211+
212+
// Re-open SSE
213+
await this.reconnectSSE();
214+
if (this.disposed) return;
215+
216+
// Success!
217+
console.log(`[Bridge] Reconnected after ${this.reconnectAttempt} attempt(s)`);
218+
this.reconnecting = false;
219+
this.reconnectAttempt = 0;
220+
221+
// Re-sync server state with the webview
222+
await this.syncAfterReconnect();
223+
224+
this.notifyReconnection({
225+
status: 'reconnected',
226+
attempt: this.reconnectAttempt,
227+
});
228+
} catch (err) {
229+
console.warn('[Bridge] Reconnect attempt failed:', err);
230+
if (!this.disposed) {
231+
this.attemptReconnect();
232+
}
233+
}
234+
}, delay);
235+
}
236+
237+
/**
238+
* Re-open the SSE stream (without the full connect() ceremony).
239+
* Rejects if the handshake times out or the connection fails.
240+
*/
241+
private reconnectSSE(): Promise<void> {
242+
return new Promise<void>((resolve, reject) => {
243+
const timeout = setTimeout(
244+
() => reject(new Error('SSE reconnect timeout')),
245+
SSE_CONNECT_TIMEOUT_MS,
246+
);
247+
248+
// Disconnect old SSE if it still exists
249+
this.sse?.disconnect();
250+
251+
this.sse = new SSEClient(
252+
this.api.sseUrl(),
253+
this.api.authPassword,
254+
(event) => {
255+
if (event.event === 'session:connected' && !this.connected) {
256+
clearTimeout(timeout);
257+
this.handleSessionConnected(event);
258+
this.connected = true;
259+
resolve();
260+
} else {
261+
this.handleSSEEvent(event);
262+
}
263+
},
264+
(error) => {
265+
if (!this.connected) {
266+
clearTimeout(timeout);
267+
reject(error);
268+
} else {
269+
console.error('[Bridge] SSE error:', error);
270+
}
271+
},
272+
() => {
273+
console.warn('[Bridge] SSE disconnected');
274+
this.connected = false;
275+
this.dispatch('server/statusChanged', 'Stopped');
276+
this.scheduleReconnect();
277+
},
278+
);
279+
280+
this.sse.connect().catch((err) => {
281+
clearTimeout(timeout);
282+
reject(err);
283+
});
284+
});
285+
}
286+
287+
/**
288+
* After a successful reconnect, refresh session config and re-dispatch
289+
* a "Running" status so the webview knows the server is back.
290+
*/
291+
private async syncAfterReconnect(): Promise<void> {
292+
if (!this.sessionState) return;
293+
294+
// Re-dispatch workspace and config in case the server restarted
295+
if (this.sessionState.workspaceFolders) {
296+
this.dispatch('server/setWorkspaceFolders', this.sessionState.workspaceFolders);
297+
}
298+
if (this.sessionState.config) {
299+
this.dispatch('config/updated', this.sessionState.config);
300+
}
301+
if (this.sessionState.mcpServers) {
302+
this.mcpServers = [...this.sessionState.mcpServers];
303+
this.dispatch('tool/serversUpdated', this.mcpServers);
304+
}
305+
this.dispatch('server/setTrust', this.sessionState.trust ?? false);
306+
this.dispatch('server/statusChanged', 'Running');
307+
}
308+
309+
private notifyReconnection(state: ReconnectionState): void {
310+
this.onReconnectionChange?.(state);
311+
}
312+
313+
private cleanUpReconnect(): void {
314+
this.reconnecting = false;
315+
this.reconnectAttempt = 0;
316+
if (this.reconnectTimer) {
317+
clearTimeout(this.reconnectTimer);
318+
this.reconnectTimer = null;
319+
}
320+
}
321+
130322
// ---------------------------------------------------------------------------
131323
// Chat list API (for the sidebar)
132324
// ---------------------------------------------------------------------------
@@ -220,6 +412,11 @@ export class WebBridge {
220412
console.warn('[Bridge] SSE disconnected');
221413
this.connected = false;
222414
this.dispatch('server/statusChanged', 'Stopped');
415+
416+
// Auto-reconnect if this was a previously-established connection
417+
if (this.hasConnectedOnce && !this.disposed) {
418+
this.scheduleReconnect();
419+
}
223420
},
224421
);
225422

src/bridge/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,22 @@ export interface ChatEntry {
291291
/** Callback signature for chat list change notifications. */
292292
export type ChatListChangeCallback = (chats: ChatEntry[], selectedChatId: string | null) => void;
293293

294+
// ---------------------------------------------------------------------------
295+
// Reconnection types
296+
// ---------------------------------------------------------------------------
297+
298+
/** State emitted by the bridge during auto-reconnection attempts. */
299+
export interface ReconnectionState {
300+
status: 'reconnecting' | 'reconnected' | 'failed';
301+
/** Current attempt number (1-based). */
302+
attempt: number;
303+
/** Milliseconds until the next retry (only while status === 'reconnecting'). */
304+
nextRetryMs?: number;
305+
}
306+
307+
/** Callback signature for reconnection state changes. */
308+
export type ReconnectionCallback = (state: ReconnectionState) => void;
309+
294310
// ---------------------------------------------------------------------------
295311
// Webview dispatch types (bridge → webview via postMessage)
296312
// ---------------------------------------------------------------------------

src/pages/ConnectionBar.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,10 @@ function formatHost(host: string): string {
9595
/** Map connection status to a CSS dot class. */
9696
function dotClass(status: ConnectionEntry['status']): string {
9797
switch (status) {
98-
case 'connected': return 'dot-connected';
99-
case 'connecting': return 'dot-connecting';
100-
case 'error': return 'dot-error';
101-
default: return 'dot-idle';
98+
case 'connected': return 'dot-connected';
99+
case 'connecting': return 'dot-connecting';
100+
case 'reconnecting': return 'dot-reconnecting';
101+
case 'error': return 'dot-error';
102+
default: return 'dot-idle';
102103
}
103104
}

src/pages/RemoteProduct.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@
9595
animation: dot-pulse 1s ease-in-out infinite;
9696
}
9797

98+
.dot-reconnecting {
99+
background: #ffaa32;
100+
animation: dot-pulse 1s ease-in-out infinite;
101+
}
102+
98103
.dot-error {
99104
background: #f14c4c;
100105
}

0 commit comments

Comments
 (0)