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:
@@ -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
@@ -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) ||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user