aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py22
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py18
-rw-r--r--packages/meshbay-node/tests/test_root_work_outlives_the_session.py176
3 files changed, 212 insertions, 4 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index c9c362a..932a90d 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -764,9 +764,27 @@ class NodeDaemon:
Serialised by _reload_lock: fire-and-forget reloads from config-mutating
endpoints can overlap with the wizard's explicit /api/reload call,
and two concurrent hot-loads of the same group corrupt the runtime state.
+
+ The reload belongs to the node, never to whoever asked for it. A root
+ added from a browser reaches here through the operator's WebRTC session,
+ whose tasks are all cancelled when that session closes — and on
+ 2026-09-14 one closed 47 s into the scan of a 900 GB root. The reload
+ died without a line in the log, the new root was in node.toml and in
+ the indexer but never in the group's context, and nothing ever tried
+ again: the lock was free, and nobody was waiting on it. So the work runs
+ in a task of its own, and a caller that goes away only stops waiting.
"""
- async with self._reload_lock:
- await self._reload_config_inner()
+ await asyncio.shield(spawn(self._reload_config_locked(), what="config reload"))
+
+ async def _reload_config_locked(self) -> None:
+ try:
+ async with self._reload_lock:
+ await self._reload_config_inner()
+ except asyncio.CancelledError:
+ log.warning("Config reload cancelled before it finished — the node may "
+ "be serving part of the previous configuration until the "
+ "next reload")
+ raise
async def _reload_config_inner(self) -> None:
log.info("Reloading config from %s", self._config_path)
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index 2b68550..c20902e 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -953,8 +953,22 @@ class DirectoryIndexer:
return
root.ejected = False
root.available = root.is_live()
- if root.available:
- log.info("Root %r plugged — rescanning", root.name)
+ if not root.available:
+ await self._finish_plug(None)
+ return
+ log.info("Root %r plugged — rescanning", root.name)
+ # `_rescan_root` drops the root's entries before it walks the disk, and
+ # this is called from an admin op running in the operator's WebRTC
+ # session — which cancels everything it started when it closes. So the
+ # rescan runs in a task this indexer owns, and a caller that goes away
+ # only stops waiting for it instead of leaving the root emptied.
+ task = asyncio.create_task(self._finish_plug(root))
+ self._scan_tasks.add(task)
+ task.add_done_callback(self._scan_tasks.discard)
+ await asyncio.shield(task)
+
+ async def _finish_plug(self, root: Root | None) -> None:
+ if root is not None:
await self._rescan_root(root)
self._restart_observer()
self._index.roots = self.roots.describe()
diff --git a/packages/meshbay-node/tests/test_root_work_outlives_the_session.py b/packages/meshbay-node/tests/test_root_work_outlives_the_session.py
new file mode 100644
index 0000000..31de12e
--- /dev/null
+++ b/packages/meshbay-node/tests/test_root_work_outlives_the_session.py
@@ -0,0 +1,176 @@
+"""
+Work on a group's roots belongs to the node, not to the session that asked for it.
+
+Found live on 2026-09-14. A 900 GB directory was added from the desktop client,
+so the op arrived over MNP and `_retarget_indexer` started the daemon's reload
+with the session's own `_spawn`. That session closed 47 seconds later — the
+client reconnected — and `shutdown_tasks()` cancelled everything it had started:
+the reload, part-way through hashing the 42nd file, and the reload the
+"removable" toggle had queued behind it. A cancelled task logs nothing. The new
+root was in node.toml and in the indexer's own set, and never in the group's
+context, so the node went on serving the old table for eight hours while
+reconcile hashed the whole drive as "missed events" — and a client reloaded in
+the morning showed nothing new. One loopback reload, which nothing could cancel,
+put it right in nine milliseconds.
+
+`test_root_ops_reach_the_live_set.py` covered this seam, with `_spawn` replaced
+by a list: a fixture that could not cancel anything, testing a path whose whole
+failure was being cancelled. These tests close the session for real.
+"""
+
+import asyncio
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_node.config import load_config
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.indexer.indexer import DirectoryIndexer
+from meshbay_node.roots import RootSet
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+pytestmark = pytest.mark.asyncio
+
+GROUP = "g" * 32
+
+
+def _dirs(tmp_path: Path) -> tuple[Path, Path]:
+ one, two = tmp_path / "one", tmp_path / "two"
+ one.mkdir()
+ two.mkdir()
+ (one / "a.txt").write_bytes(b"first root")
+ (two / "b.txt").write_bytes(b"second root, one")
+ (two / "c.txt").write_bytes(b"second root, two")
+ return one, two
+
+
+def _set(*dirs: Path) -> RootSet:
+ return RootSet.build([{"path": str(d), "name": d.name} for d in dirs])
+
+
+def _names(idx) -> list[str]:
+ return sorted(e.name for e in idx.index.entries)
+
+
+async def _daemon(tmp_path: Path, one: Path, two: Path):
+ """A daemon whose reload is held at the door, hosting one group whose
+ node.toml already names a second root the running set does not have."""
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ f'data_dir = "{(tmp_path / "data").as_posix()}"\n\n'
+ f'[[groups]]\nid = "{GROUP}"\nname = "plop"\n\n'
+ f' [[groups.roots]]\n path = "{one.as_posix()}"\n name = "one"\n\n'
+ f' [[groups.roots]]\n path = "{two.as_posix()}"\n name = "two"\n')
+
+ idx = DirectoryIndexer(roots=_set(one), group_id=GROUP,
+ sk_node=Ed25519PrivateKey.generate(), gek=None)
+ await idx.initial_scan()
+
+ ctx = {"roots": idx.roots}
+ daemon = NodeDaemon.__new__(NodeDaemon)
+ daemon._config_path = conf
+ daemon._config = load_config(conf)
+ daemon._roster = None
+ daemon._hub = None
+ daemon._indexers = [idx]
+ daemon._reload_lock = asyncio.Lock()
+ daemon._state = {"groups_ctx": {GROUP: ctx}, "indexes": {}, "indexers": {},
+ "reload_fn": daemon._reload_config}
+
+ gate, entered = asyncio.Event(), asyncio.Event()
+
+ async def held_build_roots(group_cfg):
+ entered.set()
+ await gate.wait()
+ return await NodeDaemon._build_roots(daemon, group_cfg)
+
+ daemon._build_roots = held_build_roots
+ return daemon, idx, ctx, gate, entered
+
+
+async def _until(predicate, timeout: float = 3.0) -> bool:
+ deadline = asyncio.get_running_loop().time() + timeout
+ while not predicate():
+ if asyncio.get_running_loop().time() > deadline:
+ return False
+ await asyncio.sleep(0.02)
+ return True
+
+
+async def test_a_reload_started_over_mnp_survives_the_session_closing(tmp_path):
+ one, two = _dirs(tmp_path)
+ daemon, idx, ctx, gate, entered = await _daemon(tmp_path, one, two)
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {"daemon_state": daemon._state}
+ session._tasks = set()
+ session._stop_stream = lambda: None
+ session._release_transfers = lambda: None
+ try:
+ await session._retarget_indexer(GROUP)
+ await asyncio.wait_for(entered.wait(), 2)
+
+ await session.shutdown_tasks() # what a closed client does
+ gate.set()
+
+ assert await _until(lambda: [r.name for r in ctx["roots"]] == ["one", "two"]), (
+ "closing the session that asked for the reload cancelled it")
+ assert await _until(lambda: not daemon._reload_lock.locked())
+ await asyncio.wait_for(asyncio.gather(*list(idx._scan_tasks)), 5)
+ assert _names(idx) == ["a.txt", "b.txt", "c.txt"]
+ finally:
+ gate.set()
+ await idx.stop()
+
+
+async def test_a_reload_whose_caller_is_cancelled_still_finishes(tmp_path):
+ """`ops.reload_config` awaits the reload inside the session's task too."""
+ one, two = _dirs(tmp_path)
+ daemon, idx, ctx, gate, entered = await _daemon(tmp_path, one, two)
+ try:
+ caller = asyncio.create_task(daemon._reload_config())
+ await asyncio.wait_for(entered.wait(), 2)
+ caller.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await caller
+ gate.set()
+
+ assert await _until(lambda: [r.name for r in ctx["roots"]] == ["one", "two"])
+ assert await _until(lambda: not daemon._reload_lock.locked())
+ finally:
+ gate.set()
+ await idx.stop()
+
+
+async def test_a_plug_whose_caller_goes_away_does_not_empty_the_root(tmp_path):
+ one, two = _dirs(tmp_path)
+
+ class _Held(DirectoryIndexer):
+ gate = None
+ at_gate = None
+
+ async def _scan_root(self, root):
+ if self.gate is not None:
+ self.at_gate.set()
+ await self.gate.wait()
+ return await super()._scan_root(root)
+
+ idx = _Held(roots=_set(one, two), group_id=GROUP,
+ sk_node=Ed25519PrivateKey.generate(), gek=None)
+ await idx.initial_scan()
+ idx.eject_root("two")
+ idx.gate, idx.at_gate = asyncio.Event(), asyncio.Event()
+ try:
+ caller = asyncio.create_task(idx.plug_root("two"))
+ await asyncio.wait_for(idx.at_gate.wait(), 2)
+ caller.cancel() # the admin op's session closed
+ with pytest.raises(asyncio.CancelledError):
+ await caller
+ idx.gate.set()
+
+ await asyncio.wait_for(asyncio.gather(*list(idx._scan_tasks)), 5)
+ assert _names(idx) == ["a.txt", "b.txt", "c.txt"], (
+ "the plug's rescan was cancelled after it had dropped the root's entries")
+ finally:
+ idx.gate.set()
+ await idx.stop()