Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/core-replies-queued-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed
- On herdr-core, the terminal's own answers (colours, focus) are no longer typed into the program
when they arrive while a key-bar key is still on its way.
12 changes: 7 additions & 5 deletions server/core/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ATTACH_HELD_MESSAGE } from "./terminal-process.ts";
import { paneAgentOf } from "../pane-context.ts";
import type { TerminalSighting } from "./terminals.ts";
import type { ClientMessage } from "../../shared/protocol.ts";
import { isMouseReport, typingGuards } from "../input-guard.ts";
import { isMouseReport, isTerminalReply, typingGuards } from "../input-guard.ts";
import { HerdrError, paneRead, paneSendKeys, paneSendText } from "../herdr/client.ts";
import { secretPrompt, validSecret } from "../../shared/secret-prompt.ts";
import { insertTextProblem } from "../../shared/terminal-insert.ts";
Expand Down Expand Up @@ -150,8 +150,10 @@ export function createInputLane({ TYPED_SETTLE_MS, KEY_SETTLE_MS, clients, sight
if (paneQueues.has(message.pane_id) || stop) {
const text = message.text;
// a click or a wheel (a click replayed behind a held arrow) is the attach's own mouse:
// herdr encodes it for the program's mouse mode, send_text would type its bytes (#667)
const mouse = isMouseReport(text);
// herdr encodes it for the program's mouse mode, send_text would type its bytes (#667).
// The terminal's answers to its attach's questions go to that attach too, or they are typed (#2816):
// only what the browser's terminal made itself, so a paste of the same bytes is still typed
const viaAttach = isMouseReport(text) || (message.automatic === true && isTerminalReply(text));
// typed into the attach it saw: one that ended meanwhile (and was attached again) takes none of it
const roles = connectionOf(client).roles;
const allowed = () => mayType(client) && connectionOf(client).roles === roles && typingGuards.queuedTyping(sighting.facts());
Expand All @@ -163,8 +165,8 @@ export function createInputLane({ TYPED_SETTLE_MS, KEY_SETTLE_MS, clients, sight
// nothing typed outlives its connection
if (!clients.has(client)) return;
authorizeSocket(client);
if (mouse) {
if (!allowed() || !sighting.write(text)) { inputFailed(); return; }
if (viaAttach) {
if (!allowed() || !sighting.write(text, message.automatic === true)) { inputFailed(); return; }
typedIntoPty(text);
receipt(true);
return;
Expand Down
14 changes: 13 additions & 1 deletion server/input-guard.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";

import { isMouseReport, sightingFacts, typingGuards } from "./input-guard.ts";
import { isMouseReport, isTerminalReply, sightingFacts, typingGuards } from "./input-guard.ts";

const attach = (...members: string[]) => ({ clients: new Set(members), ready: true, process: {}, kind: "pty" as const, cols: 80 });
/** typing taken while `origin` was the attach the client was in (none: typed with no attach of its own), now that `current` is the pane's */
Expand Down Expand Up @@ -28,6 +28,18 @@ test("a click or a wheel is told from typing, so a queued one keeps the attach i
}
});

test("the terminal's own answers are told from typing, so queued ones go to the attach that asked (#2816)", () => {
expect(isTerminalReply("\x1b[I")).toBe(true);
expect(isTerminalReply("\x1b[O")).toBe(true);
expect(isTerminalReply("\x1b]11;rgb:1818/1616/1313\x07")).toBe(true);
expect(isTerminalReply("\x1b[6;17;8t")).toBe(true);
expect(isTerminalReply("\x1b[I\x1b]10;rgb:d8d8/d0d0/c3c3\x1b\\\x1b]12;rgb:f0f0/a8a8/3030\x1b\\\x1b[?1;2c")).toBe(true);
// typing, keys and an answer mixed with typing are not answers
for (const text of ["", "I", "\x1b[A", "\x1b[1;2A", "\x1bOB", "\x1b]11;?\x1b\\", "\x1b[Ix", "x\x1b[I", "\x1b]13;rgb:0/0/0\x07", "\x1b]11;hello\rworld\x07"]) {
expect(isTerminalReply(text)).toBe(false);
}
});

test("typing taken into an attach goes only into that attach, while it still has the typist", () => {
const origin = attach("a");
expect(typedThen(origin, origin, "a")).toBe(true);
Expand Down
11 changes: 11 additions & 0 deletions server/input-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,14 @@ const MOUSE_REPORTS = /^(?:\x1b\[<\d+;\d+;\d+[Mm]|\x1b\[M[\s\S]{3})+$/;
export function isMouseReport(text: string): boolean {
return MOUSE_REPORTS.test(text);
}

/** One or more of the answers a herdr-core attach takes out of the input (`takeReplies`): colours, cell size, DA1, focus. */
const TERMINAL_REPLIES = /^(?:\x1b\]1[012];[^\x07\x1b\r\n?]*(?:\x07|\x1b\\)|\x1b\[6;\d+;\d+t|\x1b\[\?[\d;]*c|\x1b\[[IO])+$/;

/**
* Whether `text` is nothing but the terminal's own answers to what its attach asked. The attach
* takes them out of what reaches the program, while `pane.send_text` would type them into it (#2816).
*/
export function isTerminalReply(text: string): boolean {
return TERMINAL_REPLIES.test(text);
}
17 changes: 11 additions & 6 deletions server/keys-lifecycle.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { PtySession } from "./pty/session.ts";

/** A left click as xterm reports it with herdr's SGR mouse mode on: press, then release. */
const CLICK = "\x1b[<0;5;3M\x1b[<0;5;3m";
/** xterm's answers to a herdr-core attach's questions (focus, colours, cursor colour, DA1), as #2816 saw them typed. */
const REPLIES = "\x1b[I\x1b]10;rgb:d8d8/d0d0/c3c3\x1b\\\x1b]11;rgb:1818/1616/1313\x1b\\\x1b]12;rgb:f0f0/a8a8/3030\x1b\\\x1b[?1;2c";

/** Hold one keys RPC so the next chord or text waits across attachment transitions. */
async function queuedKeys(leave: "none" | "detach" | "replace" | "reattach" | "refresh", input = false, text = "queued text") {
async function queuedKeys(leave: "none" | "detach" | "replace" | "reattach" | "refresh", input = false, text = "queued text", automatic = false) {
const root = mkdtempSync(join(tmpdir(), "ovis-keys-lifecycle-"));
const server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: root });
const sockets: WebSocket[] = [];
Expand Down Expand Up @@ -68,7 +70,7 @@ async function queuedKeys(leave: "none" | "detach" | "replace" | "reattach" | "r
}
owner.send({ type: "keys", pane_id: pane, keys: ["ctrl+right"] });
await until(() => calls.length === 1);
owner.send(input ? { type: "input", pane_id: pane, text }
owner.send(input ? { type: "input", pane_id: pane, text, ...(automatic ? { automatic: true } : {}) }
: { type: "keys", pane_id: pane, keys: ["ctrl+alt+shift+left"] });
if (leave !== "none" && leave !== "refresh") owner.send({ type: "detach", pane_id: pane });
// An acknowledged later frame is a barrier: the queued key and detach were handled.
Expand All @@ -86,12 +88,13 @@ async function queuedKeys(leave: "none" | "detach" | "replace" | "reattach" | "r
}
release();
if (leave === "none" || leave === "refresh") {
if (input && text === CLICK) {
if (input && (text === CLICK || automatic)) {
// a mouse report keeps the attach input path, where herdr encodes it for the program's
// own mouse mode; pane.send_text would type its bytes literally (#667)
await until(() => written.includes(CLICK) || texts.length > 0);
// own mouse mode; pane.send_text would type its bytes literally (#667). The terminal's
// answers go to the attach that asked, never typed into the program (#2816)
await until(() => written.includes(text) || texts.length > 0);
expect(texts).toEqual([]);
expect(written).toContain(CLICK);
expect(written).toContain(text);
expect(owner.seen.filter((frame) => frame.type === "error")).toEqual([]);
} else if (input) {
await until(() => texts.length === 1);
Expand Down Expand Up @@ -136,6 +139,8 @@ it("preserves queued text on repeated attach without detach", () => queuedKeys("
it("writes a click queued behind an arrow into the attach, not through send_text", () => queuedKeys("none", true, CLICK), 20_000);
it("drops a queued click after its sender detaches while a peer keeps the attachment", () => queuedKeys("detach", true, CLICK), 20_000);
it("drops a queued click after its attachment is replaced", () => queuedKeys("replace", true, CLICK), 20_000);
it("writes the terminal's answers queued behind a key into the attach, never typed (#2816)", () => queuedKeys("none", true, REPLIES, true), 20_000);
it("types a paste of the same bytes queued behind a key, as typing", () => queuedKeys("none", true, REPLIES), 20_000);

it("cancels a pending attach continuation when detach and reattach installs a new claim", async () => {
const root = mkdtempSync(join(tmpdir(), "ovis-attach-claim-"));
Expand Down
Loading