Skip to content

Commit bd730e5

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. Server accepted any path and the overrun arrived at the first tmux command as a passed- through "File name too long", blamed on new-session rather than on the socket, and without the one number the caller needed: how far over. The length is usually inherited rather than typed -- a deep pytest tmp_path, an XDG runtime dir, a nested worktree, a long $TMUX_TMPDIR -- so the path that failed is one nobody wrote. Closes #725. what: - Add libtmux.exc.SocketPathTooLong, carrying the byte count, the limit, the path, and the socket name when the path was resolved rather than passed in - Add _internal.env.SOCKET_PATH_MAX_BYTES (sun_path size per platform, less the NUL) and check_socket_path_length(), which measures with os.fsencode as the kernel does - Measure in Server.__init__: a given socket_path, and the path a socket_name, a socket_name_factory, or a bare Server() resolves to under $TMUX_TMPDIR - Document the limit and the shorter-socket-dir workaround on Server, in the pytest plugin usage guide, and on the env var page - Test both routes, the boundary, and probe the running kernel to keep the per-platform size honest
1 parent 1bd85e1 commit bd730e5

7 files changed

Lines changed: 402 additions & 12 deletions

File tree

CHANGES

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,26 @@ $ 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 (#725)
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 it is about to use and raises the new
55+
{exc}`~libtmux.exc.SocketPathTooLong`, carrying the byte count, the limit, and
56+
the path. Previously the object constructed fine and the overrun arrived at the
57+
first tmux command as a passed-through `File name too long`, attributed to
58+
whichever subcommand ran — `new-session`, usually — rather than to the socket.
59+
60+
Both routes to a path are checked: a `socket_path` passed in, and the path a
61+
`socket_name` resolves to under `$TMUX_TMPDIR`. The second is the one that
62+
bites, since the length is inherited from the environment — a pytest
63+
`tmp_path`, an XDG runtime dir, a nested worktree, a CI checkout under a long
64+
workspace prefix — so the caller never typed the path that failed. The
65+
workaround is a shorter socket directory: {func}`tempfile.mkdtemp` or a short
66+
`$TMUX_TMPDIR`. See {ref}`socket_path_length` for the pytest case.
67+
4868
### Fixes
4969

5070
- {class}`~libtmux.Server` now reprs the socket path tmux resolves from

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,38 @@ 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 the path at construction and raises
125+
{exc}`~libtmux.exc.SocketPathTooLong` with the byte count, rather than letting
126+
tmux report `File name too long` at whichever command runs first:
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+
The fixtures in this plugin sidestep it: {fixture}`server
140+
<libtmux.pytest_plugin.server>` and {fixture}`TestServer
141+
<libtmux.pytest_plugin.TestServer>` name their sockets with `socket_name`, which
142+
tmux resolves under its own short socket directory. In your own tests, keep
143+
`tmp_path` for files and reach for {func}`tempfile.mkdtemp` — which gives a
144+
short `/tmp/<random>` — when you need a socket path of your own, or point
145+
`TMUX_TMPDIR` somewhere short.
146+
115147
(set_home)=
116148

117149
### 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/env.py

Lines changed: 76 additions & 0 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.
@@ -131,6 +149,64 @@ def resolve_socket_path(
131149
)
132150

133151

152+
def check_socket_path_length(
153+
socket_path: StrPath,
154+
*,
155+
socket_name: str | None = None,
156+
) -> None:
157+
"""Raise if *socket_path* is too long to be a UNIX socket address.
158+
159+
A tmux socket is a UNIX domain socket, so its path has to fit in
160+
:data:`SOCKET_PATH_MAX_BYTES` -- a filesystem that accepts the path says
161+
nothing about whether a socket can be bound at it. Length is counted in
162+
*bytes*, as the kernel counts it, so a non-ASCII path runs out sooner than
163+
its character count suggests.
164+
165+
Parameters
166+
----------
167+
socket_path : str or :class:`os.PathLike`
168+
Path to measure.
169+
socket_name : str, optional
170+
Socket name *socket_path* was resolved from, when it was resolved
171+
rather than passed in. Recorded on the exception so the message can say
172+
the length was inherited from ``$TMUX_TMPDIR``.
173+
174+
Raises
175+
------
176+
:exc:`~libtmux.exc.SocketPathTooLong`
177+
When *socket_path* exceeds :data:`SOCKET_PATH_MAX_BYTES` bytes.
178+
179+
Examples
180+
--------
181+
>>> from libtmux._internal.env import (
182+
... check_socket_path_length,
183+
... SOCKET_PATH_MAX_BYTES,
184+
... )
185+
>>> check_socket_path_length("/tmp/tmux-1000/default")
186+
187+
>>> try:
188+
... check_socket_path_length("/tmp/" + "d" * 200 + "/sock")
189+
... except exc.SocketPathTooLong as e:
190+
... (e.length, e.limit == SOCKET_PATH_MAX_BYTES)
191+
(210, True)
192+
193+
A name that resolves somewhere too deep reports the name too:
194+
195+
>>> deep = resolve_socket_path("dev", env={"TMUX_TMPDIR": "/tmp/" + "d" * 200})
196+
>>> try:
197+
... check_socket_path_length(deep, socket_name="dev")
198+
... except exc.SocketPathTooLong as e:
199+
... e.socket_name
200+
'dev'
201+
"""
202+
if len(os.fsencode(socket_path)) > SOCKET_PATH_MAX_BYTES:
203+
raise exc.SocketPathTooLong(
204+
socket_path,
205+
SOCKET_PATH_MAX_BYTES,
206+
socket_name=socket_name,
207+
)
208+
209+
134210
def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str:
135211
"""Return the tmux socket path recorded in ``$TMUX``.
136212

src/libtmux/exc.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77

88
from __future__ import annotations
99

10+
import os
1011
import typing as t
1112

1213
if t.TYPE_CHECKING:
14+
from libtmux._internal.types import StrPath
1315
from libtmux.neo import ListExtraArgs
1416

1517

@@ -151,6 +153,97 @@ def __init__(
151153
)
152154

153155

156+
class SocketPathTooLong(LibTmuxException):
157+
"""A tmux socket path is longer than a UNIX socket address can hold.
158+
159+
``sun_path`` in ``struct sockaddr_un`` is a fixed-size buffer, so a path
160+
over the platform's limit can never be connected to, whatever the
161+
filesystem allows. tmux reports it as a passed-through ``File name too
162+
long`` at the first command, attributed to that subcommand rather than to
163+
the socket; :class:`~libtmux.Server` raises this while the caller still has
164+
the frame that built it.
165+
166+
The overrun is usually inherited rather than typed: a deep pytest
167+
``tmp_path``, an XDG runtime dir, a nested worktree, a long
168+
``$TMUX_TMPDIR``. So the message carries the byte count and the limit --
169+
the numbers tmux does not give.
170+
171+
Parameters
172+
----------
173+
socket_path : str or :class:`os.PathLike`
174+
The path that does not fit. Measured in bytes, as the kernel does.
175+
limit : int
176+
Bytes available for a socket path on this platform.
177+
*args : object
178+
Forwarded to :class:`LibTmuxException`.
179+
socket_name : str, optional
180+
Set when the path was *resolved* from a socket name rather than passed
181+
in, in which case the length came from ``$TMUX_TMPDIR``.
182+
183+
Attributes
184+
----------
185+
socket_path : str or :class:`os.PathLike`
186+
The path that does not fit.
187+
length : int
188+
Length of *socket_path* in bytes.
189+
limit : int
190+
Bytes available for a socket path on this platform.
191+
socket_name : str or None
192+
Socket name the path was resolved from, if any.
193+
194+
Examples
195+
--------
196+
>>> from libtmux import exc
197+
>>> print(exc.SocketPathTooLong("/tmp/" + "d" * 120 + "/sock", 107))
198+
Socket path is 130 bytes, over the 107 byte limit: /tmp/ddd...
199+
200+
A path nobody typed says where it came from:
201+
202+
>>> print(
203+
... exc.SocketPathTooLong(
204+
... "/tmp/" + "d" * 120 + "/tmux-1000/dev", 107, socket_name="dev"
205+
... )
206+
... )
207+
Socket path for socket_name='dev' is 139 bytes, over the 107 byte
208+
limit: /tmp/ddd...
209+
210+
The byte count and the limit are readable, for a caller that would rather
211+
format its own message:
212+
213+
>>> e = exc.SocketPathTooLong("/tmp/" + "d" * 120 + "/sock", 107)
214+
>>> e.length, e.limit
215+
(130, 107)
216+
217+
It is part of the :exc:`LibTmuxException` hierarchy, so
218+
``except LibTmuxException`` catches it:
219+
220+
>>> issubclass(exc.SocketPathTooLong, exc.LibTmuxException)
221+
True
222+
223+
.. versionadded:: 0.63
224+
"""
225+
226+
def __init__(
227+
self,
228+
socket_path: StrPath,
229+
limit: int,
230+
*args: object,
231+
socket_name: str | None = None,
232+
) -> None:
233+
self.socket_path: StrPath = socket_path
234+
self.length: int = len(os.fsencode(socket_path))
235+
self.limit: int = limit
236+
self.socket_name: str | None = socket_name
237+
subject = "Socket path"
238+
if socket_name is not None:
239+
subject += f" for socket_name={socket_name!r}"
240+
super().__init__(
241+
f"{subject} is {self.length} bytes, over the {limit} byte limit: "
242+
f"{socket_path}",
243+
*args,
244+
)
245+
246+
154247
class ObjectDoesNotExist(LibTmuxException):
155248
"""A lookup expected one object and matched none.
156249

src/libtmux/server.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
import warnings
1717

1818
from libtmux import exc
19-
from libtmux._internal.env import resolve_socket_path, socket_path_from_env
19+
from libtmux._internal.env import (
20+
check_socket_path_length,
21+
resolve_socket_path,
22+
socket_path_from_env,
23+
)
2024
from libtmux._internal.query_list import QueryList
2125
from libtmux.client import Client
2226
from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd
@@ -99,6 +103,16 @@ class Server(
99103
100104
When instantiated stores information on live, running tmux server.
101105
106+
A tmux socket is a UNIX domain socket, so its path is capped by
107+
``sockaddr_un`` -- 107 bytes on Linux, 103 on macOS. Both a ``socket_path``
108+
passed in and the path a ``socket_name`` resolves to under ``$TMUX_TMPDIR``
109+
are measured here, so an unusable socket is refused at construction rather
110+
than surfacing later as a tmux ``File name too long`` blamed on whichever
111+
command ran first. When the depth is not yours to choose -- a pytest
112+
``tmp_path``, a nested worktree, a CI checkout under a long workspace prefix
113+
-- put the socket under :func:`tempfile.mkdtemp` or point ``$TMUX_TMPDIR``
114+
at something short.
115+
102116
Parameters
103117
----------
104118
socket_name : str, optional
@@ -109,6 +123,12 @@ class Server(
109123
socket_name_factory : callable, optional
110124
tmux_bin : str or pathlib.Path, optional
111125
126+
Raises
127+
------
128+
:exc:`~libtmux.exc.SocketPathTooLong`
129+
When the socket path -- given, or resolved from ``socket_name`` --
130+
cannot fit in a UNIX socket address.
131+
112132
Examples
113133
--------
114134
>>> server
@@ -133,6 +153,27 @@ class Server(
133153
... # Do work with the session
134154
... # Server will be killed automatically when exiting the context
135155
156+
A socket path that cannot fit in a UNIX socket address is refused, with the
157+
byte count tmux would not have given:
158+
159+
>>> from libtmux.server import Server as TmuxServer
160+
>>> try:
161+
... TmuxServer(socket_path="/tmp/" + "d" * 120 + "/sock")
162+
... except exc.SocketPathTooLong as e:
163+
... print(e.length)
164+
130
165+
166+
The same goes for the path a short ``socket_name`` resolves to, when
167+
``$TMUX_TMPDIR`` is the long part:
168+
169+
>>> with monkeypatch.context() as m:
170+
... m.setenv("TMUX_TMPDIR", "/tmp/" + "d" * 120)
171+
... try:
172+
... TmuxServer(socket_name="dev")
173+
... except exc.SocketPathTooLong as e:
174+
... print(e.socket_name)
175+
dev
176+
136177
References
137178
----------
138179
.. [server_manual] CLIENTS AND SESSIONS. openbsd manpage for TMUX(1)
@@ -184,11 +225,19 @@ def __init__(
184225
self._panes: list[PaneDict] = []
185226

186227
if socket_path is not None:
228+
check_socket_path_length(socket_path)
187229
self.socket_path = socket_path
188-
elif socket_name is not None:
189-
self.socket_name = socket_name
190-
elif socket_name_factory is not None:
191-
self.socket_name = socket_name_factory()
230+
else:
231+
if socket_name is None and socket_name_factory is not None:
232+
socket_name = socket_name_factory()
233+
# Also covers the bare ``Server()``, whose socket path is inherited
234+
# from ``$TMUX_TMPDIR`` in full.
235+
check_socket_path_length(
236+
resolve_socket_path(socket_name),
237+
socket_name=socket_name,
238+
)
239+
if socket_name is not None:
240+
self.socket_name = socket_name
192241

193242
if config_file:
194243
self.config_file = config_file

0 commit comments

Comments
 (0)