Skip to content

Grouped and linked windows make Server.panes.get() / Server.windows.get() raise an uncatchable MultipleObjectsReturned #716

Description

@tony

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#L36class 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

  • #710Pane.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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions