Repository navigation
perf(sessions): store PTY replay buffers as bounded flat pieces - #727
Merged
Merged
Conversation
Record why the per-session replay strings are replaced by a chunk list before touching SessionManager: the 2026-09-03 heap snapshot shows the sliced-string parents the current append keeps alive. Refs #726 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
SessionManager kept each session's terminal and agent-PTY replay buffer as one string and appended with `prev + data` → charCodeAt → slice. Once full, every chunk from every session flattened a fresh cap-sized copy on the main thread and the stored slice retained that whole parent; a chunk larger than the cap was retained in full (six identical 1.8 MB docker-build repaints in the 2026-09-03 heap snapshot). CappedTextBuffer keeps a list of flat pieces with a running length: append is amortised O(chunk), eviction drops whole oldest pieces without copying retained bytes, and a large chunk is cut into surrogate-safe pieces so an overflow discards at most one piece rather than the whole replay. The join runs only on attach. The attach/replay contract and both caps are unchanged. Fixes #726 Refs #390, #321, #103 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
… tests Review follow-ups: the oversized path copied the tail twice (flatTail, then cutPieces per piece) — cut straight from the tail offset instead; pieceSize must be an integer >= 2 so a piece can always hold a surrogate pair, which makes the boundary pull-back provably non-stalling; the chunk-wholeness invariant this buffer relies on (node-pty decodes per code point) and the store-by-reference trade for small chunks are stated in the code. The surrogate test now uses a chunk whose naive cuts land inside both pairs and pins the exact retained text per eviction, and a fuzz run with a small piece size and oversized chunks exercises cutting and the tail path. Refs #726 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
Owner
Author
|
Self-review pass (adversarial review of the first cut) — resolved in HEAD:
Not adopted: coalescing tiny pieces (spinner case) — overhead measured well under 1 MB; noted as a possible follow-up. |
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
SessionManagerkeeps two per-session PTY replay buffers (terminalBuffers, 256 KiB;agentPtyBuffers, 512 KiB) as plain strings and appends withappendCappedBuffer:prev + data, acharCodeAtthat flattens the concat into a fresh (cap + chunk) copy, thenslice, whose result is a V8 SlicedString keeping that whole parent alive. Once a buffer is full — minutes into any agent session, because TUIs repaint the whole screen — every chunk from every session copies the whole cap on the main thread (with 20 sessions, on the order of 100 MB/s of short-lived allocation, the #390 GC-storm shape), the retained value is cap + chunk rather than cap, and a chunk larger than the cap is retained in full. The 2026-09-03 main-process heap snapshot holds six identical 1.8 MB docker-build repaints and ~10 identical 1 MB Claude screen frames for exactly this reason. Details in #726.What changed
src/main/sessions/cappedTextBuffer.ts: a list of flat pieces plus a running length.appendis amortised O(chunk); eviction drops whole oldest pieces without copying retained bytes; large chunks are cut into surrogate-safe pieces (default 1/16 of the cap, floor 4 KiB) so an overflow discards at most one piece rather than the whole replay; a chunk larger than the cap is replaced by a flat copy of its tail. Pieces are forced flat with the utf16leBufferround-trip already used insrc/main/subagents/shared.ts(fix(#288): make sub-agent body truncation actually free memory (V8 slice trap + toolUseResult miss) #321), so no piece keeps a large parent alive.read()joins once, on attach.SessionManager: the twoMap<string, string>becomeMap<string, CappedTextBuffer>; the five touch points (two inits, two appends, two attach reads) swap over;appendCappedBufferis deleted. The attach/replay contract — bytes accumulate from process start, attach returns the current tail in the same synchronous block that flips the attached flag — and both caps are unchanged.Design notes
shiftper full-buffer append.Linked issues
Fixes #726. Refs #390 (main-process GC pressure), #321 (sliced-string retention), #103, #327.
Verification
NODE_ENV=test npx vitest run --project unit src/main/sessions/ src/main/sessionManager— all passing, including the newcappedTextBuffer.test.ts(retains newest whole pieces; never exceeds the cap with minimal drops across 5,000 random appends; oversized chunk keeps a surrogate-safe tail and later appends continue from it; overflow loss bounded to one piece; no surrogate pair split across pieces; lazy compaction).npx tsc -p tsconfig.node.json --noEmit— clean.eventLoop.meanMsand GC pressure during heavy multi-session output should drop; no session should retain more than its cap.Plan:
docs/superpowers/plans/2026-09-03-pty-replay-buffer-chunks.md.🤖 Generated with Claude Code
https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa