fix(client): coalesce output-burst frames into one paint (phux-jhv8)#48
Merged
Conversation
Opening neovim in an attach froze the client for ~0.5s. The cause was not render compute (~4ms total) but byte amplification feeding a synchronous blocking flush: - render_at repaints whole dirty rows (CUP + SGR + every column), so a 1KB nvim delta across a 200-col highlighted screen re-emits full rows (~3x amplification, scaling with window size x highlight richness). - nvim's startup sends a back-to-back burst of frames; the attach loop rendered AND flushed each one separately. On a slow host terminal the blocking flush stalls the whole biased select! loop -> the freeze. Coalesce the burst: FrameReader now buffers the socket and exposes a non-blocking try_recv, so after a recv wake-up the driver drains every queued frame, applies all vt_writes, and paints once. A frame defers its paint iff a LATER frame in the same drain repaints the SAME pane (coalesce_defer_flags), so every touched pane settles exactly once on its last frame -- no pane is left stale, and the hot single-pane case collapses to one paint. server_frame's defer_paint reuses the existing overlay_active "apply vt_write, skip paint" seam. Measured (200x60, nvim on a 2500-line highlighted file, throttled 150KB/s sink = the freeze condition): bytes emitted to host 93,418 -> ~57,900 (-38%); render+flush count 74 -> 25 (-66%). Verified the client pty stream renders byte-identical to baseline's final screen (pyte, 6/6 trials). Savings scale with sink slowness, so a genuinely slow terminal benefits at least this much. Residual (separate lever): coalescing removes the redundant intermediate redraws but not the per-dirty-row amplification; per-column damage-span rendering would attack that. Tests: coalesce_defer_flags (single-pane, interleaved panes, control frames, empty) and decode_buffered framing (back-to-back drain, partial-frame hold, empty). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`[`ServerState::add_pane_to_session`]` referenced a type that does not exist (the method lives on a type in state.rs), so rustdoc's -D warnings promoted it to an error and failed `just ci`'s doc gate. Demote it to a plain code span. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Opening neovim in a
phux attachfroze the client for ~0.5s. This coalesces the back-to-back frame burst nvim's startup produces into a single paint, cutting bytes emitted to the host terminal −38% and render+blocking-flush count −66% under the freeze condition.Root cause (measured, not guessed)
Built a headless repro (
phuxserver + nvim in a sized pty, JSON span capture + emitted-byte accounting). Client render compute is ~4ms total / 0.3ms per frame — not the bottleneck. The freeze is a chain:render_atrepaints whole dirty rows (CUP + SGR + every column), so a 1KB nvim delta across a 200-col syntax-highlighted screen re-emits full rows — ~3× byte amplification, scaling with window size × highlight richness (hence "depends on your terminal").paint → render_at → out.flush()is synchronous in thebiasedselect loop, so on a slow host terminal the blocking flush stalls the whole loop — the freeze (captured a ~2s blocked paint).Fix — per-pane frame coalescing
connection.rs:FrameReadernow buffers the socket (chunked reads, decode complete frames off the front) and exposes a non-blockingtry_recv().driver.rs: after arecvwake-up, drain every already-queued frame, apply allvt_writes, and paint once. A frame defers its paint iff a later frame in the same drain repaints the same pane (coalesce_defer_flags) — so every touched pane settles exactly once on its last frame, no pane left stale, and the hot single-pane case collapses to one paint.server_frame.rs:defer_paintflag reuses the existingoverlay_active"applyvt_write, skip paint" seam.Measurement (200×60, nvim on a 2,500-line highlighted file, throttled 150KB/s sink = the freeze condition)
Savings scale with sink slowness (more frames pile up per stalled flush → bigger coalesce), so a genuinely slow terminal benefits at least this much. On a fast sink it's a no-op (lone frame → old one-frame-one-paint path), so no added latency.
Correctness
Client pty stream rendered through pyte is byte-identical to baseline's final screen (6/6 trials, nvim on a real file). An intermediate "defer to last focused-output frame" variant was multi-pane-correct but lost most of the byte win; the per-pane rule gets both.
Residual (separate lever, not in scope)
Coalescing removes the redundant intermediate redraws (nvim's splash→clear→buffer collapsing to one), not the per-dirty-row amplification itself. Per-column damage-span rendering in
render_at_innerwould attack that.Tests
coalesce_defer_flags: single-pane, interleaved panes, control frames, empty.decode_buffered: back-to-back drain, partial-frame hold, empty.just cigreen.The second commit fixes a pre-existing broken intra-doc link in
runtime.rsthat was already failingjust ci's-D warningsdoc gate (unrelated to the freeze; included so CI is green).🤖 Generated with Claude Code