[GH-ISSUE #789] Render loop silently stalls when rendering flag blocks requestRender during event burst #985

Open
opened 2026-03-14 09:12:03 +03:00 by kerem · 1 comment
Owner

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 when this.rendering is true:

if (this.rendering) {
  this.immediateRerenderRequested = true;
  return;
}

And in loop(), the render scheduling depends on immediateRerenderRequested:

if (this._isRunning || this.immediateRerenderRequested) {
  // schedule next loop()
} else {
  // NO next loop scheduled — render loop dies
}

The failure sequence:

  1. A burst of events triggers many requestRender() calls
  2. While one frame is rendering (this.rendering = true), all subsequent calls hit SKIP: rendering, queued and only set immediateRerenderRequested = true
  3. The in-flight frame reads immediateRerenderRequested = true at line 1989, schedules one more frame, and sets it to false at line 1992
  4. That final frame completes with _isRunning = false and immediateRerenderRequested = false
  5. No next loop is scheduled — the render loop dies silently
  6. this.rendering is set to false in the finally block, but no new frame is ever triggered
  7. The screen stays blank because no new render is produced

Evidence

Diagnostic logging in requestRender() captured this during a blank screen event:

04:07:45 requestRender SKIP: rendering, queued  (×23 in <1ms)
04:07:49 heartbeat: rendering=false scheduled=false running=false  ← loop stopped
04:07:54 heartbeat: rendering=false scheduled=false running=false  ← still stopped

The renderer reports kitty=true mouse=true ctrl=idle — everything looks healthy, but zero frames are being produced. strace on the process confirmed zero write() syscalls to the terminal fd while the Zig render thread was idle.

Reproduction

  • Run opencode in tmux with an active session receiving streaming events
  • The blank occurs intermittently, especially with high event throughput
  • Ctrl+R (opens modal) renders correctly on top of the blank — suggesting the renderer CAN draw, just isn't triggering draws
  • renderer.requestRender() from a heartbeat timer produces SCHEDULED delay=0 but the resulting frame has no diff (both buffers identical/blank)

Environment

  • tmux 3.6a
  • Linux x86_64
  • opentui 0.1.86
  • useThread: true (default on Linux)

Suggested Fix

In loop(), after the finally block sets this.rendering = false, check if any render requests were queued while rendering was in progress. If immediateRerenderRequested was set during the blocked period, schedule another frame:

} finally {
  this.rendering = false
  if (this.immediateRerenderRequested) {
    this.immediateRerenderRequested = false
    this.requestRender()
  }
  if (this._destroyPending) {
    this.finalizeDestroy()
  }
  this.resolveIdleIfNeeded()
}

Alternatively, the requestRender() early-return for this.rendering could set a second flag that's checked in the finally block, ensuring no queued requests are lost.

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 when `this.rendering` is true: ```ts if (this.rendering) { this.immediateRerenderRequested = true; return; } ``` And in `loop()`, the render scheduling depends on `immediateRerenderRequested`: ```ts if (this._isRunning || this.immediateRerenderRequested) { // schedule next loop() } else { // NO next loop scheduled — render loop dies } ``` **The failure sequence:** 1. A burst of events triggers many `requestRender()` calls 2. While one frame is rendering (`this.rendering = true`), all subsequent calls hit `SKIP: rendering, queued` and only set `immediateRerenderRequested = true` 3. The in-flight frame reads `immediateRerenderRequested = true` at line 1989, schedules one more frame, and sets it to `false` at line 1992 4. That final frame completes with `_isRunning = false` and `immediateRerenderRequested = false` 5. No next loop is scheduled — the render loop dies silently 6. `this.rendering` is set to `false` in the `finally` block, but no new frame is ever triggered 7. The screen stays blank because no new render is produced ## Evidence Diagnostic logging in `requestRender()` captured this during a blank screen event: ``` 04:07:45 requestRender SKIP: rendering, queued (×23 in <1ms) 04:07:49 heartbeat: rendering=false scheduled=false running=false ← loop stopped 04:07:54 heartbeat: rendering=false scheduled=false running=false ← still stopped ``` The renderer reports `kitty=true mouse=true ctrl=idle` — everything looks healthy, but zero frames are being produced. `strace` on the process confirmed zero `write()` syscalls to the terminal fd while the Zig render thread was idle. ## Reproduction - Run opencode in tmux with an active session receiving streaming events - The blank occurs intermittently, especially with high event throughput - Ctrl+R (opens modal) renders correctly on top of the blank — suggesting the renderer CAN draw, just isn't triggering draws - `renderer.requestRender()` from a heartbeat timer produces `SCHEDULED delay=0` but the resulting frame has no diff (both buffers identical/blank) ## Environment - tmux 3.6a - Linux x86_64 - opentui 0.1.86 - `useThread: true` (default on Linux) ## Suggested Fix In `loop()`, after the `finally` block sets `this.rendering = false`, check if any render requests were queued while rendering was in progress. If `immediateRerenderRequested` was set during the blocked period, schedule another frame: ```ts } finally { this.rendering = false if (this.immediateRerenderRequested) { this.immediateRerenderRequested = false this.requestRender() } if (this._destroyPending) { this.finalizeDestroy() } this.resolveIdleIfNeeded() } ``` Alternatively, the `requestRender()` early-return for `this.rendering` could set a second flag that's checked in the finally block, ensuring no queued requests are lost.
Author
Owner

@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 leaving updateScheduled=true until after await this.loop() resolved.
  • That created a window where rendering=false but updateScheduled=true, so a requestRender() arriving in that gap was dropped as "already scheduled".
  • When activateFrame() then cleared updateScheduled, the dropped request was gone and the loop could go idle permanently.

The fix that held up under regression tests was:

  1. Clear updateScheduled before awaiting loop() in activateFrame().
  2. Remove _rerenderAfterFrame.
  3. Only absorb requestRender() calls during the frame-callback phase, not for the entire render pass. A blanket if (this.rendering) return regressed async/layout follow-up renders (scrollbox.test caught this).

Validated locally with:

  • bun test src/tests/renderer.render-loop-stall.test.ts
  • bun test src/tests/renderer.idle.test.ts
  • bun test src/tests/scrollbox.test.ts
  • bun test src/tests/

If useful I can turn this into a PR, but the key correction is that the lost render happens in the updateScheduled microtask gap, while render-body follow-up frames still need to be coalesced rather than fully suppressed.

<!-- gh-comment-id:4015558041 --> @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 leaving `updateScheduled=true` until *after* `await this.loop()` resolved. - That created a window where `rendering=false` but `updateScheduled=true`, so a `requestRender()` arriving in that gap was dropped as "already scheduled". - When `activateFrame()` then cleared `updateScheduled`, the dropped request was gone and the loop could go idle permanently. The fix that held up under regression tests was: 1. Clear `updateScheduled` before awaiting `loop()` in `activateFrame()`. 2. Remove `_rerenderAfterFrame`. 3. Only absorb `requestRender()` calls during the frame-callback phase, not for the entire render pass. A blanket `if (this.rendering) return` regressed async/layout follow-up renders (`scrollbox.test` caught this). Validated locally with: - `bun test src/tests/renderer.render-loop-stall.test.ts` - `bun test src/tests/renderer.idle.test.ts` - `bun test src/tests/scrollbox.test.ts` - `bun test src/tests/` If useful I can turn this into a PR, but the key correction is that the lost render happens in the `updateScheduled` microtask gap, while render-body follow-up frames still need to be coalesced rather than fully suppressed.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/opentui#985
No description provided.