Skip to content

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

Open
tony wants to merge 11 commits into
masterfrom
winlinks
Open

Resolve winlinks: the right window index, and lookups that say what happened#718
tony wants to merge 11 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 77.55102% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.30%. Comparing base (619cfb6) to head (64cebf3).

Files with missing lines Patch % Lines
src/libtmux/exc.py 68.00% 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.30%   +0.16%     
==========================================
  Files          26       26              
  Lines        3688     3726      +38     
  Branches      741      747       +6     
==========================================
+ Hits         1923     1949      +26     
- 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 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
tony added 3 commits July 12, 2026 14:21
why: The fallback returned rows[0], correct only while the caller
handed rows in ascending window_index order -- a precondition that
was documented but never enforced. A future reorder would silently
resolve the wrong winlink.

what:
- Return the lowest-window_index row via min(), not rows[0], so the
  fallback matches tmux's "first" regardless of input order
- Drop the ascending-order precondition from the docstring
- Add a doctest proving a high-index-first listing still resolves to
  tmux's first
why: get() guarded the empty-match branch with `default == no_arg`.
A default whose __eq__ is non-identity (a Mock, a numpy array) makes
that comparison truthy or non-bool, so get(default=x) wrongly raised
ObjectDoesNotExist instead of returning x.

what:
- Compare the no_arg sentinel with `is`, not `==`
- Add a regression test with a broad-__eq__ default
why: The constructor accepts `query=` and renders it into the
message but discarded it, so `except ObjectDoesNotExist as e:
e.query` raised AttributeError -- unlike its sibling
MultipleObjectsReturned, which exposes the same data.

what:
- Store `self.query` in ObjectDoesNotExist.__init__, matching
  MultipleObjectsReturned
- Add a regression test asserting the attribute
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