From c8356b9df368dbee950b49e1350da6d45922bf99 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 29 Jul 2026 19:05:47 -0500 Subject: [PATCH] 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 (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`, 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 --- CHANGES | 42 ++++ docs/api/testing/pytest-plugin/usage.md | 45 ++++ docs/topics/configuration.md | 17 +- src/libtmux/_internal/control_mode.py | 9 +- src/libtmux/_internal/env.py | 135 ++++++++++++ src/libtmux/exc.py | 118 ++++++++++ src/libtmux/neo.py | 11 +- src/libtmux/server.py | 183 ++++++++++++++- tests/test_server.py | 281 ++++++++++++++++++++++++ 9 files changed, 806 insertions(+), 35 deletions(-) diff --git a/CHANGES b/CHANGES index 928080202..ac63d0ed9 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,48 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### 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 (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`, 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. + +#### `Server.socket_args()` builds a server's socket flags (#730) + +{meth}`Server.socket_args() ` returns the +`-S`/`-L` flags that address a server, measuring the socket path on the way. +Every libtmux path that spawns tmux — {meth}`~libtmux.Server.cmd`, +{meth}`~libtmux.Server.raise_if_dead`, the format-query layer, the control-mode +client — builds its argv from it, and code that shells out to tmux on a +libtmux {class}`~libtmux.Server`'s behalf can do the same instead of +reconstructing the flags from {attr}`~libtmux.Server.socket_name` and +{attr}`~libtmux.Server.socket_path`. + ### Fixes - {class}`~libtmux.Server` now reprs the socket path tmux resolves from diff --git a/docs/api/testing/pytest-plugin/usage.md b/docs/api/testing/pytest-plugin/usage.md index 9ae0fd49d..aa5ab7873 100644 --- a/docs/api/testing/pytest-plugin/usage.md +++ b/docs/api/testing/pytest-plugin/usage.md @@ -112,6 +112,51 @@ 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-/pytest-/`), 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: + +```python +>>> from libtmux.server import Server as TmuxServer +>>> with monkeypatch.context() as m: +... m.setenv("TMUX_TMPDIR", "/tmp/" + "d" * 120) +... TmuxServer(socket_name="deep").is_alive() +False +``` + +The fixtures in this plugin sidestep it: {fixture}`server +` and {fixture}`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/` — when you need a socket path of your own, or point +`TMUX_TMPDIR` somewhere short. + (set_home)= ### Setting a temporary home directory diff --git a/docs/topics/configuration.md b/docs/topics/configuration.md index e1180c380..0033a8a36 100644 --- a/docs/topics/configuration.md +++ b/docs/topics/configuration.md @@ -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 diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index 05945451e..a8e6af806 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -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", diff --git a/src/libtmux/_internal/env.py b/src/libtmux/_internal/env.py index f257ea440..6d009801b 100644 --- a/src/libtmux/_internal/env.py +++ b/src/libtmux/_internal/env.py @@ -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``.""" @@ -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. @@ -131,6 +149,73 @@ def resolve_socket_path( ) +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: + + >>> 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, + 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``. @@ -188,6 +273,56 @@ def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str: return parts[0] +def resolve_ambient_socket_path(env: t.Mapping[str, str] | None = None) -> pathlib.Path: + """Resolve the socket a *bare* tmux invocation talks to, in tmux's own order. + + A tmux client given no ``-L`` or ``-S`` prefers ``$TMUX`` -- the socket of + the pane it is running inside -- and only falls back to computing a path + under ``$TMUX_TMPDIR`` when there is no pane. Measured against tmux 3.7b: a + bare client with ``$TMUX`` set connects even when ``$TMUX_TMPDIR`` names a + directory far too deep to bind, because it never looks there. + + That order only holds for the bare client. Passing ``-L`` sends tmux to + ``$TMUX_TMPDIR`` regardless of ``$TMUX``, so a named socket resolves through + :func:`resolve_socket_path` instead. + + Parameters + ---------- + env : :class:`typing.Mapping`, optional + Environment to read. Defaults to :data:`os.environ`. + + Returns + ------- + :class:`pathlib.Path` + Socket path a bare tmux client would use. + + Examples + -------- + >>> from libtmux._internal.env import resolve_ambient_socket_path + + Inside a pane, ``$TMUX`` names the socket outright: + + >>> resolve_ambient_socket_path({"TMUX": "/tmp/tmux-1000/default,8421,0"}) + PosixPath('/tmp/tmux-1000/default') + + ``$TMUX_TMPDIR`` is not consulted when there is a pane to inherit from: + + >>> resolve_ambient_socket_path( + ... {"TMUX": "/tmp/sock,8421,0", "TMUX_TMPDIR": "/nowhere"} + ... ) + PosixPath('/tmp/sock') + + Outside tmux it falls back to the computed path: + + >>> resolve_ambient_socket_path({}) + PosixPath('/tmp/tmux-.../default') + """ + try: + return pathlib.Path(socket_path_from_env(env)) + except exc.NotInsideTmux: + return resolve_socket_path(env=env) + + def pane_id_from_env(env: t.Mapping[str, str] | None = None) -> str: """Return the pane id recorded in ``$TMUX_PANE``. diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 57bb06102..e024eb892 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -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 @@ -151,6 +153,122 @@ 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 says ``error connecting to (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, a long + ``$TMUX_TMPDIR``. So the message adds the numbers tmux leaves out -- how + many bytes over the limit, and where the length came from. + + 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``. + env_var : str, optional + Environment variable the directory came from, when one did. + env_value : str, optional + What that variable held, so the caller can see what to shorten. + + 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. + over : int + Bytes to cut before the path fits. + socket_name : str or None + Socket name the path was resolved from, if any. + env_var : str or None + Environment variable the directory came from, if any. + env_value : str or None + Value that variable held, if any. + + Examples + -------- + >>> from libtmux import exc + >>> print(exc.SocketPathTooLong("/tmp/" + "d" * 120 + "/sock", 107)) + Socket path is 130 bytes, 23 over the 107 byte limit: /tmp/ddd... + + A path nobody typed says where it came from, and what to shorten: + + >>> print( + ... exc.SocketPathTooLong( + ... "/tmp/" + "d" * 120 + "/tmux-1000/dev", + ... 107, + ... socket_name="dev", + ... env_var="TMUX_TMPDIR", + ... env_value="/tmp/" + "d" * 120, + ... ) + ... ) + Socket path for socket_name='dev' is 139 bytes, 32 over the 107 byte + limit: /tmp/ddd... Inherited from $TMUX_TMPDIR: shorten it, or pass a + socket_path under a shorter directory. + + The numbers and the provenance are readable, for a caller that would + rather format its own message: + + >>> e = exc.SocketPathTooLong("/tmp/" + "d" * 120 + "/sock", 107) + >>> e.length, e.over, e.limit, e.env_var + (130, 23, 107, None) + + 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, + env_var: str | None = None, + env_value: str | None = None, + ) -> None: + self.socket_path: StrPath = socket_path + self.length: int = len(os.fsencode(socket_path)) + self.limit: int = limit + self.over: int = self.length - limit + self.socket_name: str | None = socket_name + self.env_var: str | None = env_var + self.env_value: str | None = env_value + + subject = "Socket path" + if socket_name is not None: + subject += f" for socket_name={socket_name!r}" + message = ( + f"{subject} is {self.length} bytes, {self.over} over the " + f"{limit} byte limit: {socket_path}" + ) + if env_var is not None: + message += ( + f" Inherited from ${env_var}: shorten it, or pass a " + f"socket_path under a shorter directory." + ) + super().__init__(message, *args) + + class ObjectDoesNotExist(LibTmuxException): """A lookup expected one object and matched none. diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 98ece86fa..f0e9b5865 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1098,15 +1098,8 @@ def fetch_objs( tmux_version = str(get_version(tmux_bin=server.tmux_bin)) _fields, format_string = get_output_format(list_cmd, tmux_version) - cmd_args: list[str | int] = [] - - if server.socket_name: - cmd_args.insert(0, f"-L{server.socket_name}") - if server.socket_path: - cmd_args.insert(0, f"-S{server.socket_path}") - - tmux_cmds = [ - *cmd_args, + tmux_cmds: list[str | int] = [ + *server.socket_args(), list_cmd, ] diff --git a/src/libtmux/server.py b/src/libtmux/server.py index a3e5bdff3..d07c6889e 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -16,7 +16,14 @@ import warnings from libtmux import exc -from libtmux._internal.env import resolve_socket_path, socket_path_from_env +from libtmux._internal.env import ( + TMUX, + TMUX_TMPDIR, + check_socket_path_length, + resolve_ambient_socket_path, + 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 @@ -99,6 +106,21 @@ 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. An explicit + *socket_path* is handed to tmux unchanged, so it is measured here and an + over-long one raises :exc:`~libtmux.exc.SocketPathTooLong` from the + constructor. + + Naming a server is free. A *socket_name* resolves against ``$TMUX_TMPDIR`` + when tmux runs, so it is measured then rather than now: a named or bare + :class:`Server` can always be constructed and asked :meth:`is_alive`, and + the first command that would have to bind the socket is what raises. 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 @@ -133,6 +155,37 @@ class Server( ... # Do work with the session ... # Server will be killed automatically when exiting the context + A ``socket_path`` is handed to tmux unchanged, so one that cannot fit in a + UNIX socket address is refused where it was written: + + >>> from libtmux.server import Server as TmuxServer + >>> try: + ... TmuxServer(socket_path="/tmp/" + "d" * 120 + "/sock") + ... except exc.SocketPathTooLong as e: + ... print(e.length) + 130 + + A name resolves against ``$TMUX_TMPDIR``, which tmux re-reads when it runs, + so its length is only knowable at dispatch. The server builds, and one at + an unbindable address is simply not alive: + + >>> with monkeypatch.context() as m: + ... m.setenv("TMUX_TMPDIR", "/tmp/" + "d" * 120) + ... named = TmuxServer(socket_name="dev") + ... named.is_alive() + False + + The command that would have had to bind it says so, with the byte count + tmux would not have given: + + >>> with monkeypatch.context() as m: + ... m.setenv("TMUX_TMPDIR", "/tmp/" + "d" * 120) + ... try: + ... named.cmd("list-sessions") + ... except exc.SocketPathTooLong as e: + ... print(e.socket_name) + dev + References ---------- .. [server_manual] CLIENTS AND SESSIONS. openbsd manpage for TMUX(1) @@ -184,6 +237,13 @@ def __init__( self._panes: list[PaneDict] = [] if socket_path is not None: + # ``socket_path`` is the one socket libtmux passes through + # unchanged, as ``-S``, so its length is settled here and + # measuring it now costs the caller nothing it can still change. + # A name or a bare server resolves against the environment tmux + # reads at exec, which is not knowable yet -- those are measured in + # :meth:`socket_args`. + check_socket_path_length(socket_path) self.socket_path = socket_path elif socket_name is not None: self.socket_name = socket_name @@ -290,8 +350,20 @@ def __exit__( def is_alive(self) -> bool: """Return True if tmux server alive. + Every way of failing to reach the server is a ``False`` here, so this + stays answerable for any :class:`Server` that exists. Callers who need + the reason use :meth:`raise_if_dead`. + >>> tmux = Server(socket_name="no_exist") >>> assert not tmux.is_alive() + + A socket path too long to bind is one of those ways -- nothing can be + listening at an address the kernel cannot hold: + + >>> from libtmux.server import Server as TmuxServer + >>> with monkeypatch.context() as m: + ... m.setenv("TMUX_TMPDIR", "/tmp/" + "d" * 120) + ... assert not TmuxServer(socket_name="unbindable").is_alive() """ try: res = self.cmd("list-sessions") @@ -306,6 +378,9 @@ def raise_if_dead(self) -> None: ------ :exc:`exc.TmuxCommandNotFound` When the tmux binary cannot be found or executed. + :exc:`~libtmux.exc.SocketPathTooLong` + When the socket this server names cannot fit in a UNIX socket + address, so no tmux command could ever reach it. :class:`subprocess.CalledProcessError` When the tmux server is not running (non-zero exit from ``list-sessions``). @@ -321,11 +396,7 @@ def raise_if_dead(self) -> None: if resolved is None: raise exc.TmuxCommandNotFound - cmd_args: list[str] = ["list-sessions"] - if self.socket_name: - cmd_args.insert(0, f"-L{self.socket_name}") - if self.socket_path: - cmd_args.insert(0, f"-S{self.socket_path}") + cmd_args: list[str] = [*self.socket_args(), "list-sessions"] if self.config_file: cmd_args.insert(0, f"-f{self.config_file}") @@ -337,6 +408,92 @@ def raise_if_dead(self) -> None: # # Command # + def socket_args(self) -> list[str]: + """Return this server's ``-S``/``-L`` flags, measuring the socket path. + + Every path that spawns tmux for this server builds its argv from here, + which is what makes the measurement total: a socket flag cannot reach + tmux without having been measured on the way. + + A tmux socket is a UNIX domain socket, so its path has to fit in + :data:`~libtmux._internal.env.SOCKET_PATH_MAX_BYTES`. An explicit + :attr:`socket_path` is settled the moment it is passed and is measured + in ``__init__``; measuring it again here is free and keeps this method + total over every flag it renders. + + A name or a bare server is different, and is measured only here, for + the same reason ``colors`` is checked at this point: naming a server is + not using one, so :meth:`is_alive` has to stay answerable for a server + that cannot be reached. It also reads ``$TMUX_TMPDIR`` at the moment + the tmux binary would read it, so an environment changed after + construction is measured as tmux sees it rather than as it once was. + + Returns + ------- + list[str] + ``["-S"]``, ``["-L"]``, or ``[]`` for the socket + inherited from ``$TMUX_TMPDIR``. + + Raises + ------ + :exc:`~libtmux.exc.SocketPathTooLong` + When the socket path -- given, or resolved from ``socket_name`` -- + cannot fit in a UNIX socket address. + + Examples + -------- + >>> server.socket_args() + ['-Llibtmux_test...'] + + >>> from libtmux.server import Server as TmuxServer + >>> TmuxServer(socket_path="/tmp/short/sock").socket_args() + ['-S/tmp/short/sock'] + + A bare server names no socket, and tmux falls back to its own default: + + >>> TmuxServer().socket_args() + [] + + A path too long to bind is refused here, before tmux is spawned: + + >>> try: + ... TmuxServer(socket_path="/tmp/" + "d" * 120 + "/sock").socket_args() + ... except exc.SocketPathTooLong as e: + ... print(e.length) + 130 + + .. versionadded:: 0.63 + """ + tmpdir = os.environ.get(TMUX_TMPDIR) or None + + if self.socket_path: + check_socket_path_length(self.socket_path) + elif self.socket_name: + # ``-L`` sends tmux to ``$TMUX_TMPDIR`` whatever ``$TMUX`` says, so + # the name resolves against the directory even inside a pane. + check_socket_path_length( + resolve_socket_path(self.socket_name), + socket_name=self.socket_name, + env_var=TMUX_TMPDIR if tmpdir else None, + env_value=tmpdir, + ) + else: + # A bare client prefers the pane's own socket and only computes a + # path under ``$TMUX_TMPDIR`` when there is no pane to inherit. + inside_pane = bool(os.environ.get(TMUX)) + check_socket_path_length( + resolve_ambient_socket_path(), + env_var=TMUX_TMPDIR if tmpdir and not inside_pane else None, + env_value=None if inside_pane else tmpdir, + ) + + args: list[str] = [] + if self.socket_path: + args.append(f"-S{self.socket_path}") + if self.socket_name: + args.append(f"-L{self.socket_name}") + return args + def cmd( self, cmd: str, @@ -384,18 +541,22 @@ def cmd( ------- :class:`common.tmux_cmd` + Raises + ------ + :exc:`~libtmux.exc.SocketPathTooLong` + When the socket path -- given, or resolved from ``socket_name`` -- + cannot fit in a UNIX socket address. See :meth:`socket_args`. + :exc:`~libtmux.exc.UnknownColorOption` + When ``colors`` is neither 88 nor 256. + Notes ----- .. versionchanged:: 0.8 Renamed from ``.tmux`` to ``.cmd``. """ - svr_args: list[str | int] = [cmd] + svr_args: list[str | int] = [*self.socket_args(), cmd] cmd_args: list[str | int] = [] - if self.socket_name: - svr_args.insert(0, f"-L{self.socket_name}") - if self.socket_path: - svr_args.insert(0, f"-S{self.socket_path}") if self.config_file: svr_args.insert(0, f"-f{self.config_file}") if self.colors: diff --git a/tests/test_server.py b/tests/test_server.py index 625addf0a..97a720a46 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -7,6 +7,7 @@ import os import pathlib import shutil +import socket import subprocess import time import typing as t @@ -15,6 +16,7 @@ from libtmux import exc from libtmux._internal.control_mode import ControlMode +from libtmux._internal.env import SOCKET_PATH_MAX_BYTES from libtmux.server import Server if t.TYPE_CHECKING: @@ -112,6 +114,285 @@ def test_repr_socket_name(monkeypatch: pytest.MonkeyPatch) -> None: assert repr(myserver) == "Server(socket_name=libtmux_test_repr)" +def _af_unix_path_fits(length: int) -> bool: + """Return True if a *length*-byte path fits in a UNIX socket address. + + CPython measures ``sun_path`` itself and refuses an oversized path before + any syscall, so this asks the live platform for its limit without creating a + socket file. A path that fits fails for an ordinary reason instead -- + nothing is listening at it. + """ + sock = socket.socket(socket.AF_UNIX) + try: + sock.connect("/" + "x" * (length - 1)) + except OSError as e: + return "too long" not in str(e) + finally: + sock.close() + return True + + +def test_socket_path_max_bytes_matches_platform() -> None: + """The compiled-in limit is the one this platform actually enforces. + + ``sun_path``'s size is not exposed by the stdlib, so libtmux spells it out + per platform. This keeps that number honest by probing. + """ + assert _af_unix_path_fits(SOCKET_PATH_MAX_BYTES) + assert not _af_unix_path_fits(SOCKET_PATH_MAX_BYTES + 1) + + +def test_socket_path_too_long_raises_at_construction() -> None: + """An explicit ``socket_path`` is measurable the moment it is passed. + + Regression for #725: without the measurement the overrun surfaced as a + passed-through ``File name too long`` without the byte count. + + This is the one socket libtmux hands to tmux unchanged, as ``-S``, + so its length cannot be revised by anything that happens later. A name or + a bare server resolves against an environment tmux re-reads at exec, which + is why those are measured at dispatch instead. + """ + socket_path = f"/tmp/{'d' * 200}/sock" + + with pytest.raises(exc.SocketPathTooLong) as excinfo: + Server(socket_path=socket_path) + + assert excinfo.value.length == len(socket_path) + assert excinfo.value.limit == SOCKET_PATH_MAX_BYTES + assert excinfo.value.over == len(socket_path) - SOCKET_PATH_MAX_BYTES + assert excinfo.value.socket_name is None + assert excinfo.value.env_var is None + + +def test_socket_path_at_limit_is_accepted() -> None: + """A path that exactly fills the limit is usable, so it is dispatched.""" + socket_path = "/tmp/" + "d" * (SOCKET_PATH_MAX_BYTES - len("/tmp/")) + assert len(socket_path) == SOCKET_PATH_MAX_BYTES + + myserver = Server(socket_path=socket_path) + + assert myserver.socket_path == socket_path + assert myserver.socket_args() == [f"-S{socket_path}"] + + +def test_socket_name_too_long_via_tmux_tmpdir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A short name resolving under a long ``$TMUX_TMPDIR`` overruns too. + + Regression for #725. This is the case that bites: the length is inherited + from the environment, so the caller never saw the path that failed -- hence + the resolved path and the name are both reported. + """ + monkeypatch.setenv("TMUX_TMPDIR", f"/tmp/{'d' * 200}") + myserver = Server(socket_name="dev") + + with pytest.raises(exc.SocketPathTooLong) as excinfo: + myserver.cmd("list-sessions") + + assert excinfo.value.socket_name == "dev" + assert str(excinfo.value.socket_path).endswith(f"/tmux-{os.geteuid()}/dev") + + +def test_socket_name_factory_too_long_via_tmux_tmpdir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A factory-generated name resolves under ``$TMUX_TMPDIR`` like any other. + + The factory runs in ``__init__``, so its name is on the object well before + dispatch -- the measurement still happens at dispatch, against the name the + factory produced. + """ + monkeypatch.setenv("TMUX_TMPDIR", f"/tmp/{'d' * 200}") + myserver = Server(socket_name_factory=lambda: "factory_made") + + assert myserver.socket_name == "factory_made" + assert not myserver.is_alive() + + with pytest.raises(exc.SocketPathTooLong) as excinfo: + myserver.cmd("list-sessions") + + assert excinfo.value.socket_name == "factory_made" + + +def test_socket_path_measured_at_dispatch_not_construction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``$TMUX_TMPDIR`` is read when tmux would read it, not before. + + The tmux binary resolves its socket directory at exec time, so a server + built under a short ``$TMUX_TMPDIR`` and used under a long one is measured + against the long one -- and the reverse recovers without rebuilding the + object. + """ + myserver = Server(socket_name="dev") + assert myserver.socket_args() == ["-Ldev"] + + monkeypatch.setenv("TMUX_TMPDIR", f"/tmp/{'d' * 200}") + with pytest.raises(exc.SocketPathTooLong): + myserver.socket_args() + + monkeypatch.delenv("TMUX_TMPDIR") + assert myserver.socket_args() == ["-Ldev"] + + +def test_long_symlinked_tmpdir_is_measured_after_resolving( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """A long ``$TMUX_TMPDIR`` symlink to a short directory is accepted. + + tmux resolves the socket directory before binding, so the link's own + length is not what has to fit. Measuring the unresolved path would refuse + a server tmux runs happily. + """ + target = tmp_path / "t" + target.mkdir() + link = tmp_path / ("s" * 120) + link.symlink_to(target) + monkeypatch.setenv("TMUX_TMPDIR", str(link)) + + assert len(os.fsencode(str(link))) > SOCKET_PATH_MAX_BYTES + + myserver = Server(socket_name="dev") + + assert myserver.socket_args() == ["-Ldev"] + + +def _unbindable_tmux_tmpdir( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> pathlib.Path: + """Point ``$TMUX_TMPDIR`` at a real directory too deep to hold a socket. + + The directory has to exist: tmux falls back to ``/tmp`` when it cannot + create ``$TMUX_TMPDIR/tmux-``, and would then reach a real server + instead of failing. ``$TMUX`` is cleared because a bare tmux client + prefers it over ``$TMUX_TMPDIR``, so a suite run from inside a pane would + otherwise be measuring a socket nothing here chose. + """ + tmpdir = tmp_path / ("d" * 120) + tmpdir.mkdir() + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setenv("TMUX_TMPDIR", str(tmpdir)) + return tmpdir + + +def test_default_socket_too_long_via_tmux_tmpdir( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A bare ``Server()`` inherits its whole path, so it is measured as well. + + The constructor still succeeds and :meth:`Server.is_alive` still answers: + a server at an address the kernel cannot hold is not alive. + """ + _unbindable_tmux_tmpdir(tmp_path, monkeypatch) + + myserver = Server() + + assert not myserver.is_alive() + + with pytest.raises(exc.SocketPathTooLong) as excinfo: + myserver.cmd("list-sessions") + + assert excinfo.value.socket_name is None + assert str(excinfo.value.socket_path).endswith("/default") + assert excinfo.value.env_var == "TMUX_TMPDIR" + + +def test_bare_server_inside_a_pane_ignores_tmux_tmpdir( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + session: Session, +) -> None: + """A bare client inside a pane uses ``$TMUX``, so a deep dir is irrelevant. + + Measuring ``$TMUX_TMPDIR`` here would refuse a server tmux reaches without + difficulty -- the common case for a script run inside tmux, which is where + libtmux is most often used. + + The patched environment is scoped to the assertions rather than the test. + The ``server`` fixture takes ``monkeypatch``, so pytest undoes the patches + only after that fixture's finalizer has run -- and a finalizer that resolves + the socket under an unreachable ``$TMUX_TMPDIR`` finds nothing to kill and + unlinks a path that never existed, leaving the daemon running. + """ + socket_path = session.server.cmd( + "display-message", + "-p", + "#{socket_path}", + ).stdout[0] + deep = tmp_path / ("d" * 120) + deep.mkdir() + + with monkeypatch.context() as m: + m.setenv("TMUX_TMPDIR", str(deep)) + m.setenv("TMUX", f"{socket_path},{os.getpid()},0") + + myserver = Server() + + assert myserver.socket_args() == [] + assert myserver.is_alive() + + +def test_socket_args_covers_every_tmux_spawn( + monkeypatch: pytest.MonkeyPatch, + session: Session, +) -> None: + """Every code path that spawns tmux builds its socket flags in one place. + + The measurement is only as total as the argv construction it rides on, so + this pins the audit: each spawn site is exercised against a live server and + asserted to have gone through :meth:`Server.socket_args`. + """ + server = session.server + calls: list[list[str]] = [] + real = Server.socket_args + + def spy(self: Server) -> list[str]: + args = real(self) + calls.append(args) + return args + + monkeypatch.setattr(Server, "socket_args", spy) + + server.cmd("list-sessions") + assert calls, "Server.cmd did not build socket flags via socket_args" + + calls.clear() + server.raise_if_dead() + assert calls, "Server.raise_if_dead did not build socket flags via socket_args" + + calls.clear() + assert server.sessions + assert calls, "neo.fetch_objs did not build socket flags via socket_args" + + calls.clear() + with ControlMode(server=server, session=session) as ctl: + assert ctl.client_name != "" + assert calls, "ControlMode did not build socket flags via socket_args" + + +def test_socket_path_too_long_raises_on_raise_if_dead( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``raise_if_dead`` is the loud primitive, so it reports the overrun. + + It builds its own argv and spawns tmux directly rather than going through + :meth:`Server.cmd`, which is exactly how a dispatch-time check goes stale. + An inherited path is the case that reaches it: an explicit ``socket_path`` + is refused at construction, so there is no object left to ask. + """ + _unbindable_tmux_tmpdir(tmp_path, monkeypatch) + myserver = Server() + + with pytest.raises(exc.SocketPathTooLong): + myserver.raise_if_dead() + + def test_config(server: Server) -> None: """``-f`` file for tmux(1) configuration.""" myserver = Server(config_file="test")