From d322bf38d17d4e7ca54ab0aaa1ee3c3ae170a385 Mon Sep 17 00:00:00 2001 From: Jun Zhou Date: Tue, 4 Aug 2026 19:25:54 -0700 Subject: [PATCH] fix(daemon): keep the socket path under the AF_UNIX limit The socket address is /daemon.sock, but sun_path caps a unix socket at 104 bytes on macOS (108 on Linux) where ordinary files get PATH_MAX. A deep $HOME -- a sandbox, a container, a CI runner -- pushes past it, and bind() fails with "AF_UNIX path too long" surfacing as "Daemon process exited before it became ready", which reads like a broken install rather than a path-length problem. Fall back to a short temp-dir address keyed by a hash of the runtime dir when the natural path is too long, so distinct runtime dirs keep distinct sockets; the uid is in the name because /tmp is shared on Linux. Client and daemon both resolve through daemon_socket_path(), so they agree either way. Found by an eval agent working in a sandbox with a long $HOME; it lost several turns diagnosing the daemon before deducing the COCOINDEX_CODE_RUNTIME_DIR workaround. Note pytest's own tmp_path is ~118 bytes on macOS, so the overflow is not an exotic case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LksW8LnigLAiFnrgLauW8M --- src/cocoindex_code/_daemon_paths.py | 46 ++++++++++++++++----- tests/test_settings.py | 63 +++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/src/cocoindex_code/_daemon_paths.py b/src/cocoindex_code/_daemon_paths.py index 787a8a7..fdde4a7 100644 --- a/src/cocoindex_code/_daemon_paths.py +++ b/src/cocoindex_code/_daemon_paths.py @@ -6,15 +6,22 @@ from __future__ import annotations +import hashlib import json import os import sys +import tempfile import time from dataclasses import dataclass from pathlib import Path from .settings import user_settings_dir +# Max bytes in sockaddr_un.sun_path, including the NUL terminator: 104 on the +# BSDs (incl. macOS), 108 on Linux. Exceeding it makes bind() fail with +# "AF_UNIX path too long" — unlike ordinary files, which get PATH_MAX (~1024). +_SUN_PATH_MAX = 104 if sys.platform == "darwin" else 108 + def daemon_runtime_dir() -> Path: """Return the directory that holds daemon runtime artifacts. @@ -39,18 +46,37 @@ def connection_family() -> str: return "AF_PIPE" if sys.platform == "win32" else "AF_UNIX" +def _runtime_dir_hash() -> str: + """Stable short id for the current runtime dir. + + Distinguishes daemon instances that differ only by + ``COCOINDEX_CODE_RUNTIME_DIR`` / ``COCOINDEX_CODE_DIR``, so tests, users, + and containers never collide on one address. + """ + return hashlib.md5(str(daemon_runtime_dir()).encode()).hexdigest()[:12] + + def daemon_socket_path() -> str: - """Return the daemon socket/pipe address.""" + """Return the daemon socket/pipe address. + + Normally ``/daemon.sock``. When that exceeds the platform's + ``sun_path`` limit — a deep ``$HOME``, a sandbox, a container path — it + would fail at bind() with a message that reads like a broken install, so + fall back to a short path under the temp dir keyed by the runtime dir. + Client and daemon both resolve through here, so they agree either way. + """ if sys.platform == "win32": - import hashlib - - # Hash the runtime dir so COCOINDEX_CODE_RUNTIME_DIR (or the - # COCOINDEX_CODE_DIR fallback) overrides produce unique pipe names, - # preventing conflicts between different daemon instances (tests, - # users, etc.) - dir_hash = hashlib.md5(str(daemon_runtime_dir()).encode()).hexdigest()[:12] - return rf"\\.\pipe\cocoindex_code_{dir_hash}" - return str(daemon_runtime_dir() / "daemon.sock") + return rf"\\.\pipe\cocoindex_code_{_runtime_dir_hash()}" + + path = daemon_runtime_dir() / "daemon.sock" + if len(str(path).encode()) < _SUN_PATH_MAX: + return str(path) + + # gettempdir() honors $TMPDIR, which is per-user and private on macOS; on + # Linux it is usually shared /tmp, so include the uid to avoid a foreign + # socket sitting at our address. + short = Path(tempfile.gettempdir()) / f"ccc-{os.getuid()}-{_runtime_dir_hash()}.sock" + return str(short) def daemon_pid_path() -> Path: diff --git a/tests/test_settings.py b/tests/test_settings.py index cd040b9..ec29665 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2,6 +2,9 @@ from __future__ import annotations +import shutil +import sys +import tempfile from collections.abc import Iterator from pathlib import Path @@ -635,6 +638,66 @@ def test_daemon_runtime_dir_falls_back_to_user_settings_dir( assert daemon_runtime_dir() == settings_dir +# --------------------------------------------------------------------------- +# daemon_socket_path +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(sys.platform == "win32", reason="named pipes have no length limit") +def test_daemon_socket_path_uses_runtime_dir_when_short(monkeypatch: pytest.MonkeyPatch) -> None: + """The common case is unchanged: socket sits in the runtime dir. + + Deliberately not pytest's ``tmp_path`` — on macOS that is ~118 bytes, past + sun_path already, which is how routine this overflow is. + """ + from cocoindex_code._daemon_paths import daemon_socket_path + + short_dir = tempfile.mkdtemp(prefix="ccc", dir=tempfile.gettempdir()) + try: + monkeypatch.setenv("COCOINDEX_CODE_RUNTIME_DIR", short_dir) + assert daemon_socket_path() == str(Path(short_dir) / "daemon.sock") + finally: + shutil.rmtree(short_dir, ignore_errors=True) + + +@pytest.mark.skipif(sys.platform == "win32", reason="named pipes have no length limit") +def test_daemon_socket_path_falls_back_when_over_sun_path_limit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A deep runtime dir must not produce an unbindable socket address. + + bind() fails with "AF_UNIX path too long" past sun_path (104 bytes on + macOS), which surfaces as a generic daemon-startup failure. Seen in the + wild under sandboxes and containers with long $HOME paths. + """ + from cocoindex_code._daemon_paths import _SUN_PATH_MAX, daemon_socket_path + + deep = tmp_path / ("d" * 80) / ("e" * 80) + monkeypatch.setenv("COCOINDEX_CODE_RUNTIME_DIR", str(deep)) + + path = daemon_socket_path() + assert not path.startswith(str(deep)) + assert len(path.encode()) < _SUN_PATH_MAX + + +@pytest.mark.skipif(sys.platform == "win32", reason="named pipes have no length limit") +def test_daemon_socket_path_fallback_is_unique_per_runtime_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Two over-long runtime dirs must not collide on one socket address.""" + from cocoindex_code._daemon_paths import daemon_socket_path + + long_a = tmp_path / ("a" * 80) / ("x" * 80) + long_b = tmp_path / ("b" * 80) / ("y" * 80) + + monkeypatch.setenv("COCOINDEX_CODE_RUNTIME_DIR", str(long_a)) + first = daemon_socket_path() + monkeypatch.setenv("COCOINDEX_CODE_RUNTIME_DIR", str(long_b)) + second = daemon_socket_path() + + assert first != second + + # --------------------------------------------------------------------------- # indexing_params / query_params round-trip and templates # ---------------------------------------------------------------------------