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

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
Expand Down
41 changes: 25 additions & 16 deletions src/libtmux/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2298,19 +2298,19 @@ def sessions(self) -> QueryList[Session]:
:meth:`.sessions.get() <libtmux._internal.query_list.QueryList.get()>` and
:meth:`.sessions.filter() <libtmux._internal.query_list.QueryList.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
Expand Down Expand Up @@ -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`
Expand All @@ -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(
Expand Down
37 changes: 18 additions & 19 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1322,24 +1322,24 @@ 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")

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(
Expand All @@ -1362,25 +1362,25 @@ 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")

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