From 227aaef984707aa80a88769d6c094c97ade40423 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 03:50:50 +0000 Subject: [PATCH] fix(tui): coalesced multiplexer resize events into one settled render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tmux/screen/zellij send SIGWINCH while the pane is still mid-reflow and fire several events during drag-resize or pane-close animations. Forcing an immediate render on each event raced those mid-reflow paints — the multiplexer overwrote the TUI output and the user saw the viewport flash blank before the next throttled frame. The SIGWINCH callback now debounces inside multiplexer sessions through a 50 ms render-scheduler timer; subsequent events cancel and re-arm it, so a single forced render fires at the final geometry once the pane is quiet. `#resizeEventPending` is still set on every event so the eventual render classifies as a resize, and stop() cancels the pending timer. Fixes #2088 --- packages/tui/CHANGELOG.md | 4 + packages/tui/src/tui.ts | 51 +++++- packages/tui/test/issue-2088-repro.test.ts | 183 +++++++++++++++++++ packages/tui/test/render-regressions.test.ts | 7 +- 4 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 packages/tui/test/issue-2088-repro.test.ts diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b512b951f..d90dcc1cc 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Coalesced terminal-multiplexer SIGWINCH events into a single forced render once the pane stops resizing so closing/dragging a tmux/screen/zellij split no longer flashes the viewport blank before the new geometry repaints ([#2088](https://github.com/can1357/oh-my-pi/issues/2088)). + ## [15.10.2] - 2026-06-08 ### Added diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2af7f90fc..aebc6398d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -482,6 +482,16 @@ export class TUI extends Container { #renderScheduler: RenderScheduler; #lastRenderAt = 0; static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30; + // Pane-reflow settle window for tmux/screen/zellij. The host process gets + // SIGWINCH (and `process.stdout` already reports the new geometry) before + // the multiplexer finishes repainting the pane at the new size, and + // drag-resize/pane-close animations fire several events in flight. A forced + // render on each SIGWINCH races those mid-reflow paints — the multiplexer's + // catch-up paint then partially overwrites the TUI output, which the user + // sees as a viewport flash or blank screen before the next throttled frame + // arrives (issue #2088). Coalescing every SIGWINCH inside this window into + // a single forced render lets the multiplexer settle first. + static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50; #cursorRow = 0; // Logical cursor row (end of rendered content) #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) #hardwareCursorState: HardwareCursorState | null = null; @@ -549,6 +559,9 @@ export class TUI extends Container { // between the viewport and scrollback, so the previous frame no longer // describes the screen. Tracking only the dimension delta misses this. #resizeEventPending = false; + // Active multiplexer SIGWINCH debounce. Reset on each event so the timer + // only fires once the pane stops resizing. + #multiplexerResizeTimer: RenderTimer | undefined; #stopped = false; // Transient alternate-screen state for a fullscreen overlay. While active, the @@ -858,12 +871,35 @@ export class TUI extends Container { this.terminal.start( data => this.#handleInput(data), () => { - // Repaint immediately rather than via the throttled path: a resize must - // clear and replay at the fresh geometry before the terminal's reflow - // settles into a state a throttled frame would race. Forced render skips - // the 30fps coalescing window, matching resetDisplay()'s prompt repaint. + // Real terminals deliver SIGWINCH (and the equivalent ConPTY + // notification) atomically with the new `process.stdout` geometry, so + // a forced render must fire immediately: it clears and replays at the + // fresh size before the terminal's reflow settles into a state a + // throttled frame would race. Multiplexer panes (tmux/screen/zellij) + // do not give that guarantee. The host receives SIGWINCH while the + // multiplexer is still mid-reflow — it has not finished repainting + // the pane buffer at the new size — and a drag-resize or pane-close + // animation fires several events in flight. Forcing a render on each + // event races those mid-reflow paints: the multiplexer's catch-up + // paint then partially overwrites the TUI output, which the user sees + // as a viewport flash or blank screen before the next throttled + // frame arrives (issue #2088). Coalesce SIGWINCHes inside the settle + // window so a single forced render fires once the pane is quiet — + // `#resizeEventPending` is set on every event so the eventual render + // still classifies as a resize. this.#resizeEventPending = true; - this.requestRender(true); + if (!isMultiplexerSession()) { + this.requestRender(true); + return; + } + if (this.#multiplexerResizeTimer) { + this.#multiplexerResizeTimer.cancel(); + } + this.#multiplexerResizeTimer = this.#renderScheduler.scheduleRender(() => { + this.#multiplexerResizeTimer = undefined; + if (this.#stopped) return; + this.requestRender(true); + }, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS); }, ); for (const listener of this.#startListeners) { @@ -1066,6 +1102,10 @@ export class TUI extends Container { this.#renderTimer.cancel(); this.#renderTimer = undefined; } + if (this.#multiplexerResizeTimer) { + this.#multiplexerResizeTimer.cancel(); + this.#multiplexerResizeTimer = undefined; + } // Place the parent shell on the first line after the rendered content. When // that line is still inside the viewport, moving there and writing `\r` is // enough; emitting `\r\n` would create an extra blank row. If the content @@ -1167,7 +1207,6 @@ export class TUI extends Container { this.#renderRequested = true; this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender()); } - #prepareForcedRender(clearScrollback: boolean): void { const geometryChanged = (this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) || diff --git a/packages/tui/test/issue-2088-repro.test.ts b/packages/tui/test/issue-2088-repro.test.ts new file mode 100644 index 000000000..8f52d859e --- /dev/null +++ b/packages/tui/test/issue-2088-repro.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { type Component, TUI } from "@oh-my-pi/pi-tui"; +import { VirtualTerminal } from "./virtual-terminal"; + +// Regression test for https://github.com/can1357/oh-my-pi/issues/2088 +// +// Closing a tmux horizontal split widens the surviving pane. SIGWINCH fires +// on the host process before tmux finishes repainting the pane buffer at +// the new size, and drag-resize/pane-close animations also fire several +// SIGWINCHes in flight. Forcing an immediate render on every event raced +// those mid-reflow paints — tmux's catch-up paint then partially overwrote +// the TUI output, which the user saw as a viewport flash or blank screen +// before the next throttled frame arrived. +// +// Fix: coalesce SIGWINCHes inside a multiplexer settle window so a single +// forced render fires once the pane is quiet. `#resizeEventPending` is set +// on every event so the eventual render still classifies as a resize. + +// Pad the production debounce by 30 ms so the test consistently observes the +// settled render without re-encoding the constant. +const DEBOUNCE_SETTLE_WAIT_MS = 80; + +class MutableLinesComponent implements Component { + #lines: string[]; + + constructor(lines: string[]) { + this.#lines = [...lines]; + } + + invalidate(): void {} + + render(width: number): string[] { + return this.#lines.map(line => line.slice(0, width)); + } +} + +async function withEnvPatch(patch: Record, run: () => T | Promise): Promise { + const saved: Record = {}; + for (const key in patch) { + saved[key] = Bun.env[key]; + const value = patch[key]; + if (value === undefined) { + delete Bun.env[key]; + } else { + Bun.env[key] = value; + } + } + try { + return await run(); + } finally { + for (const key in saved) { + const value = saved[key]; + if (value === undefined) { + delete Bun.env[key]; + } else { + Bun.env[key] = value; + } + } + } +} + +async function settle(term: VirtualTerminal): Promise { + const nextTick = Promise.withResolvers(); + process.nextTick(nextTick.resolve); + await nextTick.promise; + await Bun.sleep(1); + await term.flush(); +} + +function captureWrites(term: VirtualTerminal): string[] { + const writes: string[] = []; + const realWrite = term.write.bind(term); + vi.spyOn(term, "write").mockImplementation((data: string) => { + writes.push(data); + realWrite(data); + }); + return writes; +} + +function visible(term: VirtualTerminal): string[] { + return term.getViewport().map(line => line.trimEnd()); +} + +const TMUX_ENV: Record = { TMUX: "1", STY: undefined, ZELLIJ: undefined }; +const NO_MULTIPLEXER_ENV: Record = { TMUX: undefined, STY: undefined, ZELLIJ: undefined }; + +describe("issue #2088: tmux pane-resize race produces viewport flash", () => { + let monotonicNow = 0; + + beforeEach(() => { + monotonicNow = 0; + vi.spyOn(performance, "now").mockImplementation(() => { + monotonicNow += 40; + return monotonicNow; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("coalesces a burst of multiplexer resize events into a single settled render", async () => { + await withEnvPatch(TMUX_ENV, async () => { + const term = new VirtualTerminal(40, 10, 1000); + const tui = new TUI(term); + tui.addChild(new MutableLinesComponent(Array.from({ length: 20 }, (_v, i) => `line-${i}`))); + + try { + tui.start(); + await settle(term); + + const baselineRedraws = tui.fullRedraws; + const writes = captureWrites(term); + + // Simulate a tmux pane-close animation: several SIGWINCHes arrive + // while tmux is still mid-reflow, each carrying an intermediate + // width. Only the final width should be painted, and only once. + term.resize(60, 10); + term.resize(75, 10); + term.resize(80, 10); + + // Inside the debounce window: no new paint must have landed yet, + // otherwise the TUI would be writing into a pane tmux has not + // finished reflowing. + await Bun.sleep(10); + expect(tui.fullRedraws).toBe(baselineRedraws); + expect(writes.length).toBe(0); + + // After the settle window the single coalesced render fires at the + // final geometry — exactly one paint covering 80×10. + await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS); + await settle(term); + expect(tui.fullRedraws - baselineRedraws).toBe(1); + expect(visible(term)).toEqual(Array.from({ length: 10 }, (_v, i) => `line-${i + 10}`)); + } finally { + tui.stop(); + } + }); + }); + + it("renders immediately on resize outside a multiplexer", async () => { + await withEnvPatch(NO_MULTIPLEXER_ENV, async () => { + const term = new VirtualTerminal(40, 10, 1000); + const tui = new TUI(term); + tui.addChild(new MutableLinesComponent(Array.from({ length: 20 }, (_v, i) => `line-${i}`))); + + try { + tui.start(); + await settle(term); + + const baselineRedraws = tui.fullRedraws; + term.resize(80, 10); + await settle(term); + expect(tui.fullRedraws).toBeGreaterThan(baselineRedraws); + expect(visible(term)).toEqual(Array.from({ length: 10 }, (_v, i) => `line-${i + 10}`)); + } finally { + tui.stop(); + } + }); + }); + + it("cancels a pending multiplexer resize timer on stop()", async () => { + await withEnvPatch(TMUX_ENV, async () => { + const term = new VirtualTerminal(40, 10, 1000); + const tui = new TUI(term); + tui.addChild(new MutableLinesComponent(Array.from({ length: 20 }, (_v, i) => `line-${i}`))); + + tui.start(); + await settle(term); + + const writes = captureWrites(term); + term.resize(80, 10); + tui.stop(); + + // stop() must cancel the pending debounce; no render bytes appear + // after the settle window has elapsed, even though the resize was + // armed only moments ago. + await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS); + const lateRepaintBytes = writes.filter(chunk => chunk.includes("\x1b[H")).length; + expect(lateRepaintBytes).toBe(0); + }); + }); +}); diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 29ff72ace..d5a3aa396 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -1527,11 +1527,14 @@ describe("TUI terminal-state regressions", () => { await settle(term); // SIGWINCH (height shrink) and a streamed token arrive inside the - // same ~33ms frame budget. The TUI's own resize handler schedules a - // non-forced render; the append rides along. + // same multiplexer-resize debounce window. The TUI coalesces every + // SIGWINCH into one settled forced render once the pane stops + // resizing (issue #2088); the streamed append rides along on the + // eventual render at the new geometry. lines.push("line-40 streamed"); component.setLines(lines); term.resize(40, 6); + await Bun.sleep(80); await settle(term); // The visible pane must show the frame tail at the new geometry —