Files
orca/config/scripts/terminal-shutdown-byte-limit-benchmark.mjs
T

272 lines
9.5 KiB
JavaScript

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())
}
}