Skip to content

Bound every tmux command from a server-wide timeout, listings included - #757

Draft
tony wants to merge 3 commits into
masterfrom
tmux-cmd-timeout
Draft

tony wants to merge 3 commits into
masterfrom
tmux-cmd-timeout

Conversation

@tony

@tony tony commented Sep 13, 2026

Copy link
Copy Markdown
Member

Overlaps #735 and reverses one of its decisions. Opened as a draft so the two approaches can be compared side by side.

What this does

tmux_cmd waited on Popen.communicate() with no deadline, so a tmux server that accepts a connection and never replies held its caller forever. Cancelling the coroutine that awaits such a call does not interrupt it, so hung calls only accumulate; downstream, forty of them exhausted anyio's default thread limiter and the host process stopped serving every socket, healthy ones included.

  • tmux_cmd(..., timeout=) bounds one command. On expiry the tmux process is killed and reaped before exc.TmuxTimeout is raised, carrying the argv and the bound, so a timed-out call leaves nothing running behind it.
  • Server(timeout=) sets the bound once. Server.cmd() uses it unless a call passes its own timeout, and neo.fetch_objs uses it too.

The second funnel is the point of the server-wide default. fetch_objs builds a tmux_cmd directly and is the engine behind Server.sessions, Session.windows, Window.panes and Server.clients. Those listings never go through Server.cmd(), so a per-call timeout cannot reach them, and neither can a Server subclass.

Without a timeout nothing changes: every default is None, which waits as before.

TmuxTimeout is not a LibTmuxException

The listing accessors are lenient: they read a LibTmuxException as "nothing to list". That is right for a daemon that has not started and wrong for a server that has stopped answering, because a caller told there are no sessions goes on to create one on a server that already has them. A sibling exception type gets the loud failure at every such site without touching the accessors. The listing test is parametrized over sessions, windows, panes and clients to show it holds at all four.

Relationship to #735

#735 puts timeout on each call instead: on cmd() for Server, Session, Window and Pane, and on Server.wait_for(), raising TmuxCommandTimeout(WaitTimeout).

Where the two differ:

  • Instance default. Bound a tmux command that may never return #735 rejected Server(timeout=) after measuring server.cmd("attach-session", ...) killed at ~1.06s under Server(timeout=1.0). That risk applies here unchanged: Server.attach_session() goes through Server.cmd() with no exemption. A per-call timeout=None also falls back to the server's default, so there is no way to ask for an unbounded call on a bounded server.
  • Listings. Bound a tmux command that may never return #735 does not bound fetch_objs, so sessions, windows, panes and clients stay unbounded there. Its exception is also a LibTmuxException (via WaitTimeout), which the lenient accessors would swallow if it were raised from them.
  • Surface. Bound a tmux command that may never return #735 adds timeout to Session.cmd(), Window.cmd(), Pane.cmd() and Server.wait_for(); this branch does not.
  • Exception type. Landing both would leave two timeout exceptions, TmuxTimeout and TmuxCommandTimeout, so one of them should give way.

Tests

  • tmux_cmd raises on expiry, and the process is gone afterwards. Shown failing on the kill: without it the pid is still alive.
  • A hanging_tmux fixture stands in for a wedged server: it answers -V and hangs on everything else.
  • Every listing accessor raises rather than answering empty on a wedged server.

Verification

ruff check, ruff format, mypy and py.test --reruns 0 (1462 passed, 1 skipped), plus just build-docs, all clean on the branch tip.

why: `tmux_cmd` waited on `Popen.communicate()` with no deadline, so a
tmux server that accepts a connection and never replies held its caller
forever. Cancelling the coroutine that awaits such a call does not
interrupt it, so hung calls only accumulate; downstream, forty of them
exhausted anyio's default thread limiter and the host process stopped
serving every socket, healthy ones included.

`TmuxTimeout` is deliberately NOT a `LibTmuxException`. The listing
accessors absorb one of those as "nothing to list", which is right for
a daemon that has not started and wrong for a server that stopped
answering: a caller told there are no sessions goes on to create one on
a server that already has them. A sibling type gets that for free at
every such site.

what:
- Add `exc.TmuxTimeout`, carrying the argv and the bound it passed
- Add `tmux_cmd(..., timeout=)`; on expiry kill the child and reap it
  before raising, so repeated timeouts do not leave tmux processes
  nothing is waiting on
- Add a `hanging_tmux` fixture: a stand-in that answers `-V` and hangs
  on everything else, which is the shape of a wedged server
- Cover the raise, and that the process is gone afterwards. Shown
  failing on the kill: without it the pid is still alive
why: `Server.cmd` is not the only funnel. `neo.fetch_objs` builds a
`tmux_cmd` directly and is the engine behind `Server.sessions`,
`Session.windows` and `Window.panes`, so a consumer cannot bound its
calls with a `Server` subclass -- the busiest path is not reachable
that way.

what:
- Add `Server(timeout=)`, used by `Server.cmd` unless a call overrides it
- Pass the server's timeout through `fetch_objs`
- Assert every listing accessor raises rather than answering empty on a
  wedged server: `sessions`, `windows`, `panes`, `clients`. That is what
  the sibling exception type buys, and the parametrization is what shows
  it holds at all four
why: New capability with a default that changes nothing, plus one
exception type whose place in the hierarchy is a deliberate choice
worth stating where users read it.

what:
- Add a `### What's new` deliverable for the bound and its exception
@codecov

codecov Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.53%. Comparing base (76c50c5) to head (5ed8ae9).

Files with missing lines Patch % Lines
src/libtmux/exc.py 60.00% 2 Missing ⚠️
src/libtmux/common.py 83.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #757      +/-   ##
==========================================
+ Coverage   52.37%   52.53%   +0.16%     
==========================================
  Files          26       26              
  Lines        3729     3746      +17     
  Branches      747      747              
==========================================
+ Hits         1953     1968      +15     
- Misses       1472     1474       +2     
  Partials      304      304              

☔ 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.

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.

1 participant