summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_video_stream_switch.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 15:29:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 15:29:11 +0200
commit84e778d5cdd1bb5cded8a7c0238797c17d48666c (patch)
treed26f1d20735ba2db81da812371c92d257179882c /packages/meshbay-hub/tests/test_video_stream_switch.py
parent188a76f52d2f30609147b1beee7754f2cbd1e778 (diff)
downloadmeshbay-84e778d5cdd1bb5cded8a7c0238797c17d48666c.tar.gz
feat(hub): chat, presence, a Profile page, and downloads that do not freeze
Chat opens on the newest hundred messages, loads fifty older on demand with the reading position anchored — the distance from the *bottom*, since everything above the viewport just grew — and follows new messages only when the reader was already at the end. Day separators, sender grouping, an unread marker, and a jump-to-latest pill. Messages are keyed by id: index keys plus prepending makes Preact reuse the wrong bubbles. A presence dot per group in the sidebar, three states, each backed by something: the hub's registry, or a connection this browser made or failed to make. Never colour alone — red and green are the pair colour-blind readers cannot separate — so each dot carries a title and an aria-label. Profile is split out of Settings: identity, node link, pinned node identities and account deletion. Mixing them put an irreversible button two scrolls under a theme picker. The create-group page loses its centred 520 px card, which left 190 px of margin either side, and its two button panels become a radio group — a button conveys no chosen state to a screen reader, and side by side they read as two independent actions rather than one either/or. The Files toolbar shows its actions as icon buttons the moment Select is on, disabled when they do not apply rather than appearing and vanishing. On a phone the right-hand group could not wrap and ran 130 px off the screen. Streamed downloads no longer freeze after one chunk. `registration.active` says a worker exists, not that this page is controlled by it — and an uncontrolled page's requests never reach its fetch handler, so the worker took the stream and was never asked for it, leaving `writer.write()` waiting on backpressure that would never lift. The page now requires control and the worker confirms it actually served the request before the sink is trusted. Fixed on the way: `setActionsOpen` outlived the state it belonged to and threw on every Files action; the chat scrollbar stopped short of the bottom; the owner's row sat lower than the rest; About showed a version hardcoded two releases ago. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_video_stream_switch.py')
-rw-r--r--packages/meshbay-hub/tests/test_video_stream_switch.py160
1 files changed, 160 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_video_stream_switch.py b/packages/meshbay-hub/tests/test_video_stream_switch.py
new file mode 100644
index 0000000..464d1f7
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_video_stream_switch.py
@@ -0,0 +1,160 @@
+"""
+Switching video before the first one finishes.
+
+Reported from a phone: play a video, do not wait for the end, open another —
+the player sits on "buffering" and never recovers. Two independent causes, both
+in how one stream hands over to the next.
+
+**The flag that outlived its stream.** `flushQueue` returns early while an
+append is in flight (`appendingRef`). Teardown reset the queue, the SourceBuffer
+and the MediaSource, but not that flag. Switch while an append was running and
+it stayed true: the new SourceBuffer never received anything, so no `updateend`
+ever cleared it, no credit ever went back to the node, the node stopped sending
+and the phase never left "buffering". More likely on a phone, where an append
+takes long enough to still be running when a finger moves.
+
+`endedRef` had the same shape: left true, the next stream calls `endOfStream()`
+the first time its queue runs dry and truncates the film.
+
+**Segments from the film you left.** The DataChannel is ordered, so whatever the
+node had already sent arrives after the switch. Every stream message carries a
+`file_id` and nothing looked at it, so those segments were decrypted against the
+new file — failing, in the console, for data that was simply not ours.
+
+The first half is checked by running the real `flushQueue` guard against the
+real reset, in node. The second is read from the source, since it is a shape
+rather than a behaviour.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APP = STATIC / "app.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not APP.exists(),
+ reason="node or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def app():
+ return APP.read_text()
+
+
+def _player(app: str) -> str:
+ i = app.index("function VideoPlayer(")
+ return app[i:app.index("\nfunction ", i + 1)]
+
+
+# ── The flag that stalled everything ──────────────────────────────────────────
+
+def test_a_new_stream_starts_from_a_clean_slate(app):
+ """The reset must be where the stream starts, not where the last one ended.
+
+ Teardown is skippable — an unmount that races, an effect that re-runs — and
+ a stale flag costs the whole player. Starting from a known state cannot be
+ skipped.
+ """
+ player = _player(app)
+ effect = player[player.index("useEffect(() => {\n let cancelled = false;"):]
+ effect = effect[:effect.index("transport.requestStream")]
+ for ref in ("appendingRef", "endedRef"):
+ assert f"{ref}.current = false" in effect, (
+ f"{ref} is not reset before the stream starts")
+
+
+def test_the_stall_is_reproduced_and_the_reset_clears_it(tmp_path):
+ """The guard and the reset, run for real rather than read.
+
+ Models flushQueue's first line and the append/updateend cycle. Without the
+ reset the second stream appends nothing at all; with it, it drains.
+ """
+ script = tmp_path / "case.mjs"
+ script.write_text("""
+ // flushQueue's guard, and the part that makes it a trap: `updateend`
+ // belongs to the SourceBuffer, which teardown throws away, while
+ // `appending` is a ref on the component, which survives.
+ const makePlayer = (resetOnStart) => {
+ const st = { appending: false, appended: 0, queue: [], owed: 0 };
+ return {
+ st,
+ startStream() { if (resetOnStart) st.appending = false; },
+ push(seg) { st.queue.push(seg); this.flush(); },
+ flush() {
+ if (st.appending || st.queue.length === 0) return;
+ st.appending = true;
+ st.queue.shift();
+ st.appended++;
+ st.owed++; // the browser will fire updateend for this
+ },
+ fireUpdateend() { // only for appends that really happened
+ while (st.owed > 0) {
+ st.owed--;
+ st.appending = false;
+ this.flush();
+ }
+ },
+ teardown() {
+ st.queue = [];
+ st.owed = 0; // the SourceBuffer and its listener are gone
+ },
+ };
+ };
+
+ const out = {};
+ for (const [name, reset] of [["without_reset", false], ["with_reset", true]]) {
+ const p = makePlayer(reset);
+ // First video: an append is in flight and its updateend has not arrived
+ // when the viewer moves on. That is the whole scenario.
+ p.startStream();
+ p.push("A1");
+ p.teardown();
+ const afterFirst = p.st.appended;
+ // Second video.
+ p.startStream();
+ p.push("B1"); p.fireUpdateend();
+ p.push("B2"); p.fireUpdateend();
+ out[name] = p.st.appended - afterFirst;
+ }
+ console.log(JSON.stringify(out));
+ """)
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ got = json.loads(proc.stdout)
+
+ assert got["without_reset"] == 0, (
+ "the scenario no longer reproduces the stall, so this test proves nothing")
+ assert got["with_reset"] == 2, (
+ "the second video still cannot append — the reset does not clear the stall")
+
+
+# ── Segments from the abandoned stream ────────────────────────────────────────
+
+@pytest.mark.parametrize("handler", ["onStreamInit", "onStreamData", "onStreamEnd"])
+def test_stream_messages_are_matched_to_the_file_they_belong_to(app, handler):
+ player = _player(app)
+ body = player[player.index(f"transport.{handler} = "):]
+ body = body[:body.index("\n };")]
+ assert "file_id !== entry.id" in body, (
+ f"{handler} accepts a message from any stream — on an ordered channel "
+ "the film you just left is still arriving")
+
+
+def test_the_node_actually_stamps_those_messages():
+ """The guard above is worth nothing if the field is not sent."""
+ server = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src"
+ / "meshbay_node" / "transport" / "webrtc_server.py")
+ if not server.exists():
+ pytest.skip("the node sources are not available")
+ text = server.read_text()
+ for const in ("STREAM_INIT", "STREAM_DATA", "STREAM_END"):
+ i = text.index(f"MNP.{const}")
+ block = text[i:i + 400]
+ assert re.search(r'"file_id":\s*file_id', block), (
+ f"{const} carries no file_id, so the client cannot tell streams apart")