summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_video_buffer_ceiling.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_video_buffer_ceiling.py')
-rw-r--r--packages/meshbay-hub/tests/test_video_buffer_ceiling.py233
1 files changed, 233 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py
new file mode 100644
index 0000000..9063c46
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py
@@ -0,0 +1,233 @@
+"""
+A big film stops at around 100 MB.
+
+Reported against 0.5: upload a 500 MB video, play it, and the player loads
+roughly 100 MB and then hangs on "buffering" for good.
+
+100 MB is not a number in our code. It is where the browser stops: a video
+SourceBuffer is capped at a few hundred megabytes and `appendBuffer` throws
+QuotaExceededError past it. The node remuxes with `-c copy`, so the bytes on
+the wire are the file's own — a 500 MB film really does try to put 500 MB into
+that buffer, and on a fast link it reaches the ceiling in the first minute,
+long before anyone has watched enough for eviction to have anything to drop.
+
+Two defects, and the second is the one that makes it permanent.
+
+**Nothing bounded how far ahead we pulled.** Credit was granted once per
+append: the node sent exactly as fast as the browser could append, which is as
+fast as the network allows, which for a film is very much faster than watching
+it. Memory was bounded by the browser's ceiling rather than by anything we
+chose. Buffering by *time* past the playhead instead makes a two-hour film cost
+the same as a two-minute clip.
+
+**The pipeline could not restart itself.** An append refused for quota fires no
+`updateend`. `updateend` was where credit was granted, so no credit went out;
+the node then sent nothing, so no segment arrived to call `flushQueue` again.
+Every wakeup the append path had was downstream of the append that had just
+failed. Playback continuing past the segment, which is exactly what frees the
+room needed to recover, woke nothing at all. The player deadlocked against
+itself.
+
+The first version of these tests modelled the pipeline and passed while the
+player still hung, because a model of a fix written by whoever wrote the fix
+agrees with it by construction. They now run the shipped `bufferedAhead`,
+`evictBehind`, `flushQueue` and `pump`, lifted out of app.js as text, against a
+fake SourceBuffer — see harness/mse_harness.mjs. What is modelled is the
+browser, not us.
+"""
+
+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"
+NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src"
+ / "meshbay_node" / "transport" / "webrtc_server.py")
+
+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 shipped functions, run against a browser that has a ceiling ───────────
+
+HARNESS = Path(__file__).parent / "harness" / "mse_harness.mjs"
+
+
+def _harness(**cfg) -> dict:
+ proc = subprocess.run(
+ ["node", str(HARNESS), str(APP), json.dumps(cfg)],
+ capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+@pytest.fixture(scope="module")
+def idle():
+ """Nobody pressed play — autoplay is blocked on a phone more often than not."""
+ return _harness(playing=False)
+
+
+@pytest.fixture(scope="module")
+def watched():
+ return _harness(playing=True)
+
+
+def test_the_ceiling_is_never_reached_when_nobody_presses_play(idle):
+ """The reported hang, from the side that produces it.
+
+ A film left on the loading screen used to pull until the browser refused an
+ append, and that refusal was unrecoverable. Nothing should get near it.
+ """
+ assert not idle["hitCeiling"], (
+ f"filled the buffer to the ceiling ({idle['heldInBufferMB']} MB)")
+ assert idle["quotaRefusals"] == 0, (
+ f"{idle['quotaRefusals']} appends refused for quota — the state the "
+ "player cannot get out of on its own")
+
+
+def test_the_ceiling_is_never_reached_while_watching(watched):
+ assert not watched["hitCeiling"], (
+ f"filled the buffer to the ceiling ({watched['heldInBufferMB']} MB)")
+ assert watched["quotaRefusals"] == 0
+
+
+def test_the_read_ahead_is_bounded_by_the_playhead(idle, watched):
+ """What replaced "as fast as the network allows"."""
+ ahead = int(re.search(r"const BUFFER_AHEAD_S = (\d+)", APP.read_text()).group(1))
+ for name, run in (("idle", idle), ("watching", watched)):
+ assert run["bufferedAheadS"] < ahead * 2, (
+ f"{name}: {run['bufferedAheadS']}s buffered against a {ahead}s "
+ "bound — the gate is not holding")
+
+
+def test_a_watched_film_keeps_being_fed(watched):
+ """The gate must throttle the stream, not stop it.
+
+ Holding credit for good would be just as broken as never holding it, and
+ would look the same from the sofa.
+ """
+ assert watched["watchedS"] > 500, (
+ "playback did not advance, so this run says nothing about throttling")
+ assert watched["sentMB"] > 40, (
+ f"only {watched['sentMB']} MB reached the player in ten minutes of "
+ "playback — the gate is holding credit it should have released")
+ assert watched["removes"] > 0, "nothing was ever evicted behind the playhead"
+
+
+def test_memory_stays_bounded_over_a_long_watch(watched):
+ assert watched["heldInBufferMB"] < 40, (
+ f"holding {watched['heldInBufferMB']} MB — eviction is not keeping up")
+
+
+# ── The shape the fix depends on ──────────────────────────────────────────────
+
+def test_credit_is_granted_in_exactly_one_place(app):
+ """Granting from `updateend` is the deadlock. It must not come back.
+
+ A second grant site is how this regresses: it would work, until the append
+ it hangs off is the one the ceiling refuses.
+ """
+ player = _player(app)
+ sites = player.count("grantStreamCredit(")
+ assert sites == 2, (
+ f"{sites} calls to grantStreamCredit — expected exactly two, both "
+ "inside pump(): the keepalive and the release")
+ pump = player[player.index("const pump = useCallback("):]
+ pump = pump[:pump.index("\n }, [")]
+ assert pump.count("grantStreamCredit(") == 2, (
+ "credit is granted outside pump(), so an append that is refused for "
+ "quota can still be the only thing that would have woken the pipeline")
+
+
+def test_something_other_than_data_drives_the_pipeline(app):
+ """The recovery path cannot depend on a segment arriving."""
+ player = _player(app)
+ assert "setInterval(pump" in player, (
+ "no clock drives pump(): once the ceiling refuses an append, nothing "
+ "arrives and nothing retries")
+ assert "clearInterval(pumpTimer)" in player, "the pump timer outlives the player"
+ assert "addEventListener('timeupdate', pump)" in player, (
+ "playback progress is what frees room to evict, and it wakes nothing")
+
+
+def test_a_buffered_viewer_still_tells_the_node_it_is_there(app):
+ """Holding credit back must not read as a closed tab."""
+ player = _player(app)
+ assert "grantStreamCredit(0)" in player, (
+ "a viewer that is far enough ahead grants nothing and says nothing, so "
+ "the node's stall timeout ends a film that is merely paused")
+
+
+@pytest.mark.skipif(not NODE_SERVER.exists(), reason="node sources unavailable")
+def test_the_node_ends_a_stream_on_silence_not_on_stinginess():
+ """The other half of the keepalive: the node has to honour it."""
+ text = NODE_SERVER.read_text()
+ i = text.index("async def _await_stream_credit")
+ body = text[i:text.index("\n async def ", i + 1)]
+ assert "self._stream_heard_at" in body, (
+ "the stall budget still accumulates over the whole wait, so a keepalive "
+ "that grants no credit cannot keep a paused film alive")
+ assert "waited += STREAM_CREDIT_POLL" not in body, (
+ "the budget still accumulates over the whole wait rather than being "
+ "measured from the last thing the peer said")
+ grant = text[text.index("def _grant_stream_credit"):]
+ grant = grant[:grant.index("\n def ", 1)]
+ assert "self._stream_heard_at = time.monotonic()" in grant, (
+ "n=0 does not refresh the timeout, so the keepalive is a no-op")
+
+
+def test_appending_does_not_earn_credit(app):
+ """What may be in flight is a question about the buffer, not about appends.
+
+ Tying the two was the original design and it was wrong twice over.
+ `updateend` fires for `remove()` as well, so the player paid the node for
+ its own evictions; and crediting per append meant taking segments as fast
+ as they could be written, which is as fast as the network allows.
+ """
+ player = _player(app)
+ handler = player[player.index("sb.addEventListener('updateend'"):]
+ handler = handler[:handler.index("\n });")]
+ assert "grantStreamCredit" not in handler, (
+ "credit is granted from updateend, which fires for remove() too")
+ assert "outstandingRef" not in handler, (
+ "the in-flight window is adjusted from updateend rather than from the "
+ "buffer, so an eviction still counts as room for another segment")
+
+
+def test_credit_is_a_window_and_not_a_debt(app):
+ """It must be topped up, not paid off.
+
+ Accumulating a credit per append and handing over the whole balance when
+ the buffer finally had room sent six megabytes in one burst, overshot the
+ target by a minute of film, and then said nothing for forty-six seconds.
+ Measured in Chrome against real fragmented MP4.
+ """
+ player = _player(app)
+ pump = player[player.index("const pump = useCallback("):]
+ pump = pump[:pump.index("\n }, [")]
+ assert "STREAM_WINDOW - outstandingRef.current" in pump, (
+ "pump() no longer tops a window up to what is allowed in flight")
+ src = APP.read_text()
+ window = int(re.search(r"const STREAM_WINDOW = (\d+)", src).group(1))
+ assert 2 <= window <= 16, (
+ f"a window of {window} segments is either too small to keep the pipe "
+ "busy or big enough to be a burst again")
+ assert "outstandingRef.current = Math.max(0, outstandingRef.current - 1)" in player, (
+ "nothing decrements the window when a segment lands, so it fills once "
+ "and never reopens")