fix(FIX-015): render desync on scroll / window-resize / pane-resize#36
Merged
Conversation
When the user wheel-scrolls, drags the window edge, or drags a pane divider, the rendered cell grid was painting stale cells inside the new viewport geometry — visible to the user as duplicate rows at multiple y-offsets, displaced chart-art in TUIs like htop, or content that required a keystroke to "kick" the screen back into a coherent state. Root cause: a cache coherence gap between libghostty-vt and our Rust mirror. `Terminal::resize` and the scroll_viewport_* functions correctly set `cache.screen_dirty = true`, but `sync_screen` only consulted libghostty's `GHOSTTY_RENDER_STATE_DATA_DIRTY` flag — never the Rust flag. libghostty doesn't always set its own dirty flag for viewport mutations (resize, scroll), so sync_screen would short-circuit and return the previous grid contents inside the new geometry. The bug had three triggers (wheel scroll, window resize, pane divider) all rooted in this one cache-coherence hole, with a coordination-bug aggravation between connect_resize and the 16 ms scrollbar poll. Fix is three coordinated landings entirely client-side, satisfying AD-007 (no daemon involvement), AD-008 (clients own VT semantics), AD-009 (no new polling on the data path), and AD-014 (palette resolution location unchanged): 1. crates/forgetty-vt/src/terminal.rs: sync_screen's early-return guard now honors `cache.screen_dirty` alongside libghostty's render-state dirty flag, so resize / scroll_viewport_delta / scroll_viewport_bottom force a re-extract on the next paint. 2. crates/forgetty-gtk/src/terminal.rs (connect_resize): the resize handler becomes the canonical writer of the scrollbar adjustment under `s.updating_scrollbar = true`, eliminating the race window where the 16 ms poll could read a different (total, offset, len) triple and trigger a divergent viewport scroll via the value-changed path. Scrollbar widget construction moves earlier so the resize handler can capture the adjustment. 3. crates/forgetty-gtk/src/terminal.rs (draw_terminal): top-of-frame re-read of `scrollbar_state()` updates `s.viewport_offset` from libghostty's truth, so the cells we draw and the viewport_offset we use to project absolute selection rows both come from one snapshot. Single FFI struct read, sub-µs. ADs: AD-007 / AD-008 / AD-009 / AD-014 all preserved (verified by audit pass). No new RPC, no new file write, no new parsing, no new FFI surface. crates/forgetty-vt/src/ffi.rs untouched. Perf: cargo bench --bench daemon_hotpath all benches comfortably under SPEC ceilings — pty_to_subscriber_idle 21.86 µs (23× under 500 µs), daemon_cold_start 27.23 ms (7.4× under 200 ms). FIX-015 doesn't touch daemon-side code (libghostty-vt is not linked by the daemon binary per V2-008/AD-007), so bench movement is host noise. Net: 3 files, +41 / -23 / +18 LoC. The forgetty-session/src/manager.rs change is an incidental cargo-fmt reformat (multi-line call → single-line, fits within rustfmt's 100-char limit) that is rustfmt-preferred and semantically identical. Filed retroactively in BACKLOG as FIX-015 (originally drafted as FIX-014 by a parallel session that never wrote the BACKLOG entry; the FIX-014 slot was claimed by the bundled command-palette removal in PR #35).
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
Fixes a render desync bug where wheel scroll, window-edge drag, or pane-divider drag would paint stale cells inside the new viewport geometry. User-visible symptom: duplicated rows at multiple y-offsets, displaced chart-art in TUIs (htop, vim), or content requiring a keystroke to "kick" the screen back into a coherent state.
Net diff: 3 files, +41 / −23 / +18 LoC. All changes are client-side (no daemon involvement, no protocol change). Three AD invariants verified preserved: AD-007 (daemon owns no VT state), AD-008 (clients own terminal semantics), AD-009 (no new polling), AD-014 (palette resolution location unchanged).
Root cause
Cache coherence gap between libghostty-vt and our Rust mirror:
Terminal::resize,scroll_viewport_delta,scroll_viewport_bottom,feed, andresetall correctly setcache.screen_dirty = true.sync_screenonly consulted libghostty'sGHOSTTY_RENDER_STATE_DATA_DIRTYflag, never the Rust mirror flag.sync_screenwould short-circuit on a clean libghostty dirty flag and return the previous grid contents inside the new geometry.Aggravation: a coordination race between
connect_resizeand the 16 ms scrollbar poll let the timer write a divergent(total, offset, len)triple to the scrollbar adjustment, fireconnect_value_changed, and trigger a strayscroll_viewport_deltabased on a stales.viewport_offset— making the post-resize first paint paint the wrong viewport.Fan-out: dragging a
GtkPaneddivider firesconnect_resizeon every pane in the affected subtree, so a single user gesture would corrupt every pane's first paint.All three triggers (wheel / window edge / pane divider) and the cascade are one root bug.
How the fix works
Three coordinated landings, all in client code:
Step 1 —
crates/forgetty-vt/src/terminal.rssync_screen's early-return guard now honorscache.screen_dirtyalongside libghostty'sdirtyflag:5-line change including the explanatory comment. Every existing
cache.screen_dirty = truesetter (resize, scroll_viewport_delta, scroll_viewport_bottom, reset, feed) is now load-bearing.Step 2 —
crates/forgetty-gtk/src/terminal.rs(connect_resize)The resize handler becomes the canonical writer of the scrollbar adjustment under
s.updating_scrollbar = true, eliminating the timer race:Scrollbar widget construction moved earlier in
create_terminalso the resize handler can capture theadj_resizeclone. The 16 ms poll cadence is unchanged (AD-009 documents the poll as a permitted UI-state mechanism).Step 3 —
crates/forgetty-gtk/src/terminal.rs(draw_terminal)Top-of-frame re-read of
scrollbar_state()updatess.viewport_offsetfrom libghostty's truth so the cells we draw and the viewport_offset we use to project absolute selection rows both come from one snapshot:Single FFI struct read per paint frame, sub-µs cost vs ~1.5 ms paint budget.
ADs
None changed status. Verified preserved by audit pass:
crates/forgetty-vt/src/ffi.rsuntouched; no new symbol imported byforgetty-session/forgetty-socket/forgetty-daemon.forgetty-vt+forgetty-gtk.Tests
Automated (all PASS):
cargo check --workspacecargo build --releasecargo clippy --workspace -- -D warningscargo fmt --allcargo test --workspace --exclude forgetty-gtk— 193 unit tests passcargo bench --bench daemon_hotpath(withulimit -n 4096):pty_to_subscriber_idle: 21.86 µs (SPEC ceiling 500 µs → 23× under)pty_to_subscriber_load_10/10: 221 µs (sub-ms)send_input_latency: 2.88 µs (sub-µs effectively)daemon_cold_start/new_then_create_tab: 27.23 ms (SPEC ceiling 200 ms → 7.4× under)Criterion
change%is host-noise across runs because the daemon binary is unaffected by FIX-015 (forgetty-vt is not linked by the daemon per V2-008/AD-007).Audit pass:
cache.screen_dirty(single-thread invariant, all 5 setters + 1 clearer audited), GLib re-entrancy correctness (RefCelltry_borrow_mutis the load-bearing primitive;updating_scrollbarflag is correct defense-in-depth),scrollbar_state()FFI-purity (no alloc, no mutation, no lock).Human (Vick, daily-driver dogfood):
copy_selection(app.rs:5398) is too strict —trailing_spaces < num_cols / 5misses lines wrapped at word boundaries with many trailing spaces. ~10-line heuristic improvement.ADRs / SPEC trail
docs/harness/FIX-015/SPEC.md(31 KB, 5 root-cause findings, 8 binary ACs, 3-step technical approach) — drafted by a parallel session that originally numbered this FIX-014; renumbered after PR fix(FIX-011, FIX-014): drop --temp mode and command palette; rebind Ctrl+Shift+{N,P} #35 claimed FIX-014 for the command-palette removal.docs/harness/FIX-015/BUILDER_NOTES.md.docs/harness/FIX-015/AUDITS.md.Files changed
crates/forgetty-vt/src/terminal.rssync_screenearly-return guard honorscache.screen_dirty. Comment + trace line updated. (+10/-3)crates/forgetty-gtk/src/terminal.rsdraw_terminalre-readsscrollbar_state()at top of every paint frame. (+30/-13)crates/forgetty-session/src/manager.rs