From d69ee1033ee306d02d90781466ea4afbec34948f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 30 Jul 2026 00:28:12 +0300 Subject: [PATCH] fix(vscode): prevent early SSE replay loss --- .../vscode/src/AgentManagerPanelProvider.ts | 10 ++++---- packages/vscode/src/ChatViewProvider.ts | 10 ++++---- packages/vscode/src/DOCUMENTATION.md | 1 + .../vscode/src/SessionEditorPanelProvider.ts | 10 ++++---- packages/vscode/webview/api/bridge.test.ts | 15 +++++++++++- packages/vscode/webview/api/bridge.ts | 1 + packages/vscode/webview/main.tsx | 23 ++++++++++++++----- 7 files changed, 51 insertions(+), 19 deletions(-) diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index 13f46991..566f9679 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -185,7 +185,7 @@ export class AgentManagerPanelProvider { private async _startSseProxy(message: BridgeRequest): Promise { const { id, type, payload } = message; - const { path, headers } = (payload || {}) as { path?: string; headers?: Record }; + const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record; streamId?: string }; const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event'; if (!this._openCodeManager) { @@ -197,8 +197,11 @@ export class AgentManagerPanelProvider { }; } - const streamId = `sse_${++this._sseCounter}_${Date.now()}`; + const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId) + ? requestedStreamId + : `sse_${++this._sseCounter}_${Date.now()}`; const controller = new AbortController(); + this._sseStreams.set(streamId, controller); try { const start = await openSseProxy({ @@ -211,8 +214,6 @@ export class AgentManagerPanelProvider { }, }); - this._sseStreams.set(streamId, controller); - start.run .then(() => { this._panel?.webview.postMessage({ type: 'api:sse:end', streamId }); @@ -238,6 +239,7 @@ export class AgentManagerPanelProvider { }, }; } catch (error) { + this._sseStreams.delete(streamId); const message = error instanceof Error ? error.message : String(error); return { id, diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index 038a9e4c..d289b31e 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -536,7 +536,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { private async _startSseProxy(message: BridgeRequest): Promise { const { id, type, payload } = message; - const { path, headers } = (payload || {}) as { path?: string; headers?: Record }; + const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record; streamId?: string }; const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event'; if (!this._openCodeManager) { @@ -548,8 +548,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { }; } - const streamId = `sse_${++this._sseCounter}_${Date.now()}`; + const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId) + ? requestedStreamId + : `sse_${++this._sseCounter}_${Date.now()}`; const controller = new AbortController(); + this._sseStreams.set(streamId, { controller, view: this._view }); try { const start = await openSseProxy({ @@ -562,8 +565,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { }, }); - this._sseStreams.set(streamId, { controller, view: this._view }); - start.run .then(() => { this._view?.webview.postMessage({ type: 'api:sse:end', streamId }); @@ -589,6 +590,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { }, }; } catch (error) { + this._sseStreams.delete(streamId); const message = error instanceof Error ? error.message : String(error); return { id, diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 0060c110..6b358890 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -47,6 +47,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r - `bridge-proxy-runtime.ts` - 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. + - The webview allocates each SSE stream ID and installs its listener before requesting the upstream stream, so immediate OpenCode replay events cannot race the bridge start response. - `bridge-config-runtime.ts` - Config and skills message handlers (`api:config/*`). diff --git a/packages/vscode/src/SessionEditorPanelProvider.ts b/packages/vscode/src/SessionEditorPanelProvider.ts index 68e21daa..76a82b02 100644 --- a/packages/vscode/src/SessionEditorPanelProvider.ts +++ b/packages/vscode/src/SessionEditorPanelProvider.ts @@ -415,7 +415,7 @@ export class SessionEditorPanelProvider { private async _startSseProxy(message: BridgeRequest, entry: SessionPanelState): Promise { const { id, type, payload } = message; - const { path, headers } = (payload || {}) as { path?: string; headers?: Record }; + const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record; streamId?: string }; const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event'; if (!this._openCodeManager) { @@ -427,8 +427,11 @@ export class SessionEditorPanelProvider { }; } - const streamId = `sse_${++this._sseCounter}_${Date.now()}`; + const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId) + ? requestedStreamId + : `sse_${++this._sseCounter}_${Date.now()}`; const controller = new AbortController(); + entry.sseStreams.set(streamId, controller); try { const start = await openSseProxy({ @@ -442,8 +445,6 @@ export class SessionEditorPanelProvider { }, }); - entry.sseStreams.set(streamId, controller); - start.run .then(() => { entry.panel?.webview?.postMessage({ type: 'api:sse:end', streamId }); @@ -469,6 +470,7 @@ export class SessionEditorPanelProvider { }, }; } catch (error) { + entry.sseStreams.delete(streamId); const messageText = error instanceof Error ? error.message : String(error); return { id, diff --git a/packages/vscode/webview/api/bridge.test.ts b/packages/vscode/webview/api/bridge.test.ts index a775220c..b7af7c04 100644 --- a/packages/vscode/webview/api/bridge.test.ts +++ b/packages/vscode/webview/api/bridge.test.ts @@ -21,7 +21,7 @@ describe('VS Code webview bridge requests', () => { }), }); - const { sendBridgeMessageWithOptions } = await import('./bridge'); + const { sendBridgeMessageWithOptions, startSseProxy } = await import('./bridge'); const controller = new AbortController(); controller.abort(); @@ -36,6 +36,19 @@ describe('VS Code webview bridge requests', () => { assert.ok(result instanceof DOMException); assert.equal(result.name, 'AbortError'); assert.equal(messages.length, 0); + + const startPromise = startSseProxy({ path: '/global/event', streamId: 'sse_webview_1_1' }); + const request = messages[0] as { id: string; payload?: { streamId?: string } }; + assert.equal(request.payload?.streamId, 'sse_webview_1_1'); + globalThis.window.dispatchEvent(new MessageEvent('message', { + data: { + id: request.id, + type: 'api:sse:start', + success: true, + data: { status: 200, headers: {}, streamId: 'sse_webview_1_1' }, + }, + })); + assert.equal((await startPromise).streamId, 'sse_webview_1_1'); } finally { Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); Object.defineProperty(globalThis, 'acquireVsCodeApi', { configurable: true, value: originalAcquire }); diff --git a/packages/vscode/webview/api/bridge.ts b/packages/vscode/webview/api/bridge.ts index 8692a5aa..48c10f95 100644 --- a/packages/vscode/webview/api/bridge.ts +++ b/packages/vscode/webview/api/bridge.ts @@ -189,6 +189,7 @@ export type ProxiedSseStartResponse = { export async function startSseProxy(options: { path: string; headers?: Record; + streamId?: string; }): Promise { return sendBridgeMessage('api:sse:start', options); } diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 4c6085ec..ad081713 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -1122,6 +1122,7 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R }; const originalFetch = window.fetch.bind(window); +let sseStreamCounter = 0; window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const targetUrl = typeof input === 'string' || input instanceof URL ? normalizeUrl(input) : normalizeUrl((input as Request).url); const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase(); @@ -1160,12 +1161,10 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const headers = { ...headersFromRequest, ...headersFromInit }; if (isSseApiPath(targetUrl.pathname)) { - const start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers })); - if (!start.streamId) { - return new Response(null, { status: start.status || 503, headers: start.headers || {} }); - } - - const streamId = start.streamId; + // Install the listener before the extension opens the upstream stream. A + // reconnect can replay an event immediately, before the start response + // has crossed the VS Code bridge. + const streamId = `sse_webview_${Date.now()}_${++sseStreamCounter}`; const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined; const encoder = new TextEncoder(); let unsubscribe: (() => void) | null = null; @@ -1224,6 +1223,18 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { }, }); + let start; + try { + start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers, streamId })); + } catch (error) { + await stream.cancel(); + throw error; + } + if (!start.streamId) { + void stream.cancel(); + return new Response(null, { status: start.status || 503, headers: start.headers || {} }); + } + return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } }); }