feat(bus): host-local UNIX-socket pub/sub worker (DEBT-029)
Land the `decnet bus` worker and `get_bus()` factory. Transport is a host-local UNIX-domain socket (0660, group=decnet); authz is the file mode. Wire framing is a tiny verb-line + 4-byte-BE length + orjson body. NATS-style wildcard topics (`*`, `>`). At-most-once, fire-and-forget — DB stays the source of truth. `FakeBus` / `NullBus` for tests and the disabled path. Cross-host federation is deferred to a future `--bridge-tcp` mode; DEBT-030 is master-only and unblocked.
This commit is contained in:
0
tests/bus/__init__.py
Normal file
0
tests/bus/__init__.py
Normal file
59
tests/bus/conftest.py
Normal file
59
tests/bus/conftest.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Shared fixtures for decnet.bus tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
from typing import AsyncIterator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.bus.unix_client import UnixSocketBus
|
||||
from decnet.bus.unix_server import BusServer
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def fake_bus() -> AsyncIterator[FakeBus]:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
try:
|
||||
yield bus
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def unix_bus(tmp_path: pathlib.Path) -> AsyncIterator[tuple[BusServer, UnixSocketBus]]:
|
||||
"""Spin a BusServer on a tmp socket, yield (server, connected client).
|
||||
|
||||
Teardown closes both in the right order. No privileged group chown —
|
||||
the fixture passes ``group=None`` so the socket stays owned by the
|
||||
test-runner's process group.
|
||||
"""
|
||||
sock = tmp_path / "bus.sock"
|
||||
server = BusServer(sock, group=None)
|
||||
await server.start()
|
||||
serve_task = asyncio.create_task(server.serve_forever())
|
||||
|
||||
client = UnixSocketBus(sock, client_name="test-client")
|
||||
await client.connect()
|
||||
|
||||
try:
|
||||
yield server, client
|
||||
finally:
|
||||
await client.close()
|
||||
serve_task.cancel()
|
||||
try:
|
||||
await serve_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await server.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bus_env_fake(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Point :func:`decnet.bus.factory.get_bus` at the in-process FakeBus."""
|
||||
monkeypatch.setenv("DECNET_BUS_TYPE", "fake")
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "true")
|
||||
monkeypatch.delenv("DECNET_BUS_SOCKET", raising=False)
|
||||
66
tests/bus/test_base.py
Normal file
66
tests/bus/test_base.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Unit tests for :mod:`decnet.bus.base` — wildcard matching and the Event envelope."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus.base import EVENT_SCHEMA_VERSION, Event, matches
|
||||
|
||||
|
||||
class TestMatches:
|
||||
@pytest.mark.parametrize("pattern,topic", [
|
||||
("topology.abc.mutation.applied", "topology.abc.mutation.applied"),
|
||||
("topology.*.mutation.applied", "topology.abc.mutation.applied"),
|
||||
("topology.*.mutation.*", "topology.abc.mutation.applied"),
|
||||
("topology.>", "topology.abc.mutation.applied"),
|
||||
("topology.>", "topology.abc.status"),
|
||||
("decky.*.state", "decky.xyz.state"),
|
||||
("system.bus.health", "system.bus.health"),
|
||||
])
|
||||
def test_matches_positive(self, pattern: str, topic: str) -> None:
|
||||
assert matches(pattern, topic) is True
|
||||
|
||||
@pytest.mark.parametrize("pattern,topic", [
|
||||
("topology.abc.mutation.applied", "topology.abc.mutation.failed"),
|
||||
("topology.*", "topology.abc.mutation.applied"), # * is one token
|
||||
("topology.>", "topology"), # > needs ≥1 trailing
|
||||
("decky.*.state", "decky.state"), # missing middle token
|
||||
("decky.*.state", "decky.xyz.status"),
|
||||
("a.b.c", "a.b"),
|
||||
("a.b", "a.b.c"),
|
||||
])
|
||||
def test_matches_negative(self, pattern: str, topic: str) -> None:
|
||||
assert matches(pattern, topic) is False
|
||||
|
||||
|
||||
class TestEvent:
|
||||
def test_to_dict_round_trip(self) -> None:
|
||||
event = Event(topic="topology.abc.status", payload={"status": "active"}, type="status")
|
||||
data = event.to_dict()
|
||||
assert data["v"] == EVENT_SCHEMA_VERSION
|
||||
assert data["topic"] == "topology.abc.status"
|
||||
assert data["payload"] == {"status": "active"}
|
||||
assert data["type"] == "status"
|
||||
assert isinstance(data["id"], str)
|
||||
assert isinstance(data["ts"], float)
|
||||
|
||||
def test_from_dict_prefers_wire_fields_but_ignores_topic(self) -> None:
|
||||
# The wire topic is the authoritative one (passed from the transport);
|
||||
# a malicious "topic" field in the body must be ignored.
|
||||
data = {
|
||||
"v": 1, "id": "abc", "type": "status",
|
||||
"topic": "attacker.spoofed", # ignored
|
||||
"ts": 123.0,
|
||||
"payload": {"x": 1},
|
||||
}
|
||||
event = Event.from_dict("topology.abc.status", data)
|
||||
assert event.topic == "topology.abc.status"
|
||||
assert event.payload == {"x": 1}
|
||||
assert event.id == "abc"
|
||||
assert event.ts == 123.0
|
||||
|
||||
def test_from_dict_tolerates_missing_fields(self) -> None:
|
||||
event = Event.from_dict("system.log", {})
|
||||
assert event.topic == "system.log"
|
||||
assert event.payload == {}
|
||||
assert event.v == EVENT_SCHEMA_VERSION
|
||||
assert event.id # auto-generated
|
||||
52
tests/bus/test_factory.py
Normal file
52
tests/bus/test_factory.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Tests for :func:`decnet.bus.factory.get_bus` dispatch."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus.factory import _default_socket_path, get_bus
|
||||
from decnet.bus.fake import FakeBus, NullBus
|
||||
from decnet.bus.unix_client import UnixSocketBus
|
||||
|
||||
|
||||
def test_disabled_returns_null_bus(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "false")
|
||||
monkeypatch.setenv("DECNET_BUS_TYPE", "unix") # ignored when disabled
|
||||
bus = get_bus()
|
||||
assert isinstance(bus, NullBus)
|
||||
|
||||
|
||||
def test_fake_dispatch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "true")
|
||||
monkeypatch.setenv("DECNET_BUS_TYPE", "fake")
|
||||
bus = get_bus()
|
||||
assert isinstance(bus, FakeBus)
|
||||
|
||||
|
||||
def test_unix_dispatch(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "true")
|
||||
monkeypatch.setenv("DECNET_BUS_TYPE", "unix")
|
||||
monkeypatch.setenv("DECNET_BUS_SOCKET", str(tmp_path / "b.sock"))
|
||||
bus = get_bus()
|
||||
assert isinstance(bus, UnixSocketBus)
|
||||
|
||||
|
||||
def test_unknown_type_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "true")
|
||||
monkeypatch.setenv("DECNET_BUS_TYPE", "mqtt")
|
||||
with pytest.raises(ValueError, match="Unsupported bus type"):
|
||||
get_bus()
|
||||
|
||||
|
||||
def test_default_socket_path_honors_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DECNET_BUS_SOCKET", "/tmp/explicit.sock")
|
||||
assert _default_socket_path() == "/tmp/explicit.sock"
|
||||
|
||||
|
||||
def test_default_socket_path_falls_back_to_home(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("DECNET_BUS_SOCKET", raising=False)
|
||||
# Force /run/decnet to look unusable.
|
||||
monkeypatch.setattr("os.path.isdir", lambda p: False)
|
||||
path = _default_socket_path()
|
||||
assert path.endswith(".decnet/bus.sock")
|
||||
108
tests/bus/test_fake_bus.py
Normal file
108
tests/bus/test_fake_bus.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tests for :class:`decnet.bus.fake.FakeBus` and :class:`NullBus`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus.fake import FakeBus, NullBus
|
||||
|
||||
|
||||
async def _collect(sub, n: int, timeout: float = 1.0) -> list:
|
||||
out = []
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
async for event in sub:
|
||||
out.append(event)
|
||||
if len(out) >= n:
|
||||
break
|
||||
except TimeoutError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
class TestFakeBus:
|
||||
async def test_publish_delivers_to_exact_match(self, fake_bus: FakeBus) -> None:
|
||||
sub = fake_bus.subscribe("topology.abc.status")
|
||||
async with sub:
|
||||
await fake_bus.publish("topology.abc.status", {"status": "active"})
|
||||
events = await _collect(sub, 1)
|
||||
assert len(events) == 1
|
||||
assert events[0].payload == {"status": "active"}
|
||||
|
||||
async def test_publish_delivers_to_wildcard(self, fake_bus: FakeBus) -> None:
|
||||
sub = fake_bus.subscribe("topology.*.mutation.*")
|
||||
async with sub:
|
||||
await fake_bus.publish("topology.t1.mutation.applied", {"id": 1})
|
||||
await fake_bus.publish("topology.t2.mutation.failed", {"id": 2})
|
||||
await fake_bus.publish("decky.x.state", {"state": "running"}) # should not match
|
||||
events = await _collect(sub, 2)
|
||||
assert len(events) == 2
|
||||
assert {e.payload["id"] for e in events} == {1, 2}
|
||||
|
||||
async def test_multiple_subscribers_each_get_copy(self, fake_bus: FakeBus) -> None:
|
||||
sub_a = fake_bus.subscribe("topology.>")
|
||||
sub_b = fake_bus.subscribe("topology.>")
|
||||
async with sub_a, sub_b:
|
||||
await fake_bus.publish("topology.abc.status", {"status": "active"})
|
||||
a = await _collect(sub_a, 1)
|
||||
b = await _collect(sub_b, 1)
|
||||
assert len(a) == 1
|
||||
assert len(b) == 1
|
||||
|
||||
async def test_subscription_close_unblocks_iter(self, fake_bus: FakeBus) -> None:
|
||||
sub = fake_bus.subscribe("topology.>")
|
||||
|
||||
async def consume() -> list:
|
||||
out = []
|
||||
async for event in sub:
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
task = asyncio.create_task(consume())
|
||||
await asyncio.sleep(0.01) # let task block on queue.get()
|
||||
await sub.aclose()
|
||||
events = await asyncio.wait_for(task, timeout=0.5)
|
||||
assert events == []
|
||||
|
||||
async def test_close_is_idempotent(self, fake_bus: FakeBus) -> None:
|
||||
await fake_bus.close()
|
||||
await fake_bus.close() # second call must not raise
|
||||
|
||||
async def test_publish_on_closed_raises(self, fake_bus: FakeBus) -> None:
|
||||
await fake_bus.close()
|
||||
with pytest.raises(RuntimeError):
|
||||
await fake_bus.publish("x", {})
|
||||
with pytest.raises(RuntimeError):
|
||||
fake_bus.subscribe("x")
|
||||
|
||||
async def test_backpressure_drops_oldest(self) -> None:
|
||||
bus = FakeBus(queue_size=2)
|
||||
await bus.connect()
|
||||
try:
|
||||
sub = bus.subscribe("t")
|
||||
# Don't consume; publish 5 — queue holds at most 2, oldest dropped.
|
||||
for i in range(5):
|
||||
await bus.publish("t", {"i": i})
|
||||
events = await _collect(sub, 2, timeout=0.2)
|
||||
assert len(events) == 2
|
||||
# We kept the 2 most recent.
|
||||
assert events[-1].payload["i"] == 4
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
class TestNullBus:
|
||||
async def test_publish_is_noop(self) -> None:
|
||||
bus = NullBus()
|
||||
await bus.connect()
|
||||
await bus.publish("anything", {"x": 1})
|
||||
await bus.close()
|
||||
|
||||
async def test_subscribe_yields_nothing(self) -> None:
|
||||
bus = NullBus()
|
||||
sub = bus.subscribe("topology.>")
|
||||
async with sub:
|
||||
# Iteration must stop immediately.
|
||||
events = [e async for e in sub]
|
||||
assert events == []
|
||||
87
tests/bus/test_protocol.py
Normal file
87
tests/bus/test_protocol.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Tests for the wire protocol framing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus import protocol
|
||||
|
||||
|
||||
def _reader_from(data: bytes) -> asyncio.StreamReader:
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(data)
|
||||
reader.feed_eof()
|
||||
return reader
|
||||
|
||||
|
||||
async def _read_one(data: bytes) -> protocol.Frame | None:
|
||||
return await protocol.read_frame(_reader_from(data))
|
||||
|
||||
|
||||
class TestEncodeDecode:
|
||||
async def test_pub_round_trip(self) -> None:
|
||||
data = protocol.encode(protocol.PUB, args="topology.abc.status", body={"payload": {"x": 1}})
|
||||
frame = await _read_one(data)
|
||||
assert frame is not None
|
||||
assert frame.verb == protocol.PUB
|
||||
assert frame.args == "topology.abc.status"
|
||||
assert protocol.decode_body(frame.body) == {"payload": {"x": 1}}
|
||||
|
||||
async def test_sub_empty_body(self) -> None:
|
||||
data = protocol.encode(protocol.SUB, args="topology.*.mutation.*")
|
||||
frame = await _read_one(data)
|
||||
assert frame is not None
|
||||
assert frame.verb == protocol.SUB
|
||||
assert frame.args == "topology.*.mutation.*"
|
||||
assert frame.body == b""
|
||||
|
||||
async def test_bye_no_args(self) -> None:
|
||||
data = protocol.encode(protocol.BYE)
|
||||
frame = await _read_one(data)
|
||||
assert frame is not None
|
||||
assert frame.verb == protocol.BYE
|
||||
assert frame.args == ""
|
||||
assert frame.body == b""
|
||||
|
||||
async def test_clean_eof_returns_none(self) -> None:
|
||||
assert await _read_one(b"") is None
|
||||
|
||||
|
||||
class TestProtocolErrors:
|
||||
def test_encode_rejects_unknown_verb(self) -> None:
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
protocol.encode("NOPE", args="x")
|
||||
|
||||
def test_encode_rejects_newline_in_args(self) -> None:
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
protocol.encode(protocol.PUB, args="bad\ntopic")
|
||||
|
||||
def test_encode_rejects_oversized_body(self) -> None:
|
||||
big = {"payload": {"x": "a" * (protocol.MAX_BODY_BYTES + 1)}}
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
protocol.encode(protocol.PUB, args="t", body=big)
|
||||
|
||||
async def test_decode_rejects_unknown_verb(self) -> None:
|
||||
bad = b"NOPE x\n" + struct.pack(">I", 0)
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
await _read_one(bad)
|
||||
|
||||
async def test_decode_rejects_oversized_body_length(self) -> None:
|
||||
bad = b"PUB x\n" + struct.pack(">I", protocol.MAX_BODY_BYTES + 1)
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
await _read_one(bad)
|
||||
|
||||
async def test_decode_rejects_truncated_body(self) -> None:
|
||||
bad = b"PUB x\n" + struct.pack(">I", 10) + b"short"
|
||||
with pytest.raises(Exception): # IncompleteReadError bubbles up
|
||||
await _read_one(bad)
|
||||
|
||||
def test_decode_body_rejects_non_object(self) -> None:
|
||||
import orjson
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
protocol.decode_body(orjson.dumps([1, 2, 3]))
|
||||
|
||||
def test_decode_body_empty_returns_empty_dict(self) -> None:
|
||||
assert protocol.decode_body(b"") == {}
|
||||
42
tests/bus/test_topics.py
Normal file
42
tests/bus/test_topics.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Tests for the topic hierarchy builders."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus import topics
|
||||
|
||||
|
||||
def test_topology_mutation_builder() -> None:
|
||||
topic = topics.topology_mutation("abc123", topics.MUTATION_APPLIED)
|
||||
assert topic == "topology.abc123.mutation.applied"
|
||||
|
||||
|
||||
def test_topology_status_builder() -> None:
|
||||
assert topics.topology_status("t-1") == "topology.t-1.status"
|
||||
|
||||
|
||||
def test_decky_builder() -> None:
|
||||
assert topics.decky("d-42", topics.DECKY_STATE) == "decky.d-42.state"
|
||||
assert topics.decky("d-42", topics.DECKY_TRAFFIC) == "decky.d-42.traffic"
|
||||
|
||||
|
||||
def test_system_builder_allows_dotted_leaf() -> None:
|
||||
# system.bus.health has a dot in the leaf — that's intentional and a
|
||||
# legitimate hierarchy refinement, not a segment violation.
|
||||
assert topics.system(topics.SYSTEM_BUS_HEALTH) == "system.bus.health"
|
||||
assert topics.system(topics.SYSTEM_LOG) == "system.log"
|
||||
|
||||
|
||||
def test_system_builder_rejects_empty() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
topics.system("")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "has.dot", "has*wildcard", "has>wild", "with space", "with\ttab"])
|
||||
def test_segment_validation(bad: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
topics.topology_mutation(bad, topics.MUTATION_APPLIED)
|
||||
with pytest.raises(ValueError):
|
||||
topics.topology_status(bad)
|
||||
with pytest.raises(ValueError):
|
||||
topics.decky(bad, topics.DECKY_STATE)
|
||||
131
tests/bus/test_unix_socket_bus.py
Normal file
131
tests/bus/test_unix_socket_bus.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""End-to-end tests for :class:`UnixSocketBus` against a real :class:`BusServer`.
|
||||
|
||||
These tests run in the dev loop (no pytest marker) because they only need
|
||||
the tmp filesystem — no Docker, no external broker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus.unix_client import UnixSocketBus
|
||||
from decnet.bus.unix_server import BusServer
|
||||
|
||||
|
||||
async def _drain(sub, n: int, timeout: float = 1.5) -> list:
|
||||
out = []
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
async for event in sub:
|
||||
out.append(event)
|
||||
if len(out) >= n:
|
||||
break
|
||||
except TimeoutError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
class TestEndToEnd:
|
||||
async def test_pub_sub_exact(self, unix_bus) -> None:
|
||||
server, client = unix_bus
|
||||
sub = client.subscribe("topology.abc.status")
|
||||
# Give the SUB frame a tick to register on the server.
|
||||
await asyncio.sleep(0.05)
|
||||
async with sub:
|
||||
await client.publish("topology.abc.status", {"status": "active"})
|
||||
events = await _drain(sub, 1)
|
||||
# A publisher doesn't see its own events — use a second client.
|
||||
assert events == []
|
||||
|
||||
async def test_pub_sub_across_two_clients(
|
||||
self, tmp_path: pathlib.Path,
|
||||
) -> None:
|
||||
sock = tmp_path / "bus.sock"
|
||||
server = BusServer(sock, group=None)
|
||||
await server.start()
|
||||
serve_task = asyncio.create_task(server.serve_forever())
|
||||
|
||||
publisher = UnixSocketBus(sock, client_name="publisher")
|
||||
subscriber = UnixSocketBus(sock, client_name="subscriber")
|
||||
await publisher.connect()
|
||||
await subscriber.connect()
|
||||
|
||||
try:
|
||||
sub = subscriber.subscribe("topology.*.mutation.*")
|
||||
await asyncio.sleep(0.05) # let SUB register
|
||||
|
||||
async with sub:
|
||||
await publisher.publish(
|
||||
"topology.t1.mutation.applied", {"id": 1}, event_type="applied",
|
||||
)
|
||||
await publisher.publish(
|
||||
"decky.xyz.state", {"state": "running"}, # should not match
|
||||
)
|
||||
await publisher.publish(
|
||||
"topology.t2.mutation.failed", {"id": 2}, event_type="failed",
|
||||
)
|
||||
events = await _drain(sub, 2)
|
||||
ids = {e.payload["id"] for e in events}
|
||||
assert ids == {1, 2}
|
||||
finally:
|
||||
await publisher.close()
|
||||
await subscriber.close()
|
||||
serve_task.cancel()
|
||||
try:
|
||||
await serve_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await server.close()
|
||||
|
||||
async def test_socket_file_mode(self, tmp_path: pathlib.Path) -> None:
|
||||
sock = tmp_path / "bus.sock"
|
||||
server = BusServer(sock, group=None)
|
||||
await server.start()
|
||||
try:
|
||||
mode = stat.S_IMODE(sock.stat().st_mode)
|
||||
assert mode == 0o660
|
||||
finally:
|
||||
await server.close()
|
||||
|
||||
async def test_server_close_wakes_subscribers(
|
||||
self, tmp_path: pathlib.Path,
|
||||
) -> None:
|
||||
sock = tmp_path / "bus.sock"
|
||||
server = BusServer(sock, group=None)
|
||||
await server.start()
|
||||
serve_task = asyncio.create_task(server.serve_forever())
|
||||
|
||||
client = UnixSocketBus(sock, client_name="watcher")
|
||||
await client.connect()
|
||||
sub = client.subscribe("system.>")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
async def consume() -> list:
|
||||
out = []
|
||||
async for event in sub:
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
consumer = asyncio.create_task(consume())
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
serve_task.cancel()
|
||||
try:
|
||||
await serve_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await server.close()
|
||||
|
||||
# The consumer must unblock within a reasonable time.
|
||||
events = await asyncio.wait_for(consumer, timeout=1.0)
|
||||
assert events == []
|
||||
await client.close()
|
||||
|
||||
async def test_start_rejects_missing_parent(self, tmp_path: pathlib.Path) -> None:
|
||||
sock = tmp_path / "nonexistent-dir" / "bus.sock"
|
||||
server = BusServer(sock, group=None)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await server.start()
|
||||
68
tests/bus/test_worker.py
Normal file
68
tests/bus/test_worker.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Tests for :func:`decnet.bus.worker.bus_worker` lifecycle + heartbeat."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus import topics
|
||||
from decnet.bus.unix_client import UnixSocketBus
|
||||
from decnet.bus.worker import bus_worker
|
||||
|
||||
|
||||
class TestBusWorker:
|
||||
async def test_worker_serves_and_heartbeats(
|
||||
self, tmp_path: pathlib.Path,
|
||||
) -> None:
|
||||
sock = tmp_path / "bus.sock"
|
||||
task = asyncio.create_task(
|
||||
bus_worker(sock, group=None, heartbeat_interval=1),
|
||||
)
|
||||
# Wait for the socket to exist.
|
||||
for _ in range(40):
|
||||
if sock.exists():
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert sock.exists(), "bus worker did not create socket"
|
||||
|
||||
client = UnixSocketBus(sock, client_name="hb-watcher")
|
||||
await client.connect()
|
||||
sub = client.subscribe(topics.system(topics.SYSTEM_BUS_HEALTH))
|
||||
try:
|
||||
async with sub:
|
||||
async with asyncio.timeout(3.0):
|
||||
async for event in sub:
|
||||
assert event.topic == "system.bus.health"
|
||||
assert "pid" in event.payload
|
||||
break
|
||||
finally:
|
||||
await client.close()
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def test_worker_creates_home_fallback_parent(
|
||||
self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Point Path.home() at tmp_path so the "auto-mkdir ~/.decnet" branch
|
||||
# activates without touching the real home directory.
|
||||
monkeypatch.setattr(pathlib.Path, "home", classmethod(lambda cls: tmp_path))
|
||||
sock = tmp_path / ".decnet" / "bus.sock"
|
||||
task = asyncio.create_task(
|
||||
bus_worker(sock, group=None, heartbeat_interval=60),
|
||||
)
|
||||
try:
|
||||
for _ in range(40):
|
||||
if sock.exists():
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert sock.exists()
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
Reference in New Issue
Block a user