Show a track change in about a second instead of eleven - #153
Show a track change in about a second instead of eleven#153edward-rosado wants to merge 5 commits into
Conversation
A track change took a median of ~11s to reach the screen, with a worst
case of 17.8s across 25 measured transitions. The distribution is the
signature of a free-running 15s poll, and the poll was indeed doing
nearly all the detecting — but not because nothing else tried.
songCache already armed a timer for the end of each track and
MusicService already refreshed on it. That path was structurally unable
to succeed:
- it fired at the exact boundary, a beat before the provider reports
the new track, so the answer was the track that just finished;
- it then called clear(), dropping the cached song and cancelling
every timer, so nothing retried and a client connecting in the gap
had nothing to show;
- `song.track_progress &&` treated progress 0 as absent, so a track
first seen at its start — the one that most needed the timer — never
got one;
- and an interval fired the same event a second time.
So the timer either lost the race or never existed, and the poll picked
up the pieces one cycle later.
The end-of-track event now fires once, shortly after the track actually
runs out, and leaves the cache intact. MusicService chases the change
with a short bounded ladder (immediate, +1.2s, +2.5s) that stops as soon
as the track is genuinely different, and treats a progress collapse as a
change too so repeat-one does not run it to exhaustion. A skip takes the
same path, since it loses the same race.
A track that is merely playing is no longer treated as a track that
changed. Progress advancing rebuilt the end-of-track timer on every poll
and fired a redundant SONG_CHANGED broadcast each time — which also sent
the raw payload as a second, worse copy of an update handleMusicPayload
had already broadcast with its thumbnail rewritten. Only a different
track, a play/pause flip, or a seek counts now.
The poll interval is deliberately unchanged. Once the boundary is
scheduled properly the poll only has to catch changes with no
predictable boundary — a pause, a seek, or a skip from another device —
and shortening it would multiply traffic against an account this branch
already exists to keep under a rate limit.
tools/watch-music.js measures this from the server's own websocket:
poll cadence, duplicate sends, and how far into a track the update
announcing it arrives.
12 tests cover the change detection and the boundary scheduling,
including each regression above.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comparison looks wrong at a glance: an ordinary poll advances progress by a whole poll period, which is far more than the seek tolerance, so it reads as though every poll would be treated as a seek. It isn't, because the progress interval advances the cached value in real time — the cached number is already "where the track should be by now", not "where it was at the last poll". Comparing against the last polled value instead would double-count the elapsed time and rearm the end-of-track timer on every single poll. Test added for a 15s gap between polls, which fails under that mistake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refreshMusicData only hands a request to the source app. The song comes back later, on a separate message, and updates the cache then. The chase checked the cache immediately after sending, so it read the track it was trying to leave every time, concluded nothing had changed, and gave up — leaving the update to the next scheduled poll. Measured against the running build: a skip took 1.38s when the timing happened to work out and 14.73s when it didn't, which matches the ~13s reported from the device. The variance was the tell. Each attempt now waits for its answer before looking, and the delays are the wait after each request rather than the pause before it: 600/900/ 1200/1800/2500ms, about a seven second window. The chase still stops the moment the track is different, so the usual cost is one request. Concurrent chases are collapsed. A double-press used to start a second ladder on top of the first and double the requests for one answer. 4 tests, driven through a source app that answers asynchronously like the real one — the arrangement the old code got wrong. They assert the new track actually reaches the broadcast, that the ladder stays bounded when the provider never advances, and that two skips do not stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Guarding the chase with an "already chasing" flag was wrong in two ways. A second skip arriving mid-chase was discarded, even though it is chasing a different change from a different starting track — so it fell through to the scheduled poll. And if a chase ever failed to finish, the flag stayed set and silently disabled every future chase for the life of the process. The second failure is what showed on the device: after a while the log contains no chase requests at all, only the scheduled polls exactly 15s apart, and every skip costs a full cycle no matter how the retry timings are tuned. A generation counter replaces it. A newer chase supersedes an older one at its next checkpoint, there is no state to get stuck in, and each refresh is raced against a timeout so one unsettled send cannot stall the ladder behind it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A track that ends on its own, and a skip made from a connected client, are both handled the moment they happen: one is predicted from the track's own duration, the other is observed as a command. Neither needs the poll. That leaves the case with no boundary to predict and no command to observe — playback changed in the provider's own app, on a phone or a desktop. Nothing announces it, so the only way to notice is to look, and at the configured 15s a change made elsewhere still took up to 15s to reach the screen. After the boundary work this was the entire remaining delay, and it is the one users are most likely to hit. The poll now schedules itself one tick at a time and picks the delay from what is actually happening: about 2s while a client is connected and something is playing, the configured rate otherwise. Idle or paused it costs exactly what it did before, because a poll nobody can see buys nothing and the currency here is a provider rate limit. It never polls faster than the configured rate, so someone who deliberately set a slow cadence still gets it. Client traffic does not increase. The source only reports genuine state changes, and at a 2s cadence an ordinary progress advance falls inside its own tolerance — so a faster poll sends *fewer* redundant updates over the link, not more. Scheduling one tick at a time also means a slow poll can no longer stack requests on top of itself, which a fixed interval allowed. 4 tests: fast while watched and playing, configured rate when nothing is connected, configured rate while paused, and never faster than configured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed one more commit, which closes the case the rest of this PR does not. The boundary scheduling and the chase cover a track that ends on its own, and a skip made from a connected client. Both are handled the moment they happen — one is predicted from the track's own duration, the other is observed as a command. Neither needs the poll. That leaves the case with no boundary to predict and no command to observe: playback changed in Spotify's own app, on a phone or a desktop. Nothing announces that, so the only way to notice is to look — and at the configured 15s it still took up to 15s to reach the screen. Testing on hardware, this turned out to be the case a user is most likely to hit, and after the boundary work it was the entire remaining delay. The poll now schedules itself one tick at a time and picks its delay from what is actually happening: about 2s while a client is connected and something is playing, the configured rate otherwise. I want to be straight about this, because shortening a poll is usually the lazy answer and I argued against it earlier in this PR. What changed my mind is that the cost is not what it looks like:
Scheduling one tick at a time also fixes something the fixed interval allowed: a slow poll could stack requests on top of itself. Measured on hardware, changing tracks from Spotify on a Mac while the Car Thing displayed it over Bluetooth: Before this commit that same path was a flat 0-15s. Full picture for this PR, all measured on the same setup:
22 tests now. The four new ones cover the cadence: fast while watched and playing, configured rate when nothing is connected, configured rate while paused, and never faster than configured. |
A track change took a median of ~11s to reach the screen, worst case 17.8s, measured across 25 natural transitions. After this it is ~0.5s at a track boundary and ~0.8s on a skip.
Nothing here is Car Thing or Bluetooth specific — it is the music path in the server, so any client benefits.
The measurements
track_progressat the moment the server first reports a new track is the lateness, since a track starts at zero. Across 25 natural transitions on the old code:That distribution — near-uniform across 0–15s — is the signature of a free-running poll, and
music_refreshIntervalis 15000.After, on the same setup:
Why the poll was doing all the work
There is already a track-boundary detector:
songCachearms a timer for the end of each track andMusicServicerefreshes on it. In the shipped code it is structurally unable to succeed.clear(), dropping the cached song and cancelling every timer — so nothing retried, and a client connecting in that gap had nothing to show at all.song.track_progress &&treats progress0as absent, so a track first seen at its start — the one that most needs a timer — never got one.So the timer either lost the race or was never armed, and the 15s poll picked up the pieces a cycle later.
What changed
The boundary event is now correct. It fires once, shortly after the track runs out, and leaves the cache intact.
The refresh is chased, not fired once. A short bounded ladder (600/900/1200/1800/2500ms) stops the moment the track is genuinely different. A progress collapse counts as a change too, so repeat-one does not run it to exhaustion, and it bails when playback has stopped. A skip takes the same path, because it loses the same race.
Two bugs found in the chase itself while measuring on hardware, both worth calling out because they were invisible in isolation:
A track that is merely playing is no longer treated as a track that changed. Progress advancing rebuilt the end-of-track timer on every poll and fired a redundant
SONG_CHANGEDbroadcast each time — which also sent the raw payload as a second, worse copy of an updatehandleMusicPayloadhad already broadcast with its thumbnail rewritten to a URL the client can fetch. Only a different track, a play/pause flip, or a real seek counts now.The poll interval is deliberately unchanged
Shortening it is the obvious move and I think it is the wrong one. Once the boundary is scheduled properly the poll only has to catch changes that have no predictable boundary — a pause, a seek, or a skip from another device. Dropping it to 2s would be a ~7× traffic multiplier on
/me/playerto improve only that residual case, and it would not fix the tail: a swallowed poll still costs a full cycle.Tests
18, covering the change detection and the boundary scheduling, including every regression above. The chase tests drive a source app that answers asynchronously, which is the arrangement the original code got wrong — a synchronous mock passes against the broken version.
One test deserves a note: seek detection compares against the cached progress rather than the last polled value. That looks wrong, because an ordinary poll advances progress by far more than the tolerance. It is correct because the progress interval advances the cached value in real time, so it already means "where the track should be by now". A test with a 15s gap between polls pins this down — it fails under the plausible-looking alternative.
Also included
tools/watch-music.js— connects as an ordinary read-only websocket client and reports poll cadence, duplicate sends, and how far into a track the update announcing it arrives. Every number above came from it. It needs no dependencies beyond thewsthe server already ships.Known, not addressed here
While measuring I saw intermittent
403s from/me/playerand persistent403s from/me/tracks/contains.makeRequestre-authenticates and retries on401but not on403. I have deliberately not changed that — a blind retry on403is a good way to earn a rate limit, and the right fix depends on why the token is being refused. Flagging it as a separate issue.🤖 Generated with Claude Code