Skip to content

Commit 1bd85e1

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 1bd85e1

5 files changed

Lines changed: 147 additions & 16 deletions

File tree

CHANGES

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ $ uvx --from 'libtmux' --prerelease allow python
4545
_Notes on the upcoming release will go here._
4646
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
4747

48+
### Fixes
49+
50+
- {class}`~libtmux.Server` now reprs the socket path tmux resolves from
51+
`$TMUX_TMPDIR` instead of a hard-coded `/tmp/tmux-<euid>/default` (#723)
52+
4853
### Documentation
4954

5055
#### Cleaner `from_env` examples (#719)

src/libtmux/_internal/env.py

Lines changed: 67 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

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: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
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 resolve_socket_path, socket_path_from_env
2020
from libtmux._internal.query_list import QueryList
2121
from libtmux.client import Client
2222
from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd
@@ -2682,17 +2682,30 @@ def __eq__(self, other: object) -> bool:
26822682
return False
26832683

26842684
def __repr__(self) -> str:
2685-
"""Representation of :class:`Server` object."""
2685+
"""Representation of :class:`Server` object.
2686+
2687+
A server given neither ``socket_name`` nor ``socket_path`` talks to the
2688+
socket tmux resolves from ``$TMUX_TMPDIR``, so that is the path shown.
2689+
2690+
Examples
2691+
--------
2692+
>>> from libtmux.server import Server
2693+
>>> Server(socket_name="libtmux_repr_demo")
2694+
Server(socket_name=libtmux_repr_demo)
2695+
2696+
>>> Server(socket_path="/run/user/1000/tmux-1000/demo")
2697+
Server(socket_path=/run/user/1000/tmux-1000/demo)
2698+
2699+
>>> with monkeypatch.context() as m:
2700+
... m.setenv("TMUX_TMPDIR", "/run/user/1000")
2701+
... Server()
2702+
Server(socket_path=/run/user/1000/tmux-.../default)
2703+
"""
26862704
if self.socket_name is not None:
2687-
return (
2688-
f"{self.__class__.__name__}"
2689-
f"(socket_name={getattr(self, 'socket_name', 'default')})"
2690-
)
2705+
return f"{self.__class__.__name__}(socket_name={self.socket_name})"
26912706
if self.socket_path is not None:
26922707
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-
)
2708+
return f"{self.__class__.__name__}(socket_path={resolve_socket_path()})"
26962709

26972710
#
26982711
# Legacy: Redundant stuff we want to remove

tests/test_server.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,54 @@ 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.setenv("TMUX_TMPDIR", str(tmp_path))
77+
78+
myserver = Server()
79+
80+
socket_path = tmp_path / f"tmux-{os.geteuid()}" / "default"
81+
assert repr(myserver) == f"Server(socket_path={socket_path})"
82+
83+
84+
def test_repr_socket_path_resolves_a_symlinked_tmpdir(
85+
monkeypatch: pytest.MonkeyPatch,
86+
tmp_path: pathlib.Path,
87+
) -> None:
88+
"""A symlinked ``$TMUX_TMPDIR`` reprs the path tmux reports, not the link.
89+
90+
tmux resolves the socket directory before binding, so the link's own
91+
length is not what a socket has to fit in. Reporting the unresolved path
92+
would name a socket tmux never creates.
93+
"""
94+
target = tmp_path / "target"
95+
target.mkdir()
96+
link = tmp_path / "link"
97+
link.symlink_to(target)
98+
monkeypatch.setenv("TMUX_TMPDIR", str(link))
99+
100+
myserver = Server()
101+
102+
socket_path = target.resolve() / f"tmux-{os.geteuid()}" / "default"
103+
assert repr(myserver) == f"Server(socket_path={socket_path})"
104+
105+
106+
def test_repr_socket_name(monkeypatch: pytest.MonkeyPatch) -> None:
107+
"""A named socket reprs its name, whatever ``$TMUX_TMPDIR`` says."""
108+
monkeypatch.setenv("TMUX_TMPDIR", "/nonexistent-tmux-tmpdir")
109+
110+
myserver = Server(socket_name="libtmux_test_repr")
111+
112+
assert repr(myserver) == "Server(socket_name=libtmux_test_repr)"
113+
114+
67115
def test_config(server: Server) -> None:
68116
"""``-f`` file for tmux(1) configuration."""
69117
myserver = Server(config_file="test")

0 commit comments

Comments
 (0)