Skip to content

fix(FIX-015): render desync on scroll / window-resize / pane-resize#36

Merged
vikgmdev merged 2 commits into
mainfrom
fix/FIX-015-render-desync-resize-scroll
May 8, 2026
Merged

fix(FIX-015): render desync on scroll / window-resize / pane-resize#36
vikgmdev merged 2 commits into
mainfrom
fix/FIX-015-render-desync-resize-scroll

Conversation

@vikgmdev
Copy link
Copy Markdown
Owner

@vikgmdev vikgmdev commented May 7, 2026

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, and reset all correctly set cache.screen_dirty = true.
  • BUT sync_screen only consulted libghostty's GHOSTTY_RENDER_STATE_DATA_DIRTY flag, never the Rust mirror flag.
  • libghostty does not always set its own dirty flag for viewport mutations (resize, scroll) because the underlying scrollback bytes haven't changed — only the projection window has moved.
  • Result: sync_screen would short-circuit on a clean libghostty dirty flag and return the previous grid contents inside the new geometry.

Aggravation: a coordination race between connect_resize and the 16 ms scrollbar poll let the timer write a divergent (total, offset, len) triple to the scrollbar adjustment, fire connect_value_changed, and trigger a stray scroll_viewport_delta based on a stale s.viewport_offset — making the post-resize first paint paint the wrong viewport.

Fan-out: dragging a GtkPaned divider fires connect_resize on 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.rs

sync_screen's early-return guard now honors cache.screen_dirty alongside libghostty's dirty flag:

// Before
if dirty == GHOSTTY_RENDER_STATE_DIRTY_FALSE && !first_sync {
    return;
}

// After
if dirty == GHOSTTY_RENDER_STATE_DIRTY_FALSE && !first_sync && !cache.screen_dirty {
    return;
}

5-line change including the explanatory comment. Every existing cache.screen_dirty = true setter (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:

let (total, off, len) = s.terminal.scrollbar_state();
s.viewport_offset = off;
s.updating_scrollbar = true;
adj_resize.set_lower(0.0);
adj_resize.set_upper(total as f64);
adj_resize.set_page_size(len as f64);
adj_resize.set_value(off as f64);
s.updating_scrollbar = false;

Scrollbar widget construction moved earlier in create_terminal so the resize handler can capture the adj_resize clone. 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() 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:

let (_sb_total, sb_offset, _sb_len) = s.terminal.scrollbar_state();
s.viewport_offset = sb_offset;
let viewport_offset = sb_offset as usize;

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:

  • AD-007 (daemon owns no VT state) — crates/forgetty-vt/src/ffi.rs untouched; no new symbol imported by forgetty-session / forgetty-socket / forgetty-daemon.
  • AD-008 (clients own terminal semantics) — fix is entirely in forgetty-vt + forgetty-gtk.
  • AD-009 (no polling on PTY → render path) — Step 1 is event-driven; the 16 ms scrollbar poll is unchanged (UI-state poll, not a data-path poll, per V2-005).
  • AD-014 (palette resolution at render time) — palette code untouched.
  • AD-011 (no local-PTY fallback) — none introduced.

Tests

Automated (all PASS):

  • cargo check --workspace

  • cargo build --release

  • cargo clippy --workspace -- -D warnings

  • cargo fmt --all

  • cargo test --workspace --exclude forgetty-gtk — 193 unit tests pass

  • cargo bench --bench daemon_hotpath (with ulimit -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:

  • simplify: 1 inline fix (FIX-014 → FIX-015 in 4 source comments after the renumber); 5 notes-for-QA accepted.
  • audit-rust: 0 BLOCKERs. Verified soundness of cache.screen_dirty (single-thread invariant, all 5 setters + 1 clearer audited), GLib re-entrancy correctness (RefCell try_borrow_mut is the load-bearing primitive; updating_scrollbar flag is correct defense-in-depth), scrollbar_state() FFI-purity (no alloc, no mutation, no lock).
  • audit-security: 0 findings. Attack surface unchanged — no new RPC, no new file write, no new parsing, no new FFI surface.

Human (Vick, daily-driver dogfood):

  • Wheel scroll / window resize / pane resize on production-style content — confirmed visually improved ("perfect, what I tested...").
  • Two follow-up bugs surfaced during testing (filed in BACKLOG, not addressed in this PR):
    • FIX-016: copy of soft-wrapped text inserts hard newline + leading indent because the heuristic in copy_selection (app.rs:5398) is too strict — trailing_spaces < num_cols / 5 misses lines wrapped at word boundaries with many trailing spaces. ~10-line heuristic improvement.
    • P-019: optional "preserve scrollback wrap points across resize" config flag for users who want libghostty's reflow behavior overridden. ~200 LoC feature; schedule when there's appetite.

ADRs / SPEC trail

  • Phase 1 SPEC at 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.
  • Phase 2 BUILDER_NOTES at docs/harness/FIX-015/BUILDER_NOTES.md.
  • Audit results at docs/harness/FIX-015/AUDITS.md.

Files changed

File Change
crates/forgetty-vt/src/terminal.rs Step 1: sync_screen early-return guard honors cache.screen_dirty. Comment + trace line updated. (+10/-3)
crates/forgetty-gtk/src/terminal.rs Steps 2 & 3: resize handler is canonical scrollbar adjustment writer; scrollbar widget construction moved earlier; draw_terminal re-reads scrollbar_state() at top of every paint frame. (+30/-13)
crates/forgetty-session/src/manager.rs Incidental cargo-fmt reformat (multi-line call → single-line). Rustfmt-preferred. (+1/-7)

vikgmdev added 2 commits May 7, 2026 02:18
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).
@vikgmdev vikgmdev merged commit 72ba6b0 into main May 8, 2026
2 of 3 checks passed
@vikgmdev vikgmdev deleted the fix/FIX-015-render-desync-resize-scroll branch May 8, 2026 12: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