server-client: don't let a drag's own sync-start defer its redraw

server_client_key_callback()'s mouse-drag dispatch opens a
synchronized-output frame (tty_sync_start()) before running the drag
callback, on every single drag motion event - deliberately, so a fast-
path write and any later damage-composed correction land in one atomic
terminal update instead of two visible frames. But
server_client_check_redraw() later in the same pass checks
EVBUFFER_LENGTH(tty->out) != 0 to decide whether to defer this pass's
redraw, and nothing drains tty->out in between (the actual write
happens later, via libevent) - so the frame-open sequence just queued
(8 bytes: "\033[?2026h" on a synchronized-output-capable terminal)
makes that check see "outstanding output" and defer against itself,
escalating the drag's damage to a full-window redraw on every single
motion event. Confirmed via the server's own -vv log: a 6-step drag
produced five "redraw deferred (8 left)" lines, one per motion event,
with no fix.

Fix: record how much was already queued at the instant the sync frame
opened (tty->sync_offset), and have the redraw check discount
anything queued after that point - it's already part of the frame
this pass is committed to flushing, not a reason to defer. If the
buffer was genuinely non-empty before the frame opened, sync_offset
holds that real backlog and deferral still happens correctly.

regress/floating-pane-drag-sync-no-self-defer.sh drags a floating pane
on a synchronized-output-capable terminal and asserts the server log
never shows the self-inflicted 8-byte deferral. Verified failing 3/3
against the pre-fix code (5 occurrences per run, matching the manual
-vv repro) and passing 5/5 against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Grant
2026-09-22 10:06:09 +01:00
parent 4a1e445b38
commit a7f74730d7
4 changed files with 115 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
#!/bin/sh
# server_client_key_callback()'s mouse-drag dispatch opens a synchronized-
# output frame (tty_sync_start()) before running the drag callback, on
# every single drag motion event. server_client_check_redraw() then checks
# EVBUFFER_LENGTH(tty->out) != 0 later in the same pass to decide whether
# to defer this pass's redraw - nothing drains tty->out in between, so the
# frame-open sequence just queued (8 bytes: "\033[?2026h") makes that check
# see "outstanding output" and defer against itself, escalating the drag's
# damage to a full-window redraw on every motion event on any
# synchronized-output-capable terminal. This checks the server's own -vv
# log for that exact self-inflicted "8 left" deferral pattern during a
# drag, and requires it never appears.
PATH=/bin:/usr/bin
TERM=screen
LC_ALL=C.UTF-8
export PATH TERM LC_ALL
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
DIR=$(mktemp -d) || exit 1
cd "$DIR" || exit 1
INNER="$TEST_TMUX -vv -Lsyncdefer-inner-$$ -f/dev/null"
OUTER="$TEST_TMUX -Lsyncdefer-outer-$$ -f/dev/null"
fail()
{
echo "$*" >&2
exit 1
}
cleanup()
{
$OUTER kill-server 2>/dev/null
$INNER kill-server 2>/dev/null
cd /
rm -rf "$DIR"
}
trap cleanup 0 1 15
mouse()
{
sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4")
$OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1
sleep 0.15
}
$INNER new-session -d -s inner -x 40 -y 10 'sleep 100' || exit 1
$INNER set-option -g status off || exit 1
$INNER set-option -g window-size manual || exit 1
$INNER set-option -g mouse on || exit 1
FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 15 -y 5 -X 5 -Y 2 \
'sleep 100') || exit 1
$OUTER new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1
$OUTER set-option -g status off || exit 1
$OUTER set-option -g window-size manual || exit 1
$OUTER set-option -g default-terminal screen-256color || exit 1
$OUTER set-option -as terminal-features ',screen-256color:sync' || exit 1
$OUTER respawn-pane -k -t outer:0.0 \
"$TEST_TMUX -Lsyncdefer-inner-$$ -f/dev/null attach-session -t inner" ||
exit 1
sleep 0.5
XOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}')
YOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_top}')
GRABCOL=$((XOFF + 3))
BORDERROW=$YOFF
# Plain (non-Alt) top-border drag: "MouseDrag1Border" -> resize-pane -M ->
# a move, since grabbing the top border moves rather than resizes. Several
# small steps, each its own drag-motion event and so its own pass through
# the code under test.
mouse 0 "$GRABCOL" "$BORDERROW" M
i=0
while [ $i -lt 6 ]; do
GRABCOL=$((GRABCOL + 1))
mouse 32 "$GRABCOL" "$BORDERROW" M
i=$((i + 1))
done
mouse 0 "$GRABCOL" "$BORDERROW" m
sleep 0.3
NEWXOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}')
[ "$NEWXOFF" != "$XOFF" ] || fail "sanity: floating pane did not move (still at $XOFF)"
LOG=$(ls tmux-server*.log 2>/dev/null | head -1)
[ -n "$LOG" ] || fail "sanity: no server -vv log was produced"
n=$(grep -c "redraw deferred (8 left)" "$LOG")
[ "$n" -eq 0 ] ||
fail "drag self-deferred against its own queued sync bytes $n time(s)"
exit 0

View File

@@ -2429,8 +2429,19 @@ server_client_check_redraw(struct client *c)
* that clear and forces a full catch-up redraw once this client is
* unblocked, rather than trying to keep the fine-grained state
* around for a retry.
*
* If a synchronized-output frame is open, discount anything queued
* since it started (down to sync_offset, the length when it opened):
* those bytes are already part of the frame this pass is committed
* to flushing (see tty_sync_start()), not a reason to defer this
* pass's redraw - without this, a mouse-drag callback that itself
* opens the frame before writing anything would see its own
* just-queued bytes as "outstanding output" and defer against
* itself every single motion event.
*/
n = EVBUFFER_LENGTH(tty->out);
if ((tty->flags & TTY_SYNCING) && n > tty->sync_offset)
n = tty->sync_offset;
if (n != 0 || (tty->flags & TTY_BLOCK)) {
if (n != 0)
log_debug("%s: redraw deferred (%zu left)", c->name, n);

8
tmux.h
View File

@@ -1802,6 +1802,14 @@ struct tty {
struct event timer;
size_t discarded;
/*
* Buffer length at the instant a synchronized-output frame opened
* (tty_sync_start()), so server_client_check_redraw()'s "is there
* already outstanding output" check can discount whatever this
* pass itself queued into that frame - see tty_sync_start().
*/
size_t sync_offset;
struct termios tio;
struct grid_cell cell;

1
tty.c
View File

@@ -1519,6 +1519,7 @@ tty_sync_start(struct tty *tty)
if (tty->flags & TTY_SYNCING)
return;
tty->flags |= TTY_SYNCING;
tty->sync_offset = EVBUFFER_LENGTH(tty->out);
if (tty_term_has(tty->term, TTYC_SYNC)) {
log_debug("%s sync start", tty->client->name);