Skip to content

Server.wait_for() has no timeout, so a pane that never signals hangs forever #732

Description

@tony

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions