Skip to content

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

Open

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.


Delivery contract from the rmux helper audit

Reuse the existing missing-timeout report. Route Server.wait_for(timeout=...) through the selected command deadline mechanism, retaining its existing default and return behavior. A narrow implementation can ship before the full async/ops roadmap if it honors the same cleanup and exception contract.

Use a unique channel per operation and explicit server addressing. A remembered signal can avoid a lost wakeup, but channel reuse can consume stale state. A timeout is not proof that a producer did not execute; it means the waiting client did not observe completion within its bound.

Acceptance: no signal, signal before waiter, signal after waiter registration, producer death, cancellation, and an ordinary command after timeout. Verify cleanup of the owned waiting client. Current tmux wait-channel code is source evidence, not a reason to require new tmux behavior.

Delivery links: #689, #673. Track this work under #629 in the libtmux project.

Independent PR delivery

Follow the minimal slice and shared compatibility contract in #629. Build and test against master with every other unmerged feature branch absent. Cross-links above identify semantic contracts and future consumers; use a private adapter where needed instead of introducing a merge dependency. Preserve existing facade behavior and the inspected engine-ops interfaces. Leave deferred acceptance criteria open after an MVP lands.

Activity

  1. added 9 commits that reference this issue on Jul 31, 2026
    100bed3
    1e7f7a5
    e46466a
    f402f45
    d00a4ea
    b9f094a
    1a6fbb8
    ff6d99a
    202ce63
  2. moved this to Todo in libtmuxon Oct 31, 2026
  3. tony commented on Nov 1, 2026

    @tony
    MemberAuthor

    PR #809 supplies the independent ordinary channel-wait deadline. timeout=None retains the existing blocking dispatcher path; an opt-in positive finite bound raises the existing WaitTimeout with the native timeout as cause. It kills/reaps only its owned client and closes its pipes. It does not require PR #807 or an engine branch, and does not change the tmux minimum.

    The scope is deliberately an ordinary signal wait. A bound combined with lock, unlock or set_flag refuses before effects; it does not pretend that killing a queued lock client rolls back a later grant. Custom instance/class dispatch and caller-supplied engines also refuse before a local subprocess is started. Existing unbounded flag behavior and subclass dispatch remain unchanged.

    Evidence covers missing/remembered signals, signal after native registration, a dead producer, large finite bounds, borrowed-server survival and inherited-pipe cleanup. Removing reaping deliberately fails its regression. The class-level custom-dispatch regression failed before its repair and passes afterward. The focused cases pass on tmux 3.2a and local 3.8a. Complete server tests, Ruff, full-tree mypy, executed docs examples and the project docs build pass. The full local suite retains the same unrelated hooks/popup/floating failures reproduced on unchanged master. All eight hosted tmux build lanes and CodeQL pass at this exact PR head.

    A disposable integration check against the inspected engine candidate verified the native default path, injected-backend refusal before local effects, and continued use of the borrowed engine. A separate disposable integration with PR #807 passed. These are compatibility receipts, not a new engine dependency or a claim about unrun future implementations.

    The interactive-shell documentation attempt failed to send its expected signal in the controlled bound. The committed example instead owns a noninteractive producer and addresses the daemon explicitly; it passes without sleeps or retries. Keep that environment limitation distinct from the channel wait itself: expiry does not prove a producer failed or that a signal remains available for another waiter. Use unique channels and never retry an uncertain producer automatically.

    Pinned implementation: channel adapter and API, regressions, owned-producer example. Local evidence covers Linux/WSL. No macOS work is added. Keep #732 open for merge and its remaining broader channel/cancellation criteria.

  4. moved this from Todo to In Progress in libtmuxon Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    • Status
      In Progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions