"""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)
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-editor), macOS arm64 (Darwin 25.3)TERM=xterm-256color, repro runs fresh headless in its own ptyRepro
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:Script at the bottom of this issue.
Observed on 0.4.5
Case by case:
\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.\xc2\xa1).ESC[M+\xc0\xfa\xfa,ESC[Mxy): nothing leaks to B, but see below.<C4>,<C5>) were delivered to terminal A — the unfocused, mouse-enabled pane — asC4>5>, with the leading</Ccharacters 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 lonezin 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)