fix(vscode): recover stalled OpenCode SSE streams
This commit is contained in:
@@ -46,6 +46,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r
|
|||||||
|
|
||||||
- `bridge-proxy-runtime.ts`
|
- `bridge-proxy-runtime.ts`
|
||||||
- Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies.
|
- Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies.
|
||||||
|
- SSE routes are intentionally excluded from the generic proxy and use `sseProxy.ts`, whose upstream-only stall watchdog closes a quiet OpenCode stream so the webview can reconnect instead of trusting an open but silent response.
|
||||||
|
|
||||||
- `bridge-config-runtime.ts`
|
- `bridge-config-runtime.ts`
|
||||||
- Config and skills message handlers (`api:config/*`).
|
- Config and skills message handlers (`api:config/*`).
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { OpenCodeManager } from './opencode';
|
||||||
|
import { openSseProxy } from './sseProxy';
|
||||||
|
|
||||||
|
const createManager = (): OpenCodeManager => ({
|
||||||
|
start: async () => {},
|
||||||
|
stop: async () => {},
|
||||||
|
restart: async () => {},
|
||||||
|
setWorkingDirectory: async (path) => ({ success: true, path }),
|
||||||
|
getStatus: () => 'connected',
|
||||||
|
getApiUrl: () => 'http://127.0.0.1:3902',
|
||||||
|
getOpenCodeAuthHeaders: () => ({}),
|
||||||
|
getWorkingDirectory: () => '/workspace',
|
||||||
|
isCliAvailable: () => true,
|
||||||
|
getDebugInfo: () => ({
|
||||||
|
mode: 'managed',
|
||||||
|
status: 'connected',
|
||||||
|
workingDirectory: '/workspace',
|
||||||
|
cliAvailable: true,
|
||||||
|
cliPath: null,
|
||||||
|
configuredApiUrl: null,
|
||||||
|
configuredPort: null,
|
||||||
|
detectedPort: 3902,
|
||||||
|
apiPrefix: '',
|
||||||
|
apiPrefixDetected: true,
|
||||||
|
startCount: 1,
|
||||||
|
restartCount: 0,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastConnectedAt: null,
|
||||||
|
lastExitCode: null,
|
||||||
|
serverUrl: 'http://127.0.0.1:3902',
|
||||||
|
lastReadyElapsedMs: null,
|
||||||
|
lastReadyAttempts: null,
|
||||||
|
lastStartAttempts: null,
|
||||||
|
version: null,
|
||||||
|
secureConnection: false,
|
||||||
|
authSource: null,
|
||||||
|
}),
|
||||||
|
onStatusChange: (callback) => {
|
||||||
|
callback('connected');
|
||||||
|
return { dispose: () => {} };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VS Code SSE proxy', () => {
|
||||||
|
test('closes a quiet upstream SSE stream after the stall timeout', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream' },
|
||||||
|
})) as typeof fetch;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const proxy = await openSseProxy({
|
||||||
|
manager: createManager(),
|
||||||
|
path: '/global/event',
|
||||||
|
signal: controller.signal,
|
||||||
|
stallTimeoutMs: 20,
|
||||||
|
onChunk: () => assert.fail('quiet stream should not emit chunks'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.doesNotReject(proxy.run);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resets the stall timeout when upstream bytes arrive', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
setTimeout(() => controller.enqueue(new TextEncoder().encode(':first\n\n')), 5);
|
||||||
|
setTimeout(() => controller.enqueue(new TextEncoder().encode('data: second\n\n')), 15);
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream' },
|
||||||
|
})) as typeof fetch;
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const controller = new AbortController();
|
||||||
|
const proxy = await openSseProxy({
|
||||||
|
manager: createManager(),
|
||||||
|
path: '/global/event',
|
||||||
|
signal: controller.signal,
|
||||||
|
stallTimeoutMs: 18,
|
||||||
|
onChunk: (chunk) => chunks.push(chunk),
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.doesNotReject(proxy.run);
|
||||||
|
assert.deepEqual(chunks, [':first\n\n', 'data: second\n\n']);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ type OpenSseProxyOptions = {
|
|||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
onChunk: (chunk: string) => void;
|
onChunk: (chunk: string) => void;
|
||||||
|
stallTimeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OpenSseProxyResult = {
|
type OpenSseProxyResult = {
|
||||||
@@ -22,6 +23,7 @@ const SSE_RESPONSE_HEADERS = {
|
|||||||
// SSE reconnect configuration
|
// SSE reconnect configuration
|
||||||
const MAX_RECONNECTS = 3;
|
const MAX_RECONNECTS = 3;
|
||||||
const BASE_RECONNECT_DELAY = 1000; // 1 second
|
const BASE_RECONNECT_DELAY = 1000; // 1 second
|
||||||
|
const DEFAULT_UPSTREAM_STALL_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
const sleep = (ms: number, signal: AbortSignal) => new Promise<void>((resolve) => {
|
const sleep = (ms: number, signal: AbortSignal) => new Promise<void>((resolve) => {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
@@ -117,21 +119,54 @@ const fetchSseResponse = async (
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pipeSseResponse = async (response: Response, signal: AbortSignal, onChunk: (chunk: string) => void): Promise<void> => {
|
const resolveStallTimeoutMs = (value: number | undefined): number => (
|
||||||
|
Number.isFinite(value) && typeof value === 'number' ? value : DEFAULT_UPSTREAM_STALL_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipeSseResponse = async (
|
||||||
|
response: Response,
|
||||||
|
signal: AbortSignal,
|
||||||
|
onChunk: (chunk: string) => void,
|
||||||
|
stallTimeoutMs?: number,
|
||||||
|
): Promise<void> => {
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
throw new Error('OpenCode SSE response missing body');
|
throw new Error('OpenCode SSE response missing body');
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
let stalled = false;
|
||||||
|
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const clearStallTimer = () => {
|
||||||
|
if (!stallTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(stallTimer);
|
||||||
|
stallTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetStallTimer = () => {
|
||||||
|
clearStallTimer();
|
||||||
|
const timeoutMs = resolveStallTimeoutMs(stallTimeoutMs);
|
||||||
|
if (timeoutMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stallTimer = setTimeout(() => {
|
||||||
|
stalled = true;
|
||||||
|
void reader.cancel().catch(() => {});
|
||||||
|
}, timeoutMs);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
resetStallTimer();
|
||||||
while (!signal.aborted) {
|
while (!signal.aborted) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) {
|
if (done) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (value && value.length > 0) {
|
if (value && value.length > 0) {
|
||||||
|
resetStallTimer();
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
if (chunk.length > 0) {
|
if (chunk.length > 0) {
|
||||||
onChunk(chunk);
|
onChunk(chunk);
|
||||||
@@ -143,7 +178,12 @@ const pipeSseResponse = async (response: Response, signal: AbortSignal, onChunk:
|
|||||||
if (!signal.aborted && remaining.length > 0) {
|
if (!signal.aborted && remaining.length > 0) {
|
||||||
onChunk(remaining);
|
onChunk(remaining);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!stalled) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearStallTimer();
|
||||||
try {
|
try {
|
||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -163,6 +203,7 @@ export const openSseProxy = async ({
|
|||||||
headers,
|
headers,
|
||||||
signal,
|
signal,
|
||||||
onChunk,
|
onChunk,
|
||||||
|
stallTimeoutMs,
|
||||||
}: OpenSseProxyOptions): Promise<OpenSseProxyResult> => {
|
}: OpenSseProxyOptions): Promise<OpenSseProxyResult> => {
|
||||||
// Reconnect logic with exponential backoff
|
// Reconnect logic with exponential backoff
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
@@ -208,7 +249,7 @@ export const openSseProxy = async ({
|
|||||||
const run = (async () => {
|
const run = (async () => {
|
||||||
let activeResponse = response;
|
let activeResponse = response;
|
||||||
try {
|
try {
|
||||||
await pipeSseResponse(activeResponse, signal, onChunk);
|
await pipeSseResponse(activeResponse, signal, onChunk, stallTimeoutMs);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const cause = (error as { cause?: { code?: string } } | null)?.cause;
|
const cause = (error as { cause?: { code?: string } } | null)?.cause;
|
||||||
|
|
||||||
@@ -228,7 +269,7 @@ export const openSseProxy = async ({
|
|||||||
// Attempt to reconnect
|
// Attempt to reconnect
|
||||||
try {
|
try {
|
||||||
activeResponse = await connect();
|
activeResponse = await connect();
|
||||||
await pipeSseResponse(activeResponse, signal, onChunk);
|
await pipeSseResponse(activeResponse, signal, onChunk, stallTimeoutMs);
|
||||||
return; // Successfully reconnected
|
return; // Successfully reconnected
|
||||||
} catch (reconnectError) {
|
} catch (reconnectError) {
|
||||||
console.error('[SSE] Reconnect failed', reconnectError);
|
console.error('[SSE] Reconnect failed', reconnectError);
|
||||||
|
|||||||
Reference in New Issue
Block a user