Skip to content

perf(terminal): replay in line with output, cheaper font steps and output path - #925

Merged
omartelo merged 4 commits into
mainfrom
perf/terminal-output-path
Oct 23, 2026
Merged

omartelo merged 4 commits into
mainfrom
perf/terminal-output-path

Conversation

@omartelo

Copy link
Copy Markdown
Collaborator

Summary

Four fixes from a stress run of the native terminal (floods, nvim, Claude Code and Codex sessions, splits, font and layout changes), one commit each.

  • A reopened terminal no longer types into its session, and no longer loses output (fix(terminal)). The window wrote the replayed tail into a terminal already wired to the PTY, so the terminal answered the queries in it again (nvim's device attributes, cursor reports) and the answers landed on the session's input: command not found in a shell, stray text in an agent's prompt. Output emitted between the replay snapshot and the terminal's registration was dropped. terminal.Replay now queues the tail on /ws as a replay frame ([0][id length][id][tail]), in line with the output frames and with what the coalescer held folded into it; the window registers the terminal first, takes no output until the replay frame and sends no replies while it writes it. The RPC returns whether a replay frame is coming.
  • fontconfig is asked once per face (perf(fonts)). Every terminal renderer built ran four fc-match subprocesses inside the frame. fonts.Locate keeps each answer for the run; the trap this sets (a family installed while lich runs keeps its substitute) is in docs/ceilings.md.
  • A font size step resizes the renderers it has (perf(native)). It built a new renderer per open terminal. Renderer gains SetSize, taken in by the next Measure the way a new display scale is.
  • Session output is copied once on its way to the window (perf(terminal)). A hidden flood allocated about 7.7x its output in the backend. The coalescer keeps its batch buffer, the replay tail is one buffer grown up to its 2 MB cap and then overwritten, and the outbox copies a batch straight into its /ws frame. Start refuses a session id over 255 bytes, which no frame header can carry, before its PTY spawns.

Measured

A/B against 564f74c89 on a rig (own backend, virtual display, interleaved runs):

before after
font size step, 1 / 4 / 8 terminals (p50) 61 / 159 / 290 ms 26 / 28 / 28 ms
first show of a card, to first frame with content (p50) 65 ms 33 ms
replies typed into the session on reopen 3 of 3 reopens 0 of 3
seq 1 2000000, card opened mid-flood 2000 numbers lost, 2 of 2 runs none lost or repeated

Visible flood throughput and fps, hidden flood backend CPU and idle CPU stayed within noise. BenchmarkHiddenFlood (new) for the last commit: 232 KB allocated per 32 KB read, 2 allocs, down to 34 KB and 0 allocs.

Tests changed

  • The respawn_test and sandbox_spawn_linux_test helpers read the tail from the replay buffer: Replay no longer returns it (contract changed).
  • TestReplayBufferKeepsOneChunkLargerThanCap is replaced by TestReplayBufferKeepsTheEndOfAWriteLargerThanCap: a write larger than the cap now keeps its last 2 MB (contract changed; a PTY read is 32 KB).
  • The transport tests build frames with frameFor, since send takes a frame.

Test plan

  • gofmt, go vet ./..., go test ./... at the root and in native/; internal/terminal with -race
  • New TestServiceReplayQueuesTheTailBetweenOutputFrames (fails with the coalescer's batch kept on a cut), TestAttachedTerminalStartsFromTheReplay (fails both with output taken before the replay and with replies sent during it)
  • New TestLocateAsksFontconfigOncePerFont (fails without the cache), TestFontSizeStepResizesTheRenderersItHas (fails on the rebuild path)
  • New TestReplayBufferKeepsTheLastCapBytesAcrossReads, TestStartRefusesAnIDNoFrameCanCarry
  • go test ./api -update: terminal.Replay now returns bool

…ith its output

terminal.Replay queues the session's tail on /ws as a replay frame, behind
the output already emitted and ahead of the rest, and drops what the
coalescer held, which the tail already carries. The window registers the
terminal before asking, takes no output until the replay frame lands, and
sends none of the terminal's replies to queries inside the tail.

Before, the window wrote the tail returned by the RPC into a terminal wired
to the PTY: the answers to queries in it (nvim's device attributes, cursor
reports) were typed into the session, and output emitted between the
snapshot and the terminal's registration was lost.

The RPC now returns whether a replay frame is coming. Tests that read the
tail through Replay read the buffer directly: the contract changed.
Every fc-match is a subprocess, and a terminal asked for its family's four
styles each time its renderer was built: a card's first show and every font
size step, inside the frame. Locate keeps each answer by the font asked
for. A family installed after it was asked for keeps its substitute until
lich restarts, written down in docs/ceilings.md.
A size step built a new renderer for every open terminal, loading the
family's faces again inside the frame. Renderer gains SetSize, which the
next Measure takes in the same way it takes a new display scale; a new
family still builds a renderer.
A hidden session flooding at ~147 MB/s made the backend allocate about
7.7x its output: the coalescer dropped its batch buffer after every flush
and grew a new one, the replay tail allocated a chunk per PTY read, and the
outbox copied each batch only for the transport to copy it again into the
/ws frame.

- The coalescer keeps its batch buffer across flushes; emit already may
  not retain it.
- The replay tail is one buffer grown up to its 2 MB cap and then
  overwritten from its oldest byte.
- The outbox copies a batch straight into its /ws frame behind the
  session's header, and the transport sends that frame as is. Start
  refuses a session id no frame header can carry (over 255 bytes) before
  its PTY spawns, where its output used to fall back to /events, which the
  window does not read.

BenchmarkHiddenFlood (new): 232 KB allocated per 32 KB read, 2 allocs,
down to 34 KB and 0 allocs.

TestReplayBufferKeepsOneChunkLargerThanCap is replaced by
TestReplayBufferKeepsTheEndOfAWriteLargerThanCap: the contract changed, a
write larger than the cap now keeps its last capBytes (a PTY read never is
one). The transport tests build frames with frameFor, since send takes a
frame.
@omartelo
omartelo merged commit 825665c into main Oct 23, 2026
7 checks passed
@omartelo
omartelo deleted the perf/terminal-output-path branch October 23, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant