Report pacing once per stretch, and stop stranding paused users - #76
Merged
Conversation
The 2026-07-27 deploy replaced reactive rate-limit handling with preventive pacing, but left the wait at the top of the per-track loop. The rolling window only ever frees one slot at a time, so once it filled, every single track took that branch: `scrobble_paused`/`burst_limit` went from ~15 a day to 734, and the whole view flipped into the paused panel and back once per track. The waits are sub-second, so `pauseWithCountdown` was also rounding each one up to a full second against a 1s interval tick. Pacing is now a stretch rather than a pause. `beginPacing`/`endPacing` bracket it, report once on entry and once on exit (`scrobble_pacing_ended`, with `paced_tracks`/`paced_wait_ms`/`pacing_duration_ms`), and waits under PACING_COUNTDOWN_THRESHOLD_MS use a plain sleep behind an inline notice instead of taking over the view. Separate scrobble_stopped from scrobble_paused ---------------------------------------------- Every terminal case used to reuse the `scrobble_paused` event name, and two of them (`repeated_rejections`, `repeated_failures`) emitted nothing at all, so "how often does a run end early, and why" was unanswerable. Terminal paths now go through a `trackStopped()` helper emitting `scrobble_stopped`, carrying `auto_saved` to distinguish an interruption from lost work. Give every terminal path a way back in --------------------------------------- The paused panel gated its resume button on `stopped`, which `manualPause`, `repeated_rejections` and `repeated_failures` never set. All three rendered a *disabled* "Wait Here" button waiting on an auto-resume the loop had already returned from — a dead end whose only exit was leaving and re-importing. The button now keys off `canResume` (`stopped || manuallyPaused`); a deliberate pause sets `manuallyPaused` so it reads as `info` rather than a red error. "Pause & Save" also never saved, despite the label, and neither did the repeated-failures stop. Both save now. The save deliberately happens in the loop's between-tracks pause check rather than in `manualPause`: snapshotting on the click would omit the in-flight track's increment and re-send it on resume, which for a re-tagged play means a freshly allocated timestamp and a phantom duplicate scrobble. Don't let the error reporter throw ----------------------------------- `trackError` coerced with `String(error)` and normalised the payload *outside* its try/catch, breaking the one invariant the file documents. Both throw on a Symbol or an object with a throwing toString, and the global handlers pass through whatever a third party threw — losing the report and raising a fresh error out of a catch block or a global handler. Coercion is now guarded in `toError()` and normalisation happens inside the try. Both new behaviours are covered by regression tests, each confirmed to fail without its fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f5423b1e-b316-412a-bade-c28bf90eeb0f
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The 2026-07-27 deploy swapped reactive rate-limit handling for preventive pacing. It worked — real Last.fm throttling all but disappeared — but the wait sits at the top of the per-track loop, and
RateLimitTracker.msUntilWindowHasRoomonly ever frees one slot at a time. So once the rolling window filled, every track took that branch.scrobble_paused/burst_limit, per day:rate_limitburst_limitThose 734 came from 5
scrobble_startedevents. Medianwait_mswas 630ms and medianburst_countwas 500 — pinned exactly atburst_limit. The old code paused 10 minutes and reset the counter, giving one event per ~950 scrobbles; this was roughly one per scrobble.It wasn't only telemetry.
pauseWithCountdownsetspaused = true, which swaps the entire template into the paused panel — so users watched the UI flicker in and out of "Paused" once per track. And because it resolves on a 1s interval tick, every sub-second wait was rounded up to a full second.What changed
Pacing is a stretch, not a pause.
beginPacing/endPacingbracket it and report once on entry and once on exit (scrobble_pacing_ended, carryingpaced_tracks/paced_wait_ms/pacing_duration_ms). Waits underPACING_COUNTDOWN_THRESHOLD_MSuse a plain sleep behind an inline notice.scrobble_stoppedsplit out ofscrobble_paused. Every terminal case previously reused thescrobble_pausedname, andrepeated_rejections/repeated_failuresemitted nothing at all. Now:scrobble_paused— transient, auto-resumes:burst_limit,rate_limit,network_errorscrobble_stopped— terminal:daily_limit,lastfm_daily_limit,rate_limit_exhausted,repeated_rejections,repeated_failures,manualAll terminal paths go through a
trackStopped()helper so a new one can't silently skip the event, and each carriesauto_saved.Terminal paths all offer a way back in. The resume button was gated on
stopped, whichmanualPause,repeated_rejectionsandrepeated_failuresnever set — all three rendered a disabled "Wait Here" button waiting on an auto-resume the loop had already returned from. The only escape was leaving and re-importing. It now keys offcanResume(stopped || manuallyPaused), with a manual pause styledinforather than red."Pause & Save" actually saves. It never called
autoSave(), despite the label; nor did the repeated-failures stop. Note the save happens in the loop's between-tracks pause check, not inmanualPause— snapshotting on the click would omit the in-flight track's increment and re-send it on resume, and for a re-tagged play that means a freshly allocated timestamp and a phantom duplicate scrobble.trackErrorcan no longer throw. It coerced withString(error)and normalised the payload outside its own try/catch, breaking the invariant the file documents. Both throw on a Symbol or an object with a throwingtoString, and the global handlers pass through whatever a third party threw — so it lost the report and raised a fresh error out of a catch block. Coercion is now guarded intoError().Testing
npm run lint:check— 0 errors (86 pre-existing warnings)npm run build— cleanpreventive pacing keeps scrobbling, it does not pause per track— samples repeatedly for the paused panel, since the bug was a per-track flicker a single check could land betweena manual pause saves, offers a way back, and does not re-send the in-flight track— asserts the enabled resume button, absence of "Wait Here", the save confirmation, that the loop halts, and that the resumed run scrobbles each track exactly onceScrobble Step,Rate limit handling,Session Resume,No JS Errors,Import robustness. The one failure,gives up and saves instead of retrying a rate limit forever, is a pre-existing local-Windows flake — confirmed by stashingsrc/and reproducing at HEAD; it passes in CI.Heads-up
scrobble_stoppedis a breaking change for any saved PostHog insight that filtersscrobble_pausedon a terminal reason — those reasons now live on the new event.Also note
burst_limitcounts between 2026-07-27 and 2026-07-29 are inflated by this bug and aren't comparable with anything after it.AGENTS.mdrecords that.