Skip to content

No self-location API (TMUX_PANE), and the socket_path derivation in Server.__init__ is unreachable dead code #704

Description

@tony

Filed against tmux-python/libtmux (v0.61.0). Found while building an MCP server on libtmux; both items force every downstream consumer to re-implement the same two things.

1. Server.__init__'s socket_path derivation is unreachable

The TMUX_TMPDIR-based socket_path auto-derivation block in Server.__init__ can never execute — its conditions are mutually contradictory. A few lines earlier the local is rebound (socket_name = self.socket_name or "default"), so self.socket_name is None implies the local socket_name == "default", and the two guards can never both hold.

Consequence: server.socket_path is always None unless the caller passed socket_path= explicitly. Verified: Server(socket_name="x").socket_path is None, and bare Server().socket_path is None.

This is why downstream code has to re-resolve the socket path by hand (re-deriving TMUX_TMPDIR + euid + socket name, then falling back to shelling out tmux display-message -p '#{socket_path}'). Every consumer that needs to prove which server it is talking to writes this twice.

Suggested fix: either repair the condition so the derivation runs, or delete the dead block and document that socket_path is populated only when supplied. Repairing it is better — a Server that knows its own socket path lets consumers do socket-identity checks without a subprocess.

2. There is no self-location API

TMUX_PANE does not appear anywhere in the package (verified by ripgrep over the whole tree at v0.61.0). There is no Server.from_env(), no current_pane(), no equivalent. The only place libtmux touches $TMUX at all is Server.new_session, which deletes it before shelling out (so a nested new-session doesn't inherit the parent server) and restores it in a finally — it never parses it.

So any process running inside a tmux pane — an agent, an MCP server, a CLI tool — must hand-roll:

pane_id = os.environ.get("TMUX_PANE")            # "%65"
socket_path, server_pid, session_id = os.environ["TMUX"].split(",")

…and then find a way to turn that into a Pane. The obvious route is a trap:

approach cost why
Pane.from_pane_id(...) 28.4 ms delegates to neo.fetch_obj → full list-panes -a with the wide -F template, then a Python-side linear scan (neo.py#L787)
server.panes.filter(pane_id=...) 28.7 ms QueryList.filter/get are pure-Python predicates over an already-enumerated list; they never push down to tmux (query_list.py#L513)
Server.search_panes(filter=...) 9.8 ms does push tmux's native -f, but still runs list-panes -a with the wide template (server.py#L2563)
Pane(server=srv, pane_id=...) + display_message 3.3 ms dataclass construction is free, then one targeted display-message

The fast path works only because of two facts a caller has to discover: Pane is a dataclass whose only required field is server (neo.py#L389), and Pane.cmd auto-injects -t <pane_id> (pane.py#L214). Nothing signposts this, so the natural-looking API (from_pane_id) is ~9× slower than the right one.

Note also that Client.attached_pane / Session.active_window / Window.active_pane are not substitutes: they resolve "currently focused" via list-clients / pane_active=1, not "the pane this process is running in". A headless process has no client row at all.

Proposal

libtmux.Server.from_env()   # parse $TMUX -> Server(socket_path=...)   (no subprocess)
libtmux.current_pane()      # read $TMUX_PANE -> Pane(server=..., pane_id=...)  (no subprocess)

Both are a handful of lines, need no tmux round-trip, and would let every downstream consumer stop re-implementing the same env parse. Bonus: socket_path and pid are already universal format tokens declared on Obj (neo.py#L146), so a single display-message can return the server's socket path and PID — exactly the first two components of TMUX=<socketpath>,<pid>,<session> — giving a zero-extra-cost proof that a pane really belongs to the caller's server.

3. Two smaller papercuts found alongside

(a) Server.start_server() leaves a stale socket file. It runs tmux start-server, but with exit-empty on (the default) the sessionless daemon exits immediately — is_alive() still returns False, yet a stale socket file remains on disk and survives kill(). So socket-file existence is not a valid liveness test, and start_server() alone cannot "ensure a server".

Suggested: document this, or have start_server() verify liveness and raise/warn.

(b) Error-handling asymmetry between .sessions and .panes. Server.sessions and Server.clients swallow every LibTmuxException and return an empty QueryList — so a permission error is indistinguishable from "no sessions". Server.panes/Server.windows instead go through _fetch_or_empty, which swallows only daemon-not-up errors (classified by _is_daemon_not_up_error) and re-raises everything else. Confirmed: a "File name too long" socket error propagates out of .panes but not out of .sessions.

Suggested: route .sessions/.clients through _fetch_or_empty too, so emptiness means the same thing everywhere.

(c) _is_daemon_not_up_error is private but universally needed. It is the canonical classifier for both tmux phrasings ("no server running" and "error connecting to … (No such file or directory)"). Downstream consumers currently string-match independently. Consider exporting it, or a public ServerStatus enum alongside is_alive().

(d) EnvironmentMixin.set_environment raises a bare ValueError on tmux stderr, bypassing the LibTmuxException hierarchy. Downstream error mappers key on LibTmuxException, so a routine "no tmux server running" escapes as an unexpected error.

Environment

  • libtmux: 0.61.0
  • tmux: 3.7b
  • Python: 3.14.0

Cross-references — filed together from a single audit of v0.1.0a17. The self-location gap is the root cause; the rest are what it exposed.

(← this issue: #704)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions