Skip to content

Commit 499e22b

Browse files
authored
fix(app): reuse terminal cells during serialization (#46763)
1 parent dfe3052 commit 499e22b

9 files changed

Lines changed: 577 additions & 6 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Native Terminal Benchmark
2+
3+
Manual Windows benchmark. Run only in an isolated development worktree. It does
4+
not connect to an OpenCode service, user profile, or database.
5+
6+
Build from `packages/app` with
7+
`bun x vite build --config e2e/performance/terminals/vite.config.ts`, then freeze
8+
`dist` outside the repository. Set `PLAYWRIGHT_BUILD=1`, `PLAYWRIGHT_BASE_URL` to
9+
an unused loopback URL, `TERMINAL_BUILD` to the frozen build,
10+
`TERMINAL_ARTIFACTS` to an existing external directory, and `TERMINAL_RESULTS`
11+
to an external result directory. Run:
12+
13+
```sh
14+
bun x playwright test --config e2e/performance/terminals/playwright.config.ts --repeat-each=20
15+
```
16+
17+
The runner owns its preview server and each test owns a PowerShell ConPTY process.
18+
Session metadata is deterministic. Native output is forwarded through Playwright's
19+
WebSocket fixture into the real production `Terminal`, writer, Ghostty WASM/canvas,
20+
and serializer. No output is dropped, paused, or delayed. This is native terminal
21+
plus production renderer evidence, not the production PTY backend or Electron IPC.
22+
23+
The workload is 12,000 colored build/test log lines with file paths, durations, and
24+
result descriptions. Cases separate visible output, the same output while hidden,
25+
and closing the session tab after filling the configured scrollback. Ghostty
26+
converts the app's 10,000-line setting to bytes at its initial 80-column width;
27+
resizing reduces the effective row capacity. The report records actual retained
28+
rows and the first retained fixture record rather than assuming 10,000 rows. Completion
29+
requires the final marker in Ghostty and completion of its write callbacks, not
30+
just WebSocket delivery. Teardown requires Home readiness and the final serialized
31+
snapshot. Input, focus, resizing, and native process survival are checked.
32+
33+
`probe.ts` is included only by this benchmark build. It observes actual writes,
34+
renderer calls, and serialization. Chrome `TaskDuration` measures renderer task
35+
time, not total process CPU or RAM. For attribution, set
36+
`OPENCODE_PERFORMANCE_TRACE_DIR`; keep traced runs separate from clean timing.
37+
`TERMINAL_DRAW_PROBE=1` separately counts actual canvas draws to verify hidden
38+
rendering; do not mix these instrumented samples with clean timing.
39+
Use `TERMINAL_REVISION` and `TERMINAL_BUNDLE` to identify frozen artifacts.
40+
`TERMINAL_SCREENSHOTS` captures the visible result after timing.
41+
42+
The benchmark has no machine-dependent performance thresholds. Keep raw logs,
43+
snapshots, traces, and screenshots outside Git. Run heavy work through the
44+
coordinator's exclusive gate when participating in a shared performance wave.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { defineConfig } from "@playwright/test"
2+
import config from "../../../playwright.config"
3+
4+
export default defineConfig({
5+
...config,
6+
testDir: ".",
7+
testIgnore: [],
8+
testMatch: "terminal-benchmark.spec.ts",
9+
workers: 1,
10+
retries: 0,
11+
timeout: 120_000,
12+
outputDir: process.env.TERMINAL_RESULTS,
13+
reporter: [["line"]],
14+
webServer: {
15+
command: `bun x vite preview --host 127.0.0.1 --port ${new URL(process.env.PLAYWRIGHT_BASE_URL!).port} --strictPort --outDir ${process.env.TERMINAL_BUILD}`,
16+
url: process.env.PLAYWRIGHT_BASE_URL,
17+
reuseExistingServer: false,
18+
},
19+
use: { ...config.use, viewport: { width: 1440, height: 900 }, trace: "off", video: "off", serviceWorkers: "block" },
20+
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Terminal } from "ghostty-web"
2+
import { SerializeAddon } from "../../../src/session/terminal/serialize"
3+
4+
export type TerminalProbe = {
5+
term?: Terminal
6+
writes: number
7+
pending: number
8+
bytes: number
9+
renders: number
10+
hiddenRenders: number
11+
draws: number
12+
hiddenDraws: number
13+
serialized: { ms: number; bytes: number; value: string }[]
14+
}
15+
16+
declare global {
17+
interface Window {
18+
terminalProbe: TerminalProbe
19+
}
20+
}
21+
22+
const probe: TerminalProbe = {
23+
writes: 0,
24+
pending: 0,
25+
bytes: 0,
26+
renders: 0,
27+
hiddenRenders: 0,
28+
draws: 0,
29+
hiddenDraws: 0,
30+
serialized: [],
31+
}
32+
window.terminalProbe = probe
33+
const open = Terminal.prototype.open
34+
Terminal.prototype.open = function (element) {
35+
probe.term = this
36+
open.call(this, element)
37+
// Ghostty does not expose render events. This benchmark-only wrapper observes its
38+
// actual renderer; it does not alter scheduling, parsing, or drawing.
39+
const renderer = (this as unknown as { renderer: { render: (...args: unknown[]) => void } }).renderer
40+
const render = renderer.render
41+
let hidden = false
42+
renderer.render = function (...args) {
43+
probe.renders++
44+
hidden = !element.checkVisibility()
45+
if (hidden) probe.hiddenRenders++
46+
return render.apply(this, args)
47+
}
48+
if (new URL(location.href).searchParams.has("terminalDrawProbe")) {
49+
const context = element.querySelector("canvas")!.getContext("2d")!
50+
const draw = context.drawImage
51+
context.drawImage = function (...args: unknown[]) {
52+
probe.draws++
53+
if (hidden) probe.hiddenDraws++
54+
Reflect.apply(draw, this, args)
55+
}
56+
}
57+
}
58+
const write = Terminal.prototype.write
59+
Terminal.prototype.write = function (data, done) {
60+
probe.writes++
61+
probe.pending++
62+
probe.bytes += typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength
63+
return write.call(this, data, () => {
64+
probe.pending--
65+
done?.()
66+
})
67+
}
68+
const serialize = SerializeAddon.prototype.serialize
69+
SerializeAddon.prototype.serialize = function (options) {
70+
const start = performance.now()
71+
const value = serialize.call(this, options)
72+
probe.serialized.push({ ms: performance.now() - start, bytes: new TextEncoder().encode(value).byteLength, value })
73+
return value
74+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
param([Parameter(Mandatory = $true)][string]$Fixture)
2+
$ErrorActionPreference = 'Stop'
3+
[Console]::WriteLine('TERMINAL_FIXTURE_READY')
4+
while ($null -ne ($command = [Console]::ReadLine())) {
5+
if ($command -eq 'exit') { exit 0 }
6+
if ($command -eq 'run') {
7+
foreach ($line in [System.IO.File]::ReadLines($Fixture)) {
8+
[Console]::WriteLine($line)
9+
}
10+
[Console]::WriteLine('TERMINAL_WORKLOAD_DONE')
11+
}
12+
if ($command -eq 'ping') { [Console]::WriteLine('TERMINAL_PROCESS_ALIVE') }
13+
}

0 commit comments

Comments
 (0)