From c39f180876b242ac210c26ea47e6e8c81c63aecf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 04:50:31 -0500 Subject: [PATCH 01/73] neo(feat[parse]): Add _split_records why: tmux writes one record per line, but any format value may itself contain a newline, which splits that record across output lines. A parser that iterates lines cannot recover the boundaries. Regrouping on the field separator can: the `-F` template from `get_output_format` terminates every field with one, so a record holds exactly `len(fields)` separators and a newline is never among them. what: - Add `_split_records`, which rejoins stdout into one blob, splits it on the field separator, and regroups the values into records of `field_count` fields - Drop the empty tail the split leaves, since every record ends with a separator - Strip the newline that terminated the previous record, which the rejoin leaves glued to the next record's first value - Raise `LibTmuxException` naming the cause when the values do not divide into whole records, which means a value carried the separator itself - Cover newlines in the first, middle, and last field, consecutive newlines, a poisoned record between clean ones, a forged separator, and an empty listing Nothing calls it yet; the next commit points `fetch_objs` at it. --- src/libtmux/neo.py | 52 ++++++++++++++++++++++++++++++++ tests/test_neo.py | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 98ece86fa5..22a0b8eee1 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1036,6 +1036,58 @@ def parse_output( return {k: v for k, v in formatter.items() if v} +def _split_records(stdout: list[str], field_count: int) -> list[str]: + """Regroup ``-F`` output into one string per object. + + tmux writes one record per line, but any format value may itself + contain a newline -- ``pane_current_path`` for a directory whose + name has one -- and that splits the record across output lines. + Iterating lines then hands :func:`parse_output` a fragment with too + few values, which its strict ``zip`` rejects, so one directory + breaks every object on the server rather than the one pane in it. + + Regrouping on the separator is exact rather than merely better: the + template from :func:`get_output_format` terminates *every* field + with a separator, so one record holds exactly ``field_count`` of + them and a newline is never one. Nothing is split on newlines, so a + value may contain any number of them, in any position. + + Raises + ------ + :exc:`~libtmux.exc.LibTmuxException` + If the values do not divide into whole records, which means a + value contained the separator itself. + """ + blob = "\n".join(stdout) + if not blob: + return [] + + values = blob.split(FORMAT_SEPARATOR) + # Every record ends with a separator, so the split always leaves one + # trailing empty for the final record. + if values and values[-1] == "": + values.pop() + + if field_count <= 0 or len(values) % field_count: + msg = ( + f"tmux output could not be parsed: {len(values)} values for " + f"{field_count} fields per record. A format value probably " + f"contains the field separator ({FORMAT_SEPARATOR!r})." + ) + raise exc.LibTmuxException(msg) + + records: list[str] = [] + for start in range(0, len(values), field_count): + chunk = values[start : start + field_count] + # The newline that terminated the previous record survives the + # join glued to this record's first value. It is a delimiter, + # not data. + if start and chunk[0].startswith("\n"): + chunk[0] = chunk[0][1:] + records.append(FORMAT_SEPARATOR.join(chunk) + FORMAT_SEPARATOR) + return records + + def fetch_objs( server: Server, list_cmd: ListCmd, diff --git a/tests/test_neo.py b/tests/test_neo.py index f67215e57b..e3a25bc7b4 100644 --- a/tests/test_neo.py +++ b/tests/test_neo.py @@ -13,12 +13,15 @@ import pytest +from libtmux import exc +from libtmux.formats import FORMAT_SEPARATOR from libtmux.neo import ( _CONTEXT_ONLY_TOKENS, FIELD_VERSION, SCOPES_BY_LIST_CMD, Obj, _is_target_not_found_error, + _split_records, _token_scope, get_output_format, ) @@ -258,3 +261,75 @@ def test_every_obj_field_classifies_to_known_scope() -> None: "(add them to _SCOPE_OVERRIDES, _SCOPE_PREFIXES, " f"_UNIVERSAL_TOKENS, or _CONTEXT_ONLY_TOKENS): {unclassified}" ) + + +class SplitRecordsFixture(t.NamedTuple): + """Test fixture for :func:`_split_records`.""" + + test_id: str + values: list[list[str]] + + +SPLIT_RECORDS_FIXTURES: list[SplitRecordsFixture] = [ + SplitRecordsFixture("single_clean_record", [["a", "b", "c"]]), + SplitRecordsFixture("two_clean_records", [["a", "b", "c"], ["d", "e", "f"]]), + SplitRecordsFixture("newline_in_first_field", [["a\nx", "b", "c"]]), + SplitRecordsFixture("newline_in_middle_field", [["a", "b\nx", "c"]]), + SplitRecordsFixture("newline_in_last_field", [["a", "b", "c\nx"]]), + SplitRecordsFixture("consecutive_newlines", [["a", "b\n\n\nx", "c"]]), + SplitRecordsFixture( + "poisoned_record_between_clean_ones", + [["a", "b", "c"], ["d", "e\npath", "f"], ["g", "h", "i"]], + ), + SplitRecordsFixture("empty_values", [["", "", ""]]), +] + + +@pytest.mark.parametrize( + SplitRecordsFixture._fields, + SPLIT_RECORDS_FIXTURES, + ids=[fixture.test_id for fixture in SPLIT_RECORDS_FIXTURES], +) +def test_split_records_round_trips_newlines( + test_id: str, + values: list[list[str]], +) -> None: + """A newline inside a value must not split its record. + + tmux emits one record per line, so a value containing a newline -- + ``pane_current_path`` under a directory whose name has one -- used + to arrive as two short fragments and fail ``parse_output``'s strict + ``zip``. Because every pane row carries ``pane_current_path``, that + broke enumeration for the whole server, not just the one pane. + """ + assert test_id + field_count = len(values[0]) + # Rebuild exactly what tmux writes: each record's fields, every one + # terminated by the separator, and records terminated by newlines. + stdout_text = "".join( + "".join(f"{value}{FORMAT_SEPARATOR}" for value in record) + "\n" + for record in values + ) + stdout = stdout_text.split("\n") + while stdout and stdout[-1] == "": + stdout.pop() + + records = _split_records(stdout, field_count) + + assert len(records) == len(values) + for record, expected in zip(records, values, strict=True): + parsed = record.split(FORMAT_SEPARATOR)[:-1] + assert parsed == expected + + +def test_split_records_reports_a_forged_separator() -> None: + """A value carrying the separator is named, not a ``zip`` message.""" + stdout = [f"a{FORMAT_SEPARATOR}b{FORMAT_SEPARATOR}c{FORMAT_SEPARATOR}"] + + with pytest.raises(exc.LibTmuxException, match="could not be parsed"): + _split_records(stdout, 2) + + +def test_split_records_handles_no_objects() -> None: + """An empty listing yields no records rather than a bogus one.""" + assert _split_records([], 5) == [] From f118a360ee59efd44b064ee078157cd142eca007 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 04:50:31 -0500 Subject: [PATCH 02/73] neo(fix[parse]): Regroup records on the separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: A pane whose `pane_current_path` contained a newline made `Server.panes` and `Server.windows` raise `ValueError: zip() argument 2 is shorter than argument 1` for the entire server, healthy panes included. `fetch_objs` iterated stdout one line per object, so a value containing a newline split its record across two lines and each fragment reached `parse_output` with too few values. Every pane row carries `pane_current_path` and every pane-targeting lookup enumerates panes, so one directory took out resolution for all of them. The blast radius also moved with the active pane, because session and window rows resolve `pane_*` against it — the same server appeared to work or fail as the user switched panes. Reported against libtmux-mcp, where an agent hit it by cd-ing a pane into such a directory and then could not repair it through the MCP, because every tool that could have moved the pane needed the same enumeration. what: - Build the `parse_output` inputs with `_split_records` instead of iterating `proc.stdout` line by line, so a value may hold any number of newlines, in any position - Surface a `LibTmuxException` naming the cause, rather than a `zip()` message, when a value carries the separator itself --- src/libtmux/neo.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 22a0b8eee1..a74c871fa8 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1189,7 +1189,10 @@ def fetch_objs( raise_if_stderr(proc, list_cmd) - outputs = [parse_output(line, list_cmd, tmux_version) for line in proc.stdout] + outputs = [ + parse_output(record, list_cmd, tmux_version) + for record in _split_records(proc.stdout, len(_fields)) + ] if logger.isEnabledFor(logging.DEBUG): if cmd_str is None: From f8d0ee886e5a0cf0ed787b2b7fea337a58ecbe75 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 08:26:43 -0500 Subject: [PATCH 03/73] Common(feat[timeout]): Let a caller bound a tmux command why: `tmux_cmd` waited on `Popen.communicate()` with no deadline, so a tmux server that accepts a connection and never replies held its caller forever. Cancelling the coroutine that awaits such a call does not interrupt it, so hung calls only accumulate; downstream, forty of them exhausted anyio's default thread limiter and the host process stopped serving every socket, healthy ones included. `TmuxTimeout` is deliberately NOT a `LibTmuxException`. The listing accessors absorb one of those as "nothing to list", which is right for a daemon that has not started and wrong for a server that stopped answering: a caller told there are no sessions goes on to create one on a server that already has them. A sibling type gets that for free at every such site. what: - Add `exc.TmuxTimeout`, carrying the argv and the bound it passed - Add `tmux_cmd(..., timeout=)`; on expiry kill the child and reap it before raising, so repeated timeouts do not leave tmux processes nothing is waiting on - Add a `hanging_tmux` fixture: a stand-in that answers `-V` and hangs on everything else, which is the shape of a wedged server - Cover the raise, and that the process is gone afterwards. Shown failing on the kill: without it the pid is still alive --- src/libtmux/common.py | 31 +++++++++++++++++++++++++++++-- src/libtmux/exc.py | 20 ++++++++++++++++++++ tests/conftest.py | 28 ++++++++++++++++++++++++++++ tests/test_common.py | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2871547700..9b8d05b9a2 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -303,13 +303,33 @@ class tmux_cmd: $ tmux new-session -s my session + Parameters + ---------- + tmux_bin : str, optional + Path to the tmux binary. Defaults to the first ``tmux`` on ``PATH``. + timeout : float, optional + Seconds to wait for the command. On expiry the tmux process is + killed and reaped, then :exc:`~libtmux.exc.TmuxTimeout` is + raised. ``None`` waits indefinitely. + + Raises + ------ + :exc:`~libtmux.exc.TmuxTimeout` + ``timeout`` elapsed. The command may or may not have taken + effect -- the process was killed mid-command. + Notes ----- .. versionchanged:: 0.8 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, + timeout: float | None = None, + ) -> None: resolved = tmux_bin or shutil.which("tmux") if not resolved: raise exc.TmuxCommandNotFound @@ -336,8 +356,15 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: encoding="utf-8", errors="backslashreplace", ) - stdout, stderr = self.process.communicate() + stdout, stderr = self.process.communicate(timeout=timeout) returncode = self.process.returncode + except subprocess.TimeoutExpired: + # Kill and reap before raising. A caller that gives up on an + # unbounded call leaves the child running, so repeated + # timeouts accumulate tmux clients that nothing is waiting on. + self.process.kill() + self.process.communicate() + raise exc.TmuxTimeout(cmd, t.cast("float", timeout)) from None except FileNotFoundError: raise exc.TmuxCommandNotFound from None except Exception: diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 57bb06102f..f87c9be0de 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -350,6 +350,26 @@ class WaitTimeout(LibTmuxException): """Function timed out without meeting condition.""" +class TmuxTimeout(Exception): + """A tmux command did not return within its timeout. + + Deliberately NOT a :exc:`LibTmuxException`. The listing accessors + absorb one of those as "nothing to list", which is right for a + daemon that has not started and wrong for a server that stopped + answering -- a caller told there are no sessions goes on to create + one on a server that already has them. + + Distinct from :exc:`WaitTimeout`, which is a helper giving up on a + condition. This one means the tmux process was killed mid-command, + so nothing can be said about whether it took effect. + """ + + def __init__(self, cmd: list[str], timeout: float, *args: object) -> None: + self.cmd = cmd + self.timeout = timeout + super().__init__(f"tmux did not return within {timeout}s: {' '.join(cmd)}") + + class VariableUnpackingError(LibTmuxException): """Error unpacking variable.""" diff --git a/tests/conftest.py b/tests/conftest.py index c0015e4e65..e745ced251 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,15 @@ from __future__ import annotations +import typing as t + import pytest from libtmux.common import get_version, get_version_str +if t.TYPE_CHECKING: + import pathlib + @pytest.fixture(autouse=True) def _clear_get_version_cache() -> None: @@ -20,3 +25,26 @@ def _clear_get_version_cache() -> None: """ get_version.cache_clear() get_version_str.cache_clear() + + +@pytest.fixture +def hanging_tmux(tmp_path: pathlib.Path) -> tuple[str, pathlib.Path]: + """Return a stand-in tmux that never answers, and its pid file. + + Models the failure a real fixture cannot produce cheaply: a server + that accepts a connection and never replies. ``-V`` is answered, + because a wedged SERVER does not stop the local binary reporting its + own version, and `Server.sessions` reads it on the way past. + ``exec`` preserves the recorded pid, so a test can check the process + afterwards instead of trusting that it was killed. + """ + pid_file = tmp_path / "pid" + binary = tmp_path / "tmux" + binary.write_text( + "#!/bin/sh\n" + 'if [ "$1" = "-V" ]; then echo "tmux 3.7"; exit 0; fi\n' + f"echo $$ > {pid_file}\n" + "exec sleep 30\n" + ) + binary.chmod(0o755) + return str(binary), pid_file diff --git a/tests/test_common.py b/tests/test_common.py index 426b72d573..b6e41bd2c5 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -4,8 +4,10 @@ import locale import logging +import os import re import sys +import time import typing as t import pytest @@ -29,6 +31,8 @@ ) if t.TYPE_CHECKING: + import pathlib + from libtmux.server import Server from libtmux.session import Session @@ -762,3 +766,39 @@ def test_tmux_cmd_format_separator_survives_non_utf8_locale( result = parse_output(line, "list-sessions", tmux_version) assert isinstance(result, dict) assert "session_id" in result + + +def test_tmux_cmd_timeout_kills_and_reaps( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """An expired command leaves no tmux process behind. + + The kill is the load-bearing half. Without it a caller that gives up + only stops waiting, so repeated timeouts accumulate tmux processes + nobody is listening to. + """ + binary, pid_file = hanging_tmux + + with pytest.raises(exc.TmuxTimeout) as excinfo: + tmux_cmd("list-sessions", tmux_bin=binary, timeout=0.3) + + assert excinfo.value.timeout == 0.3 + assert "list-sessions" in str(excinfo.value) + + pid = int(pid_file.read_text()) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + + +def test_tmux_cmd_without_timeout_still_waits( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """The bound is opt-in; omitting it keeps the historical behaviour.""" + binary, _pid_file = hanging_tmux + started = time.monotonic() + + with pytest.raises(exc.TmuxTimeout): + tmux_cmd("list-sessions", tmux_bin=binary, timeout=0.3) + + # A test that never waits would pass whether or not `timeout` is read. + assert time.monotonic() - started >= 0.3 From 628011c802b01945da7f3876250ff79d27286b52 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 08:26:43 -0500 Subject: [PATCH 04/73] Server(feat[timeout]): Set the bound once, for every command why: `Server.cmd` is not the only funnel. `neo.fetch_objs` builds a `tmux_cmd` directly and is the engine behind `Server.sessions`, `Session.windows` and `Window.panes`, so a consumer cannot bound its calls with a `Server` subclass -- the busiest path is not reachable that way. what: - Add `Server(timeout=)`, used by `Server.cmd` unless a call overrides it - Pass the server's timeout through `fetch_objs` - Assert every listing accessor raises rather than answering empty on a wedged server: `sessions`, `windows`, `panes`, `clients`. That is what the sibling exception type buys, and the parametrization is what shows it holds at all four --- src/libtmux/neo.py | 1 + src/libtmux/server.py | 10 +++++++++- tests/test_server.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index a74c871fa8..d3d0f5b19f 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1185,6 +1185,7 @@ def fetch_objs( proc = tmux_cmd( *tmux_cmds, tmux_bin=server.tmux_bin, + timeout=server.timeout, ) raise_if_stderr(proc, list_cmd) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c34..9d39e05d92 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -176,10 +176,12 @@ def __init__( on_init: t.Callable[[Server], None] | None = None, socket_name_factory: t.Callable[[], str] | None = None, tmux_bin: str | pathlib.Path | None = None, + timeout: float | None = None, **kwargs: t.Any, ) -> None: EnvironmentMixin.__init__(self, "-g") self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self.timeout = timeout self._windows: list[WindowDict] = [] self._panes: list[PaneDict] = [] @@ -342,6 +344,7 @@ def cmd( cmd: str, *args: t.Any, target: str | int | None = None, + timeout: float | None = None, ) -> tmux_cmd: """Execute tmux command respective of socket name and file, return output. @@ -408,7 +411,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, + timeout=self.timeout if timeout is None else timeout, + ) @property def attached_sessions(self) -> list[Session]: diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9ad..cfe00e9c03 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1765,3 +1765,34 @@ def test_server_display_message_warns_on_tmux_error( """ with pytest.warns(UserWarning, match="only one of -F or argument"): server.display_message("x", get_text=True, format_string="#{version}") + + +def test_server_timeout_bounds_every_command( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """A server-level timeout is the policy for commands through it.""" + binary, _pid_file = hanging_tmux + server = Server(tmux_bin=binary, timeout=0.3) + + with pytest.raises(exc.TmuxTimeout): + server.cmd("list-sessions") + + +@pytest.mark.parametrize("accessor", ["sessions", "windows", "panes", "clients"]) +def test_a_wedged_server_is_not_reported_as_empty( + hanging_tmux: tuple[str, pathlib.Path], + accessor: str, +) -> None: + """A listing must raise rather than answer empty. + + A tmux server that stopped answering has not said it has nothing. + Returning ``[]`` sends a caller on to create a session on a server + that already has them. Every accessor is covered because three of + them reimplemented the "empty means not ready" rule inline instead + of sharing it. + """ + binary, _pid_file = hanging_tmux + server = Server(tmux_bin=binary, timeout=0.3) + + with pytest.raises(exc.TmuxTimeout): + _ = getattr(server, accessor) From 41b5659cad47af6253568013ee9a6a4c75fb630f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 20:39:54 -0500 Subject: [PATCH 05/73] Command(feat): Add a separate process runner why: Execution and captured results need separate APIs. what: Add run_command and CommandResult while preserving tmux_cmd behavior. --- docs/api/libtmux.common.md | 17 +++ src/libtmux/common.py | 231 +++++++++++++++++++++++++------------ tests/test_common.py | 53 ++++++++- 3 files changed, 226 insertions(+), 75 deletions(-) diff --git a/docs/api/libtmux.common.md b/docs/api/libtmux.common.md index 8d15a0520c..03b34b94c1 100644 --- a/docs/api/libtmux.common.md +++ b/docs/api/libtmux.common.md @@ -1,5 +1,22 @@ # Utilities +{func}`libtmux.common.run_command` executes tmux and returns a separate +{class}`libtmux.common.CommandResult`. Constructing a result performs no I/O. +Completed nonzero exits remain result data; transport and timeout failures raise. + +```python +>>> from libtmux.common import CommandResult, run_command +>>> result = run_command("-V") +>>> isinstance(result, CommandResult) +True +>>> result.returncode +0 +``` + +The existing {class}`libtmux.common.tmux_cmd` constructor delegates to this +runner. Contextual `server.cmd()` calls retain their existing return type, +attributes and output conventions. + ```{eval-rst} .. automodule:: libtmux.common :members: diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 9b8d05b9a2..9b1e5895b8 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,6 +7,7 @@ from __future__ import annotations +import dataclasses import functools import logging import re @@ -242,7 +243,7 @@ def getenv(self, name: str) -> str | bool | None: return opts_dict.get(name) -def raise_if_stderr(proc: tmux_cmd, subcommand: str) -> None: +def raise_if_stderr(proc: tmux_cmd | CommandResult, subcommand: str) -> None: """Raise :exc:`LibTmuxException` tagged with the tmux subcommand on stderr. Centralizes the ``if proc.stderr: raise exc.LibTmuxException(proc.stderr)`` @@ -280,9 +281,158 @@ def raise_if_stderr(proc: tmux_cmd, subcommand: str) -> None: ) +@dataclasses.dataclass() +class CommandResult: + """Captured output of a completed command; construction performs no I/O. + + A completed nonzero exit remains result data. ``process`` is the already + reaped subprocess retained for compatibility and process metadata. + + Attributes + ---------- + cmd : list[str] + Executable and arguments after string conversion. + stdout : list[str] + UTF-8 output with invalid bytes escaped; trailing empty lines removed. + stderr : list[str] + UTF-8 diagnostics with invalid bytes escaped; empty lines removed. + returncode : int + Completed process exit status. + process : subprocess.Popen[str] + Completed child process. + """ + + cmd: list[str] + stdout: list[str] + stderr: list[str] + returncode: int + process: subprocess.Popen[str] = dataclasses.field(repr=False, compare=False) + + +def run_command( + *args: object, + tmux_bin: str | None = None, + timeout: float | None = None, +) -> CommandResult: + """Run a command and capture its completed result. + + Parameters + ---------- + *args : object + tmux arguments, converted to strings without shell interpretation. + tmux_bin : str, optional + Executable path. Defaults to the first ``tmux`` on ``PATH``. + timeout : float, optional + Seconds to wait. ``None`` waits indefinitely. + + Returns + ------- + CommandResult + Captured output and exit status, including completed nonzero exits. + + Raises + ------ + :exc:`~libtmux.exc.TmuxCommandNotFound` + The executable cannot be found. + :exc:`~libtmux.exc.TmuxTimeout` + The deadline elapsed. The child is killed and reaped before raising; + the command may already have taken effect. + + Notes + ----- + Preserves :class:`tmux_cmd` output conventions, including copying the + first stderr line to stdout for a failed ``has-session`` with no stdout. + + Examples + -------- + >>> result = run_command("-V") + >>> isinstance(result, CommandResult) + True + >>> result.returncode + 0 + """ + resolved = tmux_bin or shutil.which("tmux") + if not resolved: + raise exc.TmuxCommandNotFound + + cmd = [str(value) for value in (resolved, *args)] + + if logger.isEnabledFor(logging.DEBUG): + cmd_str = shlex.join(cmd) + logger.debug( + "tmux command dispatched", + extra={"tmux_cmd": cmd_str}, + ) + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="backslashreplace", + ) + stdout, stderr = process.communicate(timeout=timeout) + returncode = process.returncode + except subprocess.TimeoutExpired: + # Kill and reap before raising. A caller that gives up on an + # unbounded call leaves the child running, so repeated + # timeouts accumulate tmux clients that nothing is waiting on. + process.kill() + process.communicate() + raise exc.TmuxTimeout(cmd, t.cast("float", timeout)) from None + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + except Exception: + logger.error( # noqa: TRY400 + "tmux subprocess failed", + extra={ + "tmux_cmd": shlex.join(cmd), + }, + ) + raise + + stdout_split = stdout.split("\n") + # remove trailing newlines from stdout + while stdout_split and stdout_split[-1] == "": + stdout_split.pop() + + stderr_split = stderr.split("\n") + stderr_lines = list(filter(None, stderr_split)) # filter empty values + + if "has-session" in cmd and len(stderr_lines) and not stdout_split: + stdout_lines = [stderr_lines[0]] + else: + stdout_lines = stdout_split + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "tmux command completed", + extra={ + "tmux_cmd": shlex.join(cmd), + "tmux_exit_code": returncode, + "tmux_stdout": stdout_lines[:100], + "tmux_stderr": stderr_lines[:100], + "tmux_stdout_len": len(stdout_lines), + "tmux_stderr_len": len(stderr_lines), + }, + ) + + return CommandResult( + cmd=cmd, + stdout=stdout_lines, + stderr=stderr_lines, + returncode=t.cast("int", returncode), + process=process, + ) + + class tmux_cmd: """Run any :term:`tmux(1)` command through :py:mod:`subprocess`. + Compatibility facade for :func:`run_command`, preserving result attributes. + Examples -------- Create a new session, check for error: @@ -330,79 +480,12 @@ def __init__( tmux_bin: str | None = None, timeout: float | None = None, ) -> None: - resolved = tmux_bin or shutil.which("tmux") - if not resolved: - raise exc.TmuxCommandNotFound - - cmd = [resolved] - cmd += args # add the command arguments to cmd - cmd = [str(c) for c in cmd] - - self.cmd = cmd - - if logger.isEnabledFor(logging.DEBUG): - cmd_str = shlex.join(cmd) - logger.debug( - "tmux command dispatched", - extra={"tmux_cmd": cmd_str}, - ) - - try: - self.process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="backslashreplace", - ) - stdout, stderr = self.process.communicate(timeout=timeout) - returncode = self.process.returncode - except subprocess.TimeoutExpired: - # Kill and reap before raising. A caller that gives up on an - # unbounded call leaves the child running, so repeated - # timeouts accumulate tmux clients that nothing is waiting on. - self.process.kill() - self.process.communicate() - raise exc.TmuxTimeout(cmd, t.cast("float", timeout)) from None - except FileNotFoundError: - raise exc.TmuxCommandNotFound from None - except Exception: - logger.error( # noqa: TRY400 - "tmux subprocess failed", - extra={ - "tmux_cmd": shlex.join(cmd), - }, - ) - raise - - self.returncode = returncode - - stdout_split = stdout.split("\n") - # remove trailing newlines from stdout - while stdout_split and stdout_split[-1] == "": - stdout_split.pop() - - stderr_split = stderr.split("\n") - self.stderr = list(filter(None, stderr_split)) # filter empty values - - if "has-session" in cmd and len(self.stderr) and not stdout_split: - self.stdout = [self.stderr[0]] - else: - self.stdout = stdout_split - - if logger.isEnabledFor(logging.DEBUG): - logger.debug( - "tmux command completed", - extra={ - "tmux_cmd": shlex.join(cmd), - "tmux_exit_code": self.returncode, - "tmux_stdout": self.stdout[:100], - "tmux_stderr": self.stderr[:100], - "tmux_stdout_len": len(self.stdout), - "tmux_stderr_len": len(self.stderr), - }, - ) + result = run_command(*args, tmux_bin=tmux_bin, timeout=timeout) + self.cmd = result.cmd + self.stdout = result.stdout + self.stderr = result.stderr + self.returncode = result.returncode + self.process = result.process class _TmuxVersionUnavailable(Exception): diff --git a/tests/test_common.py b/tests/test_common.py index b6e41bd2c5..db4b5c9258 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -180,6 +180,54 @@ def test_tmux_cmd_unicode(session: Session) -> None: session.cmd("new-window", "-n", "юникод", "-F", "Ελληνικά", target=3) +@pytest.mark.parametrize("runner_name", ["run_command", "tmux_cmd"]) +def test_command_result_preserves_status_and_decoding(runner_name: str) -> None: + """A child process supplies malformed bytes and a completed nonzero exit.""" + runner = getattr(libtmux.common, runner_name) + script = ( + "import sys; " + "sys.stdout.buffer.write(b'first\\n\\nlast\\xff\\n\\n'); " + "sys.stderr.buffer.write(b'problem\\xfe\\n\\n'); " + "sys.exit(7)" + ) + result = runner("-c", script, 17, tmux_bin=sys.executable) + + assert result.cmd == [sys.executable, "-c", script, "17"] + assert result.stdout == ["first", "", "last\\xff"] + assert result.stderr == ["problem\\xfe"] + assert result.returncode == 7 + assert result.process.returncode == 7 + if runner_name == "run_command": + assert isinstance(result, libtmux.common.CommandResult) + + +def test_tmux_cmd_delegates_to_runner( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The compatibility facade delegates without starting another child.""" + result = libtmux.common.run_command("-V", tmux_bin=server.tmux_bin) + received: list[tuple[tuple[object, ...], str | None, float | None]] = [] + + def run( + *args: object, + tmux_bin: str | None = None, + timeout: float | None = None, + ) -> libtmux.common.CommandResult: + received.append((args, tmux_bin, timeout)) + return result + + monkeypatch.setattr(libtmux.common, "run_command", run) + facade = tmux_cmd("display-message", "-p", tmux_bin="custom", timeout=0.5) + + assert received == [(("display-message", "-p"), "custom", 0.5)] + assert facade.cmd is result.cmd + assert facade.stdout is result.stdout + assert facade.stderr is result.stderr + assert facade.returncode == result.returncode + assert facade.process is result.process + + class SessionCheckName(t.NamedTuple): """Test fixture for test_session_check_name().""" @@ -768,8 +816,10 @@ def test_tmux_cmd_format_separator_survives_non_utf8_locale( assert "session_id" in result +@pytest.mark.parametrize("runner_name", ["run_command", "tmux_cmd"]) def test_tmux_cmd_timeout_kills_and_reaps( hanging_tmux: tuple[str, pathlib.Path], + runner_name: str, ) -> None: """An expired command leaves no tmux process behind. @@ -778,9 +828,10 @@ def test_tmux_cmd_timeout_kills_and_reaps( nobody is listening to. """ binary, pid_file = hanging_tmux + runner = getattr(libtmux.common, runner_name) with pytest.raises(exc.TmuxTimeout) as excinfo: - tmux_cmd("list-sessions", tmux_bin=binary, timeout=0.3) + runner("list-sessions", tmux_bin=binary, timeout=0.3) assert excinfo.value.timeout == 0.3 assert "list-sessions" in str(excinfo.value) From f76e4aa4f7ea5c025e9dd0d04b52a87b84b1f4e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 20:39:55 -0500 Subject: [PATCH 06/73] Query(feat): Expose typed public lookups why: Callers need a stable import and precise required/defaulted results. what: Export the existing QueryList and add compatible get overloads. --- docs/api/index.md | 5 +++ docs/api/libtmux.query.md | 28 ++++++++++++++ .../api/libtmux._internal.query_list.md | 3 +- docs/project/public-api.md | 5 +++ src/libtmux/__init__.py | 2 + src/libtmux/_internal/query_list.py | 37 ++++++++++++++++--- tests/_internal/test_query_list.py | 27 ++++++++++++++ 7 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 docs/api/libtmux.query.md diff --git a/docs/api/index.md b/docs/api/index.md index 23cd9043b1..b83616b7e2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -81,6 +81,10 @@ Attached terminal. Read read-only state, theme, termtype. ## Supporting Modules +{class}`~libtmux.QueryList` is the ordinary list subclass returned by live +listings. Its {doc}`public query API ` filters those returned +values locally and provides required or defaulted lookups. + ::::{grid} 1 2 3 3 :gutter: 2 2 3 3 @@ -173,6 +177,7 @@ Window Pane Client Common +QueryList Neo Options Hooks diff --git a/docs/api/libtmux.query.md b/docs/api/libtmux.query.md new file mode 100644 index 0000000000..bce86761d6 --- /dev/null +++ b/docs/api/libtmux.query.md @@ -0,0 +1,28 @@ +# QueryList + +Import {class}`~libtmux.QueryList` from the package root. It is the same list +subclass returned by session, window and pane listings. Iteration, slicing and +comprehensions keep their existing Python list behavior. + +```python +>>> from libtmux import QueryList +>>> values = QueryList([1, 2, 3]) +>>> values.filter(lambda value: value > 1) +[2, 3] +>>> values.get(2) +2 +>>> values.get(9, default="missing") +'missing' +``` + +`get()` without a default returns the element type or raises +{class}`~libtmux.exc.ObjectDoesNotExist`. Supplying a default adds that default's +type to the return type. Both forms raise +{class}`~libtmux.exc.MultipleObjectsReturned` for an ambiguous match. Filtering +and lookup perform no I/O; obtaining `server.sessions` still performs a live +read with classic libtmux's listing behavior. + +```{eval-rst} +.. autoclass:: libtmux.QueryList + :members: +``` diff --git a/docs/internals/api/libtmux._internal.query_list.md b/docs/internals/api/libtmux._internal.query_list.md index 3e195139cc..b50c9dd9a2 100644 --- a/docs/internals/api/libtmux._internal.query_list.md +++ b/docs/internals/api/libtmux._internal.query_list.md @@ -1,7 +1,8 @@ # List querying The {mod}`libtmux._internal.query_list` module contains the private collection -filtering implementation behind public list accessors. +filtering implementation behind public list accessors. Consumers use the +{doc}`public QueryList import <../../api/libtmux.query>`. ```{eval-rst} .. automodule:: libtmux._internal.query_list diff --git a/docs/project/public-api.md b/docs/project/public-api.md index ba7eae89a4..76f12f9ea4 100644 --- a/docs/project/public-api.md +++ b/docs/project/public-api.md @@ -13,6 +13,7 @@ This includes: | {class}`~libtmux.Session` | `from libtmux.session import Session` | | {class}`~libtmux.Window` | `from libtmux.window import Window` | | {class}`~libtmux.Pane` | `from libtmux.pane import Pane` | +| {class}`~libtmux.QueryList` | `from libtmux import QueryList` | | Common | `from libtmux.common import ...` | | Neo | `from libtmux.neo import ...` | | Options | `from libtmux.options import ...` | @@ -32,6 +33,10 @@ This includes: Modules under `libtmux._internal` and `libtmux._vendor` are **not public**. They may change or be removed without notice between any release. +`QueryList` is public through its package-root import even though its +implementation lives in `_internal`. Use the public import rather than the +implementation module. + Do not import from: - `libtmux._internal.*` - `libtmux._vendor.*` diff --git a/src/libtmux/__init__.py b/src/libtmux/__init__.py index c99fde4bda..dff3615a11 100644 --- a/src/libtmux/__init__.py +++ b/src/libtmux/__init__.py @@ -14,6 +14,7 @@ __title__, __version__, ) +from ._internal.query_list import QueryList from .client import Client from .pane import Pane from .server import Server @@ -25,6 +26,7 @@ __all__ = ( "Client", "Pane", + "QueryList", "Server", "Session", "Window", diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 20aeb407f6..d44881a396 100644 --- a/src/libtmux/_internal/query_list.py +++ b/src/libtmux/_internal/query_list.py @@ -2,7 +2,8 @@ Note ---- -This is an internal API not covered by versioning policy. +Only :class:`QueryList`, exported as ``libtmux.QueryList``, is public. +The remaining helpers are internal and not covered by versioning policy. """ from __future__ import annotations @@ -34,6 +35,7 @@ def __call__( T = t.TypeVar("T") +D = t.TypeVar("D") no_arg = object() @@ -327,7 +329,8 @@ def __init__(self, op: str, *args: object) -> None: class QueryList(list[T], t.Generic[T]): """Filter list of object/dictionaries. For small, local datasets. - *Experimental, unstable*. + Import the public collection with ``from libtmux import QueryList``. + Filtering and cardinality checks operate on the existing list without I/O. **With dictionaries**: @@ -549,12 +552,36 @@ def val_match(obj: str | list[t.Any] | T) -> bool: return self.__class__(k for k in self if filter_(k)) + @t.overload def get( self, matcher: Callable[[T], bool] | T | None = None, - default: t.Any | None = no_arg, + *, + default: D, **kwargs: t.Any, - ) -> T | None: + ) -> T | D: ... + + @t.overload + def get( + self, + matcher: Callable[[T], bool] | T | None, + default: D, + **kwargs: t.Any, + ) -> T | D: ... + + @t.overload + def get( + self, + matcher: Callable[[T], bool] | T | None = None, + **kwargs: t.Any, + ) -> T: ... + + def get( + self, + matcher: Callable[[T], bool] | T | None = None, + default: object = no_arg, + **kwargs: t.Any, + ) -> object: """Retrieve exactly one object. Parameters @@ -583,7 +610,7 @@ def get( Examples -------- - >>> from libtmux._internal.query_list import QueryList + >>> from libtmux import QueryList >>> from libtmux import exc >>> qs = QueryList([{"pane_id": "%0"}, {"pane_id": "%0"}, {"pane_id": "%1"}]) diff --git a/tests/_internal/test_query_list.py b/tests/_internal/test_query_list.py index 7d4a306c4a..cd8c2ae741 100644 --- a/tests/_internal/test_query_list.py +++ b/tests/_internal/test_query_list.py @@ -5,6 +5,7 @@ import pytest +import libtmux from libtmux._internal.query_list import ( MultipleObjectsReturned, ObjectDoesNotExist, @@ -14,6 +15,32 @@ if t.TYPE_CHECKING: from collections.abc import Callable + from typing_extensions import assert_type + + def check_public_query_types(values: libtmux.QueryList[int]) -> None: + """Public get distinguishes a required result from each default type.""" + assert_type(values.get(), int) + assert_type(values.get(lambda value: value > 0), int) + assert_type(values.get(default=None), int | None) + assert_type(values.get(default="missing"), int | str) + assert_type(values.get(None, "missing"), int | str) + assert_type(values.get(lambda value: value > 0, None), int | None) + assert_type(values.filter(lambda value: value > 0), libtmux.QueryList[int]) + + +def test_public_query_uses_existing_collection() -> None: + """The public import preserves list behavior and lookup failure contracts.""" + assert libtmux.QueryList is QueryList + values = libtmux.QueryList([1, 2, 2]) + assert values.get(1) == 1 + assert values.get(9, "missing") == "missing" + assert values.get(9, default=None) is None + assert values.filter(lambda value: value > 1) == [2, 2] + with pytest.raises(ObjectDoesNotExist): + values.get(9) + with pytest.raises(MultipleObjectsReturned): + values.get(2, default=None) + @dataclasses.dataclass class Obj: From 739ad6d11e117972e5cb3cbabf2a66d5babfcf96 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 20:46:41 -0500 Subject: [PATCH 07/73] Models(feat): Add decoded scalar properties why: Numeric and boolean state should be usable without manual parsing. what: Add local typed properties while preserving raw fields and aliases. --- docs/api/libtmux.pane.md | 16 ++++++++++++++++ docs/api/libtmux.session.md | 5 +++++ docs/api/libtmux.window.md | 7 +++++++ src/libtmux/pane.py | 32 ++++++++++++++++++++++++++++++++ src/libtmux/session.py | 8 ++++++++ src/libtmux/window.py | 24 ++++++++++++++++++++++++ tests/test_pane.py | 36 ++++++++++++++++++++++++++++++++++-- tests/test_session.py | 17 ++++++++++++----- tests/test_window.py | 20 ++++++++++++++++++++ 9 files changed, 158 insertions(+), 7 deletions(-) diff --git a/docs/api/libtmux.pane.md b/docs/api/libtmux.pane.md index 383b4286f3..c47a150afd 100644 --- a/docs/api/libtmux.pane.md +++ b/docs/api/libtmux.pane.md @@ -9,6 +9,22 @@ [pseudoterminal]: https://en.wikipedia.org/wiki/Pseudoterminal [pty(4)]: https://www.freebsd.org/cgi/man.cgi?query=pty&sektion=4 +`width_cells` and `height_cells` return captured dimensions as `int | None`; +`is_active` and `is_dead` return captured flags as `bool | None`. These reads +perform no I/O and preserve `None` when a field was unavailable. The raw +`pane_*` fields and existing string-valued `width` and `height` aliases remain +available. Invalid manually assigned numeric text raises `ValueError` when +decoded. + +```python +>>> isinstance(pane.width_cells, int) +True +>>> pane.width == pane.pane_width +True +>>> pane.is_dead +False +``` + ```{eval-rst} .. autoclass:: libtmux.Pane :members: diff --git a/docs/api/libtmux.session.md b/docs/api/libtmux.session.md index 008d64df9f..a7d0ef5ded 100644 --- a/docs/api/libtmux.session.md +++ b/docs/api/libtmux.session.md @@ -6,6 +6,11 @@ - Contain {ref}`Windows` (which contain {ref}`Panes`) - Identified by `$`, e.g. `$313` +`attached_count` returns the captured number of attached clients as `int | None` +without performing I/O. `None` means the field was unavailable. The raw +`session_attached` string remains available; invalid manually assigned numeric +text raises `ValueError` when decoded. + ```{eval-rst} .. autoclass:: libtmux.Session :members: diff --git a/docs/api/libtmux.window.md b/docs/api/libtmux.window.md index 384879ae62..95242d5cca 100644 --- a/docs/api/libtmux.window.md +++ b/docs/api/libtmux.window.md @@ -10,6 +10,13 @@ :no-index: ``` +`width_cells` and `height_cells` return captured dimensions as `int | None`. +`is_active` returns the captured active flag within the session as `bool | None`. +These properties perform no I/O; `None` means the field was unavailable. +The raw `window_*` fields and existing string-valued dimension aliases remain +unchanged. Invalid manually assigned numeric text raises `ValueError` when +decoded. + ```{eval-rst} .. autoclass:: Window :members: diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e0c2f59619..6e20a3d26e 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -2708,6 +2708,38 @@ def title(self) -> str | None: """ return self.pane_title + @property + def width_cells(self) -> int | None: + """Captured width in character cells, or ``None`` when unavailable. + + Reads locally. The existing :attr:`width` alias retains its raw string. + """ + return int(self.pane_width) if self.pane_width is not None else None + + @property + def height_cells(self) -> int | None: + """Captured height in character cells, or ``None`` when unavailable. + + Reads locally. The existing :attr:`height` alias retains its raw string. + """ + return int(self.pane_height) if self.pane_height is not None else None + + @property + def is_active(self) -> bool | None: + """Captured active flag within the window, or ``None`` when unavailable. + + Reads locally; zero is false and a nonzero integer is true. + """ + return bool(int(self.pane_active)) if self.pane_active is not None else None + + @property + def is_dead(self) -> bool | None: + """Captured pane-process exit flag, or ``None`` when unavailable. + + Reads locally; zero is false and a nonzero integer is true. + """ + return bool(int(self.pane_dead)) if self.pane_dead is not None else None + @property def at_top(self) -> bool: """Typed, converted wrapper around :attr:`Pane.pane_at_top`. diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 4277052a37..b251f2a635 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -1032,6 +1032,14 @@ def name(self) -> str | None: """ return self.session_name + @property + def attached_count(self) -> int | None: + """Captured attached-client count, or ``None`` when unavailable. + + Reads locally. :attr:`session_attached` retains the raw tmux string. + """ + return int(self.session_attached) if self.session_attached is not None else None + # # Legacy: Redundant stuff we want to remove # diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b57db99692..f0b7f78785 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -1722,6 +1722,30 @@ def width(self) -> str | None: """ return self.window_width + @property + def width_cells(self) -> int | None: + """Captured width in character cells, or ``None`` when unavailable. + + Reads locally. The existing :attr:`width` alias retains its raw string. + """ + return int(self.window_width) if self.window_width is not None else None + + @property + def height_cells(self) -> int | None: + """Captured height in character cells, or ``None`` when unavailable. + + Reads locally. The existing :attr:`height` alias retains its raw string. + """ + return int(self.window_height) if self.window_height is not None else None + + @property + def is_active(self) -> bool | None: + """Captured active flag within the session, or ``None`` when unavailable. + + Reads locally; zero is false and a nonzero integer is true. + """ + return bool(int(self.window_active)) if self.window_active is not None else None + # # Legacy: Redundant stuff we want to remove # diff --git a/tests/test_pane.py b/tests/test_pane.py index 416032ce62..e05e96eb77 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -9,19 +9,51 @@ import pytest -from libtmux import exc +from libtmux import Pane, Server, exc from libtmux.common import has_gte_version from libtmux.constants import PaneDirection, ResizeAdjustmentDirection from libtmux.test.retry import retry_until if t.TYPE_CHECKING: from libtmux._internal.types import StrPath - from libtmux.pane import Pane from libtmux.session import Session logger = logging.getLogger(__name__) +@pytest.mark.parametrize("raw", [None, "0", "1"]) +def test_decoded_pane_fields_are_local(raw: str | None) -> None: + """Decoded fields preserve absence and zero without executing tmux.""" + pane = Pane( + server=Server(tmux_bin="missing-decoded-fields-tmux"), + pane_width="80", + pane_height="24", + pane_active=raw, + pane_dead=raw, + ) + assert pane.width_cells == 80 + assert pane.height_cells == 24 + assert pane.width == "80" + assert pane.height == "24" + assert pane.is_active is (None if raw is None else raw == "1") + assert pane.is_dead is (None if raw is None else raw == "1") + pane.pane_width = None + pane.pane_height = None + assert pane.width_cells is None + assert pane.height_cells is None + + +def test_decoded_pane_fields_match_live_capture(session: Session) -> None: + """Active and inactive panes retain distinct typed captured flags.""" + window = session.active_window + window.split(attach=False) + panes = window.panes + assert len(panes) == 2 + assert sum(pane.is_active is True for pane in panes) == 1 + assert all(pane.is_dead is False for pane in panes) + assert all(isinstance(pane.width_cells, int) for pane in panes) + + def test_send_keys(session: Session) -> None: """Verify Pane.send_keys().""" pane = session.active_window.active_pane diff --git a/tests/test_session.py b/tests/test_session.py index f7d95e4cca..d708c20ecc 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -10,7 +10,7 @@ import pytest -from libtmux import exc +from libtmux import Server, exc from libtmux.constants import WindowDirection from libtmux.pane import Pane from libtmux.session import Session @@ -18,9 +18,6 @@ from libtmux.test.random import namer from libtmux.window import Window -if t.TYPE_CHECKING: - from libtmux.server import Server - if t.TYPE_CHECKING: from typing import TypeAlias @@ -32,11 +29,21 @@ RaisesExc: TypeAlias = RaisesContext[Exception] # type: ignore[no-redef] from libtmux._internal.types import StrPath - from libtmux.server import Server logger = logging.getLogger(__name__) +@pytest.mark.parametrize("raw", [None, "0", "2"]) +def test_decoded_session_fields_are_local(raw: str | None) -> None: + """Attached-client counts preserve uncaptured and zero values.""" + session = Session( + server=Server(tmux_bin="missing-decoded-fields-tmux"), + session_attached=raw, + ) + assert session.attached_count == (None if raw is None else int(raw)) + assert session.session_attached == raw + + def test_has_session(server: Server, session: Session) -> None: """Server.has_session returns True if has session_name exists.""" TEST_SESSION_NAME = session.session_name diff --git a/tests/test_window.py b/tests/test_window.py index 73e6f61089..4daf340d05 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -29,6 +29,26 @@ logger = logging.getLogger(__name__) +@pytest.mark.parametrize("raw", [None, "0", "1"]) +def test_decoded_window_fields_are_local(raw: str | None) -> None: + """Window dimensions and flags decode without a running server.""" + window = Window( + server=Server(tmux_bin="missing-decoded-fields-tmux"), + window_width="80", + window_height="24", + window_active=raw, + ) + assert window.width_cells == 80 + assert window.height_cells == 24 + assert window.width == "80" + assert window.height == "24" + assert window.is_active is (None if raw is None else raw == "1") + window.window_width = None + window.window_height = None + assert window.width_cells is None + assert window.height_cells is None + + def test_select_window(session: Session) -> None: """Test Window.select_window().""" window_count = len(session.windows) From 6cc47c3eda9cc4228742dac490e073140282c6cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 20:47:29 -0500 Subject: [PATCH 08/73] Server(feat): Add explicitly owned scopes why: Cleanup should target only resources created by the scope. what: Add private server and guarded session scopes, and document legacy context manager destruction. --- docs/topics/context_managers.md | 163 ++++++++++++-------------------- src/libtmux/pane.py | 3 + src/libtmux/server.py | 156 ++++++++++++++++++++++++++++++ src/libtmux/session.py | 3 + src/libtmux/window.py | 3 + tests/test_server.py | 155 +++++++++++++++++++++++++++++- 6 files changed, 380 insertions(+), 103 deletions(-) diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md index d42b16b8b0..fb2cd7710d 100644 --- a/docs/topics/context_managers.md +++ b/docs/topics/context_managers.md @@ -2,137 +2,96 @@ # Context managers -When you create tmux objects through libtmux, they normally live until you -explicitly kill them. A context manager hands that cleanup back to Python: you -scope an object to a block, and libtmux kills the underlying tmux object the -moment you leave it — whether you exit cleanly or an exception unwinds the -stack. The {class}`~libtmux.Server`, {class}`~libtmux.Session`, -{class}`~libtmux.Window`, and {class}`~libtmux.Pane` classes (all main tmux -objects) support this. +Use explicitly owned scopes for temporary tmux resources. Ordinary server, +session, window and pane handles can refer to resources created elsewhere; +obtaining a handle does not transfer ownership. -Most readers never reach for this. If you're building a long-running -application, you typically let objects persist and tear them down yourself. The -context-manager form earns its keep in test fixtures and short-lived scripts, -where you want a tmux object to exist for exactly one block and then vanish. +## Own a private server -Open two terminals: +{meth}`~libtmux.Server.owned` creates a private socket directory. The daemon +starts when you create the first session. Exiting the block kills the daemon +at that private endpoint and removes its directory, including when the body +raises. This scope never accepts an existing socket and defaults to an empty +configuration. -Terminal one: start tmux in a separate terminal: - -```console -$ tmux +```python +>>> from libtmux.server import Server as TmuxServer +>>> with TmuxServer.owned() as temporary: +... created = temporary.new_session("build") +... temporary.is_alive() +True +>>> temporary.is_alive() +False ``` -Terminal two, `python` or `ptpython` if you have it: +The scope keeps its original cleanup endpoint if the yielded handle is +reconfigured. If cleanup fails, the exception propagates and the directory +remains available for retry. A body exception remains in the exception chain. -```console -$ python -``` +## Own one session -Import `libtmux`: +{meth}`~libtmux.Server.owned_session` creates a detached session on an existing +server. It rejects an existing name and cleans up only the session it created. +Other sessions remain running. Cleanup follows the session ID after a rename; +deleting the session or replacing its daemon does not transfer ownership to +another resource. ```python ->>> import libtmux +>>> with server.owned_session("temporary") as created: +... created.rename_session("renamed") +Session($... renamed) +>>> server.has_session("renamed") +False ``` -## Server context manager +Creation and cleanup use the server's command timeout. A timeout raises +{class}`~libtmux.exc.TmuxTimeout`; the command may already have taken effect. +Cleanup errors propagate instead of being interpreted as successful removal. -You create a temporary server that will be killed when you're done: +## Legacy handle contexts -```python ->>> with Server() as server: -... session = server.new_session() -... print(server.is_alive()) -True ->>> print(server.is_alive()) # Server is killed after exiting context -False -``` +The existing `with Server(...)`, `with session`, `with window` and `with pane` +forms retain their destructive behavior: they kill the addressed resource on +exit even when it existed before the block. A server with no explicit socket +addresses the default daemon, so putting that handle in a context can destroy +existing interactive sessions. Use `Server.owned()` for a private server. -## Session context manager +Lookup does not make the legacy entity contexts safe to use as borrowed +scopes. A session returned by `server.sessions.get()`, a window returned by +`session.windows.get()`, or a pane returned by `window.panes.get()` is still +killed when its context exits. Keep looked-up handles outside a `with` block +when you intend to leave their resources running. -You create a temporary session that will be killed when you're done: +This example deliberately creates a session before obtaining a second handle +through lookup. Exiting the lookup handle's context kills that session: ```python ->>> server = Server() ->>> with server.new_session() as session: -... print(session in server.sessions) -... window = session.new_window() +>>> created = server.new_session("lookup-context") +>>> with server.sessions.get(session_id=created.session_id) as looked_up: +... looked_up.session_id == created.session_id True ->>> print(session in server.sessions) # Session is killed after exiting context +>>> server.has_session("lookup-context") False ``` -## Window context manager - -You create a temporary window that will be killed when you're done: +The legacy creation patterns remain available for windows and panes: ```python ->>> server = Server() ->>> session = server.new_session() ->>> with session.new_window() as window: -... print(window in session.windows) -... pane = window.split() +>>> with session.new_window() as temporary_window: +... temporary_window in session.windows True ->>> print(window in session.windows) # Window is killed after exiting context +>>> temporary_window in session.windows False ``` -## Pane context manager - -You create a temporary pane that will be killed when you're done: - ```python ->>> server = Server() ->>> session = server.new_session() ->>> window = session.new_window() ->>> with window.split() as pane: -... print(pane in window.panes) -... pane.send_keys('echo "Hello"') +>>> with window.split() as temporary_pane: +... temporary_pane in window.panes True ->>> print(pane in window.panes) # Pane is killed after exiting context +>>> temporary_pane in window.panes False ``` -## Nested context managers - -For complex setups, you can nest contexts to build a whole tmux hierarchy at -once and have every layer torn down for you: - -```python ->>> with Server() as server: -... with server.new_session() as session: -... with session.new_window() as window: -... with window.split() as pane: -... pane.send_keys('echo "Hello"') -... # Do work with the pane -... # Everything is cleaned up automatically when exiting contexts -``` - -This ensures that: - -1. The pane is killed when exiting its context -2. The window is killed when exiting its context -3. The session is killed when exiting its context -4. The server is killed when exiting its context - -The cleanup happens in reverse order (pane → window → session → server), ensuring proper resource management. - -## Benefits - -Reaching for a context manager buys you a few things. Resources clean themselves -up the moment you leave the block, so you never manually call the -{meth}`~libtmux.Server.kill`, {meth}`~libtmux.Session.kill`, -{meth}`~libtmux.Window.kill`, or {meth}`~libtmux.Pane.kill` methods and the code -stays uncluttered. Because cleanup runs on the way out of the block, it fires -even when an exception unwinds the stack — so you don't leak a stray session or -pane on the error path. And when you nest contexts, the objects tear down in -hierarchical order, which keeps tmux's own bookkeeping consistent. - -## When to use - -Use context managers when you're writing test fixtures, running short-lived -sessions, or managing several tmux servers that each need to disappear cleanly. -They also pay off in any script that might raise partway through, or when you're -spinning up an isolated environment that has to be cleaned up afterward. - -[target]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS +Nested contexts clean up in reverse order. Killing a session also affects its +windows and panes according to tmux's normal lifetime rules; closing a Python +handle alone does not terminate a tmux resource. diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 6e20a3d26e..03a09add1d 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -132,6 +132,9 @@ def __exit__( ) -> None: """Exit the context, killing the pane if it exists. + This also destroys a pane obtained through lookup, not only one + created in this process. Keep borrowed handles outside a ``with`` block. + Parameters ---------- exc_type : type[BaseException] | None diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 9d39e05d92..92519b33ca 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -7,11 +7,13 @@ from __future__ import annotations +import contextlib import logging import os import pathlib import shutil import subprocess +import tempfile import typing as t import warnings @@ -38,6 +40,7 @@ if t.TYPE_CHECKING: import types + from collections.abc import Iterator from typing import TypeAlias from typing_extensions import Self @@ -259,6 +262,69 @@ def from_env(cls, env: t.Mapping[str, str] | None = None) -> Server: """ return cls(socket_path=socket_path_from_env(env)) + @classmethod + @contextlib.contextmanager + def owned( + cls, + *, + config_file: str = os.devnull, + tmux_bin: str | pathlib.Path | None = None, + timeout: float | None = None, + ) -> Iterator[Self]: + """Own a private server endpoint for the duration of a block. + + Creates a private socket directory on entry. tmux starts when the + first session is created. On exit, kills that endpoint's server and + removes the directory. No existing endpoint can be supplied. + + Parameters + ---------- + config_file : str, optional + Configuration for the new daemon; defaults to an empty config. + tmux_bin : str or Path, optional + Executable path; defaults to ``tmux`` on ``PATH``. + timeout : float, optional + Per-command timeout, including cleanup, in seconds. + + Yields + ------ + Server + Server addressed by the private socket. + + Notes + ----- + Cleanup retains its original endpoint if the yielded handle changes. + A cleanup failure propagates and leaves the socket directory available + for retry; a body exception remains in the exception chain. + + Examples + -------- + >>> from libtmux.server import Server as TmuxServer + >>> with TmuxServer.owned() as temporary: + ... created = temporary.new_session("build") + ... temporary.is_alive() + True + >>> temporary.is_alive() + False + """ + directory = pathlib.Path(tempfile.mkdtemp(prefix="libtmux-owned-")) + socket_path = directory / "socket" + try: + yield cls( + socket_path=socket_path, + config_file=config_file, + tmux_bin=tmux_bin, + timeout=timeout, + ) + finally: + if socket_path.exists(): + Server( + socket_path=socket_path, + tmux_bin=tmux_bin, + timeout=timeout, + ).kill() + shutil.rmtree(directory) + def __enter__(self) -> Self: """Enter the context, returning self. @@ -277,6 +343,9 @@ def __exit__( ) -> None: """Exit the context, killing the server if it exists. + This legacy scope also kills a daemon that existed before entry. + Use :meth:`owned` to create and own a private endpoint instead. + Parameters ---------- exc_type : type[BaseException] | None @@ -2404,6 +2473,93 @@ def new_session( return session + @contextlib.contextmanager + def owned_session( + self, + session_name: str | None = None, + *, + start_directory: StrPath | None = None, + window_name: str | None = None, + window_command: str | None = None, + environment: dict[str, str] | None = None, + ) -> Iterator[Session]: + """Create a detached session and kill only that session on exit. + + Existing names are rejected. Cleanup follows the created session's + ID after a rename and leaves a replacement session or daemon alone. + Deleting the session inside the block makes cleanup a no-op. + + Parameters + ---------- + session_name : str, optional + Name for the new session; tmux chooses one when omitted. + start_directory : str or PathLike, optional + Working directory for the initial window. + window_name : str, optional + Name for the initial window. + window_command : str, optional + Command for the initial window. + environment : dict[str, str], optional + Environment variables for the new session. + + Yields + ------ + Session + The newly created session. + + Raises + ------ + :exc:`~libtmux.exc.TmuxSessionExists` + The requested name already exists; it is never adopted or killed. + :exc:`~libtmux.exc.LibTmuxException` + Creation or cleanup fails. + :exc:`~libtmux.exc.TmuxTimeout` + A command exceeds this server's timeout. Its effects may be unknown. + + Examples + -------- + >>> with server.owned_session("temporary") as created: + ... created.session_name + 'temporary' + >>> server.has_session("temporary") + False + """ + cleanup_server = Server( + socket_name=self.socket_name, + socket_path=self.socket_path, + tmux_bin=self.tmux_bin, + timeout=self.timeout, + ) + session = self.new_session( + session_name, + start_directory=start_directory, + window_name=window_name, + window_command=window_command, + environment=environment, + ) + assert session.session_id is not None + assert session.pid is not None + assert session.start_time is not None + session_id = f"${int(session.session_id.removeprefix('$'))}" + pid = int(session.pid) + started = int(session.start_time) + generation = f"#{{&&:#{{==:#{{pid}},{pid}}},#{{==:#{{start_time}},{started}}}}}" + exists = f"#{{S:#{{?#{{==:#{{session_id}},{session_id}}},1,}}}}" + predicate = f"#{{&&:{generation},{exists}}}" + try: + yield session + finally: + # Check identity and kill within tmux's synchronous command queue. + proc = cleanup_server.cmd( + "if-shell", "-F", predicate, f"kill-session -t {session_id}" + ) + if (proc.returncode or proc.stderr) and not _is_daemon_not_up_error( + " ".join(proc.stderr) + ): + raise exc.LibTmuxException( + proc.stderr or f"Session cleanup exited with {proc.returncode}" + ) + # # Relations # diff --git a/src/libtmux/session.py b/src/libtmux/session.py index b251f2a635..13b4ecf9ee 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -134,6 +134,9 @@ def __exit__( ) -> None: """Exit the context, killing the session if it exists. + This legacy behavior also applies to handles obtained through lookup. + Use :meth:`Server.owned_session` for a scope that creates its resource. + Parameters ---------- exc_type : type[BaseException] | None diff --git a/src/libtmux/window.py b/src/libtmux/window.py index f0b7f78785..bb8d0fe07f 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -148,6 +148,9 @@ def __exit__( ) -> None: """Exit the context, killing the window if it exists. + This also destroys a window obtained through lookup, not only one + created in this process. Keep borrowed handles outside a ``with`` block. + Parameters ---------- exc_type : type[BaseException] | None diff --git a/tests/test_server.py b/tests/test_server.py index cfe00e9c03..2d0371dc51 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -13,7 +13,7 @@ import pytest -from libtmux import exc +from libtmux import common, exc from libtmux._internal.control_mode import ControlMode from libtmux.server import Server @@ -365,6 +365,159 @@ def test_server_context_manager(TestServer: type[Server]) -> None: assert not server.is_alive() +def test_owned_server_keeps_its_private_endpoint( + server: Server, + session: Session, +) -> None: + """Cleanup targets the created endpoint even if the yielded handle changes.""" + with Server.owned(tmux_bin=server.tmux_bin) as owned: + owned.new_session("temporary") + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + assert socket_path.parent.stat().st_mode & 0o777 == 0o700 + assert owned.is_alive() + owned.socket_path = server.socket_path + owned.socket_name = server.socket_name + + assert not socket_path.parent.exists() + assert not Server(socket_path=socket_path).is_alive() + assert session in server.sessions + + +def test_owned_server_cleans_up_after_body_failure(server: Server) -> None: + """An exception still terminates the private daemon and removes its socket.""" + body_error = RuntimeError("body failed") + with ( + pytest.raises(RuntimeError, match="body failed"), + Server.owned(tmux_bin=server.tmux_bin) as owned, + ): + owned.new_session("temporary") + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + raise body_error + assert not socket_path.parent.exists() + + +def test_owned_session_cleans_up_by_id_after_rename( + server: Server, + session: Session, +) -> None: + """The created session is removed while pre-existing sessions survive.""" + with server.owned_session("temporary") as owned: + session_id = owned.session_id + owned.rename_session("renamed") + assert server.sessions.get(session_id=session_id, default=None) is None + assert session in server.sessions + + +def test_owned_session_refuses_an_existing_name( + server: Server, + session: Session, +) -> None: + """A failed creation never adopts or destroys the existing session.""" + with ( + pytest.raises(exc.TmuxSessionExists), + server.owned_session(session.session_name), + ): + pytest.fail("an existing session must not be yielded") + assert session in server.sessions + + +def test_owned_session_preserves_same_name_replacement(server: Server) -> None: + """Deleting the owned session does not transfer ownership to its old name.""" + with server.owned_session("temporary") as owned: + owned.kill() + replacement = server.new_session("temporary") + assert replacement in server.sessions + + +def test_owned_session_preserves_restarted_daemon(server: Server) -> None: + """Reused ids on a new daemon must not receive cleanup for the old daemon.""" + with Server.owned(tmux_bin=server.tmux_bin) as private: + with private.owned_session("original") as owned: + original_id = owned.session_id + private.kill() + replacement = private.new_session("replacement") + assert replacement.session_id == original_id + assert replacement in private.sessions + + +def test_owned_session_cleans_up_after_body_failure(server: Server) -> None: + """Cleanup runs during exception unwinding without swallowing the body error.""" + body_error = RuntimeError("body failed") + with ( + pytest.raises(RuntimeError, match="body failed"), + server.owned_session("temporary") as owned, + ): + session_id = owned.session_id + raise body_error + assert server.sessions.get(session_id=session_id, default=None) is None + + +def test_owned_session_preserves_body_and_cleanup_errors( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cleanup timeout remains visible with the original body error chained.""" + run_command = common.run_command + body_error = RuntimeError("body failed") + + def run( + *args: object, + tmux_bin: str | None = None, + timeout: float | None = None, + ) -> common.CommandResult: + if "if-shell" in args: + raise exc.TmuxTimeout([str(arg) for arg in args], 0.1) + return run_command(*args, tmux_bin=tmux_bin, timeout=timeout) + + with ( + monkeypatch.context() as patch, + pytest.raises(exc.TmuxTimeout) as caught, + server.owned_session() as owned, + ): + patch.setattr(common, "run_command", run) + raise body_error + assert caught.value.__context__ is body_error + assert caught.value.timeout == 0.1 + assert owned in server.sessions + owned.kill() + + +def test_owned_server_preserves_socket_after_cleanup_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Failed cleanup retains a reachable endpoint so the caller can retry.""" + run_command = common.run_command + cleanup_error = PermissionError("cleanup denied") + + def run( + *args: object, + tmux_bin: str | None = None, + timeout: float | None = None, + ) -> common.CommandResult: + if "kill-server" in args: + raise cleanup_error + return run_command(*args, tmux_bin=tmux_bin, timeout=timeout) + + try: + with ( + monkeypatch.context() as patch, + pytest.raises(PermissionError), + Server.owned(tmux_bin=server.tmux_bin) as owned, + ): + owned.new_session() + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + patch.setattr(common, "run_command", run) + assert socket_path.exists() + assert owned.is_alive() + finally: + owned.kill() + shutil.rmtree(socket_path.parent) + + class StartDirectoryTestFixture(t.NamedTuple): """Test fixture for start_directory parameter testing.""" From 34bd5b048b2878798e264b8ffea1b275e5f76f53 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 21:20:32 -0500 Subject: [PATCH 09/73] Server(fix): Preserve owned endpoints after failed cleanup Check both exit status and stderr before removing an owned server's socket directory. Keep completed failures visible and the endpoint available for retry without changing legacy Server.kill behavior. --- src/libtmux/server.py | 10 ++++++++-- tests/test_server.py | 46 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 92519b33ca..e5372aa67d 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -318,11 +318,17 @@ def owned( ) finally: if socket_path.exists(): - Server( + proc = Server( socket_path=socket_path, tmux_bin=tmux_bin, timeout=timeout, - ).kill() + ).cmd("kill-server") + if (proc.returncode or proc.stderr) and not _is_daemon_not_up_error( + " ".join(proc.stderr) + ): + raise exc.LibTmuxException( + proc.stderr or f"Server cleanup exited with {proc.returncode}" + ) shutil.rmtree(directory) def __enter__(self) -> Self: diff --git a/tests/test_server.py b/tests/test_server.py index 2d0371dc51..5c0303296a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -518,6 +518,52 @@ def run( shutil.rmtree(socket_path.parent) +def test_owned_server_preserves_socket_after_silent_cleanup_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A completed failure without stderr remains visible and retryable.""" + run_command = common.run_command + completed = run_command("-V", tmux_bin=server.tmux_bin) + removed: list[pathlib.Path] = [] + + def run( + *args: object, + tmux_bin: str | None = None, + timeout: float | None = None, + ) -> common.CommandResult: + if "kill-server" in args: + return common.CommandResult( + cmd=[str(arg) for arg in args], + stdout=[], + stderr=[], + returncode=7, + process=completed.process, + ) + return run_command(*args, tmux_bin=tmux_bin, timeout=timeout) + + try: + with monkeypatch.context() as patch: + # Keep the endpoint reachable even if the assertion exposes a regression. + patch.setattr(shutil, "rmtree", removed.append) + with ( + pytest.raises( + exc.LibTmuxException, match="Server cleanup exited with 7" + ), + Server.owned(tmux_bin=server.tmux_bin) as owned, + ): + owned.new_session() + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + patch.setattr(common, "run_command", run) + assert not removed + assert socket_path.exists() + assert owned.is_alive() + finally: + owned.kill() + shutil.rmtree(socket_path.parent) + + class StartDirectoryTestFixture(t.NamedTuple): """Test fixture for start_directory parameter testing.""" From 3c1c1e37366735a084986c9aa5ea4363bcae0d7d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 06:54:38 -0500 Subject: [PATCH 10/73] Docs(fix): Restore context manager walkthrough why: The owned-scope guide displaced useful examples even though ordinary context manager behavior remains supported. what: - Restore server, session, window, pane and nested examples - Assert cleanup order and exception cleanup - Keep explicit ownership guidance alongside the walkthrough --- docs/topics/context_managers.md | 230 +++++++++++++++++++++++++------- 1 file changed, 182 insertions(+), 48 deletions(-) diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md index fb2cd7710d..a0d2575a8f 100644 --- a/docs/topics/context_managers.md +++ b/docs/topics/context_managers.md @@ -2,17 +2,174 @@ # Context managers -Use explicitly owned scopes for temporary tmux resources. Ordinary server, -session, window and pane handles can refer to resources created elsewhere; -obtaining a handle does not transfer ownership. +When you create tmux objects through libtmux, they normally live until you +explicitly kill them. A context manager hands that cleanup back to Python: you +scope an object to a block, and libtmux kills the underlying tmux object the +moment you leave it — whether you exit cleanly or an exception unwinds the +stack. The {class}`~libtmux.Server`, {class}`~libtmux.Session`, +{class}`~libtmux.Window`, and {class}`~libtmux.Pane` classes (all main tmux +objects) support this. + +Most readers never reach for this. If you're building a long-running +application, you typically let objects persist and tear them down yourself. The +context-manager form earns its keep in test fixtures and short-lived scripts, +where you want a tmux object to exist for exactly one block and then vanish. + +Entering a handle's context opts into destruction even if the resource already +exists. In particular, `Server()` addresses the default daemon: its context +kills all sessions on that daemon. Use {meth}`~libtmux.Server.owned` for a +private server or {meth}`~libtmux.Server.owned_session` for a session the block +creates; see {ref}`owned-context-managers` below. + +Open two terminals: + +Terminal one: start tmux in a separate terminal: + +```console +$ tmux +``` + +Terminal two, `python` or `ptpython` if you have it: + +```console +$ python +``` + +Import {class}`~libtmux.Server`: + +```python +>>> from libtmux import Server +``` + +## Server context manager + +The context kills the addressed server when you're done, including any sessions +that existed before the block: + +```python +>>> with Server() as server: +... session = server.new_session() +... print(server.is_alive()) +True +>>> print(server.is_alive()) # Server is killed after exiting context +False +``` + +## Session context manager + +You create a temporary session that will be killed when you're done: -## Own a private server +```python +>>> server = Server() +>>> with server.new_session() as session: +... print(session in server.sessions) +... window = session.new_window() +True +>>> print(session in server.sessions) # Session is killed after exiting context +False +``` + +## Window context manager -{meth}`~libtmux.Server.owned` creates a private socket directory. The daemon -starts when you create the first session. Exiting the block kills the daemon -at that private endpoint and removes its directory, including when the body -raises. This scope never accepts an existing socket and defaults to an empty -configuration. +You create a temporary window that will be killed when you're done: + +```python +>>> server = Server() +>>> session = server.new_session() +>>> with session.new_window() as window: +... print(window in session.windows) +... pane = window.split() +True +>>> print(window in session.windows) # Window is killed after exiting context +False +``` + +## Pane context manager + +You create a temporary pane that will be killed when you're done: + +```python +>>> server = Server() +>>> session = server.new_session() +>>> window = session.new_window() +>>> with window.split() as pane: +... print(pane in window.panes) +... pane.send_keys('echo "Hello"') +True +>>> print(pane in window.panes) # Pane is killed after exiting context +False +``` + +## Nested context managers + +For complex setups, you can nest contexts to build a whole tmux hierarchy at +once and have every layer torn down for you: + +```python +>>> with Server() as server: +... with server.new_session() as session: +... with session.new_window() as window: +... with window.split() as pane: +... pane.send_keys('echo "Hello"') +... # Do work with the pane +... assert pane not in window.panes +... assert window not in session.windows +... assert session not in server.sessions +>>> server.is_alive() +False +``` + +This ensures that: + +1. The pane is killed when exiting its context +2. The window is killed when exiting its context +3. The session is killed when exiting its context +4. The server is killed when exiting its context + +The cleanup happens in reverse order (pane → window → session → server), ensuring proper resource management. + +## Cleanup after an exception + +The same cleanup runs when the body raises. The exception still reaches the +caller after the context exits: + +```python +>>> try: +... with server.new_session() as temporary: +... assert temporary in server.sessions +... raise RuntimeError("body failed") +... except RuntimeError as error: +... print(error) +body failed +>>> temporary in server.sessions +False +``` + +## Benefits + +Reaching for a context manager buys you a few things. Resources clean themselves +up the moment you leave the block, so you never manually call the +{meth}`~libtmux.Server.kill`, {meth}`~libtmux.Session.kill`, +{meth}`~libtmux.Window.kill`, or {meth}`~libtmux.Pane.kill` methods and the code +stays uncluttered. Because cleanup runs on the way out of the block, it fires +even when an exception unwinds the stack — so you don't leak a stray session or +pane on the error path. And when you nest contexts, the objects tear down in +hierarchical order, which keeps tmux's own bookkeeping consistent. + +(owned-context-managers)= + +## Explicit ownership + +Owned scopes create their resources instead of adopting an existing handle. +This makes the boundary useful when a script shares a tmux server with other +work. + +### Own a private server + +`Server.owned()` creates a private socket directory. The daemon starts when you +create the first session. Exiting the block kills the daemon at that private +endpoint and removes its directory, including when the body raises. This scope +never accepts an existing socket and defaults to an empty configuration. ```python >>> from libtmux.server import Server as TmuxServer @@ -28,11 +185,11 @@ The scope keeps its original cleanup endpoint if the yielded handle is reconfigured. If cleanup fails, the exception propagates and the directory remains available for retry. A body exception remains in the exception chain. -## Own one session +### Own one session -{meth}`~libtmux.Server.owned_session` creates a detached session on an existing -server. It rejects an existing name and cleans up only the session it created. -Other sessions remain running. Cleanup follows the session ID after a rename; +`server.owned_session()` creates a detached session on an existing server. It +rejects an existing name and cleans up only the session it created. Other +sessions remain running. Cleanup follows the session ID after a rename; deleting the session or replacing its daemon does not transfer ownership to another resource. @@ -45,25 +202,15 @@ False ``` Creation and cleanup use the server's command timeout. A timeout raises -{class}`~libtmux.exc.TmuxTimeout`; the command may already have taken effect. +{exc}`~libtmux.exc.TmuxTimeout`; the command may already have taken effect. Cleanup errors propagate instead of being interpreted as successful removal. -## Legacy handle contexts - -The existing `with Server(...)`, `with session`, `with window` and `with pane` -forms retain their destructive behavior: they kill the addressed resource on -exit even when it existed before the block. A server with no explicit socket -addresses the default daemon, so putting that handle in a context can destroy -existing interactive sessions. Use `Server.owned()` for a private server. +### Contexts on looked-up handles -Lookup does not make the legacy entity contexts safe to use as borrowed -scopes. A session returned by `server.sessions.get()`, a window returned by -`session.windows.get()`, or a pane returned by `window.panes.get()` is still -killed when its context exits. Keep looked-up handles outside a `with` block -when you intend to leave their resources running. - -This example deliberately creates a session before obtaining a second handle -through lookup. Exiting the lookup handle's context kills that session: +The ordinary session, window and pane contexts also kill resources obtained +through lookup. Keep those handles outside a `with` block when you intend to +leave their resources running. Here, exiting a second handle's context kills +the session created before the block: ```python >>> created = server.new_session("lookup-context") @@ -74,24 +221,11 @@ True False ``` -The legacy creation patterns remain available for windows and panes: - -```python ->>> with session.new_window() as temporary_window: -... temporary_window in session.windows -True ->>> temporary_window in session.windows -False -``` +## When to use -```python ->>> with window.split() as temporary_pane: -... temporary_pane in window.panes -True ->>> temporary_pane in window.panes -False -``` +Use context managers when you're writing test fixtures, running short-lived +sessions, or managing several tmux servers that each need to disappear cleanly. +They also pay off in any script that might raise partway through, or when you're +spinning up an isolated environment that has to be cleaned up afterward. -Nested contexts clean up in reverse order. Killing a session also affects its -windows and panes according to tmux's normal lifetime rules; closing a Python -handle alone does not terminate a tmux resource. +[target]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS From 10379bec7726f270110d6f5017c59db005c6d0c3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 08:11:14 -0500 Subject: [PATCH 11/73] Tests(fix): Control the retry test clock why: Scheduler delays can exceed the elapsed-time assertions even when retry behavior is correct. what: - Advance a clock local to the retry module without sleeping - Verify attempts, intervals and timeout failure channels - Preserve success and failure coverage in parameterized tests --- tests/test/test_retry.py | 123 ++++++++++++++------------------------- 1 file changed, 44 insertions(+), 79 deletions(-) diff --git a/tests/test/test_retry.py b/tests/test/test_retry.py index c2a0f9255b..09c74964e0 100644 --- a/tests/test/test_retry.py +++ b/tests/test/test_retry.py @@ -2,104 +2,69 @@ from __future__ import annotations -from time import sleep, time +from dataclasses import dataclass import pytest from libtmux import exc -from libtmux.test.retry import retry_until +from libtmux.test import retry -def test_retry_three_times() -> None: - """Test retry_until().""" - ini = time() - value = 0 +@dataclass +class Clock: + """Advance retry time without relying on operating-system scheduling.""" - def call_me_three_times() -> bool: - nonlocal value - sleep(0.3) # Sleep for 0.3 seconds to simulate work + milliseconds: int = 0 - if value == 2: - return True + def time(self) -> float: + """Return the controlled time in seconds.""" + return self.milliseconds / 1000 - value += 1 - return False - - retry_until(call_me_three_times, 1) - - end = time() + def sleep(self, seconds: float) -> None: + """Advance time without blocking the test.""" + self.milliseconds += round(seconds * 1000) - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations +@pytest.fixture +def clock(monkeypatch: pytest.MonkeyPatch) -> Clock: + """Replace only the retry module's clock, leaving pytest's clock intact.""" + controlled = Clock() + monkeypatch.setattr(retry, "time", controlled) + return controlled -def test_function_times_out() -> None: - """Test time outs with retry_until().""" - ini = time() - - def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) - return False - with pytest.raises(exc.WaitTimeout): - retry_until(never_true, 1) +@pytest.mark.parametrize("raises", [True, False]) +def test_retry_three_times(clock: Clock, raises: bool) -> None: + """Return success after two false results and the configured intervals.""" + attempts = 0 - end = time() + def eventually_true() -> bool: + nonlocal attempts + clock.sleep(0.3) + attempts += 1 + return attempts == 3 - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert retry.retry_until(eventually_true, 1, raises=raises) + assert attempts == 3 + assert clock.time() == 1 -def test_function_times_out_no_raise() -> None: - """Tests retry_until() with exception raising disabled.""" - ini = time() +@pytest.mark.parametrize("raises", [True, False, None]) +def test_function_times_out(clock: Clock, raises: bool | None) -> None: + """Stop retrying at the deadline through the selected failure channel.""" + attempts = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) - return False - - retry_until(never_true, 1, raises=False) - - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations - - -def test_function_times_out_no_raise_assert() -> None: - """Tests retry_until() with exception raising disabled, returning False.""" - ini = time() - - def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) - return False - - assert not retry_until(never_true, 1, raises=False) - - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations - - -def test_retry_three_times_no_raise_assert() -> None: - """Tests retry_until() with exception raising disabled, with closure variable.""" - ini = time() - value = 0 - - def call_me_three_times() -> bool: - nonlocal value - sleep( - 0.3, - ) # Sleep for 0.3 seconds to simulate work (called 3 times in ~0.9 seconds) - - if value == 2: - return True - - value += 1 + nonlocal attempts + clock.sleep(0.1) + attempts += 1 return False - assert retry_until(call_me_three_times, 1, raises=False) + if raises: + with pytest.raises(exc.WaitTimeout): + retry.retry_until(never_true, 1, raises=raises) + else: + assert not retry.retry_until(never_true, 1, raises=raises) - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert attempts == 7 + assert clock.time() == 1 From 6f9cd641e07b552bfb11c4d40909bc82eb18ab95 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 08:34:22 -0500 Subject: [PATCH 12/73] CI(fix): Measure pytest plugin imports why: Pytest imports the libtmux plugin before pytest-cov starts, leaving executed declarations absent from the coverage report. what: - Start coverage before pytest and combine worker process data - Require coverage with built-in subprocess instrumentation - Document the local command and quote workflow filesystem values --- .github/CONTRIBUTING.md | 10 ++++++++++ .github/workflows/tests.yml | 13 ++++++------- pyproject.toml | 6 ++++-- uv.lock | 4 ++-- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7c0edbe84a..dd7703d062 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -62,6 +62,16 @@ Test: $ uv run pytest ``` +Start coverage before pytest so plugin imports and worker processes are +measured: + +```console +$ uv run coverage erase && \ + uv run coverage run -m pytest -n auto && \ + uv run coverage combine && \ + uv run coverage xml +``` + Documentation is a gate, not a courtesy. Examples in docstrings, documentation pages, and `README.md` are executed by `pytest`; the doctest flags live in `pyproject.toml`, so there is no separate doctest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d7d18a903b..fe8e07766e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,7 +53,7 @@ jobs: cd ~/tmux-src/tmux-${{ matrix.tmux-version }} git checkout ${{ matrix.tmux-version }} sh autogen.sh - ./configure --prefix=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }} && make && make install + ./configure --prefix="$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}" && make && make install export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH cd ~ tmux -V @@ -77,13 +77,12 @@ jobs: run: | sudo apt install libevent-2.1-7 export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH - ls $HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin + ls "$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin" tmux -V - uv run py.test --cov=./ --cov-append --cov-report=xml -n auto --verbose - env: - COV_CORE_SOURCE: . - COV_CORE_CONFIG: .coveragerc - COV_CORE_DATAFILE: .coverage.eager + uv run coverage erase + uv run coverage run -m pytest -n auto --verbose + uv run coverage combine + uv run coverage xml - uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/pyproject.toml b/pyproject.toml index 21d658f2b3..bc357c47cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dev = [ "pytest-xdist", # Coverage "codecov", - "coverage", + "coverage>=7.10.6", "pytest-cov", # Lint "ruff>=0.16.1", @@ -90,7 +90,7 @@ testing = [ ] coverage =[ "codecov", - "coverage", + "coverage>=7.10.6", "pytest-cov", ] lint = [ @@ -150,6 +150,8 @@ files = [ [tool.coverage.run] +source = ["src/libtmux"] +patch = ["subprocess"] branch = true parallel = true omit = [ diff --git a/uv.lock b/uv.lock index d0a9ddac0f..cf70797414 100644 --- a/uv.lock +++ b/uv.lock @@ -815,12 +815,12 @@ testing = [ [package.metadata.requires-dev] coverage = [ { name = "codecov" }, - { name = "coverage" }, + { name = "coverage", specifier = ">=7.10.6" }, { name = "pytest-cov" }, ] dev = [ { name = "codecov" }, - { name = "coverage" }, + { name = "coverage", specifier = ">=7.10.6" }, { name = "gp-libs", specifier = ">=0.0.19" }, { name = "gp-sphinx", specifier = "==0.1.0a37" }, { name = "mypy" }, From 8d8755568fe6a7fdb9576a74605d094f7dae6b4b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 08:34:22 -0500 Subject: [PATCH 13/73] Tests(test): Cover command and cleanup errors why: Command startup and completed cleanup refusals must preserve the original failure and enough context for callers to recover. what: - Exercise permission failures through both command entry points - Exercise session cleanup refusals with and without stderr - Assert retained sessions and chained body errors remain accessible --- tests/test_common.py | 25 +++++++++++++++++++++++++ tests/test_server.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/tests/test_common.py b/tests/test_common.py index db4b5c9258..919eea7183 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -6,6 +6,7 @@ import logging import os import re +import shlex import sys import time import typing as t @@ -201,6 +202,30 @@ def test_command_result_preserves_status_and_decoding(runner_name: str) -> None: assert isinstance(result, libtmux.common.CommandResult) +@pytest.mark.parametrize("runner_name", ["run_command", "tmux_cmd"]) +def test_command_permission_failure_preserves_context( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, + runner_name: str, +) -> None: + """An unexecutable file raises the OS error with structured command context.""" + binary = tmp_path / "tmux denied" + binary.write_text("#!/bin/sh\nexit 0\n") + binary.chmod(0o600) + runner = getattr(libtmux.common, runner_name) + + with ( + caplog.at_level(logging.ERROR, logger="libtmux.common"), + pytest.raises(PermissionError) as caught, + ): + runner("list-sessions", tmux_bin=str(binary)) + + assert caught.value.filename == str(binary) + records = [r for r in caplog.records if hasattr(r, "tmux_cmd")] + assert len(records) == 1 + assert records[0].tmux_cmd == shlex.join([str(binary), "list-sessions"]) + + def test_tmux_cmd_delegates_to_runner( server: Server, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_server.py b/tests/test_server.py index 5c0303296a..963f0e955e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,6 +6,7 @@ import logging import os import pathlib +import shlex import shutil import subprocess import time @@ -484,6 +485,42 @@ def run( owned.kill() +@pytest.mark.parametrize("stderr", ["", "cleanup refused"]) +def test_owned_session_preserves_completed_cleanup_failure( + server: Server, + tmp_path: pathlib.Path, + stderr: str, +) -> None: + """A real child refuses cleanup; the error keeps the body failure chained.""" + executable = server.tmux_bin or shutil.which("tmux") + assert executable is not None + wrapper = tmp_path / "tmux-cleanup-refusal" + wrapper.write_text( + "#!/bin/sh\n" + 'for arg do\nif [ "$arg" = "if-shell" ]; then\n' + f"printf %s {shlex.quote(stderr)} >&2\nexit 7\nfi\ndone\n" + f'exec {shlex.quote(executable)} "$@"\n' + ) + wrapper.chmod(0o700) + refusing = Server( + socket_name=server.socket_name, + socket_path=server.socket_path, + tmux_bin=str(wrapper), + ) + body_error = RuntimeError("body failed") + + with ( + pytest.raises(exc.LibTmuxException) as caught, + refusing.owned_session() as owned, + ): + raise body_error + + assert caught.value.__context__ is body_error + assert (stderr or "Session cleanup exited with 7") in str(caught.value) + assert owned in server.sessions + owned.kill() + + def test_owned_server_preserves_socket_after_cleanup_failure( server: Server, monkeypatch: pytest.MonkeyPatch, From a90a9bbad5879965a13e25e7fcce18bcba4c6bb9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 09:01:47 -0500 Subject: [PATCH 14/73] CI(fix): Cache tmux by resolved revision why: A permanent master cache kept testing an upstream bug already fixed in current tmux, and matrix jobs competed to save one uv cache. what: - Resolve and validate each tmux ref before caching and checkout - Include platform and source revision in the tmux cache key - Let one matrix job save the shared dependency cache --- .github/workflows/tests.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fe8e07766e..aee0aaeec1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,6 +20,7 @@ jobs: uses: astral-sh/setup-uv@v10.0.1 with: enable-cache: true + save-cache: ${{ strategy.job-index == 0 }} - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} @@ -36,22 +37,39 @@ jobs: - name: Install dependencies run: uv sync --all-extras --dev + - name: Resolve tmux revision + id: tmux-source + env: + TMUX_REF: ${{ matrix.tmux-version }} + run: | + if [[ "$TMUX_REF" == master ]]; then + ref=refs/heads/master + else + ref="refs/tags/$TMUX_REF" + fi + revision=$(git ls-remote --refs https://github.com/tmux/tmux.git "$ref") + revision=${revision%%[[:space:]]*} + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + - name: Setup tmux build cache for tmux ${{ matrix.tmux-version }} id: tmux-build-cache uses: actions/cache@v6 with: path: ~/tmux-builds/tmux-${{ matrix.tmux-version }} - key: tmux-${{ matrix.tmux-version }} + key: tmux-${{ runner.os }}-${{ runner.arch }}-${{ matrix.tmux-version }}-${{ steps.tmux-source.outputs.revision }} - name: Build tmux ${{ matrix.tmux-version }} if: steps.tmux-build-cache.outputs.cache-hit != 'true' + env: + TMUX_REVISION: ${{ steps.tmux-source.outputs.revision }} run: | sudo apt install libevent-dev libncurses5-dev libtinfo-dev libutempter-dev bison mkdir ~/tmux-builds mkdir ~/tmux-src git clone https://github.com/tmux/tmux.git ~/tmux-src/tmux-${{ matrix.tmux-version }} cd ~/tmux-src/tmux-${{ matrix.tmux-version }} - git checkout ${{ matrix.tmux-version }} + git checkout "$TMUX_REVISION" sh autogen.sh ./configure --prefix="$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}" && make && make install export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH From efaeeeab119661a746bff7151736c51796ebf600 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 09:10:45 -0500 Subject: [PATCH 15/73] Hooks(fix): Decode tmux 3.8 events why: New tmux hooks made complete show-hooks output fail typed decoding. what: - Append 22 typed sparse hook fields with documented event meanings - Exercise new hooks against tmux 3.8 through set/show/unset cycles - Verify removed after-queue rejects setting on 3.8 --- src/libtmux/_internal/constants.py | 67 ++++++++++++++++++++++++++++++ tests/test_hooks.py | 43 ++++++++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/libtmux/_internal/constants.py b/src/libtmux/_internal/constants.py index df4d9843f2..93cc3977f4 100644 --- a/src/libtmux/_internal/constants.py +++ b/src/libtmux/_internal/constants.py @@ -834,6 +834,50 @@ 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 swapping windows. Requires tmux 3.8+. + client_closed : SparseArray[str] + Runs when a client closes. Requires tmux 3.8+. + client_created : SparseArray[str] + Runs when a client is created. Requires tmux 3.8+. + marked_pane_changed : SparseArray[str] + Runs when the marked pane changes. Requires tmux 3.8+. + pane_activity : SparseArray[str] + Runs when pane output arrives. Requires tmux 3.8+. + pane_bell : SparseArray[str] + Runs when a pane receives a bell. Requires tmux 3.8+. + pane_command_finished : SparseArray[str] + Runs when an OSC 133 command finishes. Requires tmux 3.8+. + pane_command_started : SparseArray[str] + Runs when an OSC 133 command starts. Requires tmux 3.8+. + pane_created : SparseArray[str] + Runs when a pane is created or respawned. Requires tmux 3.8+. + pane_mode_entered : SparseArray[str] + Runs when a pane enters a mode. Requires tmux 3.8+. + pane_mode_exited : SparseArray[str] + Runs when a pane leaves a mode. Requires tmux 3.8+. + pane_moved : SparseArray[str] + Runs when a pane moves between windows. Requires tmux 3.8+. + pane_prompt_closed : SparseArray[str] + Runs when a pane prompt closes. Requires tmux 3.8+. + pane_prompt_opened : SparseArray[str] + Runs when a pane prompt opens. Requires tmux 3.8+. + pane_resized : SparseArray[str] + Runs when a pane changes size. Requires tmux 3.8+. + pane_shell_prompt : SparseArray[str] + Runs when an OSC 133 shell prompt starts. Requires tmux 3.8+. + session_added_to_group : SparseArray[str] + Runs when a session joins a group. Requires tmux 3.8+. + session_removed_from_group : SparseArray[str] + Runs when a session leaves a group. Requires tmux 3.8+. + window_closed : SparseArray[str] + Runs when a window closes. Requires tmux 3.8+. + window_created : SparseArray[str] + Runs when a window is created. Requires tmux 3.8+. + window_unzoomed : SparseArray[str] + Runs when a window leaves zoom mode. Requires tmux 3.8+. + window_zoomed : SparseArray[str] + Runs when a window enters zoom mode. Requires tmux 3.8+. Examples -------- @@ -1093,6 +1137,29 @@ class Hooks( # Runs when a command fails (tmux 3.5+) command_error: SparseArray[str] = field(default_factory=SparseArray) + after_swap_window: SparseArray[str] = field(default_factory=SparseArray) + client_closed: SparseArray[str] = field(default_factory=SparseArray) + client_created: SparseArray[str] = field(default_factory=SparseArray) + marked_pane_changed: SparseArray[str] = field(default_factory=SparseArray) + pane_activity: SparseArray[str] = field(default_factory=SparseArray) + pane_bell: SparseArray[str] = field(default_factory=SparseArray) + pane_command_finished: SparseArray[str] = field(default_factory=SparseArray) + pane_command_started: SparseArray[str] = field(default_factory=SparseArray) + pane_created: SparseArray[str] = field(default_factory=SparseArray) + pane_mode_entered: SparseArray[str] = field(default_factory=SparseArray) + pane_mode_exited: SparseArray[str] = field(default_factory=SparseArray) + pane_moved: SparseArray[str] = field(default_factory=SparseArray) + pane_prompt_closed: SparseArray[str] = field(default_factory=SparseArray) + pane_prompt_opened: SparseArray[str] = field(default_factory=SparseArray) + pane_resized: SparseArray[str] = field(default_factory=SparseArray) + pane_shell_prompt: SparseArray[str] = field(default_factory=SparseArray) + session_added_to_group: SparseArray[str] = field(default_factory=SparseArray) + session_removed_from_group: SparseArray[str] = field(default_factory=SparseArray) + window_closed: SparseArray[str] = field(default_factory=SparseArray) + window_created: SparseArray[str] = field(default_factory=SparseArray) + window_unzoomed: SparseArray[str] = field(default_factory=SparseArray) + window_zoomed: SparseArray[str] = field(default_factory=SparseArray) + @classmethod def from_stdout(cls, value: list[str]) -> Hooks: """Parse raw tmux hook output into a Hooks instance. diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 4e03f0a26b..77e4d7e846 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -6,6 +6,7 @@ import pytest +from libtmux import exc from libtmux._internal.constants import Hooks from libtmux._internal.sparse_array import SparseArray from libtmux.common import has_gte_version @@ -315,6 +316,7 @@ class HookTestCase(t.NamedTuple): hook: str # tmux hook name (hyphenated) min_version: str = "3.0" # Minimum tmux version required xfail_reason: str | None = None # Mark as expected failure with reason + removed_version: str | None = None # --- Alert Hooks --- @@ -381,7 +383,7 @@ 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"), + HookTestCase("after_queue", "after-queue", removed_version="3.8"), HookTestCase("after_refresh_client", "after-refresh-client"), HookTestCase("after_rename_session", "after-rename-session"), HookTestCase("after_rename_window", "after-rename-window"), @@ -416,7 +418,39 @@ 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 + + [ + HookTestCase(name.replace("-", "_"), name, "3.8") + for name in ( + "after-swap-window", + "client-closed", + "client-created", + "marked-pane-changed", + "pane-activity", + "pane-bell", + "pane-command-finished", + "pane-command-started", + "pane-created", + "pane-mode-entered", + "pane-mode-exited", + "pane-moved", + "pane-prompt-closed", + "pane-prompt-opened", + "pane-resized", + "pane-shell-prompt", + "session-added-to-group", + "session-removed-from-group", + "window-closed", + "window-created", + "window-unzoomed", + "window-zoomed", + ) + ] ) @@ -451,6 +485,11 @@ def test_hook_set_show_unset_cycle(server: Server, test_case: HookTestCase) -> N hook_cmd = "display-message 'test hook fired'" + if test_case.removed_version and has_gte_version(test_case.removed_version): + with pytest.raises(exc.InvalidOption, match="invalid option"): + session.set_hook(f"{test_case.hook}[0]", hook_cmd) + return + # Test set_hook (using session-level hook which works on all tmux versions) session.set_hook(f"{test_case.hook}[0]", hook_cmd) From 1619969c841d959c439a0657b4f9e36ec02e0246 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 09:10:45 -0500 Subject: [PATCH 16/73] Docs(fix): Explain floating pane borders why: tmux 3.8 measures floating geometry including borders, while pane formats report the content area. what: - Describe size and position semantics in both creation methods - Show exact content placement with borders disabled - Verify default-border size and coordinates across tmux versions --- docs/topics/floating_panes.md | 10 ++++++++-- src/libtmux/pane.py | 4 ++++ src/libtmux/window.py | 4 ++++ tests/test_pane.py | 7 +++++-- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/topics/floating_panes.md b/docs/topics/floating_panes.md index 7aa96a6c3d..8a5c312796 100644 --- a/docs/topics/floating_panes.md +++ b/docs/topics/floating_panes.md @@ -47,14 +47,20 @@ which is `"1"` when it floats: You set the pane's **size** with `width` and `height` (tmux's `-x` / `-y`), and its **position** with `x` and `y` — cells measured from the top-left of the -window (tmux's `-X` / `-Y`). tmux reports the placement back through the +window (tmux's `-X` / `-Y`). On tmux 3.8+, these values include the border: +an 80-by-15 pane with a border has 78-by-13 content cells. tmux 3.7 uses +content dimensions directly. tmux reports the content position through the {attr}`pane_x ` / -{attr}`pane_y ` fields: +{attr}`pane_y ` fields, one cell inside a border on +tmux 3.8+. Disable borders when the requested position should match those +fields exactly: ```python >>> from libtmux.common import has_gte_version >>> if has_gte_version("3.7"): +... if has_gte_version("3.8"): +... _ = window.set_option("pane-border-lines", "none") ... placed = window.new_pane(width=20, height=5, x=2, y=1, shell="sleep 30") ... position = (placed.pane_x, placed.pane_y) ... else: diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 03a09add1d..9baa9a56df 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1474,12 +1474,16 @@ def new_pane( Environment variables for the new pane (``-e`` flag). width : int, optional Width of the floating pane in cells (``-x`` flag). + Includes the border on tmux 3.8+; ``pane_width`` reports content cells. height : int, optional Height of the floating pane in cells (``-y`` flag). + Includes the border on tmux 3.8+; ``pane_height`` reports content cells. x : int, optional X position of the floating pane in cells (``-X`` flag). + Places the outer border on tmux 3.8+; ``pane_x`` reports content position. y : int, optional Y position of the floating pane in cells (``-Y`` flag). + Places the outer border on tmux 3.8+; ``pane_y`` reports content position. zoom : bool, optional Zoom the pane (``-Z`` flag). empty : bool, optional diff --git a/src/libtmux/window.py b/src/libtmux/window.py index bb8d0fe07f..524d2378db 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -658,12 +658,16 @@ def new_pane( Environment variables for the new pane (``-e``). width : int, optional Width in cells (``-x``). + Includes borders on tmux 3.8+. height : int, optional Height in cells (``-y``). + Includes borders on tmux 3.8+. x : int, optional X position in cells (``-X``). + Places the outer border on tmux 3.8+. y : int, optional Y position in cells (``-Y``). + Places the outer border on tmux 3.8+. zoom : bool, optional Zoom the pane (``-Z``). empty : bool, optional diff --git a/tests/test_pane.py b/tests/test_pane.py index e05e96eb77..44ce84528a 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -1844,8 +1844,11 @@ 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" + border = 1 if has_gte_version("3.8") else 0 + assert floating.pane_width == str(80 - 2 * border) + assert floating.pane_height == str(15 - 2 * border) + assert floating.pane_x == str(5 + border) + assert floating.pane_y == str(3 + border) else: with pytest.raises(exc.LibTmuxException, match=r"new_pane .*requires tmux 3.7"): pane.new_pane(width=40, height=10) From 01aedb8ecee346cb6558f2b693b1c95c49d985f4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 10:33:12 -0500 Subject: [PATCH 17/73] Coverage(test): Measure executable branches why: Qualified overload declarations are not executable, and record splitting always produces at least one field. what: - Exclude both supported overload decorator spellings - Remove the impossible empty split branch - Cover malformed records with and without a trailing separator --- pyproject.toml | 2 +- src/libtmux/neo.py | 2 +- tests/test_neo.py | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bc357c47cb..cb2d2e61a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -172,7 +172,7 @@ exclude_lines = [ "def parse_args", "if TYPE_CHECKING:", "if t.TYPE_CHECKING:", - "@overload( |$)", + '@(?:t\.)?overload( |$)', 'class .*\bProtocol\):', "from __future__ import annotations", "import typing as t", diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index d3d0f5b19f..01e64ffc11 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1065,7 +1065,7 @@ def _split_records(stdout: list[str], field_count: int) -> list[str]: values = blob.split(FORMAT_SEPARATOR) # Every record ends with a separator, so the split always leaves one # trailing empty for the final record. - if values and values[-1] == "": + if values[-1] == "": values.pop() if field_count <= 0 or len(values) % field_count: diff --git a/tests/test_neo.py b/tests/test_neo.py index e3a25bc7b4..d5bd2b23a1 100644 --- a/tests/test_neo.py +++ b/tests/test_neo.py @@ -322,9 +322,10 @@ def test_split_records_round_trips_newlines( assert parsed == expected -def test_split_records_reports_a_forged_separator() -> None: - """A value carrying the separator is named, not a ``zip`` message.""" - stdout = [f"a{FORMAT_SEPARATOR}b{FORMAT_SEPARATOR}c{FORMAT_SEPARATOR}"] +@pytest.mark.parametrize("trailer", ["", FORMAT_SEPARATOR]) +def test_split_records_reports_a_forged_separator(trailer: str) -> None: + """Malformed field counts fail even if the last delimiter is missing.""" + stdout = [f"a{FORMAT_SEPARATOR}b{FORMAT_SEPARATOR}c{trailer}"] with pytest.raises(exc.LibTmuxException, match="could not be parsed"): _split_records(stdout, 2) From f3479a38e96fcce1ea40554b7f43e9ad01e7c791 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 10:33:12 -0500 Subject: [PATCH 18/73] Control(test): Drain buffered replies why: Selecting the pipe misses output already held by its text reader, so the UTF-8 regression can time out after valid data arrives. what: - Detach after the marker and collect the remaining process output - Preserve the locale-based decoding regression and bounded wait --- tests/test_control_mode.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index f72f68466b..8c06c928d3 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -4,7 +4,6 @@ import locale import os -import select import sys import typing as t @@ -83,17 +82,10 @@ def test_control_mode_stdout_preserves_non_ascii_output( with control_mode() as ctl: os.write( ctl._write_fd, - f"display-message -p '{FORMAT_SEPARATOR}'\n".encode(), + f"display-message -p '{FORMAT_SEPARATOR}'\ndetach-client\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: - break - else: - pytest.fail("FORMAT_SEPARATOR U+241E not found in control output") + stdout, stderr = ctl._proc.communicate(timeout=5) + assert ctl._proc.returncode == 0, stderr + assert FORMAT_SEPARATOR in stdout finally: locale.setlocale(locale.LC_CTYPE, old_lc_ctype) From 39e29e46fad7124e5a99a9803fcacf3028f7ab4f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 10:33:12 -0500 Subject: [PATCH 19/73] Capture(test): Wait for emitted markers why: The echoed command contains the completion marker before its output has reached the terminal. what: - Print the marker on its own line - Wait for an exact joined capture line before checking output --- tests/test_pane_capture_pane.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 5111a06706..269d6098c3 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -353,13 +353,12 @@ def prompt_ready() -> bool: # Send command with a unique marker to detect completion marker = f"__DONE_{test_id}__" - full_command = f'{command}; echo "{marker}"' + full_command = f'{command}; printf "\\n%s\\n" "{marker}"' pane.send_keys(full_command, literal=False, suppress_history=False) - # Wait for marker to appear + # The echoed command contains the marker before its output arrives. def command_complete() -> bool: - output = "\n".join(pane.capture_pane()) - return marker in output + return marker in pane.capture_pane(join_wrapped=True) retry_until(command_complete, 5, raises=True) From c910a501dc2d5caa03bc9fcd7b94620840f34372 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 10:33:12 -0500 Subject: [PATCH 20/73] Ownership(test): Exercise cleanup refusals why: Completed cleanup failures must retain the owned endpoint, and fixture setup errors must not bypass native teardown. what: - Refuse cleanup through a real tmux wrapper - Cover exit status and stderr independently - Capture the endpoint before startup and preserve cleanup errors --- tests/test_server.py | 63 ++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 963f0e955e..2ebc3eac98 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import functools import logging import os @@ -555,50 +556,50 @@ def run( shutil.rmtree(socket_path.parent) -def test_owned_server_preserves_socket_after_silent_cleanup_failure( +@pytest.mark.parametrize( + ("returncode", "stderr"), [(7, ""), (7, "cleanup refused"), (0, "cleanup refused")] +) +def test_owned_server_preserves_socket_after_completed_cleanup_failure( server: Server, monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + returncode: int, + stderr: str, ) -> None: - """A completed failure without stderr remains visible and retryable.""" - run_command = common.run_command - completed = run_command("-V", tmux_bin=server.tmux_bin) + """A real cleanup refusal retains a reachable endpoint for retry.""" + executable = server.tmux_bin or shutil.which("tmux") + assert executable is not None + wrapper = tmp_path / "tmux-server-cleanup-refusal" + wrapper.write_text( + "#!/bin/sh\n" + 'for arg do\nif [ "$arg" = "kill-server" ]; then\n' + f"printf %s {shlex.quote(stderr)} >&2\nexit {returncode}\nfi\ndone\n" + f'exec {shlex.quote(executable)} "$@"\n' + ) + wrapper.chmod(0o700) removed: list[pathlib.Path] = [] - - def run( - *args: object, - tmux_bin: str | None = None, - timeout: float | None = None, - ) -> common.CommandResult: - if "kill-server" in args: - return common.CommandResult( - cmd=[str(arg) for arg in args], - stdout=[], - stderr=[], - returncode=7, - process=completed.process, - ) - return run_command(*args, tmux_bin=tmux_bin, timeout=timeout) + socket_path: pathlib.Path | None = None try: - with monkeypatch.context() as patch: + with monkeypatch.context() as patch, contextlib.ExitStack() as cleanup: # Keep the endpoint reachable even if the assertion exposes a regression. patch.setattr(shutil, "rmtree", removed.append) - with ( - pytest.raises( - exc.LibTmuxException, match="Server cleanup exited with 7" - ), - Server.owned(tmux_bin=server.tmux_bin) as owned, + owned = cleanup.enter_context(Server.owned(tmux_bin=str(wrapper))) + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + owned.new_session() + with pytest.raises( + exc.LibTmuxException, + match=stderr or f"Server cleanup exited with {returncode}", ): - owned.new_session() - assert owned.socket_path is not None - socket_path = pathlib.Path(owned.socket_path) - patch.setattr(common, "run_command", run) + cleanup.close() assert not removed assert socket_path.exists() assert owned.is_alive() finally: - owned.kill() - shutil.rmtree(socket_path.parent) + if socket_path is not None: + Server(socket_path=str(socket_path), tmux_bin=executable).kill() + shutil.rmtree(socket_path.parent) class StartDirectoryTestFixture(t.NamedTuple): From f44c35d865c55af33c250ee2464152e9670b566d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 10:33:12 -0500 Subject: [PATCH 21/73] Popup(test): Attach a terminal client why: Control clients do not execute popup commands on tmux master. what: - Exercise popup flags and completion through a real terminal client - Bound process cleanup and close PTYs on setup or wait failure - State the control-client limitation in its module documentation --- src/libtmux/_internal/control_mode.py | 4 +- tests/test_pane.py | 97 +++++++++++++++++++++------ 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index 05945451eb..03dfdc4b95 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -1,8 +1,8 @@ """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``). +subprocess, creating a real tmux client for commands such as +``detach-client``. Popups require a terminal client to run their commands. """ from __future__ import annotations diff --git a/tests/test_pane.py b/tests/test_pane.py index 44ce84528a..04c4a0be0f 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -2,9 +2,17 @@ from __future__ import annotations +import contextlib +import fcntl import logging +import os import pathlib +import select import shutil +import struct +import subprocess +import termios +import threading import typing as t import pytest @@ -1114,6 +1122,65 @@ class DisplayPopupCase(t.NamedTuple): ] +@pytest.fixture +def terminal_client(server: Server, session: Session) -> t.Iterator[str]: + """Attach a terminal client for popup execution.""" + socket_path = server.cmd("display-message", "-p", "#{socket_path}").stdout[0] + with contextlib.ExitStack() as cleanup: + master, slave = os.openpty() + cleanup.callback(os.close, master) + cleanup.callback(os.close, slave) + stop = threading.Event() + client: subprocess.Popen[bytes] | None = None + + def drain() -> None: + while not stop.is_set(): + if select.select([master], [], [], 0.1)[0]: + try: + if not os.read(master, 65536): + return + except OSError: + return + + try: + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0)) + client = subprocess.Popen( + [ + server.tmux_bin or "tmux", + "-S", + socket_path, + "attach-session", + "-t", + str(session.session_id), + ], + stdin=slave, + stdout=slave, + stderr=slave, + env={**os.environ, "TERM": "xterm-256color"}, + start_new_session=True, + ) + reader = threading.Thread(target=drain, daemon=True) + reader.start() + cleanup.callback(reader.join, timeout=1) + cleanup.callback(stop.set) + client_name = os.ttyname(slave) + + def attached() -> bool: + clients = server.cmd("list-clients", "-F", "#{client_name}").stdout + return client_name in clients + + retry_until(attached, 3, raises=True) + yield client_name + finally: + if client is not None: + client.terminate() + try: + client.wait(timeout=3) + except subprocess.TimeoutExpired: + client.kill() + client.wait(timeout=3) + + @pytest.mark.parametrize( list(DisplayPopupCase._fields), DISPLAY_POPUP_CASES, @@ -1123,7 +1190,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], + terminal_client: str, session: Session, tmp_path: pathlib.Path, ) -> None: @@ -1142,14 +1209,13 @@ def test_display_popup_flags( call_kwargs = {"command": f"touch {marker}", "close_on_exit": True, **kwargs} - with control_mode(): - pane.display_popup(**call_kwargs) + 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], + terminal_client: str, session: Session, tmp_path: pathlib.Path, ) -> None: @@ -1158,8 +1224,7 @@ 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) + pane.display_popup(command=f"touch {marker}", close_on_success=True) retry_until(lambda: marker.exists(), 3, raises=True) @@ -1189,26 +1254,20 @@ def test_display_popup_close_existing( def test_display_popup_target_client( - control_mode: t.Callable[..., t.Any], + terminal_client: 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. - """ + """Run the popup command on the specified terminal client.""" 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, - ) + pane.display_popup( + command=f"touch {marker}", + close_on_exit=True, + target_client=terminal_client, + ) retry_until(lambda: marker.exists(), 3, raises=True) From fe3fe70f290ee01f339ea1de956de02c96eeb543 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 11:00:37 -0500 Subject: [PATCH 22/73] Ownership(test): Cover unused server scopes why: A scope without a session still owns a temporary directory. what: - Verify directory removal without a tmux executable - Keep failed assertions from leaking the temporary directory --- tests/test_server.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_server.py b/tests/test_server.py index 2ebc3eac98..796c90e824 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -367,6 +367,22 @@ def test_server_context_manager(TestServer: type[Server]) -> None: assert not server.is_alive() +def test_owned_server_removes_unused_socket_directory(tmp_path: pathlib.Path) -> None: + """An unused scope needs no executable and removes its private directory.""" + directory: pathlib.Path | None = None + try: + with Server.owned(tmux_bin=tmp_path / "missing-tmux") as owned: + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + directory = socket_path.parent + assert directory.is_dir() + assert not socket_path.exists() + assert not directory.exists() + finally: + if directory is not None: + shutil.rmtree(directory, ignore_errors=True) + + def test_owned_server_keeps_its_private_endpoint( server: Server, session: Session, From cddd333801e19922d9c77387cee45846a2b3c678 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 11:00:37 -0500 Subject: [PATCH 23/73] Docs(fix): Verify automation completion why: Echoed commands and stale markers can report completion too early. what: - Match complete output lines and use fresh markers for repeated work - Verify running and completed states without shell startup timing - Stop retries and task queues after an unfinished command times out - Describe popup requests against terminal and control clients --- docs/topics/automation_patterns.md | 365 ++++++++++++++++++----------- src/libtmux/pane.py | 13 +- 2 files changed, 237 insertions(+), 141 deletions(-) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index ba96411085..b4af655d07 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -47,6 +47,10 @@ doctests against a live tmux server; in your own scripts you create them yoursel {meth}`~libtmux.Server.new_session`). Each example builds its own window or pane and tears it down at the end, so the snippets stand alone and don't depend on each other. +The sample windows run a plain POSIX shell so user shell initialization does not +delay their commands. Match complete output lines, and choose fresh markers when +reusing a pane so an earlier command's output cannot satisfy a later wait. + ## Process control ### Starting long-running processes @@ -58,13 +62,13 @@ going in the pane. The pane object stays your handle on that running work. ```python >>> import time ->>> proc_window = session.new_window(window_name='process', attach=False) +>>> proc_window = session.new_window(window_name='process', attach=False, window_shell='sh') >>> proc_pane = proc_window.active_pane >>> # Start a background process >>> proc_pane.send_keys('sleep 2 && echo "Process complete"') ->>> # Process is running +>>> # The window remains available to the caller. >>> time.sleep(0.1) >>> proc_window.window_name 'process' @@ -78,31 +82,47 @@ going in the pane. The pane object stays your handle on that running work. Because {meth}`send_keys() ` doesn't wait, you find out whether a command is still running the same way a person would: by reading what's on screen. Capture the pane and look for a marker your command prints -when it reaches a known state. +when it reaches a known state. Match whole lines and check the completion +marker first: a start marker remains in the scrollback after a command ends. ```python >>> import time ->>> status_window = session.new_window(window_name='status-check', attach=False) +>>> status_window = session.new_window(window_name='status-check', attach=False, window_shell='sh') >>> status_pane = status_window.active_pane ->>> def is_process_running(pane, marker='RUNNING'): -... """Check if a marker indicates process is still running.""" -... output = pane.capture_pane() -... return marker in '\\n'.join(output) +>>> def is_process_running(pane, marker='RUNNING', completed='DONE'): +... """Check whether output records a start without completion.""" +... lines = pane.capture_pane(join_wrapped=True) +... return completed not in lines and marker in lines ->>> # Start and mark a process ->>> status_pane.send_keys('echo "RUNNING"; sleep 0.3; echo "DONE"') ->>> time.sleep(0.1) +>>> is_process_running(status_pane) +False + +>>> # Wait for input so the running state lasts until we release it. +>>> status_pane.send_keys(r'printf "\nRUNNING\n"; read response; printf "\nDONE\n"') ->>> # Check while running ->>> 'RUNNING' in '\\n'.join(status_pane.capture_pane()) +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... if 'RUNNING' in status_pane.capture_pane(join_wrapped=True): +... break +... time.sleep(0.05) +>>> is_process_running(status_pane) True +>>> # Enter releases read; the command can now print its completion marker. +>>> _ = status_pane.enter() + >>> # Wait for completion ->>> time.sleep(0.5) ->>> 'DONE' in '\\n'.join(status_pane.capture_pane()) +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... if 'DONE' in status_pane.capture_pane(join_wrapped=True): +... break +... time.sleep(0.05) +>>> 'DONE' in status_pane.capture_pane(join_wrapped=True) True +>>> is_process_running(status_pane) +False >>> # Clean up >>> status_window.kill() @@ -112,29 +132,29 @@ True ### Waiting for specific output -The workhorse of terminal automation is "run something, then block until a string -shows up." You wrap {meth}`~libtmux.Pane.capture_pane` in a loop with a timeout, so a -command that never finishes can't hang your script forever. The `poll_interval` is -the latency/work trade in one knob: poll faster to react sooner, slower to spare tmux -the round-trips. +Wait for a whole output line so an echoed shell command cannot satisfy the +condition before it runs. A timeout bounds the wait. The `poll_interval` +controls how often the loop calls {meth}`~libtmux.Pane.capture_pane`. ```python >>> import time ->>> monitor_window = session.new_window(window_name='monitor', attach=False) +>>> monitor_window = session.new_window(window_name='monitor', attach=False, window_shell='sh') >>> monitor_pane = monitor_window.active_pane >>> def wait_for_output(pane, text, timeout=5.0, poll_interval=0.1): -... """Wait for specific text to appear in pane output.""" -... start = time.time() -... while time.time() - start < timeout: -... output = '\\n'.join(pane.capture_pane()) -... if text in output: +... """Wait for an exact line of pane output.""" +... deadline = time.monotonic() + timeout +... while time.monotonic() < deadline: +... if text in pane.capture_pane(join_wrapped=True): ... return True ... time.sleep(poll_interval) ... return False ->>> monitor_pane.send_keys('sleep 0.2; echo "READY"') +>>> monitor_pane.send_keys(r'printf "\nREADY\n"', enter=False) +>>> wait_for_output(monitor_pane, 'READY', timeout=0.1) +False +>>> _ = monitor_pane.enter() >>> wait_for_output(monitor_pane, 'READY', timeout=2.0) True @@ -151,68 +171,78 @@ early instead of timing out on a command that already crashed. ```python >>> import time ->>> error_window = session.new_window(window_name='error-check', attach=False) +>>> error_window = session.new_window(window_name='error-check', attach=False, window_shell='sh') >>> error_pane = error_window.active_pane >>> def check_for_errors(pane, patterns=None): ... """Check pane output for error patterns.""" ... if patterns is None: ... patterns = ['Error:', 'error:', 'ERROR', 'FAILED', 'Exception'] -... output = '\\n'.join(pane.capture_pane()) +... lines = pane.capture_pane(join_wrapped=True) ... for pattern in patterns: -... if pattern in output: +... if any(line.startswith(pattern) for line in lines): ... return pattern ... return None >>> # Test with successful output ->>> error_pane.send_keys('echo "Success!"') ->>> time.sleep(0.1) +>>> error_pane.send_keys(r'printf "\nSuccess!\n"') +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... if 'Success!' in error_pane.capture_pane(join_wrapped=True): +... break +... time.sleep(0.05) +>>> 'Success!' in error_pane.capture_pane(join_wrapped=True) +True >>> check_for_errors(error_pane) is None True +>>> # An error in the typed command is not output yet. +>>> error_pane.send_keys(r'printf "\nError: unavailable\n"', enter=False) +>>> check_for_errors(error_pane) is None +True +>>> _ = error_pane.enter() +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline and check_for_errors(error_pane) is None: +... time.sleep(0.05) +>>> check_for_errors(error_pane) +'Error:' + >>> # Clean up >>> error_window.kill() ``` ### Capturing output between markers -Sometimes you don't want the whole scrollback — you want just the lines a command -produced. Bracket the interesting output with a marker you control, then return -everything that follows it. This is how you pull a command's result out of a shared -pane without dragging along the prompt and prior history. +Bracket a command's output with distinct start and end markers. Match whole +lines so echoed command text cannot satisfy the wait. The end marker confirms +that the command has finished writing its output. ```python >>> import time ->>> capture_window = session.new_window(window_name='capture', attach=False) +>>> capture_window = session.new_window(window_name='capture', attach=False, window_shell='sh') >>> capture_pane = capture_window.active_pane ->>> def capture_after_marker(pane, marker, timeout=5.0): -... """Capture output after a marker appears.""" -... start_time = time.time() -... while time.time() - start_time < timeout: -... lines = pane.capture_pane() -... output = '\\n'.join(lines) -... if marker in output: -... # Return all lines after the marker -... found = False -... result = [] -... for line in lines: -... if marker in line: -... found = True -... continue -... if found: -... result.append(line) -... return result -... time.sleep(0.1) +>>> def capture_between_markers(pane, start_marker, end_marker, timeout=5.0): +... """Capture complete output between two exact marker lines.""" +... deadline = time.monotonic() + timeout +... while time.monotonic() < deadline: +... lines = pane.capture_pane(join_wrapped=True) +... try: +... start = lines.index(start_marker) +... end = lines.index(end_marker, start + 1) +... except ValueError: +... time.sleep(0.05) +... continue +... return lines[start + 1:end] ... return None >>> # Test marker capture ->>> capture_pane.send_keys('echo "MARKER"; echo "captured data"') ->>> time.sleep(0.3) ->>> result = capture_after_marker(capture_pane, 'MARKER', timeout=2.0) ->>> any('captured' in line for line in (result or [])) -True +>>> capture_pane.send_keys( +... r'printf "\n%s\n%s\n%s\n" "BEGIN" "captured data" "END"' +... ) +>>> capture_between_markers(capture_pane, 'BEGIN', 'END', timeout=2.0) +['captured data'] >>> # Clean up >>> capture_window.kill() @@ -232,29 +262,37 @@ concurrently; you gather their results afterward by capturing every pane. >>> import time >>> from libtmux.constants import PaneDirection ->>> parallel_window = session.new_window(window_name='parallel', attach=False) +>>> parallel_window = session.new_window(window_name='parallel', attach=False, window_shell='sh') >>> parallel_window.resize(height=40, width=120) # doctest: +ELLIPSIS Window(@... ...) >>> pane1 = parallel_window.active_pane ->>> pane2 = pane1.split(direction=PaneDirection.Right) ->>> pane3 = pane1.split(direction=PaneDirection.Below) +>>> pane2 = pane1.split(direction=PaneDirection.Right, shell='sh') +>>> pane3 = pane1.split(direction=PaneDirection.Below, shell='sh') >>> # Start tasks in parallel >>> tasks = [ -... (pane1, 'echo "Task 1"; sleep 0.2; echo "DONE1"'), -... (pane2, 'echo "Task 2"; sleep 0.1; echo "DONE2"'), -... (pane3, 'echo "Task 3"; sleep 0.3; echo "DONE3"'), +... (pane1, r'echo "Task 1"; sleep 0.2; printf "\nDONE1\n"', 'DONE1'), +... (pane2, r'echo "Task 2"; sleep 0.1; printf "\nDONE2\n"', 'DONE2'), +... (pane3, r'echo "Task 3"; sleep 0.3; printf "\nDONE3\n"', 'DONE3'), ... ] ->>> for pane, cmd in tasks: -... pane.send_keys(cmd) +>>> for pane, cmd, marker in tasks: +... pane.send_keys(cmd, enter=False) +>>> any(marker in p.capture_pane(join_wrapped=True) for p, _, marker in tasks) +False +>>> for pane, _, _ in tasks: +... _ = pane.enter() >>> # Wait for all tasks ->>> time.sleep(0.5) +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... if all(marker in p.capture_pane(join_wrapped=True) for p, _, marker in tasks): +... break +... time.sleep(0.05) >>> # Verify all completed ->>> all('DONE' in '\\n'.join(p.capture_pane()) for p, _ in tasks) +>>> all(marker in p.capture_pane(join_wrapped=True) for p, _, marker in tasks) True >>> # Clean up @@ -272,28 +310,34 @@ instead of always waiting for a worst-case timeout. >>> import time >>> from libtmux.constants import PaneDirection ->>> multi_window = session.new_window(window_name='multi-monitor', attach=False) +>>> multi_window = session.new_window(window_name='multi-monitor', attach=False, window_shell='sh') >>> multi_window.resize(height=40, width=120) # doctest: +ELLIPSIS Window(@... ...) >>> panes = [multi_window.active_pane] ->>> panes.append(panes[0].split(direction=PaneDirection.Right)) ->>> panes.append(panes[0].split(direction=PaneDirection.Below)) +>>> panes.append(panes[0].split(direction=PaneDirection.Right, shell='sh')) +>>> panes.append(panes[0].split(direction=PaneDirection.Below, shell='sh')) >>> def wait_all_complete(panes, marker='COMPLETE', timeout=10.0): ... """Wait for all panes to show completion marker.""" -... start = time.time() +... deadline = time.monotonic() + timeout ... remaining = set(range(len(panes))) -... while remaining and time.time() - start < timeout: +... while remaining and time.monotonic() < deadline: ... for i in list(remaining): -... if marker in '\\n'.join(panes[i].capture_pane()): +... if marker in panes[i].capture_pane(join_wrapped=True): ... remaining.remove(i) -... time.sleep(0.1) +... if remaining: +... time.sleep(0.05) ... return len(remaining) == 0 >>> # Start tasks with different durations >>> for i, pane in enumerate(panes): -... pane.send_keys(f'sleep 0.{i+1}; echo "COMPLETE"') +... pane.send_keys(fr'sleep 0.{i+1}; printf "\nCOMPLETE\n"', enter=False) + +>>> wait_all_complete(panes, 'COMPLETE', timeout=0.1) +False +>>> for pane in panes: +... _ = pane.enter() >>> # Wait for all >>> wait_all_complete(panes, 'COMPLETE', timeout=2.0) @@ -315,8 +359,8 @@ down, but you get a guaranteed-clean slate that never leaks. ```python >>> # Create isolated session for a task ->>> with server.new_session(session_name='temp-work') as temp_session: -... window = temp_session.new_window(window_name='task') +>>> with server.new_session(session_name='temp-work', window_command='sh') as temp_session: +... window = temp_session.new_window(window_name='task', window_shell='sh') ... pane = window.active_pane ... pane.send_keys('echo "Isolated work"') ... # Session exists during work @@ -337,11 +381,15 @@ never outlives its purpose. ```python >>> import time ->>> with session.new_window(window_name='subtask') as sub_window: +>>> with session.new_window(window_name='subtask', window_shell='sh') as sub_window: ... pane = sub_window.active_pane -... pane.send_keys('echo "Subtask running"') -... time.sleep(0.1) -... 'Subtask' in '\\n'.join(pane.capture_pane()) +... pane.send_keys(r'printf "\nSubtask running\n"') +... deadline = time.monotonic() + 2.0 +... while time.monotonic() < deadline: +... if 'Subtask running' in pane.capture_pane(join_wrapped=True): +... break +... time.sleep(0.05) +... 'Subtask running' in pane.capture_pane(join_wrapped=True) True >>> # Window cleaned up automatically @@ -356,12 +404,15 @@ True Any command you wait on can hang, so give every wait an upper bound. Pair the command with a completion marker and poll until either the marker shows up or the clock runs out — and when it runs out, raise, so a stuck command surfaces as an error you can -catch instead of a script that quietly stalls. +catch instead of a script that quietly stalls. A timeout stops waiting; it does +not cancel the command. This example kills its temporary window afterward. ```python +>>> import shlex >>> import time +>>> import uuid ->>> timeout_window = session.new_window(window_name='timeout-demo', attach=False) +>>> timeout_window = session.new_window(window_name='timeout-demo', attach=False, window_shell='sh') >>> timeout_pane = timeout_window.active_pane >>> class CommandTimeout(Exception): @@ -370,55 +421,83 @@ catch instead of a script that quietly stalls. >>> def run_with_timeout(pane, command, marker='__DONE__', timeout=5.0): ... """Run command and wait for completion with timeout.""" -... pane.send_keys(f'{command}; echo {marker}') -... start = time.time() -... while time.time() - start < timeout: -... output = '\\n'.join(pane.capture_pane()) -... if marker in output: -... return output -... time.sleep(0.1) +... marker = f'{marker}_{uuid.uuid4().hex}' +... pane.send_keys(fr'{command}; printf "\n%s\n" {shlex.quote(marker)}') +... deadline = time.monotonic() + timeout +... while time.monotonic() < deadline: +... lines = pane.capture_pane(join_wrapped=True) +... if marker in lines: +... return '\n'.join(lines) +... time.sleep(0.05) ... raise CommandTimeout(f'Command timed out after {timeout}s') >>> # Test successful command ->>> result = run_with_timeout(timeout_pane, 'echo "fast"', timeout=2.0) ->>> 'fast' in result +>>> result = run_with_timeout(timeout_pane, r'printf "\nfast\n"', timeout=2.0) +>>> 'fast' in result.splitlines() True +>>> # This command waits for input; the previous marker must not complete it. +>>> try: +... run_with_timeout(timeout_pane, 'read response', timeout=0.1) +... except CommandTimeout: +... print('timed out') +timed out + >>> # Clean up >>> timeout_window.kill() ``` ### Retry pattern -For flaky work that succeeds on a later attempt, retry until a success marker -appears. Be honest about the cost: each retry runs the command again and waits the -full `delay`, so a slow `delay` times `max_retries` is the worst case you're signing -up for. Tune both for how expensive the command is and how patient you can be. +Retry only after the preceding attempt finishes. Bracket each attempt's output +so an older success marker cannot satisfy the current attempt. The delay falls +between completed failures; a timeout returns without queuing another attempt. +The command may still be running after a timeout, until you cancel it or close +the temporary window. ```python >>> import time +>>> import uuid ->>> retry_window = session.new_window(window_name='retry-demo', attach=False) +>>> retry_window = session.new_window(window_name='retry-demo', attach=False, window_shell='sh') >>> retry_pane = retry_window.active_pane ->>> def retry_until_success(pane, command, success_marker, max_retries=3, delay=0.5): +>>> def retry_until_success(pane, command, success_marker, max_retries=3, delay=0.5, timeout=5.0): ... """Retry command until success marker appears.""" ... for attempt in range(max_retries): -... pane.send_keys(command) -... time.sleep(delay) -... output = '\\n'.join(pane.capture_pane()) -... if success_marker in output: -... return True, attempt + 1 +... begin = f'__ATTEMPT_{uuid.uuid4().hex}__' +... end = f'{begin}_END' +... pane.send_keys(fr'printf "\n%s\n" "{begin}"; {command}; printf "\n%s\n" "{end}"') +... deadline = time.monotonic() + timeout +... while time.monotonic() < deadline: +... lines = pane.capture_pane(start='-', join_wrapped=True) +... try: +... first = lines.index(begin) +... last = lines.index(end, first + 1) +... except ValueError: +... time.sleep(0.05) +... continue +... if success_marker in lines[first + 1:last]: +... return True, attempt + 1 +... break +... else: +... return False, attempt + 1 +... if attempt + 1 < max_retries: +... time.sleep(delay) ... return False, max_retries ->>> # Test retry +>>> # The first attempt prints NOT OK; only the second prints the exact marker. +>>> command = ( +... 'libtmux_attempt=${libtmux_attempt:-0}; libtmux_attempt=$((libtmux_attempt+1)); ' +... r'if [ "$libtmux_attempt" -ge 2 ]; then printf "\nOK\n"; else printf "\nNOT OK\n"; fi' +... ) >>> success, attempts = retry_until_success( -... retry_pane, 'echo "OK"', 'OK', max_retries=3, delay=0.2 +... retry_pane, command, 'OK', max_retries=3, delay=0.2 ... ) >>> success True >>> attempts -1 +2 >>> # Clean up >>> retry_window.kill() @@ -433,36 +512,47 @@ genuinely a pipeline of steps, not a single call. ### Task queue processor A task queue runs a list of commands in order, waiting for each to finish before -starting the next. You tag every task with an indexed marker so you know exactly -which step you're waiting on, and you collect a pass/fail result per task. +starting the next. You tag every task with a fresh indexed marker so you know +which step you're waiting on, and stop on the first timeout. Each result records +completion before the deadline, not the command's exit status. ```python +>>> import shlex >>> import time +>>> import uuid ->>> queue_window = session.new_window(window_name='queue', attach=False) +>>> queue_window = session.new_window(window_name='queue', attach=False, window_shell='sh') >>> queue_pane = queue_window.active_pane ->>> def process_task_queue(pane, tasks, completion_marker='TASK_DONE'): +>>> def process_task_queue(pane, tasks, completion_marker='TASK_DONE', timeout=5.0): ... """Process a queue of tasks sequentially.""" ... results = [] +... completion_marker = f'{completion_marker}_{uuid.uuid4().hex}' ... for i, task in enumerate(tasks): -... pane.send_keys(f'{task}; echo "{completion_marker}_{i}"') +... marker = f'{completion_marker}_{i}' +... pane.send_keys(fr'{task}; printf "\n%s\n" {shlex.quote(marker)}') ... # Wait for this task to complete -... start = time.time() -... while time.time() - start < 5.0: -... output = '\\n'.join(pane.capture_pane()) -... if f'{completion_marker}_{i}' in output: +... deadline = time.monotonic() + timeout +... while time.monotonic() < deadline: +... if marker in pane.capture_pane(join_wrapped=True): ... results.append((i, True)) ... break -... time.sleep(0.1) +... time.sleep(0.05) ... else: ... results.append((i, False)) +... break ... return results >>> tasks = ['echo "Step 1"', 'echo "Step 2"', 'echo "Step 3"'] >>> results = process_task_queue(queue_pane, tasks) >>> all(success for _, success in results) True +>>> len(results) +3 + +>>> # Stop before submitting a second task when the first waits for input. +>>> process_task_queue(queue_pane, ['read response', 'echo "not submitted"'], timeout=0.1) +[(0, False)] >>> # Clean up >>> queue_window.kill() @@ -478,7 +568,7 @@ and the history tells you how far you got before it stopped. ```python >>> import time ->>> state_window = session.new_window(window_name='state-machine', attach=False) +>>> state_window = session.new_window(window_name='state-machine', attach=False, window_shell='sh') >>> state_pane = state_window.active_pane >>> def run_state_machine(pane, states, timeout_per_state=2.0): @@ -490,23 +580,22 @@ and the history tells you how far you got before it stopped. ... state_name, command, next_marker = states[current_state] ... pane.send_keys(command) ... -... start = time.time() -... while time.time() - start < timeout_per_state: -... output = '\\n'.join(pane.capture_pane()) -... if next_marker in output: +... deadline = time.monotonic() + timeout_per_state +... while time.monotonic() < deadline: +... if next_marker in pane.capture_pane(join_wrapped=True): ... history.append(state_name) ... current_state += 1 ... break -... time.sleep(0.1) +... time.sleep(0.05) ... else: ... return history, False # Timeout ... ... return history, True >>> states = [ -... ('init', 'echo "INIT_DONE"', 'INIT_DONE'), -... ('process', 'echo "PROCESS_DONE"', 'PROCESS_DONE'), -... ('cleanup', 'echo "CLEANUP_DONE"', 'CLEANUP_DONE'), +... ('init', r'printf "\nINIT_DONE\n"', 'INIT_DONE'), +... ('process', r'printf "\nPROCESS_DONE\n"', 'PROCESS_DONE'), +... ('cleanup', r'printf "\nCLEANUP_DONE\n"', 'CLEANUP_DONE'), ... ] >>> history, success = run_state_machine(state_pane, states) @@ -515,6 +604,10 @@ True >>> len(history) 3 +>>> blocked = [('blocked', r'read response; printf "\nUNREACHED\n"', 'UNREACHED')] +>>> run_state_machine(state_pane, blocked, timeout_per_state=0.1) +([], False) + >>> # Clean up >>> state_window.kill() ``` @@ -528,16 +621,20 @@ command finished, have it print an explicit marker and poll for that. Your autom then reacts to what actually happened rather than to a clock. ```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='sh') >>> bp_pane = bp_window.active_pane >>> # Good: Use completion marker ->>> bp_pane.send_keys('long_command; echo "__DONE__"') +>>> bp_pane.send_keys(r'sleep 0.1; printf "\n__DONE__\n"') >>> # Then poll for marker >>> import time ->>> time.sleep(0.2) ->>> '__DONE__' in '\\n'.join(bp_pane.capture_pane()) +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... if '__DONE__' in bp_pane.capture_pane(join_wrapped=True): +... break +... time.sleep(0.05) +>>> '__DONE__' in bp_pane.capture_pane(join_wrapped=True) True >>> bp_window.kill() @@ -550,7 +647,7 @@ it. Tear down what you opened when you're done, so a long-running automation pro doesn't accumulate orphaned objects. ```python ->>> cleanup_window = session.new_window(window_name='cleanup-demo', attach=False) +>>> cleanup_window = session.new_window(window_name='cleanup-demo', attach=False, window_shell='sh') >>> cleanup_window # doctest: +ELLIPSIS Window(@... ...) @@ -570,7 +667,7 @@ is released whether the work succeeded or blew up. ```python >>> # Context managers ensure cleanup even on exceptions ->>> with session.new_window(window_name='safe-work') as safe_window: +>>> with session.new_window(window_name='safe-work', window_shell='sh') as safe_window: ... pane = safe_window.active_pane ... # Work happens here ... pass # Even if exception occurs, window is cleaned up diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 9baa9a56df..de6bcf2aa8 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1691,9 +1691,9 @@ 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 terminal client to display the + popup and run its command. A control-mode client can accept this + request without executing the popup command. Parameters ---------- @@ -1743,12 +1743,11 @@ def display_popup( Examples -------- - Not directly testable — popup rendering requires a TTY-backed client. - Control-mode provides an attached client for invocation but the popup - itself is not visible or verifiable. + This control-mode client has no popup. The close request returns + without changing its state: >>> with control_mode() as ctl: - ... pane.display_popup(command='true', close_on_exit=True) + ... pane.display_popup(close_existing=True, target_client=ctl.client_name) """ if close_on_exit and close_on_success: msg = ( From 3a9a50af3a3470dfa8b49b4e71c678666f6cb229 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 11:38:46 -0500 Subject: [PATCH 24/73] Docs(fix): Match padded completion markers why: Joined captures on older tmux pad completed marker lines, so examples and capture tests timed out after their commands finished. what: - Compare complete marker lines after removing right-padding spaces - Preserve captured payloads and reject echoed or stale markers --- docs/topics/automation_patterns.md | 64 +++++++++++++++++++----------- tests/test_pane_capture_pane.py | 4 +- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index b4af655d07..d51da5c601 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -84,6 +84,9 @@ whether a command is still running the same way a person would: by reading what's on screen. Capture the pane and look for a marker your command prints when it reaches a known state. Match whole lines and check the completion marker first: a start marker remains in the scrollback after a command ends. +Joined captures can include right-padding spaces, including on tmux 3.2a. +These markers have no trailing spaces, so comparisons remove ASCII spaces +from each line's right edge before checking equality. ```python >>> import time @@ -93,7 +96,7 @@ marker first: a start marker remains in the scrollback after a command ends. >>> def is_process_running(pane, marker='RUNNING', completed='DONE'): ... """Check whether output records a start without completion.""" -... lines = pane.capture_pane(join_wrapped=True) +... lines = [line.rstrip(' ') for line in pane.capture_pane(join_wrapped=True)] ... return completed not in lines and marker in lines >>> is_process_running(status_pane) @@ -104,7 +107,7 @@ False >>> deadline = time.monotonic() + 2.0 >>> while time.monotonic() < deadline: -... if 'RUNNING' in status_pane.capture_pane(join_wrapped=True): +... if any(line.rstrip(' ') == 'RUNNING' for line in status_pane.capture_pane(join_wrapped=True)): ... break ... time.sleep(0.05) >>> is_process_running(status_pane) @@ -116,10 +119,10 @@ True >>> # Wait for completion >>> deadline = time.monotonic() + 2.0 >>> while time.monotonic() < deadline: -... if 'DONE' in status_pane.capture_pane(join_wrapped=True): +... if any(line.rstrip(' ') == 'DONE' for line in status_pane.capture_pane(join_wrapped=True)): ... break ... time.sleep(0.05) ->>> 'DONE' in status_pane.capture_pane(join_wrapped=True) +>>> any(line.rstrip(' ') == 'DONE' for line in status_pane.capture_pane(join_wrapped=True)) True >>> is_process_running(status_pane) False @@ -146,7 +149,7 @@ controls how often the loop calls {meth}`~libtmux.Pane.capture_pane`. ... """Wait for an exact line of pane output.""" ... deadline = time.monotonic() + timeout ... while time.monotonic() < deadline: -... if text in pane.capture_pane(join_wrapped=True): +... if any(line.rstrip(' ') == text for line in pane.capture_pane(join_wrapped=True)): ... return True ... time.sleep(poll_interval) ... return False @@ -188,10 +191,10 @@ early instead of timing out on a command that already crashed. >>> error_pane.send_keys(r'printf "\nSuccess!\n"') >>> deadline = time.monotonic() + 2.0 >>> while time.monotonic() < deadline: -... if 'Success!' in error_pane.capture_pane(join_wrapped=True): +... if any(line.rstrip(' ') == 'Success!' for line in error_pane.capture_pane(join_wrapped=True)): ... break ... time.sleep(0.05) ->>> 'Success!' in error_pane.capture_pane(join_wrapped=True) +>>> any(line.rstrip(' ') == 'Success!' for line in error_pane.capture_pane(join_wrapped=True)) True >>> check_for_errors(error_pane) is None True @@ -215,7 +218,9 @@ True Bracket a command's output with distinct start and end markers. Match whole lines so echoed command text cannot satisfy the wait. The end marker confirms -that the command has finished writing its output. +that the command has finished writing its output. The helper preserves payload +lines, including their trailing spaces; the example removes those spaces only +when displaying its result. ```python >>> import time @@ -228,9 +233,10 @@ that the command has finished writing its output. ... deadline = time.monotonic() + timeout ... while time.monotonic() < deadline: ... lines = pane.capture_pane(join_wrapped=True) +... markers = [line.rstrip(' ') for line in lines] ... try: -... start = lines.index(start_marker) -... end = lines.index(end_marker, start + 1) +... start = markers.index(start_marker) +... end = markers.index(end_marker, start + 1) ... except ValueError: ... time.sleep(0.05) ... continue @@ -241,7 +247,8 @@ that the command has finished writing its output. >>> capture_pane.send_keys( ... r'printf "\n%s\n%s\n%s\n" "BEGIN" "captured data" "END"' ... ) ->>> capture_between_markers(capture_pane, 'BEGIN', 'END', timeout=2.0) +>>> captured = capture_between_markers(capture_pane, 'BEGIN', 'END', timeout=2.0) +>>> [line.rstrip(' ') for line in captured] ['captured data'] >>> # Clean up @@ -279,7 +286,10 @@ Window(@... ...) >>> for pane, cmd, marker in tasks: ... pane.send_keys(cmd, enter=False) ->>> any(marker in p.capture_pane(join_wrapped=True) for p, _, marker in tasks) +>>> any( +... any(line.rstrip(' ') == marker for line in p.capture_pane(join_wrapped=True)) +... for p, _, marker in tasks +... ) False >>> for pane, _, _ in tasks: ... _ = pane.enter() @@ -287,12 +297,18 @@ False >>> # Wait for all tasks >>> deadline = time.monotonic() + 2.0 >>> while time.monotonic() < deadline: -... if all(marker in p.capture_pane(join_wrapped=True) for p, _, marker in tasks): +... if all( +... any(line.rstrip(' ') == marker for line in p.capture_pane(join_wrapped=True)) +... for p, _, marker in tasks +... ): ... break ... time.sleep(0.05) >>> # Verify all completed ->>> all(marker in p.capture_pane(join_wrapped=True) for p, _, marker in tasks) +>>> all( +... any(line.rstrip(' ') == marker for line in p.capture_pane(join_wrapped=True)) +... for p, _, marker in tasks +... ) True >>> # Clean up @@ -324,7 +340,7 @@ Window(@... ...) ... remaining = set(range(len(panes))) ... while remaining and time.monotonic() < deadline: ... for i in list(remaining): -... if marker in panes[i].capture_pane(join_wrapped=True): +... if any(line.rstrip(' ') == marker for line in panes[i].capture_pane(join_wrapped=True)): ... remaining.remove(i) ... if remaining: ... time.sleep(0.05) @@ -386,10 +402,10 @@ never outlives its purpose. ... pane.send_keys(r'printf "\nSubtask running\n"') ... deadline = time.monotonic() + 2.0 ... while time.monotonic() < deadline: -... if 'Subtask running' in pane.capture_pane(join_wrapped=True): +... if any(line.rstrip(' ') == 'Subtask running' for line in pane.capture_pane(join_wrapped=True)): ... break ... time.sleep(0.05) -... 'Subtask running' in pane.capture_pane(join_wrapped=True) +... any(line.rstrip(' ') == 'Subtask running' for line in pane.capture_pane(join_wrapped=True)) True >>> # Window cleaned up automatically @@ -426,14 +442,14 @@ not cancel the command. This example kills its temporary window afterward. ... deadline = time.monotonic() + timeout ... while time.monotonic() < deadline: ... lines = pane.capture_pane(join_wrapped=True) -... if marker in lines: +... if any(line.rstrip(' ') == marker for line in lines): ... return '\n'.join(lines) ... time.sleep(0.05) ... raise CommandTimeout(f'Command timed out after {timeout}s') >>> # Test successful command >>> result = run_with_timeout(timeout_pane, r'printf "\nfast\n"', timeout=2.0) ->>> 'fast' in result.splitlines() +>>> any(line.rstrip(' ') == 'fast' for line in result.splitlines()) True >>> # This command waits for input; the previous marker must not complete it. @@ -470,7 +486,7 @@ the temporary window. ... pane.send_keys(fr'printf "\n%s\n" "{begin}"; {command}; printf "\n%s\n" "{end}"') ... deadline = time.monotonic() + timeout ... while time.monotonic() < deadline: -... lines = pane.capture_pane(start='-', join_wrapped=True) +... lines = [line.rstrip(' ') for line in pane.capture_pane(start='-', join_wrapped=True)] ... try: ... first = lines.index(begin) ... last = lines.index(end, first + 1) @@ -534,7 +550,7 @@ completion before the deadline, not the command's exit status. ... # Wait for this task to complete ... deadline = time.monotonic() + timeout ... while time.monotonic() < deadline: -... if marker in pane.capture_pane(join_wrapped=True): +... if any(line.rstrip(' ') == marker for line in pane.capture_pane(join_wrapped=True)): ... results.append((i, True)) ... break ... time.sleep(0.05) @@ -582,7 +598,7 @@ and the history tells you how far you got before it stopped. ... ... deadline = time.monotonic() + timeout_per_state ... while time.monotonic() < deadline: -... if next_marker in pane.capture_pane(join_wrapped=True): +... if any(line.rstrip(' ') == next_marker for line in pane.capture_pane(join_wrapped=True)): ... history.append(state_name) ... current_state += 1 ... break @@ -631,10 +647,10 @@ then reacts to what actually happened rather than to a clock. >>> import time >>> deadline = time.monotonic() + 2.0 >>> while time.monotonic() < deadline: -... if '__DONE__' in bp_pane.capture_pane(join_wrapped=True): +... if any(line.rstrip(' ') == '__DONE__' for line in bp_pane.capture_pane(join_wrapped=True)): ... break ... time.sleep(0.05) ->>> '__DONE__' in bp_pane.capture_pane(join_wrapped=True) +>>> any(line.rstrip(' ') == '__DONE__' for line in bp_pane.capture_pane(join_wrapped=True)) True >>> bp_window.kill() diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 269d6098c3..2c8d61e39e 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -358,7 +358,9 @@ def prompt_ready() -> bool: # The echoed command contains the marker before its output arrives. def command_complete() -> bool: - return marker in pane.capture_pane(join_wrapped=True) + return any( + line.rstrip(" ") == marker for line in pane.capture_pane(join_wrapped=True) + ) retry_until(command_complete, 5, raises=True) From 30aa1ad765fa40c98a1f7f87623712183309be56 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 11:38:46 -0500 Subject: [PATCH 25/73] Control(fix): Close client streams on exit why: Reaping control clients left their output streams open, and an interrupted registration bypassed cleanup. what: - Share process and stream cleanup across exit and failed startup - Cover ordinary, stopped and interrupted clients with real processes --- src/libtmux/_internal/control_mode.py | 32 +++++++++--------- tests/test_control_mode.py | 48 ++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index 03dfdc4b95..af0ef0651a 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -116,14 +116,8 @@ def client_registered() -> bool: try: retry_until(client_registered, 3, raises=True) - except Exception: - os.close(self._write_fd) - self._proc.terminate() - try: - self._proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self._proc.kill() - self._proc.wait() + except BaseException: + self._stop() raise return self @@ -134,13 +128,19 @@ 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) + """Terminate the control-mode client and close its streams.""" + self._stop() - self._proc.terminate() + def _stop(self) -> None: try: - self._proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self._proc.kill() - self._proc.wait() + os.close(self._write_fd) + self._proc.terminate() + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + finally: + self.stdout.close() + if self._proc.stderr is not None: + self._proc.stderr.close() diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index 8c06c928d3..21b4da8d6b 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -4,11 +4,13 @@ import locale import os +import signal import sys import typing as t import pytest +from libtmux._internal import control_mode as control_module from libtmux._internal.control_mode import ControlMode from libtmux.formats import FORMAT_SEPARATOR @@ -27,19 +29,57 @@ def test_control_mode_creates_client( assert ctl.client_name != "" +@pytest.mark.parametrize("stop_client", [False, True], ids=["normal", "stopped"]) def test_control_mode_cleanup( control_mode: t.Callable[[], ControlMode], server: Server, + stop_client: bool, ) -> None: - """Client is removed after ControlMode context exits.""" - with control_mode(): + """Exiting releases the client and its streams.""" + with control_mode() as ctl: assert len(server.list_clients()) > 0 - - # After context exit, client should be gone + if stop_client: + os.kill(ctl._proc.pid, signal.SIGSTOP) + _, state = os.waitpid(ctl._proc.pid, os.WUNTRACED) + assert os.WIFSTOPPED(state) + + assert ctl.stdout.closed + assert ctl._proc.stderr is not None and ctl._proc.stderr.closed + assert ctl._proc.poll() is not None clients = server.list_clients() assert len(clients) == 0 +@pytest.mark.parametrize("problem", [RuntimeError, KeyboardInterrupt]) +def test_control_mode_failed_registration_closes_streams( + control_mode: t.Callable[[], ControlMode], + monkeypatch: pytest.MonkeyPatch, + problem: type[BaseException], +) -> None: + """A failed handshake must release the real subprocess and its pipes.""" + + def reject_registration(*args: object, **kwargs: object) -> None: + message = "registration failed" + raise problem(message) + + monkeypatch.setattr(control_module, "retry_until", reject_registration) + ctl = control_mode() + try: + with pytest.raises(problem, match="registration failed"), ctl: + pytest.fail("Registration must fail before entering the body") + assert ctl.stdout.closed + assert ctl._proc.stderr is not None and ctl._proc.stderr.closed + assert ctl._proc.poll() is not None + finally: + if ctl._proc.poll() is None: + os.close(ctl._write_fd) + ctl._proc.kill() + ctl._proc.wait(timeout=5) + ctl.stdout.close() + if ctl._proc.stderr is not None: + ctl._proc.stderr.close() + + def test_control_mode_client_name( control_mode: t.Callable[[], ControlMode], ) -> None: From 02b39119752f8f33cd02a0a6e4cea0c75aabc695 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 11:38:46 -0500 Subject: [PATCH 26/73] Server(test): Require successful status messages why: The no-text test accepted a command refusal on the oldest tmux because both successful display and failure returned None. what: - Use implicit client selection and fail on unexpected warnings - Explain the native client-option parsing limitation accurately --- tests/test_server.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 796c90e824..8c3b64074b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1948,16 +1948,13 @@ def test_server_display_message_flags( omits ``-t `` but still needs a client to receive stdout. The headless test environment provides one via :class:`ControlMode`. - Skipped on tmux 3.2a: ``display-message -p -c `` - returns empty stdout on that release (output dispatch via a control-mode - client was unreliable until later versions). + tmux 3.2a rejects ``display-message -c `` because its option + parser treats ``-c`` as a flag without an argument. """ from libtmux.common import has_gte_version if not has_gte_version("3.3"): - pytest.skip( - "display-message -p via control-mode client unreliable on tmux 3.2a" - ) + pytest.skip("display-message -c requires tmux 3.3+") if min_tmux_version and not has_gte_version(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") @@ -1972,15 +1969,14 @@ def test_server_display_message_flags( assert expected_in_output in output +@pytest.mark.filterwarnings("error") def test_server_display_message_no_text_returns_none( control_mode: t.Callable[..., t.Any], server: Server, ) -> None: """Without ``get_text=True`` the call renders to status line and returns None.""" - with control_mode() as ctl: - result = server.display_message( - "hi from libtmux", target_client=ctl.client_name - ) + with control_mode(): + result = server.display_message("hi from libtmux") assert result is None @@ -1992,9 +1988,7 @@ def test_server_display_message_target_client( from libtmux.common import has_gte_version if not has_gte_version("3.3"): - pytest.skip( - "display-message -p via control-mode client unreliable on tmux 3.2a" - ) + pytest.skip("display-message -c requires tmux 3.3+") with control_mode() as ctl: result = server.display_message( From 33c760ae48d7b830c3356dd9146c8778ee1ec10a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 12:55:10 -0500 Subject: [PATCH 27/73] Tests(pane): Wait for capture prerequisites why: tmux can return before the shell prompt or command completion, so immediate output assertions race the pane process. what: - Wait for the initial prompt and completed command output - Keep the original payload and capture-bound assertions --- tests/test_pane.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_pane.py b/tests/test_pane.py index 04c4a0be0f..0285e0118c 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -119,6 +119,7 @@ def test_capture_pane(session: Session) -> None: ) pane = session.active_window.active_pane assert pane is not None + retry_until(lambda: pane.capture_pane() == ["$"], 1, raises=True) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == "$" pane.send_keys( @@ -126,6 +127,14 @@ def test_capture_pane(session: Session) -> None: literal=True, suppress_history=False, ) + retry_until( + lambda: ( + pane.capture_pane() + == [r'$ printf "\n%s\n" "Hello World !"', "", "Hello World !", "$"] + ), + 1, + raises=True, + ) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == r'$ printf "\n%s\n" "Hello World !"{}'.format( "\n\nHello World !\n$", @@ -144,9 +153,15 @@ def test_capture_pane_start(session: Session) -> None: ) pane = session.active_window.active_pane assert pane is not None + retry_until(lambda: pane.capture_pane() == ["$"], 1, raises=True) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == "$" pane.send_keys(r'printf "%s"', literal=True, suppress_history=False) + retry_until( + lambda: pane.capture_pane() == ['$ printf "%s"', "$"], + 1, + raises=True, + ) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == '$ printf "%s"\n$' pane.send_keys("clear -x", literal=True, suppress_history=False) @@ -189,9 +204,15 @@ def test_capture_pane_end(session: Session) -> None: ) pane = session.active_window.active_pane assert pane is not None + retry_until(lambda: pane.capture_pane() == ["$"], 1, raises=True) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == "$" pane.send_keys(r'printf "%s"', literal=True, suppress_history=False) + retry_until( + lambda: pane.capture_pane() == ['$ printf "%s"', "$"], + 1, + raises=True, + ) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == '$ printf "%s"\n$' pane_contents = "\n".join(pane.capture_pane(end=0)) From 4c1cc3de5b4da36eef2ab04e805e96967d231bf4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 13:04:47 -0500 Subject: [PATCH 28/73] Tests(window): Wait for split command output why: tmux can return before a split shell and its command output are ready, so a fixed sleep leaves environment assertions racy. what: - Wait for the initial split-shell prompt - Retry each environment output with a bounded deadline --- tests/test_window.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_window.py b/tests/test_window.py index 4daf340d05..d5565ee43d 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -5,7 +5,6 @@ import logging import pathlib import shutil -import time import typing as t import pytest @@ -20,6 +19,7 @@ ) from libtmux.pane import Pane from libtmux.server import Server +from libtmux.test.retry import retry_until from libtmux.window import Window if t.TYPE_CHECKING: @@ -577,10 +577,15 @@ def test_split_with_environment( environment=environment, ) assert pane is not None - # wait a bit for the prompt to be ready as the test gets flaky otherwise - time.sleep(0.05) + retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) for k, v in environment.items(): pane.send_keys(f"echo ${k}") + + def output_ready(expected: str = v) -> bool: + lines = pane.capture_pane() + return len(lines) >= 2 and lines[-2] == expected + + retry_until(output_ready, 2, raises=True) assert pane.capture_pane()[-2] == v From dcf1ddc29f2e843c9cf4bd99343aadab813c13a6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 13:07:04 -0500 Subject: [PATCH 29/73] Docs(changelog): Describe commands and ownership why: Explain the complete API and automation changes to upgrading users. what: - Cover command results, timeouts, owned scopes and captured fields - Document typed queries, newline-safe listings and hook decoding - Describe executable automation and terminal geometry examples --- CHANGES | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/CHANGES b/CHANGES index 7a691b0cc9..ca530ec769 100644 --- a/CHANGES +++ b/CHANGES @@ -45,8 +45,64 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### Command results and optional timeouts (#758) + +{func}`~libtmux.common.run_command` executes tmux and returns a separate +{class}`~libtmux.common.CommandResult`; the existing +{class}`~libtmux.common.tmux_cmd` facade remains compatible. Optional command +and server timeouts bound calls, including listings, and raise +{exc}`~libtmux.exc.TmuxTimeout` after cleanup when the deadline expires. + +#### Explicitly owned temporary resources (#758) + +{meth}`~libtmux.Server.owned` creates a private server scope, and +{meth}`~libtmux.Server.owned_session` creates a temporary session on an existing +server. Cleanup affects the created resource and reports failures; legacy +handle contexts retain their destructive behavior. See +{doc}`topics/context_managers` for ownership and cleanup rules. + +#### Decoded captured fields (#758) + +Panes and windows expose captured dimensions and activity as numeric and +boolean properties. Panes also expose {attr}`~libtmux.Pane.is_dead`, and +sessions expose {attr}`~libtmux.Session.attached_count`. These reads are local; +raw fields and established aliases remain available. + +#### Public local queries (#758) + +{class}`~libtmux.QueryList` is available from the package root. Required +lookups and caller-supplied defaults retain their distinct result types while +filtering the existing collection locally. + +### Fixes + +#### Listings preserve paths containing newlines (#758) + +Session, window and pane listings remain usable when a pane's directory +contains a newline. The captured path retains its original value. + +#### Expanded tmux hook decoding (#758) + +Reading hooks recognizes tmux's additional pane, window, client and command +events, preserving typed access to the complete hook table. + ### Documentation +#### Reliable automation examples (#758) + +The {doc}`automation examples ` distinguish +command output from echoed input and verify running and completed states. +Repeated operations use fresh markers, and retries and task queues stop +when a preceding command times out. + +#### Popup and floating-pane examples (#758) + +{meth}`~libtmux.Pane.display_popup` documents the terminal client required to +run popup commands. The {doc}`floating-pane examples ` +distinguish pane content from its borders. + #### Cleaner `from_env` examples (#719) The rendered examples for {meth}`Pane.from_env() ` and From d658025c2d498c929486446bb93d06470b41ee82 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 13:15:48 -0500 Subject: [PATCH 30/73] Tests: Wait for tmux terminal state --- conftest.py | 2 ++ src/libtmux/pane.py | 7 +++++++ tests/test_server.py | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/conftest.py b/conftest.py index 88a2656d29..7a893cb746 100644 --- a/conftest.py +++ b/conftest.py @@ -23,6 +23,7 @@ from libtmux.pytest_plugin import USING_ZSH from libtmux.server import Server from libtmux.session import Session +from libtmux.test.retry import retry_until from libtmux.window import Window if t.TYPE_CHECKING: @@ -52,6 +53,7 @@ def add_doctest_fixtures( doctest_namespace["pane"] = session.active_pane doctest_namespace["request"] = request doctest_namespace["ControlMode"] = ControlMode + doctest_namespace["retry_until"] = retry_until doctest_namespace["control_mode"] = functools.partial( ControlMode, server=session.server, diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index de6bcf2aa8..b284017ae0 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -613,11 +613,18 @@ def capture_pane( Examples -------- >>> pane = window.split(shell='sh') + >>> retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2) + True >>> pane.capture_pane() ['$'] >>> pane.send_keys('echo "Hello world"', enter=True) + >>> def command_finished(): + ... lines = pane.capture_pane() + ... return len(lines) >= 2 and lines[-2:] == ['Hello world', '$'] + >>> retry_until(command_finished, 2) + True >>> pane.capture_pane() ['$ echo "Hello world"', 'Hello world', '$'] diff --git a/tests/test_server.py b/tests/test_server.py index 8c3b64074b..784b7408f8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -18,6 +18,7 @@ from libtmux import common, exc from libtmux._internal.control_mode import ControlMode from libtmux.server import Server +from libtmux.test.retry import retry_until if t.TYPE_CHECKING: from libtmux._internal.types import StrPath @@ -1871,6 +1872,11 @@ def test_detach_all_clients_no_keep_preserves_one( server.detach_all_clients() + retry_until( + lambda: len(server.cmd("list-clients", "-F", "#{client_name}").stdout) == 1, + 2, + raises=True, + ) after = server.cmd("list-clients", "-F", "#{client_name}").stdout assert len(after) == 1 From b20ea25f6c0d3a72bab6bbfdf04883c4b5cf1290 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:46:06 -0500 Subject: [PATCH 31/73] Server(fix[new_session]): Regroup the -P -F reply before parsing why: new_session read proc.stdout[0] straight off the new-session reply, so a value containing a newline (pane_current_path, echoed because a session row also reports its active pane's fields -- reachable through start_directory) split the record across output lines. parse_output's strict zip then rejected the truncated fragment with ValueError before a Session was ever built. fetch_objs got the same fix already; this was the other parse call site it missed. what: - Regroup proc.stdout with _split_records, matching fetch_objs, before handing the record to parse_output - Add a start_directory-with-newline regression test --- CHANGES | 3 +++ src/libtmux/server.py | 9 +++++++-- tests/test_server.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index ca530ec769..bbacab95f4 100644 --- a/CHANGES +++ b/CHANGES @@ -82,6 +82,9 @@ filtering the existing collection locally. Session, window and pane listings remain usable when a pane's directory contains a newline. The captured path retains its original value. +{meth}`~libtmux.Server.new_session` parses its own ``-P -F`` reply the same +way, so a newline in ``start_directory`` no longer raises `ValueError` before +the `Session` is built. #### Expanded tmux hook decoding (#758) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e5372aa67d..0246230890 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -24,7 +24,7 @@ from libtmux.common import get_version, 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 +from libtmux.neo import _split_records, fetch_objs, get_output_format, parse_output from libtmux.pane import Pane from libtmux.session import Session from libtmux.window import Window @@ -2460,7 +2460,12 @@ def new_session( raise_if_stderr(proc, "new-session") - session_stdout = proc.stdout[0] + # Regroup on the separator, not on stdout's lines: a format + # value (e.g. pane_current_path, via start_directory) may + # itself contain a newline, which would otherwise split this + # one record across proc.stdout and hand parse_output a + # truncated fragment. See _split_records for the mechanism. + session_stdout = _split_records(proc.stdout, len(_fields))[0] finally: if env: diff --git a/tests/test_server.py b/tests/test_server.py index 784b7408f8..a201f56647 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -727,6 +727,36 @@ def test_new_session_start_directory_pathlib( assert actual_path == expected_path +def test_new_session_start_directory_with_newline( + server: Server, + tmp_path: pathlib.Path, +) -> None: + """A newline in ``start_directory`` must not corrupt the ``-P -F`` record. + + ``new_session`` parses its own record straight off ``proc.stdout[0]``, so + a value containing a newline -- here ``pane_current_path``, echoed back + because a session row also reports its active pane's fields -- used to + split the record across output lines. ``parse_output``'s strict ``zip`` + then rejected the truncated fragment with ``ValueError: zip() argument 2 + is shorter than argument 1`` before a ``Session`` was ever built. + """ + weird_directory = tmp_path / "we\nird" + weird_directory.mkdir() + + session = server.new_session( + session_name="test_newline_start_dir", + start_directory=weird_directory, + ) + + assert session.session_name == "test_newline_start_dir" + active_pane = session.active_window.active_pane + assert active_pane is not None + active_pane.refresh() + assert active_pane.pane_current_path is not None + actual_path = pathlib.Path(active_pane.pane_current_path).resolve() + assert actual_path == weird_directory.resolve() + + def test_tmux_bin_default(server: Server) -> None: """Default tmux_bin is None, falls back to shutil.which.""" assert server.tmux_bin is None From 0c5ec27e457ddd50496c0875744bbe2f06a09de6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:46:39 -0500 Subject: [PATCH 32/73] Tests(fix[conftest]): Quote the interpolated path in hanging_tmux why: The stub script built pid_file's path straight into an unquoted shell redirection. tmp_path doesn't carry a space by default, but a custom --basetemp or a differently configured runner can hand pytest one, and the script would break on it instead of the intended hang. what: - shlex.quote the interpolated path before writing it into the script --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index e745ced251..3d064aca73 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shlex import typing as t import pytest @@ -43,7 +44,7 @@ def hanging_tmux(tmp_path: pathlib.Path) -> tuple[str, pathlib.Path]: binary.write_text( "#!/bin/sh\n" 'if [ "$1" = "-V" ]; then echo "tmux 3.7"; exit 0; fi\n' - f"echo $$ > {pid_file}\n" + f"echo $$ > {shlex.quote(str(pid_file))}\n" "exec sleep 30\n" ) binary.chmod(0o755) From 3345e4c61d2341ad7d5f9987931db9cac4dc8924 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:47:16 -0500 Subject: [PATCH 33/73] Common(fix[timeout]): Bound the post-kill drain why: On TimeoutExpired, run_command killed the process then called a bare communicate() to reap it. kill() ends the timed-out process immediately, but a descendant that inherited its stdout/stderr pipes (and outlives it) keeps them open, so reading for EOF blocked on that descendant's lifetime instead of completing anywhere near the caller's own deadline -- observed blocking well past it in a reproduction with an orphaned pipe holder. what: - Bound the post-kill communicate() with _KILL_REAP_TIMEOUT; on a second TimeoutExpired, close libtmux's own pipe ends and wait() the already-killed process instead of continuing to read - Add hanging_tmux_with_orphan, a stub that backgrounds and disowns a child before exec, to reproduce a surviving pipe holder - Regression test asserting the drain stays bounded and the timed-out process is gone --- CHANGES | 4 +++- src/libtmux/common.py | 20 +++++++++++++++++++- tests/conftest.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_common.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index bbacab95f4..41f986a38b 100644 --- a/CHANGES +++ b/CHANGES @@ -53,7 +53,9 @@ _Notes on the upcoming release will go here._ {class}`~libtmux.common.CommandResult`; the existing {class}`~libtmux.common.tmux_cmd` facade remains compatible. Optional command and server timeouts bound calls, including listings, and raise -{exc}`~libtmux.exc.TmuxTimeout` after cleanup when the deadline expires. +{exc}`~libtmux.exc.TmuxTimeout` after a bounded cleanup when the deadline +expires, even if a surviving descendant keeps the command's own +stdout/stderr pipes open. #### Explicitly owned temporary resources (#758) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 9b1e5895b8..722cb7fd88 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -32,6 +32,13 @@ #: Most recent version of tmux supported TMUX_MAX_VERSION = "3.7" +#: Bound on draining stdout/stderr after :meth:`subprocess.Popen.kill`. +#: SIGKILL ends the killed process itself immediately, but a surviving +#: descendant that inherited its pipe file descriptors can keep them open +#: indefinitely, which would otherwise make ``communicate()`` block past +#: the caller's own deadline while waiting for EOF that never comes. +_KILL_REAP_TIMEOUT = 1.0 + SessionDict = dict[str, t.Any] WindowDict = dict[str, t.Any] WindowOptionDict = dict[str, t.Any] @@ -380,7 +387,18 @@ def run_command( # unbounded call leaves the child running, so repeated # timeouts accumulate tmux clients that nothing is waiting on. process.kill() - process.communicate() + try: + process.communicate(timeout=_KILL_REAP_TIMEOUT) + except subprocess.TimeoutExpired: + # A descendant inherited the stdout/stderr pipes and kept its + # own copy open, so reading for EOF would block indefinitely + # even though the killed process is already gone. Close our + # ends to stop waiting on it, then reap the process itself. + if process.stdout is not None: + process.stdout.close() + if process.stderr is not None: + process.stderr.close() + process.wait() raise exc.TmuxTimeout(cmd, t.cast("float", timeout)) from None except FileNotFoundError: raise exc.TmuxCommandNotFound from None diff --git a/tests/conftest.py b/tests/conftest.py index 3d064aca73..8132e10a47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,10 @@ from __future__ import annotations +import contextlib +import os import shlex +import signal import typing as t import pytest @@ -11,6 +14,7 @@ if t.TYPE_CHECKING: import pathlib + from collections.abc import Iterator @pytest.fixture(autouse=True) @@ -49,3 +53,33 @@ def hanging_tmux(tmp_path: pathlib.Path) -> tuple[str, pathlib.Path]: ) binary.chmod(0o755) return str(binary), pid_file + + +@pytest.fixture +def hanging_tmux_with_orphan( + tmp_path: pathlib.Path, +) -> Iterator[tuple[str, pathlib.Path, pathlib.Path]]: + """Return a stand-in tmux whose kill leaves a pipe-holding orphan behind. + + Models a failure ``hanging_tmux`` cannot: the killed process is not the + only one holding its inherited stdout/stderr pipes. A child backgrounded + and ``disown``-ed before ``exec`` survives the kill and keeps writing to + those pipes, so draining stdout/stderr for EOF after the kill blocks on + that orphan instead of completing with the caller's own deadline. + """ + pid_file = tmp_path / "pid" + orphan_pid_file = tmp_path / "orphan_pid" + binary = tmp_path / "tmux" + binary.write_text( + "#!/bin/sh\n" + 'if [ "$1" = "-V" ]; then echo "tmux 3.7"; exit 0; fi\n' + f"sleep 30 & echo $! > {shlex.quote(str(orphan_pid_file))}\n" + "disown\n" + f"echo $$ > {shlex.quote(str(pid_file))}\n" + "exec sleep 30\n" + ) + binary.chmod(0o755) + yield str(binary), pid_file, orphan_pid_file + if orphan_pid_file.exists(): + with contextlib.suppress(ProcessLookupError, ValueError): + os.kill(int(orphan_pid_file.read_text()), signal.SIGKILL) diff --git a/tests/test_common.py b/tests/test_common.py index 919eea7183..29f1954061 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -866,6 +866,34 @@ def test_tmux_cmd_timeout_kills_and_reaps( os.kill(pid, 0) +def test_tmux_cmd_timeout_survives_orphaned_pipe_holder( + hanging_tmux_with_orphan: tuple[str, pathlib.Path, pathlib.Path], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The post-kill drain does not block on a descendant's inherited pipes. + + SIGKILL ends the timed-out process itself immediately, but an orphan + that inherited the same stdout/stderr pipes keeps them open. A bare + ``communicate()`` after ``kill()`` reads until EOF on those pipes, so + without its own bound it blocks on the orphan's lifetime rather than + completing anywhere near the caller's deadline. + """ + binary, pid_file, _orphan_pid_file = hanging_tmux_with_orphan + monkeypatch.setattr(libtmux.common, "_KILL_REAP_TIMEOUT", 0.1) + + started = time.monotonic() + with pytest.raises(exc.TmuxTimeout): + libtmux.common.run_command("list-sessions", tmux_bin=binary, timeout=0.2) + elapsed = time.monotonic() - started + + # Bounded by timeout + the (patched) reap grace, not the orphan's sleep. + assert elapsed < 1.0 + + pid = int(pid_file.read_text()) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + + def test_tmux_cmd_without_timeout_still_waits( hanging_tmux: tuple[str, pathlib.Path], ) -> None: From 139cd052a53868591d6b9488cc01e6ece5a2e0d8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:48:18 -0500 Subject: [PATCH 34/73] Exc(fix[TmuxTimeout]): Keep self.args shaped like the constructor why: __init__ forwarded a pre-formatted message string to Exception.__init__, so self.args held one string while the constructor itself requires (cmd, timeout, *args). pickle and copy reconstruct exceptions via type(exc)(*exc.args), which raised TypeError: missing 1 required positional argument: 'timeout' -- surfacing under e.g. ProcessPoolExecutor, which pickles exceptions to send them back to the parent process. what: - Forward the constructor's own arguments to super().__init__ instead of a formatted string - Move the formatted message to __str__, so str(exc) is unchanged - Add a pickle/copy/deepcopy round-trip test --- CHANGES | 3 ++- src/libtmux/exc.py | 10 +++++++++- tests/test_common.py | 27 +++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 41f986a38b..a98d725311 100644 --- a/CHANGES +++ b/CHANGES @@ -55,7 +55,8 @@ _Notes on the upcoming release will go here._ and server timeouts bound calls, including listings, and raise {exc}`~libtmux.exc.TmuxTimeout` after a bounded cleanup when the deadline expires, even if a surviving descendant keeps the command's own -stdout/stderr pipes open. +stdout/stderr pipes open. {exc}`~libtmux.exc.TmuxTimeout` round-trips through +`pickle` and `copy`, so it survives crossing a `ProcessPoolExecutor` boundary. #### Explicitly owned temporary resources (#758) diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index f87c9be0de..05fb8ed503 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -367,7 +367,15 @@ class TmuxTimeout(Exception): def __init__(self, cmd: list[str], timeout: float, *args: object) -> None: self.cmd = cmd self.timeout = timeout - super().__init__(f"tmux did not return within {timeout}s: {' '.join(cmd)}") + # Forward the constructor's own arguments, not a pre-formatted + # message, so self.args stays shaped like __init__'s signature. + # pickle/copy reconstruct via `type(e)(*e.args)`; a lone message + # string there would mismatch this signature and raise TypeError. + super().__init__(cmd, timeout, *args) + + def __str__(self) -> str: + """Render the deadline and the command that missed it.""" + return f"tmux did not return within {self.timeout}s: {' '.join(self.cmd)}" class VariableUnpackingError(LibTmuxException): diff --git a/tests/test_common.py b/tests/test_common.py index 29f1954061..3c3d488520 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -2,9 +2,11 @@ from __future__ import annotations +import copy import locale import logging import os +import pickle import re import shlex import sys @@ -866,6 +868,31 @@ def test_tmux_cmd_timeout_kills_and_reaps( os.kill(pid, 0) +def test_tmux_timeout_round_trips_through_pickle_and_copy() -> None: + """``TmuxTimeout.args`` stays shaped like its own constructor. + + The previous ``__init__`` forwarded a pre-formatted message string to + ``Exception.__init__``, so ``self.args`` held one string while the + constructor required ``(cmd, timeout, *args)``. pickle and ``copy`` + reconstruct via ``type(exc)(*exc.args)``, which raised ``TypeError: + missing 1 required positional argument: 'timeout'`` -- surfacing under + e.g. ``ProcessPoolExecutor``, which pickles exceptions to send them + back to the parent process. + """ + original = exc.TmuxTimeout(["tmux", "list-sessions"], 0.3) + + # Round-trips data this process just produced, not untrusted input. + for reconstructed in ( + pickle.loads(pickle.dumps(original)), + copy.copy(original), + copy.deepcopy(original), + ): + assert isinstance(reconstructed, exc.TmuxTimeout) + assert reconstructed.cmd == original.cmd + assert reconstructed.timeout == original.timeout + assert str(reconstructed) == str(original) + + def test_tmux_cmd_timeout_survives_orphaned_pipe_holder( hanging_tmux_with_orphan: tuple[str, pathlib.Path, pathlib.Path], monkeypatch: pytest.MonkeyPatch, From 9712e8425cd42479b37a3148fb3765dc06fda9a4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:49:29 -0500 Subject: [PATCH 35/73] Neo(fix[parse]): Distinguish a malformed record from an unreachable server why: _split_records raised the generic LibTmuxException for a value that carried the field separator. Server.sessions and Server.clients catch that broad type and return QueryList([]), which is the correct, tested contract for an unreachable server (no daemon, missing socket, permission error, subprocess crash) but wrong here: the invocation succeeded and tmux may hold rows libtmux simply could not parse back. Before _split_records existed, this same condition raised a bare ValueError, which that except clause never caught, so it propagated -- the empty-by-default contract silently widened to cover a case it was never meant to. Server.windows and Server.panes were unaffected: _fetch_or_empty only absorbs the daemon-not-up string, so they already raised on this. what: - Add exc.TmuxRecordParseError, a LibTmuxException subtype, and raise it from _split_records instead of the bare base class - Server.sessions/Server.clients re-raise TmuxRecordParseError before falling through to the existing empty-on-LibTmuxException handling, so a generic tmux failure still yields QueryList([]) but a parse failure propagates, matching windows/panes - Update the docstrings, both AGENTS.md files, and fetch_objs' Raises section for the narrowed contract - Add propagation tests mirroring the existing empty-on-error tests --- AGENTS.md | 9 +++++---- CHANGES | 10 ++++++++++ src/libtmux/AGENTS.md | 18 ++++++++++++----- src/libtmux/exc.py | 15 ++++++++++++++ src/libtmux/neo.py | 9 +++++++-- src/libtmux/server.py | 29 ++++++++++++++++++++------- tests/test_server.py | 46 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 79c289585f..1c6ae6089f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,11 +55,12 @@ be stated twice, the file listed above is the one that governs. tmux >= 3.2a 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 — +raising when the underlying tmux list invocation fails for any reason — list-shaped accessors are lenient by default; `Server.is_alive()` and -`Server.raise_if_dead()` are the explicit, loud-failure primitives. See -`src/libtmux/AGENTS.md` for the full contract and this package's -logging conventions. +`Server.raise_if_dead()` are the explicit, loud-failure primitives. A +parse failure (`exc.TmuxRecordParseError`) or a timeout +(`exc.TmuxTimeout`) still propagates — see `src/libtmux/AGENTS.md` for +the full contract and this package's logging conventions. ## References diff --git a/CHANGES b/CHANGES index a98d725311..f9fab491ff 100644 --- a/CHANGES +++ b/CHANGES @@ -89,6 +89,16 @@ contains a newline. The captured path retains its original value. way, so a newline in ``start_directory`` no longer raises `ValueError` before the `Session` is built. +#### `Server.sessions`/`Server.clients` agree with the other listings on parse failures (#758) + +A malformed record now raises the new {exc}`~libtmux.exc.TmuxRecordParseError` +instead of the generic {exc}`~libtmux.exc.LibTmuxException`, and +{attr}`~libtmux.Server.sessions` and {attr}`~libtmux.Server.clients` let it +propagate rather than folding it into their empty-by-default contract, which +covers an unreachable server, not a reply libtmux could not parse. This +matches {attr}`~libtmux.Server.windows` and {attr}`~libtmux.Server.panes`, +which already raised it. + #### Expanded tmux hook decoding (#758) Reading hooks recognizes tmux's additional pane, window, client and command diff --git a/src/libtmux/AGENTS.md b/src/libtmux/AGENTS.md index 380707c714..123033c73e 100644 --- a/src/libtmux/AGENTS.md +++ b/src/libtmux/AGENTS.md @@ -20,13 +20,21 @@ facts specific to this package. ## List-returning accessors: empty by default on tmux errors `Server.sessions`, `Server.clients`, and `Server.attached_sessions` -return an empty `QueryList` when tmux's underlying list command fails -for any reason — no running daemon, a missing socket, a permission -error, a subprocess crash. This is a deliberate API contract: -list-shaped accessors are lenient by default. Callers that need to -distinguish "no rows" from "tmux unreachable" use the explicit +return an empty `QueryList` when tmux's underlying list *invocation* +fails for any reason — no running daemon, a missing socket, a +permission error, a subprocess crash. This is a deliberate API +contract: list-shaped accessors are lenient by default. Callers that +need to distinguish "no rows" from "tmux unreachable" use the explicit `Server.is_alive()` or `Server.raise_if_dead()` primitives. +Two exceptions propagate instead of collapsing to empty, because +neither means "no rows": `exc.TmuxRecordParseError` (the invocation +succeeded but a value contained the field separator, so the reply +itself could not be parsed — see `neo._split_records`) and +`exc.TmuxTimeout` (the command was killed mid-flight; whether it took +effect is unknown). Swallowing either would tell a caller "nothing to +list" when tmux may hold rows libtmux simply couldn't read back. + When adding a new list-returning accessor, follow this convention. If a future feature genuinely benefits from loud-failure semantics, expose it as a scoped opt-in (e.g. a `Server.raise_server_errors()` context diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 05fb8ed503..4f2f912c83 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -378,6 +378,21 @@ def __str__(self) -> str: return f"tmux did not return within {self.timeout}s: {' '.join(self.cmd)}" +class TmuxRecordParseError(LibTmuxException): + """A ``list-*`` record could not be split into its fields. + + Raised by ``libtmux.neo._split_records`` when a value contains the + field separator itself, so the output no longer divides evenly into + whole records. + + Deliberately not absorbed by the list-returning accessors' + empty-by-default contract: this means libtmux received a reply it + cannot interpret, not that tmux was unreachable, so treating it like + "nothing to list" would hide real rows behind a parsing bug rather + than a connectivity gap. + """ + + class VariableUnpackingError(LibTmuxException): """Error unpacking variable.""" diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 01e64ffc11..7c222bd405 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1054,7 +1054,7 @@ def _split_records(stdout: list[str], field_count: int) -> list[str]: Raises ------ - :exc:`~libtmux.exc.LibTmuxException` + :exc:`~libtmux.exc.TmuxRecordParseError` If the values do not divide into whole records, which means a value contained the separator itself. """ @@ -1074,7 +1074,7 @@ def _split_records(stdout: list[str], field_count: int) -> list[str]: f"{field_count} fields per record. A format value probably " f"contains the field separator ({FORMAT_SEPARATOR!r})." ) - raise exc.LibTmuxException(msg) + raise exc.TmuxRecordParseError(msg) records: list[str] = [] for start in range(0, len(values), field_count): @@ -1135,6 +1135,11 @@ def fetch_objs( ------ :exc:`~libtmux.exc.LibTmuxException` If the tmux command writes to stderr. + :exc:`~libtmux.exc.TmuxRecordParseError` + If a returned value contains the field separator, so the output + cannot be regrouped into whole records. + :exc:`~libtmux.exc.TmuxTimeout` + If the command does not return within ``server.timeout``. Examples -------- diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 0246230890..4c4dcb29b0 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -2583,16 +2583,23 @@ def sessions(self) -> QueryList[Session]: :meth:`.sessions.filter() ` Returns an empty :class:`~libtmux._internal.query_list.QueryList` when - tmux's ``list-sessions`` fails for any reason — no running daemon, a - missing socket, a permission error, or a subprocess failure. To - distinguish "no sessions" from "tmux unreachable", call + tmux's ``list-sessions`` invocation fails for any reason — no running + daemon, a 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`. + + Does *not* absorb a :exc:`~libtmux.exc.TmuxRecordParseError` (the + invocation succeeded but its output could not be parsed) or a + :exc:`~libtmux.exc.TmuxTimeout` (unknown whether it took effect) — + both propagate, since neither means "no sessions". """ try: sessions: list[Session] = [ Session(server=self, **obj) for obj in fetch_objs(server=self, list_cmd="list-sessions") ] + except exc.TmuxRecordParseError: + raise except exc.LibTmuxException: return QueryList([]) return QueryList(sessions) @@ -2644,10 +2651,16 @@ def clients(self) -> QueryList[Client]: ``client.client_session`` etc. read tmux's ``client_*`` format tokens. Returns an empty :class:`~libtmux._internal.query_list.QueryList` when - tmux's ``list-clients`` fails for any reason — no running daemon, a - 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`. + tmux's ``list-clients`` invocation fails for any reason — no running + daemon, a 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`. + + Does *not* absorb a :exc:`~libtmux.exc.TmuxRecordParseError` (the + invocation succeeded but its output could not be parsed) or a + :exc:`~libtmux.exc.TmuxTimeout` (unknown whether it took effect) — + both propagate, since neither means "no clients". Returns ------- @@ -2665,6 +2678,8 @@ def clients(self) -> QueryList[Client]: Client(server=self, **obj) for obj in fetch_objs(server=self, list_cmd="list-clients") ] + except exc.TmuxRecordParseError: + raise except exc.LibTmuxException: return QueryList([]) return QueryList(clients) diff --git a/tests/test_server.py b/tests/test_server.py index a201f56647..a71bcea210 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1703,6 +1703,31 @@ def _boom(**_: object) -> list[dict[str, str]]: assert list(server.clients) == [] +def test_server_clients_propagates_record_parse_error( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``Server.clients`` re-raises a malformed-record failure. + + A :exc:`~libtmux.exc.TmuxRecordParseError` means ``list-clients`` + ran and replied, but a value contained the field separator, so the + reply itself could not be split into records -- distinct from the + generic :exc:`~libtmux.exc.LibTmuxException` cases above, which mean + the invocation itself failed. Swallowing it into ``QueryList([])`` + would tell a caller "no clients" when tmux may hold clients libtmux + simply could not read back; ``Server.windows``/``Server.panes`` + already raise the same failure via ``_fetch_or_empty``. + """ + sentinel = exc.TmuxRecordParseError("simulated malformed record") + + def _boom(**_: object) -> list[dict[str, str]]: + raise sentinel + + monkeypatch.setattr("libtmux.server.fetch_objs", _boom) + with pytest.raises(exc.TmuxRecordParseError, match="simulated malformed record"): + list(server.clients) + + def test_server_search_sessions_propagates_errors( server: Server, monkeypatch: pytest.MonkeyPatch, @@ -1744,6 +1769,27 @@ def _boom(**_: object) -> list[dict[str, str]]: assert list(server.sessions) == [] +def test_server_sessions_propagates_record_parse_error( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``Server.sessions`` re-raises a malformed-record failure. + + Mirrors ``test_server_clients_propagates_record_parse_error``: a + :exc:`~libtmux.exc.TmuxRecordParseError` means the reply could not + be parsed, not that ``list-sessions`` was unreachable, so it is not + a case the empty-by-default contract covers. + """ + sentinel = exc.TmuxRecordParseError("simulated malformed record") + + def _boom(**_: object) -> list[dict[str, str]]: + raise sentinel + + monkeypatch.setattr("libtmux.server.fetch_objs", _boom) + with pytest.raises(exc.TmuxRecordParseError, match="simulated malformed record"): + list(server.sessions) + + def test_server_sessions_missing_socket_returns_empty(tmp_path: pathlib.Path) -> None: """A not-yet-created tmux socket preserves the empty-list contract.""" missing_server = Server(socket_path=tmp_path / "missing.sock") From c8bd040fd889c5126062024c1be284e643143e04 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:54:31 -0500 Subject: [PATCH 36/73] Server(fix[timeout]): Make liveness primitives honor Server.timeout why: raise_if_dead called subprocess.check_call directly, bypassing Server.cmd entirely, so it ignored Server.timeout and could block forever against a wedged server -- the one place still able to hang after this PR bounded every other command path. is_alive's bare `except Exception: return False` swallowed the new TmuxTimeout the same way it swallows a real "no server here", so a wedged server (alive, just not answering) was reported dead. Server.__exit__ does `if self.is_alive(): self.kill()`, so that false "dead" made it skip the kill and leak the daemon. what: - raise_if_dead now runs "list-sessions" through Server.cmd instead of a bare subprocess.check_call, so it honors Server.timeout and raises subprocess.CalledProcessError on a non-zero exit, matching its documented contract (also stops leaking list-sessions output to the parent's stdout, which check_call did) - is_alive re-raises TmuxTimeout before its catch-all: a wedged server is not a dead one, so it must not collapse to False - __exit__ treats a timeout from is_alive as "unknown, assume alive" and attempts the kill regardless, rather than skipping it -- if the server really is wedged, kill() will itself time out and raise, a loud leak instead of a silent one - Update the per-file BLE001 ruff ignore's comment, which predates TmuxTimeout, to name the one exception it no longer covers - Add timeout-propagation tests for is_alive/raise_if_dead and a test that __exit__ still attempts the kill when is_alive times out --- CHANGES | 10 +++++++ pyproject.toml | 5 ++-- src/libtmux/server.py | 55 ++++++++++++++++++++++++------------- tests/test_server.py | 63 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 20 deletions(-) diff --git a/CHANGES b/CHANGES index f9fab491ff..758aaf04ba 100644 --- a/CHANGES +++ b/CHANGES @@ -99,6 +99,16 @@ covers an unreachable server, not a reply libtmux could not parse. This matches {attr}`~libtmux.Server.windows` and {attr}`~libtmux.Server.panes`, which already raised it. +#### Liveness checks honor `Server.timeout` (#758) + +{meth}`~libtmux.Server.is_alive` re-raises {exc}`~libtmux.exc.TmuxTimeout` +instead of reporting a wedged server as dead, and +{meth}`~libtmux.Server.raise_if_dead` now runs through +{meth}`~libtmux.Server.cmd`, so it honors {attr}`~libtmux.Server.timeout` +instead of blocking indefinitely. `Server.__exit__` still attempts +{meth}`~libtmux.Server.kill` when a timeout leaves liveness unknown, rather +than skipping it and leaking the daemon. + #### Expanded tmux hook decoding (#758) Reading hooks recognizes tmux's additional pane, window, client and command diff --git a/pyproject.toml b/pyproject.toml index cb2d2e61a4..ad1c587ea8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -263,8 +263,9 @@ convention = "numpy" ] "src/libtmux/server.py" = [ # `Server.is_alive` answers a yes/no question about an unreachable server. - # Every way of failing to reach it is a "no"; callers who need the reason - # use `Server.raise_if_dead`. + # Every way of failing to reach it is a "no", except a `TmuxTimeout` + # (re-raised before this catch-all: a wedged server is not a dead one). + # Callers who need the reason use `Server.raise_if_dead`. "BLE001", ] diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 4c4dcb29b0..0710a3f60a 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -361,7 +361,17 @@ def __exit__( exc_tb : types.TracebackType | None The traceback of the exception that was raised """ - if self.is_alive(): + try: + alive = self.is_alive() + except exc.TmuxTimeout: + # A wedged server answers neither "alive" nor "dead". Assume + # alive and attempt the kill rather than skip it: a live + # server left unkilled is a silent leak, while a kill attempt + # against a truly dead one is a cheap, harmless no-op. If the + # server really is wedged, kill() will itself time out and + # raise -- a loud leak the caller can act on. + alive = True + if alive: self.kill() def is_alive(self) -> bool: @@ -369,9 +379,20 @@ def is_alive(self) -> bool: >>> tmux = Server(socket_name="no_exist") >>> assert not tmux.is_alive() + + Raises + ------ + :exc:`~libtmux.exc.TmuxTimeout` + The command did not return within :attr:`Server.timeout`. + Unlike every other way of failing to reach the server, this is + not treated as "no" -- a wedged server is not a dead one, and + a caller told "dead" may go on to start a second server + alongside one that is merely slow to answer. """ try: res = self.cmd("list-sessions") + except exc.TmuxTimeout: + raise except Exception: return False return res.returncode == 0 @@ -379,13 +400,19 @@ def is_alive(self) -> bool: def raise_if_dead(self) -> None: """Raise if server not connected. + Routed through :meth:`Server.cmd`, so this honors + :attr:`Server.timeout` like every other command instead of + blocking indefinitely against a wedged server. + Raises ------ - :exc:`exc.TmuxCommandNotFound` + :exc:`~libtmux.exc.TmuxCommandNotFound` When the tmux binary cannot be found or executed. :class:`subprocess.CalledProcessError` When the tmux server is not running (non-zero exit from ``list-sessions``). + :exc:`~libtmux.exc.TmuxTimeout` + The command did not return within :attr:`Server.timeout`. >>> tmux = Server(socket_name="no_exist") >>> try: @@ -394,22 +421,14 @@ def raise_if_dead(self) -> None: ... print(type(e)) """ - resolved = self.tmux_bin or shutil.which("tmux") - if resolved is None: - raise exc.TmuxCommandNotFound - - cmd_args: list[str] = ["list-sessions"] - if self.socket_name: - cmd_args.insert(0, f"-L{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 + proc = self.cmd("list-sessions") + if proc.returncode != 0: + raise subprocess.CalledProcessError( + proc.returncode, + proc.cmd, + output="\n".join(proc.stdout), + stderr="\n".join(proc.stderr), + ) # # Command diff --git a/tests/test_server.py b/tests/test_server.py index a71bcea210..c3d43f22d5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -288,6 +288,69 @@ def test_raise_if_dead_does_not_raise_if_alive(server: Server) -> None: server.raise_if_dead() +def test_is_alive_propagates_timeout( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """A wedged server is not reported ``False`` -- it is unknown, not dead. + + A bare ``except Exception: return False`` would swallow + :exc:`~libtmux.exc.TmuxTimeout` into "dead", which is wrong for a + server that is merely slow to answer; a caller told "dead" may start + a second server alongside one that is still there. Bounded by + ``Server.timeout`` rather than the stub's full 30s sleep. + """ + binary, _pid_file = hanging_tmux + wedged = Server(tmux_bin=binary, timeout=0.2) + + started = time.monotonic() + with pytest.raises(exc.TmuxTimeout): + wedged.is_alive() + assert time.monotonic() - started < 5 + + +def test_raise_if_dead_propagates_timeout( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """``raise_if_dead`` honors ``Server.timeout`` instead of blocking. + + It used to run ``subprocess.check_call`` directly, bypassing + ``Server.cmd`` and the server-wide timeout entirely, so this could + block indefinitely against a wedged server. Bounded here by + ``Server.timeout`` rather than the stub's full 30s sleep. + """ + binary, _pid_file = hanging_tmux + wedged = Server(tmux_bin=binary, timeout=0.2) + + started = time.monotonic() + with pytest.raises(exc.TmuxTimeout): + wedged.raise_if_dead() + assert time.monotonic() - started < 5 + + +def test_context_manager_exit_kills_despite_is_alive_timeout( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``__exit__`` still attempts a kill when ``is_alive`` times out. + + A wedged server is "unknown", not "dead" -- treating the timeout as + "dead" would skip :meth:`Server.kill` and leak the daemon. ``__exit__`` + assumes alive and attempts the kill regardless. + """ + killed: list[bool] = [] + monkeypatch.setattr(server, "kill", lambda *a, **kw: killed.append(True)) + + def _boom() -> bool: + raise exc.TmuxTimeout(["tmux", "list-sessions"], 0.2) + + monkeypatch.setattr(server, "is_alive", _boom) + + with server: + pass + + assert killed == [True] + + def test_on_init(server: Server) -> None: """Verify on_init callback is called during Server initialization.""" called_with: list[Server] = [] From 4631951a484f0004e436624c22aaf3fdb44be154 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:58:40 -0500 Subject: [PATCH 37/73] Server(fix[owned_session]): Guard identity-building against leaking the session why: owned_session created the session, then ran bare asserts and int() conversions to build its identity-checked cleanup predicate before ever entering the try/finally that runs that cleanup. A failure in that gap -- or an assert silently skipped under `python -O` letting a None reach the f-strings as the literal text "None" -- left the session behind with nothing left to kill it. what: - Extract the identity-guard construction into _session_identity_predicate, replacing the bare asserts with explicit exc.LibTmuxException raises - Call it in its own try/except that kills the session directly (no user code has run yet, so the reuse race the predicate itself guards against below cannot have happened) and re-raises - Add a regression test simulating a session missing an identity field --- CHANGES | 7 ++++++ src/libtmux/server.py | 52 +++++++++++++++++++++++++++++++++++-------- tests/test_server.py | 33 +++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/CHANGES b/CHANGES index 758aaf04ba..f70a5a8052 100644 --- a/CHANGES +++ b/CHANGES @@ -109,6 +109,13 @@ instead of blocking indefinitely. `Server.__exit__` still attempts {meth}`~libtmux.Server.kill` when a timeout leaves liveness unknown, rather than skipping it and leaking the daemon. +#### `Server.owned_session` no longer leaks on a malformed session (#758) + +Building the created session's identity guard now happens inside a guarded +block that kills the session on failure, instead of in a gap before cleanup +was armed. The guard's checks no longer use bare `assert`, which +`python -O` strips. + #### Expanded tmux hook decoding (#758) Reading hooks recognizes tmux's additional pane, window, client and command diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 0710a3f60a..c52c4f5045 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -85,6 +85,39 @@ def _fetch_or_empty( raise +def _session_identity_predicate(session: Session) -> tuple[str, str]: + """Return ``(session_id, predicate)`` identifying a freshly created session. + + ``predicate`` is a tmux format expression true only for a session with + this exact id *and* this exact pid/start_time generation, so a later + check against it cannot match a same-named replacement that reused the + id after this one was killed. + + Raises + ------ + :exc:`~libtmux.exc.LibTmuxException` + ``session`` is missing an id, pid, or start_time -- a bare + ``assert`` would vanish under ``python -O`` and let a ``None`` + reach the f-strings below as the literal text ``"None"``. + """ + if session.session_id is None: + msg = "New session has no session_id" + raise exc.LibTmuxException(msg) + if session.pid is None: + msg = "New session has no pid" + raise exc.LibTmuxException(msg) + if session.start_time is None: + msg = "New session has no start_time" + raise exc.LibTmuxException(msg) + session_id = f"${int(session.session_id.removeprefix('$'))}" + pid = int(session.pid) + started = int(session.start_time) + generation = f"#{{&&:#{{==:#{{pid}},{pid}}},#{{==:#{{start_time}},{started}}}}}" + exists = f"#{{S:#{{?#{{==:#{{session_id}},{session_id}}},1,}}}}" + predicate = f"#{{&&:{generation},{exists}}}" + return session_id, predicate + + class Server( EnvironmentMixin, OptionsMixin, @@ -2567,15 +2600,16 @@ def owned_session( window_command=window_command, environment=environment, ) - assert session.session_id is not None - assert session.pid is not None - assert session.start_time is not None - session_id = f"${int(session.session_id.removeprefix('$'))}" - pid = int(session.pid) - started = int(session.start_time) - generation = f"#{{&&:#{{==:#{{pid}},{pid}}},#{{==:#{{start_time}},{started}}}}}" - exists = f"#{{S:#{{?#{{==:#{{session_id}},{session_id}}},1,}}}}" - predicate = f"#{{&&:{generation},{exists}}}" + try: + session_id, predicate = _session_identity_predicate(session) + except Exception: + # The identity guard never finished building, so there is no + # predicate to check it against on the way out. Kill directly + # by identity instead of leaking the session -- no user code + # has run yet, so the reuse race the guard exists for below + # cannot have happened. + session.kill() + raise try: yield session finally: diff --git a/tests/test_server.py b/tests/test_server.py index c3d43f22d5..2cbe999361 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -492,6 +492,39 @@ def test_owned_session_cleans_up_by_id_after_rename( assert session in server.sessions +def test_owned_session_kills_on_identity_guard_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A session missing an identity field is killed, not leaked. + + ``owned_session`` used to create the session, then run asserts and + ``int()`` conversions in a gap before its try/finally began, so a + failure there (or a bare ``assert`` skipped under ``python -O``) + leaked the session instead of triggering cleanup. + """ + real_new_session = Server.new_session + + def _new_session_missing_start_time( + self: Server, + *args: object, + **kwargs: object, + ) -> Session: + created = real_new_session(self, *args, **kwargs) + created.start_time = None + return created + + monkeypatch.setattr(Server, "new_session", _new_session_missing_start_time) + + with ( + pytest.raises(exc.LibTmuxException, match="start_time"), + server.owned_session("identity_guard_failure"), + ): + pass + + assert not server.has_session("identity_guard_failure") + + def test_owned_session_refuses_an_existing_name( server: Server, session: Session, From c2160b7f51e226ca01c719721b0547d9526100a8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:00:08 -0500 Subject: [PATCH 38/73] Control(fix): SIGCONT a stopped client before waiting on termination why: _stop() sent SIGTERM then unconditionally waited up to 5 seconds before falling back to SIGKILL. A client stopped by SIGSTOP cannot process SIGTERM while stopped, so against a stopped client that wait always ran its full 5 seconds -- the parametrized test_control_mode_cleanup[stopped] case paid this on every run, a structural wait with no slow marker or documented reason. what: - Send SIGCONT (ignoring ProcessLookupError) right after terminate(), so a stopped client can actually see the pending SIGTERM and exit promptly instead of guaranteeing the wait times out; a running client just ignores the extra signal - test_control_mode_cleanup[stopped] now completes in well under a second instead of 5+, so it needs no slow marker --- CHANGES | 7 +++++++ src/libtmux/_internal/control_mode.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGES b/CHANGES index f70a5a8052..f0c4c88ff2 100644 --- a/CHANGES +++ b/CHANGES @@ -116,6 +116,13 @@ block that kills the session on failure, instead of in a gap before cleanup was armed. The guard's checks no longer use bare `assert`, which `python -O` strips. +#### Faster cleanup for a stopped control-mode client (#758) + +{class}`~libtmux._internal.control_mode.ControlMode` sends `SIGCONT` before +waiting on a terminated client. A client stopped (e.g. `SIGSTOP`, a debugger) +cannot process `SIGTERM` until resumed, so cleanup previously ran out its +full 5-second wait before falling back to `SIGKILL` every time. + #### Expanded tmux hook decoding (#758) Reading hooks recognizes tmux's additional pane, window, client and command diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index af0ef0651a..75079532d5 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -7,7 +7,9 @@ from __future__ import annotations +import contextlib import os +import signal import subprocess import typing as t @@ -135,6 +137,13 @@ def _stop(self) -> None: try: os.close(self._write_fd) self._proc.terminate() + # A client stopped (e.g. SIGSTOP, a debugger, a frozen cgroup) + # cannot process SIGTERM until resumed, so the wait below would + # otherwise time out unconditionally. SIGCONT lets a stopped + # process actually see the pending SIGTERM and exit promptly; a + # running process ignores it. + with contextlib.suppress(ProcessLookupError): + self._proc.send_signal(signal.SIGCONT) try: self._proc.wait(timeout=5) except subprocess.TimeoutExpired: From 8b5ecc63d136090f5bd7ab8b3b7804d49d8b3550 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:02:17 -0500 Subject: [PATCH 39/73] Tests(fix[server]): Guard cleanup-failure test's finally against unbound names why: owned/socket_path were bound only inside the with-body. An earlier failure before either line ran (e.g. new_session raising something other than the PermissionError the test injects) left them unbound, so the finally block's owned.kill() or shutil.rmtree(socket_path.parent) raised UnboundLocalError -- replacing the real failure as what the test reports, skipping the kill, and leaking the daemon and its temp directory. what: - Pre-declare owned/socket_path as None before the try - Guard the finally's kill and rmtree on each being set --- tests/test_server.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 2cbe999361..1154443eba 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -652,6 +652,12 @@ def run( raise cleanup_error return run_command(*args, tmux_bin=tmux_bin, timeout=timeout) + # Bound in the try below only on success; an earlier failure (e.g. inside + # Server.owned itself) must not make the finally block dereference an + # unbound name and mask that failure behind an UnboundLocalError, which + # would also skip the kill and leak the daemon. + owned: Server | None = None + socket_path: pathlib.Path | None = None try: with ( monkeypatch.context() as patch, @@ -662,11 +668,14 @@ def run( assert owned.socket_path is not None socket_path = pathlib.Path(owned.socket_path) patch.setattr(common, "run_command", run) + assert socket_path is not None assert socket_path.exists() assert owned.is_alive() finally: - owned.kill() - shutil.rmtree(socket_path.parent) + if owned is not None: + owned.kill() + if socket_path is not None: + shutil.rmtree(socket_path.parent) @pytest.mark.parametrize( From e4855c6351d57011cad892a6c608bd3da47843f5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:03:31 -0500 Subject: [PATCH 40/73] Tests(fix[common]): Exercise the timeout=None path it claims to test why: The body passed timeout=0.3 and asserted TmuxTimeout -- the bounded path, not the None case the name and docstring claim. It would pass identically whether or not a bare timeout=None call ever waited, so a regression there (e.g. None silently getting some default bound) would go undetected. what: - Run the call on a thread; assert it is still running past a bound well under the stub's 30s sleep, proving it did not raise early - Kill the stub directly (bypassing libtmux's own timeout/kill path, which is what this asserts was never invoked) to let the thread return without the test itself waiting on the full sleep - Assert the call completed without raising TmuxTimeout --- tests/test_common.py | 45 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/tests/test_common.py b/tests/test_common.py index 3c3d488520..6e9b47a6f1 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -9,7 +9,9 @@ import pickle import re import shlex +import signal import sys +import threading import time import typing as t @@ -924,12 +926,37 @@ def test_tmux_cmd_timeout_survives_orphaned_pipe_holder( def test_tmux_cmd_without_timeout_still_waits( hanging_tmux: tuple[str, pathlib.Path], ) -> None: - """The bound is opt-in; omitting it keeps the historical behaviour.""" - binary, _pid_file = hanging_tmux - started = time.monotonic() - - with pytest.raises(exc.TmuxTimeout): - tmux_cmd("list-sessions", tmux_bin=binary, timeout=0.3) - - # A test that never waits would pass whether or not `timeout` is read. - assert time.monotonic() - started >= 0.3 + """The bound is opt-in; omitting it keeps the historical behaviour. + + The previous body passed ``timeout=0.3`` and asserted + :exc:`~libtmux.exc.TmuxTimeout`, which exercises the *bounded* path, + not the ``None`` case this test's name and docstring claim -- it + would pass identically whether or not a bare ``timeout=None`` call + ever waited at all. Runs the call on a thread bounded well under the + stub's 30s sleep: still running after that bound means it did not + raise early, and killing the stub directly lets the thread return + without this test itself waiting anywhere near 30s. + """ + binary, pid_file = hanging_tmux + outcome: list[tmux_cmd | BaseException] = [] + + def call() -> None: + try: + outcome.append(tmux_cmd("list-sessions", tmux_bin=binary)) + except BaseException as e: # noqa: BLE001 + outcome.append(e) + + thread = threading.Thread(target=call, daemon=True) + thread.start() + thread.join(timeout=0.3) + assert thread.is_alive(), "a bare `timeout=None` call must still be waiting" + + # Unblock the thread by killing the stub directly, not through + # libtmux's own timeout/kill path -- that is what this test verifies + # was never invoked. + os.kill(int(pid_file.read_text()), signal.SIGKILL) + thread.join(timeout=5) + assert not thread.is_alive() + + assert len(outcome) == 1 + assert not isinstance(outcome[0], exc.TmuxTimeout) From 15204b3de71010bfd198e04a89552f676e9da3d1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:04:53 -0500 Subject: [PATCH 41/73] Server(fix): Give Server.timeout a class-level default why: Every other configuration attribute (socket_name, socket_path, tmux_bin, ...) is declared at class level with a default, so an instance built without going through __init__ -- object.__new__, or a subclass whose __init__ skips super().__init__() -- still has a value to read. timeout was assigned only inside __init__, so that same construction path raised AttributeError on first use. what: - Add `timeout: float | None = None` alongside tmux_bin - Add a regression test constructing a Server via object.__new__ --- CHANGES | 3 +++ src/libtmux/server.py | 3 +++ tests/test_server.py | 14 ++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/CHANGES b/CHANGES index f0c4c88ff2..7bacb70c24 100644 --- a/CHANGES +++ b/CHANGES @@ -57,6 +57,9 @@ and server timeouts bound calls, including listings, and raise expires, even if a surviving descendant keeps the command's own stdout/stderr pipes open. {exc}`~libtmux.exc.TmuxTimeout` round-trips through `pickle` and `copy`, so it survives crossing a `ProcessPoolExecutor` boundary. +{attr}`~libtmux.Server.timeout` has a class-level default of `None`, matching +`tmux_bin` and the other configuration attributes, so an instance built +without going through `__init__` still has a value to read. #### Explicitly owned temporary resources (#758) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c52c4f5045..182cb87a84 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -202,6 +202,9 @@ class Server( """For hook management.""" tmux_bin: str | None = None """Custom path to tmux binary. Falls back to ``shutil.which("tmux")``.""" + timeout: float | None = None + """Seconds to wait for a command before raising + :exc:`~libtmux.exc.TmuxTimeout`. ``None`` waits indefinitely.""" def __init__( self, diff --git a/tests/test_server.py b/tests/test_server.py index 1154443eba..c314b71034 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -867,6 +867,20 @@ def test_tmux_bin_default(server: Server) -> None: assert server.tmux_bin is None +def test_timeout_has_class_level_default() -> None: + """``Server.timeout`` falls back like ``tmux_bin`` for a skipped ``__init__``. + + Every other configuration attribute (``socket_name``, ``socket_path``, + ``tmux_bin``, ...) is declared at class level, so an instance built + without going through ``__init__`` -- ``object.__new__``, or a subclass + whose own ``__init__`` does not call ``super().__init__()`` -- still has + a value to read. ``timeout`` was assigned only inside ``__init__``, so + the same construction path raised ``AttributeError`` on first use. + """ + bare = object.__new__(Server) + assert bare.timeout is None + + def test_tmux_bin_custom_path(caplog: pytest.LogCaptureFixture) -> None: """Custom tmux_bin path is used for commands. From 507c11935324c6a516e35d44de65a753d6c16840 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:06:31 -0500 Subject: [PATCH 42/73] Server(fix[owned]): Store socket_path as str, matching every other constructor why: owned() built socket_path as a pathlib.Path and passed it straight through. __init__ stored it as-is, so an owned server's socket_path was a Path while every other Server constructor only ever produces a str. __eq__ compares socket_path by value, and Path("/x") != "/x", so an owned server never equaled the same endpoint addressed by string. what: - Coerce socket_path to str in __init__, mirroring the existing tmux_bin coercion, instead of special-casing owned() - Add a regression test comparing an owned server to the same endpoint constructed from str(owned.socket_path) --- CHANGES | 4 +++- src/libtmux/server.py | 6 +++++- tests/test_server.py | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 7bacb70c24..559d89139e 100644 --- a/CHANGES +++ b/CHANGES @@ -67,7 +67,9 @@ without going through `__init__` still has a value to read. {meth}`~libtmux.Server.owned_session` creates a temporary session on an existing server. Cleanup affects the created resource and reports failures; legacy handle contexts retain their destructive behavior. See -{doc}`topics/context_managers` for ownership and cleanup rules. +{doc}`topics/context_managers` for ownership and cleanup rules. The yielded +server's `socket_path` is a `str`, like every other constructor produces, so +it compares equal to the same endpoint addressed by string. #### Decoded captured fields (#758) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 182cb87a84..78440d0937 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -225,7 +225,11 @@ def __init__( self._panes: list[PaneDict] = [] if socket_path is not None: - self.socket_path = socket_path + # str, not the pathlib.Path the type also accepts: __eq__ + # compares socket_path by value, and Path("/x") != "/x", so a + # Path here would make an otherwise-identical endpoint compare + # unequal to one addressed by string. + self.socket_path = str(socket_path) elif socket_name is not None: self.socket_name = socket_name elif socket_name_factory is not None: diff --git a/tests/test_server.py b/tests/test_server.py index c314b71034..7884fd460f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -466,6 +466,24 @@ def test_owned_server_keeps_its_private_endpoint( assert session in server.sessions +def test_owned_server_socket_path_equals_the_same_endpoint_by_string( + server: Server, +) -> None: + """``Server.owned``'s endpoint compares equal to itself addressed by ``str``. + + ``owned`` builds ``socket_path`` as a ``pathlib.Path``. ``__eq__`` + compares ``socket_path`` by value, and ``Path("/x") != "/x"``, so + passing that ``Path`` straight through used to make the owned server + compare unequal to the identical endpoint addressed by string -- + unlike every other constructor, which only ever sees a ``str``. + """ + with Server.owned(tmux_bin=server.tmux_bin) as owned: + assert owned.socket_path is not None + assert isinstance(owned.socket_path, str) + by_string = Server(socket_path=str(owned.socket_path), tmux_bin=server.tmux_bin) + assert owned == by_string + + def test_owned_server_cleans_up_after_body_failure(server: Server) -> None: """An exception still terminates the private daemon and removes its socket.""" body_error = RuntimeError("body failed") From 4944d0dfe8bd076c4f0984934b748db7f0f431cb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:10:18 -0500 Subject: [PATCH 43/73] Tests(fix[server]): Type the new_session monkeypatch stub as Any why: The finding-5 regression test's replacement new_session took *args/**kwargs typed as object, but real_new_session's actual parameters are typed narrower (str | None, bool, StrPath | None, ...), so mypy rejected forwarding them. new_session's own signature already types its *args/**kwargs as t.Any for the same reason. what: - Type the stub's *args/**kwargs as t.Any, matching new_session --- tests/test_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 7884fd460f..e0d5dfa516 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -525,8 +525,8 @@ def test_owned_session_kills_on_identity_guard_failure( def _new_session_missing_start_time( self: Server, - *args: object, - **kwargs: object, + *args: t.Any, + **kwargs: t.Any, ) -> Session: created = real_new_session(self, *args, **kwargs) created.start_time = None From 175842b1a33755dd9d9f21ec49912896873d5125 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:10:48 -0500 Subject: [PATCH 44/73] Server(fix[cmd]): Distinguish an omitted timeout from an explicit None why: `timeout=self.timeout if timeout is None else timeout` treated an explicit `timeout=None` the same as an omitted argument, both defaulting to None, so a caller could never opt one command out of a server-wide timeout -- the override always collapsed back onto Server.timeout. what: - Add a private _NotSet sentinel and default `timeout` to it instead of None, so cmd() can tell "not passed" from "passed as None" - Document the three states (omitted / None / a number) in cmd()'s docstring - Add tests: omitting timeout uses the server's bound; an explicit timeout=None runs the call unbounded even though the server has one --- CHANGES | 4 ++++ src/libtmux/server.py | 28 +++++++++++++++++++++++-- tests/test_server.py | 48 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 559d89139e..39cdbaba87 100644 --- a/CHANGES +++ b/CHANGES @@ -60,6 +60,10 @@ stdout/stderr pipes open. {exc}`~libtmux.exc.TmuxTimeout` round-trips through {attr}`~libtmux.Server.timeout` has a class-level default of `None`, matching `tmux_bin` and the other configuration attributes, so an instance built without going through `__init__` still has a value to read. +{meth}`~libtmux.Server.cmd`'s `timeout` distinguishes an omitted argument +(falls back to `Server.timeout`) from an explicit `None` (runs that one call +unbounded even when the server has a timeout) -- the two used to collapse +onto the same behavior. #### Explicitly owned temporary resources (#758) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 78440d0937..c916eccadf 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -52,6 +52,23 @@ logger = logging.getLogger(__name__) +class _NotSet: + """Sentinel for an omitted ``timeout`` argument, distinct from ``None``. + + :meth:`Server.cmd`'s ``timeout`` has three meanings: omitted (fall back + to :attr:`Server.timeout`), ``None`` (run this one call unbounded, even + when the server has a timeout), or a number (override it). ``None`` as + the default would erase the second meaning by making it indistinguishable + from the first. + """ + + def __repr__(self) -> str: + return "" + + +_NOT_SET = _NotSet() + + def _is_daemon_not_up_error(stderr_text: str) -> bool: """Return True if the error indicates the tmux server is not running. @@ -478,7 +495,7 @@ def cmd( cmd: str, *args: t.Any, target: str | int | None = None, - timeout: float | None = None, + timeout: float | _NotSet | None = _NOT_SET, ) -> tmux_cmd: """Execute tmux command respective of socket name and file, return output. @@ -516,6 +533,11 @@ def cmd( ---------- target : str, optional Optional custom target. + timeout : float, optional + Per-call override for :attr:`Server.timeout`. Omit to use the + server's timeout; pass ``None`` to run this one call without a + bound even when the server has one; pass a number to bound just + this call. Returns ------- @@ -545,11 +567,13 @@ def cmd( cmd_args = ["-t", str(target), *args] if target is not None else [*args] + resolved_timeout = self.timeout if isinstance(timeout, _NotSet) else timeout + return tmux_cmd( *svr_args, *cmd_args, tmux_bin=self.tmux_bin, - timeout=self.timeout if timeout is None else timeout, + timeout=resolved_timeout, ) @property diff --git a/tests/test_server.py b/tests/test_server.py index e0d5dfa516..a860b2719b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -9,7 +9,9 @@ import pathlib import shlex import shutil +import signal import subprocess +import threading import time import typing as t @@ -327,6 +329,52 @@ def test_raise_if_dead_propagates_timeout( assert time.monotonic() - started < 5 +def test_cmd_timeout_falls_back_to_server_default( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """Omitting ``timeout`` on ``Server.cmd`` uses the server's own bound.""" + binary, _pid_file = hanging_tmux + bounded = Server(tmux_bin=binary, timeout=0.2) + + with pytest.raises(exc.TmuxTimeout): + bounded.cmd("list-sessions") + + +def test_cmd_timeout_none_opts_out_of_the_server_default( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """An explicit ``timeout=None`` on ``Server.cmd`` overrides the server bound. + + ``timeout=self.timeout if timeout is None else timeout`` used to + collapse an explicit opt-out onto the server default -- indistinguishable + from omitting it -- so a caller could never run one command unbounded on + a server that has a timeout. + """ + binary, pid_file = hanging_tmux + bounded = Server(tmux_bin=binary, timeout=0.2) + outcome: list[object] = [] + + def call() -> None: + try: + outcome.append(bounded.cmd("list-sessions", timeout=None)) + except BaseException as e: # noqa: BLE001 + outcome.append(e) + + thread = threading.Thread(target=call, daemon=True) + thread.start() + thread.join(timeout=0.5) + assert thread.is_alive(), "explicit timeout=None must not use the server bound" + + # Unblock the thread directly; libtmux's own timeout/kill path is what + # this test verifies was never invoked. + os.kill(int(pid_file.read_text()), signal.SIGKILL) + thread.join(timeout=5) + assert not thread.is_alive() + + assert len(outcome) == 1 + assert not isinstance(outcome[0], exc.TmuxTimeout) + + def test_context_manager_exit_kills_despite_is_alive_timeout( server: Server, monkeypatch: pytest.MonkeyPatch, From df722ecef3621dc9d23f57ef3579953754d6f7ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 17:56:30 -0500 Subject: [PATCH 45/73] Tests(fix[control_mode]): Bound cleanup time for a stopped client test_control_mode_cleanup[stopped] never actually exercised whether _stop() sends SIGCONT: removing it still passes the test, just five seconds slower, because the wait(timeout=5)/kill() fallback reaps the process regardless. Assert elapsed time stays well under that fallback so a dropped SIGCONT fails the test instead of only slowing it down. Verified by reverting the SIGCONT call locally: the test now fails at 5.01s with the intended message, and passes at 0.36s with the call restored. --- tests/test_control_mode.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index 21b4da8d6b..19d24cdc77 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -6,6 +6,7 @@ import os import signal import sys +import time import typing as t import pytest @@ -35,19 +36,34 @@ def test_control_mode_cleanup( server: Server, stop_client: bool, ) -> None: - """Exiting releases the client and its streams.""" + """Exiting releases the client and its streams. + + For the stopped client, cleanup must also be *prompt*: SIGCONT wakes a + SIGSTOP'd process so the pending SIGTERM is seen immediately. Without + it, ``_stop()`` still cleans up correctly -- ``wait(timeout=5)`` expires + and the ``kill()`` fallback reaps the process -- but only after 5s, + which is the production hang this test exists to catch. Bounding + elapsed time well under that fallback makes a dropped SIGCONT fail the + test instead of only slowing it down. + """ + started = time.monotonic() with control_mode() as ctl: assert len(server.list_clients()) > 0 if stop_client: os.kill(ctl._proc.pid, signal.SIGSTOP) _, state = os.waitpid(ctl._proc.pid, os.WUNTRACED) assert os.WIFSTOPPED(state) + elapsed = time.monotonic() - started assert ctl.stdout.closed assert ctl._proc.stderr is not None and ctl._proc.stderr.closed assert ctl._proc.poll() is not None clients = server.list_clients() assert len(clients) == 0 + assert elapsed < 2, ( + f"cleanup took {elapsed:.2f}s; a stopped client should be woken by " + "SIGCONT and not fall through to the 5s wait() timeout" + ) @pytest.mark.parametrize("problem", [RuntimeError, KeyboardInterrupt]) From 88d6b9c5b59b1269ec1939a78b5e4b6296ebd0d9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 18:02:42 -0500 Subject: [PATCH 46/73] Tests(fix[control_mode]): Time only _stop(), not spawn/registration Starting the clock before entering the with-block also counted Popen and the client_registered retry loop, which are unrelated to the SIGCONT path and could eat into the 2s margin under CPU contention. Move the start to the last line inside the block, immediately before __exit__ runs, so elapsed measures only _stop() itself. Re-verified the same way as the prior commit: reverting SIGCONT still fails at ~5.00s, restoring it passes. --- tests/test_control_mode.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index 19d24cdc77..0879094481 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -44,15 +44,18 @@ def test_control_mode_cleanup( and the ``kill()`` fallback reaps the process -- but only after 5s, which is the production hang this test exists to catch. Bounding elapsed time well under that fallback makes a dropped SIGCONT fail the - test instead of only slowing it down. + test instead of only slowing it down. The clock starts just before the + ``with`` block exits, so it times only ``__exit__``/``_stop()`` -- not + spawn or registration, which are unrelated to the SIGCONT path and + would otherwise eat into the margin under load. """ - started = time.monotonic() with control_mode() as ctl: assert len(server.list_clients()) > 0 if stop_client: os.kill(ctl._proc.pid, signal.SIGSTOP) _, state = os.waitpid(ctl._proc.pid, os.WUNTRACED) assert os.WIFSTOPPED(state) + started = time.monotonic() elapsed = time.monotonic() - started assert ctl.stdout.closed From 33572c1d568eaa1090159c1ee8fb9b2ae83d9266 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:32:07 -0500 Subject: [PATCH 47/73] Server(fix[server_access]): Emit flags before the positional user why: tmux's server-access arg spec ("adlrw", 0, 1) declares every letter flag value-less and takes the user as a single trailing positional (usage: "[-adlrw] [user]"). server_access() built `-a -r`: once tmux's getopt-style parser reaches the bare username right after -a, it stops recognizing further "-" tokens as flags and reads "-r" as a second positional, rejecting the whole call as "too many arguments" -- -r/-w silently never applied whenever combined with allow/deny. what: - Collect the target user separately and append it once, after every boolean flag (-a/-d/-l/-r/-w) - test_server_access_argv's stubbed argv assertions encoded the old, wrong order; it never caught this because it never exercised real tmux. Corrected to `(-a, -r, alice)` / `(-a, -w, bob)` - Added test_server_access_flags_precede_positional_user against a real tmux: the suite has no second real OS user to allow (tmux refuses to touch the server owner's own entry), so it proves the fix by reaching tmux's *next* validation step -- an unknown-user lookup -- instead of failing on argv shape first. Reverting the fix reproduces "too many arguments" on this test and the wrong tuples on test_server_access_argv; both pass again restored. Found while auditing tmux 3.8's server-access -l U/G markers for this round's format-change sweep -- unrelated to those markers, but the same code path. --- CHANGES | 8 ++++++++ src/libtmux/server.py | 17 +++++++++++++++-- tests/test_server.py | 30 ++++++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 39cdbaba87..d4acc24c6a 100644 --- a/CHANGES +++ b/CHANGES @@ -137,6 +137,14 @@ full 5-second wait before falling back to `SIGKILL` every time. Reading hooks recognizes tmux's additional pane, window, client and command events, preserving typed access to the complete hook table. +#### `Server.server_access` orders flags before the user (#758) + +{meth}`~libtmux.Server.server_access` emitted `-a -r`, which tmux's +argument parser reads as two positional arguments once it reaches the bare +username, rejecting the call as "too many arguments" instead of applying +`-r`. Flags now precede the trailing positional user, matching +`server-access`'s own `[-adlrw] [user]` usage. + ### Documentation #### Reliable automation examples (#758) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c916eccadf..99160cd6c2 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1058,11 +1058,21 @@ def server_access( tmux_args: tuple[str, ...] = () + # tmux's own arg spec (`cmd-server-access.c`) declares "adlrw" as + # value-less flags and takes the user as a single trailing + # positional -- `server-access -a myuser -r` is two positional + # arguments ("myuser", "-r") once getopt sees the first bare word, + # and tmux rejects it as "too many arguments". Every flag must come + # before the positional user. + user: str | None = None + if allow is not None: - tmux_args += ("-a", allow) + tmux_args += ("-a",) + user = allow if deny is not None: - tmux_args += ("-d", deny) + tmux_args += ("-d",) + user = deny if list_access: tmux_args += ("-l",) @@ -1073,6 +1083,9 @@ def server_access( if write: tmux_args += ("-w",) + if user is not None: + tmux_args += (user,) + proc = self.cmd("server-access", *tmux_args) raise_if_stderr(proc, "server-access") diff --git a/tests/test_server.py b/tests/test_server.py index a860b2719b..5c04293535 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1395,6 +1395,32 @@ def test_server_access_list(server: Server) -> None: assert isinstance(result, list) +def test_server_access_flags_precede_positional_user(server: Server) -> None: + """Boolean flags reach tmux's user lookup instead of its argv parser. + + ``server-access``'s own arg spec (``cmd-server-access.c``) declares + ``adlrw`` as value-less flags with the user as a single trailing + positional. Emitting ``-a myuser -r`` used to put ``myuser`` right + after ``-a``, so tmux's getopt-style parser stopped recognizing ``-r`` + as a flag once it saw that bare word and rejected the call as "too many + arguments" before ever looking up the user. + + ``server-access`` also refuses to touch the server owner's own entry + (``pw_uid == getuid()``), and this suite has no second real OS account + to allow -- so this proves the fix by reaching tmux's *next* validation + step (an unknown-user lookup) rather than failing on argv shape first. + """ + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip("server-access added in tmux 3.3") + + server.new_session(session_name="access_argv_order_test") + + with pytest.raises(exc.LibTmuxException, match="unknown user"): + server.server_access(allow="nonexistent-libtmux-test-user", read_only=True) + + 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 @@ -1437,10 +1463,10 @@ def fake_cmd(cmd: str, *args: str, **_kw: t.Any) -> t.Any: monkeypatch.setattr(server, "cmd", fake_cmd) server.server_access(allow="alice", read_only=True) - assert captured[-1][1:] == ("-a", "alice", "-r") + assert captured[-1][1:] == ("-a", "-r", "alice") server.server_access(allow="bob", write=True) - assert captured[-1][1:] == ("-a", "bob", "-w") + assert captured[-1][1:] == ("-a", "-w", "bob") def test_start_server(server: Server) -> None: From 2803e1e2eb994d93739cfde197a86a56c144f68d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:32:23 -0500 Subject: [PATCH 48/73] Tests(fix[3.8]): Pin pane_pid emptiness and the window_layout round trip tmux 3.8 changed four format outputs; this round's 1553-pass next-3.9 run exercised the suite as it stood but didn't pin these two properties as regression tests: - #{pane_pid} is now an empty string, not "0", for a pane whose process has already exited (libtmux-java crashed on exactly this). Confirmed live against the next-3.9 probe binary: a dead pane's pid goes from numeric (tmux 3.7d) to "" (next-3.9). libtmux never calls int() on pane_pid -- verified across src/ -- so nothing needed fixing; test_dead_pane_pid_has_no_numeric_coercion pins that contract against a real dead pane on whichever tmux is under test. - #{window_layout} is JSON for non-control clients on 3.8+, and select-layout accepts both forms with a byte-exact round trip (measured last round). Existing tests only compared layouts across next/previous-layout cycling; nothing fed a saved layout string straight back into select_layout(). Added test_select_layout_round_trip_is_byte_exact for that direct path; verified it fails when the restored layout is mutated, passes restored. Two of the four format changes need no new coverage: - #{q:...}'s widened escaping doesn't apply -- neo.py builds every format string as bare `#{field}` plus a private separator (FORMAT_SEPARATOR), never `#{q:...}`. libtmux does not decode q: escaping at all. - server-access -l's new U/G markers: Server.server_access() returns proc.stdout verbatim with no parsing, so a marker it has never seen cannot break it (covered separately in the server_access argv-order fix in this branch). Not touched: TMUX_MAX_VERSION ("3.7" in common.py) undershoots what tmux's git master already reports, but the tmux source under study (~/study/c/tmux) has only a 3.8-rc tag, no final 3.8 -- bumping it now would claim support for a release that hasn't shipped. It only affects two synthetic fallbacks (OpenBSD's no -V tmux, and a literal "master" version string); has_gte_version()-style checks query the live binary and are unaffected. --- tests/test_pane.py | 24 ++++++++++++++++++++++++ tests/test_window.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/tests/test_pane.py b/tests/test_pane.py index 0285e0118c..73da787b8f 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -62,6 +62,30 @@ def test_decoded_pane_fields_match_live_capture(session: Session) -> None: assert all(isinstance(pane.width_cells, int) for pane in panes) +def test_dead_pane_pid_has_no_numeric_coercion(session: Session) -> None: + """A dead pane's ``#{pane_pid}`` never breaks a refresh. + + tmux 3.8 changed ``#{pane_pid}`` from ``"0"`` to an empty string for a + pane whose process has already exited (libtmux-java crashed on exactly + this). libtmux stores ``pane_pid`` as ``str | None`` and never calls + ``int()`` on it, so both shapes must round-trip through a live + ``refresh()`` without raising. + """ + window = session.new_window(window_name="dead_pane_pid") + pane = window.active_pane + assert pane is not None + pane.cmd("set-option", "-p", "remain-on-exit", "on") + pane.send_keys("exit", enter=True) + + def _pane_is_dead() -> bool: + pane.refresh() + return pane.pane_dead == "1" + + retry_until(_pane_is_dead, 3, raises=True) + + assert pane.pane_pid == "" or (pane.pane_pid or "").isdigit() + + def test_send_keys(session: Session) -> None: """Verify Pane.send_keys().""" pane = session.active_window.active_pane diff --git a/tests/test_window.py b/tests/test_window.py index d5565ee43d..b790a6bb3f 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -932,6 +932,36 @@ def test_select_layout_next_previous(session: Session) -> None: assert layout_after_prev == layout_before +def test_select_layout_round_trip_is_byte_exact(session: Session) -> None: + """A saved ``window_layout`` fed back into ``select_layout`` is exact. + + tmux 3.8 made ``#{window_layout}`` JSON for non-control clients, while + ``select-layout`` still accepts the classic grammar too. libtmux treats + the value as an opaque token on every version -- it never parses or + validates it -- so a saved layout must restore byte-for-byte regardless + of which form the running tmux emits. + """ + window = session.new_window(window_name="test_layout_round_trip") + window.resize(height=40, width=80) + pane = window.active_pane + assert pane is not None + pane.split() + pane.split() + + window.select_layout("even-horizontal") + window.refresh() + saved = window.window_layout + assert saved is not None + + window.select_layout("main-vertical") + window.refresh() + assert window.window_layout != saved + + window.select_layout(saved) + window.refresh() + assert window.window_layout == saved + + def test_last_pane(session: Session) -> None: """Test Window.last_pane() selects the previously active pane.""" window = session.new_window(window_name="test_last_pane") From 9999b572617c4e84415da85d6843409b55162e93 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:34:15 -0500 Subject: [PATCH 49/73] CI(fix[tests]): Gate the master-tmux matrix lane on its own test step why: `continue-on-error: ${{ matrix.tmux-version == 'master' }}` made the one CI lane built to catch a tmux behavior change before its release unable to fail. A check that cannot fail is the CI-level form of the same defect shape found five times in code this round. The matrix already builds tmux from git master and runs it on every push and PR -- this was suppressing signal, not saving cost. Evidence for flipping now rather than deferring: - addopts already sets --reruns=2, which is the tool for absorbing timing flakiness; continue-on-error at the job-step level duplicated that with a blunter instrument (swallows real failures too). - Building tmux itself failing already hard-fails an earlier, unguarded step; this flag only ever shielded pytest failures. - Checked the `Test with pytest` step's own conclusion (not just the job's rollup, which continue-on-error can mask) across this branch's last several pushes via `gh api .../actions/jobs/` -- green on every one, against tmux's real git master, not a local probe. Not independently provable as a negative test: this is CI policy, not a runtime assertion, and deliberately breaking master tmux's build to prove the gate can fail would mean shipping that breakage. The falsifiable claim above is the recent step-level history, checked directly against the GitHub API rather than assumed from the green job badge. --- .github/workflows/tests.yml | 1 - CHANGES | 11 +++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aee0aaeec1..79d1bfc58f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -91,7 +91,6 @@ jobs: uv run python -V - name: Test with pytest - continue-on-error: ${{ matrix.tmux-version == 'master' }} run: | sudo apt install libevent-2.1-7 export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH diff --git a/CHANGES b/CHANGES index d4acc24c6a..7086b809e1 100644 --- a/CHANGES +++ b/CHANGES @@ -172,6 +172,17 @@ it. ### Development +#### The `master`-tmux CI job now gates (#758) + +The `master` matrix entry's `Test with pytest` step no longer carries +`continue-on-error`. It already builds tmux from git `master` and runs the +full suite on every push and pull request; letting that step fail silently +gave up the one lane built to catch a behavior change before its release, +while contributing nothing that `addopts`' `--reruns=2` doesn't already +cover for genuine flakiness. Tmux's own build failing still fails a separate, +earlier step regardless of this change. Verified green across this branch's +last several pushes on tmux's real git `master` before flipping. + #### CI actions updated to current majors Workflow actions moved to their current major releases: `actions/checkout` v7, From a5caede5d9315a488eef986b73a4dffc76903a60 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:48:18 -0500 Subject: [PATCH 50/73] Docs(feat[examples]): Add a runnable examples/ directory, executed by pytest This is the reference implementation the other seven libtmux ports are ported from, and it shipped nothing a reader could paste and run -- go has examples/ with each one compiled and tested in isolation, swift has Examples/ with a check_examples.py gate, ts has examples/. python had doctested snippets in docstrings and docs/ pages, which cover the API surface but assume a fixture-provided server/session/pane already in scope -- nothing a reader runs standalone. what: - 5 standalone scripts under examples/: quickstart (the Server -> Session -> Window -> Pane walkthrough), command_results (run_command()/CommandResult), owned_scopes (Server.owned() vs Server.owned_session()), resilient_automation (a bounded timeout, TmuxTimeout, and verifying pane state instead of assuming it), polling_for_changes (the answer to "how do I notice a change" -- Session.windows re-queries tmux, so retry_until() over it is the supported pattern; sets up the ControlMode decision in the next commit) - Every script uses Server.owned() for a private daemon, never the bare Server() the doctest_namespace substitutes for testing -- running one of these as shown must never touch a reader's own default-socket session - tests/test_examples.py runs each script as a real subprocess (`sys.executable