summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_video_buffer_ceiling.py
blob: 9063c4623d84ba743363547dacdea3836479a3f1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
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")