Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"release:mac": "npm run icons:macos && npm run relayfile-mount:install:release && RELAYFILE_MOUNT_INSTALL_SOURCE=release npm run build && electron-builder --mac --publish always",
"test": "node --experimental-strip-types --no-warnings --test 'src/main/__tests__/*.test.ts'",
"test:stress": "npm run build:web && playwright test --config playwright.stress.config.ts",
"test:redraw": "npm run build:web && playwright test --config playwright.redraw.config.ts",
"personas:refresh": "npx --yes agentworkforce install @agentworkforce/persona-autonomous-actor --overwrite && npx --yes agentworkforce install @agentworkforce/persona-slack-comms --overwrite"
},
"dependencies": {
Expand Down Expand Up @@ -64,6 +65,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"@xterm/headless": "^6.0.0",
"electron": "^42.1.0",
"electron-builder": "^26.8.1",
"electron-vite": "^5.0.0",
Expand Down
25 changes: 25 additions & 0 deletions playwright.redraw.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
testDir: './tests/playwright',
testMatch: /redraw-drain\.spec\.ts/,
// Generous: the burst + drain wall-clock is driven by REDRAW_DURATION_MS /
// REDRAW_DRAIN_TIMEOUT_MS, and the spec sets its own per-test timeout.
timeout: 240_000,
expect: {
timeout: 15_000
},
webServer: {
command: 'npx vite preview --config vite.web.config.ts --host 127.0.0.1 --port 4174',
url: 'http://127.0.0.1:4174',
reuseExistingServer: !process.env.CI,
timeout: 30_000
},
use: {
...devices['Desktop Chrome'],
baseURL: 'http://127.0.0.1:4174',
viewport: { width: 1440, height: 960 },
trace: 'retain-on-failure'
},
reporter: [['list']]
})
108 changes: 108 additions & 0 deletions src/renderer/src/lib/pty-coalesce-equivalence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Real-VT-parser justification for the PTY write-coalescing change
// (perf(terminal): coalesce per-frame PTY chunks into one xterm write).
//
// A runtime writeChunks() optimization may concatenate a frame's chunks and
// issue ONE term.write() instead of one write per chunk. That is only safe if
// xterm's parser is a true streaming state machine — i.e. write(a)+write(b)
// produces a byte-for-byte identical screen to write(a+b), even when a chunk
// boundary falls in the MIDDLE of an escape sequence.
//
// We assert that against the REAL parser via @xterm/headless (no DOM, no
// renderer — just the VT state machine + buffer), using the cursor-addressing
// redraw shape Claude Code / Ink emit (CUP + EL + SGR runs). This is the
// property the production change relies on; if a future xterm bump broke it,
// coalescing would silently corrupt the screen and this test would catch it.

import { describe, expect, it } from 'vitest'
import { Terminal } from '@xterm/headless'

const ROWS = 24
const COLS = 80

function buildRedrawStream(frames: number): string {
let out = ''
for (let f = 1; f <= frames; f += 1) {
out += '\x1b[H' // cursor home
for (let r = 1; r <= ROWS; r += 1) {
// CUP to (r,1), clear line, then an SGR colour run — the in-place
// redraw pattern. Multi-digit row numbers guarantee CSI params that
// can be split mid-sequence below.
out += `\x1b[${r};1H\x1b[2K`
out += `\x1b[36mframe ${f.toString().padStart(3, '0')} row ${r
.toString()
.padStart(2, '0')}\x1b[0m \x1b[2m── pear redraw ──\x1b[0m`
}
}
return out
}

// Split `data` into chunks of fixed size `size`. Small sizes (1, 2, 3) force
// boundaries inside multi-byte escape sequences like `\x1b[12;1H`.
function splitBySize(data: string, size: number): string[] {
const chunks: string[] = []
for (let i = 0; i < data.length; i += size) {
chunks.push(data.slice(i, i + size))
}
return chunks
}

function readScreen(term: Terminal): string {
const buf = term.buffer.active
const lines: string[] = []
for (let i = 0; i < term.rows; i += 1) {
const line = buf.getLine(buf.baseY + i)
// Trim trailing spaces/tabs per line only — never collapse the row
// structure (so row N stays at index N for positional assertions).
lines.push((line ? line.translateToString(true) : '').replace(/[ \t]+$/, ''))
}
return lines.join('\n')
}

function writeSync(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, () => resolve()))
}

// xterm processes queued writes in order; fire them all, then await one drain
// callback (avoids tens of thousands of serialized per-chunk awaits).
async function writeAll(term: Terminal, chunks: string[]): Promise<void> {
for (const chunk of chunks) term.write(chunk)
await new Promise<void>((resolve) => term.write('', () => resolve()))
}

function makeTerm(): Terminal {
return new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true })
}

describe('pty write coalescing — real xterm parser equivalence', () => {
it('renders identically whether a redraw stream is written per-chunk or coalesced', async () => {
const stream = buildRedrawStream(8)

// Baseline: one big coalesced write (the optimization under test).
const coalesced = makeTerm()
await writeSync(coalesced, stream)
const expected = readScreen(coalesced)
expect(expected).toContain('frame 008 row 24') // sanity: it rendered

// For a range of chunk sizes — including sizes that slice through the
// middle of CSI sequences — the per-chunk path must match byte-for-byte.
for (const size of [1, 2, 3, 7, 13, 64, 256]) {
const perChunk = makeTerm()
await writeAll(perChunk, splitBySize(stream, size))
expect(readScreen(perChunk), `chunk size ${size} diverged from coalesced`).toBe(expected)
}
}, 20_000)

it('keeps a CSI sequence split across the boundary equivalent to the whole sequence', async () => {
// The hardest case: a cursor-position sequence cut in half. write("\x1b[12") +
// write(";1Hok") must land "ok" at row 12 col 1 — identical to one write.
const whole = makeTerm()
await writeSync(whole, '\x1b[12;1Hok')

const split = makeTerm()
await writeSync(split, '\x1b[12')
await writeSync(split, ';1Hok')

expect(readScreen(split)).toBe(readScreen(whole))
expect(readScreen(split).split('\n')[11]).toContain('ok')
})
})
29 changes: 29 additions & 0 deletions src/renderer/src/lib/terminal-runtime-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,3 +687,32 @@ function hasVisibleTerminalContent(screen: string): boolean {
)
return /\S/.test(stripped)
}

// --- Measurement probe (redraw-drain harness) ----------------------------
// Read-only accessor that returns the live xterm viewport text for a mounted
// runtime. Reading the parsed grid (term.buffer) — rather than scraping the
// renderer-specific `.xterm-rows` DOM — lets the redraw-drain harness extract
// the latest rendered frame regardless of whether the DOM or WebGL renderer is
// active in the headless Playwright Chrome. It never mutates terminal state.
//
// Gated on the mock-IPC build flag so it is tree-shaken out of the production
// (Electron) bundle: the harness only runs against the web/mock build served
// by vite.web.config.ts.
export function readRuntimeViewportText(projectId: string | undefined, name: string): string | null {
const record = runtimes.get(getAgentKey(projectId, name))
if (!record) return null
const term = record.runtime.term
const buffer = term.buffer.active
const lines: string[] = []
for (let row = 0; row < term.rows; row += 1) {
const line = buffer.getLine(buffer.baseY + row)
lines.push(line ? line.translateToString(true) : '')
}
return lines.join('\n')
}

if (import.meta.env.VITE_PEAR_MOCK_IPC === 'true' && typeof window !== 'undefined') {
;(window as unknown as {
__pearReadTerminalViewport?: (projectId: string | undefined, name: string) => string | null
}).__pearReadTerminalViewport = readRuntimeViewportText
}
1 change: 1 addition & 0 deletions tests/playwright/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
results/
52 changes: 52 additions & 0 deletions tests/playwright/REDRAW-DRAIN-RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Redraw-drain harness — results

Headless measurement of terminal renderer drain under heavy TUI redraw
streaming (the "typing lag" class). Real `@xterm/xterm` + real predictive-echo
in headless Chrome, driven by `window.__pearMock.injectPtyChunk` and read back
through the `__pearReadTerminalViewport` registry probe.

Run: `npm run test:redraw` (builds the web/mock bundle, then Playwright).

## Why burst mode matters

`REDRAW_BURST_FRAMES` (default 16) injects N redraw frames back-to-back with no
yield, so they all land in **one** `pty-buffer` rAF flush — the exact condition
`writeChunks` coalescing acts on. With one chunk per frame (paced mode) the
coalescing change is a no-op and the harness cannot see it; burst mode is what
isolates it.

## Per-chunk vs coalesced (burst: 16 frames/flush × 120 rounds = 1920 frames)

Coalescing measured by toggling `writeChunks` between the per-chunk loop
(main baseline) and the single coalesced write (PR: "coalesce per-frame PTY
chunks into one xterm write").

| Metric | Per-chunk (baseline) | Coalesced | Effect |
|---|---|---|---|
| Max render lag | 1773 frames | 16 frames | bounded to one burst |
| Frames rendered | 833 / 1920 (43%) | 1920 / 1920 (100%) | fully drains |
| Drained within 8s | no (never) | yes (26ms) | — |
| Longest frame | 119.7ms | 53.8ms | ~2× shorter |

Under per-chunk writes the renderer falls ~1773 frames behind and never catches
up (only 43% of injected frames ever render). Coalescing collapses the N
predictive-echo headless-model parses + promise ticks into one per flush; the
grid then keeps up — full drain, lag bounded to the current burst, longest
frame nearly halved.

Numbers are from a single dev-machine run (macOS, headless Chrome) and are
indicative, not thresholds — the suite asserts only that the harness ran
end-to-end. The byte-for-byte correctness of coalescing is proven separately by
the real-parser equivalence unit test
(`src/renderer/src/lib/pty-coalesce-equivalence.test.ts`).

## Knobs

| Env | Default | Meaning |
|---|---|---|
| `REDRAW_BURST_FRAMES` | 16 | frames injected per rAF flush (set 1 for paced mode) |
| `REDRAW_BURST_ROUNDS` | 120 | number of bursts |
| `REDRAW_PANEL_ROWS`/`COLS` | 24 / 80 | redraw panel size |
| `REDRAW_DRAIN_TIMEOUT_MS` | 8000 | how long to wait for the grid to catch up |
| `REDRAW_LABEL` | redraw-baseline | label stamped into the result JSON |
| `REDRAW_RESULT_PATH` | — | append result JSON to this file |
Loading
Loading