From 6fc44b000fff848a298b1b6befa84988e7766e71 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 11 Jul 2026 22:16:37 -0700 Subject: [PATCH 1/6] Add remote tmux layout repro harness --- scripts/remote-tmux-fuzz-host.sh | 89 +++++++ scripts/remote-tmux-layout-repro.sh | 344 ++++++++++++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100755 scripts/remote-tmux-fuzz-host.sh create mode 100755 scripts/remote-tmux-layout-repro.sh diff --git a/scripts/remote-tmux-fuzz-host.sh b/scripts/remote-tmux-fuzz-host.sh new file mode 100755 index 000000000000..3b8348492c85 --- /dev/null +++ b/scripts/remote-tmux-fuzz-host.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Stand up (or restart) the local ssh host that the remote-tmux harnesses +# drive: a loopback-only sshd whose logins land in an isolated tmux server. +# Everything the harnesses call "remote" — ssh, tmux control mode, the +# mirror connection — runs against 127.0.0.1 with generated keys, so the +# repro and fuzz scripts work on any machine with no real network, no MFA, +# and no risk to the user's own tmux server. +# +# The sshd caps itself at one session (MaxSessions 1), matching hosts that +# limit concurrent ssh sessions — the constrained regime remote-tmux has to +# handle by sharing a single connection. The forced command in +# authorized_keys pins TMUX_TMPDIR to this host's own directory, so its +# tmux server can be created and killed freely without ever touching the +# default tmux socket. +# +# Usage: scripts/remote-tmux-fuzz-host.sh [name] [port] +# Then add the printed Host block to ~/.ssh/config (the app resolves the +# alias from there), and run e.g.: +# CMUX_TAG= scripts/remote-tmux-live-fuzz.sh 1 25 +set -eu + +NAME="${1:-cmux-fuzzhost}" +PORT="${2:-2296}" +DIR="/tmp/${NAME}-sshd" +TMUX_DIR="/tmp/${NAME}" + +mkdir -p "$DIR" "$TMUX_DIR" +[ -f "$DIR/hostkey" ] || ssh-keygen -q -t ed25519 -N '' -f "$DIR/hostkey" +[ -f "$DIR/clientkey" ] || ssh-keygen -q -t ed25519 -N '' -f "$DIR/clientkey" + +cat > "$DIR/wrap.sh" < "$DIR/authorized_keys" +chmod 600 "$DIR/authorized_keys" + +cat > "$DIR/sshd_config" </dev/null; then + kill "$(cat "$DIR/sshd.pid")" + sleep 1 +fi +/usr/sbin/sshd -f "$DIR/sshd_config" -E "$DIR/sshd.log" + +# Prove a login lands in the pinned tmux dir before calling it ready. +probe=$(ssh -p "$PORT" -i "$DIR/clientkey" -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$DIR/known_hosts" \ + "$USER@127.0.0.1" 'echo "$TMUX_TMPDIR"; tmux -V') +printf 'probe: %s\n' "$probe" | tr '\n' ' '; echo +case "$probe" in + "$TMUX_DIR"*) ;; + *) echo "FAIL: login did not land in $TMUX_DIR (is another sshd on port $PORT?)" >&2 + exit 1 ;; +esac + +echo "ready. If ~/.ssh/config lacks it, add:" +cat <&2 + exit 2 +} + +PYTHON_BIN="$(command -v python3 || true)" +[ -n "$PYTHON_BIN" ] || { + echo "ERROR: python3 is required for parsing cmux JSON output" >&2 + exit 2 +} + +if [ -z "$TMUXBIN" ]; then + for candidate in /opt/homebrew/bin/tmux /usr/local/bin/tmux /usr/bin/tmux; do + if [ -x "$candidate" ]; then TMUXBIN="$candidate"; break; fi + done +fi +[ -n "$TMUXBIN" ] || { + echo "ERROR: tmux not found at /opt/homebrew/bin/tmux, /usr/local/bin/tmux, or /usr/bin/tmux" >&2 + exit 2 +} + +[ -x "$SHIM" ] || { + echo "ERROR: missing executable ssh shim: $SHIM" >&2 + exit 2 +} + +wait_until() { # $1=timeout_s, $2..=predicate command + local timeout_s="$1"; shift + local deadline=$((SECONDS + timeout_s)) + until "$@"; do + [ "$SECONDS" -ge "$deadline" ] && return 1 + sleep 0.2 + done +} + +srv() { + (unset TMUX; TMUX_TMPDIR="$SRVDIR" "$TMUXBIN" "$@") +} + +cli() { + CMUX_TAG="$CMUX_TAG" "$TIMEOUT_BIN" 90 "$REPO/scripts/cmux-debug-cli.sh" "$@" 2>&1 +} + +norm() { + sed 's/[[:space:]]*$//' | awk '{a[NR]=$0} END{last=NR; while(last>0 && a[last]=="") last--; for(i=1;i<=last;i++) print a[i]}' +} + +remote_visible() { + srv capture-pane -p -J -t "$1" 2>/dev/null | norm +} + +mirror_visible() { + cli read-screen --window "$MIRROR_WINDOW" 2>/dev/null | norm +} + +srv_down() { + ! srv list-sessions >/dev/null 2>&1 +} + +reset_srv() { + mkdir -p "$SRVDIR" + srv kill-server >/dev/null 2>&1 || true + wait_until 5 srv_down || true +} + +ruler_command() { + local width="$1" + printf "%s" "CMUX_RULER_WIDTH=$width /bin/sh -c 'w=\${CMUX_RULER_WIDTH:-80}; printf \"RULER\\n\"; i=1; while [ \"\$i\" -le \"\$w\" ]; do printf \"%d\" \$((i % 10)); i=\$((i + 1)); done; printf \"\\n\"; exec sleep 3600'" +} + +pane_has_ruler() { + srv capture-pane -p -t "$1" 2>/dev/null | grep -q '^RULER$' +} + +run_ruler_in_pane() { + local pane="$1" width command + width="$(srv display-message -p -t "$pane" '#{pane_width}' 2>/dev/null)" || return 1 + command="$(ruler_command "$width")" + srv respawn-pane -k -t "$pane" "$command" >/dev/null || return 1 + wait_until 8 pane_has_ruler "$pane" +} + +run_rulers() { + local pane + while IFS= read -r pane; do + [ -n "$pane" ] || continue + run_ruler_in_pane "$pane" || { + echo "ERROR: ruler did not paint in $pane" >&2 + return 1 + } + done < <(srv list-panes -s -t "$SESSION" -F '#{pane_id}') +} + +build_sixcol() { + local panes=() + local widths i observed pane + srv new-session -d -s "$SESSION" -x 154 -y 30 -n sixcol >/dev/null || return 1 + for _ in 1 2 3 4 5; do + srv split-window -h -t "$SESSION:sixcol" >/dev/null || return 1 + done + srv select-layout -t "$SESSION:sixcol" even-horizontal >/dev/null || return 1 + + while IFS= read -r pane; do + [ -n "$pane" ] && panes+=("$pane") + done < <(srv list-panes -t "$SESSION:sixcol" -F '#{pane_id}') + widths=(30 20 12 38 25 24) + [ "${#panes[@]}" -eq 6 ] || return 1 + for _ in 1 2; do + for i in "${!widths[@]}"; do + srv resize-pane -t "${panes[$i]}" -x "${widths[$i]}" >/dev/null || return 1 + done + done + observed="$(srv list-panes -t "$SESSION:sixcol" -F '#{pane_width}' | paste -sd/ -)" + echo "sixcol pane widths: $observed" +} + +build_deep() { + local target direction percent + srv new-window -t "$SESSION" -n deep >/dev/null || return 1 + target="$(srv list-panes -t "$SESSION:deep" -F '#{pane_id}' | head -1)" + while IFS=: read -r direction percent; do + target="$(srv split-window "$direction" -p "$percent" -P -F '#{pane_id}' -t "$target")" || return 1 + done <<'EOF' +-h:63 +-v:58 +-h:52 +-v:47 +-h:41 +EOF + echo "deep panes: $(srv list-panes -t "$SESSION:deep" -F '#{pane_id}' | paste -sd' ' -)" +} + +build_lab() { + reset_srv + build_sixcol || return 1 + build_deep || return 1 + srv select-window -t "$SESSION:sixcol" >/dev/null || return 1 + run_rulers || return 1 +} + +window_ids() { + cli list-windows | awk '{for(i=1;i<=NF;i++) if($i ~ /^selected_workspace=/ && $(i-1) ~ /'"$UUID"'/) print $(i-1)}' +} + +attach_mirror() { + local output status + echo "Attaching with: CMUX_TAG=$CMUX_TAG scripts/cmux-debug-cli.sh ssh-tmux $HOST" + output="$(cli ssh-tmux "$HOST")" + status=$? + printf '%s\n' "$output" + [ "$status" -eq 0 ] || return "$status" + MIRROR_WINDOW="$(printf '%s\n' "$output" | awk -F'window=' '/^OK host=/{split($2,a," "); print a[1]; exit}')" + if [ -z "$MIRROR_WINDOW" ]; then + # Fallback for older CLI output: pick the key/newest cmux window. + MIRROR_WINDOW="$(window_ids | tail -1)" + fi + [ -n "$MIRROR_WINDOW" ] +} + +panel_id_for_title() { + local title="$1" + cli --json list-panels --window "$MIRROR_WINDOW" 2>/dev/null | "$PYTHON_BIN" -c ' +import json, sys +title = sys.argv[1] +try: + data = json.load(sys.stdin) +except Exception: + sys.exit(1) +for item in data.get("surfaces", []): + if item.get("title") == title: + value = item.get("id") or item.get("handle") + if value: + print(value) + sys.exit(0) +sys.exit(1) +' "$title" +} + +focus_tab() { + local title="$1" panel deadline + deadline=$((SECONDS + 20)) + while [ "$SECONDS" -lt "$deadline" ]; do + panel="$(panel_id_for_title "$title" 2>/dev/null || true)" + if [ -n "$panel" ]; then + cli focus-panel --window "$MIRROR_WINDOW" --panel "$panel" >/dev/null && return 0 + fi + sleep 0.4 + done + return 1 +} + +R="" +M="" +compare_pane_settled() { + local pane="$1" deadline + deadline=$((SECONDS + 20)) + while :; do + srv select-pane -t "$pane" >/dev/null 2>&1 || true + R="$(remote_visible "$pane" || true)" + M="$(mirror_visible || true)" + if [ -n "$R" ] && [ "$R" = "$M" ]; then + return 0 + fi + [ "$SECONDS" -ge "$deadline" ] && return 1 + sleep 0.5 + done +} + +PASS=0 +FAIL=0 +FAILED="" + +record_pass() { + PASS=$((PASS + 1)) + echo " PASS $1" +} + +record_fail() { + FAIL=$((FAIL + 1)) + FAILED="$FAILED $1" + echo " FAIL $1" +} + +compare_window_panes() { + local window_name="$1" pane label + if ! focus_tab "$window_name"; then + while IFS= read -r pane; do + [ -n "$pane" ] || continue + record_fail "$window_name/$pane(no-tab)" + done < <(srv list-panes -t "$SESSION:$window_name" -F '#{pane_id}') + return + fi + + while IFS= read -r pane; do + [ -n "$pane" ] || continue + label="$window_name/$pane" + if compare_pane_settled "$pane"; then + record_pass "$label content" + else + record_fail "$label content" + echo " remote=$(printf '%s' "$R" | grep -c .) non-blank lines mirror=$(printf '%s' "$M" | grep -c .) non-blank lines" + paste <(printf '%s\n' "$R" | cat -n) <(printf '%s\n' "$M" | cat -n) | head -10 | sed 's/^/ /' + fi + done < <(srv list-panes -t "$SESSION:$window_name" -F '#{pane_id}') +} + +window_size() { + srv display-message -p -t "$SESSION:$1" '#{window_width}x#{window_height}' 2>/dev/null +} + +check_window_stability() { + local window_name="$1" first="" size="" changed=0 samples=() i + for i in 1 2 3 4 5; do + size="$(window_size "$window_name" || true)" + samples+=("$size") + if [ -z "$first" ]; then + first="$size" + elif [ "$size" != "$first" ]; then + changed=1 + fi + [ "$i" -lt 5 ] && sleep 2 + done + if [ "$changed" -eq 0 ] && [ -n "$first" ]; then + record_pass "$window_name stable ${samples[*]}" + else + record_fail "$window_name size-changed ${samples[*]}" + fi +} + +cleanup() { + if [ "${CMUX_LAYOUT_KEEP_TMUX:-0}" != "1" ]; then + srv kill-server >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +echo "=== remote-tmux layout repro tag=$CMUX_TAG host=$HOST tmux_tmpdir=$SRVDIR ===" +echo "Expected tagged app env: CMUX_REMOTE_TMUX_SSH_FOR_TESTING=$SHIM TMUX_TMPDIR=$SRVDIR" + +build_lab || { + echo "ERROR: failed to build isolated tmux lab under $SRVDIR" >&2 + exit 2 +} + +attach_mirror || { + cat >&2 < Date: Sat, 11 Jul 2026 23:50:17 -0700 Subject: [PATCH 2/6] remote-tmux: extract the divider plan and fuzz the native mirror layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the mirror's divider-fraction walk out of the bonsplit applier into RemoteTmuxNativeSplitLayout.plan, a pure function from (measured tree, metrics, container size) to per-split fractions and per-pane outer sizes, modeling the native split view's whole-point division. The mirror now just zips the plan onto the bonsplit tree. Add a seeded fuzz that drives random layouts and containers through the claim (clientGrid), a tmux-style cell assignment, and this exact walk, then derives each pane's rendered grid from its outer size the way the terminal surface does. It asserts every pane renders at least its assigned span — one column short means every full-width line in that pane wraps. The fuzz FAILS on this commit: proportional ideal-over-ideal fractions let whole-point rounding compound down the binary split chain, so in a near-exact container the deepest panes come up short. The fix follows. --- Sources/RemoteTmuxNativeSplitLayout.swift | 84 ++++ Sources/RemoteTmuxWindowMirror+Bonsplit.swift | 28 +- cmux.xcodeproj/project.pbxproj | 8 + ...emoteTmuxNativeMirrorLayoutFuzzTests.swift | 375 ++++++++++++++++++ 4 files changed, 480 insertions(+), 15 deletions(-) create mode 100644 Sources/RemoteTmuxNativeSplitLayout.swift create mode 100644 cmuxTests/RemoteTmuxNativeMirrorLayoutFuzzTests.swift diff --git a/Sources/RemoteTmuxNativeSplitLayout.swift b/Sources/RemoteTmuxNativeSplitLayout.swift new file mode 100644 index 000000000000..9f135428a456 --- /dev/null +++ b/Sources/RemoteTmuxNativeSplitLayout.swift @@ -0,0 +1,84 @@ +import Bonsplit +import Foundation + +/// The pure sizing walk behind the mirror's divider imposition: given a +/// measured tmux split tree, the native metrics, and the container's actual +/// size, decide every split's divider fraction and every pane's resulting +/// outer size, mirroring the native split view's whole-point division. +/// +/// Extracted from the mirror so the whole pipeline — claim, ideals, +/// fractions, per-level rounding — is testable without views: the fuzz +/// drives random trees and containers through this exact walk and checks +/// every pane still derives its assigned tmux span. +enum RemoteTmuxNativeSplitLayout { + /// One node of the computed plan, shaped like the measured tree it was + /// derived from. Fractions apply to splits in the same positions. + indirect enum Plan { + case leaf(paneId: Int?, outer: CGSize?) + case split(orientation: SplitOrientation, fraction: CGFloat, first: Plan, second: Plan) + } + + /// Computes the divider plan: each split's fraction divides the actual + /// extent proportionally between the two subtrees' ideal extents, and + /// the walk models the native split view's whole-point division to + /// derive the outer size every pane will receive. + static func plan( + tree: RemoteTmuxNativeMeasuredSplitTree, + metrics: RemoteTmuxNativeLayoutMetrics, + parentSize: CGSize? + ) -> Plan { + switch tree { + case .atomic(let layout, _): + var paneId: Int? + if case .pane(let id) = layout.content { paneId = id } + return .leaf(paneId: paneId, outer: parentSize) + case .split(_, _, let orientation, let firstTree, let secondTree): + let fraction = metrics.dividerFraction( + first: firstTree, + second: secondTree, + orientation: orientation + ) + var firstSize: CGSize? + var secondSize: CGSize? + if let parentSize { + let parentExtent = orientation == .horizontal + ? parentSize.width + : parentSize.height + let available = max(0, parentExtent - metrics.dividerThickness) + let firstExtent = (available * fraction).rounded() + let secondExtent = max(0, available - firstExtent) + if orientation == .horizontal { + firstSize = CGSize(width: firstExtent, height: parentSize.height) + secondSize = CGSize(width: secondExtent, height: parentSize.height) + } else { + firstSize = CGSize(width: parentSize.width, height: firstExtent) + secondSize = CGSize(width: parentSize.width, height: secondExtent) + } + } + return .split( + orientation: orientation, + fraction: fraction, + first: plan(tree: firstTree, metrics: metrics, parentSize: firstSize), + second: plan(tree: secondTree, metrics: metrics, parentSize: secondSize) + ) + } + } + + /// Flattens a plan's leaves into pane-id → outer size (panes whose size + /// the plan could not model are absent). + static func outerSizes(of plan: Plan) -> [Int: CGSize] { + var sizes: [Int: CGSize] = [:] + collectOuterSizes(plan, into: &sizes) + return sizes + } + + private static func collectOuterSizes(_ plan: Plan, into sizes: inout [Int: CGSize]) { + switch plan { + case .leaf(let paneId, let outer): + if let paneId, let outer { sizes[paneId] = outer } + case .split(_, _, let first, let second): + collectOuterSizes(first, into: &sizes) + collectOuterSizes(second, into: &sizes) + } + } +} diff --git a/Sources/RemoteTmuxWindowMirror+Bonsplit.swift b/Sources/RemoteTmuxWindowMirror+Bonsplit.swift index c0b8629ec45c..686a82aa9f91 100644 --- a/Sources/RemoteTmuxWindowMirror+Bonsplit.swift +++ b/Sources/RemoteTmuxWindowMirror+Bonsplit.swift @@ -104,37 +104,35 @@ extension RemoteTmuxWindowMirror { let treeNode = bonsplitController.treeSnapshot() let splitTree = RemoteTmuxNativeSplitTree(layout: renderedLayout) if let metrics = nativeLayoutMetrics() { - applyDividerPositions( - tmuxTree: RemoteTmuxNativeMeasuredSplitTree( + let plan = RemoteTmuxNativeSplitLayout.plan( + tree: RemoteTmuxNativeMeasuredSplitTree( tree: splitTree, metrics: metrics ), - treeNode: treeNode, - metrics: metrics + metrics: metrics, + parentSize: containerSizePt ) + applyDividerPositions(plan: plan, treeNode: treeNode) } else { applyFallbackDividerPositions(tmuxTree: splitTree, treeNode: treeNode) } } + /// Applies a computed divider plan (``RemoteTmuxNativeSplitLayout``) to + /// the bonsplit tree — position-by-position, so the plan's shape must + /// match the snapshot it was computed against. func applyDividerPositions( - tmuxTree: RemoteTmuxNativeMeasuredSplitTree, - treeNode: ExternalTreeNode, - metrics: RemoteTmuxNativeLayoutMetrics + plan: RemoteTmuxNativeSplitLayout.Plan, + treeNode: ExternalTreeNode ) { guard case .split(let split) = treeNode, - case .split(_, _, let orientation, let firstTree, let secondTree) = tmuxTree, + case .split(let orientation, let fraction, let firstPlan, let secondPlan) = plan, split.orientation == (orientation == .horizontal ? "horizontal" : "vertical"), let splitId = UUID(uuidString: split.id) else { return } - let fraction = metrics.dividerFraction( - first: firstTree, - second: secondTree, - orientation: orientation - ) _ = bonsplitController.setDividerPosition(fraction, forSplit: splitId, fromExternal: true) lastDividerPositions[splitId] = fraction - applyDividerPositions(tmuxTree: firstTree, treeNode: split.first, metrics: metrics) - applyDividerPositions(tmuxTree: secondTree, treeNode: split.second, metrics: metrics) + applyDividerPositions(plan: firstPlan, treeNode: split.first) + applyDividerPositions(plan: secondPlan, treeNode: split.second) } func applyFallbackDividerPositions( diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 0a0d9e79237d..9862933ca97c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1100,6 +1100,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 7368BEEF7368BEEF7368B002 /* RemoteTmuxMissingTmuxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7368BEEF7368BEEF7368B001 /* RemoteTmuxMissingTmuxTests.swift */; }; 740600000000000000000013 /* RemoteTmuxNativeLayoutMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740600000000000000000012 /* RemoteTmuxNativeLayoutMetrics.swift */; }; 74060000000000000000001F /* RemoteTmuxNativeMeasuredSplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74060000000000000000001E /* RemoteTmuxNativeMeasuredSplitTree.swift */; }; + B8E2A4C1D5F3096871A2B3C3 /* RemoteTmuxNativeMirrorLayoutFuzzTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8E2A4C1D5F3096871A2B3C4 /* RemoteTmuxNativeMirrorLayoutFuzzTests.swift */; }; + B8E2A4C1D5F3096871A2B3C1 /* RemoteTmuxNativeSplitLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8E2A4C1D5F3096871A2B3C2 /* RemoteTmuxNativeSplitLayout.swift */; }; 74060000000000000000001D /* RemoteTmuxNativeSplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74060000000000000000001C /* RemoteTmuxNativeSplitTree.swift */; }; 0C7D0CDD0C7D0CDD0C7D0002 /* RemoteTmuxNewWindowCwdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D0CDD0C7D0CDD0C7D0001 /* RemoteTmuxNewWindowCwdTests.swift */; }; E9A8CC35D632F14A8669B7D1 /* RemoteTmuxPaneForegroundState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 123BF93B1ADC00C5D25EE028 /* RemoteTmuxPaneForegroundState.swift */; }; @@ -2820,6 +2822,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7368BEEF7368BEEF7368B001 /* RemoteTmuxMissingTmuxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMissingTmuxTests.swift; sourceTree = ""; }; 740600000000000000000012 /* RemoteTmuxNativeLayoutMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxNativeLayoutMetrics.swift; sourceTree = ""; }; 74060000000000000000001E /* RemoteTmuxNativeMeasuredSplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxNativeMeasuredSplitTree.swift; sourceTree = ""; }; + B8E2A4C1D5F3096871A2B3C4 /* RemoteTmuxNativeMirrorLayoutFuzzTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxNativeMirrorLayoutFuzzTests.swift; sourceTree = ""; }; + B8E2A4C1D5F3096871A2B3C2 /* RemoteTmuxNativeSplitLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxNativeSplitLayout.swift; sourceTree = ""; }; 74060000000000000000001C /* RemoteTmuxNativeSplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxNativeSplitTree.swift; sourceTree = ""; }; 0C7D0CDD0C7D0CDD0C7D0001 /* RemoteTmuxNewWindowCwdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxNewWindowCwdTests.swift; sourceTree = ""; }; 123BF93B1ADC00C5D25EE028 /* RemoteTmuxPaneForegroundState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxPaneForegroundState.swift; sourceTree = ""; }; @@ -4527,6 +4531,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 740600000000000000000012 /* RemoteTmuxNativeLayoutMetrics.swift */, 74060000000000000000001A /* BonsplitConfiguration+RemoteTmuxEmbedded.swift */, 74060000000000000000001C /* RemoteTmuxNativeSplitTree.swift */, + B8E2A4C1D5F3096871A2B3C2 /* RemoteTmuxNativeSplitLayout.swift */, 74060000000000000000001E /* RemoteTmuxNativeMeasuredSplitTree.swift */, 4AFCBA6217C0EB912342F0A5 /* RemoteTmuxWindowMirrorSizingSnapshot.swift */, 740600000000000000000007 /* RemoteTmuxWindowMirrorSplitView.swift */, @@ -5149,6 +5154,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 736200000000000000000005 /* RemoteTmuxMirrorLifecycleTests.swift */, 736200000000000000000007 /* RemoteTmuxMirrorTargetingTests.swift */, 31FFFE3EFCDCCD6BDF405370 /* RemoteTmuxSessionSnapshotTests.swift */, + B8E2A4C1D5F3096871A2B3C4 /* RemoteTmuxNativeMirrorLayoutFuzzTests.swift */, 08850489C70ECFACA540C2F3 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift */, B561A95C6DC21BA9C4B2BCDD /* WorkspaceEnvironmentTests.swift */, 4F34462AE2F2EECEF5298031 /* GhosttyOptionAsAltModsTests.swift */, @@ -6319,6 +6325,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F861B8D7C62A0BCB252A523A /* RemoteTmuxMirrorTabActivity.swift in Sources */, 740600000000000000000013 /* RemoteTmuxNativeLayoutMetrics.swift in Sources */, 74060000000000000000001F /* RemoteTmuxNativeMeasuredSplitTree.swift in Sources */, + B8E2A4C1D5F3096871A2B3C1 /* RemoteTmuxNativeSplitLayout.swift in Sources */, 74060000000000000000001D /* RemoteTmuxNativeSplitTree.swift in Sources */, E9A8CC35D632F14A8669B7D1 /* RemoteTmuxPaneForegroundState.swift in Sources */, 7371A0010000000000000001 /* RemoteTmuxPendingLayout.swift in Sources */, @@ -7185,6 +7192,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D4F8A2E61C5B39707A8E6F12 /* RemoteTmuxMirrorSplitRoutingTests.swift in Sources */, 736200000000000000000008 /* RemoteTmuxMirrorTargetingTests.swift in Sources */, 7368BEEF7368BEEF7368B002 /* RemoteTmuxMissingTmuxTests.swift in Sources */, + B8E2A4C1D5F3096871A2B3C3 /* RemoteTmuxNativeMirrorLayoutFuzzTests.swift in Sources */, 0C7D0CDD0C7D0CDD0C7D0002 /* RemoteTmuxNewWindowCwdTests.swift in Sources */, 7020C0DE7020C0DE7020A002 /* RemoteTmuxProxyTransportRetryTests.swift in Sources */, 6B14EBE2544B2139E2902453 /* RemoteTmuxRectPublicationTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxNativeMirrorLayoutFuzzTests.swift b/cmuxTests/RemoteTmuxNativeMirrorLayoutFuzzTests.swift new file mode 100644 index 000000000000..a5190d45d311 --- /dev/null +++ b/cmuxTests/RemoteTmuxNativeMirrorLayoutFuzzTests.swift @@ -0,0 +1,375 @@ +import Bonsplit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Deterministic seedable RNG (SplitMix64): every failure reproduces from +/// the seed + trial printed in the assertion message. +private struct SplitMix64 { + private var state: UInt64 + + init(seed: UInt64) { state = seed } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } +} + +/// Seeded fuzz of the native mirror sizing pipeline, end to end and pure: +/// claim a client grid for a random container (`clientGrid`), assign the +/// claimed cells across a random tree the way tmux does, then run the exact +/// divider walk the mirror applies (`RemoteTmuxNativeSplitLayout.plan`) and +/// derive each pane's rendered grid from its outer size the way the terminal +/// surface does. Every pane must render AT LEAST its assigned span — one +/// column short means every full-width line in that pane wraps, which is the +/// live regression this suite pins down (proportional divider fractions +/// starve the deepest panes of a near-exact container). +@Suite struct RemoteTmuxNativeMirrorLayoutFuzzTests { + private static let seeds: [UInt64] = [ + 0x1, 0x2A, 0xBEEF, 0xC0FFEE, + 0xDEAD10CC, 0xFAB1E5, 0x7209, 0x424242, + ] + + @Test(arguments: seeds) + func everyPaneRendersAtLeastItsAssignedSpan(seed: UInt64) throws { + var rng = SplitMix64(seed: seed) + + for trial in 0..<80 { + let scale: CGFloat = Self.draw(2, using: &rng) == 0 ? 1 : 2 + let cellWidthPx = 7 + Self.draw(18, using: &rng) + let cellHeightPx = 14 + Self.draw(30, using: &rng) + let padWidthPx = Self.draw(10, using: &rng) + let padHeightPx = Self.draw(4, using: &rng) + let metrics = RemoteTmuxNativeLayoutMetrics( + cellSize: CGSize( + width: CGFloat(cellWidthPx) / scale, + height: CGFloat(cellHeightPx) / scale + ), + surfacePadding: CGSize( + width: CGFloat(padWidthPx) / scale, + height: CGFloat(padHeightPx) / scale + ), + tabBarHeight: CGFloat(24 + Self.draw(8, using: &rng)), + dividerThickness: CGFloat(1 + Self.draw(2, using: &rng)) + ) + + let paneCount = 2 + Self.draw(7, using: &rng) + var nextPaneId = 1 + let shape = Self.randomShape( + paneCount: paneCount, + nextPaneId: &nextPaneId, + depth: 0, + previousAxis: nil, + using: &rng + ) + let structure = Self.placeholderNode(shape) + + // Two container regimes per trial: a loose one (random spare + // beyond the claim), and the killer case — the tight container + // whose claim consumes it exactly, leaving no spare to hide + // rounding in. + let overhead = metrics.residual(of: structure) + let minimum = Self.minimumCells(shape, minLeaf: 2) + let cols = max(minimum.cols, RemoteTmuxMirrorGeometry.minCols) + + 4 + Self.draw(100, using: &rng) + let rows = max(minimum.rows, RemoteTmuxMirrorGeometry.minRows) + + 4 + Self.draw(50, using: &rng) + let tightSize = CGSize( + width: CGFloat(cols) * metrics.cellSize.width + overhead.width, + height: CGFloat(rows) * metrics.cellSize.height + overhead.height + ) + let looseSize = CGSize( + width: tightSize.width + CGFloat(Self.draw(Int(metrics.cellSize.width), using: &rng)), + height: tightSize.height + CGFloat(Self.draw(Int(metrics.cellSize.height), using: &rng)) + ) + + // Regimes: tight/loose assign exactly what the claim allows; the + // over-constrained pair assigns ONE MORE cell than the claim on a + // single axis — tmux really does that transiently (a claim racing + // a structure change, a co-attached client) — and the invariants + // are that the overloaded axis degrades EVENLY (no pane loses + // more than one cell) while the other axis stays exact + // everywhere. This is the regime that catches fit-failure on one + // axis silently turning the whole subtree proportional, and a + // shortfall being dumped on a single trailing pane. + for (regime, container, extraCols, extraRows) in [ + (regime: "tight", container: tightSize, extraCols: 0, extraRows: 0), + (regime: "loose", container: looseSize, extraCols: 0, extraRows: 0), + (regime: "overRows", container: tightSize, extraCols: 0, extraRows: 1), + (regime: "overCols", container: tightSize, extraCols: 1, extraRows: 0), + ] { + let claim = try #require( + metrics.clientGrid(layout: structure, contentSize: container) + ) + guard claim.columns >= minimum.cols, claim.rows >= minimum.rows else { continue } + let layout = Self.assign( + shape, + cols: claim.columns + extraCols, + rows: claim.rows + extraRows, + x: 0, + y: 0, + using: &rng + ) + let context = "seed=0x\(String(seed, radix: 16)) trial=\(trial) regime=\(regime)" + + " shape=\(Self.describe(layout)) container=\(Int(container.width))x\(Int(container.height))" + + " claim=\(claim.columns)x\(claim.rows)" + + let measured = RemoteTmuxNativeMeasuredSplitTree( + tree: RemoteTmuxNativeSplitTree(layout: layout), + metrics: metrics + ) + let plan = RemoteTmuxNativeSplitLayout.plan( + tree: measured, + metrics: metrics, + parentSize: container + ) + let outers = RemoteTmuxNativeSplitLayout.outerSizes(of: plan) + + for leaf in Self.leaves(of: layout) { + guard case .pane(let paneId) = leaf.content else { continue } + let outer = try #require( + outers[paneId], + "plan dropped pane \(paneId): \(context)" + ) + // What the terminal surface derives from the pane's + // outer size, in the renderer's own arithmetic: view + // frames land on whole device pixels, and the renderer + // floors the padded INTEGER pixel budget to whole cells + // — no float epsilon, or the suite would pass boundary + // cases the real surface fails. + let outerWidthPx = Int((outer.width * scale).rounded()) + let surfaceHeightPx = Int( + ((outer.height - metrics.tabBarHeight) * scale).rounded() + ) + let renderedCols = (outerWidthPx - padWidthPx) / cellWidthPx + let renderedRows = (surfaceHeightPx - padHeightPx) / cellHeightPx + // An over-assigned axis cannot fit by definition; the + // guarantees are that the OTHER axis is untouched by it, + // and the overloaded axis spreads its one-cell shortfall + // evenly — no pane loses more than one cell. + if extraCols == 0 { + #expect( + renderedCols >= leaf.width, + "pane \(paneId) renders \(renderedCols) cols < assigned \(leaf.width) — wraps: \(context)" + ) + } else { + #expect( + renderedCols >= leaf.width - 1, + "pane \(paneId) renders \(renderedCols) cols, assigned \(leaf.width) — over-assignment dumped on one pane: \(context)" + ) + } + if extraRows == 0 { + #expect( + renderedRows >= leaf.height, + "pane \(paneId) renders \(renderedRows) rows < assigned \(leaf.height): \(context)" + ) + } else { + #expect( + renderedRows >= leaf.height - 1, + "pane \(paneId) renders \(renderedRows) rows, assigned \(leaf.height) — over-assignment dumped on one pane: \(context)" + ) + } + // No surplus ceiling on purpose. Surplus beyond the + // assigned span is blank margin, and it is legitimate in + // two shapes: reserved slack a full-span pane still + // physically covers (about a cell), and fill-axis room a + // pane inherits when it shares a row/column with a + // chrome-heavier sibling stack (one tab bar per stacked + // pane). Runaway growth cannot happen in this walk at + // all — every split partitions its parent's extent, so + // sizes conserve by construction. Runaway requires the + // LIVE loop (render feeding the measured container, + // container feeding the claim), which is what the + // closed-loop convergence coverage is for. + } + } + } + } + + // MARK: - Random generation + + private enum Axis { + case horizontal + case vertical + + var opposite: Axis { + switch self { + case .horizontal: return .vertical + case .vertical: return .horizontal + } + } + } + + private enum Shape { + case pane(Int) + case split(Axis, [Shape]) + } + + private static func draw(_ upperBound: Int, using rng: inout SplitMix64) -> Int { + guard upperBound > 0 else { return 0 } + return Int(rng.next() % UInt64(upperBound)) + } + + private static func randomShape( + paneCount: Int, + nextPaneId: inout Int, + depth: Int, + previousAxis: Axis?, + using rng: inout SplitMix64 + ) -> Shape { + if paneCount == 1 { + defer { nextPaneId += 1 } + return .pane(nextPaneId) + } + let axis: Axis + if let previousAxis { + axis = previousAxis.opposite + } else { + axis = draw(2, using: &rng) == 0 ? .horizontal : .vertical + } + let maxChildren = min(4, paneCount) + let childCount = maxChildren == 2 ? 2 : 2 + draw(maxChildren - 1, using: &rng) + var remaining = paneCount + var childCounts: [Int] = [] + for index in 0.. RemoteTmuxLayoutNode { + switch shape { + case .pane(let paneId): + return RemoteTmuxLayoutNode(width: 1, height: 1, x: 0, y: 0, content: .pane(paneId)) + case .split(let axis, let children): + let nodes = children.map(placeholderNode) + return RemoteTmuxLayoutNode( + width: 1, height: 1, x: 0, y: 0, + content: axis == .horizontal ? .horizontal(nodes) : .vertical(nodes) + ) + } + } + + /// Minimum cols/rows a shape needs so every leaf keeps at least + /// `minLeaf` cells per axis, with one separator cell between siblings. + private static func minimumCells(_ shape: Shape, minLeaf: Int) -> (cols: Int, rows: Int) { + switch shape { + case .pane: + return (cols: minLeaf, rows: minLeaf) + case .split(let axis, let children): + let mins = children.map { minimumCells($0, minLeaf: minLeaf) } + let separators = children.count - 1 + if axis == .horizontal { + return ( + cols: mins.reduce(0) { $0 + $1.cols } + separators, + rows: mins.map(\.rows).max() ?? minLeaf + ) + } + return ( + cols: mins.map(\.cols).max() ?? minLeaf, + rows: mins.reduce(0) { $0 + $1.rows } + separators + ) + } + } + + /// Distributes assigned spans across a shape the way tmux lays out a + /// window: same-axis children split the parent span minus one separator + /// cell between each pair; cross-axis children inherit the parent span. + private static func assign( + _ shape: Shape, + cols: Int, + rows: Int, + x: Int, + y: Int, + using rng: inout SplitMix64 + ) -> RemoteTmuxLayoutNode { + switch shape { + case .pane(let paneId): + return RemoteTmuxLayoutNode( + width: cols, height: rows, x: x, y: y, content: .pane(paneId) + ) + case .split(let axis, let children): + let mins = children.map { minimumCells($0, minLeaf: 2) } + let separators = children.count - 1 + let span = axis == .horizontal ? cols : rows + let minTotal = mins.reduce(0) { $0 + (axis == .horizontal ? $1.cols : $1.rows) } + var spare = span - separators - minTotal + var spans: [Int] = mins.map { axis == .horizontal ? $0.cols : $0.rows } + while spare > 0 { + let index = draw(children.count, using: &rng) + spans[index] += 1 + spare -= 1 + } + var nodes: [RemoteTmuxLayoutNode] = [] + var cursorX = x + var cursorY = y + for (index, child) in children.enumerated() { + let childCols = axis == .horizontal ? spans[index] : cols + let childRows = axis == .horizontal ? rows : spans[index] + nodes.append(assign( + child, + cols: childCols, + rows: childRows, + x: cursorX, + y: cursorY, + using: &rng + )) + if axis == .horizontal { + cursorX += spans[index] + 1 + } else { + cursorY += spans[index] + 1 + } + } + return RemoteTmuxLayoutNode( + width: cols, height: rows, x: x, y: y, + content: axis == .horizontal ? .horizontal(nodes) : .vertical(nodes) + ) + } + } + + private static func leaves(of node: RemoteTmuxLayoutNode) -> [RemoteTmuxLayoutNode] { + switch node.content { + case .pane: + return [node] + case .horizontal(let children), .vertical(let children): + return children.flatMap { leaves(of: $0) } + } + } + + private static func describe(_ node: RemoteTmuxLayoutNode) -> String { + switch node.content { + case .pane(let paneId): + return "p\(paneId)[\(node.width)x\(node.height)]" + case .horizontal(let children): + return "h(" + children.map(describe).joined(separator: ",") + ")" + case .vertical(let children): + return "v(" + children.map(describe).joined(separator: ",") + ")" + } + } +} From f77fc42786fa68812fe63c2b0d903cc9de7adfcf Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 11 Jul 2026 23:50:31 -0700 Subject: [PATCH 3/6] remote-tmux: impose exact pane extents so mirrors never render short MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panes in a mirrored tmux window could render narrower than the width tmux assigned them. A pane even one column short wraps every full-width line, so multi-column layouts looked shredded; a window whose claimed size exactly filled the container garbled every pane at once. The terminal surface floors its size to whole cells, so a pane's pixels must never come out below its assigned span — but pane sizes were made by translating exact point targets into normalized divider fractions, and the fraction machinery cannot carry that precision: its drift deadbands eat sub-1% changes (two or three columns at terminal sizes), rounding compounds down the binary split chain, and the claim reserved no margin, so every pane sat exactly on the boundary where one lost point costs one column. Stop translating. Bonsplit now accepts an imposed first-child extent in points per split and applies it verbatim: clamped only by pane minimums, the equivalent fraction mirrored back for ratio readers, cleared by a user drag, and convergence memo-based so a target AppKit refuses cannot spin the main thread. The plan computes each split's extent directly — assigned cells times cell size plus measured chrome, including tmux's own pane title rows when pane-border-status is active — scaled evenly when the container genuinely cannot fit, quantized upward to the device-pixel grid with an axis-tagged running remainder so error never accumulates with depth or leaks across axes. The claim leaves one device pixel per axis unclaimed: exactly the quantum round-up can accumulate to, not a tuning constant. Container resizes re-impose the plan. DEBUG observability so harnesses ask instead of guessing with timers: a remote.grid.mismatch log line whenever a pane settles on a grid different from its assignment, and a remote.tmux.sizing_settled socket verb reporting per visible window whether sizing settled and which panes fall short. The native layout fuzz from the previous commit goes green: random trees, metrics, and containers — including exact-fit and one-extra-cell regimes — every pane renders at least its assigned span, degradation is even, and the other axis stays exact. --- .../Wire/ControlCommandExecutionPolicy.swift | 6 +- Sources/GhosttyTerminalView.swift | 19 + Sources/RemoteTmuxLayoutNode.swift | 17 + Sources/RemoteTmuxNativeLayoutMetrics.swift | 108 +++++- Sources/RemoteTmuxNativeSplitLayout.swift | 118 +++++-- Sources/RemoteTmuxNativeSplitTree.swift | 9 + Sources/RemoteTmuxWindowMirror+Bonsplit.swift | 89 ++++- ...emoteTmuxWindowMirror+BonsplitLayout.swift | 5 +- ...RemoteTmuxWindowMirror+DividerSizing.swift | 32 +- Sources/RemoteTmuxWindowMirror.swift | 118 ++++++- Sources/RemoteTmuxWindowMirrorSplitView.swift | 27 +- .../TerminalController+DebugMethodNames.swift | 1 + ...inalController+RemoteTmuxTestSupport.swift | 90 +++++ Sources/TerminalController.swift | 1 + .../RemoteTmuxMirrorFeedForwardTests.swift | 66 +++- .../RemoteTmuxMirrorLayoutMathTests.swift | 17 +- ...emoteTmuxNativeMirrorLayoutFuzzTests.swift | 15 + scripts/remote-tmux-fuzz-marathon.sh | 135 ++++++++ scripts/remote-tmux-layout-repro.sh | 2 +- scripts/remote-tmux-live-fuzz.sh | 327 ++++++++++++++++++ vendor/bonsplit | 2 +- 21 files changed, 1125 insertions(+), 79 deletions(-) create mode 100755 scripts/remote-tmux-fuzz-marathon.sh create mode 100755 scripts/remote-tmux-live-fuzz.sh diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index ef88ea186a26..6b89f280d693 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -29,7 +29,11 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { /// - Parameter method: The trimmed method name. public init(forMethod method: String) { #if DEBUG - if method == "remote.tmux.test_exec" || method == "remote.tmux.test_set_frame" { self = .socketWorker(mainThreadCallable: false); return } + if method == "remote.tmux.test_exec" || method == "remote.tmux.test_set_frame" + || method == "remote.tmux.sizing_settled" { + self = .socketWorker(mainThreadCallable: false) + return + } #endif if method.hasPrefix("vm.") || method.hasPrefix("remotes.") || method.hasPrefix("aiAccounts.") || Self.socketWorkerMethods.contains(method) { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b93e5c6a05c4..ca54cbcc01c3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -12176,6 +12176,19 @@ struct GhosttyTerminalView: NSViewRepresentable { TerminalWindowPortalRegistry.synchronizeForAnchor(host, syncLayout: false) } + // A terminal is space-filling: it renders in whatever space its + // container gives it and must never answer a size proposal with a + // content-derived ideal. Without this, SwiftUI's default representable + // sizing falls through to AppKit's fittingSize, and the portal-hosted + // terminal view's autoresizing constraints make that the HOSTED view's + // current frame — so any ancestor that honors ideals adopts the + // terminal's own layout as its size and hands it back grown (observed + // live as a mirror container inflating 19 points per layout pass inside + // a normal-sized window). + func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSView, context: Context) -> CGSize? { + CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0) + } + func makeNSView(context: Context) -> NSView { let container = HostContainerView(frame: .zero) container.wantsLayer = false @@ -12317,6 +12330,12 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) + // A grid resize that applied while this view was between + // windows (portal churn during a tab switch) owed its size + // report; entering a window is the moment it becomes + // deliverable. Without this, the listener's rendered-grid + // bookkeeping stays frozen at the pre-detach grid forever. + terminalSurface.flushPendingManualSizeReportIfAttached() } host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in guard let host, let hostedView, let coordinator else { return } diff --git a/Sources/RemoteTmuxLayoutNode.swift b/Sources/RemoteTmuxLayoutNode.swift index 844ec6ccde3f..fb485e8da161 100644 --- a/Sources/RemoteTmuxLayoutNode.swift +++ b/Sources/RemoteTmuxLayoutNode.swift @@ -115,3 +115,20 @@ struct RemoteTmuxLayoutNode: Sendable, Equatable, Codable { } } } + + +extension RemoteTmuxLayoutNode { + /// The leaf carrying `paneId`, or nil — a lookup for probes that compare + /// a pane's rendered grid against its assigned span. + func firstLeaf(withPaneId paneId: Int) -> RemoteTmuxLayoutNode? { + switch content { + case .pane(let id): + return id == paneId ? self : nil + case .horizontal(let children), .vertical(let children): + for child in children { + if let found = child.firstLeaf(withPaneId: paneId) { return found } + } + return nil + } + } +} diff --git a/Sources/RemoteTmuxNativeLayoutMetrics.swift b/Sources/RemoteTmuxNativeLayoutMetrics.swift index 4859a5321bd9..ae94cfcb8d8a 100644 --- a/Sources/RemoteTmuxNativeLayoutMetrics.swift +++ b/Sources/RemoteTmuxNativeLayoutMetrics.swift @@ -7,6 +7,23 @@ struct RemoteTmuxNativeLayoutMetrics: Equatable, Sendable { let surfacePadding: CGSize let tabBarHeight: CGFloat let dividerThickness: CGFloat + /// Extra height each pane's outer size carries for tmux's own title row + /// (`pane-border-status`): one cell when active, zero otherwise. tmux + /// assigns the pane one row FEWER than its visual region — the title row + /// is tmux chrome inside the pane's rectangle but outside its grid — so + /// the claim must reserve it and the ideals must grant it, or every pane + /// renders one row over and the accounting drifts. + var paneTitleRowHeight: CGFloat = 0 + /// One point of slack per pane per axis: extents are quantized to whole + /// points on cumulative rails rounded to NEAREST, so a pane sits within + /// half a point of its exact span — and half a point below an exact + /// boundary would cost a whole column when the surface floors to cells. + /// One point covers the worst case (0.5) with the same again as margin. + /// Whole points and nearest are both load-bearing: finer grids leave + /// nested view edges on half pixels where backing alignment shaves a + /// device pixel, and rounding UP would overshoot into trailing siblings + /// and compound across cross-axis nesting levels. + static let paneQuantizationSlack: CGFloat = 1 func clientGrid( layout: RemoteTmuxLayoutNode, @@ -32,8 +49,9 @@ struct RemoteTmuxNativeLayoutMetrics: Equatable, Sendable { switch node.content { case .pane: return CGSize( - width: surfacePadding.width, - height: tabBarHeight + surfacePadding.height + width: surfacePadding.width + Self.paneQuantizationSlack, + height: tabBarHeight + surfacePadding.height + paneTitleRowHeight + + Self.paneQuantizationSlack ) case .horizontal(let children): let childResiduals = children.map(residual(of:)) @@ -86,6 +104,59 @@ struct RemoteTmuxNativeLayoutMetrics: Equatable, Sendable { return firstExtent / max(1, firstExtent + secondExtent) } + /// The exact points a subtree needs along one axis: its cell span plus + /// its folded chrome residual. + func idealExtent( + of tree: RemoteTmuxNativeMeasuredSplitTree, + along orientation: SplitOrientation + ) -> CGFloat { + extent(of: tree.layout, residual: tree.residual, along: orientation) + } + + /// The whole-point extent the FIRST subtree of a split should receive: + /// its ideal extent — scaled down evenly when the split's actual extent + /// cannot fit both subtrees' ideals — plus the region's leading-edge + /// rounding error (`carry`), rounded to the nearest whole point. + /// `round(ideal + carry)` is "round the boundary's absolute coordinate, + /// measured from the region's rounded leading edge", so allocations + /// track exact boundary positions and per-split error cannot accumulate + /// with depth (see the plan walk in ``RemoteTmuxNativeSplitLayout`` for + /// how the carries flow through the tree). + /// + /// When the ideals do not fit (mid-resize, a co-attached client holding + /// the window small, tmux briefly exceeding the claim on one axis) + /// every subtree shrinks by the same factor — an even degradation with + /// no pane singled out. Returns nil only for a degenerate extent + /// (nothing to divide). + /// + /// `secondCarry` is the rounding error of the freshly placed boundary — + /// its exact position minus its rounded position — which is precisely + /// the trailing subtree's leading-edge error along this split's axis. + func railAllocation( + firstIdeal: CGFloat, + secondIdeal: CGFloat, + carry: CGFloat, + available: CGFloat + ) -> (firstExtent: CGFloat, secondCarry: CGFloat)? { + guard available > 0 else { return nil } + let idealSum = firstIdeal + secondIdeal + // Fit is judged against the EXACT span, not the rounded one. The + // carry is positional — this region's leading edge landed |carry| + // inside or beyond its exact position — so `available - carry` is + // the true room the ideals were budgeted for. Comparing against the + // rounded span would let a half-point edge masquerade as + // overconstraint and scale every child down when everything fits. + let exactSpan = available - carry + let scale = idealSum > exactSpan && idealSum > 0 ? max(0, exactSpan) / idealSum : 1 + let target = firstIdeal * scale + carry + // NEAREST whole point: rounding the boundary's absolute coordinate + // keeps every edge within half a point of exact — every pane within + // one point of ideal, which the per-pane quantization slack covers. + // Rounding up instead would overshoot into trailing siblings. + let allocated = min(max(0, target.rounded()), available) + return (firstExtent: allocated, secondCarry: target - allocated) + } + func requestedTmuxSpan( first: RemoteTmuxLayoutNode, orientation: SplitOrientation, @@ -142,8 +213,37 @@ struct RemoteTmuxNativeLayoutMetrics: Equatable, Sendable { func childExtents(parentExtent: CGFloat, dividerPosition: CGFloat) -> (first: CGFloat, second: CGFloat) { let available = max(0, parentExtent - dividerThickness) - let first = available * dividerPosition - return (first: first, second: available - first) + // Whole points: the native split view lays children out on the point + // grid, so modeling the division unrounded would disagree with the + // sizes panes actually receive. + let first = (available * dividerPosition).rounded() + return (first: first, second: max(0, available - first)) + } + + /// Splits a parent's size into the two child sizes a split with + /// `firstExtent` points for its first child produces — the one shared + /// model of a split's geometry, used by the divider plan (writing + /// fractions to the native tree) and the drag sync walk (reading them + /// back), so the two directions can never disagree about child sizes. + func childSizes( + parentSize: CGSize, + orientation: SplitOrientation, + firstExtent: CGFloat + ) -> (first: CGSize, second: CGSize) { + let parentExtent = orientation == .horizontal ? parentSize.width : parentSize.height + let available = max(0, parentExtent - dividerThickness) + let first = min(max(0, firstExtent), available) + let second = max(0, available - first) + if orientation == .horizontal { + return ( + first: CGSize(width: first, height: parentSize.height), + second: CGSize(width: second, height: parentSize.height) + ) + } + return ( + first: CGSize(width: parentSize.width, height: first), + second: CGSize(width: parentSize.width, height: second) + ) } func joinedResidual( diff --git a/Sources/RemoteTmuxNativeSplitLayout.swift b/Sources/RemoteTmuxNativeSplitLayout.swift index 9f135428a456..d7624cb12cc8 100644 --- a/Sources/RemoteTmuxNativeSplitLayout.swift +++ b/Sources/RemoteTmuxNativeSplitLayout.swift @@ -6,26 +6,55 @@ import Foundation /// size, decide every split's divider fraction and every pane's resulting /// outer size, mirroring the native split view's whole-point division. /// -/// Extracted from the mirror so the whole pipeline — claim, ideals, -/// fractions, per-level rounding — is testable without views: the fuzz +/// Extracted from the mirror so the whole pipeline — claim, ideals, rail +/// allocation, per-level rounding — is testable without views: the fuzz /// drives random trees and containers through this exact walk and checks /// every pane still derives its assigned tmux span. enum RemoteTmuxNativeSplitLayout { /// One node of the computed plan, shaped like the measured tree it was - /// derived from. Fractions apply to splits in the same positions. + /// derived from. `firstExtent` is the exact point extent to IMPOSE on + /// the split's first child; `fraction` is the equivalent normalized + /// value, kept for the paths that cannot impose (no container measured + /// yet — `firstExtent` nil — and readers that think in ratios). indirect enum Plan { case leaf(paneId: Int?, outer: CGSize?) - case split(orientation: SplitOrientation, fraction: CGFloat, first: Plan, second: Plan) + case split( + orientation: SplitOrientation, + fraction: CGFloat, + firstExtent: CGFloat?, + first: Plan, + second: Plan + ) } - /// Computes the divider plan: each split's fraction divides the actual - /// extent proportionally between the two subtrees' ideal extents, and - /// the walk models the native split view's whole-point division to - /// derive the outer size every pane will receive. + /// Computes the divider plan by rounding ABSOLUTE split boundaries, not + /// per-split extents. Each carry is the rounding error of a region's + /// leading edge along one axis — exact position minus rounded position — + /// and `round(ideal + carry)` is exactly "round the boundary's absolute + /// coordinate, measured from that edge". Both children inherit their + /// region's edge errors: the first child shares the parent's leading + /// edge, the trailing child starts at the freshly rounded boundary, and + /// a cross-axis split moves neither child's edges along the other axis, + /// so carries pass through it unchanged. Every boundary then lands + /// within half a point of its exact position no matter how deep the + /// tree is, and every pane within one point of its ideal — the bound + /// the per-pane quantization slack covers. (Rounding per split with + /// carries that reset at each level re-anchors the error at every + /// nesting depth, and a leaf under k same-axis levels could fall half + /// a point short per level — one lost column at depth two.) + /// + /// When a split's extent cannot fit both ideals, both are scaled evenly + /// and the boundaries of that region are rounded on the scaled ideals. + /// Without a parent size (no container measured yet, or a degenerate + /// extent mid-collapse), fractions fall back to the proportional + /// ideal-over-ideal split and sizes stop being modeled below that + /// point. static func plan( tree: RemoteTmuxNativeMeasuredSplitTree, metrics: RemoteTmuxNativeLayoutMetrics, - parentSize: CGSize? + parentSize: CGSize?, + horizontalCarry: CGFloat = 0, + verticalCarry: CGFloat = 0 ) -> Plan { switch tree { case .atomic(let layout, _): @@ -33,33 +62,56 @@ enum RemoteTmuxNativeSplitLayout { if case .pane(let id) = layout.content { paneId = id } return .leaf(paneId: paneId, outer: parentSize) case .split(_, _, let orientation, let firstTree, let secondTree): - let fraction = metrics.dividerFraction( - first: firstTree, - second: secondTree, - orientation: orientation - ) + let axisCarry = orientation == .horizontal ? horizontalCarry : verticalCarry + var fraction: CGFloat? var firstSize: CGSize? var secondSize: CGSize? + var secondCarry: CGFloat = 0 if let parentSize { let parentExtent = orientation == .horizontal ? parentSize.width : parentSize.height - let available = max(0, parentExtent - metrics.dividerThickness) - let firstExtent = (available * fraction).rounded() - let secondExtent = max(0, available - firstExtent) - if orientation == .horizontal { - firstSize = CGSize(width: firstExtent, height: parentSize.height) - secondSize = CGSize(width: secondExtent, height: parentSize.height) - } else { - firstSize = CGSize(width: parentSize.width, height: firstExtent) - secondSize = CGSize(width: parentSize.width, height: secondExtent) + let available = parentExtent - metrics.dividerThickness + if let allocation = metrics.railAllocation( + firstIdeal: metrics.idealExtent(of: firstTree, along: orientation), + secondIdeal: metrics.idealExtent(of: secondTree, along: orientation), + carry: axisCarry, + available: available + ) { + fraction = allocation.firstExtent / available + secondCarry = allocation.secondCarry + let sizes = metrics.childSizes( + parentSize: parentSize, + orientation: orientation, + firstExtent: allocation.firstExtent + ) + firstSize = sizes.first + secondSize = sizes.second } } + let applied = fraction ?? metrics.dividerFraction( + first: firstTree, + second: secondTree, + orientation: orientation + ) return .split( orientation: orientation, - fraction: fraction, - first: plan(tree: firstTree, metrics: metrics, parentSize: firstSize), - second: plan(tree: secondTree, metrics: metrics, parentSize: secondSize) + fraction: applied, + firstExtent: fraction != nil ? firstSize.map { + orientation == .horizontal ? $0.width : $0.height + } : nil, + first: plan( + tree: firstTree, metrics: metrics, + parentSize: firstSize, + horizontalCarry: horizontalCarry, + verticalCarry: verticalCarry + ), + second: plan( + tree: secondTree, metrics: metrics, + parentSize: secondSize, + horizontalCarry: orientation == .horizontal ? secondCarry : horizontalCarry, + verticalCarry: orientation == .vertical ? secondCarry : verticalCarry + ) ) } } @@ -67,18 +119,12 @@ enum RemoteTmuxNativeSplitLayout { /// Flattens a plan's leaves into pane-id → outer size (panes whose size /// the plan could not model are absent). static func outerSizes(of plan: Plan) -> [Int: CGSize] { - var sizes: [Int: CGSize] = [:] - collectOuterSizes(plan, into: &sizes) - return sizes - } - - private static func collectOuterSizes(_ plan: Plan, into sizes: inout [Int: CGSize]) { switch plan { case .leaf(let paneId, let outer): - if let paneId, let outer { sizes[paneId] = outer } - case .split(_, _, let first, let second): - collectOuterSizes(first, into: &sizes) - collectOuterSizes(second, into: &sizes) + guard let paneId, let outer else { return [:] } + return [paneId: outer] + case .split(_, _, _, let first, let second): + return outerSizes(of: first).merging(outerSizes(of: second)) { first, _ in first } } } } diff --git a/Sources/RemoteTmuxNativeSplitTree.swift b/Sources/RemoteTmuxNativeSplitTree.swift index 2cfd8db3ad04..789d44168cc6 100644 --- a/Sources/RemoteTmuxNativeSplitTree.swift +++ b/Sources/RemoteTmuxNativeSplitTree.swift @@ -1,6 +1,15 @@ import Bonsplit import Foundation +extension SplitOrientation { + /// The orientation string Bonsplit's external tree snapshot carries for + /// a split — the one place this mapping lives, so a guard comparing a + /// snapshot against a tmux orientation can't drift per call site. + var bonsplitTreeName: String { + self == .horizontal ? "horizontal" : "vertical" + } +} + /// Right-associated binary view of tmux's n-ary layout, matching Bonsplit's split tree. indirect enum RemoteTmuxNativeSplitTree: Sendable { case atomic(RemoteTmuxLayoutNode) diff --git a/Sources/RemoteTmuxWindowMirror+Bonsplit.swift b/Sources/RemoteTmuxWindowMirror+Bonsplit.swift index 686a82aa9f91..b9f41aba784d 100644 --- a/Sources/RemoteTmuxWindowMirror+Bonsplit.swift +++ b/Sources/RemoteTmuxWindowMirror+Bonsplit.swift @@ -100,6 +100,29 @@ extension RemoteTmuxWindowMirror { } func refreshDividerPositions() { + // Never impose while hidden. A hidden tab's tree lives in a portal + // host with no window clamping its bounds, and moving a divider to + // an absolute point extent there GROWS the container instead of + // shrinking the second child — the next pass then plans against the + // grown container, and the feedback compounds without bound + // (observed live: a hidden window's host at 224k points claiming + // 27,984 columns). Fractions never fed back this way because they + // are relative to the container. The visibility flip re-imposes, so + // a shown tab always plans against real, window-clamped bounds. + guard isVisibleForSizing else { return } + // Idempotence by INPUTS: a pass with the same layout, container, + // and metrics as the last completed pass imposes the same extents, + // and each apply's layout re-emits sizing samples whose handlers + // land back here — without this gate that echo sustains a + // full-CPU replan loop. Anything that actually changes sizing + // changes one of these inputs; view recreation re-applies from the + // split state itself, so skipping identical passes loses nothing. + let inputs = (renderedLayout, containerSizePt, nativeLayoutMetrics()) + if let last = lastPlanInputs, + last.0 == inputs.0, last.1 == inputs.1, last.2 == inputs.2 { + return + } + lastPlanInputs = inputs lastDividerPositions.removeAll() let treeNode = bonsplitController.treeSnapshot() let splitTree = RemoteTmuxNativeSplitTree(layout: renderedLayout) @@ -113,6 +136,11 @@ extension RemoteTmuxWindowMirror { parentSize: containerSizePt ) applyDividerPositions(plan: plan, treeNode: treeNode) + // Re-baseline the drag detector on what the tree ACTUALLY holds: + // bonsplit may clamp a requested fraction (minimum pane sizes), + // and a stale requested-vs-actual gap would read as a user drag + // on the next geometry event and echo a spurious resize to tmux. + recordActualDividerPositions(treeNode: bonsplitController.treeSnapshot()) } else { applyFallbackDividerPositions(tmuxTree: splitTree, treeNode: treeNode) } @@ -120,34 +148,79 @@ extension RemoteTmuxWindowMirror { /// Applies a computed divider plan (``RemoteTmuxNativeSplitLayout``) to /// the bonsplit tree — position-by-position, so the plan's shape must - /// match the snapshot it was computed against. + /// match the snapshot it was computed against. A mismatch means the + /// bonsplit tree drifted from the layout the plan was computed for; + /// every divider below the mismatch keeps its stale fraction, so make + /// it loud in DEBUG instead of silently misrendering. func applyDividerPositions( plan: RemoteTmuxNativeSplitLayout.Plan, treeNode: ExternalTreeNode ) { guard case .split(let split) = treeNode, - case .split(let orientation, let fraction, let firstPlan, let secondPlan) = plan, - split.orientation == (orientation == .horizontal ? "horizontal" : "vertical"), - let splitId = UUID(uuidString: split.id) else { return } - _ = bonsplitController.setDividerPosition(fraction, forSplit: splitId, fromExternal: true) - lastDividerPositions[splitId] = fraction + case .split( + let orientation, let fraction, let firstExtent, let firstPlan, let secondPlan + ) = plan + else { + if case .split = treeNode { + #if DEBUG + cmuxDebugLog("remote.divider.plan mismatch: plan leaf vs bonsplit split") + #endif + } + return + } + guard split.orientation == orientation.bonsplitTreeName, + let splitId = UUID(uuidString: split.id) + else { + #if DEBUG + cmuxDebugLog( + "remote.divider.plan mismatch: orientation \(split.orientation) vs \(orientation)" + ) + #endif + return + } + // Impose exact points when the plan has them; a normalized fraction + // is kept only for the no-container fallback, because fractions pass + // through drift deadbands that can eat several columns at terminal + // container sizes. + _ = bonsplitController.setImposedFirstExtent( + firstExtent, forSplit: splitId, fromExternal: true + ) + if firstExtent == nil { + _ = bonsplitController.setDividerPosition(fraction, forSplit: splitId, fromExternal: true) + } + // The drag-detection baseline is written ONLY by + // recordActualDividerPositions after the apply pass: writing the + // plan's ideal fraction here would leave a requested-vs-actual gap + // a geometry event could misread as a user drag. applyDividerPositions(plan: firstPlan, treeNode: split.first) applyDividerPositions(plan: secondPlan, treeNode: split.second) } + /// Overwrites the drag-detection baseline with the fractions the + /// bonsplit tree actually holds after an imposition pass. + private func recordActualDividerPositions(treeNode: ExternalTreeNode) { + guard case .split(let split) = treeNode else { return } + if let splitId = UUID(uuidString: split.id) { + lastDividerPositions[splitId] = CGFloat(split.dividerPosition) + } + recordActualDividerPositions(treeNode: split.first) + recordActualDividerPositions(treeNode: split.second) + } + func applyFallbackDividerPositions( tmuxTree: RemoteTmuxNativeSplitTree, treeNode: ExternalTreeNode ) { guard case .split(let split) = treeNode, case .split(_, let orientation, let firstTree, let secondTree) = tmuxTree, - split.orientation == (orientation == .horizontal ? "horizontal" : "vertical"), + split.orientation == orientation.bonsplitTreeName, let splitId = UUID(uuidString: split.id) else { return } let fraction = Self.dividerFraction( first: firstTree.layout, rest: [secondTree.layout], horizontal: orientation == .horizontal ) + _ = bonsplitController.setImposedFirstExtent(nil, forSplit: splitId, fromExternal: true) _ = bonsplitController.setDividerPosition(fraction, forSplit: splitId, fromExternal: true) lastDividerPositions[splitId] = fraction applyFallbackDividerPositions(tmuxTree: firstTree, treeNode: split.first) @@ -311,7 +384,7 @@ extension RemoteTmuxWindowMirror { ) -> Bool { guard children.count > 1, case .split(let split) = treeNode, - split.orientation == (orientation == .horizontal ? "horizontal" : "vertical"), + split.orientation == orientation.bonsplitTreeName, let first = children.first else { return false } return bonsplitTreeMatches(layout: first, treeNode: split.first) && bonsplitTreeMatches( diff --git a/Sources/RemoteTmuxWindowMirror+BonsplitLayout.swift b/Sources/RemoteTmuxWindowMirror+BonsplitLayout.swift index b6ce4c34f83b..863896757f56 100644 --- a/Sources/RemoteTmuxWindowMirror+BonsplitLayout.swift +++ b/Sources/RemoteTmuxWindowMirror+BonsplitLayout.swift @@ -19,7 +19,10 @@ extension RemoteTmuxWindowMirror { height: CGFloat(geometry.surfacePadHeightPx) / geometry.scale ), tabBarHeight: appearance.tabBarHeight, - dividerThickness: appearance.dividerThickness + dividerThickness: appearance.dividerThickness, + paneTitleRowHeight: tmuxTitleRowsVisible + ? CGFloat(geometry.cellHeightPx) / geometry.scale + : 0 ) } diff --git a/Sources/RemoteTmuxWindowMirror+DividerSizing.swift b/Sources/RemoteTmuxWindowMirror+DividerSizing.swift index 24ec0d9bd686..4fbefb8927c9 100644 --- a/Sources/RemoteTmuxWindowMirror+DividerSizing.swift +++ b/Sources/RemoteTmuxWindowMirror+DividerSizing.swift @@ -29,11 +29,14 @@ extension RemoteTmuxWindowMirror { guard case .split(let split) = treeNode, case .split(_, _, let orientation, let firstTree, let secondTree) = tmuxTree, let splitID = UUID(uuidString: split.id), - split.orientation == (orientation == .horizontal ? "horizontal" : "vertical") else { return } + split.orientation == orientation.bonsplitTreeName else { return } let first = firstTree.layout let position = CGFloat(split.dividerPosition) let previous = lastDividerPositions[splitID] ?? position - if abs(position - previous) > 0.005 { + // A split holding an imposed extent cannot be mid-drag — a user + // drag clears the imposition — so any fraction delta on it is + // imposition churn, never a gesture to sync back to tmux. + if split.imposedFirstExtent == nil, abs(position - previous) > 0.005 { lastDividerPositions[splitID] = position let parentExtent = orientation == .horizontal ? parentSize.width @@ -44,7 +47,7 @@ extension RemoteTmuxWindowMirror { parentExtent: parentExtent, dividerPosition: position ) - let axis = orientation == .horizontal ? "horizontal" : "vertical" + let axis = orientation.bonsplitTreeName if let targetPaneID = first.paneIDsInOrder.first { _ = requestResizePane( targetPaneID, @@ -61,15 +64,20 @@ extension RemoteTmuxWindowMirror { parentExtent: parentExtent, dividerPosition: position ) - let firstSize: CGSize - let secondSize: CGSize - if orientation == .horizontal { - firstSize = CGSize(width: childExtents.first, height: parentSize.height) - secondSize = CGSize(width: childExtents.second, height: parentSize.height) - } else { - firstSize = CGSize(width: parentSize.width, height: childExtents.first) - secondSize = CGSize(width: parentSize.width, height: childExtents.second) - } + // Descend with the imposed extent itself when one is active: the + // divider fraction is mirrored back from the same value, so the two + // agree to floating-point noise, but the extent is the exact point + // count the plan chose — no reason to round-trip it through a ratio. + let firstExtent = split.imposedFirstExtent + .map { min(max(0, CGFloat($0)), max(0, parentExtent - metrics.dividerThickness)) } + ?? childExtents.first + let sizes = metrics.childSizes( + parentSize: parentSize, + orientation: orientation, + firstExtent: firstExtent + ) + let firstSize = sizes.first + let secondSize = sizes.second syncChangedDividerPositions( treeNode: split.first, tmuxTree: firstTree, diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 7c2b3f12da6a..4dc9ada67c9f 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -75,6 +75,26 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { /// prevents early surface callbacks from treating an unselected mirror as visible. @ObservationIgnored var isVisibleForSizing = false + /// The flag above, cross-checked against what is actually on screen — + /// for the settled/mismatch JUDGE, not the sizing gates. The flag alone + /// goes stale in one direction: tab content is recreated on switch, so a + /// hidden tab's view can be dismantled without its visibility callback + /// ever firing, leaving the flag stuck true. Judging that mirror + /// compares tmux's live assignments against grids nothing renders (its + /// panes sit in the offscreen parking window) and reports phantom + /// mismatches. A pane view in a window that is ordered in is the ground + /// truth for "this mirror's grids are on screen". The sizing gates keep + /// the plain flag: a stale-true mirror re-claims only its own frozen, + /// sane size (per-window, deduped), while gating them on view state + /// would break headless callers that never put panes in a window. + var isEffectivelyVisibleForSizing: Bool { + guard isVisibleForSizing else { return false } + guard let window = panelsByPaneId.values.first?.hostedView.window else { + return false + } + return window.isVisible + } + /// ``TerminalPanel`` per tmux pane id. Not observation-tracked: the view /// re-reads it whenever ``layout`` (which IS tracked) changes, and the two /// are always updated together in ``reconcile(layout:)``. @@ -88,6 +108,12 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { @ObservationIgnored var isApplyingRemoteLayout = false @ObservationIgnored var isApplyingTmuxFocus = false @ObservationIgnored var lastDividerPositions: [UUID: CGFloat] = [:] + /// The (layout, container, metrics) of the last completed imposition + /// pass — see refreshDividerPositions for why identical inputs skip. + @ObservationIgnored var lastPlanInputs: (RemoteTmuxLayoutNode, CGSize?, RemoteTmuxNativeLayoutMetrics?)? + /// Last grid each pane's surface reported (from sizing samples) — the + /// live half of the settled/mismatch probe. + @ObservationIgnored var lastRenderedGrids: [Int: (cols: Int, rows: Int)] = [:] // MARK: Sizing inputs (locally owned; never tmux-derived) @@ -240,7 +266,13 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { let labels = (connection?.paneHeaderLabels ?? [:]).filter { livePaneIds.contains($0.key) } if labels != paneHeaderLabels { paneHeaderLabels = labels } let titleRows = connection?.windowTitleRowsVisible[windowId] ?? false - if tmuxTitleRowsVisible != titleRows { tmuxTitleRowsVisible = titleRows } + if tmuxTitleRowsVisible != titleRows { + tmuxTitleRowsVisible = titleRows + // Title rows are part of the claim's chrome (one cell per pane + // when active): flipping pane-border-status changes how many + // rows fit, so the claim must re-push, not just the dividers. + _ = updateClientSize() + } reconcileBonsplitTree(from: previousRenderedLayout, to: renderedLayout) // Adopt tmux's known active pane when this mirror has none yet: on // first attach the rects reply emits the active-pane event BEFORE the @@ -270,13 +302,17 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { private func configurePanePanel(_ panel: TerminalPanel, paneId: Int, needsSeed: Bool) { let surface = panel.surface - surface.onManualSizeApplied = { [weak self] in self?.handleSizingSample($0) } + surface.onManualSizeApplied = { [weak self] in + self?.handleSizingSample($0, paneId: paneId) + } surface.onRuntimeReady = { [weak self, weak surface] in guard let sample = surface?.rawSizingSample() else { return } - self?.handleSizingSample(sample) + self?.handleSizingSample(sample, paneId: paneId) } surface.flushPendingManualSizeReportIfAttached() - if let sample = surface.rawSizingSample() { handleSizingSample(sample) } + if let sample = surface.rawSizingSample() { + handleSizingSample(sample, paneId: paneId) + } if needsSeed { connection?.seedPane(paneId: paneId) } } @@ -287,9 +323,66 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { /// Records the container's size (points) and backing scale — f's variable /// inputs, delivered by the view on mount and every geometry change. + /// + /// A size change also re-imposes the divider plan: rail fractions are a + /// function of the container (``RemoteTmuxNativeSplitLayout``), so a + /// resize that stays inside one claim bucket — same claim, no + /// `%layout-change` echo, no reconcile — would otherwise leave the tree + /// scaling stale fractions proportionally, and a lopsided split can + /// lose more than the pane slack and wrap. The old ideal-over-ideal + /// fractions were container-independent, so no trigger existed here. func noteContainerSize(pointSize: CGSize, scale: CGFloat) { + // Hidden tabs keep their last visible geometry. A hidden tab's + // portal-hosted views have no window clamping them, so their + // reported bounds are not the size anything renders at — and once + // impositions inflate a hidden host (see refreshDividerPositions), + // recording those bounds would poison the claim tmux hears when a + // reconnect lets every window claim again. The one exception is the + // very first measurement: a never-shown mirror must still record + // its attach-time size so the initial claim can keep tmux off its + // 80×24 default. + guard isVisibleForSizing || containerSizePt == nil else { return } + // Nothing larger than the hosting window can be displayed, so no + // honest container is larger either. SwiftUI can hand this callback + // a content-derived size when some ancestor adopts a layout ideal — + // and recording it would feed the claim, grow tmux's assignments, + // grow the layout, and hand back a bigger size next pass, without + // bound. Clamping at this boundary breaks that loop no matter which + // view leaks an ideal. + var pointSize = pointSize + if let window = panelsByPaneId.values.first?.hostedView.window, + window.isVisible { + let bound = window.contentLayoutRect.size + if bound.width > 1, bound.height > 1 { + pointSize.width = min(pointSize.width, bound.width) + pointSize.height = min(pointSize.height, bound.height) + } + } else if containerSizePt != nil { + // No visible window to validate against means the panes are + // parked (portal limbo) and this callback's size is not + // evidence of anything on screen — recording it is how limbo + // geometry reached the claim. Only the very first measurement + // may pass unvalidated: it is the attach-time size the initial + // claim needs, delivered before the panes enter a window. + return + } + #if DEBUG + if pointSize.width > 3000 || pointSize.height > 3000 { + let window = panelsByPaneId.values.first?.hostedView.window + cmuxDebugLog( + "remote.container.record @\(windowId)" + + " size=\(Int(pointSize.width))x\(Int(pointSize.height))" + + " panels=\(panelsByPaneId.count)" + + " win=\(window.map { "\(Int($0.contentLayoutRect.width))x\(Int($0.contentLayoutRect.height)) vis=\($0.isVisible ? 1 : 0) cls=\(String(describing: type(of: $0)))" } ?? "nil")" + ) + } + #endif + let sizeChanged = containerSizePt != pointSize containerSizePt = pointSize containerScale = scale + if sizeChanged { + refreshDividerPositions() + } } /// Ingests one sizing sample into the min-tracked pad constants. @@ -321,8 +414,23 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { } } - private func handleSizingSample(_ sample: TerminalSurfaceRawSizingSample) { + private func handleSizingSample(_ sample: TerminalSurfaceRawSizingSample, paneId: Int) { ingest(sample: sample) + lastRenderedGrids[paneId] = (cols: sample.columns, rows: sample.rows) + #if DEBUG + // The one line that makes "tests green, screen wrong" a grep instead + // of a debugging session: whenever a surface settles on a grid that + // disagrees with the span tmux assigned its pane, say so. Rendering + // FEWER columns than assigned wraps every full-width line. + if let leaf = renderedLayout.firstLeaf(withPaneId: paneId), + sample.columns < leaf.width || sample.rows < leaf.height { + cmuxDebugLog( + "remote.grid.mismatch @\(windowId) pane=%\(paneId)" + + " rendered=\(sample.columns)x\(sample.rows)" + + " assigned=\(leaf.width)x\(leaf.height)" + ) + } + #endif _ = updateClientSize() } diff --git a/Sources/RemoteTmuxWindowMirrorSplitView.swift b/Sources/RemoteTmuxWindowMirrorSplitView.swift index c7a76ef1b06b..bc5bc19851c8 100644 --- a/Sources/RemoteTmuxWindowMirrorSplitView.swift +++ b/Sources/RemoteTmuxWindowMirrorSplitView.swift @@ -53,16 +53,29 @@ struct RemoteTmuxWindowMirrorSplitView: View { .onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { newSize in + #if DEBUG + // Tripwire for content-sized-container feedback: no real display + // is anywhere near this many points, so a container this large + // means some host is adopting layout-derived ideals again (the + // grep that caught a window inflating one point per layout pass). + if newSize.width > 4000 || newSize.height > 4000 { + cmuxDebugLog( + "remote.container.suspect @\(mirror.windowId)" + + " size=\(Int(newSize.width))x\(Int(newSize.height))" + + " visibleInUI=\(isVisibleInUI ? 1 : 0)" + ) + } + #endif containerSize = newSize pushClientSize(pointSize: newSize) } .onAppear { mirror.isVisibleForSizing = isVisibleInUI - if isVisibleInUI { pushClientSize(pointSize: containerSize) } + if isVisibleInUI { becameVisible() } } .onChange(of: isVisibleInUI) { _, visible in mirror.isVisibleForSizing = visible - if visible { pushClientSize(pointSize: containerSize) } + if visible { becameVisible() } } .onChange(of: mirror.layoutStructureVersion) { _, _ in pushClientSize(pointSize: containerSize) @@ -75,4 +88,14 @@ struct RemoteTmuxWindowMirrorSplitView: View { mirror.noteContainerSize(pointSize: pointSize, scale: displayScale) _ = mirror.updateClientSize() } + + /// Impositions and container tracking are suspended while hidden (see + /// ``RemoteTmuxWindowMirror/refreshDividerPositions()``), so a tab that + /// becomes visible must both re-claim and re-impose: tmux layouts that + /// arrived while it was hidden were applied to the tree but never + /// planned into divider extents. + private func becameVisible() { + pushClientSize(pointSize: containerSize) + mirror.refreshDividerPositions() + } } diff --git a/Sources/TerminalController+DebugMethodNames.swift b/Sources/TerminalController+DebugMethodNames.swift index ea2578cb5b3f..401d4f70bc47 100644 --- a/Sources/TerminalController+DebugMethodNames.swift +++ b/Sources/TerminalController+DebugMethodNames.swift @@ -3,6 +3,7 @@ extension TerminalController { nonisolated static let v2DebugMethodNames: [String] = [ "remote.tmux.test_exec", "remote.tmux.test_set_frame", + "remote.tmux.sizing_settled", "debug.shortcut.set", "debug.shortcut.simulate", "debug.type", diff --git a/Sources/TerminalController+RemoteTmuxTestSupport.swift b/Sources/TerminalController+RemoteTmuxTestSupport.swift index 21778906ca51..b9d30a050fba 100644 --- a/Sources/TerminalController+RemoteTmuxTestSupport.swift +++ b/Sources/TerminalController+RemoteTmuxTestSupport.swift @@ -150,5 +150,95 @@ extension TerminalController { ] } } + + /// `remote.tmux.sizing_settled` (DEBUG only) — answers "has every + /// mirrored window finished settling, and does every pane render exactly + /// its assigned span?" in one call. Harnesses poll this instead of + /// guessing with timers: a timer too short misreads transitions as bugs, + /// too long crawls. `settled` means each mirror's tmux layout matches the + /// size it claimed; `mismatches` lists panes whose last rendered grid + /// differs from their assigned span. A mismatch while `settled` is true + /// is a real rendering bug, no ambiguity. + nonisolated func v2RemoteTmuxSizingSettled(id: Any?) -> String { + v2VmCall(id: id, timeoutSeconds: 15) { + let report: [[String: Any]] = await MainActor.run { + var windows: [[String: Any]] = [] + for workspace in self.tabManager?.tabs ?? [] { + guard let session = workspace.remoteTmuxSessionMirror else { continue } + for (windowId, mirror) in session.windowMirrorByWindowId { + // Hidden mirrors stop tracking by design (they claim + // once and their surfaces report collapsed sizes); + // only the visible mirror's state is judgeable. + guard mirror.isEffectivelyVisibleForSizing else { continue } + let claimed = mirror.connection?.lastWindowSizes[windowId] + var mismatches: [String] = [] + // While zoomed, the visible tree is what panes render. + let tree = mirror.visibleLayout ?? mirror.layout + for leaf in tree.paneIDsInOrder { + guard let node = tree.firstLeaf(withPaneId: leaf) else { continue } + // Only a SHORTFALL is a defect: a pane one + // column under its span wraps every full line, + // while surplus is blank margin (the trailing + // pane legitimately absorbs sub-cell leftover). + guard let rendered = mirror.lastRenderedGrids[leaf] else { + // No size report yet: absence of evidence is + // not settled evidence — keep pollers waiting. + mismatches.append( + "%\(leaf) no-sample assigned=\(node.width)x\(node.height)" + ) + continue + } + if rendered.cols < node.width || rendered.rows < node.height { + var detail = "%\(leaf) rendered=\(rendered.cols)x\(rendered.rows)" + + " assigned=\(node.width)x\(node.height)" + // The surface's own pixel report — ground + // truth for diagnosing which side (plan or + // layout) lost the width. + if let sample = mirror.panelsByPaneId[leaf]?.surface.rawSizingSample() { + detail += " surfacePx=\(Int(sample.surfaceWidthPx))x\(Int(sample.surfaceHeightPx))" + + " cellPx=\(sample.cellWidthPx)x\(sample.cellHeightPx)" + } + // Layer bisect for a live mismatch: what the + // plan wants for this pane right now, and + // what its view actually measures. plan≠view + // means the split tree diverged from the + // plan; plan==view but surface short means + // the portal-hosted surface lags its view. + if let metrics = mirror.nativeLayoutMetrics() { + let plan = RemoteTmuxNativeSplitLayout.plan( + tree: RemoteTmuxNativeMeasuredSplitTree( + tree: RemoteTmuxNativeSplitTree(layout: tree), + metrics: metrics + ), + metrics: metrics, + parentSize: mirror.containerSizePt + ) + if let outer = RemoteTmuxNativeSplitLayout.outerSizes(of: plan)[leaf] { + detail += " plan=\(Int(outer.width))x\(Int(outer.height))" + } + } + if let view = mirror.panelsByPaneId[leaf]?.hostedView { + detail += " view=\(Int(view.frame.width))x\(Int(view.frame.height))" + + " inWin=\(view.window != nil ? 1 : 0)" + } + mismatches.append(detail) + } + } + windows.append([ + "window": windowId, + "claimed": claimed.map { "\($0.0)x\($0.1)" } ?? "none", + "layout": "\(mirror.layout.width)x\(mirror.layout.height)", + "settled": claimed.map { + $0.0 == mirror.layout.width && $0.1 == mirror.layout.height + } ?? false, + "mismatches": mismatches, + ]) + } + } + return windows + } + return ["windows": report] + } + } } #endif diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 337c229ad914..952392f12821 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1382,6 +1382,7 @@ class TerminalController { #if DEBUG case "remote.tmux.test_exec": return v2RemoteTmuxTestExec(id: request.id, params: request.params) case "remote.tmux.test_set_frame": return v2RemoteTmuxTestSetFrame(id: request.id, params: request.params) + case "remote.tmux.sizing_settled": return v2RemoteTmuxSizingSettled(id: request.id) #endif case "sidebar.custom.validate": return v2Result(id: request.id, v2CustomSidebarValidate(params: request.params)) diff --git a/cmuxTests/RemoteTmuxMirrorFeedForwardTests.swift b/cmuxTests/RemoteTmuxMirrorFeedForwardTests.swift index 2eed7f1fe4d8..390ba37db4b4 100644 --- a/cmuxTests/RemoteTmuxMirrorFeedForwardTests.swift +++ b/cmuxTests/RemoteTmuxMirrorFeedForwardTests.swift @@ -1,3 +1,4 @@ +import Bonsplit import Foundation import Testing @@ -154,7 +155,8 @@ import Testing // Both present: ready, and it lands per-window. mirror.noteContainerSize(pointSize: CGSize(width: 800, height: 620), scale: 2) #expect(mirror.updateClientSize()) - #expect(pushed(connection)?.cols == 100) // native dividers replace tmux's separator columns + // (800 − 3 panes × (pad 4 + quantization slack 1) − 2 dividers × (1 − cell 8)) / 8 → 99. + #expect(pushed(connection)?.cols == 99) #expect(pushed(connection)?.rows == 34) // 620pt − the native 30pt pane tab bar #expect(connection.lastWindowSizes[0] != nil) } @@ -183,13 +185,50 @@ import Testing let (mirror, connection) = readyMirror(layout: reflow123) mirror.reconcile(layout: reflow122) let claim = pushed(connection) - #expect(claim?.cols == 100) + #expect(claim?.cols == 99) mirror.reconcile(layout: reflow123) mirror.reconcile(layout: reflow122) #expect(pushed(connection)?.cols == claim?.cols) #expect(pushed(connection)?.rows == claim?.rows) } + @Test func containerResizeReimposesDividerFractions() { + // A container-only resize produces no tmux layout echo, so the + // mirror itself must recompute the divider plan. In the normal case + // the imposed point extents don't change (points don't scale with + // the container — that staleness was a fraction disease), so the + // observable is the overconstrained case: a container too small for + // the assigned cells must rescale every imposed extent evenly. + let (mirror, _) = readyMirror(layout: reflow123) + mirror.isVisibleForSizing = true + mirror.reconcile(layout: reflow123) + let before = Self.imposedExtents(of: mirror.bonsplitController.treeSnapshot()) + #expect(!before.isEmpty, "reconcile must impose exact extents") + // Shrink far below the layout's ideal width: extents must rescale. + mirror.noteContainerSize(pointSize: CGSize(width: 400, height: 620), scale: 2) + let after = Self.imposedExtents(of: mirror.bonsplitController.treeSnapshot()) + #expect(Set(before.keys) == Set(after.keys)) + for (id, extent) in after { + let original = before[id] ?? 0 + #expect( + extent < original, + "imposed extent must rescale with the container: \(extent) vs \(original)" + ) + } + } + + private static func imposedExtents(of node: ExternalTreeNode) -> [String: Double] { + switch node { + case .pane: + return [:] + case .split(let split): + var extents = imposedExtents(of: split.first) + .merging(imposedExtents(of: split.second)) { first, _ in first } + if let imposed = split.imposedFirstExtent { extents[split.id] = imposed } + return extents + } + } + @Test func hiddenMirrorWritesOnlyTheInitialClaim() { // The first per-window size on a connection drops every unclaimed // window to tmux's 80×24 default, so a hidden mirror claims its size @@ -207,6 +246,29 @@ import Testing #expect(connection.lastWindowSizes.count == 1) } + @Test func hiddenMirrorNeverImposesAndFreezesItsContainer() { + // While hidden, the tree lives in a portal host that no window + // clamps, so imposing an absolute extent there grows the host + // instead of shrinking the second child — and the grown bounds come + // back through noteContainerSize, compounding every pass (observed + // live: a hidden window's host at 224k points claiming 27,984 + // columns). Hidden mirrors therefore neither impose nor record + // container sizes; showing the tab does both against real bounds. + let (mirror, connection) = readyMirror(layout: reflow123) + mirror.isVisibleForSizing = false + mirror.reconcile(layout: reflow123) + #expect(Self.imposedExtents(of: mirror.bonsplitController.treeSnapshot()).isEmpty) + // Inflated portal-limbo bounds arrive while hidden: not recorded. + mirror.noteContainerSize(pointSize: CGSize(width: 224_000, height: 620), scale: 2) + mirror.isVisibleForSizing = true + mirror.refreshDividerPositions() + #expect(!Self.imposedExtents(of: mirror.bonsplitController.treeSnapshot()).isEmpty) + #expect(mirror.updateClientSize()) + // The claim reflects the frozen 800pt container, not limbo bounds. + #expect(pushed(connection)?.cols == 99) + #expect(connection.lastWindowSizes.count == 1) + } + @Test func degeneratePixelsClampToWorkableFloors() { let (mirror, connection) = makeMirror(layout: reflow123, geometry: calibratedGeometry) mirror.noteContainerSize(pointSize: CGSize(width: 30, height: 20), scale: 2) diff --git a/cmuxTests/RemoteTmuxMirrorLayoutMathTests.swift b/cmuxTests/RemoteTmuxMirrorLayoutMathTests.swift index bb495fcd1121..085cb0c01470 100644 --- a/cmuxTests/RemoteTmuxMirrorLayoutMathTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLayoutMathTests.swift @@ -26,7 +26,10 @@ import Testing dividerThickness: 1 ) - #expect(grid?.columns == 80) + // Width overhead is the per-pane quantization slack (1); height is + // two tab bars plus slack (31 each) minus the divider-for-separator + // credit (1 − 10): (800 − 1)/10 → 79, (300 − 53)/10 → 24. + #expect(grid?.columns == 79) #expect(grid?.rows == 24) } @@ -47,8 +50,10 @@ import Testing dividerThickness: 1 ) + // Width overhead: 1 + 1 + (1 − 10) = −7 → (800 + 7)/10 → 80; height: + // one tab bar plus slack (31): (300 − 31)/10 → 26. #expect(grid?.columns == 80) - #expect(grid?.rows == 27) + #expect(grid?.rows == 26) } @Test func mixedTreeSubtractsWorstPathChrome() throws { @@ -87,7 +92,7 @@ import Testing rest: [rootChildren[1]], orientation: .horizontal ) - #expect(abs(rootFraction - 0.504_201_680_7) < 0.000_001) + #expect(abs(rootFraction - 601.0 / 1192.0) < 0.000_001) guard case .vertical(let nestedChildren) = rootChildren[1].content else { Issue.record("Expected nested vertical split") @@ -98,7 +103,7 @@ import Testing rest: [nestedChildren[1]], orientation: .vertical ) - #expect(abs(nestedFraction - 0.511_111_111_1) < 0.000_001) + #expect(abs(nestedFraction - 231.0 / 452.0) < 0.000_001) } @Test func dragConversionUsesTheActualLocalParentExtentBelowTenPercent() { @@ -167,7 +172,7 @@ import Testing first: first, second: rest, orientation: orientation - ) - (90.0 / 571.0)) < 0.000_001) + ) - (91.0 / 574.0)) < 0.000_001) guard case .split(_, _, let restOrientation, let second, let third) = rest else { Issue.record("Expected right-associated remainder") return @@ -176,7 +181,7 @@ import Testing first: second, second: third, orientation: restOrientation - ) - (190.0 / 480.0)) < 0.000_001) + ) - (191.0 / 482.0)) < 0.000_001) } @Test func embeddedBonsplitProfileKeepsOnlySupportedNestedActions() { diff --git a/cmuxTests/RemoteTmuxNativeMirrorLayoutFuzzTests.swift b/cmuxTests/RemoteTmuxNativeMirrorLayoutFuzzTests.swift index a5190d45d311..80de8fe6afc7 100644 --- a/cmuxTests/RemoteTmuxNativeMirrorLayoutFuzzTests.swift +++ b/cmuxTests/RemoteTmuxNativeMirrorLayoutFuzzTests.swift @@ -43,6 +43,14 @@ private struct SplitMix64 { func everyPaneRendersAtLeastItsAssignedSpan(seed: UInt64) throws { var rng = SplitMix64(seed: seed) + // The minimum-cells guard below skips regimes whose claim came out + // too small to assign. That guard exists for pathological metric + // draws only — containers are built at least four cells above the + // minimum on each axis — so if most regimes stop executing, the + // generator or the claim collapsed and the suite would otherwise + // pass green while testing nothing. + var executedRegimes = 0 + for trial in 0..<80 { let scale: CGFloat = Self.draw(2, using: &rng) == 0 ? 1 : 2 let cellWidthPx = 7 + Self.draw(18, using: &rng) @@ -111,6 +119,7 @@ private struct SplitMix64 { metrics.clientGrid(layout: structure, contentSize: container) ) guard claim.columns >= minimum.cols, claim.rows >= minimum.rows else { continue } + executedRegimes += 1 let layout = Self.assign( shape, cols: claim.columns + extraCols, @@ -122,6 +131,8 @@ private struct SplitMix64 { let context = "seed=0x\(String(seed, radix: 16)) trial=\(trial) regime=\(regime)" + " shape=\(Self.describe(layout)) container=\(Int(container.width))x\(Int(container.height))" + " claim=\(claim.columns)x\(claim.rows)" + + " cellPx=\(cellWidthPx)x\(cellHeightPx) padPx=\(padWidthPx)x\(padHeightPx)" + + " scale=\(Int(scale)) divider=\(metrics.dividerThickness) tabBar=\(metrics.tabBarHeight)" let measured = RemoteTmuxNativeMeasuredSplitTree( tree: RemoteTmuxNativeSplitTree(layout: layout), @@ -193,6 +204,10 @@ private struct SplitMix64 { } } } + #expect( + executedRegimes >= 100, + "only \(executedRegimes)/320 regimes executed — generator or claim collapsed" + ) } // MARK: - Random generation diff --git a/scripts/remote-tmux-fuzz-marathon.sh b/scripts/remote-tmux-fuzz-marathon.sh new file mode 100755 index 000000000000..b5458f3abfd8 --- /dev/null +++ b/scripts/remote-tmux-fuzz-marathon.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# Long unattended run of the live layout fuzz: many seeds, evidence capture, +# automatic recovery. Every failure or hang leaves enough on disk to +# reproduce and diagnose it: the seed and iteration, the fuzz log, the app's +# debug log tail, and — for hangs — a process sample taken while stuck. +# +# Usage: CMUX_TAG=main scripts/remote-tmux-fuzz-marathon.sh [seeds] [iters-per-seed] +# Output: /tmp/cmux-fuzz-marathon// +set -u + +HOST="${1:?usage: CMUX_TAG= $0 [seeds] [iters]}" +SEEDS="${2:-40}" +ITERS="${3:-25}" +: "${CMUX_TAG:?CMUX_TAG is required}" +APP="${CMUX_FUZZ_APP:-$HOME/Library/Developer/Xcode/DerivedData/cmux-${CMUX_TAG}/Build/Products/Debug/cmux DEV ${CMUX_TAG}.app}" +DEBUG_LOG="/tmp/cmux-debug-${CMUX_TAG}.log" +# Exactly one driver. Concurrent marathons share one app and one tmux lab, +# and each seed's setup kills the lab server — yanking layouts out from +# under the other run's iterations and manufacturing failures no code +# produced. A stale pid file (dead owner) is taken over silently. +LOCK=/tmp/cmux-fuzz-marathon.pid +if [ -f "$LOCK" ] && kill -0 "$(cat "$LOCK" 2>/dev/null)" 2>/dev/null; then + echo "another marathon (pid $(cat "$LOCK")) is running — refusing to start a second" + exit 96 +fi +echo $$ > "$LOCK" + +DIR="/tmp/cmux-fuzz-marathon/$(date +%Y%m%d-%H%M%S)" +HERE="$(cd "$(dirname "$0")" && pwd)" +mkdir -p "$DIR" +echo "marathon: $SEEDS seeds x $ITERS iters -> $DIR" + +app_pid() { pgrep -f "cmux-${CMUX_TAG}/Build.*MacOS/cmux DEV" | head -1; } + +relaunch_app() { + local pid; pid=$(app_pid) + if [ -n "$pid" ]; then + kill -9 "$pid" 2>/dev/null + # Wait for the process to actually exit rather than guessing with a + # sleep: launching the replacement while the old instance still holds + # the socket makes the new one look dead. + local waited=0 + while kill -0 "$pid" 2>/dev/null && [ "$waited" -lt 20 ]; do + sleep 1; waited=$((waited + 1)) + done + fi + # Launch through the login shell so the app runs outside any sandbox. + ssh cmux-srvA "zsh -lc 'open \"$APP\"'" >/dev/null 2>&1 +} + +capture_evidence() { + local seed=$1 kind=$2 + local pid; pid=$(app_pid) + local out="$DIR/seed-$seed-$kind" + if [ -n "$pid" ] && [ "$kind" = hang ]; then + # `sample` needs the user session; route it through the default tmux. + tmux send-keys -t '1:0' "sample $pid 5 -file $out-sample.txt >/dev/null 2>&1" Enter 2>/dev/null + sleep 8 + fi + tail -400 "$DEBUG_LOG" > "$out-debuglog.txt" 2>/dev/null + ps -o pid,%cpu,state -p "${pid:-0}" > "$out-ps.txt" 2>/dev/null +} + +# A freshly relaunched app needs a moment before it can host a mirror: +# the socket comes up first and the workspaces restore after. Poll until +# the workspace list is non-empty rather than sleeping a guessed amount — +# seeds started against a still-restoring app fail setup instantly. +wait_app_ready() { + local tries=0 + while [ "$tries" -lt 30 ]; do + if [ -n "$(CMUX_QUIET=1 "$HERE/cmux-debug-cli.sh" list-workspaces 2>/dev/null | head -1)" ]; then + return 0 + fi + tries=$((tries + 1)) + sleep 2 + done + return 1 +} + +hangs=0; fails=0; crashes=0 +relaunch_app +wait_app_ready +for seed in $(seq 1 "$SEEDS"); do + log="$DIR/seed-$seed.log" + CMUX_FUZZ_SETTLE_SECS="${CMUX_FUZZ_SETTLE_SECS:-5}" \ + "$HERE/remote-tmux-live-fuzz.sh" "$HOST" "$seed" "$ITERS" > "$log" 2>&1 + rc=$? + if [ "$rc" -eq 98 ]; then + # Setup failed: the app had no workspace mirroring the fuzz session, + # which almost always means the app just died or is mid-restore. + # Relaunch, wait until it is actually ready, and retry the seed once — + # burning the remaining seeds against a dead app tells us nothing. + echo "seed=$seed SETUP FAIL — relaunching app and retrying once" + capture_evidence "$seed" setup + relaunch_app + wait_app_ready + CMUX_FUZZ_SETTLE_SECS="${CMUX_FUZZ_SETTLE_SECS:-5}" \ + "$HERE/remote-tmux-live-fuzz.sh" "$HOST" "$seed" "$ITERS" > "$log" 2>&1 + rc=$? + fi + if [ "$rc" -eq 97 ]; then + fails=$((fails + 1)) + echo "seed=$seed INERT — fuzzer bug, aborting marathon (fix the fuzzer first)" + capture_evidence "$seed" inert + break + elif [ "$rc" -eq 99 ]; then + hangs=$((hangs + 1)) + echo "seed=$seed HANG (evidence: seed-$seed-hang-*)" + capture_evidence "$seed" hang + relaunch_app + wait_app_ready + elif [ -z "$(app_pid)" ]; then + crashes=$((crashes + 1)) + echo "seed=$seed CRASH (app gone; evidence: seed-$seed-crash-*)" + capture_evidence "$seed" crash + relaunch_app + wait_app_ready + elif [ "$rc" -ne 0 ]; then + fails=$((fails + 1)) + echo "seed=$seed FAILURES rc=$rc (see $log)" + capture_evidence "$seed" fail + else + echo "seed=$seed ok" + fi +done + +echo "MARATHON DONE seeds=$SEEDS hangs=$hangs crashes=$crashes fail-seeds=$fails dir=$DIR" +{ + echo "seeds=$SEEDS iters=$ITERS hangs=$hangs crashes=$crashes fail-seeds=$fails" + grep -l "FUZZ FAIL\|FUZZ HANG" "$DIR"/seed-*.log 2>/dev/null +} > "$DIR/summary.txt" +# Boolean exit — the counts live in the MARATHON DONE line and summary.txt; +# a raw sum could wrap past 255 and read as success. +[ $((hangs + crashes + fails)) -gt 0 ] && exit 1 +exit 0 diff --git a/scripts/remote-tmux-layout-repro.sh b/scripts/remote-tmux-layout-repro.sh index b8ad8bbae571..5382bc81c513 100755 --- a/scripts/remote-tmux-layout-repro.sh +++ b/scripts/remote-tmux-layout-repro.sh @@ -202,7 +202,7 @@ except Exception: sys.exit(1) for item in data.get("surfaces", []): if item.get("title") == title: - value = item.get("id") or item.get("handle") + value = item.get("id") or item.get("handle") or item.get("ref") if value: print(value) sys.exit(0) diff --git a/scripts/remote-tmux-live-fuzz.sh b/scripts/remote-tmux-live-fuzz.sh new file mode 100755 index 000000000000..bf51c859a8ca --- /dev/null +++ b/scripts/remote-tmux-live-fuzz.sh @@ -0,0 +1,327 @@ +#!/bin/bash +# Live layout fuzz for the remote-tmux mirror: the real app mirroring a real +# tmux server, driven with random layouts and random churn. The unit fuzz +# checks the sizing math; this one checks what actually lands on screen. +# +# Builds a random pane layout in an isolated tmux server, lets the running +# tagged app mirror it, then applies random mutations — tmux-side pane +# resizes, window switches, splits and kills, and app-window resizes via the +# DEBUG rpc — and after each settle checks two things: +# 1. the app's own probe: no new "remote.grid.mismatch" lines in the debug +# log once settled (a mismatch means a pane rendered a different grid +# than tmux assigned — one column short wraps every full-width line); +# 2. per-pane text: read-screen output equals tmux capture-pane for every +# pane surface. +# Every failure prints the seed and iteration so the run reproduces exactly. +# +# Usage: CMUX_TAG=main scripts/remote-tmux-live-fuzz.sh [seed] [iters] +# Requires: the tagged DEBUG app running with remoteTmux enabled, an isolated +# tmux server behind (TMUX_TMPDIR wrapper), and the debug CLI. +set -u + +HOST="${1:?usage: CMUX_TAG= $0 [seed] [iters]}" +SEED="${2:-1}" +ITERS="${3:-25}" +: "${CMUX_TAG:?CMUX_TAG is required}" +TMPDIR_REMOTE="${CMUX_FUZZ_TMUX_TMPDIR:-/tmp/cmux-max1}" +DEBUG_LOG="${CMUX_FUZZ_DEBUG_LOG:-/tmp/cmux-debug-${CMUX_TAG}.log}" +CLI="$(cd "$(dirname "$0")" && pwd)/cmux-debug-cli.sh" +SETTLE="${CMUX_FUZZ_SETTLE_SECS:-6}" +SESSION=fuzz + +# Deterministic RNG (LCG) so a seed reproduces the exact op sequence. +# Returns via the global R: command substitution would fork a subshell and +# the state would never advance — a fuzzer repeating one op forever while +# reporting green is worse than no fuzzer. +state=$SEED +rand() { state=$(( (state * 1103515245 + 12345) % 2147483648 )); R=$(( state % $1 )); } + +t() { TMUX_TMPDIR="$TMPDIR_REMOTE" tmux "$@"; } +fail=0 +note_fail() { echo "FUZZ FAIL seed=$SEED iter=$1: $2"; fail=$((fail + 1)); } + +# Fresh lab: 2 windows, random pane counts, ruler panes that redraw to their +# tty size every 2s (a wrapped ruler is visible in text comparison). +t kill-server 2>/dev/null +mkdir -p "$TMPDIR_REMOTE" +RULER=/tmp/cmux-fuzz-ruler.sh +cat > "$RULER" <<'EOF' +#!/bin/sh +unset COLUMNS LINES +while :; do + sz=$(stty size 2>/dev/null); rows=${sz%% *}; cols=${sz##* } + [ -n "$rows" ] || rows=24; [ -n "$cols" ] || cols=80 + base=$(printf '%0.s0123456789' $(seq 1 60)) + printf '\033[2J\033[H' + r=1 + while [ "$r" -lt "$rows" ]; do + printf '%s\n' "$(printf '%03dx%03d %s' "$cols" "$rows" "$base" | cut -c1-"$cols")" + r=$((r+1)) + done + printf 'END %03dx%03d' "$cols" "$rows" + sleep 2 +done +EOF +chmod +x "$RULER" + +env -u COLUMNS -u LINES TMUX_TMPDIR="$TMPDIR_REMOTE" \ + tmux new-session -d -s $SESSION -n w0 -x 200 -y 50 "sh $RULER" +for w in 0 1; do + [ "$w" = 1 ] && t new-window -t $SESSION -n w1 "sh $RULER" + rand 5; panes=$(( 2 + R )) + for _ in $(seq 2 "$panes"); do + rand 2 + if [ "$R" = 0 ]; then + t split-window -h -t $SESSION:w$w "sh $RULER" 2>/dev/null + else + t split-window -v -t $SESSION:w$w "sh $RULER" 2>/dev/null + fi + t select-layout -t $SESSION:w$w tiled + done +done + +DRY="${CMUX_FUZZ_DRY:-0}" +if [ "$DRY" = 1 ]; then + # Dry mode: run the exact op sequence against tmux alone and log the + # layout after every step, so the op mix's coverage can be inspected + # without the app: do we reach deep nests, tiny panes, many windows — + # or does the distribution collapse into boring shapes? + CONNECT_OUT="" +else + CONNECT_OUT=$("$CLI" ssh-tmux "$HOST" 2>/dev/null | tail -1) +fi +WINDOW_ID="${CMUX_FUZZ_WINDOW_ID:-$(printf '%s' "$CONNECT_OUT" | sed -n 's/.*window=\([A-F0-9-]*\).*/\1/p')}" +sleep "$SETTLE" + +check_iter() { + local iter=$1 mark=$2 + # Hang detector: if the app stops answering the socket, that IS the bug. + # Exit with a distinct code so a marathon wrapper can sample the process, + # keep the evidence, and restart. + if ! timeout 8 "$CLI" ping >/dev/null 2>&1; then + echo "FUZZ HANG seed=$SEED iter=$iter: app socket unresponsive" + exit 99 + fi + # Ask the app whether everything has finished settling, instead of + # guessing with a timer. Poll up to 20s; a window that never settles is + # itself a failure, and any pane mismatch reported WHILE settled is a + # real rendering bug — no transition ambiguity. + local settled_json="" tries=0 + while [ "$tries" -lt 10 ]; do + settled_json=$(timeout 8 "$CLI" rpc remote.tmux.sizing_settled 2>/dev/null) + # A failed or timed-out RPC has no windows KEY at all — that is absence + # of evidence, not settledness. Keep polling; exhausting the loop + # reports it as never settled with whatever came back. + if ! printf '%s' "$settled_json" | grep -q '"windows"'; then + tries=$((tries + 1)) + sleep 2 + continue + fi + # An EMPTY window list is settled only when there is genuinely nothing + # to judge: the visible tab mirrors a single-pane window. If the lab's + # active window has several panes, empty means the fuzz mirror is not + # the visible workspace — the gate would be blind, so re-select it and + # keep polling; exhausting the loop then reports the failure. + if ! printf '%s' "$settled_json" | grep -q '"window"'; then + local active_panes + active_panes=$(t display -t $SESSION -p '#{window_panes}' 2>/dev/null || echo 1) + if [ "${active_panes:-1}" -le 1 ]; then + break + fi + "$CLI" workspace select "$WORKSPACE_REF" >/dev/null 2>&1 + tries=$((tries + 1)) + sleep 2 + continue + fi + # A pane with no sizing sample yet is still in transition even when the + # window's claim/layout dims agree — keep polling until every pane has + # reported. A rendered-grid mismatch does NOT block the break: a pane + # wrong AT settle is exactly the bug this harness exists to capture, + # and waiting it out would misreport it as "never settled". + if printf '%s' "$settled_json" | grep -q '"settled" : true' \ + && ! printf '%s' "$settled_json" | grep -q '"settled" : false' \ + && ! printf '%s' "$settled_json" | grep -q 'no-sample'; then + break + fi + tries=$((tries + 1)) + sleep 2 + done + if [ "$tries" -ge 10 ]; then + note_fail "$iter" "windows never settled after 20s: $(printf '%s' "$settled_json" | tr -d '\n' | cut -c1-300)" + elif printf '%s' "$settled_json" | grep -q 'rendered='; then + note_fail "$iter" "settled with pane mismatches:" + printf '%s' "$settled_json" | grep -o '"%[0-9]* rendered=[^"]*"' | head -4 | sed 's/^/ /' + fi + # Oracle 2: every ruler redrew to its actual pane size on the tmux side + # (a stale ruler would make on-screen text look mangled without any + # rendering bug — see op 8's comment). + for pane in $(t list-panes -s -t $SESSION -F '#{pane_id}'); do + local got + got=$(t display -t "$pane" -p '#{pane_width}') + local first + first=$(t capture-pane -p -J -t "$pane" 2>/dev/null | grep -m1 "^[0-9]*x[0-9]* 01" | wc -c | tr -d ' ') + if [ -n "$first" ] && [ "$first" -gt 1 ] && [ $((first - 1)) -ne "$got" ]; then + # The ruler redraws every 2s; confirm with a second read before + # calling it a failure, and tolerate the pane dying mid-check. + sleep 3 + got=$(t display -t "$pane" -p '#{pane_width}' 2>/dev/null) || continue + first=$(t capture-pane -p -J -t "$pane" 2>/dev/null | grep -m1 "^[0-9]*x[0-9]* 01" | wc -c | tr -d ' ') + if [ -n "$first" ] && [ "$first" -gt 1 ] && [ -n "$got" ] && [ $((first - 1)) -ne "$got" ]; then + note_fail "$iter" "pane $pane tmux-side ruler ${first}c != width ${got} after confirm" + fi + fi + done +} + +# Both return via globals (RW / RP): command substitution would fork a +# subshell and the RNG state would never advance — and `sort -R` would pick +# with system randomness, so the same seed would not replay the same run. +random_window() { + local names count + names=$(t list-windows -t $SESSION -F '#{window_name}') + count=$(printf '%s\n' "$names" | wc -l | tr -d ' ') + rand "$count" + RW="$SESSION:$(printf '%s\n' "$names" | sed -n "$((R + 1))p")" +} +random_pane() { + local ids count + ids=$(t list-panes -t "$1" -F '#{pane_id}') + count=$(printf '%s\n' "$ids" | wc -l | tr -d ' ') + rand "$count" + RP=$(printf '%s\n' "$ids" | sed -n "$((R + 1))p") +} + +app_resize() { + rand 1400; local width=$(( 900 + R )) + rand 500; local height=$(( 500 + R )) + [ -n "${WINDOW_ID:-}" ] && "$CLI" rpc remote.tmux.test_set_frame \ + "{\"window_id\":\"$WINDOW_ID\",\"width\":$width,\"height\":$height}" >/dev/null 2>&1 +} + +do_op() { + local w + random_window; w="$RW" + rand 10 + case $R in + 0) # pane resize, including starvation sizes down to 1 cell + local pane; random_pane "$w"; pane="$RP" + rand 2 + if [ "$R" = 0 ]; then + rand 70; t resize-pane -t "$pane" -x $(( 1 + R )) 2>/dev/null + else + rand 24; t resize-pane -t "$pane" -y $(( 1 + R )) 2>/dev/null + fi ;; + 1) # switch active window + random_window; t select-window -t "$RW" ;; + 2) # split or kill — allowed all the way down to a single pane, so the + # mirror's single↔multi pane lifecycle boundary gets crossed + local count; count=$(t list-panes -t "$w" | wc -l | tr -d ' ') + rand 2 + if [ "$count" -gt 1 ] && { [ "$count" -gt 5 ] || [ "$R" = 0 ]; }; then + random_pane "$w"; t kill-pane -t "$RP" 2>/dev/null + elif rand 2; [ "$R" = 0 ]; then + t split-window -h -t "$w" "sh $RULER" 2>/dev/null + else + t split-window -v -t "$w" "sh $RULER" 2>/dev/null + fi ;; + 3) # app window resize + app_resize ;; + 4) # zoom toggle: the visible tree collapses to one pane and back + random_pane "$w"; t resize-pane -Z -t "$RP" 2>/dev/null ;; + 5) # pane title rows on/off: every pane shifts a row and chrome changes + rand 2 + if [ "$R" = 0 ]; then + t set-option -t $SESSION pane-border-status top 2>/dev/null + else + t set-option -t $SESSION pane-border-status off 2>/dev/null + fi ;; + 6) # window churn: create a window or kill one (keep at least one) + local wins; wins=$(t list-windows -t $SESSION | wc -l | tr -d ' ') + rand 2 + if [ "$wins" -gt 2 ] || { [ "$wins" -gt 1 ] && [ "$R" = 0 ]; }; then + random_window; t kill-window -t "$RW" 2>/dev/null + else + t new-window -t $SESSION "sh $RULER" 2>/dev/null + fi ;; + 7) # container and assignment changing in the same instant + local pane; random_pane "$w"; pane="$RP" + rand 60; t resize-pane -t "$pane" -x $(( 5 + R )) 2>/dev/null & + app_resize + wait ;; + 8) # output flood while reflowing: the historical redraw-mangle recipe. + # The flood gets its own short-lived pane — Ctrl-C into a ruler pane + # would kill its redraw loop and leave stale wide text that looks + # like a rendering bug on screen (it isn't; tmux rewraps history). + local pane; random_pane "$w"; pane="$RP" + t split-window -t "$w" "seq 1 20000; sleep 2" 2>/dev/null + rand 50; t resize-pane -t "$pane" -x $(( 10 + R )) 2>/dev/null ;; + 9) # drop and re-establish the control connection (reseed + re-impose) + "$CLI" workspace reconnect --workspace "$WORKSPACE_REF" >/dev/null 2>&1 ;; + esac +} + +# Find the mirror workspace by its session name, then SELECT it. Relying on +# ssh-tmux having focused it is not enough: an app restored with other +# workspaces can keep its old selection, every fuzz mirror stays hidden, and +# hidden mirrors are excluded from sizing_settled by design — the whole run +# judges nothing and passes vacuously. That happened; this is the fix. +WORKSPACE_REF=$("$CLI" list-workspaces 2>/dev/null \ + | awk -v s="$SESSION" '$0 ~ " " s "( |$)" {for (i = 1; i <= NF; i++) if ($i ~ /^workspace:/) { print $i; exit }}') +if [ "$DRY" != 1 ] && [ -z "${WINDOW_ID:-}" ]; then + echo "FUZZ SETUP FAIL seed=$SEED: connect returned no window id ($CONNECT_OUT)" + exit 98 +fi +if [ "$DRY" != 1 ]; then + if [ -z "$WORKSPACE_REF" ]; then + echo "FUZZ SETUP FAIL seed=$SEED: no workspace mirroring session '$SESSION'" \ + "(have: $("$CLI" list-workspaces 2>/dev/null | tr '\n' ';'))" + exit 98 + fi + "$CLI" workspace select "$WORKSPACE_REF" >/dev/null 2>&1 +fi +# Inertness guard: a fuzzer that mutates nothing and reports green is worse +# than none (this happened — a subshell bug froze the RNG). Fingerprint the +# tmux layout every iteration; several identical fingerprints in a row with +# no debug-log growth means the ops aren't landing: fail loudly. +layout_fingerprint() { + t list-panes -s -t $SESSION -F '#{window_name}:#{pane_id}:#{pane_width}x#{pane_height}' 2>/dev/null | md5 -q +} +inert=0 +last_fp="" +last_size=0 +for i in $(seq 1 "$ITERS"); do + mark=$(stat -f%z "$DEBUG_LOG" 2>/dev/null || echo 0) + # Bursts: a third of iterations fire 2-3 mutations with no settle between, + # racing the claim debounce against interleaved layout echoes. + ops=1 + rand 3 + if [ "$R" = 0 ]; then rand 2; ops=$(( 2 + R )); fi + for _ in $(seq 1 "$ops"); do do_op; done + echo "iter=$i/$ITERS ops=$ops panes=$(t list-panes -s -t $SESSION 2>/dev/null | wc -l | tr -d ' ') windows=$(t list-windows -t $SESSION 2>/dev/null | wc -l | tr -d ' ') fails=$fail" + if [ "$DRY" = 1 ]; then + t list-windows -t $SESSION -F "iter=$i win=#{window_name} #{window_width}x#{window_height} panes=#{window_panes} zoom=#{window_zoomed_flag} layout=#{window_layout}" 2>/dev/null + continue + fi + sleep "$SETTLE" + check_iter "$i" "$mark" + fp=$(layout_fingerprint) + size=$(stat -f%z "$DEBUG_LOG" 2>/dev/null || echo 0) + if [ "$fp" = "$last_fp" ] && [ "$size" = "$last_size" ]; then + inert=$((inert + 1)) + if [ "$inert" -ge 4 ]; then + echo "FUZZ INERT seed=$SEED iter=$i: 4 iterations changed nothing — the fuzzer is not fuzzing" + exit 97 + fi + else + inert=0 + fi + last_fp=$fp + last_size=$size +done + +echo "FUZZ DONE seed=$SEED iters=$ITERS failures=$fail" +# Boolean exit: a raw count could wrap past 255 or collide with the +# reserved sentinel codes (97 inert, 98 setup, 99 hang). The count itself +# is in the FUZZ DONE line. +[ "$fail" -gt 0 ] && exit 1 +exit 0 diff --git a/vendor/bonsplit b/vendor/bonsplit index cd4e4c27e7b3..9453da38bf2c 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit cd4e4c27e7b34cd8840c02f96b99c4b442715b94 +Subproject commit 9453da38bf2c87acbf5317f7ab88af2591a87b33 From 8faf35fc18dd579ee4bf0c5d96331c5a15efd84f Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 12 Jul 2026 00:02:21 -0700 Subject: [PATCH 4/6] remote-tmux: remove the superseded pure-frame layout walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RemoteTmuxMirrorGeometry.clientCells/frames and the mirror's framesForRender had no callers outside their own tests: the native chrome rewrite replaced the live pipeline with RemoteTmuxNativeLayoutMetrics for the claim and bonsplit divider fractions for the render, but left the old walk in the tree with doc comments still describing it as the sizing authority. That cost us a real debugging detour — the comments point at the wrong code when the mirror misrenders. Keep the struct itself: it carries the measured render constants (cell/padding/scale) the native metrics are built from, plus the claim floors. Drop the dead functions, their frames value type, their test suite, and the one feed-forward test that exercised the dead entry point, and rewrite the mirror's header to describe the pipeline that actually runs. The one still-live function the deleted suite covered (patchingLeafRects) keeps its regression test. --- Sources/RemoteTmuxMirrorFrames.swift | 16 - Sources/RemoteTmuxMirrorGeometry.swift | 346 +--------------- Sources/RemoteTmuxWindowMirror.swift | 33 +- cmux.xcodeproj/project.pbxproj | 8 - .../RemoteTmuxMirrorFeedForwardTests.swift | 15 - cmuxTests/RemoteTmuxMirrorGeometryTests.swift | 384 ------------------ .../RemoteTmuxRectPublicationTests.swift | 32 ++ 7 files changed, 51 insertions(+), 783 deletions(-) delete mode 100644 Sources/RemoteTmuxMirrorFrames.swift delete mode 100644 cmuxTests/RemoteTmuxMirrorGeometryTests.swift diff --git a/Sources/RemoteTmuxMirrorFrames.swift b/Sources/RemoteTmuxMirrorFrames.swift deleted file mode 100644 index ae0151bec6ba..000000000000 --- a/Sources/RemoteTmuxMirrorFrames.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -/// The output of ``RemoteTmuxMirrorGeometry/frames(layout:containerPt:)``: -/// exact per-pane frames and divider strips for one assigned layout, in points, -/// in the mirror container's coordinate space. -/// -/// A value snapshot by design — the container view applies it verbatim and -/// holds no reference back to the geometry or the mirror, keeping the render -/// path free of observable-state reads inside `ForEach` rows. -struct RemoteTmuxMirrorFrames: Equatable, Sendable { - /// Pane id → the exact frame its panel (header + terminal) occupies. - let paneFramesPt: [Int: CGRect] - /// Separator strips between siblings (one tmux cell wide/tall), painted - /// as divider background. - let dividersPt: [CGRect] -} diff --git a/Sources/RemoteTmuxMirrorGeometry.swift b/Sources/RemoteTmuxMirrorGeometry.swift index 81ad75b85f4d..f69ad01bc0c1 100644 --- a/Sources/RemoteTmuxMirrorGeometry.swift +++ b/Sources/RemoteTmuxMirrorGeometry.swift @@ -1,26 +1,16 @@ import Foundation -/// The feed-forward sizing math for mirrored tmux windows: one value type -/// holding the measured render constants, exposing the two pure functions the -/// mirror needs — the client size implied by a pixel budget (`clientCells`, -/// "f") and the exact frames an assigned layout occupies inside that budget -/// (`frames`, "g"). +/// The measured render constants for mirrored tmux windows: terminal cell +/// size, the ghostty surface padding, and the backing scale, as sampled from +/// a live surface (`ghostty_surface_size`). ``RemoteTmuxWindowMirror`` +/// ingests sizing samples into one of these snapshots, and +/// ``RemoteTmuxNativeLayoutMetrics`` converts it to point-space metrics for +/// the claim and divider math. /// -/// Dataflow discipline (the reason this type exists): f depends only on the -/// pixel budget, the layout tree's STRUCTURE, and these locally-owned -/// constants — never on tmux-assigned geometry or rendered grids — so an echo of -/// our own `refresh-client` push recomputes to the identical size and dedups -/// to silence. g imposes tmux's assigned cells verbatim; nothing measures the -/// render back. Neither function reads mutable state: both are pure over -/// their arguments, computed in DEVICE PIXELS with cumulative edge rails so -/// per-pane rounding error cannot accumulate, and converted to points only at -/// the boundary. -/// -/// Constants are measured, not assumed: `cellWidthPx`/`cellHeightPx` and the -/// ghostty padding come from `ghostty_surface_size` on a live surface -/// (calibrated 2026-07-03: `cols == floor((surface_px − pad_w)/cell_px)` -/// exact on 100% of settled samples, pad_w = 8 device px at 2× with the -/// default ghostty config, pad_h = 0; `surface_px == view_pt × scale` exact). +/// Constants are measured, not assumed (calibrated 2026-07-03: +/// `cols == floor((surface_px − pad_w)/cell_px)` exact on 100% of settled +/// samples, pad_w = 8 device px at 2× with the default ghostty config, +/// pad_h = 0; `surface_px == view_pt × scale` exact). struct RemoteTmuxMirrorGeometry: Equatable, Sendable { /// Terminal cell width in device pixels (integer, from ghostty). let cellWidthPx: Int @@ -35,326 +25,10 @@ struct RemoteTmuxMirrorGeometry: Equatable, Sendable { /// The hosting window's backing scale (1.0 or 2.0 on macOS). let scale: CGFloat - /// The client size (tmux cols × rows) whose layout fits this pixel budget. - /// - /// Width: every pane column and every separator column costs exactly - /// `cellWidthPx`; each pane additionally costs its surface padding, and - /// the number of panes stacked side by side varies per row of the tree — - /// the structural fold below accounts both; height is the transpose. The - /// fold depends only on the tree's - /// STRUCTURE (pane nesting), never on assigned sizes: sums of assigned cells - /// along a split collapse to the parent total, so tmux's layout cancels out. - /// - /// - Parameter pixelWidth/pixelHeight: the mirror container's size in - /// device pixels (points × backing scale). - /// - Parameter structure: the window's BASE layout tree (never the - /// zoomed/visible tree — zoom must not flap the pushed size). - /// - Returns: cols/rows floored at tmux-workable minimums. - func clientCells( - pixelWidth: Int, - pixelHeight: Int, - structure: RemoteTmuxLayoutNode - ) -> (cols: Int, rows: Int) { - let chrome = Self.chromePx( - of: structure, - padW: surfacePadWidthPx, - padH: surfacePadHeightPx - ) - let cols = (pixelWidth - chrome.width) / max(1, cellWidthPx) - // − 1 row: the title band reserved across the top of the mirror - // (``frames(layout:containerPt:)``). One row TOTAL, not per pane: - // every pane below the window top already sits under a tmux - // separator row, so the band is the only strip tmux doesn't provide - // — and because it is uniform across branches, bottom edges still - // align regardless of stacking depth. When tmux itself reserves the - // title rows (pane-border-status: no pane sits at the window top), - // the band is tmux's and every pushed row is a grid row. - let band = Self.paneTouchesTop(of: structure) ? 1 : 0 - let rows = (pixelHeight - chrome.height) / max(1, cellHeightPx) - band - return (cols: max(Self.minCols, cols), rows: max(Self.minRows, rows)) - } - - /// Whether any pane's cell rect starts at the window's top row. False - /// exactly when tmux inserted a row above every top pane — its - /// `pane-border-status` title rows — in which case the mirror must not - /// stack a second, synthetic band on top. - static func paneTouchesTop(of node: RemoteTmuxLayoutNode) -> Bool { - switch node.content { - case .pane: - // <= 0, not == 0: real tmux offsets are never negative, but - // trees built programmatically (tests, placeholders) may carry - // -1 — those must behave like the ordinary no-title-rows case. - return node.y <= 0 - case let .horizontal(children), let .vertical(children): - return children.contains { paneTouchesTop(of: $0) } - } - } - /// Floors below which a client size is never pushed: tmux clamps /// per-window at the layout minimum anyway (measured: no errors, no /// restructures down to 1×1), but a session-visible postage stamp from a /// transient degenerate frame is never useful. static let minCols = 20 static let minRows = 5 - - /// The pixel cost of everything that is NOT tmux cell grid, folded over - /// the layout structure. Per pane: the surface padding — the ONLY - /// off-grid chrome the mirror has. Every other non-content pixel is a - /// row or column tmux itself allocated (separators, and title rows when - /// pane-border-status is set), so it lives inside the cell budget and - /// never appears here. Same-axis splits sum their children; - /// cross-axis splits take the max. Separator columns/rows are deliberately - /// absorbed into the CELL budget (a separator costs exactly one cell), so - /// they never appear here — that collapse is what makes the fold - /// layout-independent. - static func chromePx( - of node: RemoteTmuxLayoutNode, - padW: Int, - padH: Int - ) -> (width: Int, height: Int) { - switch node.content { - case .pane: - return (width: padW, height: padH) - case let .horizontal(children): - var width = 0 - var height = 0 - for child in children { - let c = chromePx(of: child, padW: padW, padH: padH) - width += c.width - height = max(height, c.height) - } - return (width, height) - case let .vertical(children): - var width = 0 - var height = 0 - for child in children { - let c = chromePx(of: child, padW: padW, padH: padH) - width = max(width, c.width) - height += c.height - } - return (width, height) - } - } - - /// Per-node chrome, both folds, computed in ONE bottom-up pass per - /// `frames()` call. Placement previously re-ran the recursive - /// ``chromePx(of:padW:padH:)`` for every child at every level, walking - /// each subtree once per ancestor — superlinear on pane count. Threading - /// this tree through ``place`` keeps the whole derivation linear. - private struct ChromeTree { - /// ``chromePx``'s width fold with `(padW: surfacePadWidthPx, padH: 0)`. - let width: Int - /// ``chromePx``'s height fold with `(padW: 0, padH: surfacePadHeightPx)`. - let height: Int - let children: [ChromeTree] - } - - private func chromeTree(of node: RemoteTmuxLayoutNode) -> ChromeTree { - switch node.content { - case .pane: - return ChromeTree(width: surfacePadWidthPx, height: surfacePadHeightPx, children: []) - case let .horizontal(children): - let kids = children.map { chromeTree(of: $0) } - return ChromeTree( - width: kids.reduce(0) { $0 + $1.width }, - height: kids.map(\.height).max() ?? 0, - children: kids - ) - case let .vertical(children): - let kids = children.map { chromeTree(of: $0) } - return ChromeTree( - width: kids.map(\.width).max() ?? 0, - height: kids.reduce(0) { $0 + $1.height }, - children: kids - ) - } - } - - /// The exact frames an assigned layout occupies inside a container, computed - /// as integer-device-pixel EDGE RAILS (each edge is the rounded cumulative - /// spend, so per-pane error never accumulates) and returned in points. - /// - /// Split-axis exact, cross-axis fill: a leaf's extent on its split axis - /// is exactly `cells·cellPx + padding`; on - /// the cross axis it fills whatever the parent allocated, so asymmetric - /// trees don't accumulate dead bands. Trailing leftover pixels (budget tmux's - /// layout doesn't consume) stay at the trailing edge as background. - /// - /// - Parameter layout: the window's VISIBLE layout tree (the zoomed - /// single-pane tree while zoomed — g renders what tmux displays). - /// - Parameter containerPt: the mirror container size in points. - /// - Returns: per-pane frames and divider strips, in points, in the - /// container's coordinate space. - func frames( - layout: RemoteTmuxLayoutNode, - containerPt: CGSize - ) -> RemoteTmuxMirrorFrames { - var paneFrames: [Int: CGRect] = [:] - var dividers: [CGRect] = [] - var containerPx = CGRect( - x: 0, y: 0, - width: containerPt.width * scale, - height: containerPt.height * scale - ) - // The title band: one cell-high strip across the top, the synthetic - // twin of tmux's separator rows, so EVERY pane has a strip above it - // (window-top panes get this band; every other pane sits under a - // tmux separator). The active pane's dot renders in the strip above - // it — over strip background, never over content. Skipped when tmux - // reserves the title rows itself (pane-border-status): those arrive - // as offset gaps below and render as strips without our help. - if Self.paneTouchesTop(of: layout) { - let bandPx = CGFloat(cellHeightPx) - dividers.append(CGRect( - x: 0, y: 0, width: containerPx.width, height: bandPx - )) - containerPx = CGRect( - x: 0, y: bandPx, - width: containerPx.width, height: containerPx.height - bandPx - ) - } else if case .pane = layout.content, layout.y > 0 { - // A root LEAF can itself sit below tmux title rows (the patched - // single-pane visible tree under `pane-border-status top` — a - // zoomed window, or a mirror whittled down to one pane). - // ``place(_:chrome:in:cellOrigin:paneFrames:dividers:)`` only - // bands offsets between siblings, so reserve the leaf's own - // leading rows here: they are tmux's title row, not content. - let bandPx = min(CGFloat(layout.y * cellHeightPx), containerPx.height) - dividers.append(CGRect( - x: 0, y: 0, width: containerPx.width, height: bandPx - )) - containerPx = CGRect( - x: 0, y: bandPx, - width: containerPx.width, height: containerPx.height - bandPx - ) - } - place( - layout, chrome: chromeTree(of: layout), - in: containerPx, cellOrigin: (x: layout.x, y: layout.y), - paneFrames: &paneFrames, dividers: ÷rs - ) - return RemoteTmuxMirrorFrames( - paneFramesPt: paneFrames.mapValues { $0.divided(by: scale) }, - dividersPt: dividers.map { $0.divided(by: scale) } - ) - } - - /// Recursive placement in device pixels. `region` is the pixel rect the - /// parent allocated to this node and `cellOrigin` the cell coordinate - /// that rect's top-leading corner corresponds to (cross-axis fill - /// happens by inheriting the region's cross extent). - /// - /// GAPS COME FROM THE TREE'S OWN OFFSETS, never from an assumed one-cell - /// separator: each child declares its absolute cell position, so - /// whatever rows or columns tmux inserted between (or above) children — - /// separators, or per-pane title rows under `pane-border-status` — land - /// here as strip rects automatically. A hardcoded gap silently - /// mis-places every pane the moment tmux grows new kinds of chrome. - private func place( - _ node: RemoteTmuxLayoutNode, - chrome: ChromeTree, - in region: CGRect, - cellOrigin: (x: Int, y: Int), - paneFrames: inout [Int: CGRect], - dividers: inout [CGRect] - ) { - switch node.content { - case .pane(let id): - paneFrames[id] = region - case .horizontal(let children): - // Edge rails: x_k = region.minX + rounded cumulative spend, where - // each child spends cells·cellPx + its own chrome width and each - // gap spends exactly the cells the offsets declare. - var cursor = region.minX - var cursorCellX = cellOrigin.x - for (index, child) in children.enumerated() { - let gapCells = max(0, child.x - cursorCellX) - if gapCells > 0 { - let gapEnd = min(cursor + CGFloat(gapCells * cellWidthPx), region.maxX) - dividers.append(CGRect( - x: cursor, y: region.minY, width: gapEnd - cursor, height: region.height - )) - cursor = gapEnd - cursorCellX = child.x - } - let childChrome = chrome.children[index] - let spend = CGFloat(child.width * cellWidthPx + childChrome.width) - let next = (cursor + spend).rounded() - // A child can also sit BELOW its declared row inside this - // region (its own title row under pane-border-status): band - // the skipped rows as a strip and give the child what - // remains. - var childTop = region.minY - let dropCells = max(0, child.y - cellOrigin.y) - if dropCells > 0 { - let bandEnd = min(childTop + CGFloat(dropCells * cellHeightPx), region.maxY) - dividers.append(CGRect( - x: cursor, y: childTop, - width: min(next + 1, region.maxX) - cursor, height: bandEnd - childTop - )) - childTop = bandEnd - } - // +1 device px into the following gap: the frame otherwise sits - // EXACTLY on the cell-quantization boundary, and any half-pixel - // lost downstream (point conversion, portal snapping) floors the - // surface to one column short — a full-width line then wraps. - // Rails stay exact; the overlap hides under the pane (dividers - // render below panes). - let childRegion = CGRect( - x: cursor, y: childTop, - width: min(next + 1, region.maxX) - cursor, - height: region.maxY - childTop - ) - place( - child, chrome: childChrome, - in: childRegion, cellOrigin: (x: child.x, y: child.y), - paneFrames: &paneFrames, dividers: ÷rs - ) - cursor = next - cursorCellX = child.x + child.width - } - case .vertical(let children): - var cursor = region.minY - var cursorCellY = cellOrigin.y - for (index, child) in children.enumerated() { - let gapCells = max(0, child.y - cursorCellY) - if gapCells > 0 { - let gapEnd = min(cursor + CGFloat(gapCells * cellHeightPx), region.maxY) - dividers.append(CGRect( - x: region.minX, y: cursor, width: region.width, height: gapEnd - cursor - )) - cursor = gapEnd - cursorCellY = child.y - } - let childChrome = chrome.children[index] - let spend = CGFloat(child.height * cellHeightPx + childChrome.height) - let next = (cursor + spend).rounded() - // Same +1 device px as the horizontal rails (see above). - let childRegion = CGRect( - x: region.minX, y: cursor, - width: region.width, - height: min(next + 1, region.maxY) - cursor - ) - place( - child, chrome: childChrome, - in: childRegion, cellOrigin: (x: child.x, y: child.y), - paneFrames: &paneFrames, dividers: ÷rs - ) - cursor = next - cursorCellY = child.y + child.height - } - } - } -} - -extension CGRect { - /// Scales a device-pixel rect back to points. - fileprivate func divided(by scale: CGFloat) -> CGRect { - CGRect( - x: origin.x / scale, - y: origin.y / scale, - width: size.width / scale, - height: size.height / scale - ) - } } diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 4dc9ada67c9f..083a5954dab0 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -17,13 +17,13 @@ import Observation /// /// SIZING IS FEED-FORWARD. The size pushed to tmux (``updateClientSize()``) is /// a pure function of the container's pixel size, the BASE layout tree's -/// STRUCTURE, and measured render constants (``RemoteTmuxMirrorGeometry``) — -/// never of tmux-assigned geometry or rendered grids. The render -/// (``framesForRender(containerPt:)``) imposes tmux's assigned cells verbatim. -/// Neither direction measures the other back, so tmux's `%layout-change` echo -/// of our own push recomputes to the identical size and dedups to silence: -/// there is no feedback loop to gate, budget, or pin against. Pane ratios are -/// user state and are never written. +/// STRUCTURE, and measured render constants — never of tmux-assigned geometry +/// or rendered grids. The render side sets bonsplit divider fractions from +/// tmux's assigned cells (``RemoteTmuxNativeSplitLayout`` via +/// ``refreshDividerPositions()``). Neither direction measures the other back, +/// so tmux's `%layout-change` echo of our own push recomputes to the identical +/// size and dedups to silence: there is no feedback loop to gate, budget, or +/// pin against. Pane ratios are user state and are never written. @MainActor @Observable final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { @@ -446,9 +446,8 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { /// The measured render constants, or nil while no sample has arrived /// yet. A pure read of the stored snapshot (or the injected test - /// source), safe from view-body projection (`framesForRender`): the - /// render never touches live surfaces, so it can't observe half-applied - /// resize state. + /// source): consumers never touch live surfaces, so they can't observe + /// half-applied resize state. func currentGeometry() -> RemoteTmuxMirrorGeometry? { if let geometrySource { return geometrySource() } return geometrySnapshot @@ -495,20 +494,6 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { return true } - /// The exact frames to impose for the current tmux layout, or `nil` when the - /// render should fall back to the proportional TRANSIENT mode: constants - /// still unknown, or tmux's layout doesn't match what f wants for the current - /// pixels (a push is in flight — drag mid-motion, attach settling, or a - /// co-attached client constraining the size). The transient mode always - /// fits by construction; imposition resumes on tmux's layout that matches. - func framesForRender(containerPt: CGSize) -> RemoteTmuxMirrorFrames? { - guard let geometry = currentGeometry(), - let cells = clientGrid(contentSize: containerPt), - layout.width == cells.columns, - layout.height == cells.rows else { return nil } - return geometry.frames(layout: visibleLayout ?? layout, containerPt: containerPt) - } - /// Records tmux's active pane as reported by the remote /// (`%window-pane-changed` or the rects fetch) — the strip dot follows /// tmux truth, not local focus alone. Tolerates unknown panes: the diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 9862933ca97c..1564275b57dc 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1082,9 +1082,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 7738A0027738A0027738A002 /* RemoteTmuxMirrorCLIFailClosedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738B0027738B0027738B002 /* RemoteTmuxMirrorCLIFailClosedTests.swift */; }; 7738A0017738A0017738A001 /* RemoteTmuxMirrorCLIObservabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738B0017738B0017738B001 /* RemoteTmuxMirrorCLIObservabilityTests.swift */; }; D4F8A2E61C5B39707A8E6F13 /* RemoteTmuxMirrorFeedForwardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C4D2B3F09865E217D4C1 /* RemoteTmuxMirrorFeedForwardTests.swift */; }; - 7E110877D4049839B248D5B2 /* RemoteTmuxMirrorFrames.swift in Sources */ = {isa = PBXBuildFile; fileRef = 609C73EBCC5E35DDC4E76978 /* RemoteTmuxMirrorFrames.swift */; }; A11FBDE1CC12989770B70EBE /* RemoteTmuxMirrorGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D32650AC37D12294A52E4D63 /* RemoteTmuxMirrorGeometry.swift */; }; - BC22F721323079468ED57528 /* RemoteTmuxMirrorGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD61EDB9377BE136E564F02B /* RemoteTmuxMirrorGeometryTests.swift */; }; 783300000000000000000002 /* RemoteTmuxMirrorLayoutIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 783300000000000000000001 /* RemoteTmuxMirrorLayoutIdentityTests.swift */; }; 74060000000000000000000A /* RemoteTmuxMirrorLayoutMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740600000000000000000009 /* RemoteTmuxMirrorLayoutMathTests.swift */; }; 736200000000000000000006 /* RemoteTmuxMirrorLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736200000000000000000005 /* RemoteTmuxMirrorLifecycleTests.swift */; }; @@ -2804,9 +2802,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7738B0027738B0027738B002 /* RemoteTmuxMirrorCLIFailClosedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorCLIFailClosedTests.swift; sourceTree = ""; }; 7738B0017738B0017738B001 /* RemoteTmuxMirrorCLIObservabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorCLIObservabilityTests.swift; sourceTree = ""; }; A7E1C4D2B3F09865E217D4C1 /* RemoteTmuxMirrorFeedForwardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorFeedForwardTests.swift; sourceTree = ""; }; - 609C73EBCC5E35DDC4E76978 /* RemoteTmuxMirrorFrames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorFrames.swift; sourceTree = ""; }; D32650AC37D12294A52E4D63 /* RemoteTmuxMirrorGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorGeometry.swift; sourceTree = ""; }; - AD61EDB9377BE136E564F02B /* RemoteTmuxMirrorGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorGeometryTests.swift; sourceTree = ""; }; 783300000000000000000001 /* RemoteTmuxMirrorLayoutIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorLayoutIdentityTests.swift; sourceTree = ""; }; 740600000000000000000009 /* RemoteTmuxMirrorLayoutMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorLayoutMathTests.swift; sourceTree = ""; }; 736200000000000000000005 /* RemoteTmuxMirrorLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorLifecycleTests.swift; sourceTree = ""; }; @@ -4535,7 +4531,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 74060000000000000000001E /* RemoteTmuxNativeMeasuredSplitTree.swift */, 4AFCBA6217C0EB912342F0A5 /* RemoteTmuxWindowMirrorSizingSnapshot.swift */, 740600000000000000000007 /* RemoteTmuxWindowMirrorSplitView.swift */, - 609C73EBCC5E35DDC4E76978 /* RemoteTmuxMirrorFrames.swift */, D32650AC37D12294A52E4D63 /* RemoteTmuxMirrorGeometry.swift */, F2A6C8E40D5B17293C8F6A42 /* TerminalPanelCreationOutcome.swift */, D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */, @@ -5137,7 +5132,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5C7032605BF360630E6E207A /* RemoteTmuxConnectionWindowSizingTests.swift */, 4D8BD9A2D8B7F7A27B77B289 /* RemoteTmuxRectPublicationTests.swift */, A7C9D1E3F5B7A9C1D3E5F701 /* RemoteTmuxWindowReorderTests.swift */, - AD61EDB9377BE136E564F02B /* RemoteTmuxMirrorGeometryTests.swift */, F11ED7AB0002000200020002 /* RemoteTmuxMirrorNewTabPlacementTests.swift */, FA00C0DE0002BEEF0002CAFE /* RemoteTmuxWindowRegistryTests.swift */, 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */, @@ -6317,7 +6311,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 240EDE51707EB251790C8985 /* RemoteTmuxHost.swift in Sources */, B12E809B546C64DC359C54E3 /* RemoteTmuxLayoutContent.swift in Sources */, E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */, - 7E110877D4049839B248D5B2 /* RemoteTmuxMirrorFrames.swift in Sources */, A11FBDE1CC12989770B70EBE /* RemoteTmuxMirrorGeometry.swift in Sources */, D77330010000000000000004 /* RemoteTmuxMirrorMutationCoordinator.swift in Sources */, D77330010000000000000002 /* RemoteTmuxMirrorMutationSnapshot.swift in Sources */, @@ -7182,7 +7175,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7738A0027738A0027738A002 /* RemoteTmuxMirrorCLIFailClosedTests.swift in Sources */, 7738A0017738A0017738A001 /* RemoteTmuxMirrorCLIObservabilityTests.swift in Sources */, D4F8A2E61C5B39707A8E6F13 /* RemoteTmuxMirrorFeedForwardTests.swift in Sources */, - BC22F721323079468ED57528 /* RemoteTmuxMirrorGeometryTests.swift in Sources */, 783300000000000000000002 /* RemoteTmuxMirrorLayoutIdentityTests.swift in Sources */, 74060000000000000000000A /* RemoteTmuxMirrorLayoutMathTests.swift in Sources */, 736200000000000000000006 /* RemoteTmuxMirrorLifecycleTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxMirrorFeedForwardTests.swift b/cmuxTests/RemoteTmuxMirrorFeedForwardTests.swift index 390ba37db4b4..c087b3134856 100644 --- a/cmuxTests/RemoteTmuxMirrorFeedForwardTests.swift +++ b/cmuxTests/RemoteTmuxMirrorFeedForwardTests.swift @@ -307,21 +307,6 @@ import Testing #expect(mirror.visibleLayout == nil) } - // MARK: render-mode selection - - @Test func framesImposeOnlyWhenTheAssignmentMatchesF() { - let (mirror, _) = readyMirror(layout: reflow123) - // Assigned size (123×35) ≠ f for these pixels (100×34): transient mode (nil). - #expect(mirror.framesForRender(containerPt: CGSize(width: 800, height: 620)) == nil) - // A tmux layout matching f imposes. - let matching = node(.horizontal([ - node(.pane(1), w: 33, h: 34), node(.pane(2), w: 33, h: 34), node(.pane(3), w: 32, h: 34), - ]), w: 100, h: 34) - mirror.reconcile(layout: matching) - let frames = mirror.framesForRender(containerPt: CGSize(width: 800, height: 620)) - #expect(frames != nil) - #expect(frames?.paneFramesPt.count == 3) - } } /// Per-window sizing semantics on the CONNECTION: dedup per window, the diff --git a/cmuxTests/RemoteTmuxMirrorGeometryTests.swift b/cmuxTests/RemoteTmuxMirrorGeometryTests.swift deleted file mode 100644 index c030c5df4aa9..000000000000 --- a/cmuxTests/RemoteTmuxMirrorGeometryTests.swift +++ /dev/null @@ -1,384 +0,0 @@ -import Foundation -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -/// Property coverage for the feed-forward sizing math -/// (``RemoteTmuxMirrorGeometry``): f (pixels+structure → client cells) and g -/// (assigned layout → exact frames) must be inverses on the split axis, f must -/// be invariant under re-assigns of the same structure, and the edge-rail -/// placement must never accumulate rounding error — the three properties -/// that make the sizing loop-free by construction. -@Suite struct RemoteTmuxMirrorGeometryTests { - /// The calibrated 2× constants from the Phase 0 sweep (cell 16×34 px, - /// pad 8×0 px). - private var geometry: RemoteTmuxMirrorGeometry { - RemoteTmuxMirrorGeometry( - cellWidthPx: 16, - cellHeightPx: 34, - surfacePadWidthPx: 8, - surfacePadHeightPx: 0, - scale: 2 - ) - } - - private func node( - _ content: RemoteTmuxLayoutContent, w: Int = -1, h: Int = -1, x: Int = 0, y: Int = 0 - ) -> RemoteTmuxLayoutNode { - RemoteTmuxLayoutNode(width: w, height: h, x: x, y: y, content: content) - } - - /// Deterministic pseudo-random generator so failures reproduce. - private struct SplitMix: RandomNumberGenerator { - var state: UInt64 - mutating func next() -> UInt64 { - state &+= 0x9E37_79B9_7F4A_7C15 - var z = state - z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 - z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB - return z ^ (z >> 31) - } - } - - /// Distributes `total` cells among `n` children with every child ≥ `minEach`, - /// matching tmux's invariant (children sum to parent − separators). - private func distribute( - _ total: Int, among n: Int, minEach: Int, using rng: inout SplitMix - ) -> [Int] { - var remaining = total - n * minEach - var parts = [Int](repeating: minEach, count: n) - for index in 0..<(n - 1) where remaining > 0 { - let take = Int.random(in: 0...remaining, using: &rng) - parts[index] += take - remaining -= take - } - parts[n - 1] += remaining - return parts - } - - /// A random structural skeleton (dims are placeholders): structure - /// decisions consume ONLY the rng, never a size, so the same seed always - /// yields the same shape — the property the invariance test needs. - private func randomStructure(depth: Int, using rng: inout SplitMix) -> RemoteTmuxLayoutNode { - if depth >= 3 || Bool.random(using: &rng) { - return node(.pane(Int.random(in: 0...9999, using: &rng))) - } - let horizontal = Bool.random(using: &rng) - let count = Int.random(in: 2...3, using: &rng) - let children = (0.. (cols: Int, rows: Int) { - switch tree.content { - case .pane: - return (minLeaf, minLeaf) - case let .horizontal(children): - let mins = children.map { minimumCells(of: $0, minLeaf: minLeaf) } - return (mins.map(\.cols).reduce(0, +) + children.count - 1, mins.map(\.rows).max() ?? minLeaf) - case let .vertical(children): - let mins = children.map { minimumCells(of: $0, minLeaf: minLeaf) } - return (mins.map(\.cols).max() ?? minLeaf, mins.map(\.rows).reduce(0, +) + children.count - 1) - } - } - - /// Assigns `cols`×`rows` onto an existing structure, preserving its shape - /// exactly and tmux's sum invariant at every split. - private func assign( - _ structure: RemoteTmuxLayoutNode, cols: Int, rows: Int, using rng: inout SplitMix - ) -> RemoteTmuxLayoutNode { - switch structure.content { - case let .pane(id): - return node(.pane(id), w: cols, h: rows) - case let .horizontal(children): - let mins = children.map { minimumCells(of: $0, minLeaf: 2).cols } - var widths = mins - var spare = cols - (children.count - 1) - mins.reduce(0, +) - for index in 0.. 0 { - let take = index == children.count - 1 ? spare : Int.random(in: 0...spare, using: &rng) - widths[index] += take - spare -= take - } - let assigned = zip(children, widths).map { assign($0, cols: $1, rows: rows, using: &rng) } - return node(.horizontal(assigned), w: cols, h: rows) - case let .vertical(children): - let mins = children.map { minimumCells(of: $0, minLeaf: 2).rows } - var heights = mins - var spare = rows - (children.count - 1) - mins.reduce(0, +) - for index in 0.. 0 { - let take = index == children.count - 1 ? spare : Int.random(in: 0...spare, using: &rng) - heights[index] += take - spare -= take - } - let assigned = zip(children, heights).map { assign($0, cols: cols, rows: $1, using: &rng) } - return node(.vertical(assigned), w: cols, h: rows) - } - } - - private func leaves(of tree: RemoteTmuxLayoutNode) -> [RemoteTmuxLayoutNode] { - switch tree.content { - case .pane: return [tree] - case let .horizontal(children), let .vertical(children): - return children.flatMap { leaves(of: $0) } - } - } - - @Test func fAndGAreInversesOnTheSplitAxisAcrossRandomTrees() { - var rng = SplitMix(state: 0xC0FFEE) - let geo = geometry - for trial in 0..<200 { - let pxW = Int.random(in: 700...3000, using: &rng) - let pxH = Int.random(in: 500...2200, using: &rng) - var shapeRng = SplitMix(state: UInt64(trial) &* 7919 &+ 13) - let structure = randomStructure(depth: 0, using: &shapeRng) - let cells = geo.clientCells(pixelWidth: pxW, pixelHeight: pxH, structure: structure) - let need = minimumCells(of: structure, minLeaf: 2) - guard cells.cols >= need.cols, cells.rows >= need.rows else { continue } - let assigned = assign(structure, cols: cells.cols, rows: cells.rows, using: &rng) - let frames = geo.frames( - layout: assigned, - containerPt: CGSize(width: CGFloat(pxW) / geo.scale, height: CGFloat(pxH) / geo.scale) - ) - for leaf in leaves(of: assigned) { - guard case let .pane(id) = leaf.content, - let frame = frames.paneFramesPt[id] else { - Issue.record("missing frame for a assigned leaf (trial \(trial))") - continue - } - let widthPx = (frame.width * geo.scale).rounded() - let heightPx = (frame.height * geo.scale).rounded() - let needW = CGFloat(leaf.width * geo.cellWidthPx + geo.surfacePadWidthPx) - let needH = CGFloat(leaf.height * geo.cellHeightPx + geo.surfacePadHeightPx) - // Split-axis exactness / cross-axis fill: a frame never holds - // FEWER pixels than its assigned cells need — a one-pixel - // shortfall renders one column short and wraps every - // full-width line (the bug class this design kills). - #expect(widthPx >= needW - 0.5, - "trial \(trial) pane \(id): width \(widthPx)px < needed \(needW)px") - #expect(heightPx >= needH - 0.5, - "trial \(trial) pane \(id): height \(heightPx)px < needed \(needH)px") - } - for (id, frame) in frames.paneFramesPt { - #expect(frame.maxX <= CGFloat(pxW) / geo.scale + 0.5, "pane \(id) overflows width") - #expect(frame.maxY <= CGFloat(pxH) / geo.scale + 0.5, "pane \(id) overflows height") - } - } - } - - @Test func clientCellsIsInvariantUnderReassignmentOfTheSameStructure() { - var rng = SplitMix(state: 42) - let geo = geometry - for trial in 0..<100 { - var shapeRng = SplitMix(state: UInt64(trial) &* 104_729 &+ 1) - let structure = randomStructure(depth: 0, using: &shapeRng) - var rngA = SplitMix(state: UInt64(trial) &* 31 &+ 7) - var rngB = SplitMix(state: UInt64(trial) &* 63 &+ 11) - let need = minimumCells(of: structure, minLeaf: 2) - let assignedA = assign(structure, cols: need.cols + 90, rows: need.rows + 40, using: &rngA) - let assignedB = assign(structure, cols: need.cols + 17, rows: need.rows + 9, using: &rngB) - let pxW = Int.random(in: 700...3000, using: &rng) - let pxH = Int.random(in: 500...2200, using: &rng) - let a = geo.clientCells(pixelWidth: pxW, pixelHeight: pxH, structure: assignedA) - let b = geo.clientCells(pixelWidth: pxW, pixelHeight: pxH, structure: assignedB) - #expect(a.cols == b.cols && a.rows == b.rows, - "trial \(trial): f depended on tmux-assigned geometry, not structure") - } - } - - @Test func edgeRailsNeverAccumulateDrift() { - // Twelve side-by-side panes: with width-based (per-pane rounded) - // placement, half-pixel errors compound to a full cell by the last - // pane; rails place every edge at the exact cumulative spend. Each - // frame carries the +1 device-pixel boundary bias (a frame sitting - // EXACTLY on the quantization boundary loses a column to any - // half-pixel shaved downstream), so a pane holds [needed, needed+1] - // pixels — never less, and never a full cell more. - let geo = geometry - let panes = (0..<12).map { node(.pane($0), w: 10, h: 40) } - let tree = node(.horizontal(panes), w: 10 * 12 + 11, h: 40) - let frames = geo.frames(layout: tree, containerPt: CGSize(width: 1200, height: 700)) - for index in 0..<12 { - guard let frame = frames.paneFramesPt[index] else { - Issue.record("missing pane \(index)"); continue - } - let widthPx = frame.width * geo.scale - let needed = CGFloat(10 * geo.cellWidthPx + geo.surfacePadWidthPx) - #expect(widthPx >= needed - 0.001, - "pane \(index): \(widthPx)px < needed \(needed)px — rail drift") - #expect(widthPx <= needed + 1.001, - "pane \(index): \(widthPx)px > needed+1 \(needed + 1)px — bias grew") - } - } - - @Test func crossAxisFillsTheParentAllocation() { - // h[pane, v[a, b]]: the tall left pane FILLS the region height even - // though its assigned rows would cost less — the dead-band regression - // the adversarial pass caught in exact-both-axes imposition. - let geo = geometry - let tree = node(.horizontal([ - node(.pane(1), w: 40, h: 30), - node(.vertical([ - node(.pane(2), w: 40, h: 14), - node(.pane(3), w: 40, h: 15), - ]), w: 40, h: 30), - ]), w: 81, h: 30) - let container = CGSize(width: 800, height: 620) - let frames = geo.frames(layout: tree, containerPt: container) - let left = frames.paneFramesPt[1]! - let bandPt = CGFloat(geo.cellHeightPx) / geo.scale - #expect(abs(left.height - (container.height - bandPt)) < 0.001, - "left pane must fill the container below the title band (no dead band), got \(left.height)") - } - - @Test func clientCellsClampsDegenerateBudgets() { - let geo = geometry - let tiny = geo.clientCells( - pixelWidth: 10, pixelHeight: 8, - structure: node(.pane(1), w: 1, h: 1) - ) - #expect(tiny.cols == RemoteTmuxMirrorGeometry.minCols) - #expect(tiny.rows == RemoteTmuxMirrorGeometry.minRows) - } - - /// The pane-header regression guard: panes carry NO per-pane vertical - /// chrome, so a window's row budget must not depend on how deeply one - /// branch stacks panes. (With the old 24pt header, a 10-pane column cost - /// the whole window ~14 rows and shallow branches rendered them as a - /// blank band below their last row.) - @Test func rowBudgetIsIndependentOfStackingDepth() { - let geo = geometry - let single = node(.pane(1)) - let stackedThree = node(.vertical((0..<3).map { node(.pane($0)) })) - let stackedTen = node(.vertical((0..<10).map { node(.pane($0)) })) - for (pxW, pxH) in [(1600, 1288), (900, 700), (2800, 2000)] { - let a = geo.clientCells(pixelWidth: pxW, pixelHeight: pxH, structure: single) - let b = geo.clientCells(pixelWidth: pxW, pixelHeight: pxH, structure: stackedThree) - let c = geo.clientCells(pixelWidth: pxW, pixelHeight: pxH, structure: stackedTen) - #expect(a.rows == b.rows && b.rows == c.rows, - "row budget varied with stacking depth: \(a.rows)/\(b.rows)/\(c.rows) at \(pxW)x\(pxH)") - } - } - - /// The title band: one cell-high strip across the top of the window — - /// every window-top pane's header row (all other panes sit under a tmux - /// separator). Uniform across branches, so it costs exactly one row of - /// the budget and cannot skew bottom alignment. - @Test func framesReserveTheTitleBandAndFOmitsItsRow() { - let geo = geometry - let tree = node(.pane(1), w: 98, h: 35) - let container = CGSize(width: 800, height: 620) - let frames = geo.frames(layout: tree, containerPt: container) - let bandPt = CGFloat(geo.cellHeightPx) / geo.scale - let band = frames.dividersPt.first - #expect(band != nil && abs(band!.minY) < 0.001 && abs(band!.height - bandPt) < 0.001, - "first strip must be the full-width title band, got \(String(describing: band))") - let pane = frames.paneFramesPt[1]! - #expect(abs(pane.minY - bandPt) < 0.001, "pane must start below the band") - let cells = geo.clientCells(pixelWidth: 1600, pixelHeight: 1240, structure: tree) - #expect(cells.rows == 1240 / 34 - 1, "rows must exclude the title-band row") - } - - /// Placement gaps come from the tree's own offsets, so rows tmux - /// inserts beyond plain separators — pane-border-status title rows — - /// land as strips and panes sit exactly where tmux put them. With tmux - /// providing every title row (no pane at the window top), the synthetic - /// band must also stand down, in both g and f. - @Test func offsetDrivenPlacementAdoptsTmuxTitleRows() { - let geo = geometry - let cellPt = CGFloat(geo.cellHeightPx) / geo.scale - // v[p1,p2] as tmux lays it out under pane-border-status top: - // title row 0, p1 rows 1–9, title/separator row 10, p2 rows 11–19. - let stacked = node(.vertical([ - node(.pane(1), w: 40, h: 9, x: 0, y: 1), - node(.pane(2), w: 40, h: 9, x: 0, y: 11), - ]), w: 40, h: 20) - let container = CGSize(width: 400, height: 400) - let frames = geo.frames(layout: stacked, containerPt: container) - let p1 = frames.paneFramesPt[1]! - let p2 = frames.paneFramesPt[2]! - #expect(abs(p1.minY - cellPt) < 0.6, "p1 must sit below tmux's title row, got \(p1.minY)") - let p2ExpectedTop = p1.maxY - 0.5 + cellPt // rail end (+1px bias) + title row - #expect(abs(p2.minY - p2ExpectedTop) < 1.1, - "p2 must sit below the mid title row, got \(p2.minY) expected ~\(p2ExpectedTop)") - // No synthetic band: the first strip is tmux's own title row (full - // width at y 0), and f keeps every pushed row a grid row. - #expect(RemoteTmuxMirrorGeometry.paneTouchesTop(of: stacked) == false) - let rows = geo.clientCells(pixelWidth: 800, pixelHeight: 680, structure: stacked).rows - #expect(rows == 680 / geo.cellHeightPx, "no band row may be subtracted when tmux provides titles") - // Side-by-side panes each carrying their own title row: the drop - // renders as a strip and the panes sit one row down. - let sideBySide = node(.horizontal([ - node(.pane(1), w: 20, h: 19, x: 0, y: 1), - node(.pane(2), w: 19, h: 19, x: 21, y: 1), - ]), w: 40, h: 20) - let hFrames = geo.frames(layout: sideBySide, containerPt: container) - #expect(abs(hFrames.paneFramesPt[1]!.minY - cellPt) < 0.6) - #expect(abs(hFrames.paneFramesPt[2]!.minY - cellPt) < 0.6) - } - - /// A root LEAF can itself carry a leading title-row offset (the patched - /// single-pane visible tree under `pane-border-status top`: a zoomed - /// window, or a mirror whittled down to one pane). Those rows are tmux's - /// title row and must render as a strip — handing the full container to - /// the pane would shift content up under the header. - @Test func rootLeafReservesItsOwnTitleRowOffset() { - let geo = geometry - let cellPt = CGFloat(geo.cellHeightPx) / geo.scale - let container = CGSize(width: 400, height: 400) - let leaf = node(.pane(7), w: 40, h: 19, x: 0, y: 1) - let frames = geo.frames(layout: leaf, containerPt: container) - let strip = frames.dividersPt.first - #expect(strip != nil && abs(strip!.minY) < 0.001 && abs(strip!.height - cellPt) < 0.001, - "the leading offset must band as a full-width strip, got \(String(describing: strip))") - let pane = frames.paneFramesPt[7]! - #expect(abs(pane.minY - cellPt) < 0.001, - "the pane must sit below tmux's title row, got \(pane.minY)") - #expect(abs(pane.maxY - container.height) < 0.001, - "the pane keeps the remaining height, got \(pane.maxY)") - } - - /// list-panes rect patching: leaves take their REAL rects (the layout - /// string's pre-title geometry replaced), splits keep string geometry, - /// unknown panes stay untouched. - @Test func patchingLeafRectsRewritesOnlyKnownLeaves() { - let tree = node(.horizontal([ - node(.pane(1), w: 50, h: 20, x: 0, y: 0), - node(.vertical([ - node(.pane(2), w: 49, h: 9, x: 51, y: 0), - node(.pane(3), w: 49, h: 10, x: 51, y: 10), - ]), w: 49, h: 20, x: 51, y: 0), - ]), w: 100, h: 20) - let patched = tree.patchingLeafRects([ - 1: (x: 0, y: 1, width: 50, height: 19), - 2: (x: 51, y: 1, width: 49, height: 8), - ]) - guard case let .horizontal(top) = patched.content, - case let .vertical(right) = top[1].content else { - Issue.record("structure must be preserved"); return - } - #expect(top[0].y == 1 && top[0].height == 19) - #expect(right[0].y == 1 && right[0].height == 8) - #expect(right[1].y == 10 && right[1].height == 10) // unknown: untouched - #expect(top[1].width == 49 && top[1].y == 0) // split node: untouched - #expect(patched.width == 100) - } - - @Test func chromeFoldMatchesHandComputedNestedTree() { - // h[pane, v[pane, pane]]: width chrome = pad + pad = 16 (max of - // the right column is one pane's pad); height chrome = max(pad, 2·pad) - // = 100 with a synthetic 50px pad. - let tree = node(.horizontal([ - node(.pane(1)), - node(.vertical([node(.pane(2)), node(.pane(3))])), - ])) - let chrome = RemoteTmuxMirrorGeometry.chromePx(of: tree, padW: 8, padH: 50) - #expect(chrome.width == 16) - #expect(chrome.height == 100) - } -} diff --git a/cmuxTests/RemoteTmuxRectPublicationTests.swift b/cmuxTests/RemoteTmuxRectPublicationTests.swift index 97601359dff0..f38d909d4327 100644 --- a/cmuxTests/RemoteTmuxRectPublicationTests.swift +++ b/cmuxTests/RemoteTmuxRectPublicationTests.swift @@ -492,4 +492,36 @@ import Testing #expect(connection.paneForegroundStates[4] != nil) #expect(connection.paneForegroundStates[5] == nil) } + + /// `patchingLeafRects` is the step that swaps tmux's layout-string + /// geometry for the REAL rects a `list-panes` fetch reported: known + /// leaves take the fetched rect, unknown leaves and split nodes keep + /// their string geometry untouched. + @Test func patchingLeafRectsRewritesOnlyKnownLeaves() { + func node( + _ content: RemoteTmuxLayoutContent, w: Int, h: Int, x: Int = 0, y: Int = 0 + ) -> RemoteTmuxLayoutNode { + RemoteTmuxLayoutNode(width: w, height: h, x: x, y: y, content: content) + } + let tree = node(.horizontal([ + node(.pane(1), w: 50, h: 20, x: 0, y: 0), + node(.vertical([ + node(.pane(2), w: 49, h: 9, x: 51, y: 0), + node(.pane(3), w: 49, h: 10, x: 51, y: 10), + ]), w: 49, h: 20, x: 51, y: 0), + ]), w: 100, h: 20) + let patched = tree.patchingLeafRects([ + 1: (x: 0, y: 1, width: 50, height: 19), + 2: (x: 51, y: 1, width: 49, height: 8), + ]) + guard case let .horizontal(top) = patched.content, + case let .vertical(right) = top[1].content else { + Issue.record("structure must be preserved"); return + } + #expect(top[0].y == 1 && top[0].height == 19) + #expect(right[0].y == 1 && right[0].height == 8) + #expect(right[1].y == 10 && right[1].height == 10) // unknown: untouched + #expect(top[1].width == 49 && top[1].y == 0) // split node: untouched + #expect(patched.width == 100) + } } From c423e9d0f4dffe514421ddb6b2a7429e163eac8d Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 12 Jul 2026 04:54:19 -0700 Subject: [PATCH 5/6] session restore: clamp saved window frames to their display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window larger than its own display is never a state a user created — interactive resizing and zoom are both display-bounded — yet exact-frame preservation restored such frames verbatim whenever the saved display still matched. A layout feedback bug (fixed separately in this branch) once persisted a 13,000-point-wide window, and every launch after that restored the giant frame and re-poisoned everything derived from window geometry. Restore now clamps a saved frame's size to its display's visible frame; position handling is unchanged. --- Sources/App/CmuxMainWindow.swift | 19 +++++++++++++ Sources/AppDelegate.swift | 19 ++++++++++++- ...AppDelegateDisplayConfigRestoreTests.swift | 27 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index ae6512f5c91b..c5555b7e6d83 100644 --- a/Sources/App/CmuxMainWindow.swift +++ b/Sources/App/CmuxMainWindow.swift @@ -74,6 +74,25 @@ func configureCmuxMainWindowDragBehavior(_ window: NSWindow) { @MainActor final class CmuxMainWindow: NSWindow { + /// No content may resize this window past its display. The content view + /// hosts AppKit subtrees whose subviews carry REQUIRED autoresizing-mask + /// constraints, and if any of them is ever laid out oversized, AppKit + /// satisfies those constraints by growing the WINDOW — and since this + /// window is non-movable, nothing ever constrains it back. A layout bug + /// then compounds through everything derived from window geometry + /// (observed live: the window at 29,000 points wide, growing every + /// pass). The user sizes this window; layout does not. + override func setFrame(_ frameRect: NSRect, display flag: Bool) { + var frame = frameRect + if !styleMask.contains(.fullScreen), + let limit = (screen ?? NSScreen.main)?.frame, + limit.width > 1, limit.height > 1 { + frame.size.width = min(frame.size.width, limit.width) + frame.size.height = min(frame.size.height, limit.height) + } + super.setFrame(frame, display: flag) + } + static var minimumContentSize: NSSize { NSSize( width: CGFloat(SessionPersistencePolicy.minimumWindowWidth), diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 88294bcefb4b..015ab11f5f0c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3546,7 +3546,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent displaySnapshot: displaySnapshot, targetDisplay: targetDisplay ) { - return frame + // Same display, same geometry: keep the exact frame — unless + // its SIZE exceeds the display itself. No user interaction + // produces a window larger than its own screen (interactive + // resizing and zoom are both display-bounded), so such a + // frame can only be programmatic growth — a content-sizing + // feedback loop once persisted a 13,000-point-wide window + // here — and restoring it verbatim resurrects the pathology + // on every launch. + if frame.width <= targetDisplay.visibleFrame.width + 1, + frame.height <= targetDisplay.visibleFrame.height + 1 { + return frame + } + return clampFrame( + frame, + within: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } return resolvedWindowFrame( frame: frame, diff --git a/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift b/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift index 66b701f87083..7f1d119e633a 100644 --- a/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift +++ b/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift @@ -137,6 +137,33 @@ struct AppDelegateDisplayConfigRestoreTests { #expect(externalVisible.intersects(resolved), "restored frame lands on the external monitor") } + @Test + func oversizedSavedFrameClampsToItsDisplay() throws { + // A saved frame larger than its own display can only be programmatic + // growth — interactive resizing and zoom are both display-bounded. A + // content-sizing feedback loop once persisted a 13,000-point-wide + // window, and exact-frame preservation round-tripped it verbatim on + // every launch, resurrecting the pathology. Same display, same + // geometry, absurd size: the restore must clamp. + let poisoned = CGRect(x: 4, y: 4, width: 13_375, height: 889) + let restored = try #require( + AppDelegate.resolvedWindowFrame( + from: SessionRectSnapshot(poisoned), + display: SessionDisplaySnapshot( + displayID: 1, + stableID: "uuid:BUILTIN", + frame: SessionRectSnapshot(builtInFrame), + visibleFrame: SessionRectSnapshot(builtInVisible) + ), + availableDisplays: [builtIn], + fallbackDisplay: builtIn + ) + ) + #expect(restored.width <= builtInVisible.width) + #expect(restored.height <= builtInVisible.height) + #expect(builtInVisible.contains(restored)) + } + @Test func stableDisplayIdentityWinsWhenDisplayIDIsReassigned() throws { let savedFrame = CGRect(x: -1_600, y: 200, width: 1_000, height: 700) From c57be5d2b35a0d2a863fd346d22fb89187644c54 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 12 Jul 2026 08:31:08 -0700 Subject: [PATCH 6/6] portal: skip geometry-sync passes whose geometry is unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sync pass lays out hosted split views and writes the host frame, and the notifications those emit can be delivered after the pass ends, so no in-pass reentrancy flag catches them all. The echo then re-runs the sync forever on identical geometry, pinning the main thread. Each pass now fingerprints everything it reads or writes — window size, container, reference, host, and hosted frames — and an incoming pass with an identical fingerprint is a no-op, so echoes die in one cheap comparison while any real change still syncs fully. --- Sources/RemoteTmuxWindowMirror+Bonsplit.swift | 6 ++ Sources/RemoteTmuxWindowMirror.swift | 27 ++++++ Sources/RemoteTmuxWindowMirrorSplitView.swift | 4 + Sources/TerminalWindowPortal.swift | 86 ++++++++++++++++++- 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/Sources/RemoteTmuxWindowMirror+Bonsplit.swift b/Sources/RemoteTmuxWindowMirror+Bonsplit.swift index b9f41aba784d..c88c4fda2ce7 100644 --- a/Sources/RemoteTmuxWindowMirror+Bonsplit.swift +++ b/Sources/RemoteTmuxWindowMirror+Bonsplit.swift @@ -27,6 +27,7 @@ extension RemoteTmuxWindowMirror { } else if treeReady, Self.sameShapeAndPaneIds(previousLayout, newLayout) { refreshDividerPositions() } else if treeReady, applyTargetedStructureChange(from: previousLayout, to: newLayout) { + lastPlanInputs = nil refreshDividerPositions() } else { rebuildBonsplitTree() @@ -36,6 +37,11 @@ extension RemoteTmuxWindowMirror { func rebuildBonsplitTree() { isApplyingRemoteLayout = true defer { isApplyingRemoteLayout = false } + // A rebuilt tree has fresh split states: the last imposition pass's + // inputs no longer describe what is on screen, so the idempotence + // gate must not skip the next pass (a new split can also be imposed + // before its view exists — the follow-up pass is its recovery). + lastPlanInputs = nil resetToSingleEmptyPane() tabIdByPaneId.removeAll() paneIdByPaneId.removeAll() diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 083a5954dab0..0ec9f0a7e58f 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -414,6 +414,33 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { } } + #if DEBUG + /// One-shot per window: at container-suspect time, walk from a pane's + /// view to the window logging each ancestor's class and width. The + /// FIRST ancestor wider than the window names the view whose sizing + /// rule still adopts a content-derived ideal. + private static var dumpedAncestorChains = Set() + func debugDumpAncestorWidths() { + guard !Self.dumpedAncestorChains.contains(windowId), + let view = panelsByPaneId.values.first?.hostedView else { return } + Self.dumpedAncestorChains.insert(windowId) + let windowWidth = view.window?.contentLayoutRect.width ?? -1 + var node: NSView? = view + var depth = 0 + while let current = node, depth < 60 { + let width = Int(current.frame.width) + let marker = CGFloat(width) > windowWidth + 1 ? " OVERSIZED" : "" + cmuxDebugLog( + "remote.container.chain @\(windowId) [\(depth)]" + + " \(String(describing: type(of: current))) w=\(width)\(marker)" + ) + node = current.superview + depth += 1 + } + cmuxDebugLog("remote.container.chain @\(windowId) window=\(Int(windowWidth))") + } + #endif + private func handleSizingSample(_ sample: TerminalSurfaceRawSizingSample, paneId: Int) { ingest(sample: sample) lastRenderedGrids[paneId] = (cols: sample.columns, rows: sample.rows) diff --git a/Sources/RemoteTmuxWindowMirrorSplitView.swift b/Sources/RemoteTmuxWindowMirrorSplitView.swift index bc5bc19851c8..d96e27b7a94a 100644 --- a/Sources/RemoteTmuxWindowMirrorSplitView.swift +++ b/Sources/RemoteTmuxWindowMirrorSplitView.swift @@ -64,6 +64,7 @@ struct RemoteTmuxWindowMirrorSplitView: View { + " size=\(Int(newSize.width))x\(Int(newSize.height))" + " visibleInUI=\(isVisibleInUI ? 1 : 0)" ) + mirror.debugDumpAncestorWidths() } #endif containerSize = newSize @@ -96,6 +97,9 @@ struct RemoteTmuxWindowMirrorSplitView: View { /// planned into divider extents. private func becameVisible() { pushClientSize(pointSize: containerSize) + // The tab's views may have been recreated while hidden; identical + // plan inputs do not mean the fresh views hold the plan. + mirror.lastPlanInputs = nil mirror.refreshDividerPositions() } } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index e5940a49e74a..9f7d9f107467 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -737,7 +737,28 @@ final class WindowTerminalPortal: NSObject { scheduleExternalGeometrySynchronize(forceImmediate: true) } + /// See scheduleExternalGeometrySynchronize: true exactly while ANY + /// portal's sync pass runs. Static on purpose — one portal's layout + /// pass fires other portals' observers (didResizeSubviews is observed + /// with object: nil, and windows host several portals), so a + /// per-instance flag just turns the self-echo into cross-portal + /// ping-pong. The main thread runs nothing else during a pass, so any + /// geometry notification in that window is echo, whoever receives it. + private var isSynchronizingExternalGeometry: Bool { + get { Self.anyPortalSynchronizingExternalGeometry } + set { Self.anyPortalSynchronizingExternalGeometry = newValue } + } + private static var anyPortalSynchronizingExternalGeometry = false + fileprivate func scheduleExternalGeometrySynchronize(forceImmediate: Bool) { + // Geometry notifications that arrive while OUR OWN sync pass runs + // are that pass's echo: the pass lays out hosted split views (which + // post didResizeSubviews) and writes hostView.frame (which posts + // frameDidChange), and the main thread runs nothing else meanwhile. + // Scheduling on them makes the sync self-sustaining — observed as + // the main thread pinned at full CPU inside this class under + // multi-window churn. + guard !isSynchronizingExternalGeometry else { return } // Coalesce to the latest request so ancestor/frame churn (for example // sidebar toggles) doesn't resize the PTY at stale intermediate widths. externalGeometrySyncGeneration &+= 1 @@ -800,26 +821,52 @@ final class WindowTerminalPortal: NSObject { } private func synchronizeLayoutHierarchy() { + // Idempotence at the choke point. Several paths funnel here (window + // notifications, anchor geometry callbacks, deferred full syncs, + // transient recovery), each forcing subtree layout — and each layout + // pass emits the notifications and callbacks that re-enter those + // same paths, possibly delivered after any in-pass flag is down. + // When everything this pass reads and writes is unchanged since the + // last completed pass, the pass is a no-op: skip the layout storm + // and the echo dies here, whichever path carried it. AppKit still + // runs pending inner layout before display on its own. + let signature = externalGeometrySignature() + if let last = lastHierarchySyncSignature, last == signature { return } installedContainerView?.layoutSubtreeIfNeeded() installedReferenceView?.layoutSubtreeIfNeeded() hostView.superview?.layoutSubtreeIfNeeded() hostView.layoutSubtreeIfNeeded() _ = synchronizeHostFrameToReference() + lastHierarchySyncSignature = externalGeometrySignature() } + private var lastHierarchySyncSignature: String? + @discardableResult private func synchronizeHostFrameToReference() -> Bool { guard let container = installedContainerView, let reference = installedReferenceView else { return false } - let frameInContainer = container.convert(reference.bounds, from: reference) + var frameInContainer = container.convert(reference.bounds, from: reference) let hasFiniteFrame = frameInContainer.origin.x.isFinite && frameInContainer.origin.y.isFinite && frameInContainer.size.width.isFinite && frameInContainer.size.height.isFinite guard hasFiniteFrame else { return false } + // The host mirrors ON-SCREEN geometry, and nothing larger than the + // window's content is on screen. The SwiftUI reference view can + // report a content-derived size that exceeds the window (SwiftUI + // children may exceed their proposals), and adopting it verbatim + // makes every hosted layout pass pay for the oversized tree — the + // probe caught this host at 4,241 points inside a 1,728-point + // window, with the display cycle relaying the excess every frame. + if let bound = container.window?.contentLayoutRect.size, + bound.width > 1, bound.height > 1 { + frameInContainer.size.width = min(frameInContainer.size.width, bound.width) + frameInContainer.size.height = min(frameInContainer.size.height, bound.height) + } if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) { CATransaction.begin() @@ -837,12 +884,39 @@ final class WindowTerminalPortal: NSObject { } fileprivate func synchronizeAllEntriesFromExternalGeometryChange() { + guard !isSynchronizingExternalGeometry else { return } + isSynchronizingExternalGeometry = true + defer { isSynchronizingExternalGeometry = false } + // Content-based echo cut. A sync pass lays out hosted split views + // and writes hostView.frame, and the notifications those emit can + // be DELIVERED AFTER the pass ends (block observers on .main), so + // no in-pass flag can catch them all — the sync then re-runs + // forever on identical geometry, pinning the main thread. An echo + // carries the exact geometry the last pass left behind, so it dies + // here in one cheap comparison; any real change differs somewhere + // and syncs fully. guard ensureInstalled() else { return } synchronizeLayoutHierarchy() synchronizeAllHostedViews(excluding: nil) reconcileVisibleHostedViewsAfterGeometrySync(reason: "portal.externalGeometrySync") } + /// Cheap (no-layout) fingerprint of everything a sync pass reads or + /// writes: window size, container/reference/host frames, and every + /// hosted view's frame. Identical fingerprint = the pass would be a + /// no-op = skip it (and with it, the relayout that echoes). + private func externalGeometrySignature() -> String { + var parts: [String] = [] + if let window { parts.append("w\(Int(window.frame.width))x\(Int(window.frame.height))") } + parts.append("h\(hostView.frame.debugDescription)") + if let container = installedContainerView { parts.append("c\(container.frame.debugDescription)") } + if let reference = installedReferenceView { parts.append("r\(reference.frame.debugDescription)") } + for (id, entry) in entriesByHostedId.sorted(by: { $0.key.hashValue < $1.key.hashValue }) { + parts.append("e\(id.hashValue):\(entry.hostedView?.frame.debugDescription ?? "gone")") + } + return parts.joined(separator: "|") + } + private func ensureDividerOverlayOnTop() { if dividerOverlayView.superview !== hostView { dividerOverlayView.frame = hostView.bounds @@ -1305,12 +1379,22 @@ final class WindowTerminalPortal: NSObject { reason: String ) -> Bool { guard Self.transientRecoveryEnabled else { return false } + // 0 = idle (a fresh episode may begin), -1 = EXHAUSTED. Without the + // sentinel, an exhausted budget decayed back to 0, looked idle, and + // refilled — so an entry that stays not-ready (a hosted view + // mid-teardown during workspace churn) drove one full sync and + // relayout per runloop turn indefinitely, pinning the main thread. + // Only a successful sync (resetTransientRecoveryRetryIfNeeded) + // returns an exhausted entry to idle. if entry.transientRecoveryRetriesRemaining == 0 { entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget } guard entry.transientRecoveryRetriesRemaining > 0 else { return false } entry.transientRecoveryRetriesRemaining -= 1 + if entry.transientRecoveryRetriesRemaining == 0 { + entry.transientRecoveryRetriesRemaining = -1 + } entriesByHostedId[hostedId] = entry #if DEBUG cmuxDebugLog(