From 397271ec2065f2ac6241c6aee6de437f6774f8da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 04:50:31 -0500 Subject: [PATCH 01/69] 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 1948adf654ad4fbab4bacedcc0295e00f4a8edd0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 04:50:31 -0500 Subject: [PATCH 02/69] 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 6c34c60456c5c2c6141bf425e3f0f855a67db06a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 08:26:43 -0500 Subject: [PATCH 03/69] 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 | 29 +++++++++++++++++++++++++++++ tests/test_common.py | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 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..3d064aca73 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,16 @@ from __future__ import annotations +import shlex +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 +26,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 $$ > {shlex.quote(str(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 b04e60c3900d62ff3dc30deb6031282832ec73e2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 08:26:43 -0500 Subject: [PATCH 04/69] 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 8e1d627df044cc60008e5fc7a4a345e1ccf26a7e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 20:39:54 -0500 Subject: [PATCH 05/69] 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 1cfdf14de08b29ea55169bc599d70121ea2479ec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 20:39:55 -0500 Subject: [PATCH 06/69] 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 306b8f98569808b6ce0ab4f1eaa83fb2c9a6e4aa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 20:46:41 -0500 Subject: [PATCH 07/69] 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 | 75 +++++++++++++++++++++++++++++++++++++ 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, 201 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..30e060fabb 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -2708,6 +2708,81 @@ 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, like :attr:`is_active` -- never re-queries tmux. A + stale handle keeps reporting whatever it last captured; call + :meth:`refresh` first for a live answer. That answer also depends + on ``remain-on-exit``: without it, tmux destroys a pane outright + when its process exits, so there is no "dead" state to read, only + a handle whose :meth:`refresh` now raises + :exc:`~libtmux.exc.TmuxObjectDoesNotExist`. + + Examples + -------- + A stale handle answers from its last snapshot, not from tmux. The + pane exits just after :meth:`Window.split` reads it back, so the + read-back itself never races the exit: + + >>> gone = window.split(shell="sh -c 'sleep 0.2; exit 0'") + >>> retry_until( + ... lambda: len(window.panes.filter(pane_id=gone.pane_id)) == 0, 2 + ... ) + True + >>> gone.is_dead # last snapshot said "alive"; never re-queried + False + + Refreshing that same handle raises -- the pane wasn't merely + marked dead, tmux removed it (no ``remain-on-exit``): + + >>> from libtmux import exc + >>> try: + ... gone.refresh() + ... except exc.TmuxObjectDoesNotExist: + ... print("destroyed, not merely dead") + destroyed, not merely dead + + With ``remain-on-exit``, the pane survives and a refreshed handle + reports it: + + >>> stays = window.split(shell="sh") + >>> stays.cmd("set-option", "-p", "remain-on-exit", "on") # doctest: +HIDE + + >>> stays.send_keys("exit", enter=True) + >>> def _stays_dead() -> bool | None: + ... stays.refresh() + ... return stays.is_dead + >>> retry_until(_stays_dead, 2) + 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 06ee1fe5ed969f459e3092d7857ba94a75e06b9b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 20:47:29 -0500 Subject: [PATCH 08/69] 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 - Check both exit status and stderr before removing an owned server's socket directory, so a completed cleanup failure stays visible and the endpoint remains available for retry, without changing legacy Server.kill behavior --- docs/topics/context_managers.md | 163 ++++++++++--------------- src/libtmux/pane.py | 3 + src/libtmux/server.py | 162 ++++++++++++++++++++++++ src/libtmux/session.py | 3 + src/libtmux/window.py | 3 + tests/test_server.py | 210 +++++++++++++++++++++++++++++++- 6 files changed, 441 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 30e060fabb..82e5e3ba28 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..e5372aa67d 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,75 @@ 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(): + proc = Server( + socket_path=socket_path, + tmux_bin=tmux_bin, + timeout=timeout, + ).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: """Enter the context, returning self. @@ -277,6 +349,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 +2479,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..9e0e0e5392 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,214 @@ 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) + + # 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, + 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 is not None + assert socket_path.exists() + assert owned.is_alive() + finally: + if owned is not None: + owned.kill() + if socket_path is not None: + 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 70df280c6e99311818d688214e91d120f9e7abbb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 06:54:38 -0500 Subject: [PATCH 09/69] 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 68bf9c6150c438f0828065066769cb151398dd67 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 08:11:14 -0500 Subject: [PATCH 10/69] 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 ceb39e26e66293630a7acfeb729037ab76b793da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 08:34:22 -0500 Subject: [PATCH 11/69] 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 9e382d320c..bcd89b6146 100644 --- a/uv.lock +++ b/uv.lock @@ -812,12 +812,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 5f760c21cb085c9cd93d976b6040f35f6c6d3f27 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 08:34:22 -0500 Subject: [PATCH 12/69] 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 9e0e0e5392..00e48a8f7a 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 38e855ce3e35d4be45da92fcc2b43e6aa8ad7c84 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 09:01:47 -0500 Subject: [PATCH 13/69] 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 64871802a2a6af055d8d960d43faabc8f8796dd3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 09:10:45 -0500 Subject: [PATCH 14/69] 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 d0c70364993b9f25d35707cd43b78b64294a7293 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 09:10:45 -0500 Subject: [PATCH 15/69] 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 82e5e3ba28..e507fe83f1 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 0d96a9a1109c5d4c6d56c03f981e1aeefffbfda8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 10:33:12 -0500 Subject: [PATCH 16/69] 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 8ff4aec56cab120b98891a47899bcf429d6227c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 10:33:12 -0500 Subject: [PATCH 17/69] 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 0bf7cc72d23daf0102254b4cfc7a65f704fa2a02 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 10:33:12 -0500 Subject: [PATCH 18/69] 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 196e04366c8ac0d25d68e927610589c10ba25404 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 10:33:12 -0500 Subject: [PATCH 19/69] 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 00e48a8f7a..8a3d3c5319 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 @@ -564,50 +565,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 2466d5ec8b8e185c71839196aaac8cf59133ff75 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 10:33:12 -0500 Subject: [PATCH 20/69] 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 92ad98baf622d136db05e4a8b1e35971dac51416 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 11:00:37 -0500 Subject: [PATCH 21/69] 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 8a3d3c5319..e2bda60c4c 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 a6cef8a1ddfc3781113960ef2025c2c03d4381c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 11:00:37 -0500 Subject: [PATCH 22/69] Docs(fix): Verify automation completion why: Echoed commands and stale markers can report completion too early. Joined captures on older tmux also pad completed marker lines, which can time out examples and capture tests waiting for an exact match. what: - Match complete output lines and use fresh markers for repeated work - Compare completed marker lines after removing right-padding spaces, and reject echoed or stale markers - 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 | 381 +++++++++++++++++++---------- src/libtmux/pane.py | 13 +- tests/test_pane_capture_pane.py | 4 +- 3 files changed, 256 insertions(+), 142 deletions(-) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index ba96411085..d51da5c601 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,50 @@ 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. +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 ->>> 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 = [line.rstrip(' ') for line in 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 any(line.rstrip(' ') == 'RUNNING' for line 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 any(line.rstrip(' ') == 'DONE' for line in status_pane.capture_pane(join_wrapped=True)): +... break +... time.sleep(0.05) +>>> any(line.rstrip(' ') == 'DONE' for line in status_pane.capture_pane(join_wrapped=True)) True +>>> is_process_running(status_pane) +False >>> # Clean up >>> status_window.kill() @@ -112,29 +135,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 any(line.rstrip(' ') == text for line 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 +174,82 @@ 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 any(line.rstrip(' ') == 'Success!' for line in error_pane.capture_pane(join_wrapped=True)): +... break +... time.sleep(0.05) +>>> any(line.rstrip(' ') == 'Success!' for line 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. The helper preserves payload +lines, including their trailing spaces; the example removes those spaces only +when displaying its result. ```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) +... markers = [line.rstrip(' ') for line in lines] +... try: +... start = markers.index(start_marker) +... end = markers.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"' +... ) +>>> captured = capture_between_markers(capture_pane, 'BEGIN', 'END', timeout=2.0) +>>> [line.rstrip(' ') for line in captured] +['captured data'] >>> # Clean up >>> capture_window.kill() @@ -232,29 +269,46 @@ 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( +... 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() >>> # Wait for all tasks ->>> time.sleep(0.5) +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... 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('DONE' in '\\n'.join(p.capture_pane()) for p, _ in tasks) +>>> all( +... any(line.rstrip(' ') == marker for line in p.capture_pane(join_wrapped=True)) +... for p, _, marker in tasks +... ) True >>> # Clean up @@ -272,28 +326,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 any(line.rstrip(' ') == marker for line 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 +375,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 +397,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 any(line.rstrip(' ') == 'Subtask running' for line in pane.capture_pane(join_wrapped=True)): +... break +... time.sleep(0.05) +... any(line.rstrip(' ') == 'Subtask running' for line in pane.capture_pane(join_wrapped=True)) True >>> # Window cleaned up automatically @@ -356,12 +420,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 +437,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 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, 'echo "fast"', timeout=2.0) ->>> 'fast' in result +>>> result = run_with_timeout(timeout_pane, r'printf "\nfast\n"', timeout=2.0) +>>> any(line.rstrip(' ') == 'fast' for line 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 = [line.rstrip(' ') for line in 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 +528,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 any(line.rstrip(' ') == marker for line 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 +584,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 +596,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 any(line.rstrip(' ') == next_marker for line 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 +620,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 +637,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 any(line.rstrip(' ') == '__DONE__' for line in bp_pane.capture_pane(join_wrapped=True)): +... break +... time.sleep(0.05) +>>> any(line.rstrip(' ') == '__DONE__' for line in bp_pane.capture_pane(join_wrapped=True)) True >>> bp_window.kill() @@ -550,7 +663,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 +683,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 e507fe83f1..f758c59e53 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 = ( 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 4963acdcd92dbc84ec332fd1eab94a56e6bbb217 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 11:38:46 -0500 Subject: [PATCH 23/69] 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 0ae5cb4b0a48973c3941f98c196102aaf55a5759 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 11:38:46 -0500 Subject: [PATCH 24/69] 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 e2bda60c4c..10021b5e7a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1957,16 +1957,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}+") @@ -1981,15 +1978,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 @@ -2001,9 +1997,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 eb69f6930098c4e48b570ee1917fdcd5bae71bf1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 12:55:10 -0500 Subject: [PATCH 25/69] 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 6e6e9e573834f580dd829b16963f0706d10ce1f7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 13:04:47 -0500 Subject: [PATCH 26/69] 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 ea33f7ee748adb0afc10a9eae43d23362d94735a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 13:15:48 -0500 Subject: [PATCH 27/69] 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 f758c59e53..4f8e3f4298 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 10021b5e7a..b09a2cc003 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 @@ -1880,6 +1881,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 ae99cbd63d28b4bf97ffedfb2bf408b3376bc66e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:46:06 -0500 Subject: [PATCH 28/69] 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 --- src/libtmux/server.py | 7 +++++-- tests/test_server.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e5372aa67d..7e9e499aba 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,10 @@ def new_session( raise_if_stderr(proc, "new-session") - session_stdout = proc.stdout[0] + # Regroup on the separator, not stdout's lines: a value (e.g. + # pane_current_path) may itself contain a newline and split + # this record across proc.stdout otherwise. See _split_records. + 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 b09a2cc003..0cc2931cfb 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -736,6 +736,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 bc6dc315909896dd399c08350dae6748b76ad861 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:47:16 -0500 Subject: [PATCH 29/69] 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 --- src/libtmux/common.py | 15 ++++++++++++++- tests/conftest.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_common.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 9b1e5895b8..75af7a26da 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -32,6 +32,10 @@ #: Most recent version of tmux supported TMUX_MAX_VERSION = "3.7" +#: Bound on draining stdout/stderr after :meth:`subprocess.Popen.kill`: +#: a surviving descendant can inherit the pipes and keep them open past SIGKILL. +_KILL_REAP_TIMEOUT = 1.0 + SessionDict = dict[str, t.Any] WindowDict = dict[str, t.Any] WindowOptionDict = dict[str, t.Any] @@ -380,7 +384,16 @@ 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 them + # open, so reading for EOF here would block indefinitely. + 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 648d252278f2c18c1f3b81722fa7340d3fe5af25 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:48:18 -0500 Subject: [PATCH 30/69] 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 --- src/libtmux/exc.py | 9 ++++++++- tests/test_common.py | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index f87c9be0de..edb7fc7cf5 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -367,7 +367,14 @@ 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)}") + # self.args must stay shaped like __init__'s signature: pickle/copy + # reconstruct via `type(e)(*e.args)`, which a lone message string + # here would mismatch 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 a573f09ab38b2b5d20c095a65fe1727095fc43c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:49:29 -0500 Subject: [PATCH 31/69] 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 +++++---- 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 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 108 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/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 edb7fc7cf5..2951c5513b 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -377,6 +377,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 7e9e499aba..cd1d10e526 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -2581,16 +2581,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) @@ -2642,10 +2649,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 ------- @@ -2663,6 +2676,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 0cc2931cfb..6169ee0fac 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1712,6 +1712,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, @@ -1753,6 +1778,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 818771b8dfb35724ad189b6a0e4c9329f700133a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:54:31 -0500 Subject: [PATCH 32/69] 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 --- pyproject.toml | 5 ++-- src/libtmux/server.py | 52 ++++++++++++++++++++++------------- tests/test_server.py | 63 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 20 deletions(-) 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 cd1d10e526..bf5c63b7c7 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -361,7 +361,14 @@ 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 is neither "alive" nor "dead": assume alive + # and attempt the kill -- a truly dead one no-ops, and a still- + # wedged one makes kill() itself time out and raise, loudly. + alive = True + if alive: self.kill() def is_alive(self) -> bool: @@ -369,9 +376,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 +397,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 +418,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 6169ee0fac..70773ce9fa 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 85e6d2aea6f6c3b0763cd38d088b4987227c6af3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:58:40 -0500 Subject: [PATCH 33/69] 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 --- src/libtmux/server.py | 50 +++++++++++++++++++++++++++++++++++-------- tests/test_server.py | 33 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index bf5c63b7c7..a19fe738e2 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, @@ -2562,15 +2595,14 @@ 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, so kill directly instead + # of leaking the session -- no user code has run, so the reuse + # race guarded below cannot have happened yet. + session.kill() + raise try: yield session finally: diff --git a/tests/test_server.py b/tests/test_server.py index 70773ce9fa..f4a6039682 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: t.Any, + **kwargs: t.Any, + ) -> 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 2b288e036c16c346c7efd3577c6be586aec71fed Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:00:08 -0500 Subject: [PATCH 34/69] 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 --- src/libtmux/_internal/control_mode.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index af0ef0651a..a3b90de7b2 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,10 @@ def _stop(self) -> None: try: os.close(self._write_fd) self._proc.terminate() + # A stopped process (SIGSTOP, a debugger) cannot process SIGTERM + # until resumed; SIGCONT wakes it so the wait below doesn't time out. + with contextlib.suppress(ProcessLookupError): + self._proc.send_signal(signal.SIGCONT) try: self._proc.wait(timeout=5) except subprocess.TimeoutExpired: From a2cf0b53d44dbc1e13b7f21c6bcfcdf9f15dc337 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:03:31 -0500 Subject: [PATCH 35/69] 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 60ac3212dff3bb709e4d6ff148667f0aba3b59c8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:04:53 -0500 Subject: [PATCH 36/69] 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__ --- src/libtmux/server.py | 3 +++ tests/test_server.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index a19fe738e2..a04256c9e2 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 f4a6039682..a6b75c21d2 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 b762412d2f4ebb212431052c97bd438e2d6f31c3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:06:31 -0500 Subject: [PATCH 37/69] 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) --- src/libtmux/server.py | 4 +++- tests/test_server.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index a04256c9e2..012c133498 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -225,7 +225,9 @@ def __init__( self._panes: list[PaneDict] = [] if socket_path is not None: - self.socket_path = socket_path + # str, not Path: __eq__ compares socket_path by value, and + # Path("/x") != "/x", so a Path here would break equality. + 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 a6b75c21d2..e0d5dfa516 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 06163025b94c4429a9c9d38f8ef7a279faa52569 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:10:48 -0500 Subject: [PATCH 38/69] 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 --- src/libtmux/server.py | 28 +++++++++++++++++++++++-- tests/test_server.py | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 012c133498..1fee77a60b 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. @@ -473,7 +490,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. @@ -511,6 +528,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 ------- @@ -540,11 +562,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 1b67370365be8ca1f5c3bc2c6737846f80c8a04f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 17:56:30 -0500 Subject: [PATCH 39/69] 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 0991bec6150be134239484f117fdb44d6e8224a4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 18:02:42 -0500 Subject: [PATCH 40/69] 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 4500a964cb07e390e267c42fb56dcb77aa48a26a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:32:07 -0500 Subject: [PATCH 41/69] 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. --- src/libtmux/server.py | 13 +++++++++++-- tests/test_server.py | 30 ++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 1fee77a60b..7a2771508c 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1053,11 +1053,17 @@ def server_access( tmux_args: tuple[str, ...] = () + # tmux's server-access arg spec takes "adlrw" as value-less flags + # and the user as one trailing positional -- flags must precede it. + 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",) @@ -1068,6 +1074,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 b2084bbee01b74b054a1ca93c591601c925d3551 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:32:23 -0500 Subject: [PATCH 42/69] 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 tmux's own repository 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 a5966945835d0bee0d374e5109c92174e012966c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:34:15 -0500 Subject: [PATCH 43/69] 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 - 1 file changed, 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 From 188dc25ff2ae937794f7dc41cadddbcc47a6761a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:48:18 -0500 Subject: [PATCH 44/69] 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