diff --git a/CHANGES b/CHANGES index 928080202..7625d78d3 100644 --- a/CHANGES +++ b/CHANGES @@ -45,10 +45,42 @@ $ 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. + ### Fixes -- {class}`~libtmux.Server` now reprs the socket path tmux resolves from - `$TMUX_TMPDIR` instead of a hard-coded `/tmp/tmux-/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-/default` (#727) ### Documentation diff --git a/docs/api/testing/pytest-plugin/usage.md b/docs/api/testing/pytest-plugin/usage.md index 9ae0fd49d..dd7f1676e 100644 --- a/docs/api/testing/pytest-plugin/usage.md +++ b/docs/api/testing/pytest-plugin/usage.md @@ -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-/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: + +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 +` 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..4fad1aedb 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 bc3af8218..6ce9e8014 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. @@ -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-`` 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 @@ -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``. 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..c56ad145a 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 cc2938b72..07763d1e8 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -17,7 +17,11 @@ from libtmux import exc 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 @@ -102,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 @@ -136,6 +155,42 @@ 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 directory has to exist: + tmux falls back to ``/tmp`` when it cannot resolve one, and a socket it + never binds is not worth measuring. The server builds, and one at an + unbindable address is simply not alive: + + >>> deep = request.getfixturevalue("tmp_path") / ("d" * 120) + >>> deep.mkdir() + + >>> with monkeypatch.context() as m: + ... m.setenv("TMUX_TMPDIR", str(deep)) + ... 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", str(deep)) + ... 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) @@ -187,6 +242,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 @@ -293,8 +355,24 @@ 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 + >>> 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)) + ... assert not TmuxServer(socket_name="unbindable").is_alive() """ try: res = self.cmd("list-sessions") @@ -309,6 +387,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``). @@ -324,11 +405,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}") @@ -340,6 +417,93 @@ 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 a bare server, + which leaves tmux to pick the socket itself -- ``$TMUX`` inside a + pane, and a path under ``$TMUX_TMPDIR`` outside one. + + 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, @@ -387,18 +551,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: @@ -2706,16 +2874,16 @@ def __repr__(self) -> str: >>> with monkeypatch.context() as m: ... m.delenv("TMUX", raising=False) - ... m.setenv("TMUX_TMPDIR", "/run/user/1000") + ... m.setenv("TMUX_TMPDIR", "/usr") ... Server() - Server(socket_path=/run/user/1000/tmux-.../default) + Server(socket_path=/usr/tmux-.../default) Inside one, ``$TMUX`` names the socket outright and the directory is not consulted: >>> with monkeypatch.context() as m: ... m.setenv("TMUX", "/tmp/tmux-1000/inherited,8421,0") - ... m.setenv("TMUX_TMPDIR", "/run/user/1000") + ... m.setenv("TMUX_TMPDIR", "/usr") ... Server() Server(socket_path=/tmp/tmux-1000/inherited) """ diff --git a/tests/test_server.py b/tests/test_server.py index a750f246f..59e41f4ab 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, resolve_socket_path from libtmux.server import Server if t.TYPE_CHECKING: @@ -114,6 +116,311 @@ 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( + tmp_path: pathlib.Path, + 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. + """ + _unbindable_tmux_tmpdir(tmp_path, monkeypatch) + 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( + tmp_path: pathlib.Path, + 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. + """ + _unbindable_tmux_tmpdir(tmp_path, monkeypatch) + 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( + tmp_path: pathlib.Path, + 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"] + + _unbindable_tmux_tmpdir(tmp_path, monkeypatch) + 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_unusable_tmux_tmpdir_falls_back_to_tmp( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A ``$TMUX_TMPDIR`` tmux cannot use is not the path that gets measured. + + tmux takes the first of ``$TMUX_TMPDIR`` and ``/tmp`` that resolves, so a + directory that does not exist never holds the socket however long its name + is. Measuring it anyway would refuse a server tmux reaches without + difficulty: :meth:`Server.is_alive` would report a live daemon dead and the + first command would raise. + """ + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setenv("TMUX_TMPDIR", f"/nonexistent-{'d' * 200}") + + assert resolve_socket_path("dev") == pathlib.Path( + f"/tmp/tmux-{os.geteuid()}/dev", + ) + + myserver = Server(socket_name="dev") + + assert myserver._socket_args() == ["-Ldev"] + + +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")