Merge remote-tracking branch 'origin/farm/b7f4bc6d/fix-tmux-resize-viewport-flash'

This commit is contained in:
can1357
2026-06-08 06:20:09 +02:00
4 changed files with 418 additions and 7 deletions
+4
View File
@@ -5,6 +5,10 @@
- Fixed DEC 2048 in-band resize reports (`CSI 48;rows;cols;hpx;wpx t`) leaking into the focused editor as literal text during a rapid resize. When the window is resized quickly the event loop stays busy long enough for the `StdinBuffer` flush timeout to fire mid-report; the `\x1b[48;…` prefix was emitted as one event and the tail (e.g. `8;125;1156;1125t`) arrived as bare printable characters that the editor inserted. `ProcessTerminal` now reassembles a split in-band report (including a split at the bare `\x1b[4` type field) until its terminator and then drives the resize. A reassembled sequence that turns out not to be a resize report — such as a split kitty key like `\x1b[48;5u` (codepoint 48 = `0`) — is forwarded to the input handler as a single escape sequence rather than dropped or leaked.
### 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
+104 -5
View File
@@ -484,6 +484,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;
@@ -551,6 +561,13 @@ 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. Forced renders (resetDisplay,
// finishSixelProbe, …) issued during the settle window route through the
// same timer; their `clearScrollback` intent is OR'd into the deferred
// flag below so the settled paint still honours every caller's request.
#multiplexerResizeTimer: RenderTimer | undefined;
#deferredForcedClearScrollback = false;
#stopped = false;
// Transient alternate-screen state for a fullscreen overlay. While active, the
@@ -860,12 +877,29 @@ 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). `#armMultiplexerResizeTimer` coalesces
// SIGWINCHes (and any forced repaints arriving during the settle
// window) into a single render once the pane is quiet —
// `#resizeEventPending` is set first so the eventual render still
// classifies as a resize.
this.#resizeEventPending = true;
this.requestRender(true);
if (!isMultiplexerSession()) {
this.requestRender(true);
return;
}
this.#armMultiplexerResizeTimer(false);
},
);
for (const listener of this.#startListeners) {
@@ -1068,6 +1102,11 @@ export class TUI extends Container {
this.#renderTimer.cancel();
this.#renderTimer = undefined;
}
if (this.#multiplexerResizeTimer) {
this.#multiplexerResizeTimer.cancel();
this.#multiplexerResizeTimer = undefined;
}
this.#deferredForcedClearScrollback = false;
// 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
@@ -1142,6 +1181,15 @@ export class TUI extends Container {
resetDisplay(): void {
if (this.#stopped) return;
this.invalidate();
// A reset that lands inside a tmux/screen/zellij resize burst would
// paint mid-reflow and re-introduce the flash race (issue #2088).
// Fold it into the in-flight debounce instead; the settled paint runs
// the same `#prepareForcedRender(!isMultiplexerSession())` path via
// `requestRender(true)`, so the clear-scrollback intent is preserved.
if (this.#multiplexerResizeTimer) {
this.#armMultiplexerResizeTimer(!isMultiplexerSession());
return;
}
this.#prepareForcedRender(!isMultiplexerSession());
this.#resizeEventPending = true;
this.#renderRequested = false;
@@ -1153,6 +1201,19 @@ export class TUI extends Container {
const allowUnknownViewportMutation = options?.allowUnknownViewportMutation === true;
this.#allowUnknownViewportMutationOnNextRender ||= allowUnknownViewportMutation;
if (force) {
// Forced repaints landing inside the multiplexer resize debounce
// (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
// `requestRender(true)`) would paint into a still-reflowing pane
// and reintroduce the flash race. Fold them into the in-flight
// debounce while preserving the caller's `clearScrollback` intent
// for the settled paint. The timer's own callback clears
// `#multiplexerResizeTimer` before re-entering `requestRender(true)`,
// so this guard only catches external callers — the deferred render
// itself proceeds straight to `#prepareForcedRender`.
if (this.#multiplexerResizeTimer) {
this.#armMultiplexerResizeTimer(options?.clearScrollback === true);
return;
}
this.#prepareForcedRender(options?.clearScrollback === true);
this.#renderRequested = true;
this.#renderScheduler.scheduleImmediate(() => {
@@ -1170,6 +1231,37 @@ export class TUI extends Container {
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
}
/**
* Arm or extend the multiplexer-resize debounce so a single forced render
* fires once the pane is quiet. Called by the SIGWINCH callback on every
* resize event, and by `requestRender(true)` / `resetDisplay()` when they
* land inside an in-flight settle window. Each call cancels the prior
* timer, supersedes any queued throttled render (otherwise it would race
* tmux's mid-reflow paint), and OR's the caller's `clearScrollback`
* intent into `#deferredForcedClearScrollback` — the timer's callback
* consumes that flag exactly once when it re-enters `requestRender(true)`.
*/
#armMultiplexerResizeTimer(clearScrollback: boolean): void {
this.#deferredForcedClearScrollback ||= clearScrollback;
if (this.#renderTimer) {
this.#renderTimer.cancel();
this.#renderTimer = undefined;
}
this.#renderRequested = false;
if (this.#multiplexerResizeTimer) {
this.#multiplexerResizeTimer.cancel();
}
this.#multiplexerResizeTimer = this.#renderScheduler.scheduleRender(() => {
this.#multiplexerResizeTimer = undefined;
if (this.#stopped) {
this.#deferredForcedClearScrollback = false;
return;
}
const deferredClearScrollback = this.#deferredForcedClearScrollback;
this.#deferredForcedClearScrollback = false;
this.requestRender(true, { clearScrollback: deferredClearScrollback });
}, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
}
#prepareForcedRender(clearScrollback: boolean): void {
const geometryChanged =
(this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
@@ -1193,6 +1285,13 @@ export class TUI extends Container {
if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
return;
}
// Defer any new throttled render scheduled inside the multiplexer
// resize settle window: it would race tmux's mid-reflow pane repaint.
// `#renderRequested` stays set so the eventual forced render — armed
// by the SIGWINCH callback — picks up the latest component state.
if (this.#multiplexerResizeTimer) {
return;
}
const elapsed = this.#renderScheduler.now() - this.#lastRenderAt;
const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
+305
View File
@@ -0,0 +1,305 @@
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];
}
setLines(lines: string[]): void {
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);
});
});
it("supersedes a throttled render queued just before a multiplexer SIGWINCH", async () => {
await withEnvPatch(TMUX_ENV, async () => {
const term = new VirtualTerminal(40, 10, 1000);
const tui = new TUI(term);
const lines = Array.from({ length: 20 }, (_v, i) => `line-${i}`);
const component = new MutableLinesComponent(lines);
tui.addChild(component);
try {
tui.start();
await settle(term);
const baselineRedraws = tui.fullRedraws;
const writes = captureWrites(term);
// A streamed token lands in the same 30fps frame as the SIGWINCH:
// `requestRender(false)` arms `#renderTimer`, then `term.resize`
// fires the SIGWINCH that arms the multiplexer debounce. If the
// queued throttled render were left active it would fire inside
// the 50 ms settle window and paint mid-reflow.
lines[19] = "line-19 streamed";
component.setLines(lines);
tui.requestRender();
term.resize(80, 10);
// During the debounce window: no paint must land. The queued
// throttled timer was canceled and any follow-on
// `requestRender(false)` is held off until the multiplexer
// settles.
await Bun.sleep(10);
expect(tui.fullRedraws).toBe(baselineRedraws);
expect(writes.length).toBe(0);
// After the settle window: exactly one forced render lands, at
// the new geometry, with the streamed token visible.
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
await settle(term);
expect(tui.fullRedraws - baselineRedraws).toBe(1);
expect(visible(term).at(-1)).toBe("line-19 streamed");
} finally {
tui.stop();
}
});
});
it("defers a forced repaint that lands inside the multiplexer settle window", 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);
// A SIGWINCH starts the debounce. Then a `requestRender(true)`
// (e.g. from finishSixelProbe or an image-budget eviction)
// arrives mid-window. Without deferral it would paint
// immediately into a still-reflowing pane.
term.resize(80, 10);
await Bun.sleep(10);
tui.requestRender(true);
// Inside the window: still no paint. The forced render was
// folded into the in-flight debounce.
await Bun.sleep(20);
expect(tui.fullRedraws).toBe(baselineRedraws);
expect(writes.length).toBe(0);
// After the window: exactly one settled paint at the final
// geometry.
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("defers resetDisplay() that lands inside the multiplexer settle window", 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);
term.resize(80, 10);
await Bun.sleep(10);
tui.resetDisplay();
// resetDisplay normally repaints synchronously; here it must
// route through the multiplexer debounce so no paint lands
// while tmux is still reflowing.
await Bun.sleep(20);
expect(tui.fullRedraws).toBe(baselineRedraws);
expect(writes.length).toBe(0);
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();
}
});
});
});
+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 —