From 6cdc6016d72dcfb7530ac38a8fa92232418ac305 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 5 Oct 2026 11:10:50 +0200 Subject: fix(node): plug an auto-ejected removable root back once its files return A node started with the desktop session runs before the session has mounted its USB drives. The safety net then auto-ejected every removable root and persisted it exactly like an operator's eject, so after each reboot those roots stayed ejected until someone plugged them by hand (seen on a node whose /media drives were mounted a minute after it started). An auto-eject is now stored as such ("auto" in roster.db). At startup and at every reconcile, an auto-ejected root whose path is readable again is checked against a few files the hash cache knows under it, at the same path with the same size and mtime; one found and the root is plugged back and rescanned. An empty mount point or another drive in its place is not recognised and stays ejected. An operator's eject is never undone automatically. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-node/tests/test_root_eject.py | 128 +++++++++++++++++++++++++ 1 file changed, 128 insertions(+) (limited to 'packages/meshbay-node/tests/test_root_eject.py') diff --git a/packages/meshbay-node/tests/test_root_eject.py b/packages/meshbay-node/tests/test_root_eject.py index d73e71c..b6b50aa 100644 --- a/packages/meshbay-node/tests/test_root_eject.py +++ b/packages/meshbay-node/tests/test_root_eject.py @@ -264,3 +264,131 @@ async def test_the_ejected_key_is_case_folded(tmp_path): assert Roster.root_ejected_key("Films") == Roster.root_ejected_key("FILMS") finally: await roster.close() + + +# ── The safety net's eject undoes itself; the operator's does not ─────────── + +def _vanish(films: Path) -> None: + for f in films.iterdir(): + f.unlink() + films.rmdir() + + +async def test_an_auto_ejected_root_comes_back_with_its_own_files(tmp_path): + """ + The drive that was not mounted yet when the node started (found on a node + started with the session, its USB drives mounted a minute later): the + safety net ejected it, and once the same files are readable at the same + place it is plugged back without anyone having to. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + seen: list[tuple[str, bool]] = [] + + async def record(name: str, ejected: bool) -> None: + seen.append((name, ejected)) + + roots = _roots(films) + idx = await _indexer(roots, on_root_ejected=record) + hidden = tmp_path / "unmounted" + films.rename(hidden) + await idx.reconcile() + assert roots.roots[0].ejected is True + + hidden.rename(films) + await idx.reconcile() + assert roots.roots[0].ejected is False + assert roots.roots[0].available is True + assert _names(idx) == {"a.mkv"} + assert seen == [("Films", True), ("Films", False)] + + +@pytest.mark.parametrize("what_came_back", ["empty", "another drive"]) +async def test_an_auto_ejected_root_stays_out_when_its_files_are_not_there( + tmp_path, what_came_back): + """ + What the safety net exists for: an empty mount point, or another drive + mounted at the same place, is not the library. Plugging it back would + rescan it, and the rescan would read the library as erased. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + roots = _roots(films) + idx = await _indexer(roots) + _vanish(films) + await idx.reconcile() + + films.mkdir() + if what_came_back == "another drive": + (films / "other.mkv").write_bytes(b"something else") + await idx.reconcile() + assert roots.roots[0].ejected is True + assert _names(idx) == {"a.mkv"}, "the library was treated as erased" + + +async def test_an_operator_eject_is_never_undone_automatically(tmp_path): + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + roots = _roots(films) + idx = await _indexer(roots) + + idx.eject_root("Films") + await idx.reconcile() + assert roots.roots[0].ejected is True + + +async def test_after_a_restart_the_cache_recognises_the_drive(tmp_path): + """ + A restarted node has no index entry for an ejected root: the index is + rebuilt by scanning, and an ejected root is not scanned. What it does have is + the hash cache, with the size and mtime of every file it read there. + """ + import os + + from meshbay_node.indexer.cache import IndexCache + + films = tmp_path / "Films" + films.mkdir() + movie = films / "a.mkv" + movie.write_bytes(b"a") + st = movie.stat() + + def restarted_ejected() -> RootSet: + return RootSet.build([{"path": str(films), "removable": True, + "ejected": True, "ejected_auto": True}]) + + async with IndexCache(tmp_path / "cache.db") as cache: + await _indexer(_roots(films), cache=cache) + + # Another drive at the same place, with a file of the same name. + movie.write_bytes(b"another drive") + roots = restarted_ejected() + await _indexer(roots, cache=cache) + assert roots.roots[0].ejected is True + + # The drive itself. + movie.write_bytes(b"a") + os.utime(movie, ns=(st.st_atime_ns, st.st_mtime_ns)) + roots = restarted_ejected() + idx = await _indexer(roots, cache=cache) + assert roots.roots[0].ejected is False + assert _names(idx) == {"a.mkv"} + + +async def test_the_roster_keeps_an_auto_eject_apart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_root_ejected("g1", "Films", True, set_by="op", auto=True) + await roster.set_root_ejected("g1", "Music", True, set_by="op") + assert await roster.ejected_roots("g1") == {"films", "music"} + assert await roster.auto_ejected_roots("g1") == {"films"} + + # An operator eject of the same root replaces the safety net's. + await roster.set_root_ejected("g1", "Films", True, set_by="op") + assert await roster.auto_ejected_roots("g1") == set() + finally: + await roster.close() -- cgit v1.2.3