diff --git a/CHANGES b/CHANGES index 7ec3ccdc0..96ab5e271 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,31 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### Bounded tmux commands (#735) + +{meth}`Server.wait_for() ` takes a `timeout` in seconds. +A tmux channel is a rendezvous with no clock on it: when the pane that was going +to signal is killed, exits early, or has its window closed, the waiter blocks for +the life of the process. `server.wait_for(channel, timeout=60)` turns that into +a {exc}`~libtmux.exc.TmuxCommandTimeout` you can catch. It subclasses +{exc}`~libtmux.exc.WaitTimeout`, so existing handlers keep working, and it +carries the command that was killed and the bound it exceeded. + +The bound is available to any tmux command, not just `wait-for`. +{meth}`Server.cmd() `, +{meth}`Session.cmd() `, +{meth}`Window.cmd() `, +{meth}`Pane.cmd() `, and +{class}`~libtmux.common.tmux_cmd` beneath them all accept `timeout`, and all +kill and reap the tmux client libtmux spawned before raising, so the call +leaves no child of its own behind. Work the command started — a pane's +foreground process, the tmux server — keeps running. The default, `None`, +waits as long as tmux takes. + +See {ref}`automation-patterns` for waiting on a channel instead of polling. + ### Documentation #### Automation patterns waits for tmux instead of guessing (#734) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index 55ffc29ef..77ee638fe 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -126,8 +126,9 @@ block on that channel with {meth}`~libtmux.Server.wait_for`. tmux remembers a si sent before the waiter starts, so this has no lost-wakeup race. It also avoids confusing the shell's echoed command with the command's output. -{meth}`~libtmux.Server.wait_for` has no timeout. Make sure every expected exit path -reaches `tmux wait-for -S` so a failed command cannot leave your script blocked. +{meth}`~libtmux.Server.wait_for` waits indefinitely unless you give it a `timeout`. +Either make sure every expected exit path reaches `tmux wait-for -S`, or bound the +wait, so a failed command cannot leave your script blocked. Channels are server-wide, so give each in-flight command a distinct channel name. ```python @@ -453,6 +454,65 @@ True >>> timeout_window.kill() ``` +### Waiting for a signal instead of polling + +Polling costs you a tmux round-trip per read and a `sleep` you pay whether the +command finished or not. tmux offers the other half of the trade: a *channel*, +where one process waits and another signals, and nobody polls in between. The +pane signals with `tmux wait-for -S`, and your script waits with +{meth}`~libtmux.Server.wait_for`. + +What you give up is the guarantee that the signal arrives at all — kill the pane, +close its window, or have the command exit before it reaches the `wait-for`, and +the waiter waits forever. Pass a `timeout` to cap it, and a missing signal becomes +a {exc}`~libtmux.exc.TmuxCommandTimeout` you can catch. It is a +{exc}`~libtmux.exc.WaitTimeout`, so an existing handler still catches it, and it +carries the command that was killed and the bound it blew. + +```python +>>> signal_window = session.new_window(window_name='signal-demo', attach=False) +>>> signal_pane = signal_window.active_pane + +>>> channel = 'demo-work-done' +>>> signal_pane.send_keys(f'echo "working"; tmux wait-for -S {channel}') +>>> session.server.wait_for(channel, timeout=60) + +>>> signal_window.kill() +``` + +Nothing signals `never-arrives`, so the wait ends on the clock rather than on the +work: + +```python +>>> from libtmux import exc + +>>> try: +... session.server.wait_for('never-arrives', timeout=0.25) +... except exc.TmuxCommandTimeout as e: +... print(f'gave up after {e.timeout}s') +gave up after 0.25s +``` + +The bound belongs to the call, not to the server object, so the same server can +carry a patient wait for a build and an impatient one for a health check. + +Two properties of tmux's rendezvous are worth knowing before you rely on it. + +A wait that times out is abandoned rather than withdrawn, and tmux only +*remembers* a signal when nothing is waiting for the channel. The abandoned wait +still counts, so the next signal on that channel is spent waking it instead of +being remembered — one signal, after which the channel behaves normally again. +Waits already in flight are woken as usual and other channels are untouched. +Naming a fresh channel per rendezvous, from a build id or a UUID, sidesteps it +entirely. + +A returning `wait_for` also means less than it appears to. tmux releases every +waiter when the server shuts down, and it does so exactly as if the channel had +been signalled, so a pane that died and a command that finished are +indistinguishable from the wait alone. When it matters whether the work actually +succeeded, have the command report its own result — write the exit status to a +pane option and read it back — rather than treating the wake-up as proof. + ### Retry pattern For flaky work that succeeds on a later attempt, wait for each attempt to finish and diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 287154770..f4660eef4 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -280,9 +280,70 @@ def raise_if_stderr(proc: tmux_cmd, subcommand: str) -> None: ) +def _kill_and_reap(process: subprocess.Popen[str]) -> None: + """Kill a subprocess that outstayed its timeout, then reap it. + + :meth:`subprocess.Popen.communicate` leaves the child running when its + *timeout* expires -- the caller has to kill and reap it, the same dance + :func:`subprocess.run` does on its own timeout path. Skipping it leaks one + tmux process per expiry. + + The child is waited for rather than drained: after ``SIGKILL`` it exits + promptly, while reading its pipes to EOF could block on a grandchild that + inherited them -- past the bound the caller just asked to enforce. The + pipes are closed by hand instead, since nothing will read them. + + Parameters + ---------- + process : :class:`subprocess.Popen` + The timed-out child. + + Examples + -------- + >>> from libtmux.common import _kill_and_reap + >>> process = subprocess.Popen( + ... [sys.executable, '-c', 'import time; time.sleep(300)'], + ... stdout=subprocess.PIPE, + ... stderr=subprocess.PIPE, + ... text=True, + ... ) + >>> process.poll() is None # still running + True + + >>> _kill_and_reap(process) + >>> process.returncode is not None # dead, and its exit status collected + True + """ + process.kill() + process.wait() + for stream in (process.stdout, process.stderr): + if stream is not None: + stream.close() + + class tmux_cmd: """Run any :term:`tmux(1)` command through :py:mod:`subprocess`. + Parameters + ---------- + *args : object + tmux arguments, stringified and appended after the binary. + tmux_bin : str, optional + Path to the tmux binary. Resolved from ``$PATH`` when *None*. + timeout : float, optional + Seconds to allow tmux to run. *None* (the default) waits as long as + tmux takes, which is what a rendezvous like ``wait-for`` needs when + nobody is watching the clock. Give it a number when the command can + block on something that may never happen. + + Raises + ------ + :exc:`~libtmux.exc.TmuxCommandTimeout` + When *timeout* elapses. The tmux client this spawned is killed and + reaped before the exception leaves, so the call leaves no child of its + own behind. Work the command started -- a pane's foreground process, + the tmux server -- is unaffected and keeps running. + Examples -------- Create a new session, check for error: @@ -303,13 +364,46 @@ class tmux_cmd: $ tmux new-session -s my session + ``tmux wait-for`` blocks until another process signals the channel. Bound + it, and a channel nobody signals costs a known amount of time: + + >>> from libtmux import exc + >>> try: + ... tmux_cmd( + ... f'-L{server.socket_name}', 'wait-for', 'nobody-signals-me', + ... timeout=0.25, + ... ) + ... except exc.TmuxCommandTimeout as e: + ... print(e) + tmux command timed out after 0.25s: ...wait-for nobody-signals-me + + The exception carries what was killed and the bound it blew, so a caller + does not have to parse the message back apart: + + >>> try: + ... tmux_cmd( + ... f'-L{server.socket_name}', 'wait-for', 'nobody-signals-me', + ... timeout=0.25, + ... ) + ... except exc.TmuxCommandTimeout as e: + ... (e.timeout, e.cmd[-2:]) + (0.25, ['wait-for', 'nobody-signals-me']) + Notes ----- + .. versionchanged:: 0.63 + Added *timeout*. + .. 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,10 +430,20 @@ 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 FileNotFoundError: raise exc.TmuxCommandNotFound from None + except subprocess.TimeoutExpired as e: + _kill_and_reap(self.process) + logger.error( # noqa: TRY400 + "tmux command timed out", + extra={ + "tmux_cmd": shlex.join(cmd), + "tmux_timeout": e.timeout, + }, + ) + raise exc.TmuxCommandTimeout(cmd=cmd, timeout=e.timeout) from None except Exception: logger.error( # noqa: TRY400 "tmux subprocess failed", diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 57bb06102..f1f4370d6 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -7,6 +7,7 @@ from __future__ import annotations +import shlex import typing as t if t.TYPE_CHECKING: @@ -350,6 +351,78 @@ class WaitTimeout(LibTmuxException): """Function timed out without meeting condition.""" +class TmuxCommandTimeout(WaitTimeout): + """A tmux command outlived its timeout and the tmux client was killed. + + Raised from :class:`~libtmux.common.tmux_cmd`, and therefore from every + :meth:`Server.cmd() `, + :meth:`Session.cmd() `, + :meth:`Window.cmd() `, and + :meth:`Pane.cmd() ` call layered on it, whenever a + timeout is in force and the command exceeds it. The tmux client libtmux + spawned is sent ``SIGKILL`` and reaped before this is raised, so the call + leaves no child of its own behind. + + That is the client, not the work. Anything the command set in motion -- + a pane's foreground process, the tmux server itself -- keeps running, and + a caller that needs it stopped has to say so. + + Subclasses :exc:`WaitTimeout` rather than replacing it: code that already + catches libtmux's timeout keeps working, while callers who want the + command line and the bound that was exceeded can reach for them. + + Parameters + ---------- + cmd : list[str] + Full tmux command line that was killed, argv-style. + timeout : float + Bound, in seconds, that the command exceeded. + *args : object + Forwarded to :exc:`LibTmuxException`. + + Attributes + ---------- + cmd : list[str] + Full tmux command line that was killed, argv-style. + timeout : float + Bound, in seconds, that the command exceeded. + + Examples + -------- + >>> from libtmux import exc + >>> err = exc.TmuxCommandTimeout( + ... cmd=["tmux", "wait-for", "build-done"], + ... timeout=1.5, + ... ) + >>> str(err) + 'tmux command timed out after 1.5s: tmux wait-for build-done' + + Existing handlers keep working, because it is still a + :exc:`WaitTimeout`: + + >>> isinstance(err, exc.WaitTimeout) + True + + >>> err.timeout + 1.5 + + .. versionadded:: 0.63 + """ + + def __init__( + self, + cmd: list[str], + timeout: float, + *args: object, + ) -> None: + self.cmd = cmd + self.timeout = timeout + super().__init__( + f"tmux command timed out after {timeout}s: {shlex.join(cmd)}", + *args, + ) + + class VariableUnpackingError(LibTmuxException): """Error unpacking variable.""" diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e0c2f5961..21da9bc2e 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -311,6 +311,7 @@ def cmd( cmd: str, *args: t.Any, target: str | int | None = None, + timeout: float | None = None, ) -> tmux_cmd: """Execute tmux subcommand within pane context. @@ -332,15 +333,31 @@ def cmd( ---------- target : str, optional Optional custom target override. By default, the target is the pane ID. + timeout : float, optional + Seconds to allow this command to run before killing the tmux + client libtmux spawned and raising + :exc:`~libtmux.exc.TmuxCommandTimeout`. *None* (the default) + waits indefinitely. Returns ------- :meth:`server.cmd` + + Raises + ------ + :exc:`~libtmux.exc.TmuxCommandTimeout` + When *timeout* elapses. + + Notes + ----- + .. versionchanged:: 0.63 + + Added ``timeout``. """ if target is None: target = self.pane_id - return self.server.cmd(cmd, *args, target=target) + return self.server.cmd(cmd, *args, target=target, timeout=timeout) """ Commands (tmux-like) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c3..7b8b45174 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -342,6 +342,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. @@ -375,17 +376,42 @@ def cmd( ... 'split-window', '-P', '-F#{pane_id}').stdout[0], server=window.server) Pane(%... Window(@... ...:..., Session($1 libtmux_...))) + Most tmux commands return at once. The few that block -- ``wait-for``, + a foreground ``run-shell`` -- can be bounded, and give up as a libtmux + error rather than hanging: + + >>> from libtmux import exc + >>> try: + ... server.cmd('wait-for', 'nobody-signals-me', timeout=0.25) + ... except exc.TmuxCommandTimeout: + ... print('gave up') + gave up + Parameters ---------- target : str, optional Optional custom target. + timeout : float, optional + Seconds to allow this command to run before killing the tmux + client libtmux spawned and raising + :exc:`~libtmux.exc.TmuxCommandTimeout`. *None* (the default) + waits indefinitely. Returns ------- :class:`common.tmux_cmd` + Raises + ------ + :exc:`~libtmux.exc.TmuxCommandTimeout` + When *timeout* elapses. + Notes ----- + .. versionchanged:: 0.63 + + Added ``timeout``. + .. versionchanged:: 0.8 Renamed from ``.tmux`` to ``.cmd``. @@ -408,7 +434,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=timeout, + ) @property def attached_sessions(self) -> list[Session]: @@ -624,9 +655,16 @@ def wait_for( lock: bool | None = None, unlock: bool | None = None, set_flag: bool | None = None, + timeout: float | None = None, ) -> None: """Wait for, signal, or lock a channel via ``$ tmux wait-for``. + A channel is a rendezvous: one process waits, another signals, and + tmux wakes the waiter with no polling in between. The catch is that + nothing guarantees the signal ever arrives -- the pane that was going + to send it can be killed, exit early, or have its window closed. Pass + *timeout* to put a ceiling on the wait. + Parameters ---------- channel : str @@ -637,12 +675,44 @@ def wait_for( Unlock the channel (``-U`` flag). set_flag : bool, optional Set the channel flag and wake waiters (``-S`` flag). + timeout : float, optional + Seconds to wait before giving up and raising + :exc:`~libtmux.exc.TmuxCommandTimeout`. *None* (the default) waits + forever, which is what you want when the signal is certain. + + Raises + ------ + :exc:`~libtmux.exc.TmuxCommandTimeout` + When *timeout* elapses before the channel is signalled. Examples -------- >>> server.new_session(session_name='wait_test') Session(...) >>> server.wait_for('test_channel', set_flag=True) + + Wait on a pane that signals when its work is done, bounded in case + the work never gets that far: + + >>> channel = 'build-done' + >>> pane.send_keys(f'make; tmux wait-for -S {channel}') + >>> server.wait_for(channel, timeout=60) + + A channel nobody signals costs *timeout* seconds instead of the rest + of the process's life: + + >>> from libtmux import exc + >>> try: + ... server.wait_for('nobody-signals-me', timeout=0.25) + ... except exc.TmuxCommandTimeout: + ... print('gave up waiting') + gave up waiting + + Notes + ----- + .. versionchanged:: 0.63 + + Added ``timeout``. """ tmux_args: tuple[str, ...] = () @@ -657,7 +727,7 @@ def wait_for( tmux_args += (channel,) - proc = self.cmd("wait-for", *tmux_args) + proc = self.cmd("wait-for", *tmux_args, timeout=timeout) raise_if_stderr(proc, "wait-for") diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 4277052a3..6b1bea6f9 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -418,6 +418,7 @@ def cmd( cmd: str, *args: t.Any, target: str | int | None = None, + timeout: float | None = None, ) -> tmux_cmd: """Execute tmux subcommand within session context. @@ -439,13 +440,27 @@ def cmd( ---------- target : str, optional Optional custom target override. By default, the target is the session ID. + timeout : float, optional + Seconds to allow this command to run before killing the tmux + client libtmux spawned and raising + :exc:`~libtmux.exc.TmuxCommandTimeout`. *None* (the default) + waits indefinitely. Returns ------- :meth:`server.cmd` + Raises + ------ + :exc:`~libtmux.exc.TmuxCommandTimeout` + When *timeout* elapses. + Notes ----- + .. versionchanged:: 0.63 + + Added ``timeout``. + .. versionchanged:: 0.34 Passing target by ``-t`` is ignored. Use ``target`` keyword argument instead. @@ -456,7 +471,7 @@ def cmd( """ if target is None: target = self.session_id - return self.server.cmd(cmd, *args, target=target) + return self.server.cmd(cmd, *args, target=target, timeout=timeout) """ Commands (tmux-like) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b57db9969..26da00f8f 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -463,6 +463,7 @@ def cmd( cmd: str, *args: t.Any, target: str | int | None = None, + timeout: float | None = None, ) -> tmux_cmd: """Execute tmux subcommand within window context. @@ -486,15 +487,31 @@ def cmd( ---------- target : str, optional Optional custom target override. By default, the target is the window ID. + timeout : float, optional + Seconds to allow this command to run before killing the tmux + client libtmux spawned and raising + :exc:`~libtmux.exc.TmuxCommandTimeout`. *None* (the default) + waits indefinitely. Returns ------- :meth:`server.cmd` + + Raises + ------ + :exc:`~libtmux.exc.TmuxCommandTimeout` + When *timeout* elapses. + + Notes + ----- + .. versionchanged:: 0.63 + + Added ``timeout``. """ if target is None: target = self.window_id - return self.server.cmd(cmd, *args, target=target) + return self.server.cmd(cmd, *args, target=target, timeout=timeout) """ Commands (tmux-like) diff --git a/tests/test_common.py b/tests/test_common.py index 426b72d57..5b9783d02 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -2,9 +2,13 @@ from __future__ import annotations +import inspect import locale import logging +import os import re +import signal +import subprocess import sys import typing as t @@ -762,3 +766,82 @@ 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_the_child( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A timed-out tmux process is killed, reaped, and its pipes closed. + + :meth:`subprocess.Popen.communicate` leaves the child running when its + timeout expires, so an unhandled expiry leaks one tmux process per call. + The recorder wraps :class:`subprocess.Popen` because the exception path + never hands the caller the ``tmux_cmd`` holding the process. + """ + spawned: list[subprocess.Popen[t.Any]] = [] + real_popen = subprocess.Popen + + def record_popen(*args: t.Any, **kwargs: t.Any) -> subprocess.Popen[t.Any]: + process = real_popen(*args, **kwargs) + spawned.append(process) + return process + + monkeypatch.setattr(subprocess, "Popen", record_popen) + + with pytest.raises(exc.TmuxCommandTimeout): + session.server.cmd("wait-for", "libtmux_reap_channel", timeout=0.5) + + assert spawned, "no tmux subprocess was spawned" + process = spawned[-1] + + assert process.returncode is not None, "child was left running" + assert process.returncode == -signal.SIGKILL, "child was not killed" + + with pytest.raises(ChildProcessError): + os.waitpid(process.pid, os.WNOHANG) + + assert process.stdout is not None + assert process.stdout.closed, "stdout pipe leaked" + assert process.stderr is not None + assert process.stderr.closed, "stderr pipe leaked" + + +def test_tmux_cmd_timeout_that_is_not_reached_returns_normally( + session: Session, +) -> None: + """A command that finishes inside its bound parses its output as usual.""" + proc = tmux_cmd( + f"-L{session.server.socket_name}", + "display-message", + "-p", + "ok", + timeout=60, + ) + + assert proc.stdout == ["ok"] + assert proc.returncode == 0 + assert proc.stderr == [] + + +def test_timeout_defaults_to_none_at_every_entry_point() -> None: + """Existing callers must not start timing out. + + ``None`` is the only default that keeps today's unbounded behavior, and + keyword-only is what lets the parameter be added without disturbing the + positional ``*args`` every one of these entry points forwards to tmux. + """ + entry_points = ( + tmux_cmd.__init__, + libtmux.Server.cmd, + libtmux.Session.cmd, + libtmux.Window.cmd, + libtmux.Pane.cmd, + libtmux.Server.wait_for, + ) + + for func in entry_points: + parameter = inspect.signature(func).parameters["timeout"] + + assert parameter.default is None, f"{func.__qualname__} defaults to a bound" + assert parameter.kind is inspect.Parameter.KEYWORD_ONLY diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9a..f5e290957 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1765,3 +1765,100 @@ 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_wait_for_bounds_a_channel_that_is_never_signalled( + session: Session, +) -> None: + """A signal that never arrives costs ``timeout`` seconds, not forever. + + Regression test for #732. ``enter=False`` types the command into the pane + without running it, so the ``wait-for -S`` half of the rendezvous never + happens -- the same shape as a pane that is killed, exits early, or has + its window closed before it can signal. + """ + window = session.new_window(window_name="wait_for_timeout") + pane = window.active_pane + assert pane is not None + + channel = "libtmux_never_signalled" + pane.send_keys(f"echo READY; tmux wait-for -S {channel}", enter=False) + + start = time.monotonic() + with pytest.raises(exc.WaitTimeout): + session.server.wait_for(channel, timeout=1) + elapsed = time.monotonic() - start + + assert elapsed < 30, "wait_for(timeout=) did not bound the wait" + + +def test_wait_for_returns_when_the_channel_is_signalled(session: Session) -> None: + """A bounded wait still resolves normally when the signal does arrive.""" + window = session.new_window(window_name="wait_for_signalled") + pane = window.active_pane + assert pane is not None + + channel = "libtmux_signalled" + pane.send_keys(f"tmux wait-for -S {channel}") + + session.server.wait_for(channel, timeout=60) + + +def test_wait_for_timeout_raises_a_libtmux_error(session: Session) -> None: + """The timeout surfaces as a libtmux error, never as a stdlib one. + + Callers write ``except LibTmuxException``; a :exc:`subprocess.TimeoutExpired` + escaping here would leak the implementation through the library boundary, + including through exception chaining. + """ + with pytest.raises(exc.TmuxCommandTimeout) as excinfo: + session.server.wait_for("libtmux_boundary_channel", timeout=0.5) + + assert isinstance(excinfo.value, exc.LibTmuxException) + assert not isinstance(excinfo.value, subprocess.TimeoutExpired) + assert not isinstance(excinfo.value.__cause__, subprocess.TimeoutExpired) + assert "timed out after 0.5s" in str(excinfo.value) + + +def test_wait_for_timeout_reports_what_it_killed(session: Session) -> None: + """The timeout carries the killed command and the bound it exceeded. + + Callers that catch it should not have to parse the message back apart to + learn which command was killed, and an existing ``except WaitTimeout`` + handler keeps working because the error is still one. + """ + with pytest.raises(exc.TmuxCommandTimeout) as excinfo: + session.server.wait_for("libtmux_reported_channel", timeout=0.5) + + assert isinstance(excinfo.value, exc.WaitTimeout) + assert excinfo.value.timeout == 0.5 + assert excinfo.value.cmd[-2:] == ["wait-for", "libtmux_reported_channel"] + + +def test_cmd_timeout_threads_through_the_object_hierarchy(session: Session) -> None: + """Every ``cmd()`` in the hierarchy honors ``timeout``. + + A foreground ``run-shell`` blocks the tmux client until the shell command + finishes, and unlike ``wait-for`` it accepts the ``-t`` target that + :meth:`Session.cmd`, :meth:`Window.cmd`, and :meth:`Pane.cmd` bind + automatically. + """ + window = session.new_window(window_name="cmd_timeout") + pane = window.active_pane + assert pane is not None + + for obj in (session.server, session, window, pane): + start = time.monotonic() + with pytest.raises(exc.WaitTimeout): + obj.cmd("run-shell", "sleep 10", timeout=0.5) + elapsed = time.monotonic() - start + + assert elapsed < 30, f"{type(obj).__name__}.cmd(timeout=) did not bound" + + +def test_cmd_without_timeout_still_returns(session: Session) -> None: + """Commands that finish on their own are untouched by the new parameter.""" + proc = session.server.cmd("display-message", "-p", "ok") + + assert proc.stdout == ["ok"] + assert proc.returncode == 0