Skip to content

ACP terminal scrollback buffers never freed after tool call exits #57099

@beardfaceguy

Description

@beardfaceguy

Summary

When an external ACP agent (Cursor, Claude Code, etc.) runs a bash/shell tool call, AcpThread creates a display-only terminal::Terminal entity and stores it in self.terminals. When the tool call finishes, TerminalProviderEvent::Exit is fired — but the handler only calls cx.notify() and never removes the terminal from self.terminals. Every tool call that produces terminal output therefore leaks its scrollback buffer (~7 MB at the default 10 000-line cap) for the entire lifetime of the ACP session.

Root cause

In crates/acp_thread/src/acp_thread.rs, on_terminal_provider_event:

TerminalProviderEvent::Exit { terminal_id, status } => {
    if let Some(entity) = self.terminals.get(&terminal_id) {
        entity.update(cx, |_term, cx| {
            cx.notify();
        });
    } else {
        self.pending_terminal_exit.insert(terminal_id, status);
    }
    // BUG: self.terminals.remove(&terminal_id) is never called
}

The Created handler inserts into self.terminals; there is no matching removal anywhere.

Impact

Measured via heaptrack on a 2-minute session with moderate bash tool call usage:

  • 60–69 MB from alacritty_terminal::grid::Grid::scroll_up via the ACP terminal path
  • Call stack: Grid::scroll_up ← on_terminal_provider_event ← handle_session_notification
  • A 30-minute heavy-use session (50+ bash calls) would accumulate ~350 MB of dead terminal objects

Each terminal::Terminal holds an alacritty Term<ZedListener> with a Grid allocated up to scrolling_history rows (default 10 000). That memory is never released until the AcpThread entity itself is dropped.

Reproducer

The following test fails against the current code and passes after the one-line fix:

#[gpui::test]
async fn test_acp_terminals_removed_on_exit(cx: &mut gpui::TestAppContext) {
    // ... create ACP thread, register a terminal, write output, send Exit ...
    thread.read_with(cx, |thread, _cx| {
        assert!(
            thread.terminal(terminal_id.clone()).is_err(),
            "terminal should be removed from the thread after exit to free scrollback memory"
        );
    });
}

Fix

Add one line to the Exit branch:

TerminalProviderEvent::Exit { terminal_id, status } => {
    if let Some(entity) = self.terminals.get(&terminal_id) {
        entity.update(cx, |_term, cx| {
            cx.notify();
        });
    } else {
        self.pending_terminal_exit.insert(terminal_id, status);
    }
    self.terminals.remove(&terminal_id);  // free scrollback memory
}

Environment

  • Branch: main (confirmed identical Exit handler)
  • Zed commit: current main as of 2026-05-18
  • Profiling tool: heaptrack 1.5.0 on Linux

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:ai/acpFeedback for Zed's Agent Client Protocolarea:performance/memory leakFeedback for memory leaksfrequency:uncommonBugs that happen for a small subset of users, special configurations, rare circumstances, etcpriority:P2Average run-of-the-mill bugsstate:reproducibleVerified steps to reproduce included and someone on the team managed to reproduce

    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