Skip to content

fix(client): coalesce output-burst frames into one paint (phux-jhv8)#48

Merged
phall1 merged 2 commits into
mainfrom
fix/jhv8-output-coalesce
Jun 3, 2026
Merged

fix(client): coalesce output-burst frames into one paint (phux-jhv8)#48
phall1 merged 2 commits into
mainfrom
fix/jhv8-output-coalesce

Conversation

@phall1
Copy link
Copy Markdown
Owner

@phall1 phall1 commented Jun 3, 2026

Summary

Opening neovim in a phux attach froze 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 (phux server + 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_at repaints 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").
  • nvim's startup sends a back-to-back burst (61 of 72 inter-frame gaps <2ms); the attach loop rendered and flushed each frame separately. paint → render_at → out.flush() is synchronous in the biased select 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: FrameReader now buffers the socket (chunked reads, decode complete frames off the front) and exposes a non-blocking try_recv().
  • driver.rs: after a recv wake-up, drain every already-queued frame, apply all vt_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_paint flag reuses the existing overlay_active "apply vt_write, skip paint" seam.

Measurement (200×60, nvim on a 2,500-line highlighted file, throttled 150KB/s sink = the freeze condition)

Metric Baseline Fixed Δ
Bytes emitted to host 93,418 ~57,900 −38%
Render + blocking flushes 74 25 −66%

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_inner would attack that.

Tests

  • coalesce_defer_flags: single-pane, interleaved panes, control frames, empty.
  • decode_buffered: back-to-back drain, partial-frame hold, empty.
  • Full just ci green.

The second commit fixes a pre-existing broken intra-doc link in runtime.rs that was already failing just ci's -D warnings doc gate (unrelated to the freeze; included so CI is green).

🤖 Generated with Claude Code

phall1 and others added 2 commits June 3, 2026 16:09
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>
@phall1 phall1 merged commit 624c305 into main Jun 3, 2026
5 of 6 checks passed
@phall1 phall1 deleted the fix/jhv8-output-coalesce branch June 3, 2026 22:25
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