mirror of
https://github.com/anomalyco/opentui.git
synced 2026-04-24 20:45:56 +03:00
[GH-ISSUE #789] Render loop silently stalls when rendering flag blocks requestRender during event burst #985
Labels
No labels
bug
core
documentation
feature
good first issue
help wanted
pull-request
question
react
solid
tmux
windows
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
starred/opentui#985
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @sjawhar on GitHub (Mar 7, 2026).
Original GitHub issue: https://github.com/anomalyco/opentui/issues/789
Summary
The render loop can silently stop producing frames while the renderer reports healthy state. This manifests as a completely blank screen in tmux with the process still alive and responding to keyboard input (modals render correctly on top of the blank).
Root Cause
In
renderer.ts,requestRender()has an early-return whenthis.renderingis true:And in
loop(), the render scheduling depends onimmediateRerenderRequested:The failure sequence:
requestRender()callsthis.rendering = true), all subsequent calls hitSKIP: rendering, queuedand only setimmediateRerenderRequested = trueimmediateRerenderRequested = trueat line 1989, schedules one more frame, and sets it tofalseat line 1992_isRunning = falseandimmediateRerenderRequested = falsethis.renderingis set tofalsein thefinallyblock, but no new frame is ever triggeredEvidence
Diagnostic logging in
requestRender()captured this during a blank screen event:The renderer reports
kitty=true mouse=true ctrl=idle— everything looks healthy, but zero frames are being produced.straceon the process confirmed zerowrite()syscalls to the terminal fd while the Zig render thread was idle.Reproduction
renderer.requestRender()from a heartbeat timer producesSCHEDULED delay=0but the resulting frame has no diff (both buffers identical/blank)Environment
useThread: true(default on Linux)Suggested Fix
In
loop(), after thefinallyblock setsthis.rendering = false, check if any render requests were queued while rendering was in progress. IfimmediateRerenderRequestedwas set during the blocked period, schedule another frame:Alternatively, the
requestRender()early-return forthis.renderingcould set a second flag that's checked in the finally block, ensuring no queued requests are lost.@sjawhar commented on GitHub (Mar 7, 2026):
Verified locally with tests. The primary stall is the
activateFrame()scheduling gap, not a missing finally-block requeue.Corrected diagnosis:
activateFrame()was leavingupdateScheduled=trueuntil afterawait this.loop()resolved.rendering=falsebutupdateScheduled=true, so arequestRender()arriving in that gap was dropped as "already scheduled".activateFrame()then clearedupdateScheduled, the dropped request was gone and the loop could go idle permanently.The fix that held up under regression tests was:
updateScheduledbefore awaitingloop()inactivateFrame()._rerenderAfterFrame.requestRender()calls during the frame-callback phase, not for the entire render pass. A blanketif (this.rendering) returnregressed async/layout follow-up renders (scrollbox.testcaught this).Validated locally with:
bun test src/tests/renderer.render-loop-stall.test.tsbun test src/tests/renderer.idle.test.tsbun test src/tests/scrollbox.test.tsbun test src/tests/If useful I can turn this into a PR, but the key correction is that the lost render happens in the
updateScheduledmicrotask gap, while render-body follow-up frames still need to be coalesced rather than fully suppressed.