Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,31 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### What's new

#### Bounded tmux commands (#735)

{meth}`Server.wait_for() <libtmux.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() <libtmux.Server.cmd>`,
{meth}`Session.cmd() <libtmux.Session.cmd>`,
{meth}`Window.cmd() <libtmux.Window.cmd>`,
{meth}`Pane.cmd() <libtmux.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)
Expand Down
64 changes: 62 additions & 2 deletions docs/topics/automation_patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
108 changes: 106 additions & 2 deletions src/libtmux/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions src/libtmux/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import shlex
import typing as t

if t.TYPE_CHECKING:
Expand Down Expand Up @@ -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() <libtmux.Server.cmd>`,
:meth:`Session.cmd() <libtmux.Session.cmd>`,
:meth:`Window.cmd() <libtmux.Window.cmd>`, and
:meth:`Pane.cmd() <libtmux.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."""

Expand Down
19 changes: 18 additions & 1 deletion src/libtmux/pane.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)
Expand Down
Loading
Loading