From 267893512e830decdb7f653068636e39b8576e37 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 18 May 2026 17:19:45 -0500 Subject: [PATCH 1/4] Server(fix[sessions,clients]): Return empty on tmux errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: A change in 0.57.0 made Server.sessions and Server.clients propagate LibTmuxException on any tmux failure (socket permission errors, subprocess crashes, malformed output). That broke the contract Server.sessions had carried since 0.17.0 and the one Server.clients was born with: a list-shaped accessor returns an empty QueryList when tmux cannot answer, and callers reach for Server.is_alive() / Server.raise_if_dead() when they need an explicit connectivity signal. Restore that contract for both accessors. Server.search_sessions, Server.search_windows, and Server.search_panes still raise; they take a caller-supplied filter, so a tmux failure there is signal, not noise. what: - Wrap Server.sessions and Server.clients bodies in try/except LibTmuxException → QueryList([]). Both now call fetch_objs directly instead of routing through _fetch_or_empty — the broader try/except already absorbs the daemon-not-up case the helper exists for. - Drop the Raises clause on Server.sessions; add prose pointing at is_alive/raise_if_dead for connectivity checks. --- src/libtmux/server.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 9cc40e034..30ec0d699 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -2298,19 +2298,19 @@ def sessions(self) -> QueryList[Session]: :meth:`.sessions.get() ` and :meth:`.sessions.filter() ` - Raises - ------ - :exc:`~libtmux.exc.LibTmuxException` - When tmux's ``list-sessions`` fails for a reason other than - a not-yet-started server, such as socket permission errors or - unsupported tmux flags. A server with no sessions, or a server - before its daemon has started, returns an empty - :class:`~libtmux._internal.query_list.QueryList`. + Returns an empty :class:`~libtmux._internal.query_list.QueryList` when + tmux's ``list-sessions`` fails for any reason — no running daemon, a + missing socket, a permission error, or a subprocess failure. To + distinguish "no sessions" from "tmux unreachable", call + :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. """ - sessions: list[Session] = [ - Session(server=self, **obj) - for obj in _fetch_or_empty(server=self, list_cmd="list-sessions") - ] + try: + sessions: list[Session] = [ + Session(server=self, **obj) + for obj in fetch_objs(server=self, list_cmd="list-sessions") + ] + except exc.LibTmuxException: + return QueryList([]) return QueryList(sessions) @property @@ -2359,6 +2359,12 @@ def clients(self) -> QueryList[Client]: returns the typed view; ``client.client_readonly``, ``client.client_termtype``, ``client.client_session`` etc. read tmux's ``client_*`` format tokens. + Returns an empty :class:`~libtmux._internal.query_list.QueryList` when + tmux's ``list-clients`` fails for any reason — no running daemon, a + missing socket, a permission error, or a subprocess failure. To + distinguish "no clients attached" from "tmux unreachable", call + :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. + Returns ------- :class:`~libtmux._internal.query_list.QueryList` of :class:`Client` @@ -2370,10 +2376,13 @@ def clients(self) -> QueryList[Client]: ... ctl.client_name in names True """ - clients: list[Client] = [ - Client(server=self, **obj) - for obj in _fetch_or_empty(server=self, list_cmd="list-clients") - ] + try: + clients: list[Client] = [ + Client(server=self, **obj) + for obj in fetch_objs(server=self, list_cmd="list-clients") + ] + except exc.LibTmuxException: + return QueryList([]) return QueryList(clients) def search_sessions( From d33ae37fea04cd96d460fe24836c4d424825020a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 18 May 2026 17:19:52 -0500 Subject: [PATCH 2/4] tests(test_server[sessions,clients]): Pin empty-on-tmux-error contract why: Lock in the lenient-by-default contract restored in the preceding commit. A tmux failure under Server.sessions or Server.clients yields an empty QueryList; connectivity checks go through Server.is_alive() / Server.raise_if_dead(). what: - Replace test_server_sessions_propagates_errors, test_server_clients_propagates_errors, and test_server_sessions_permission_error_propagates with test_server_sessions_returns_empty_on_tmux_error, test_server_clients_returns_empty_on_tmux_error, and test_server_sessions_permission_error_returns_empty. - Leave the daemon-not-up and missing-socket tests unchanged. --- tests/test_server.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 6f2e8df6d..01091d322 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1322,15 +1322,16 @@ def test_server_search_panes_filter_by_id(server: Server) -> None: assert [p.pane_id for p in matches] == [target.pane_id] -def test_server_clients_propagates_errors( +def test_server_clients_returns_empty_on_tmux_error( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """``Server.clients`` re-raises tmux errors instead of swallowing them. + """``Server.clients`` returns an empty QueryList on tmux failure. - The wrapper used to ``except Exception: pass`` and return an empty - QueryList on any failure, masking real tmux errors as "no clients". - A genuine failure should surface so callers can react. + Lenient-by-default contract: ``list-clients`` failing for any reason + yields ``QueryList([])``, matching the historic shape of + :attr:`Server.sessions`. Callers needing a connectivity check should + use :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. """ sentinel = exc.LibTmuxException("simulated list-clients failure") @@ -1338,8 +1339,7 @@ def _boom(**_: object) -> list[dict[str, str]]: raise sentinel monkeypatch.setattr("libtmux.server.fetch_objs", _boom) - with pytest.raises(exc.LibTmuxException, match="simulated list-clients failure"): - server.clients # noqa: B018 + assert list(server.clients) == [] def test_server_search_sessions_propagates_errors( @@ -1362,16 +1362,17 @@ def _boom(**_: object) -> list[dict[str, str]]: server.search_sessions(filter="#{m:keep_*,#{session_name}}") -def test_server_sessions_propagates_errors( +def test_server_sessions_returns_empty_on_tmux_error( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """``Server.sessions`` re-raises tmux errors instead of swallowing them. + """``Server.sessions`` returns an empty QueryList on tmux failure. - Closes the gap left when the clients/search_sessions accessors moved - off the legacy ``except Exception: pass`` shape but ``Server.sessions`` - stayed behind. A genuine list-sessions failure should surface the - same way for all three accessors. + Pins the lenient-by-default contract: a ``list-sessions`` failure — + daemon down, missing socket, permission error, subprocess crash — + yields ``QueryList([])`` rather than propagating. Callers that need + to distinguish "no sessions" from "tmux unreachable" should use + :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. """ sentinel = exc.LibTmuxException("simulated list-sessions failure") @@ -1379,8 +1380,7 @@ def _boom(**_: object) -> list[dict[str, str]]: raise sentinel monkeypatch.setattr("libtmux.server.fetch_objs", _boom) - with pytest.raises(exc.LibTmuxException, match="simulated list-sessions failure"): - server.sessions # noqa: B018 + assert list(server.sessions) == [] def test_server_sessions_missing_socket_returns_empty(tmp_path: pathlib.Path) -> None: @@ -1390,11 +1390,11 @@ def test_server_sessions_missing_socket_returns_empty(tmp_path: pathlib.Path) -> assert list(missing_server.sessions) == [] -def test_server_sessions_permission_error_propagates( +def test_server_sessions_permission_error_returns_empty( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Connection errors other than missing-daemon states still raise.""" + """Connection errors are absorbed into the empty-list contract too.""" sentinel = exc.LibTmuxException( "error connecting to /root/libtmux-review.sock (Permission denied)" ) @@ -1403,8 +1403,7 @@ def _boom(**_: object) -> list[dict[str, str]]: raise sentinel monkeypatch.setattr("libtmux.server.fetch_objs", _boom) - with pytest.raises(exc.LibTmuxException, match="Permission denied"): - server.sessions # noqa: B018 + assert list(server.sessions) == [] def test_if_shell_true(server: Server) -> None: From 5b068d85f9da2c3c15fc0b22bd7a26584f92118f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 18 May 2026 17:19:58 -0500 Subject: [PATCH 3/4] docs(CHANGES): Note Server.sessions/clients lenient-by-default restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Surface the revert in the unreleased section so the user-facing release notes reflect the restored contract when the next version ships. what: - Add a "Lenient Server.sessions and Server.clients accessors" deliverable under the unreleased entry placeholder. - Add a "Stricter search_* methods" deliverable noting that the newer search_* accessors continue to raise. - Leave the 0.57.0 entry intact — it accurately records what 0.57.0 shipped. --- CHANGES | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHANGES b/CHANGES index d15d14ea6..848bd0488 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,37 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +Restores the "lenient-by-default" behavior for +{attr}`~libtmux.Server.sessions` and {attr}`~libtmux.Server.clients` that +was changed in 0.57.0. + +### Behavioral Changes + +#### Lenient `Server.sessions` and `Server.clients` accessors + +{attr}`~libtmux.Server.sessions` and {attr}`~libtmux.Server.clients` once +again return an empty {class}`~libtmux._internal.query_list.QueryList` +when tmux fails for any reason (permission errors, subprocess crashes, +etc.). + +This reverts a change in 0.57.0 that made these accessors raise +{exc}`~libtmux.exc.LibTmuxException`. The restored behavior matches the +contract {attr}`~libtmux.Server.sessions` has followed since 0.17.0. + +- **Upgrade path:** Code that added ``try/except LibTmuxException`` blocks around + these accessors for 0.57.0 can now remove them. +- **Connectivity checks:** If you need to distinguish between "no results" and + "tmux is unreachable", use the explicit {meth}`~libtmux.Server.is_alive` or + {meth}`~libtmux.Server.raise_if_dead` methods. + +#### Stricter `search_*` methods + +The newer {meth}`~libtmux.Server.search_sessions`, +{meth}`~libtmux.Server.search_windows`, and +{meth}`~libtmux.Server.search_panes` continue to raise on tmux errors. +Since these methods take a caller-supplied filter, a tmux failure is +considered a meaningful error signal that should not be swallowed. + ## libtmux 0.57.0 (2026-05-17) libtmux 0.57.0 broadens tmux support around attached clients, tmux-native From 874108f75e0a012a79aeb98f9361b1b93062c70f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 18 May 2026 17:20:05 -0500 Subject: [PATCH 4/4] ai(rules[AGENTS]): Document empty-by-default for list accessors why: 0.57.0's brief detour into raise-on-tmux-error for Server.sessions and Server.clients (reverted in the preceding commits) showed the convention isn't written down anywhere. Make it explicit so future list-returning accessors don't drift toward loud-failure by default. what: - Add a "List-returning accessors: empty by default on tmux errors" subsection under tmux-Specific Considerations. States the contract for Server.sessions / Server.clients / Server.attached_sessions, points at Server.is_alive() / Server.raise_if_dead() as the explicit-check primitives, and asks any future loud-failure mode to be a scoped opt-in instead of a baked-in raise on a new accessor. --- AGENTS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index de323f3ea..c94a20de9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -561,6 +561,23 @@ libtmux uses tmux's format system extensively: - Use refresh methods (e.g., `session.refresh()`) to update object state - Alternative: use `neo.py` query interface for fresh data +### List-returning accessors: empty by default on tmux errors + +`Server.sessions`, `Server.clients`, and `Server.attached_sessions` +return an empty `QueryList` when tmux's underlying list command fails +for any reason — no running daemon, a missing socket, a permission +error, a subprocess crash. This is a deliberate API contract: +list-shaped accessors are lenient by default. Callers that need to +distinguish "no rows" from "tmux unreachable" use the explicit +`Server.is_alive()` or `Server.raise_if_dead()` primitives. + +When adding a new list-returning accessor, follow this convention. If +a future feature genuinely benefits from loud-failure semantics, expose +it as a scoped opt-in (e.g. a `Server.raise_server_errors()` context +manager) rather than changing the default contract of an existing +accessor or hard-coding raise-on-tmux-error into a new one. +Empty-on-tmux-error stays the default; raise is opt-in. + ## References - Documentation: https://libtmux.git-pull.com/