Skip to content

Bound a tmux command that may never return - #735

Open
tony wants to merge 4 commits into
docs/lift-automation-patternsfrom
fix/732-cmd-timeout
Open

Bound a tmux command that may never return#735
tony wants to merge 4 commits into
docs/lift-automation-patternsfrom
fix/732-cmd-timeout

Conversation

@tony

@tony tony commented Jul 31, 2026

Copy link
Copy Markdown
Member

Closes #732. Stacked on #734, which owns docs/topics/automation_patterns.md.

What this does

Server.wait_for() wraps tmux wait-for, a rendezvous with no clock on it. When whatever was going to signal the channel dies first — the command is killed, the shell exits, the window closes — the wait never ends, and there was no ceiling anywhere beneath it either: communicate() was called without one.

pane.send_keys(f"make; tmux wait-for -S {channel}")
server.wait_for(channel, timeout=60)

timeout is keyword-only and available on wait_for() and on cmd() for Server, Session, Window and Pane, forwarding to tmux_cmd. Every entry point defaults to None, which is unbounded, so no existing call changes behaviour.

Why the bound rides on the call

An instance-level default (Server(timeout=30)) was built and measured before this was chosen. It works, and it breaks blocking-by-design commands: on Server(timeout=1.0), server.cmd("attach-session", ...) is killed at ~1.06s. Mitigating it needs a hand-maintained deny-list that by construction cannot cover a command a user dispatches themselves.

The reason is that the analogy to HTTP clients does not hold. httpx gets away with a client-level timeout because requests are homogeneous; tmux's command set is not. display-message should take milliseconds and attach-session should take hours, so no single per-server number is correct, and it is wrong precisely where being wrong hurts most.

Per-call also stays well-defined under batching, which matters for the engine work: a timeout carried by the request has an unambiguous meaning in a batch, where an engine-level default or an ambient scope has to choose between bounding the batch and bounding each command in it.

Killing is not enough

communicate() does not kill the child when its timeout expires, and kill() alone leaves a zombie. Both are covered, because the difference is invisible to the obvious assertion — os.kill(pid, 0) succeeds against a zombie. The test pins returncode == -SIGKILL and that os.waitpid raises ChildProcessError.

TmuxCommandTimeout carries the killed command line and the bound it exceeded. It subclasses WaitTimeout, so handlers that already catch libtmux's timeout keep working, and it deliberately does not subclass subprocess.TimeoutExpired — the stdlib type never crosses the library boundary, including by chaining.

Two tmux behaviours documented

Both were measured against tmux 3.7b while building this, and both are properties of the rendezvous rather than of this change.

A wait that times out is abandoned rather than withdrawn. tmux only remembers a signal when nothing is waiting for the channel (cmd-wait-for.c#L148), so the next signal is spent waking the abandoned wait — one signal, after which the channel behaves normally. A fresh channel name per rendezvous avoids it.

A returning wait is also weaker evidence than it looks. tmux releases every waiter when the server shuts down (cmd-wait-for.c#L244), and does so exactly as if the channel had been signalled, so a pane that died and a command that finished are indistinguishable from the wait alone. When the outcome matters, have the command report its own exit status.

Verification

ruff check, ruff format, mypy (69 files), py.test --reruns 0 (1468 passed, 1 skipped) and just build-docs all clean.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.27273% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.56%. Comparing base (8d35933) to head (2a58783).

Files with missing lines Patch % Lines
src/libtmux/common.py 75.00% 2 Missing and 1 partial ⚠️
src/libtmux/exc.py 60.00% 2 Missing ⚠️
Additional details and impacted files
@@                        Coverage Diff                        @@
##           docs/lift-automation-patterns     #735      +/-   ##
=================================================================
+ Coverage                          52.40%   52.56%   +0.16%     
=================================================================
  Files                                 26       26              
  Lines                               3729     3744      +15     
  Branches                             747      749       +2     
=================================================================
+ Hits                                1954     1968      +14     
  Misses                              1472     1472              
- Partials                             303      304       +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 force-pushed the docs/lift-automation-patterns branch from 6c1c5b7 to a09ab07 Compare July 31, 2026 22:49
@tony
tony force-pushed the fix/732-cmd-timeout branch from f657e4c to 0c4f728 Compare July 31, 2026 22:55
@tony
tony force-pushed the docs/lift-automation-patterns branch from a09ab07 to d4c8f7f Compare August 1, 2026 00:33
@tony
tony force-pushed the fix/732-cmd-timeout branch from 0c4f728 to 24085b2 Compare August 1, 2026 00:35
@tony

tony commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony
tony force-pushed the docs/lift-automation-patterns branch from d4c8f7f to 80c8797 Compare August 1, 2026 01:07
@tony
tony force-pushed the fix/732-cmd-timeout branch from 24085b2 to 39e87af Compare August 1, 2026 01:09
@tony
tony force-pushed the docs/lift-automation-patterns branch from 80c8797 to 3f81cf8 Compare August 1, 2026 03:15
@tony
tony force-pushed the fix/732-cmd-timeout branch from 39e87af to d172f7c Compare August 1, 2026 03:18
@tony

tony commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 1 issue, fixed in d172f7c:

  1. "no orphan survives" overstated what is reaped. The kill covers the tmux client libtmux spawned; the pane's foreground process and the tmux server keep running, so a caller reading that could believe a timeout stopped the work.

https://github.com/tmux-python/libtmux/blob/39e87af1d0a4c8b2e5f7a9c1b3d6e8f0a2c4d5e6/src/libtmux/exc.py#L361-L365

Both TmuxCommandTimeout and tmux_cmd's Raises section now say the call leaves no child of its own behind, and that work the command started is unaffected.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony
tony force-pushed the docs/lift-automation-patterns branch from 3f81cf8 to 5d304cb Compare August 1, 2026 04:05
@tony
tony force-pushed the fix/732-cmd-timeout branch from d172f7c to e4e1f92 Compare August 1, 2026 04:10
@tony

tony commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony
tony force-pushed the fix/732-cmd-timeout branch from d0f7a2b to 9378bed Compare August 1, 2026 10:21
@tony

tony commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 1 issue, fixed in 9378bed:

  1. The previous round's correction to what a timeout actually kills landed in exc.py and common.py but not in the four public cmd() docstrings or CHANGES, which still said the timeout kills "tmux" and that "an expiry leaves nothing behind".

https://github.com/tmux-python/libtmux/blob/e4e1f920d3b8a5c7f9e1d2a4b6c8e0f2a4c6d8e0/CHANGES#L64-L68

All five now say the same thing: the kill covers the tmux client libtmux spawned, and work the command started keeps running.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony
tony force-pushed the docs/lift-automation-patterns branch from 5d304cb to c5aa46c Compare August 1, 2026 10:47
@tony
tony force-pushed the fix/732-cmd-timeout branch from 9378bed to 044fa42 Compare August 1, 2026 10:55
@tony
tony force-pushed the docs/lift-automation-patterns branch from c5aa46c to 21de528 Compare August 1, 2026 11:44
@tony
tony force-pushed the fix/732-cmd-timeout branch from 044fa42 to 12c2afc Compare August 1, 2026 11:49
@tony
tony force-pushed the docs/lift-automation-patterns branch from 21de528 to 8d35933 Compare August 1, 2026 12:19
tony added 4 commits August 1, 2026 07:19
why: A tmux command that blocks on something that may never happen
blocks the calling process for its whole life. `wait-for` is a
rendezvous with no clock on it, and `communicate()` was called with no
ceiling, so there was no bound anywhere beneath it.

what:
- Add keyword-only `timeout` to `tmux_cmd`, forwarded to
  `Popen.communicate()`. `None`, the default, waits as long as tmux
  takes, so existing callers are unaffected.
- Kill and reap the tmux process before raising. `communicate()` leaves
  the child running when its timeout expires, and `kill()` alone leaves
  a zombie, so an unhandled expiry leaks one process per call.
- Add `TmuxCommandTimeout`, carrying the killed command line and the
  bound it exceeded. It subclasses `WaitTimeout`, so handlers that
  already catch libtmux's timeout keep working, and it deliberately
  does not subclass `subprocess.TimeoutExpired` -- the stdlib type
  never crosses the library boundary, including by chaining.
why: `Server.wait_for()` wraps `tmux wait-for`, the poll-free rendezvous
that is the right way to wait on a command you authored. Whatever was
going to signal the channel can die first -- the command is killed, the
shell exits, the window closes -- and the wait then never ends, so the
primitive could not be recommended in documentation.

what:
- Add keyword-only `timeout` to `Server.wait_for()`, and to `cmd()` on
  `Server`, `Session`, `Window` and `Pane`, forwarding to `tmux_cmd`.
  Every entry point defaults to `None`, which is unbounded.
- The bound rides on the call rather than the object: a tmux command set
  is not homogeneous, so one server-wide number cannot be right for both
  `display-message` and `attach-session`.
- Cover the reported case (keys typed but never entered, so nothing ever
  signals), that a signalled channel still resolves, that the error is a
  libtmux one carrying the killed command, and that every entry point
  still defaults to unbounded.

Fixes #732
why: The page taught polling with a bound, which costs a tmux round-trip
per read and a sleep paid whether the command finished or not. tmux
offers a rendezvous instead, and it is worth teaching next to the poll
so a reader can see the trade.

what:
- Add "Waiting for a signal instead of polling": a pane signals with
  `tmux wait-for -S` and the script waits with `Server.wait_for()`,
  bounded so a signal that never arrives is catchable.
- Record two properties of the rendezvous a reader will otherwise meet
  the hard way. A wait that times out is abandoned rather than
  withdrawn, and tmux only remembers a signal when nothing is waiting,
  so the next signal on that channel is spent waking the abandoned wait
  -- one signal, after which the channel behaves normally. A fresh
  channel name per rendezvous avoids it.
- Note that a returning wait is not proof the work succeeded: tmux
  releases every waiter on server shutdown exactly as if the channel had
  been signalled, so a pane that died and a command that finished look
  the same from the wait alone.
why: `Server.wait_for()` could block for the life of the process, and
downstream code was already working around it by killing the tmux
child itself.

what:
- Record the `timeout` parameter and `TmuxCommandTimeout` under
  `What's new`, naming what the kill covers and what it does not.
@tony
tony force-pushed the fix/732-cmd-timeout branch from 12c2afc to 2a58783 Compare August 1, 2026 12:21
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