From 30e8ea877f93a3facbf2be3ebf82051e8097b6d2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 29 Jul 2026 18:38:58 -0500 Subject: [PATCH 1/2] Server(fix[__repr__]): Resolve socket dir from env why: The repr fell through to a hard-coded /tmp/tmux-/default when neither socket_name nor socket_path was given, which is every bare Server(). tmux resolves its socket directory from $TMUX_TMPDIR, so under a test harness, sandbox, or container the repr named a socket the object was not talking to -- in tracebacks, --showlocals banners, and logs, which is where someone is trying to tell servers apart. Closes #723. what: - Add libtmux._internal.env.resolve_socket_path(), which resolves tmux-/ under $TMUX_TMPDIR, else /tmp ($TMPDIR is not consulted, matching tmux) - Resolve the socket directory through symlinks, as tmux does before binding, so the path reported is the one tmux itself reports - Use it for the Server.__repr__ fall-through, and for the socket unlink in pytest_plugin._reap_test_server, which had to compute it inline - Drop the getattr(self, 'socket_name', 'default') in the socket_name branch, which had already established socket_name is not None - Scope the doctest's $TMUX_TMPDIR patch to a monkeypatch.context(), so it does not outlive the example and strand the fixture's server - Add regression tests for $TMUX_TMPDIR and for a symlinked one --- src/libtmux/_internal/env.py | 118 ++++++++++++++++++++++++++++++++++- src/libtmux/pytest_plugin.py | 11 ++-- src/libtmux/server.py | 49 ++++++++++++--- tests/test_server.py | 50 +++++++++++++++ 4 files changed, 212 insertions(+), 16 deletions(-) diff --git a/src/libtmux/_internal/env.py b/src/libtmux/_internal/env.py index 407b0934f..bc3af8218 100644 --- a/src/libtmux/_internal/env.py +++ b/src/libtmux/_internal/env.py @@ -1,4 +1,4 @@ -"""Readers for the tmux variables exported into every pane's environment. +"""Readers for the tmux variables libtmux takes its bearings from. libtmux._internal.env ~~~~~~~~~~~~~~~~~~~~~ @@ -24,11 +24,16 @@ libtmux therefore reads *only* the socket path out of ``TMUX`` and asks tmux itself -- targeting ``TMUX_PANE`` -- for the pane's window and session. See :meth:`libtmux.Pane.from_env`. + +A third variable, ``TMUX_TMPDIR``, is read *by* tmux rather than exported by +it: it picks the directory tmux keeps its sockets in. See +:func:`resolve_socket_path`. """ from __future__ import annotations import os +import pathlib import typing as t from libtmux import exc @@ -39,6 +44,15 @@ TMUX_PANE: t.Final = "TMUX_PANE" """Environment variable tmux exports with the pane's id, e.g. ``%3``.""" +TMUX_TMPDIR: t.Final = "TMUX_TMPDIR" +"""Environment variable naming the directory tmux keeps its sockets in.""" + +DEFAULT_SOCKET_DIR: t.Final = "/tmp" +"""Socket directory tmux falls back to when ``$TMUX_TMPDIR`` is unset.""" + +DEFAULT_SOCKET_NAME: t.Final = "default" +"""Socket name tmux uses when neither ``-L`` nor ``-S`` was given.""" + def resolve_env(env: t.Mapping[str, str] | None = None) -> t.Mapping[str, str]: """Return *env*, defaulting to the live process environment. @@ -65,6 +79,58 @@ def resolve_env(env: t.Mapping[str, str] | None = None) -> t.Mapping[str, str]: return os.environ if env is None else env +def resolve_socket_path( + socket_name: str | None = None, + env: t.Mapping[str, str] | None = None, +) -> pathlib.Path: + """Resolve the socket path tmux uses for *socket_name*. + + tmux keeps its sockets in ``tmux-`` under ``$TMUX_TMPDIR``, falling + back to ``/tmp`` when that is unset or empty. ``$TMPDIR`` is deliberately + not consulted -- tmux does not consult it either. The socket directory is + resolved through symlinks, as tmux resolves it before binding, so a + symlinked ``$TMUX_TMPDIR`` yields the path tmux itself reports. + + The path is *computed*, not observed: it says where tmux would put the + socket, not that a daemon is listening there. Code holding a live + :class:`~libtmux.Server` should ask tmux instead, with the + ``#{socket_path}`` format. + + Parameters + ---------- + socket_name : str, optional + Socket name, as passed to tmux's ``-L``. Defaults to tmux's own + default, ``"default"``. + env : :class:`typing.Mapping`, optional + Environment to read. Defaults to :data:`os.environ`. + + Returns + ------- + :class:`pathlib.Path` + Path tmux resolves the socket to. + + Examples + -------- + >>> from libtmux._internal.env import resolve_socket_path + >>> resolve_socket_path(env={}) + PosixPath('/tmp/tmux-.../default') + + >>> resolve_socket_path("mysocket", env={"TMUX_TMPDIR": "/run/user/1000"}) + PosixPath('/run/user/1000/tmux-.../mysocket') + + ``$TMPDIR`` is not a socket directory, so it changes nothing: + + >>> resolve_socket_path(env={"TMPDIR": "/var/folders/xy"}) + PosixPath('/tmp/tmux-.../default') + """ + tmpdir = resolve_env(env).get(TMUX_TMPDIR) or DEFAULT_SOCKET_DIR + return ( + pathlib.Path(tmpdir).resolve() + / f"tmux-{os.geteuid()}" + / (socket_name or DEFAULT_SOCKET_NAME) + ) + + def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str: """Return the tmux socket path recorded in ``$TMUX``. @@ -122,6 +188,56 @@ def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str: return parts[0] +def resolve_ambient_socket_path(env: t.Mapping[str, str] | None = None) -> pathlib.Path: + """Resolve the socket a *bare* tmux invocation talks to, in tmux's own order. + + A tmux client given no ``-L`` or ``-S`` prefers ``$TMUX`` -- the socket of + the pane it is running inside -- and only falls back to computing a path + under ``$TMUX_TMPDIR`` when there is no pane. Measured against tmux 3.7b: a + bare client with ``$TMUX`` set connects even when ``$TMUX_TMPDIR`` names a + directory far too deep to bind, because it never looks there. + + That order only holds for the bare client. Passing ``-L`` sends tmux to + ``$TMUX_TMPDIR`` regardless of ``$TMUX``, so a named socket resolves through + :func:`resolve_socket_path` instead. + + Parameters + ---------- + env : :class:`typing.Mapping`, optional + Environment to read. Defaults to :data:`os.environ`. + + Returns + ------- + :class:`pathlib.Path` + Socket path a bare tmux client would use. + + Examples + -------- + >>> from libtmux._internal.env import resolve_ambient_socket_path + + Inside a pane, ``$TMUX`` names the socket outright: + + >>> resolve_ambient_socket_path({"TMUX": "/tmp/tmux-1000/default,8421,0"}) + PosixPath('/tmp/tmux-1000/default') + + ``$TMUX_TMPDIR`` is not consulted when there is a pane to inherit from: + + >>> resolve_ambient_socket_path( + ... {"TMUX": "/tmp/sock,8421,0", "TMUX_TMPDIR": "/nowhere"} + ... ) + PosixPath('/tmp/sock') + + Outside tmux it falls back to the computed path: + + >>> resolve_ambient_socket_path({}) + PosixPath('/tmp/tmux-.../default') + """ + try: + return pathlib.Path(socket_path_from_env(env)) + except exc.NotInsideTmux: + return resolve_socket_path(env=env) + + def pane_id_from_env(env: t.Mapping[str, str] | None = None) -> str: """Return the pane id recorded in ``$TMUX_PANE``. diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index fcc3ce052..758de21ab 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -14,6 +14,7 @@ from libtmux import exc from libtmux._internal.control_mode import ControlMode +from libtmux._internal.env import resolve_socket_path from libtmux.server import Server from libtmux.test.constants import TEST_SESSION_PREFIX from libtmux.test.random import get_test_session_name, namer @@ -49,12 +50,10 @@ def _reap_test_server(socket_name: str | None) -> None: if srv.is_alive(): srv.kill() - # ``Server(socket_name=...)`` does not populate ``socket_path`` — - # the Server class only derives the path when neither ``socket_name`` - # nor ``socket_path`` was supplied. Recompute the location tmux uses - # so we can unlink the file regardless of daemon state. - tmux_tmpdir = pathlib.Path(os.environ.get("TMUX_TMPDIR", "/tmp")) - socket_path = tmux_tmpdir / f"tmux-{os.geteuid()}" / socket_name + # ``Server(socket_name=...)`` does not populate ``socket_path``, so + # resolve where tmux put the socket to unlink it regardless of daemon + # state. + socket_path = resolve_socket_path(socket_name) with contextlib.suppress(OSError): socket_path.unlink(missing_ok=True) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c3..cc2938b72 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -16,7 +16,10 @@ import warnings from libtmux import exc -from libtmux._internal.env import socket_path_from_env +from libtmux._internal.env import ( + resolve_ambient_socket_path, + socket_path_from_env, +) from libtmux._internal.query_list import QueryList from libtmux.client import Client from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd @@ -2682,17 +2685,45 @@ def __eq__(self, other: object) -> bool: return False def __repr__(self) -> str: - """Representation of :class:`Server` object.""" + """Representation of :class:`Server` object. + + A server given neither ``socket_name`` nor ``socket_path`` talks to + whichever socket a bare tmux client would: ``$TMUX`` when it runs + inside a pane, and the path resolved from ``$TMUX_TMPDIR`` otherwise. + That is the path shown, so the repr names the server the object will + actually reach. + + Examples + -------- + >>> from libtmux.server import Server + >>> Server(socket_name="libtmux_repr_demo") + Server(socket_name=libtmux_repr_demo) + + >>> Server(socket_path="/run/user/1000/tmux-1000/demo") + Server(socket_path=/run/user/1000/tmux-1000/demo) + + Outside a pane the socket directory is the one ``$TMUX_TMPDIR`` names: + + >>> with monkeypatch.context() as m: + ... m.delenv("TMUX", raising=False) + ... m.setenv("TMUX_TMPDIR", "/run/user/1000") + ... Server() + Server(socket_path=/run/user/1000/tmux-.../default) + + Inside one, ``$TMUX`` names the socket outright and the directory is + not consulted: + + >>> with monkeypatch.context() as m: + ... m.setenv("TMUX", "/tmp/tmux-1000/inherited,8421,0") + ... m.setenv("TMUX_TMPDIR", "/run/user/1000") + ... Server() + Server(socket_path=/tmp/tmux-1000/inherited) + """ if self.socket_name is not None: - return ( - f"{self.__class__.__name__}" - f"(socket_name={getattr(self, 'socket_name', 'default')})" - ) + return f"{self.__class__.__name__}(socket_name={self.socket_name})" if self.socket_path is not None: return f"{self.__class__.__name__}(socket_path={self.socket_path})" - return ( - f"{self.__class__.__name__}(socket_path=/tmp/tmux-{os.geteuid()}/default)" - ) + return f"{self.__class__.__name__}(socket_path={resolve_ambient_socket_path()})" # # Legacy: Redundant stuff we want to remove diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9a..a750f246f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -64,6 +64,56 @@ def test_socket_path_not_derived_from_socket_name() -> None: assert myserver.socket_path is None +def test_repr_socket_path_honors_tmux_tmpdir( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """A default ``Server()`` reprs the socket tmux resolves, not ``/tmp``. + + Regression for #723: the repr hard-coded ``/tmp/tmux-/default``, so + under any ``$TMUX_TMPDIR`` it named a socket the object was not using. + """ + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path)) + + myserver = Server() + + socket_path = tmp_path / f"tmux-{os.geteuid()}" / "default" + assert repr(myserver) == f"Server(socket_path={socket_path})" + + +def test_repr_socket_path_resolves_a_symlinked_tmpdir( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """A symlinked ``$TMUX_TMPDIR`` reprs the path tmux reports, not the link. + + tmux resolves the socket directory before binding, so the link's own + length is not what a socket has to fit in. Reporting the unresolved path + would name a socket tmux never creates. + """ + target = tmp_path / "target" + target.mkdir() + link = tmp_path / "link" + link.symlink_to(target) + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setenv("TMUX_TMPDIR", str(link)) + + myserver = Server() + + socket_path = target.resolve() / f"tmux-{os.geteuid()}" / "default" + assert repr(myserver) == f"Server(socket_path={socket_path})" + + +def test_repr_socket_name(monkeypatch: pytest.MonkeyPatch) -> None: + """A named socket reprs its name, whatever ``$TMUX_TMPDIR`` says.""" + monkeypatch.setenv("TMUX_TMPDIR", "/nonexistent-tmux-tmpdir") + + myserver = Server(socket_name="libtmux_test_repr") + + assert repr(myserver) == "Server(socket_name=libtmux_test_repr)" + + def test_config(server: Server) -> None: """``-f`` file for tmux(1) configuration.""" myserver = Server(config_file="test") From 6ca471e3d9965ad0c2f3f997611516527c33d198 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 1 Aug 2026 05:41:54 -0500 Subject: [PATCH 2/2] docs(CHANGES): Server repr names the socket it uses why: The old repr named a socket the object was not talking to, so anyone debugging which server they held was misled by the one line they would look at first. what: - Record the repr fix under `Fixes`. --- CHANGES | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES b/CHANGES index 7a691b0cc..928080202 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,11 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### Fixes + +- {class}`~libtmux.Server` now reprs the socket path tmux resolves from + `$TMUX_TMPDIR` instead of a hard-coded `/tmp/tmux-/default` (#723) + ### Documentation #### Cleaner `from_env` examples (#719)