aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-21 00:43:14 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-21 00:43:14 +0200
commit90e26beff212e4994b8e4f18c8260f2aa8a9b40c (patch)
treeca91dbe01dfe3addddb729617d23d009afd5fd87
parentf7d33c299ae0d0c4f406779b8f5324d80637adc8 (diff)
downloadmeshbay-90e26beff212e4994b8e4f18c8260f2aa8a9b40c.tar.gz
fix(hub): keep the video read-ahead across a reconnection
A reconnection restarted the stream at the playhead, and that empties the source buffer — spending the whole read-ahead at the one moment it was the thing carrying the film. It carries on from the end of the buffer instead, except where the buffer is short, the stream already ended, or a cast is active: the receiver is fed from the wire and never had that buffer, so resuming there would skip it forward by the lot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js173
-rw-r--r--packages/meshbay-hub/tests/test_video_reconnect.py252
3 files changed, 434 insertions, 10 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 892b747..0211d3c 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -2124,6 +2124,24 @@ MSE string) fed to a source buffer, with the node holding one slot per viewer.
Autoplay is blocked on a phone more often than not, so that is the ordinary
first state of every playback. Pausing does not shrink it: the lead already
paid for is the viewer's.
+- **A reconnection carries the read-ahead across; it does not spend it.** The
+ old stream dies with the connection, so something must be asked for again,
+ and the question is *where*. Restarting at the playhead goes through the
+ reinit that empties the source buffer — which a seek needs and a reconnection
+ does not: the viewer navigated nowhere, the film kept playing out of the
+ buffer for the whole outage, and emptying it at the moment the link returns
+ is what finally stops the picture. So where there is read-ahead worth
+ keeping, the stream carries on from the *end* of the buffer and what is there
+ is left alone. **The two modes are not interchangeable** — resuming keeps the
+ buffer and must not move the playhead, seeking discards it and must — and the
+ choice is one pure function so it can be tested by being run. Below a few
+ seconds of read-ahead, and for a stream that already ended, the seek is still
+ what happens: there is nothing to save, and it is the proven path. The node
+ lands on a keyframe at or before what is asked for, so the new material
+ overlaps the tail of the old and the coded frame processing replaces it; a
+ start that somehow fell *past* the buffer would leave a gap, which is a
+ permanent silent stall, so that case falls back to the reinit rather than
+ trusting the reasoning.
- **An append refused for quota is not retried until something frees room.** It
will be refused again for as long as the answer has not changed, and the
pipeline's clock calls the append path several times a second: the retry
@@ -3170,7 +3188,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| **QUIC** | Off by default, and **not at parity**: it serves the index and file chunks with no transfer lease, no leaseless ceiling and no root-availability check, does its file I/O on the event loop, and returns exception text to the peer (**L3**). No client speaks it. Either it comes to parity or it goes; until then §5.1's "chat is the only gap" is the one sentence here that overstates the code |
| **The relay registry** | **Closed in the code**: `relay.RELAYS_ENABLED` is False and every `/v1/relays` route answers 503, as federation does. Nothing in the tree calls them, node or client, and §11.1 measured two ISPs with no TURN relay needed. Kept code that nothing calls is what **L7** says not to keep; it stays only as the proof-of-possession design (**AV6**) until a node needs a relay or it is deleted |
| **A very high bitrate wedges the player against a small buffer ceiling** | Where even the *floor* read-ahead does not fit — ninety seconds plus the minute kept behind, at the file's bitrate, above what the engine will hold — the film stalls: measured on the harness at 9.3 Mbit/s against a 100 MB ceiling, 100.8 s of film played in 900 s of wall clock. **Predates the byte budget and is unchanged by it**, to the tenth of a second; what the budget did change there is the refusal count, 1560 → 2. The fix is not a bound at all, it is a second stage of buffer outside the SourceBuffer, which means gating the append path — the riskiest change in this area and not one to make alongside another |
-| **A reconnection throws the read-ahead away** | `addReconnectListener` resumes with a seek to the playhead, and `reinitAt` empties the SourceBuffer, so a transport drop costs every second of buffer that was the point of building it. Harmless while the read-ahead was ninety seconds and the buffer was empty by the time the link returned; with a budget of minutes it is the thing that stops the budget paying. Resuming at the *end* of what is buffered instead is the fix, and it touches the seek path, which is why it is here and not in the same commit |
| **The reconnect backoff only wakes on `visibilitychange`** | So a tab that stays visible through an outage — which is what a screen wake lock guarantees while a film is playing — waits out the full backoff, up to 30 s, after the network is already back. Nothing listens for `online` |
| **Per-device revocation has no CLI** | A device is revoked over MNP (`roster.revoke_device`), from a device the node has already pinned. On a headless node the operator's only lever is `member unpin`, which removes **every** device of that account — so the per-device control the roster is built around is reachable from an interface and from nowhere else. §6.7 listed a `meshbay-node member device list\|revoke` verb that was never written, and that listing is how this was found: `USERGUIDE.md` was the first document written by reading the CLI rather than this specification, and the verb it copied out did not run |
| **Migrations run on SQLite only** | The chain reaches head and agrees with the models there (§12), which is not where it ships. **The exposure is one revision deep, not the whole chain**: every revision behind the first packaged release was development that no installation ever ran, so nothing replays them on PostgreSQL. What is unguarded is the *next* migration — a default, an index type or a constraint PostgreSQL refuses reaches a deploy without the suite saying so |
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
index 0fed88c..6eaacc5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -63,6 +63,10 @@ const STREAM_WINDOW = 8;
// kills an ffmpeg and spawns another. Only where the finger stops is worth a
// restart.
const SEEK_DEBOUNCE_MS = 350;
+// How much read-ahead is worth carrying across a reconnection. Under this
+// there is nothing much to lose, and restarting at the playhead — the path
+// that has always run — is simpler and no worse.
+const RECONNECT_KEEP_MIN_S = 10;
// A position is remembered per file, in this browser. Below the first threshold
// there is nothing to resume; above the second the film is finished and
// offering to resume thirty seconds before the credits is a nuisance.
@@ -224,6 +228,48 @@ function castSubtitleFor(sub, start) {
};
}
+/**
+ * What a reconnection should ask the node for, and on which terms.
+ *
+ * The old stream died with the connection, so something has to be asked for
+ * again; the question is *where*, and the answer used to be "the playhead",
+ * unconditionally. That goes through `reinitAt`, which empties the
+ * SourceBuffer — so a reconnection spent the entire read-ahead at the one
+ * moment it was worth most: the link is back, the film never stopped because
+ * the buffer was carrying it, and emptying the buffer is what finally stops
+ * it. Harmless while the read-ahead was ninety seconds and a dead spot had
+ * already drained it; with a budget of minutes it is the thing that stops the
+ * budget paying for anything.
+ *
+ * So where there is read-ahead worth keeping, carry on from the *end* of it
+ * and leave what is there alone. The two modes are not interchangeable:
+ * resuming keeps the buffer and must not move the playhead, seeking discards
+ * the buffer and must move it.
+ *
+ * Pure, and at module scope, so it can be tested by being run rather than by
+ * being read.
+ */
+function reconnectPlan(playhead, range, ended, castActive) {
+ // A stream that already ended has nothing left to fetch, and asking for a
+ // position at the end of the film would start an ffmpeg to serve nothing.
+ // Seeking is what this did before and is left exactly as it was.
+ //
+ // A cast is the same answer for a quite different reason: **the receiver
+ // does not share this buffer.** The relay is fed from segments as they
+ // arrive off the wire, so everything sitting in the SourceBuffer is material
+ // it never saw — carrying on from the end of it would restart the relay
+ // there and jump the receiver forward by the whole read-ahead, which with a
+ // budget of minutes is minutes of film silently skipped on somebody's
+ // television. What this protects is the *local* buffer; where playback is
+ // not local, restarting at the playhead is the correct answer and not merely
+ // the cautious one.
+ if (!ended && !castActive
+ && range && range[1] - playhead >= RECONNECT_KEEP_MIN_S) {
+ return { mode: 'resume', at: range[1] };
+ }
+ return { mode: 'seek', at: playhead };
+}
+
function _mseSupported(codec) {
if (!window.MediaSource) return false;
const mime = `video/mp4; codecs="${codec}"`;
@@ -343,6 +389,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
const awaitingInitRef = useRef(false);
const seekTargetRef = useRef(null);
const seekTimerRef = useRef(null);
+ // True between asking the node to carry the stream on from the end of the
+ // buffer and that request's `stream_init` arriving. It is what tells the two
+ // landings apart: one keeps the buffer, the other empties it.
+ const resumingRef = useRef(false);
// The seek is built inside the effect, where the transport and `cancelled`
// live; the render needs to reach it for "start from the beginning".
const requestSeekRef = useRef(null);
@@ -663,6 +713,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// the old one the moment that much is buffered.
awaitingInitRef.current = false;
seekTargetRef.current = null;
+ // And the same trap once more: a resume asked for on the film being left
+ // would have the new film's first stream_init keep a buffer belonging to
+ // the old one, on a SourceBuffer built for neither.
+ resumingRef.current = false;
clearTimeout(seekTimerRef.current);
const transport = transportRef.current;
if (!transport || !transport.connected) {
@@ -716,6 +770,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
ranges: describeRanges(),
});
awaitingInitRef.current = true;
+ // A seek supersedes a resume that had been asked for and not landed:
+ // this one empties the buffer, and taking the resume branch on its
+ // `stream_init` would leave the film we navigated away from in place.
+ resumingRef.current = false;
seekTargetRef.current = target;
outstandingRef.current = STREAM_WINDOW;
setPhase('loading');
@@ -726,6 +784,33 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
requestSeekRef.current = requestSeek;
/**
+ * Ask the node to carry the stream on from `target`, keeping the buffer.
+ *
+ * Not debounced, unlike `requestSeek`: the 350 ms there is for a finger on
+ * the scrubber, and a reconnection happens once. Nor does it show the
+ * loading screen — the film is still playing out of the buffer, and there
+ * is nothing for the viewer to wait for.
+ */
+ const requestResume = (target) => {
+ clearTimeout(seekTimerRef.current);
+ const t = transportRef.current;
+ if (cancelled || !t || !t.connected) return;
+ t.sendStreamDiag({
+ event: 'resume-request', target: +target.toFixed(1),
+ t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
+ ready: videoRef.current ? videoRef.current.readyState : null,
+ offset: sbRef.current ? sbRef.current.timestampOffset : null,
+ ranges: describeRanges(),
+ });
+ resumingRef.current = true;
+ awaitingInitRef.current = true;
+ seekTargetRef.current = null;
+ outstandingRef.current = STREAM_WINDOW;
+ console.log('[resume] request', +target.toFixed(1));
+ t.requestStream(entry.id, STREAM_WINDOW, target, audioTrackRef.current);
+ };
+
+ /**
* Move the playhead onto a seek once the data for it has arrived.
*
* Setting `currentTime` into a region that is not buffered yet leaves the
@@ -812,6 +897,65 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
pump();
};
+ /**
+ * Carry the stream on at `start`, keeping everything already buffered.
+ *
+ * `reinitAt`'s counterpart, for the one case where the buffer is the thing
+ * worth saving rather than the thing in the way — see `reconnectPlan`. The
+ * node lands on a keyframe at or before what was asked for, so the new
+ * material overlaps the tail of what is there and the coded frame
+ * processing replaces it; the range stays continuous and the playhead
+ * never notices.
+ */
+ const resumeAt = async (start) => {
+ const sb = sbRef.current;
+ if (!sb) return;
+ // If the node could not start at or before where the buffer ends, the
+ // new material would not join what is there and the film would stall on
+ // the gap for good. It should not happen — a keyframe at or before the
+ // request, and clamping only ever moves it earlier — but a permanent
+ // silent stall is not a risk worth taking on reasoning alone, and the
+ // old path is right there and proven.
+ const range = currentRange();
+ if (!range || start > range[1] + 0.5) {
+ console.warn('[resume] node started at', start, 'past the buffer end',
+ range ? range[1] : null, '— falling back to reinit');
+ return reinitAt(start);
+ }
+ // Same reason as reinitAt: ffmpeg was killed mid-fragment, so the parser
+ // holds half of one and the next stream's header on top of that is a
+ // decode error. `abort()` resets the parser and leaves what is buffered
+ // alone, which is exactly what this path needs.
+ try { sb.abort(); } catch { /* not in a state that needs it */ }
+ await settled(sb);
+ // ffmpeg restarts its timestamps at zero however far in it was asked to
+ // seek, so this is what puts the new fragments back on the film's
+ // timeline — beside the ones already there rather than on top of them.
+ try { sb.timestampOffset = start; } catch { /* older browsers */ }
+ const tr = transportRef.current;
+ if (tr) {
+ tr.sendStreamDiag({
+ event: 'resume', target: start, offset: sb.timestampOffset,
+ t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
+ ranges: describeRanges(),
+ });
+ }
+ queueRef.current = [];
+ appendingRef.current = false;
+ endedRef.current = false;
+ awaitingInitRef.current = false;
+ // No seek target. The playhead is already where it belongs and has not
+ // stopped; setting one would have `landPlayhead` jump the film forward
+ // to the end of the buffer — over the very minutes this path exists to
+ // keep. `quotaHold` is left alone for the mirror-image reason: nothing
+ // was emptied, so a buffer that had no room still has none.
+ seekTargetRef.current = null;
+ console.log('[resume] resumeAt done, start:', start,
+ 'kept:', describeRanges());
+ setPhase('streaming');
+ pump();
+ };
+
const onSeeking = () => {
if (landingPlayheadRef.current) {
landingPlayheadRef.current = false;
@@ -850,19 +994,19 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// The old stream died with the connection (the node retires it the
// moment its session goes away — see webrtc_server.py's
// on_state_change), so there is nothing to resume on the wire, only a
- // reason to ask again. requestSeek already knows how to land a new
- // stream_init on the live SourceBuffer without resetting playback —
- // exactly what dragging the scrubber does — so reusing it here means a
- // screen-lock reconnect looks like a seek to where the film already
- // was, not a reload.
+ // reason to ask again. Where to ask from is `reconnectPlan`: the end of
+ // the buffer when there is one worth keeping, the playhead otherwise.
offReconnect = transport.addReconnectListener(() => {
if (cancelled) return;
const v = videoRef.current;
const seek = requestSeekRef.current;
if (!v || !seek) return;
- console.log('[MeshBay] transport reconnected — resuming stream at',
- v.currentTime.toFixed(1));
- seek(v.currentTime);
+ const plan = reconnectPlan(v.currentTime, currentRange(),
+ endedRef.current, castActiveRef.current);
+ console.log('[MeshBay] transport reconnected —', plan.mode, 'at',
+ plan.at.toFixed(1), 'from', v.currentTime.toFixed(1));
+ if (plan.mode === 'resume') requestResume(plan.at);
+ else seek(plan.at);
});
transport.onStreamInit = (msg) => {
@@ -915,12 +1059,18 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
if (sbRef.current && msRef.current
&& msRef.current.readyState === 'open') {
console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current);
+ // Read and cleared together: whichever landing runs, the next
+ // `stream_init` is a fresh question and must not inherit this one's
+ // answer.
+ const resuming = resumingRef.current;
+ resumingRef.current = false;
initSegmentRef.current = null;
if (castActiveRef.current && platform.cast.available) {
platform.cast.stop().catch(() => {});
castRestartPendingRef.current = true;
}
- reinitAt(msg.start || 0).catch(() => {
+ const land = resuming ? resumeAt : reinitAt;
+ land(msg.start || 0).catch(() => {
setError(t('video.err_transport'));
setPhase('error');
});
@@ -934,6 +1084,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
awaitingInitRef.current = false;
endedRef.current = false;
appendingRef.current = false;
+ // This branch builds a new SourceBuffer, so there is no buffer left to
+ // carry anything on from — whatever asked for a resume has had its
+ // answer, and a flag left standing here would send the *next*
+ // stream_init down a path whose precondition is gone.
+ resumingRef.current = false;
queueRef.current = [];
sbRef.current = null;
initSegmentRef.current = null;
diff --git a/packages/meshbay-hub/tests/test_video_reconnect.py b/packages/meshbay-hub/tests/test_video_reconnect.py
new file mode 100644
index 0000000..beeeace
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_video_reconnect.py
@@ -0,0 +1,252 @@
+"""
+A reconnection used to throw the read-ahead away.
+
+The transport drops — a tunnel, a dead spot, a screen lock — and comes back.
+The old stream died with it, so something has to be asked for again, and what
+the player asked for was always the same thing: the stream, restarted at the
+playhead. That goes through `reinitAt`, whose whole job is to empty the
+SourceBuffer, because a seek has to.
+
+But a reconnection is not a seek. The viewer did not navigate anywhere; the
+film has been playing out of the buffer the entire time the link was gone, and
+the buffer is the only reason it was still playing. Emptying it at the moment
+the link returns is what finally stops the picture — and it is the *one* moment
+the buffer is worth most.
+
+This was invisible while the read-ahead was a fixed ninety seconds: a dead spot
+long enough to notice had already drained the buffer, so there was nothing left
+to throw away. Once the read-ahead became a byte budget worth minutes
+(`test_video_buffer_ceiling.py`), it became the thing that stops the budget
+paying for anything at all — several minutes of lead, bought and then discarded
+by the reconnection it was bought for.
+
+So `reconnectPlan` decides, and there are two modes that are not
+interchangeable:
+
+**resume** keeps the buffer and carries the stream on from the *end* of it. The
+playhead must not move — it is already in the right place and still running.
+
+**seek** discards the buffer and restarts at the playhead. The playhead must
+move. This is the path that has always run, and it is still what happens when
+there is no read-ahead worth keeping, or when the stream already ended.
+
+`reconnectPlan` is pure and at module scope so that this file can *run* it
+rather than read it. The rest is read, which is weak evidence and the only
+evidence available for code that lives inside a component's effect.
+"""
+
+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 / "video-player.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(")
+ nxt = app.find("\nfunction ", i + 1)
+ return app[i:nxt if nxt > 0 else len(app)]
+
+
+def _fn(player: str, name: str) -> str:
+ """One `const <name> = ...` defined inside the effect, as text."""
+ i = player.index(f"const {name} = ")
+ return player[i:player.index("\n };", i)]
+
+
+# ── The decision, executed ────────────────────────────────────────────────────
+
+def _plan(app: str, cases: list[dict]) -> list[dict]:
+ """Run the SHIPPED `reconnectPlan` over a table of cases.
+
+ Lifted out as text and executed, in the manner of harness/mse_harness.mjs:
+ a second implementation written here would agree with the first by
+ construction and prove nothing.
+ """
+ i = app.index("function reconnectPlan(")
+ body = app[i:app.index("\n}\n", i) + 3]
+ const = re.search(r"^const RECONNECT_KEEP_MIN_S = .+;$", app, re.M)
+ assert const, "RECONNECT_KEEP_MIN_S is not a constant in video-player.js"
+ script = (
+ f"{const.group(0)}\n{body}\n"
+ f"console.log(JSON.stringify({json.dumps(cases)}.map("
+ "(c) => reconnectPlan(c.playhead, c.range, c.ended, c.cast))));"
+ )
+ proc = subprocess.run(["node", "--input-type=module", "-e", script],
+ capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def test_a_buffer_worth_keeping_is_carried_on_from_its_end(app):
+ """The whole point: the minutes already paid for survive the reconnection."""
+ plan, = _plan(app, [{"playhead": 100, "range": [40, 400], "ended": False}])
+ assert plan["mode"] == "resume", (
+ "five minutes of read-ahead in hand and the reconnection still throws "
+ "it away")
+ assert plan["at"] == 400, (
+ f"resuming at {plan['at']} rather than the end of the buffer — "
+ "anything earlier re-fetches film already held")
+
+
+def test_an_empty_buffer_still_restarts_at_the_playhead(app):
+ """The old path, unchanged, for the case it was written for."""
+ plan, = _plan(app, [{"playhead": 100, "range": None, "ended": False}])
+ assert plan == {"mode": "seek", "at": 100}
+
+
+def test_a_buffer_too_short_to_matter_takes_the_proven_path(app):
+ """Below the threshold there is nothing to save and no reason to be clever.
+
+ Resuming buys a couple of seconds and costs a second code path through a
+ SourceBuffer that has just been reconnected; seeking is proven.
+ """
+ keep = float(re.search(r"const RECONNECT_KEEP_MIN_S = (\d+)",
+ app).group(1))
+ below, at_threshold = _plan(app, [
+ {"playhead": 100, "range": [40, 100 + keep - 1], "ended": False},
+ {"playhead": 100, "range": [40, 100 + keep], "ended": False},
+ ])
+ assert below["mode"] == "seek", (
+ f"{keep - 1}s of read-ahead took the resume path")
+ assert at_threshold["mode"] == "resume", (
+ f"exactly {keep}s of read-ahead did not, so the threshold excludes "
+ "its own boundary")
+
+
+def test_a_cast_restarts_at_the_playhead_because_it_never_had_the_buffer(app):
+ """The receiver is fed from the wire, not from this SourceBuffer.
+
+ `platform.cast.push` hands the relay each segment as it arrives, so
+ everything held in the SourceBuffer is material the receiver never saw.
+ Carrying on from the end of it would restart the relay there and skip the
+ television forward by the entire read-ahead — with a budget of minutes,
+ minutes of film silently missed.
+ """
+ plan, = _plan(app, [{"playhead": 100, "range": [40, 400],
+ "ended": False, "cast": True}])
+ assert plan == {"mode": "seek", "at": 100}
+
+
+def test_a_finished_stream_is_not_restarted_to_serve_nothing(app):
+ """`endedRef` means the node already sent everything.
+
+ Asking to carry on from the end of the buffer would spawn an ffmpeg to
+ serve a position at or past the end of the film. Seeking is what this did
+ before the plan existed and is deliberately left alone.
+ """
+ plan, = _plan(app, [{"playhead": 100, "range": [40, 400], "ended": True}])
+ assert plan["mode"] == "seek"
+
+
+# ── Resuming must not do a seek's work ────────────────────────────────────────
+
+def test_resuming_does_not_empty_the_buffer(app):
+ """The one thing this whole change exists to stop."""
+ resume = _fn(_player(app), "resumeAt")
+ assert "sb.remove(0, Infinity)" not in resume, (
+ "resumeAt empties the SourceBuffer, which is exactly what it was "
+ "written to avoid")
+ assert "sb.abort()" in resume, (
+ "ffmpeg was killed mid-fragment, so the parser holds half of one and "
+ "the next stream's header lands on top of it")
+ assert "sb.timestampOffset = start" in resume, (
+ "the new fragments are not placed on the film's timeline, so they "
+ "land at zero instead of beside what is already buffered")
+
+
+def test_resuming_does_not_move_the_playhead(app):
+ """`landPlayhead` jumps the film to `seekTarget` once it is buffered.
+
+ A resume that set one would jump forward to the end of the buffer — over
+ the very minutes it just went to the trouble of keeping.
+ """
+ resume = _fn(_player(app), "resumeAt")
+ assert "seekTargetRef.current = null" in resume, (
+ "resumeAt sets a seek target, so the film jumps to the end of the "
+ "buffer instead of playing through it")
+ assert "seekTargetRef.current = start" not in resume
+
+
+def test_a_resume_that_would_leave_a_gap_falls_back(app):
+ """A gap in the middle of a film is a permanent silent stall.
+
+ The node lands on a keyframe at or before what was asked for, and clamping
+ only ever moves it earlier, so this should not happen. "Should not happen"
+ is not the same as "cannot", and the proven path is one line away.
+ """
+ resume = _fn(_player(app), "resumeAt")
+ assert "reinitAt(start)" in resume, (
+ "nothing catches a stream that starts past the end of the buffer, so "
+ "the film stalls on the gap for the rest of its run")
+
+
+def test_the_reconnection_asks_the_plan_rather_than_assuming(app):
+ player = _player(app)
+ i = player.index("addReconnectListener(")
+ listener = player[i:player.index("\n });", i)]
+ assert "reconnectPlan(" in listener, (
+ "the reconnection still restarts at the playhead unconditionally")
+ assert "requestResume(" in listener, "nothing ever takes the resume path"
+ assert "seek(plan.at)" in listener, "nothing takes the seek path any more"
+
+
+def test_resuming_does_not_show_the_loading_screen(app):
+ """The film never stopped, so there is nothing to tell the viewer to await."""
+ resume_req = _fn(_player(app), "requestResume")
+ assert "setPhase('loading')" not in resume_req, (
+ "a reconnection that the buffer covered still blanks the picture for "
+ "a loading screen")
+
+
+def test_the_resume_is_not_debounced(app):
+ """The 350 ms exists for a finger on a scrubber; a reconnection happens once."""
+ resume_req = _fn(_player(app), "requestResume")
+ assert "SEEK_DEBOUNCE_MS" not in resume_req, (
+ "the resume waits out the scrubber debounce before asking, so the "
+ "stream restarts a third of a second later than it could")
+
+
+# ── The flag that tells the two landings apart ────────────────────────────────
+
+def test_the_two_landings_are_told_apart_and_the_flag_is_cleared(app):
+ player = _player(app)
+ init = player[player.index("transport.onStreamInit = "):]
+ init = init[:init.index("\n };")]
+ assert "const resuming = resumingRef.current;" in init
+ assert "resumingRef.current = false;" in init, (
+ "the flag outlives the stream_init it was raised for, so the next one "
+ "keeps a buffer nobody asked it to keep")
+ assert "resuming ? resumeAt : reinitAt" in init, (
+ "both landings still go through the same path")
+
+
+def test_a_new_stream_and_a_seek_both_clear_the_resume_flag(app):
+ """The `awaitingInitRef` trap, one ref along — see MESHBAY_DESIGN.md §8.5.
+
+ A resume in flight when the film changes, or when the viewer seeks, would
+ have the next `stream_init` keep a buffer belonging to something else.
+ """
+ player = _player(app)
+ effect = player[player.index("useEffect(() => {\n let cancelled = false;"):]
+ reset = effect[:effect.index("const requestSeek = ")]
+ assert "resumingRef.current = false" in reset, (
+ "a resume in flight when the film changes is inherited by the new one")
+ seek = _fn(player, "requestSeek")
+ assert "resumingRef.current = false" in seek, (
+ "a seek after an unlanded resume takes the resume branch and keeps the "
+ "buffer it was supposed to discard")