[Bug]: Large terminal clipboard sequences repeatedly rescan pending history#23817

opened1d agobyAAksharP5

Area

apps/server

Steps to reproduce

  1. Use main at 22840ab943b8418744b48223411e46ba4fdcef1a on Linux x64, Node 26.12.1.
  2. 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.
  3. Run the reproduction below, or inspect sanitizeTerminalHistoryChunk in apps/server/src/terminal/Manager.ts as 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 chunksBaselinePatch
Median (min–max), 7 trials34.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.

sh
1node --input-type=module <<'JS'
2import * as assert from 'node:assert/strict';
3import * as fs from 'node:fs';
4import { execFileSync } from 'node:child_process';
5import { stripTypeScriptTypes } from 'node:module';
6import { performance } from 'node:perf_hooks';
7
8const base = '22840ab943b8418744b48223411e46ba4fdcef1a';
9const file = 'apps/server/src/terminal/Manager.ts';
10async 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}
17const baseline = await load(execFileSync('git', ['show', `${base}:${file}`], { encoding: 'utf8' }), 'before');
18const patched = await load(fs.readFileSync(file, 'utf8'), 'after');
19const text = 'export const answer = 42;\n'.repeat(11_000).slice(0, 256 * 1024);
20const wire = '\x1b]52;c;' + Buffer.from(text).toString('base64') + '\x07';
21const chunks = Array.from({ length: Math.ceil(wire.length / 4096) }, (_, i) => wire.slice(i * 4096, (i + 1) * 4096));
22function 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}
32for (let i = 0; i < 2; i++) { run(baseline); run(patched); }
33const samples = { baseline: [], patched: [] };
34for (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}
42const 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};
46console.log(JSON.stringify({ node: process.version, decodedBytes: text.length, chunks: chunks.length, baseline: stats(samples.baseline), patched: stats(samples.patched) }, null, 2));
47JS