tmux-python / tmux-python/libtmux
`Server.wait_for()` has no timeout, so a pane that never signals hangs forever
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1.2k
- Forks
- 127
- Avg merge
- 2h 13m
- Merged PRs (30d)
- 1
Description
Filed against tmux-python/libtmux v0.62.0, tmux 3.7b.
What happens
Server.wait_for() blocks until the channel is signalled, with no way to bound the wait. If the pane that was going to signal dies first — the command was killed, the shell exited, the window was closed — the call blocks forever.
There is no ceiling anywhere below it either. tmux_cmd calls communicate() with no timeout:
So the hang is process-wide and not interruptible except by signal.
wait-for blocking indefinitely is correct tmux behaviour, not a tmux bug — the command is a rendezvous and has no timeout flag:
.args = { "LSU", 1, 1, NULL },
.usage = "[-L|-S|-U] channel",
The gap is that libtmux exposes the rendezvous without exposing a way to give up on it.
Why it matters now
wait-for is the exact, poll-free way to wait for a command a caller authored: the command signals a channel when it finishes and the caller blocks on that channel. Measured on tmux 3.7b — a pane running sleep 1.5; tmux wait-for -S <ch> released the waiter after 1.506 s, with zero polling and no round trips. A signal sent before the waiter starts is remembered, so there is no lost-wakeup race: signalling first and then waiting returned in 3 ms.
That makes it the right thing to teach in documentation for "run a command, then read its output", replacing the time.sleep() guesses currently in the topic pages. The blocker is that every such example either carries subprocess timeout ceremony or teaches a hang.
Concretely, the shape wanted in a doc example is:
pane.send_keys(f"make; tmux wait-for -S {channel}")
server.wait_for(channel, timeout=60)
and today the second line cannot be written.
Reproduction
A pane that never signals, because the keys were typed but never entered:
pane.send_keys(f"echo READY; tmux wait-for -S {channel}", enter=False)
server.wait_for(channel) # blocks forever
The bound is achievable one level down, which is why this is a missing parameter rather than a missing capability:
subprocess.run(["tmux", "-S", socket_path, "wait-for", channel], timeout=1.0)
# raises subprocess.TimeoutExpired after 1.00s
What a fix needs
- Add
timeout: float | None = NonetoServer.wait_for(). A parameter on an existing method rather than new API surface. On expiry, kill the child and raise —libtmux.exc.WaitTimeoutalready exists and is the natural type, which keeps this consistent withretry_until. - Decide where the timeout is enforced. Either add
timeout=totmux_cmd— a load-bearing path used by every command in the library, so a wider blast radius — or havewait_forbypass it with a direct timeout-bounded subprocess call for this one command. The second is narrower and reversible. - A killed child must not leave a stray
wait-forprocess holding the channel. Terminate it on expiry and verify no process survives the raise. - Document that a signal already sent is remembered, so ordering between signaller and waiter does not matter. That property is what makes the pattern race-free and it is not obvious from the tmux manual.
Default stays None, so existing callers are unaffected.
Related
Server.wait_for() has shipped since it was added and currently has no callers anywhere in the library, its tests, or its documentation. The waiting problem it solves is instead worked around with time.sleep() in the topic pages, which is the discoverability half of the same gap.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with Server.wait_for() in src/libtmux/server.py, then read tmux_cmd in src/libtmux/common.py and WaitTimeout in src/libtmux/exc.py. Review retry_until and the topic-page examples that use time.sleep(). Done means a bounded wait raises WaitTimeout without leaving a child process, while the default remains unchanged and the signal-ordering behavior is documented.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, cli
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100