diff --git a/clawmetry/asimov/daemon.py b/clawmetry/asimov/daemon.py index cd5e5e09d0..65a2b3ae76 100644 --- a/clawmetry/asimov/daemon.py +++ b/clawmetry/asimov/daemon.py @@ -23,6 +23,23 @@ def journal_directory(): Path.home() / ".clawmetry" / "robotics" / "journal").expanduser() +def daemon_writer(store): + """Return a handle that can actually write, asking again when it cannot. + + The daemon warms ``local_store.get_store`` up before ``local_server`` + marks this process the writer owner, so the handle captured at that + moment can be the read-only proxy the dashboard uses. Collection is + started with that captured handle and refused forever, while every later + caller in the same process is handed the real writer. Resolve it here + instead of trusting the handle the caller happened to hold. + """ + if store is not None and not getattr(store, "_read_only", True): + return store + from clawmetry import local_store + + return local_store.get_store(read_only=False) + + def start(store): """Start local journal ingestion once; replace a previously ended worker. @@ -33,7 +50,7 @@ def start(store): with _lock: if _worker is not None and _worker.is_alive(): return - reader = AsimovLocalStore(store, initialize=True) + reader = AsimovLocalStore(daemon_writer(store), initialize=True) _stop.clear() def collect(): diff --git a/tests/test_asimov_daemon.py b/tests/test_asimov_daemon.py index d93d36f5cd..79c025776b 100644 --- a/tests/test_asimov_daemon.py +++ b/tests/test_asimov_daemon.py @@ -103,6 +103,65 @@ def fail_once(self, directory, **kwargs): assert "collector will try again" in caplog.text +def test_start_resolves_the_writer_when_warm_up_captured_the_read_only_proxy( + daemon, writer, monkeypatch): + """A handle captured before writer ownership must not disable collection. + + REQ-ASIMOV-001: free local evidence cannot depend on the daemon happening + to warm its store up after ``local_server`` marked this process the + writer owner. The daemon does it in the other order, so the handle passed + in here can be the dashboard's read-only proxy, and the collector refused + to start on every boot of an install that had already registered a node. + """ + from clawmetry import local_store + from clawmetry.asimov.store import AsimovLocalStore + + class ReadOnlyProxy: + _read_only = True + + ingested = threading.Event() + ingest = AsimovLocalStore.ingest + asked = [] + + def observe(self, directory, **kwargs): + result = ingest(self, directory, **kwargs) + ingested.set() + return result + + def get_store(read_only=False): + asked.append(read_only) + return writer + + monkeypatch.setattr(AsimovLocalStore, "ingest", observe) + monkeypatch.setattr(local_store, "get_store", get_store) + daemon.start(ReadOnlyProxy()) + assert ingested.wait(3), "the collector refused a store it could have resolved" + assert daemon._worker.is_alive() + assert asked == [False], "the collector asks once, for the writer" + + +def test_start_keeps_a_writer_it_was_already_given(daemon, writer, monkeypatch): + """Resolving must not churn the store for the ordinary, correct call.""" + from clawmetry import local_store + from clawmetry.asimov.store import AsimovLocalStore + + ingested = threading.Event() + ingest = AsimovLocalStore.ingest + + def observe(self, directory, **kwargs): + result = ingest(self, directory, **kwargs) + ingested.set() + return result + + def refuse(*args, **kwargs): + raise AssertionError("a usable writer was re-resolved") + + monkeypatch.setattr(AsimovLocalStore, "ingest", observe) + monkeypatch.setattr(local_store, "get_store", refuse) + daemon.start(writer) + assert ingested.wait(3) + + def test_start_replaces_an_actually_terminated_thread(daemon, writer, monkeypatch): from clawmetry.asimov.store import AsimovLocalStore