fix: implement loading timeout, SSE reconnect, and message retry (#857)

* fix: implement loading timeout, SSE reconnect, and message retry

- Loading timeout: 30s timeout with retry/cancel buttons to prevent infinite loading
- SSE reconnect: Auto-reconnect up to 3 times with exponential backoff (1s, 2s, 4s)
- Message retry: Ensure critical messages reach webview with 5s timeout and 3 retries

This fix prevents the chat from getting stuck in a permanent loading state
and improves reliability of SSE connections and webview communication.

Fixes #851

* fix: harden vscode bridge retry flow

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jwcrystal
2026-04-07 15:46:06 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 1fed81439c
commit 4cb918f6cb
3 changed files with 197 additions and 25 deletions
+85 -19
View File
@@ -27,6 +27,10 @@ const SSE_RESPONSE_HEADERS = {
'cache-control': 'no-cache',
} as const;
// SSE reconnect configuration
const MAX_RECONNECTS = 3;
const BASE_RECONNECT_DELAY = 1000; // 1 second
const serializeSseEventBlock = (event: StreamEvent<unknown>): string => {
const lines: string[] = [];
if (typeof event.id === 'string' && event.id.length > 0) {
@@ -103,28 +107,65 @@ export const openSseProxy = async ({
const { pathname, directory } = normalizeSsePath(path);
const resolvedDirectory = directory || resolveDefaultDirectory(manager);
const connect = async () => {
if (pathname === '/global/event') {
try {
return await client.global.event(getSseOptions(signal, onChunk));
} catch (error) {
if ((error as Error)?.name === 'AbortError' || signal.aborted) {
throw error;
}
return client.event.subscribe(
{ directory: resolvedDirectory },
getSseOptions(signal, onChunk, resolvedDirectory),
);
}
}
// Reconnect logic with exponential backoff
let reconnectAttempts = 0;
return client.event.subscribe(
{ directory: resolvedDirectory },
getSseOptions(signal, onChunk),
);
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const connect = async (): Promise<{ stream: AsyncIterable<unknown> }> => {
try {
console.log(`[SSE] Connecting to ${pathname} (attempt ${reconnectAttempts + 1}/${MAX_RECONNECTS + 1})`);
if (pathname === '/global/event') {
try {
const result = await client.global.event(getSseOptions(signal, onChunk));
// Reset reconnect counter on successful connection
reconnectAttempts = 0;
return result;
} catch (error) {
if ((error as Error)?.name === 'AbortError' || signal.aborted) {
throw error;
}
// Fallback to directory event on error
console.warn('[SSE] Global event failed, falling back to directory event', error);
const result = client.event.subscribe(
{ directory: resolvedDirectory },
getSseOptions(signal, onChunk, resolvedDirectory),
);
reconnectAttempts = 0;
return result;
}
}
const result = client.event.subscribe(
{ directory: resolvedDirectory },
getSseOptions(signal, onChunk),
);
reconnectAttempts = 0;
return result;
} catch (error) {
// Implement reconnect logic
if (!signal.aborted && reconnectAttempts < MAX_RECONNECTS) {
reconnectAttempts++;
const delay = BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1); // Exponential backoff
console.warn(
`[SSE] Connection failed (attempt ${reconnectAttempts}/${MAX_RECONNECTS}), ` +
`retrying in ${delay}ms...`,
error
);
await sleep(delay);
return connect(); // Recursive retry
}
console.error(`[SSE] Connection failed after ${reconnectAttempts} attempts`, error);
throw error;
}
};
const result = await connect();
const run = (async () => {
try {
for await (const _ of result.stream) {
@@ -135,7 +176,32 @@ export const openSseProxy = async ({
}
} catch (error: unknown) {
const cause = (error as { cause?: { code?: string } } | null)?.cause;
if (!signal.aborted && cause?.code !== 'UND_ERR_SOCKET') {
// Attempt reconnect on socket errors
if (!signal.aborted) {
if (cause?.code === 'UND_ERR_SOCKET' || cause?.code === 'ECONNRESET') {
console.warn('[SSE] Socket error detected, attempting reconnect...');
if (reconnectAttempts < MAX_RECONNECTS) {
reconnectAttempts++;
const delay = BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1);
await sleep(delay);
// Attempt to reconnect
try {
const newResult = await connect();
for await (const _ of newResult.stream) {
void _;
if (signal.aborted) break;
}
return; // Successfully reconnected
} catch (reconnectError) {
console.error('[SSE] Reconnect failed', reconnectError);
}
}
}
// Re-throw if we couldn't recover
throw error;
}
}