style(terminal): tighten string-retention rationale

(cherry picked from commit 6c0388db40)
This commit is contained in:
Neil
2026-08-10 18:06:52 -07:00
committed by Jinjing
parent cb7bf409c2
commit 391e9b2198
2 changed files with 5 additions and 17 deletions
@@ -1,11 +1,4 @@
/**
* V8 backs `String.prototype.slice` with a SlicedString that points at the whole parent, so a
* short tail of a large chunk keeps that whole chunk alive. Concatenating forces a ConsString
* whose flattening allocates a fresh sequential string of just this length, dropping the parent.
*
* Use this whenever a slice outlives the value it was cut from — a queued remainder, or an
* overlap-trimmed payload handed to a component that will hold it.
*/
// V8 slices can retain their full parent; force a standalone copy for values that outlive it.
export function flattenRetainedSlice(value: string): string {
return value.length === 0 ? value : `${value} `.slice(0, -1)
}
@@ -50,7 +50,7 @@ type WriteTerminalOutputOptions = {
type QueueChunk = {
data: string
// Why: `data` may be a V8 SlicedString pinning a much larger parent; this is what the queue really holds alive.
// Tracks the backing data still reachable through this queue slot.
retainedChars: number
foreground: boolean
forceForegroundRefresh: boolean
@@ -634,9 +634,7 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null {
data += chunk.data
remaining -= chunk.data.length
entry.queuedChars -= chunk.data.length
// Why: compaction only splices every 64 chunks, so without this a fully consumed chunk keeps
// its (possibly parent-pinning) string and callbacks alive while charging nothing. Nothing
// reads below chunkIndex, so the drained slot only has to stay type-shaped.
// Clear drained slots before the 64-chunk compaction can release them.
entry.chunks[entry.chunkIndex] = {
data: '',
retainedChars: 0,
@@ -658,7 +656,7 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null {
data += chunk.data.slice(0, remaining)
const residual = chunk.data.slice(remaining)
// Why: the residual is a SlicedString still pinning `retainedChars`; copy it flat once it pins 2x its own length so a drained 2MB chunk stops holding 2MB to serve a 1KB tail. Halving keeps total copying linear in the original chunk.
// Geometric flattening bounds retained parents while keeping total copy work linear.
const flatten = residual.length * 2 <= chunk.retainedChars
entry.chunks[entry.chunkIndex] = {
...chunk,
@@ -738,10 +736,7 @@ function enqueueChunk(
ackCredit?: () => void
}
): void {
// Why copy at the boundary: producers hand us slices and concatenations that pin far more than
// they measure - restore-overlap trims and OSC status stripping both do - and the queue holds
// this string long after the parent is otherwise dead. Owning our copy is what makes
// retainedChars true, and it costs ~0.1ms per 512KB against a ~30MB/s sustained drain.
// Own queued data so producer slices cannot pin larger PTY or restore buffers.
const owned = flattenRetainedSlice(data)
entry.chunks.push({
data: owned,