From 059df9643c51b2f3812fa18904c6a438eca984f3 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Tue, 6 Oct 2026 18:50:37 +0100 Subject: [PATCH] Tests for 5675. --- regress/input-requests.sh | 137 +++++++++++++++++++------ regress/tty-keys-extended-escape.sh | 97 ++++++++++++++++++ regress/tty-keys-focus-escape.sh | 143 +++++++++++++++++++++++++++ regress/tty-keys-mouse-escape.sh | 98 ++++++++++++++++++ regress/tty-keys-paste.sh | 87 ++++++++++++++++ regress/tty-keys-replies.sh | 148 ++++++++++++++++++++++++++++ regress/tty-keys-reply-escape.sh | 127 ++++++++++++++++++++++++ 7 files changed, 806 insertions(+), 31 deletions(-) create mode 100644 regress/tty-keys-extended-escape.sh create mode 100644 regress/tty-keys-focus-escape.sh create mode 100644 regress/tty-keys-mouse-escape.sh create mode 100644 regress/tty-keys-paste.sh create mode 100644 regress/tty-keys-replies.sh create mode 100644 regress/tty-keys-reply-escape.sh diff --git a/regress/input-requests.sh b/regress/input-requests.sh index 094ce2a8a..5cb348760 100644 --- a/regress/input-requests.sh +++ b/regress/input-requests.sh @@ -1,5 +1,10 @@ #!/bin/sh +# Verify palette and clipboard payload delivery with both OSC terminators, +# fragmented replies and a separate Escape before the reply. Invalid payloads +# must leave the request pending for a subsequent valid reply. Check that +# foreground and background reports update the colours queried by a pane. + PATH=/bin:/usr/bin TERM=screen @@ -8,6 +13,7 @@ TERM=screen python3 - "$TEST_TMUX" <<'PY' import os import select +import shlex import signal import subprocess import sys @@ -49,13 +55,13 @@ def read_until(fd, needle, timeout=5): raise RuntimeError("did not see terminal request %r in %r" % (needle, data)) -def wait_file(path, timeout=5): +def wait_file(path, length, timeout=5): end = time.time() + timeout while time.time() < end: try: with open(path, "rb") as f: data = f.read() - if data: + if len(data) >= length: return data except FileNotFoundError: pass @@ -66,6 +72,13 @@ def respawn(command): run("respawn-window", "-k", "-t", "requests:0", command) time.sleep(0.2) +def wait_pane(expected, context): + end = time.monotonic() + 5 + while run("capture-pane", "-p").stdout.strip() != expected: + assert time.monotonic() < end, (context, "unexpected pane bytes", + run("capture-pane", "-p").stdout.strip()) + time.sleep(.01) + def cleanup(pid=None): if pid is not None: try: @@ -73,6 +86,8 @@ def cleanup(pid=None): except ProcessLookupError: pass run("kill-server", check=False) + if pid is not None: + os.waitpid(pid, 0) run("kill-server", check=False) run("new-session", "-d", "-x", "80", "-y", "24", "-s", "requests", @@ -82,37 +97,97 @@ pid, fd = attach() try: time.sleep(0.5) - with tempfile.NamedTemporaryFile(delete=False) as f: - palette_out = f.name - respawn("stty raw -echo min 1 time 50; " - "printf '\\033]4;99;?\\033\\\\'; " - "dd bs=1 count=27 2>/dev/null | cat -v >%s; sleep 1" % - palette_out) - read_until(fd, b"\033]4;99;?\033\\") - os.write(fd, b"\033]4;99;rgb:0101/0202/0303\033\\") - got = wait_file(palette_out) - expected = b"^[]4;99;rgb:0101/0202/0303^[\\" - if got != expected: - raise AssertionError("palette reply: expected %r got %r" % - (expected, got)) - + run("set-option", "-g", "assume-paste-time", "0") + run("set-option", "-s", "escape-time", "1000") + run("set-option", "-g", "@seen", "") + run("bind-key", "-n", "Escape", "set-option", "-gF", "@seen", + "#{@seen}E") run("set-option", "-s", "set-clipboard", "on") run("set-option", "-s", "get-clipboard", "request") - with tempfile.NamedTemporaryFile(delete=False) as f: - clip_out = f.name - respawn("stty raw -echo min 1 time 50; " - "printf '\\033]52;c;?\\033\\\\'; " - "dd bs=1 count=21 2>/dev/null | cat -v >%s; sleep 1" % - clip_out) - data = read_until(fd, b"]52;") - if b"?" not in data: - raise RuntimeError("clipboard request missing query in %r" % data) - os.write(fd, b"\033]52;c;UmVxdWVzdA==\033\\") - got = wait_file(clip_out) - expected = b"^[]52;c;UmVxdWVzdA==^[\\" - if got != expected: - raise AssertionError("clipboard reply: expected %r got %r" % - (expected, got)) + expected_escapes = b"" + replies = [ + ("palette", "\\033]4;99;?\\033\\\\", b"\033]4;99;?\033\\", + b"\033]4;99;rgb:0101/0202/0303", b"\033]4;99;invalid"), + ("clipboard", "\\033]52;c;?\\033\\\\", b"]52;", + b"\033]52;c;UmVxdWVzdA==", b"\033]52;c;!!!!"), + ] + with tempfile.TemporaryDirectory() as directory: + output = os.path.join(directory, "reply") + for name, query, needle, payload, invalid in replies: + for prefixed in (False, True): + for split in (False, True): + for terminator in (b"\007", b"\033\\"): + context = (name, prefixed, split, terminator) + # Replies to the pane use its request's ST terminator, + # regardless of the terminal reply's terminator. + expected = payload + b"\033\\" + respawn("stty raw -echo min 1 time 50; " + "printf '%s'; dd bs=1 count=%d 2>/dev/null >%s; " + "exec cat -v" % + (query, len(expected), shlex.quote(output))) + data = read_until(fd, needle) + assert b"?" in data, (context, "query missing", data) + + os.write(fd, invalid + terminator) + time.sleep(.05) + with open(output, "rb") as f: + assert f.read() == b"", (context, "invalid payload delivered") + + wire = (b"\033" if prefixed else b"") + payload + terminator + if split: + os.write(fd, wire[:-1]) + time.sleep(.05) + with open(output, "rb") as f: + assert f.read() == b"", (context, "partial payload delivered") + os.write(fd, wire[-1:]) + else: + os.write(fd, wire) + got = wait_file(output, len(expected)) + assert got == expected, (context, expected, got) + if prefixed: + expected_escapes += b"E" + seen = run("show-option", "-gv", "@seen").stdout.strip() + assert seen == expected_escapes, (context, "Escape count", seen) + + # Check that decoding did not deliver the reply twice + # or leak raw bytes after delivering the payload. + os.write(fd, b"Z") + wait_pane(b"Z", context) + + colour_value = 1 + for colour in (10, 11): + for prefixed in (False, True): + for terminator in (b"\007", b"\033\\"): + context = ("colour", colour, prefixed, terminator) + # A different colour each time prevents an earlier + # successful report from masking a later ignored one. + payload = b"\033]%d;rgb:%04x/0202/0303" % ( + colour, colour_value * 257) + colour_value += 1 + wire = (b"\033" if prefixed else b"") + payload + terminator + if prefixed: + os.write(fd, wire[:-1]) + time.sleep(.05) + assert run("capture-pane", "-p").stdout.strip() == b"Z", ( + context, "partial reply leaked") + os.write(fd, wire[-1:] + b"X") + expected_escapes += b"E" + else: + os.write(fd, wire + b"X") + wait_pane(b"ZX", context) + seen = run("show-option", "-gv", "@seen").stdout.strip() + assert seen == expected_escapes, (context, "Escape count", seen) + + expected = payload + b"\033\\" + respawn("stty raw -echo min 1 time 50; " + "printf '\\033]%d;?\\033\\\\'; " + "dd bs=1 count=%d 2>/dev/null >%s; exec cat -v" % + (colour, len(expected), shlex.quote(output))) + got = wait_file(output, len(expected)) + assert got == expected, (context, "colour not applied", got) + os.write(fd, b"Z") + wait_pane(b"Z", context) finally: cleanup(pid) + os.close(fd) PY diff --git a/regress/tty-keys-extended-escape.sh b/regress/tty-keys-extended-escape.sh new file mode 100644 index 000000000..5ebdf1b6c --- /dev/null +++ b/regress/tty-keys-extended-escape.sh @@ -0,0 +1,97 @@ +#!/bin/sh + +# An Escape followed by an extended key must remain a separate key. The +# sequence's leading Escape must not be consumed as the second half of +# M-Escape. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +$INNER new-session -d -s inner -x80 -y10 "stty raw -echo; exec cat -v" || + exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g assume-paste-time 0 || exit 1 +$INNER set-option -s escape-time 1000 || exit 1 +$INNER set-option -s extended-keys on || exit 1 +$INNER unbind-key -a -T root || exit 1 +$OUTER new-session -d -s outer -x80 -y10 "$INNER attach -t inner" || exit 1 +$OUTER set-option -g assume-paste-time 0 || exit 1 + +i=0 +while [ -z "$($INNER list-clients -F '#{client_name}')" ]; do + [ "$i" -lt 100 ] || fail 'inner client did not attach' + sleep 0.05 + i=$((i + 1)) +done + +bind_key() +{ + $INNER bind-key -n "$1" send-keys -l "$2" || exit 1 +} +bind_key Escape E +bind_key M-Escape A +bind_key C-a C +bind_key M-C-a M +bind_key M-Up U +bind_key M-DC D + +expected_output= + +send_raw() +{ + # Hex arguments avoid command separators in a partial sequence. + bytes=$(printf '%s' "$1" | od -An -v -tx1) + $OUTER send-keys -H $bytes || exit 1 +} + +assert_events() +{ + name=$1 + expected_output=$expected_output$2 + send_raw "$3" + if [ "$#" -eq 4 ]; then + sleep 0.05 + send_raw "$4" + fi + i=0 + while [ "$i" -lt 100 ]; do + actual=$($INNER capture-pane -p | tr -d '\n') + [ "$actual" = "$expected_output" ] && return 0 + sleep 0.05 + i=$((i + 1)) + done + fail "$name: got '$actual', expected '$expected_output'" +} + +assert_events 'CSI u' C "$(printf '\033[97;5u')" +assert_events 'Escape and CSI u' EC "$(printf '\033\033[97;5u')" +assert_events 'Escape and xterm' EC "$(printf '\033\033[27;5;97~')" +assert_events 'Escape and CSI u with Meta' EM "$(printf '\033\033[97;7u')" +assert_events 'split CSI u' EC "$(printf '\033\033[97;')" '5u' +assert_events 'split xterm' EC "$(printf '\033\033[27;5')" ';97~' +assert_events 'CSI u split after two Escapes' EC "$(printf '\033\033')" '[97;5u' +assert_events 'xterm split after two Escapes' EC "$(printf '\033\033')" '[27;5;97~' +assert_events 'M-Escape' A "$(printf '\033\033')" +assert_events 'M-Up' U "$(printf '\033\033[A')" +assert_events 'M-DC' D "$(printf '\033\033[3~')" + +exit 0 diff --git a/regress/tty-keys-focus-escape.sh b/regress/tty-keys-focus-escape.sh new file mode 100644 index 000000000..8d6c3d831 --- /dev/null +++ b/regress/tty-keys-focus-escape.sh @@ -0,0 +1,143 @@ +#!/bin/sh + +# An Escape followed by a focus report must remain a separate key. The +# report's leading Escape must not be consumed as the second half of M-Escape, +# and the focus change must still reach the client and the pane. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +$INNER new-session -d -s inner -x80 -y10 \ + "printf '\033[?1004h'; stty raw -echo; exec cat -v" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g assume-paste-time 0 || exit 1 +$INNER set-option -g focus-events on || exit 1 +$INNER set-option -s escape-time 1000 || exit 1 +$INNER unbind-key -a -T root || exit 1 +$OUTER new-session -d -s outer -x80 -y10 "$INNER attach -t inner" || exit 1 +$OUTER set-option -g assume-paste-time 0 || exit 1 + +i=0 +while [ -z "$($INNER list-clients -F '#{client_name}')" ]; do + [ "$i" -lt 100 ] || fail 'inner client did not attach' + sleep 0.05 + i=$((i + 1)) +done + +bind_key() +{ + $INNER bind-key -n "$1" send-keys -l "$2" || exit 1 +} +bind_key Escape E +bind_key M-Escape A +bind_key M-Up U + +send_raw() +{ + # Hex arguments avoid command separators in a partial sequence. + bytes=$(printf '%s' "$1" | od -An -v -tx1) + $OUTER send-keys -H $bytes || exit 1 +} + +capture() +{ + $INNER capture-pane -p | tr -d '\n' +} + +focused() +{ + case $($INNER list-clients -F '#{client_flags}') in + *focused*) + echo yes + ;; + *) + echo no + ;; + esac +} + +# Start unfocused, whatever the state after attaching, once the pane has +# enabled focus reporting. +i=0 +while :; do + send_raw "$(printf '\033[I\033[O')" + sleep 0.05 + case $(capture) in + *'^[[O') + [ "$(focused)" = no ] && break + ;; + esac + [ "$i" -lt 100 ] || fail 'pane did not receive focus reports' + i=$((i + 1)) +done +expected_output=$(capture) + +# A focus report is forwarded to the pane directly, while the Escape binding +# runs from the command queue, so the pane may see them in either order. The +# second expected output, if not empty, is that other order. +assert_events() +{ + name=$1 + expected=$expected_output$2 + if [ -n "$3" ]; then + alternative=$expected_output$3 + else + alternative=$expected + fi + expected_focus=$4 + send_raw "$5" + if [ "$#" -eq 6 ]; then + sleep 0.05 + send_raw "$6" + fi + i=0 + while [ "$i" -lt 100 ]; do + actual=$(capture) + actual_focus=$(focused) + if [ "$actual" = "$expected" ] || + [ "$actual" = "$alternative" ]; then + if [ "$actual_focus" = "$expected_focus" ]; then + expected_output=$actual + return 0 + fi + fi + sleep 0.05 + i=$((i + 1)) + done + fail "$name: got '$actual' focused=$actual_focus," \ + "expected '$expected' focused=$expected_focus" +} + +assert_events 'FocusIn' '^[[I' '' yes "$(printf '\033[I')" +assert_events 'FocusOut' '^[[O' '' no "$(printf '\033[O')" +assert_events 'Escape and FocusIn' 'E^[[I' '^[[IE' yes \ + "$(printf '\033\033[I')" +assert_events 'Escape and FocusOut' 'E^[[O' '^[[OE' no \ + "$(printf '\033\033[O')" +assert_events 'split FocusIn' 'E^[[I' '^[[IE' yes \ + "$(printf '\033\033[')" 'I' +assert_events 'FocusOut split after two Escapes' 'E^[[O' '^[[OE' no \ + "$(printf '\033\033')" '[O' +assert_events 'M-Escape' A '' no "$(printf '\033\033')" +assert_events 'M-Up' U '' no "$(printf '\033\033[A')" + +exit 0 diff --git a/regress/tty-keys-mouse-escape.sh b/regress/tty-keys-mouse-escape.sh new file mode 100644 index 000000000..569fa6642 --- /dev/null +++ b/regress/tty-keys-mouse-escape.sh @@ -0,0 +1,98 @@ +#!/bin/sh + +# An Escape followed by a mouse report must remain a separate key. The +# report's leading Escape must not be consumed as the second half of M-Escape. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +$INNER new-session -d -s inner -x80 -y10 \ + "stty raw -echo; printf '\033[?1003h\033[?1006h'; exec cat -v" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g assume-paste-time 0 || exit 1 +$INNER set-option -s escape-time 1000 || exit 1 +$INNER unbind-key -a -T root || exit 1 +$OUTER new-session -d -s outer -x80 -y10 "$INNER attach -t inner" || exit 1 +$OUTER set-option -g assume-paste-time 0 || exit 1 + +i=0 +while [ -z "$($INNER list-clients -F '#{client_name}')" ]; do + [ "$i" -lt 100 ] || fail 'inner client did not attach' + sleep 0.05 + i=$((i + 1)) +done + +bind_key() +{ + $INNER bind-key -n "$1" send-keys -l "$2" || exit 1 +} +bind_key Escape E +bind_key M-Escape A +bind_key M-a B +bind_key M-Up U + +expected_output= + +send_raw() +{ + # Hex arguments avoid command separators in a partial mouse report. + bytes=$(printf '%s' "$1" | od -An -v -tx1) + $OUTER send-keys -H $bytes || exit 1 +} + +assert_events() +{ + name=$1 + expected_output=$expected_output$2 + send_raw "$3" + if [ "$#" -eq 4 ]; then + sleep 0.05 + send_raw "$4" + fi + i=0 + while [ "$i" -lt 100 ]; do + actual=$($INNER capture-pane -p | tr -d '\n') + [ "$actual" = "$expected_output" ] && return 0 + sleep 0.05 + i=$((i + 1)) + done + fail "$name: got '$actual', expected '$expected_output'" +} + +assert_events 'SGR motion' 'E^[[<35;3;3M' "$(printf '\033\033[<35;3;3M')" +assert_events 'SGR press' 'E^[[<0;3;3M' "$(printf '\033\033[<0;3;3M')" +assert_events 'SGR release' 'E^[[<0;3;3m' "$(printf '\033\033[<0;3;3m')" +assert_events 'SGR wheel' 'E^[[<64;3;3M' "$(printf '\033\033[<64;3;3M')" +assert_events 'X10 motion' 'E^[[MC##' "$(printf '\033\033[MC##')" +assert_events 'X10 press' 'E^[[M ##' "$(printf '\033\033[M ##')" +assert_events 'split SGR report' 'E^[[<35;3;3M' "$(printf '\033\033[<35;')" '3;3M' +assert_events 'split mouse prefix' 'E^[[<35;3;3M' "$(printf '\033\033[')" '<35;3;3M' +assert_events 'split after two Escapes' 'E^[[<35;3;3M' "$(printf '\033\033')" '[<35;3;3M' +assert_events 'invalid SGR coordinates' E "$(printf '\033\033[<35;0;3M')" +assert_events 'discarded wheel release' E "$(printf '\033\033[<64;3;3m')" +assert_events 'M-Escape' A "$(printf '\033\033')" +assert_events 'M-Escape and bracket' 'A[' "$(printf '\033\033[')" +assert_events 'M-a' B "$(printf '\033a')" +assert_events 'M-Up' U "$(printf '\033\033[A')" + +exit 0 diff --git a/regress/tty-keys-paste.sh b/regress/tty-keys-paste.sh new file mode 100644 index 000000000..694230095 --- /dev/null +++ b/regress/tty-keys-paste.sh @@ -0,0 +1,87 @@ +#!/bin/sh + +# Paste state must change only when the start or end key completes. A separate +# Escape before paste start uses its binding, pasted keys bypass bindings, and +# the first key after paste end uses its binding again. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +$INNER new-session -d -s inner -x80 -y10 \ + "stty raw -echo; printf '\033[?2004h'; exec cat -v" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g assume-paste-time 0 || exit 1 +$INNER set-option -s escape-time 20 || exit 1 +$INNER unbind-key -a -T root || exit 1 +$INNER bind-key -n Escape send-keys -l E || exit 1 +$INNER bind-key -n a send-keys -l A || exit 1 +$OUTER new-session -d -s outer -x80 -y10 "$INNER attach -t inner" || exit 1 +$OUTER set-option -g assume-paste-time 0 || exit 1 + +i=0 +while [ -z "$($INNER list-clients -F '#{client_name}')" ] || + [ "$($INNER display-message -p '#{bracket_paste_flag}')" != 1 ]; do + [ "$i" -lt 100 ] || fail 'client did not attach or enable bracketed paste' + sleep 0.05 + i=$((i + 1)) +done + +send_raw() +{ + bytes=$(printf '%s' "$1" | od -An -v -tx1) + $OUTER send-keys -H $bytes || exit 1 +} + +assert_output() +{ + i=0 + while [ "$i" -lt 100 ]; do + actual=$($INNER capture-pane -p | tr -d '\n') + [ "$actual" = "$1" ] && return 0 + sleep 0.05 + i=$((i + 1)) + done + fail "got '$actual', expected '$1'" +} + +send_raw "$(printf '\033\033[200~a\033[201~a')" +expected='E^[[200~a^[[201~A' +assert_output "$expected" + +# A partial paste end gets more time than escape-time. The final key must +# still be outside the paste when the rest arrives after that time. +send_raw "$(printf '\033[200~a\033[20')" +sleep 0.05 +send_raw '1~a' +expected=$expected'^[[200~a^[[201~A' +assert_output "$expected" + +# Split the start immediately before its final byte, including an extra +# Escape. Neither the Escape binding nor paste mode may be lost. +$INNER set-option -s escape-time 1000 || exit 1 +send_raw "$(printf '\033\033[200')" +sleep 0.05 +send_raw "$(printf '~a\033[201~a')" +expected=$expected'E^[[200~a^[[201~A' +assert_output "$expected" + +exit 0 diff --git a/regress/tty-keys-replies.sh b/regress/tty-keys-replies.sh new file mode 100644 index 000000000..292f43fd3 --- /dev/null +++ b/regress/tty-keys-replies.sh @@ -0,0 +1,148 @@ +#!/bin/sh + +# Test terminal reply state changes, including replies fragmented at every +# byte and replies preceded by a separate Escape. Use a PTY directly so that +# an outer tmux cannot answer startup queries before the test does. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +python3 - "$TEST_TMUX" <<'PY' +import fcntl +import os +import select +import struct +import subprocess +import sys +import termios +import time + +tmux = sys.argv[1] +server = [tmux, "", "-f/dev/null"] + +def run(*args, check=True): + p = subprocess.run(server + list(args), capture_output=True) + if check and p.returncode != 0: + raise RuntimeError("%r: %s" % (args, p.stderr.decode())) + return p.stdout.decode().strip() + +def state(): + return run("list-clients", "-F", + "#{client_termfeatures}|#{client_termtype}|" + "#{client_width}x#{client_height}|" + "#{client_cell_width}x#{client_cell_height}") + +def matches(name, value): + features, kind, size, cells = value.split("|") + features = set(features.split(",")) + if name == "DA": + return {"sixel", "margins", "rectfill", "clipboard"} <= features + if name == "DA2": + return "ignorefkeys" in features + if name == "XDA": + return kind == "ReplyTest" + if name == "sync": + return "sync" in features + if name == "characters": + return size == "61x17" + if name == "pixels": + return cells == "10x20" + +reports = [ + ("DA", b"\033[?65;4;21;28;52c"), + ("DA2", b"\033[>85;1;0c"), + ("XDA", b"\033P>|ReplyTest\033\\"), + ("sync", b"\033[?2026;2$y"), + ("characters", b"\033[8;17;61t"), + ("pixels", b"\033[4;480;800t"), +] +cases = 0 +for name, report in reports: + for prefixed in (False, True): + deliveries = ["whole", "split", "bytewise"] + if name == "sync" and not prefixed: + deliveries += ["invalid", "expired"] + for delivery in deliveries: + context = (name, prefixed, delivery) + pid = fd = None + # Each case needs fresh one-shot startup reply flags. A new + # socket also avoids racing shutdown of the previous server. + server[1] = "-LtestReplies%d-%d" % (os.getpid(), cases) + try: + run("new-session", "-d", "-s", "replies", "-x", "80", + "-y", "24", "stty raw -echo; exec cat -v") + run("set-option", "-g", "status", "off") + run("set-option", "-g", "assume-paste-time", "0") + run("set-option", "-s", "escape-time", "1000") + run("set-option", "-g", "@seen", "") + run("bind-key", "-n", "Escape", "set-option", "-gF", + "@seen", "#{@seen}E") + pid, fd = os.forkpty() + if pid == 0: + os.environ["TERM"] = "xterm-256color" + fcntl.ioctl(0, termios.TIOCSWINSZ, + struct.pack("HHHH", 24, 80, 0, 0)) + os.execv(tmux, server + ["attach-session", "-t", "replies"]) + output = b"" + deadline = time.monotonic() + 5 + while b"\033[?2026$p" not in output: + assert time.monotonic() < deadline, (context, "missing query") + if select.select([fd], [], [], .01)[0]: + output += os.read(fd, 65536) + baseline = state() + assert not matches(name, baseline), (context, baseline) + wire = (b"\033" if prefixed else b"") + report + if delivery == "invalid": + # A near match must fall back to ordinary key parsing. + wire = b"\033[?2026;5$y" + if delivery in ("whole", "invalid"): + chunks = [wire] + elif delivery in ("split", "expired"): + chunks = [wire[:-1], wire[-1:]] + else: + chunks = [wire[i:i+1] for i in range(len(wire))] + for part in chunks[:-1]: + os.write(fd, part) + time.sleep(.01) + actual = state() + assert actual == baseline, (context, "premature effect", actual) + assert run("capture-pane", "-p") == "", (context, "partial leak") + expected = "Z" + if delivery == "expired": + # Allow the partial reply's timer to expire. The pending + # query extends it to at least 500ms; wait for fallback + # rather than assuming a fixed expiration time. + expected = wire[:-1].replace(b"\033", b"^[").decode() + deadline = time.monotonic() + 5 + while run("capture-pane", "-p") != expected: + assert time.monotonic() < deadline, (context, "timer stalled") + if select.select([fd], [], [], .01)[0]: + os.read(fd, 65536) + if delivery in ("invalid", "expired"): + expected = wire.replace(b"\033", b"^[").decode() + "Z" + # A trailing key confirms that the entire reply has been + # processed, including cases with no extra Escape. + os.write(fd, chunks[-1] + b"Z") + deadline = time.monotonic() + 5 + while run("capture-pane", "-p") != expected: + assert time.monotonic() < deadline, (context, + "reply leaked or input stalled", run("capture-pane", "-p")) + if select.select([fd], [], [], .01)[0]: + os.read(fd, 65536) + actual = state() + if delivery in ("invalid", "expired"): + assert actual == baseline, (context, "unexpected effect", actual) + else: + assert matches(name, actual), (context, "missing effect", actual) + assert run("show-option", "-gv", "@seen") == ( + "E" if prefixed else ""), (context, "Escape count") + cases += 1 + finally: + run("kill-server", check=False) + if fd is not None: + os.close(fd) + if pid is not None: + os.waitpid(pid, 0) +PY diff --git a/regress/tty-keys-reply-escape.sh b/regress/tty-keys-reply-escape.sh new file mode 100644 index 000000000..41516f431 --- /dev/null +++ b/regress/tty-keys-reply-escape.sh @@ -0,0 +1,127 @@ +#!/bin/sh + +# An Escape followed by a terminal reply must remain a separate key. The +# reply's leading Escape must not be consumed as the second half of a Meta +# key, which would send the rest of the reply to the pane as text. A reply +# split before the end of a known key must wait for the rest. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +$INNER new-session -d -s inner -x80 -y10 "stty raw -echo; exec cat -v" || + exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g assume-paste-time 0 || exit 1 +$INNER set-option -s escape-time 1000 || exit 1 +$INNER unbind-key -a -T root || exit 1 +$OUTER new-session -d -s outer -x80 -y10 "$INNER attach -t inner" || exit 1 +$OUTER set-option -g assume-paste-time 0 || exit 1 + +i=0 +while [ -z "$($INNER list-clients -F '#{client_name}')" ]; do + [ "$i" -lt 100 ] || fail 'inner client did not attach' + sleep 0.05 + i=$((i + 1)) +done + +bind_key() +{ + $INNER bind-key -n "$1" send-keys -l "$2" || exit 1 +} +bind_key Escape E +bind_key M-Escape A +bind_key M-] B +bind_key M-O P +bind_key M-Up U + +send_raw() +{ + # Hex arguments avoid command separators in a partial sequence. + bytes=$(printf '%s' "$1" | od -An -v -tx1) + $OUTER send-keys -H $bytes || exit 1 +} + +capture() +{ + $INNER capture-pane -p | tr -d '\n' +} + +theme() +{ + $INNER list-clients -F '#{client_theme}' +} + +expected_output= +assert_keys() +{ + name=$1 + expected=$expected_output$2 + expected_theme=$3 + send_raw "$4" + if [ "$#" -eq 5 ]; then + sleep 0.05 + send_raw "$5" + fi + i=0 + while [ "$i" -lt 100 ]; do + actual=$(capture) + actual_theme=$(theme) + if [ "$actual" = "$expected" ] && + [ "$actual_theme" = "$expected_theme" ]; then + expected_output=$actual + return 0 + fi + sleep 0.05 + i=$((i + 1)) + done + fail "$name: got '$actual' theme=$actual_theme," \ + "expected '$expected' theme=$expected_theme" +} + +assert_keys 'Escape and dark theme report' E dark \ + "$(printf '\033\033[?997;1n')" +assert_keys 'Escape and light theme report' E light \ + "$(printf '\033\033[?997;2n')" +assert_keys 'split theme report' '' dark \ + "$(printf '\033[?997')" ';1n' +assert_keys 'Escape and light theme report, split' E light \ + "$(printf '\033\033[?99')" '7;2n' +assert_keys 'theme report split after two Escapes' E dark \ + "$(printf '\033\033')" '[?997;1n' +assert_keys 'Escape and light theme report again' E light \ + "$(printf '\033\033[?997;2n')" +assert_keys 'Escape and background colour' E light \ + "$(printf '\033\033]11;rgb:ffff/ffff/ffff\033\\')" +assert_keys 'Escape and foreground colour (BEL)' E light \ + "$(printf '\033\033]10;rgb:0000/0000/0000\007')" +assert_keys 'split background colour' E light \ + "$(printf '\033\033]11;rgb:ff')" "$(printf 'ff/ffff/ffff\033\\')" +assert_keys 'Escape and palette colour' E light \ + "$(printf '\033\033]4;1;rgb:ffff/0000/0000\033\\')" +assert_keys 'Escape and clipboard' E light \ + "$(printf '\033\033]52;c;dGVzdA==\007')" +assert_keys 'M-Escape' A light "$(printf '\033\033')" +assert_keys 'M-]' B light "$(printf '\033]')" +assert_keys 'M-O' P light "$(printf '\033O')" +assert_keys 'M-Up' U light "$(printf '\033\033[A')" + +exit 0