fix(acp): prevent memory blowup from streaming terminal tool output

Large raw_output snapshots from tool_call_update notifications caused
O(N²) traffic through the event pipeline, multi-GB transient allocations
and WKWebView crashes. The fix turns cumulative snapshots into bounded
incremental deltas and removes redundant payload copies.

- Add ToolCallOutputCache keyed by tool_call_id with a 8KB tail
  fingerprint and total length. Detects cumulative extensions by
  matching the cached tail at the expected offset in the incoming
  snapshot, so it works even when the full output grows into the MB
  range. Emits suffix deltas with raw_output_append=true; falls back
  to a truncated replacement when content diverges.
- Cap any single emitted raw_output chunk at 64KB (MAX_SINGLE_EMIT_BYTES)
  with a UTF-8 char-boundary-safe tail and ANSI-sequence-safe trimming.
  Apply the same cap to emit_terminal_output_update.
- Bound the cache at 256 entries with FIFO eviction, and clear entries
  when the tool call reaches completed / failed / cancelled / error.
- Seed the cache via a dedicated seed() method on SessionUpdate::ToolCall
  so the initial event never emits an accidental append.
- Share emit payloads as Arc<serde_json::Value> across broadcast
  receivers and skip the Tauri-side clone: serialize once and hand the
  same Arc to both the webview emit and the WebSocket broadcaster.
- Add 14 unit tests covering boundary cases: identity, prefix extension
  past the cached tail, divergence, oversized deltas, multibyte UTF-8
  truncation, final-status cleanup, FIFO eviction, seed semantics, and
  ANSI-safe trimming.
This commit is contained in:
xintaofei
2026-04-24 14:28:41 +08:00
parent e9c3076197
commit bcfaba10c7
4 changed files with 536 additions and 28 deletions
@@ -108,7 +108,7 @@ pub fn spawn_event_subscriber(
last_push.retain(|_, t| t.elapsed() < Duration::from_secs(DEBOUNCE_SECS * 2));
if let Some((event_type, msg)) =
parse_event(&event.channel, &event.payload, config.lang)
parse_event(&event.channel, event.payload.as_ref(), config.lang)
{
// Global event filter check
if let Some(filter) = &config.global_filter {
@@ -48,7 +48,7 @@ pub fn spawn_session_event_subscriber(
if event.channel == "acp://event" {
handle_acp_event_payload(
&event.payload,
event.payload.as_ref(),
&bridge,
&manager,
&conn_mgr,