Skip to content
Merged
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
46 changes: 36 additions & 10 deletions src/cocoindex_code/_daemon_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 ``<runtime dir>/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:
Expand Down
63 changes: 63 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from __future__ import annotations

import shutil
import sys
import tempfile
from collections.abc import Iterator
from pathlib import Path

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading