Skip to content

fix(Pane,Window): Resolve point lookups through tmux#713

Merged
tony merged 2 commits into
masterfrom
targeted-lookups
Jul 12, 2026
Merged

fix(Pane,Window): Resolve point lookups through tmux#713
tony merged 2 commits into
masterfrom
targeted-lookups

Conversation

@tony

@tony tony commented Jul 11, 2026

Copy link
Copy Markdown
Member

Fixes #710.

The bug

A window can be linked into several sessions at once. Ask libtmux which session such a window (or a pane inside it) is in, and the answer depends on what the sessions are called:

>>> window = home.active_window                       # 'aaa-home'
>>> server.cmd("link-window", "-s", window.window_id, "-t", "zzz-guest")
>>> Window.from_window_id(server, window.window_id).session_name
'zzz-guest'

Rename zzz-guest to aaa-guest and the same call answers aaa-home instead. Nothing about the window changed.

The four point lookups — Pane.refresh, Pane.from_pane_id, Window.refresh, Window.from_window_id — passed -a to list-panes / list-windows, listing every object on the server. tmux emits a linked window once per holding session, ordered by session name (its session tree is keyed by strcmpsession.c:41-44), and neo.fetch_obj scans that listing keeping the last row that matches the id. So "which session?" resolved to whichever session sorted last alphabetically.

It also disagreed with tmux. tmux display-message -t @0 '#{session_id}' resolves the same window through cmd_find, which picks the most recently active session holding it (cmd-find.c:176-202). libtmux and tmux gave different answers for the same id.

The fix

Name the object with -t and let tmux resolve it.

tmux's cmd_find_target routes a target to a slot by sigil, not by the command's declared target type (cmd-find.c:1078-1083): $→session, @→window, %→pane. So list-panes -t %ID routes %ID to the pane slot even though list-panes declares a window target, and resolves upward through cmd_find_best_session_with_window() — the same function tmux uses for every -t you type. One row comes back, stamped with the session tmux itself would act on.

This is byte-identical in every tmux CI covers (3.2a3.7b), and libtmux already ships -t on these commands for same-type targets (Window.panes, Session.windows).

Point lookups also get cheaper: -t scans one window's panes or one session's windows instead of the whole server.

Server.panes / Server.windows / Server.sessions keep -a — a server-wide listing is what they're for.

The catch, and what it cost

A live server rejects an unknown -t target on stderr (can't find pane: %99) rather than by returning an empty listing. raise_if_stderr turns that into a LibTmuxException, so fetch_obj would have stopped raising TmuxObjectDoesNotExist altogether — and TmuxObjectDoesNotExist extends ObjectDoesNotExist, not LibTmuxException, so nothing downstream would have caught it.

neo._is_target_not_found_error classifies that stderr, mirroring the existing server._is_daemon_not_up_error. It buys nothing new: it keeps a distinction that already works, which -t would otherwise have destroyed. A missing object stays TmuxObjectDoesNotExist; a dead daemon, a missing socket or a permission error stay LibTmuxException — exactly as in 0.61.0. Only the mechanism behind that contract moved.

Behaviour change

For a window in exactly one session — the overwhelmingly common case — nothing changes.

For a linked or grouped window, refresh() / from_pane_id() / from_window_id() now report tmux's canonical session instead of the alphabetically-last one. That is the fix. Note the consequence: because tmux picks by activity, the answer can now change over time as focus moves between the sessions holding the window, where before it was stable but arbitrary. That is tmux's own semantics, and it is what every -t command already does. Grouped sessions (tmux new-session -t) are ordinary usage — tmuxp creates them — so this is worth knowing before you upgrade.

Not in this PR

Duplicate rows still crash the server-wide accessors — #716. Server.panes / Server.windows keep -a, so server.panes.get(pane_id=…) still raises MultipleObjectsReturned for a window shared between sessions: tmux list-panes -a emits one row per holding session, and QueryList.get() rejects >1 match before consulting default. Untouched here, and it pre-dates this branch. It is also why the fix has to be -t: you cannot dedupe an -a listing without inventing the very tie-break tmux already owns. from_pane_id / from_window_id are the correct migration target for callers hitting it.

window_index is still wrong for a window linked twice into one session — #717. list-windows -t @N returns one row per winlink, so a window linked into the same session at two indexes still produces two rows inside the -t scope, and the survivor loop still keeps the last. This PR fixes the Pane side (list-panes emits each pane once) and does not reach the Window side. The session this PR is about is right either way — both rows carry the same session_id — but window_index is not, and fixing it means resolving through display-message -p -t, which is a refactor rather than a patch.

Tests

tests/test_resolution.py builds a window linked into two sessions whose name order and activity order disagree (zzz-old sorts last but aaa-recent is active), so a resolver that reads the last row of an -a listing cannot pass by luck. Both linked-window tests fail on master and pass here.

The others are guards for what the -t switch could have broken, and pass on master too:

  • a Pane held across a move-window still finds its session (Pane.session resolves through Pane.window, which re-queries — answering from the pane's cached session_id would be one subprocess cheaper and would break here, because tmux destroys a session when its last window leaves)
  • refresh() on a killed window/pane still raises TmuxObjectDoesNotExist
  • a missing pane on a live server is TmuxObjectDoesNotExist; a dead server is not

tests/test_neo.py covers the classifier against real tmux stderr for both kinds of failure.

Verification

ruff · ruff format · mypy · pytest --reruns 0 · just build-docs — all green locally, and CI is green across the full matrix (3.2amaster).

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 51.93%. Comparing base (765697a) to head (20394b3).

Files with missing lines Patch % Lines
src/libtmux/neo.py 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #713      +/-   ##
==========================================
+ Coverage   51.78%   51.93%   +0.14%     
==========================================
  Files          25       25              
  Lines        3638     3645       +7     
  Branches      733      735       +2     
==========================================
+ Hits         1884     1893       +9     
+ Misses       1448     1446       -2     
  Partials      306      306              

☔ 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 a commit that referenced this pull request Jul 11, 2026
why: The answer to "which session is this window in?" changes for
anyone with a linked window, and the exception a failed lookup raises
is now narrower. Both are visible from outside.

what:
- Add a Fixes entry for #713
tony added a commit that referenced this pull request Jul 12, 2026
why: The answer to "which session is this window in?" changes for
anyone with a linked window, and the exception a failed lookup raises
is now narrower. Both are visible from outside.

what:
- Add a Fixes entry for #713
@tony tony force-pushed the targeted-lookups branch from 674206f to 2becdce Compare July 12, 2026 00:27
why: A window can be linked into several sessions at once. The four
point lookups -- Pane.refresh, Pane.from_pane_id, Window.refresh,
Window.from_window_id -- listed every object on the server and picked
the last row matching the id. tmux orders that listing by session name,
so libtmux answered "which session is this in?" with whichever session
happened to sort last, and the answer changed if you renamed a session.
It also disagreed with tmux, which resolves the same id to the session
it would itself act on.

Naming the object with -t hands the question to tmux's cmd_find, which
picks the most recently active session holding the window. One row comes
back instead of a server-wide scan, so the lookups also get cheaper.

The catch: a live server rejects an unknown -t target on stderr rather
than by returning an empty listing, so fetch_obj would have stopped
raising TmuxObjectDoesNotExist entirely. Classifying that stderr also
separates "the object is gone" from "the server is gone", which the two
failures had no way to distinguish before.

what:
- Add neo._is_target_not_found_error, mirroring
  server._is_daemon_not_up_error, and map tmux's "can't find <kind>"
  back to TmuxObjectDoesNotExist inside fetch_obj
- Switch the four point lookups from list_extra_args=("-a",) to
  ("-t", <id>)
- Cover the classifier with a parametrized NamedTuple fixture, and the
  resolution itself against a window linked into two sessions whose
  name order and activity order disagree
tony added a commit that referenced this pull request Jul 12, 2026
why: The answer to "which session is this window in?" changes for
anyone with a linked window, and the exception a failed lookup raises
is now narrower. Both are visible from outside.

what:
- Add a Fixes entry for #713
@tony tony force-pushed the targeted-lookups branch 2 times, most recently from 157e016 to 5a424af Compare July 12, 2026 11:16
why: A window linked into several sessions resolved to whichever session
sorted last by name, so renaming a session changed libtmux's answer. That
shipped in 0.61.0, so users of a published release met it.

what:
- Describe the shipped defect and the answer tmux itself gives
- Warn that a linked or grouped window's session now follows activity,
  so it can change between calls where it used to be stably arbitrary
- Note the lookups no longer scan the whole server
@tony tony merged commit 1102472 into master Jul 12, 2026
15 checks passed
@tony tony deleted the targeted-lookups branch July 12, 2026 11:30
tony added a commit that referenced this pull request Jul 12, 2026
A tmux window can be linked into several sessions at once, and into one
session at several indexes -- tmux calls each of these a winlink.
libtmux flattened them: a multiply-linked window could report a session
or index tmux itself would not act on, a shared window had no way to
name every session holding it, and an ambiguous lookup failed with no
explanation. This teaches the resolution path, a new accessor, and the
lookup errors to model winlinks the way tmux does.

- **Resolve**: Window.from_window_id(), Pane.from_pane_id() and both
  refresh() methods now return the session and index tmux would act on
  -- the current winlink when it holds the window, otherwise the
  lowest-indexed one -- instead of whichever row sorted last. This
  extends #713, which fixed the session, to the window index.
- **List**: Window.linked_sessions names every session a window is
  reachable from, each once even when it holds the window at several
  indexes, in two list commands regardless of holder count. It returns
  an empty result on tmux error, like the other list accessors.
- **Explain**: QueryList.get() now names both the lookup and its
  outcome -- "No objects found: pane_id='%99'", "Multiple objects
  returned (2): pane_id='%0'" -- and the exceptions carry that data
  (count, query) for callers that branch on it.
- **Tests**: end-to-end cases build windows linked across and within
  sessions and compare resolution against live tmux over the supported
  version range, so a resolver that reads the wrong row cannot pass by
  luck.
- **Docs**: a winlink glossary entry and a filtering guide for why
  Server.windows and Server.panes enumerate winlinks rather than
  deduplicating windows.

Breaking change: exc.ObjectDoesNotExist and exc.MultipleObjectsReturned
now subclass LibTmuxException, so TmuxObjectDoesNotExist falls under it
too. Order exception handlers most-specific-first, and exclude these two
deterministic lookup exceptions from blanket LibTmuxException retry
policies. The libtmux._internal.query_list import path still works. See
MIGRATION for before/after examples.

Fixes #717. Addresses #716. Refs #713.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pane.session / Window.session return an arbitrary session for a linked window

1 participant