# BST-TIMEOUT: 300 # every session case waits on a real pty round-trip # # tmux 3.3a — the terminal multiplexer, driven entirely without a keyboard. # # THE SIBLING SUITE IS 89-screen.test, AND THE PAIR IS THE POINT. screen and # tmux solve the same problem with opposite architectures: screen's client # re-execs into a backend and talks to it over a named pipe plus signals, while # tmux runs a single server addressed over a UNIX-DOMAIN SOCKET and answers # every command on that socket. On this arm that difference is not academic — # brintos/linux#21 (signal delivery is observed only when the task next crosses # a dispatch boundary) is what forced a patch into screen's -Q query path, and # tmux's equivalent channel is a socket round trip that needs no delivery at # all. So this suite is also the control that tells a signal-delivery failure # apart from a pty failure: both multiplexers allocate ptys the same way, and # only one of them depends on signals to answer a question. # # WHAT EACH CASE ACTUALLY EXERCISES. `new-session -d` allocates a pty, forks a # shell onto its slave and returns without a terminal anywhere — so a passing # case has been through openpty/TIOCSCTTY/termios, the same path screen's # hardcopy cases cover. `send-keys` writes into that pty and `capture-pane -p` # reads back what tmux's own terminal emulator painted, which closes the loop # on the emulator end. The split cases then ask the layout engine to report its # own geometry, and `kill-server` is the only case that asserts on the # filesystem rather than on tmux's word for it. # # EXECUTION IS PROVEN BY ARITHMETIC, NOT BY THE MARKER ALONE. A pane echoes # what is typed into it, so a marker appearing in the capture proves the # emulator painted the KEYSTROKES and nothing more. Sending `expr 6 \* 42` and # asserting on `252` — a string that appears nowhere in the input — is what # separates "the pty carried the typing" from "the shell on the far end ran # it". The marker cases are kept beside it because they localise a failure: # marker-but-no-product means the shell never ran, no-marker-at-all means the # pty round trip never completed. . "$BST_LIB" require tmux tmux/present SD=${TMPDIR:-/tmp}/bst-tmux.$$ SOCK=$SD/sock SESS=bsttmux$$ rm -rf "$SD" mkdir -p "$SD" 2>/dev/null chmod 700 "$SD" 2>/dev/null TERM=${TERM:-xterm}; export TERM # Every command is addressed with -S so the suite owns its server outright: # the default socket lives under /tmp/tmux-/ and would let a stray server # from an earlier run — or from a human on the same machine — answer these # cases. It also makes the teardown assertion possible at all, since the socket # whose disappearance is checked is one this suite named. tm() { tmux -S "$SOCK" "$@"; } # capture the pane and wait for to appear in it, bounded. # # Polling rather than one fixed sleep: the guest is far slower than the host # these expectations were taken on, so a fixed delay would either flake or # spend the suite's whole budget waiting for something already done. Unlike # screen's hardcopy there is no file to clear between rounds — capture-pane -p # writes to stdout — so each round is independent by construction. _cap_wait() { _needle=$1; _tries=$2 while [ "$_tries" -gt 0 ]; do if tm capture-pane -p -t "$SESS" 2>/dev/null | grep -q "$_needle"; then return 0; fi _tries=$((_tries - 1)); sleep 1 done return 1 } # --- CLI surface, no server required ---------------------------------------- check_out tmux/version "tmux 3.3a" tmux -V # `ls` against a socket with no server behind it is an ERROR exit by design, # and it is how a script asks "is anything up?" before starting one. out=$(tm ls 2>&1); rc=$? if [ "$rc" -ne 0 ]; then bst_ok tmux/ls-empty-rc else bst_notok tmux/ls-empty-rc "want non-zero with no server, got 0 -- $(_bst_1line "$out")"; fi case "$out" in *"no server running"*|*"No such file"*|*"error connecting"*) bst_ok tmux/ls-empty-text ;; *) bst_notok tmux/ls-empty-text "$(_bst_1line "$out")" ;; esac # --- session lifecycle: the pty half ---------------------------------------- check tmux/new-detached tm new-session -d -s "$SESS" /bin/sh # The server starts asynchronously; wait for it rather than racing it. tries=15 while [ "$tries" -gt 0 ]; do tm has-session -t "$SESS" >/dev/null 2>&1 && break tries=$((tries - 1)); sleep 1 done check tmux/has-session tm has-session -t "$SESS" out=$(tm ls 2>&1) case "$out" in *"$SESS"*) bst_ok tmux/ls-names-session ;; *) bst_notok tmux/ls-names-session "$(_bst_1line "$out")" ;; esac # The socket is the whole of tmux's addressing story on this arm, so assert it # exists now — otherwise the teardown case at the end could pass against a # socket that was never created. if [ -S "$SOCK" ]; then bst_ok tmux/socket-present else bst_notok tmux/socket-present "no socket at $SOCK"; fi # A detached session reports exactly one attached client: none. check_out tmux/session-detached "0" tm display-message -p -t "$SESS" '#{session_attached}' # --- send-keys -> capture-pane: the end-to-end pty round trip --------------- check tmux/send-keys-rc tm send-keys -t "$SESS" 'echo BST-TMUX-MARKER; expr 6 \* 42' Enter if _cap_wait BST-TMUX-MARKER 30; then bst_ok tmux/capture-pane bst_ok tmux/send-keys-painted else # Separate the two failures the one wait can mean: capture-pane returning # nothing at all (the emulator or the capture path is broken) vs. returning # a pane that never carried the marker (the pty round trip never # completed). Re-read once, unbounded by the needle, to tell them apart. cap=$(tm capture-pane -p -t "$SESS" 2>&1) if [ -n "$cap" ]; then bst_ok tmux/capture-pane bst_notok tmux/send-keys-painted "pane captured but no marker after 30s -- $(_bst_1line "$cap")" else bst_notok tmux/capture-pane "capture-pane produced nothing" bst_notok tmux/send-keys-painted "no pane to read the marker from" fi fi # 252 appears nowhere in what was typed, so it can only have come from the # shell on the far end of the pty actually running the command. if _cap_wait '^252$' 20; then bst_ok tmux/shell-ran-command else bst_notok tmux/shell-ran-command "no 252 in pane -- $(_bst_1line "$(tm capture-pane -p -t "$SESS" 2>&1)")" fi # --- window management: the layout engine ----------------------------------- check_out tmux/panes-one-before "1" tm display-message -p -t "$SESS" '#{window_panes}' check tmux/split-window tm split-window -v -t "$SESS" check_out tmux/panes-two-after "2" tm display-message -p -t "$SESS" '#{window_panes}' n=$(tm list-panes -t "$SESS" 2>/dev/null | grep -c .) if [ "${n:-0}" -eq 2 ]; then bst_ok tmux/list-panes-two else bst_notok tmux/list-panes-two "list-panes reported ${n:-0} pane(s), want 2"; fi # The layout string is tmux's own account of the geometry it just produced: a # checksum, the window box, and then a BRACKETED list of the panes a split # created. An unsplit window has no brackets at all, so their presence is the # split reported by the engine rather than inferred from a pane count. lay=$(tm display-message -p -t "$SESS" '#{window_layout}' 2>&1) case "$lay" in *"["*"]"*) bst_ok tmux/layout-has-split ;; *) bst_notok tmux/layout-has-split "no bracketed split in layout -- $(_bst_1line "$lay")" ;; esac # A vertical split stacks the panes, so the two must share a column origin and # differ in their row origin. Read from tmux's per-pane geometry rather than # parsed out of the layout string, so the two accounts are independent. tops=$(tm list-panes -t "$SESS" -F '#{pane_top}' 2>/dev/null | tr '\n' ' ') lefts=$(tm list-panes -t "$SESS" -F '#{pane_left}' 2>/dev/null | tr '\n' ' ') case "$tops" in "0 0 "|"0 0") bst_notok tmux/split-is-vertical "both panes at row 0 -- not stacked ($tops)" ;; *) case "$lefts" in "0 0 "|"0 0") bst_ok tmux/split-is-vertical ;; *) bst_notok tmux/split-is-vertical "panes not column-aligned (lefts=$lefts tops=$tops)" ;; esac ;; esac # --- teardown ---------------------------------------------------------------- # # "kill-server and the socket is gone" IS NOT WHAT tmux DOES, and asserting it # literally would ship a red against correct behaviour. tmux 3.3a's only # unlink of the socket path is in server_create_socket() (server.c:118), where # a STARTING server clears a stale path before bind; nothing unlinks it on the # way out. Measured the same way on a native tmux: after kill-server the inode # is still there, `stat` still calls it a socket, and connecting to it yields # "no server running on ". # # So the teardown is asserted the way a script would actually establish it — # the server no longer answers, and the path is reusable — rather than on the # presence of a file tmux never promised to remove. The reuse case is the # stronger of the two: it is the one that would catch a teardown that left the # path bound, or a stale-socket unlink that stopped working. check tmux/kill-server tm kill-server tries=15 while [ "$tries" -gt 0 ]; do tm has-session -t "$SESS" >/dev/null 2>&1 || break tries=$((tries - 1)); sleep 1 done out=$(tm ls 2>&1); rc=$? if [ "$rc" -ne 0 ]; then bst_ok tmux/ls-after-kill else bst_notok tmux/ls-after-kill "server still answering after kill-server -- $(_bst_1line "$out")"; fi case "$out" in *"no server running"*|*"No such file"*|*"error connecting"*) bst_ok tmux/server-gone-text ;; *) bst_notok tmux/server-gone-text "$(_bst_1line "$out")" ;; esac # The path is reusable: a second server binds it, which is server_create_socket # unlinking the stale entry and binding again. This is the case that gives # "the socket is gone" its operational meaning on a tmux that leaves the inode. if tm new-session -d -s "${SESS}b" /bin/sh >/dev/null 2>&1; then tries=15 while [ "$tries" -gt 0 ]; do tm has-session -t "${SESS}b" >/dev/null 2>&1 && break tries=$((tries - 1)); sleep 1 done if tm has-session -t "${SESS}b" >/dev/null 2>&1; then bst_ok tmux/socket-path-reusable else bst_notok tmux/socket-path-reusable "second server started but its session never appeared"; fi else bst_notok tmux/socket-path-reusable "cannot start a second server on $SOCK after kill-server" fi tm kill-server >/dev/null 2>&1 rm -rf "$SD"