Files
KoalaSync/docs/episode-transition-stray-pause.md
T
Timo 5711daefd4 docs: record the open episode-transition stray-pause investigation
A peer can be left paused at the end of an episode while the other moves on,
and nothing recovers it. The episode guard, the title on the wire and the
comparison all exist already, so the interesting part is why the guard lets
that one pause through — and that cannot be settled without logs from both
sides while it happens.

Records what was verified from the code, the three candidate causes that are
still hypotheses, the exact log lines that separate them, the planned fix
order, and the approaches already ruled out (forcing the comparison would
break every platform that exposes no media title).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:20:45 +02:00

9.9 KiB

Stray pause at an episode transition (open investigation)

Status: Open. Root cause not confirmed — blocked on logs from both sides while it happens. Everything below marked verified was read from the code; everything marked hypothesis was not observed yet. Do not "fix" this from the hypotheses alone.

Reported: 2026-08-15, against v3.1.0. Affected platform supports episode auto-sync (parseable SxxEyy titles) — title parsing is explicitly not the problem here.


Symptom

Two peers watch a series together. At the end of an episode, one of them moves on to the next episode while the other stays paused at the end of the previous one and never recovers. Happens intermittently, not on every transition.

Reported sequence

  1. Peer A finishes episode 1, the player advances to episode 2.
  2. A detects the transition, opens an episode lobby and pauses itself in episode 2, waiting for the others.
  3. Peer B is still a few seconds from the end of episode 1.
  4. B receives a PAUSE and applies it — although that pause originates from a peer that is already on episode 2, so it should have been discarded.
  5. B is now paused before its own transition, so its player never auto-advances, so B never reports ready. A's lobby runs into the 60 s timeout and is cancelled. From then on A and B are on different episodes and the guard (correctly) blocks everything, so nothing recovers.

What the code does today (verified)

The episode guard already exists and already runs before anything is applied. content.js:1197-1213 is the first thing in the SERVER_COMMAND branch (after the host-control desync check). It compares the sender's title from the payload against the local title and skips PLAY/PAUSE/SEEK/ FORCE_SYNC_* on mismatch.

The title is already shipped with every content-script command. sendContentEvent attaches mediaTitle to play/pause/seek (content.js:1450-1469), and routeToContent forwards the payload to the content script unchanged (background.js:2576). Adding the title to these events is therefore not an available fix — it is already there.

The guard is permissive by design. isDifferentEpisode (content.js:897-903) returns false — i.e. "allow" — when either side has no title or no parseable episode ID. This is deliberate: Netflix exposes no media title at all, JkAnime none, Disney+ only the series name (see TESTED_SERVICES.md). Making the comparison mandatory would silently break sync on those platforms.

Titles come from navigator.mediaSession.metadata (content.js:863) — a page-level signal that is not coupled to what the <video> element is actually playing. It can lead, lag, or be briefly null across a source swap.

Self-inflicted pauses are suppressed by a 300 ms timer, not by a latch. _setSuppress (content.js:65-70) self-clears after 300 ms. If the native pause event arrives later than that, the event is relayed as a normal user pause (content.js:1514-1517). The lobby pause (PAUSE_FOR_LOBBY, content.js:1291-1303) is issued exactly when the player is busy loading the next episode.

There is no ended listener. Only play/pause/seeked/loadeddata/waiting are attached (content.js:1695), so a pause at the end of an episode is indistinguishable from a user pause.

Nothing resumes anyone after a failed lobby. EPISODE_LOBBY_CANCEL only stops the poll (content.js:1284); cancelEpisodeLobby (background.js:1732) does not resume the initiator.

Why the guard can let the pause through (hypotheses)

Given that both the label and the comparison exist, the pause can only be applied if one of these three inputs is wrong at that instant. Which one is unknown.

(a) The sender's label is empty. Players commonly clear mediaSession.metadata and re-set it across a source swap. A leaked lobby pause emitted inside that window carries mediaTitle: null → guard allows.

(b) The receiver's label is ahead. The page may switch its metadata to episode 2 before the <video> element actually advances. Then both sides read "episode 2", agree, and the pause is applied while B's picture is still episode 1.

(c) The guard is off on the receiver. It only runs when _autoSyncEnabled is true (content.js:1203), which is cached from storage.local (content.js:241-243) — per device, never synced between peers. If "Auto-Sync next Episode" is disabled on the peer that freezes, it swallows every cross-episode command. This is the cheapest one to check and explains the asymmetry without any timing argument.

(a) and (b) share a root cause: episode identity is sampled from a page-global signal that is not tied to the element's content.

How to confirm (do this next)

Reproduce, then collect the logs from both peers via the Status tab → Copy Logs, and line them up by timestamp. Relevant strings, verbatim from the code:

Log line Emitted by Meaning
Episode transition detected: "…" content.js:933 that peer's player advanced
Episode lobby created: "…" background.js:3582 that peer became the lobby initiator
Episode lobby received: waiting for "…" content.js:1276 the other peer entered the lobby
Episode mismatch: sender="…" vs mine="…" — skipping … content.js:1207 the guard fired
Local episode ready: "…" background.js:3620 that peer reached the expected episode
Episode lobby cancelled: … background.js:1740 60 s timeout (constants.js:90)

What to read off:

  • A PAUSE from the other peer on B's side without an accompanying Episode mismatch line → the guard did not fire. The sender="…" value in a neighbouring mismatch line (if any) shows what A's label looked like at that moment, which separates (a) from (b).
  • No Episode mismatch line anywhere in B's log for the whole session, while episodes clearly diverged → suspect (c); verify the setting directly.
  • A PAUSE from You in A's log shortly after Episode lobby created → A's own lobby pause leaked past the 300 ms suppression.

Fix plan once confirmed

Ordered by risk. The guiding rule is fail open: no change may create a state where nothing syncs at all — that is worse than the current symptom.

  1. Never relay a pause we caused ourselves. Replace the 300 ms stopwatch in _setSuppress with an explicit latch that is cleared when the expected native event arrives (or when the lobby ends). A programmatic pause is not a user command, regardless of how long the player takes to report it.
  2. Latch the episode identity to the element. Derive it at loadeddata / source change (content.js:1733) and use the latched value both for labelling outgoing commands and for the local side of the comparison, instead of a live mediaSession read. Kills (a) and (b). The building blocks (lastKnownMediaTitle, lastVideoSrc) already exist.
  3. Resume after a failed lobby. Remember that we paused for the lobby and undo it on cancel, so a failed auto-sync degrades to "keep watching" instead of a silent freeze.
  4. Close the remaining title-less paths (hygiene, not the cause of this symptom): popup play/pause send payload: {} (popup.js:2251), and the lobby's force-sync sends { targetTime: 0.0 } without a title (background.js:1794) — the latter means a force-sync firing while a peer is still on the old episode seeks that peer to 0.0 of the old episode.

Rejected approaches (and why)

  • "Just send the title with the pause." Already done for this event (content.js:1458) — nothing to add.
  • "Force the comparison / refuse commands without a title." Breaks Netflix, JkAnime and Disney+ outright, plus films whose titles differ per language or provider, plus the hidden media-title privacy mode which strips the title by design (title-privacy.js:31-40). A silent "nothing syncs any more" is worse than the current bug.
  • Pinning the title at event time alone. Tempting, but it would make the failure deterministic rather than fix it: the stray pause would then always carry the old episode and always be applied. Only acceptable together with items 1 and 2.
  • Automatically resuming a stranded peer. Reaching into someone else's playback is the kind of automation that is worse than the symptom when it misfires. Only reconsider if items 1-3 prove insufficient.

Code map

Concern Location
Episode ID parsing / comparison episode-utils.js (synced into content.js at build time)
Guard on incoming commands content.js:1197-1213
Outgoing event + title sampling content.js:1450-1545
Echo suppression content.js:59-77
Transition detection content.js:904-946
Lobby (content side) content.js:947-1003, content.js:1264-1303
Lobby (background side) background.js:1707-1838, background.js:3498-3624
Title privacy title-privacy.js