Merge PR #7801: fix(tui): avoid leaking DA1 through tmux appearance refresh (@anatoli-tsinovoy)

This commit is contained in:
can1357
2026-08-07 13:37:56 +02:00
3 changed files with 70 additions and 16 deletions
+3
View File
@@ -13,6 +13,9 @@
### Fixed
- Fixed Herdr panes losing native scrollback when TUI transcript replacement or resize redraws emitted destructive terminal-history clears.
### Fixed
- Fixed explicit display resets inside tmux retaining the stale attach-time light/dark palette and leaking terminal capability bytes into the editor. OMP now lets the passthrough OSC 11 probe update tmux's background cache without sending a passthrough DA1 sentinel, then reads that refreshed cache directly.
## [17.2.9] - 2026-08-05
+32 -15
View File
@@ -459,12 +459,13 @@ export interface Terminal {
callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void,
): (() => void) | void;
/**
* Issue a single OSC 11 background-color re-query, driving the appearance
* Start a bounded OSC 11 background-color refresh cycle, driving appearance
* callbacks through the same parse/dedup pipeline used at startup and on Mode
* 2031 notifications. Bounded: one probe per call, no timers. Invoked on the
* user's explicit display-reset gesture so terminals that cannot
* deliver end-to-end Mode 2031 notifications still pick up a light/dark switch
* without a restart.
* 2031 notifications. Direct terminals need one query; tmux needs a
* passthrough query to update its cache followed by one delayed direct cache
* read. Invoked on the user's explicit display-reset gesture so terminals
* without end-to-end Mode 2031 notifications pick up a light/dark switch
* without a restart. No periodic probes are armed.
*
* A caller-provided token must be propagated unchanged to callbacks and
* returned when the request is accepted. This lets callers establish ownership
@@ -517,6 +518,7 @@ function parseOsc99KeyValues(section: string): Map<string, string> {
}
const XTERM_SCROLL_TO_BOTTOM_MODES = [1010, 1011] as const;
type Osc11QueryRoute = "direct" | "tmux";
const TMUX_OSC11_CACHE_REFRESH_DELAY_MS = 100;
function isXtermScrollToBottomMode(mode: number): boolean {
return mode === 1010 || mode === 1011;
@@ -596,6 +598,7 @@ export class ProcessTerminal implements Terminal {
#osc11QueuedQuery?: { route: Osc11QueryRoute; token?: TerminalAppearanceRequestToken };
#nextAppearanceRequestToken = 1;
#osc11ResponseBuffer = "";
#osc11TmuxRefreshTimer?: Timer;
#osc99PendingId: string | undefined;
#osc99ResponseBuffer = "";
#osc99Capabilities = new Map<string, string>();
@@ -671,13 +674,14 @@ export class ProcessTerminal implements Terminal {
}
/**
* Re-query the terminal background via a single OSC 11 probe. Reuses the
* startup DA1-sentinel FIFO, pending/queued gating, parsing, dedup, and
* appearance callbacks. Inside tmux, only this explicit path wraps the query
* and sentinel together for passthrough to the outer terminal; startup and
* Mode 2031 probes remain direct. Bounded to one probe per call; no timers are
* armed. Suppressed while inactive, headless, or after the terminal is torn
* down.
* Re-query the terminal background through the startup DA1-sentinel FIFO,
* pending/queued gating, parsing, dedup, and appearance callbacks. Inside
* tmux, only this explicit path first passes an OSC 11 query to the outer
* terminal, waits briefly for tmux to consume the response into its cache,
* then reads that cache with a direct query. The outer query deliberately has
* no DA1 sentinel: multiplexers can decode a fragmented DA1 response as a key
* sequence and leak the remaining bytes into the editor. Startup and Mode 2031
* probes remain direct. Suppressed while inactive, headless, or after teardown.
*/
refreshAppearance(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void {
if (!this.#active || this.#headless || this.#dead) return;
@@ -1041,7 +1045,7 @@ export class ProcessTerminal implements Terminal {
switch (owner.kind) {
case "osc11": {
if (this.#osc11Pending) {
// DA1 arrived before the OSC 11 reply: terminal does not support OSC 11.
// DA1 arrived before OSC 11 response: terminal doesn't support OSC 11.
this.#osc11Pending = false;
this.#osc11ActiveToken = undefined;
this.#osc11ResponseBuffer = "";
@@ -1221,11 +1225,20 @@ export class ProcessTerminal implements Terminal {
this.#osc11Pending = true;
this.#osc11ActiveToken = token;
this.#osc11ResponseBuffer = "";
this.#da1SentinelOwners.push({ kind: "osc11" });
if (route === "tmux") {
this.#safeWrite(wrapTmuxPassthrough("\x1b]11;?\x07\x1b[c"));
this.#safeWrite(wrapTmuxPassthrough("\x1b]11;?\x07"));
this.#osc11TmuxRefreshTimer = setTimeout(() => {
this.#osc11TmuxRefreshTimer = undefined;
if (this.#dead || !this.#osc11Pending) return;
this.#startDirectOsc11Query();
}, TMUX_OSC11_CACHE_REFRESH_DELAY_MS);
return;
}
this.#startDirectOsc11Query();
}
#startDirectOsc11Query(): void {
this.#da1SentinelOwners.push({ kind: "osc11" });
this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated)
this.#safeWrite("\x1b[c"); // DA1 sentinel
}
@@ -1562,6 +1575,10 @@ export class ProcessTerminal implements Terminal {
clearTimeout(this.#mode2031DebounceTimer);
this.#mode2031DebounceTimer = undefined;
}
if (this.#osc11TmuxRefreshTimer) {
clearTimeout(this.#osc11TmuxRefreshTimer);
this.#osc11TmuxRefreshTimer = undefined;
}
this.#appearanceCallbacks = [];
this.#appearanceReportCallbacks = [];
this.#osc11Pending = false;
+35 -1
View File
@@ -47,6 +47,7 @@ describe("ProcessTerminal OSC 11 appearance detection", () => {
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdin, "setRawMode", { value: vi.fn(), configurable: true });
previousHeadless = setTerminalHeadless(false);
delete Bun.env.TMUX;
});
afterEach(() => {
@@ -371,11 +372,44 @@ describe("ProcessTerminal OSC 11 appearance detection", () => {
for (let i = 0; i < 7; i++) process.stdin.emit("data", "\x1b[?1;2c");
terminal.refreshAppearance?.();
expect(writes).toContain("\x1bPtmux;\x1b\x1b]11;?\x07\x1b\x1b[c\x1b\\");
expect(writes).toContain("\x1bPtmux;\x1b\x1b]11;?\x07\x1b\\");
terminal.stop();
});
it("reads tmux's refreshed cache without passing a DA1 reply through tmux", () => {
vi.useFakeTimers();
Bun.env.TMUX = "/tmp/tmux-1000/default,1234,0";
const { terminal, received, queryCount } = setupTerminal();
process.stdin.emit("data", "\x1b]11;rgb:0000/0000/0000\x07");
for (let i = 0; i < 7; i++) process.stdin.emit("data", "\x1b[?1;2c");
const reports: Array<{ appearance: string; token: number | undefined }> = [];
terminal.onAppearanceReport?.((appearance, token) => reports.push({ appearance, token }));
const beforeRefresh = queryCount();
const token = 42;
terminal.refreshAppearance?.(token);
expect(queryCount()).toBe(beforeRefresh);
// A fragmented outer DA1 reply can be decoded by tmux as an Alt+[ key
// followed by printable capability bytes. Wait for the OSC 11 response to
// reach tmux's cache, then query that cache directly with a local sentinel.
vi.advanceTimersByTime(99);
expect(queryCount()).toBe(beforeRefresh);
vi.advanceTimersByTime(1);
expect(queryCount()).toBe(beforeRefresh + 1);
process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07");
process.stdin.emit("data", "\x1b[?1;2c");
const detected = terminal.appearance;
terminal.stop();
expect(detected).toBe("light");
expect(reports).toEqual([{ appearance: "light", token }]);
expect(received).toEqual([]);
});
it("refreshAppearance() re-evaluates a changed background through the callback pipeline", () => {
const { terminal } = setupTerminal();
const appearances: string[] = [];