A playing session's position stands still between reports, where upstream's advances #201

Closed
opened 2026-09-12 15:04:37 +02:00 by Claude · 1 comment
Collaborator

Upstream advances a playing session's position on a timer and this server
does not, so /Sessions disagrees about where a client is within a second of
it reporting.

SessionInfo.StartAutomaticProgress adds a second to PlayState.PositionTicks
every second while a session is playing and not paused, between the reports a
client actually sends. Measured side by side on the same anchor copy, after
one report of 90000000:

          t+1s       t+2s       t+3s
upstream  100000000  110000000  120000000
ours       90000000   90000000   90000000

A paused session agrees on both.

Why it is worth filing rather than shrugging at

It was found while diffing SyncPlay's group adoption, and it is what made that
diff disagree: upstream's group adopts the position its timer has advanced to,
and then seeks every member back to it. Controlling for the drift, adoption
agrees exactly — so this is the session registry's difference and not
SyncPlay's, and it will keep surfacing as one wherever a position is read
between two reports.

What reads a position between reports: /Sessions (the dashboard's now-playing
row), the resume point a second client is offered, and now a group's adopted
position.

What it is not

Not the write path. What a client reports is filed identically on both sides —
crates/ephyra-app/src/session/sessions.rs records the report and moves
LastPlaybackCheckIn, which is upstream's own behaviour. The difference is
only in what is reported back between two reports.

Worth settling before it is written

Whether the advance is computed on read — position + (now - last report) for
an unpaused session, which needs no timer and no task — or written by a ticker
as upstream's is. The first is cheaper and has no state to leak; the second is
what upstream does and is what a reader comparing the two servers' /Sessions
row by row would expect to find in the column.

Upstream advances a playing session's position on a timer and this server does not, so `/Sessions` disagrees about where a client is within a second of it reporting. `SessionInfo.StartAutomaticProgress` adds a second to `PlayState.PositionTicks` every second while a session is playing and not paused, between the reports a client actually sends. Measured side by side on the same anchor copy, after one report of `90000000`: ``` t+1s t+2s t+3s upstream 100000000 110000000 120000000 ours 90000000 90000000 90000000 ``` A paused session agrees on both. ### Why it is worth filing rather than shrugging at It was found while diffing SyncPlay's group adoption, and it is what made that diff disagree: upstream's group adopts the position its timer has advanced to, and then seeks every member back to it. Controlling for the drift, adoption agrees exactly — so this is the session registry's difference and not SyncPlay's, and it will keep surfacing as one wherever a position is read between two reports. What reads a position between reports: `/Sessions` (the dashboard's now-playing row), the resume point a second client is offered, and now a group's adopted position. ### What it is not Not the write path. What a client reports is filed identically on both sides — `crates/ephyra-app/src/session/sessions.rs` records the report and moves `LastPlaybackCheckIn`, which is upstream's own behaviour. The difference is only in what is reported back between two reports. ### Worth settling before it is written Whether the advance is computed on read — `position + (now - last report)` for an unpaused session, which needs no timer and no task — or written by a ticker as upstream's is. The first is cheaper and has no state to leak; the second is what upstream does and is what a reader comparing the two servers' `/Sessions` row by row would expect to find in the column.
Author
Collaborator

Landed on main as 78be052e (the advance) and 1fe062f5 (the guards the review found untested, and the ignore-list entry a replay now needs).

The shape: computed on read, not a ticker

Live::position_ticks_at(now, run_time_ticks) in crates/ephyra-app/src/session/sessions.rs. No timer, no task, no second stored position.

Upstream does run one — SessionInfo._progressTimer, Timer(…, 1000, 1000), armed by OnPlaybackStart (SessionManager.cs:784) and re-armed by every non-automated OnPlaybackProgress (:959), stopped on OnPlaybackStopped (:1061). The callback adds exactly one second and re-enters OnPlaybackProgress(isAutomated: true), which writes PlayState.PositionTicks and LastActivityDate but not LastPlaybackCheckIn and not user data (:925, "only update saved user data on actual check-ins").

Because the timer mutates _lastProgressInfo.PositionTicks in place and only a real report replaces that object, upstream's iterative sum is exactly reported + floor(elapsed since the last real report). That equivalence is what makes the closed form safe — it was checked against a simulation of the timer over 14 inputs (advancing, paused, null, negative, exact-second clamp, fractional runtime, position past the end, no runtime), agreeing in every case. Its two gates carry over: if (progressInfo.IsPaused) return; and if (newPositionTicks >= runtimeTicks) return;, the second of which stops the position at the last whole second strictly inside the item.

Measured against upstream v12.0.0

After one report of 900000000:

            t+0.4s      t+2.4s      t+5.4s      t+10.4s     t+20.4s
upstream    900000000   920000000   950000000   1000000000  1100000000
before      900000000   900000000   900000000    900000000   900000000
after       900000000   920000000   950000000   1000000000  1100000000

Paused stands still on both. Resume and seek both restart the advance from the new report. Reported 4.3s from the end: both climb to runtime − 3s and stay. Reported exactly 4s from the end: both stop at runtime − 1s, three steps and not four. Stopped: both null.

SyncPlay was visibly wrong and is the reason this was worth doing beyond the dashboard — a group opened 6s after a report adopted StartPositionTicks=900000000 where upstream adopted 960000000.

The review

Twelve mutations, nine killed. The three survivors were all guards with no test, and one of them was a panic: without .max(0) on the room left in the item, a client reporting a position past the runtime makes i64::clamp panic (min = 0, max = -11) — and playstate.rs:174 rejects negatives only on /Sessions/Playing/Stopped, so that is reachable input. 1fe062f5 adds the three tests; each now fails on exactly its own mutation and on nothing else. The code was already right — what was missing was anything that would notice if it stopped being.

3249 tests, clippy silent, fmt clean.

Split out rather than buried

  • #202 — a socket subscribed to Sessions is not woken between reports, so a dashboard on the websocket is still frozen. This is the one piece of the symptom described here that the fix does not reach.
  • #203 — the position is bounded by the item row's runtime; upstream bounds it by the selected media source's, which differs for an alternate version or a live stream.
  • #204 — nothing the harness replays reads a session while it is playing, plus two smaller divergences (LastActivityDate not advancing, which changes the activeWithinSeconds filter; and an empty ItemId which upstream does advance).

Closing this one: the position a reader is told is now upstream's, on every path that asks.

Landed on `main` as `78be052e` (the advance) and `1fe062f5` (the guards the review found untested, and the ignore-list entry a replay now needs). ## The shape: computed on read, not a ticker `Live::position_ticks_at(now, run_time_ticks)` in `crates/ephyra-app/src/session/sessions.rs`. No timer, no task, no second stored position. Upstream does run one — `SessionInfo._progressTimer`, `Timer(…, 1000, 1000)`, armed by `OnPlaybackStart` (`SessionManager.cs:784`) and re-armed by every **non-automated** `OnPlaybackProgress` (`:959`), stopped on `OnPlaybackStopped` (`:1061`). The callback adds exactly one second and re-enters `OnPlaybackProgress(isAutomated: true)`, which writes `PlayState.PositionTicks` and `LastActivityDate` but not `LastPlaybackCheckIn` and not user data (`:925`, "only update saved user data on actual check-ins"). Because the timer mutates `_lastProgressInfo.PositionTicks` in place and only a real report replaces that object, upstream's iterative sum is exactly `reported + floor(elapsed since the last real report)`. That equivalence is what makes the closed form safe — it was checked against a simulation of the timer over 14 inputs (advancing, paused, null, negative, exact-second clamp, fractional runtime, position past the end, no runtime), agreeing in every case. Its two gates carry over: `if (progressInfo.IsPaused) return;` and `if (newPositionTicks >= runtimeTicks) return;`, the second of which stops the position at the last whole second **strictly inside** the item. ## Measured against upstream v12.0.0 After one report of `900000000`: ``` t+0.4s t+2.4s t+5.4s t+10.4s t+20.4s upstream 900000000 920000000 950000000 1000000000 1100000000 before 900000000 900000000 900000000 900000000 900000000 after 900000000 920000000 950000000 1000000000 1100000000 ``` Paused stands still on both. Resume and seek both restart the advance from the new report. Reported 4.3s from the end: both climb to runtime − 3s and stay. Reported **exactly** 4s from the end: both stop at runtime − 1s, three steps and not four. Stopped: both `null`. SyncPlay was visibly wrong and is the reason this was worth doing beyond the dashboard — a group opened 6s after a report adopted `StartPositionTicks=900000000` where upstream adopted `960000000`. ## The review Twelve mutations, nine killed. The three survivors were all guards with no test, and one of them was a panic: without `.max(0)` on the room left in the item, a client reporting a position past the runtime makes `i64::clamp` panic (`min = 0, max = -11`) — and `playstate.rs:174` rejects negatives only on `/Sessions/Playing/Stopped`, so that is reachable input. `1fe062f5` adds the three tests; each now fails on exactly its own mutation and on nothing else. The code was already right — what was missing was anything that would notice if it stopped being. 3249 tests, clippy silent, fmt clean. ## Split out rather than buried - #202 — a socket subscribed to `Sessions` is not woken between reports, so a dashboard on the websocket is still frozen. This is the one piece of the symptom described here that the fix does not reach. - #203 — the position is bounded by the item row's runtime; upstream bounds it by the selected media source's, which differs for an alternate version or a live stream. - #204 — nothing the harness replays reads a session while it is playing, plus two smaller divergences (`LastActivityDate` not advancing, which changes the `activeWithinSeconds` filter; and an empty `ItemId` which upstream does advance). Closing this one: the position a reader is told is now upstream's, on every path that asks.
Sign in to join this conversation.
No description provided.