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:
@@ -1,12 +1,25 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{ser::SerializeStruct, Serialize, Serializer};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
/// Broadcast-delivered event.
|
||||
///
|
||||
/// `payload` is wrapped in `Arc` so cloning across broadcast receivers is
|
||||
/// refcount-only — avoids copying multi-MB JSON trees per subscriber.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WebEvent {
|
||||
pub channel: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub payload: Arc<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Serialize for WebEvent {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut state = serializer.serialize_struct("WebEvent", 2)?;
|
||||
state.serialize_field("channel", &self.channel)?;
|
||||
state.serialize_field("payload", self.payload.as_ref())?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebEventBroadcaster {
|
||||
@@ -25,16 +38,28 @@ impl WebEventBroadcaster {
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
pub fn send(&self, channel: &str, payload: &impl Serialize) {
|
||||
/// Serialize `payload` once and broadcast. Returns the serialized
|
||||
/// `Value` so Tauri callers can reuse it without serializing twice.
|
||||
pub fn send(&self, channel: &str, payload: &impl Serialize) -> Option<Arc<serde_json::Value>> {
|
||||
let value = Arc::new(serde_json::to_value(payload).ok()?);
|
||||
if self.sender.receiver_count() > 0 {
|
||||
let _ = self.sender.send(WebEvent {
|
||||
channel: channel.to_string(),
|
||||
payload: value.clone(),
|
||||
});
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
/// Broadcast a pre-serialized `Value` without re-serialization.
|
||||
pub fn send_value(&self, channel: &str, payload: Arc<serde_json::Value>) {
|
||||
if self.sender.receiver_count() == 0 {
|
||||
return;
|
||||
}
|
||||
if let Ok(value) = serde_json::to_value(payload) {
|
||||
let _ = self.sender.send(WebEvent {
|
||||
channel: channel.to_string(),
|
||||
payload: value,
|
||||
});
|
||||
}
|
||||
let _ = self.sender.send(WebEvent {
|
||||
channel: channel.to_string(),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<WebEvent> {
|
||||
@@ -55,19 +80,26 @@ pub enum EventEmitter {
|
||||
Noop,
|
||||
}
|
||||
|
||||
/// Unified event emission: sends to both Tauri webview and Web clients (if applicable).
|
||||
pub fn emit_event(emitter: &EventEmitter, event: &str, payload: impl Serialize + Clone) {
|
||||
/// Unified event emission: serializes the payload exactly once and dispatches
|
||||
/// the shared `Arc<Value>` to both the Tauri webview and the web broadcaster.
|
||||
pub fn emit_event(emitter: &EventEmitter, event: &str, payload: impl Serialize) {
|
||||
match emitter {
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
EventEmitter::Tauri(app) => {
|
||||
use tauri::{Emitter, Manager};
|
||||
let _ = app.emit(event, payload.clone());
|
||||
let Ok(value) = serde_json::to_value(&payload) else {
|
||||
return;
|
||||
};
|
||||
let shared = Arc::new(value);
|
||||
// `&Value` is Copy, so Tauri's `Clone` bound is satisfied without
|
||||
// copying the payload — Tauri serializes through the reference.
|
||||
let _ = app.emit(event, shared.as_ref());
|
||||
if let Some(web) = app.try_state::<Arc<WebEventBroadcaster>>() {
|
||||
web.send(event, &payload);
|
||||
web.send_value(event, shared);
|
||||
}
|
||||
}
|
||||
EventEmitter::WebOnly(broadcaster) => {
|
||||
broadcaster.send(event, &payload);
|
||||
let _ = broadcaster.send(event, &payload);
|
||||
}
|
||||
EventEmitter::Noop => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user