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:
src/libtmux/common.py#L339
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:
cmd-wait-for.c#L34-L38
.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 = None to Server.wait_for(). A parameter on an existing method rather than new API surface. On expiry, kill the child and raise — libtmux.exc.WaitTimeout already exists and is the natural type, which keeps this consistent with retry_until.
- Decide where the timeout is enforced. Either add
timeout= to tmux_cmd — a load-bearing path used by every command in the library, so a wider blast radius — or have wait_for bypass 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-for process 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.
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_cmdcallscommunicate()with no timeout:src/libtmux/common.py#L339So the hang is process-wide and not interruptible except by signal.
wait-forblocking indefinitely is correct tmux behaviour, not a tmux bug — the command is a rendezvous and has no timeout flag:cmd-wait-for.c#L34-L38The gap is that libtmux exposes the rendezvous without exposing a way to give up on it.
Why it matters now
wait-foris 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 runningsleep 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 carriessubprocesstimeout ceremony or teaches a hang.Concretely, the shape wanted in a doc example is:
and today the second line cannot be written.
Reproduction
A pane that never signals, because the keys were typed but never entered:
The bound is achievable one level down, which is why this is a missing parameter rather than a missing capability:
What a fix needs
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.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.wait-forprocess holding the channel. Terminate it on expiry and verify no process survives the raise.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 withtime.sleep()in the topic pages, which is the discoverability half of the same gap.