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
36 changes: 34 additions & 2 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,42 @@ $ 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 (#730)

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 and raises the new {exc}`~libtmux.exc.SocketPathTooLong`, carrying the
byte count, how far over the limit it is, and where the length came from.
tmux reports the overrun as `error connecting to <path> (File name too long)`,
which names the path but not the numbers, nor which variable made it long.

Where the measurement happens follows what tmux actually reads. A
`socket_path` is passed through unchanged as `-S<path>`, so it is measured at
construction, where the caller can still change it. A `socket_name` resolves
against `$TMUX_TMPDIR` — which tmux re-reads when it runs, not when the
{class}`~libtmux.Server` was built — so it is measured on each command
instead. The inherited case is the one that bites: a pytest `tmp_path`, an XDG
runtime dir, a nested worktree, a CI checkout under a long workspace prefix.
The fix is a shorter socket directory: {func}`tempfile.mkdtemp` or a short
`$TMUX_TMPDIR`. See {ref}`socket_path_length` for the pytest case.

Naming a server that way stays free of side effects, so
{meth}`~libtmux.Server.is_alive` keeps answering — an address the kernel
cannot hold is one more way of not being alive — and
{meth}`~libtmux.Server.raise_if_dead` keeps being the way to ask why.

A bare {class}`~libtmux.Server` inside a tmux pane is left alone. tmux prefers
`$TMUX` over `$TMUX_TMPDIR` when no socket is named, so a script running
inside tmux is measured against the socket it will actually use rather than a
directory tmux never consults.

### Fixes

- {class}`~libtmux.Server` now reprs the socket path tmux resolves from
`$TMUX_TMPDIR` instead of a hard-coded `/tmp/tmux-<euid>/default` (#723)
- {class}`~libtmux.Server` now reprs the socket a bare tmux client would use —
`$TMUX` inside a pane, otherwise the path resolved from `$TMUX_TMPDIR` —
instead of a hard-coded `/tmp/tmux-<euid>/default` (#727)

### Documentation

Expand Down
52 changes: 52 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,58 @@ 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 an explicit `socket_path` as soon as it is passed and raises
{exc}`~libtmux.exc.SocketPathTooLong` with the byte count, rather than letting
tmux report `File name too long` with only the path to go on:

```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
```

A `socket_name` is different: tmux resolves it against `$TMUX_TMPDIR` when it
runs, so the length is only knowable at dispatch. Building the object is safe,
and a test that only asks whether a server is there gets an answer instead of an
exception — an unbindable address is one more way of not being alive:

The directory has to exist for that to be the socket tmux would bind: tmux takes
the first of `$TMUX_TMPDIR` and `/tmp` that resolves.

```python
>>> from libtmux.server import Server as TmuxServer
>>> deep = request.getfixturevalue("tmp_path") / ("d" * 120)
>>> deep.mkdir()

>>> with monkeypatch.context() as m:
... m.delenv("TMUX", raising=False)
... m.setenv("TMUX_TMPDIR", str(deep))
... TmuxServer(socket_name="deep").is_alive()
False
```

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
9 changes: 1 addition & 8 deletions src/libtmux/_internal/control_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,9 @@ def __enter__(self) -> Self:

tmux_bin = self.server.tmux_bin or "tmux"

if self.server.socket_name is not None:
socket_args = ["-L", str(self.server.socket_name)]
elif self.server.socket_path is not None:
socket_args = ["-S", str(self.server.socket_path)]
else:
socket_args = []

cmd = [
tmux_bin,
*socket_args,
*self.server._socket_args(),
"-C",
"attach-session",
"-t",
Expand Down
110 changes: 105 additions & 5 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 @@ -91,6 +109,13 @@ def resolve_socket_path(
resolved through symlinks, as tmux resolves it before binding, so a
symlinked ``$TMUX_TMPDIR`` yields the path tmux itself reports.

A ``$TMUX_TMPDIR`` tmux cannot resolve falls back the same way. tmux takes
the first of ``$TMUX_TMPDIR`` and ``/tmp`` that resolves, so a path that is
not there -- or a broken symlink -- is never the one it binds, and
measuring it would refuse a server tmux reaches without difficulty. A
directory it resolves but cannot create ``tmux-<euid>`` under is an error
from tmux, not a fallback.

The path is *computed*, not observed: it says where tmux would put the
socket, not that a daemon is listening there. Code holding a live
:class:`~libtmux.Server` should ask tmux instead, with the
Expand All @@ -115,22 +140,97 @@ def resolve_socket_path(
>>> resolve_socket_path(env={})
PosixPath('/tmp/tmux-.../default')

>>> resolve_socket_path("mysocket", env={"TMUX_TMPDIR": "/run/user/1000"})
PosixPath('/run/user/1000/tmux-.../mysocket')
>>> resolve_socket_path("mysocket", env={"TMUX_TMPDIR": "/usr"})
PosixPath('/usr/tmux-.../mysocket')

``$TMPDIR`` is not a socket directory, so it changes nothing:

>>> resolve_socket_path(env={"TMPDIR": "/var/folders/xy"})
PosixPath('/tmp/tmux-.../default')

Nor does a ``$TMUX_TMPDIR`` tmux cannot use, however long it is:

>>> resolve_socket_path(env={"TMUX_TMPDIR": "/nonexistent-" + "d" * 200})
PosixPath('/tmp/tmux-.../default')
"""
tmpdir = resolve_env(env).get(TMUX_TMPDIR) or DEFAULT_SOCKET_DIR
base = pathlib.Path(tmpdir)
if not base.exists():
base = pathlib.Path(DEFAULT_SOCKET_DIR)
return (
pathlib.Path(tmpdir).resolve()
/ f"tmux-{os.geteuid()}"
/ (socket_name or DEFAULT_SOCKET_NAME)
base.resolve() / f"tmux-{os.geteuid()}" / (socket_name or DEFAULT_SOCKET_NAME)
)


def check_socket_path_length(
socket_path: StrPath,
*,
socket_name: str | None = None,
env_var: str | None = None,
env_value: 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``.
env_var : str, optional
Environment variable the socket directory came from, when one did.
Recorded on the exception so the message can name it.
env_value : str, optional
What that variable held, so the caller can see what to shorten.

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. The path is
measured as given -- whether tmux would really bind there is settled by
:func:`resolve_socket_path` before this is called:

>>> deep = pathlib.Path("/tmp/" + "d" * 200) / "tmux-1000" / "dev"
>>> 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,
env_var=env_var,
env_value=env_value,
)


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

Expand Down
Loading
Loading