Skip to content

Shellper replay buffer is unbounded for no-newline TUI streams; full Buffer.concat on every connect → multi-GB allocation spikes when opening a session #1253

Description

@amrmelsayed

Symptom (reported 2026-07-26)

Opening an existing terminal session sometimes allocates several GB (observed up to ~10GB), causing memory pressure and sometimes process termination.

Root cause — two compounding defects, both confirmed in source

1. Line-count-only eviction never fires for TUI streams (shellper-replay-buffer.ts)

ShellperReplayBuffer.append() evicts oldest chunks only when lineCount > maxLines (10,000), counting \n bytes. A full-screen TUI (Claude Code) emits almost no newlines — its output is cursor-movement redraws — so lineCount barely advances and eviction never triggers. chunks[] grows unboundedly for the life of the shellper (which survives Tower restarts by design). Builders are the worst case: weeks of continuous test/tool output. Trajectory already evidenced in the #1198 incident (17.6MB/17.7MB replay buffers after ~a week of light architect use); heavy builder sessions reach GBs.

2. getReplayData() full-concats on EVERY connect (shellper-process.ts WELCOME path)

Buffer.concat(this.chunks) materializes the entire accumulated history into one new Buffer per client connect — transiently doubling the shellper's footprint at exactly the moment a user opens the session. A ~5GB buffer → ~10GB peak → the reported spike. The #1198 send cap (REPLAY_PAYLOAD_MAX 8MB) is applied AFTER the concat via subarray, so it bounds the wire, not the allocation. On pre-#1204/#1218 binaries (i.e., every installed deployment today) there is no send cap either, so the full payload additionally ships and is buffered again Tower-side.

Secondary same-class defect (Tower-side, slower)

RingBuffer.pushData keeps the newline-less partial "whole and unbounded" (ring-buffer.ts:37-41) with the documented rationale that truncating would corrupt alt-screen replay. That rationale is obsolete: since #1198, viewers repaint via the post-connect resize nudge, so front-truncation no longer breaks correctness. Live evidence on the originating machine: the #1047 partial monitor shows max partial at ~2.6MB growing ~1MB/hour on the busiest session — months-scale GB risk, and getAll()/getSince() copy it per attach.

Fix directions

  1. Byte-cap eviction in ShellperReplayBuffer: add maxBytes (e.g. 16–32MB) alongside maxLines; evict oldest chunks when EITHER limit is exceeded. The repaint nudge makes front-truncation safe.
  2. Cap before concat: walk chunks from the tail collecting only up to REPLAY_PAYLOAD_MAX, and concat just those — O(cap) allocation per connect instead of O(history). With fix 1 in place this becomes belt-and-suspenders, but it's the cheap guard that directly kills the open-time spike even for legacy-accumulated buffers.
  3. Cap the Tower RingBuffer partial (e.g. keep the last N MB) now that the nudge covers repaint; update the tower + vscode: terminal freeze from oversized-replay reconnect storm (unbounded no-newline buffers + client backpressure loop) #1047 monitor from observe-only to observe-and-enforce.
  4. Observability: shellper logs its buffer size at WELCOME (new binaries already log when the send cap trims); afx status/Codev process fleet drives macOS memory-pressure kills: stranded shellper husks accumulate per restart + no idle-fleet memory policy #1227's fleet RSS view will surface a ballooning shellper once deployed.

Deployment interaction

Every shipped fix in this area (#1204's frame tolerance, #1218's send cap) is merged-but-uninstalled on affected machines; installed binaries are the worst case (unbounded accumulate + uncapped send + fatal oversized-frame handling). Fix 1/2 land in the shellper binary, which — per the detached-shellper design — only takes effect for shellpers spawned AFTER the upgrade; long-lived pre-upgrade shellpers keep their accumulated buffers until restarted or reaped. Worth a release-notes remediation note (restart heavy long-lived sessions after upgrading).

Related

#1047 (line-based wire contract + partial observability), #1198/#1204 (oversized-REPLAY incident: the 16MB frames were this bug's earlier, smaller manifestation), #1218 (send cap — wire-level only), #1227 (fleet memory pressure; this is the per-session sibling of that fleet-level issue).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/towerArea: Tower server / agent farm CLI

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions