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) diff --git a/src/libtmux/_internal/env.py b/src/libtmux/_internal/env.py index 407b0934f..f257ea440 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``. 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..a3e5bdff3 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -16,7 +16,7 @@ import warnings from libtmux import exc -from libtmux._internal.env import socket_path_from_env +from libtmux._internal.env import resolve_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 +2682,30 @@ 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 the + socket tmux resolves from ``$TMUX_TMPDIR``, so that is the path shown. + + 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) + + >>> with monkeypatch.context() as m: + ... m.setenv("TMUX_TMPDIR", "/run/user/1000") + ... Server() + Server(socket_path=/run/user/1000/tmux-.../default) + """ 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_socket_path()})" # # Legacy: Redundant stuff we want to remove diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9a..625addf0a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -64,6 +64,54 @@ 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.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.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")