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;
root: HTMLDivElement;
runtime: TerminalEmulatorRuntime;
inputs: string[];
sizes: TerminalSize[];
}
@@ -65,9 +66,13 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
document.body.appendChild(root);
const sizes: TerminalSize[] = [];
const inputs: string[] = [];
const runtime = new TerminalEmulatorRuntime();
runtime.setCallbacks({
callbacks: {
onInput: (data) => {
inputs.push(data);
},
onResize: (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);
return mounted;
}
@@ -157,6 +162,22 @@ describe("terminal emulator runtime in a real browser", () => {
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 () => {
await page.viewport(900, 600);
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.
// Without this, xterm.js generates DA/CPR responses via onData that feed back
// to the PTY as visible text.
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
// Suppress cursor/status query responses whose async browser replies can race
// with foreground app exit and feed back to the shell as visible text. DA is
// intentionally left to xterm so attached TUIs can identify the terminal.
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => 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({
root: input.root,
@@ -978,6 +978,23 @@ describe("subscribe", () => {
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", () => {
-9
View File
@@ -657,15 +657,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
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;
if (!lockedTitle) {
disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {