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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
This commit is contained in:
0xMink 2026-02-09 13:03:24 -05:00 committed by GitHub
parent 115d6c5fce
commit 62a0106ce0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 53 additions and 1 deletions

View file

@ -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> {

View file

@ -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)