Skip to content

Resolve winlinks: the right window index, and lookups that say what happened#718

Open
tony wants to merge 8 commits into
masterfrom
winlinks
Open

Resolve winlinks: the right window index, and lookups that say what happened#718
tony wants to merge 8 commits into
masterfrom
winlinks

Conversation

@tony

@tony tony commented Jul 12, 2026

Copy link
Copy Markdown
Member

Fixes #716. Fixes #717.

Both issues are one modelling gap: a window_id names a window, but a row in list-windows / list-panes names a winlink — the (session, index, window) edge — and one window can own several. libtmux's object layer assumed rows were unique per id. #717 is that gap surfacing as wrong attribution; #716 is the same gap surfacing as wrong arity.

This is not an inference. tmux enumerates winlinks in its own format tokens — link a window into aaa twice and into zzz once, and #{window_linked_sessions_list} answers aaa,zzz,aaa.

#717 — the wrong window index

A window linked twice into one session has two winlinks, so list-windows -t @0 returns two rows with the same window_id and different window_index. neo.fetch_obj's survivor loop kept the last, so libtmux reported the highest index where tmux reports the one it would act on.

The fix ports tmux's own rule, whose comment reads "the current if it contains the window, otherwise the first" (cmd-find.c:204-231):

def _best_winlink(rows: OutputsRaw) -> OutputRaw:
    if len(rows) == 1:                      # every pane, session and client
        return rows[0]
    for row in rows:
        if row.get("window_active") == "1": # tmux: wl == s->curw
            return row
    return rows[0]                          # tmux: "otherwise the first"

Both halves are read off rows we already fetched. #{window_active} is not a proxy for tmux's test — format_cb_window_active is ft->wl == ft->wl->session->curw, the identical predicate; and a listing walks the winlinks RB-tree, which winlink_cmp keys by index, so "the first" is the lowest index. Unchanged 3.2a→3.7b.

Note this is not "libtmux inventing a tie-break" — keeping the last row was an invented tie-break, just an unnamed and incorrect one. Master happens to agree with tmux in exactly one of the three current-window states, by accident. The tests cover all three.

The -t scoping from #713 already delegates the cross-session question to tmux, so the port only has to be right within a session — precisely where the format tokens are exact. Across sessions they are not: tmux compares struct timeval with timercmp() while #{session_activity} renders tv_sec only, and on a headless server activity_time never advances past creation_time. That half is left to tmux, where it belongs.

#716 — the ambiguous lookup

The collections are unchanged. Server.panes / Server.windows still enumerate winlinks, because that is what tmux stores and what -a means. A shared pane really is reachable two ways, and docs/topics/self_location.md already relies on that signal.

What was broken is that the ambiguity was reported illegibly. MultipleObjectsReturned sat outside LibTmuxException and raised bare, so except LibTmuxException walked past it and str(e) was empty. The user saw nothing, caught nothing, learned nothing. Now:

>>> server.panes.get(pane_id="%0")
MultipleObjectsReturned: Multiple objects returned (2): pane_id='%0'

…catchable with everything else libtmux raises. The point lookups (Pane.from_pane_id, Window.from_window_id) always had exactly one answer, because they ask tmux — the docs now say so.

Window.linked_sessions is the supported way to ask "which sessions hold this window?", listing each holder once however many indexes it links the window at.

⚠️ Breaking change

MultipleObjectsReturned and ObjectDoesNotExist now subclass LibTmuxException. TmuxObjectDoesNotExist inherits from ObjectDoesNotExist, so it moves under LibTmuxException too.

Two consequences, both in CHANGES:

  1. A handler that catches the base before the specific one leaves the specific clause unreachable. Order most-specific-first.
  2. Code that retries on LibTmuxException should exclude ObjectDoesNotExist — an object that is not there will not appear on a retry. libtmux-mcp's ReadonlyRetryMiddleware defaults to retry_exceptions=(LibTmuxException,) and needs a lockstep change.

The old import path still works: from libtmux._internal.query_list import MultipleObjectsReturned resolves via re-export.

No cycle is introduced. exc.py imports nothing from libtmux at runtime, and _internal already depends on exc (_internal/env.py) — the arrow was simply backwards before.

The exception commit (f084df97) is isolated and revertible on its own if the hierarchy change is contested.

Verification

ruff check --fix · ruff format · mypy · pytest --reruns 0 · just build-docs — all green.

Every resolution test asserts against display-message -p -t <id>, tmux's own answer, never a hardcoded index — so a change in tmux breaks CI loudly instead of drifting silently. Coverage: same-session double link, cross-session link, grouped session (new-session -t), and all three current-window states.

Also fixes a shipped copy-paste bug found in passing: TmuxObjectDoesNotExist's docstring read "The query returned multiple objects when only one was expected."

@codecov

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.59574% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.29%. Comparing base (619cfb6) to head (9a1c47a).

Files with missing lines Patch % Lines
src/libtmux/exc.py 66.66% 7 Missing and 1 partial ⚠️
src/libtmux/window.py 81.81% 2 Missing ⚠️
src/libtmux/neo.py 90.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #718      +/-   ##
==========================================
+ Coverage   52.14%   52.29%   +0.15%     
==========================================
  Files          26       26              
  Lines        3688     3725      +37     
  Branches      741      747       +6     
==========================================
+ Hits         1923     1948      +25     
- Misses       1461     1472      +11     
- Partials      304      305       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony

tony commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

Code review — resolved

This review originally covered 89396ba. The reviewed branch is now 9a1c47a.

The four valid findings are addressed:

  1. Window.linked_sessions returns an empty QueryList when either snapshot fails, uses two list commands regardless of holder count, deduplicates holders, and tolerates a session disappearing between snapshots.
  2. _best_winlink no longer claims that an object id identifies one row; its single-session scope is explicit.
  3. MultipleObjectsReturned uses versionadded, and its count and query data are introspectable.
  4. _best_winlink documents the ascending-window_index precondition.

MIGRATION now covers the breaking exception-hierarchy change, most-specific-first handlers, and retry exclusions.

The _internal/query_list.py / libvcs vendoring concern from the original item 3 is retracted. It was misclassified as must-fix: this internal module has no standalone public/versioning contract, and the import direction has zero downstream impact. A dual-base exception hierarchy is not warranted.

Maintainer sign-off: keep _best_winlink. Its call sites provide non-empty, single-session rows in ascending index order, and current-row-then-first matches tmux's display-message oracle across the supported tmux matrix. Those preconditions are now documented explicitly.

@tony

tony commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

Code review — addendum

An adversarial bug-scan pass found no correctness bugs in the change, and independently verified the part I most wanted checked: no fetch_obj caller passes -a (window.py, pane.py, session.py, client.py are all -t-scoped or unique-keyed), so _best_winlink's load-bearing precondition — rows already scoped to the one session tmux resolved the id to — actually holds at every call site. It also confirmed against live tmux that rows arrive in ascending window_index order regardless of link creation order, and that parse_output cannot drop window_active (tmux emits a literal "0"/"1", and "0" is truthy). A cross-session double-link probe matched display-message -t exactly.

That downgrades issue 5 in my earlier comment from a live bug to a latent one — worth a contract line, not a fix.

One more real issue, though:

  1. count and query are documented as Parameters but never stored, so they are not introspectable — they are formatted into the message and discarded. except MultipleObjectsReturned as e: e.count raises AttributeError. A programmatic consumer (libtmux-mcp, for one) wants the count and the query as data, not as a string to re-parse out of str(e). Two lines to fix if they were meant to be readable.

libtmux/src/libtmux/exc.py

Lines 245 to 262 in 89396ba

.. versionchanged:: 0.62
Re-based on :exc:`LibTmuxException` and given a message. Importing it
from :mod:`libtmux._internal.query_list` still works.
"""
def __init__(
self,
*args: object,
count: int | None = None,
query: Mapping[str, t.Any] | None = None,
) -> None:
if args:
super().__init__(*args)
return
msg = "Multiple objects returned"
if count is not None:

Also worth knowing, pre-existing and not introduced here: TmuxObjectDoesNotExist loses its message under copy.copy / pickle__reduce__ replays args[0] into the obj_key slot, so it degrades to 'Could not find object'. It behaves identically on master. It only becomes more likely to be hit now that the class sits inside the LibTmuxException hierarchy and gets caught and serialized generically.

🤖 Generated with Claude Code

tony added 8 commits July 12, 2026 11:37
why: Both #716 and #717 come from one gap: a window_id names a
window, but a row in list-windows names a winlink -- the (session,
index, window) edge -- and one window can own several. The word has no
entry, so neither issue can be explained without inventing vocabulary.

what:
- Add a winlink glossary term, tying it to Window and Session
why: A window linked twice into one session has two winlinks, so
list-windows -t @id returns two rows for it. The survivor loop kept the
last, reporting the highest index; tmux reports the winlink it would
act on. Users of 0.61.0 met this as a wrong window_index.

what:
- Port cmd_find_best_winlink_with_window: the current window if it
  holds the target, otherwise the first. #{window_active} is tmux's
  own wl == s->curw test, and a listing walks the winlinks in index
  order, so both halves read off the rows already fetched
- Keep the single-row path untouched: panes, sessions, and clients
  have no winlink edge and never reach the tie-break
why: A failed QueryList.get() raised MultipleObjectsReturned or
ObjectDoesNotExist -- both outside LibTmuxException and both raised
bare, so `except LibTmuxException` walked past them and str(e) was
empty. The caller saw nothing, caught nothing, learned nothing.

BREAKING: TmuxObjectDoesNotExist inherits ObjectDoesNotExist, so it
moves under LibTmuxException too. Handlers that catch the base before
the specific one now leave the specific clause unreachable, and code
that retries on LibTmuxException should exclude ObjectDoesNotExist --
an object that is not there will not appear on a retry.

what:
- Move both exceptions into exc.py, where they subclass
  LibTmuxException and render in autodoc; query_list re-exports them,
  so the old import path keeps working
- exc.py imports nothing from libtmux at runtime, so no cycle is
  possible; _internal already depends on exc (see _internal/env.py)
- Give get() failures a message naming the count and the query
- Revive two asserts that named the intended messages but never ran
why: A shared window is genuinely in several sessions at once, but
Window.session answers with only the session recorded on that window.
Asking which sessions hold a window meant reading duplicate rows out of
a server-wide listing -- inferring the answer from an artifact instead
of asking.

what:
- Add Window.linked_sessions, listing each holding session once however
  many indexes it links the window at
why: The winlink shapes had no coverage. The same-session double link
had no helper at all, and nothing asserted libtmux against tmux's own
answer for it.

what:
- Assert against the display-message oracle, never a hardcoded index,
  so a tmux behaviour change breaks CI loudly instead of drifting
- Cover all three shapes: same-session double link, cross-session
  link, grouped session -- and all three current-window states, since
  master agrees with tmux in exactly one of them by accident
why: A server-wide listing enumerates winlinks, not windows, so a
shared window appears once per session holding it. Nothing said so,
and a reader who hit it had no way to tell a bug from the model.

what:
- Say what a server-wide listing enumerates, and when an id can match
  more than one row
- Point a contested lookup at Pane.from_pane_id and
  Window.from_window_id, which ask tmux and always have exactly one
  answer
why: Upgraders need to account for lookup errors joining the common
libtmux exception hierarchy in 0.62.

what:
- Document the new exception relationships and handler order
- Exclude deterministic lookup outcomes from blanket retries
why: Readers need the forthcoming release framed around the breaking
lookup hierarchy, linked-window behavior, and public accessors.

what:
- Add release lead and exception migration guidance
- Document linked sessions, winlink resolution, and lookup messages
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant