fix(tui): coalesced multiplexer resize events into one settled render

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
This commit is contained in:
roboomp
2026-06-08 03:50:50 +00:00
parent ea14cee2bc
commit 227aaef984
4 changed files with 237 additions and 8 deletions
+4
View File
@@ -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
+45 -6
View File
@@ -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) ||
+183
View File
@@ -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<T>(patch: Record<string, string | undefined>, run: () => T | Promise<T>): Promise<T> {
const saved: Record<string, string | undefined> = {};
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<void> {
const nextTick = Promise.withResolvers<void>();
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<string, string | undefined> = { TMUX: "1", STY: undefined, ZELLIJ: undefined };
const NO_MULTIPLEXER_ENV: Record<string, string | undefined> = { 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);
});
});
});
+5 -2
View File
@@ -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 —