Skip to content

Commit adcc295

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 adcc295

9 files changed

Lines changed: 893 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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,58 @@ 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+
The directory has to exist for that to be the socket tmux would bind: tmux takes
145+
the first of `$TMUX_TMPDIR` and `/tmp` that resolves.
146+
147+
```python
148+
>>> from libtmux.server import Server as TmuxServer
149+
>>> deep = request.getfixturevalue("tmp_path") / ("d" * 120)
150+
>>> deep.mkdir()
151+
152+
>>> with monkeypatch.context() as m:
153+
... m.delenv("TMUX", raising=False)
154+
... m.setenv("TMUX_TMPDIR", str(deep))
155+
... TmuxServer(socket_name="deep").is_alive()
156+
False
157+
```
158+
159+
The fixtures in this plugin sidestep it: {fixture}`server
160+
<libtmux.pytest_plugin.server>` and {fixture}`TestServer
161+
<libtmux.pytest_plugin.TestServer>` name their sockets with `socket_name`, which
162+
tmux resolves under its own short socket directory. In your own tests, keep
163+
`tmp_path` for files and reach for {func}`tempfile.mkdtemp` — which gives a
164+
short `/tmp/<random>` — when you need a socket path of your own, or point
165+
`TMUX_TMPDIR` somewhere short.
166+
115167
(set_home)=
116168

117169
### 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: 155 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,13 @@ 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`` tmux cannot resolve falls back the same way. tmux takes
113+
the first of ``$TMUX_TMPDIR`` and ``/tmp`` that resolves, so a path that is
114+
not there -- or a broken symlink -- is never the one it binds, and
115+
measuring it would refuse a server tmux reaches without difficulty. A
116+
directory it resolves but cannot create ``tmux-<euid>`` under is an error
117+
from tmux, not a fallback.
118+
94119
The path is *computed*, not observed: it says where tmux would put the
95120
socket, not that a daemon is listening there. Code holding a live
96121
:class:`~libtmux.Server` should ask tmux instead, with the
@@ -115,22 +140,97 @@ def resolve_socket_path(
115140
>>> resolve_socket_path(env={})
116141
PosixPath('/tmp/tmux-.../default')
117142
118-
>>> resolve_socket_path("mysocket", env={"TMUX_TMPDIR": "/run/user/1000"})
119-
PosixPath('/run/user/1000/tmux-.../mysocket')
143+
>>> resolve_socket_path("mysocket", env={"TMUX_TMPDIR": "/usr"})
144+
PosixPath('/usr/tmux-.../mysocket')
120145
121146
``$TMPDIR`` is not a socket directory, so it changes nothing:
122147
123148
>>> resolve_socket_path(env={"TMPDIR": "/var/folders/xy"})
124149
PosixPath('/tmp/tmux-.../default')
150+
151+
Nor does a ``$TMUX_TMPDIR`` tmux cannot use, however long it is:
152+
153+
>>> resolve_socket_path(env={"TMUX_TMPDIR": "/nonexistent-" + "d" * 200})
154+
PosixPath('/tmp/tmux-.../default')
125155
"""
126156
tmpdir = resolve_env(env).get(TMUX_TMPDIR) or DEFAULT_SOCKET_DIR
157+
base = pathlib.Path(tmpdir)
158+
if not base.exists():
159+
base = pathlib.Path(DEFAULT_SOCKET_DIR)
127160
return (
128-
pathlib.Path(tmpdir).resolve()
129-
/ f"tmux-{os.geteuid()}"
130-
/ (socket_name or DEFAULT_SOCKET_NAME)
161+
base.resolve() / f"tmux-{os.geteuid()}" / (socket_name or DEFAULT_SOCKET_NAME)
131162
)
132163

133164

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

190290

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

0 commit comments

Comments
 (0)