Skip to content

Commit cefc4ec

Browse files
committed
fix(webview): retry failed tab initialization and clear failed sidebar recovery
1 parent 190f6c1 commit cefc4ec

3 files changed

Lines changed: 162 additions & 7 deletions

File tree

‎apps/vscode-e2e/src/visual/heartbeat.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ The message-port interceptor follows the pinned VS Code implementation. Check it
1717

1818
A gray sidebar can persist after renderer termination even when the watchdog assigns fresh HTML. HTML delivery alone cannot restart a dead renderer. Recovery now waits ten seconds for a heartbeat after assigning HTML, then recreates the affected host if the renderer has not responded. Sidebar recovery uses the generated view-removal and focus commands after checking their availability; tab recovery replaces its panel while retaining its provider. The crash test covers the real sidebar path. Unit tests cover tab replacement and task ownership.
1919

20-
A recovery attempt includes HTML-generation errors. Three attempts exhaust the budget; a startup heartbeat alone does not reset it. Periodic heartbeats must span a 60-second healthy window to replenish the budget. Exhaustion or unavailable/failed scoped host recovery logs once and offers a non-modal Reload Window or Show Logs action. A sidebar replacement that arrives after the ten-second wait retains the incident budget and displayed task. Only sustained healthy heartbeats replenish that budget. Window reload is never automatic. A late action from an old notification is ignored after liveness returns.
20+
A recovery attempt includes HTML-generation errors. Three attempts exhaust the budget; a startup heartbeat alone does not reset it. Periodic heartbeats must span a 60-second healthy window to replenish the budget. Exhaustion or unavailable/failed scoped host recovery logs once and offers a non-modal Reload Window or Show Logs action. A sidebar replacement that arrives after the ten-second wait retains the incident budget and displayed task. A failed sidebar focus command clears the pending marker so a later manual open starts normally. A tab that fails initialization after publication keeps its task and a watchdog for bounded retries. Only sustained healthy heartbeats replenish the incident budget. Window reload is never automatic. A late action from an old notification is ignored after liveness returns.
2121

2222
Keep exact timing boundaries, individual post-await guards, and reload-budget assertions in provider unit tests. Exercise OS minimize and sleep/resume behavior manually; the Electron scenarios do not reproduce OS suspension or its scheduling order.

‎src/core/webview/ClineProvider.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3744,7 +3744,7 @@ export class ClineProvider
37443744
if (this.webviewHostRecoveryInProgress || this._disposed || this.view !== view || !view.visible) return
37453745
const revision = this.webviewHeartbeatRevision
37463746
let started = false
3747-
let sidebarRemoved = false
3747+
let sidebarFocused = false
37483748
try {
37493749
let commands: string[] | undefined
37503750
if (!("onDidChangeViewState" in view)) {
@@ -3783,13 +3783,24 @@ export class ClineProvider
37833783
const resolved = this.resolveWebviewView(replacement)
37843784
if (this.view === replacement) view.dispose()
37853785
else replacement.dispose()
3786-
await resolved
3786+
try {
3787+
await resolved
3788+
} catch (error) {
3789+
// A published panel already owns this provider and its task.
3790+
// Keep its disposal subscription, and restore the watchdog even
3791+
// if HTML generation or state hydration failed before installation.
3792+
// The next stale tick uses the existing bounded recovery budget.
3793+
if (this.view === view) throw error
3794+
if (this._disposed || this.view !== replacement) return
3795+
this.log(`[Zoo Code] Failed to initialize replacement tab: ${String(error)}`)
3796+
this.startWebviewWatchdog()
3797+
}
37873798
} else {
37883799
this.pendingSidebarRecovery = true
37893800
await vscode.commands.executeCommand(`${ClineProvider.sideBarId}.removeView`)
3790-
sidebarRemoved = true
37913801
if (this._disposed) return
37923802
await vscode.commands.executeCommand(`${ClineProvider.sideBarId}.focus`, { preserveFocus: true })
3803+
sidebarFocused = true
37933804
await pWaitFor(
37943805
() =>
37953806
this._disposed ||
@@ -3801,9 +3812,9 @@ export class ClineProvider
38013812
)
38023813
}
38033814
} catch (error) {
3804-
// If removal failed, no replacement is expected. A removed sidebar
3805-
// can still resolve after the wait expires and must retain its task.
3806-
if (started && !sidebarRemoved) this.pendingSidebarRecovery = false
3815+
// Only a successful focus can leave a delayed replacement pending.
3816+
// Command failures must not mark a later manual open as recovery.
3817+
if (started && !sidebarFocused) this.pendingSidebarRecovery = false
38073818
// A late discovery failure must not stop a recovered/replaced view.
38083819
// Once recreation starts, disposal of the old view is expected.
38093820
if (

‎src/core/webview/__tests__/ClineProvider.spec.ts‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1511,6 +1511,36 @@ describe("ClineProvider", () => {
15111511
},
15121512
)
15131513

1514+
test("clears a failed focus incident before a manual sidebar open", async () => {
1515+
await provider.resolveWebviewView(mockWebviewView)
1516+
const task = new Task(defaultTaskOptions)
1517+
task.abort = true
1518+
provider["taskRegistry"].push(task)
1519+
const remove = vi.spyOn(provider, "removeClineFromStack")
1520+
vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => {
1521+
if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback()
1522+
if (command === `${ClineProvider.sideBarId}.focus`) throw new Error("focus failed")
1523+
})
1524+
1525+
await vi.advanceTimersByTimeAsync(130_000)
1526+
1527+
expect(provider["view"]).toBeUndefined()
1528+
expect(provider["pendingSidebarRecovery"]).toBe(false)
1529+
expect(provider["webviewHostRecoveryInProgress"]).toBe(false)
1530+
expect(provider["webviewRecoveryAttempts"]).toBe(3)
1531+
expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1)
1532+
expect(remove).not.toHaveBeenCalled()
1533+
1534+
const manualView = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } }
1535+
await provider.resolveWebviewView(manualView)
1536+
1537+
expect(provider["view"]).toBe(manualView)
1538+
expect(provider["webviewRecoveryAttempts"]).toBe(0)
1539+
expect(provider["webviewRecoveryFailureReported"]).toBe(false)
1540+
expect(remove).toHaveBeenCalledTimes(1)
1541+
expect(provider.getCurrentTask()).toBeUndefined()
1542+
})
1543+
15141544
test.each(["Reload Window", "Show Logs"] as const)(
15151545
"runs only the selected recovery action: %s",
15161546
async (action) => {
@@ -2366,6 +2396,120 @@ describe("ClineProvider", () => {
23662396
expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1)
23672397
})
23682398

2399+
function replacementPanel() {
2400+
return {
2401+
...mockWebviewPanel,
2402+
webview: {
2403+
...mockWebviewPanel.webview,
2404+
html: "",
2405+
onDidReceiveMessage: vi.fn(() => ({ dispose: vi.fn() })),
2406+
},
2407+
onDidDispose: vi.fn(() => ({ dispose: vi.fn() })),
2408+
dispose: vi.fn(),
2409+
}
2410+
}
2411+
2412+
test.each(["HTML generation", "state hydration"] as const)(
2413+
"retries a published tab after failed %s without disposing its interrupted task",
2414+
async (stage) => {
2415+
await provider.resolveWebviewView(mockWebviewPanel)
2416+
const task = new Task(defaultTaskOptions)
2417+
task.abort = true
2418+
provider["taskRegistry"].push(task)
2419+
const remove = vi.spyOn(provider, "removeClineFromStack")
2420+
provider["getWebviewHtml"] = vi.fn().mockResolvedValue("recovered-html")
2421+
await provider["reloadWebviewForRecovery"]()
2422+
const failed = replacementPanel()
2423+
const healthy = replacementPanel()
2424+
vi.mocked(vscode.window.createWebviewPanel).mockReturnValueOnce(failed).mockReturnValue(healthy)
2425+
if (stage === "HTML generation") {
2426+
vi.mocked(provider["getWebviewHtml"]).mockRejectedValueOnce(new Error("HTML failed"))
2427+
} else {
2428+
vi.spyOn(provider, "getState").mockRejectedValueOnce(new Error("hydration failed"))
2429+
}
2430+
await vi.advanceTimersByTimeAsync(10_000)
2431+
2432+
expect(provider["view"]).toBe(failed)
2433+
expect(failed.webview.onDidReceiveMessage).not.toHaveBeenCalled()
2434+
expect(failed.dispose).not.toHaveBeenCalled()
2435+
expect(provider["webviewWatchdogInterval"]).not.toBeNull()
2436+
expect(provider["webviewHostRecoveryInProgress"]).toBe(false)
2437+
expect(provider["webviewRecoveryAttempts"]).toBe(1)
2438+
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled()
2439+
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
2440+
expect.stringContaining("Failed to initialize replacement tab"),
2441+
)
2442+
2443+
await vi.advanceTimersByTimeAsync(130_000)
2444+
2445+
expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2)
2446+
expect(provider["view"]).toBe(healthy)
2447+
// The provider and focus tracker each install one message listener.
2448+
expect(healthy.webview.onDidReceiveMessage).toHaveBeenCalledTimes(2)
2449+
expect(failed.dispose).toHaveBeenCalledTimes(1)
2450+
expect(provider["webviewRecoveryAttempts"]).toBe(2)
2451+
expect(provider.getCurrentTask()).toBe(task)
2452+
expect(provider["_disposed"]).toBe(false)
2453+
expect(remove).not.toHaveBeenCalled()
2454+
expect(task.dispose).not.toHaveBeenCalled()
2455+
expect(task.abortTask).not.toHaveBeenCalled()
2456+
},
2457+
)
2458+
2459+
test.each(["disposed", "replaced"] as const)(
2460+
"does not restart a stale replacement watchdog after the provider is %s",
2461+
async (event) => {
2462+
await provider.resolveWebviewView(mockWebviewPanel)
2463+
provider["getWebviewHtml"] = vi.fn().mockResolvedValue("recovered-html")
2464+
await provider["reloadWebviewForRecovery"]()
2465+
let rejectHtml: (error: Error) => void = () => {
2466+
throw new Error("Replacement HTML generation has not started")
2467+
}
2468+
vi.mocked(provider["getWebviewHtml"]).mockImplementationOnce(
2469+
() =>
2470+
new Promise<string>((_resolve, reject) => {
2471+
rejectHtml = reject
2472+
}),
2473+
)
2474+
const failed = replacementPanel()
2475+
vi.mocked(vscode.window.createWebviewPanel).mockReturnValue(failed)
2476+
await vi.advanceTimersByTimeAsync(10_000)
2477+
expect(provider["view"]).toBe(failed)
2478+
if (event === "disposed") await provider.dispose()
2479+
else await provider.resolveWebviewView(replacementPanel())
2480+
const watchdog = provider["webviewWatchdogInterval"]
2481+
rejectHtml(new Error("stale HTML failure"))
2482+
await vi.advanceTimersByTimeAsync(0)
2483+
expect(provider["webviewWatchdogInterval"]).toBe(watchdog)
2484+
expect(provider["webviewHostRecoveryInProgress"]).toBe(false)
2485+
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled()
2486+
},
2487+
)
2488+
2489+
test("bounds repeated published-tab initialization failures and keeps the task available", async () => {
2490+
await provider.resolveWebviewView(mockWebviewPanel)
2491+
const task = new Task(defaultTaskOptions)
2492+
provider["taskRegistry"].push(task)
2493+
provider["getWebviewHtml"] = vi.fn().mockResolvedValue("recovered-html")
2494+
vi.mocked(vscode.window.createWebviewPanel).mockImplementation(() => {
2495+
const replacement = replacementPanel()
2496+
// Fail after publication, before any message listener is installed.
2497+
vi.spyOn(provider, "getState").mockRejectedValueOnce(new Error("hydration failed"))
2498+
return replacement
2499+
})
2500+
2501+
await vi.advanceTimersByTimeAsync(720_000)
2502+
2503+
expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(3)
2504+
expect(provider["webviewRecoveryAttempts"]).toBe(3)
2505+
expect(provider["webviewHostRecoveryInProgress"]).toBe(false)
2506+
expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1)
2507+
expect(provider["_disposed"]).toBe(false)
2508+
expect(provider.getCurrentTask()).toBe(task)
2509+
expect(task.dispose).not.toHaveBeenCalled()
2510+
expect(task.abortTask).not.toHaveBeenCalled()
2511+
})
2512+
23692513
test("reloads the tab webview when the heartbeat is stale and the tab is visible", async () => {
23702514
await provider.resolveWebviewView(mockWebviewPanel)
23712515
const htmlAfterResolve = mockWebviewPanel.webview.html

0 commit comments

Comments
 (0)