diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-19 17:13:17 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-19 17:13:17 +0200 |
| commit | 2eaf6887295614509f8d0bc24a9b77ccd915ef88 (patch) | |
| tree | c713fec411770da9250a44f9b1e08c4165c8d439 /packages/meshbay-hub/tests/test_reconnect_refresh.py | |
| parent | d2495a2c4b89fbbfc18cefec83ae96cabdd745e2 (diff) | |
| download | meshbay-2eaf6887295614509f8d0bc24a9b77ccd915ef88.tar.gz | |
fix(hub): a reconnect re-reads the index, not just the handshake
_reconnectLoop re-did the handshake and nothing else, so a page kept
whatever it last saw until someone reloaded it. That is invisible until
the node restarts: it rebuilds its index from index_cache.db, which
holds no enrichment, and Music is the one app whose enrichment is
persisted nowhere — for the length of the re-read pass it serves tracks
with no artist, and the album grid drew nothing.
onReconnected was one slot the video player took on open and cleared on
close; it is a listener set now, and carries the fresh ack.
docs/MESHBAY_DESIGN.md §15.3 records the two defects found alongside and
not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_reconnect_refresh.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_reconnect_refresh.py | 167 |
1 files changed, 167 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_reconnect_refresh.py b/packages/meshbay-hub/tests/test_reconnect_refresh.py new file mode 100644 index 0000000..c93e94d --- /dev/null +++ b/packages/meshbay-hub/tests/test_reconnect_refresh.py @@ -0,0 +1,167 @@ +""" +What an automatic reconnect has to re-read, and who is allowed to stop +listening for one. + +Both defects here produce a plausible screen rather than an error, so they read +the source — the only evidence available for the SPA (CLAUDE.md), and the right +kind for a fault whose whole symptom is a page that looks fine and is stale. + + - `_reconnectLoop` re-did the handshake and nothing else. Nobody asked for an + index again, and a push sent while the old channel was dying reached + nobody, so the page stayed frozen at whatever it last saw until someone + reloaded it. Survivable while a node that came back came back with the same + answers; a *restarted* one does not. It rebuilds its index from + `index_cache.db` — path/mtime/size/hash/type, no enrichment — so during its + re-enrichment pass the index it serves carries no artist and no album on + any track, and a client that reconnected inside that window drew an empty + Music grid for as long as it stayed open. + + - `onReconnected` was one setter. The video player took it on open and set it + back to `null` on close, which silently disabled every other consumer's + handler — including, once the group page had one, the refresh above. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +TRANSPORT = STATIC / "transport.js" +GROUP_PAGE = STATIC / "group-page.js" +VIDEO_PLAYER = STATIC / "video-player.js" + +pytestmark = pytest.mark.skipif( + not TRANSPORT.exists(), reason="the SPA sources are not available") + + +@pytest.fixture(scope="module") +def transport(): + return TRANSPORT.read_text() + + +@pytest.fixture(scope="module") +def group_page(): + return GROUP_PAGE.read_text() + + +@pytest.fixture(scope="module") +def video_player(): + return VIDEO_PLAYER.read_text() + + +def _reconnect_loop(transport: str) -> str: + body = transport[transport.index("async _reconnectLoop()"):] + return body[:body.index("\n /**")] + + +def test_a_reconnect_is_announced_to_every_listener(transport): + """One consumer unsubscribing must not silence the others.""" + assert "addReconnectListener(fn)" in transport + assert "set onReconnected(" not in transport, ( + "a single slot is what let the video player unset the group page's handler") + assert "this._reconnectListeners = new Set()" in transport + + +def test_addReconnectListener_hands_back_its_own_unsubscribe(transport): + body = transport[transport.index("addReconnectListener(fn) {"):] + body = body[:body.index("\n }")] + assert "this._reconnectListeners.add(fn)" in body + assert "return () => this._reconnectListeners.delete(fn)" in body, ( + "without it a caller can only stop listening by clearing the whole set") + + +def test_the_reconnect_carries_the_fresh_ack(transport): + """ + The page's whole view of the node — which folders each app reads, which + apps are on, the roots — was answered by a process that may since have + restarted. + """ + loop = _reconnect_loop(transport) + assert re.search(r"ack\s*=\s*await this\.connect\(", loop), ( + "the reconnect's own handshake answer was thrown away") + assert re.search(r"fn\(ack\)", loop) + + +def test_one_listener_throwing_does_not_rob_the_next(transport): + loop = _reconnect_loop(transport) + notify = loop[loop.index("_reconnectListeners"):] + assert "try {" in notify and "catch" in notify + + +def test_nothing_assigns_the_old_setter(): + """`grep onReconnected =` is what this is, spelled so it cannot rot.""" + offenders = [p.name for p in STATIC.glob("*.js") + if re.search(r"\.onReconnected\s*=", p.read_text())] + assert offenders == [], ( + f"{offenders} still assign a slot that no longer exists") + + +def test_the_group_page_refetches_the_index_on_reconnect(group_page): + assert "addReconnectListener(" in group_page, ( + "a reconnect that re-reads nothing leaves the page a node-restart old") + listener = group_page[group_page.index("addReconnectListener("):] + listener = listener[:listener.index("\n });")] + assert "applyAck(" in listener, "the ack is a restarted node's answers, not the old one's" + assert "transport.fetchIndex()" in listener, ( + "a delta is computed against a snapshot only the node has, and this " + "session was never told what it missed") + assert "applyIndex(" in listener + + +def test_the_group_page_reimports_the_gek_on_reconnect(group_page): + """A chat epoch or a re-key while we were away hands back a different GEK.""" + listener = group_page[group_page.index("addReconnectListener("):] + listener = listener[:listener.index("\n });")] + assert "importGEK" in listener + + +def test_the_ack_is_applied_by_one_implementation(group_page): + """ + Two copies drifting is how `helloworld`'s directories went missing from one + of them; the connect path and the reconnect path read the same function. + """ + assert group_page.count("const applyAck = useCallback(") == 1 + assert group_page.count("applyAck(ack);") == 1 + assert group_page.count("applyAck(reack);") == 1 + body = group_page[group_page.index("const applyAck = useCallback("):] + body = body[:body.index("\n }, [")] + for setter in ("setEnabledApps", "setAppDirectories", "setMusicbrainzConfig", + "setTmdbConfig", "setChatDirectory", "setSearchListed"): + assert setter in body, f"{setter} is not re-read on a reconnect" + + +def test_every_listener_is_dropped_by_whoever_registered_it(group_page, video_player): + """ + A transport handed on to a running download (`releaseWhenIdle`) outlives + the page that opened it, and would go on driving a component that is gone. + """ + for name, source in (("group-page.js", group_page), + ("video-player.js", video_player)): + assert re.search(r"=\s*transport\.addReconnectListener\(", source), ( + f"{name} must keep the unsubscribe it is handed") + assert "offReconnect()" in source, ( + f"{name} registers a reconnect listener it never drops") + + +def test_every_harness_that_renders_the_group_page_stubs_the_subscription(): + """ + The fast half of a guard `test_group_tab_fallback` already provides slowly. + + GroupPage subscribes on connect and calls the unsubscribe on unmount, so a + stub node without this throws inside `connect()` and the page renders its + error state — a whole harness reporting a layout it never drew. Three of + these stubs answer an unknown method through a Proxy, which hands back a + promise where an unsubscribe belongs and defers the same failure to the + teardown. Nine minutes of browser tests found it; this finds it in a + fraction of a second. + """ + harness = Path(__file__).parent / "harness" + stubs = [p for p in harness.glob("*.py") + if "window.MeshBayTransport" in p.read_text() + and "GroupPage" in p.read_text()] + assert stubs, "no harness stubs the transport any more — has this moved?" + missing = [p.name for p in stubs + if "addReconnectListener" not in p.read_text()] + assert missing == [], ( + f"{missing} render GroupPage against a node that cannot be subscribed to") |