Skip to content

Commit 30e8ea8

Browse files
committed
Server(fix[__repr__]): Resolve socket dir from env
why: The repr fell through to a hard-coded /tmp/tmux-<euid>/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-<euid>/<socket_name or "default"> 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
1 parent be7c8c1 commit 30e8ea8

4 files changed

Lines changed: 212 additions & 16 deletions

File tree

src/libtmux/_internal/env.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Readers for the tmux variables exported into every pane's environment.
1+
"""Readers for the tmux variables libtmux takes its bearings from.
22
33
libtmux._internal.env
44
~~~~~~~~~~~~~~~~~~~~~
@@ -24,11 +24,16 @@
2424
libtmux therefore reads *only* the socket path out of ``TMUX`` and asks tmux
2525
itself -- targeting ``TMUX_PANE`` -- for the pane's window and session. See
2626
:meth:`libtmux.Pane.from_env`.
27+
28+
A third variable, ``TMUX_TMPDIR``, is read *by* tmux rather than exported by
29+
it: it picks the directory tmux keeps its sockets in. See
30+
:func:`resolve_socket_path`.
2731
"""
2832

2933
from __future__ import annotations
3034

3135
import os
36+
import pathlib
3237
import typing as t
3338

3439
from libtmux import exc
@@ -39,6 +44,15 @@
3944
TMUX_PANE: t.Final = "TMUX_PANE"
4045
"""Environment variable tmux exports with the pane's id, e.g. ``%3``."""
4146

47+
TMUX_TMPDIR: t.Final = "TMUX_TMPDIR"
48+
"""Environment variable naming the directory tmux keeps its sockets in."""
49+
50+
DEFAULT_SOCKET_DIR: t.Final = "/tmp"
51+
"""Socket directory tmux falls back to when ``$TMUX_TMPDIR`` is unset."""
52+
53+
DEFAULT_SOCKET_NAME: t.Final = "default"
54+
"""Socket name tmux uses when neither ``-L`` nor ``-S`` was given."""
55+
4256

4357
def resolve_env(env: t.Mapping[str, str] | None = None) -> t.Mapping[str, str]:
4458
"""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]:
6579
return os.environ if env is None else env
6680

6781

82+
def resolve_socket_path(
83+
socket_name: str | None = None,
84+
env: t.Mapping[str, str] | None = None,
85+
) -> pathlib.Path:
86+
"""Resolve the socket path tmux uses for *socket_name*.
87+
88+
tmux keeps its sockets in ``tmux-<euid>`` under ``$TMUX_TMPDIR``, falling
89+
back to ``/tmp`` when that is unset or empty. ``$TMPDIR`` is deliberately
90+
not consulted -- tmux does not consult it either. The socket directory is
91+
resolved through symlinks, as tmux resolves it before binding, so a
92+
symlinked ``$TMUX_TMPDIR`` yields the path tmux itself reports.
93+
94+
The path is *computed*, not observed: it says where tmux would put the
95+
socket, not that a daemon is listening there. Code holding a live
96+
:class:`~libtmux.Server` should ask tmux instead, with the
97+
``#{socket_path}`` format.
98+
99+
Parameters
100+
----------
101+
socket_name : str, optional
102+
Socket name, as passed to tmux's ``-L``. Defaults to tmux's own
103+
default, ``"default"``.
104+
env : :class:`typing.Mapping`, optional
105+
Environment to read. Defaults to :data:`os.environ`.
106+
107+
Returns
108+
-------
109+
:class:`pathlib.Path`
110+
Path tmux resolves the socket to.
111+
112+
Examples
113+
--------
114+
>>> from libtmux._internal.env import resolve_socket_path
115+
>>> resolve_socket_path(env={})
116+
PosixPath('/tmp/tmux-.../default')
117+
118+
>>> resolve_socket_path("mysocket", env={"TMUX_TMPDIR": "/run/user/1000"})
119+
PosixPath('/run/user/1000/tmux-.../mysocket')
120+
121+
``$TMPDIR`` is not a socket directory, so it changes nothing:
122+
123+
>>> resolve_socket_path(env={"TMPDIR": "/var/folders/xy"})
124+
PosixPath('/tmp/tmux-.../default')
125+
"""
126+
tmpdir = resolve_env(env).get(TMUX_TMPDIR) or DEFAULT_SOCKET_DIR
127+
return (
128+
pathlib.Path(tmpdir).resolve()
129+
/ f"tmux-{os.geteuid()}"
130+
/ (socket_name or DEFAULT_SOCKET_NAME)
131+
)
132+
133+
68134
def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str:
69135
"""Return the tmux socket path recorded in ``$TMUX``.
70136
@@ -122,6 +188,56 @@ def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str:
122188
return parts[0]
123189

124190

191+
def resolve_ambient_socket_path(env: t.Mapping[str, str] | None = None) -> pathlib.Path:
192+
"""Resolve the socket a *bare* tmux invocation talks to, in tmux's own order.
193+
194+
A tmux client given no ``-L`` or ``-S`` prefers ``$TMUX`` -- the socket of
195+
the pane it is running inside -- and only falls back to computing a path
196+
under ``$TMUX_TMPDIR`` when there is no pane. Measured against tmux 3.7b: a
197+
bare client with ``$TMUX`` set connects even when ``$TMUX_TMPDIR`` names a
198+
directory far too deep to bind, because it never looks there.
199+
200+
That order only holds for the bare client. Passing ``-L`` sends tmux to
201+
``$TMUX_TMPDIR`` regardless of ``$TMUX``, so a named socket resolves through
202+
:func:`resolve_socket_path` instead.
203+
204+
Parameters
205+
----------
206+
env : :class:`typing.Mapping`, optional
207+
Environment to read. Defaults to :data:`os.environ`.
208+
209+
Returns
210+
-------
211+
:class:`pathlib.Path`
212+
Socket path a bare tmux client would use.
213+
214+
Examples
215+
--------
216+
>>> from libtmux._internal.env import resolve_ambient_socket_path
217+
218+
Inside a pane, ``$TMUX`` names the socket outright:
219+
220+
>>> resolve_ambient_socket_path({"TMUX": "/tmp/tmux-1000/default,8421,0"})
221+
PosixPath('/tmp/tmux-1000/default')
222+
223+
``$TMUX_TMPDIR`` is not consulted when there is a pane to inherit from:
224+
225+
>>> resolve_ambient_socket_path(
226+
... {"TMUX": "/tmp/sock,8421,0", "TMUX_TMPDIR": "/nowhere"}
227+
... )
228+
PosixPath('/tmp/sock')
229+
230+
Outside tmux it falls back to the computed path:
231+
232+
>>> resolve_ambient_socket_path({})
233+
PosixPath('/tmp/tmux-.../default')
234+
"""
235+
try:
236+
return pathlib.Path(socket_path_from_env(env))
237+
except exc.NotInsideTmux:
238+
return resolve_socket_path(env=env)
239+
240+
125241
def pane_id_from_env(env: t.Mapping[str, str] | None = None) -> str:
126242
"""Return the pane id recorded in ``$TMUX_PANE``.
127243

src/libtmux/pytest_plugin.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from libtmux import exc
1616
from libtmux._internal.control_mode import ControlMode
17+
from libtmux._internal.env import resolve_socket_path
1718
from libtmux.server import Server
1819
from libtmux.test.constants import TEST_SESSION_PREFIX
1920
from libtmux.test.random import get_test_session_name, namer
@@ -49,12 +50,10 @@ def _reap_test_server(socket_name: str | None) -> None:
4950
if srv.is_alive():
5051
srv.kill()
5152

52-
# ``Server(socket_name=...)`` does not populate ``socket_path`` —
53-
# the Server class only derives the path when neither ``socket_name``
54-
# nor ``socket_path`` was supplied. Recompute the location tmux uses
55-
# so we can unlink the file regardless of daemon state.
56-
tmux_tmpdir = pathlib.Path(os.environ.get("TMUX_TMPDIR", "/tmp"))
57-
socket_path = tmux_tmpdir / f"tmux-{os.geteuid()}" / socket_name
53+
# ``Server(socket_name=...)`` does not populate ``socket_path``, so
54+
# resolve where tmux put the socket to unlink it regardless of daemon
55+
# state.
56+
socket_path = resolve_socket_path(socket_name)
5857
with contextlib.suppress(OSError):
5958
socket_path.unlink(missing_ok=True)
6059

src/libtmux/server.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
import warnings
1717

1818
from libtmux import exc
19-
from libtmux._internal.env import socket_path_from_env
19+
from libtmux._internal.env import (
20+
resolve_ambient_socket_path,
21+
socket_path_from_env,
22+
)
2023
from libtmux._internal.query_list import QueryList
2124
from libtmux.client import Client
2225
from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd
@@ -2682,17 +2685,45 @@ def __eq__(self, other: object) -> bool:
26822685
return False
26832686

26842687
def __repr__(self) -> str:
2685-
"""Representation of :class:`Server` object."""
2688+
"""Representation of :class:`Server` object.
2689+
2690+
A server given neither ``socket_name`` nor ``socket_path`` talks to
2691+
whichever socket a bare tmux client would: ``$TMUX`` when it runs
2692+
inside a pane, and the path resolved from ``$TMUX_TMPDIR`` otherwise.
2693+
That is the path shown, so the repr names the server the object will
2694+
actually reach.
2695+
2696+
Examples
2697+
--------
2698+
>>> from libtmux.server import Server
2699+
>>> Server(socket_name="libtmux_repr_demo")
2700+
Server(socket_name=libtmux_repr_demo)
2701+
2702+
>>> Server(socket_path="/run/user/1000/tmux-1000/demo")
2703+
Server(socket_path=/run/user/1000/tmux-1000/demo)
2704+
2705+
Outside a pane the socket directory is the one ``$TMUX_TMPDIR`` names:
2706+
2707+
>>> with monkeypatch.context() as m:
2708+
... m.delenv("TMUX", raising=False)
2709+
... m.setenv("TMUX_TMPDIR", "/run/user/1000")
2710+
... Server()
2711+
Server(socket_path=/run/user/1000/tmux-.../default)
2712+
2713+
Inside one, ``$TMUX`` names the socket outright and the directory is
2714+
not consulted:
2715+
2716+
>>> with monkeypatch.context() as m:
2717+
... m.setenv("TMUX", "/tmp/tmux-1000/inherited,8421,0")
2718+
... m.setenv("TMUX_TMPDIR", "/run/user/1000")
2719+
... Server()
2720+
Server(socket_path=/tmp/tmux-1000/inherited)
2721+
"""
26862722
if self.socket_name is not None:
2687-
return (
2688-
f"{self.__class__.__name__}"
2689-
f"(socket_name={getattr(self, 'socket_name', 'default')})"
2690-
)
2723+
return f"{self.__class__.__name__}(socket_name={self.socket_name})"
26912724
if self.socket_path is not None:
26922725
return f"{self.__class__.__name__}(socket_path={self.socket_path})"
2693-
return (
2694-
f"{self.__class__.__name__}(socket_path=/tmp/tmux-{os.geteuid()}/default)"
2695-
)
2726+
return f"{self.__class__.__name__}(socket_path={resolve_ambient_socket_path()})"
26962727

26972728
#
26982729
# Legacy: Redundant stuff we want to remove

tests/test_server.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,56 @@ def test_socket_path_not_derived_from_socket_name() -> None:
6464
assert myserver.socket_path is None
6565

6666

67+
def test_repr_socket_path_honors_tmux_tmpdir(
68+
monkeypatch: pytest.MonkeyPatch,
69+
tmp_path: pathlib.Path,
70+
) -> None:
71+
"""A default ``Server()`` reprs the socket tmux resolves, not ``/tmp``.
72+
73+
Regression for #723: the repr hard-coded ``/tmp/tmux-<euid>/default``, so
74+
under any ``$TMUX_TMPDIR`` it named a socket the object was not using.
75+
"""
76+
monkeypatch.delenv("TMUX", raising=False)
77+
monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))
78+
79+
myserver = Server()
80+
81+
socket_path = tmp_path / f"tmux-{os.geteuid()}" / "default"
82+
assert repr(myserver) == f"Server(socket_path={socket_path})"
83+
84+
85+
def test_repr_socket_path_resolves_a_symlinked_tmpdir(
86+
monkeypatch: pytest.MonkeyPatch,
87+
tmp_path: pathlib.Path,
88+
) -> None:
89+
"""A symlinked ``$TMUX_TMPDIR`` reprs the path tmux reports, not the link.
90+
91+
tmux resolves the socket directory before binding, so the link's own
92+
length is not what a socket has to fit in. Reporting the unresolved path
93+
would name a socket tmux never creates.
94+
"""
95+
target = tmp_path / "target"
96+
target.mkdir()
97+
link = tmp_path / "link"
98+
link.symlink_to(target)
99+
monkeypatch.delenv("TMUX", raising=False)
100+
monkeypatch.setenv("TMUX_TMPDIR", str(link))
101+
102+
myserver = Server()
103+
104+
socket_path = target.resolve() / f"tmux-{os.geteuid()}" / "default"
105+
assert repr(myserver) == f"Server(socket_path={socket_path})"
106+
107+
108+
def test_repr_socket_name(monkeypatch: pytest.MonkeyPatch) -> None:
109+
"""A named socket reprs its name, whatever ``$TMUX_TMPDIR`` says."""
110+
monkeypatch.setenv("TMUX_TMPDIR", "/nonexistent-tmux-tmpdir")
111+
112+
myserver = Server(socket_name="libtmux_test_repr")
113+
114+
assert repr(myserver) == "Server(socket_name=libtmux_test_repr)"
115+
116+
67117
def test_config(server: Server) -> None:
68118
"""``-f`` file for tmux(1) configuration."""
69119
myserver = Server(config_file="test")

0 commit comments

Comments
 (0)