Skip to content

0.4.5: split/out-of-spec mouse sequences still leak into embedded terminals; malformed X10 can move keyboard focus and swallow keys (follow-up to #2745) #2793

Description

@mruff-aeq

The 0.4.5 input-parser rewrite fixed the common cases — atomic well-formed events and coalesced floods are now fully consumed (thank you!) — but a residue remains, and one new symptom appeared that is arguably worse than the leak: malformed mouse input can move keyboard focus to a different pane and swallow typed characters.

Environment

  • fresh 0.4.5 (homebrew bottle, fresh-editor), macOS arm64 (Darwin 25.3)
  • TERM=xterm-256color, repro runs fresh headless in its own pty

Repro

Same harness as #2745 (isolated fake $HOME, two embedded terminals: A opts into mouse reporting, B is focused and never does), but each sequence class is injected between typed marker keys, so B's stdin log attributes every leaked byte to the case that caused it. Single file, prints per-case PASS/FAIL, exits nonzero on failure:

python3 repro-045-residual.py

Script at the bottom of this issue.

Observed on 0.4.5

B raw stdin   : b'<BOOT>\x1b[MCH4<C1>\x1b[<35;41;20M<C2>\xc2\xa1<C3>'
A raw stdin   : b'C4>5>'

FAIL  markers: every typed marker reached the focused child
FAIL  C1 split X10: leaked b'\x1b[MCH4'
FAIL  C2 split SGR: leaked b'\x1b[<35;41;20M'
FAIL  C3 UTF-8 coords: leaked b'\xc2\xa1'
PASS  C4 malformed X10: leaked b''
FAIL  C5 truncated ESC[M then 'z': expected b'z', got b''
FAIL  A isolation: mouse-enabled child got host bytes

Case by case:

  • C1 / C2 — split well-formed events still leak, now reassembled. An X10 or SGR event split across two writes at certain byte offsets arrives in the focused child fully reassembled as literal bytes (\x1b[MCH4, \x1b[<35;41;20M). On 0.4.3 these leaked as fragments; on 0.4.5 the parser appears to buffer across the boundary, then dispatch the whole sequence as keyboard input instead of consuming it as a mouse event.
  • C3 — 1005-style UTF-8 coordinates partially leak (\xc2\xa1).
  • C4 — out-of-range button + non-coordinate ASCII payload (ESC[M + \xc0\xfa\xfa, ESC[Mxy): nothing leaks to B, but see below.
  • C5 + focus corruption — the headline. After C4's malformed events, the subsequently typed marker keys (<C4>, <C5>) were delivered to terminal A — the unfocused, mouse-enabled pane — as C4>5>, with the leading </C characters eaten. So a malformed X10 sequence was (presumably) interpreted as a click at its garbage coordinates, silently refocusing another pane, and the parser also swallowed adjacent literal keystrokes (the lone z in C5 never arrived anywhere).

The real-world shape of this: stray boundary-split motion events during normal mouse use can still echo ^[[M… garbage at a shell prompt, and — new in 0.4.5 — a malformed sequence can yank keyboard focus to another pane mid-typing, with characters lost.

Expected

All five cases: nothing mouse-derived reaches any child pty that didn't enable mouse reporting; literal keys always reach the focused child; malformed sequences are dropped without side effects (no focus change, no swallowed keys).

repro-045-residual.py

click to expand (single file, self-contained)
"""Follow-up to #2745: fresh 0.4.5's input-parser rewrite fixed most mouse
leakage, but split/out-of-spec mouse sequences STILL reach the focused
embedded terminal's child pty as literal input — and a literal keystroke
typed after a truncated `ESC [ M` is swallowed.

Usage:  python3 repro-045-residual.py     (needs `fresh` on PATH)

Single file — it writes its own terminal-child logger. Fully isolated
(fake $HOME under ./run/), same harness as the #2745 repro, but each
sequence class is injected between typed MARKER keys, so the focused
child's stdin log attributes every leaked byte to the case that caused
it. Prints per-case PASS/FAIL and exits nonzero on any failure.

Cases (all injected at the host pty; terminal B is focused and never
enables mouse reporting, so B must receive NONE of these bytes):
  C1  well-formed X10 event, split across two writes at every byte offset
  C2  well-formed SGR event, split across two writes at every byte offset
  C3  X10 with UTF-8 (1005-style) coordinate bytes
  C4  X10 with out-of-range button / non-coordinate ASCII payload
  C5  truncated `ESC [ M`, then — after a pause — the literal key "z".
      The mouse bytes must not leak AND the "z" must still arrive.
"""
import os, pty, time, fcntl, termios, struct, signal, shutil, sys

BASE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "run")
LOGGER = os.path.join(BASE, "logger.py")
A_LOG = os.path.join(BASE, "a-mousey-stdin.log")
B_LOG = os.path.join(BASE, "b-logger-stdin.log")

LOGGER_SRC = '''"""Log every stdin byte to argv[1]; --enable-mouse opts into reporting."""
import os, sys, tty
log = open(sys.argv[1], "ab", 0)
try:
    tty.setraw(0)
except Exception:
    pass
if len(sys.argv) > 2 and sys.argv[2] == "--enable-mouse":
    os.write(1, b"\\x1b[?1000h\\x1b[?1002h\\x1b[?1003h\\x1b[?1006h")
while True:
    b = os.read(0, 1024)
    if not b:
        break
    log.write(b)
'''

shutil.rmtree(BASE, ignore_errors=True)
os.makedirs(os.path.join(BASE, "home/.config/fresh"), exist_ok=True)
os.makedirs(os.path.join(BASE, "ws"), exist_ok=True)
with open(LOGGER, "w") as f:
    f.write(LOGGER_SRC)

INIT = f"""// Two embedded terminals, focus on the one WITHOUT mouse opt-in.
(async () => {{
	await editor.delay(100);
	const editorSplitId = editor.listSplits()[0]?.splitId;
	await editor.createTerminal({{
		direction: "vertical",
		ratio: 0.5,
		command: ["python3", {LOGGER!r}, {A_LOG!r}, "--enable-mouse"],
		title: "mousey",
		focus: false,
		persistent: false,
	}});
	if (editorSplitId !== undefined) editor.focusSplit(editorSplitId);
	await editor.createTerminal({{
		direction: "horizontal",
		ratio: 0.5,
		command: ["python3", {LOGGER!r}, {B_LOG!r}],
		title: "logger",
		focus: true,
		persistent: false,
	}});
}})();
"""
with open(os.path.join(BASE, "home/.config/fresh/init.ts"), "w") as f:
    f.write(INIT)
with open(os.path.join(BASE, "home/.config/fresh/config.json"), "w") as f:
    f.write("{}\n")

OUT = open(os.path.join(BASE, "fresh-stdout.log"), "wb", 0)
env = dict(os.environ)
env["HOME"] = os.path.join(BASE, "home")
env["TERM"] = "xterm-256color"

pid, fd = pty.fork()
if pid == 0:
    os.chdir(os.path.join(BASE, "ws"))
    os.execvpe("fresh", ["fresh", "--no-restore"], env)

fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 100, 0, 0))
os.set_blocking(fd, False)

def drain(sec):
    end = time.time() + sec
    while time.time() < end:
        try:
            d = os.read(fd, 65536)
            if d:
                OUT.write(d)
        except (BlockingIOError, OSError):
            time.sleep(0.05)

def send(b):
    os.write(fd, b)

def x10(btn, x, y):
    return b"\x1b[M" + bytes([btn + 32, x + 32, y + 32])

def split_all_offsets(seq):
    for cut in range(1, len(seq)):
        send(seq[:cut]); time.sleep(0.02); send(seq[cut:]); time.sleep(0.02)

def marker(tag):
    # Plain literal keys — must always reach the focused child; they also
    # segment B's stdin log so leaks attribute to the case that caused them.
    drain(0.5)
    send(b"<" + tag + b">")
    drain(0.5)

drain(8)                       # fresh boot + init.ts terminals

marker(b"BOOT")                # control: proves plain keys flow to B
split_all_offsets(x10(35, 40, 20))            # C1
marker(b"C1")
split_all_offsets(b"\x1b[<35;41;20M")         # C2
marker(b"C2")
send(b"\x1b[M" + bytes([35 + 32]) + b"\xc2\xa0\xc2\xa1")  # C3: UTF-8 coords
marker(b"C3")
send(b"\x1b[M" + bytes([96 + 32, 250, 250]))  # C4: odd button…
send(b"\x1b[Mxy")                             #     …and non-coord ASCII
marker(b"C4")
send(b"\x1b[M"); time.sleep(0.2); send(b"z")  # C5: truncated, then a real key
marker(b"C5")
drain(3)

os.kill(pid, signal.SIGTERM)
drain(1)
try:
    os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
    pass

def read(path):
    try:
        return open(path, "rb").read()
    except FileNotFoundError:
        return None

b_data, a_data = read(B_LOG), read(A_LOG)
print()
print(f"fresh version : {os.popen('fresh --version').read().strip()}")
print(f"B raw stdin   : {b_data!r}")
print(f"A raw stdin   : {a_data!r}")
print()

if b_data is None:
    print("FAIL  harness: terminal B never spawned"); sys.exit(2)

# Segment B's log on the markers: leaks between <X> and the next marker
# belong to case X's injections (markers themselves are expected input).
segs, rest = {}, b_data
order = [b"BOOT", b"C1", b"C2", b"C3", b"C4", b"C5"]
prev = b""
for tag in order:
    m = b"<" + tag + b">"
    idx = rest.find(m)
    segs[prev] = rest[:idx] if idx >= 0 else rest
    rest = rest[idx + len(m):] if idx >= 0 else b""
    prev = tag

failures = 0
def check(name, cond, detail):
    global failures
    print(f"{'PASS' if cond else 'FAIL'}  {name}: {detail}")
    if not cond:
        failures += 1

check("markers", all((b"<" + t + b">") in b_data for t in order),
      "every typed marker reached the focused child")
check("C1 split X10", segs.get(b"BOOT") == b"", f"leaked {segs.get(b'BOOT')!r}")
check("C2 split SGR", segs.get(b"C1") == b"", f"leaked {segs.get(b'C1')!r}")
check("C3 UTF-8 coords", segs.get(b"C2") == b"", f"leaked {segs.get(b'C2')!r}")
check("C4 malformed X10", segs.get(b"C3") == b"", f"leaked {segs.get(b'C3')!r}")
check("C5 truncated ESC[M then 'z'", segs.get(b"C4") == b"z",
      f"expected b'z', got {segs.get(b'C4')!r}")
check("A isolation", not a_data, "mouse-enabled child got host bytes" if a_data
      else "nothing reached the mouse-enabled child directly")

print()
sys.exit(1 if failures else 0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions