feat: add webview heartbeat monitoring for gray screen detection and recovery

Implements a ping-pong heartbeat system to detect when the webview becomes
unresponsive (gray screen issue) and provide auto-recovery options.

Changes:
- Add ping/pong message types to ExtensionMessage and WebviewMessage
- Implement heartbeat sender and monitoring in ClineProvider
- Add automatic detection of unresponsive webviews after 3 missed heartbeats
- Show notification with option to reload webview on detection
- Handle pong messages in webviewMessageHandler
- Respond to ping messages in webview ExtensionStateContext

Fixes #10737
This commit is contained in:
Roo Code 2026-01-15 01:22:12 +00:00
parent dbf206f06b
commit 122d28f2e0
4 changed files with 149 additions and 0 deletions

View file

@ -94,6 +94,7 @@ export interface ExtensionMessage {
| "claudeCodeRateLimits"
| "customToolsResult"
| "modes"
| "ping"
text?: string
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
checkpointWarning?: {
@ -509,6 +510,7 @@ export interface WebviewMessage {
| "requestModes"
| "switchMode"
| "debugSetting"
| "pong"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"

View file

@ -147,6 +147,14 @@ export class ClineProvider
private taskEventListeners: WeakMap<Task, Array<() => void>> = new WeakMap()
private currentWorkspacePath: string | undefined
// Webview heartbeat monitoring for detecting unresponsive webviews
private heartbeatInterval?: ReturnType<typeof setInterval>
private lastPongTimestamp: number = 0
private heartbeatMissCount: number = 0
private static readonly HEARTBEAT_INTERVAL_MS = 5000 // Send ping every 5 seconds
private static readonly HEARTBEAT_TIMEOUT_MS = 15000 // Consider unresponsive after 15 seconds (3 missed pings)
private static readonly MAX_HEARTBEAT_MISSES = 3 // Number of missed heartbeats before showing recovery option
private recentTasksCache?: string[]
private pendingOperations: Map<string, PendingEditOperation> = new Map()
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
@ -578,6 +586,9 @@ export class ClineProvider
async dispose() {
this.log("Disposing ClineProvider...")
// Stop heartbeat monitoring
this.stopHeartbeatMonitoring()
// Clear all tasks from the stack.
while (this.clineStack.length > 0) {
await this.removeClineFromStack()
@ -630,6 +641,131 @@ export class ClineProvider
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
}
// Webview Heartbeat Monitoring Methods
/**
* Starts heartbeat monitoring for the webview.
* Sends periodic ping messages to the webview and monitors for pong responses.
* If the webview becomes unresponsive (no pong received), offers recovery options.
*/
private startHeartbeatMonitoring(): void {
// Clear any existing heartbeat interval
this.stopHeartbeatMonitoring()
// Initialize heartbeat state
this.lastPongTimestamp = Date.now()
this.heartbeatMissCount = 0
this.heartbeatInterval = setInterval(() => {
// Only monitor when the webview is visible
if (!this.view?.visible) {
// Reset miss count when not visible - the webview might be hidden
this.heartbeatMissCount = 0
return
}
// Send ping to webview
this.postMessageToWebview({ type: "ping" })
// Check if we've received a pong recently
const timeSinceLastPong = Date.now() - this.lastPongTimestamp
if (timeSinceLastPong > ClineProvider.HEARTBEAT_TIMEOUT_MS) {
this.heartbeatMissCount++
this.log(
`[Heartbeat] Webview unresponsive - no pong received for ${Math.round(timeSinceLastPong / 1000)}s (miss count: ${this.heartbeatMissCount})`,
)
// After MAX_HEARTBEAT_MISSES, offer recovery option
if (this.heartbeatMissCount >= ClineProvider.MAX_HEARTBEAT_MISSES) {
this.handleWebviewUnresponsive()
}
}
}, ClineProvider.HEARTBEAT_INTERVAL_MS)
this.log("[Heartbeat] Monitoring started")
}
/**
* Stops heartbeat monitoring for the webview.
*/
private stopHeartbeatMonitoring(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = undefined
this.log("[Heartbeat] Monitoring stopped")
}
}
/**
* Handles pong response from the webview, indicating it's still responsive.
* Called by webviewMessageHandler when a "pong" message is received.
*/
public handlePong(): void {
this.lastPongTimestamp = Date.now()
this.heartbeatMissCount = 0
}
/**
* Handles the case when the webview becomes unresponsive.
* Shows a notification with options to reload the webview.
*/
private handleWebviewUnresponsive(): void {
// Stop sending more pings while handling the unresponsive state
this.stopHeartbeatMonitoring()
this.log("[Heartbeat] Webview detected as unresponsive, showing recovery notification")
// Show notification with reload option
vscode.window
.showWarningMessage(
"Roo Code panel has become unresponsive. This can happen during long-running tasks with auto-approval enabled.",
"Reload Panel",
"Dismiss",
)
.then((selection) => {
if (selection === "Reload Panel") {
this.reloadWebview()
} else {
// If dismissed, restart monitoring in case the user wants to continue
this.startHeartbeatMonitoring()
}
})
}
/**
* Reloads the webview to recover from an unresponsive state.
* This preserves the current task and state.
*/
public async reloadWebview(): Promise<void> {
this.log("[Heartbeat] Reloading webview...")
if (!this.view?.webview) {
this.log("[Heartbeat] Cannot reload - no webview available")
return
}
try {
// Regenerate and set the HTML content
this.view.webview.html =
this.contextProxy.extensionMode === vscode.ExtensionMode.Development
? await this.getHMRHtmlContent(this.view.webview)
: await this.getHtmlContent(this.view.webview)
// The webview will re-initialize and request state via "webviewDidLaunch"
// which will trigger postStateToWebview() and restore the UI
// Restart heartbeat monitoring after reload
this.startHeartbeatMonitoring()
this.log("[Heartbeat] Webview reloaded successfully")
vscode.window.showInformationMessage("Roo Code panel has been reloaded.")
} catch (error) {
this.log(`[Heartbeat] Failed to reload webview: ${error instanceof Error ? error.message : String(error)}`)
vscode.window.showErrorMessage("Failed to reload Roo Code panel. Please try reopening the panel manually.")
}
}
public static async getInstance(): Promise<ClineProvider | undefined> {
let visibleProvider = ClineProvider.getVisibleInstance()
@ -861,6 +997,9 @@ export class ClineProvider
// If the extension is starting a new session, clear previous task state.
await this.removeClineFromStack()
// Start heartbeat monitoring to detect unresponsive webviews
this.startHeartbeatMonitoring()
}
public async createTaskWithHistoryItem(

View file

@ -447,6 +447,10 @@ export const webviewMessageHandler = async (
}
switch (message.type) {
case "pong":
// Heartbeat response from webview - delegate to provider
provider.handlePong()
break
case "webviewDidLaunch":
// Load custom modes first
const customModes = await provider.customModesManager.getCustomModes()

View file

@ -321,6 +321,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
(event: MessageEvent) => {
const message: ExtensionMessage = event.data
switch (message.type) {
case "ping":
// Respond to heartbeat ping immediately
vscode.postMessage({ type: "pong" })
break
case "state": {
const newState = message.state!
setState((prevState) => mergeExtensionState(prevState, newState))