Skip to content

Fix Windows TUI frame freeze by always draining staging textures#116

Open
wey-gu wants to merge 4 commits intomainfrom
claude/fix-issue-114-macos-2tw6q
Open

Fix Windows TUI frame freeze by always draining staging textures#116
wey-gu wants to merge 4 commits intomainfrom
claude/fix-issue-114-macos-2tw6q

Conversation

@wey-gu
Copy link
Copy Markdown
Member

@wey-gu wey-gu commented May 3, 2026

Summary

Fixes issue #114 where codex's TUI output on Windows would freeze and stop refreshing unless the user pressed Enter or clicked the terminal. The root cause was a mailbox semantics bug in the D3D11 staging texture ring buffer that prevented older readbacks from being drained when continuous VT output kept needs_draw true on every prepaint cycle.

Changes

  • Always drain the oldest in-flight staging texture slot regardless of needs_draw state. The previous logic only drained when needs_draw was false, which caused the pipeline to stall under continuous output.

  • Return drained frames during submit when available instead of always returning Pending after a fresh submit. When a prior readback completes while we're submitting a new frame, present that drained frame immediately rather than waiting for the next prepaint.

  • Updated comments to explain the continuous-output fallback path and why draining must happen in both needs_draw cases.

Implementation Details

The fix changes the drain logic in Renderer::render:

  1. Removed the (!needs_draw) guard from drain_target computation — now always attempt to drain the oldest in-flight slot via non-blocking try_drain.

  2. When needs_draw is true and a drained readback is available, return RenderOutcome::Rendered(...) with that frame instead of Pending. This ensures forward progress on the displayed image even when the VT is continuously updating.

  3. The just-submitted fresh copy remains in flight and will be picked up by the next prepaint, so pipeline depth is unchanged.

This preserves the original "no slideshow during bursts" intent for short command output (ls, dir, clear) while fixing the indefinite freeze for long-running TUIs (codex spinner, watch, top, btop, streaming output).

The changes are Windows-only and gated by #[cfg(target_os = "windows")].

https://claude.ai/code/session_01T6fGh6NZDctPMvVE2RAwPp


Note

Medium Risk
Changes Windows D3D11 render/present scheduling and staging-ring draining logic; could affect frame cadence or introduce subtle latency/perf regressions, but is OS-scoped and guarded with rate limits.

Overview
Fixes a Windows-only bug where continuously updating TUIs could freeze because the staging-texture ring was only drained when needs_draw became false.

Renderer::render now always attempts to non-blocking drain the oldest in-flight staging slot, and adds a bounded fallback that can present a just-drained prior frame during a fresh submit when prefer_latest is false and a 60Hz MIN_FALLBACK_PRESENT_INTERVAL gate allows it. All Rendered paths stamp last_presented_at to enforce the cap.

Adds a postmortem document (postmortem/2026-05-03-windows-codex-frame-freeze.md) describing the issue, root cause, and rationale for the new behavior.

Reviewed by Cursor Bugbot for commit 5764154. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue on Windows where terminal output would freeze when displaying continuously updating content, requiring user input to refresh.
  • Documentation

    • Added a postmortem documenting the Windows rendering issue and resolution approach.

…TUIs

The Windows D3D11 renderer's mailbox-style staging ring only drained a
completed readback when the VT had quiesced (`needs_draw=false`). With
continuously streaming TUIs (codex, watch, top, btop) every prepaint
sees a fresh VT generation, so the path always submitted and returned
`Pending` without ever mapping the in-flight slot. The on-screen image
froze; clicking flipped `prefer_latest=true`, the `block_drain` branch
fired, and the catch-up frame made the cursor appear to jump several
rows.

Always compute `drain_target` and, when a prior submit's readback is
ready while we're submitting a fresher copy, present it instead of
returning `Pending`. The screen now lags the live VT by at most one
frame instead of freezing indefinitely.

Windows-only — macOS uses embedded libghostty's Metal NSView and
doesn't go through this code.

Fixes #114
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/con-ghostty/src/windows/render/mod.rs`:
- Around line 521-551: The branch that returns RenderOutcome::Rendered from a
prior readback (in the drained-handling block using
self.frame_from_readback(readback)) can satisfy low-latency gating with a stale
frame; modify RenderOutcome to carry a freshness/generation indicator (e.g. add
a boolean like presented_generation_matches_target or an Optional<generation> on
the Rendered variant) and set that field when constructing the outcome in this
block (compare the readback’s generation against the current snapshot or
snapshot.generation). Update callers (notably the logic in host_view.rs that
clears low_latency_generation_target on any non-Pending outcome) to only clear
the target when the RenderOutcome indicates the presented frame actually matches
the target generation. Ensure frame_from_readback or the readback struct exposes
the generation value used for the comparison so the drained branch can set the
freshness bit appropriately.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bdbd50e3-cc6b-4f0e-8190-f616e0fa94ca

📥 Commits

Reviewing files that changed from the base of the PR and between 019b7aa and 7a841ec.

📒 Files selected for processing (2)
  • crates/con-ghostty/src/windows/render/mod.rs
  • postmortem/2026-05-03-windows-codex-frame-freeze.md

Comment thread crates/con-ghostty/src/windows/render/mod.rs
Comment thread crates/con-ghostty/src/windows/render/mod.rs
…under prefer_latest

Address PR #116 review:

- Cursor Bugbot (HIGH): the previous commit also removed the
  `!needs_draw` gate from `discard_oldest_in_flight()`. With 2 slots
  in flight and `needs_draw=true`, that discards the OLDEST slot
  (most likely GPU-ready) and forces the drain onto the NEWER slot
  (less likely ready). On GPUs where copies span >1 prepaint, this
  loops without progress and re-creates the freeze. Restore the
  gate; only `drain_target` itself stays ungated.

- CodeRabbit (MAJOR): the new "drained_during_submit" shortcut
  could return `Rendered` with a frame older than the VT generation
  the user is waiting for. `host_view` clears
  `low_latency_generation_target` on any non-Pending outcome, so a
  paste/key handshake would be satisfied by a stale frame. Skip the
  shortcut when `prefer_latest=true` — that means a user-driven
  block_drain already tried; if it didn't fire, owe the caller
  Pending so target tracking stays correct.

Postmortem updated to document both guards.
@nowledge-co nowledge-co deleted a comment from coderabbitai Bot May 3, 2026
@wey-gu
Copy link
Copy Markdown
Member Author

wey-gu commented May 3, 2026

Bugbot run

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 77a6ccb. Configure here.

…ilbox bursts

Decouple presentation rate from VT update rate so mailbox-for-bursts
and forward-progress-for-streaming fall out of one rule.

The renderer now stamps `last_presented_at` on every `Rendered`
outcome. The continuous-output drained-during-submit shortcut only
fires when at least `MIN_FALLBACK_PRESENT_INTERVAL` (16 ms = one 60 Hz
vsync) has elapsed since the last present. This keeps:

- Short bursts (`ls`, `dir`, `clear`) on the established mailbox
  snap-to-final path (the fallback never fires inside one interval),
  preserving the 2026-04-26 windows-command-render-latency tuning.
- Sustained TUIs (codex, top, watch, btop) at 60 Hz updates with
  bounded image-handoff cost — no faster than GPUI would composite
  anyway, since each present pays a full-frame Map + memcpy +
  bgra_frame_to_image + sprite-atlas upload.
- User-driven echo / paste / click on the unchanged `block_drain`
  fast path; the `!prefer_latest` gate keeps the cap from interfering
  with `host_view`'s low-latency generation-target handshake.

Postmortem updated with the rate-cap design and references to the
prior Windows perf work.
@nowledge-co nowledge-co deleted a comment from coderabbitai Bot May 3, 2026
@wey-gu
Copy link
Copy Markdown
Member Author

wey-gu commented May 3, 2026

Bugbot run

@wey-gu
Copy link
Copy Markdown
Member Author

wey-gu commented May 3, 2026

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5764154. Configure here.

…e machine

After the continuous-output fallback + 60 Hz cap landed, the doc on
`Renderer::render` and the postmortem still described the old two-mode
behavior. Rewrite both so the contract a reader sees matches what the
code does:

- The doc enumerates the four `RenderOutcome` decisions in priority
  order (user-driven block_drain → continuous-output fallback →
  quiet-VT catch-up → pending → idle), names every input the function
  reads, and calls out the mailbox property at the submit level vs.
  the oldest-first preference at the drain side.

- The postmortem drops a stale paragraph that contradicted the new
  rate-cap design, adds a section on how the fallback composes with
  `host_view`'s 750 ms `LOW_LATENCY_BURST_WINDOW` (the two timelines
  partition cleanly with no overlap), and sharpens the "what we
  learned" bullets around the conflated-concern diagnosis.

No behavior change.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 2026

📝 Walkthrough

Walkthrough

The Windows renderer's Renderer::render method is refactored to decouple drain-target selection from the needs_draw flag. The drain target is now always ring.oldest_in_flight(), while discarding oldest in-flight slots remains gated by !needs_draw. A continuous-output fallback returns previously drained readbacks when !prefer_latest and presentation timing allows, backed by wall-clock last_presented_at tracking. A postmortem documents the frame-freeze issue and fix.

Changes

Windows Renderer Presentation & Drain Selection

Layer / File(s) Summary
Data Shape
crates/con-ghostty/src/windows/render/mod.rs
Add last_presented_at: Mutex<Option<Instant>> field to Renderer, import Duration, and define MIN_FALLBACK_PRESENT_INTERVAL constant.
Core Logic
crates/con-ghostty/src/windows/render/mod.rs
Decouple drain-target selection from needs_draw: always set drain_target = ring.oldest_in_flight(). Keep "discard oldest" only under !needs_draw && in_flight_before_submit > 1. Add continuous-output fallback: when needs_draw && !prefer_latest && presentation_due() and drained readback is ready, return RenderOutcome::Rendered(...) immediately.
Integration
crates/con-ghostty/src/windows/render/mod.rs
Call self.mark_presented() on all Rendered exit paths (blocked-drain branch, drained readback fallback, and later drained readback path). Implement helper methods presentation_due() and mark_presented().
Documentation
postmortem/2026-05-03-windows-codex-frame-freeze.md
Document the Windows codex TUI frame-freeze issue, root cause in staging-ring mailbox behavior and needs_draw drain gating, the fix decoupling drain/presentation, and interaction with host_view's 750ms burst window.

Sequence Diagram

sequenceDiagram
    actor Caller
    participant Renderer
    participant Ring as D3D11 Staging Ring
    participant Readback
    participant Clock as Wall-Clock

    Note over Renderer: Before: drain_target gated by needs_draw

    Caller->>Renderer: render(needs_draw=true)
    Renderer->>Ring: oldest_in_flight() [NOW always selected]
    Ring-->>Renderer: drain_target
    Renderer->>Readback: Try drain oldest

    alt Drained & !prefer_latest & presentation_due()
        Renderer->>Clock: mark_presented(now)
        Renderer-->>Caller: RenderOutcome::Rendered [continuous fallback]
        Note over Renderer: New path: return cached drained frame instead of Pending
    else Not due or prefer_latest
        Renderer-->>Caller: RenderOutcome::Pending
    end

    Caller->>Renderer: render(needs_draw=false)
    Renderer->>Ring: oldest_in_flight()
    Ring-->>Renderer: drain_target
    alt in_flight_before > 1
        Renderer->>Ring: Discard oldest [still gated by !needs_draw]
    end
    Renderer->>Readback: Try drain oldest
    alt Drained
        Renderer->>Clock: mark_presented(now)
        Renderer-->>Caller: RenderOutcome::Rendered
    else Not drained
        Renderer-->>Caller: RenderOutcome::Pending
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • PR #60: Also modifies the Windows renderer's staging-ring drain/readback hot path in crates/con-ghostty/src/windows/render/mod.rs, affecting drain-target selection and readback handling.
  • PR #64: Directly modifies the same Renderer::render logic in crates/con-ghostty/src/windows/render/mod.rs, including drain-target/mailbox selection, prefer_latest low-latency handling, and presentation bookkeeping.

Poem

🐰 A frame was stuck, no refresh in sight,
'til drains aligned with timely light!
The ring now flows when needed most,
no more do frozen draws like ghosts!
Wall-clocks tick true, latency's tamed—
continuous output's now reclaimed!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main fix: addressing a Windows TUI frame freeze by always draining staging textures, which is the core architectural change in the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/fix-issue-114-macos-2tw6q

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2a3b7647-0f03-4286-bc45-3fbab3101aaf

📥 Commits

Reviewing files that changed from the base of the PR and between 7a841ec and fee9828.

📒 Files selected for processing (2)
  • crates/con-ghostty/src/windows/render/mod.rs
  • postmortem/2026-05-03-windows-codex-frame-freeze.md

Comment on lines +623 to +646
if !prefer_latest
&& self.presentation_due()
&& let Some(readback) = drained
{
let outcome = RenderOutcome::Rendered(self.frame_from_readback(readback));
self.mark_presented();
log_render_profile(
prof_started,
snapshot,
prefer_latest,
needs_draw,
in_flight_before_submit,
drain_target,
true,
backlog,
submitted,
draw_ms,
submit_ms,
drain_ms,
block_drain_ms,
"rendered",
"drained_during_submit",
);
return Ok(outcome);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve a follow-up prepaint after drained_during_submit.

This branch still leaves the freshly submitted copy in flight, but GhosttyView::render only self-schedules another prepaint on RenderOutcome::Pending (crates/con-ghostty/src/windows/host_view.rs:228-268). If VT output stops right after this fallback fires, we publish the older drained frame here and never get the retry needed to drain/present the just-submitted frame, so the terminal can stay one frame stale until some unrelated wake arrives.

Please carry a “more work still in flight” signal in RenderOutcome (or add a dedicated variant) so the caller can still cx.notify() after this path.

Possible shape
 pub enum RenderOutcome {
     Unchanged,
-    Rendered(FrameBgra),
+    Rendered {
+        frame: FrameBgra,
+        needs_followup_prepaint: bool,
+    },
     Pending,
 }

Then set needs_followup_prepaint: true for drained_during_submit, and have host_view schedule another prepaint when that flag is set.

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.

2 participants