ソースを参照

fix(reliability): prevent webview postMessage crashes and make dispose idempotent (#11313)

* fix(reliability): prevent webview postMessage crashes and make dispose idempotent

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 <[email protected]>

* 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.

* chore: trigger CI

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: daniel-lxs <[email protected]>
0xMink 5 日 前
コミット
62a0106ce0

+ 15 - 1
src/core/webview/ClineProvider.ts

@@ -148,6 +148,7 @@ export class ClineProvider
 	private taskCreationCallback: (task: Task) => void
 	private taskEventListeners: WeakMap<Task, Array<() => void>> = new WeakMap()
 	private currentWorkspacePath: string | undefined
+	private _disposed = false
 
 	private recentTasksCache?: string[]
 	private taskHistoryWriteLock: Promise<void> = Promise.resolve()
@@ -579,6 +580,11 @@ export class ClineProvider
 	}
 
 	async dispose() {
+		if (this._disposed) {
+			return
+		}
+
+		this._disposed = true
 		this.log("Disposing ClineProvider...")
 
 		// Clear all tasks from the stack.
@@ -1078,7 +1084,15 @@ export class ClineProvider
 	}
 
 	public async postMessageToWebview(message: ExtensionMessage) {
-		await this.view?.webview.postMessage(message)
+		if (this._disposed) {
+			return
+		}
+
+		try {
+			await this.view?.webview.postMessage(message)
+		} catch {
+			// View disposed, drop message silently
+		}
 	}
 
 	private async getHMRHtmlContent(webview: vscode.Webview): Promise<string> {

+ 38 - 0
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,43 @@ 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("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)
+
+		await provider.dispose()
+		await provider.dispose()
+
+		// dispose body runs only once: log "Disposing ClineProvider..." appears once
+		const disposeCalls = (mockOutputChannel.appendLine as ReturnType<typeof vi.fn>).mock.calls.filter(
+			([msg]) => typeof msg === "string" && msg.includes("Disposing ClineProvider..."),
+		)
+		expect(disposeCalls).toHaveLength(1)
+	})
+
 	test("handles webviewDidLaunch message", async () => {
 		await provider.resolveWebviewView(mockWebviewView)