diff --git a/backend/app/channels/service.py b/backend/app/channels/service.py index 92a82708b..19427cdb5 100644 --- a/backend/app/channels/service.py +++ b/backend/app/channels/service.py @@ -469,7 +469,13 @@ class ChannelService: # (tests, tooling) stay free of filesystem side effects. from deerflow.config.paths import get_paths - config["seen_event_store_path"] = str(Path(get_paths().base_dir) / "channels" / "buzz_seen_events.json") + def _default_seen_store_path() -> str: + # Worker thread: ``base_dir`` resolves through realpath, and a + # channel start runs on the Gateway event loop — including the + # per-request ``POST /api/channels/{name}/restart`` path. + return str(Path(get_paths().base_dir) / "channels" / "buzz_seen_events.json") + + config["seen_event_store_path"] = await asyncio.to_thread(_default_seen_store_path) if self._connection_repo is not None: config["connection_repo"] = self._connection_repo channel = channel_cls(bus=self.bus, config=config) diff --git a/backend/tests/blocking_io/test_buzz_channel_seen_events.py b/backend/tests/blocking_io/test_buzz_channel_seen_events.py index f0514a265..bfb188449 100644 --- a/backend/tests/blocking_io/test_buzz_channel_seen_events.py +++ b/backend/tests/blocking_io/test_buzz_channel_seen_events.py @@ -1,4 +1,5 @@ -"""Regression coverage for Buzz replay persistence at the channel boundary.""" +"""Regression coverage for Buzz replay persistence at the channel boundary, and +for the ``_start_channel`` wiring that gives the guard its store path.""" from __future__ import annotations @@ -13,6 +14,7 @@ from app.channels import buzz_nostr, buzz_seen_events from app.channels.buzz import BuzzChannel from app.channels.buzz_seen_events import BuzzSeenEventStore from app.channels.message_bus import MessageBus +from app.channels.service import ChannelService pytestmark = pytest.mark.asyncio @@ -186,3 +188,42 @@ async def test_abandoned_relay_records_are_drained_by_retried_stop_or_restart(tm assert await restarted_view.aseen(_CHANNEL_ID, "restart-event") finally: await channel.stop() + + +async def test_start_channel_resolves_seen_store_path_off_the_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Every channel start wires the Buzz replay guard its default store path, and + # ``Paths.base_dir`` is a realpath call — so this resolution has to leave the + # loop. It is reachable per request: ``POST /api/channels/{name}/restart`` + # restarts a running channel through ``restart_channel`` on the Gateway loop. + # + # The offline twin of this test (``test_service_wiring_injects_persistent_store_path``) + # replaces ``get_paths`` with a stub whose ``base_dir`` is a plain string, so + # it cannot see the syscall at all; here the real ``Paths`` object stays in + # place and is isolated through ``DEER_FLOW_HOME`` instead. + captured: dict[str, object] = {} + + class StubChannel: + def __init__(self, bus, config): + captured.update(config) + self.is_running = True + + async def start(self): + pass + + monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) + monkeypatch.setattr("deerflow.config.paths._paths", None) + monkeypatch.setattr("deerflow.reflection.resolve_class", lambda _path, base_class=None: StubChannel) + + # Construction is IO-free by contract: ``start_channel_service`` builds the + # service in a worker thread because ``ChannelStore`` resolves its JSON file + # path there. The test mirrors that boundary so only ``_start_channel``'s own + # resolution is exercised on the loop. + service = await asyncio.to_thread(ChannelService, channels_config={}) + started = await service._start_channel("buzz", {"relay_url": "wss://buzz.example.com", "private_key": "unused-by-this-test"}) + + # ``_start_channel`` keeps a blanket ``except Exception`` around the whole + # wiring, so a regression onto the loop arrives here as a failed start plus a + # logged ``BlockingError: Blocking call to os.path.abspath`` (see the captured + # log of the red run) rather than as a raised gate error. + assert started is True + assert captured["seen_event_store_path"] == str(tmp_path / "channels" / "buzz_seen_events.json")