diff --git a/config/scripts/terminal-shutdown-byte-limit-benchmark.mjs b/config/scripts/terminal-shutdown-byte-limit-benchmark.mjs new file mode 100644 index 00000000000..366015c984f --- /dev/null +++ b/config/scripts/terminal-shutdown-byte-limit-benchmark.mjs @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import ts from 'typescript-api' +import { build } from 'esbuild' + +// Pipe the baseline capture module on stdin. No Electron or visible terminal is launched. +const renderer = path.resolve('src/renderer/src') +const entry = path.join(renderer, 'components/terminal-pane/terminal-shutdown-layout-capture.ts') +const sources = [readFileSync(0, 'utf8'), readFileSync(entry, 'utf8')] +assert(sources.every((source) => source.includes('export function captureTerminalShutdownLayout'))) +const layoutFile = path.join(renderer, 'components/terminal-pane/layout-serialization.ts') +const layoutSource = readFileSync(layoutFile, 'utf8') +const layoutAst = ts.createSourceFile(layoutFile, layoutSource, ts.ScriptTarget.Latest, true) +const layoutNames = ['getLayoutChildNodes', 'serializePaneTree', 'serializeTerminalLayout'] +const layoutFunctions = layoutAst.statements.filter( + (node) => ts.isFunctionDeclaration(node) && layoutNames.includes(node.name?.text) +) +assert.equal(layoutFunctions.length, layoutNames.length) +const layoutSubset = `import { isTerminalLeafId } from '../../../../shared/stable-pane-id'; +${layoutFunctions.map((node) => node.getText(layoutAst)).join('\n')}` + +async function load(source) { + const result = await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + alias: { '@': renderer }, + plugins: [ + { + name: 'capture-benchmark', + setup(builder) { + builder.onLoad({ filter: /terminal-shutdown-layout-capture\.ts$/ }, () => ({ + contents: `${source}\nexport { fitsSessionScrollbackByteLimit, MAX_BUFFER_BYTES };`, + loader: 'ts', + resolveDir: path.dirname(entry) + })) + // Select unchanged layout declarations to exclude unrelated renderer module side effects. + builder.onLoad({ filter: /[/\\]layout-serialization\.ts$/ }, () => ({ + contents: layoutSubset, + loader: 'ts', + resolveDir: path.dirname(layoutFile) + })) + builder.onResolve({ filter: /pane-terminal-output-scheduler$/ }, () => ({ + path: 'capture-scheduler', + namespace: 'fixture' + })) + builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ + contents: 'export function flushTerminalOutput(terminal) { terminal.recordFlush?.(); }', + loader: 'js' + })) + } + } + ] + }) + const bundled = `${result.outputFiles[0].text}\n//# sourceURL=shutdown-byte-limit-benchmark-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(bundled).toString('base64')}`) +} +const modules = await Promise.all(sources.map(load)) +const captures = modules.map((module) => module.captureTerminalShutdownLayout) +const predicates = modules.map((module) => module.fitsSessionScrollbackByteLimit) + +class FixtureElement { + constructor({ classes = [], dataset = {}, children = [], firstElementChild = null } = {}) { + this.classList = { contains: (name) => classes.includes(name) } + this.dataset = dataset + this.children = children + this.firstElementChild = firstElementChild + this.style = { flex: '1' } + } +} +globalThis.HTMLElement = FixtureElement + +function leafId(index) { + return `${String(index + 1).padStart(8, '0')}-1111-4111-8111-111111111111` +} + +function captureArgs(panes, captureBuffers = true, cleared = false) { + const leaves = panes.map( + (pane) => + new FixtureElement({ + classes: ['pane'], + dataset: { leafId: pane.leafId } + }) + ) + let root = leaves[0] ?? null + for (const leaf of leaves.slice(1)) { + root = new FixtureElement({ classes: ['pane-split', 'is-horizontal'], children: [root, leaf] }) + } + const prior = Object.fromEntries(panes.map((pane) => [pane.leafId, 'prior-buffer'])) + return { + manager: { getPanes: () => panes, getActivePane: () => panes[0] ?? null }, + container: new FixtureElement({ firstElementChild: root }), + expandedPaneId: panes[1]?.id ?? null, + paneTransports: new Map( + panes.map((pane, index) => [ + pane.id, + { + getPtyId: () => (index % 2 ? null : `ssh:target@@pty-${index}`) + } + ]) + ), + paneTitlesByPaneId: Object.fromEntries(panes.map((pane) => [pane.id, `title-${pane.id}`])), + existingLayout: Object.freeze({ + root: null, + activeLeafId: null, + expandedLeafId: null, + buffersByLeafId: Object.freeze(prior), + scrollbackRefsByLeafId: Object.freeze({ [leafId(0)]: 'v1-prior' }), + ptyIdsByLeafId: Object.freeze({ [leafId(1)]: 'local-prior' }) + }), + captureBuffers, + clearedScrollbackLeafIds: new Set(cleared ? [leafId(0)] : []) + } +} + +function syntheticFixture(config) { + const events = [] + const panes = config.units.map((unit, index) => + Object.freeze({ + id: index + 1, + leafId: leafId(index), + terminal: Object.freeze({ + options: { scrollback: config.rows }, + cols: 120, + rows: 40, + buffer: { active: { cursorX: index, cursorY: index } }, + recordFlush: () => events.push(['flush', index]) + }), + serializeAddon: { + serialize(options) { + const rows = options?.scrollback ?? 0 + events.push(['serialize', index, rows]) + if (config.fail && index === 0) { + throw new Error('fixture serializer failure') + } + return unit.repeat(Math.max(0, rows)) + } + } + }) + ) + return { args: captureArgs(panes, config.capture, config.cleared), events } +} + +let seed = 0x17c0ffee +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return (seed >>> 8) % max +} +const limit = modules[0].MAX_BUFFER_BYTES +assert.equal(modules[1].MAX_BUFFER_BYTES, limit) +const units = ['x', 'é', '界', '😀', '\ud83d', '\udc00', '\x1b[31mtext\x1b[0m\r\n', ''] +let comparisons = 0 +function compareFixture(config) { + const results = captures.map((capture) => { + const fixture = syntheticFixture(config) + const layout = capture(fixture.args) + return { layout, events: fixture.events } + }) + assert.deepEqual(results[1], results[0]) + comparisons++ +} +for (const unit of units.filter(Boolean)) { + for (let delta = -2; delta <= 2; delta++) { + const rows = Math.floor((limit - 7) / Buffer.byteLength(unit)) + delta + compareFixture({ units: [unit], rows, capture: true, cleared: false, fail: false }) + } +} +for (let iteration = 0; iteration < 1500; iteration++) { + compareFixture({ + units: Array.from({ length: random(4) }, () => units[random(units.length)].repeat(random(50))), + rows: random(2000), + capture: random(5) !== 0, + cleared: random(3) === 0, + fail: random(9) === 0 + }) +} +for (const unit of units) { + for (const length of [0, 1, 64, limit - 1, limit, limit + 1]) { + const input = unit.repeat(length) + assert.equal(predicates[1](input), predicates[0](input)) + } +} +console.log(`${comparisons} full capture layouts/event traces and 48 byte-fit cases match`) + +function sample(arm, input, repeats) { + const start = performance.now() + let output + for (let i = 0; i < repeats; i++) { + output = arm(input) + } + return { elapsed: (performance.now() - start) / repeats, output } +} + +function benchmark(name, input, arms, minimumRepeats = 1) { + const expected = arms[0](input) + assert.deepEqual(arms[1](input), expected) + for (const arm of arms) { + const until = performance.now() + 250 + while (performance.now() < until) { + sample(arm, input, 1) + } + } + const repeats = Math.max( + minimumRepeats, + Math.min(100000, Math.ceil(50 / sample(arms[0], input, 1).elapsed)) + ) + /** @type {number[][]} */ + const times = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + const result = sample(arms[index], input, repeats) + times[index].push(result.elapsed) + assert.deepEqual(result.output, expected) + } + } + const median = times.map((values) => { + values.sort((a, b) => a - b) + return (values[3] + values[4]) / 2 + }) + console.log(JSON.stringify({ name, repeats, median, times })) +} + +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + pairs: 8, + unit: 'ms' + }) +) +for (const size of [64, 32768, limit, 2 * 1024 * 1024]) { + benchmark(`fit ASCII ${size} code units`, 'x'.repeat(size), predicates) + benchmark(`fit Unicode ${size} code units`, '界'.repeat(size), predicates) +} + +const xterm = await import('@xterm/headless') +const addon = await import('@xterm/addon-serialize') +const Terminal = xterm.Terminal ?? xterm.default.Terminal +const SerializeAddon = addon.SerializeAddon ?? addon.default.SerializeAddon +for (const [rows, unicode, paneCount] of [ + [50, false, 1], + [500, false, 1], + [5000, false, 1], + [5000, true, 1], + [500, false, 8] +]) { + const panes = [] + for (let index = 0; index < paneCount; index++) { + const terminal = new Terminal({ cols: 120, rows: 40, scrollback: 5000, allowProposedApi: true }) + const serializeAddon = new SerializeAddon() + terminal.loadAddon(serializeAddon) + const row = unicode ? '界é😀'.repeat(15) : 'line x'.repeat(18) + await new Promise((resolve) => terminal.write(`${row}\r\n`.repeat(rows), resolve)) + panes.push({ id: index + 1, leafId: leafId(index), terminal, serializeAddon }) + } + try { + benchmark( + `full capture ${paneCount} panes / ${rows} ${unicode ? 'Unicode' : 'ASCII'} lines`, + captureArgs(panes), + captures, + 4 + ) + } finally { + panes.forEach((pane) => pane.terminal.dispose()) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index 9e2b375939c..066aadfdb46 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -91,6 +91,70 @@ function mockRootForSplit(firstPaneId = 1, secondPaneId = 2): HTMLDivElement { } describe('captureTerminalShutdownLayout', () => { + it('uses the bounded native fit check without a separate JS byte scan', async () => { + const byteLimits = await import('../../../../shared/utf8-byte-limits') + const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') + const measure = vi.spyOn(byteLimits, 'measureUtf8ByteLength') + const contents = 'x'.repeat(32 * 1024) + const pane = { + id: 1, + leafId: LEAF_ID, + terminal: mockTerminal(5_000), + serializeAddon: { serialize: vi.fn(() => contents) } + } + try { + const layout = captureTerminalShutdownLayout({ + manager: { getPanes: () => [pane], getActivePane: () => pane } as never, + container: mockRootForPane(1), + expandedPaneId: null, + paneTransports: new Map(), + paneTitlesByPaneId: {}, + existingLayout: undefined + }) + expect(layout.buffersByLeafId).toEqual({ [LEAF_ID]: `${contents}${CURSOR_HOME}` }) + expect(pane.serializeAddon.serialize).toHaveBeenCalledExactlyOnceWith({ scrollback: 5_000 }) + expect(measure.mock.calls.length).toBe(0) + } finally { + measure.mockRestore() + } + }) + + it.each(['x', 'é', '界', '😀', '\ud83d', '\udc00'])( + 'preserves exact-cap decisions and probe options for %j', + async (unit) => { + const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') + for (const delta of [-1, 0, 1]) { + const payloadBytes = + TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT + delta - CURSOR_HOME.length + const unitBytes = Buffer.byteLength(unit) + const contents = + unit.repeat(Math.floor(payloadBytes / unitBytes)) + 'x'.repeat(payloadBytes % unitBytes) + const serialize = vi.fn((options?: { scrollback?: number }) => + options?.scrollback === 5_000 ? contents : 'short' + ) + const pane = Object.freeze({ + id: 1, + leafId: LEAF_ID, + terminal: mockTerminal(5_000), + serializeAddon: { serialize } + }) + const layout = captureTerminalShutdownLayout({ + manager: { getPanes: () => [pane], getActivePane: () => pane } as never, + container: mockRootForPane(1), + expandedPaneId: null, + paneTransports: new Map(), + paneTitlesByPaneId: {}, + existingLayout: undefined + }) + const expected = delta <= 0 ? contents : 'short' + expect(layout.buffersByLeafId).toEqual({ [LEAF_ID]: `${expected}${CURSOR_HOME}` }) + expect(serialize.mock.calls.map(([options]) => options?.scrollback)).toEqual( + delta <= 0 ? [5_000] : [5_000, 2_500, 3_750, 4_375, 4_687] + ) + } + } + ) + it('flushes queued terminal output before serializing shutdown scrollback', async () => { const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') const order: string[] = [] diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index f43d8026bd0..9e4be85da55 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -7,7 +7,7 @@ import { mergeCapturedLeafState } from './merge-captured-leaf-state' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' -import { getUtf8ByteLength, measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' +import { getUtf8ByteLength, isUtf8ByteLengthWithinLimit } from '../../../../shared/utf8-byte-limits' const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT @@ -43,7 +43,7 @@ function omitClearedLeafState( } function fitsSessionScrollbackByteLimit(serialized: string): boolean { - return !measureUtf8ByteLength(serialized, { stopAfterBytes: MAX_BUFFER_BYTES }).exceededLimit + return isUtf8ByteLengthWithinLimit(serialized, MAX_BUFFER_BYTES) } // Why bounded: a plain row bisection costs ~13 full serializes per over-limit pane (~250ms at the