Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### What's new

#### Socket paths are measured before tmux sees them (#725)

A tmux socket is a UNIX domain socket, so its path is capped by `sockaddr_un`
— 107 bytes on Linux, 103 on macOS. {class}`~libtmux.Server` now measures the
path it is about to use and raises the new
{exc}`~libtmux.exc.SocketPathTooLong`, carrying the byte count, the limit, and
the path. Previously the object constructed fine and the overrun arrived at the
first tmux command as a passed-through `File name too long`, attributed to
whichever subcommand ran — `new-session`, usually — rather than to the socket.

Both routes to a path are checked: a `socket_path` passed in, and the path a
`socket_name` resolves to under `$TMUX_TMPDIR`. The second is the one that
bites, since the length is inherited from the environment — a pytest
`tmp_path`, an XDG runtime dir, a nested worktree, a CI checkout under a long
workspace prefix — so the caller never typed the path that failed. The
workaround is a shorter socket directory: {func}`tempfile.mkdtemp` or a short
`$TMUX_TMPDIR`. See {ref}`socket_path_length` for the pytest case.

### Fixes

- {class}`~libtmux.Server` now reprs the socket path tmux resolves from
Expand Down
32 changes: 32 additions & 0 deletions docs/api/testing/pytest-plugin/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,38 @@ True

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

(socket_path_length)=

### Socket paths and the UNIX socket limit

A tmux socket is a UNIX domain socket, so its path is capped by `sockaddr_un`
— 107 bytes on Linux, 103 on macOS. pytest's {fixture}`tmp_path` is nested
deep by design (`/tmp/pytest-of-<user>/pytest-<n>/<test-name><n>`), so putting
a socket under it — directly, or by pointing `TMUX_TMPDIR` at it — can overrun
the limit on a long test name or a long temporary root. {class}`~libtmux.Server`
measures the path at construction and raises
{exc}`~libtmux.exc.SocketPathTooLong` with the byte count, rather than letting
tmux report `File name too long` at whichever command runs first:

```python
>>> from libtmux import exc
>>> from libtmux.server import Server as TmuxServer
>>> deep_socket = "/tmp/" + "d" * 120 + "/sock"
>>> try:
... TmuxServer(socket_path=deep_socket)
... except exc.SocketPathTooLong as e:
... print(e.length)
130
```

The fixtures in this plugin sidestep it: {fixture}`server
<libtmux.pytest_plugin.server>` and {fixture}`TestServer
<libtmux.pytest_plugin.TestServer>` name their sockets with `socket_name`, which
tmux resolves under its own short socket directory. In your own tests, keep
`tmp_path` for files and reach for {func}`tempfile.mkdtemp` — which gives a
short `/tmp/<random>` — when you need a socket path of your own, or point
`TMUX_TMPDIR` somewhere short.

(set_home)=

### Setting a temporary home directory
Expand Down
17 changes: 10 additions & 7 deletions docs/topics/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,16 @@ without you arranging anything.

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

## Format strings

Expand Down
76 changes: 76 additions & 0 deletions src/libtmux/_internal/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@

import os
import pathlib
import sys
import typing as t

from libtmux import exc

if t.TYPE_CHECKING:
from libtmux._internal.types import StrPath

TMUX: t.Final = "TMUX"
"""Environment variable tmux exports with ``socket_path,server_pid,session_id``."""

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

# ``sun_path`` in ``struct sockaddr_un`` is a fixed-size char array, and the
# stdlib publishes no constant for its size, so it is spelled out per platform.
# The size is part of each platform's frozen ABI: 104 bytes on the BSD-derived
# kernels (macOS, FreeBSD, OpenBSD, NetBSD), 108 on Linux and elsewhere. One
# byte of it is the NUL terminator. The test suite probes the running kernel to
# keep this honest, which reads better than bisecting for the limit at import
# time.
_SUN_PATH_SIZE: t.Final = (
104 if sys.platform.startswith(("darwin", "freebsd", "openbsd", "netbsd")) else 108
)

SOCKET_PATH_MAX_BYTES: t.Final = _SUN_PATH_SIZE - 1
"""Bytes a tmux socket path may occupy on this platform."""


def resolve_env(env: t.Mapping[str, str] | None = None) -> t.Mapping[str, str]:
"""Return *env*, defaulting to the live process environment.
Expand Down Expand Up @@ -129,6 +147,64 @@ def resolve_socket_path(
)


def check_socket_path_length(
socket_path: StrPath,
*,
socket_name: str | None = None,
) -> None:
"""Raise if *socket_path* is too long to be a UNIX socket address.

A tmux socket is a UNIX domain socket, so its path has to fit in
:data:`SOCKET_PATH_MAX_BYTES` -- a filesystem that accepts the path says
nothing about whether a socket can be bound at it. Length is counted in
*bytes*, as the kernel counts it, so a non-ASCII path runs out sooner than
its character count suggests.

Parameters
----------
socket_path : str or :class:`os.PathLike`
Path to measure.
socket_name : str, optional
Socket name *socket_path* was resolved from, when it was resolved
rather than passed in. Recorded on the exception so the message can say
the length was inherited from ``$TMUX_TMPDIR``.

Raises
------
:exc:`~libtmux.exc.SocketPathTooLong`
When *socket_path* exceeds :data:`SOCKET_PATH_MAX_BYTES` bytes.

Examples
--------
>>> from libtmux._internal.env import (
... check_socket_path_length,
... SOCKET_PATH_MAX_BYTES,
... )
>>> check_socket_path_length("/tmp/tmux-1000/default")

>>> try:
... check_socket_path_length("/tmp/" + "d" * 200 + "/sock")
... except exc.SocketPathTooLong as e:
... (e.length, e.limit == SOCKET_PATH_MAX_BYTES)
(210, True)

A name that resolves somewhere too deep reports the name too:

>>> deep = resolve_socket_path("dev", env={"TMUX_TMPDIR": "/tmp/" + "d" * 200})
>>> try:
... check_socket_path_length(deep, socket_name="dev")
... except exc.SocketPathTooLong as e:
... e.socket_name
'dev'
"""
if len(os.fsencode(socket_path)) > SOCKET_PATH_MAX_BYTES:
raise exc.SocketPathTooLong(
socket_path,
SOCKET_PATH_MAX_BYTES,
socket_name=socket_name,
)


def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str:
"""Return the tmux socket path recorded in ``$TMUX``.

Expand Down
93 changes: 93 additions & 0 deletions src/libtmux/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@

from __future__ import annotations

import os
import typing as t

if t.TYPE_CHECKING:
from libtmux._internal.types import StrPath
from libtmux.neo import ListExtraArgs


Expand Down Expand Up @@ -151,6 +153,97 @@ def __init__(
)


class SocketPathTooLong(LibTmuxException):
"""A tmux socket path is longer than a UNIX socket address can hold.

``sun_path`` in ``struct sockaddr_un`` is a fixed-size buffer, so a path
over the platform's limit can never be connected to, whatever the
filesystem allows. tmux reports it as a passed-through ``File name too
long`` at the first command, attributed to that subcommand rather than to
the socket; :class:`~libtmux.Server` raises this while the caller still has
the frame that built it.

The overrun is usually inherited rather than typed: a deep pytest
``tmp_path``, an XDG runtime dir, a nested worktree, a long
``$TMUX_TMPDIR``. So the message carries the byte count and the limit --
the numbers tmux does not give.

Parameters
----------
socket_path : str or :class:`os.PathLike`
The path that does not fit. Measured in bytes, as the kernel does.
limit : int
Bytes available for a socket path on this platform.
*args : object
Forwarded to :class:`LibTmuxException`.
socket_name : str, optional
Set when the path was *resolved* from a socket name rather than passed
in, in which case the length came from ``$TMUX_TMPDIR``.

Attributes
----------
socket_path : str or :class:`os.PathLike`
The path that does not fit.
length : int
Length of *socket_path* in bytes.
limit : int
Bytes available for a socket path on this platform.
socket_name : str or None
Socket name the path was resolved from, if any.

Examples
--------
>>> from libtmux import exc
>>> print(exc.SocketPathTooLong("/tmp/" + "d" * 120 + "/sock", 107))
Socket path is 130 bytes, over the 107 byte limit: /tmp/ddd...

A path nobody typed says where it came from:

>>> print(
... exc.SocketPathTooLong(
... "/tmp/" + "d" * 120 + "/tmux-1000/dev", 107, socket_name="dev"
... )
... )
Socket path for socket_name='dev' is 139 bytes, over the 107 byte
limit: /tmp/ddd...

The byte count and the limit are readable, for a caller that would rather
format its own message:

>>> e = exc.SocketPathTooLong("/tmp/" + "d" * 120 + "/sock", 107)
>>> e.length, e.limit
(130, 107)

It is part of the :exc:`LibTmuxException` hierarchy, so
``except LibTmuxException`` catches it:

>>> issubclass(exc.SocketPathTooLong, exc.LibTmuxException)
True

.. versionadded:: 0.63
"""

def __init__(
self,
socket_path: StrPath,
limit: int,
*args: object,
socket_name: str | None = None,
) -> None:
self.socket_path: StrPath = socket_path
self.length: int = len(os.fsencode(socket_path))
self.limit: int = limit
self.socket_name: str | None = socket_name
subject = "Socket path"
if socket_name is not None:
subject += f" for socket_name={socket_name!r}"
super().__init__(
f"{subject} is {self.length} bytes, over the {limit} byte limit: "
f"{socket_path}",
*args,
)


class ObjectDoesNotExist(LibTmuxException):
"""A lookup expected one object and matched none.

Expand Down
59 changes: 54 additions & 5 deletions src/libtmux/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
import warnings

from libtmux import exc
from libtmux._internal.env import resolve_socket_path, socket_path_from_env
from libtmux._internal.env import (
check_socket_path_length,
resolve_socket_path,
socket_path_from_env,
)
from libtmux._internal.query_list import QueryList
from libtmux.client import Client
from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd
Expand Down Expand Up @@ -99,6 +103,16 @@ class Server(

When instantiated stores information on live, running tmux server.

A tmux socket is a UNIX domain socket, so its path is capped by
``sockaddr_un`` -- 107 bytes on Linux, 103 on macOS. Both a ``socket_path``
passed in and the path a ``socket_name`` resolves to under ``$TMUX_TMPDIR``
are measured here, so an unusable socket is refused at construction rather
than surfacing later as a tmux ``File name too long`` blamed on whichever
command ran first. When the depth is not yours to choose -- a pytest
``tmp_path``, a nested worktree, a CI checkout under a long workspace prefix
-- put the socket under :func:`tempfile.mkdtemp` or point ``$TMUX_TMPDIR``
at something short.

Parameters
----------
socket_name : str, optional
Expand All @@ -109,6 +123,12 @@ class Server(
socket_name_factory : callable, optional
tmux_bin : str or pathlib.Path, optional

Raises
------
:exc:`~libtmux.exc.SocketPathTooLong`
When the socket path -- given, or resolved from ``socket_name`` --
cannot fit in a UNIX socket address.

Examples
--------
>>> server
Expand All @@ -133,6 +153,27 @@ class Server(
... # Do work with the session
... # Server will be killed automatically when exiting the context

A socket path that cannot fit in a UNIX socket address is refused, with the
byte count tmux would not have given:

>>> from libtmux.server import Server as TmuxServer
>>> try:
... TmuxServer(socket_path="/tmp/" + "d" * 120 + "/sock")
... except exc.SocketPathTooLong as e:
... print(e.length)
130

The same goes for the path a short ``socket_name`` resolves to, when
``$TMUX_TMPDIR`` is the long part:

>>> with monkeypatch.context() as m:
... m.setenv("TMUX_TMPDIR", "/tmp/" + "d" * 120)
... try:
... TmuxServer(socket_name="dev")
... except exc.SocketPathTooLong as e:
... print(e.socket_name)
dev

References
----------
.. [server_manual] CLIENTS AND SESSIONS. openbsd manpage for TMUX(1)
Expand Down Expand Up @@ -184,11 +225,19 @@ def __init__(
self._panes: list[PaneDict] = []

if socket_path is not None:
check_socket_path_length(socket_path)
self.socket_path = socket_path
elif socket_name is not None:
self.socket_name = socket_name
elif socket_name_factory is not None:
self.socket_name = socket_name_factory()
else:
if socket_name is None and socket_name_factory is not None:
socket_name = socket_name_factory()
# Also covers the bare ``Server()``, whose socket path is inherited
# from ``$TMUX_TMPDIR`` in full.
check_socket_path_length(
resolve_socket_path(socket_name),
socket_name=socket_name,
)
if socket_name is not None:
self.socket_name = socket_name

if config_file:
self.config_file = config_file
Expand Down
Loading