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 -->

### Breaking changes

#### Scoping a `Server` no longer kills it (#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: the same handle addresses a daemon whether or not your process
started it, so scoping one to a block is not grounds to destroy it.

Teardown is now something you ask for, with the new
{attr}`~libtmux.Server.kill_on_exit`. {class}`~libtmux.Session`,
{class}`~libtmux.Window` and {class}`~libtmux.Pane` are unchanged — those you
get by creating them. See {ref}`context_managers`.

```python
# Before: killed whatever daemon was on that socket
with Server(socket_name="my-work") as server:
pass

# After: nothing is killed unless you say so
with Server(socket_name="my-work", kill_on_exit=True) as server:
pass
```

### Documentation

#### Cleaner `from_env` examples (#719)
Expand Down
63 changes: 43 additions & 20 deletions docs/topics/context_managers.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ 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.
stack. {class}`~libtmux.Session`, {class}`~libtmux.Window`, and
{class}`~libtmux.Pane` work this way.

{class}`~libtmux.Server` is the exception, and deliberately so: it does not
kill itself on the way out unless you ask it to. A server is shared, and a
handle on one says nothing about who 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 @@ -37,17 +40,36 @@ Import `libtmux`:

## Server context manager

You create a temporary server that will be killed when you're done:
A {class}`~libtmux.Server` is a handle on a socket, not a connection. The same
handle addresses a daemon whether or not your process started it, and a bare
`Server()` addresses the default socket — usually the tmux you have been
working in all day. Scoping one to a block is not grounds to destroy it, so
leaving the block changes nothing:

```python
>>> with Server() as server:
... session = server.new_session()
... print(server.is_alive())
>>> 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(server.is_alive()) # Server is killed after exiting context
>>> print([session.session_name for session in already_running.sessions])
['important-work']
```

When teardown *is* what you want, say so with `kill_on_exit`:

```python
>>> with Server(socket_name='libtmux_doctest_ctx', kill_on_exit=True) as disposable:
... _ = disposable.new_session()
>>> print(disposable.is_alive())
False
```

That is the one asymmetry in this page. A session, window or pane is yours by
construction — you called {meth}`~libtmux.Server.new_session` or
{meth}`~libtmux.Window.split` to get it. A server was very likely already
there.

## Session context manager

You create a temporary session that will be killed when you're done:
Expand Down Expand Up @@ -96,7 +118,7 @@ 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:
once and have the layers you created torn down for you:

```python
>>> with Server() as server:
Expand All @@ -105,28 +127,29 @@ once and have every layer torn down for you:
... 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
4. The server is left running, unless it was built with `kill_on_exit=True`

The cleanup happens in reverse order (pane → window → session → server), ensuring proper resource management.
The pane, window and session tear down in reverse order (pane → window →
session), which keeps tmux's own bookkeeping consistent.

## 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.
Reaching for a context manager buys you a few things. Sessions, windows and
panes clean themselves up the moment you leave the block, so you never manually
call {meth}`~libtmux.Session.kill`, {meth}`~libtmux.Window.kill`, or
{meth}`~libtmux.Pane.kill` 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. A server is the exception: call
{meth}`~libtmux.Server.kill` yourself, or ask for it with `kill_on_exit`.

## When to use

Expand Down
78 changes: 71 additions & 7 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. Off by default.
See :attr:`Server.kill_on_exit`.

.. versionadded:: 0.63

Examples
--------
Expand All @@ -126,12 +131,17 @@ 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:
A server can be used as a context manager to scope a block, but unlike
:class:`~libtmux.Session`, :class:`~libtmux.Window` and
:class:`~libtmux.Pane`, **leaving the block does not kill it**. A server is
a handle on a socket, and holding one says nothing about who started the
daemon behind it — often it is the tmux the reader has been working in all
day. Ask for teardown when you want it:

>>> with Server() as server:
... session = server.new_session()
... # Do work with the session
... # Server will be killed automatically when exiting the context
>>> with Server(socket_name="libtmux_doctest_scoped", kill_on_exit=True) as scoped:
... _ = scoped.new_session()
>>> scoped.is_alive()
False

References
----------
Expand Down Expand Up @@ -166,6 +176,13 @@ 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 = False
"""Whether leaving a ``with`` block kills the server.

Off by default. A :class:`Server` is a handle on a socket, not a
connection, so the same handle addresses a daemon whether or not this
process started it — scoping one is not grounds to destroy it.
"""

def __init__(
self,
Expand All @@ -176,10 +193,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,
kill_on_exit: bool = False,
**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] = []

Expand Down Expand Up @@ -260,10 +279,21 @@ def from_env(cls, env: t.Mapping[str, str] | None = None) -> Server:
def __enter__(self) -> Self:
"""Enter the context, returning self.

Costs nothing and probes nothing: whether the block may destroy the
daemon is decided by :attr:`Server.kill_on_exit`, which the caller set
before entering.

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

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

Expand All @@ -273,7 +303,17 @@ 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 only if asked to.

A :class:`Server` is a handle on a socket, not a connection, so the
same handle addresses a daemon whether or not this process started it.
Scoping one to a block is therefore not grounds to destroy it, and the
default socket is usually the tmux the reader has been working in all
day. Set :attr:`Server.kill_on_exit` when teardown is what you want.

This is why a server differs from :class:`~libtmux.Session`,
:class:`~libtmux.Window` and :class:`~libtmux.Pane`, which do tear
themselves down.

Parameters
----------
Expand All @@ -283,8 +323,32 @@ 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 survives the block, even one the block started:

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

``kill_on_exit=True`` asks for teardown:

>>> disposable = Server(kill_on_exit=True)
>>> with disposable:
... _ = disposable.new_session()
>>> disposable.is_alive()
False

.. versionchanged:: 0.63

Previously killed any live server on the way out, including one the
block did not start. Pass ``kill_on_exit=True`` for that.
"""
if self.is_alive():
if self.kill_on_exit and self.is_alive():
self.kill()

def is_alive(self) -> bool:
Expand Down
80 changes: 77 additions & 3 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,15 +354,89 @@ def socket_name_factory() -> str:


def test_server_context_manager(TestServer: type[Server]) -> None:
"""Test Server context manager functionality."""
"""Leaving the block does not kill the server, even one it started."""
with TestServer() as server:
session = server.new_session()
assert server.is_alive()
assert len(server.sessions) == 1
assert session in server.sessions

# Server should be killed after exiting context
assert not server.is_alive()
assert server.is_alive()
assert [s.session_name for s in server.sessions] == [session.session_name]

server.kill()


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_spares_the_default_socket(
monkeypatch: pytest.MonkeyPatch,
tmp_path: pathlib.Path,
) -> None:
"""A bare ``Server()`` leaves the default socket's daemon alone.

The shape of the original report: no socket name, no socket path, so the
handle addresses whichever tmux the caller already has open. Every other
test here goes through the ``TestServer`` factory, which mints a private
socket and therefore cannot reproduce it.
"""
monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))

already_running = Server()
already_running.new_session(session_name="important-work")

with Server() as scoped:
assert scoped.is_alive()

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

already_running.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_true_when_block_started_it(
TestServer: type[Server],
) -> None:
"""``kill_on_exit=True`` tears down a server the block itself started."""
with TestServer(kill_on_exit=True) as scoped:
scoped.new_session(session_name="disposable")
assert scoped.is_alive()

assert not scoped.is_alive()


class StartDirectoryTestFixture(t.NamedTuple):
Expand Down
Loading