From cbd8e1e72f91417f888866d7401eef9c4f95e04d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:22:02 -0500 Subject: [PATCH 01/60] Core(fix): Check for tmux 3.3a before commands why: A newer client can connect to an older daemon, while normalized version comparisons lose the patch letter that distinguishes 3.3a. what: - Share a bounded daemon-first version check across ordinary raw dispatch - Fence the original native command batch on the checked release - Preserve standalone binary diagnostics without authorizing effects - Route liveness through the version check and make absent cleanup a no-op - Cover patch ordering, raw batches and replacement before mutation --- src/libtmux/_internal/support/versions.py | 133 ++++++++++++++++ src/libtmux/common.py | 31 ++-- src/libtmux/server.py | 34 ++-- tests/legacy_api/test_common.py | 19 +-- tests/test_common.py | 9 +- tests/test_versions.py | 181 ++++++++++++++++++++++ 6 files changed, 367 insertions(+), 40 deletions(-) create mode 100644 src/libtmux/_internal/support/versions.py create mode 100644 tests/test_versions.py diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py new file mode 100644 index 0000000000..5be31760ed --- /dev/null +++ b/src/libtmux/_internal/support/versions.py @@ -0,0 +1,133 @@ +"""Version checks for ordinary commands on a live or newly created tmux endpoint.""" + +from __future__ import annotations + +import getopt +import re +import subprocess +import uuid + +from libtmux import exc + +MINIMUM = "3.3a" +_RELEASE = re.compile(r"(\d+)\.(\d+)([a-z]?)(?:-(master|dev|rc\d+))?") + + +def require_version(version: str, minimum: str = MINIMUM) -> None: + """Reject unknown or older releases, preserving numeric and patch order.""" + # Development lanes are explicit candidates, not stable feature guarantees. + candidate = version.removeprefix("next-") + if candidate == "master": + return + parsed = _RELEASE.fullmatch(candidate) + floor = _RELEASE.fullmatch(minimum) + if parsed is not None and floor is not None: + running = (int(parsed[1]), int(parsed[2]), parsed[3]) + required = (int(floor[1]), int(floor[2]), floor[3]) + if running > required or ( + running == required + and not version.startswith("next-") + and not (parsed[4] or "").startswith("rc") + ): + return + msg = f"libtmux requires tmux {minimum} or newer; endpoint reports {version!r}" + raise exc.VersionTooLow(msg) + + +def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + argv, + capture_output=True, + text=True, + encoding="utf-8", + errors="backslashreplace", + timeout=5, + check=False, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + except subprocess.TimeoutExpired as error: + msg = "tmux version check timed out before command dispatch" + raise exc.LibTmuxException(msg) from error + + +def prepare(argv: list[str]) -> tuple[list[str], str | None]: + """Admit the daemon, then fence dispatch against its exact version.""" + try: + options, commands = getopt.getopt(argv[1:], "28c:CDdf:hlL:NqS:T:uUvV") + except getopt.GetoptError as error: + raise exc.LibTmuxException(str(error)) from error + if any(flag in {"-V", "-h"} for flag, _ in options): + return argv, None + if any(flag == "-c" for flag, _ in options): + msg = "tmux -c is outside command dispatch; use Server.run_shell instead" + raise exc.LibTmuxException(msg) + if not commands: + commands = ["new-session"] + globals_: list[str] = [] + selectors: list[str] = [] + for flag, value in options: + globals_.append(flag) + if flag in {"-f", "-L", "-S", "-T"}: + globals_.append(value) + if flag in {"-L", "-S"}: + selectors.extend((flag, value)) + version = endpoint_version(argv[0], selectors) + marker = "libtmux-version-" + uuid.uuid4().hex + condition = "#{==:#{version}," + version + "}" + return [ + argv[0], + *globals_, + "if-shell", + "-F", + condition, + "", + marker, + ";", + *commands, + ], marker + + +def check_result(stderr: str, marker: str | None) -> None: + """Reject a replaced daemon before returning an ordinary result.""" + if marker is not None and f"unknown command: {marker}" in stderr.splitlines(): + msg = "tmux daemon changed before dispatch; retry the command" + raise exc.VersionTooLow(msg) + + +def live_version(binary: str, selectors: list[str]) -> str | None: + """Admit an existing daemon, returning None for an absent endpoint.""" + probe = _run([binary, *selectors, "-N", "display-message", "-p", "#{version}"]) + if probe.returncode == 0: + version = probe.stdout.strip() + require_version(version) + return version + if "no server running" in probe.stderr or ( + "error connecting to" in probe.stderr + and any( + reason in probe.stderr + for reason in ( + "No such file or directory", + "Connection refused", + ) + ) + ): + return None + msg = probe.stderr.strip() or "tmux did not report a daemon version" + raise exc.LibTmuxException(msg) + + +def endpoint_version(binary: str, selectors: list[str]) -> str: + """Read a fresh daemon version, or the executable for an absent endpoint.""" + version = live_version(binary, selectors) + if version is not None: + return version + result = _run([binary, "-V"]) + if result.returncode or not result.stdout.startswith("tmux "): + detail = result.stderr.strip() or result.stdout.strip() + msg = f"cannot establish tmux version before command dispatch: {detail}" + raise exc.VersionTooLow(msg) + version = result.stdout.removeprefix("tmux ").strip() + require_version(version) + return version diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2871547700..560e139103 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -18,6 +18,7 @@ from . import exc from ._compat import LooseVersion +from ._internal.support import versions if t.TYPE_CHECKING: from collections.abc import Callable @@ -26,7 +27,7 @@ #: Minimum version of tmux required to run libtmux -TMUX_MIN_VERSION = "3.2a" +TMUX_MIN_VERSION = versions.MINIMUM #: Most recent version of tmux supported TMUX_MAX_VERSION = "3.7" @@ -319,6 +320,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: cmd = [str(c) for c in cmd] self.cmd = cmd + dispatched, marker = support.prepare(cmd) if logger.isEnabledFor(logging.DEBUG): cmd_str = shlex.join(cmd) @@ -329,7 +331,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: try: self.process = subprocess.Popen( - cmd, + dispatched, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -349,6 +351,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: ) raise + support.check_result(stderr, marker) self.returncode = returncode stdout_split = stdout.split("\n") @@ -428,7 +431,10 @@ def _query_version(tmux_bin: str | None = None) -> str: raise _TmuxVersionUnavailable raise exc.VersionTooLow(proc.stderr) - return proc.stdout[0].split("tmux ")[1] + if len(proc.stdout) != 1 or not proc.stdout[0].startswith("tmux "): + msg = "tmux -V did not report a single version" + raise exc.VersionTooLow(msg) + return proc.stdout[0].removeprefix("tmux ") @functools.cache @@ -606,7 +612,10 @@ def has_lt_version(max_version: str, tmux_bin: str | None = None) -> bool: def has_minimum_version(raises: bool = True, tmux_bin: str | None = None) -> bool: - """Return True if tmux meets version requirement. Version >= 3.2a. + """Return True if the executable meets the complete release minimum. + + Commands check their connected daemon's version on their own. This helper preserves + patch letters and does not use the legacy normalized version cache. Parameters ---------- @@ -637,15 +646,13 @@ def has_minimum_version(raises: bool = True, tmux_bin: str | None = None) -> boo Versions will now remove trailing letters per `Issue 55 `_. """ - current_version = get_version(tmux_bin=tmux_bin) - if current_version < LooseVersion(TMUX_MIN_VERSION): + try: + version = _query_version(tmux_bin=tmux_bin) + versions.require_version(version, minimum=TMUX_MIN_VERSION) + except (exc.VersionTooLow, _TmuxVersionUnavailable): if raises: - msg = ( - f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater. This " - f"system has {current_version} installed. Upgrade your " - "tmux to use libtmux, or use libtmux v0.48.x for older tmux versions." - ) - raise exc.VersionTooLow(msg) + msg = f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater" + raise exc.VersionTooLow(msg) from None return False return True diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c34..c344e8ff48 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -18,6 +18,7 @@ from libtmux import exc from libtmux._internal.env import socket_path_from_env from libtmux._internal.query_list import QueryList +from libtmux._internal.support import versions from libtmux.client import Client from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope @@ -295,6 +296,8 @@ def is_alive(self) -> bool: """ try: res = self.cmd("list-sessions") + except exc.VersionTooLow: + raise except Exception: return False return res.returncode == 0 @@ -317,22 +320,25 @@ def raise_if_dead(self) -> None: ... print(type(e)) """ - resolved = self.tmux_bin or shutil.which("tmux") - if resolved is None: - raise exc.TmuxCommandNotFound + result = self.cmd("list-sessions") + if result.returncode: + raise subprocess.CalledProcessError( + result.returncode, + result.cmd, + output="\n".join(result.stdout), + stderr="\n".join(result.stderr), + ) - cmd_args: list[str] = ["list-sessions"] + def _version_endpoint(self) -> tuple[str, list[str]]: + binary = self.tmux_bin or shutil.which("tmux") + if binary is None: + raise exc.TmuxCommandNotFound + selectors: list[str] = [] if self.socket_name: - cmd_args.insert(0, f"-L{self.socket_name}") + selectors.extend(("-L", str(self.socket_name))) if self.socket_path: - cmd_args.insert(0, f"-S{self.socket_path}") - if self.config_file: - cmd_args.insert(0, f"-f{self.config_file}") - - try: - subprocess.check_call([resolved, *cmd_args]) - except FileNotFoundError: - raise exc.TmuxCommandNotFound from None + selectors = ["-S", str(self.socket_path), *selectors] + return binary, selectors # # Command @@ -472,6 +478,8 @@ def kill(self) -> None: >>> svr.is_alive() False """ + if support.live_version(*self._version_endpoint()) is None: + return proc = self.cmd("kill-server") if proc.stderr: stderr_text = " ".join(str(line) for line in proc.stderr) diff --git a/tests/legacy_api/test_common.py b/tests/legacy_api/test_common.py index a6631f7136..b4f3960ddb 100644 --- a/tests/legacy_api/test_common.py +++ b/tests/legacy_api/test_common.py @@ -86,7 +86,7 @@ def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> Hi: monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) monkeypatch.setattr(sys, "platform", "openbsd 5.2") - assert has_minimum_version() + assert not has_minimum_version(raises=False) assert has_gte_version(TMUX_MIN_VERSION) assert has_gt_version(TMUX_MAX_VERSION), "Greater than the max-supported version" assert get_version() == f"{TMUX_MAX_VERSION}-openbsd", ( @@ -133,20 +133,11 @@ def test_ignores_letter_versions(monkeypatch: pytest.MonkeyPatch) -> None: def test_error_version_less_1_7(monkeypatch: pytest.MonkeyPatch) -> None: - """Test raises if tmux version less than 1.7.""" - - def mock_get_version(tmux_bin: str | None = None) -> LooseVersion: - return LooseVersion("1.7") - - monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) - with pytest.raises(exc.LibTmuxException) as excinfo: + """Refuse an old executable without dropping its version report.""" + monkeypatch.setattr(libtmux.common, "_query_version", lambda **kwargs: "1.7") + with pytest.raises(exc.VersionTooLow, match="libtmux only supports"): has_minimum_version() - excinfo.match(r"libtmux only supports") - - with pytest.raises(exc.LibTmuxException) as excinfo: - has_minimum_version() - - excinfo.match(r"libtmux only supports") + assert not has_minimum_version(raises=False) def test_has_version() -> None: diff --git a/tests/test_common.py b/tests/test_common.py index 426b72d573..2f4fc33be8 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -488,7 +488,10 @@ def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> MockTmuxOutput: exc_info.match(exc_msg_regex) else: assert get_version() == expected_version - assert has_minimum_version() + if test_id == "openbsd_version": + assert not has_minimum_version(raises=False) + else: + assert has_minimum_version() assert has_gte_version(TMUX_MIN_VERSION) assert has_gt_version(TMUX_MAX_VERSION) @@ -623,6 +626,10 @@ def mock_get_version(tmux_bin: str | None = None) -> LooseVersion: return LooseVersion(mock_version) monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) + if check_type == "min_version": + monkeypatch.setattr( + libtmux.common, "_query_version", lambda **kwargs: mock_version + ) if check_type == "min_version": if raises: diff --git a/tests/test_versions.py b/tests/test_versions.py new file mode 100644 index 0000000000..7ed44a35eb --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,181 @@ +"""tmux version checks before ordinary public dispatch.""" + +from __future__ import annotations + +import subprocess +import typing as t + +import pytest + +from libtmux import Server, exc +from libtmux._internal import support +from libtmux.common import tmux_cmd + + +@pytest.mark.parametrize( + "version", + ["3.3a", "3.3b", "3.4", "3.10", "4.0", "3.3a-master", "next-3.8", "master"], +) +def test_admitted_release(version: str) -> None: + """Keep patch letters and numeric minor ordering at the floor.""" + support.require_version(version) + + +@pytest.mark.parametrize( + "version", + [ + "3.2a", + "3.3", + "3.3rc1", + "3.3a-rc1", + "next-3.3", + "next-3.3a", + "", + "unknown", + "3.3alpha", + "3.3a\n3.7", + ], +) +def test_refused_release(version: str) -> None: + """Unknown and pre-floor reports cannot inherit admission.""" + with pytest.raises(exc.VersionTooLow, match=r"3\.3a"): + support.require_version(version) + + +@pytest.mark.parametrize( + "route", + ["raw-read", "raw-write", "raw-batch", "server", "alive", "raise-if-dead", "kill"], +) +def test_older_daemon_refused_before_dispatch( + monkeypatch: pytest.MonkeyPatch, route: str +) -> None: + """Synthetic version reports let the test assert zero requested effects.""" + probes: list[list[str]] = [] + + def probe(argv: list[str]) -> subprocess.CompletedProcess[str]: + probes.append(argv) + return subprocess.CompletedProcess(argv, 0, "3.3\n", "") + + def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + pytest.fail("requested process started below the minimum") + + monkeypatch.setattr(versions, "_run", probe) + monkeypatch.setattr(subprocess, "Popen", dispatch) + server = Server(tmux_bin="/test/tmux", socket_path="/private/socket") + with pytest.raises(exc.VersionTooLow, match=r"3\.3"): + if route == "raw-read": + tmux_cmd("-S", "/private/socket", "list-sessions", tmux_bin="/test/tmux") + elif route == "raw-write": + tmux_cmd("set-option", "-g", "@probe", "changed", tmux_bin="/test/tmux") + elif route == "raw-batch": + tmux_cmd( + "set-option", + "-g", + "@probe", + "changed", + ";", + "list-sessions", + tmux_bin="/test/tmux", + ) + elif route == "alive": + server.is_alive() + elif route == "kill": + server.kill() + elif route == "raise-if-dead": + server.raise_if_dead() + else: + server.cmd("new-session", "-d") + assert len(probes) == 1 + assert probes[0][-3:] == ["display-message", "-p", "#{version}"] + + +def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep native argv and batch separators after the admission prefix.""" + monkeypatch.setattr( + support, "_run", lambda argv: subprocess.CompletedProcess(argv, 0, "3.3a\n", "") + ) + original = [ + "/tmux", + "-Lodd;socket", + "set-option", + "-g", + "@value", + "literal\\;", + ";", + "list-sessions", + ] + guarded, marker = versions.prepare(original) + assert guarded[:3] == ["/tmux", "-L", "odd;socket"] + assert guarded[-6:] == original[-6:] + assert guarded[3:7] == ["if-shell", "-F", "#{==:#{version},3.3a}", ""] + assert guarded[7] == marker + + +def test_changed_daemon_is_explicit() -> None: + """The native false branch identifies refusal before requested commands.""" + with pytest.raises(exc.VersionTooLow, match="changed before dispatch"): + versions.check_result("unknown command: private-marker\n", "private-marker") + + +def test_probe_error_is_not_a_cold_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: + """Permission and protocol failures never authorize server creation.""" + calls: list[list[str]] = [] + + def probe(argv: list[str]) -> subprocess.CompletedProcess[str]: + calls.append(argv) + return subprocess.CompletedProcess(argv, 1, "", "permission denied") + + monkeypatch.setattr(versions, "_run", probe) + with pytest.raises(exc.LibTmuxException, match="permission denied"): + versions.prepare(["/tmux", "new-session", "-d"]) + assert len(calls) == 1 + + +def test_native_prefix_refuses_a_changed_report( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A native mismatch aborts every requested command in the same batch.""" + server.new_session("admission") + run = support._run + + def changed(argv: list[str]) -> subprocess.CompletedProcess[str]: + result = run(argv) + if result.returncode == 0 and argv[-1] == "#{version}": + return subprocess.CompletedProcess(argv, 0, "3.99\n", "") + return result + + with monkeypatch.context() as scoped: + scoped.setattr(versions, "_run", changed) + with pytest.raises(exc.VersionTooLow, match="changed before dispatch"): + server.cmd( + "set-option", + "-g", + "@version-first", + "changed", + ";", + "set-option", + "-g", + "@version-second", + "changed", + ) + assert server.cmd("show-option", "-gqv", "@version-first").stdout == [] + assert server.cmd("show-option", "-gqv", "@version-second").stdout == [] + + +def test_absent_cleanup_never_dispatches(monkeypatch: pytest.MonkeyPatch) -> None: + """An absent endpoint stays untouched even when its binary is too old.""" + calls: list[list[str]] = [] + + def absent(argv: list[str]) -> subprocess.CompletedProcess[str]: + calls.append(argv) + return subprocess.CompletedProcess(argv, 1, "", "no server running") + + def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + pytest.fail("absent cleanup dispatched a process") + + monkeypatch.setattr(versions, "_run", absent) + monkeypatch.setattr(subprocess, "Popen", dispatch) + Server(socket_name="absent", tmux_bin="/test/old-tmux").kill() + assert len(calls) == 1 + assert calls[0][-1] == "#{version}" From d0b409d8da671758e99ee314780b4f6cd00a68ba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:24:23 -0500 Subject: [PATCH 02/60] Core(refactor): Use the daemon feature baseline why: A supported daemon can differ from its client executable. Numeric format introduction checks must also keep tmux patch letters out of Python package prerelease ordering. what: - Select format fields from fresh endpoint reports - Preserve native introduction metadata for offline format utilities - Remove obsolete popup, hook and prompt fallbacks below 3.3a - Restore the session dimensions test after retiring its 3.2 defect --- src/libtmux/hooks.py | 53 +++++++----------------------------------- src/libtmux/neo.py | 12 ++++++---- src/libtmux/pane.py | 50 ++++++--------------------------------- src/libtmux/server.py | 27 ++++----------------- tests/test_server.py | 1 - tests/test_versions.py | 11 +++++++++ 6 files changed, 38 insertions(+), 116 deletions(-) diff --git a/src/libtmux/hooks.py b/src/libtmux/hooks.py index d9ad24d407..64760570bb 100644 --- a/src/libtmux/hooks.py +++ b/src/libtmux/hooks.py @@ -5,7 +5,7 @@ Hooks are array options (e.g., ``session-renamed[0]``, ``session-renamed[1]``) with sparse index support (can have gaps: ``[0]``, ``[5]``, ``[10]``). -All features available in libtmux's minimum supported version (tmux 3.2+): +Features available on the tmux 3.3a baseline: - Session, window, and pane-level hooks - Window hooks via ``-w`` flag, pane hooks via ``-p`` flag @@ -38,7 +38,7 @@ Hooks, ) from libtmux._internal.sparse_array import SparseArray -from libtmux.common import CmdMixin, has_lt_version +from libtmux.common import CmdMixin from libtmux.constants import ( DEFAULT_OPTION_SCOPE, HOOK_SCOPE_FLAG_MAP, @@ -59,7 +59,7 @@ class HooksMixin(CmdMixin): """Mixin for manager scoped hooks in tmux. - Requires tmux 3.1+. For older versions, use raw commands. + Commands share the tmux 3.3a core minimum. """ default_hook_scope: OptionScope | None @@ -70,13 +70,6 @@ def __init__(self, default_hook_scope: OptionScope | None) -> None: self.default_hook_scope = default_hook_scope self.hooks = Hooks() - @property - def _tmux_bin(self) -> str | None: - """Resolve tmux_bin from self (Server) or self.server (Session/Window/Pane).""" - return getattr(self, "tmux_bin", None) or getattr( - getattr(self, "server", None), "tmux_bin", None - ) - def run_hook( self, hook: str, @@ -96,13 +89,7 @@ def run_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd( "set-hook", @@ -175,13 +162,7 @@ def set_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd( "set-hook", @@ -228,13 +209,7 @@ def unset_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd( "set-hook", @@ -293,13 +268,7 @@ def show_hooks( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd("show-hooks", *flags) output = cmd.stdout @@ -351,13 +320,7 @@ def _show_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) flags += (hook,) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 98ece86fa5..51ec827157 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -5,13 +5,14 @@ import dataclasses import functools import logging +import re import shlex import typing as t from collections.abc import Iterable from libtmux import exc from libtmux._compat import LooseVersion -from libtmux.common import get_version, raise_if_stderr, tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: @@ -277,7 +278,8 @@ def _normalize_tmux_version(version: str) -> LooseVersion: """ if "master" in version.lower(): return LooseVersion("99.0") - return LooseVersion(version) + release = re.match(r"(?:next-)?(\d+\.\d+)", version) + return LooseVersion(release[1] if release is not None else version) @dataclasses.dataclass() @@ -908,7 +910,7 @@ def _refresh( @functools.cache def get_output_format( list_cmd: str = "list-panes", - tmux_version: str = "3.2a", + tmux_version: str = "3.3a", ) -> tuple[tuple[str, ...], str]: """Return field names and tmux format string filtered by scope and version. @@ -985,7 +987,7 @@ def get_output_format( def parse_output( output: str, list_cmd: str = "list-panes", - tmux_version: str = "3.2a", + tmux_version: str = "3.3a", ) -> OutputRaw: """Parse a tmux ``-F`` line into a dict keyed by Obj field name. @@ -1095,7 +1097,7 @@ def fetch_objs( >>> 'session_id' in objs[0] True """ - tmux_version = str(get_version(tmux_bin=server.tmux_bin)) + tmux_version = server._get_version() _fields, format_string = get_output_format(list_cmd, tmux_version) cmd_args: list[str | int] = [] diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e0c2f59619..eb8091f619 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1782,59 +1782,23 @@ def display_popup( tmux_args += ("-d", str(start_path)) if title is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-T", title) - else: - warnings.warn( - "title requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args += ("-T", title) if border_lines is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-b", border_lines) - else: - warnings.warn( - "border_lines requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args += ("-b", border_lines) if style is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-s", style) - else: - warnings.warn( - "style requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args += ("-s", style) if border_style is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-S", border_style) - else: - warnings.warn( - "border_style requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args += ("-S", border_style) if environment: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - for k, v in environment.items(): - tmux_args += (f"-e{k}={v}",) - else: - warnings.warn( - "environment requires tmux 3.3+, ignoring", - stacklevel=2, - ) + for k, v in environment.items(): + tmux_args += (f"-e{k}={v}",) if no_border: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-B",) - else: - warnings.warn( - "no_border requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args += ("-B",) if close_on_any_key: if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c344e8ff48..7760b93153 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -20,7 +20,7 @@ from libtmux._internal.query_list import QueryList from libtmux._internal.support import versions from libtmux.client import Client -from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin from libtmux.neo import fetch_objs, get_output_format, parse_output @@ -340,6 +340,9 @@ def _version_endpoint(self) -> tuple[str, list[str]]: selectors = ["-S", str(self.socket_path), *selectors] return binary, selectors + def _get_version(self) -> str: + return support.endpoint_version(*self._version_endpoint()) + # # Command # @@ -890,10 +893,6 @@ def server_access( ... result = server.server_access(list_access=True) ... assert isinstance(result, list) """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): - msg = "server_access requires tmux 3.3+" - raise exc.LibTmuxException(msg) - if read_only and write: msg = "read_only and write are mutually exclusive (tmux rejects -r -w)" raise ValueError(msg) @@ -1160,10 +1159,6 @@ def confirm_before( >>> result 'yes' """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): - msg = "confirm_before requires tmux 3.3+" - raise exc.LibTmuxException(msg) - tmux_args: tuple[str, ...] = ("-b",) if prompt is not None: @@ -1276,10 +1271,6 @@ def command_prompt( >>> result 'hi' """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): - msg = "command_prompt requires tmux 3.3+" - raise exc.LibTmuxException(msg) - tmux_args: tuple[str, ...] = ("-b",) if one_key: @@ -1766,10 +1757,6 @@ def show_prompt_history( >>> isinstance(result, list) True """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): - msg = "show_prompt_history requires tmux 3.3+" - raise exc.LibTmuxException(msg) - tmux_args: tuple[str, ...] = () if prompt_type is not None: @@ -1800,10 +1787,6 @@ def clear_prompt_history( >>> if has_gte_version("3.3"): ... server.clear_prompt_history() """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): - msg = "clear_prompt_history requires tmux 3.3+" - raise exc.LibTmuxException(msg) - tmux_args: tuple[str, ...] = () if prompt_type is not None: @@ -2338,7 +2321,7 @@ def new_session( del os.environ["TMUX"] try: - tmux_version = str(get_version(tmux_bin=self.tmux_bin)) + tmux_version = self._get_version() _fields, format_string = get_output_format("list-sessions", tmux_version) tmux_args: tuple[str | int, ...] = ( diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9ad..e64eeb896f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -227,7 +227,6 @@ def test_new_session_shell_env(server: Server) -> None: assert pane_start_command.replace('"', "") == cmd -@pytest.mark.skipif(True, reason="tmux 3.2 returns wrong width - test needs rework") def test_new_session_width_height(server: Server) -> None: """Verify ``Server.new_session`` creates valid session running w/ dimensions.""" cmd = "/usr/bin/env PS1='$ ' sh" diff --git a/tests/test_versions.py b/tests/test_versions.py index 7ed44a35eb..881c953ac4 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -131,6 +131,17 @@ def probe(argv: list[str]) -> subprocess.CompletedProcess[str]: assert len(calls) == 1 +def test_format_version_uses_tmux_release_order() -> None: + """Patch letters qualify the core but keep the release's format tokens.""" + from libtmux.neo import get_output_format + + fields, _ = get_output_format("list-panes", "3.7d") + assert "pane_floating_flag" in fields + fields, _ = get_output_format("list-panes", "3.3a") + assert "pane_dead_signal" in fields + assert "pane_floating_flag" not in fields + + def test_native_prefix_refuses_a_changed_report( server: Server, monkeypatch: pytest.MonkeyPatch, From 17e37feeb69002a10d7d06626719f0763de90a52 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:26:59 -0500 Subject: [PATCH 03/60] Docs(docs): Align support and CI on tmux 3.3a why: Installation guidance and tests must name the same baseline that ordinary commands enforce on their connected daemon. what: - Retire 3.2a from the ordinary CI matrix and state the 3.3a floor - Explain daemon authority, diagnostics and absent cleanup - Document development reports and unversioned OpenBSD refusal - Describe fresh format selection separately from cached diagnostics --- .github/workflows/tests.yml | 2 +- AGENTS.md | 2 +- README.md | 2 +- docs/project/compatibility.md | 32 ++++++++++++++++++++++++++++---- docs/quickstart.md | 2 +- docs/topics/format-tokens.md | 32 +++++++++++++------------------- src/libtmux/server.py | 2 ++ 7 files changed, 47 insertions(+), 27 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c40e0a25e..9fb46f5e30 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: python-version: ['3.14'] - tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b', 'master'] + tmux-version: ['3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b', 'master'] steps: - uses: actions/checkout@v7 diff --git a/AGENTS.md b/AGENTS.md index 79c289585f..3c37b7db48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ be stated twice, the file listed above is the one that governs. - A passing gate is evidence only once it has been shown capable of failing. Pair a new test with a deliberate break that proves it bites. -tmux >= 3.2a is the compatibility floor (see `tests.yml`'s build +tmux >= 3.3a is the compatibility floor (see `tests.yml`'s build matrix). `Server.sessions`, `Server.clients`, and `Server.attached_sessions` return an empty `QueryList` rather than raising when the underlying tmux list command fails for any reason — diff --git a/README.md b/README.md index 920af194ff..61dc454b3f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ libtmux is a typed Python API over [tmux], the terminal multiplexer. Stop shelli ## Requirements & support -- tmux: >= 3.2a +- tmux: >= 3.3a - Python: >= 3.10 (CPython and PyPy) Maintenance-only backports (no new fixes): diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 4dc5347e14..fe83bff4e7 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -8,10 +8,34 @@ ## tmux -- **Minimum**: tmux 3.2a -- **Tested**: latest stable tmux release -- libtmux uses tmux's format system and control mode -- older tmux versions - may lack required format variables +Core operations require tmux **3.3a or newer**, including the patch letter. +Tmux 3.3 is below the minimum. The CI matrix includes 3.3a and later releases; +`master` is a development lane. + +Commands check the connected daemon, which can differ from the selected +client executable. An absent endpoint uses the executable's fresh version +report before startup. Raw commands, batches and liveness checks share this +rule. An unsupported or unreadable version raises +{exc}`~libtmux.exc.VersionTooLow` before the requested command runs. +A replacement daemon is checked again before dispatch. +{meth}`~libtmux.Server.kill` returns without dispatching when its endpoint is +absent. + +Each command adds a read-only version query, bounded to five seconds. Admission +does not cache daemon versions. Binary diagnostics through `tmux -V` and +`tmux -h` remain available. Raw `tmux -c` shell execution is refused; use +{meth}`~libtmux.Server.run_shell` for a tmux command instead. + +`master` and numbered development builds newer than the baseline are accepted +as development candidates. They do not inherit stable feature qualification. +Unknown reports are refused. On OpenBSD, an existing daemon must report a +qualified release; an executable without `-V` cannot start an absent endpoint. +The synthetic values returned by legacy version helpers are diagnostic values +and never authorize a command. + +Individual features can require a newer daemon. Their API documentation states +those requirements. Raising the core floor does not qualify control startup, +output synchronization or other optional behavior on every later release. ## Platforms diff --git a/docs/quickstart.md b/docs/quickstart.md index b8f9188493..a352bf95f5 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -12,7 +12,7 @@ from inside a live tmux session. ## Requirements -- [tmux] 3.2a or newer +- [tmux] 3.3a or newer - [pip] - for this handbook's examples [tmux]: https://tmux.github.io/ diff --git a/docs/topics/format-tokens.md b/docs/topics/format-tokens.md index 237c19feaf..670b523619 100644 --- a/docs/topics/format-tokens.md +++ b/docs/topics/format-tokens.md @@ -37,8 +37,7 @@ supported version. A typed field is `None` for one of three reasons: - **Not yet introduced.** Older tmux doesn't know the token at all. - {attr}`~libtmux.Pane.pane_dead_signal` is `None` on tmux 3.2a because - the token landed in 3.3. + {attr}`~libtmux.Pane.pane_flags` is `None` before tmux 3.7. - **Wrong scope for this object.** A {class}`~libtmux.Client` row can report client tokens plus the client's current session/window/pane. `buffer_*` tokens never apply to client rows. @@ -47,15 +46,13 @@ A typed field is `None` for one of three reasons: binding, copy-mode, popup) — never in a `list-*` snapshot. libtmux excludes them from every `-F` template. -The version map for post-3.2a tokens is small and stable. The -following are the tokens libtmux currently gates: +The following tokens arrived after the core 3.3a baseline: | Added in | Tokens | |----------|--------| -| 3.3 | {attr}`~libtmux.Pane.pane_dead_signal`, {attr}`~libtmux.Pane.pane_dead_time` | | 3.7 | {attr}`~libtmux.Pane.bracket_paste_flag`, {attr}`~libtmux.Pane.pane_flags`, {attr}`~libtmux.Pane.pane_floating_flag`, {attr}`~libtmux.Pane.pane_pb_progress`, {attr}`~libtmux.Pane.pane_pb_state`, {attr}`~libtmux.Pane.pane_pipe_pid`, {attr}`~libtmux.Pane.pane_x`, {attr}`~libtmux.Pane.pane_y`, {attr}`~libtmux.Pane.pane_z`, {attr}`~libtmux.Pane.pane_zoomed_flag`, {attr}`~libtmux.Pane.synchronized_output_flag` | -Everything not listed above is safe on every supported tmux (≥ 3.2a). +Everything not listed above is safe on every supported tmux (≥ 3.3a). Fields for newer tmux tokens will be added as each supported version is validated. @@ -118,19 +115,16 @@ The result is cached per `(list_cmd, tmux_version)` pair. ## tmux version detection -You never call this directly, but it's worth knowing how the version -gate gets its answer. libtmux detects the live tmux version via -{func}`libtmux.common.get_version` and passes it through to -`get_output_format` whenever it builds a `-F` template. That lookup is -memoized for the process lifetime, as is the raw-string -{func}`libtmux.common.get_version_str`; the two cache independently, so -if you're swapping the `tmux` binary mid-test, clear both with -`libtmux.common.get_version.cache_clear()` and -`get_version_str.cache_clear()`. - -The {ref}`project` page tracks the project's minimum tmux version -(currently 3.2a); see {doc}`/project/compatibility` for the full -matrix. +Format templates use a fresh version report from the selected daemon. Before +startup, they use the selected executable's report. This keeps field selection +correct when the executable and daemon have different versions. + +{func}`libtmux.common.get_version` and +{func}`libtmux.common.get_version_str` describe the executable and cache their +results independently. Their caches do not authorize daemon operations. + +See {doc}`/project/compatibility` for the tmux 3.3a core minimum and the +version-check rules. ## See also diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 7760b93153..01ec7e4eff 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -466,6 +466,8 @@ def has_session(self, target_session: str, exact: bool = True) -> bool: def kill(self) -> None: """Kill tmux server. + An absent endpoint returns without dispatching or starting a daemon. + >>> svr = Server(socket_name="testing") >>> svr Server(socket_name=testing) From 8fb3404994168b9cec52654afd7dadd1516a1073 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:32:34 -0500 Subject: [PATCH 04/60] Core(fix): Reject undated version reports Keep legacy master normalization available for diagnostics, but require an identifiable release before authorizing a daemon command. The synthetic maximum returned by version helpers cannot establish that an undated development daemon meets the minimum. Verification: the added master case fails with the previous version-check path; restored focused version-check suites pass all 105 tests. Ruff and mypy pass. --- docs/project/compatibility.md | 10 ++++++---- src/libtmux/_internal/support/versions.py | 2 -- tests/legacy_api/test_common.py | 6 ++++-- tests/test_common.py | 2 +- tests/test_versions.py | 3 ++- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index fe83bff4e7..d3e1100666 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -26,10 +26,12 @@ does not cache daemon versions. Binary diagnostics through `tmux -V` and `tmux -h` remain available. Raw `tmux -c` shell execution is refused; use {meth}`~libtmux.Server.run_shell` for a tmux command instead. -`master` and numbered development builds newer than the baseline are accepted -as development candidates. They do not inherit stable feature qualification. -Unknown reports are refused. On OpenBSD, an existing daemon must report a -qualified release; an executable without `-V` cannot start an absent endpoint. +Numbered development reports are accepted as candidates when their release +base meets the baseline; a `next-` build precedes the release it names. These +candidates do not inherit stable feature qualification. Unknown reports, +including an undated `master`, are refused. On OpenBSD, an existing daemon +must report a qualified release; an executable without `-V` cannot start an +absent endpoint. The synthetic values returned by legacy version helpers are diagnostic values and never authorize a command. diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 5be31760ed..15ae81f708 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -17,8 +17,6 @@ def require_version(version: str, minimum: str = MINIMUM) -> None: """Reject unknown or older releases, preserving numeric and patch order.""" # Development lanes are explicit candidates, not stable feature guarantees. candidate = version.removeprefix("next-") - if candidate == "master": - return parsed = _RELEASE.fullmatch(candidate) floor = _RELEASE.fullmatch(minimum) if parsed is not None and floor is not None: diff --git a/tests/legacy_api/test_common.py b/tests/legacy_api/test_common.py index b4f3960ddb..77e35ecd08 100644 --- a/tests/legacy_api/test_common.py +++ b/tests/legacy_api/test_common.py @@ -36,7 +36,9 @@ version_regex = re.compile(r"([0-9]\.[0-9])|(master)") -def test_allows_master_version(monkeypatch: pytest.MonkeyPatch) -> None: +def test_master_diagnostic_does_not_qualify_minimum( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Assert get_version() works with builds from git trunk.""" class Hi: @@ -48,7 +50,7 @@ def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> Hi: monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) - assert has_minimum_version() + assert not has_minimum_version(raises=False) assert has_gte_version(TMUX_MIN_VERSION) assert has_gt_version(TMUX_MAX_VERSION), "Greater than the max-supported version" assert get_version() == f"{TMUX_MAX_VERSION}-master", ( diff --git a/tests/test_common.py b/tests/test_common.py index 2f4fc33be8..14823bc6df 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -488,7 +488,7 @@ def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> MockTmuxOutput: exc_info.match(exc_msg_regex) else: assert get_version() == expected_version - if test_id == "openbsd_version": + if test_id in {"openbsd_version", "master_version"}: assert not has_minimum_version(raises=False) else: assert has_minimum_version() diff --git a/tests/test_versions.py b/tests/test_versions.py index 881c953ac4..2e3d7492f0 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -14,7 +14,7 @@ @pytest.mark.parametrize( "version", - ["3.3a", "3.3b", "3.4", "3.10", "4.0", "3.3a-master", "next-3.8", "master"], + ["3.3a", "3.3b", "3.4", "3.10", "4.0", "3.3a-master", "next-3.8"], ) def test_admitted_release(version: str) -> None: """Keep patch letters and numeric minor ordering at the floor.""" @@ -32,6 +32,7 @@ def test_admitted_release(version: str) -> None: "next-3.3a", "", "unknown", + "master", "3.3alpha", "3.3a\n3.7", ], From 5fd6dbe23ebcb4f84f514d31f6fe005f3303d7cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:37:41 -0500 Subject: [PATCH 05/60] Core(fix): Prevent unchecked daemon restarts Use -N when dispatch follows a successful live-daemon version check. If that daemon exits, the configured executable may have a different version and must not autostart before the command precondition runs. Cold startup still validates the executable and preserves native creation behavior. Verification: the new native disappearance regression restarts a daemon without this fix and fails. The restored focused suites pass 221 tests; ruff, mypy and strict documentation build pass. --- docs/project/compatibility.md | 4 ++++ src/libtmux/_internal/support/versions.py | 11 ++++++++- tests/test_versions.py | 27 +++++++++++++++++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index d3e1100666..bed0276e23 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -35,6 +35,10 @@ absent endpoint. The synthetic values returned by legacy version helpers are diagnostic values and never authorize a command. +If a checked daemon disappears before dispatch, that command fails without +starting the configured executable. Retry against the absent endpoint to +validate the executable before creating a replacement. + Individual features can require a newer daemon. Their API documentation states those requirements. Raising the core floor does not qualify control startup, output synchronization or other optional behavior on every later release. diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 15ae81f708..8e221aca8d 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -71,7 +71,12 @@ def prepare(argv: list[str]) -> tuple[list[str], str | None]: globals_.append(value) if flag in {"-L", "-S"}: selectors.extend((flag, value)) - version = endpoint_version(argv[0], selectors) + version = live_version(argv[0], selectors) + if version is None: + version = _binary_version(argv[0]) + elif "-N" not in globals_: + # A vanished daemon must not restart an older configured executable. + globals_.append("-N") marker = "libtmux-version-" + uuid.uuid4().hex condition = "#{==:#{version}," + version + "}" return [ @@ -121,6 +126,10 @@ def endpoint_version(binary: str, selectors: list[str]) -> str: version = live_version(binary, selectors) if version is not None: return version + return _binary_version(binary) + + +def _binary_version(binary: str) -> str: result = _run([binary, "-V"]) if result.returncode or not result.stdout.startswith("tmux "): detail = result.stderr.strip() or result.stdout.strip() diff --git a/tests/test_versions.py b/tests/test_versions.py index 2e3d7492f0..a450900d8d 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -108,8 +108,8 @@ def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> N guarded, marker = versions.prepare(original) assert guarded[:3] == ["/tmux", "-L", "odd;socket"] assert guarded[-6:] == original[-6:] - assert guarded[3:7] == ["if-shell", "-F", "#{==:#{version},3.3a}", ""] - assert guarded[7] == marker + assert guarded[3:8] == ["-N", "if-shell", "-F", "#{==:#{version},3.3a}", ""] + assert guarded[8] == marker def test_changed_daemon_is_explicit() -> None: @@ -191,3 +191,26 @@ def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: Server(socket_name="absent", tmux_bin="/test/old-tmux").kill() assert len(calls) == 1 assert calls[0][-1] == "#{version}" + + +def test_checked_daemon_disappearance_never_restarts( + server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """Losing an admitted daemon cannot start the configured executable.""" + server.new_session("admission", window_command="sleep 300") + run = support._run + + def disappear(argv: list[str]) -> subprocess.CompletedProcess[str]: + result = run(argv) + if result.returncode == 0 and argv[-1] == "#{version}": + assert run([*argv[:-4], "kill-server"]).returncode == 0 + return result + + with monkeypatch.context() as scoped: + scoped.setattr(versions, "_run", disappear) + result = server.cmd( + "new-session", "-d", "-s", "unexpected-restart", "sleep 300" + ) + assert result.returncode != 0, "dispatch restarted the checked daemon" + binary, selectors = server._version_endpoint() + assert support.live_version(binary, selectors) is None From b9c61c809fdb381509e68cb97e55a7558ab8aafd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:37:41 -0500 Subject: [PATCH 06/60] Changelog(docs): Record the tmux 3.3a floor Describe the daemon version check behavior, patch-letter boundary, diagnostic exception and migration for the unreleased entry. Keep release summaries and version assignment with the maintainer. --- CHANGES | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGES b/CHANGES index 7a691b0cc9..9207a5b5e4 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,30 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### Breaking changes + +#### Minimum tmux 3.3a + +Minimum `tmux>=3.3a` (was `>=3.2a`). Ordinary reads, writes, batches, and raw +commands raise {exc}`~libtmux.exc.VersionTooLow` for an older daemon, including +3.3 without its patch letter. The daemon's version governs an existing +endpoint; the executable's version governs creation on an absent endpoint. + +```text +# Before +tmux>=3.2a + +# After +tmux>=3.3a +``` + +Upgrade tmux and restart existing daemons after saving their sessions. +Updating the client executable alone does not upgrade a running daemon. +Binary version and help diagnostics remain available. Undated development +reports cannot qualify admission. {meth}`Server.kill() ` +remains a no-op for an absent endpoint. See {doc}`project/compatibility` for +support and feature requirements. + ### Documentation #### Cleaner `from_env` examples (#719) From a5ba0db42dba718021d2f3d26210fa5f20192f0a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:40:02 -0500 Subject: [PATCH 07/60] Core(fix): Distinguish option flags from their values Check the parsed global option names when retaining no-autostart behavior. Socket or configuration arguments may themselves be the literal -N, which must not count as the no-start flag. Verification: a direct regression fails on the preceding code; all three -L/-S/-f value cases pass with the fix. Ruff format and checks pass. --- src/libtmux/_internal/support/versions.py | 2 +- tests/test_versions.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 8e221aca8d..c0e9410b1b 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -74,7 +74,7 @@ def prepare(argv: list[str]) -> tuple[list[str], str | None]: version = live_version(argv[0], selectors) if version is None: version = _binary_version(argv[0]) - elif "-N" not in globals_: + elif not any(flag == "-N" for flag, _ in options): # A vanished daemon must not restart an older configured executable. globals_.append("-N") marker = "libtmux-version-" + uuid.uuid4().hex diff --git a/tests/test_versions.py b/tests/test_versions.py index a450900d8d..0c1b0fc99e 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -118,6 +118,18 @@ def test_changed_daemon_is_explicit() -> None: versions.check_result("unknown command: private-marker\n", "private-marker") +@pytest.mark.parametrize("option", ["-L", "-S", "-f"]) +def test_no_start_flag_distinguishes_option_values( + monkeypatch: pytest.MonkeyPatch, option: str +) -> None: + """A literal option value cannot disable daemon restart protection.""" + monkeypatch.setattr( + support, "_run", lambda argv: subprocess.CompletedProcess(argv, 0, "3.3a\n", "") + ) + guarded, _ = support.prepare(["/tmux", option, "-N", "new-session", "-d"]) + assert guarded[:4] == ["/tmux", option, "-N", "-N"] + + def test_probe_error_is_not_a_cold_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: """Permission and protocol failures never authorize server creation.""" calls: list[list[str]] = [] From 3587ff7697ac2889b50107677b948fe449afad40 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:46:50 -0500 Subject: [PATCH 08/60] Formats(fix): Keep numbered development catalogs bounded Normalize a numbered development report to its numeric format catalog before applying the legacy bare-master offline convenience. A 3.3a-master daemon must not request fields introduced by later releases. Verification: the preceding code fails the 3.3a-master future-field regression. Restored version-check, format and break-pane checks pass all 66 selected tests; strict Sphinx build, ruff and mypy pass. --- src/libtmux/neo.py | 13 +++++++++---- tests/test_versions.py | 7 +++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 51ec827157..19cd6d2354 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -265,8 +265,9 @@ def _token_scope(field_name: str) -> str: def _normalize_tmux_version(version: str) -> LooseVersion: """Convert a tmux version string into a comparable :class:`LooseVersion`. - tmux master is reported as ``"master"`` (or e.g. ``"3.6a-master"``); - treat it as larger than any tagged release. + Numbered reports select their release's format catalog. Bare ``"master"`` + selects the complete catalog for offline inspection; it never passes a + daemon's version check. Examples -------- @@ -275,11 +276,15 @@ def _normalize_tmux_version(version: str) -> LooseVersion: True >>> _normalize_tmux_version("3.2a") < _normalize_tmux_version("3.6a") True + >>> _normalize_tmux_version("3.3a-master") == _normalize_tmux_version("3.3") + True """ + release = re.match(r"(?:next-)?(\d+\.\d+)", version) + if release is not None: + return LooseVersion(release[1]) if "master" in version.lower(): return LooseVersion("99.0") - release = re.match(r"(?:next-)?(\d+\.\d+)", version) - return LooseVersion(release[1] if release is not None else version) + return LooseVersion(version) @dataclasses.dataclass() diff --git a/tests/test_versions.py b/tests/test_versions.py index 0c1b0fc99e..a6131c1b54 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -144,13 +144,16 @@ def probe(argv: list[str]) -> subprocess.CompletedProcess[str]: assert len(calls) == 1 -def test_format_version_uses_tmux_release_order() -> None: +@pytest.mark.parametrize( + "floor_report", ["3.3a", "3.3a-master", "3.3a-dev", "next-3.3b"] +) +def test_format_version_uses_tmux_release_order(floor_report: str) -> None: """Patch letters qualify the core but keep the release's format tokens.""" from libtmux.neo import get_output_format fields, _ = get_output_format("list-panes", "3.7d") assert "pane_floating_flag" in fields - fields, _ = get_output_format("list-panes", "3.3a") + fields, _ = get_output_format("list-panes", floor_report) assert "pane_dead_signal" in fields assert "pane_floating_flag" not in fields From f77d537c21f0c705cfdc29ce8fec3e4550b375b7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:46:50 -0500 Subject: [PATCH 09/60] Tests(fix): Wait for the break-pane naming command Run exec sleep and wait for its foreground name before asserting the window auto-name. A POSIX shell can retain its own foreground process while waiting for a child, so neither a split reply nor a fixed delay establishes the expected sleep name. Verification: forcing the exact-3.7 placeholder on a newer daemon fails on the intended libtmux-versus-sleep name assertion. Restoring the release guard passes the focused tests without reruns. --- tests/test_pane.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_pane.py b/tests/test_pane.py index 416032ce62..11d410f6d9 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -1358,7 +1358,13 @@ def test_break_pane_no_name_uses_natural_name(session: Session) -> None: pane = window.active_pane assert pane is not None - new_pane = pane.split(shell="sleep 123") + new_pane = pane.split(shell="exec sleep 123") + + def command_started() -> bool: + new_pane.refresh() + return new_pane.pane_current_command == "sleep" + + retry_until(command_started) broken = new_pane.break_pane() assert broken.window_name == "sleep" From 7c27adafcb14f18d795667c9d5fe1dc1d2b28674 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:52:50 -0500 Subject: [PATCH 10/60] Core(fix): Preserve version refusals through traversal Propagate VersionTooLow through list-shaped accessors, winlink discovery and query lookup. Unsupported endpoints must not appear as empty collections or missing attributes. Keep ordinary command errors under the existing list leniency contract. Verification: all five public traversal routes fail the preceding implementation and raise the intended refusal after correction. Final pytest and executed documentation examples pass all1499 tests without reruns; native25-check mixed-version and replacement proof, strictSphinx, ruff and mypy pass. Independent review found no remaining material issue in the changed catch paths. --- AGENTS.md | 3 +++ src/libtmux/AGENTS.md | 3 +++ src/libtmux/_internal/query_list.py | 5 +++++ src/libtmux/neo.py | 2 ++ src/libtmux/server.py | 8 ++++++++ src/libtmux/window.py | 2 ++ tests/test_versions.py | 29 +++++++++++++++++++++++++++-- 7 files changed, 50 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3c37b7db48..070d97225c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,9 @@ list-shaped accessors are lenient by default; `Server.is_alive()` and `src/libtmux/AGENTS.md` for the full contract and this package's logging conventions. +An unsupported tmux version raises `VersionTooLow`, including from lenient +list accessors and query traversal. Version refusals are not empty results. + ## References - Documentation: https://libtmux.git-pull.com/ diff --git a/src/libtmux/AGENTS.md b/src/libtmux/AGENTS.md index 380707c714..76215aa4bc 100644 --- a/src/libtmux/AGENTS.md +++ b/src/libtmux/AGENTS.md @@ -34,6 +34,9 @@ manager) rather than changing the default contract of an existing accessor or hard-coding raise-on-tmux-error into a new one. Empty-on-tmux-error stays the default; raise is opt-in. +Unsupported-version refusals raise `VersionTooLow` before a listing is dispatched. +Do not convert those refusals into empty results or missing attributes. + ## Logging These rules guide future logging changes; existing code may not yet diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 20aeb407f6..03c1b34e75 100644 --- a/src/libtmux/_internal/query_list.py +++ b/src/libtmux/_internal/query_list.py @@ -12,6 +12,7 @@ import typing as t from collections.abc import Callable, Iterable, Mapping, Sequence +from libtmux import exc from libtmux.exc import ( MultipleObjectsReturned as MultipleObjectsReturned, ObjectDoesNotExist as ObjectDoesNotExist, @@ -101,6 +102,8 @@ def keygetter( elif hasattr(dct, sub_field): dct = getattr(dct, sub_field) + except exc.VersionTooLow: + raise except Exception: logger.debug( "key lookup failed for path: %s", @@ -145,6 +148,8 @@ def parse_lookup( field_name = path.split(lookup, maxsplit=1)[0] if field_name is not None: return keygetter(obj, field_name) + except exc.VersionTooLow: + raise except Exception: logger.debug( "lookup parsing failed for path: %s", diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 19cd6d2354..702a4c4238 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1354,6 +1354,8 @@ def fetch_obj( list_cmd=list_cmd, list_extra_args=list_extra_args, ) + except exc.VersionTooLow: + raise except exc.LibTmuxException as e: # A ``-t``-scoped listing pushes the "does it exist?" question down # into tmux, which answers on stderr rather than with an empty listing. diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 01ec7e4eff..20ca2484c3 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -77,6 +77,8 @@ def _fetch_or_empty( """ try: return fetch_objs(server=server, list_cmd=list_cmd, **kwargs) # type: ignore[arg-type] + except exc.VersionTooLow: + raise except exc.LibTmuxException as e: if e.args and _is_daemon_not_up_error(str(e.args[0])): return [] @@ -2405,12 +2407,15 @@ def sessions(self) -> QueryList[Session]: missing socket, a permission error, or a subprocess failure. To distinguish "no sessions" from "tmux unreachable", call :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. + Unsupported tmux reports raise :exc:`~libtmux.exc.VersionTooLow`. """ try: sessions: list[Session] = [ Session(server=self, **obj) for obj in fetch_objs(server=self, list_cmd="list-sessions") ] + except exc.VersionTooLow: + raise except exc.LibTmuxException: return QueryList([]) return QueryList(sessions) @@ -2466,6 +2471,7 @@ def clients(self) -> QueryList[Client]: missing socket, a permission error, or a subprocess failure. To distinguish "no clients attached" from "tmux unreachable", call :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. + Unsupported tmux reports raise :exc:`~libtmux.exc.VersionTooLow`. Returns ------- @@ -2483,6 +2489,8 @@ def clients(self) -> QueryList[Client]: Client(server=self, **obj) for obj in fetch_objs(server=self, list_cmd="list-clients") ] + except exc.VersionTooLow: + raise except exc.LibTmuxException: return QueryList([]) return QueryList(clients) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b57db99692..6cbec4df7e 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -363,6 +363,8 @@ def linked_sessions(self) -> QueryList[Session]: server=self.server, list_cmd="list-sessions", ) + except exc.VersionTooLow: + raise except exc.LibTmuxException: return QueryList([]) diff --git a/tests/test_versions.py b/tests/test_versions.py index a6131c1b54..f962bf3baf 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -7,8 +7,10 @@ import pytest -from libtmux import Server, exc +from libtmux import Server, Window, exc from libtmux._internal import support +from libtmux._internal.query_list import QueryList +from libtmux._internal.support import versions from libtmux.common import tmux_cmd @@ -45,7 +47,20 @@ def test_refused_release(version: str) -> None: @pytest.mark.parametrize( "route", - ["raw-read", "raw-write", "raw-batch", "server", "alive", "raise-if-dead", "kill"], + [ + "raw-read", + "raw-write", + "raw-batch", + "server", + "alive", + "raise-if-dead", + "kill", + "sessions", + "clients", + "attached-sessions", + "linked-sessions", + "query", + ], ) def test_older_daemon_refused_before_dispatch( monkeypatch: pytest.MonkeyPatch, route: str @@ -84,6 +99,16 @@ def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: server.kill() elif route == "raise-if-dead": server.raise_if_dead() + elif route == "sessions": + _ = server.sessions + elif route == "clients": + _ = server.clients + elif route == "attached-sessions": + _ = server.attached_sessions + elif route == "linked-sessions": + _ = Window(server=server, window_id="@1").linked_sessions + elif route == "query": + QueryList([server]).filter(sessions__noeq=[]) else: server.cmd("new-session", "-d") assert len(probes) == 1 From a79748591262da70a222ea198d51fafd59f06704 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:56:43 -0500 Subject: [PATCH 11/60] Changelog(docs): Link the core support proposal Add the assigned pull-request reference to the isolated unreleased deliverable heading. StrictSphinx rebuild passes; runtime sources are unchanged from the qualified core revision. --- CHANGES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 9207a5b5e4..55f240cfab 100644 --- a/CHANGES +++ b/CHANGES @@ -47,7 +47,7 @@ _Notes on the upcoming release will go here._ ### Breaking changes -#### Minimum tmux 3.3a +#### Minimum tmux 3.3a (#779) Minimum `tmux>=3.3a` (was `>=3.2a`). Ordinary reads, writes, batches, and raw commands raise {exc}`~libtmux.exc.VersionTooLow` for an older daemon, including From e24070a7424d06bed80e18ee813656f46e520617 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 21:08:14 -0500 Subject: [PATCH 12/60] Docs(fix): Wait for a complete marker in the completion example Start a real command with the window and poll for its exact output line. The previous nonexistent command and fixed 200 ms delay failed in the 3.3a, 3.6 and 3.7b CI lanes and could also match terminal input echo. Keep the pane alive to capture its completed output. The doctest passes without retries on real 3.3a and 3.7d; changing only the printed marker makes the unchanged predicate fail with WaitTimeout. Strict docs pass. --- docs/topics/automation_patterns.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index ba96411085..074df56ed7 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -523,21 +523,19 @@ True ### 1. Always use markers for completion detection -Timing is a guess; a marker is a fact. Instead of sleeping long enough and hoping a -command finished, have it print an explicit marker and poll for that. Your automation -then reacts to what actually happened rather than to a clock. +Have the command print a marker when it finishes, then wait until that marker appears +as a complete output line. Starting the command with the window avoids matching an +echo of typed input. The shell keeps the pane open so its output remains available. ```python ->>> bp_window = session.new_window(window_name='best-practice', attach=False) +>>> bp_window = session.new_window( +... window_name='best-practice', attach=False, +... window_shell="printf '%s\\n' 'work complete' '__DONE__'; exec /bin/sh", +... ) >>> bp_pane = bp_window.active_pane ->>> # Good: Use completion marker ->>> bp_pane.send_keys('long_command; echo "__DONE__"') - ->>> # Then poll for marker ->>> import time ->>> time.sleep(0.2) ->>> '__DONE__' in '\\n'.join(bp_pane.capture_pane()) +>>> from libtmux.test.retry import retry_until +>>> retry_until(lambda: '__DONE__' in bp_pane.capture_pane(), seconds=3) True >>> bp_window.kill() From a7f0527fc81964690aedc479a1f004de11c1e6a6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 22:11:55 -0500 Subject: [PATCH 13/60] Core(fix): Accept the configured native executable banner tmux 3.3a prints its executable basename for -V. Match that exact name before the canonical tmux prefix, including names with spaces and non-UTF-8 bytes. Keep complete release validation and controlled daemon framing unchanged. Verification: all 52 version check cases pass on native 3.3a. The preceding parser fails four intended banner cases, including three real renamed executables. Ruff, mypy, strict Sphinx and independent source review pass. --- src/libtmux/_internal/support/versions.py | 14 ++++- tests/test_versions.py | 62 ++++++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index c0e9410b1b..dd949950ad 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -3,6 +3,8 @@ from __future__ import annotations import getopt +import os +import pathlib import re import subprocess import uuid @@ -131,10 +133,18 @@ def endpoint_version(binary: str, selectors: list[str]) -> str: def _binary_version(binary: str) -> str: result = _run([binary, "-V"]) - if result.returncode or not result.stdout.startswith("tmux "): + # tmux 3.3a prints its executable name, including non-UTF-8 bytes. + name = os.fsencode(pathlib.Path(binary).name).decode( + "utf-8", errors="backslashreplace" + ) + prefix = next( + (item for item in (name + " ", "tmux ") if result.stdout.startswith(item)), + None, + ) + if result.returncode or prefix is None: detail = result.stderr.strip() or result.stdout.strip() msg = f"cannot establish tmux version before command dispatch: {detail}" raise exc.VersionTooLow(msg) - version = result.stdout.removeprefix("tmux ").strip() + version = result.stdout[len(prefix) :].strip() require_version(version) return version diff --git a/tests/test_versions.py b/tests/test_versions.py index f962bf3baf..0f01f784c2 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pathlib +import shutil import subprocess import typing as t @@ -253,4 +255,62 @@ def disappear(argv: list[str]) -> subprocess.CompletedProcess[str]: ) assert result.returncode != 0, "dispatch restarted the checked daemon" binary, selectors = server._version_endpoint() - assert support.live_version(binary, selectors) is None + assert versions.live_version(binary, selectors) is None + + +@pytest.mark.parametrize("name", ["renamed-tmux", "tmux with spaces", "tmux\udcff"]) +def test_named_33a_executable_can_create(tmp_path: pathlib.Path, name: str) -> None: + """The native 3.3a banner includes the configured executable basename.""" + binary = shutil.which("tmux") + assert binary is not None + report = subprocess.run( + [binary, "-V"], capture_output=True, check=True, timeout=5 + ).stdout + if report != b"tmux 3.3a\n": + pytest.skip("requires native tmux 3.3a") + renamed = tmp_path / name + renamed.symlink_to(binary) + socket = tmp_path / "s" + server = Server( + tmux_bin=str(renamed), socket_path=str(socket), config_file="/dev/null" + ) + try: + session = server.new_session( + "named-executable", window_command="exec sleep 123" + ) + assert session.session_id is not None + assert server._get_version() == "3.3a" + finally: + subprocess.run( + [binary, "-N", "-S", str(socket), "kill-server"], + capture_output=True, + check=False, + timeout=5, + ) + + +@pytest.mark.parametrize( + ("banner", "accepted"), + [ + ("tmux 3.3a\n", True), + ("custom tmux 3.3a\n", True), + ("unrelated 3.3a\n", False), + ("custom tmux-suffix 3.3a\n", False), + ("custom tmux 3.3a extra\n", False), + ("custom tmux 3.3\n", False), + ], +) +def test_executable_report_uses_its_exact_name( + monkeypatch: pytest.MonkeyPatch, banner: str, accepted: bool +) -> None: + """An executable's native name cannot relax the complete release token.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: subprocess.CompletedProcess(argv, 0, banner, ""), + ) + if accepted: + assert versions._binary_version("/test/custom tmux") == "3.3a" + else: + with pytest.raises(exc.VersionTooLow): + support._binary_version("/test/custom tmux") From 6e9cd9e36cd68c34a2580cbfc2d49f7a91eeb4ed Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 22:11:55 -0500 Subject: [PATCH 14/60] Changelog(docs): Record renamed executable version checks Clarify that a native 3.3a executable remains eligible when invoked under a different basename. Keep the release note separate from runtime changes. --- CHANGES | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES b/CHANGES index 55f240cfab..f74e712c62 100644 --- a/CHANGES +++ b/CHANGES @@ -64,6 +64,7 @@ tmux>=3.3a Upgrade tmux and restart existing daemons after saving their sessions. Updating the client executable alone does not upgrade a running daemon. +A renamed tmux 3.3a executable still qualifies the minimum. Binary version and help diagnostics remain available. Undated development reports cannot qualify admission. {meth}`Server.kill() ` remains a no-op for an absent endpoint. See {doc}`project/compatibility` for From 66ee83e43c5c3f9b63ee06e1f252f35b9927f0d0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 03:37:02 -0500 Subject: [PATCH 15/60] Core(fix): Probe the daemon version without firing hooks The live version probe ran display-message -p '#{version}', which fires a user's after-display-message hook on every release before the version check could refuse anything. Probe with `-N run-shell -C '#{version}'` instead: run-shell has no after-hook, and with -C the expanded string is parsed in a timer callback after exec returned, so cmdq never inserts command-error. The command exits 1 with stderr `unknown command: `; the version is read from that line, and any other failure stays an error rather than an absent endpoint. Tests: a native test sets after-display-message (all releases) and command-error (guarded; set-hook refuses it before 3.5) hooks, runs the probe, a prepared command and a dispatched command, then fires a control display-message and a failing command. Hooks run in order, so each sentinel holding exactly one letter shows no probe fired a hook. The mocked probes in test_versions.py now answer in the run-shell format. Verification: the new test passes on 3.3a, 3.4, 3.5, 3.6b, 3.7d, 3.8 and master (next-3.9). Restoring display-message as the probe fails it on 3.3a, 3.5, 3.8 and master. 236 tests pass across test_versions, test_server, test_common and test_version on 3.7d; ruff and mypy are clean. --- src/libtmux/_internal/support/versions.py | 20 ++++-- tests/test_versions.py | 75 ++++++++++++++++++----- 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index dd949950ad..4e6490e18e 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -102,12 +102,20 @@ def check_result(stderr: str, marker: str | None) -> None: def live_version(binary: str, selectors: list[str]) -> str | None: - """Admit an existing daemon, returning None for an absent endpoint.""" - probe = _run([binary, *selectors, "-N", "display-message", "-p", "#{version}"]) - if probe.returncode == 0: - version = probe.stdout.strip() - require_version(version) - return version + """Check an existing daemon's version, returning None for an absent endpoint.""" + # run-shell has no after-hook and, with -C, fails while parsing its own + # output, so the probe fires neither after-* nor command-error hooks. + marker = "libtmux-version-" + uuid.uuid4().hex + "-" + probe = _run([binary, *selectors, "-N", "run-shell", "-C", marker + "#{version}"]) + prefix = f"unknown command: {marker}" + reports = [ + line[len(prefix) :] + for line in probe.stderr.splitlines() + if line.startswith(prefix) + ] + if probe.returncode == 1 and len(reports) == 1: + require_version(reports[0]) + return reports[0] if "no server running" in probe.stderr or ( "error connecting to" in probe.stderr and any( diff --git a/tests/test_versions.py b/tests/test_versions.py index 0f01f784c2..1e7f0b97b2 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -16,6 +16,17 @@ from libtmux.common import tmux_cmd +def _is_probe(argv: list[str]) -> bool: + return argv[-3:-1] == ["run-shell", "-C"] + + +def _reply(argv: list[str], version: str) -> subprocess.CompletedProcess[str]: + """Answer a version probe the way a live daemon does.""" + return subprocess.CompletedProcess( + argv, 1, "", f"unknown command: {argv[-1].replace('#{version}', version)}\n" + ) + + @pytest.mark.parametrize( "version", ["3.3a", "3.3b", "3.4", "3.10", "4.0", "3.3a-master", "next-3.8"], @@ -72,7 +83,7 @@ def test_older_daemon_refused_before_dispatch( def probe(argv: list[str]) -> subprocess.CompletedProcess[str]: probes.append(argv) - return subprocess.CompletedProcess(argv, 0, "3.3\n", "") + return _reply(argv, "3.3") def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: pytest.fail("requested process started below the minimum") @@ -114,14 +125,52 @@ def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: else: server.cmd("new-session", "-d") assert len(probes) == 1 - assert probes[0][-3:] == ["display-message", "-p", "#{version}"] + assert probes[0][-4:-1] == ["-N", "run-shell", "-C"] + assert probes[0][-1].endswith("#{version}") -def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> None: - """Keep native argv and batch separators after the admission prefix.""" - monkeypatch.setattr( - support, "_run", lambda argv: subprocess.CompletedProcess(argv, 0, "3.3a\n", "") +def test_version_check_fires_no_user_hooks(server: Server) -> None: + """The version probe has no side effects a user's hooks can observe.""" + from libtmux.test.retry import retry_until + + server.new_session("versioned", window_command="sleep 300") + version = server._get_version() + has_command_error = True + try: + versions.require_version(version, "3.5") + except exc.VersionTooLow: + has_command_error = False + server.cmd( + "set-hook", "-g", "after-display-message", "set-option -ag @probe-after x" ) + if has_command_error: + server.cmd("set-hook", "-g", "command-error", "set-option -ag @probe-error y") + + binary, selectors = server._version_endpoint() + assert versions.live_version(binary, selectors) == version + versions.prepare([binary, *selectors, "list-sessions"]) + server.cmd("list-sessions") + + # Hooks run in order, so a control firing after the probes proves that no + # earlier probe left a mark: each sentinel holds exactly one letter. + server.cmd("display-message", "-p", "control") + if has_command_error: + assert server.cmd("kill-session", "-t", "=missing").returncode != 0 + + def settled() -> bool: + after = server.cmd("show-option", "-gqv", "@probe-after").stdout + error = server.cmd("show-option", "-gqv", "@probe-error").stdout + return after == ["x"] and (error == ["y"] or not has_command_error) + + assert retry_until(settled, seconds=5, raises=False) + assert server.cmd("show-option", "-gqv", "@probe-after").stdout == ["x"] + if has_command_error: + assert server.cmd("show-option", "-gqv", "@probe-error").stdout == ["y"] + + +def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep native argv and batch separators after the version fence.""" + monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) original = [ "/tmux", "-Lodd;socket", @@ -150,10 +199,8 @@ def test_no_start_flag_distinguishes_option_values( monkeypatch: pytest.MonkeyPatch, option: str ) -> None: """A literal option value cannot disable daemon restart protection.""" - monkeypatch.setattr( - support, "_run", lambda argv: subprocess.CompletedProcess(argv, 0, "3.3a\n", "") - ) - guarded, _ = support.prepare(["/tmux", option, "-N", "new-session", "-d"]) + monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) + guarded, _ = versions.prepare(["/tmux", option, "-N", "new-session", "-d"]) assert guarded[:4] == ["/tmux", option, "-N", "-N"] @@ -195,8 +242,8 @@ def test_native_prefix_refuses_a_changed_report( def changed(argv: list[str]) -> subprocess.CompletedProcess[str]: result = run(argv) - if result.returncode == 0 and argv[-1] == "#{version}": - return subprocess.CompletedProcess(argv, 0, "3.99\n", "") + if _is_probe(argv) and result.returncode == 1: + return _reply(argv, "3.99") return result with monkeypatch.context() as scoped: @@ -232,7 +279,7 @@ def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: monkeypatch.setattr(subprocess, "Popen", dispatch) Server(socket_name="absent", tmux_bin="/test/old-tmux").kill() assert len(calls) == 1 - assert calls[0][-1] == "#{version}" + assert _is_probe(calls[0]) def test_checked_daemon_disappearance_never_restarts( @@ -244,7 +291,7 @@ def test_checked_daemon_disappearance_never_restarts( def disappear(argv: list[str]) -> subprocess.CompletedProcess[str]: result = run(argv) - if result.returncode == 0 and argv[-1] == "#{version}": + if _is_probe(argv) and result.returncode == 1: assert run([*argv[:-4], "kill-server"]).returncode == 0 return result From 5fbed359b4bd6f639fb1f782d6c897d52eca19c3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 03:37:02 -0500 Subject: [PATCH 16/60] Support(docs): Describe the hook-free version probe Replace the read-only wording: the version check queries with run-shell -C, which fires no after-* or command-error hook, and name the one case where command-error fires, a replaced daemon failing the fence on tmux 3.5 or newer. --- docs/project/compatibility.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index bed0276e23..ce3a28ce2a 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -21,8 +21,12 @@ A replacement daemon is checked again before dispatch. {meth}`~libtmux.Server.kill` returns without dispatching when its endpoint is absent. -Each command adds a read-only version query, bounded to five seconds. Admission -does not cache daemon versions. Binary diagnostics through `tmux -V` and +Each command adds a version query, bounded to five seconds. Admission does not +cache daemon versions. The query is `run-shell -C` with a marker format, which +fires neither `after-*` hooks nor `command-error`, so a user's hooks never run +for a version check. It exits 1 with the version in its error text. If a replaced +daemon fails the fence on tmux 3.5 or newer, `command-error` fires once for +that refusal, which reports a real error. Binary diagnostics through `tmux -V` and `tmux -h` remain available. Raw `tmux -c` shell execution is refused; use {meth}`~libtmux.Server.run_shell` for a tmux command instead. From c89bfb1075c6d245f1bbb6f937aaa2d5c2867b7b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 03:37:02 -0500 Subject: [PATCH 17/60] Changelog(docs): Record the hook-free version probe Note that the version check runs no user hooks. Keep the release note separate from runtime changes. --- CHANGES | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES b/CHANGES index f74e712c62..849a9f501c 100644 --- a/CHANGES +++ b/CHANGES @@ -65,6 +65,7 @@ tmux>=3.3a Upgrade tmux and restart existing daemons after saving their sessions. Updating the client executable alone does not upgrade a running daemon. A renamed tmux 3.3a executable still qualifies the minimum. +Version admission runs no user hooks. Binary version and help diagnostics remain available. Undated development reports cannot qualify admission. {meth}`Server.kill() ` remains a no-op for an absent endpoint. See {doc}`project/compatibility` for From c76fa7dbfd6a04a9fe4535466865730773e9819d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 06:57:16 -0500 Subject: [PATCH 18/60] Core(fix): Order tmux version reports by their real grammar The version check accepted `3.3a-master` and `3.3a-dev` at the floor while it refused `next-3.3a`, rejected the bare `3.8-rc` that tmux itself reports, and let `03.3a` through. Those suffixes never come from tmux; they came from library wrappers. Parse only the forms tmux has emitted: `X.Y` with an optional patch letter, `X.Y-rc` and `X.Y-rcN`, and `next-X.Y`, with numbers that have no leading zeros and ASCII digits only. They order as next-X.Y < X.Y-rc < X.Y-rcN < X.Y < X.Ya, so a candidate or development build is supported only above the floor. Tests: one table-driven vector list (check, below floor, unparseable) plus the ordering chains and the 3.7 feature-floor cases from version-vectors.md. Verification: widening the pattern to accept `-master`/`-dev` and require rc digits fails seven cases, including `3.8-rc` and `3.3a-master`. The version check module passes; ruff and mypy are clean. --- docs/project/compatibility.md | 16 ++-- src/libtmux/_internal/support/versions.py | 60 ++++++++++---- tests/test_versions.py | 95 ++++++++++++++++++----- 3 files changed, 129 insertions(+), 42 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index ce3a28ce2a..2b7f89d64c 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -30,12 +30,16 @@ that refusal, which reports a real error. Binary diagnostics through `tmux -V` a `tmux -h` remain available. Raw `tmux -c` shell execution is refused; use {meth}`~libtmux.Server.run_shell` for a tmux command instead. -Numbered development reports are accepted as candidates when their release -base meets the baseline; a `next-` build precedes the release it names. These -candidates do not inherit stable feature qualification. Unknown reports, -including an undated `master`, are refused. On OpenBSD, an existing daemon -must report a qualified release; an executable without `-V` cannot start an -absent endpoint. +The version check reads the forms tmux reports: a release with an optional patch +letter (`3.3a`), a candidate (`3.8-rc`, `3.8-rc2`) and a development build +(`next-3.9`). They order as `next-3.9` < `3.9-rc` < `3.9-rc2` < `3.9` < `3.9a`, +and minor numbers compare numerically, so a candidate or development build is +supported only when its release number is above the baseline. Such builds do not +inherit stable feature qualification. Everything else is refused: an undated +`master`, a development suffix on a release (`3.3a-dev`), a patch letter on a +development build (`next-3.3a`), leading zeros and OpenBSD's own numbering. On +OpenBSD, an existing daemon must report a qualified release; an executable +without `-V` cannot start an absent endpoint. The synthetic values returned by legacy version helpers are diagnostic values and never authorize a command. diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 4e6490e18e..aa64d1403c 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -12,28 +12,58 @@ from libtmux import exc MINIMUM = "3.3a" -_RELEASE = re.compile(r"(\d+)\.(\d+)([a-z]?)(?:-(master|dev|rc\d+))?") + +_NUMBER = r"(?:0|[1-9][0-9]*)" +_REPORT = re.compile( + rf"(?Pnext-)?(?P{_NUMBER})\.(?P{_NUMBER})" + r"(?:(?P[a-z])|-rc(?P[0-9]*))?", + re.ASCII, +) + + +def _rank(report: str) -> tuple[int, int, int, int, str] | None: + """Order the forms tmux reports, or return None for any other string. + + ``next-X.Y`` precedes ``X.Y-rc``, then ``X.Y-rcN``, then ``X.Y`` and its + patch letters. A bare ``-rc`` ranks as the first candidate. + """ + parsed = _REPORT.fullmatch(report) + if parsed is None: + return None + dev = parsed["dev"] is not None + letter = parsed["letter"] + candidate = parsed["candidate"] + if dev and (letter or candidate is not None): + return None + stage = 0 if dev else 1 if candidate is not None else 2 + return ( + int(parsed["major"]), + int(parsed["minor"]), + stage, + int(candidate or 1) if stage == 1 else 0, + letter or "", + ) def require_version(version: str, minimum: str = MINIMUM) -> None: - """Reject unknown or older releases, preserving numeric and patch order.""" - # Development lanes are explicit candidates, not stable feature guarantees. - candidate = version.removeprefix("next-") - parsed = _RELEASE.fullmatch(candidate) - floor = _RELEASE.fullmatch(minimum) - if parsed is not None and floor is not None: - running = (int(parsed[1]), int(parsed[2]), parsed[3]) - required = (int(floor[1]), int(floor[2]), floor[3]) - if running > required or ( - running == required - and not version.startswith("next-") - and not (parsed[4] or "").startswith("rc") - ): - return + """Reject unknown or older reports, preserving numeric and patch order.""" + running = _rank(version) + required = _rank(minimum) + if running is not None and required is not None and running >= required: + return msg = f"libtmux requires tmux {minimum} or newer; endpoint reports {version!r}" raise exc.VersionTooLow(msg) +def is_at_least(version: str, minimum: str) -> bool: + """Compare a checked version report with a feature's complete release floor.""" + try: + require_version(version, minimum=minimum) + except exc.VersionTooLow: + return False + return True + + def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: try: return subprocess.run( diff --git a/tests/test_versions.py b/tests/test_versions.py index 1e7f0b97b2..815bc1040c 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import itertools import pathlib import shutil import subprocess @@ -27,35 +28,87 @@ def _reply(argv: list[str], version: str) -> subprocess.CompletedProcess[str]: ) +SUPPORTED = [ + "3.3a", + "3.3b", + "3.4", + "3.7d", + "3.8", + "3.10", + "4.0", + "3.4-rc", + "3.8-rc", + "3.8-rc2", + "next-3.4", + "next-3.9", +] +BELOW_FLOOR = ["3.3", "3.2a", "3.3-rc", "3.2-rc5", "next-3.3"] +UNPARSEABLE = [ + "master", + "openbsd-7.6", + "", + "3", + "garbage", + "3.3a garbage", + "3.3a\n3.7", + "3.3a-master", + "3.3a-dev", + "next-3.3a", + "next-3.9-master", + "next-3.9a", + "3.3a-rc", + "3.3a-rc1", + "03.3a", + "3.03a", + "3.3A", + "3.3aa", + "3.3a ", + "\u0663.\u0663a", +] + + +@pytest.mark.parametrize("version", SUPPORTED) +def test_checked_release(version: str) -> None: + """Every form tmux reports at or above the floor is supported.""" + versions.require_version(version) + + +@pytest.mark.parametrize("version", [*BELOW_FLOOR, *UNPARSEABLE]) +def test_refused_release(version: str) -> None: + """Older and unparseable reports are never supported.""" + with pytest.raises(exc.VersionTooLow, match=r"3\.3a"): + versions.require_version(version) + + @pytest.mark.parametrize( - "version", - ["3.3a", "3.3b", "3.4", "3.10", "4.0", "3.3a-master", "next-3.8"], + "ascending", + [ + ["next-3.7", "3.7-rc", "3.7-rc3", "3.7", "3.7a"], + ["next-3.4", "3.4-rc", "3.4"], + ["3.9", "3.10"], + ["3.3", "3.3a", "3.3b"], + ], ) -def test_admitted_release(version: str) -> None: - """Keep patch letters and numeric minor ordering at the floor.""" - support.require_version(version) +def test_release_order(ascending: list[str]) -> None: + """Development builds precede candidates, which precede the release.""" + for lower, higher in itertools.pairwise(ascending): + assert versions.is_at_least(higher, lower) + assert not versions.is_at_least(lower, higher) @pytest.mark.parametrize( - "version", + ("version", "allowed"), [ - "3.2a", - "3.3", - "3.3rc1", - "3.3a-rc1", - "next-3.3", - "next-3.3a", - "", - "unknown", - "master", - "3.3alpha", - "3.3a\n3.7", + ("3.8-rc2", True), + ("next-3.8", True), + ("next-3.7", False), + ("3.7-rc2", False), + ("3.7", True), ], ) -def test_refused_release(version: str) -> None: - """Unknown and pre-floor reports cannot inherit admission.""" - with pytest.raises(exc.VersionTooLow, match=r"3\.3a"): - support.require_version(version) +def test_candidates_rank_against_a_feature_floor(version: str, allowed: bool) -> None: + """A build below a release does not inherit that release's features.""" + assert versions.is_at_least(version, "3.7") is allowed @pytest.mark.parametrize( From c5c0dabe4847cea53bafd8f0ec95ddfa6dc251a1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:04:37 -0500 Subject: [PATCH 19/60] Core(fix): Select optional flags from the daemon's release Pane, Window, Session and Server chose optional flags such as `-E`, `-k` and `-C` from the client executable's `-V`, so a current client talking to a 3.3a daemon sent flags the daemon rejects with a native error instead of the warn-and-ignore path. Add Server._has_gte_version, which compares the connected daemon's release, and use it at every flag gate. Exact-3.7 break-pane naming now reads the daemon's release too. The warn-on-old-tmux tests patch that decision. Test: with the client helper forced to say "newest" and the daemon reporting 3.3a, the daemon answers. Making _has_gte_version consult the client again fails it (`assert not True`). The server, pane, capture, window and session modules pass on 3.7d. --- src/libtmux/pane.py | 38 ++++++++++++++++---------------- src/libtmux/server.py | 39 ++++++++++++++++++--------------- src/libtmux/session.py | 4 ++-- src/libtmux/window.py | 4 ++-- tests/test_pane_capture_pane.py | 8 +++---- tests/test_server.py | 15 ++++--------- tests/test_versions.py | 12 ++++++++++ 7 files changed, 63 insertions(+), 57 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index eb8091f619..d1559bb8bd 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -15,7 +15,7 @@ from libtmux import exc from libtmux._internal.env import pane_id_from_env -from libtmux.common import get_version_str, has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.constants import ( PANE_DIRECTION_FLAG_MAP, RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, @@ -641,7 +641,7 @@ def capture_pane( if preserve_trailing: cmd.append("-N") if trim_trailing: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): cmd.append("-T") else: warnings.warn( @@ -653,7 +653,7 @@ def capture_pane( if quiet: cmd.append("-q") if mode_screen: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.6"): cmd.append("-M") else: warnings.warn( @@ -663,7 +663,7 @@ def capture_pane( if pending: cmd.append("-P") if hyperlinks: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): cmd.append("-H") else: warnings.warn( @@ -671,7 +671,7 @@ def capture_pane( stacklevel=2, ) if line_numbers: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): cmd.append("-L") else: warnings.warn( @@ -679,7 +679,7 @@ def capture_pane( stacklevel=2, ) if line_flags: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): cmd.append("-F") else: warnings.warn( @@ -805,7 +805,7 @@ def send_keys( tmux_args += ("-H",) if key_name: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): tmux_args += ("-K",) else: warnings.warn( @@ -820,7 +820,7 @@ def send_keys( tmux_args += ("-N", str(repeat)) if target_client is not None: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): tmux_args += ("-c", target_client) else: warnings.warn( @@ -971,7 +971,7 @@ def display_message( tmux_args += ("-v",) if no_expand: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): tmux_args += ("-l",) else: warnings.warn( @@ -983,7 +983,7 @@ def display_message( tmux_args += ("-N",) if update_pane: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.6"): tmux_args += ("-C",) else: warnings.warn( @@ -1365,7 +1365,7 @@ def split( tmux_args += (f"-e{k}={v}",) if empty: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): tmux_args += ("-E",) else: warnings.warn( @@ -1380,7 +1380,7 @@ def split( "-m": message, } if keep or any(v is not None for v in styling.values()): - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): for flag, value in styling.items(): if value is not None: tmux_args += (flag, value) @@ -1515,7 +1515,7 @@ def new_pane( >>> is_floating '1' """ - if not has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if not self.server._has_gte_version("3.7"): msg = "new_pane (floating panes) requires tmux 3.7+" raise exc.LibTmuxException(msg) @@ -1801,7 +1801,7 @@ def display_popup( tmux_args += ("-B",) if close_on_any_key: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.6"): tmux_args += ("-k",) else: warnings.warn( @@ -1810,7 +1810,7 @@ def display_popup( ) if no_keys: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.6"): tmux_args += ("-N",) else: warnings.warn( @@ -1880,7 +1880,7 @@ def paste_buffer( tmux_args += ("-s", separator) if no_vis: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): tmux_args += ("-S",) else: warnings.warn( @@ -1986,7 +1986,7 @@ def copy_mode( tmux_args += ("-M",) if page_down: - if has_gte_version("3.5", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.5"): tmux_args += ("-d",) else: warnings.warn( @@ -2433,7 +2433,7 @@ def break_pane( # given (NULL-deref); 3.7a reverted it. When needed, pass a placeholder # -n then set the real name via rename-window below. Compare the raw # version string to gate the workaround on the literal 3.7 release only. - breaks_without_name = get_version_str(tmux_bin=self.server.tmux_bin) == "3.7" + breaks_without_name = self.server._get_version() == "3.7" tmux_args: tuple[str, ...] = ("-P", "-F#{window_id}") @@ -2554,7 +2554,7 @@ def clear_history(self, *, reset_hyperlinks: bool | None = None) -> None: tmux_args: tuple[str, ...] = () if reset_hyperlinks: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): tmux_args += ("-H",) else: warnings.warn( diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 20ca2484c3..4612ddb3ad 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -20,7 +20,7 @@ from libtmux._internal.query_list import QueryList from libtmux._internal.support import versions from libtmux.client import Client -from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin from libtmux.neo import fetch_objs, get_output_format, parse_output @@ -345,6 +345,9 @@ def _version_endpoint(self) -> tuple[str, list[str]]: def _get_version(self) -> str: return support.endpoint_version(*self._version_endpoint()) + def _has_gte_version(self, minimum: str) -> bool: + return versions.is_at_least(self._get_version(), minimum) + # # Command # @@ -596,7 +599,7 @@ def run_shell( tmux_args += ("-t", target_pane) if cwd is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-c", str(cwd)) else: warnings.warn( @@ -605,7 +608,7 @@ def run_shell( ) if show_stderr: - if has_gte_version("3.6", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.6"): tmux_args += ("-E",) else: warnings.warn( @@ -616,7 +619,7 @@ def run_shell( tmux_args += (command,) if args: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.7"): tmux_args += tuple(args) else: warnings.warn( @@ -799,7 +802,7 @@ def list_keys( tmux_args += ("-T", key_table) if format_ is not None: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.7"): tmux_args += ("-F", format_) else: warnings.warn( @@ -957,7 +960,7 @@ def refresh_client( tmux_args += ("-t", target_client) if request_clipboard: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.7"): tmux_args += ("-l",) else: warnings.warn( @@ -1169,7 +1172,7 @@ def confirm_before( tmux_args += ("-p", prompt) if confirm_key is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-c", confirm_key) else: warnings.warn( @@ -1178,7 +1181,7 @@ def confirm_before( ) if default_yes: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-y",) else: warnings.warn( @@ -1293,7 +1296,7 @@ def command_prompt( tmux_args += ("-F",) if literal: - if has_gte_version("3.6", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.6"): tmux_args += ("-l",) else: warnings.warn( @@ -1302,7 +1305,7 @@ def command_prompt( ) if bspace_exit: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.7"): tmux_args += ("-e",) else: warnings.warn( @@ -1311,7 +1314,7 @@ def command_prompt( ) if no_freeze: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.7"): tmux_args += ("-C",) else: warnings.warn( @@ -1427,7 +1430,7 @@ def display_menu( tmux_args += ("-y", str(y)) if starting_choice is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-C", str(starting_choice)) else: warnings.warn( @@ -1436,7 +1439,7 @@ def display_menu( ) if border_lines is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-b", border_lines) else: warnings.warn( @@ -1445,7 +1448,7 @@ def display_menu( ) if style is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-s", style) else: warnings.warn( @@ -1454,7 +1457,7 @@ def display_menu( ) if border_style is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-S", border_style) else: warnings.warn( @@ -1463,7 +1466,7 @@ def display_menu( ) if selected_style is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-H", selected_style) else: warnings.warn( @@ -1472,7 +1475,7 @@ def display_menu( ) if mouse: - if has_gte_version("3.5", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.5"): tmux_args += ("-M",) else: warnings.warn( @@ -1698,7 +1701,7 @@ def display_message( tmux_args += ("-v",) if no_expand: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-l",) else: warnings.warn( diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 4277052a37..38f6a587e0 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -14,7 +14,7 @@ import warnings from libtmux._internal.query_list import QueryList -from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.constants import WINDOW_DIRECTION_FLAG_MAP, OptionScope, WindowDirection from libtmux.formats import FORMAT_SEPARATOR from libtmux.hooks import HooksMixin @@ -723,7 +723,7 @@ def kill( flags += ("-C",) if group: # Kill all sessions in this session's group (tmux 3.7+) - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): flags += ("-g",) else: warnings.warn( diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 6cbec4df7e..b173f6e5e7 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -15,7 +15,7 @@ import warnings from libtmux._internal.query_list import QueryList -from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.constants import ( RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, OptionScope, @@ -1324,7 +1324,7 @@ def display_message( tmux_args += ("-v",) if no_expand: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): tmux_args += ("-l",) else: warnings.warn( diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 5111a06706..5f9e85e0a0 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -450,12 +450,10 @@ def test_capture_pane_trim_trailing_warning( """Test that trim_trailing issues a warning on tmux < 3.4.""" import warnings - from libtmux import pane as pane_module + from libtmux import Server - # has_gte_version is imported into libtmux.pane at module import time; - # monkeypatching the libtmux.common attribute would not propagate, so - # patch the pane module's binding directly. - monkeypatch.setattr(pane_module, "has_gte_version", lambda v, **kw: v != "3.4") + # Feature flags follow the daemon's release, so patch that decision. + monkeypatch.setattr(Server, "_has_gte_version", lambda _self, v: v != "3.4") pane = session.active_window.split(shell="sh") diff --git a/tests/test_server.py b/tests/test_server.py index e64eeb896f..92df5f18af 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1177,13 +1177,9 @@ def test_run_shell_cwd_warns_on_old_tmux( ) -> None: """``cwd=`` emits a warning and skips ``-c`` on tmux <3.4. - Simulates older tmux by patching ``has_gte_version`` in the - :mod:`libtmux.server` module namespace (where it's bound at - import time). + Simulates an older daemon by patching the daemon release decision. """ - import libtmux.server - - monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + monkeypatch.setattr(Server, "_has_gte_version", lambda *_a: False) server.new_session(session_name="run_shell_cwd_warn_test") with pytest.warns(UserWarning, match="cwd requires tmux 3.4+"): server.run_shell("true", cwd=tmp_path) @@ -1195,12 +1191,9 @@ def test_run_shell_show_stderr_warns_on_old_tmux( ) -> None: """``show_stderr=True`` emits a warning and skips ``-E`` on tmux <3.6. - Simulates older tmux by patching ``has_gte_version`` in the - :mod:`libtmux.server` module namespace. + Simulates an older daemon by patching the daemon release decision. """ - import libtmux.server - - monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + monkeypatch.setattr(Server, "_has_gte_version", lambda *_a: False) server.new_session(session_name="run_shell_stderr_warn_test") with pytest.warns(UserWarning, match="show_stderr requires tmux 3.6+"): server.run_shell("true", show_stderr=True) diff --git a/tests/test_versions.py b/tests/test_versions.py index 815bc1040c..6994423679 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -221,6 +221,18 @@ def settled() -> bool: assert server.cmd("show-option", "-gqv", "@probe-error").stdout == ["y"] +def test_feature_flags_follow_the_daemon_not_the_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A newer client executable cannot select flags an older daemon rejects.""" + monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) + monkeypatch.setattr("libtmux.common.has_gte_version", lambda *args, **kwargs: True) + server = Server(tmux_bin="/test/newer-client") + assert server._has_gte_version("3.3a") + assert not server._has_gte_version("3.4") + assert not server._has_gte_version("3.7") + + def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> None: """Keep native argv and batch separators after the version fence.""" monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) From 829d81ede14f836705a2094154b84252b2db5972 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:08:52 -0500 Subject: [PATCH 20/60] Core(perf): Reuse the checked release for each endpoint Every command spawned a version probe plus the command itself, and each optional-flag decision (`_has_gte_version`) spawned another, so a lookup took two to four tmux processes. The in-band `if-shell -F` fence already detects a replaced daemon, so the release only needs probing again after the fence fires or the daemon disappears. Each Server now holds a VersionCache: the release last checked for its endpoint (binary, selectors, TMUX_TMPDIR and TMUX). prepare() and Server._get_version() read it; a fence refusal clears it before raising; a command that finds no daemon clears it and checks again once, which validates the executable for an absent endpoint exactly as the first call does and cannot have had an effect. The first call, an absent endpoint and Server.kill() always read the endpoint directly. An absent endpoint is never cached. fetch_objs passes the Server's cache so list accessors share it. Measured on 3.7d, config /dev/null, WSL2 under shared load (wall times vary 30 percent between runs, spawn counts do not). tmux spawns per call before and after: Server.cmd("list-sessions") 2 and 1, Server.sessions 3 and 1, Pane.capture_pane with two optional flags 4 and 1. Wall time, best of two runs, before and after: 100 list-sessions 3.02s and 1.94s, 100 Server.sessions 4.11s and 1.72s, 50 capture_pane 0.83s and 0.88s. Tests: probe once across three commands, a list accessor and a flag decision; a stale cached release is refused with no effect, forgotten, and re-read once; a cached daemon killed from outside is detected and a new session starts after a fresh version check; kill() probes afresh. The existing disappearance and changed-report tests clear the cache so they still exercise the probe. Breaking each guard fails its test: not clearing on a fence refusal fails two, skipping the gone-daemon retry fails one, never storing the release fails four. Version check module: 83 pass on 3.7d. --- src/libtmux/_internal/support/versions.py | 112 ++++++++++++++++++---- src/libtmux/common.py | 41 +++++--- src/libtmux/neo.py | 1 + src/libtmux/server.py | 20 +++- tests/test_versions.py | 89 ++++++++++++++++- 5 files changed, 226 insertions(+), 37 deletions(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index aa64d1403c..5428636d68 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -7,6 +7,7 @@ import pathlib import re import subprocess +import threading import uuid from libtmux import exc @@ -82,8 +83,72 @@ def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: raise exc.LibTmuxException(msg) from error -def prepare(argv: list[str]) -> tuple[list[str], str | None]: - """Admit the daemon, then fence dispatch against its exact version.""" +class VersionCache: + """The daemon release last checked for one endpoint. + + The in-band version fence catches a replaced daemon, so a cached release + only needs a new probe after the fence fires or the daemon disappears. + An absent endpoint is never cached. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._entry: tuple[tuple[str, tuple[str, ...], str, str], str] | None = None + + @staticmethod + def _key( + binary: str, selectors: list[str] + ) -> tuple[str, tuple[str, ...], str, str]: + # The socket directory follows the environment when no path is named. + env = os.environ + return ( + binary, + tuple(selectors), + env.get("TMUX_TMPDIR", ""), + env.get("TMUX", ""), + ) + + def get(self, binary: str, selectors: list[str]) -> str | None: + """Return the checked release for this endpoint, if one is cached.""" + with self._lock: + entry = self._entry + if entry is not None and entry[0] == self._key(binary, selectors): + return entry[1] + return None + + def put(self, binary: str, selectors: list[str], version: str) -> None: + """Remember a checked release.""" + with self._lock: + self._entry = (self._key(binary, selectors), version) + + def clear(self) -> None: + """Forget the checked release.""" + with self._lock: + self._entry = None + + def __bool__(self) -> bool: + return self._entry is not None + + +def _absent_endpoint(stderr: str) -> bool: + return "no server running" in stderr or ( + "error connecting to" in stderr + and any( + reason in stderr + for reason in ("No such file or directory", "Connection refused") + ) + ) + + +def daemon_gone(stderr: str) -> bool: + """Report that a command found no daemon to talk to.""" + return _absent_endpoint(stderr) + + +def prepare( + argv: list[str], *, cache: VersionCache | None = None +) -> tuple[list[str], str | None]: + """Check the daemon's version, then fence dispatch against it.""" try: options, commands = getopt.getopt(argv[1:], "28c:CDdf:hlL:NqS:T:uUvV") except getopt.GetoptError as error: @@ -103,7 +168,14 @@ def prepare(argv: list[str]) -> tuple[list[str], str | None]: globals_.append(value) if flag in {"-L", "-S"}: selectors.extend((flag, value)) - version = live_version(argv[0], selectors) + version = cache.get(argv[0], selectors) if cache is not None else None + if version is None: + version = live_version(argv[0], selectors) + if cache is not None: + if version is None: + cache.clear() + else: + cache.put(argv[0], selectors, version) if version is None: version = _binary_version(argv[0]) elif not any(flag == "-N" for flag, _ in options): @@ -124,9 +196,13 @@ def prepare(argv: list[str]) -> tuple[list[str], str | None]: ], marker -def check_result(stderr: str, marker: str | None) -> None: +def check_result( + stderr: str, marker: str | None, *, cache: VersionCache | None = None +) -> None: """Reject a replaced daemon before returning an ordinary result.""" if marker is not None and f"unknown command: {marker}" in stderr.splitlines(): + if cache is not None: + cache.clear() msg = "tmux daemon changed before dispatch; retry the command" raise exc.VersionTooLow(msg) @@ -146,26 +222,30 @@ def live_version(binary: str, selectors: list[str]) -> str | None: if probe.returncode == 1 and len(reports) == 1: require_version(reports[0]) return reports[0] - if "no server running" in probe.stderr or ( - "error connecting to" in probe.stderr - and any( - reason in probe.stderr - for reason in ( - "No such file or directory", - "Connection refused", - ) - ) - ): + if _absent_endpoint(probe.stderr): return None msg = probe.stderr.strip() or "tmux did not report a daemon version" raise exc.LibTmuxException(msg) -def endpoint_version(binary: str, selectors: list[str]) -> str: - """Read a fresh daemon version, or the executable for an absent endpoint.""" +def endpoint_version( + binary: str, selectors: list[str], *, cache: VersionCache | None = None +) -> str: + """Read the daemon version, or the executable's for an absent endpoint. + + With a cache, a release checked earlier is returned without a probe. + """ + if cache is not None: + cached = cache.get(binary, selectors) + if cached is not None: + return cached version = live_version(binary, selectors) if version is not None: + if cache is not None: + cache.put(binary, selectors, version) return version + if cache is not None: + cache.clear() return _binary_version(binary) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 560e139103..2d5961d2b3 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -310,7 +310,12 @@ class tmux_cmd: Renamed from ``tmux`` to ``tmux_cmd``. """ - def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: + def __init__( + self, + *args: t.Any, + tmux_bin: str | None = None, + _version_cache: versions.VersionCache | None = None, + ) -> None: resolved = tmux_bin or shutil.which("tmux") if not resolved: raise exc.TmuxCommandNotFound @@ -320,7 +325,8 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: cmd = [str(c) for c in cmd] self.cmd = cmd - dispatched, marker = support.prepare(cmd) + cached = _version_cache is not None and bool(_version_cache) + dispatched, marker = versions.prepare(cmd, cache=_version_cache) if logger.isEnabledFor(logging.DEBUG): cmd_str = shlex.join(cmd) @@ -330,16 +336,13 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: ) try: - self.process = subprocess.Popen( - dispatched, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="backslashreplace", - ) - stdout, stderr = self.process.communicate() - returncode = self.process.returncode + stdout, stderr, returncode = self._dispatch(dispatched) + if cached and versions.daemon_gone(stderr): + # The cached daemon vanished; nothing ran, so check its version again. + assert _version_cache is not None + _version_cache.clear() + dispatched, marker = versions.prepare(cmd, cache=_version_cache) + stdout, stderr, returncode = self._dispatch(dispatched) except FileNotFoundError: raise exc.TmuxCommandNotFound from None except Exception: @@ -351,7 +354,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: ) raise - support.check_result(stderr, marker) + versions.check_result(stderr, marker, cache=_version_cache) self.returncode = returncode stdout_split = stdout.split("\n") @@ -380,6 +383,18 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: }, ) + def _dispatch(self, argv: list[str]) -> tuple[str, str, int]: + self.process = subprocess.Popen( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="backslashreplace", + ) + stdout, stderr = self.process.communicate() + return stdout, stderr, self.process.returncode + class _TmuxVersionUnavailable(Exception): """Internal signal: this tmux predates the ``-V`` flag (pre-1.7).""" diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 702a4c4238..eceaec1bd4 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1140,6 +1140,7 @@ def fetch_objs( proc = tmux_cmd( *tmux_cmds, tmux_bin=server.tmux_bin, + _version_cache=server._version_cache, ) raise_if_stderr(proc, list_cmd) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 4612ddb3ad..c8060dee9d 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -183,6 +183,7 @@ def __init__( ) -> None: EnvironmentMixin.__init__(self, "-g") self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self._version_cache = versions.VersionCache() self._windows: list[WindowDict] = [] self._panes: list[PaneDict] = [] @@ -343,7 +344,9 @@ def _version_endpoint(self) -> tuple[str, list[str]]: return binary, selectors def _get_version(self) -> str: - return support.endpoint_version(*self._version_endpoint()) + return versions.endpoint_version( + *self._version_endpoint(), cache=self._version_cache + ) def _has_gte_version(self, minimum: str) -> bool: return versions.is_at_least(self._get_version(), minimum) @@ -422,7 +425,12 @@ def cmd( cmd_args = ["-t", str(target), *args] if target is not None else [*args] - return tmux_cmd(*svr_args, *cmd_args, tmux_bin=self.tmux_bin) + return tmux_cmd( + *svr_args, + *cmd_args, + tmux_bin=self.tmux_bin, + _version_cache=self._version_cache, + ) @property def attached_sessions(self) -> list[Session]: @@ -488,9 +496,15 @@ def kill(self) -> None: >>> svr.is_alive() False """ - if support.live_version(*self._version_endpoint()) is None: + # Liveness is read afresh; the kill then reuses the version that read checked. + self._version_cache.clear() + binary, selectors = self._version_endpoint() + version = versions.live_version(binary, selectors) + if version is None: return + self._version_cache.put(binary, selectors, version) proc = self.cmd("kill-server") + self._version_cache.clear() if proc.stderr: stderr_text = " ".join(str(line) for line in proc.stderr) if _is_daemon_not_up_error(stderr_text): diff --git a/tests/test_versions.py b/tests/test_versions.py index 6994423679..d10f807c5e 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -302,8 +302,9 @@ def test_native_prefix_refuses_a_changed_report( monkeypatch: pytest.MonkeyPatch, ) -> None: """A native mismatch aborts every requested command in the same batch.""" - server.new_session("admission") - run = support._run + server.new_session("versioned") + server._version_cache.clear() + run = versions._run def changed(argv: list[str]) -> subprocess.CompletedProcess[str]: result = run(argv) @@ -329,6 +330,83 @@ def changed(argv: list[str]) -> subprocess.CompletedProcess[str]: assert server.cmd("show-option", "-gqv", "@version-second").stdout == [] +def _count_probes(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]: + probes: list[list[str]] = [] + run = versions._run + + def counted(argv: list[str]) -> subprocess.CompletedProcess[str]: + if _is_probe(argv): + probes.append(argv) + return run(argv) + + monkeypatch.setattr(versions, "_run", counted) + return probes + + +def test_checked_release_is_probed_once_per_endpoint( + server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """Later commands and flag decisions reuse the release the fence guards.""" + server.new_session("versioned", window_command="sleep 300") + server._version_cache.clear() + probes = _count_probes(monkeypatch) + for _ in range(3): + assert server.cmd("list-sessions").returncode == 0 + _ = server.sessions + assert server._has_gte_version("3.3a") + assert len(probes) == 1 + + +def test_stale_cached_release_is_refused_and_forgotten( + server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """A daemon that no longer matches the cached release fails the fence.""" + server.new_session("versioned", window_command="sleep 300") + server.cmd("list-sessions") + binary, selectors = server._version_endpoint() + server._version_cache.put(binary, selectors, "3.99") + with pytest.raises(exc.VersionTooLow, match="changed before dispatch"): + server.cmd("set-option", "-g", "@stale-effect", "ran") + assert not server._version_cache + probes = _count_probes(monkeypatch) + assert server.cmd("show-option", "-gqv", "@stale-effect").stdout == [] + assert server.cmd("list-sessions").returncode == 0 + assert len(probes) == 1 + + +def test_cached_release_survives_daemon_loss_without_a_stale_refusal( + server: Server, +) -> None: + """A vanished daemon is detected, and the next command checks again.""" + server.new_session("versioned", window_command="sleep 300") + server.cmd("list-sessions") + assert server._version_cache + binary, selectors = server._version_endpoint() + stop = subprocess.run( + [binary, "-N", *selectors, "kill-server"], capture_output=True, check=False + ) + assert stop.returncode == 0 + assert not server.is_alive() + assert not server._version_cache + created = server.new_session("returned", window_command="sleep 300") + assert created.session_name == "returned" + assert server.is_alive() + + +def test_kill_reads_liveness_afresh( + server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """Server.kill() probes even with a cached release, then forgets it.""" + server.new_session("versioned", window_command="sleep 300") + server.cmd("list-sessions") + assert server._version_cache + probes = _count_probes(monkeypatch) + server.kill() + assert len(probes) == 1 + assert not server._version_cache + assert not server.is_alive() + + def test_absent_cleanup_never_dispatches(monkeypatch: pytest.MonkeyPatch) -> None: """An absent endpoint stays untouched even when its binary is too old.""" calls: list[list[str]] = [] @@ -350,9 +428,10 @@ def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: def test_checked_daemon_disappearance_never_restarts( server: Server, monkeypatch: pytest.MonkeyPatch ) -> None: - """Losing an admitted daemon cannot start the configured executable.""" - server.new_session("admission", window_command="sleep 300") - run = support._run + """Losing a checked daemon cannot start the configured executable.""" + server.new_session("versioned", window_command="sleep 300") + server._version_cache.clear() + run = versions._run def disappear(argv: list[str]) -> subprocess.CompletedProcess[str]: result = run(argv) From eba1672bc26e4dd34bb4fb8aa523ae1735ea589b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:08:52 -0500 Subject: [PATCH 21/60] Support(docs): Describe per-endpoint version reuse Replace the claim that version check never caches: a Server probes its endpoint once, the fence refuses a replaced daemon with no effect, and kill() and an absent endpoint read the endpoint directly. --- docs/project/compatibility.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 2b7f89d64c..0e14e7e3ad 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -21,8 +21,12 @@ A replacement daemon is checked again before dispatch. {meth}`~libtmux.Server.kill` returns without dispatching when its endpoint is absent. -Each command adds a version query, bounded to five seconds. Admission does not -cache daemon versions. The query is `run-shell -C` with a marker format, which +Each {class}`~libtmux.Server` probes its endpoint once, bounded to five +seconds, and reuses the checked release for later commands and optional-flag +decisions. The version fence in every command catches a replaced daemon: it +refuses with no effect and the next command probes again. A vanished daemon is +detected and the next command checks the version again; {meth}`~libtmux.Server.kill` and +an absent endpoint always read the endpoint directly. The query is `run-shell -C` with a marker format, which fires neither `after-*` hooks nor `command-error`, so a user's hooks never run for a version check. It exits 1 with the version in its error text. If a replaced daemon fails the fence on tmux 3.5 or newer, `command-error` fires once for From bfe4472fc59f561f8ea1b5d7d8adddf11ff97ce5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:10:00 -0500 Subject: [PATCH 22/60] Core(feat): Add a small version-check surface Packages built on libtmux, such as libtmux-mcp and tmuxp, had to import libtmux._internal.support and read private Server methods to reuse the daemon-first version check. Add libtmux._internal.support with MINIMUM, VersionTooLow, require_version, is_at_least, server_version(server) and check_fence, so consumers depend on names that are meant to be stable. The module wraps the internal helpers; behavior is unchanged. Test: the exported names, an older daemon refused through server_version, a checked release returned, and a fence marker converted to VersionTooLow while an unrelated line is ignored. The module doctest runs too. --- src/libtmux/_internal/support/__init__.py | 72 +++++++++++++++++++++++ tests/test_versions.py | 22 +++++++ 2 files changed, 94 insertions(+) create mode 100644 src/libtmux/_internal/support/__init__.py diff --git a/src/libtmux/_internal/support/__init__.py b/src/libtmux/_internal/support/__init__.py new file mode 100644 index 0000000000..bcf57ea208 --- /dev/null +++ b/src/libtmux/_internal/support/__init__.py @@ -0,0 +1,72 @@ +"""tmux version and feature support inside libtmux. + +Here "support" means which tmux releases and features libtmux works with, not +helper code. libtmux checks the connected daemon's version, not the client +executable's, before every command. A program built on libtmux that dispatches +its own tmux subprocesses can reuse that check: + +- :func:`server_version` reads the release a :class:`~libtmux.Server` talks to. +- :func:`require_version` and :func:`is_at_least` compare reports with a floor. +- :func:`check_fence` turns the replaced-daemon marker of a fenced batch into + :exc:`~libtmux.exc.VersionTooLow`. + +Examples +-------- +>>> from libtmux._internal import support +>>> support.MINIMUM +'3.3a' +>>> support.is_at_least("3.4-rc", "3.3a") +True +>>> support.is_at_least("3.3", "3.3a") +False +>>> support.server_version(server) == server._get_version() +True +""" + +from __future__ import annotations + +import typing as t + +from libtmux._internal.support.versions import ( + MINIMUM, + check_result, + is_at_least, + require_version, +) +from libtmux.exc import VersionTooLow + +if t.TYPE_CHECKING: + from libtmux.server import Server + +__all__ = ( + "MINIMUM", + "VersionTooLow", + "check_fence", + "is_at_least", + "require_version", + "server_version", +) + + +def server_version(server: Server) -> str: + """Return the tmux release *server* talks to, refusing an unsupported one. + + A live daemon answers with its own release; an absent endpoint answers + with the configured executable's. Either must meet :data:`MINIMUM`. + + Raises + ------ + :exc:`~libtmux.exc.VersionTooLow` + When the release is below the minimum or cannot be established. + """ + return server._get_version() + + +def check_fence(stderr: str, marker: str | None) -> None: + """Raise :exc:`~libtmux.exc.VersionTooLow` if a fenced batch was refused. + + A batch prefixed with ``if-shell -F '#{==:#{version},}' '' ;`` + reports ``unknown command: `` on stderr when the daemon is not the + release the caller admitted, and none of its commands ran. + """ + check_result(stderr, marker) diff --git a/tests/test_versions.py b/tests/test_versions.py index d10f807c5e..f3d3b843ae 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -233,6 +233,28 @@ def test_feature_flags_follow_the_daemon_not_the_client( assert not server._has_gte_version("3.7") +def test_public_admission_surface(monkeypatch: pytest.MonkeyPatch) -> None: + """Consumers reach the decision through libtmux.admission alone.""" + from libtmux import admission + + assert set(admission.__all__) == { + "MINIMUM", + "VersionTooLow", + "server_version", + "check_fence", + "is_at_least", + "require_version", + } + monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3")) + with pytest.raises(support.VersionTooLow, match=r"3\.3a"): + support.server_version(Server(tmux_bin="/test/tmux")) + monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.8")) + assert support.server_version(Server(tmux_bin="/test/tmux")) == "3.8" + with pytest.raises(support.VersionTooLow, match="changed before dispatch"): + support.check_fence("unknown command: private-marker\n", "private-marker") + support.check_fence("unknown command: other\n", "private-marker") + + def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> None: """Keep native argv and batch separators after the version fence.""" monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) From 5577a0ddf9a19e22a312a9c50fbbca9f9feeab7f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:10:00 -0500 Subject: [PATCH 23/60] Docs(feat): Document the internal support package Document libtmux._internal.support beside the exception hierarchy, with a card on the API index. --- docs/internals/api/libtmux._internal.support.md | 17 +++++++++++++++++ docs/internals/index.md | 7 +++++++ 2 files changed, 24 insertions(+) create mode 100644 docs/internals/api/libtmux._internal.support.md diff --git a/docs/internals/api/libtmux._internal.support.md b/docs/internals/api/libtmux._internal.support.md new file mode 100644 index 0000000000..6e23e5a319 --- /dev/null +++ b/docs/internals/api/libtmux._internal.support.md @@ -0,0 +1,17 @@ +# Internal Support + +The {mod}`libtmux._internal.support` package holds the tmux version check that +every command runs against the connected daemon. + +:::{warning} +These APIs are private and are **not** covered by version policies. They can +break or be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._internal.support + :members: + +.. automodule:: libtmux._internal.support.versions + :members: +``` diff --git a/docs/internals/index.md b/docs/internals/index.md index c3748026a5..286ffe2d01 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -37,6 +37,12 @@ Internal format strings and tmux constants. Sparse array data structure for tmux format parsing. ::: +:::{grid-item-card} Support +:link: api/libtmux._internal.support +:link-type: doc +The tmux version check every command runs against the connected daemon. +::: + :::: ```{toctree} @@ -47,6 +53,7 @@ api/libtmux._internal.dataclasses api/libtmux._internal.query_list api/libtmux._internal.constants api/libtmux._internal.sparse_array +api/libtmux._internal.support ``` ## Environmental variables From 10a2b0039cd92d81b61ac277d0f22261650915e6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:11:03 -0500 Subject: [PATCH 24/60] Tests(feat): Refuse real 3.2a and unpatched 3.3 daemons Refusal below the floor was covered only by a monkeypatched probe. Add native tests that start a real 3.2a or 3.3 daemon and drive it with a 3.3a client: a write, a new session, a raw tmux_cmd kill-server and a list accessor all raise VersionTooLow, and a sentinel option and the session list stay unchanged. A cold 3.2a or 3.3 executable cannot create its socket, and a real 3.3a daemon is checked as the control. They skip unless the three LIBTMUX_TEST_*_TMUX variables name native builds, the same convention as libtmux-mcp and tmuxp. Verification: all seven native cases pass against 3.2a, 3.3 and 3.3a builds. Making require_version accept everything fails the four refusal cases. --- tests/test_versions.py | 120 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/tests/test_versions.py b/tests/test_versions.py index f3d3b843ae..f2073b67a4 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -3,9 +3,11 @@ from __future__ import annotations import itertools +import os import pathlib import shutil import subprocess +import tempfile import typing as t import pytest @@ -526,4 +528,120 @@ def test_executable_report_uses_its_exact_name( assert versions._binary_version("/test/custom tmux") == "3.3a" else: with pytest.raises(exc.VersionTooLow): - support._binary_version("/test/custom tmux") + versions._binary_version("/test/custom tmux") + + +def _native_binary(variable: str, expected: str) -> str: + value = os.environ.get(variable) + if value is None: + pytest.skip(f"set {variable} to native tmux {expected}") + report = subprocess.run([value, "-V"], capture_output=True, timeout=5, check=True) + assert report.stdout == f"tmux {expected}\n".encode() + return value + + +def _native( + binary: str, socket: pathlib.Path, *argv: str +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [binary, "-N", "-S", str(socket), *argv], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + + +@pytest.fixture +def rejection_socket() -> t.Iterator[pathlib.Path]: + """Return a short private socket path for a real older daemon.""" + folder = tempfile.mkdtemp(prefix="lt-") + try: + yield pathlib.Path(folder) / "s" + finally: + shutil.rmtree(folder, ignore_errors=True) + + +REJECTED_RELEASES = [ + ("LIBTMUX_TEST_UNSUPPORTED_TMUX", "3.2a"), + ("LIBTMUX_TEST_UNPATCHED_TMUX", "3.3"), +] + + +@pytest.mark.parametrize(("variable", "release"), REJECTED_RELEASES) +def test_native_older_daemon_refuses_every_route_without_effects( + rejection_socket: pathlib.Path, variable: str, release: str +) -> None: + """A supported client refuses a real 3.2a or 3.3 daemon.""" + supported = _native_binary("LIBTMUX_TEST_MINIMUM_TMUX", "3.3a") + older = _native_binary(variable, release) + started = subprocess.run( + [ + older, + "-f", + "/dev/null", + "-S", + str(rejection_socket), + "new-session", + "-d", + "-s", + "kept", + "exec sleep 120", + ], + capture_output=True, + timeout=5, + check=False, + ) + assert started.returncode == 0, started.stderr + server = Server(tmux_bin=supported, socket_path=str(rejection_socket)) + try: + with pytest.raises(exc.VersionTooLow, match=r"3\.3a"): + server.cmd("set-option", "-g", "@forbidden", "changed") + with pytest.raises(exc.VersionTooLow): + server.new_session("forbidden", window_command="exec sleep 120") + with pytest.raises(exc.VersionTooLow): + tmux_cmd( + "-S", + str(rejection_socket), + "kill-server", + tmux_bin=supported, + ) + with pytest.raises(exc.VersionTooLow): + _ = server.sessions + assert ( + _native( + older, rejection_socket, "show-options", "-gqv", "@forbidden" + ).stdout + == "" + ) + names = _native(older, rejection_socket, "list-sessions", "-F", "#S") + assert names.stdout.split() == ["kept"] + finally: + _native(older, rejection_socket, "kill-server") + + +@pytest.mark.parametrize(("variable", "release"), REJECTED_RELEASES) +def test_native_older_executable_does_not_start_an_endpoint( + rejection_socket: pathlib.Path, variable: str, release: str +) -> None: + """Cold creation validates the executable before new-session can run.""" + older = _native_binary(variable, release) + server = Server(tmux_bin=older, socket_path=str(rejection_socket)) + with pytest.raises(exc.VersionTooLow): + server.new_session("forbidden", window_command="exec sleep 120") + assert not rejection_socket.exists() + + +def test_native_supported_daemon_passes(rejection_socket: pathlib.Path) -> None: + """Control: the same client accepts and serves a real 3.3a daemon.""" + supported = _native_binary("LIBTMUX_TEST_MINIMUM_TMUX", "3.3a") + server = Server( + tmux_bin=supported, socket_path=str(rejection_socket), config_file="/dev/null" + ) + try: + session = server.new_session("supported", window_command="exec sleep 120") + assert server._get_version() == "3.3a" + assert [item.session_name for item in server.sessions] == ["supported"] + assert session.session_id is not None + finally: + server.kill() From de094003377ec7dff71016db0dfae3fdde572b03 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:11:03 -0500 Subject: [PATCH 25/60] CI(test): Run the native rejection lanes Add a version check job that builds tmux 3.2a, 3.3 and 3.3a from source, exports the three native paths and runs the version check module so a real older daemon is refused with no effects. The supported matrix still starts at 3.3a. --- .github/workflows/tests.yml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9fb46f5e30..4f3cfb2328 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -90,6 +90,44 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} + unsupported-versions: + # Real 3.2a and 3.3 daemons must be refused with no effects. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.2.0 + with: + enable-cache: true + + - name: Set up Python 3.14 + run: uv python install 3.14 + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Build supported and rejected native endpoints + run: | + sudo apt-get update + sudo apt-get install -y libevent-dev libncurses-dev bison + mkdir -p "$RUNNER_TEMP/tmux-versions" + for version in 3.2a 3.3 3.3a; do + git clone --quiet --depth=1 --branch "$version" https://github.com/tmux/tmux.git "$RUNNER_TEMP/tmux-versions/$version" + cd "$RUNNER_TEMP/tmux-versions/$version" + sh autogen.sh + ./configure --prefix="$RUNNER_TEMP/tmux-versions/$version/install" + make -j2 + make install + done + echo "LIBTMUX_TEST_MINIMUM_TMUX=$RUNNER_TEMP/tmux-versions/3.3a/install/bin/tmux" >> "$GITHUB_ENV" + echo "LIBTMUX_TEST_UNSUPPORTED_TMUX=$RUNNER_TEMP/tmux-versions/3.2a/install/bin/tmux" >> "$GITHUB_ENV" + echo "LIBTMUX_TEST_UNPATCHED_TMUX=$RUNNER_TEMP/tmux-versions/3.3/install/bin/tmux" >> "$GITHUB_ENV" + + - name: Refuse older daemons + run: uv run pytest tests/test_versions.py --reruns=0 -q + release: runs-on: ubuntu-latest needs: build From 1efaeaa0d9a18ea69618dbe6e0ab23c82894dfe9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:11:13 -0500 Subject: [PATCH 26/60] Changelog(docs): Record endpoint reuse and the support module Note the per-endpoint release reuse, daemon-selected optional flags, the accepted version grammar and the public libtmux._internal.support surface. Keep the release note separate from runtime changes. --- CHANGES | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index 849a9f501c..980595e350 100644 --- a/CHANGES +++ b/CHANGES @@ -65,9 +65,14 @@ tmux>=3.3a Upgrade tmux and restart existing daemons after saving their sessions. Updating the client executable alone does not upgrade a running daemon. A renamed tmux 3.3a executable still qualifies the minimum. -Version admission runs no user hooks. -Binary version and help diagnostics remain available. Undated development -reports cannot qualify admission. {meth}`Server.kill() ` +The version check runs no user hooks, and each +{class}`~libtmux.Server` probes its endpoint once, then relies on the version +fence to notice a replaced daemon. Optional flags follow the daemon's release, +not the client executable's. The version check reads the forms tmux reports +(`3.3a`, `3.8-rc`, `next-3.9`) and refuses a development suffix on a release, +`master` and leading zeros. {mod}`libtmux._internal.support` +lets programs built on libtmux reuse the check. +Binary version and help diagnostics remain available. {meth}`Server.kill() ` remains a no-op for an absent endpoint. See {doc}`project/compatibility` for support and feature requirements. From dadd54a1bd206df284d714510652d04594be717d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 10:50:23 -0500 Subject: [PATCH 27/60] CI(test): Add the tmux 3.8 lane tmux 3.8 is a released tag, so run the full matrix against it before master. The 3.8 lane gates the build like the other released versions; only master stays continue-on-error. Local focused runs of the version check, control and optional-feature modules already pass against a 3.8 build. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4f3cfb2328..ca6e5b1614 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: python-version: ['3.14'] - tmux-version: ['3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b', 'master'] + tmux-version: ['3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b', '3.8', 'master'] steps: - uses: actions/checkout@v7 From 9b1ed42cd41ed15058e076c7bfdc6ed371b90ad4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:42:32 -0500 Subject: [PATCH 28/60] Hooks(feat): Track the tmux 3.8 hook table why: tmux 3.8 adds after-swap-window, client-closed, client-created, marked-pane-changed, session-added-to-group and session-removed-from-group, and drops after-queue. Hooks.from_stdout raised TypeError on the first hook its dataclass lacked, so show-hooks on 3.8 broke parsing. what: - Add the six 3.8 hooks as Hooks fields - Skip hooks the table does not know instead of failing the parse - Cover the 3.8 hooks in the set/show/unset grid, gated on 3.8 - Gate the after-queue case to tmux up to 3.7 --- src/libtmux/_internal/constants.py | 41 +++++++++++++++++++++++++++--- tests/test_hooks.py | 26 ++++++++++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/libtmux/_internal/constants.py b/src/libtmux/_internal/constants.py index df4d9843f2..8b9540f8f6 100644 --- a/src/libtmux/_internal/constants.py +++ b/src/libtmux/_internal/constants.py @@ -4,7 +4,7 @@ import io import typing as t -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from libtmux._internal.dataclasses import SkipDefaultFieldsReprMixin from libtmux._internal.sparse_array import SparseArray, is_sparse_array_list @@ -793,7 +793,7 @@ class Hooks( after_pipe_pane : SparseArray[str] Runs after 'pipe-pane' completes. after_queue : SparseArray[str] - Runs after 'queue' command is processed. + Runs after 'queue' command is processed (removed in tmux 3.8). after_refresh_client : SparseArray[str] Runs after 'refresh-client' completes. after_rename_session : SparseArray[str] @@ -834,6 +834,18 @@ class Hooks( Runs after 'unbind-key' completes. command_error : SparseArray[str] Runs when a command fails (tmux 3.5+). + after_swap_window : SparseArray[str] + Runs after 'swap-window' completes (tmux 3.8+). + client_closed : SparseArray[str] + Run when a client is closed (tmux 3.8+). + client_created : SparseArray[str] + Run when a client is created (tmux 3.8+). + marked_pane_changed : SparseArray[str] + Run when the marked pane changes (tmux 3.8+). + session_added_to_group : SparseArray[str] + Run when a session is added to a session group (tmux 3.8+). + session_removed_from_group : SparseArray[str] + Run when a session is removed from a session group (tmux 3.8+). Examples -------- @@ -1050,7 +1062,7 @@ class Hooks( after_paste_buffer: SparseArray[str] = field(default_factory=SparseArray) # Runs after 'pipe-pane' completes after_pipe_pane: SparseArray[str] = field(default_factory=SparseArray) - # Runs after 'queue' command is processed + # Runs after 'queue' command is processed (removed in tmux 3.8) after_queue: SparseArray[str] = field(default_factory=SparseArray) # Runs after 'refresh-client' completes after_refresh_client: SparseArray[str] = field(default_factory=SparseArray) @@ -1092,6 +1104,18 @@ class Hooks( after_unbind_key: SparseArray[str] = field(default_factory=SparseArray) # Runs when a command fails (tmux 3.5+) command_error: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'swap-window' completes (tmux 3.8+). + after_swap_window: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client is closed (tmux 3.8+). + client_closed: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client is created (tmux 3.8+). + client_created: SparseArray[str] = field(default_factory=SparseArray) + # Run when the marked pane changes (tmux 3.8+). + marked_pane_changed: SparseArray[str] = field(default_factory=SparseArray) + # Run when a session is added to a session group (tmux 3.8+). + session_added_to_group: SparseArray[str] = field(default_factory=SparseArray) + # Run when a session is removed from a session group (tmux 3.8+). + session_removed_from_group: SparseArray[str] = field(default_factory=SparseArray) @classmethod def from_stdout(cls, value: list[str]) -> Hooks: @@ -1135,6 +1159,12 @@ def from_stdout(cls, value: list[str]) -> Hooks: >>> sorted(hooks.after_select_window.keys()) [0, 10] + A hook this table does not know is ignored: + + >>> hooks_unknown = Hooks.from_stdout(["future-hook[0] refresh-client"]) + >>> hooks_unknown == Hooks() + True + Empty input returns empty SparseArrays: >>> hooks_empty = Hooks.from_stdout([]) @@ -1164,4 +1194,7 @@ def from_stdout(cls, value: list[str]) -> Hooks: k.lstrip("%").replace("-", "_"): v for k, v in output_exploded.items() } - return cls(**output_renamed) + # A newer tmux can list hooks this table predates; skip them rather + # than fail the whole parse. + known = {f.name for f in fields(cls)} + return cls(**{k: v for k, v in output_renamed.items() if k in known}) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 4e03f0a26b..5bb062c393 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -8,7 +8,7 @@ from libtmux._internal.constants import Hooks from libtmux._internal.sparse_array import SparseArray -from libtmux.common import has_gte_version +from libtmux.common import has_gte_version, has_lte_version if t.TYPE_CHECKING: from libtmux.server import Server @@ -314,6 +314,7 @@ class HookTestCase(t.NamedTuple): test_id: str hook: str # tmux hook name (hyphenated) min_version: str = "3.0" # Minimum tmux version required + max_version: str | None = None # Last tmux version that has the hook xfail_reason: str | None = None # Mark as expected failure with reason @@ -381,7 +382,8 @@ class HookTestCase(t.NamedTuple): HookTestCase("after_new_window", "after-new-window"), HookTestCase("after_paste_buffer", "after-paste-buffer"), HookTestCase("after_pipe_pane", "after-pipe-pane"), - HookTestCase("after_queue", "after-queue"), + # tmux 3.8 dropped after-queue from its hook table. + HookTestCase("after_queue", "after-queue", max_version="3.7"), HookTestCase("after_refresh_client", "after-refresh-client"), HookTestCase("after_rename_session", "after-rename-session"), HookTestCase("after_rename_window", "after-rename-window"), @@ -400,9 +402,19 @@ class HookTestCase(t.NamedTuple): HookTestCase("after_show_messages", "after-show-messages"), HookTestCase("after_show_options", "after-show-options"), HookTestCase("after_split_window", "after-split-window"), + HookTestCase("after_swap_window", "after-swap-window", "3.8"), HookTestCase("after_unbind_key", "after-unbind-key"), ] +# --- Hooks added in tmux 3.8 --- +TMUX_38_HOOKS: list[HookTestCase] = [ + HookTestCase("client_closed", "client-closed", "3.8"), + HookTestCase("client_created", "client-created", "3.8"), + HookTestCase("marked_pane_changed", "marked-pane-changed", "3.8"), + HookTestCase("session_added_to_group", "session-added-to-group", "3.8"), + HookTestCase("session_removed_from_group", "session-removed-from-group", "3.8"), +] + # --- New Hooks (tmux 3.5+) --- NEW_HOOKS: list[HookTestCase] = [ HookTestCase( @@ -416,7 +428,13 @@ class HookTestCase(t.NamedTuple): # Combine all hook test cases ALL_HOOK_TEST_CASES: list[HookTestCase] = ( - ALERT_HOOKS + CLIENT_HOOKS + SESSION_HOOKS + WINDOW_HOOKS + PANE_HOOKS + AFTER_HOOKS + ALERT_HOOKS + + CLIENT_HOOKS + + SESSION_HOOKS + + WINDOW_HOOKS + + PANE_HOOKS + + AFTER_HOOKS + + TMUX_38_HOOKS ) @@ -442,6 +460,8 @@ def test_hook_set_show_unset_cycle(server: Server, test_case: HookTestCase) -> N """ if not has_gte_version(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") + if test_case.max_version and not has_lte_version(test_case.max_version): + pytest.skip(f"Removed after tmux {test_case.max_version}") session = server.new_session(session_name="test_hook_cycle") window = session.active_window From c8c9c0093d165a1ce70248997455cf99946258e1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:42:43 -0500 Subject: [PATCH 29/60] Pane(fix[new_pane]): Expect tmux 3.8's bordered footprint why: From 3.8, new-pane -x/-y/-X/-Y give the on-screen footprint including the default border (regress/floating-pane-geometry.sh), so a pane asked for 80 columns reads pane_width 78. The test and the floating_panes doctest assumed content-box sizing and failed on 3.8. what: - Document the border offset on Pane.new_pane and Window.new_pane - Expect width/height minus 2 and x/y plus 1 on 3.8+, in the test and doctest --- docs/topics/floating_panes.md | 12 ++++++++++-- src/libtmux/pane.py | 14 ++++++++++---- src/libtmux/window.py | 11 +++++++---- tests/test_pane.py | 8 ++++++-- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/topics/floating_panes.md b/docs/topics/floating_panes.md index 7aa96a6c3d..01f1c90871 100644 --- a/docs/topics/floating_panes.md +++ b/docs/topics/floating_panes.md @@ -59,10 +59,18 @@ window (tmux's `-X` / `-Y`). tmux reports the placement back through the ... position = (placed.pane_x, placed.pane_y) ... else: ... position = ("2", "1") ->>> position -('2', '1') +>>> border = 1 if has_gte_version("3.8") else 0 +>>> position == (str(2 + border), str(1 + border)) +True ``` +From tmux 3.8, `width`, `height`, `x` and `y` give the pane's footprint on screen +including its border, which is drawn by default. A pane created with +`width=20, height=5, x=2, y=1` then reports `pane_width` 18, `pane_height` 3, +and `pane_x` / `pane_y` of 3 and 2: the border sits on the requested column and +row, and the content one cell inside. Earlier versions size and place the +content directly. + ## Styling For the rarer cases where appearance matters, you can style a floating pane with diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index d1559bb8bd..20e491833f 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1470,13 +1470,19 @@ def new_pane( environment : dict, optional Environment variables for the new pane (``-e`` flag). width : int, optional - Width of the floating pane in cells (``-x`` flag). + Width of the floating pane in cells (``-x`` flag). On tmux 3.8+ + a bordered pane (the default) counts its border, so ``pane_width`` + reads 2 less than requested. height : int, optional - Height of the floating pane in cells (``-y`` flag). + Height of the floating pane in cells (``-y`` flag). Counts the + border on tmux 3.8+, like *width*. x : int, optional - X position of the floating pane in cells (``-X`` flag). + X position of the floating pane in cells (``-X`` flag). On tmux + 3.8+ the border lands on this column, so ``pane_left`` reads 1 + more. y : int, optional - Y position of the floating pane in cells (``-Y`` flag). + Y position of the floating pane in cells (``-Y`` flag). On tmux + 3.8+ the border lands on this row, so ``pane_top`` reads 1 more. zoom : bool, optional Zoom the pane (``-Z`` flag). empty : bool, optional diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b173f6e5e7..d3c00bccba 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -656,13 +656,16 @@ def new_pane( environment : dict, optional Environment variables for the new pane (``-e``). width : int, optional - Width in cells (``-x``). + Width in cells (``-x``). On tmux 3.8+ a bordered pane (the + default) counts its border, so ``pane_width`` reads 2 less. height : int, optional - Height in cells (``-y``). + Height in cells (``-y``). Counts the border on tmux 3.8+. x : int, optional - X position in cells (``-X``). + X position in cells (``-X``). On tmux 3.8+ the border lands on + this column, so ``pane_left`` reads 1 more. y : int, optional - Y position in cells (``-Y``). + Y position in cells (``-Y``). On tmux 3.8+ the border lands on + this row, so ``pane_top`` reads 1 more. zoom : bool, optional Zoom the pane (``-Z``). empty : bool, optional diff --git a/tests/test_pane.py b/tests/test_pane.py index 11d410f6d9..484cbd1c81 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -1818,8 +1818,12 @@ def test_new_pane_floating(session: Session) -> None: if has_gte_version("3.7"): floating = pane.new_pane(width=80, height=15, x=5, y=3, shell="sleep 30") assert floating.pane_floating_flag == "1" - assert floating.pane_width == "80" - assert floating.pane_height == "15" + # tmux 3.8 counts the border in -x/-y/-X/-Y, so the content is smaller. + border = 2 if has_gte_version("3.8") else 0 + assert floating.pane_width == str(80 - border) + assert floating.pane_height == str(15 - border) + assert floating.pane_x == str(5 + border // 2) + assert floating.pane_y == str(3 + border // 2) else: with pytest.raises(exc.LibTmuxException, match=r"new_pane .*requires tmux 3.7"): pane.new_pane(width=40, height=10) From 7d7299a8e229dd3d9496a11af92eaf92e55ede5a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:42:43 -0500 Subject: [PATCH 30/60] tests(fix[popup]): Attach popup clients on a pty why: tmux 3.8 display-popup returns early for control-mode clients (cmd-display-menu.c: CLIENT_CONTROL check), so the command never ran and the popup tests timed out waiting on their marker files. what: - Add a pty_client fixture that attaches a terminal-backed client - Use it in the display_popup flag, close_on_success and target_client tests - Note the 3.8 control-client behaviour on Pane.display_popup --- src/libtmux/pane.py | 7 +++-- tests/conftest.py | 69 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 30 ++++++++++---------- 3 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 20e491833f..0554081e67 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1690,9 +1690,10 @@ def display_popup( ) -> None: """Display a popup overlay via ``$ tmux display-popup``. - Requires tmux 3.2+ and an attached client. Use - :class:`~libtmux._internal.control_mode.ControlMode` in tests to provide - a client. + Requires tmux 3.2+ and an attached client. tmux 3.8+ ignores the + command for control-mode clients (such as + :class:`~libtmux._internal.control_mode.ControlMode`), so a test needs a + client attached on a terminal. Parameters ---------- diff --git a/tests/conftest.py b/tests/conftest.py index c0015e4e65..f250744c5c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,23 @@ from __future__ import annotations +import contextlib +import fcntl +import os +import pty +import struct +import subprocess +import termios +import typing as t + import pytest from libtmux.common import get_version, get_version_str +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session @pytest.fixture(autouse=True) @@ -20,3 +34,58 @@ def _clear_get_version_cache() -> None: """ get_version.cache_clear() get_version_str.cache_clear() + + +@pytest.fixture +def pty_client( + server: Server, + session: Session, +) -> t.Iterator[t.Callable[[], str]]: + """Attach a client on a pseudo-terminal and return its name. + + ``display-popup`` draws on a client's terminal. tmux 3.8 turned it into a + no-op for control-mode clients, so popup tests need a terminal-backed one. + """ + opened: list[tuple[subprocess.Popen[bytes], int, int]] = [] + + def attach() -> str: + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + tmux_bin = server.tmux_bin or "tmux" + if server.socket_name is not None: + socket_args = ["-L", str(server.socket_name)] + elif server.socket_path is not None: + socket_args = ["-S", str(server.socket_path)] + else: + socket_args = [] + proc = subprocess.Popen( + [tmux_bin, *socket_args, "attach-session", "-t", str(session.session_id)], + stdin=slave, + stdout=slave, + stderr=slave, + env={**os.environ, "TERM": "xterm-256color"}, + ) + opened.append((proc, master, slave)) + found: list[str] = [] + + def registered() -> bool: + result = server.cmd("list-clients", "-F", "#{client_pid}\t#{client_name}") + for line in result.stdout: + pid, _, name = line.partition("\t") + if pid == str(proc.pid) and name: + found.append(name.strip()) + return True + return False + + retry_until(registered, 3, raises=True) + return found[0] + + yield attach + + for proc, master, slave in opened: + proc.kill() + proc.wait() + with contextlib.suppress(OSError): + os.close(master) + with contextlib.suppress(OSError): + os.close(slave) diff --git a/tests/test_pane.py b/tests/test_pane.py index 484cbd1c81..c1280317ff 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -1091,7 +1091,7 @@ def test_display_popup_flags( test_id: str, kwargs: dict[str, t.Any], min_tmux_version: str | None, - control_mode: t.Callable[..., t.Any], + pty_client: t.Callable[[], str], session: Session, tmp_path: pathlib.Path, ) -> None: @@ -1110,14 +1110,14 @@ def test_display_popup_flags( call_kwargs = {"command": f"touch {marker}", "close_on_exit": True, **kwargs} - with control_mode(): - pane.display_popup(**call_kwargs) + pty_client() + pane.display_popup(**call_kwargs) retry_until(lambda: marker.exists(), 3, raises=True) def test_display_popup_close_on_success( - control_mode: t.Callable[..., t.Any], + pty_client: t.Callable[[], str], session: Session, tmp_path: pathlib.Path, ) -> None: @@ -1126,8 +1126,8 @@ def test_display_popup_close_on_success( pane = session.active_window.active_pane assert pane is not None - with control_mode(): - pane.display_popup(command=f"touch {marker}", close_on_success=True) + pty_client() + pane.display_popup(command=f"touch {marker}", close_on_success=True) retry_until(lambda: marker.exists(), 3, raises=True) @@ -1157,26 +1157,26 @@ def test_display_popup_close_existing( def test_display_popup_target_client( - control_mode: t.Callable[..., t.Any], + pty_client: t.Callable[[], str], session: Session, tmp_path: pathlib.Path, ) -> None: """Test Pane.display_popup(target_client=...) emits ``-c ``. ``-c`` has been on ``display-popup`` since tmux 3.2a, so no version - guard is needed. The popup itself is invisible without a TTY-backed - client; this is a smoke test for the flag-passing path. + guard is needed. The client is attached on a pseudo-terminal because + tmux 3.8 ignores ``display-popup`` for control-mode clients. """ pane = session.active_window.active_pane assert pane is not None marker = tmp_path / "popup_target_client.marker" - with control_mode() as ctl: - pane.display_popup( - command=f"touch {marker}", - close_on_exit=True, - target_client=ctl.client_name, - ) + client_name = pty_client() + pane.display_popup( + command=f"touch {marker}", + close_on_exit=True, + target_client=client_name, + ) retry_until(lambda: marker.exists(), 3, raises=True) From ce246555d4d507170539b211e6b8c62a90fc3319 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:51:08 -0500 Subject: [PATCH 31/60] Server(fix[support]): Copy and pickle a Server without its version cache why: The per-Server version cache holds a threading.Lock, so copy.deepcopy(Server(...)) and pickle.dumps(Server(...)) raised "cannot pickle '_thread.lock' object". Both work on master. what: - Give VersionCache __getstate__/__setstate__ that drop the cached release and create a fresh lock, so a copy checks its daemon again rather than trusting a release checked by another object - Test deepcopy and pickle round trips keep the endpoint and start without a checked release Removing __getstate__ makes the round-trip test fail with the original TypeError (python/review2-breaks.log). --- src/libtmux/_internal/support/versions.py | 10 ++++++++++ tests/test_versions.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 5428636d68..a42f84c721 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -8,6 +8,7 @@ import re import subprocess import threading +import typing as t import uuid from libtmux import exc @@ -129,6 +130,15 @@ def clear(self) -> None: def __bool__(self) -> bool: return self._entry is not None + def __getstate__(self) -> dict[str, t.Any]: + # A pickled or copied Server must check its daemon again: the lock + # cannot be copied, and a release checked elsewhere proves nothing. + return {} + + def __setstate__(self, state: dict[str, t.Any]) -> None: + self._lock = threading.Lock() + self._entry = None + def _absent_endpoint(stderr: str) -> bool: return "no server running" in stderr or ( diff --git a/tests/test_versions.py b/tests/test_versions.py index f2073b67a4..0c3c6f958f 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -2,9 +2,11 @@ from __future__ import annotations +import copy import itertools import os import pathlib +import pickle import shutil import subprocess import tempfile @@ -645,3 +647,16 @@ def test_native_supported_daemon_passes(rejection_socket: pathlib.Path) -> None: assert session.session_id is not None finally: server.kill() + + +def test_server_pickles_and_copies_without_its_version_cache() -> None: + """A copied or unpickled Server checks its daemon again.""" + server = Server(tmux_bin="/test/tmux", socket_name="copied") + server._version_cache.put(*server._version_endpoint(), "3.8") + clones: list[Server] = [copy.deepcopy(server), pickle.loads(pickle.dumps(server))] + for clone in clones: + assert clone.socket_name == "copied" + assert not clone._admission + clone._admission.put(*clone._version_endpoint(), "3.4") + assert clone._admission.get(*clone._version_endpoint()) == "3.4" + assert server._admission.get(*server._version_endpoint()) == "3.8" From 84453ec427a00adfc7baac96f45111b9571895fa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:51:08 -0500 Subject: [PATCH 32/60] Support(fix): Read the daemon afresh in server_version why: server_version(server) returned the release the Server had cached, so after a 3.3a daemon was replaced by 3.2a on the same socket it still answered "3.3a". check_fence had no way to tell a Server that its cached version was stale, so a consumer dispatching its own fenced subprocess could not invalidate it either. what: - Clear the Server's cached version before server_version probes - Accept an optional server in check_fence and clear its cached version when the fence fires, before re-raising VersionTooLow - Test a real 3.3a daemon replaced by 3.2a: server_version refuses it and leaves nothing cached - Test that a fired fence clears the cache and a foreign marker does not - Import libtmux._internal.support at module level in the tests Each guard has a deliberate break that fails its test: server_version reading the cache, and check_fence keeping it (python/review2-breaks.log). --- src/libtmux/_internal/support/__init__.py | 20 ++++++-- tests/test_versions.py | 62 +++++++++++++++++++---- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/libtmux/_internal/support/__init__.py b/src/libtmux/_internal/support/__init__.py index bcf57ea208..d190441cb3 100644 --- a/src/libtmux/_internal/support/__init__.py +++ b/src/libtmux/_internal/support/__init__.py @@ -51,22 +51,32 @@ def server_version(server: Server) -> str: """Return the tmux release *server* talks to, refusing an unsupported one. - A live daemon answers with its own release; an absent endpoint answers - with the configured executable's. Either must meet :data:`MINIMUM`. + The release is read from the endpoint on every call, never from what + *server* checked earlier, so a daemon replaced since then is reported as + it is now. A live daemon answers with its own release; an absent endpoint + answers with the configured executable's. Either must meet + :data:`MINIMUM`. Raises ------ :exc:`~libtmux.exc.VersionTooLow` When the release is below the minimum or cannot be established. """ + server._version_cache.clear() return server._get_version() -def check_fence(stderr: str, marker: str | None) -> None: +def check_fence(stderr: str, marker: str | None, server: Server | None = None) -> None: """Raise :exc:`~libtmux.exc.VersionTooLow` if a fenced batch was refused. A batch prefixed with ``if-shell -F '#{==:#{version},}' '' ;`` reports ``unknown command: `` on stderr when the daemon is not the - release the caller admitted, and none of its commands ran. + release the caller checked, and none of its commands ran. Pass *server* + so its next command reads the replacement daemon's release afresh. """ - check_result(stderr, marker) + try: + check_result(stderr, marker) + except VersionTooLow: + if server is not None: + server._version_cache.clear() + raise diff --git a/tests/test_versions.py b/tests/test_versions.py index 0c3c6f958f..e8ef6d3e67 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -237,11 +237,9 @@ def test_feature_flags_follow_the_daemon_not_the_client( assert not server._has_gte_version("3.7") -def test_public_admission_surface(monkeypatch: pytest.MonkeyPatch) -> None: - """Consumers reach the decision through libtmux.admission alone.""" - from libtmux import admission - - assert set(admission.__all__) == { +def test_support_surface(monkeypatch: pytest.MonkeyPatch) -> None: + """Consumers reach the version check through libtmux._internal.support.""" + assert set(support.__all__) == { "MINIMUM", "VersionTooLow", "server_version", @@ -656,7 +654,53 @@ def test_server_pickles_and_copies_without_its_version_cache() -> None: clones: list[Server] = [copy.deepcopy(server), pickle.loads(pickle.dumps(server))] for clone in clones: assert clone.socket_name == "copied" - assert not clone._admission - clone._admission.put(*clone._version_endpoint(), "3.4") - assert clone._admission.get(*clone._version_endpoint()) == "3.4" - assert server._admission.get(*server._version_endpoint()) == "3.8" + assert not clone._version_cache + binary, selectors = clone._version_endpoint() + clone._version_cache.put(binary, selectors, "3.4") + assert clone._version_cache.get(binary, selectors) == "3.4" + assert server._version_cache.get(*server._version_endpoint()) == "3.8" + + +def test_native_server_version_reads_a_replaced_daemon( + rejection_socket: pathlib.Path, +) -> None: + """server_version reports the daemon now on the socket, not a cached one.""" + supported = _native_binary("LIBTMUX_TEST_MINIMUM_TMUX", "3.3a") + older = _native_binary("LIBTMUX_TEST_UNSUPPORTED_TMUX", "3.2a") + + def start(binary: str) -> None: + started = subprocess.run( + [ + binary, + *("-f", "/dev/null", "-S", str(rejection_socket)), + *("new-session", "-d", "-s", "kept", "exec sleep 120"), + ], + capture_output=True, + timeout=5, + check=False, + ) + assert started.returncode == 0, started.stderr + + start(supported) + server = Server(tmux_bin=supported, socket_path=str(rejection_socket)) + try: + assert support.server_version(server) == "3.3a" + _native(supported, rejection_socket, "kill-server") + start(older) + with pytest.raises(exc.VersionTooLow, match=r"3\.3a"): + support.server_version(server) + assert not server._version_cache + finally: + _native(older, rejection_socket, "kill-server") + _native(supported, rejection_socket, "kill-server") + + +def test_check_fence_forgets_the_checked_release() -> None: + """A fired fence makes the server read its daemon's release afresh.""" + server = Server(tmux_bin="/test/tmux", socket_name="fenced") + server._version_cache.put(*server._version_endpoint(), "3.8") + support.check_fence("unknown command: other\n", "marker", server) + assert server._version_cache + with pytest.raises(support.VersionTooLow): + support.check_fence("unknown command: marker\n", "marker", server) + assert not server._version_cache From 45077c1c2360634db9fee9e3b90a30b936b7de6b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:24:17 -0500 Subject: [PATCH 33/60] Core(refactor[version]): Move executable version helpers to _internal.version why: get_version and the has_*_version helpers read the tmux client executable, which can be a different release from the daemon a Server talks to. libtmux checks the daemon through libtmux._internal.support, so the executable helpers are legacy and belong out of common. what: - Add libtmux/_internal/version.py holding get_version, get_version_str, has_version, has_gt/gte/lte/lt_version, has_minimum_version, their private helpers, TMUX_MIN_VERSION and TMUX_MAX_VERSION - Each public helper emits a DeprecationWarning (stacklevel 2) saying it reads the client executable and naming the libtmux._internal.support replacement, e.g. is_at_least(server_version(server), "3.4") - Keep cache_clear() on get_version and get_version_str, typed so mypy accepts it - Re-export every helper from libtmux.common, and serve the two constants through a module __getattr__ that warns, so old imports keep working - Resolve tmux_cmd through libtmux.common at call time so tests that replace it keep working - Point the tests that patch TMUX_MIN_VERSION and _query_version at the module that now owns them --- src/libtmux/_internal/version.py | 407 +++++++++++++++++++++++++++++++ src/libtmux/common.py | 332 ++++--------------------- tests/legacy_api/test_common.py | 7 +- tests/test_common.py | 5 +- 4 files changed, 463 insertions(+), 288 deletions(-) create mode 100644 src/libtmux/_internal/version.py diff --git a/src/libtmux/_internal/version.py b/src/libtmux/_internal/version.py new file mode 100644 index 0000000000..a9b8dd4227 --- /dev/null +++ b/src/libtmux/_internal/version.py @@ -0,0 +1,407 @@ +"""Legacy helpers that read the tmux client executable's version. + +``libtmux.common`` re-exports every public name here, so existing imports keep +working. The helpers report the executable found on ``PATH`` (or *tmux_bin*), +not the daemon a :class:`~libtmux.Server` is connected to, which can be a +different release. Each one emits a :exc:`DeprecationWarning`; libtmux itself +checks the daemon through :mod:`libtmux._internal.support`. +""" + +from __future__ import annotations + +import functools +import re +import sys +import typing as t +import warnings + +from libtmux import exc +from libtmux._compat import LooseVersion +from libtmux._internal.support import versions + +if t.TYPE_CHECKING: + from collections.abc import Callable + +#: Minimum version of tmux required to run libtmux +TMUX_MIN_VERSION = versions.MINIMUM + +#: Most recent version of tmux supported +TMUX_MAX_VERSION = "3.7" + +_COMPARE_HINT = 'is_at_least(server_version(server), "3.4")' + + +def _tmux_cmd(*args: str, tmux_bin: str | None = None) -> t.Any: + """Run ``tmux`` through ``libtmux.common.tmux_cmd``, looked up at call time.""" + from libtmux import common + + return common.tmux_cmd(*args, tmux_bin=tmux_bin) + + +def _deprecation(name: str, replacement: str) -> str: + """Return the warning text shared by the deprecated helpers.""" + return ( + f"libtmux.common.{name}() reads the tmux client executable, not the " + f"daemon a Server is connected to. Use {replacement} from " + "libtmux._internal.support." + ) + + +class _TmuxVersionUnavailable(Exception): + """Internal signal: this tmux predates the ``-V`` flag (pre-1.7).""" + + +def _no_version_flag_fallback() -> str: + """Return a synthetic version string when tmux lacks ``-V``. + + OpenBSD ships a ``-V``-less base tmux, so assume the maximum supported + version; any other platform is genuinely too old. + """ + if sys.platform.startswith("openbsd"): # openbsd has no tmux -V + return f"{TMUX_MAX_VERSION}-openbsd" + msg = ( + f"libtmux supports tmux {TMUX_MIN_VERSION} and greater. This system" + " does not meet the minimum tmux version requirement." + ) + raise exc.LibTmuxException(msg) + + +def _query_version(tmux_bin: str | None = None) -> str: + """Return the raw ``tmux -V`` version token, letter suffix intact. + + Not memoized -- :func:`_get_version` and :func:`_get_version_str` each + cache their own result on top of this query. + + Raises + ------ + _TmuxVersionUnavailable + tmux predates the ``-V`` flag; callers apply + :func:`_no_version_flag_fallback`. + :exc:`~libtmux.exc.VersionTooLow` + tmux reported another error on ``-V``. + """ + proc = _tmux_cmd("-V", tmux_bin=tmux_bin) + if proc.stderr: + if proc.stderr[0] == "tmux: unknown option -- V": + raise _TmuxVersionUnavailable + raise exc.VersionTooLow(proc.stderr) + + if len(proc.stdout) != 1 or not proc.stdout[0].startswith("tmux "): + msg = "tmux -V did not report a single version" + raise exc.VersionTooLow(msg) + version: str = proc.stdout[0].removeprefix("tmux ") + return version + + +@functools.cache +def _get_version_str(tmux_bin: str | None = None) -> str: + """Return the executable's version token verbatim, letter suffix intact.""" + try: + return _query_version(tmux_bin=tmux_bin) + except _TmuxVersionUnavailable: + return _no_version_flag_fallback() + + +@functools.cache +def _get_version(tmux_bin: str | None = None) -> LooseVersion: + """Return the executable's version with letter suffixes stripped.""" + try: + version = _query_version(tmux_bin=tmux_bin) + except _TmuxVersionUnavailable: + # OpenBSD base tmux lacks ``-V``; skip letter-stripping on the synthetic. + return LooseVersion(_no_version_flag_fallback()) + + # Allow latest tmux HEAD + if version == "master": + return LooseVersion(f"{TMUX_MAX_VERSION}-master") + + version = re.sub(r"[a-z-]", "", version) + + return LooseVersion(version) + + +_R = t.TypeVar("_R") +_R_co = t.TypeVar("_R_co", covariant=True) + + +class _CachedHelper(t.Protocol[_R_co]): + """A public helper that still offers the old ``cache_clear()`` hook.""" + + def __call__(self, tmux_bin: str | None = None) -> _R_co: ... + + def cache_clear(self) -> None: ... + + +def _clears( + cached: t.Any, +) -> Callable[[Callable[[str | None], _R]], _CachedHelper[_R]]: + """Expose *cached*'s ``cache_clear`` on the public helper it backs.""" + + def decorate(func: Callable[[str | None], _R]) -> _CachedHelper[_R]: + func.cache_clear = cached.cache_clear # type: ignore[attr-defined] + return t.cast("_CachedHelper[_R]", func) + + return decorate + + +@_clears(_get_version_str) +def get_version_str(tmux_bin: str | None = None) -> str: + """Return the tmux executable's version string, preserving letter suffixes. + + .. deprecated:: 0.63.0 + Reads the client executable. Use + ``libtmux._internal.support.server_version(server)``. + + Parameters + ---------- + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + str + Raw tmux version, e.g. ``"3.7a"``. Git builds return ``"master"``; + OpenBSD base tmux returns ``"-openbsd"``. + + Notes + ----- + Memoized per *tmux_bin*, independently of :func:`get_version`. Call + ``get_version_str.cache_clear()`` after swapping the tmux binary. + """ + warnings.warn( + _deprecation("get_version_str", "server_version(server)"), + DeprecationWarning, + stacklevel=2, + ) + return _get_version_str(tmux_bin) + + +@_clears(_get_version) +def get_version(tmux_bin: str | None = None) -> LooseVersion: + """Return the tmux executable's version. + + .. deprecated:: 0.63.0 + Reads the client executable. Use + ``libtmux._internal.support.server_version(server)``. + + If tmux is built from git master, the version returned will be the latest + version appended with -master, e.g. ``2.4-master``. + + If using OpenBSD's base system tmux, the version will have ``-openbsd`` + appended to the latest version, e.g. ``2.4-openbsd``. + + Parameters + ---------- + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux from + :func:`shutil.which`. + + Returns + ------- + :class:`distutils.version.LooseVersion` + tmux version according to *tmux_bin* if provided, otherwise the + system tmux from :func:`shutil.which` + + Notes + ----- + Memoized per *tmux_bin* (``None`` is a distinct key), independently of + :func:`get_version_str`. The cache is sticky across ``PATH`` changes and + on-disk binary swaps; call ``get_version.cache_clear()`` to invalidate. + """ + warnings.warn( + _deprecation("get_version", "server_version(server)"), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) + + +def has_version(version: str, tmux_bin: str | None = None) -> bool: + """Return True if the tmux executable is exactly *version*. + + .. deprecated:: 0.63.0 + Reads the client executable, not the daemon. + + Parameters + ---------- + version : str + version number, e.g. '3.2a' + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if version matches + """ + warnings.warn( + _deprecation("has_version", _COMPARE_HINT), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) == LooseVersion(version) + + +def has_gt_version(min_version: str, tmux_bin: str | None = None) -> bool: + """Return True if the tmux executable is newer than *min_version*. + + .. deprecated:: 0.63.0 + Reads the client executable, not the daemon. + + Parameters + ---------- + min_version : str + tmux version, e.g. '3.2a' + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if version above min_version + """ + warnings.warn( + _deprecation("has_gt_version", _COMPARE_HINT), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) > LooseVersion(min_version) + + +def has_gte_version(min_version: str, tmux_bin: str | None = None) -> bool: + """Return True if the tmux executable is at least *min_version*. + + .. deprecated:: 0.63.0 + Reads the client executable. Use + ``is_at_least(server_version(server), min_version)`` from + ``libtmux._internal.support``. + + Parameters + ---------- + min_version : str + tmux version, e.g. '3.2a' + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if version above or equal to min_version + """ + warnings.warn( + _deprecation("has_gte_version", _COMPARE_HINT), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) >= LooseVersion(min_version) + + +def has_lte_version(max_version: str, tmux_bin: str | None = None) -> bool: + """Return True if the tmux executable is at most *max_version*. + + .. deprecated:: 0.63.0 + Reads the client executable, not the daemon. + + Parameters + ---------- + max_version : str + tmux version, e.g. '3.2a' + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if version below or equal to max_version + """ + warnings.warn( + _deprecation("has_lte_version", _COMPARE_HINT), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) <= LooseVersion(max_version) + + +def has_lt_version(max_version: str, tmux_bin: str | None = None) -> bool: + """Return True if the tmux executable is older than *max_version*. + + .. deprecated:: 0.63.0 + Reads the client executable, not the daemon. Use + ``not is_at_least(server_version(server), max_version)`` from + ``libtmux._internal.support``. + + Parameters + ---------- + max_version : str + tmux version, e.g. '3.2a' + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if version below max_version + """ + warnings.warn( + _deprecation("has_lt_version", f"not {_COMPARE_HINT}"), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) < LooseVersion(max_version) + + +def has_minimum_version(raises: bool = True, tmux_bin: str | None = None) -> bool: + """Return True if the executable meets the complete release minimum. + + .. deprecated:: 0.63.0 + Reads the client executable. Commands check their connected daemon's + version on their own; use + ``require_version(server_version(server))`` from + ``libtmux._internal.support`` to check one explicitly. + + This helper preserves patch letters and does not use the legacy + normalized version cache. + + Parameters + ---------- + raises : bool + raise exception if below minimum version requirement + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if tmux meets minimum required version. + + Raises + ------ + libtmux.exc.VersionTooLow + tmux version below minimum required for libtmux + + Notes + ----- + .. versionchanged:: 0.49.0 + Minimum version bumped to 3.2a. For older tmux, use libtmux v0.48.x. + + .. versionchanged:: 0.7.0 + No longer returns version, returns True or False + + .. versionchanged:: 0.1.7 + Versions will now remove trailing letters per + `Issue 55 `_. + """ + warnings.warn( + _deprecation("has_minimum_version", "require_version(server_version(server))"), + DeprecationWarning, + stacklevel=2, + ) + try: + version = _query_version(tmux_bin=tmux_bin) + versions.require_version(version, minimum=TMUX_MIN_VERSION) + except (exc.VersionTooLow, _TmuxVersionUnavailable): + if raises: + msg = f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater" + raise exc.VersionTooLow(msg) from None + return False + return True diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2d5961d2b3..3560246195 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,30 +7,72 @@ from __future__ import annotations -import functools import logging -import re import shlex import shutil import subprocess -import sys import typing as t +import warnings from . import exc from ._compat import LooseVersion +from ._internal import version as _version from ._internal.support import versions +from ._internal.version import ( + get_version, + get_version_str, + has_gt_version, + has_gte_version, + has_lt_version, + has_lte_version, + has_minimum_version, + has_version, +) if t.TYPE_CHECKING: from collections.abc import Callable +__all__ = ( + "CmdMixin", + "CmdProtocol", + "EnvironmentMixin", + "PaneDict", + "SessionDict", + "WindowDict", + "WindowOptionDict", + "get_libtmux_version", + "get_version", + "get_version_str", + "has_gt_version", + "has_gte_version", + "has_lt_version", + "has_lte_version", + "has_minimum_version", + "has_version", + "raise_if_stderr", + "session_check_name", + "tmux_cmd", +) + logger = logging.getLogger(__name__) +_DEPRECATED_CONSTANTS = frozenset({"TMUX_MIN_VERSION", "TMUX_MAX_VERSION"}) + -#: Minimum version of tmux required to run libtmux -TMUX_MIN_VERSION = versions.MINIMUM +def __getattr__(name: str) -> t.Any: + """Serve the legacy tmux version constants, warning on each access.""" + if name in _DEPRECATED_CONSTANTS: + warnings.warn( + f"libtmux.common.{name} describes the tmux client executable's " + "supported range and is deprecated. Use " + "libtmux._internal.support.MINIMUM for the floor.", + DeprecationWarning, + stacklevel=2, + ) + return getattr(_version, name) + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) -#: Most recent version of tmux supported -TMUX_MAX_VERSION = "3.7" SessionDict = dict[str, t.Any] WindowDict = dict[str, t.Any] @@ -396,282 +438,6 @@ def _dispatch(self, argv: list[str]) -> tuple[str, str, int]: return stdout, stderr, self.process.returncode -class _TmuxVersionUnavailable(Exception): - """Internal signal: this tmux predates the ``-V`` flag (pre-1.7).""" - - -def _no_version_flag_fallback() -> str: - """Return a synthetic version string when tmux lacks ``-V``. - - OpenBSD ships a ``-V``-less base tmux, so assume the maximum supported - version; any other platform is genuinely too old. - """ - if sys.platform.startswith("openbsd"): # openbsd has no tmux -V - return f"{TMUX_MAX_VERSION}-openbsd" - msg = ( - f"libtmux supports tmux {TMUX_MIN_VERSION} and greater. This system" - " does not meet the minimum tmux version requirement." - ) - raise exc.LibTmuxException(msg) - - -def _query_version(tmux_bin: str | None = None) -> str: - """Return the raw ``tmux -V`` version token, letter suffix intact. - - Runs ``tmux -V`` and extracts the version token (e.g. ``"3.7a"``, - ``"master"``, ``"next-3.8"``). Not memoized -- :func:`get_version` and - :func:`get_version_str` each cache their own result on top of this query. - - Parameters - ---------- - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - str - Raw version token from ``tmux -V``. - - Raises - ------ - _TmuxVersionUnavailable - tmux predates the ``-V`` flag; callers apply - :func:`_no_version_flag_fallback`. - :exc:`~libtmux.exc.VersionTooLow` - tmux reported another error on ``-V``. - """ - proc = tmux_cmd("-V", tmux_bin=tmux_bin) - if proc.stderr: - if proc.stderr[0] == "tmux: unknown option -- V": - raise _TmuxVersionUnavailable - raise exc.VersionTooLow(proc.stderr) - - if len(proc.stdout) != 1 or not proc.stdout[0].startswith("tmux "): - msg = "tmux -V did not report a single version" - raise exc.VersionTooLow(msg) - return proc.stdout[0].removeprefix("tmux ") - - -@functools.cache -def get_version_str(tmux_bin: str | None = None) -> str: - """Return the tmux version string verbatim, preserving letter suffixes. - - :func:`get_version` normalizes point releases for numeric comparison - (``"3.7a"`` becomes ``LooseVersion("3.7")``). This helper keeps the raw - suffix, so callers can distinguish patch releases whose behavior differs - -- for example the tmux 3.7 break-pane crash, reverted in 3.7a. - - Parameters - ---------- - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - str - Raw tmux version, e.g. ``"3.7a"``. Git builds return ``"master"``; - OpenBSD base tmux returns ``"-openbsd"``. - - Examples - -------- - >>> isinstance(get_version_str(), str) - True - - Notes - ----- - Memoized via :func:`functools.cache`, keyed on *tmux_bin*, independently of - :func:`get_version`. Call ``get_version_str.cache_clear()`` after swapping - the tmux binary. - """ - try: - return _query_version(tmux_bin=tmux_bin) - except _TmuxVersionUnavailable: - return _no_version_flag_fallback() - - -@functools.cache -def get_version(tmux_bin: str | None = None) -> LooseVersion: - """Return tmux version. - - If tmux is built from git master, the version returned will be the latest - version appended with -master, e.g. ``2.4-master``. - - If using OpenBSD's base system tmux, the version will have ``-openbsd`` - appended to the latest version, e.g. ``2.4-openbsd``. - - Parameters - ---------- - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux from - :func:`shutil.which`. - - Returns - ------- - :class:`distutils.version.LooseVersion` - tmux version according to *tmux_bin* if provided, otherwise the - system tmux from :func:`shutil.which` - - Notes - ----- - Memoized via :func:`functools.cache`, keyed on the *tmux_bin* argument - (``None`` is a distinct key from any explicit path), independently of - :func:`get_version_str`. The cache is sticky across ``PATH`` changes and - on-disk binary swaps when *tmux_bin* is ``None`` or the same path string -- - call ``get_version.cache_clear()`` to invalidate. Tests that monkey-patch - :class:`tmux_cmd` should call ``cache_clear()`` before asserting - parsed-version behavior. - """ - try: - version = _query_version(tmux_bin=tmux_bin) - except _TmuxVersionUnavailable: - # OpenBSD base tmux lacks ``-V``; skip letter-stripping on the synthetic. - return LooseVersion(_no_version_flag_fallback()) - - # Allow latest tmux HEAD - if version == "master": - return LooseVersion(f"{TMUX_MAX_VERSION}-master") - - version = re.sub(r"[a-z-]", "", version) - - return LooseVersion(version) - - -def has_version(version: str, tmux_bin: str | None = None) -> bool: - """Return True if tmux version installed. - - Parameters - ---------- - version : str - version number, e.g. '3.2a' - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if version matches - """ - return get_version(tmux_bin=tmux_bin) == LooseVersion(version) - - -def has_gt_version(min_version: str, tmux_bin: str | None = None) -> bool: - """Return True if tmux version greater than minimum. - - Parameters - ---------- - min_version : str - tmux version, e.g. '3.2a' - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if version above min_version - """ - return get_version(tmux_bin=tmux_bin) > LooseVersion(min_version) - - -def has_gte_version(min_version: str, tmux_bin: str | None = None) -> bool: - """Return True if tmux version greater or equal to minimum. - - Parameters - ---------- - min_version : str - tmux version, e.g. '3.2a' - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if version above or equal to min_version - """ - return get_version(tmux_bin=tmux_bin) >= LooseVersion(min_version) - - -def has_lte_version(max_version: str, tmux_bin: str | None = None) -> bool: - """Return True if tmux version less or equal to minimum. - - Parameters - ---------- - max_version : str - tmux version, e.g. '3.2a' - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if version below or equal to max_version - """ - return get_version(tmux_bin=tmux_bin) <= LooseVersion(max_version) - - -def has_lt_version(max_version: str, tmux_bin: str | None = None) -> bool: - """Return True if tmux version less than minimum. - - Parameters - ---------- - max_version : str - tmux version, e.g. '3.2a' - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if version below max_version - """ - return get_version(tmux_bin=tmux_bin) < LooseVersion(max_version) - - -def has_minimum_version(raises: bool = True, tmux_bin: str | None = None) -> bool: - """Return True if the executable meets the complete release minimum. - - Commands check their connected daemon's version on their own. This helper preserves - patch letters and does not use the legacy normalized version cache. - - Parameters - ---------- - raises : bool - raise exception if below minimum version requirement - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if tmux meets minimum required version. - - Raises - ------ - libtmux.exc.VersionTooLow - tmux version below minimum required for libtmux - - Notes - ----- - .. versionchanged:: 0.49.0 - Minimum version bumped to 3.2a. For older tmux, use libtmux v0.48.x. - - .. versionchanged:: 0.7.0 - No longer returns version, returns True or False - - .. versionchanged:: 0.1.7 - Versions will now remove trailing letters per - `Issue 55 `_. - """ - try: - version = _query_version(tmux_bin=tmux_bin) - versions.require_version(version, minimum=TMUX_MIN_VERSION) - except (exc.VersionTooLow, _TmuxVersionUnavailable): - if raises: - msg = f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater" - raise exc.VersionTooLow(msg) from None - return False - return True - - def session_check_name(session_name: str | None) -> None: """Raise exception session name invalid, modeled after tmux function. diff --git a/tests/legacy_api/test_common.py b/tests/legacy_api/test_common.py index 77e35ecd08..560e720554 100644 --- a/tests/legacy_api/test_common.py +++ b/tests/legacy_api/test_common.py @@ -15,6 +15,7 @@ import libtmux from libtmux import exc from libtmux._compat import LooseVersion +from libtmux._internal import version as legacy_version from libtmux.common import ( TMUX_MAX_VERSION, TMUX_MIN_VERSION, @@ -120,11 +121,11 @@ def test_ignores_letter_versions(monkeypatch: pytest.MonkeyPatch) -> None: allow letters. """ - monkeypatch.setattr(libtmux.common, "TMUX_MIN_VERSION", "1.9a") + monkeypatch.setattr(legacy_version, "TMUX_MIN_VERSION", "1.9a") result = has_minimum_version() assert result - monkeypatch.setattr(libtmux.common, "TMUX_MIN_VERSION", "1.8a") + monkeypatch.setattr(legacy_version, "TMUX_MIN_VERSION", "1.8a") result = has_minimum_version() assert result @@ -136,7 +137,7 @@ def test_ignores_letter_versions(monkeypatch: pytest.MonkeyPatch) -> None: def test_error_version_less_1_7(monkeypatch: pytest.MonkeyPatch) -> None: """Refuse an old executable without dropping its version report.""" - monkeypatch.setattr(libtmux.common, "_query_version", lambda **kwargs: "1.7") + monkeypatch.setattr(legacy_version, "_query_version", lambda **kwargs: "1.7") with pytest.raises(exc.VersionTooLow, match="libtmux only supports"): has_minimum_version() assert not has_minimum_version(raises=False) diff --git a/tests/test_common.py b/tests/test_common.py index 14823bc6df..3e394d6b8e 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -13,6 +13,7 @@ import libtmux from libtmux import exc from libtmux._compat import LooseVersion +from libtmux._internal import version as legacy_version from libtmux.common import ( TMUX_MAX_VERSION, TMUX_MIN_VERSION, @@ -618,7 +619,7 @@ def test_version_validation( ) -> None: """Test version validation.""" if mock_min_version is not None: - monkeypatch.setattr(libtmux.common, "TMUX_MIN_VERSION", mock_min_version) + monkeypatch.setattr(legacy_version, "TMUX_MIN_VERSION", mock_min_version) if mock_version is not None: @@ -628,7 +629,7 @@ def mock_get_version(tmux_bin: str | None = None) -> LooseVersion: monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) if check_type == "min_version": monkeypatch.setattr( - libtmux.common, "_query_version", lambda **kwargs: mock_version + legacy_version, "_query_version", lambda **kwargs: mock_version ) if check_type == "min_version": From 9d234e954cf51a64d116d8b7b22c77a717b860d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:24:52 -0500 Subject: [PATCH 34/60] tests(refactor[legacy_api]): Move executable version helper tests to legacy_api why: The tests that exercise get_version and the has_*_version helpers test the legacy executable API, so they belong beside the other legacy tests and not among the suite that libtmux's own behaviour is built on. what: - Move the helper tests from tests/test_common.py to tests/legacy_api/test_version_helpers.py unchanged: memoization, cache keying, comparisons, version parsing and validation - Silence DeprecationWarning for that module only, since it deliberately calls the deprecated helpers - Leave tmux_cmd, session_check_name and the other common tests in place --- tests/legacy_api/test_version_helpers.py | 549 +++++++++++++++++++++++ tests/test_common.py | 527 ---------------------- 2 files changed, 549 insertions(+), 527 deletions(-) create mode 100644 tests/legacy_api/test_version_helpers.py diff --git a/tests/legacy_api/test_version_helpers.py b/tests/legacy_api/test_version_helpers.py new file mode 100644 index 0000000000..db42b4dd6c --- /dev/null +++ b/tests/legacy_api/test_version_helpers.py @@ -0,0 +1,549 @@ +"""Tests for the deprecated tmux executable version helpers. + +These helpers read the client executable and stay importable from +``libtmux.common`` for existing callers. They warn on use; the warning +itself is asserted in ``test_deprecation_warnings.py``. +""" + +from __future__ import annotations + +import sys +import typing as t + +import pytest + +import libtmux +from libtmux import exc +from libtmux._compat import LooseVersion +from libtmux._internal import version as legacy_version +from libtmux.common import ( + TMUX_MAX_VERSION, + TMUX_MIN_VERSION, + get_version, + has_gt_version, + has_gte_version, + has_lt_version, + has_lte_version, + has_minimum_version, + has_version, +) + +pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning") + + +def test_has_version() -> None: + """Test has_version().""" + assert has_version(str(get_version())) + + +def test_get_version_is_memoized_for_same_tmux_bin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two calls with the same tmux_bin fork tmux -V once. + + Validates the @functools.cache contract: identical-arg calls hit the + cache after the first miss. + """ + call_count = {"n": 0} + + class _MockProc: + stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] + stderr: t.ClassVar[list[str]] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + v1 = get_version() + v2 = get_version() + + assert v1 == v2 + assert call_count["n"] == 1 + + +def test_get_version_cache_keyed_by_tmux_bin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Different tmux_bin args cache independently; same arg revisits hit.""" + call_count = {"n": 0} + versions = {"/path/a/tmux": "tmux 3.4", "/path/b/tmux": "tmux 3.6a"} + + class _MockProc: + def __init__(self, line: str) -> None: + self.stdout = [line] + self.stderr: list[str] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc(versions[kwargs["tmux_bin"]]) + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + a1 = get_version(tmux_bin="/path/a/tmux") + b1 = get_version(tmux_bin="/path/b/tmux") + a2 = get_version(tmux_bin="/path/a/tmux") + + assert a1 != b1 + assert a1 == a2 + assert call_count["n"] == 2 # /a once, /b once, /a hits cache + + +def test_get_version_cache_clear_invalidates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """cache_clear() forces a fresh subprocess on the next call.""" + call_count = {"n": 0} + + class _MockProc: + stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] + stderr: t.ClassVar[list[str]] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + get_version() + get_version() + assert call_count["n"] == 1 + + get_version.cache_clear() + get_version() + assert call_count["n"] == 2 + + +def test_get_version_binary_swap_requires_explicit_cache_clear( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Documents the sticky-cache trap when tmux_bin=None and PATH changes. + + Simulates a user upgrading tmux mid-process: two consecutive + ``get_version()`` calls with ``tmux_bin=None`` see different + underlying binaries, but the cache pins the first answer. The + escape hatch is ``get_version.cache_clear()`` — this test asserts + the trap is real and the escape hatch works. + """ + versions = ["tmux 3.2a", "tmux 3.6a"] + call_count = {"n": 0} + + class _MockProc: + def __init__(self, line: str) -> None: + self.stdout = [line] + self.stderr: list[str] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + proc = _MockProc(versions[call_count["n"]]) + call_count["n"] += 1 + return proc + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + first = get_version() + assert str(first) == "3.2" + + # "Binary swap" — PATH changed, but cache is sticky. + second = get_version() + assert str(second) == "3.2" # Stale: still the cached 3.2a. + assert call_count["n"] == 1 # No fresh subprocess. + + # Escape hatch. + get_version.cache_clear() + third = get_version() + assert str(third) == "3.6" # Fresh lookup. + assert call_count["n"] == 2 + + +class VersionComparisonFixture(t.NamedTuple): + """Test fixture for version comparison functions.""" + + test_id: str + version: str + comparison_type: t.Literal["gt", "gte", "lt", "lte"] + expected: bool + + +VERSION_COMPARISON_FIXTURES: list[VersionComparisonFixture] = [ + # Greater than tests + VersionComparisonFixture( + test_id="gt_older_version", + version="1.6", + comparison_type="gt", + expected=True, + ), + VersionComparisonFixture( + test_id="gt_older_version_with_letter", + version="1.6b", + comparison_type="gt", + expected=True, + ), + VersionComparisonFixture( + test_id="gt_newer_version", + version="4.0", + comparison_type="gt", + expected=False, + ), + VersionComparisonFixture( + test_id="gt_newer_version_with_letter", + version="4.0b", + comparison_type="gt", + expected=False, + ), + # Greater than or equal tests + VersionComparisonFixture( + test_id="gte_older_version", + version="1.6", + comparison_type="gte", + expected=True, + ), + VersionComparisonFixture( + test_id="gte_older_version_with_letter", + version="1.6b", + comparison_type="gte", + expected=True, + ), + VersionComparisonFixture( + test_id="gte_current_version", + version=str(get_version()), + comparison_type="gte", + expected=True, + ), + VersionComparisonFixture( + test_id="gte_newer_version", + version="4.0", + comparison_type="gte", + expected=False, + ), + VersionComparisonFixture( + test_id="gte_newer_version_with_letter", + version="4.0b", + comparison_type="gte", + expected=False, + ), + # Less than tests + VersionComparisonFixture( + test_id="lt_newer_version_with_letter", + version="4.0a", + comparison_type="lt", + expected=True, + ), + VersionComparisonFixture( + test_id="lt_newer_version", + version="4.0", + comparison_type="lt", + expected=True, + ), + VersionComparisonFixture( + test_id="lt_older_version", + version="1.7", + comparison_type="lt", + expected=False, + ), + VersionComparisonFixture( + test_id="lt_current_version", + version=str(get_version()), + comparison_type="lt", + expected=False, + ), + # Less than or equal tests + VersionComparisonFixture( + test_id="lte_newer_version_with_letter", + version="4.0a", + comparison_type="lte", + expected=True, + ), + VersionComparisonFixture( + test_id="lte_newer_version", + version="4.0", + comparison_type="lte", + expected=True, + ), + VersionComparisonFixture( + test_id="lte_current_version", + version=str(get_version()), + comparison_type="lte", + expected=True, + ), + VersionComparisonFixture( + test_id="lte_older_version", + version="1.7", + comparison_type="lte", + expected=False, + ), + VersionComparisonFixture( + test_id="lte_older_version_with_letter", + version="1.7b", + comparison_type="lte", + expected=False, + ), +] + + +@pytest.mark.parametrize( + list(VersionComparisonFixture._fields), + VERSION_COMPARISON_FIXTURES, + ids=[test.test_id for test in VERSION_COMPARISON_FIXTURES], +) +def test_version_comparison( + test_id: str, + version: str, + comparison_type: t.Literal["gt", "gte", "lt", "lte"], + expected: bool, +) -> None: + """Test version comparison functions.""" + comparison_funcs = { + "gt": has_gt_version, + "gte": has_gte_version, + "lt": has_lt_version, + "lte": has_lte_version, + } + assert comparison_funcs[comparison_type](version) == expected + + +class VersionParsingFixture(t.NamedTuple): + """Test fixture for version parsing and validation.""" + + test_id: str + mock_stdout: list[str] | None + mock_stderr: list[str] | None + mock_platform: str | None + expected_version: str | None + raises: bool + exc_msg_regex: str | None + + +VERSION_PARSING_FIXTURES: list[VersionParsingFixture] = [ + VersionParsingFixture( + test_id="master_version", + mock_stdout=["tmux master"], + mock_stderr=None, + mock_platform=None, + expected_version=f"{TMUX_MAX_VERSION}-master", + raises=False, + exc_msg_regex=None, + ), + VersionParsingFixture( + test_id="next_version", + mock_stdout=["tmux next-3.8"], + mock_stderr=None, + mock_platform=None, + expected_version="3.8", + raises=False, + exc_msg_regex=None, + ), + VersionParsingFixture( + test_id="openbsd_version", + mock_stdout=None, + mock_stderr=["tmux: unknown option -- V"], + mock_platform="openbsd 5.2", + expected_version=f"{TMUX_MAX_VERSION}-openbsd", + raises=False, + exc_msg_regex=None, + ), + VersionParsingFixture( + test_id="too_low_version", + mock_stdout=None, + mock_stderr=["tmux: unknown option -- V"], + mock_platform=None, + expected_version=None, + raises=True, + exc_msg_regex="does not meet the minimum tmux version requirement", + ), +] + + +@pytest.mark.parametrize( + list(VersionParsingFixture._fields), + VERSION_PARSING_FIXTURES, + ids=[test.test_id for test in VERSION_PARSING_FIXTURES], +) +def test_version_parsing( + monkeypatch: pytest.MonkeyPatch, + test_id: str, + mock_stdout: list[str] | None, + mock_stderr: list[str] | None, + mock_platform: str | None, + expected_version: str | None, + raises: bool, + exc_msg_regex: str | None, +) -> None: + """Test version parsing and validation.""" + + class MockTmuxOutput: + stdout = mock_stdout + stderr = mock_stderr + + def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> MockTmuxOutput: + return MockTmuxOutput() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) + if mock_platform is not None: + monkeypatch.setattr(sys, "platform", mock_platform) + + if raises: + with pytest.raises(exc.LibTmuxException) as exc_info: + get_version() + if exc_msg_regex is not None: + exc_info.match(exc_msg_regex) + else: + assert get_version() == expected_version + if test_id in {"openbsd_version", "master_version"}: + assert not has_minimum_version(raises=False) + else: + assert has_minimum_version() + assert has_gte_version(TMUX_MIN_VERSION) + assert has_gt_version(TMUX_MAX_VERSION) + + +class VersionValidationFixture(t.NamedTuple): + """Test fixture for version validation tests.""" + + test_id: str + mock_min_version: str | None + mock_version: str | None + check_type: t.Literal["min_version", "has_version", "type_check"] + raises: bool + exc_msg_regex: str | None + + +VERSION_VALIDATION_FIXTURES: list[VersionValidationFixture] = [ + # Letter version tests + VersionValidationFixture( + test_id="accepts_letter_in_min_version_1_9a", + mock_min_version="1.9a", + mock_version=None, + check_type="min_version", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_letter_in_min_version_1_8a", + mock_min_version="1.8a", + mock_version=None, + check_type="min_version", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_version_1_8", + mock_min_version=None, + mock_version="1.8", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_version_1_8a", + mock_min_version=None, + mock_version="1.8a", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_version_1_9a", + mock_min_version=None, + mock_version="1.9a", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + # Version too low tests + VersionValidationFixture( + test_id="rejects_version_1_7", + mock_min_version=None, + mock_version="1.7", + check_type="min_version", + raises=True, + exc_msg_regex=r"libtmux only supports", + ), + # Additional test cases for version validation + VersionValidationFixture( + test_id="accepts_master_version", + mock_min_version=None, + mock_version="master", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_next_version", + mock_min_version=None, + mock_version="next-3.4", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_openbsd_version", + mock_min_version=None, + mock_version="3.3-openbsd", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_dev_version", + mock_min_version=None, + mock_version="3.3-dev", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_rc_version", + mock_min_version=None, + mock_version="3.3-rc2", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), +] + + +@pytest.mark.parametrize( + list(VersionValidationFixture._fields), + VERSION_VALIDATION_FIXTURES, + ids=[test.test_id for test in VERSION_VALIDATION_FIXTURES], +) +def test_version_validation( + monkeypatch: pytest.MonkeyPatch, + test_id: str, + mock_min_version: str | None, + mock_version: str | None, + check_type: t.Literal["min_version", "has_version", "type_check"], + raises: bool, + exc_msg_regex: str | None, +) -> None: + """Test version validation.""" + if mock_min_version is not None: + monkeypatch.setattr(legacy_version, "TMUX_MIN_VERSION", mock_min_version) + + if mock_version is not None: + + def mock_get_version(tmux_bin: str | None = None) -> LooseVersion: + return LooseVersion(mock_version) + + monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) + if check_type == "min_version": + monkeypatch.setattr( + legacy_version, "_query_version", lambda **kwargs: mock_version + ) + + if check_type == "min_version": + if raises: + with pytest.raises(exc.LibTmuxException) as exc_info: + has_minimum_version() + if exc_msg_regex is not None: + exc_info.match(exc_msg_regex) + else: + assert has_minimum_version() + elif check_type == "type_check": + assert mock_version is not None # For type checker + assert isinstance(has_version(mock_version), bool) diff --git a/tests/test_common.py b/tests/test_common.py index 3e394d6b8e..8e688b3428 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -13,18 +13,9 @@ import libtmux from libtmux import exc from libtmux._compat import LooseVersion -from libtmux._internal import version as legacy_version from libtmux.common import ( - TMUX_MAX_VERSION, - TMUX_MIN_VERSION, get_libtmux_version, get_version, - has_gt_version, - has_gte_version, - has_lt_version, - has_lte_version, - has_minimum_version, - has_version, session_check_name, tmux_cmd, ) @@ -36,135 +27,6 @@ version_regex = re.compile(r"([0-9]\.[0-9])|(master)") -def test_has_version() -> None: - """Test has_version().""" - assert has_version(str(get_version())) - - -def test_get_version_is_memoized_for_same_tmux_bin( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Two calls with the same tmux_bin fork tmux -V once. - - Validates the @functools.cache contract: identical-arg calls hit the - cache after the first miss. - """ - call_count = {"n": 0} - - class _MockProc: - stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] - stderr: t.ClassVar[list[str]] = [] - - def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: - call_count["n"] += 1 - return _MockProc() - - monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) - get_version.cache_clear() - - v1 = get_version() - v2 = get_version() - - assert v1 == v2 - assert call_count["n"] == 1 - - -def test_get_version_cache_keyed_by_tmux_bin( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Different tmux_bin args cache independently; same arg revisits hit.""" - call_count = {"n": 0} - versions = {"/path/a/tmux": "tmux 3.4", "/path/b/tmux": "tmux 3.6a"} - - class _MockProc: - def __init__(self, line: str) -> None: - self.stdout = [line] - self.stderr: list[str] = [] - - def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: - call_count["n"] += 1 - return _MockProc(versions[kwargs["tmux_bin"]]) - - monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) - get_version.cache_clear() - - a1 = get_version(tmux_bin="/path/a/tmux") - b1 = get_version(tmux_bin="/path/b/tmux") - a2 = get_version(tmux_bin="/path/a/tmux") - - assert a1 != b1 - assert a1 == a2 - assert call_count["n"] == 2 # /a once, /b once, /a hits cache - - -def test_get_version_cache_clear_invalidates( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """cache_clear() forces a fresh subprocess on the next call.""" - call_count = {"n": 0} - - class _MockProc: - stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] - stderr: t.ClassVar[list[str]] = [] - - def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: - call_count["n"] += 1 - return _MockProc() - - monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) - get_version.cache_clear() - - get_version() - get_version() - assert call_count["n"] == 1 - - get_version.cache_clear() - get_version() - assert call_count["n"] == 2 - - -def test_get_version_binary_swap_requires_explicit_cache_clear( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Documents the sticky-cache trap when tmux_bin=None and PATH changes. - - Simulates a user upgrading tmux mid-process: two consecutive - ``get_version()`` calls with ``tmux_bin=None`` see different - underlying binaries, but the cache pins the first answer. The - escape hatch is ``get_version.cache_clear()`` — this test asserts - the trap is real and the escape hatch works. - """ - versions = ["tmux 3.2a", "tmux 3.6a"] - call_count = {"n": 0} - - class _MockProc: - def __init__(self, line: str) -> None: - self.stdout = [line] - self.stderr: list[str] = [] - - def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: - proc = _MockProc(versions[call_count["n"]]) - call_count["n"] += 1 - return proc - - monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) - get_version.cache_clear() - - first = get_version() - assert str(first) == "3.2" - - # "Binary swap" — PATH changed, but cache is sticky. - second = get_version() - assert str(second) == "3.2" # Stale: still the cached 3.2a. - assert call_count["n"] == 1 # No fresh subprocess. - - # Escape hatch. - get_version.cache_clear() - third = get_version() - assert str(third) == "3.6" # Fresh lookup. - assert call_count["n"] == 2 - - def test_tmux_cmd_raises_on_not_found(monkeypatch: pytest.MonkeyPatch) -> None: """Verify raises if tmux command not found.""" monkeypatch.setenv("PATH", "") @@ -256,395 +118,6 @@ def test_get_libtmux_version() -> None: assert LooseVersion(__version__) == version -class VersionComparisonFixture(t.NamedTuple): - """Test fixture for version comparison functions.""" - - test_id: str - version: str - comparison_type: t.Literal["gt", "gte", "lt", "lte"] - expected: bool - - -VERSION_COMPARISON_FIXTURES: list[VersionComparisonFixture] = [ - # Greater than tests - VersionComparisonFixture( - test_id="gt_older_version", - version="1.6", - comparison_type="gt", - expected=True, - ), - VersionComparisonFixture( - test_id="gt_older_version_with_letter", - version="1.6b", - comparison_type="gt", - expected=True, - ), - VersionComparisonFixture( - test_id="gt_newer_version", - version="4.0", - comparison_type="gt", - expected=False, - ), - VersionComparisonFixture( - test_id="gt_newer_version_with_letter", - version="4.0b", - comparison_type="gt", - expected=False, - ), - # Greater than or equal tests - VersionComparisonFixture( - test_id="gte_older_version", - version="1.6", - comparison_type="gte", - expected=True, - ), - VersionComparisonFixture( - test_id="gte_older_version_with_letter", - version="1.6b", - comparison_type="gte", - expected=True, - ), - VersionComparisonFixture( - test_id="gte_current_version", - version=str(get_version()), - comparison_type="gte", - expected=True, - ), - VersionComparisonFixture( - test_id="gte_newer_version", - version="4.0", - comparison_type="gte", - expected=False, - ), - VersionComparisonFixture( - test_id="gte_newer_version_with_letter", - version="4.0b", - comparison_type="gte", - expected=False, - ), - # Less than tests - VersionComparisonFixture( - test_id="lt_newer_version_with_letter", - version="4.0a", - comparison_type="lt", - expected=True, - ), - VersionComparisonFixture( - test_id="lt_newer_version", - version="4.0", - comparison_type="lt", - expected=True, - ), - VersionComparisonFixture( - test_id="lt_older_version", - version="1.7", - comparison_type="lt", - expected=False, - ), - VersionComparisonFixture( - test_id="lt_current_version", - version=str(get_version()), - comparison_type="lt", - expected=False, - ), - # Less than or equal tests - VersionComparisonFixture( - test_id="lte_newer_version_with_letter", - version="4.0a", - comparison_type="lte", - expected=True, - ), - VersionComparisonFixture( - test_id="lte_newer_version", - version="4.0", - comparison_type="lte", - expected=True, - ), - VersionComparisonFixture( - test_id="lte_current_version", - version=str(get_version()), - comparison_type="lte", - expected=True, - ), - VersionComparisonFixture( - test_id="lte_older_version", - version="1.7", - comparison_type="lte", - expected=False, - ), - VersionComparisonFixture( - test_id="lte_older_version_with_letter", - version="1.7b", - comparison_type="lte", - expected=False, - ), -] - - -@pytest.mark.parametrize( - list(VersionComparisonFixture._fields), - VERSION_COMPARISON_FIXTURES, - ids=[test.test_id for test in VERSION_COMPARISON_FIXTURES], -) -def test_version_comparison( - test_id: str, - version: str, - comparison_type: t.Literal["gt", "gte", "lt", "lte"], - expected: bool, -) -> None: - """Test version comparison functions.""" - comparison_funcs = { - "gt": has_gt_version, - "gte": has_gte_version, - "lt": has_lt_version, - "lte": has_lte_version, - } - assert comparison_funcs[comparison_type](version) == expected - - -class VersionParsingFixture(t.NamedTuple): - """Test fixture for version parsing and validation.""" - - test_id: str - mock_stdout: list[str] | None - mock_stderr: list[str] | None - mock_platform: str | None - expected_version: str | None - raises: bool - exc_msg_regex: str | None - - -VERSION_PARSING_FIXTURES: list[VersionParsingFixture] = [ - VersionParsingFixture( - test_id="master_version", - mock_stdout=["tmux master"], - mock_stderr=None, - mock_platform=None, - expected_version=f"{TMUX_MAX_VERSION}-master", - raises=False, - exc_msg_regex=None, - ), - VersionParsingFixture( - test_id="next_version", - mock_stdout=["tmux next-3.8"], - mock_stderr=None, - mock_platform=None, - expected_version="3.8", - raises=False, - exc_msg_regex=None, - ), - VersionParsingFixture( - test_id="openbsd_version", - mock_stdout=None, - mock_stderr=["tmux: unknown option -- V"], - mock_platform="openbsd 5.2", - expected_version=f"{TMUX_MAX_VERSION}-openbsd", - raises=False, - exc_msg_regex=None, - ), - VersionParsingFixture( - test_id="too_low_version", - mock_stdout=None, - mock_stderr=["tmux: unknown option -- V"], - mock_platform=None, - expected_version=None, - raises=True, - exc_msg_regex="does not meet the minimum tmux version requirement", - ), -] - - -@pytest.mark.parametrize( - list(VersionParsingFixture._fields), - VERSION_PARSING_FIXTURES, - ids=[test.test_id for test in VERSION_PARSING_FIXTURES], -) -def test_version_parsing( - monkeypatch: pytest.MonkeyPatch, - test_id: str, - mock_stdout: list[str] | None, - mock_stderr: list[str] | None, - mock_platform: str | None, - expected_version: str | None, - raises: bool, - exc_msg_regex: str | None, -) -> None: - """Test version parsing and validation.""" - - class MockTmuxOutput: - stdout = mock_stdout - stderr = mock_stderr - - def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> MockTmuxOutput: - return MockTmuxOutput() - - monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) - if mock_platform is not None: - monkeypatch.setattr(sys, "platform", mock_platform) - - if raises: - with pytest.raises(exc.LibTmuxException) as exc_info: - get_version() - if exc_msg_regex is not None: - exc_info.match(exc_msg_regex) - else: - assert get_version() == expected_version - if test_id in {"openbsd_version", "master_version"}: - assert not has_minimum_version(raises=False) - else: - assert has_minimum_version() - assert has_gte_version(TMUX_MIN_VERSION) - assert has_gt_version(TMUX_MAX_VERSION) - - -class VersionValidationFixture(t.NamedTuple): - """Test fixture for version validation tests.""" - - test_id: str - mock_min_version: str | None - mock_version: str | None - check_type: t.Literal["min_version", "has_version", "type_check"] - raises: bool - exc_msg_regex: str | None - - -VERSION_VALIDATION_FIXTURES: list[VersionValidationFixture] = [ - # Letter version tests - VersionValidationFixture( - test_id="accepts_letter_in_min_version_1_9a", - mock_min_version="1.9a", - mock_version=None, - check_type="min_version", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_letter_in_min_version_1_8a", - mock_min_version="1.8a", - mock_version=None, - check_type="min_version", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_version_1_8", - mock_min_version=None, - mock_version="1.8", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_version_1_8a", - mock_min_version=None, - mock_version="1.8a", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_version_1_9a", - mock_min_version=None, - mock_version="1.9a", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - # Version too low tests - VersionValidationFixture( - test_id="rejects_version_1_7", - mock_min_version=None, - mock_version="1.7", - check_type="min_version", - raises=True, - exc_msg_regex=r"libtmux only supports", - ), - # Additional test cases for version validation - VersionValidationFixture( - test_id="accepts_master_version", - mock_min_version=None, - mock_version="master", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_next_version", - mock_min_version=None, - mock_version="next-3.4", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_openbsd_version", - mock_min_version=None, - mock_version="3.3-openbsd", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_dev_version", - mock_min_version=None, - mock_version="3.3-dev", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_rc_version", - mock_min_version=None, - mock_version="3.3-rc2", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), -] - - -@pytest.mark.parametrize( - list(VersionValidationFixture._fields), - VERSION_VALIDATION_FIXTURES, - ids=[test.test_id for test in VERSION_VALIDATION_FIXTURES], -) -def test_version_validation( - monkeypatch: pytest.MonkeyPatch, - test_id: str, - mock_min_version: str | None, - mock_version: str | None, - check_type: t.Literal["min_version", "has_version", "type_check"], - raises: bool, - exc_msg_regex: str | None, -) -> None: - """Test version validation.""" - if mock_min_version is not None: - monkeypatch.setattr(legacy_version, "TMUX_MIN_VERSION", mock_min_version) - - if mock_version is not None: - - def mock_get_version(tmux_bin: str | None = None) -> LooseVersion: - return LooseVersion(mock_version) - - monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) - if check_type == "min_version": - monkeypatch.setattr( - legacy_version, "_query_version", lambda **kwargs: mock_version - ) - - if check_type == "min_version": - if raises: - with pytest.raises(exc.LibTmuxException) as exc_info: - has_minimum_version() - if exc_msg_regex is not None: - exc_info.match(exc_msg_regex) - else: - assert has_minimum_version() - elif check_type == "type_check": - assert mock_version is not None # For type checker - assert isinstance(has_version(mock_version), bool) - - def test_tmux_cmd_pre_execution_logging( caplog: pytest.LogCaptureFixture, server: Server, From 420e8f2c378bd4b88e7f795344a1360df533c593 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:24:52 -0500 Subject: [PATCH 35/60] tests(feat[legacy_api]): Assert the executable version helpers warn why: The helpers now read like deprecated API but nothing proved they warn, point at the caller, or name the replacement, so dropping a warning would go unnoticed. what: - Parametrize over all eight public helpers: each emits a DeprecationWarning that names the client executable, its own libtmux.common name and libtmux._internal.support, and points at the caller's file - Check TMUX_MIN_VERSION and TMUX_MAX_VERSION warn through libtmux.common and still return their values - Check an unknown attribute still raises AttributeError - Check get_version.cache_clear and get_version_str.cache_clear survive --- tests/legacy_api/test_deprecation_warnings.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/legacy_api/test_deprecation_warnings.py diff --git a/tests/legacy_api/test_deprecation_warnings.py b/tests/legacy_api/test_deprecation_warnings.py new file mode 100644 index 0000000000..4152e88961 --- /dev/null +++ b/tests/legacy_api/test_deprecation_warnings.py @@ -0,0 +1,60 @@ +"""The executable version helpers warn, and say what replaces them.""" + +from __future__ import annotations + +import typing as t + +import pytest + +import libtmux.common + +HELPERS: list[tuple[str, tuple[t.Any, ...]]] = [ + ("get_version", ()), + ("get_version_str", ()), + ("has_version", ("3.3a",)), + ("has_gt_version", ("3.3a",)), + ("has_gte_version", ("3.3a",)), + ("has_lte_version", ("3.3a",)), + ("has_lt_version", ("3.3a",)), + ("has_minimum_version", ()), +] + + +@pytest.mark.parametrize( + ("name", "args"), + HELPERS, + ids=[name for name, _ in HELPERS], +) +def test_helper_warns_and_names_replacement(name: str, args: tuple[t.Any, ...]) -> None: + """Each helper warns about the client executable and names the support API.""" + helper = getattr(libtmux.common, name) + with pytest.warns(DeprecationWarning, match="client executable") as record: + helper(*args) + + message = str(record[0].message) + assert f"libtmux.common.{name}()" in message + assert "libtmux._internal.support" in message + assert "server_version(server)" in message + assert record[0].filename == __file__, "warning points at the caller" + + +@pytest.mark.parametrize("name", ["TMUX_MIN_VERSION", "TMUX_MAX_VERSION"]) +def test_constants_warn_and_keep_their_values(name: str) -> None: + """The legacy constants stay importable through libtmux.common.""" + with pytest.warns(DeprecationWarning, match=name) as record: + value = getattr(libtmux.common, name) + + assert isinstance(value, str) + assert record[0].filename == __file__ + + +def test_unknown_attribute_still_raises() -> None: + """The module __getattr__ only serves the two legacy constants.""" + with pytest.raises(AttributeError, match="no attribute 'TMUX_NOPE'"): + libtmux.common.TMUX_NOPE # noqa: B018 + + +def test_cache_clear_is_still_offered() -> None: + """Callers that flush the executable caches keep a hook.""" + libtmux.common.get_version.cache_clear() + libtmux.common.get_version_str.cache_clear() From c39db828772a0cff8a7207a16c4fddaa9174c2aa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:25:24 -0500 Subject: [PATCH 36/60] Docs(refactor[doctest]): Gate doctests on the daemon's release why: The doctests called has_gte_version, which reads the client executable and now warns. They run against the daemon the server fixture talks to, so that is the release to gate on. what: - Replace has_gte_version("X") with is_at_least(server_version(server), "X") in the Pane, Server and Window doctests, importing both from libtmux._internal.support --- src/libtmux/pane.py | 4 ++-- src/libtmux/server.py | 20 ++++++++++---------- src/libtmux/window.py | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 0554081e67..38b2d166de 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1512,8 +1512,8 @@ def new_pane( Examples -------- - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.7"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.7"): ... floating = pane.new_pane(width=40, height=10, shell="sleep 5") ... is_floating = floating.pane_floating_flag ... else: diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c8060dee9d..3252768fda 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -909,8 +909,8 @@ def server_access( Examples -------- - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.3"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.3"): ... result = server.server_access(list_access=True) ... assert isinstance(result, list) """ @@ -1166,8 +1166,8 @@ def confirm_before( works on 3.3+ but the ``send-keys -K -c `` round-trip used here is unreliable on 3.3a: - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.4"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.4"): ... with control_mode() as ctl: ... server.confirm_before( ... 'set -g @cf_test yes', @@ -1277,8 +1277,8 @@ def command_prompt( on 3.3+ but the ``send-keys -K -c `` round-trip used here is unreliable on 3.3a: - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.4"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.4"): ... with control_mode() as ctl: ... server.command_prompt( ... "set -g @cp_test '%1'", @@ -1770,8 +1770,8 @@ def show_prompt_history( Examples -------- - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.3"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.3"): ... result = server.show_prompt_history() ... else: ... result = [] @@ -1804,8 +1804,8 @@ def clear_prompt_history( Examples -------- - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.3"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.3"): ... server.clear_prompt_history() """ tmux_args: tuple[str, ...] = () diff --git a/src/libtmux/window.py b/src/libtmux/window.py index d3c00bccba..93131ea504 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -690,8 +690,8 @@ def new_pane( Examples -------- - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.7"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.7"): ... floating = window.new_pane(width=40, height=10, shell="sleep 5") ... is_floating = floating.pane_floating_flag ... else: From e7b9b5f4060f5827cb7c14fbde323c1fffe6c185 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:25:42 -0500 Subject: [PATCH 37/60] Docs(refactor[topics]): Gate the topic examples on the daemon's release why: The floating-pane examples called has_gte_version, which reads the client executable and now warns, and the format-token page described the legacy helpers as if they were the way to read a version. what: - Use is_at_least(server_version(server), "X") from libtmux._internal.support in the floating-pane examples - Say on the format-token page that get_version and get_version_str are deprecated and point at server_version for the daemon's release --- docs/topics/floating_panes.md | 18 +++++++++--------- docs/topics/format-tokens.md | 6 ++++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/topics/floating_panes.md b/docs/topics/floating_panes.md index 01f1c90871..c82af21ca6 100644 --- a/docs/topics/floating_panes.md +++ b/docs/topics/floating_panes.md @@ -32,9 +32,9 @@ floating by reading its {attr}`pane_floating_flag >> from libtmux.common import has_gte_version +>>> from libtmux._internal.support import is_at_least, server_version ->>> if has_gte_version("3.7"): +>>> if is_at_least(server_version(server), "3.7"): ... floating = window.new_pane(width=20, height=5, shell="sleep 30") ... is_floating = floating.pane_floating_flag ... else: @@ -52,14 +52,14 @@ window (tmux's `-X` / `-Y`). tmux reports the placement back through the {attr}`pane_y ` fields: ```python ->>> from libtmux.common import has_gte_version +>>> from libtmux._internal.support import is_at_least, server_version ->>> if has_gte_version("3.7"): +>>> if is_at_least(server_version(server), "3.7"): ... placed = window.new_pane(width=20, height=5, x=2, y=1, shell="sleep 30") ... position = (placed.pane_x, placed.pane_y) ... else: ... position = ("2", "1") ->>> border = 1 if has_gte_version("3.8") else 0 +>>> border = 1 if is_at_least(server_version(server), "3.8") else 0 >>> position == (str(2 + border), str(1 + border)) True ``` @@ -92,9 +92,9 @@ that stays on screen and waits for you after the command finishes instead of vanishing: ```python ->>> from libtmux.common import has_gte_version +>>> from libtmux._internal.support import is_at_least, server_version ->>> if has_gte_version("3.7"): +>>> if is_at_least(server_version(server), "3.7"): ... held = window.new_pane(width=20, height=5, shell="sleep 30", keep=True) ... remain = held.cmd("show-options", "-p", "-v", "remain-on-exit").stdout ... else: @@ -112,9 +112,9 @@ hold a pane — including filtering a window's {attr}`~libtmux.Window.panes` dow to just the floats: ```python ->>> from libtmux.common import has_gte_version +>>> from libtmux._internal.support import is_at_least, server_version ->>> if has_gte_version("3.7"): +>>> if is_at_least(server_version(server), "3.7"): ... _ = window.new_pane(width=20, height=5, shell="sleep 30") ... floating = [p for p in window.panes if p.pane_floating_flag == "1"] ... found = len(floating) >= 1 diff --git a/docs/topics/format-tokens.md b/docs/topics/format-tokens.md index 670b523619..183ea9c846 100644 --- a/docs/topics/format-tokens.md +++ b/docs/topics/format-tokens.md @@ -120,8 +120,10 @@ startup, they use the selected executable's report. This keeps field selection correct when the executable and daemon have different versions. {func}`libtmux.common.get_version` and -{func}`libtmux.common.get_version_str` describe the executable and cache their -results independently. Their caches do not authorize daemon operations. +{func}`libtmux.common.get_version_str` describe the executable, cache their +results independently and are deprecated. Their caches do not authorize daemon +operations; use `server_version(server)` from `libtmux._internal.support` to +read the daemon's release. See {doc}`/project/compatibility` for the tmux 3.3a core minimum and the version-check rules. From 8a205076fecea17c57944447666df61b8bfcd765 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:27:26 -0500 Subject: [PATCH 38/60] tests(refactor[tmux_client]): Add a warning-free executable version gate why: The suite gates tests on the release of the tmux it launches, which is the executable. The deprecated libtmux.common helpers warn on every call, so the suite needs its own small gate to keep libtmux's tests free of deprecation warnings. what: - Add tests/tmux_client.py with client_gte, client_lte and clear_caches, built on the private helpers in libtmux._internal.version so the letter-stripping comparison stays what the tests were written against - Flush the caches through clear_caches in the autouse conftest fixture - Read the daemon's release in the non-UTF-8 locale test through Server._get_version - Make the newer-client-executable test fake the executable's release where the helper now lives --- tests/conftest.py | 20 +++++++++----------- tests/test_common.py | 3 +-- tests/test_versions.py | 6 +++++- tests/tmux_client.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 tests/tmux_client.py diff --git a/tests/conftest.py b/tests/conftest.py index f250744c5c..d0ea804487 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,8 @@ import pytest -from libtmux.common import get_version, get_version_str from libtmux.test.retry import retry_until +from tests.tmux_client import clear_caches if t.TYPE_CHECKING: from libtmux.server import Server @@ -23,17 +23,15 @@ @pytest.fixture(autouse=True) def _clear_get_version_cache() -> None: - """Flush the tmux-version caches before each test. - - Several tests in test_common.py and legacy_api/test_common.py - monkey-patch libtmux.common.tmux_cmd then call get_version() to - assert parsed-version behavior. With memoization, a prior test's - cached result would mask the mock — this fixture guarantees a fresh - subprocess lookup per test. get_version and get_version_str cache - independently, so flush both. + """Flush the executable's cached tmux release before each test. + + The tests under legacy_api monkey-patch libtmux.common.tmux_cmd then + call the deprecated get_version() to assert parsed-version behavior. + With memoization, a prior test's cached result would mask the mock -- + this fixture guarantees a fresh subprocess lookup per test. get_version + and get_version_str cache independently, so flush both. """ - get_version.cache_clear() - get_version_str.cache_clear() + clear_caches() @pytest.fixture diff --git a/tests/test_common.py b/tests/test_common.py index 8e688b3428..fb22aee2b6 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -15,7 +15,6 @@ from libtmux._compat import LooseVersion from libtmux.common import ( get_libtmux_version, - get_version, session_check_name, tmux_cmd, ) @@ -222,7 +221,7 @@ def test_tmux_cmd_format_separator_survives_non_utf8_locale( server = session.server - tmux_version = str(get_version(tmux_bin=server.tmux_bin)) + tmux_version = server._get_version() _fields, fmt_str = get_output_format("list-sessions", tmux_version) old_lc_ctype = locale.setlocale(locale.LC_CTYPE) diff --git a/tests/test_versions.py b/tests/test_versions.py index e8ef6d3e67..d85106e8ef 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -15,6 +15,7 @@ import pytest from libtmux import Server, Window, exc +from libtmux._compat import LooseVersion from libtmux._internal import support from libtmux._internal.query_list import QueryList from libtmux._internal.support import versions @@ -230,7 +231,10 @@ def test_feature_flags_follow_the_daemon_not_the_client( ) -> None: """A newer client executable cannot select flags an older daemon rejects.""" monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) - monkeypatch.setattr("libtmux.common.has_gte_version", lambda *args, **kwargs: True) + monkeypatch.setattr( + "libtmux._internal.version._get_version", + lambda *args, **kwargs: LooseVersion("9.9"), + ) server = Server(tmux_bin="/test/newer-client") assert server._has_gte_version("3.3a") assert not server._has_gte_version("3.4") diff --git a/tests/tmux_client.py b/tests/tmux_client.py new file mode 100644 index 0000000000..0ec803a76d --- /dev/null +++ b/tests/tmux_client.py @@ -0,0 +1,29 @@ +"""Gate tests on the tmux client executable's release. + +These read the executable the suite launches (``LIBTMUX_TMUX`` or ``PATH``), +with letter suffixes stripped, which is what the daemons the fixtures start +run. libtmux itself checks the connected daemon through +:mod:`libtmux._internal.support`; the deprecated ``libtmux.common`` helpers +are only exercised under ``tests/legacy_api``. +""" + +from __future__ import annotations + +from libtmux._compat import LooseVersion +from libtmux._internal import version as _legacy + + +def client_gte(minimum: str) -> bool: + """Return True if the client executable is at least *minimum*.""" + return _legacy._get_version() >= LooseVersion(minimum) + + +def client_lte(maximum: str) -> bool: + """Return True if the client executable is at most *maximum*.""" + return _legacy._get_version() <= LooseVersion(maximum) + + +def clear_caches() -> None: + """Forget the executable's cached release, for tests that fake ``tmux -V``.""" + _legacy._get_version.cache_clear() + _legacy._get_version_str.cache_clear() From 5c3d17523322c2fb01ea23c67944505e6e638e03 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:27:26 -0500 Subject: [PATCH 39/60] tests(refactor[tmux_client]): Gate tests on client_gte and client_lte why: These tests called has_gte_version and has_lte_version from libtmux.common, which now warn that they read the client executable. what: - Replace the helper calls in the hooks, options, pane, capture-pane, server, session and window tests with client_gte and client_lte from tests/tmux_client.py, with the same version comparisons - Reword the window-name test docstring that cited get_version --- tests/test_hooks.py | 18 ++++---- tests/test_options.py | 58 ++++++++++++-------------- tests/test_pane.py | 34 +++++++-------- tests/test_pane_capture_pane.py | 6 +-- tests/test_server.py | 74 ++++++++++++++++----------------- tests/test_session.py | 4 +- tests/test_window.py | 19 ++++----- 7 files changed, 104 insertions(+), 109 deletions(-) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 5bb062c393..63e77ee104 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -8,7 +8,7 @@ from libtmux._internal.constants import Hooks from libtmux._internal.sparse_array import SparseArray -from libtmux.common import has_gte_version, has_lte_version +from tests.tmux_client import client_gte, client_lte if t.TYPE_CHECKING: from libtmux.server import Server @@ -106,7 +106,7 @@ def test_hooks_raw_cmd( ) assert session.cmd("show-hooks", "-s", "session-renamed[0]").stdout == [] - if has_gte_version("3.2"): + if client_gte("3.2"): # # Window # @@ -458,9 +458,9 @@ def test_hook_set_show_unset_cycle(server: Server, test_case: HookTestCase) -> N 2. Shown and verified 3. Unset cleanly """ - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") - if test_case.max_version and not has_lte_version(test_case.max_version): + if test_case.max_version and not client_lte(test_case.max_version): pytest.skip(f"Removed after tmux {test_case.max_version}") session = server.new_session(session_name="test_hook_cycle") @@ -512,7 +512,7 @@ def test_new_hooks_version_gated(server: Server, test_case: HookTestCase) -> Non These hooks are version-gated and will skip on older tmux versions. """ - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") session = server.new_session(session_name="test_new_hooks") @@ -847,7 +847,7 @@ def test_show_hooks_stores_string_values( test_case: ShowHooksTestCase, ) -> None: """Test that show_hooks() correctly stores string hook values.""" - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux >= {test_case.min_version}") session = server.new_session(session_name="test_show_hooks") @@ -870,7 +870,7 @@ def test_show_hooks_stores_string_values( def test_run_hook_basic(server: Server) -> None: """Test run_hook() method exists and can be called without error.""" - if not has_gte_version("3.2"): + if not client_gte("3.2"): pytest.skip("Requires tmux >= 3.2") session = server.new_session(session_name="test_run_hook") @@ -935,7 +935,7 @@ def test_set_hook_flag_combinations( test_case: SetHookFlagTestCase, ) -> None: """Test set_hook with various flag combinations.""" - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") session = server.new_session(session_name="test_flags") @@ -1020,7 +1020,7 @@ def test_hook_scope_handling( test_case: HookScopeTestCase, ) -> None: """Test hooks at different scopes (session, window, pane).""" - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") session = server.new_session(session_name="test_scope") diff --git a/tests/test_options.py b/tests/test_options.py index bc72061806..0c9835f942 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -17,10 +17,10 @@ WindowOptions, ) from libtmux._internal.sparse_array import SparseArray -from libtmux.common import has_gte_version from libtmux.constants import OptionScope from libtmux.options import TerminalOverrides, convert_values, explode_arrays from libtmux.pane import Pane +from tests.tmux_client import client_gte if t.TYPE_CHECKING: from typing_extensions import LiteralString @@ -62,12 +62,12 @@ def test_options(server: Server) -> None: obj._show_options(include_hooks=True) with pytest.raises(exc.OptionError): obj._show_option("test") - if has_gte_version("3.0"): + if client_gte("3.0"): obj._show_option("test", ignore_errors=True) with pytest.raises(exc.OptionError): obj.set_option("test", "invalid") if isinstance(obj, Pane): - if has_gte_version("3.0"): + if client_gte("3.0"): obj.set_option("test", "invalid", ignore_errors=True) else: with pytest.raises(exc.OptionError): @@ -84,10 +84,10 @@ def test_options_server(server: Server) -> None: server.set_option("buffer-limit", 100) assert server._show_option("buffer-limit") == 100 - if has_gte_version("3.0"): + if client_gte("3.0"): server.set_option("buffer-limit", 150, scope=OptionScope.Pane) - if has_gte_version("3.0"): + if client_gte("3.0"): # set-option and show-options w/ Pane (-p) does not exist until 3.0+ server.set_option( "buffer-limit", @@ -97,23 +97,23 @@ def test_options_server(server: Server) -> None: ) server.set_option("buffer-limit", 150, scope=OptionScope.Server) - if has_gte_version("3.0"): + if client_gte("3.0"): assert session._show_option("buffer-limit") == 150 # Server option in deeper objects - if has_gte_version("3.0"): + if client_gte("3.0"): pane.set_option("buffer-limit", 100) else: with pytest.raises(exc.OptionError): pane.set_option("buffer-limit", 100) - if has_gte_version("3.0"): + if client_gte("3.0"): assert pane._show_option("buffer-limit") == 100 assert window._show_option("buffer-limit") == 100 assert server._show_option("buffer-limit") == 100 server_options = ServerOptions(**server._show_options(scope=OptionScope.Server)) - if has_gte_version("3.0"): + if client_gte("3.0"): assert server._show_option("buffer-limit") == 100 assert server_options.buffer_limit == 100 @@ -217,11 +217,7 @@ def test_options_grid(server: Server) -> None: f"Expect {field.name} to be {expected} when " + f"scope={scope}, global_={global_}" ) - if ( - has_gte_version("3.0") - and scope == OptionScope.Window - and global_ - ): + if client_gte("3.0") and scope == OptionScope.Window and global_: assert objglobal__options.pane_base_index == 0 @@ -558,7 +554,7 @@ def test_stable_baseline_options_and_hooks(server: Server) -> None: "splitp": "split-window", } - if has_gte_version("3.2"): + if client_gte("3.2"): terminal_features = server.show_option("terminal-features") assert isinstance(terminal_features, dict) assert "screen*" in terminal_features @@ -572,17 +568,17 @@ def test_stable_baseline_options_and_hooks(server: Server) -> None: "title", ] # Additional features in tmux 3.4+ - if has_gte_version("3.4"): + if client_gte("3.4"): assert "rxvt*" in terminal_features assert terminal_features["rxvt*"] == ["ignorefkeys"] # Terminal overrides differ by version # tmux 3.0a-3.1b: has default values (xterm*, screen*) # tmux 3.2+: defaults removed, returns None unless explicitly set - if has_gte_version("3.2"): + if client_gte("3.2"): terminal_overrides = server.show_option("terminal-overrides") assert isinstance(terminal_overrides, (dict, type(None))) - elif has_gte_version("3.0"): + elif client_gte("3.0"): # tmux 3.0a/3.1b have defaults like "xterm*:XT:Ms=...,screen*:XT" terminal_overrides = server.show_option("terminal-overrides") assert isinstance(terminal_overrides, dict) @@ -596,7 +592,7 @@ def test_stable_baseline_options_and_hooks(server: Server) -> None: assert server.show_option("user-keys") is None # status-format was added in tmux 2.9 - if has_gte_version("2.9"): + if client_gte("2.9"): status_format = server.show_option("status-format") assert isinstance(status_format, (dict, type(None))) else: @@ -604,7 +600,7 @@ def test_stable_baseline_options_and_hooks(server: Server) -> None: server.show_option("status-format") # update-environment was added in tmux 3.0 - if has_gte_version("3.0"): + if client_gte("3.0"): update_env = server.show_option("update-environment") assert isinstance(update_env, (list, type(None))) else: @@ -614,7 +610,7 @@ def test_stable_baseline_options_and_hooks(server: Server) -> None: # List variables: Pane (pane-colours added in tmux 3.3) pane = session.active_pane assert pane is not None - if has_gte_version("3.3"): + if client_gte("3.3"): assert pane.show_option("pane-colours") is None @@ -651,7 +647,7 @@ def test_complex_option_values(server: Server) -> None: assert session.show_option("@empty-option") == "" # Test option inheritance (only for tmux >= 3.0) - if has_gte_version("3.0"): + if client_gte("3.0"): # Capture the inherited value (from global scope). Need include_inherited # because without an explicit session-level value, show_option returns None inherited_val = session.show_option("status-style", include_inherited=True) @@ -677,7 +673,7 @@ def test_style_option_validation(server: Server) -> None: assert isinstance(style, str) assert "fg=red" in str(style) - if has_gte_version("3.2"): + if client_gte("3.2"): # tmux 3.2+: literal string output assert "bg=default" in str(style) assert "bold" in str(style) @@ -691,14 +687,14 @@ def test_style_option_validation(server: Server) -> None: session.set_option("status-style", "invalid-style") # Test complex style with multiple attributes (tmux >= 3.0) - if has_gte_version("3.0"): + if client_gte("3.0"): session.set_option( "status-style", "fg=colour240,bg=#525252,bold,underscore", ) style = session.show_option("status-style") assert isinstance(style, str) - if has_gte_version("3.2"): + if client_gte("3.2"): # tmux 3.2+: literal string output assert style == "fg=colour240,bg=#525252,bold,underscore" else: @@ -706,7 +702,7 @@ def test_style_option_validation(server: Server) -> None: assert style == "fg=colour240,bg=#525252,bright,underscore" # Test style with variables (format expansion added in tmux 3.2) - if has_gte_version("3.2"): + if client_gte("3.2"): session.set_option("status-style", "fg=#{?pane_in_mode,red,green}") style = session.show_option("status-style") assert isinstance(style, str) @@ -728,7 +724,7 @@ def test_option_error_handling(server: Server) -> None: session.set_option("aggressive-resize", "invalid") # Test ambiguous option (if supported by tmux version) - if has_gte_version("2.4"): + if client_gte("2.4"): with pytest.raises(exc.OptionError) as exc_info: # Use a partial name that could match multiple options session.show_option( @@ -742,7 +738,7 @@ def test_terminal_features_edge_cases( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test edge cases for terminal-features option.""" - if not has_gte_version("3.2"): + if not client_gte("3.2"): pytest.skip("terminal-features requires tmux >= 3.2") # Test empty features @@ -1295,10 +1291,10 @@ def _build_option_params() -> list[t.Any]: @pytest.mark.parametrize("test_case", _build_option_params()) def test_option_set_show_cycle(server: Server, test_case: OptionTestCase) -> None: """Test set/show cycle for each option type.""" - if not has_gte_version("3.0"): + if not client_gte("3.0"): pytest.skip("Option tests require tmux 3.0+") - if test_case.min_version and not has_gte_version(test_case.min_version): + if test_case.min_version and not client_gte(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") # Get appropriate target object @@ -1405,7 +1401,7 @@ def test_show_options_returns_expected_keys( test_case: ShowOptionsTestCase, ) -> None: """Test that show_options() returns dict with expected scope-specific keys.""" - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux >= {test_case.min_version}") session = server.new_session(session_name="test_show_options") diff --git a/tests/test_pane.py b/tests/test_pane.py index c1280317ff..2fa37f6f89 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -10,9 +10,9 @@ import pytest from libtmux import exc -from libtmux.common import has_gte_version from libtmux.constants import PaneDirection, ResizeAdjustmentDirection from libtmux.test.retry import retry_until +from tests.tmux_client import client_gte if t.TYPE_CHECKING: from libtmux._internal.types import StrPath @@ -518,7 +518,7 @@ def test_send_keys_flags( session: Session, ) -> None: """Test send_keys() with various flag combinations.""" - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") env = shutil.which("env") @@ -863,7 +863,7 @@ def test_display_message_flags( session: Session, ) -> None: """Test display_message() with various flag combinations.""" - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") pane = session.active_window.active_pane @@ -888,11 +888,11 @@ def test_display_message_warns_on_tmux_error(session: Session) -> None: def test_split_percentage(session: Session) -> None: """Test Pane.split() with percentage parameter.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte # tmux 3.4 has a regression in split-window -p; fixed in 3.5. # Per CHANGES FROM 3.4 TO 3.5: "Fix split-window -p." - if not has_gte_version("3.5"): + if not client_gte("3.5"): pytest.skip("split-window -p was broken in tmux 3.4 (fixed in 3.5)") window = session.new_window(window_name="test_split_pct") @@ -949,7 +949,7 @@ def test_copy_mode_source_pane(session: Session) -> None: def test_copy_mode_page_down(session: Session) -> None: """Test Pane.copy_mode(page_down=True) on tmux 3.5+.""" - if not has_gte_version("3.5"): + if not client_gte("3.5"): pytest.skip("page_down requires tmux 3.5+") pane = session.active_window.active_pane @@ -1101,7 +1101,7 @@ def test_display_popup_flags( verifying the marker file proves the popup invoked the command and the wrapper's flag-building branch was exercised. """ - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") marker = tmp_path / f"popup_{test_id}.marker" @@ -1641,7 +1641,7 @@ def test_capture_pane_3_7_flags( pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): assert isinstance(pane.capture_pane(**kwargs), list) else: with pytest.warns(UserWarning, match=match): @@ -1655,7 +1655,7 @@ def test_split_empty(session: Session) -> None: pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): new_pane = pane.split(empty=True) assert new_pane in window.panes assert len(window.panes) == 2 @@ -1700,7 +1700,7 @@ def test_split_3_7_flags( pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): new_pane = pane.split(**kwargs) assert new_pane in window.panes else: @@ -1748,7 +1748,7 @@ def test_split_preserves_empty_option_values( pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): new_pane = pane.split(**option_kwargs) assert new_pane in window.panes else: @@ -1770,7 +1770,7 @@ def test_paste_buffer_no_vis(session: Session) -> None: retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) session.server.set_buffer("raw_content", buffer_name="novis_test") - if has_gte_version("3.7"): + if client_gte("3.7"): pane.paste_buffer(buffer_name="novis_test", no_vis=True) retry_until( lambda: "raw_content" in "\n".join(pane.capture_pane()), @@ -1815,11 +1815,11 @@ def test_new_pane_floating(session: Session) -> None: pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): floating = pane.new_pane(width=80, height=15, x=5, y=3, shell="sleep 30") assert floating.pane_floating_flag == "1" # tmux 3.8 counts the border in -x/-y/-X/-Y, so the content is smaller. - border = 2 if has_gte_version("3.8") else 0 + border = 2 if client_gte("3.8") else 0 assert floating.pane_width == str(80 - border) assert floating.pane_height == str(15 - border) assert floating.pane_x == str(5 + border // 2) @@ -1836,7 +1836,7 @@ def test_new_pane_keep(session: Session) -> None: pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): floating = pane.new_pane(width=80, height=15, keep=True, shell="sleep 30") assert floating.pane_floating_flag == "1" remain = floating.cmd("show-options", "-p", "-v", "remain-on-exit").stdout @@ -1861,7 +1861,7 @@ def test_new_pane_preserves_empty_option_values( pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): floating = pane.new_pane( width=40, height=10, @@ -1880,7 +1880,7 @@ def test_new_pane_error_tags_subcommand(session: Session) -> None: pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): with pytest.raises(exc.LibTmuxException) as excinfo: pane.new_pane(target="%99999") assert excinfo.value.subcommand == "new-pane" diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 5f9e85e0a0..c815cf6e01 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -12,8 +12,8 @@ import pytest -from libtmux.common import has_gte_version from libtmux.test.retry import retry_until +from tests.tmux_client import client_gte if t.TYPE_CHECKING: from libtmux.session import Session @@ -329,7 +329,7 @@ def test_capture_pane_flags( pytest fixture providing tmux session. """ # Skip if tmux version too old - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") # Find env for predictable shell @@ -495,7 +495,7 @@ def test_capture_pane_flag_smoke( returns a list without raising. Output-pattern assertions live in CAPTURE_PANE_CASES for the flags whose behaviour is observable. """ - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") pane = session.active_window.active_pane diff --git a/tests/test_server.py b/tests/test_server.py index 92df5f18af..f36659eb1c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -566,9 +566,9 @@ def test_confirm_before( server: Server, ) -> None: """Test Server.confirm_before() with send-keys -K confirmation.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") with control_mode() as ctl: @@ -636,9 +636,9 @@ def test_command_prompt( server: Server, ) -> None: """Test Server.command_prompt() with send-keys -K input.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") with control_mode() as ctl: @@ -681,9 +681,9 @@ def test_command_prompt_extra_flags( constructed argv instead, matching the test_display_menu_flags monkeypatch pattern. """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") captured: list[tuple[str, ...]] = [] @@ -806,9 +806,9 @@ def test_display_menu_flags( the constructed argument vector instead — the only deviation from the test-suite's standard "use real tmux" pattern. """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") captured: list[tuple[str, ...]] = [] @@ -878,9 +878,9 @@ def test_refresh_client_request_clipboard( escape sequence, which has no observable effect under the headless test server, so verify the constructed argv instead. """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.7"): + if not client_gte("3.7"): pytest.skip("refresh-client -l clipboard query requires tmux 3.7+") captured: list[tuple[str, ...]] = [] @@ -915,9 +915,9 @@ def test_suspend_client( def test_server_access_list(server: Server) -> None: """Test Server.server_access() list mode.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip("server-access added in tmux 3.3") server.new_session(session_name="access_test") @@ -927,9 +927,9 @@ def test_server_access_list(server: Server) -> None: def test_server_access_read_only_write_mutex(server: Server) -> None: """``read_only`` and ``write`` are mutually exclusive.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip("server-access added in tmux 3.3") with pytest.raises(ValueError, match="mutually exclusive"): @@ -946,9 +946,9 @@ def test_server_access_argv( testing of the side-effect would tie the suite to system config. Verify the constructed argv instead. """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip("server-access added in tmux 3.3") captured: list[tuple[str, ...]] = [] @@ -1007,10 +1007,10 @@ def test_list_keys(server: Server) -> None: def test_list_keys_format(server: Server) -> None: """Server.list_keys(format_=...) applies -F on tmux 3.7+; warns below.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte server.new_session(session_name="listkeys_fmt") - if has_gte_version("3.7"): + if client_gte("3.7"): result = server.list_keys(format_="fmt") assert result assert all(line == "fmt" for line in result) @@ -1058,9 +1058,9 @@ def test_show_messages_terminals_jobs(server: Server) -> None: :c:func:`cmd_show_messages_exec` can take the ``-T``/``-J`` early-return paths, so this clientless codepath is unreachable. """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.6"): + if not client_gte("3.6"): pytest.skip("show-messages -T/-J without a client requires tmux 3.6+") server.new_session(session_name="showmsg_alt_test") @@ -1074,9 +1074,9 @@ def test_show_messages_terminals_jobs(server: Server) -> None: def test_show_prompt_history(server: Server) -> None: """Test Server.show_prompt_history() returns history.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip("show-prompt-history added in tmux 3.3") server.new_session(session_name="showph_test") @@ -1086,9 +1086,9 @@ def test_show_prompt_history(server: Server) -> None: def test_clear_prompt_history(server: Server) -> None: """Test Server.clear_prompt_history() clears history.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip("clear-prompt-history added in tmux 3.3") server.new_session(session_name="clearph_test") @@ -1106,11 +1106,11 @@ def test_wait_for_set_flag(server: Server) -> None: def test_run_shell_basic(server: Server) -> None: """Test Server.run_shell() executes command and returns output.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte # tmux <3.5 does not write run-shell stdout back to the cmdq subprocess. # Restored by upstream commit fb37d52d, first released in 3.5. - if not has_gte_version("3.5"): + if not client_gte("3.5"): pytest.skip("run-shell stdout passthrough requires tmux 3.5+") server.new_session(session_name="run_shell_test") @@ -1128,9 +1128,9 @@ def test_run_shell_background(server: Server) -> None: def test_run_shell_cwd(server: Server, tmp_path: pathlib.Path) -> None: """``cwd=`` sets the working directory for the shell command.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.5"): + if not client_gte("3.5"): pytest.skip("run-shell stdout passthrough requires tmux 3.5+") server.new_session(session_name="run_shell_cwd_test") @@ -1141,9 +1141,9 @@ def test_run_shell_cwd(server: Server, tmp_path: pathlib.Path) -> None: def test_run_shell_show_stderr(server: Server) -> None: """``show_stderr=True`` captures the command's stderr into the output.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.6"): + if not client_gte("3.6"): pytest.skip("run-shell -E (JOB_SHOWSTDERR) requires tmux 3.6+") server.new_session(session_name="run_shell_stderr_test") @@ -1159,10 +1159,10 @@ def test_run_shell_show_stderr(server: Server) -> None: def test_run_shell_args(server: Server) -> None: """``args`` fill #{1}/#{2} in the command on tmux 3.7+; warn below.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte server.new_session(session_name="run_shell_args_test") - if has_gte_version("3.7"): + if client_gte("3.7"): result = server.run_shell("echo #{1}-#{2}", args=["alpha", "beta"]) assert result == ["alpha-beta"] else: @@ -1691,13 +1691,13 @@ def test_server_display_message_flags( returns empty stdout on that release (output dispatch via a control-mode client was unreliable until later versions). """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip( "display-message -p via control-mode client unreliable on tmux 3.2a" ) - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") with control_mode() as ctl: @@ -1728,9 +1728,9 @@ def test_server_display_message_target_client( server: Server, ) -> None: """``target_client`` is plumbed through as ``-c``; get_text=True returns stdout.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip( "display-message -p via control-mode client unreliable on tmux 3.2a" ) diff --git a/tests/test_session.py b/tests/test_session.py index f7d95e4cca..e86ead7c13 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -772,11 +772,11 @@ def test_session_refresh_raises_when_session_id_is_none(server: Server) -> None: def test_kill_session_group(server: Server) -> None: """Session.kill(group=True) uses -g on tmux 3.7+; warn-and-ignore below.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte session = server.new_session(session_name="kill_group_base") - if has_gte_version("3.7"): + if client_gte("3.7"): session.kill(group=True) assert not server.has_session("kill_group_base") else: diff --git a/tests/test_window.py b/tests/test_window.py index 73e6f61089..6a736eba55 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -287,10 +287,9 @@ def test_new_window_name_colon_period_accepted( The lone tmux 3.7 point release briefly rejected these characters before 3.7a reverted the restriction (tmux commit 166267c8, "overly - pernickety"). libtmux's version helpers strip the letter suffix, so - :func:`~libtmux.common.get_version` cannot tell 3.7 from 3.7a -- both - report ``3.7`` -- so CI exercises 3.7a/3.7b rather than the superseded - 3.7 release. + pernickety"). The suite's executable gate strips the letter suffix, so it + cannot tell 3.7 from 3.7a -- both report ``3.7`` -- so CI exercises + 3.7a/3.7b rather than the superseded 3.7 release. """ window = session.new_window(window_name=window_name) assert window.window_name == window_name @@ -1163,9 +1162,9 @@ def test_window_display_message_flags( session: Session, ) -> None: """Window.display_message() resolves window-scoped formats.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") window = session.active_window @@ -1191,9 +1190,9 @@ def test_window_display_message_target_client( session: Session, ) -> None: """``target_client`` is plumbed through as ``-c``.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip( "display-message -p via control-mode client unreliable on tmux 3.2a" ) @@ -1320,12 +1319,12 @@ def test_window_refresh_raises_when_window_id_is_none(session: Session) -> None: def test_new_pane(session: Session) -> None: """Window.new_pane() creates a floating pane on tmux 3.7+ (else raises).""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte window = session.new_window(window_name="win_floating") window.resize(height=50, width=200) - if has_gte_version("3.7"): + if client_gte("3.7"): floating = window.new_pane(width=60, height=12, shell="sleep 30") assert floating.pane_floating_flag == "1" assert floating in window.panes From 4e8d1d1e9141ee892a7803cbe91cd9660bd20f59 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:31:14 -0500 Subject: [PATCH 40/60] Changelog(docs): Record the deprecated executable version helpers why: The executable version helpers now warn, so callers need to know what they read and what replaces them. what: - Add a Deprecations entry listing the eight helpers and two constants, why they are deprecated, and a before and after example --- CHANGES | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGES b/CHANGES index 980595e350..19dfb5f300 100644 --- a/CHANGES +++ b/CHANGES @@ -76,6 +76,31 @@ Binary version and help diagnostics remain available. {meth}`Server.kill() Date: Sat, 17 Oct 2026 12:32:57 -0500 Subject: [PATCH 41/60] Development(feat[pytest]): Fail on libtmux's own deprecated version calls why: libtmux must not call the deprecated executable version helpers itself, but nothing stopped a doctest or a test from reintroducing one; the warning would only scroll past in the summary. what: - Turn a DeprecationWarning whose message starts with libtmux.common. into an error when it is raised from libtmux or from tests outside tests/legacy_api, which keeps exercising the helpers on purpose Each break fails and the restored tree passes: a doctest calling has_gte_version and a test outside legacy_api calling it (python/legacy-helper-callers-deliberate-break.log). --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0002133c92..92a4470662 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -275,6 +275,11 @@ addopts = [ "-p no:doctest", "--reruns=2" ] +filterwarnings = [ + # libtmux and its tests must not call the deprecated executable version + # helpers; tests/legacy_api exercises them on purpose. + "error:libtmux\\.common\\.:DeprecationWarning:(libtmux|tests)\\.(?!legacy_api)", +] doctest_optionflags = [ "ELLIPSIS", "NORMALIZE_WHITESPACE" From 6d55b5dcd7ca02151ab7fa464e7cfb7ceb0a281a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 22:52:11 -0500 Subject: [PATCH 42/60] Features(fix): Enforce daemon-specific native requirements Check literal commands and complete batches before effects. Require 3.4 for named-buffer identity and 3.5 for captured shell-job stdout. Refuse defective native 3.4 percentages and exact 3.7 raw breaks; retain portable typed percentages and explicit-name break repair. Select operational flags from the daemon and expose the backend policy contract. --- src/libtmux/_internal/support/versions.py | 87 ++++++++++++++++++++++- src/libtmux/common.py | 21 +++++- src/libtmux/pane.py | 32 ++++++--- src/libtmux/server.py | 44 +++++++++--- tests/test_pane.py | 23 +++--- tests/test_pane_capture_pane.py | 35 +++------ tests/test_server.py | 47 ++++++------ tests/test_window.py | 19 +++-- 8 files changed, 214 insertions(+), 94 deletions(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index a42f84c721..8c26a1812f 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -155,8 +155,77 @@ def daemon_gone(stderr: str) -> bool: return _absent_endpoint(stderr) +def _command_items(arguments: list[str]) -> list[list[str]]: + """Inspect the native argv command boundaries without rewriting them.""" + commands: list[list[str]] = [] + current: list[str] = [] + for argument in arguments: + if argument.endswith(";"): + value = argument[:-1] + if value.endswith("\\"): + current.append(value[:-1] + ";") + continue + if value: + current.append(value) + if current: + commands.append(current) + current = [] + else: + current.append(argument) + if current: + commands.append(current) + return commands + + +def _require_commands( + version: str, arguments: list[str], *, repaired_break: bool +) -> None: + for command in _command_items(arguments): + name = command[0] + if not is_at_least(version, "3.4") and ( + name == "deleteb" or "delete-buffer".startswith(name) + ): + options, _ = getopt.getopt(command[1:], "b:") + if any(flag == "-b" for flag, _ in options): + msg = "exact named-buffer deletion requires tmux 3.4 or newer" + raise exc.VersionTooLow(msg) + elif not is_at_least(version, "3.4") and ( + name == "setb" or (len(name) >= 5 and "set-buffer".startswith(name)) + ): + options, _ = getopt.getopt(command[1:], "ab:t:n:w") + flags = {flag for flag, _ in options} + if {"-b", "-n"} <= flags: + msg = "exact named-buffer rename requires tmux 3.4 or newer" + raise exc.VersionTooLow(msg) + elif version == "3.7" and (name == "breakp" or "break-pane".startswith(name)): + options, _ = getopt.getopt(command[1:], "abdPF:n:s:t:") + if not repaired_break or not any(flag == "-n" for flag, _ in options): + msg = ( + "raw break-pane is unsafe on tmux 3.7; use Pane.break_pane " + "with an explicit window_name or upgrade to 3.7a" + ) + raise exc.VersionTooLow(msg) + elif ( + is_at_least(version, "3.4") + and not is_at_least(version, "3.5") + and (name == "splitw" or "split-window".startswith(name)) + ): + options, _ = getopt.getopt(command[1:], "bc:de:fF:hIl:p:Pt:vZ") + if any(flag == "-p" for flag, _ in options): + msg = ( + "split-window -p is broken on tmux 3.4; " + "use -l with a percentage or Pane.split" + ) + raise exc.VersionTooLow(msg) + + def prepare( - argv: list[str], *, cache: VersionCache | None = None + argv: list[str], + *, + cache: VersionCache | None = None, + minimum: str = MINIMUM, + expected_version: str | None = None, + repaired_break: bool = False, ) -> tuple[list[str], str | None]: """Check the daemon's version, then fence dispatch against it.""" try: @@ -168,6 +237,9 @@ def prepare( if any(flag == "-c" for flag, _ in options): msg = "tmux -c is outside command dispatch; use Server.run_shell instead" raise exc.LibTmuxException(msg) + if any(flag == "-C" for flag, _ in options): + msg = "raw control startup cannot retain a daemon endpoint; use ControlMode" + raise exc.LibTmuxException(msg) if not commands: commands = ["new-session"] globals_: list[str] = [] @@ -191,6 +263,19 @@ def prepare( elif not any(flag == "-N" for flag, _ in options): # A vanished daemon must not restart an older configured executable. globals_.append("-N") + if not is_at_least(version, minimum): + msg = ( + f"this operation requires tmux {minimum} or newer; " + f"daemon reports {version!r}" + ) + raise exc.VersionTooLow(msg) + if expected_version is not None and version != expected_version: + msg = "tmux daemon changed while selecting the operation's capabilities" + raise exc.VersionTooLow(msg) + try: + _require_commands(version, commands, repaired_break=repaired_break) + except getopt.GetoptError as error: + raise exc.LibTmuxException(str(error)) from error marker = "libtmux-version-" + uuid.uuid4().hex condition = "#{==:#{version}," + version + "}" return [ diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 3560246195..d3150b9293 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -74,6 +74,10 @@ def __getattr__(name: str) -> t.Any: raise AttributeError(msg) +#: Version of the optional-feature policy contract for backend consumers. +TMUX_OPTIONAL_FEATURE_POLICY_VERSION = 1 + + SessionDict = dict[str, t.Any] WindowDict = dict[str, t.Any] WindowOptionDict = dict[str, t.Any] @@ -357,6 +361,9 @@ def __init__( *args: t.Any, tmux_bin: str | None = None, _version_cache: versions.VersionCache | None = None, + _minimum_version: str = versions.MINIMUM, + _expected_version: str | None = None, + _repaired_break: bool = False, ) -> None: resolved = tmux_bin or shutil.which("tmux") if not resolved: @@ -368,7 +375,17 @@ def __init__( self.cmd = cmd cached = _version_cache is not None and bool(_version_cache) - dispatched, marker = versions.prepare(cmd, cache=_version_cache) + + def prepare_dispatch() -> tuple[list[str], str | None]: + return versions.prepare( + cmd, + cache=_version_cache, + minimum=_minimum_version, + expected_version=_expected_version, + repaired_break=_repaired_break, + ) + + dispatched, marker = prepare_dispatch() if logger.isEnabledFor(logging.DEBUG): cmd_str = shlex.join(cmd) @@ -383,7 +400,7 @@ def __init__( # The cached daemon vanished; nothing ran, so check its version again. assert _version_cache is not None _version_cache.clear() - dispatched, marker = versions.prepare(cmd, cache=_version_cache) + dispatched, marker = prepare_dispatch() stdout, stderr, returncode = self._dispatch(dispatched) except FileNotFoundError: raise exc.TmuxCommandNotFound from None diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 38b2d166de..a338f9f792 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1343,7 +1343,7 @@ def split( tmux_args += (f"-l{size}",) if percentage is not None: - tmux_args += (f"-p{percentage}",) + tmux_args += (f"-l{percentage}%",) if full_window_split: tmux_args += ("-f",) @@ -2371,7 +2371,7 @@ def join( Examples -------- >>> pane_to_join = window.split(shell='sleep 1m') - >>> new_window = pane_to_join.break_pane() + >>> new_window = pane_to_join.break_pane(window_name='join-source') >>> pane_to_join.join(window) """ tmux_args: tuple[str, ...] = () @@ -2422,7 +2422,9 @@ def break_pane( detach : bool, optional Do not switch to the new window (``-d`` flag), default True. window_name : str, optional - Name for the new window (``-n`` flag). + Name for the new window (``-n`` flag). Required on exact tmux + 3.7, whose unnamed break is unsafe. Named breaks on that release + apply the name again after the pane moves. Returns ------- @@ -2436,11 +2438,16 @@ def break_pane( >>> new_window.window_name 'broken' """ - # tmux 3.7 segfaults break-pane when -n is absent and ignores -n when - # given (NULL-deref); 3.7a reverted it. When needed, pass a placeholder - # -n then set the real name via rename-window below. Compare the raw - # version string to gate the workaround on the literal 3.7 release only. - breaks_without_name = self.server._get_version() == "3.7" + # Exact 3.7 needs -n to avoid a crash, then rename-window to apply it. + # An unnamed placeholder also changes single-pane window names. + version = self.server._get_version() + breaks_without_name = version == "3.7" + if breaks_without_name and window_name is None: + msg = ( + "unnamed break-pane is unsafe on tmux 3.7; " + "provide window_name or upgrade to 3.7a" + ) + raise exc.VersionTooLow(msg) tmux_args: tuple[str, ...] = ("-P", "-F#{window_id}") @@ -2449,13 +2456,16 @@ def break_pane( if window_name is not None: tmux_args += ("-n", window_name) - elif breaks_without_name: - tmux_args += ("-n", "libtmux") tmux_args += ("-s", str(self.pane_id)) # Use server.cmd to avoid auto-adding -t from self.cmd - proc = self.server.cmd("break-pane", *tmux_args) + proc = self.server.cmd( + "break-pane", + *tmux_args, + _expected_version=version, + _repaired_break=breaks_without_name, + ) raise_if_stderr(proc, "break-pane") diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 3252768fda..d454db389f 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -359,6 +359,9 @@ def cmd( cmd: str, *args: t.Any, target: str | int | None = None, + _minimum_version: str = versions.MINIMUM, + _expected_version: str | None = None, + _repaired_break: bool = False, ) -> tmux_cmd: """Execute tmux command respective of socket name and file, return output. @@ -430,6 +433,9 @@ def cmd( *cmd_args, tmux_bin=self.tmux_bin, _version_cache=self._version_cache, + _minimum_version=_minimum_version, + _expected_version=_expected_version, + _repaired_break=_repaired_break, ) @property @@ -589,15 +595,24 @@ def run_shell( Returns ------- list[str] | None - Stdout lines, or None when *background* is True. Empty list on - tmux 3.3a/3.4 (upstream stdout passthrough was broken until 3.5). + Stdout lines, or None when *background* is True or output is + directed to *target_pane*. Captured shell-job stdout requires + tmux 3.5+; older daemons are refused before starting the job. + Background jobs, pane-directed output and native tmux-command + execution remain available on tmux 3.3a+. Examples -------- - >>> result = server.run_shell('true') + >>> result = server.run_shell('display-message -p ready', as_tmux_command=True) >>> isinstance(result, list) True """ + version = self._get_version() + minimum = ( + "3.5" + if not background and target_pane is None and not as_tmux_command + else versions.MINIMUM + ) tmux_args: tuple[str, ...] = () if background: @@ -613,7 +628,7 @@ def run_shell( tmux_args += ("-t", target_pane) if cwd is not None: - if self._has_gte_version("3.4"): + if versions.is_at_least(version, "3.4"): tmux_args += ("-c", str(cwd)) else: warnings.warn( @@ -622,7 +637,7 @@ def run_shell( ) if show_stderr: - if self._has_gte_version("3.6"): + if versions.is_at_least(version, "3.6"): tmux_args += ("-E",) else: warnings.warn( @@ -633,7 +648,7 @@ def run_shell( tmux_args += (command,) if args: - if self._has_gte_version("3.7"): + if versions.is_at_least(version, "3.7"): tmux_args += tuple(args) else: warnings.warn( @@ -641,11 +656,16 @@ def run_shell( stacklevel=2, ) - proc = self.cmd("run-shell", *tmux_args) + proc = self.cmd( + "run-shell", + *tmux_args, + _minimum_version=minimum, + _expected_version=version, + ) raise_if_stderr(proc, "run-shell") - if background: + if background or target_pane is not None: return None return proc.stdout @@ -1892,12 +1912,16 @@ def delete_buffer(self, *, buffer_name: str | None = None) -> None: ---------- buffer_name : str, optional Name of the buffer to delete (``-b`` flag). Defaults to the most - recent. + recent. Exact named deletion requires tmux 3.4+; older releases + can delete an unrelated top buffer when the name is missing. Examples -------- >>> server.set_buffer('to_delete', buffer_name='del_buf') - >>> server.delete_buffer(buffer_name='del_buf') + >>> try: + ... server.delete_buffer(buffer_name='del_buf') + ... except exc.VersionTooLow: + ... assert server.show_buffer(buffer_name='del_buf') == 'to_delete' """ tmux_args: tuple[str, ...] = () diff --git a/tests/test_pane.py b/tests/test_pane.py index 2fa37f6f89..f5e2799a52 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -888,13 +888,6 @@ def test_display_message_warns_on_tmux_error(session: Session) -> None: def test_split_percentage(session: Session) -> None: """Test Pane.split() with percentage parameter.""" - from tests.tmux_client import client_gte - - # tmux 3.4 has a regression in split-window -p; fixed in 3.5. - # Per CHANGES FROM 3.4 TO 3.5: "Fix split-window -p." - if not client_gte("3.5"): - pytest.skip("split-window -p was broken in tmux 3.4 (fixed in 3.5)") - window = session.new_window(window_name="test_split_pct") window.resize(height=40, width=80) pane = window.active_pane @@ -1286,7 +1279,7 @@ def test_join_pane(session: Session) -> None: new_pane = pane.split(shell="sleep 1m") assert len(window.panes) == 2 - new_window = new_pane.break_pane() + new_window = new_pane.break_pane(window_name="join-source") window.refresh() assert len(window.panes) == 1 @@ -1309,7 +1302,7 @@ def test_join_pane_horizontal(session: Session) -> None: assert pane is not None new_pane = pane.split(shell="sleep 1m") - new_pane.break_pane() + new_pane.break_pane(window_name="join-source") new_pane.join(window, vertical=False) window.refresh() @@ -1326,6 +1319,13 @@ def test_break_pane_basic(session: Session) -> None: new_pane = pane.split(shell="sleep 1m") assert len(window.panes) == 2 + if session.server._get_version() == "3.7": + with pytest.raises(exc.VersionTooLow, match="provide window_name"): + new_pane.break_pane() + assert len(session.windows) == initial_window_count + assert len(window.panes) == 2 + return + new_window = new_pane.break_pane() session.refresh() @@ -1365,6 +1365,11 @@ def command_started() -> bool: return new_pane.pane_current_command == "sleep" retry_until(command_started) + if session.server._get_version() == "3.7": + with pytest.raises(exc.VersionTooLow, match="provide window_name"): + new_pane.break_pane() + assert len(window.panes) == 2 + return broken = new_pane.break_pane() assert broken.window_name == "sleep" diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index c815cf6e01..a3ab60b153 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -444,33 +444,15 @@ def output_ready() -> bool: assert len(output) > 0 -def test_capture_pane_trim_trailing_warning( - session: Session, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test that trim_trailing issues a warning on tmux < 3.4.""" - import warnings - - from libtmux import Server - - # Feature flags follow the daemon's release, so patch that decision. - monkeypatch.setattr(Server, "_has_gte_version", lambda _self, v: v != "3.4") - - pane = session.active_window.split(shell="sh") - - def prompt_ready() -> bool: - return "$" in "\n".join(pane.capture_pane()) - - retry_until(prompt_ready, 2, raises=True) - - # Should issue a warning - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") +def test_capture_pane_trim_trailing_warning(session: Session) -> None: + """Warn and omit the unavailable flag on the actual older daemon.""" + if session.server._has_gte_version("3.4"): + pytest.skip("requires a daemon without capture-pane -T") + pane = session.active_window.active_pane + assert pane is not None + with pytest.warns(UserWarning, match="trim_trailing requires tmux 3.4"): pane.capture_pane(trim_trailing=True) - # Check warning was issued - assert len(w) == 1 - assert "trim_trailing requires tmux 3.4+" in str(w[0].message) - @pytest.mark.parametrize( ("kwargs", "min_tmux_version"), @@ -522,7 +504,8 @@ def test_capture_pane_to_buffer(session: Session) -> None: contents = session.server.cmd("show-buffer", "-b", "cap_test_buf").stdout assert any("BUFFER_CAPTURE_MARKER" in line for line in contents) - session.server.cmd("delete-buffer", "-b", "cap_test_buf") + if session.server._has_gte_version("3.4"): + session.server.delete_buffer(buffer_name="cap_test_buf") def test_capture_pane_pending_emits_dash_P( diff --git a/tests/test_server.py b/tests/test_server.py index f36659eb1c..74db0357cf 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1104,16 +1104,15 @@ def test_wait_for_set_flag(server: Server) -> None: server.wait_for("test_channel_set", set_flag=True) -def test_run_shell_basic(server: Server) -> None: - """Test Server.run_shell() executes command and returns output.""" - from tests.tmux_client import client_gte - - # tmux <3.5 does not write run-shell stdout back to the cmdq subprocess. - # Restored by upstream commit fb37d52d, first released in 3.5. - if not client_gte("3.5"): - pytest.skip("run-shell stdout passthrough requires tmux 3.5+") - +def test_run_shell_basic(server: Server, tmp_path: pathlib.Path) -> None: + """Capture stdout where supported and refuse before jobs on older daemons.""" server.new_session(session_name="run_shell_test") + if not server._has_gte_version("3.5"): + marker = tmp_path / "captured-job" + with pytest.raises(exc.VersionTooLow, match=r"3\.5"): + server.run_shell(f"touch {marker}") + assert not marker.exists() + return result = server.run_shell("echo hello_from_run_shell") assert result is not None assert any("hello_from_run_shell" in line for line in result) @@ -1167,36 +1166,30 @@ def test_run_shell_args(server: Server) -> None: assert result == ["alpha-beta"] else: with pytest.warns(UserWarning, match=r"args requires tmux 3.7"): - server.run_shell("echo plain", args=["alpha"]) + server.run_shell("echo plain", args=["alpha"], background=True) def test_run_shell_cwd_warns_on_old_tmux( server: Server, - monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, ) -> None: - """``cwd=`` emits a warning and skips ``-c`` on tmux <3.4. - - Simulates an older daemon by patching the daemon release decision. - """ - monkeypatch.setattr(Server, "_has_gte_version", lambda *_a: False) + """An actual older daemon warns when a background job cannot select cwd.""" server.new_session(session_name="run_shell_cwd_warn_test") + if server._has_gte_version("3.4"): + pytest.skip("requires a supported daemon below 3.4") with pytest.warns(UserWarning, match="cwd requires tmux 3.4+"): - server.run_shell("true", cwd=tmp_path) + server.run_shell("true", cwd=tmp_path, background=True) def test_run_shell_show_stderr_warns_on_old_tmux( server: Server, - monkeypatch: pytest.MonkeyPatch, ) -> None: - """``show_stderr=True`` emits a warning and skips ``-E`` on tmux <3.6. - - Simulates an older daemon by patching the daemon release decision. - """ - monkeypatch.setattr(Server, "_has_gte_version", lambda *_a: False) + """An actual older daemon warns about unavailable stderr combination.""" server.new_session(session_name="run_shell_stderr_warn_test") + if server._has_gte_version("3.6"): + pytest.skip("requires a supported daemon below 3.6") with pytest.warns(UserWarning, match="show_stderr requires tmux 3.6+"): - server.run_shell("true", show_stderr=True) + server.run_shell("true", show_stderr=True, background=True) class BufferCase(t.NamedTuple): @@ -1272,6 +1265,12 @@ def test_buffer_delete(server: Server) -> None: # Verify it exists assert server.show_buffer(buffer_name="del_buf") == "to_delete" + if not server._has_gte_version("3.4"): + with pytest.raises(exc.VersionTooLow, match="named-buffer deletion"): + server.delete_buffer(buffer_name="del_buf") + assert server.show_buffer(buffer_name="del_buf") == "to_delete" + return + # Delete it server.delete_buffer(buffer_name="del_buf") diff --git a/tests/test_window.py b/tests/test_window.py index 6a736eba55..07d9aeb093 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -264,9 +264,7 @@ class WindowNameSpecialCharFixture(t.NamedTuple): window_name: str -# The tmux 3.7 point release briefly rejected ':' and '.' in window names, -# then reverted it in 3.7a as "overly pernickety" (tmux commit 166267c8). -# Every tmux libtmux supports -- 3.2a-3.6 and 3.7a onward -- accepts them. +# Exact tmux 3.7 rejects these names; 3.7a reverts that validation. WINDOW_NAME_SPECIAL_CHAR_FIXTURES: list[WindowNameSpecialCharFixture] = [ WindowNameSpecialCharFixture(test_id="colon", window_name="project:frontend"), WindowNameSpecialCharFixture(test_id="period", window_name="app-v1.0"), @@ -283,14 +281,13 @@ def test_new_window_name_colon_period_accepted( test_id: str, window_name: str, ) -> None: - """Window names with ':' and '.' are accepted verbatim. - - The lone tmux 3.7 point release briefly rejected these characters before - 3.7a reverted the restriction (tmux commit 166267c8, "overly - pernickety"). The suite's executable gate strips the letter suffix, so it - cannot tell 3.7 from 3.7a -- both report ``3.7`` -- so CI exercises - 3.7a/3.7b rather than the superseded 3.7 release. - """ + """Preserve native names or exact 3.7's rejection before creation.""" + if session.server._get_version() == "3.7": + before = [window.window_id for window in session.windows] + with pytest.raises(exc.LibTmuxException, match="invalid window name"): + session.new_window(window_name=window_name) + assert [window.window_id for window in session.windows] == before + return window = session.new_window(window_name=window_name) assert window.window_name == window_name window.kill() From a9b164c7f264329957a60641aa9ff7386ed40513 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 22:52:11 -0500 Subject: [PATCH 43/60] Control(fix): Retain the qualified socket through acquisition Require stable daemon 3.7 before starting control mode. Own a private socket hard link, verify daemon identity through it and keep it until client cleanup. Retry short same-filesystem carriers for long paths and cross-device temp roots. Retain baseline fixtures and test native refusals, replacement, option aliases and cleanup. --- conftest.py | 7 +- src/libtmux/_internal/control_mode.py | 245 +++++++++++++++++--------- src/libtmux/pytest_plugin.py | 13 +- tests/test_control_mode.py | 174 ++++++++++++++++-- tests/test_optional_features.py | 174 ++++++++++++++++++ 5 files changed, 507 insertions(+), 106 deletions(-) create mode 100644 tests/test_optional_features.py diff --git a/conftest.py b/conftest.py index 88a2656d29..6d56cc3f07 100644 --- a/conftest.py +++ b/conftest.py @@ -17,10 +17,9 @@ import pytest from _pytest.doctest import DoctestItem -from libtmux._internal.control_mode import ControlMode from libtmux.client import Client from libtmux.pane import Pane -from libtmux.pytest_plugin import USING_ZSH +from libtmux.pytest_plugin import USING_ZSH, _control_mode_for_test from libtmux.server import Server from libtmux.session import Session from libtmux.window import Window @@ -51,9 +50,9 @@ def add_doctest_fixtures( doctest_namespace["window"] = session.active_window doctest_namespace["pane"] = session.active_pane doctest_namespace["request"] = request - doctest_namespace["ControlMode"] = ControlMode + doctest_namespace["ControlMode"] = _control_mode_for_test doctest_namespace["control_mode"] = functools.partial( - ControlMode, + _control_mode_for_test, server=session.server, session=session, ) diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index 05945451eb..c60e904f6c 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -1,42 +1,122 @@ -"""Control-mode client context manager for tmux testing. - -Provides a context manager that spawns a ``tmux -C attach-session`` -subprocess, creating a real tmux client that satisfies commands -requiring an attached client (e.g. ``display-popup``, ``detach-client``). -""" +"""Control-mode client context manager for a qualified local tmux daemon.""" from __future__ import annotations +import contextlib import os +import pathlib +import re +import stat import subprocess +import tempfile import typing as t +from libtmux import exc +from libtmux._internal.support import versions from libtmux.test.retry import retry_until if t.TYPE_CHECKING: import types + from collections.abc import Iterator from typing_extensions import Self from libtmux.server import Server from libtmux.session import Session +_IDENTITY = "#{version}\t#{pid}\t#{start_time}\t#{socket_path}" + + +def _identity(binary: str, selectors: list[str]) -> str: + result = versions._run( + [binary, "-u", *selectors, "-N", "display-message", "-p", _IDENTITY] + ) + report = result.stdout.rstrip("\n") + fields = report.split("\t", 3) + if ( + result.returncode + or "\n" in report + or len(fields) != 4 + or not fields[1].isdigit() + or not fields[2].isdigit() + or not pathlib.Path(fields[3]).is_absolute() + ): + detail = result.stderr.strip() or "tmux did not report its daemon identity" + msg = f"ControlMode cannot identify its daemon: {detail}" + raise exc.LibTmuxException(msg) + return report + + +def _require_release(version: str) -> None: + try: + versions.require_version(version, minimum="3.7") + except exc.VersionTooLow: + msg = f"ControlMode requires tmux 3.7 or newer; daemon reports {version!r}" + raise exc.VersionTooLow(msg) from None + if re.fullmatch(r"\d+\.\d+[a-z]?", version) is None: + msg = f"ControlMode requires a qualified release; daemon reports {version!r}" + raise exc.VersionTooLow(msg) + + +@contextlib.contextmanager +def _pinned_endpoint(server: Server) -> Iterator[list[str]]: + binary, selectors = server._version_endpoint() + identity = _identity(binary, selectors) + version, _, _, socket = identity.split("\t", 3) + _require_release(version) + + original = pathlib.Path(socket) + with contextlib.ExitStack() as resources: + try: + original_stat = original.lstat() + if not stat.S_ISSOCK(original_stat.st_mode): + msg = "ControlMode requires a local Unix socket" + raise exc.LibTmuxException(msg) + parents = sorted( + dict.fromkeys((pathlib.Path(tempfile.gettempdir()), original.parent)), + key=lambda path: len(os.fsencode(path)), + ) + for parent in parents: + with contextlib.ExitStack() as attempt: + try: + folder = attempt.enter_context( + tempfile.TemporaryDirectory(prefix=".lt-", dir=parent) + ) + alias = pathlib.Path(folder) / "s" + os.link(original, alias, follow_symlinks=False) + except OSError: + if parent == parents[-1]: + raise + continue + resources.enter_context(attempt.pop_all()) + break + pinned = alias.lstat() + if (original_stat.st_dev, original_stat.st_ino) != ( + pinned.st_dev, + pinned.st_ino, + ) or _identity(binary, ["-S", str(alias)]) != identity: + msg = "ControlMode daemon changed before acquisition" + raise exc.LibTmuxException(msg) + except OSError as error: + msg = f"ControlMode cannot retain the selected tmux socket: {error}" + raise exc.LibTmuxException(msg) from error + # Keep the inode reachable until the owned control client exits. + yield [binary, "-u", "-N", "-S", str(alias)] -class ControlMode: - """Context manager that spawns a tmux control-mode client. - Creates a real client attached to the session, visible in - ``Server.list_clients()``. The client communicates via the tmux - control protocol on stdout. +class ControlMode: + """Attach a control client to a local tmux 3.7 or newer release. - While active, ``Server.list_clients()`` will include this client. + The socket filesystem must permit a private hard link. An endpoint that + disappears or changes before acquisition is refused. Ordinary subprocess + operations remain available on tmux 3.3a and newer. Parameters ---------- server : Server - The tmux server instance. + Server whose local daemon to attach to. session : Session - The session to attach to. + Session on that server. Examples -------- @@ -50,83 +130,84 @@ class ControlMode: session: Session client_name: str stdout: t.IO[str] - _proc: subprocess.Popen[str] _write_fd: int + _resources: contextlib.ExitStack | None def __init__(self, server: Server, session: Session) -> None: self.server = server self.session = session + self._resources = None def __enter__(self) -> Self: - """Spawn control-mode client and wait for registration.""" - read_fd, self._write_fd = os.pipe() - - tmux_bin = self.server.tmux_bin or "tmux" - - if self.server.socket_name is not None: - socket_args = ["-L", str(self.server.socket_name)] - elif self.server.socket_path is not None: - socket_args = ["-S", str(self.server.socket_path)] - else: - socket_args = [] - - cmd = [ - tmux_bin, - *socket_args, - "-C", - "attach-session", - "-t", - str(self.session.session_id), - ] - - try: + """Check the daemon's version, acquire control and wait for registration.""" + if self._resources is not None: + msg = "ControlMode is already active" + raise exc.LibTmuxException(msg) + with contextlib.ExitStack() as resources: + endpoint = resources.enter_context(_pinned_endpoint(self.server)) + read_fd, self._write_fd = os.pipe() try: - self._proc = subprocess.Popen( - cmd, - stdin=read_fd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", + try: + self._proc = subprocess.Popen( + [ + *endpoint, + "-C", + "attach-session", + "-t", + str(self.session.session_id), + ], + stdin=read_fd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + finally: + os.close(read_fd) + except BaseException: + os.close(self._write_fd) + raise + resources.callback(self._close_client) + assert self._proc.stdout is not None + self.stdout = self._proc.stdout + client_pid = str(self._proc.pid) + + def client_registered() -> bool: + if self._proc.poll() is not None: + assert self._proc.stderr is not None + detail = self._proc.stderr.read().strip() + msg = f"ControlMode client exited before registration: {detail}" + raise exc.LibTmuxException(msg) + result = self.server.cmd( + "-u", "list-clients", "-F", "#{client_pid}\t#{client_name}" ) - finally: - # subprocess owns read_fd now - os.close(read_fd) - except BaseException: - # __exit__ will not run if __enter__ fails - os.close(self._write_fd) - raise - - self.stdout = self._proc.stdout # type: ignore[assignment] - client_pid = str(self._proc.pid) - - def client_registered() -> bool: - result = self.server.cmd( - "list-clients", - "-F", - "#{client_pid}\t#{client_name}", - ) - for line in result.stdout: - pid, _, client_name = line.partition("\t") - if pid == client_pid and client_name: - self.client_name = client_name.strip() - return True - return False + for line in result.stdout: + pid, _, client_name = line.partition("\t") + if pid == client_pid and client_name: + self.client_name = client_name.strip() + return True + return False - try: retry_until(client_registered, 3, raises=True) - except Exception: - os.close(self._write_fd) - self._proc.terminate() + self._resources = resources.pop_all() + return self + + def _close_client(self) -> None: + os.close(self._write_fd) + try: + if self._proc.poll() is None: + self._proc.terminate() try: self._proc.wait(timeout=5) except subprocess.TimeoutExpired: self._proc.kill() - self._proc.wait() - raise - - return self + self._proc.wait(timeout=5) + finally: + if self._proc.stdout is not None: + self._proc.stdout.close() + if self._proc.stderr is not None: + self._proc.stderr.close() def __exit__( self, @@ -134,13 +215,7 @@ def __exit__( exc_val: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Terminate control-mode client.""" - # Close write end — causes the control-mode client to exit (EOF on stdin) - os.close(self._write_fd) - - self._proc.terminate() - try: - self._proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self._proc.kill() - self._proc.wait() + """Close the owned client and its private socket alias.""" + if self._resources is not None: + resources, self._resources = self._resources, None + resources.close() diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index fcc3ce052d..ac193d7f27 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -13,7 +13,7 @@ import pytest from libtmux import exc -from libtmux._internal.control_mode import ControlMode +from libtmux._internal.control_mode import ControlMode, _require_release from libtmux.server import Server from libtmux.test.constants import TEST_SESSION_PREFIX from libtmux.test.random import get_test_session_name, namer @@ -318,7 +318,16 @@ def control_mode( ... with control_mode() as ctl: ... assert ctl.client_name != '' """ - return functools.partial(ControlMode, server=server, session=session) + return functools.partial(_control_mode_for_test, server=server, session=session) + + +def _control_mode_for_test(server: Server, session: Session) -> ControlMode: + """Skip positive control use when the daemon release is not qualified.""" + try: + _require_release(server._get_version()) + except exc.VersionTooLow as error: + pytest.skip(str(error)) + return ControlMode(server=server, session=session) @pytest.fixture diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index f72f68466b..52402c1f80 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -2,19 +2,90 @@ from __future__ import annotations +import io import locale import os +import pathlib +import re import select -import sys +import subprocess +import time import typing as t import pytest +from libtmux import exc from libtmux._internal.control_mode import ControlMode from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: from libtmux.server import Server + from libtmux.session import Session + + +def test_ineligible_control_refuses_before_startup( + server: Server, session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + """Refusal stays exercised on old and unqualified development daemons.""" + version = server._get_version() + stable = re.fullmatch(r"(\d+)\.(\d+)[a-z]?", version) + if stable is not None and (int(stable[1]), int(stable[2])) >= (3, 7): + pytest.skip("requires an ineligible control release") + original = subprocess.Popen + starts: list[list[str]] = [] + + def tracked( + argv: list[str], *args: t.Any, **kwargs: t.Any + ) -> subprocess.Popen[str]: + if "-C" in argv: + starts.append(argv) + return original(argv, *args, **kwargs) + + monkeypatch.setattr(subprocess, "Popen", tracked) + with ( + pytest.raises(exc.VersionTooLow, match="ControlMode requires"), + ControlMode(server, session), + ): + pytest.fail("ineligible control client registered") + assert starts == [] + assert session.session_id in [item.session_id for item in server.sessions] + + +def test_unavailable_socket_link_refuses_before_control( + server: Server, session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed pin leaves no control client or temporary socket directory.""" + version = server._get_version() + stable = re.fullmatch(r"(\d+)\.(\d+)[a-z]?", version) + if stable is None or (int(stable[1]), int(stable[2])) < (3, 7): + pytest.skip("requires an eligible control release") + socket = pathlib.Path( + server.cmd("display-message", "-p", "#{socket_path}").stdout[0] + ) + before = set(socket.parent.glob(".lt-*")) + original = subprocess.Popen + starts: list[list[str]] = [] + + def tracked( + argv: list[str], *args: t.Any, **kwargs: t.Any + ) -> subprocess.Popen[str]: + if "-C" in argv: + starts.append(argv) + return original(argv, *args, **kwargs) + + def unavailable(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + msg = "socket hard links are unavailable" + raise OSError(msg) + + monkeypatch.setattr(subprocess, "Popen", tracked) + monkeypatch.setattr(os, "link", unavailable) + with ( + pytest.raises(exc.LibTmuxException, match=r"cannot retain.*socket"), + ControlMode(server, session), + ): + pytest.fail("control started without an endpoint pin") + assert starts == [] + assert set(socket.parent.glob(".lt-*")) == before def test_control_mode_creates_client( @@ -69,31 +140,104 @@ def test_control_mode_client_name_matches_spawned_client( assert (str(second._proc.pid), second.client_name) in clients -@pytest.mark.skipif( - sys.flags.utf8_mode != 0, - reason="PYTHONUTF8 mode forces UTF-8, masking the locale bug", -) def test_control_mode_stdout_preserves_non_ascii_output( control_mode: t.Callable[[], ControlMode], + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Control-mode stdout must preserve non-ASCII tmux output.""" + """Control output stays UTF-8 when the client inherits a C locale.""" old_lc_ctype = locale.setlocale(locale.LC_CTYPE) try: locale.setlocale(locale.LC_CTYPE, "C") + for key in ("LC_ALL", "LC_CTYPE", "LANG"): + monkeypatch.setenv(key, "C") with control_mode() as ctl: + assert isinstance(ctl.stdout, io.TextIOWrapper) + assert ctl.stdout.encoding == "utf-8" os.write( ctl._write_fd, f"display-message -p '{FORMAT_SEPARATOR}'\n".encode(), ) - - for _ in range(20): - ready, _, _ = select.select([ctl.stdout], [], [], 1) - assert ready, "timed out waiting for control-mode output" - - line = ctl.stdout.readline() - if FORMAT_SEPARATOR in line: + # Read bytes to avoid waiting on an fd after TextIOWrapper prefetched. + output = bytearray() + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + ready, _, _ = select.select( + [ctl.stdout.fileno()], [], [], max(0, deadline - time.monotonic()) + ) + if not ready: + break + chunk = os.read(ctl.stdout.fileno(), 65536) + if not chunk: break - else: - pytest.fail("FORMAT_SEPARATOR U+241E not found in control output") + output.extend(chunk) + if FORMAT_SEPARATOR.encode() in output: + break + assert FORMAT_SEPARATOR in output.decode("utf-8"), repr(bytes(output)) finally: locale.setlocale(locale.LC_CTYPE, old_lc_ctype) + + +def test_control_uses_a_short_private_socket_alias( + control_mode: t.Callable[[], ControlMode], +) -> None: + """A valid long native endpoint can still acquire a pinned control client.""" + import tempfile + + from libtmux import Server + + qualified = control_mode() + binary, _ = qualified.server._version_endpoint() + with tempfile.TemporaryDirectory(prefix="long-control-") as folder: + root = pathlib.Path(folder) + parent = root / ("p" * (96 - len(os.fsencode(root)) - 3)) + parent.mkdir(mode=0o700) + socket = parent / "s" + server = Server( + tmux_bin=binary, socket_path=str(socket), config_file="/dev/null" + ) + try: + session = server.new_session( + "long-control", window_command="exec sleep 123" + ) + assert server.sessions + context = ControlMode(server, session) + with context: + argv = context._proc.args + assert isinstance(argv, list) + alias = pathlib.Path(argv[argv.index("-S") + 1]) + assert len(os.fsencode(alias)) < len(os.fsencode(socket)) + assert alias.stat().st_ino == socket.stat().st_ino + assert alias.stat().st_dev == socket.stat().st_dev + assert alias.parent.stat().st_mode & 0o777 == 0o700 + assert context.client_name + assert context._proc.poll() is not None + assert not alias.exists() and not alias.parent.exists() + assert not server.list_clients() + finally: + server.kill() + + +def test_control_carrier_retries_a_cross_device_link( + control_mode: t.Callable[[], ControlMode], monkeypatch: pytest.MonkeyPatch +) -> None: + """A cross-device temporary root falls back to an owned peer alias.""" + import errno + + context = control_mode() + original_link = os.link + attempted: list[pathlib.Path] = [] + + def link(source: t.Any, target: t.Any, **kwargs: t.Any) -> None: + attempted.append(pathlib.Path(target)) + if len(attempted) == 1: + raise OSError(errno.EXDEV, "cross-device link") + original_link(source, target, **kwargs) + + monkeypatch.setattr(os, "link", link) + with context: + assert len(attempted) == 2 + alias = attempted[-1] + assert alias.is_socket() + assert context.client_name + assert all(not item.exists() and not item.parent.exists() for item in attempted) + assert context._proc.poll() is not None diff --git a/tests/test_optional_features.py b/tests/test_optional_features.py new file mode 100644 index 0000000000..8fab53c948 --- /dev/null +++ b/tests/test_optional_features.py @@ -0,0 +1,174 @@ +"""Optional operations preserve the ordinary 3.3a support boundary.""" + +from __future__ import annotations + +import subprocess +import typing as t + +import pytest + +from libtmux import Pane, Server, Session, exc +from libtmux._internal.support import versions +from libtmux.common import tmux_cmd + + +@pytest.mark.parametrize( + ("version", "arguments", "reason"), + [ + ("3.3a", ["delete-buffer", "-b", "missing"], "deletion"), + ("3.3a", ["deleteb", "-bmissing"], "deletion"), + ("3.3a", ["delete-bu", "-b", ""], "deletion"), + ("3.3a", ["set-buffer", "-bmissing", "-n", "new"], "rename"), + ("3.3a", ["setb", "-abmissing", "-nnew"], "rename"), + ("3.3a", ["set-b", "-t", "-n", "-bmissing", "-nnew"], "rename"), + ( + "3.3a", + ["set-option", "-g", "@probe", "changed;", "deleteb", "-bmissing"], + "deletion", + ), + ("3.4", ["split-window", "-p25"], "percentage"), + ("3.4", ["splitw", "-dp", "25"], "percentage"), + ("3.7", ["break-pane", "-s", "%1"], "break-pane"), + ("3.7", ["breakp", "-n", "requested", "-s", "%1"], "break-pane"), + ("3.7", ["break-p", "-nrequested", "-s%1"], "break-pane"), + ], +) +def test_refused_native_feature_has_no_dispatch( + monkeypatch: pytest.MonkeyPatch, + version: str, + arguments: list[str], + reason: str, +) -> None: + """Direct aliases, abbreviations and flat batches share one version check.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: subprocess.CompletedProcess(argv, 0, version + "\n", ""), + ) + + def unexpected(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + pytest.fail("requested feature process started before refusal") + + monkeypatch.setattr(subprocess, "Popen", unexpected) + with pytest.raises(exc.VersionTooLow, match=reason): + tmux_cmd(*arguments, tmux_bin="/test/tmux") + + +@pytest.mark.parametrize( + ("version", "arguments"), + [ + ("3.3a", ["set", "-g", "status", "off"]), + ("3.3a", ["setw", "-g", "mode-keys", "vi"]), + ("3.3a", ["delete-buffer"]), + ("3.3a", ["set-buffer", "-b", "name", "data"]), + ("3.3a", ["set-buffer", "-n", "new"]), + ("3.3a", ["paste-buffer", "-bmissing", "-d"]), + ("3.3a", ["display-message", "-p", "delete-buffer", "-bmissing"]), + ("3.3a", ["run-shell", "printf native-output"]), + ("3.3a", ["if-shell", "true", "delete-buffer -b missing", ""]), + ("3.3a", ["display-message", "-p", "literal\\;", "delete-buffer"]), + ("3.4", ["delete-buffer", "-bmissing"]), + ("3.4", ["set-buffer", "-bmissing", "-nnew"]), + ("3.4", ["split-window", "-l25%"]), + ("3.5", ["split-window", "-p25"]), + ("3.6", ["break-pane", "-s%1"]), + ("3.7a", ["break-pane", "-s%1"]), + ], +) +def test_native_feature_scope_preserves_arguments( + monkeypatch: pytest.MonkeyPatch, version: str, arguments: list[str] +) -> None: + """Keep ordinary commands and opaque programs in their native form.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: subprocess.CompletedProcess(argv, 0, version + "\n", ""), + ) + guarded, _ = versions.prepare(["/test/tmux", *arguments]) + assert guarded[-len(arguments) :] == arguments + + +@pytest.mark.parametrize("control", ["-C", "-CC"]) +def test_raw_control_requires_owned_control_helper(control: str) -> None: + """Synchronous raw dispatch cannot own a pinned control client's lifetime.""" + with pytest.raises(exc.LibTmuxException, match="use ControlMode"): + versions.prepare(["/test/tmux", control, "attach-session"]) + + +def test_capture_refusal_uses_actual_daemon(monkeypatch: pytest.MonkeyPatch) -> None: + """A modern configured executable cannot qualify older job stdout.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: subprocess.CompletedProcess( + argv, 0, "tmux 3.7d\n" if "-V" in argv else "3.3a\n", "" + ), + ) + + def unexpected(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + pytest.fail("captured job started on an ineligible daemon") + + monkeypatch.setattr(subprocess, "Popen", unexpected) + server = Server(tmux_bin="/test/tmux") + assert not server._has_gte_version("3.4") + with pytest.raises(exc.VersionTooLow, match=r"3\.5"): + server.run_shell("touch /must-not-exist") + + +def test_unnamed_exact_37_break_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + """Refuse before topology-dependent native naming or a NULL dereference.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: subprocess.CompletedProcess(argv, 0, "3.7\n", ""), + ) + pane = Pane(server=Server(tmux_bin="/test/tmux"), pane_id="%1") + with pytest.raises(exc.VersionTooLow, match="provide window_name"): + pane.break_pane() + + +def test_capability_selection_cannot_cross_daemon_release( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A new probe cannot silently accept flags selected for another release.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: subprocess.CompletedProcess(argv, 0, "3.3a\n", ""), + ) + with pytest.raises(exc.VersionTooLow, match="selecting"): + versions.prepare( + ["/test/tmux", "run-shell", "-b", "true"], expected_version="3.7d" + ) + + +@pytest.mark.parametrize("multiple_panes", [False, True]) +def test_exact_37_break_names(session: Session, multiple_panes: bool) -> None: + """Both native topology branches preserve refusals and explicit names.""" + server = session.server + if server._get_version() != "3.7": + pytest.skip("exact tmux 3.7 regression") + window = session.new_window(window_name="original", window_shell="exec sleep 123") + pane = window.active_pane + assert pane is not None + if multiple_panes: + pane = pane.split(shell="exec sleep 123") + before = [(item.window_id, item.window_name) for item in session.windows] + with pytest.raises(exc.VersionTooLow, match="provide window_name"): + pane.break_pane() + with pytest.raises(exc.VersionTooLow, match="raw break-pane"): + server.cmd("breakp", "-nignored", "-s", pane.pane_id) + assert [(item.window_id, item.window_name) for item in session.windows] == before + broken = pane.break_pane(window_name="requested") + assert broken.window_name == "requested" + + +def test_option_aliases_keep_native_meaning(session: Session) -> None: + """Exact aliases take precedence over buffer-command prefixes.""" + server = session.server + assert server.cmd("set", "-g", "@optional_alias", "server-value").returncode == 0 + assert server.cmd("setw", "-g", "mode-keys", "vi").returncode == 0 + assert server.cmd("show-options", "-gqv", "@optional_alias").stdout == [ + "server-value" + ] + assert server.cmd("show-window-options", "-gv", "mode-keys").stdout == ["vi"] From 0a821a6a6d6fbf00273fa22834fad1f8dac919d4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 22:52:11 -0500 Subject: [PATCH 44/60] Support(docs): Describe optional feature boundaries Document each daemon requirement, native 3.7 name rejection, partial break repair and the optional-policy backend marker. State the Linux/WSL carrier evidence and continuing readiness obligations. --- README.md | 6 ++++ docs/project/compatibility.md | 62 ++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 61dc454b3f..f9d7c212de 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ libtmux is a typed Python API over [tmux], the terminal multiplexer. Stop shelli ## Requirements & support - tmux: >= 3.3a + +Optional operations have separate requirements. Control acquisition requires a +qualified tmux 3.7+ release and a retainable local socket; exact named-buffer +deletion/rename require 3.4+, and captured shell stdout requires 3.5+. +See the [compatibility policy](https://libtmux.git-pull.com/project/compatibility/) +for exact-3.7 break-pane restrictions and the operations available on 3.3a. - Python: >= 3.10 (CPython and PyPy) Maintenance-only backports (no new fixes): diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 0e14e7e3ad..c8cd7b21f8 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -32,7 +32,7 @@ for a version check. It exits 1 with the version in its error text. If a replace daemon fails the fence on tmux 3.5 or newer, `command-error` fires once for that refusal, which reports a real error. Binary diagnostics through `tmux -V` and `tmux -h` remain available. Raw `tmux -c` shell execution is refused; use -{meth}`~libtmux.Server.run_shell` for a tmux command instead. +{meth}`~libtmux.Server.run_shell` with its documented output mode. The version check reads the forms tmux reports: a release with an optional patch letter (`3.3a`), a candidate (`3.8-rc`, `3.8-rc2`) and a development build @@ -55,6 +55,66 @@ Individual features can require a newer daemon. Their API documentation states those requirements. Raising the core floor does not qualify control startup, output synchronization or other optional behavior on every later release. +### Optional features + +| Operation | Eligible daemon and behavior | +|-----------|------------------------------| +| Ordinary subprocess operations | 3.3a+. Core support continues independently of control availability. | +| `ControlMode` and the pytest control fixture | Stable numbered releases from 3.7, with a local Unix socket whose filesystem permits a private hard link. Refused before control startup on older or unqualified development builds. | +| Explicit named-buffer deletion and rename | 3.4+. On 3.3a, a missing name can select an unrelated top buffer, so these requests are refused before dispatch. | +| Buffer reads, writes, unnamed deletion and explicit named paste-and-delete | Available on 3.3a+. Named paste-and-delete rejects a missing name before any paste or deletion. It does not replace temporary-buffer abort cleanup. | +| Captured shell-job stdout from `Server.run_shell` | 3.5+. On 3.3a and 3.4, the method refuses the capture before the job starts. | +| Background jobs, pane-directed output and `as_tmux_command=True` | Available on 3.3a+. Background and pane-directed calls return `None`; background launch does not promise job completion. | +| `Pane.split(percentage=...)` | Available on 3.3a+, using `-l N%`. Direct `split-window -p` is refused on the affected 3.4 interval. | +| `break-pane` on exact 3.7 | Raw calls and unnamed typed calls are refused. `Pane.break_pane(window_name=...)` applies the explicit name after the move. A later rename failure can leave the moved pane in its new window. | +| `break-pane` on other supported releases | Native behavior is retained. The exact-3.7 restriction does not raise their minimum. | +| Window names containing `:` or `.` | Exact 3.7 rejects creation before creating a window. Other tested supported releases retain those names. There is no automatic name substitution. | + +Eligibility comes from the selected daemon, including when a newer client is +used. Native dispatch checks the release again before executing the +operation. Feature guards cover direct literal native built-in command items, +their native aliases, abbreviations and batches. They do not interpret opaque +user-authored programs or expand configured command aliases; those contents +remain outside this guarantee. +Raw command stdout is native client output; it does not promise capture of a +nested shell job's stdout. + +Backend consumers can require +`libtmux.common.TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 1` before handing off +work. This identifies the feature enforcement contract in the table; each +operation still checks its daemon and control acquisition still checks its +socket carrier. + +The version check assumes library-owned metadata and guard commands retain tmux's +built-in semantics. Owner `command-alias` redefinitions of commands such as +`display-message` or `if-shell` are outside qualification: tmux expands aliases +before resolving even a canonical built-in name. + +Control holds a private socket alias for the owned client's lifetime. Failure +to retain or identify that socket refuses acquisition. The native alias and +replacement checks have been exercised on Linux/WSL; other filesystem and +platform combinations need their own qualification. A successful idle attach +does not establish concurrent or sustained-load stability. + +### Synchronization and testing + +Version eligibility does not remove readiness, buffering or completion waits. +`ControlMode` waits for its own client registration. Shell automation should +use a complete output marker or state predicate, and streams must retain their +framing and cancellation handling. A background command's reply confirms launch. + +CI retains ordinary older-server coverage. Only tests and documentation examples +that need an ineligible control client are skipped for that release; control +refusal tests still run. Captured-stdout and named-buffer tests assert refusals +on older daemons, and exact 3.7 tests exercise both single- and multi-pane naming. +The percentage-split test also runs on 3.4 through the portable encoding. + +The release boundaries come from tmux's +[3.3a buffer operations](https://github.com/tmux/tmux/blob/0b355ae8114511e1ff6359272b164f1cdf718e80/cmd-set-buffer.c), +[3.4 repair](https://github.com/tmux/tmux/blob/9ae69c3795ab5ef6b4d760f6398cd9281151f632/cmd-set-buffer.c), +[3.5 shell output](https://github.com/tmux/tmux/blob/ac44566c9c7e3e94d23be6def4c7ae83472543f5/cmd-run-shell.c), +and [exact-3.7 break behavior](https://github.com/tmux/tmux/blob/81f88f8517c9fc5371b56cf117530c6b477c96ac/cmd-break-pane.c). + ## Platforms | Platform | Status | From 6f99adfc99be20b87e627ee465c8b9f2898b5437 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 22:52:12 -0500 Subject: [PATCH 45/60] CI(test): Exercise the exact 3.7 feature boundary Keep 3.3a and patched releases in the full matrix; add exact 3.7 so native name rejection and break-pane refusal/repair retain coverage. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ca6e5b1614..3cb594870e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: python-version: ['3.14'] - tmux-version: ['3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b', '3.8', 'master'] + tmux-version: ['3.3a', '3.4', '3.5', '3.6', '3.7', '3.7a', '3.7b', '3.8', 'master'] steps: - uses: actions/checkout@v7 From 80f625e9e1ab71b9b72178a712b762207ab5407d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 22:52:12 -0500 Subject: [PATCH 46/60] Changelog(docs): Record optional tmux feature policy Record the optional requirements independently of the 3.3a core minimum. --- CHANGES | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGES b/CHANGES index 19dfb5f300..c809978c75 100644 --- a/CHANGES +++ b/CHANGES @@ -76,6 +76,26 @@ Binary version and help diagnostics remain available. {meth}`Server.kill() ` requires a stable tmux 3.7+ +release and a local socket filesystem that permits a private hard link. +Control acquisition validates the retained endpoint before starting its client; +UTF-8 metadata and output remain usable under a C locale. + +Exact named-buffer deletion and rename require tmux 3.4+. Captured shell-job +stdout from {meth}`Server.run_shell() ` requires +3.5+ and is refused before launching the job on older daemons. Background, +pane-directed and native tmux-command execution remain available on 3.3a+. + +Percentage splits use portable `-l N%`. On exact tmux 3.7, raw `break-pane` +and unnamed {meth}`Pane.break_pane() ` requests are +refused. An explicit `window_name` uses the named break and rename repair; +a failed rename can leave the completed break in place. Other supported +releases retain ordinary naming. See {doc}`project/compatibility` for the +operation boundaries and synchronization requirements. + ### Deprecations #### Executable version helpers warn (#779) From 232a56d075835cc16dc441db522b38e303c376e5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 23:12:04 -0500 Subject: [PATCH 47/60] Pane(fix): Resolve successful sole-pane breaks without native output tmux 3.3a through 3.5 completes the sole-pane break path before printing -P, leaving the wrapper to index an empty result after the operation. Refresh the actual pane context on successful empty output and retain nonzero-exit refusal. Native regression cases cover named and unnamed calls with both current and externally moved pane contexts. The focused feature suite passes on 3.3a, 3.4, 3.5, exact 3.7 and 3.7d; exact 3.7 unnamed refusal remains in force. --- docs/project/compatibility.md | 1 + src/libtmux/pane.py | 14 ++++++++++--- tests/test_optional_features.py | 35 +++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index c8cd7b21f8..6fb8fb0d15 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -68,6 +68,7 @@ output synchronization or other optional behavior on every later release. | `Pane.split(percentage=...)` | Available on 3.3a+, using `-l N%`. Direct `split-window -p` is refused on the affected 3.4 interval. | | `break-pane` on exact 3.7 | Raw calls and unnamed typed calls are refused. `Pane.break_pane(window_name=...)` applies the explicit name after the move. A later rename failure can leave the moved pane in its new window. | | `break-pane` on other supported releases | Native behavior is retained. The exact-3.7 restriction does not raise their minimum. | +| Single-pane `break-pane` before 3.6 | Native success prints no window ID. The typed method rereads the pane's actual window after the command. | | Window names containing `:` or `.` | Exact 3.7 rejects creation before creating a window. Other tested supported releases retain those names. There is no automatic name substitution. | Eligibility comes from the selected daemon, including when a newer client is diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index a338f9f792..2dffd17443 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -2469,11 +2469,19 @@ def break_pane( raise_if_stderr(proc, "break-pane") - window_id = proc.stdout[0].strip() - from libtmux.window import Window - window = Window.from_window_id(server=self.server, window_id=window_id) + if proc.stdout: + window = Window.from_window_id( + server=self.server, window_id=proc.stdout[0].strip() + ) + elif proc.returncode: + msg = f"break-pane failed with exit code {proc.returncode}" + raise exc.LibTmuxException(msg, subcommand="break-pane") + else: + # Before 3.6 the sole-pane path returns before printing -P. + self.refresh() + window = self.window if window_name is not None and breaks_without_name: window.rename_window(window_name) diff --git a/tests/test_optional_features.py b/tests/test_optional_features.py index 8fab53c948..058eaf76de 100644 --- a/tests/test_optional_features.py +++ b/tests/test_optional_features.py @@ -12,6 +12,41 @@ from libtmux.common import tmux_cmd +@pytest.mark.parametrize("window_name", [None, "sole-after-break"]) +@pytest.mark.parametrize("stale_window", [False, True]) +def test_single_pane_break_returns_the_actual_window( + session: Session, window_name: str | None, stale_window: bool +) -> None: + """Old sole-pane relinking succeeds without printing its requested ID.""" + window = session.active_window + window.rename_window("sole-before-break") + pane = window.active_pane + assert pane is not None + before = window.window_id + expected_name = "sole-before-break" + if stale_window: + peer = session.new_window(window_name="actual-before-break") + peer_pane = peer.active_pane + assert peer_pane is not None + swapped = session.server.cmd( + "swap-pane", "-d", "-s", pane.pane_id, "-t", peer_pane.pane_id + ) + assert swapped.returncode == 0 + before = peer.window_id + expected_name = "actual-before-break" + if session.server._get_version() == "3.7" and window_name is None: + with pytest.raises(exc.VersionTooLow, match="unnamed break-pane"): + pane.break_pane() + pane.refresh() + assert pane.window.window_id == before + assert pane.window.window_name == expected_name + return + result = pane.break_pane(window_name=window_name) + assert result.window_id == before + assert result.window_name == (window_name or expected_name) + assert [item.pane_id for item in result.panes] == [pane.pane_id] + + @pytest.mark.parametrize( ("version", "arguments", "reason"), [ From 2ee5665a47319029459fe267576ad616ab4629dc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 23:12:04 -0500 Subject: [PATCH 48/60] Changelog(docs): Record older single-pane break result repair Note that a successful single-pane Pane.break_pane() returns its actual window on tmux 3.3a through 3.5, whose native relink path prints no ID. Keep the release note separate from runtime changes. --- CHANGES | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES b/CHANGES index c809978c75..cb75aa39b8 100644 --- a/CHANGES +++ b/CHANGES @@ -78,6 +78,9 @@ support and feature requirements. #### Optional tmux feature requirements +Single-pane `Pane.break_pane()` returns its actual window on tmux 3.3a through +3.5, whose native relink path succeeds without printing the requested ID. + Core support remains tmux 3.3a+. {class}`ControlMode ` requires a stable tmux 3.7+ release and a local socket filesystem that permits a private hard link. From eccb74417780a9ec41d8919b833e12a29b2807fa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 02:21:20 -0500 Subject: [PATCH 49/60] Tmux(fix): Validate older layout error inputs why: tmux 3.3a can terminate its daemon when select-layout reaches an invalid header. A raw batch must refuse that operand before its prefix commands execute. what: - Keep unique presets and standard serialized headers on 3.3a - Preserve native cycling, spread and undo argument handling - Carry canonical, alias and prefix calls through whole-batch validation - Advance the backend policy contract to version 2 - Record the native client pairing limit independently of the core floor --- docs/project/compatibility.md | 13 +++++- src/libtmux/_internal/support/versions.py | 28 ++++++++++++ src/libtmux/common.py | 2 +- tests/test_optional_features.py | 53 +++++++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 6fb8fb0d15..dfd75fbf95 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -69,6 +69,7 @@ output synchronization or other optional behavior on every later release. | `break-pane` on exact 3.7 | Raw calls and unnamed typed calls are refused. `Pane.break_pane(window_name=...)` applies the explicit name after the move. A later rename failure can leave the moved pane in its new window. | | `break-pane` on other supported releases | Native behavior is retained. The exact-3.7 restriction does not raise their minimum. | | Single-pane `break-pane` before 3.6 | Native success prints no window ID. The typed method rereads the pane's actual window after the command. | +| Literal `select-layout` on 3.3a | Require a unique native preset or a serialized layout header before dispatch. Unknown names and malformed headers are refused before any batch effect; valid presets, layout cycling and undo remain available. | | Window names containing `:` or `.` | Exact 3.7 rejects creation before creating a window. Other tested supported releases retain those names. There is no automatic name substitution. | Eligibility comes from the selected daemon, including when a newer client is @@ -81,11 +82,16 @@ Raw command stdout is native client output; it does not promise capture of a nested shell job's stdout. Backend consumers can require -`libtmux.common.TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 1` before handing off +`libtmux.common.TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2` before handing off work. This identifies the feature enforcement contract in the table; each operation still checks its daemon and control acquisition still checks its socket carrier. +On 3.3a, the layout guard validates the preset or serialized header before any +batch effect. Checksum, tree structure and pane-count errors retain native +reporting. The native missing-error-cause defect is fixed from 3.4 by +[the upstream layout repair](https://github.com/tmux/tmux/commit/f10854cfc5e43f646b02b3031bfc4bc3f5f6c513). + The version check assumes library-owned metadata and guard commands retain tmux's built-in semantics. Owner `command-alias` redefinitions of commands such as `display-message` or `if-shell` are outside qualification: tmux expands aliases @@ -97,6 +103,11 @@ replacement checks have been exercised on Linux/WSL; other filesystem and platform combinations need their own qualification. A successful idle attach does not establish concurrent or sustained-load stability. +Control also needs a natively compatible client. Native clients 3.3a, 3.4 and +3.5 fail to register with an exact-3.7 daemon; the daemon remains alive. A +newer compatible client can register with that same daemon. Ordinary client +operations retain the 3.3a minimum. + ### Synchronization and testing Version eligibility does not remove readiness, buffering or completion waits. diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 8c26a1812f..a259d1187d 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -183,6 +183,34 @@ def _require_commands( for command in _command_items(arguments): name = command[0] if not is_at_least(version, "3.4") and ( + name == "selectl" or (len(name) >= 8 and "select-layout".startswith(name)) + ): + options, operands = getopt.getopt(command[1:], "Enopt:") + flags = {flag for flag, _ in options} + if not operands or flags & {"-E", "-n", "-p"}: + continue + layout = operands[0] + # Before 3.4 an invalid header leaves the native error cause unset. + if re.fullmatch(r"[0-9a-fA-F]{4},[\s\S]+", layout): + continue + presets = ( + "even-horizontal", + "even-vertical", + "main-horizontal", + "main-vertical", + "tiled", + ) + if ( + "-o" not in flags + and sum(item.startswith(layout) for item in presets) == 1 + ): + continue + msg = ( + "invalid layout refused before dispatch on tmux 3.3a; " + "use a unique built-in layout name or a serialized layout" + ) + raise ValueError(msg) + elif not is_at_least(version, "3.4") and ( name == "deleteb" or "delete-buffer".startswith(name) ): options, _ = getopt.getopt(command[1:], "b:") diff --git a/src/libtmux/common.py b/src/libtmux/common.py index d3150b9293..a04f5c00b8 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -75,7 +75,7 @@ def __getattr__(name: str) -> t.Any: #: Version of the optional-feature policy contract for backend consumers. -TMUX_OPTIONAL_FEATURE_POLICY_VERSION = 1 +TMUX_OPTIONAL_FEATURE_POLICY_VERSION = 2 SessionDict = dict[str, t.Any] diff --git a/tests/test_optional_features.py b/tests/test_optional_features.py index 058eaf76de..750ed768ed 100644 --- a/tests/test_optional_features.py +++ b/tests/test_optional_features.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shlex import subprocess import typing as t @@ -11,6 +12,58 @@ from libtmux._internal.support import versions from libtmux.common import tmux_cmd +if t.TYPE_CHECKING: + import pathlib + + +@pytest.mark.parametrize("verb", ["select-layout", "selectl", "select-l"]) +def test_malformed_layout_is_refused_before_any_batch_effect( + session: Session, tmp_path: pathlib.Path, verb: str +) -> None: + """Refuse the 3.3a layout error path before the batch prefix runs.""" + server = session.server + marker = tmp_path / "layout-prefix-effect" + command = ( + "run-shell", + f": > {shlex.quote(str(marker))}", + ";", + verb, + "-t", + session.active_window.window_id, + "not-a-layout", + ) + if server._get_version() == "3.3a": + with pytest.raises(ValueError, match="layout"): + server.cmd(*command) + assert not marker.exists() + else: + result = server.cmd(*command) + assert result.returncode != 0 + assert marker.exists() + assert server.is_alive() + + +def test_33a_layout_validation_retains_native_safe_forms(session: Session) -> None: + """Preset prefixes, serialized forms and ignored operands stay available.""" + server = session.server + if server._get_version() != "3.3a": + pytest.skip("the narrow layout error guard applies to tmux 3.3a") + target = session.active_window.window_id + for flag in ("-n", "-p", "-E"): + assert server.cmd("selectl", flag, "-t", target, "not-a-layout").returncode == 0 + assert server.cmd("select-layout", "-t", target, "even-h").returncode == 0 + layout = server.cmd( + "display-message", "-p", "-t", target, "#{window_layout}" + ).stdout[0] + assert server.cmd("select-layout", "-t", target, layout).returncode == 0 + assert server.cmd("select-layout", "-o", "-t", target, layout).returncode == 0 + assert server.cmd("select-layout", "-o", "-t", target).returncode == 0 + invalid = server.cmd("select-layout", "-t", target, "0000,not-a-layout") + assert invalid.returncode != 0 + with pytest.raises(ValueError, match="layout"): + server.cmd("select-layout", "-o", "-t", target, "tiled") + assert server.is_alive() + @pytest.mark.parametrize("window_name", [None, "sole-after-break"]) @pytest.mark.parametrize("stale_window", [False, True]) From 33767f783515c0e5e6ab35152586af74f6f11c36 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 02:21:20 -0500 Subject: [PATCH 50/60] Changelog(docs): Record older layout validation --- CHANGES | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES b/CHANGES index cb75aa39b8..943f99b160 100644 --- a/CHANGES +++ b/CHANGES @@ -78,6 +78,10 @@ support and feature requirements. #### Optional tmux feature requirements +On tmux 3.3a, literal `select-layout` refuses unknown names and malformed +serialized headers before batch effects. Optional policy version 2 includes +this validation; ordinary layouts retain the 3.3a minimum. + Single-pane `Pane.break_pane()` returns its actual window on tmux 3.3a through 3.5, whose native relink path succeeds without printing the requested ID. From 489f943ec8672b70eafd7f831c0bcdec81d75662 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 03:41:58 -0500 Subject: [PATCH 51/60] Control(fix): Identify the control daemon without firing hooks ControlMode read the daemon's version, pid, start time and socket path with display-message -p, which fires a user's after-display-message hook before the pin or the 3.7 eligibility check could refuse anything. Read the same fields with `-N run-shell -C`, whose failed parse reports the expanded text without running an after-* or command-error hook. The expanded text is parsed as a tmux command, so the fields are comma separated and the socket path, which may hold any character, is single-quoted with embedded quotes re-opened. A report spanning several lines is refused so a newline in the path cannot truncate it. Tests: a native test sets after-display-message and command-error hooks, acquires and releases a control client, then fires a control display-message and a failing command; each sentinel holding exactly one letter shows the pin fired none. A second test reads a socket path holding quotes, a semicolon, braces, a hash, a dollar sign and a comma. The Popen trackers ignore the probe's own -C. The mocked probes in test_optional_features.py answer in the run-shell format. Verification: restoring display-message fails the hook test. The control, version check and optional-feature modules pass on 3.7d; ruff and mypy are clean. --- src/libtmux/_internal/control_mode.py | 26 +++++++++---- tests/test_control_mode.py | 56 ++++++++++++++++++++++++++- tests/test_optional_features.py | 21 +++++++--- 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index c60e904f6c..8e3554eeb6 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -10,6 +10,7 @@ import subprocess import tempfile import typing as t +import uuid from libtmux import exc from libtmux._internal.support import versions @@ -24,18 +25,29 @@ from libtmux.server import Server from libtmux.session import Session -_IDENTITY = "#{version}\t#{pid}\t#{start_time}\t#{socket_path}" +# run-shell -C parses the expanded text, so the fields avoid whitespace and the +# socket path, which may hold anything, is single-quoted with quotes re-opened. +_IDENTITY = r"#{version},#{pid},#{start_time},'#{s/'/'\\''/:socket_path}'" def _identity(binary: str, selectors: list[str]) -> str: + # run-shell -C fires no after-* or command-error hook; the expanded + # identity comes back in the error text of its failed parse. + marker = "libtmux-identity-" + uuid.uuid4().hex + "-" result = versions._run( - [binary, "-u", *selectors, "-N", "display-message", "-p", _IDENTITY] + [binary, "-u", *selectors, "-N", "run-shell", "-C", marker + _IDENTITY] ) - report = result.stdout.rstrip("\n") - fields = report.split("\t", 3) + prefix = f"unknown command: {marker}" + # A single line: a newline inside the socket path must not truncate it. + lines = result.stderr.rstrip("\n").split("\n") + report = ( + lines[0][len(prefix) :] + if result.returncode == 1 and len(lines) == 1 and lines[0].startswith(prefix) + else "" + ) + fields = report.split(",", 3) if ( - result.returncode - or "\n" in report + not report or len(fields) != 4 or not fields[1].isdigit() or not fields[2].isdigit() @@ -62,7 +74,7 @@ def _require_release(version: str) -> None: def _pinned_endpoint(server: Server) -> Iterator[list[str]]: binary, selectors = server._version_endpoint() identity = _identity(binary, selectors) - version, _, _, socket = identity.split("\t", 3) + version, _, _, socket = identity.split(",", 3) _require_release(version) original = pathlib.Path(socket) diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index 52402c1f80..2503b01b97 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -37,7 +37,7 @@ def test_ineligible_control_refuses_before_startup( def tracked( argv: list[str], *args: t.Any, **kwargs: t.Any ) -> subprocess.Popen[str]: - if "-C" in argv: + if "-C" in argv and "run-shell" not in argv: starts.append(argv) return original(argv, *args, **kwargs) @@ -69,7 +69,7 @@ def test_unavailable_socket_link_refuses_before_control( def tracked( argv: list[str], *args: t.Any, **kwargs: t.Any ) -> subprocess.Popen[str]: - if "-C" in argv: + if "-C" in argv and "run-shell" not in argv: starts.append(argv) return original(argv, *args, **kwargs) @@ -99,6 +99,58 @@ def test_control_mode_creates_client( assert ctl.client_name != "" +def test_control_identity_probe_fires_no_user_hooks( + control_mode: t.Callable[[], ControlMode], + server: Server, +) -> None: + """Pinning a control endpoint never runs the user's display-message hooks.""" + from libtmux.test.retry import retry_until + + server.cmd("set-hook", "-g", "after-display-message", "set -ag @probe-after x") + server.cmd("set-hook", "-g", "command-error", "set -ag @probe-error y") + with control_mode(): + pass + + # Hooks run in order, so one control firing afterwards proves the pinning + # left none: each sentinel holds exactly one letter. + server.cmd("display-message", "-p", "control") + assert server.cmd("kill-session", "-t", "=missing").returncode != 0 + + def settled() -> bool: + return server.cmd("show-option", "-gqv", "@probe-after").stdout == [ + "x" + ] and server.cmd("show-option", "-gqv", "@probe-error").stdout == ["y"] + + assert retry_until(settled, seconds=5, raises=False) + assert server.cmd("show-option", "-gqv", "@probe-after").stdout == ["x"] + assert server.cmd("show-option", "-gqv", "@probe-error").stdout == ["y"] + + +def test_control_identity_reports_an_unusual_socket_path( + control_mode: t.Callable[[], ControlMode], +) -> None: + """Quotes, separators and commas in a socket path survive the probe.""" + import shutil + import tempfile + + from libtmux import Server + from libtmux._internal.control_mode import _identity + + binary, _ = control_mode().server._version_endpoint() + folder = tempfile.mkdtemp(prefix="lt-") + path = pathlib.Path(folder) / "q'\"; {a} #~$x,\\b" + path.mkdir() + socket = path / "s" + server = Server(tmux_bin=binary, socket_path=str(socket), config_file="/dev/null") + try: + server.new_session("identity", window_command="exec sleep 123") + report = _identity(binary, ["-S", str(socket)]) + assert report.split(",", 3)[3] == str(socket) + finally: + server.cmd("kill-server") + shutil.rmtree(folder, ignore_errors=True) + + def test_control_mode_cleanup( control_mode: t.Callable[[], ControlMode], server: Server, diff --git a/tests/test_optional_features.py b/tests/test_optional_features.py index 750ed768ed..66e2930171 100644 --- a/tests/test_optional_features.py +++ b/tests/test_optional_features.py @@ -16,6 +16,13 @@ import pathlib +def _reply(argv: list[str], version: str) -> subprocess.CompletedProcess[str]: + """Answer a version probe the way a live daemon does.""" + return subprocess.CompletedProcess( + argv, 1, "", f"unknown command: {argv[-1].replace('#{version}', version)}\n" + ) + + @pytest.mark.parametrize("verb", ["select-layout", "selectl", "select-l"]) def test_malformed_layout_is_refused_before_any_batch_effect( session: Session, tmp_path: pathlib.Path, verb: str @@ -131,7 +138,7 @@ def test_refused_native_feature_has_no_dispatch( monkeypatch.setattr( versions, "_run", - lambda argv: subprocess.CompletedProcess(argv, 0, version + "\n", ""), + lambda argv: _reply(argv, version), ) def unexpected(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: @@ -170,7 +177,7 @@ def test_native_feature_scope_preserves_arguments( monkeypatch.setattr( versions, "_run", - lambda argv: subprocess.CompletedProcess(argv, 0, version + "\n", ""), + lambda argv: _reply(argv, version), ) guarded, _ = versions.prepare(["/test/tmux", *arguments]) assert guarded[-len(arguments) :] == arguments @@ -188,8 +195,10 @@ def test_capture_refusal_uses_actual_daemon(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr( versions, "_run", - lambda argv: subprocess.CompletedProcess( - argv, 0, "tmux 3.7d\n" if "-V" in argv else "3.3a\n", "" + lambda argv: ( + subprocess.CompletedProcess(argv, 0, "tmux 3.7d\n", "") + if "-V" in argv + else _reply(argv, "3.3a") ), ) @@ -208,7 +217,7 @@ def test_unnamed_exact_37_break_is_refused(monkeypatch: pytest.MonkeyPatch) -> N monkeypatch.setattr( versions, "_run", - lambda argv: subprocess.CompletedProcess(argv, 0, "3.7\n", ""), + lambda argv: _reply(argv, "3.7"), ) pane = Pane(server=Server(tmux_bin="/test/tmux"), pane_id="%1") with pytest.raises(exc.VersionTooLow, match="provide window_name"): @@ -222,7 +231,7 @@ def test_capability_selection_cannot_cross_daemon_release( monkeypatch.setattr( versions, "_run", - lambda argv: subprocess.CompletedProcess(argv, 0, "3.3a\n", ""), + lambda argv: _reply(argv, "3.3a"), ) with pytest.raises(exc.VersionTooLow, match="selecting"): versions.prepare( From 2b75c2bc365c9c11d35d03e97a2d449f43257ffc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 03:41:58 -0500 Subject: [PATCH 52/60] Changelog(docs): Record hook-free control acquisition Note that control acquisition runs no user hooks before it validates the endpoint. Keep the release note separate from runtime changes. --- CHANGES | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 943f99b160..0956bf67cf 100644 --- a/CHANGES +++ b/CHANGES @@ -89,7 +89,8 @@ Core support remains tmux 3.3a+. {class}`ControlMode ` requires a stable tmux 3.7+ release and a local socket filesystem that permits a private hard link. Control acquisition validates the retained endpoint before starting its client; -UTF-8 metadata and output remain usable under a C locale. +UTF-8 metadata and output remain usable under a C locale. Acquisition runs no +user hooks before it validates the endpoint. Exact named-buffer deletion and rename require tmux 3.4+. Captured shell-job stdout from {meth}`Server.run_shell() ` requires From a3736a478038223019aed823bc5fc4415af35b17 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:13:43 -0500 Subject: [PATCH 53/60] Tmux(fix): Raise a library exception for refused layouts On 3.3a an invalid select-layout string was refused with a bare ValueError, while 3.4 and newer failed the same call with a LibTmuxException, so callers that catch the library's base class (tmuxp's builder, MCP tool error mapping) saw a different type only on the floor release. Add InvalidLayout, which is a LibTmuxException and still a ValueError for existing handlers, and raise it from the guard. Test: the 3.3a refusal tests expect InvalidLayout, and a hierarchy test pins both bases. Restoring the bare ValueError fails the 3.3a refusal cases. The optional-feature module passes on 3.3a. --- src/libtmux/_internal/support/versions.py | 2 +- src/libtmux/exc.py | 4 ++++ tests/test_optional_features.py | 10 ++++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index a259d1187d..62e3c8c41b 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -209,7 +209,7 @@ def _require_commands( "invalid layout refused before dispatch on tmux 3.3a; " "use a unique built-in layout name or a serialized layout" ) - raise ValueError(msg) + raise exc.InvalidLayout(msg) elif not is_at_least(version, "3.4") and ( name == "deleteb" or "delete-buffer".startswith(name) ): diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 57bb06102f..3926059ca9 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -308,6 +308,10 @@ class VersionTooLow(LibTmuxException): """Raised if tmux below the minimum version to use libtmux.""" +class InvalidLayout(LibTmuxException, ValueError): + """Layout refused before dispatch because tmux 3.3a would mishandle it.""" + + class BadSessionName(LibTmuxException): """Disallowed session name for tmux (empty, contains periods or colons).""" diff --git a/tests/test_optional_features.py b/tests/test_optional_features.py index 66e2930171..37515b9e64 100644 --- a/tests/test_optional_features.py +++ b/tests/test_optional_features.py @@ -40,7 +40,7 @@ def test_malformed_layout_is_refused_before_any_batch_effect( "not-a-layout", ) if server._get_version() == "3.3a": - with pytest.raises(ValueError, match="layout"): + with pytest.raises(exc.InvalidLayout, match="layout"): server.cmd(*command) assert not marker.exists() else: @@ -50,6 +50,12 @@ def test_malformed_layout_is_refused_before_any_batch_effect( assert server.is_alive() +def test_refused_layout_is_a_library_exception() -> None: + """Callers that catch LibTmuxException see the refusal on every release.""" + assert issubclass(exc.InvalidLayout, exc.LibTmuxException) + assert issubclass(exc.InvalidLayout, ValueError) + + def test_33a_layout_validation_retains_native_safe_forms(session: Session) -> None: """Preset prefixes, serialized forms and ignored operands stay available.""" server = session.server @@ -67,7 +73,7 @@ def test_33a_layout_validation_retains_native_safe_forms(session: Session) -> No assert server.cmd("select-layout", "-o", "-t", target).returncode == 0 invalid = server.cmd("select-layout", "-t", target, "0000,not-a-layout") assert invalid.returncode != 0 - with pytest.raises(ValueError, match="layout"): + with pytest.raises(exc.InvalidLayout, match="layout"): server.cmd("select-layout", "-o", "-t", target, "tiled") assert server.is_alive() From 6cedde5e7c3832d2e67329d1bcf5c07675b512aa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:15:14 -0500 Subject: [PATCH 54/60] Core(feat): Expose the command policy through the support module Backend consumers identified the optional-feature contract by an integer, libtmux.common.TMUX_OPTIONAL_FEATURE_POLICY_VERSION, and called the private _require_commands. Publish version check.require_commands(version, argv, repaired_break=False) and drop the marker: a consumer that needs the policy imports the function, and an older libtmux without it fails the import rather than passing a number check. Test: the 3.3a refusals for named-buffer deletion and layouts, the 3.4 control, paste-and-delete staying available, and the exact 3.7 break-pane refusal and its explicit-name repair. Replacing the function body with a no-op fails the new test. The version check module and its doctest pass on 3.7d. --- src/libtmux/_internal/support/__init__.py | 20 ++++++++++++++++++++ tests/test_versions.py | 16 ++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/libtmux/_internal/support/__init__.py b/src/libtmux/_internal/support/__init__.py index d190441cb3..65191d708a 100644 --- a/src/libtmux/_internal/support/__init__.py +++ b/src/libtmux/_internal/support/__init__.py @@ -7,6 +7,8 @@ - :func:`server_version` reads the release a :class:`~libtmux.Server` talks to. - :func:`require_version` and :func:`is_at_least` compare reports with a floor. +- :func:`require_commands` refuses literal commands that a checked release + cannot run safely, such as named-buffer deletion before 3.4. - :func:`check_fence` turns the replaced-daemon marker of a fenced batch into :exc:`~libtmux.exc.VersionTooLow`. @@ -21,6 +23,7 @@ False >>> support.server_version(server) == server._get_version() True +>>> support.require_commands("3.3a", ["list-sessions"]) """ from __future__ import annotations @@ -29,6 +32,7 @@ from libtmux._internal.support.versions import ( MINIMUM, + _require_commands, check_result, is_at_least, require_version, @@ -43,6 +47,7 @@ "VersionTooLow", "check_fence", "is_at_least", + "require_commands", "require_version", "server_version", ) @@ -80,3 +85,18 @@ def check_fence(stderr: str, marker: str | None, server: Server | None = None) - if server is not None: server._version_cache.clear() raise + + +def require_commands( + version: str, arguments: list[str], *, repaired_break: bool = False +) -> None: + """Refuse literal tmux commands that *version* cannot run safely. + + *arguments* is the argv after any global options, with ``;`` ending each + command as tmux reads it. Raises :exc:`~libtmux.exc.VersionTooLow` for a + command whose feature needs a newer release (exact named-buffer deletion + and rename before 3.4, ``split-window -p`` on 3.4, raw ``break-pane`` on + 3.7 unless *repaired_break* says the caller names the window), and + :exc:`~libtmux.exc.InvalidLayout` for a layout 3.3a would mishandle. + """ + _require_commands(version, arguments, repaired_break=repaired_break) diff --git a/tests/test_versions.py b/tests/test_versions.py index d85106e8ef..ba9329f397 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -249,6 +249,7 @@ def test_support_surface(monkeypatch: pytest.MonkeyPatch) -> None: "server_version", "check_fence", "is_at_least", + "require_commands", "require_version", } monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3")) @@ -261,6 +262,21 @@ def test_support_surface(monkeypatch: pytest.MonkeyPatch) -> None: support.check_fence("unknown command: other\n", "private-marker") +def test_public_command_policy() -> None: + """Consumers can ask whether a checked release may run a literal command.""" + with pytest.raises(exc.VersionTooLow, match=r"3\.4"): + support.require_commands("3.3a", ["delete-buffer", "-b", "named"]) + with pytest.raises(exc.InvalidLayout): + support.require_commands("3.3a", ["select-layout", "not-a-layout"]) + support.require_commands("3.4", ["delete-buffer", "-b", "named"]) + support.require_commands("3.3a", ["paste-buffer", "-d", "-b", "named"]) + with pytest.raises(exc.VersionTooLow, match="break-pane"): + support.require_commands("3.7", ["break-pane", "-s", "%1"]) + support.require_commands( + "3.7", ["break-pane", "-n", "named", "-s", "%1"], repaired_break=True + ) + + def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> None: """Keep native argv and batch separators after the version fence.""" monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) From ce0e46f879f81fcb1781b07b4630ab40bad5fb5d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:15:14 -0500 Subject: [PATCH 55/60] Support(docs): Point backend consumers at the support module Replace the policy-version paragraph: programs that run their own tmux subprocesses apply the optional-feature checks through libtmux._internal.support.require_commands. --- docs/project/compatibility.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index dfd75fbf95..30840a8e80 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -81,11 +81,10 @@ remain outside this guarantee. Raw command stdout is native client output; it does not promise capture of a nested shell job's stdout. -Backend consumers can require -`libtmux.common.TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2` before handing off -work. This identifies the feature enforcement contract in the table; each -operation still checks its daemon and control acquisition still checks its -socket carrier. +Programs that run their own tmux subprocesses can apply the same checks +through {mod}`libtmux._internal.support`: `require_commands` refuses the literal +commands in the table for a checked release. Each libtmux operation still +checks its daemon, and control acquisition still checks its socket carrier. On 3.3a, the layout guard validates the preset or serialized header before any batch effect. Checksum, tree structure and pane-count errors retain native From ffdf05c95fe2cc39174255cce38d4326080a2c71 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:15:22 -0500 Subject: [PATCH 56/60] Changelog(docs): Record the layout exception and run_shell return type Note InvalidLayout, the public command policy and the return-type change of Server.run_shell(target_pane=...) from an empty list to None. Keep the release note separate from runtime changes. --- CHANGES | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 0956bf67cf..ae22e560a3 100644 --- a/CHANGES +++ b/CHANGES @@ -79,8 +79,11 @@ support and feature requirements. #### Optional tmux feature requirements On tmux 3.3a, literal `select-layout` refuses unknown names and malformed -serialized headers before batch effects. Optional policy version 2 includes -this validation; ordinary layouts retain the 3.3a minimum. +serialized headers before batch effects with +{exc}`~libtmux.exc.InvalidLayout`, a {exc}`~libtmux.exc.LibTmuxException` that is +also a `ValueError`; ordinary layouts retain the 3.3a minimum. Programs that run +their own tmux subprocesses can apply these checks through +{func}`libtmux._internal.support.require_commands`. Single-pane `Pane.break_pane()` returns its actual window on tmux 3.3a through 3.5, whose native relink path succeeds without printing the requested ID. @@ -97,6 +100,11 @@ stdout from {meth}`Server.run_shell() ` requires 3.5+ and is refused before launching the job on older daemons. Background, pane-directed and native tmux-command execution remain available on 3.3a+. +{meth}`Server.run_shell(target_pane=...) ` now returns +`None`, as its signature documents, instead of an empty list: the job's output +goes to the pane, so there is no captured stdout to return. Code that iterates +the result of a pane-directed call must handle `None`. + Percentage splits use portable `-l N%`. On exact tmux 3.7, raw `break-pane` and unnamed {meth}`Pane.break_pane() ` requests are refused. An explicit `window_name` uses the named break and rename repair; From 224c072177585c160afdea7e87b931c87e343eaa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 10:42:42 -0500 Subject: [PATCH 57/60] Control(fix): Rank control eligibility strictly and name clients locally ControlMode refused `3.8-rc2` and `next-3.8` because it required a stable `X.Y[a-z]` report on top of the ranked comparison. Check anything that ranks at or above 3.7 final under the same strict ordering as version check, and refuse `next-3.7` and `3.7-rc*`, which rank below it. Registration also asked `list-clients` for the control client's name, a command a user can hook. tmux names a client with no tty `client-`, so set that name from the spawned process and wait for registration with the hook-free `run-shell -C '#{L:#{client_pid},}'` instead. Tests: a table checks 3.7, 3.7a, 3.7d, 3.8, 3.8-rc, 3.8-rc2, next-3.8 and next-3.9 and refuses 3.6b, 3.7-rc, 3.7-rc3, next-3.7, 3.3a, master and the empty string; a live client's name equals tmux's own; the hook test now also arms after-list-clients, after-list-panes, after-list-sessions and after-refresh-client and asserts none fired. Restoring the stable-only check fails four version check cases; restoring the list-clients poll fails the hook test. The control module passes on 3.7d (27 passed, 1 skipped). --- src/libtmux/_internal/control_mode.py | 26 +++++++------- tests/test_control_mode.py | 49 +++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index 8e3554eeb6..fcc0c79dfd 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -5,7 +5,6 @@ import contextlib import os import pathlib -import re import stat import subprocess import tempfile @@ -60,14 +59,12 @@ def _identity(binary: str, selectors: list[str]) -> str: def _require_release(version: str) -> None: + # Strict ranking: 3.8-rc2 and next-3.8 qualify, next-3.7 and 3.7-rc* do not. try: versions.require_version(version, minimum="3.7") except exc.VersionTooLow: msg = f"ControlMode requires tmux 3.7 or newer; daemon reports {version!r}" raise exc.VersionTooLow(msg) from None - if re.fullmatch(r"\d+\.\d+[a-z]?", version) is None: - msg = f"ControlMode requires a qualified release; daemon reports {version!r}" - raise exc.VersionTooLow(msg) @contextlib.contextmanager @@ -185,21 +182,26 @@ def __enter__(self) -> Self: self.stdout = self._proc.stdout client_pid = str(self._proc.pid) + # tmux names a client with no tty "client-"; ask for the live + # client pids with run-shell -C, which fires no hook. + self.client_name = f"client-{client_pid}" + def client_registered() -> bool: if self._proc.poll() is not None: assert self._proc.stderr is not None detail = self._proc.stderr.read().strip() msg = f"ControlMode client exited before registration: {detail}" raise exc.LibTmuxException(msg) - result = self.server.cmd( - "-u", "list-clients", "-F", "#{client_pid}\t#{client_name}" + marker = "libtmux-clients-" + uuid.uuid4().hex + "-" + result = versions._run( + [*endpoint, "run-shell", "-C", marker + "#{L:#{client_pid},}"] + ) + prefix = f"unknown command: {marker}" + return any( + client_pid in line[len(prefix) :].split(",") + for line in result.stderr.splitlines() + if line.startswith(prefix) ) - for line in result.stdout: - pid, _, client_name = line.partition("\t") - if pid == client_pid and client_name: - self.client_name = client_name.strip() - return True - return False retry_until(client_registered, 3, raises=True) self._resources = resources.pop_all() diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index 2503b01b97..15c1ca4270 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -6,7 +6,6 @@ import locale import os import pathlib -import re import select import subprocess import time @@ -15,7 +14,8 @@ import pytest from libtmux import exc -from libtmux._internal.control_mode import ControlMode +from libtmux._internal import support +from libtmux._internal.control_mode import ControlMode, _require_release from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: @@ -28,8 +28,7 @@ def test_ineligible_control_refuses_before_startup( ) -> None: """Refusal stays exercised on old and unqualified development daemons.""" version = server._get_version() - stable = re.fullmatch(r"(\d+)\.(\d+)[a-z]?", version) - if stable is not None and (int(stable[1]), int(stable[2])) >= (3, 7): + if support.is_at_least(version, "3.7"): pytest.skip("requires an ineligible control release") original = subprocess.Popen starts: list[list[str]] = [] @@ -56,8 +55,7 @@ def test_unavailable_socket_link_refuses_before_control( ) -> None: """A failed pin leaves no control client or temporary socket directory.""" version = server._get_version() - stable = re.fullmatch(r"(\d+)\.(\d+)[a-z]?", version) - if stable is None or (int(stable[1]), int(stable[2])) < (3, 7): + if not support.is_at_least(version, "3.7"): pytest.skip("requires an eligible control release") socket = pathlib.Path( server.cmd("display-message", "-p", "#{socket_path}").stdout[0] @@ -88,6 +86,35 @@ def unavailable(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: assert set(socket.parent.glob(".lt-*")) == before +@pytest.mark.parametrize( + "version", + ["3.7", "3.7a", "3.7d", "3.8", "3.8-rc", "3.8-rc2", "next-3.8", "next-3.9"], +) +def test_control_admits_what_ranks_at_or_above_3_7(version: str) -> None: + """Candidates and development builds above 3.7 final qualify for control.""" + _require_release(version) + + +@pytest.mark.parametrize( + "version", + ["3.6b", "3.7-rc", "3.7-rc3", "next-3.7", "3.3a", "3.7a-dev", "master", ""], +) +def test_control_refuses_what_ranks_below_3_7(version: str) -> None: + """A build below 3.7 final does not inherit the 3.7 initialization fix.""" + with pytest.raises(exc.VersionTooLow, match=r"3\.7"): + _require_release(version) + + +def test_control_client_is_named_from_its_pid( + control_mode: t.Callable[[], ControlMode], server: Server +) -> None: + """The name is derived locally, and it is the name tmux gave the client.""" + with control_mode() as ctl: + assert ctl.client_name == f"client-{ctl._proc.pid}" + names = set(server.cmd("list-clients", "-F", "#{client_name}").stdout) + assert ctl.client_name in names + + def test_control_mode_creates_client( control_mode: t.Callable[[], ControlMode], server: Server, @@ -108,6 +135,15 @@ def test_control_identity_probe_fires_no_user_hooks( server.cmd("set-hook", "-g", "after-display-message", "set -ag @probe-after x") server.cmd("set-hook", "-g", "command-error", "set -ag @probe-error y") + # Acquisition must not run any query a user could hook, such as the old + # list-clients name lookup. + for hook in ( + "after-list-clients", + "after-list-panes", + "after-list-sessions", + "after-refresh-client", + ): + server.cmd("set-hook", "-g", hook, "set -ag @probe-extra z") with control_mode(): pass @@ -124,6 +160,7 @@ def settled() -> bool: assert retry_until(settled, seconds=5, raises=False) assert server.cmd("show-option", "-gqv", "@probe-after").stdout == ["x"] assert server.cmd("show-option", "-gqv", "@probe-error").stdout == ["y"] + assert server.cmd("show-option", "-gqv", "@probe-extra").stdout == [] def test_control_identity_reports_an_unusual_socket_path( From 243e04835be329a7e5f62ee602cf1ac1050081c6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 10:49:51 -0500 Subject: [PATCH 58/60] Features(fix): Refuse delimiters in exact-3.7 window and session names tmux 3.7 rejects `:` and `.` in window and session names; 3.7a reverted it. libtmux checked only session names, on every release, and left window names to tmux's own error after the command had started. Check `new-window -n`, `new-session -s/-n`, `rename-window` and `rename-session` (including aliases, prefixes and `;`-joined items) before dispatch when the daemon is exactly 3.7, in the same command policy that guards named buffers and break-pane, so typed methods, raw commands, batches and the libtmux-mcp/tmuxp paths that call the policy inherit it. The refusal is a VersionTooLow naming the name. Tests: nine name commands are refused on 3.7, pass on 3.3a, 3.6b, 3.7a, 3.8 and 3.8-rc2, and targets or formats holding delimiters stay allowed. The native window and session tests pass on a real exact-3.7 build. Removing the check fails all nine refusal cases. --- src/libtmux/_internal/support/versions.py | 35 ++++++++++++++++++ tests/test_optional_features.py | 44 +++++++++++++++++++++++ tests/test_window.py | 4 ++- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 62e3c8c41b..645c69a0a1 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -177,11 +177,46 @@ def _command_items(arguments: list[str]) -> list[list[str]]: return commands +def _matches(name: str, full: str, alias: str) -> bool: + """Report whether *name* is tmux's alias or a long-enough prefix of *full*.""" + return name == alias or (len(name) >= 5 and full.startswith(name)) + + +def _require_names(version: str, command: list[str]) -> None: + """Refuse names that exact tmux 3.7 rejects, before any command runs. + + 3.7 rejects ``:`` and ``.`` in session and window names; 3.7a reverted it. + """ + if version != "3.7": + return + name = command[0] + values: list[str] = [] + if _matches(name, "new-window", "neww"): + options, _ = getopt.getopt(command[1:], "abdkPSt:F:c:e:n:") + values = [value for flag, value in options if flag == "-n"] + elif _matches(name, "new-session", "new"): + options, _ = getopt.getopt(command[1:], "AdDEPXc:e:f:F:n:s:t:x:y:") + values = [value for flag, value in options if flag in {"-n", "-s"}] + elif _matches(name, "rename-window", "renamew") or _matches( + name, "rename-session", "rename" + ): + _, operands = getopt.getopt(command[1:], "t:") + values = operands[-1:] + for value in values: + if ":" in value or "." in value: + msg = ( + f"invalid window or session name {value!r}: tmux 3.7 rejects " + "':' and '.' in names; choose another name or upgrade to 3.7a" + ) + raise exc.VersionTooLow(msg) + + def _require_commands( version: str, arguments: list[str], *, repaired_break: bool ) -> None: for command in _command_items(arguments): name = command[0] + _require_names(version, command) if not is_at_least(version, "3.4") and ( name == "selectl" or (len(name) >= 8 and "select-layout".startswith(name)) ): diff --git a/tests/test_optional_features.py b/tests/test_optional_features.py index 37515b9e64..8dfa161d96 100644 --- a/tests/test_optional_features.py +++ b/tests/test_optional_features.py @@ -275,3 +275,47 @@ def test_option_aliases_keep_native_meaning(session: Session) -> None: "server-value" ] assert server.cmd("show-window-options", "-gv", "mode-keys").stdout == ["vi"] + + +NAME_COMMANDS = [ + ["new-window", "-n", "a.b"], + ["neww", "-dn", "a:b", "-t", "=s"], + ["new-session", "-d", "-s", "a.b"], + ["new", "-d", "-n", "x:y", "-s", "ok"], + ["rename-window", "-t", "@1", "a.b"], + ["renamew", "a:b"], + ["rename-session", "-t", "=s", "a.b"], + ["rename", "a:b"], + ["set-option", "-g", "@x", "1", ";", "new-window", "-n", "a.b"], +] + + +@pytest.mark.parametrize("command", NAME_COMMANDS) +def test_exact_37_refuses_delimiters_in_window_and_session_names( + command: list[str], +) -> None: + """Window and session names are checked before the batch runs on 3.7.""" + with pytest.raises(exc.VersionTooLow, match="invalid window or session name"): + versions._require_commands("3.7", command, repaired_break=False) + + +@pytest.mark.parametrize("command", NAME_COMMANDS) +@pytest.mark.parametrize("version", ["3.3a", "3.6b", "3.7a", "3.8", "3.8-rc2"]) +def test_other_releases_keep_native_names(version: str, command: list[str]) -> None: + """Only the 3.7 release rejected these names; 3.7a and the rest accept them.""" + versions._require_commands(version, command, repaired_break=False) + + +@pytest.mark.parametrize( + "command", + [ + ["new-window", "-n", "plain"], + ["new-window", "-t", "=s:1", "-n", "plain"], + ["rename-window", "-t", "@1", "plain"], + ["new-session", "-d", "-s", "plain", "-n", "also-plain"], + ["list-windows", "-F", "#{window_name}.x"], + ], +) +def test_exact_37_allows_names_without_delimiters(command: list[str]) -> None: + """Targets and formats may hold delimiters; only the name does not.""" + versions._require_commands("3.7", command, repaired_break=False) diff --git a/tests/test_window.py b/tests/test_window.py index 07d9aeb093..650c7ae66d 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -284,7 +284,9 @@ def test_new_window_name_colon_period_accepted( """Preserve native names or exact 3.7's rejection before creation.""" if session.server._get_version() == "3.7": before = [window.window_id for window in session.windows] - with pytest.raises(exc.LibTmuxException, match="invalid window name"): + with pytest.raises( + exc.LibTmuxException, match="invalid window or session name" + ): session.new_window(window_name=window_name) assert [window.window_id for window in session.windows] == before return From debe145b77da98a706e1c15538c6b2feaa74a12e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 10:50:14 -0500 Subject: [PATCH 59/60] Support(docs): Remove stale 3.2 floors from topic pages and format docs The options and hooks page, the filtering comparison and the format-field docstrings still named tmux 3.2 or 3.2a as the project minimum. State 3.3a, the supported floor; the 3.3 entries in the format table are always met. --- docs/topics/filtering.md | 2 +- docs/topics/options_and_hooks.md | 9 +++++---- src/libtmux/neo.py | 15 ++++++++------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/topics/filtering.md b/docs/topics/filtering.md index 02cbbf4b22..5f8873fb20 100644 --- a/docs/topics/filtering.md +++ b/docs/topics/filtering.md @@ -450,7 +450,7 @@ fan-out. | Filter language | libtmux's lookup operators (`__contains`, `__regex`, etc.) | tmux's [FORMATS](https://man.openbsd.org/tmux.1#FORMATS) grammar | | Round trips | one (full list, then filter in memory) | one (tmux returns only matches) | | Best for | rich Python checks, set membership, post-fetch composition | exact/glob matches over many rows | -| Stability | every libtmux version supports it | requires tmux ≥ 3.2 | +| Stability | every libtmux version supports it | requires tmux ≥ 3.3a (the supported minimum) | Both are valid; pick based on data volume and the filter language you want. diff --git a/docs/topics/options_and_hooks.md b/docs/topics/options_and_hooks.md index b74f5cc8c4..8aac4f9ecb 100644 --- a/docs/topics/options_and_hooks.md +++ b/docs/topics/options_and_hooks.md @@ -182,13 +182,14 @@ Session(...) ## tmux version compatibility Options and hooks need a reasonably recent tmux, and a few specific hooks -arrived later still. The floor for everything on this page is tmux 3.2: +arrived later still. The floor for everything on this page is tmux 3.3a, the +supported minimum: | Feature | Minimum tmux | |---------|-------------| -| All options/hooks features | 3.2+ | -| Window/Pane hook scopes (`-w`, `-p`) | 3.2+ | -| `client-active`, `window-resized` hooks | 3.3+ | +| All options/hooks features | 3.3a+ | +| Window/Pane hook scopes (`-w`, `-p`) | 3.3a+ | +| `client-active`, `window-resized` hooks | 3.3a+ | | `pane-title-changed` hook | 3.5+ | :::{seealso} diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index eceaec1bd4..3d0415ed32 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -50,8 +50,9 @@ FIELD_VERSION: dict[str, str] = { - # Post-3.2a additions (verified against tmux's format.c at each gated - # release tag, e.g. https://github.com/tmux/tmux/blob/3.6a/format.c). + # Additions after 3.2a; the 3.3 entries sit below the 3.3a floor. Verified + # against tmux's format.c at each gated release tag, e.g. + # https://github.com/tmux/tmux/blob/3.6a/format.c. "pane_dead_signal": "3.3", "pane_dead_time": "3.3", # tmux 3.7 additions (verified against format.c / tmux.1 at the 3.7 tag). @@ -69,10 +70,10 @@ } """Minimum tmux version that registers each format token. -Field names absent from this dict default to ``"3.2a"`` (always-safe within -the supported tmux range). Entries here represent tokens added after 3.2a +Field names absent from this dict default to ``"3.3a"`` (always-safe within +the supported tmux range). Entries here represent tokens added after 3.3a that need explicit gating to keep the ``-F`` template compatible with older -tmux versions. +tmux versions; the 3.3 entries are met by every supported daemon. """ @@ -931,8 +932,8 @@ def get_output_format( The tmux list subcommand the format string is being built for. Determines which token scopes are reachable. tmux_version : str - The live tmux version. Used to gate post-3.2a tokens. Defaults to - ``"3.2a"`` (the project's minimum) for safe fallback when the + The live tmux version. Used to gate post-3.3a tokens. Defaults to + ``"3.3a"`` (the project's minimum) for safe fallback when the caller can't yet detect the version. Returns From 5df9cb3e89647577b7dd8484f844ffe1053129bb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 10:50:53 -0500 Subject: [PATCH 60/60] Changelog(docs): Record control ranking and the exact-3.7 name guard Note the strict control eligibility ranking, local client naming and the window and session name refusal on tmux 3.7. Keep the release note separate from runtime changes. --- CHANGES | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index ae22e560a3..22d63a5866 100644 --- a/CHANGES +++ b/CHANGES @@ -93,7 +93,9 @@ Core support remains tmux 3.3a+. {class}`ControlMode release and a local socket filesystem that permits a private hard link. Control acquisition validates the retained endpoint before starting its client; UTF-8 metadata and output remain usable under a C locale. Acquisition runs no -user hooks before it validates the endpoint. +user hooks before it validates the endpoint and names its client locally +(`client-`), and any release ranking at or above 3.7, such as `3.8-rc2` or +`next-3.8`, qualifies while `next-3.7` and `3.7-rc*` do not. Exact named-buffer deletion and rename require tmux 3.4+. Captured shell-job stdout from {meth}`Server.run_shell() ` requires @@ -105,6 +107,10 @@ pane-directed and native tmux-command execution remain available on 3.3a+. goes to the pane, so there is no captured stdout to return. Code that iterates the result of a pane-directed call must handle `None`. +On exact tmux 3.7, window and session names containing `:` or `.` are refused +before dispatch (`new-window -n`, `new-session -s/-n`, `rename-window`, +`rename-session`); 3.7a accepts them again. + Percentage splits use portable `-l N%`. On exact tmux 3.7, raw `break-pane` and unnamed {meth}`Pane.break_pane() ` requests are refused. An explicit `window_name` uses the named break and rename repair;