Skip to content

Commit 4ad7f9c

Browse files
committed
Server(fix[socket]): Check socket path length
why: A tmux socket is a UNIX domain socket, so its path is capped by `sockaddr_un` -- 107 bytes on Linux, 103 on macOS. tmux reports an overrun as `error connecting to <path> (File name too long)`, which names the path but not how far over it is, nor which variable made it that long. The overrun is usually inherited rather than typed: a deep pytest `tmp_path`, an XDG runtime dir, a nested worktree. what: - Add `exc.SocketPathTooLong` carrying the path, the byte count, how far over the limit it is, and the environment variable it came from. - Measure each route where tmux itself resolves it. A `socket_path` is passed through unchanged as `-S<path>`, so it is settled at construction and refused there. A `socket_name` resolves against `$TMUX_TMPDIR`, which tmux re-reads at exec rather than when the Server was built, so measuring it at construction would check a value with no bearing on what tmux binds -- it is measured per command instead, where `colors` is already checked. - Add `Server.socket_args()` and build every tmux spawn from it. The flags were previously reconstructed in four places -- `cmd`, `raise_if_dead`, the format-query layer and the control-mode client, the last two spawning tmux themselves -- so a check on any one of them would have covered part of the surface. - Resolve a bare server's socket the way a bare tmux client does, preferring `$TMUX` over `$TMUX_TMPDIR`. Inside a pane tmux never consults the directory, so measuring it would refuse a server tmux reaches without difficulty. - Constructing a named or bare Server has no side effect, so `is_alive()` still answers for a server that cannot be reached, and `raise_if_dead()` still reports why. Fixes #725
1 parent 1bd85e1 commit 4ad7f9c

9 files changed

Lines changed: 881 additions & 47 deletions

File tree

CHANGES

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,53 @@ $ 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+
### What's new
49+
50+
#### Socket paths are measured before tmux sees them (#730)
51+
52+
A tmux socket is a UNIX domain socket, so its path is capped by `sockaddr_un`
53+
— 107 bytes on Linux, 103 on macOS. {class}`~libtmux.Server` now measures the
54+
path and raises the new {exc}`~libtmux.exc.SocketPathTooLong`, carrying the
55+
byte count, how far over the limit it is, and where the length came from.
56+
tmux reports the overrun as `error connecting to <path> (File name too long)`,
57+
which names the path but not the numbers, nor which variable made it long.
58+
59+
Where the measurement happens follows what tmux actually reads. A
60+
`socket_path` is passed through unchanged as `-S<path>`, so it is measured at
61+
construction, where the caller can still change it. A `socket_name` resolves
62+
against `$TMUX_TMPDIR` — which tmux re-reads when it runs, not when the
63+
{class}`~libtmux.Server` was built — so it is measured on each command
64+
instead. The inherited case is the one that bites: a pytest `tmp_path`, an XDG
65+
runtime dir, a nested worktree, a CI checkout under a long workspace prefix.
66+
The fix is a shorter socket directory: {func}`tempfile.mkdtemp` or a short
67+
`$TMUX_TMPDIR`. See {ref}`socket_path_length` for the pytest case.
68+
69+
Naming a server that way stays free of side effects, so
70+
{meth}`~libtmux.Server.is_alive` keeps answering — an address the kernel
71+
cannot hold is one more way of not being alive — and
72+
{meth}`~libtmux.Server.raise_if_dead` keeps being the way to ask why.
73+
74+
A bare {class}`~libtmux.Server` inside a tmux pane is left alone. tmux prefers
75+
`$TMUX` over `$TMUX_TMPDIR` when no socket is named, so a script running
76+
inside tmux is measured against the socket it will actually use rather than a
77+
directory tmux never consults.
78+
79+
#### `Server.socket_args()` builds a server's socket flags (#730)
80+
81+
{meth}`Server.socket_args() <libtmux.Server.socket_args>` returns the
82+
`-S`/`-L` flags that address a server, measuring the socket path on the way.
83+
Every libtmux path that spawns tmux — {meth}`~libtmux.Server.cmd`,
84+
{meth}`~libtmux.Server.raise_if_dead`, the format-query layer, the control-mode
85+
client — builds its argv from it, and code that shells out to tmux on a
86+
libtmux {class}`~libtmux.Server`'s behalf can do the same instead of
87+
reconstructing the flags from {attr}`~libtmux.Server.socket_name` and
88+
{attr}`~libtmux.Server.socket_path`.
89+
4890
### Fixes
4991

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)
92+
- {class}`~libtmux.Server` now reprs the socket a bare tmux client would use —
93+
`$TMUX` inside a pane, otherwise the path resolved from `$TMUX_TMPDIR`
94+
instead of a hard-coded `/tmp/tmux-<euid>/default` (#727)
5295

5396
### Documentation
5497

docs/api/testing/pytest-plugin/usage.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,51 @@ True
112112

113113
This is particularly useful when testing interactions between multiple tmux servers or when you need to verify behavior across server restarts.
114114

115+
(socket_path_length)=
116+
117+
### Socket paths and the UNIX socket limit
118+
119+
A tmux socket is a UNIX domain socket, so its path is capped by `sockaddr_un`
120+
— 107 bytes on Linux, 103 on macOS. pytest's {fixture}`tmp_path` is nested
121+
deep by design (`/tmp/pytest-of-<user>/pytest-<n>/<test-name><n>`), so putting
122+
a socket under it — directly, or by pointing `TMUX_TMPDIR` at it — can overrun
123+
the limit on a long test name or a long temporary root. {class}`~libtmux.Server`
124+
measures an explicit `socket_path` as soon as it is passed and raises
125+
{exc}`~libtmux.exc.SocketPathTooLong` with the byte count, rather than letting
126+
tmux report `File name too long` with only the path to go on:
127+
128+
```python
129+
>>> from libtmux import exc
130+
>>> from libtmux.server import Server as TmuxServer
131+
>>> deep_socket = "/tmp/" + "d" * 120 + "/sock"
132+
>>> try:
133+
... TmuxServer(socket_path=deep_socket)
134+
... except exc.SocketPathTooLong as e:
135+
... print(e.length)
136+
130
137+
```
138+
139+
A `socket_name` is different: tmux resolves it against `$TMUX_TMPDIR` when it
140+
runs, so the length is only knowable at dispatch. Building the object is safe,
141+
and a test that only asks whether a server is there gets an answer instead of an
142+
exception — an unbindable address is one more way of not being alive:
143+
144+
```python
145+
>>> from libtmux.server import Server as TmuxServer
146+
>>> with monkeypatch.context() as m:
147+
... m.setenv("TMUX_TMPDIR", "/tmp/" + "d" * 120)
148+
... TmuxServer(socket_name="deep").is_alive()
149+
False
150+
```
151+
152+
The fixtures in this plugin sidestep it: {fixture}`server
153+
<libtmux.pytest_plugin.server>` and {fixture}`TestServer
154+
<libtmux.pytest_plugin.TestServer>` name their sockets with `socket_name`, which
155+
tmux resolves under its own short socket directory. In your own tests, keep
156+
`tmp_path` for files and reach for {func}`tempfile.mkdtemp` — which gives a
157+
short `/tmp/<random>` — when you need a socket path of your own, or point
158+
`TMUX_TMPDIR` somewhere short.
159+
115160
(set_home)=
116161

117162
### Setting a temporary home directory

docs/topics/configuration.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,16 @@ without you arranging anything.
4646

4747
That leaves the two variables that *are* yours to set, and most people
4848
set neither. `TMUX_TMPDIR` is tmux's own — the directory it keeps sockets
49-
in. libtmux never reads it, but the tmux binary it shells out to does, so
50-
it shapes which server a bare {class}`~libtmux.Server` lands on; pass
51-
`socket_name` or `socket_path` when you would rather name the server
52-
outright. `LIBTMUX_TMUX_FORMAT_SEPARATOR` is the one variable libtmux
53-
itself defines: an advanced override for the separator (default ``) it
54-
uses internally to parse tmux's format output — you'd touch it only if
55-
that character ever collided with your own data.
49+
in. The tmux binary libtmux shells out to reads it, so it shapes which
50+
server a bare {class}`~libtmux.Server` lands on; pass `socket_name` or
51+
`socket_path` when you would rather name the server outright. libtmux
52+
reads it only to know where the socket lands, which is also how it can
53+
tell you that a deep `TMUX_TMPDIR` pushes the resolved path past what a
54+
UNIX socket address holds — {exc}`~libtmux.exc.SocketPathTooLong`, see
55+
{ref}`socket_path_length`. `LIBTMUX_TMUX_FORMAT_SEPARATOR` is the one
56+
variable libtmux itself defines: an advanced override for the separator
57+
(default ``) it uses internally to parse tmux's format output — you'd
58+
touch it only if that character ever collided with your own data.
5659

5760
## Format strings
5861

src/libtmux/_internal/control_mode.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,9 @@ def __enter__(self) -> Self:
6464

6565
tmux_bin = self.server.tmux_bin or "tmux"
6666

67-
if self.server.socket_name is not None:
68-
socket_args = ["-L", str(self.server.socket_name)]
69-
elif self.server.socket_path is not None:
70-
socket_args = ["-S", str(self.server.socket_path)]
71-
else:
72-
socket_args = []
73-
7467
cmd = [
7568
tmux_bin,
76-
*socket_args,
69+
*self.server.socket_args(),
7770
"-C",
7871
"attach-session",
7972
"-t",

src/libtmux/_internal/env.py

Lines changed: 153 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,14 @@
3434

3535
import os
3636
import pathlib
37+
import sys
3738
import typing as t
3839

3940
from libtmux import exc
4041

42+
if t.TYPE_CHECKING:
43+
from libtmux._internal.types import StrPath
44+
4145
TMUX: t.Final = "TMUX"
4246
"""Environment variable tmux exports with ``socket_path,server_pid,session_id``."""
4347

@@ -53,6 +57,20 @@
5357
DEFAULT_SOCKET_NAME: t.Final = "default"
5458
"""Socket name tmux uses when neither ``-L`` nor ``-S`` was given."""
5559

60+
# ``sun_path`` in ``struct sockaddr_un`` is a fixed-size char array, and the
61+
# stdlib publishes no constant for its size, so it is spelled out per platform.
62+
# The size is part of each platform's frozen ABI: 104 bytes on the BSD-derived
63+
# kernels (macOS, FreeBSD, OpenBSD, NetBSD), 108 on Linux and elsewhere. One
64+
# byte of it is the NUL terminator. The test suite probes the running kernel to
65+
# keep this honest, which reads better than bisecting for the limit at import
66+
# time.
67+
_SUN_PATH_SIZE: t.Final = (
68+
104 if sys.platform.startswith(("darwin", "freebsd", "openbsd", "netbsd")) else 108
69+
)
70+
71+
SOCKET_PATH_MAX_BYTES: t.Final = _SUN_PATH_SIZE - 1
72+
"""Bytes a tmux socket path may occupy on this platform."""
73+
5674

5775
def resolve_env(env: t.Mapping[str, str] | None = None) -> t.Mapping[str, str]:
5876
"""Return *env*, defaulting to the live process environment.
@@ -91,6 +109,11 @@ def resolve_socket_path(
91109
resolved through symlinks, as tmux resolves it before binding, so a
92110
symlinked ``$TMUX_TMPDIR`` yields the path tmux itself reports.
93111
112+
A ``$TMUX_TMPDIR`` that is not a directory falls back the same way. tmux
113+
creates ``<dir>/tmux-<euid>`` and quietly uses ``/tmp`` when it cannot, so
114+
a path under a directory that does not exist is never the one it binds --
115+
measuring it would refuse a server tmux reaches without difficulty.
116+
94117
The path is *computed*, not observed: it says where tmux would put the
95118
socket, not that a daemon is listening there. Code holding a live
96119
:class:`~libtmux.Server` should ask tmux instead, with the
@@ -115,22 +138,97 @@ def resolve_socket_path(
115138
>>> resolve_socket_path(env={})
116139
PosixPath('/tmp/tmux-.../default')
117140
118-
>>> resolve_socket_path("mysocket", env={"TMUX_TMPDIR": "/run/user/1000"})
119-
PosixPath('/run/user/1000/tmux-.../mysocket')
141+
>>> resolve_socket_path("mysocket", env={"TMUX_TMPDIR": "/usr"})
142+
PosixPath('/usr/tmux-.../mysocket')
120143
121144
``$TMPDIR`` is not a socket directory, so it changes nothing:
122145
123146
>>> resolve_socket_path(env={"TMPDIR": "/var/folders/xy"})
124147
PosixPath('/tmp/tmux-.../default')
148+
149+
Nor does a ``$TMUX_TMPDIR`` tmux cannot use, however long it is:
150+
151+
>>> resolve_socket_path(env={"TMUX_TMPDIR": "/nonexistent-" + "d" * 200})
152+
PosixPath('/tmp/tmux-.../default')
125153
"""
126154
tmpdir = resolve_env(env).get(TMUX_TMPDIR) or DEFAULT_SOCKET_DIR
155+
base = pathlib.Path(tmpdir)
156+
if not base.is_dir():
157+
base = pathlib.Path(DEFAULT_SOCKET_DIR)
127158
return (
128-
pathlib.Path(tmpdir).resolve()
129-
/ f"tmux-{os.geteuid()}"
130-
/ (socket_name or DEFAULT_SOCKET_NAME)
159+
base.resolve() / f"tmux-{os.geteuid()}" / (socket_name or DEFAULT_SOCKET_NAME)
131160
)
132161

133162

163+
def check_socket_path_length(
164+
socket_path: StrPath,
165+
*,
166+
socket_name: str | None = None,
167+
env_var: str | None = None,
168+
env_value: str | None = None,
169+
) -> None:
170+
"""Raise if *socket_path* is too long to be a UNIX socket address.
171+
172+
A tmux socket is a UNIX domain socket, so its path has to fit in
173+
:data:`SOCKET_PATH_MAX_BYTES` -- a filesystem that accepts the path says
174+
nothing about whether a socket can be bound at it. Length is counted in
175+
*bytes*, as the kernel counts it, so a non-ASCII path runs out sooner than
176+
its character count suggests.
177+
178+
Parameters
179+
----------
180+
socket_path : str or :class:`os.PathLike`
181+
Path to measure.
182+
socket_name : str, optional
183+
Socket name *socket_path* was resolved from, when it was resolved
184+
rather than passed in. Recorded on the exception so the message can say
185+
the length was inherited from ``$TMUX_TMPDIR``.
186+
env_var : str, optional
187+
Environment variable the socket directory came from, when one did.
188+
Recorded on the exception so the message can name it.
189+
env_value : str, optional
190+
What that variable held, so the caller can see what to shorten.
191+
192+
Raises
193+
------
194+
:exc:`~libtmux.exc.SocketPathTooLong`
195+
When *socket_path* exceeds :data:`SOCKET_PATH_MAX_BYTES` bytes.
196+
197+
Examples
198+
--------
199+
>>> from libtmux._internal.env import (
200+
... check_socket_path_length,
201+
... SOCKET_PATH_MAX_BYTES,
202+
... )
203+
>>> check_socket_path_length("/tmp/tmux-1000/default")
204+
205+
>>> try:
206+
... check_socket_path_length("/tmp/" + "d" * 200 + "/sock")
207+
... except exc.SocketPathTooLong as e:
208+
... (e.length, e.limit == SOCKET_PATH_MAX_BYTES)
209+
(210, True)
210+
211+
A name that resolves somewhere too deep reports the name too. The path is
212+
measured as given -- whether tmux would really bind there is settled by
213+
:func:`resolve_socket_path` before this is called:
214+
215+
>>> deep = pathlib.Path("/tmp/" + "d" * 200) / "tmux-1000" / "dev"
216+
>>> try:
217+
... check_socket_path_length(deep, socket_name="dev")
218+
... except exc.SocketPathTooLong as e:
219+
... e.socket_name
220+
'dev'
221+
"""
222+
if len(os.fsencode(socket_path)) > SOCKET_PATH_MAX_BYTES:
223+
raise exc.SocketPathTooLong(
224+
socket_path,
225+
SOCKET_PATH_MAX_BYTES,
226+
socket_name=socket_name,
227+
env_var=env_var,
228+
env_value=env_value,
229+
)
230+
231+
134232
def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str:
135233
"""Return the tmux socket path recorded in ``$TMUX``.
136234
@@ -188,6 +286,56 @@ def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str:
188286
return parts[0]
189287

190288

289+
def resolve_ambient_socket_path(env: t.Mapping[str, str] | None = None) -> pathlib.Path:
290+
"""Resolve the socket a *bare* tmux invocation talks to, in tmux's own order.
291+
292+
A tmux client given no ``-L`` or ``-S`` prefers ``$TMUX`` -- the socket of
293+
the pane it is running inside -- and only falls back to computing a path
294+
under ``$TMUX_TMPDIR`` when there is no pane. Measured against tmux 3.7b: a
295+
bare client with ``$TMUX`` set connects even when ``$TMUX_TMPDIR`` names a
296+
directory far too deep to bind, because it never looks there.
297+
298+
That order only holds for the bare client. Passing ``-L`` sends tmux to
299+
``$TMUX_TMPDIR`` regardless of ``$TMUX``, so a named socket resolves through
300+
:func:`resolve_socket_path` instead.
301+
302+
Parameters
303+
----------
304+
env : :class:`typing.Mapping`, optional
305+
Environment to read. Defaults to :data:`os.environ`.
306+
307+
Returns
308+
-------
309+
:class:`pathlib.Path`
310+
Socket path a bare tmux client would use.
311+
312+
Examples
313+
--------
314+
>>> from libtmux._internal.env import resolve_ambient_socket_path
315+
316+
Inside a pane, ``$TMUX`` names the socket outright:
317+
318+
>>> resolve_ambient_socket_path({"TMUX": "/tmp/tmux-1000/default,8421,0"})
319+
PosixPath('/tmp/tmux-1000/default')
320+
321+
``$TMUX_TMPDIR`` is not consulted when there is a pane to inherit from:
322+
323+
>>> resolve_ambient_socket_path(
324+
... {"TMUX": "/tmp/sock,8421,0", "TMUX_TMPDIR": "/nowhere"}
325+
... )
326+
PosixPath('/tmp/sock')
327+
328+
Outside tmux it falls back to the computed path:
329+
330+
>>> resolve_ambient_socket_path({})
331+
PosixPath('/tmp/tmux-.../default')
332+
"""
333+
try:
334+
return pathlib.Path(socket_path_from_env(env))
335+
except exc.NotInsideTmux:
336+
return resolve_socket_path(env=env)
337+
338+
191339
def pane_id_from_env(env: t.Mapping[str, str] | None = None) -> str:
192340
"""Return the pane id recorded in ``$TMUX_PANE``.
193341

0 commit comments

Comments
 (0)