[Bug]: Large terminal clipboard sequences repeatedly rescan pending history#23817
Area
apps/server
Steps to reproduce
- Use main at
22840ab943b8418744b48223411e46ba4fdcef1aon Linux x64, Node 26.12.1. - Emit an OSC 52 clipboard-write sequence for 256 KiB of text from a terminal process, split across typical 4 KiB PTY chunks. Editors and terminal applications emit these sequences for clipboard copies.
- Run the reproduction below, or inspect
sanitizeTerminalHistoryChunkinapps/server/src/terminal/Manager.tsas each chunk arrives.
Expected behavior
Scan new bytes for the string terminator, preserving split terminators and the exact saved history.
Actual behavior
Each output event joins the pending control string to the new data and scans the accumulated string from the beginning. The 256 KiB fixture visits 15,320,244 characters for a 349,536-character wire sequence. This runs synchronously before output publication.
| Same 256 KiB OSC 52 payload, 4 KiB chunks | Baseline | Patch |
|---|---|---|
| Median (min–max), 7 trials | 34.481 ms (34.272–39.601) | 1.951 ms (1.533–2.152) |
Both versions produce identical output. A focused patch scans only new data plus one previous character. A 1 MiB fixture falls from 240,239,284 character visits to 1,398,451. Replaying captured PTY chunks also reproduces the cost.
Impact and environment
Occasional CPU and output-delivery cost when a terminal application emits a large valid control string. Linux x64, Intel i5-14400F, Node 26.12.1. These are aggregate sanitizer timings, not a measured end-to-end client delay or one uninterrupted stall. Growing-string copying remains a separate cost.
Self-contained before/after reproduction
Run from the PR checkout with Node 26.12.1. This loads the exact baseline and patched production functions and checks output equality. No third-party benchmark dependency is needed.
| 1 | node --input-type=module <<'JS' |
| 2 | import * as assert from 'node:assert/strict'; |
| 3 | import * as fs from 'node:fs'; |
| 4 | import { execFileSync } from 'node:child_process'; |
| 5 | import { stripTypeScriptTypes } from 'node:module'; |
| 6 | import { performance } from 'node:perf_hooks'; |
| 7 | |
| 8 | const base = '22840ab943b8418744b48223411e46ba4fdcef1a'; |
| 9 | const file = 'apps/server/src/terminal/Manager.ts'; |
| 10 | async function load(source, name) { |
| 11 | const start = source.indexOf('function isCsiFinalByte('); |
| 12 | const end = source.indexOf('\nfunction legacySafeThreadId(', start); |
| 13 | assert.ok(start >= 0 && end > start); |
| 14 | const js = stripTypeScriptTypes(source.slice(start, end) + '\nexport { sanitizeTerminalHistoryChunk };'); |
| 15 | return import(`data:text/javascript;base64,${Buffer.from(js + `\n//# sourceURL=${name}`).toString('base64')}`); |
| 16 | } |
| 17 | const baseline = await load(execFileSync('git', ['show', `${base}:${file}`], { encoding: 'utf8' }), 'before'); |
| 18 | const patched = await load(fs.readFileSync(file, 'utf8'), 'after'); |
| 19 | const text = 'export const answer = 42;\n'.repeat(11_000).slice(0, 256 * 1024); |
| 20 | const wire = '\x1b]52;c;' + Buffer.from(text).toString('base64') + '\x07'; |
| 21 | const chunks = Array.from({ length: Math.ceil(wire.length / 4096) }, (_, i) => wire.slice(i * 4096, (i + 1) * 4096)); |
| 22 | function run(parser) { |
| 23 | let pending = '', output = ''; |
| 24 | for (const data of chunks) { |
| 25 | const result = parser.sanitizeTerminalHistoryChunk(pending, data); |
| 26 | pending = result.pendingControlSequence; |
| 27 | output += result.visibleText; |
| 28 | } |
| 29 | assert.equal(pending, ''); |
| 30 | assert.equal(output, wire); |
| 31 | } |
| 32 | for (let i = 0; i < 2; i++) { run(baseline); run(patched); } |
| 33 | const samples = { baseline: [], patched: [] }; |
| 34 | for (let i = 0; i < 7; i++) { |
| 35 | const order = i % 2 ? [['patched', patched], ['baseline', baseline]] : [['baseline', baseline], ['patched', patched]]; |
| 36 | for (const [label, parser] of order) { |
| 37 | const start = performance.now(); |
| 38 | run(parser); |
| 39 | samples[label].push(performance.now() - start); |
| 40 | } |
| 41 | } |
| 42 | const stats = values => { |
| 43 | const sorted = values.toSorted((a, b) => a - b); |
| 44 | return { minMs: sorted[0], medianMs: sorted[3], maxMs: sorted.at(-1), samples: values }; |
| 45 | }; |
| 46 | console.log(JSON.stringify({ node: process.version, decodedBytes: text.length, chunks: chunks.length, baseline: stats(samples.baseline), patched: stats(samples.patched) }, null, 2)); |
| 47 | JS |