Skip to content

fix(webview): throttle state pushes to prevent gray screen OOM at high message counts #629

Description

@edelauna

Context

The chat webview turns gray (crashes) during long-running tasks. User-reported diagnostics from logWebviewHiddenDiagnostics confirm 3,525 messages at crash time. This is tracked upstream as a recurring user complaint — PR #153 reduced the react-virtuoso pre-render buffer but did not address the root cause.

Related: Part of the broader webview message architecture addressed by #355 (Stories 3.2c + 3.2d), but scoped as an independent fix that doesn't touch the message protocol or task lifecycle.

Developer Notes

Problem

addToClineMessages (Task.ts:1015) calls postStateToWebviewWithoutTaskHistory() on every single message addition, serializing and posting the entire clineMessages array to the webview. With 3,525 messages averaging ~2KB of tool output each, every state push is ~7MB. During active tool use with checkpoint saves, this fires dozens of times per minute. The webview must deserialize, merge into React state, and re-render each time — eventually exceeding the V8 heap limit and crashing the renderer (gray screen).

Secondary issue: aggregatedCostsMap in ChatView.tsx is never cleared on task change (line 480-491 cleanup effect), causing unbounded growth across task switches with subtasks.

Proposed Fix

Throttle the hot-path state pushes — no new message types, no protocol changes.

ClineProvider.ts — add throttled variant (~10 lines)

private _postStateToWebviewThrottled = debounce(
    () => this.postStateToWebviewWithoutTaskHistory(),
    500,
    { leading: true, trailing: true, maxWait: 1000 }
)

async postStateToWebviewThrottled(): Promise<void> {
    this._postStateToWebviewThrottled()
}

Task.ts — swap 2 hot-path call sites (2 line changes)

Line Context Change
1015 addToClineMessages — every new message postStateToWebviewThrottled
527 messageQueueService.stateChanged postStateToWebviewThrottled
1828 Task start (clear state) Keep immediate
2551 Before API request Keep immediate
3291 After streaming complete Keep immediate

Partial message streaming is unaffected — updateClineMessage (Task.ts:1048) already uses the lightweight { type: "messageUpdated" } path, bypassing the full state push entirely.

ChatView.tsx:481 — clear aggregatedCostsMap on task change (1 line)

setAggregatedCostsMap(new Map())

Task.abortTask — flush debounce on abort (1 line)

Ensures the final state is delivered when a task is stopped.

Impact

At 3,525 messages:

  • Before: ~7MB × dozens/sec during rapid tool use → OOM → gray screen
  • After: ~7MB × max 2/sec (throttled) → webview stays alive

Risk Assessment

  • Low risk: No new message types, no protocol changes, no task lifecycle changes
  • UI delay: New message bubbles may appear up to 500ms late; streaming text is unaffected (uses messageUpdated path)
  • Final state guaranteed: trailing: true + abort flush ensures last state is always delivered
  • No fix(task-lifecycle): preserve parent-child link when delegated subtask is interrupted #560-style edge cases: This doesn't add states, transitions, or concurrency primitives — it just coalesces rapid calls to an existing method

Acceptance Criteria

  • Chat remains responsive at 3,500+ messages (no gray screen)
  • Streaming text still appears in real-time (messageUpdated path unchanged)
  • Task start shows clean state immediately (line 1828 not throttled)
  • Task abort delivers final state (debounce flushed)
  • aggregatedCostsMap is empty after task switch
  • Existing test suite passes unchanged

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions