diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 2f1222f8ee..e569ead435 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -473,6 +473,7 @@ export interface WebviewMessage { | "getListApiConfiguration" | "customInstructions" | "webviewDidLaunch" + | "webviewHeartbeat" | "newTask" | "askResponse" | "terminalOperation" @@ -697,6 +698,7 @@ export interface WebviewMessage { ids?: string[] terminalOperation?: "continue" | "abort" messageTs?: number + timestamp?: number // For webviewHeartbeat restoreCheckpoint?: boolean historyPreviewCollapsed?: boolean filters?: { type?: string; search?: string; tags?: string[] } diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index b251168461..c057173aff 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -365,9 +365,7 @@ describe("BaseOpenAiCompatibleProvider", () => { await customHandler.completePrompt("hello") - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ model: "some/custom-model-not-in-list" }), - ) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "some/custom-model-not-in-list" })) }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 874e2f8f08..08a5a6a38a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -205,6 +205,12 @@ export class ClineProvider private static activeInstances: Set = new Set() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] + // Subscriptions tied to the currently resolved view (message, visibility, + // active-editor, and configuration listeners plus the view's disposal + // registration). Replacing the view disposes the previous entries before + // the replacement's are installed, so a stale view can neither dispatch + // messages nor refresh the provider-wide heartbeat. + private resolvedViewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< string, { @@ -228,6 +234,23 @@ export class ClineProvider private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false + private lastWebviewHeartbeatAt = 0 + // Bumped on every accepted heartbeat, including multiple heartbeats in the + // same millisecond; the recovery reload compares revisions around its + // awaited HTML generation so no accepted heartbeat is missed. + private webviewHeartbeatRevision = 0 + private webviewWatchdogInterval: ReturnType | null = null + // Bumped to invalidate an in-flight recovery reload when the provider or + // the watched view is disposed; the reload must not reassign webview.html + // afterwards. + private webviewRecoveryEpoch = 0 + // Epoch that owns an in-flight recovery reload, if any. Only a recovery + // from the same epoch is blocked, so disposing/replacing the view (which + // bumps webviewRecoveryEpoch) never makes the replacement wait on the + // stale recovery's completion. + private webviewRecoveryInFlightEpoch: number | undefined = undefined + private static readonly WEBVIEW_WATCHDOG_TICK_MS = 60_000 + private static readonly WEBVIEW_HEARTBEAT_STALE_MS = 90_000 private readonly _postStateToWebviewThrottled = debounce( async () => { try { @@ -812,6 +835,11 @@ export class ClineProvider */ private clearWebviewResources() { this.rejectPendingThemeFixtureProbes(new Error("Webview was disposed before the theme fixture probe completed")) + this.stopWebviewWatchdog() + // Invalidate any recovery reload still awaiting its HTML so it cannot + // reassign webview.html on the disposed view. + this.webviewRecoveryEpoch++ + this.disposeResolvedViewResources() while (this.webviewDisposables.length) { const x = this.webviewDisposables.pop() if (x) { @@ -820,6 +848,16 @@ export class ClineProvider } } + /** Disposes the subscriptions registered for the previously resolved view. */ + private disposeResolvedViewResources() { + while (this.resolvedViewDisposables.length) { + const x = this.resolvedViewDisposables.pop() + if (x) { + x.dispose() + } + } + } + /** Drain one task's memoized cleanup without preventing the remaining provider shutdown work. */ private async drainTaskDisposal(task: Task): Promise { try { @@ -838,6 +876,7 @@ export class ClineProvider this._disposed = true this._postStateToWebviewThrottled.cancel() + this.stopWebviewWatchdog() this.log("Disposing ClineProvider...") // Reject any tasks still waiting for a scheduler permit so they don't @@ -1015,6 +1054,19 @@ export class ClineProvider } async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) { + // Replacing the watched view invalidates a recovery reload still + // awaiting its HTML (same epoch bump as clearWebviewResources), so the + // stale recovery can neither block nor reassign the replacement view's + // recovery. Re-resolving the same view, or the first resolve, leaves + // the epoch alone. + if (this.view && this.view !== webviewView) { + this.webviewRecoveryEpoch++ + // The replaced view's listeners must not outlive it: dispose them + // before the replacement's subscriptions are installed below so no + // duplicate message/visibility/editor/configuration listeners + // accumulate across A-to-B replacements. + this.disposeResolvedViewResources() + } this.view = webviewView const inTabMode = "onDidChangeViewState" in webviewView @@ -1037,11 +1089,15 @@ export class ClineProvider localResourceRoots: resourceRoots, } - webviewView.webview.html = - this.contextProxy.extensionMode === vscode.ExtensionMode.Development && - process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" - ? await this.getHMRHtmlContent(webviewView.webview) - : await this.getHtmlContent(webviewView.webview) + const html = await this.getWebviewHtml(webviewView.webview) + // The await yields; a disposal or replacement that landed mid-generation + // must not receive this HTML. The VS Code API throws when assigning to a + // destroyed webview, and a stale resolve must not touch the obsolete + // view it no longer owns. + if (this._disposed || this.view !== webviewView) { + return + } + webviewView.webview.html = html // Initialize out-of-scope variables that need to receive persistent // global state values. @@ -1073,9 +1129,21 @@ export class ClineProvider }, ) + // The awaits above yield; disposal or a view replacement may have + // landed while this resolve was pending. Installing listeners or the + // watchdog now would leak an interval on a disposed provider (the + // recovery path's _disposed check cannot stop the interval itself) or + // duplicate the replacement view's subscriptions. + if (this._disposed || this.view !== webviewView) { + return + } + // Sets up an event listener to listen for messages passed from the webview view context // and executes code based on the message that is received. - this.setWebviewMessageListener(webviewView.webview) + this.setWebviewMessageListener(webviewView) + + // Detect a dead webview renderer process (gray screen) via heartbeat timeout. + this.startWebviewWatchdog() // Listen for when the panel becomes visible. // https://github.com/microsoft/vscode-discussions/discussions/840 @@ -1083,25 +1151,41 @@ export class ClineProvider // WebviewView and WebviewPanel have all the same properties except // for this visibility listener panel. const viewStateDisposable = webviewView.onDidChangeViewState(() => { + if (this.view !== webviewView) { + // A replaced view must not refresh the provider-wide + // heartbeat the current view's watchdog relies on. + return + } if (this.view?.visible) { + // Hidden webviews throttle timers, so grant a fresh grace + // window instead of counting throttled heartbeats as a crash. + this.updateWebviewHeartbeat() void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { this.logWebviewHiddenDiagnostics() } }) - this.webviewDisposables.push(viewStateDisposable) + this.resolvedViewDisposables.push(viewStateDisposable) } else if ("onDidChangeVisibility" in webviewView) { // sidebar const visibilityDisposable = webviewView.onDidChangeVisibility(() => { + if (this.view !== webviewView) { + // A replaced view must not refresh the provider-wide + // heartbeat the current view's watchdog relies on. + return + } if (this.view?.visible) { + // Hidden webviews throttle timers, so grant a fresh grace + // window instead of counting throttled heartbeats as a crash. + this.updateWebviewHeartbeat() void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { this.logWebviewHiddenDiagnostics() } }) - this.webviewDisposables.push(visibilityDisposable) + this.resolvedViewDisposables.push(visibilityDisposable) } // Listen for when the view is disposed @@ -1112,12 +1196,17 @@ export class ClineProvider this.log("Disposing ClineProvider instance for tab view") await this.dispose() } else { - this.log("Clearing webview resources for sidebar view") - this.clearWebviewResources() + if (this.view === webviewView) { + this.log("Clearing webview resources for sidebar view") + this.clearWebviewResources() + // Drop the disposed view so nothing keeps polling it + // (e.g. the recovery watchdog) for the provider's lifetime. + this.view = undefined + } } }, null, - this.disposables, + this.resolvedViewDisposables, ) // Listen for when color changes @@ -1127,7 +1216,7 @@ export class ClineProvider await this.postMessageToWebview({ type: "theme", text: JSON.stringify(await getTheme()) }) } }) - this.webviewDisposables.push(configDisposable) + this.resolvedViewDisposables.push(configDisposable) // If the extension is starting a new session, clear previous task state. // But don't clear if there's already an active task (e.g., resumed via IPC/bridge). @@ -1694,14 +1783,20 @@ export class ClineProvider * Sets up an event listener to listen for messages passed from the webview context and * executes code based on the message that is received. * - * @param webview A reference to the extension webview + * @param webviewView The resolved webview view or panel */ - private setWebviewMessageListener(webview: vscode.Webview) { - const onReceiveMessage = async (message: WebviewMessage) => - webviewMessageHandler(this, message, this.marketplaceManager) + private setWebviewMessageListener(webviewView: vscode.WebviewView | vscode.WebviewPanel) { + const onReceiveMessage = async (message: WebviewMessage) => { + // A replaced view's listener stays registered until the provider + // clears its subscriptions; never let a stale renderer dispatch. + if (this.view !== webviewView) { + return + } + await webviewMessageHandler(this, message, this.marketplaceManager) + } - const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) - this.webviewDisposables.push(messageDisposable) + const messageDisposable = webviewView.webview.onDidReceiveMessage(onReceiveMessage) + this.resolvedViewDisposables.push(messageDisposable) } /** @@ -3289,6 +3384,102 @@ export class ClineProvider ) } + /** Records that the webview renderer is alive; called on every webviewHeartbeat message. */ + public updateWebviewHeartbeat(): void { + this.lastWebviewHeartbeatAt = Date.now() + this.webviewHeartbeatRevision++ + } + + /** + * Starts (or restarts) the watchdog that detects a dead webview renderer + * process. Hidden webviews throttle timers, so becoming visible resets the + * grace window instead of counting throttled heartbeats as a crash. + */ + private startWebviewWatchdog(): void { + this.updateWebviewHeartbeat() + if (this.webviewWatchdogInterval) { + clearInterval(this.webviewWatchdogInterval) + } + this.webviewWatchdogInterval = setInterval(() => { + if (this.view?.visible !== true) { + return + } + if (Date.now() - this.lastWebviewHeartbeatAt <= ClineProvider.WEBVIEW_HEARTBEAT_STALE_MS) { + return + } + this.log("[Zoo Code] Webview heartbeat stale while visible; reloading webview (dead renderer?)") + void this.reloadWebviewForRecovery() + }, ClineProvider.WEBVIEW_WATCHDOG_TICK_MS) + } + + /** Stops the renderer heartbeat watchdog; the webview it watches is gone. */ + private stopWebviewWatchdog(): void { + if (this.webviewWatchdogInterval) { + clearInterval(this.webviewWatchdogInterval) + this.webviewWatchdogInterval = null + } + } + + /** + * Reloads only this provider's own webview by regenerating its HTML (fresh + * nonce) and reassigning `webview.html`, which forces VS Code to reload that + * webview. Works for both sidebar (WebviewView) and tab (WebviewPanel) shapes. + */ + private async reloadWebviewForRecovery(): Promise { + const view = this.view + if (!view?.webview) { + return + } + // Capture the epoch so a disposal or view replacement can invalidate + // this operation while the HTML is being generated. + const epoch = this.webviewRecoveryEpoch + // A recovery already awaiting its HTML generation owns this view; a + // second one would only pile another forced reload onto it. Ownership + // is scoped to the epoch so a stale recovery (its view disposed or + // replaced, epoch bumped) never blocks the replacement view's recovery. + if (this.webviewRecoveryInFlightEpoch === epoch) { + return + } + this.webviewRecoveryInFlightEpoch = epoch + // A heartbeat that arrives while the HTML is being generated means the + // renderer is alive again; comparing revisions (not timestamps) lets the + // post-await check catch heartbeats that land in the same millisecond. + const heartbeatRevision = this.webviewHeartbeatRevision + try { + const html = await this.getWebviewHtml(view.webview) + // The await yields; assigning html now that the provider is disposed, + // the watched view was disposed/replaced, the renderer heartbeat + // recovered, or the view hid again would either touch a dead or + // unrelated webview or reload one that no longer needs recovery. + if ( + this._disposed || + this.webviewRecoveryEpoch !== epoch || + this.view !== view || + this.webviewHeartbeatRevision !== heartbeatRevision || + view.visible !== true + ) { + return + } + view.webview.html = html + } catch (error) { + this.log(`[Zoo Code] Failed to reload webview: ${error instanceof Error ? error.message : String(error)}`) + } finally { + // Clear ownership only while this completion still holds it; a + // stale recovery must not release the replacement's in-flight state. + if (this.webviewRecoveryInFlightEpoch === epoch) { + this.webviewRecoveryInFlightEpoch = undefined + } + } + } + + /** Builds the webview HTML using the same path as resolveWebviewView (HMR in development). */ + private async getWebviewHtml(webview: vscode.Webview): Promise { + return this.contextProxy.extensionMode === vscode.ExtensionMode.Development && + process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" + ? await this.getHMRHtmlContent(webview) + : await this.getHtmlContent(webview) + } + public getRecentTasks(): string[] { if (this.recentTasksCache) { return this.recentTasksCache diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b5f304fe56..52a702401b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -532,10 +532,7 @@ describe("ClineProvider", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn(), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -677,6 +674,899 @@ describe("ClineProvider", () => { }) }) + describe("webview heartbeat watchdog", () => { + let visibilityCallback: () => void + let disposeCallback: () => void + + beforeEach(() => { + // Fake timers must be active before resolveWebviewView so the + // watchdog interval is registered on the fake clock. + vi.useFakeTimers() + mockWebviewView.onDidChangeVisibility = vi.fn().mockImplementation((cb: () => void) => { + visibilityCallback = cb + return { dispose: vi.fn() } + }) + mockWebviewView.onDidDispose = vi.fn().mockImplementation((cb: () => void) => { + disposeCallback = cb + return { dispose: vi.fn() } + }) + }) + + afterEach(async () => { + await provider.dispose() + vi.useRealTimers() + }) + + test("does not reload the webview while heartbeats are fresh", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + await vi.advanceTimersByTimeAsync(110_000) + await webviewMessageHandler(provider, { type: "webviewHeartbeat", timestamp: Date.now() }) + await vi.advanceTimersByTimeAsync(60_000) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("reloads the webview with fresh HTML when the heartbeat is stale and the view is visible", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + await vi.advanceTimersByTimeAsync(120_000) + + // Reassigning webview.html with a fresh nonce is what forces the reload. + expect(mockWebviewView.webview.html).not.toBe(htmlAfterResolve) + expect(mockWebviewView.webview.html).toContain("Zoo Code") + }) + + test("does not reload while the view is hidden even when the heartbeat is stale", async () => { + await provider.resolveWebviewView(mockWebviewView) + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + const htmlAfterResolve = mockWebviewView.webview.html + + await vi.advanceTimersByTimeAsync(180_000) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("resets the grace window when the view becomes visible", async () => { + await provider.resolveWebviewView(mockWebviewView) + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + const htmlAfterResolve = mockWebviewView.webview.html + + await vi.advanceTimersByTimeAsync(120_000) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + Object.defineProperty(mockWebviewView, "visible", { value: true, configurable: true }) + visibilityCallback() + const htmlAfterBecomingVisible = mockWebviewView.webview.html + + await vi.advanceTimersByTimeAsync(60_000) + expect(mockWebviewView.webview.html).toBe(htmlAfterBecomingVisible) + + // Watchdog ticks every 60s; 120s after the flip the heartbeat is stale again. + await vi.advanceTimersByTimeAsync(60_000) + expect(mockWebviewView.webview.html).not.toBe(htmlAfterBecomingVisible) + }) + + test("stops watching after the provider is disposed", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + await provider.dispose() + await vi.advanceTimersByTimeAsync(180_000) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("stops watching and clears the view when the sidebar webview is disposed", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // Precondition: the watchdog interval was actually scheduled, so the + // null check below proves disposal stopped it rather than it never + // having started. + expect(provider["webviewWatchdogInterval"]).not.toBeNull() + + // The provider outlives a disposed sidebar view; VS Code re-resolves + // a fresh view later. Disposal must stop the watchdog and drop the + // stale view reference so no recovery reload targets the dead view. + disposeCallback() + expect(provider["webviewWatchdogInterval"]).toBeNull() + // @ts-ignore - accessing private property for testing + expect(provider.view).toBeUndefined() + + await vi.advanceTimersByTimeAsync(180_000) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("does not stack watchdog intervals when resolveWebviewView runs again", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.resolveWebviewView(mockWebviewView) + + // getHtmlContent regenerates the HTML via one getState() call per reload, + // so getState invocations after this point count watchdog reloads. + const getStateSpy = vi.spyOn(provider, "getState") + + await vi.advanceTimersByTimeAsync(120_000) + + expect(getStateSpy).toHaveBeenCalledTimes(1) + }) + + test("logs when regenerating the reload HTML fails", async () => { + await provider.resolveWebviewView(mockWebviewView) + vi.spyOn(provider, "getState").mockRejectedValue(new Error("regen boom")) + ;(mockOutputChannel.appendLine as ReturnType).mockClear() + + await vi.advanceTimersByTimeAsync(120_000) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[Zoo Code] Failed to reload webview: regen boom"), + ) + }) + + test("logs and keeps the webview html when the recovery reload rejects", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + // The watchdog reload no longer goes through a VS Code command; stub + // the recovery HTML regeneration itself to reject. + provider["getWebviewHtml"] = vi.fn().mockRejectedValue(new Error("reload boom")) + ;(mockOutputChannel.appendLine as ReturnType).mockClear() + + await vi.advanceTimersByTimeAsync(120_000) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[Zoo Code] Failed to reload webview: reload boom"), + ) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("does not reassign html when the provider is disposed mid-recovery", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // Hold the recovery reload's HTML regeneration in flight until the + // disposal below lands. + let finishReload: (state: ExtensionState) => void = () => {} + vi.spyOn(provider, "getState").mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + // The stale heartbeat started a recovery reload, but its HTML is still pending. + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + await provider.dispose() + finishReload({ apiConfiguration: {} } as unknown as ExtensionState) + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("does not reassign html when the sidebar view is disposed mid-recovery", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // Defer the recovery reload's own HTML generation so the test proves + // the disposal lands while recovery is in flight, not before it starts. + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + // The stale heartbeat started a recovery reload, and its HTML + // generation is still pending. + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + disposeCallback() + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("does not reassign html when the watched view is replaced mid-recovery", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // Defer the recovery reload's own HTML generation so the test proves + // the replacement lands while recovery is in flight, not before it + // starts. + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + // The stale heartbeat started a recovery reload, and its HTML + // generation is still pending. + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + // VS Code re-resolves a fresh view (e.g. sidebar re-opened) while the + // recovery reload for the old view is still awaiting its HTML. + // @ts-ignore - accessing private property for testing + provider.view = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + } + + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("skips the recovery reload when a heartbeat arrives while regenerating HTML", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + // The renderer process reported in again while the recovery HTML was + // still being generated, so the webview is alive and must not reload. + provider.updateWebviewHeartbeat() + + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("skips the recovery reload when two heartbeats land in the same millisecond during recovery", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + // Pin the clock to the timestamp captured before recovery: both + // heartbeats stamp the same millisecond, so a timestamp comparison + // would see no change, but the heartbeat revision moved, so the + // reload must still be skipped. + const heartbeatAtCapture = provider["lastWebviewHeartbeatAt"] + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(heartbeatAtCapture) + provider.updateWebviewHeartbeat() + expect(provider["lastWebviewHeartbeatAt"]).toBe(heartbeatAtCapture) + provider.updateWebviewHeartbeat() + expect(provider["lastWebviewHeartbeatAt"]).toBe(heartbeatAtCapture) + nowSpy.mockRestore() + + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("skips the recovery reload when the view hides while regenerating HTML", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + + // The view hid while the recovery HTML was still being generated; a + // hidden webview throttles heartbeats, so the stale heartbeat no + // longer proves a dead renderer. + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("does not start a second recovery while one is in flight and recovers after a failure", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // The first recovery blocks on a controlled pending generation; the + // retry (second call) gets its own deferred generation. + let failFirst: (error: Error) => void = () => {} + let finishRetry: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failFirst = reject + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishRetry = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + + // The watchdog ticks again while the first recovery still awaits + // its HTML; no second generation may start for the same view. + await vi.advanceTimersByTimeAsync(60_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + // The pending generation rejects; the finally block must clear the + // in-flight state so a later watchdog tick can start a fresh + // recovery instead of being blocked forever. + failFirst(new Error("reload boom")) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(60_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(2) + + // The retry's generation resolves with fresh HTML and must land in + // the webview. + finishRetry("recovered-retry") + await vi.advanceTimersByTimeAsync(0) + expect(mockWebviewView.webview.html).toContain("recovered-retry") + }) + + test("lets the replacement view's recovery start while a stale one is pending", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // Each recovery generation gets its own deferred finish callback. + const generations: Array<{ finish: (html: string) => void }> = [] + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + generations.push({ finish: resolve }) + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + + // The sidebar view is disposed (bumping webviewRecoveryEpoch) and a + // replacement view takes over while recovery A's HTML generation is + // still pending, making A stale. + disposeCallback() + // @ts-ignore - accessing private property for testing + provider.view = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + } + const replacementEpoch = provider["webviewRecoveryEpoch"] + + // Recovery B for the new view must start even though A is still + // pending: the in-flight guard is scoped to the epoch. + void provider["reloadWebviewForRecovery"]() + await vi.advanceTimersByTimeAsync(0) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(2) + + // A's completion is stale: it must not reassign the old view's html, + // and its finally must NOT release the ownership B took. + generations[0].finish("stale-A") + await vi.advanceTimersByTimeAsync(0) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + expect(provider["webviewRecoveryInFlightEpoch"]).toBe(replacementEpoch) + + // B completes and lands its HTML on the new view, then releases + // ownership so future recoveries can start. + generations[1].finish("recovered-B") + await vi.advanceTimersByTimeAsync(0) + // @ts-ignore - accessing private property for testing + expect(provider.view.webview.html).toContain("recovered-B") + expect(provider["webviewRecoveryInFlightEpoch"]).toBeUndefined() + }) + + test("advances the recovery epoch when resolveWebviewView replaces the view and lets the new recovery start", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + const epochBeforeReplacement = provider["webviewRecoveryEpoch"] + + // Recovery generations get their own deferred finish callbacks; the + // replacement view's initial HTML generation resolves immediately so + // resolveWebviewView can complete. + const generations: Array<{ finish: (html: string) => void }> = [] + provider["getWebviewHtml"] = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + generations.push({ finish: resolve }) + }), + ) + .mockImplementationOnce(() => Promise.resolve("view2-initial")) + .mockImplementation( + () => + new Promise((resolve) => { + generations.push({ finish: resolve }) + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + + // VS Code re-resolves a DIFFERENT view without a disposal in between + // (the gap path): the epoch must advance so the stale recovery can + // neither block nor reassign the replacement view's recovery. + const mockWebviewView2 = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn(), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + await provider.resolveWebviewView(mockWebviewView2) + expect(provider["webviewRecoveryEpoch"]).toBe(epochBeforeReplacement + 1) + + // Recovery B for the replacement view starts immediately, unblocked + // by A's still-pending generation. + void provider["reloadWebviewForRecovery"]() + await vi.advanceTimersByTimeAsync(0) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(3) + + // A's completion is stale: the old view's html is untouched and A's + // finally does not release the ownership B took. + generations[0].finish("stale-A") + await vi.advanceTimersByTimeAsync(0) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + expect(provider["webviewRecoveryInFlightEpoch"]).toBe(provider["webviewRecoveryEpoch"]) + + // B completes and lands its HTML on the replacement view, then + // releases ownership. + generations[1].finish("recovered-B") + await vi.advanceTimersByTimeAsync(0) + expect(mockWebviewView2.webview.html).toContain("recovered-B") + expect(provider["webviewRecoveryInFlightEpoch"]).toBeUndefined() + }) + + test("does not advance the recovery epoch when the same view is re-resolved and keeps the pending recovery intact", async () => { + await provider.resolveWebviewView(mockWebviewView) + const epochAfterFirstResolve = provider["webviewRecoveryEpoch"] + + // Recovery A blocks on a controlled pending generation; the same + // view's re-resolve initial HTML resolves immediately. + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + .mockImplementation(() => + Promise.resolve("same-view-reresolve"), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + + // Re-resolving the SAME view must not bump the epoch nor disrupt the + // pending recovery that belongs to it. + await provider.resolveWebviewView(mockWebviewView) + expect(provider["webviewRecoveryEpoch"]).toBe(epochAfterFirstResolve) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(2) + expect(provider["webviewRecoveryInFlightEpoch"]).toBe(epochAfterFirstResolve) + + // A still owns the view across the re-resolve: another recovery + // attempt is blocked until A settles. + void provider["reloadWebviewForRecovery"]() + await vi.advanceTimersByTimeAsync(0) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(2) + + // A settles cleanly: the re-resolve restarted the watchdog, which + // re-stamped the heartbeat, so the revision guard (not the epoch) + // skips the stale assignment; the ownership A held is released. + finishReload("recovered-A") + await vi.advanceTimersByTimeAsync(0) + expect(mockWebviewView.webview.html).toContain("same-view-reresolve") + expect(provider["webviewRecoveryInFlightEpoch"]).toBeUndefined() + }) + + test("reloads only its own webview when multiple providers are active", async () => { + // Structural stand-in for the VS Code webview API surface this scenario + // exercises, same as the mockContext cast below. + const mockWebviewViewB = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn(), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + const providerB = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + try { + await provider.resolveWebviewView(mockWebviewView) + await providerB.resolveWebviewView(mockWebviewViewB) + + const htmlBeforeA = mockWebviewView.webview.html + const htmlBeforeB = mockWebviewViewB.webview.html + + // The pre-PR reload path fired the global + // workbench.action.webview.reloadWebviewAction command, which + // resets every webview. The html assertions below cannot catch a + // regression back to that command because the vscode mock + // swallows executeCommand without touching any view, so also + // assert the global command was never invoked. + const executeCommandMock = vscode.commands.executeCommand as ReturnType + + await vi.advanceTimersByTimeAsync(119_000) + // B's view is still alive; refresh its heartbeat so a stale one + // would reload it too, leaving only A's watchdog to fire. + await webviewMessageHandler(providerB, { type: "webviewHeartbeat", timestamp: Date.now() }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(mockWebviewView.webview.html).not.toBe(htmlBeforeA) + expect(mockWebviewViewB.webview.html).toBe(htmlBeforeB) + expect(executeCommandMock).not.toHaveBeenCalledWith("workbench.action.webview.reloadWebviewAction") + } finally { + await providerB.dispose() + } + }) + + describe("obsolete view gating and replacement cleanup", () => { + // Sidebar-shaped replacement view; callbacks are read through thunks + // because the mock assigns them when resolveWebviewView registers. + const createViewB = () => { + let messageCallbackB: (message: WebviewMessage) => Promise = async () => {} + const viewB = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi + .fn() + .mockImplementation((cb: (message: WebviewMessage) => Promise) => { + messageCallbackB = cb + return { dispose: vi.fn() } + }), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + // Emulate the real onDidDispose(listener, thisArgs, + // disposables) contract so the disposal registration is + // tracked per view too. + onDidDispose: vi + .fn() + .mockImplementation((_cb: () => void, _thisArgs: null, disposables?: vscode.Disposable[]) => { + const d = { dispose: vi.fn() } + disposables?.push(d) + return d + }), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + return { viewB, sendMessage: (message: WebviewMessage) => messageCallbackB(message) } + } + + test("ignores messages from a replaced view but still dispatches for the current view", async () => { + let messageCallbackA: (message: WebviewMessage) => Promise = async () => {} + mockWebviewView.webview.onDidReceiveMessage = vi + .fn() + .mockImplementation((cb: (message: WebviewMessage) => Promise) => { + messageCallbackA = cb + return { dispose: vi.fn() } + }) + await provider.resolveWebviewView(mockWebviewView) + const { viewB, sendMessage } = createViewB() + await provider.resolveWebviewView(viewB) + + const revisionBefore = provider["webviewHeartbeatRevision"] + // The stale renderer reports in; its dispatch must be skipped. + await messageCallbackA({ type: "webviewHeartbeat", timestamp: Date.now() }) + expect(provider["webviewHeartbeatRevision"]).toBe(revisionBefore) + + // The current view's dispatch reaches the handler as before. + await sendMessage({ type: "webviewHeartbeat", timestamp: Date.now() }) + expect(provider["webviewHeartbeatRevision"]).toBe(revisionBefore + 1) + }) + + test("disposes the replaced view's subscriptions so listeners do not accumulate", async () => { + const aDisposables: Array<{ dispose: ReturnType }> = [] + let visibilityCallbackA: () => void = () => {} + mockWebviewView.webview.onDidReceiveMessage = vi.fn().mockImplementation(() => { + const d = { dispose: vi.fn() } + aDisposables.push(d) + return d + }) + mockWebviewView.onDidChangeVisibility = vi.fn().mockImplementation((cb: () => void) => { + visibilityCallbackA = cb + const d = { dispose: vi.fn() } + aDisposables.push(d) + return d + }) + // Emulate the real onDidDispose(listener, thisArgs, disposables) + // contract so the disposal registration is tracked per view too. + mockWebviewView.onDidDispose = vi + .fn() + .mockImplementation((cb: () => void, _thisArgs: null, disposables?: vscode.Disposable[]) => { + const d = { dispose: vi.fn() } + aDisposables.push(d) + disposables?.push(d) + return d + }) + await provider.resolveWebviewView(mockWebviewView) + expect(aDisposables.length).toBe(3) // message, visibility, disposal registration + + const { viewB } = createViewB() + await provider.resolveWebviewView(viewB) + + expect(aDisposables.map((d) => d.dispose.mock.calls.length)).toEqual([1, 1, 1]) + // Only B's subscriptions remain: message, visibility, disposal + // registration, configuration. + expect(provider["resolvedViewDisposables"].length).toBe(4) + + // Even if a stale visibility event races in, it must not refresh + // the provider-wide heartbeat the current view's watchdog reads. + const revisionBefore = provider["webviewHeartbeatRevision"] + visibilityCallbackA() + expect(provider["webviewHeartbeatRevision"]).toBe(revisionBefore) + }) + + test("keeps the replacement view's watchdog and recovery when the replaced view's dispose callback fires", async () => { + let disposeCallbackA: () => void = () => {} + mockWebviewView.onDidDispose = vi.fn().mockImplementation((cb: () => void) => { + disposeCallbackA = cb + return { dispose: vi.fn() } + }) + await provider.resolveWebviewView(mockWebviewView) + const { viewB } = createViewB() + await provider.resolveWebviewView(viewB) + + // The stale view is finally torn down; its disposal must not + // clear the replacement's resources. + disposeCallbackA() + expect(provider["webviewWatchdogInterval"]).not.toBeNull() + // @ts-ignore - accessing private property for testing + expect(provider.view).toBe(viewB) + + // B remains eligible for recovery: once its heartbeat goes stale + // the watchdog reloads B's webview. + const htmlAfterBResolve = viewB.webview.html + await vi.advanceTimersByTimeAsync(120_000) + expect(viewB.webview.html).not.toBe(htmlAfterBResolve) + }) + + test("ignores a replaced tab panel's view state changes and messages", async () => { + // WebviewPanel-shaped views: visibility arrives via + // onDidChangeViewState instead of onDidChangeVisibility. + const createPanel = () => { + let viewStateCallback: () => void = () => {} + let messageCallback: (message: WebviewMessage) => Promise = async () => {} + const panel = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi + .fn() + .mockImplementation((cb: (message: WebviewMessage) => Promise) => { + messageCallback = cb + return { dispose: vi.fn() } + }), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidChangeViewState: vi.fn().mockImplementation((cb: () => void) => { + viewStateCallback = cb + return { dispose: vi.fn() } + }), + dispose: vi.fn(), + } as unknown as vscode.WebviewPanel + return { + panel, + fireViewState: () => viewStateCallback(), + sendMessage: (message: WebviewMessage) => messageCallback(message), + } + } + const panelA = createPanel() + await provider.resolveWebviewView(panelA.panel) + const panelB = createPanel() + await provider.resolveWebviewView(panelB.panel) + + // @ts-ignore - accessing private property for testing + expect(provider.view).toBe(panelB.panel) + + const revisionBefore = provider["webviewHeartbeatRevision"] + panelA.fireViewState() + await panelA.sendMessage({ type: "webviewHeartbeat", timestamp: Date.now() }) + expect(provider["webviewHeartbeatRevision"]).toBe(revisionBefore) + + panelB.fireViewState() + expect(provider["webviewHeartbeatRevision"]).toBe(revisionBefore + 1) + }) + + test("installs no listeners or watchdog when the provider is disposed mid-resolve", async () => { + // Hold the resolve's initial HTML generation so disposal lands + // inside the pending resolve. + let finishHtml: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishHtml = resolve + }), + ) + + const resolvePromise = provider.resolveWebviewView(mockWebviewView) + await provider.dispose() + finishHtml("initial") + await resolvePromise + + // The stale resolve assigned no HTML and installed nothing. + expect(mockWebviewView.webview.html).toBe("") + expect(provider["webviewWatchdogInterval"]).toBeNull() + expect(provider["resolvedViewDisposables"].length).toBe(0) + }) + + test("installs nothing when a different view replaces the pending resolve", async () => { + // A's initial HTML stays pending; B's resolves immediately. + let finishHtmlA: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishHtmlA = resolve + }), + ) + .mockImplementation(() => Promise.resolve("viewB-initial")) + + const resolveA = provider.resolveWebviewView(mockWebviewView) + const { viewB } = createViewB() + await provider.resolveWebviewView(viewB) + + finishHtmlA("stale-A") + await resolveA + + // A's stale resolve bailed out: no HTML landed on the obsolete + // view, only B's subscriptions (message, visibility, dispose, + // configuration) are installed and B keeps the watchdog. + expect(mockWebviewView.webview.html).toBe("") + expect(provider["resolvedViewDisposables"].length).toBe(4) + expect(provider["webviewWatchdogInterval"]).not.toBeNull() + // @ts-ignore - accessing private property for testing + expect(provider.view).toBe(viewB) + }) + }) + + describe("tab panel (WebviewPanel shape)", () => { + let viewStateCallback: () => void + // Structural stand-in for the VS Code webview API surface this + // scenario exercises, same as the mockWebviewViewB cast below. + let mockWebviewPanel: vscode.WebviewPanel + + beforeEach(() => { + // WebviewPanel-shaped stand-in: same webview surface, but the + // visibility listener is onDidChangeViewState instead of + // onDidChangeVisibility, matching resolveWebviewView's tab branch. + mockWebviewPanel = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidChangeViewState: vi.fn().mockImplementation((cb: () => void) => { + viewStateCallback = cb + return { dispose: vi.fn() } + }), + dispose: vi.fn(), + } as unknown as vscode.WebviewPanel + }) + + test("reloads the tab webview when the heartbeat is stale and the tab is visible", async () => { + await provider.resolveWebviewView(mockWebviewPanel) + const htmlAfterResolve = mockWebviewPanel.webview.html + + await vi.advanceTimersByTimeAsync(120_000) + + expect(mockWebviewPanel.webview.html).not.toBe(htmlAfterResolve) + expect(mockWebviewPanel.webview.html).toContain("Zoo Code") + }) + + test("does not reload a hidden tab even when the heartbeat is stale", async () => { + await provider.resolveWebviewView(mockWebviewPanel) + Object.defineProperty(mockWebviewPanel, "visible", { value: false, configurable: true }) + const htmlAfterResolve = mockWebviewPanel.webview.html + + await vi.advanceTimersByTimeAsync(180_000) + + expect(mockWebviewPanel.webview.html).toBe(htmlAfterResolve) + }) + + test("resets the grace window when the tab becomes visible", async () => { + await provider.resolveWebviewView(mockWebviewPanel) + Object.defineProperty(mockWebviewPanel, "visible", { value: false, configurable: true }) + const htmlAfterResolve = mockWebviewPanel.webview.html + + await vi.advanceTimersByTimeAsync(120_000) + expect(mockWebviewPanel.webview.html).toBe(htmlAfterResolve) + + Object.defineProperty(mockWebviewPanel, "visible", { value: true, configurable: true }) + viewStateCallback() + const htmlAfterBecomingVisible = mockWebviewPanel.webview.html + + await vi.advanceTimersByTimeAsync(60_000) + expect(mockWebviewPanel.webview.html).toBe(htmlAfterBecomingVisible) + + // Watchdog ticks every 60s; 120s after the flip the heartbeat is stale again. + await vi.advanceTimersByTimeAsync(60_000) + expect(mockWebviewPanel.webview.html).not.toBe(htmlAfterBecomingVisible) + }) + }) + }) + test("resolveWebviewView sets up webview correctly in development mode even if local server is not running", async () => { const developmentContext = { ...mockContext, extensionMode: vscode.ExtensionMode.Development } provider = new ClineProvider( @@ -3953,10 +4843,7 @@ describe("ClineProvider - Router Models", () => { asWebviewUri: vi.fn(), }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn(), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -4307,10 +5194,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { asWebviewUri: vi.fn(), }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn(), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 753332dd5b..37ec9aa334 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -340,10 +340,7 @@ describe("ClineProvider Task History Synchronization", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn(), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4ba94d454c..50e59b5003 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -688,6 +688,10 @@ export const webviewMessageHandler = async ( provider.isViewLaunched = true break + case "webviewHeartbeat": + // Timestamp-only update for the dead-renderer watchdog; no other side effects. + provider.updateWebviewHeartbeat() + break case "newTask": // Initializing new instance of Cline will make sure that any // agentically running promises in old instance don't affect our new diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index b1fbf82999..f4e80ac480 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -206,6 +206,15 @@ const App = () => { // Tell the extension that we are ready to receive messages. useEffect(() => vscode.postMessage({ type: "webviewDidLaunch" }), []) + // Heartbeat so the extension watchdog can detect a crashed webview renderer + // process (gray screen) and reload the view. + useEffect(() => { + const postHeartbeat = () => vscode.postMessage({ type: "webviewHeartbeat", timestamp: Date.now() }) + postHeartbeat() + const interval = setInterval(postHeartbeat, 30_000) + return () => clearInterval(interval) + }, []) + // Initialize source map support for better error reporting useEffect(() => { // Initialize source maps for better error reporting in production diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index 137bed5d70..4178b999e6 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -4,6 +4,7 @@ import React from "react" import { render, screen, act, cleanup } from "@/utils/test-utils" import AppWithProviders from "../App" +import { vscode } from "@src/utils/vscode" vi.mock("@src/utils/vscode", () => ({ vscode: { @@ -476,4 +477,41 @@ describe("App", () => { expect(chatView.getAttribute("data-hidden")).toBe("false") expect(screen.queryByTestId("marketplace-view")).not.toBeInTheDocument() }) + + describe("webview heartbeat", () => { + afterEach(() => { + vi.useRealTimers() + }) + + it("posts an immediate heartbeat on mount and then every 30 seconds", () => { + vi.useFakeTimers() + render() + + const postMessageMock = vi.mocked(vscode.postMessage) + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ type: "webviewHeartbeat", timestamp: expect.any(Number) }), + ) + const callsAfterMount = postMessageMock.mock.calls.length + + vi.advanceTimersByTime(30_000) + expect(postMessageMock.mock.calls.length).toBe(callsAfterMount + 1) + + vi.advanceTimersByTime(30_000) + expect(postMessageMock.mock.calls.length).toBe(callsAfterMount + 2) + expect(postMessageMock).toHaveBeenLastCalledWith(expect.objectContaining({ type: "webviewHeartbeat" })) + }) + + it("stops posting heartbeats after unmount", () => { + vi.useFakeTimers() + const { unmount } = render() + + const postMessageMock = vi.mocked(vscode.postMessage) + const callsAfterMount = postMessageMock.mock.calls.length + + unmount() + vi.advanceTimersByTime(90_000) + + expect(postMessageMock.mock.calls.length).toBe(callsAfterMount) + }) + }) })