4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
892 lines
36 KiB
Python
892 lines
36 KiB
Python
"""Tests for the local-host pub/sub agent bus over a Unix-domain socket."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import queue
|
||
import socket
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from collections.abc import Iterator
|
||
from contextlib import suppress
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
_POSIX_FCNTL_AVAILABLE = importlib.util.find_spec("fcntl") is not None
|
||
|
||
from tools.system.fleet_monitoring import bus as bus_module
|
||
from tools.system.fleet_monitoring.bus import (
|
||
BUS_SCHEMA_VERSION,
|
||
BusMessage,
|
||
BusServer,
|
||
_pid_file_for,
|
||
_read_broker_pid,
|
||
_socket_is_live,
|
||
publish,
|
||
subscribe,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def sock_path() -> Iterator[Path]:
|
||
"""Unix domain socket path must stay short (sun_path length cap, often 104–108)."""
|
||
path = Path("/tmp") / f"opensre-bus-{uuid.uuid4().hex}.sock"
|
||
yield path
|
||
with suppress(OSError):
|
||
path.unlink(missing_ok=True)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def isolated_brokers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Ensure each test starts with empty broker + publisher caches.
|
||
|
||
Without this, the publisher socket cached from a prior test would point at
|
||
a now-stopped broker's socket path, and the first ``publish()`` of the
|
||
next test would silently sendall onto a dead fd.
|
||
"""
|
||
monkeypatch.setattr(bus_module, "_brokers", {})
|
||
monkeypatch.setattr(bus_module, "_publishers", {})
|
||
|
||
|
||
def _drain_subscriber(
|
||
sock_path: Path,
|
||
into: queue.Queue[BusMessage],
|
||
stop_after: int = 1,
|
||
*,
|
||
attach_timeout: float = 3.0,
|
||
) -> threading.Thread:
|
||
"""Spawn a daemon subscriber and block until it has attached to the broker.
|
||
|
||
Avoids the flaky ``time.sleep(0.15)`` pattern: we busy-poll the broker's
|
||
in-memory subscriber set until it ticks up by exactly one. Works because
|
||
our process is the broker for the test path (the autouse fixture clears
|
||
the broker registry, so ``subscribe()`` self-elects on first attach).
|
||
"""
|
||
|
||
def _loop() -> None:
|
||
for count, msg in enumerate(subscribe(path=sock_path), start=1):
|
||
into.put(msg)
|
||
if count >= stop_after:
|
||
return
|
||
|
||
# Snapshot the pre-attach subscriber count so we don't race against
|
||
# subscribers spawned by other helpers in the same test.
|
||
broker_before = bus_module._brokers.get(sock_path)
|
||
before = len(broker_before._subscribers) if broker_before is not None else 0
|
||
|
||
thread = threading.Thread(target=_loop, daemon=True)
|
||
thread.start()
|
||
|
||
deadline = time.monotonic() + attach_timeout
|
||
while time.monotonic() < deadline:
|
||
broker = bus_module._brokers.get(sock_path)
|
||
if broker is not None:
|
||
with broker._lock:
|
||
if len(broker._subscribers) > before:
|
||
return thread
|
||
time.sleep(0.005)
|
||
raise AssertionError(f"_drain_subscriber: subscriber did not attach within {attach_timeout}s")
|
||
|
||
|
||
class TestBusMessage:
|
||
def test_round_trip_preserves_all_fields(self) -> None:
|
||
original = BusMessage(
|
||
agent="claude-code:8421",
|
||
topic="finding",
|
||
summary="null deref on missing token",
|
||
source="github",
|
||
path="services/auth.py:42",
|
||
data={"commit": "abc", "line": 42},
|
||
)
|
||
decoded = BusMessage.from_jsonl(original.to_jsonl())
|
||
assert decoded == original
|
||
|
||
def test_to_jsonl_ends_with_newline(self) -> None:
|
||
msg = BusMessage(agent="a:1", topic="finding", summary="x")
|
||
assert msg.to_jsonl().endswith(b"\n")
|
||
|
||
def test_from_jsonl_supplies_defaults_for_optional_fields(self) -> None:
|
||
# Minimum required fields only — source/path/data should default.
|
||
wire = b'{"agent":"a:1","topic":"finding","summary":"x"}\n'
|
||
msg = BusMessage.from_jsonl(wire)
|
||
assert msg.source == ""
|
||
assert msg.path == ""
|
||
assert msg.data == {}
|
||
assert msg.schema_version == BUS_SCHEMA_VERSION
|
||
|
||
def test_from_jsonl_rejects_non_object_payload(self) -> None:
|
||
with pytest.raises(ValueError):
|
||
BusMessage.from_jsonl(b'["not","an","object"]')
|
||
|
||
def test_from_jsonl_rejects_missing_required_field(self) -> None:
|
||
with pytest.raises(KeyError):
|
||
BusMessage.from_jsonl(b'{"agent":"a:1","topic":"finding"}')
|
||
|
||
def test_is_not_hashable(self) -> None:
|
||
# ``data`` is a mapping, so a value hash would be misleading and would
|
||
# fail at call time on the auto-generated ``__hash__``. We disable it
|
||
# explicitly. Assert the contract directly (``__hash__ is None``);
|
||
# Python's hash protocol guarantees ``TypeError`` from there, no need
|
||
# to re-test the language (and triggering ``hash()`` here trips
|
||
# CodeQL's "Unhashable object hashed" rule).
|
||
assert BusMessage.__hash__ is None
|
||
|
||
def test_data_is_read_only_post_construction(self) -> None:
|
||
msg = BusMessage(agent="a:1", topic="finding", summary="x", data={"k": 1})
|
||
with pytest.raises(TypeError):
|
||
msg.data["k"] = 2 # type: ignore[index]
|
||
|
||
def test_data_is_isolated_from_caller_mutation(self) -> None:
|
||
# External mutation of the originally-passed dict must not bleed into
|
||
# the message — defensive copy in ``__post_init__`` enforces this.
|
||
src: dict[str, object] = {"k": 1}
|
||
msg = BusMessage(agent="a:1", topic="finding", summary="x", data=src)
|
||
src["k"] = 999
|
||
assert msg.data["k"] == 1
|
||
|
||
|
||
class TestBusServerLifecycle:
|
||
def test_start_binds_socket_and_stop_unlinks(self, sock_path: Path) -> None:
|
||
server = BusServer(sock_path)
|
||
try:
|
||
server.start()
|
||
assert sock_path.exists()
|
||
assert _socket_is_live(sock_path)
|
||
assert server.is_running
|
||
finally:
|
||
server.stop()
|
||
assert not sock_path.exists()
|
||
assert not server.is_running
|
||
|
||
def test_start_is_idempotent(self, sock_path: Path) -> None:
|
||
server = BusServer(sock_path)
|
||
try:
|
||
server.start()
|
||
server.start() # second call should be a no-op, not raise
|
||
assert server.is_running
|
||
finally:
|
||
server.stop()
|
||
|
||
def test_stop_is_idempotent(self, sock_path: Path) -> None:
|
||
server = BusServer(sock_path)
|
||
server.start()
|
||
server.stop()
|
||
server.stop() # second call should be a no-op, not raise
|
||
|
||
def test_start_writes_pid_file_and_stop_removes_it(self, sock_path: Path) -> None:
|
||
import os
|
||
|
||
server = BusServer(sock_path)
|
||
server.start()
|
||
try:
|
||
pid_path = _pid_file_for(sock_path)
|
||
assert pid_path.exists()
|
||
assert _read_broker_pid(sock_path) == os.getpid()
|
||
finally:
|
||
server.stop()
|
||
assert not _pid_file_for(sock_path).exists()
|
||
|
||
def test_start_rolls_back_when_pid_file_write_fails(
|
||
self, sock_path: Path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
# Simulate ENOSPC / EACCES during the PID-file write. ``start`` must
|
||
# raise *and* leave no orphaned socket file behind — otherwise peers
|
||
# would see a path with no live listener via ``_socket_is_live``,
|
||
# ``_unlink_stale`` it, and silently split the bus.
|
||
def _boom_enospc(*args: object) -> None:
|
||
del args
|
||
raise OSError(28, "No space left on device")
|
||
|
||
monkeypatch.setattr(bus_module, "_write_pid_file_atomic", _boom_enospc)
|
||
|
||
server = BusServer(sock_path)
|
||
with pytest.raises(OSError):
|
||
server.start()
|
||
assert not sock_path.exists()
|
||
assert not _pid_file_for(sock_path).exists()
|
||
assert not server.is_running
|
||
|
||
def test_ensure_broker_propagates_pid_write_failure(
|
||
self, sock_path: Path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
# ``_ensure_broker`` swallows EADDRINUSE/EEXIST as a lost bind race,
|
||
# but real failures (disk full, permission denied) must propagate so
|
||
# callers see the error instead of silently splitting the bus.
|
||
def _boom_eacces(*args: object) -> None:
|
||
del args
|
||
raise OSError(13, "Permission denied")
|
||
|
||
monkeypatch.setattr(bus_module, "_write_pid_file_atomic", _boom_eacces)
|
||
with pytest.raises(OSError):
|
||
bus_module._ensure_broker(sock_path)
|
||
|
||
|
||
class TestLivenessProbe:
|
||
def test_socket_is_live_does_not_create_phantom_subscriber(self, sock_path: Path) -> None:
|
||
# _socket_is_live used to make a real connection on every probe; under
|
||
# publish/subscribe bursts that registered a short-lived subscriber +
|
||
# reader thread per call. Verify the side-channel probe makes none.
|
||
server = BusServer(sock_path)
|
||
server.start()
|
||
try:
|
||
for _ in range(20):
|
||
assert _socket_is_live(sock_path)
|
||
with server._lock:
|
||
assert len(server._subscribers) == 0
|
||
finally:
|
||
server.stop()
|
||
|
||
def test_socket_is_live_false_when_pid_missing(self, sock_path: Path) -> None:
|
||
sock_path.parent.mkdir(parents=True, exist_ok=True)
|
||
sock_path.touch() # socket file present but no pid sidecar
|
||
assert not _socket_is_live(sock_path)
|
||
|
||
def test_socket_is_live_false_when_pid_dead(self, sock_path: Path) -> None:
|
||
sock_path.parent.mkdir(parents=True, exist_ok=True)
|
||
sock_path.touch()
|
||
# PID 999999 is almost certainly not a real process. The probe must
|
||
# report not-live so the caller will unlink + rebind.
|
||
_pid_file_for(sock_path).write_text("999999")
|
||
assert not _socket_is_live(sock_path)
|
||
|
||
|
||
class TestPublisherCache:
|
||
def test_burst_of_publishes_reuses_one_connection(self, sock_path: Path) -> None:
|
||
# Each publish() previously opened a fresh UDS connection, which the
|
||
# broker accepted, registered as a subscriber, and ran a per-connection
|
||
# reader thread for. A burst of N publishes spawned N short-lived
|
||
# threads. With the cache, all publishes from one process share one
|
||
# persistent connection and one persistent broker reader thread.
|
||
received: queue.Queue[BusMessage] = queue.Queue()
|
||
_drain_subscriber(sock_path, received, stop_after=20)
|
||
|
||
for i in range(20):
|
||
publish(BusMessage(agent="a:1", topic="finding", summary=f"n{i}"), path=sock_path)
|
||
|
||
# All frames arrive in order.
|
||
for i in range(20):
|
||
msg = received.get(timeout=2.0)
|
||
assert msg.summary == f"n{i}"
|
||
|
||
# Exactly one cached publisher socket for our process.
|
||
assert len(bus_module._publishers) == 1
|
||
cached = next(iter(bus_module._publishers.values()))
|
||
assert cached.sock.fileno() >= 0
|
||
|
||
# Broker side: at most one publisher-origin connection (plus the one
|
||
# subscriber drain). Check by counting connections opened by us. The
|
||
# broker only sees what we sent it; if reuse is working, every publish
|
||
# came from the same socket and the broker registered exactly one
|
||
# publisher connection for the burst.
|
||
broker = bus_module._brokers[sock_path]
|
||
with broker._lock:
|
||
# 1 subscriber (the drain thread above) + 1 cached publisher = 2.
|
||
# If the cache were broken we'd see many transient connections,
|
||
# but most would have closed by now — so this is a weak check;
|
||
# the strong check is len(bus_module._publishers) == 1 above.
|
||
assert len(broker._subscribers) <= 2
|
||
|
||
def test_reconnects_after_cached_socket_breaks(self, sock_path: Path) -> None:
|
||
received: queue.Queue[BusMessage] = queue.Queue()
|
||
_drain_subscriber(sock_path, received, stop_after=2)
|
||
|
||
publish(BusMessage(agent="a:1", topic="finding", summary="first"), path=sock_path)
|
||
msg1 = received.get(timeout=2.0)
|
||
assert msg1.summary == "first"
|
||
|
||
# Forcibly break the cached publisher connection.
|
||
broken_cached = next(iter(bus_module._publishers.values()))
|
||
broken_sock = broken_cached.sock
|
||
broken_sock.close()
|
||
|
||
# Next publish must transparently reconnect (one retry on OSError).
|
||
publish(BusMessage(agent="a:1", topic="finding", summary="second"), path=sock_path)
|
||
msg2 = received.get(timeout=2.0)
|
||
assert msg2.summary == "second"
|
||
|
||
# The cache now holds a fresh socket object (the OS may recycle the
|
||
# underlying fd number, so compare object identity, not fileno()).
|
||
new_cached = next(iter(bus_module._publishers.values()))
|
||
assert new_cached.sock is not broken_sock
|
||
|
||
def test_publish_retries_on_initial_connect_failure(
|
||
self, sock_path: Path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
# The first ``_get_or_open_publisher`` raises (e.g. broker exited
|
||
# between liveness check and connect). ``publish`` must run
|
||
# ``_ensure_broker`` and retry once instead of failing immediately.
|
||
real_get = bus_module._get_or_open_publisher
|
||
calls = {"n": 0}
|
||
|
||
def _flaky(target: Path, *, connect_timeout: float) -> bus_module._CachedPublisher:
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
raise ConnectionRefusedError(111, "Connection refused")
|
||
return real_get(target, connect_timeout=connect_timeout)
|
||
|
||
monkeypatch.setattr(bus_module, "_get_or_open_publisher", _flaky)
|
||
|
||
received: queue.Queue[BusMessage] = queue.Queue()
|
||
_drain_subscriber(sock_path, received, stop_after=1)
|
||
|
||
publish(BusMessage(agent="a:1", topic="finding", summary="retried"), path=sock_path)
|
||
msg = received.get(timeout=2.0)
|
||
assert msg.summary == "retried"
|
||
assert calls["n"] == 2, "publish() did not retry on initial connect failure"
|
||
|
||
def test_subscribe_retries_on_initial_connect_failure(
|
||
self, sock_path: Path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
# ``subscribe`` previously raised on the first connect failure;
|
||
# symmetric with ``publish``, it now retries once.
|
||
real_connect = bus_module._connect_client
|
||
calls = {"n": 0}
|
||
|
||
def _flaky(target: Path, timeout: float) -> socket.socket:
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
raise ConnectionRefusedError(111, "Connection refused")
|
||
return real_connect(target, timeout=timeout)
|
||
|
||
monkeypatch.setattr(bus_module, "_connect_client", _flaky)
|
||
|
||
# Just calling subscribe() and pulling the first batch is enough —
|
||
# if the retry didn't happen, the call would raise.
|
||
received: queue.Queue[BusMessage] = queue.Queue()
|
||
|
||
def _loop() -> None:
|
||
for msg in subscribe(path=sock_path):
|
||
received.put(msg)
|
||
return
|
||
|
||
thread = threading.Thread(target=_loop, daemon=True)
|
||
thread.start()
|
||
|
||
# Wait for subscribe() to attach, give up to 2s.
|
||
deadline = time.monotonic() + 2.0
|
||
while time.monotonic() < deadline:
|
||
broker = bus_module._brokers.get(sock_path)
|
||
if broker is not None:
|
||
with broker._lock:
|
||
if broker._subscribers:
|
||
break
|
||
time.sleep(0.005)
|
||
broker = bus_module._brokers.get(sock_path)
|
||
assert broker is not None and broker._subscribers, "subscriber never attached after retry"
|
||
assert calls["n"] >= 2, "subscribe() did not retry on initial connect failure"
|
||
|
||
def test_concurrent_publishes_do_not_interleave_frames(self, sock_path: Path) -> None:
|
||
# If the per-socket send_lock weren't held around sendall(), concurrent
|
||
# publishes from different threads could interleave bytes mid-frame,
|
||
# corrupting the JSONL stream.
|
||
n_threads = 10
|
||
per_thread = 5
|
||
total = n_threads * per_thread
|
||
|
||
received: queue.Queue[BusMessage] = queue.Queue()
|
||
_drain_subscriber(sock_path, received, stop_after=total)
|
||
|
||
def _spam(tid: int) -> None:
|
||
for i in range(per_thread):
|
||
publish(
|
||
BusMessage(
|
||
agent=f"t{tid}:1",
|
||
topic="finding",
|
||
summary=f"t{tid}-n{i}",
|
||
),
|
||
path=sock_path,
|
||
)
|
||
|
||
threads = [threading.Thread(target=_spam, args=(t,)) for t in range(n_threads)]
|
||
for t in threads:
|
||
t.start()
|
||
for t in threads:
|
||
t.join(timeout=5.0)
|
||
|
||
# Every frame must be parseable (no byte interleaving) and unique.
|
||
seen: set[str] = set()
|
||
for _ in range(total):
|
||
msg = received.get(timeout=5.0)
|
||
seen.add(msg.summary)
|
||
assert len(seen) == total, f"frame loss or corruption: got {len(seen)} unique of {total}"
|
||
|
||
|
||
class TestPublishSubscribe:
|
||
def test_round_trip_one_publisher_one_subscriber(self, sock_path: Path) -> None:
|
||
received: queue.Queue[BusMessage] = queue.Queue()
|
||
_drain_subscriber(sock_path, received)
|
||
|
||
publish(
|
||
BusMessage(
|
||
agent="claude-code:8421",
|
||
topic="finding",
|
||
summary="null deref",
|
||
path="services/auth.py:42",
|
||
),
|
||
path=sock_path,
|
||
)
|
||
|
||
msg = received.get(timeout=2.0)
|
||
assert msg.agent == "claude-code:8421"
|
||
assert msg.summary == "null deref"
|
||
assert msg.path == "services/auth.py:42"
|
||
|
||
def test_one_publisher_multiple_subscribers_all_receive(self, sock_path: Path) -> None:
|
||
n_subs = 3
|
||
sub_queues: list[queue.Queue[BusMessage]] = [queue.Queue() for _ in range(n_subs)]
|
||
for q in sub_queues:
|
||
_drain_subscriber(sock_path, q)
|
||
|
||
publish(BusMessage(agent="a:1", topic="finding", summary="hello"), path=sock_path)
|
||
|
||
for q in sub_queues:
|
||
msg = q.get(timeout=2.0)
|
||
assert msg.summary == "hello"
|
||
|
||
def test_broadcast_holds_per_subscriber_write_lock(self, sock_path: Path) -> None:
|
||
# The bug: two reader-threads broadcasting concurrently to the same
|
||
# subscriber socket can interleave bytes mid-frame because
|
||
# ``sendall`` is multi-syscall under back-pressure. The fix is a
|
||
# per-subscriber write lock acquired around ``select`` + ``sendall``
|
||
# in ``_broadcast``.
|
||
#
|
||
# Reliably reproducing kernel-level byte interleaving across
|
||
# systems is fragile (depends on SNDBUF/RCVBUF tuning). Test the
|
||
# lock contract directly: hold the per-subscriber write_lock
|
||
# externally and confirm ``_broadcast`` blocks until release.
|
||
server = BusServer(sock_path)
|
||
server.start()
|
||
sub: socket.socket | None = None
|
||
try:
|
||
sub = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
sub.connect(str(sock_path))
|
||
|
||
deadline = time.monotonic() + 2.0
|
||
while time.monotonic() < deadline:
|
||
with server._lock:
|
||
if server._subscribers:
|
||
break
|
||
time.sleep(0.005)
|
||
with server._lock:
|
||
assert server._subscribers, "subscriber never attached"
|
||
# Grab the broker-side (sub, write_lock) pair.
|
||
((_broker_sub, write_lock),) = server._subscribers.items()
|
||
|
||
# Hold the lock as if another reader-thread were mid-sendall.
|
||
assert write_lock.acquire(timeout=1.0)
|
||
broadcast_returned = threading.Event()
|
||
try:
|
||
|
||
def _bcast() -> None:
|
||
server._broadcast(b'{"x":"y"}\n', origin=None)
|
||
broadcast_returned.set()
|
||
|
||
t = threading.Thread(target=_bcast, daemon=True)
|
||
t.start()
|
||
|
||
# While we hold the lock, ``_broadcast`` must wait — without
|
||
# the lock it would race straight into ``sendall``.
|
||
assert not broadcast_returned.wait(timeout=0.2), (
|
||
"broadcast did not block on per-subscriber write lock"
|
||
)
|
||
finally:
|
||
write_lock.release()
|
||
|
||
# After release, broadcast should complete promptly.
|
||
assert broadcast_returned.wait(timeout=2.0), (
|
||
"broadcast did not return after lock release"
|
||
)
|
||
finally:
|
||
if sub is not None:
|
||
with suppress(OSError):
|
||
sub.close()
|
||
server.stop()
|
||
|
||
def test_publisher_does_not_receive_own_frame(self, sock_path: Path) -> None:
|
||
# Self-elect a broker so we can attach as a single client that
|
||
# both publishes and subscribes — and verify the broadcaster
|
||
# does not echo the frame back to its origin.
|
||
bus_module._ensure_broker(sock_path)
|
||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
client.connect(str(sock_path))
|
||
client.settimeout(0.5)
|
||
try:
|
||
client.sendall(BusMessage(agent="a:1", topic="finding", summary="x").to_jsonl())
|
||
with pytest.raises(socket.timeout):
|
||
client.recv(4096)
|
||
finally:
|
||
client.close()
|
||
|
||
def test_subscribe_skips_malformed_frames(self, sock_path: Path) -> None:
|
||
received: queue.Queue[BusMessage] = queue.Queue()
|
||
_drain_subscriber(sock_path, received)
|
||
|
||
# Connect a raw publisher and inject a malformed frame followed by a valid one.
|
||
bus_module._ensure_broker(sock_path)
|
||
raw = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
raw.connect(str(sock_path))
|
||
try:
|
||
raw.sendall(b"this-is-not-json\n")
|
||
raw.sendall(BusMessage(agent="a:1", topic="finding", summary="ok").to_jsonl())
|
||
finally:
|
||
# Keep the publisher alive briefly so the broker can forward both frames.
|
||
time.sleep(0.1)
|
||
raw.close()
|
||
|
||
msg = received.get(timeout=2.0)
|
||
assert msg.summary == "ok"
|
||
|
||
def test_unresponsive_subscriber_does_not_stall_broadcast(
|
||
self, sock_path: Path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
# If one subscriber's recv buffer fills, ``sendall`` on a blocking UDS
|
||
# would wedge indefinitely with no exception, freezing fan-out for
|
||
# every publisher. Verify the write-readiness gate evicts the slow
|
||
# subscriber and lets healthy ones keep receiving.
|
||
monkeypatch.setattr(bus_module, "_BROADCAST_WRITE_TIMEOUT_SECONDS", 0.05)
|
||
|
||
server = BusServer(sock_path)
|
||
server.start()
|
||
slow: socket.socket | None = None
|
||
try:
|
||
# Healthy subscriber: drains until it sees the "alive" sentinel.
|
||
seen_alive = threading.Event()
|
||
|
||
def _healthy_drain() -> None:
|
||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
client.connect(str(sock_path))
|
||
client.settimeout(3.0)
|
||
buf = b""
|
||
try:
|
||
while not seen_alive.is_set():
|
||
chunk = client.recv(8192)
|
||
if not chunk:
|
||
return
|
||
buf += chunk
|
||
while b"\n" in buf:
|
||
line, buf = buf.split(b"\n", 1)
|
||
if not line:
|
||
continue
|
||
try:
|
||
msg = BusMessage.from_jsonl(line)
|
||
except (ValueError, KeyError, TypeError):
|
||
continue
|
||
if msg.summary == "alive":
|
||
seen_alive.set()
|
||
return
|
||
except OSError:
|
||
return
|
||
finally:
|
||
client.close()
|
||
|
||
t = threading.Thread(target=_healthy_drain, daemon=True)
|
||
t.start()
|
||
# Slow subscriber: shrink recv buffer so it fills fast, never reads.
|
||
slow = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
slow.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024)
|
||
slow.connect(str(sock_path))
|
||
|
||
# Wait for both to attach.
|
||
deadline = time.time() + 2.0
|
||
while time.time() < deadline:
|
||
with server._lock:
|
||
if len(server._subscribers) >= 2:
|
||
break
|
||
time.sleep(0.02)
|
||
|
||
# Pump enough large frames to fill ``slow``'s tiny recv buffer.
|
||
for _ in range(8):
|
||
publish(
|
||
BusMessage(agent="a:1", topic="finding", summary="x" * 4000),
|
||
path=sock_path,
|
||
)
|
||
|
||
# Final small frame. Healthy subscriber must receive it — that
|
||
# proves the broker did not stall behind the wedged ``slow``.
|
||
publish(BusMessage(agent="a:1", topic="finding", summary="alive"), path=sock_path)
|
||
|
||
assert seen_alive.wait(timeout=3.0), (
|
||
"broker stalled: healthy subscriber never received the post-fill frame"
|
||
)
|
||
|
||
# Slow subscriber should have been evicted from the fan-out set.
|
||
deadline = time.time() + 1.0
|
||
slow_fd = slow.fileno()
|
||
while time.time() < deadline:
|
||
with server._lock:
|
||
still_attached = any(s.fileno() == slow_fd for s in server._subscribers)
|
||
if not still_attached:
|
||
break
|
||
time.sleep(0.05)
|
||
with server._lock:
|
||
attached_fds = {s.fileno() for s in server._subscribers}
|
||
assert slow_fd not in attached_fds, (
|
||
"unresponsive subscriber was not evicted from fan-out set"
|
||
)
|
||
finally:
|
||
if slow is not None:
|
||
slow.close()
|
||
server.stop()
|
||
|
||
def test_subscriber_disconnects_on_oversized_unterminated_stream(self, sock_path: Path) -> None:
|
||
# Simulate a hostile broker that wins the bind race and streams unlimited
|
||
# bytes without newlines. The subscriber must cap its buffer and bail
|
||
# rather than grow memory unboundedly.
|
||
from tools.system.fleet_monitoring.bus import _MAX_FRAME_BYTES, BusServer, subscribe
|
||
|
||
# Stand up a fake "hostile" broker that pushes garbage to every client.
|
||
server = BusServer(sock_path)
|
||
server.start()
|
||
try:
|
||
done = threading.Event()
|
||
error: list[Exception] = []
|
||
|
||
def _consume() -> None:
|
||
try:
|
||
# Drain until subscribe() returns (which it should, on cap breach).
|
||
for _ in subscribe(path=sock_path):
|
||
pass
|
||
except Exception as exc:
|
||
error.append(exc)
|
||
finally:
|
||
done.set()
|
||
|
||
t = threading.Thread(target=_consume, daemon=True)
|
||
t.start()
|
||
# Wait for the subscriber to attach to the broker.
|
||
deadline = time.time() + 2.0
|
||
while time.time() < deadline:
|
||
with server._lock:
|
||
if server._subscribers:
|
||
break
|
||
time.sleep(0.02)
|
||
assert server._subscribers, "subscriber never attached"
|
||
|
||
# Push a single oversized chunk through to all subscribers.
|
||
payload = b"X" * (_MAX_FRAME_BYTES * 4 + 1024)
|
||
with server._lock:
|
||
victim = next(iter(server._subscribers))
|
||
victim.sendall(payload)
|
||
|
||
# Subscriber should disconnect on its own without raising.
|
||
assert done.wait(timeout=2.0), "subscriber did not disconnect on cap breach"
|
||
assert not error, f"subscribe() raised unexpectedly: {error}"
|
||
finally:
|
||
server.stop()
|
||
|
||
|
||
class TestBrokerElectionRace:
|
||
def test_concurrent_cold_start_election_does_not_orphan_a_broker(self, sock_path: Path) -> None:
|
||
# Cold-start race: two processes both observe ``_socket_is_live``
|
||
# → False, both call ``_unlink_stale``, both try to bind. The
|
||
# kernel only lets one bind succeed, but without cross-process
|
||
# serialization the loser is left holding a listener fd whose
|
||
# filesystem path the winner just unlinked, plus the accept/
|
||
# reader daemon threads. The election ``flock`` prevents that.
|
||
#
|
||
# We simulate the race by holding the flock from this test
|
||
# process and concurrently spawning a child that calls
|
||
# ``_ensure_broker``. The child must block on the flock until we
|
||
# release it, then return ``None`` (we elected first).
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import textwrap
|
||
|
||
# Bind a broker first; once it's live, any peer (including the
|
||
# subprocess we'll spawn) sees it via the PID-file side channel and
|
||
# backs off. The flock only matters during the check-unlink-bind
|
||
# window, which closes after ``server.start()`` returns.
|
||
server = bus_module.BusServer(sock_path)
|
||
server.start()
|
||
try:
|
||
assert sock_path.exists()
|
||
assert _pid_file_for(sock_path).exists()
|
||
|
||
# Spawn a real subprocess that calls ``_ensure_broker`` for
|
||
# the same path. With the election lock and the live PID
|
||
# sidecar, the child must observe our broker and return
|
||
# ``None``. Without the flock contract, a concurrent cold
|
||
# start could unlink our socket file and rebind, orphaning
|
||
# this server.
|
||
repo_root = Path(__file__).resolve().parents[2]
|
||
child = subprocess.run(
|
||
[
|
||
sys.executable,
|
||
"-c",
|
||
textwrap.dedent(
|
||
f"""
|
||
from pathlib import Path
|
||
from tools.system.fleet_monitoring import bus
|
||
|
||
result = bus._ensure_broker(Path({str(sock_path)!r}))
|
||
print("OWNER" if result is not None else "PEER")
|
||
"""
|
||
),
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10.0,
|
||
env={**os.environ, "PYTHONPATH": str(repo_root)},
|
||
)
|
||
assert child.returncode == 0, child.stderr
|
||
assert child.stdout.strip() == "PEER", (
|
||
f"child wrongly elected itself: stdout={child.stdout!r} stderr={child.stderr!r}"
|
||
)
|
||
|
||
# Our broker is unscathed: socket file + pid file still there,
|
||
# listener still accepting connections.
|
||
assert sock_path.exists(), "winner's socket file vanished"
|
||
assert _pid_file_for(sock_path).exists(), "winner's pid file vanished"
|
||
assert server.is_running
|
||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
client.settimeout(2.0)
|
||
client.connect(str(sock_path))
|
||
client.close()
|
||
finally:
|
||
server.stop()
|
||
|
||
def test_election_lock_path_lives_next_to_socket(self, sock_path: Path) -> None:
|
||
# Sanity-check the sidecar location so future refactors don't
|
||
# silently move it (which would re-open the cross-process race).
|
||
lock_path = bus_module._election_lock_path(sock_path)
|
||
assert lock_path.parent == sock_path.parent
|
||
assert lock_path.name == sock_path.name + ".lock"
|
||
|
||
@pytest.mark.skipif(
|
||
not _POSIX_FCNTL_AVAILABLE,
|
||
reason="cross-process election flock requires POSIX fcntl (omitted on Windows)",
|
||
)
|
||
def test_ensure_broker_blocks_on_election_flock_held_by_peer(self, sock_path: Path) -> None:
|
||
# Direct test of the cross-process serialization: a child process
|
||
# holds the election flock; this process's ``_ensure_broker``
|
||
# must block on ``flock(LOCK_EX)`` until the child releases it.
|
||
# Without this, two concurrent cold-start callers can race
|
||
# through unlink+bind and leave one orphaned.
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import textwrap
|
||
|
||
repo_root = Path(__file__).resolve().parents[2]
|
||
# Ensure parent dir exists so the child can open the lock file.
|
||
sock_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Child: open + flock the election file, signal "READY", sleep,
|
||
# then exit (flock auto-released on close).
|
||
child = subprocess.Popen(
|
||
[
|
||
sys.executable,
|
||
"-u",
|
||
"-c",
|
||
textwrap.dedent(
|
||
f"""
|
||
import os, fcntl, sys, time
|
||
from pathlib import Path
|
||
from tools.system.fleet_monitoring import bus
|
||
|
||
fd = bus._acquire_election_flock(Path({str(sock_path)!r}))
|
||
print("READY", flush=True)
|
||
time.sleep(0.5)
|
||
bus._release_election_flock(fd)
|
||
"""
|
||
),
|
||
],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
env={**os.environ, "PYTHONPATH": str(repo_root)},
|
||
)
|
||
try:
|
||
# Wait for the child to confirm it holds the lock.
|
||
assert child.stdout is not None
|
||
line = child.stdout.readline()
|
||
assert line.strip() == "READY", f"child did not signal ready: {line!r}"
|
||
|
||
# Now ``_ensure_broker`` here must wait on the flock. Time
|
||
# the call: it should take roughly the remaining sleep time.
|
||
t0 = time.monotonic()
|
||
server = bus_module._ensure_broker(sock_path)
|
||
elapsed = time.monotonic() - t0
|
||
try:
|
||
assert server is not None, "we should have elected after child released"
|
||
# The child slept 0.5s total starting before READY; we
|
||
# should have waited for some appreciable fraction of
|
||
# that. 0.1s is well under the 0.5s sleep but well above
|
||
# zero, so it cleanly distinguishes "blocked on flock"
|
||
# from "didn't block at all".
|
||
assert elapsed >= 0.1, (
|
||
f"_ensure_broker did not block on the election flock (elapsed={elapsed:.3f}s)"
|
||
)
|
||
finally:
|
||
if server is not None:
|
||
server.stop()
|
||
finally:
|
||
child.wait(timeout=5.0)
|
||
|
||
|
||
class TestBrokerSelfElection:
|
||
def test_stale_socket_file_is_unlinked_and_rebound(self, sock_path: Path) -> None:
|
||
sock_path.parent.mkdir(parents=True, exist_ok=True)
|
||
sock_path.touch() # stale: no listener
|
||
assert sock_path.exists() and not _socket_is_live(sock_path)
|
||
|
||
server = bus_module._ensure_broker(sock_path)
|
||
try:
|
||
assert server is not None
|
||
assert server.is_running
|
||
assert _socket_is_live(sock_path)
|
||
finally:
|
||
if server is not None:
|
||
server.stop()
|
||
|
||
def test_ensure_broker_returns_none_when_other_process_owns_it(self, sock_path: Path) -> None:
|
||
# Stand up an "other" broker via direct BusServer (simulating another process),
|
||
# then call _ensure_broker — it should detect the live socket and back off.
|
||
other = BusServer(sock_path)
|
||
other.start()
|
||
try:
|
||
# Pretend our process has not yet elected: empty the registry.
|
||
bus_module._brokers.clear()
|
||
result = bus_module._ensure_broker(sock_path)
|
||
assert result is None
|
||
finally:
|
||
other.stop()
|
||
|
||
def test_ensure_broker_idempotent_for_same_process(self, sock_path: Path) -> None:
|
||
first = bus_module._ensure_broker(sock_path)
|
||
second = bus_module._ensure_broker(sock_path)
|
||
try:
|
||
assert first is not None
|
||
assert first is second
|
||
finally:
|
||
if first is not None:
|
||
first.stop()
|
||
|
||
|
||
class TestSlashCommandFormatter:
|
||
def test_format_includes_agent_and_summary(self) -> None:
|
||
from surfaces.interactive_shell.command_registry.agents.core import _format_bus_message
|
||
|
||
msg = BusMessage(agent="claude-code:8421", topic="finding", summary="hi")
|
||
out = _format_bus_message(msg)
|
||
assert "claude-code:8421" in out
|
||
assert "hi" in out
|
||
|
||
def test_format_includes_path_when_present(self) -> None:
|
||
from surfaces.interactive_shell.command_registry.agents.core import _format_bus_message
|
||
|
||
msg = BusMessage(agent="a:1", topic="finding", summary="x", path="services/auth.py:42")
|
||
out = _format_bus_message(msg)
|
||
assert "services/auth.py:42" in out
|
||
assert "—" in out
|
||
|
||
def test_format_omits_separator_when_no_path(self) -> None:
|
||
from surfaces.interactive_shell.command_registry.agents.core import _format_bus_message
|
||
|
||
msg = BusMessage(agent="a:1", topic="finding", summary="x")
|
||
out = _format_bus_message(msg)
|
||
assert "—" not in out
|