diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 98868cbe2e6..b8d9072d1c1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -333,6 +333,7 @@ import { } from './renderer-owned-agent-status-registry' import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery' import { directSshAuthoritiesEqual } from '@/store/slices/direct-ssh-terminal-authority-ledger' +import { flattenRetainedSlice } from '@/lib/flatten-retained-slice' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -6870,7 +6871,9 @@ export function connectPanePty( if (rawLength !== chunk.data.length) { return null } - return chunk.data.slice(offset) + // Why flatten: this tail outlives the chunk it was cut from once queued, and a raw slice + // would pin the whole original chunk in the output queue (STA-3567). + return flattenRetainedSlice(chunk.data.slice(offset)) } type RestoredSnapshotReconciliation = @@ -6920,7 +6923,9 @@ export function connectPanePty( // Why: renderer-only OSC stripping makes raw seq offsets unmappable onto cleaned text; refetch instead of risking duplicate output. return { action: 'force-fresh-restore' } } - const sliced = data.slice(restoredSnapshotBaselineSeq - startSeq) + // Why flatten: same as above - the trimmed tail is handed to the output queue, and a raw + // slice would keep the entire pre-trim payload alive behind it (STA-3567). + const sliced = flattenRetainedSlice(data.slice(restoredSnapshotBaselineSeq - startSeq)) return { action: 'write', data: sliced, diff --git a/src/renderer/src/lib/flatten-retained-slice.test.ts b/src/renderer/src/lib/flatten-retained-slice.test.ts new file mode 100644 index 00000000000..2eedfc5c6ad --- /dev/null +++ b/src/renderer/src/lib/flatten-retained-slice.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { flattenRetainedSlice } from './flatten-retained-slice' + +// Why 1 MB: comfortably above V8's SlicedString threshold, so a raw slice really does keep the +// parent alive and the difference between flattened and not is unmistakable in heapUsed. +const PARENT_CHARS = 1024 * 1024 +const TAIL_CHARS = 512 +const PARENTS = 8 + +function collect(): number { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('global.gc unavailable - config/vitest.config.ts must pass --expose-gc') + } + gc() + gc() + return process.memoryUsage().heapUsed +} + +// Distinct leading chars stop V8 sharing one backing store across the parents. +function makeParent(index: number, filler: string): string { + return String.fromCharCode(0x41 + index) + filler.repeat(PARENT_CHARS - 1) +} + +function retainedBytesForTails(filler: string, transform: (value: string) => string): number { + const parents = Array.from({ length: PARENTS }, (_unused, index) => makeParent(index, filler)) + const baseline = collect() + const tails = parents.map((parent) => transform(parent.slice(parent.length - TAIL_CHARS))) + // Drop the parents; only the tails stay reachable. + parents.length = 0 + const retained = collect() - baseline + expect(tails).toHaveLength(PARENTS) + expect(tails.every((tail) => tail.length === TAIL_CHARS)).toBe(true) + return retained +} + +describe('flattenRetainedSlice', () => { + it('preserves content exactly', () => { + expect(flattenRetainedSlice('')).toBe('') + expect(flattenRetainedSlice('a')).toBe('a') + const source = 'hello world, 漢字, , \u{1f600}' + expect(flattenRetainedSlice(source.slice(3))).toBe(source.slice(3)) + }) + + it.each([ + ['one-byte', 'a'], + ['two-byte', '漢'] + ])('drops the %s parent a raw slice would pin', (_label, filler) => { + const raw = retainedBytesForTails(filler, (value) => value) + const flattened = retainedBytesForTails(filler, flattenRetainedSlice) + + // A raw slice pins all 8 parents; flattening must keep only the tails. + expect(raw).toBeGreaterThan(PARENTS * PARENT_CHARS * 0.5) + expect(flattened).toBeLessThan(PARENTS * PARENT_CHARS * 0.05) + }) +}) diff --git a/src/renderer/src/lib/flatten-retained-slice.ts b/src/renderer/src/lib/flatten-retained-slice.ts new file mode 100644 index 00000000000..2f2088f8b9d --- /dev/null +++ b/src/renderer/src/lib/flatten-retained-slice.ts @@ -0,0 +1,11 @@ +/** + * 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. + */ +export function flattenRetainedSlice(value: string): string { + return value.length === 0 ? value : `${value} `.slice(0, -1) +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 906c85329e7..b00b2f84d6a 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -7,6 +7,7 @@ import { } from './pane-terminal-foreground-render-settle' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { flattenRetainedSlice } from '@/lib/flatten-retained-slice' import { discardInFlightTerminalOutputAckCredits, registerTerminalOutputAckCredits @@ -584,11 +585,6 @@ function coalescedQueuedDataNeedsCursorRestore(entry: QueueEntry): boolean { ) } -// Why: V8 backs `slice` with a SlicedString that points at the whole parent, so a small tail keeps a huge chunk alive. Concatenating forces a ConsString whose flattening allocates a fresh sequential string of just this length. -function flattenRetainedSlice(value: string): string { - return value.length === 0 ? value : `${value} `.slice(0, -1) -} - function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { let remaining = limit let data = '' @@ -744,6 +740,9 @@ function enqueueChunk( ): void { entry.chunks.push({ data, + // Why data.length is the truth here: callers must hand the queue a string that owns its own + // storage. A caller passing a slice of a much larger buffer would understate what the queue + // pins, so producers flatten overlap-trimmed payloads before they reach the scheduler. retainedChars: data.length, foreground: options?.foreground === true, forceForegroundRefresh: options?.forceForegroundRefresh === true,