From e0808aed9a37939e72398ae6a4d5e3f416e8e883 Mon Sep 17 00:00:00 2001 From: 0xMink Date: Sun, 8 Feb 2026 10:59:18 -0500 Subject: [PATCH 1/3] fix(reliability): prevent webview postMessage crashes and make dispose idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: #11311 1. postMessageToWebview() now catches rejections from webview.postMessage() so that messages sent after the webview is disposed do not surface as unhandled promise rejections. 2. dispose() is guarded by a _disposed flag so that repeated calls (e.g. during rapid extension deactivation) are no-ops. 3. CloudService mock in ClineProvider.spec.ts updated to include off() — a pre-existing gap exposed by the new dispose test. Co-Authored-By: Claude Opus 4.6 --- src/core/webview/ClineProvider.ts | 12 ++++++++- .../webview/__tests__/ClineProvider.spec.ts | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index fc15a8dd5c..fa71a0c0f0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -147,6 +147,7 @@ export class ClineProvider private taskCreationCallback: (task: Task) => void private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined + private _disposed = false private recentTasksCache?: string[] private pendingOperations: Map = new Map() @@ -577,6 +578,11 @@ export class ClineProvider } async dispose() { + if (this._disposed) { + return + } + + this._disposed = true this.log("Disposing ClineProvider...") // Clear all tasks from the stack. @@ -1080,7 +1086,11 @@ export class ClineProvider } public async postMessageToWebview(message: ExtensionMessage) { - await this.view?.webview.postMessage(message) + try { + await this.view?.webview.postMessage(message) + } catch { + // View disposed, drop message silently + } } private async getHMRHtmlContent(webview: vscode.Webview): Promise { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 4bad630ed5..63172b1ac2 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -327,6 +327,7 @@ vi.mock("@roo-code/cloud", () => ({ get instance() { return { isAuthenticated: vi.fn().mockReturnValue(false), + off: vi.fn(), } }, }, @@ -602,6 +603,31 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalledWith(message) }) + test("postMessageToWebview does not throw when webview is disposed", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Simulate postMessage throwing after webview disposal + mockPostMessage.mockRejectedValueOnce(new Error("Webview is disposed")) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + // Should not throw + await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() + }) + + test("dispose is idempotent — second call is a no-op", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.dispose() + await provider.dispose() + + // dispose body runs only once: log "Disposing ClineProvider..." appears once + const disposeCalls = (mockOutputChannel.appendLine as ReturnType).mock.calls.filter( + ([msg]: [string]) => typeof msg === "string" && msg.includes("Disposing ClineProvider..."), + ) + expect(disposeCalls).toHaveLength(1) + }) + test("handles webviewDidLaunch message", async () => { await provider.resolveWebviewView(mockWebviewView) From 51374c44c53e5562ba9d74d38b76fa2519675e82 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 9 Feb 2026 11:22:56 -0500 Subject: [PATCH 2/3] fix: add early _disposed check in postMessageToWebview Skip the postMessage call entirely when the provider is already disposed, avoiding unnecessary try/catch execution. Added test coverage for this path. --- src/core/webview/ClineProvider.ts | 4 ++++ src/core/webview/__tests__/ClineProvider.spec.ts | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index fa71a0c0f0..fed840e763 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1086,6 +1086,10 @@ export class ClineProvider } public async postMessageToWebview(message: ExtensionMessage) { + if (this._disposed) { + return + } + try { await this.view?.webview.postMessage(message) } catch { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 63172b1ac2..4c69746be3 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -615,6 +615,18 @@ describe("ClineProvider", () => { await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() }) + test("postMessageToWebview skips postMessage after dispose", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.dispose() + mockPostMessage.mockClear() + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + await provider.postMessageToWebview(message) + + expect(mockPostMessage).not.toHaveBeenCalled() + }) + test("dispose is idempotent — second call is a no-op", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -623,7 +635,7 @@ describe("ClineProvider", () => { // dispose body runs only once: log "Disposing ClineProvider..." appears once const disposeCalls = (mockOutputChannel.appendLine as ReturnType).mock.calls.filter( - ([msg]: [string]) => typeof msg === "string" && msg.includes("Disposing ClineProvider..."), + ([msg]) => typeof msg === "string" && msg.includes("Disposing ClineProvider..."), ) expect(disposeCalls).toHaveLength(1) }) From 948069cc1ae3b4970c7ec851df4b772a2b5464f4 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 9 Feb 2026 12:40:10 -0500 Subject: [PATCH 3/3] chore: trigger CI