diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_video_buffer_ceiling.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_video_buffer_ceiling.py | 233 |
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") |