Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/superpowers/plans/2026-09-03-pty-replay-buffer-chunks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Main: make the PTY replay buffers amortised O(chunk)

Fixes #726. Refs #390, #321, #103, #327.

## Problem

`SessionManager` keeps two per-session replay buffers as plain strings —
`terminalBuffers` (256 KiB cap) and `agentPtyBuffers` (512 KiB cap) — and
appends with `appendCappedBuffer`: `prev + data`, a `charCodeAt` that
flattens the concat into a fresh (cap + chunk) copy, then `slice`, whose
result is a V8 SlicedString that keeps that whole flattened parent alive.
Once a buffer is full (minutes into any agent session), every chunk from
every session copies the whole cap on the main thread and the stored value
retains cap + chunk. A chunk larger than the cap is retained in full: the
2026-09-03 heap snapshot holds six identical 1.8 MB docker-build repaints
and ~10 identical 1 MB Claude screen frames for exactly this reason.

## Design

Replace the string with a small `CappedTextBuffer` (new module under
`src/main/sessions/`): a list of chunks plus a running length.

- `append(chunk)` pushes the chunk and drops whole oldest chunks while the
total exceeds the cap — amortised O(chunk), no copy of the retained
bytes. Dropping whole chunks can never split a surrogate pair, which is
why the old code needed its low-surrogate check.
- A chunk larger than the cap on its own replaces the whole buffer with a
real copy of its tail (surrogate-safe cut, forced flat so the oversized
chunk is released — the #321 lesson).
- `read()` joins once. It runs only on attach, which is rare, so the join
is the right place to pay the O(cap) cost.
- `length` is exposed for tests and diagnostics.

`SessionManager` swaps the two `Map<string, string>` for
`Map<string, CappedTextBuffer>`; the attach/replay contract (bytes
accumulate from process start, attach returns the current tail in the same
synchronous block that flips the attached flag, caps unchanged) does not
move.

## Verification

- New `cappedTextBuffer.test.ts`: retains the newest bytes under the cap,
never exceeds the cap, keeps whole chunks, handles an oversized chunk by
keeping a surrogate-safe tail that is not a slice of the input, and
`read()` reflects appends in order.
- `npx tsc -p tsconfig.node.json --noEmit`.
- Existing SessionManager suites.
- After: main `eventLoop.meanMs` and GC pressure during heavy multi-session
output should drop; no session should retain more than its cap.
55 changes: 22 additions & 33 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { getToolPath, refreshToolchainFromState } from '@main/setup/toolchain.js
import { resolveToolPath } from '@main/setup/binaryResolver.js'
import { updateToolPaths } from '@main/setup/setupState.js'
import { forgetFeedDebugSession } from '@main/storage/feedDebugLog.js'
import { CappedTextBuffer } from '@main/sessions/cappedTextBuffer.js'
import type {
ConditionCustomAction,
ProviderConditionSnapshot,
Expand Down Expand Up @@ -401,23 +402,6 @@ export type ResolveConditionResult =
failedAtStep?: string
}

function appendCappedBuffer(prev: string, data: string, cap: number): string {
let next = prev + data
if (next.length <= cap) return next

// Naive slice by string length can split a UTF-16 surrogate pair.
// xterm will usually recover from a replacement character, but the
// replay buffer should not introduce corruption at its oldest edge
// when dropping the whole rune costs only one code unit.
let startIdx = next.length - cap
const firstCode = next.charCodeAt(startIdx)
if (firstCode >= 0xdc00 && firstCode <= 0xdfff) {
startIdx += 1
}
next = next.slice(startIdx)
return next
}

export class SessionManager extends EventEmitter {
private readonly sessions = new Map<string, RegistryEntry>()
private readonly spawningSessionGenerations = new Map<string, symbol>()
Expand Down Expand Up @@ -613,7 +597,10 @@ export class SessionManager extends EventEmitter {
// duplicate bytes (once in the buffer, once as a live event).
// Toggling the flag in the same synchronous tick as the buffer
// read means no data can slip through between the two.
private readonly terminalBuffers = new Map<string, string>()
// WHY CappedTextBuffer and not a string: see src/main/sessions/
// cappedTextBuffer.ts — the string version copied the whole cap on every
// chunk and retained sliced-string parents (#726).
private readonly terminalBuffers = new Map<string, CappedTextBuffer>()
private readonly terminalAttached = new Set<string>()

// Agent PTY terminal replay state. Agent sessions already publish
Expand All @@ -631,7 +618,7 @@ export class SessionManager extends EventEmitter {
// terminal, and agent PTYs can be noisy. Buffer in main, broadcast
// only after an attach, and let the renderer replay the buffer before
// draining live bytes.
private readonly agentPtyBuffers = new Map<string, string>()
private readonly agentPtyBuffers = new Map<string, CappedTextBuffer>()
private readonly agentPtyAttachCounts = new Map<string, number>()
private readonly agentPtyRestoreSizes = new Map<string, PtySize>()

Expand Down Expand Up @@ -2571,7 +2558,7 @@ export class SessionManager extends EventEmitter {
})

this.sessionSizes.set(sessionId, initialSize)
this.agentPtyBuffers.set(sessionId, '')
this.agentPtyBuffers.set(sessionId, new CappedTextBuffer(AGENT_PTY_BUFFER_CAP))
session.on('started', ({ projectDir }) => {
if (!ownsEntry()) return
this.markActivity(sessionId)
Expand Down Expand Up @@ -2608,11 +2595,12 @@ export class SessionManager extends EventEmitter {
session.on('pty-data', (data: string) => {
if (!ownsEntry()) return
this.markActivity(sessionId)
const prev = this.agentPtyBuffers.get(sessionId) ?? ''
this.agentPtyBuffers.set(
sessionId,
appendCappedBuffer(prev, data, AGENT_PTY_BUFFER_CAP),
)
let replay = this.agentPtyBuffers.get(sessionId)
if (!replay) {
replay = new CappedTextBuffer(AGENT_PTY_BUFFER_CAP)
this.agentPtyBuffers.set(sessionId, replay)
}
replay.append(data)
if ((this.agentPtyAttachCounts.get(sessionId) ?? 0) > 0) {
this.emit('agent-pty-data', { sessionId, data })
}
Expand Down Expand Up @@ -2925,7 +2913,7 @@ export class SessionManager extends EventEmitter {
// and is replayed to the renderer on attach — see the block
// comment on terminalBuffers above for the full reasoning.
this.sessionSizes.set(sessionId, initialSize)
this.terminalBuffers.set(sessionId, '')
this.terminalBuffers.set(sessionId, new CappedTextBuffer(TERMINAL_BUFFER_CAP))

// Terminal sessions only emit started / data / exit. The 'data'
// event carries raw PTY bytes for xterm.js on the renderer side;
Expand All @@ -2951,11 +2939,12 @@ export class SessionManager extends EventEmitter {
// replay the full history. Cap at TERMINAL_BUFFER_CAP —
// longer sessions just lose the oldest bytes, which is the
// standard terminal scrollback behavior.
const prev = this.terminalBuffers.get(sessionId) ?? ''
this.terminalBuffers.set(
sessionId,
appendCappedBuffer(prev, data, TERMINAL_BUFFER_CAP),
)
let replay = this.terminalBuffers.get(sessionId)
if (!replay) {
replay = new CappedTextBuffer(TERMINAL_BUFFER_CAP)
this.terminalBuffers.set(sessionId, replay)
}
replay.append(data)
// Only broadcast live events AFTER the renderer has attached.
// Before attach, the data is still in the buffer and will be
// replayed when the renderer calls attachTerminal. See the
Expand Down Expand Up @@ -3118,7 +3107,7 @@ export class SessionManager extends EventEmitter {
)
return ''
}
const buffer = this.terminalBuffers.get(sessionId) ?? ''
const buffer = this.terminalBuffers.get(sessionId)?.read() ?? ''
// Flip the attach flag in the SAME synchronous block as reading
// the buffer. JavaScript is single-threaded and event emission
// can only happen on a later tick, so nothing can sneak in.
Expand Down Expand Up @@ -3166,7 +3155,7 @@ export class SessionManager extends EventEmitter {
)
return null
}
const buffer = this.agentPtyBuffers.get(sessionId) ?? ''
const buffer = this.agentPtyBuffers.get(sessionId)?.read() ?? ''
const attachCount = this.agentPtyAttachCounts.get(sessionId) ?? 0
if (attachCount === 0) {
const currentSize = this.sessionSizes.get(sessionId)
Expand Down
153 changes: 153 additions & 0 deletions src/main/sessions/cappedTextBuffer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'

import { CappedTextBuffer } from './cappedTextBuffer.js'

describe('CappedTextBuffer', () => {
it('starts empty and ignores empty appends', () => {
const buffer = new CappedTextBuffer(8)
buffer.append('')
expect(buffer.length).toBe(0)
expect(buffer.read()).toBe('')
})

it('rejects a non-positive cap and a piece size that cannot hold a surrogate pair', () => {
expect(() => new CappedTextBuffer(0)).toThrow(RangeError)
expect(() => new CappedTextBuffer(8, 1)).toThrow(RangeError)
expect(() => new CappedTextBuffer(8, 2.5)).toThrow(RangeError)
})

it('keeps the newest whole chunks under the cap, oldest first', () => {
const buffer = new CappedTextBuffer(10)
buffer.append('abcd')
buffer.append('efgh')
expect(buffer.read()).toBe('abcdefgh')
buffer.append('ijkl')
// 12 > 10: the oldest whole chunk goes, not a partial one.
expect(buffer.read()).toBe('efghijkl')
expect(buffer.length).toBe(8)
})

it('never exceeds the cap and drops the minimum number of whole chunks', () => {
const cap = 1000
const buffer = new CappedTextBuffer(cap)
const appended: string[] = []
let seed = 7
for (let i = 0; i < 5_000; i += 1) {
seed = (seed * 48271) % 2147483647
const chunk = String.fromCharCode(97 + (i % 26)).repeat(1 + (seed % 300))
appended.push(chunk)
buffer.append(chunk)

expect(buffer.length).toBeLessThanOrEqual(cap)
// Reconstruct the expected retained suffix in whole chunks.
let expected = ''
let dropped: string | null = null
for (let j = appended.length - 1; j >= 0; j -= 1) {
const candidate = appended[j]!
if (expected.length + candidate.length > cap) {
dropped = candidate
break
}
expected = candidate + expected
}
expect(buffer.read()).toBe(expected)
if (dropped !== null) {
// Minimal drop: putting the last dropped chunk back would overflow.
expect(buffer.length + dropped.length).toBeGreaterThan(cap)
}
}
})

it('keeps only the tail of a chunk larger than the cap', () => {
const buffer = new CappedTextBuffer(8, 2)
buffer.append('old')
buffer.append('0123456789abcdef')
expect(buffer.read()).toBe('89abcdef')
expect(buffer.length).toBe(8)
// Later appends continue from that tail: the oversized chunk was stored
// as pieces, so only the oldest piece goes, not the whole replay.
buffer.append('xy')
expect(buffer.read()).toBe('abcdefxy')
})

it('bounds overflow loss to one piece even for a near-cap chunk', () => {
const buffer = new CappedTextBuffer(64, 8)
buffer.append('a'.repeat(60))
buffer.append('bbbb')
expect(buffer.length).toBe(64)
buffer.append('c')
// 65 > 64: one 8-unit piece of the 60-unit chunk is dropped, not all of it.
expect(buffer.length).toBe(57)
expect(buffer.read()).toBe('a'.repeat(52) + 'bbbbc')
})

it('never splits a surrogate pair across pieces', () => {
const buffer = new CappedTextBuffer(64, 3)
// With a 3-unit piece the naive cuts would land INSIDE both pairs
// (ab|😀 → 'ab' + high, then …); the boundary must pull back so the
// pieces come out as 'ab', '😀c', 'd😀', 'e'.
const chunk = 'ab\u{1F600}cd\u{1F600}e'
buffer.append(chunk)
expect(buffer.read()).toBe(chunk)
// Evict exactly one piece at a time (cap 64, 9 units retained): each
// step's retained text pins the piece boundaries. Without the pull-back
// the pieces would be 'ab\uD83D', '\uDE00cd', … and the second step
// would leave a lone low surrogate at the head.
buffer.append('x'.repeat(57))
expect(buffer.read()).toBe('\u{1F600}cd\u{1F600}e' + 'x'.repeat(57))
buffer.append('xx')
expect(buffer.read()).toBe('d\u{1F600}e' + 'x'.repeat(59))
buffer.append('xxx')
expect(buffer.read()).toBe('e' + 'x'.repeat(62))
})

it('holds its invariants with piece cutting and oversized chunks under fuzz', () => {
const cap = 1000
const pieceSize = 37
const buffer = new CappedTextBuffer(cap, pieceSize)
let stream = ''
let seed = 11
const alphabet = ['a', 'b', '\u{1F600}', 'c', '\u{1F4A9}', 'd']
for (let i = 0; i < 4_000; i += 1) {
seed = (seed * 48271) % 2147483647
const units = 1 + (seed % 1_500)
let chunk = ''
while (chunk.length < units) chunk += alphabet[(seed + chunk.length) % alphabet.length]!
stream += chunk
buffer.append(chunk)

const text = buffer.read()
expect(text.length).toBe(buffer.length)
expect(buffer.length).toBeLessThanOrEqual(cap)
expect(stream.endsWith(text)).toBe(true)
if (stream.length > cap) expect(buffer.length).toBeGreaterThan(cap - pieceSize)
const first = text.charCodeAt(0)
expect(first >= 0xdc00 && first <= 0xdfff).toBe(false)
}
})

it('does not start the tail of an oversized chunk on a low surrogate', () => {
// 'ab' + 😀 (two code units) + 'cd' is six code units.
const chunk = 'ab\u{1F600}cd'
const onHigh = new CappedTextBuffer(4)
onHigh.append(chunk)
expect(onHigh.read()).toBe('\u{1F600}cd')

const onLow = new CappedTextBuffer(3)
onLow.append(chunk)
// A 3-unit tail would begin with the low surrogate; drop it instead.
expect(onLow.read()).toBe('cd')
})

it('stays correct across lazy compaction of the dropped prefix', () => {
const buffer = new CappedTextBuffer(50)
const chunks: string[] = []
for (let i = 0; i < 1_000; i += 1) {
const chunk = `${i}|`.padStart(5, '0')
chunks.push(chunk)
buffer.append(chunk)
}
expect(buffer.length).toBe(50)
expect(buffer.read()).toBe(chunks.slice(-10).join(''))
})
})
Loading