Bug
A window shared between sessions is emitted once per holding session by tmux list-panes -a / list-windows -a. Server.panes and Server.windows list that way, so a shared window's pane appears in the QueryList more than once — and QueryList.get() raises MultipleObjectsReturned on a point lookup by id.
MultipleObjectsReturned extends bare Exception, not LibTmuxException, so except LibTmuxException does not catch it. Neither does default=.
Sharing a window is ordinary usage. tmux new-session -t <existing> creates a grouped session — the mechanism behind tmuxp's session groups — and link-window does the same explicitly.
Reproduction
import libtmux
server = libtmux.Server(socket_name="probe")
server.new_session(session_name="alpha")
server.cmd("new-session", "-d", "-t", "alpha", "-s", "grouped") # ordinary grouped session
server.panes.get(pane_id="%0", default=None)
tmux list-panes -a -F '#{pane_id} #{session_name}'
%0 alpha
%0 grouped
server.panes.get(pane_id="%0", default=None) -> MultipleObjectsReturned (message is empty)
server.windows.get(window_id="@0", default=None) -> MultipleObjectsReturned (message is empty)
isinstance(err, libtmux.exc.LibTmuxException) -> False
len(server.panes) -> 2 (one pane, counted twice)
Root cause
tmux emits one row per winlink, not one row per window. A window_id names a window; a row in list-windows / list-panes -a names the winlink — the (session, index, window) edge — and one window can own several. See window.c#L87-L90 (winlink_cmp) and session.c#L41-L44 (session_cmp).
libtmux's server-wide accessors list with -a. server.py#L2377-L2389 (Server.windows) and server.py#L2396-L2408 (Server.panes). That is correct for a server-wide listing, but it means the QueryList holds one entry per winlink.
QueryList.get() raises before it consults default. query_list.py#L555-L574:
objs = self.filter(matcher=matcher, **kwargs)
if len(objs) > 1:
raise MultipleObjectsReturned # <- default= is never reached
if len(objs) == 0:
if default == no_arg:
raise ObjectDoesNotExist
return default
return objs[0]
The exception is outside the libtmux hierarchy. query_list.py#L36 — class MultipleObjectsReturned(Exception). It is raised with no arguments, so str(err) is empty. A caller that wraps libtmux in except LibTmuxException gets an uncaught, unlabelled crash out of a public accessor.
Impact
Any caller doing a point lookup through the server-wide accessors, against any grouped session or linked window:
server.panes.get(pane_id=...) and server.windows.get(window_id=...) raise.
len(server.panes) / len(server.windows) overcount.
- Iterating
server.panes yields the same pane several times.
Downstream: tmux-python/libtmux-mcp#97, where both central resolvers go through server.panes.get() / server.windows.get(), so every pane- and window-targeted MCP tool dies with an opaque Internal error against a grouped session.
Suggested fix
Two independent parts.
1. Make the point lookups resolve through tmux. Pane.from_pane_id() / Window.from_window_id() name the object with -t and let tmux's cmd_find collapse the edge, returning exactly one row (#713). Callers who want "the pane with this id" should use those, not server.panes.get(pane_id=...). This is a documentation and guidance change as much as a code one — the .get() path is the obvious one to reach for, and it is the broken one.
2. Decide what the server-wide accessors owe the caller. Server.panes / Server.windows keep -a by design, so the duplicates are inherent — they are real winlinks, not a bug in the listing. The open questions are whether MultipleObjectsReturned should sit inside the LibTmuxException hierarchy so it is catchable alongside every other libtmux failure, whether it should carry a message naming the id and the matches, and whether the duplicate-row behaviour should be documented on the accessors.
Note that deduplicating the -a rows in Python is not a fix: choosing which row survives means inventing a tie-break, and tmux already owns that rule (cmd_find_best_session_with_window, cmd-find.c#L176-L202). That is exactly why the correct answer is -t.
Related
- #710 —
Pane.session / Window.session return an arbitrary session for a linked window (the sibling defect in attribution; this one is in arity)
- #713 — routes point lookups through
-t; makes from_pane_id / from_window_id the correct migration target, but does not change Server.panes / Server.windows
- tmux-python/libtmux-mcp#97 — the downstream blast radius
- #717 — the same winlink-vs-window modelling gap, showing up as a wrong
window_index instead of a wrong arity
Environment
- libtmux: 0.61.0
- tmux: 3.7b
- Python: 3.13
Bug
A window shared between sessions is emitted once per holding session by
tmux list-panes -a/list-windows -a.Server.panesandServer.windowslist that way, so a shared window's pane appears in theQueryListmore than once — andQueryList.get()raisesMultipleObjectsReturnedon a point lookup by id.MultipleObjectsReturnedextends bareException, notLibTmuxException, soexcept LibTmuxExceptiondoes not catch it. Neither doesdefault=.Sharing a window is ordinary usage.
tmux new-session -t <existing>creates a grouped session — the mechanism behind tmuxp's session groups — andlink-windowdoes the same explicitly.Reproduction
Root cause
tmux emits one row per winlink, not one row per window. A
window_idnames a window; a row inlist-windows/list-panes -anames the winlink — the (session, index, window) edge — and one window can own several. Seewindow.c#L87-L90(winlink_cmp) andsession.c#L41-L44(session_cmp).libtmux's server-wide accessors list with
-a.server.py#L2377-L2389(Server.windows) andserver.py#L2396-L2408(Server.panes). That is correct for a server-wide listing, but it means theQueryListholds one entry per winlink.QueryList.get()raises before it consultsdefault.query_list.py#L555-L574:The exception is outside the libtmux hierarchy.
query_list.py#L36—class MultipleObjectsReturned(Exception). It is raised with no arguments, sostr(err)is empty. A caller that wraps libtmux inexcept LibTmuxExceptiongets an uncaught, unlabelled crash out of a public accessor.Impact
Any caller doing a point lookup through the server-wide accessors, against any grouped session or linked window:
server.panes.get(pane_id=...)andserver.windows.get(window_id=...)raise.len(server.panes)/len(server.windows)overcount.server.panesyields the same pane several times.Downstream: tmux-python/libtmux-mcp#97, where both central resolvers go through
server.panes.get()/server.windows.get(), so every pane- and window-targeted MCP tool dies with an opaqueInternal erroragainst a grouped session.Suggested fix
Two independent parts.
1. Make the point lookups resolve through tmux.
Pane.from_pane_id()/Window.from_window_id()name the object with-tand let tmux'scmd_findcollapse the edge, returning exactly one row (#713). Callers who want "the pane with this id" should use those, notserver.panes.get(pane_id=...). This is a documentation and guidance change as much as a code one — the.get()path is the obvious one to reach for, and it is the broken one.2. Decide what the server-wide accessors owe the caller.
Server.panes/Server.windowskeep-aby design, so the duplicates are inherent — they are real winlinks, not a bug in the listing. The open questions are whetherMultipleObjectsReturnedshould sit inside theLibTmuxExceptionhierarchy so it is catchable alongside every other libtmux failure, whether it should carry a message naming the id and the matches, and whether the duplicate-row behaviour should be documented on the accessors.Note that deduplicating the
-arows in Python is not a fix: choosing which row survives means inventing a tie-break, and tmux already owns that rule (cmd_find_best_session_with_window,cmd-find.c#L176-L202). That is exactly why the correct answer is-t.Related
Pane.session/Window.sessionreturn an arbitrary session for a linked window (the sibling defect in attribution; this one is in arity)-t; makesfrom_pane_id/from_window_idthe correct migration target, but does not changeServer.panes/Server.windowswindow_indexinstead of a wrong arityEnvironment