diff --git a/CHANGES b/CHANGES index 7a691b0cc..82b0eca64 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,31 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### 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) diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md index d42b16b8b..48050139f 100644 --- a/docs/topics/context_managers.md +++ b/docs/topics/context_managers.md @@ -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 @@ -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: @@ -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: @@ -105,7 +127,6 @@ 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: @@ -113,20 +134,22 @@ 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 diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c3..ffa1ee5d8 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -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 -------- @@ -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 ---------- @@ -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, @@ -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] = [] @@ -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 @@ -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 ---------- @@ -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: diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9a..1e08b7cfd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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):