fix(plex): the reclaim window rolls, and a refused finish is not performed through a third route

Two ways round 6's own fixes did not bound what their comments said they bounded.

The reclaim budget's window anchor was written only where the window RESET, so it marked the window's
start rather than the last loss — a tumbling window, not the rolling one the constant is named for.
Any loss cadence slower than half the window satisfied the reset on every second loss, so the budget
never reached its bound: measured over 200 loss cycles, 61/70/90/119s apart all gave 200 reclaims and
never an error, while 5s and 30s apart correctly gave two and then the honest answer. The failing
band is exactly the case the ERROR handler names — a transcode that falls behind realtime — and both
committed tests spaced their losses 0s and 10min apart, i.e. outside it. The anchor now moves with
every loss.

And `finish`'s refusal handler lowered the play-through latch, which is the only gate on the 10s
interval, the page-hide beacon and `settle`. All three post `absRef`, which on the play-to-the-end
path IS the end of the media, and the backend reads a position within `_FINISH_MARGIN_S` of the
duration as a finish — so within ten seconds of the toast saying the mark had failed, the item was
marked watched, its position cleared and a scrobble sent to Plex. Round 6 closed the explicit
rollback write and left the other three open; the test it added set `upNext: false`, which tears the
effect down before any of them can fire. The refusal now takes back the CLAIM rather than the latch:
a second flag stops `settle` re-asserting watched, and playing again clears both.

`FINISH_MARGIN_S` duplicated the backend's constant in a second language with nothing relating them,
and its comment had the asymmetry backwards — if the BACKEND's margin grows, this guard starts
admitting a rollback the backend reads as a finish, i.e. re-opens the defect above. Pinned by a
contract test that reads the Python, and the comment corrected.
This commit is contained in:
2026-09-12 16:21:51 +02:00
parent 71d7646bf1
commit 8036251589
5 changed files with 149 additions and 14 deletions
+9 -3
View File
@@ -435,9 +435,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
if (parsed && data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
const now = Date.now();
// A new window: the last loss was long enough ago that this is a fresh incident, not the
// same session being lost over and over.
if (now - restartsRef.current.at > RECLAIM_WINDOW_MS)
restartsRef.current = { n: 0, at: now };
// same session being lost over and over. The anchor moves with EVERY loss, because with
// it left at the window's start the window tumbled instead of rolling: any loss period
// longer than half the window satisfied the reset on every second loss, so the budget
// never reached its bound and the reclaim loop it exists to end ran forever (measured:
// 200 losses 70s apart → 200 reclaims, never an error; 5s apart → 2 and an error, which
// is why the committed test, spacing its cases 0s and 10min apart, could not see it —
// deep-review round 7).
if (now - restartsRef.current.at > RECLAIM_WINDOW_MS) restartsRef.current.n = 0;
restartsRef.current.at = now;
if (restartsRef.current.n < MAX_SESSION_RECLAIMS) {
restartsRef.current.n += 1;
loadSession(playhead(absRef, loadStartRef), !video.paused);