fix: suppress terminal query response leaks

This commit is contained in:
Mohamed Boudra
2026-04-24 13:58:53 +07:00
parent 231ebdcfb1
commit 9a4528129f
4 changed files with 46 additions and 14 deletions
@@ -24,6 +24,7 @@ interface MountedTerminal {
host: HTMLDivElement; host: HTMLDivElement;
root: HTMLDivElement; root: HTMLDivElement;
runtime: TerminalEmulatorRuntime; runtime: TerminalEmulatorRuntime;
inputs: string[];
sizes: TerminalSize[]; sizes: TerminalSize[];
} }
@@ -65,9 +66,13 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
document.body.appendChild(root); document.body.appendChild(root);
const sizes: TerminalSize[] = []; const sizes: TerminalSize[] = [];
const inputs: string[] = [];
const runtime = new TerminalEmulatorRuntime(); const runtime = new TerminalEmulatorRuntime();
runtime.setCallbacks({ runtime.setCallbacks({
callbacks: { callbacks: {
onInput: (data) => {
inputs.push(data);
},
onResize: (size) => { onResize: (size) => {
sizes.push(size); sizes.push(size);
}, },
@@ -84,7 +89,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
}, },
}); });
const mounted = { host, root, runtime, sizes }; const mounted = { host, root, runtime, inputs, sizes };
mountedTerminals.push(mounted); mountedTerminals.push(mounted);
return mounted; return mounted;
} }
@@ -157,6 +162,22 @@ describe("terminal emulator runtime in a real browser", () => {
expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]); expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]);
}); });
it("suppresses non-identity terminal query responses as PTY input", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
const querySequences = ["\x1b[5n", "\x1b[6n", "\x1b[?6n", "\x1b[1$p", "\x1b[?1$p"];
for (const sequence of querySequences) {
mounted.runtime.write({ text: sequence });
}
await nextFrame();
expect(mounted.inputs).toEqual([]);
});
it("replays snapshots without synchronously resetting the visible terminal", async () => { it("replays snapshots without synchronously resetting the visible terminal", async () => {
await page.viewport(900, 600); await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 }); const mounted = createTerminalHost({ width: 720, height: 360 });
@@ -237,11 +237,14 @@ export class TerminalEmulatorRuntime {
} }
}); });
// Suppress terminal query responses — the server-side headless xterm handles these. // Suppress cursor/status query responses whose async browser replies can race
// Without this, xterm.js generates DA/CPR responses via onData that feed back // with foreground app exit and feed back to the shell as visible text. DA is
// to the PTY as visible text. // intentionally left to xterm so attached TUIs can identify the terminal.
terminal.parser.registerCsiHandler({ final: "c" }, () => true); terminal.parser.registerCsiHandler({ final: "n" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true); terminal.parser.registerCsiHandler({ final: "R" }, () => true);
terminal.parser.registerCsiHandler({ intermediates: "$", final: "p" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", intermediates: "$", final: "p" }, () => true);
const restoreDocumentStyles = this.applyDocumentBoundsStyles({ const restoreDocumentStyles = this.applyDocumentBoundsStyles({
root: input.root, root: input.root,
@@ -978,6 +978,23 @@ describe("subscribe", () => {
expect(messages.length).toBe(0); expect(messages.length).toBe(0);
}); });
it("does not answer terminal identity queries from the headless snapshot parser", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033[c'; echo after-da\r" });
await waitForState(session, (state) => getLines(state).join("\n").includes("after-da"));
expect(getLines(session.getState()).join("\n")).not.toContain("62;4;22c");
});
}); });
describe("stream snapshots", () => { describe("stream snapshots", () => {
-9
View File
@@ -657,15 +657,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
} }
emitTitleChange(initialTitle); emitTitleChange(initialTitle);
// Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities
terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
ptyProcess.write("\x1b[?62;4;22c");
return true;
}
return false;
});
let disposeTitleChangeSubscription: { dispose(): void } | null = null; let disposeTitleChangeSubscription: { dispose(): void } | null = null;
if (!lockedTitle) { if (!lockedTitle) {
disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => { disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {