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
26 changes: 26 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,32 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Breaking changes

#### `Server` context manager spares a server it did not start (#724)

Leaving a `with` block used to kill any live {class}`~libtmux.Server`, so
`with Server() as server:` around no work at all destroyed the default tmux
daemon and every session in it. A server is a handle on a socket rather than a
connection, so the block now kills only a daemon that was not running when the
block was entered.

A new keyword decides explicitly instead:
{attr}`~libtmux.Server.kill_on_exit` set to `True` restores the unconditional
kill, `False` never kills. {class}`~libtmux.Session`,
{class}`~libtmux.Window`, and {class}`~libtmux.Pane` are unchanged. See
{ref}`context_managers`.

```python
# Before: killed the server the reader was already working in
with Server(socket_name="my-work") as server:
pass

# After: pass kill_on_exit=True for that
with Server(socket_name="my-work", kill_on_exit=True) as server:
pass
```

### Documentation

#### Cleaner `from_env` examples (#719)
Expand Down
31 changes: 30 additions & 1 deletion docs/topics/context_managers.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ 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.
objects) support this. A server is the one exception to the unconditional
teardown: it is shared, so it is killed only when the block started it.

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
Expand Down Expand Up @@ -48,6 +49,34 @@ True
False
```

A {class}`~libtmux.Server` is a handle on a socket, not a connection, so the
same handle addresses a daemon whether or not your process started it — and the
default socket is usually the tmux you have been working in all day. Leaving the
block therefore kills only a daemon that was not running when the block was
entered:

```python
>>> already_running = Server()
>>> session = already_running.new_session(session_name='important-work')
>>> with Server(socket_name=already_running.socket_name) as scoped:
... print(scoped.is_alive())
True
>>> print([session.session_name for session in already_running.sessions])
['important-work']
```

Pass `kill_on_exit` to decide for yourself instead: `True` kills a live server
on the way out no matter who started it, `False` never kills one.

```python
>>> disposable = Server()
>>> session = disposable.new_session()
>>> with Server(socket_name=disposable.socket_name, kill_on_exit=True):
... pass
>>> print(disposable.is_alive())
False
```

## Session context manager

You create a temporary session that will be killed when you're done:
Expand Down
77 changes: 75 additions & 2 deletions src/libtmux/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ class Server(
on_init : callable, optional
socket_name_factory : callable, optional
tmux_bin : str or pathlib.Path, optional
kill_on_exit : bool, optional
Whether leaving a ``with`` block kills the server. See
:attr:`Server.kill_on_exit`.

.. versionadded:: 0.63

Examples
--------
Expand All @@ -126,7 +131,9 @@ class Server(
>>> server.sessions[0].active_pane
Pane(%1 Window(@1 1:..., Session($1 ...)))

The server can be used as a context manager to ensure proper cleanup:
The server can be used as a context manager to ensure proper cleanup. A
server the block started is killed on the way out; a server that was
already running is left alone (see ``Server.__exit__``):

>>> with Server() as server:
... session = server.new_session()
Expand Down Expand Up @@ -166,6 +173,12 @@ class Server(
"""For hook management."""
tmux_bin: str | None = None
"""Custom path to tmux binary. Falls back to ``shutil.which("tmux")``."""
kill_on_exit: bool | None = None
"""Whether leaving a ``with`` block kills the server.

``None`` (default) kills it only when the ``with`` block started it,
``True`` always kills a live server, ``False`` never kills.
"""

def __init__(
self,
Expand All @@ -176,12 +189,17 @@ 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,
kill_on_exit: bool | 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.kill_on_exit = kill_on_exit
self._windows: list[WindowDict] = []
self._panes: list[PaneDict] = []
# Assume the daemon predates this handle until ``__enter__`` observes a
# dead socket. Keeps a bare ``__exit__`` call non-destructive.
self._alive_on_enter: bool = True

if socket_path is not None:
self.socket_path = socket_path
Expand Down Expand Up @@ -260,11 +278,23 @@ def from_env(cls, env: t.Mapping[str, str] | None = None) -> Server:
def __enter__(self) -> Self:
"""Enter the context, returning self.

Records whether the daemon is already running, which is what
``Server.__exit__`` consults before killing anything. Costs one
``list-sessions`` call per block.

Returns
-------
:class:`Server`
The server instance

Examples
--------
>>> with Server() as scoped:
... _ = scoped.new_session()
... scoped.is_alive()
True
"""
self._alive_on_enter = self.is_alive()
return self

def __exit__(
Expand All @@ -273,7 +303,14 @@ def __exit__(
exc_value: BaseException | None,
exc_tb: types.TracebackType | None,
) -> None:
"""Exit the context, killing the server if it exists.
"""Exit the context, killing the server if the block started it.

A :class:`Server` is a handle on a socket, not a connection, so the
same handle addresses a daemon whether or not this process booted it.
Exiting therefore kills only a daemon that was not running when
``Server.__enter__`` ran -- a server the reader was already
working in, including the default socket, survives the block.
:attr:`Server.kill_on_exit` overrides that either way.

Parameters
----------
Expand All @@ -283,7 +320,43 @@ def __exit__(
The instance of the exception that was raised
exc_tb : types.TracebackType | None
The traceback of the exception that was raised

Examples
--------
A server the block started is gone afterwards:

>>> booted = Server()
>>> with booted:
... _ = booted.new_session()
>>> booted.is_alive()
False

A server that was already running is untouched, even when a second
handle on the same socket scopes it:

>>> running = Server()
>>> _ = running.new_session(session_name='important-work')
>>> with Server(socket_name=running.socket_name):
... pass
>>> [session.session_name for session in running.sessions]
['important-work']

``kill_on_exit=True`` restores the unconditional kill:

>>> with Server(socket_name=running.socket_name, kill_on_exit=True):
... pass
>>> running.is_alive()
False

.. versionchanged:: 0.63

Previously killed any live server, including one the block did not
start. Pass ``kill_on_exit=True`` for the old behaviour.
"""
if self.kill_on_exit is False:
return
if self.kill_on_exit is None and self._alive_on_enter:
return
if self.is_alive():
self.kill()

Expand Down
48 changes: 48 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,54 @@ def test_server_context_manager(TestServer: type[Server]) -> None:
assert not server.is_alive()


def test_server_context_manager_spares_pre_existing_server(
TestServer: type[Server],
) -> None:
"""A server the ``with`` block did not start survives the block."""
pre_existing = TestServer()
pre_existing.new_session(session_name="important-work")

with TestServer(socket_name=pre_existing.socket_name) as scoped:
assert scoped.is_alive()

assert pre_existing.is_alive()
assert [session.session_name for session in pre_existing.sessions] == [
"important-work",
]

pre_existing.kill()


def test_server_context_manager_kill_on_exit_true(
TestServer: type[Server],
) -> None:
"""``kill_on_exit=True`` kills a live server the block did not start."""
pre_existing = TestServer()
pre_existing.new_session(session_name="important-work")

with TestServer(
socket_name=pre_existing.socket_name,
kill_on_exit=True,
) as scoped:
assert scoped.is_alive()

assert not pre_existing.is_alive()


def test_server_context_manager_kill_on_exit_false(
TestServer: type[Server],
) -> None:
"""``kill_on_exit=False`` keeps a server the block itself started."""
with TestServer(kill_on_exit=False) as scoped:
scoped.new_session(session_name="survivor")
assert scoped.is_alive()

assert scoped.is_alive()
assert [session.session_name for session in scoped.sessions] == ["survivor"]

scoped.kill()


class StartDirectoryTestFixture(t.NamedTuple):
"""Test fixture for start_directory parameter testing."""

Expand Down
Loading