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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions