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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
1fed81439c
commit
4cb918f6cb
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user