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

### Fixes

#### Linked windows resolve to the session tmux would use (#713)

A window can be linked into several sessions at once, and libtmux used to pick
one of them by accident. It listed every object on the server and kept the last
row matching the id — a listing tmux orders by session *name*. So
{meth}`Window.from_window_id() <libtmux.Window.from_window_id>`,
{meth}`Pane.from_pane_id() <libtmux.Pane.from_pane_id>` and both `refresh()`
methods answered with whichever session happened to sort last, and the answer
changed if you renamed a session.

They now name the object with tmux's `-t` target and let tmux resolve it, so
libtmux's answer is the one `tmux display-message -t` gives for the same id. For
a window in a single session — the ordinary case — nothing changes. For a linked
window the answer is now tmux's own, which follows activity rather than
alphabetical order. The lookups also got cheaper: they list one window's panes or
one session's windows instead of scanning the whole server.

## libtmux 0.61.0 (2026-07-04)

libtmux 0.61.0 hardens support for the tmux 3.7 patch line. It fixes
Expand Down
132 changes: 124 additions & 8 deletions src/libtmux/neo.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,19 +776,137 @@ def fetch_objs(
return outputs


def _is_target_not_found_error(stderr_text: str) -> bool:
"""Return True if tmux failed because the ``-t`` target does not exist.

A live tmux server rejects an unknown target with ``can't find <kind>:
<target>`` on stderr (``cmd_find_target`` in tmux's ``cmd-find.c``), for
every object kind and every supported tmux version. Every *other* failure
-- a stopped daemon, a missing socket, a permission error -- says something
else, and stays a :exc:`~libtmux.exc.LibTmuxException`.

This is the mirror image of :func:`libtmux.server._is_daemon_not_up_error`:
together they answer "is the object gone, or is the server gone?" from the
same stderr text.

Parameters
----------
stderr_text : str
tmux's stderr, as carried by the raised
:exc:`~libtmux.exc.LibTmuxException`.

Returns
-------
bool
True when the object named by ``-t`` does not exist on a reachable
server.

Examples
--------
>>> from libtmux.neo import _is_target_not_found_error
>>> _is_target_not_found_error("can't find pane: %99")
True
>>> _is_target_not_found_error("can't find window: @99")
True
>>> _is_target_not_found_error("can't find session: $99")
True

A server that isn't there is a different answer:

>>> _is_target_not_found_error("no server running on /tmp/tmux-1000/default")
False
>>> _is_target_not_found_error(
... "error connecting to /tmp/tmux-1000/nope (No such file or directory)"
... )
False
"""
return "can't find " in stderr_text


def fetch_obj(
server: Server,
obj_key: str,
obj_id: str,
list_cmd: ListCmd = "list-panes",
list_extra_args: ListExtraArgs = None,
) -> OutputRaw:
"""Fetch raw data from tmux command."""
obj_formatters_filtered = fetch_objs(
server=server,
list_cmd=list_cmd,
list_extra_args=list_extra_args,
)
"""Fetch the single ``list-*`` row whose *obj_key* equals *obj_id*.

Parameters
----------
server : :class:`~libtmux.server.Server`
The tmux server to query.
obj_key : str
Identity field to match, e.g. ``"pane_id"``.
obj_id : str
Value the identity field must equal, e.g. ``"%3"``.
list_cmd : ListCmd
tmux list subcommand to run.
list_extra_args : ListExtraArgs, optional
Extra arguments appended verbatim to the tmux command, e.g.
``("-t", "%3")`` to scope the listing to one object's parent.

Returns
-------
OutputRaw
The matching row, as a dict of tmux format fields.

Raises
------
:exc:`~libtmux.exc.TmuxObjectDoesNotExist`
When the object does not exist -- whether tmux said so on stderr
(``can't find pane: %99``, for a ``-t``-scoped listing) or the object
simply never appeared in the rows.
:exc:`~libtmux.exc.LibTmuxException`
For every other tmux failure, notably an unreachable server.

Examples
--------
>>> from libtmux.neo import fetch_obj
>>> fetch_obj(
... server=pane.server,
... obj_key="pane_id",
... obj_id=pane.pane_id,
... list_cmd="list-panes",
... list_extra_args=("-t", pane.pane_id),
... )["pane_id"] == pane.pane_id
True

A pane that does not exist on a live server is a
:exc:`~libtmux.exc.TmuxObjectDoesNotExist`, not a bare tmux error:

>>> from libtmux import exc
>>> try:
... fetch_obj(
... server=pane.server,
... obj_key="pane_id",
... obj_id="%99999",
... list_cmd="list-panes",
... list_extra_args=("-t", "%99999"),
... )
... except exc.TmuxObjectDoesNotExist as e:
... print(e)
Could not find pane_id=%99999 for list-panes ('-t', '%99999')
"""
try:
obj_formatters_filtered = fetch_objs(
server=server,
list_cmd=list_cmd,
list_extra_args=list_extra_args,
)
except exc.LibTmuxException as e:
# A ``-t``-scoped listing pushes the "does it exist?" question down
# into tmux, which answers on stderr rather than with an empty listing.
# Re-raise those as the same TmuxObjectDoesNotExist an unscoped listing
# would have produced; anything else (a dead server) keeps propagating.
if not _is_target_not_found_error(str(e)):
raise
raise exc.TmuxObjectDoesNotExist(
obj_key=obj_key,
obj_id=obj_id,
list_cmd=list_cmd,
list_extra_args=list_extra_args,
) from e

obj = None
for _obj in obj_formatters_filtered:
Expand All @@ -803,6 +921,4 @@ def fetch_obj(
list_extra_args=list_extra_args,
)

assert obj is not None

return obj
52 changes: 49 additions & 3 deletions src/libtmux/pane.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,30 +133,76 @@ def __exit__(
def refresh(self) -> None:
"""Refresh pane attributes from tmux.

Scoped to this pane's own window (``list-panes -t %ID``), so tmux --
not libtmux -- decides which session the pane belongs to. See
:meth:`Pane.from_pane_id`.

Raises
------
ValueError
When ``pane_id`` is unset. Surfaces a clear error under
``python -O``, where an ``assert`` would be stripped.
:exc:`~libtmux.exc.TmuxObjectDoesNotExist`
When the pane no longer exists.
:exc:`~libtmux.exc.LibTmuxException`
When tmux itself is unreachable.

Examples
--------
>>> pane.refresh()
>>> pane.pane_id
'%1'
"""
if self.pane_id is None:
msg = "Pane must have a pane_id to refresh"
raise ValueError(msg)
return super()._refresh(
obj_key="pane_id",
obj_id=self.pane_id,
list_extra_args=("-a",),
list_extra_args=("-t", self.pane_id),
)

@classmethod
def from_pane_id(cls, server: Server, pane_id: str) -> Pane:
"""Create Pane from existing pane_id."""
"""Create Pane from existing pane_id.

tmux's ``cmd_find`` routes a ``-t`` target by *sigil*, so a ``%``-id
handed to ``list-panes`` resolves to that pane's window and lists it.
Every row comes back stamped with the session tmux considers canonical
for that window -- the most recently active one -- which is the same
session ``display-message -t %ID '#{session_id}'`` reports. A window
linked into several sessions therefore gets one authoritative answer
instead of "whichever session sorted last".

Parameters
----------
server : :class:`~libtmux.server.Server`
The tmux server holding the pane.
pane_id : str
Pane id, e.g. ``"%3"``.

Returns
-------
:class:`Pane`

Raises
------
:exc:`~libtmux.exc.TmuxObjectDoesNotExist`
When no such pane exists on a reachable server.
:exc:`~libtmux.exc.LibTmuxException`
When tmux itself is unreachable.

Examples
--------
>>> Pane.from_pane_id(server=pane.server, pane_id=pane.pane_id)
Pane(%1 Window(@1 1:..., Session($1 ...)))
"""
pane = fetch_obj(
obj_key="pane_id",
obj_id=pane_id,
server=server,
list_cmd="list-panes",
list_extra_args=("-a",),
list_extra_args=("-t", pane_id),
)
return cls(server=server, **pane)

Expand Down
50 changes: 47 additions & 3 deletions src/libtmux/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,25 @@ def __exit__(
def refresh(self) -> None:
"""Refresh window attributes from tmux.

Scoped to this window's own session (``list-windows -t @ID``), so tmux
-- not libtmux -- decides which session the window belongs to. See
:meth:`Window.from_window_id`.

Raises
------
ValueError
When ``window_id`` is unset. Surfaces a clear error under
``python -O``, where an ``assert`` would be stripped.
:exc:`~libtmux.exc.TmuxObjectDoesNotExist`
When the window no longer exists.
:exc:`~libtmux.exc.LibTmuxException`
When tmux itself is unreachable.

Examples
--------
>>> window.refresh()
>>> window.window_id
'@1'
"""
if self.window_id is None:
msg = "Window must have a window_id to refresh"
Expand All @@ -163,18 +177,48 @@ def refresh(self) -> None:
obj_key="window_id",
obj_id=self.window_id,
list_cmd="list-windows",
list_extra_args=("-a",),
list_extra_args=("-t", self.window_id),
)

@classmethod
def from_window_id(cls, server: Server, window_id: str) -> Window:
"""Create Window from existing window_id."""
"""Create Window from existing window_id.

tmux's ``cmd_find`` routes a ``-t`` target by *sigil*, so an ``@``-id
handed to ``list-windows`` resolves to that window's session and lists
it. The rows come back stamped with the session tmux considers
canonical for the window -- the most recently active one -- so a window
linked into several sessions reports the same parent tmux itself would.

Parameters
----------
server : :class:`~libtmux.server.Server`
The tmux server holding the window.
window_id : str
Window id, e.g. ``"@3"``.

Returns
-------
:class:`Window`

Raises
------
:exc:`~libtmux.exc.TmuxObjectDoesNotExist`
When no such window exists on a reachable server.
:exc:`~libtmux.exc.LibTmuxException`
When tmux itself is unreachable.

Examples
--------
>>> Window.from_window_id(server=window.server, window_id=window.window_id)
Window(@1 1:..., Session($1 ...))
"""
window = fetch_obj(
obj_key="window_id",
obj_id=window_id,
server=server,
list_cmd="list-windows",
list_extra_args=("-a",),
list_extra_args=("-t", window_id),
)
return cls(server=server, **window)

Expand Down
Loading
Loading