Skip to content

SpircHandler: only advance the queue for the entry that actually started - #2

Open
007hacky007 wants to merge 4 commits into
philippe44:masterfrom
007hacky007:fix/spirc-stale-track-notification
Open

SpircHandler: only advance the queue for the entry that actually started#2
007hacky007 wants to merge 4 commits into
philippe44:masterfrom
007hacky007:fix/spirc-stale-track-notification

Conversation

@007hacky007

Copy link
Copy Markdown

Hit this with spotupnp and two Connect controllers (playback started from one device, tracks changed from the other): after a burst of track changes, every following song was cut short by 60-90 seconds and the Spotify UI showed a track one ahead of what was actually playing. Only a restart recovered it.

What happens

notifyAudioReachedPlayback() advances the queue blindly: it either consumes notifyPending or pops the head, without checking which track the notification is for.

A track change from a second controller arrives as a full Load frame. The handler rebuilds the queue immediately, but the player reload (and the PLAYBACK_START that clears the integration's streamers) happens on the TrackPlayer task a few hundred ms later. A "reached playback" notification from a stream created before the rebuild can land in that window and consume the notifyPending that belongs to the new head. From then on the accounting is off by one for good:

  • every legitimate track start pops the head one track early
  • TrackPlayer's previous-track pointer is never found in preloadedTracks again, so every data-feed EOF goes through trackLoaded() and restarts playback from scratch instead of feeding the next track in the background
  • a renderer that buffers ahead (UPnP devices easily hold 60-90 s) loses everything buffered on each restart, so the user hears every song cut short

There is no resync path.

Fix

  • every QueuedTrack gets a process-unique sequence appended to its identifier (<fileIdHex>#<seq>). A file id alone cannot prove which playback instance started: the same track twice in a row, or PREV re-queueing the current track, are indistinguishable. Side benefit: integrations that detect track boundaries by identifier change now get one between consecutive plays of the same file, which previously merged into one stream.
  • new TrackQueue::notifyTrackReached() validates the identifier against the head (pending case) or the head's successor (advance case) and pops under a single tracksMutex hold, so a Load/Replace rebuilding the queue on the mercury thread cannot interleave between check and pop. Stale or duplicate notifications are logged and ignored.
  • the first two commits close the queue accesses that bypassed tracksMutex (unlocked currentTracksIndex read in runTask(), queueNextTrack() from the queue task, unlocked getTrackInfo() iteration, the nanopb encode callback copying currentTracks mid-rebuild) plus two empty-deque dereferences - without those the transaction would not be reliable.

The parameter is a std::string_view defaulting to empty, which keeps the exact legacy behavior for callers that cannot identify the playing track (flow mode, spotraop, the CLI player) - those compile and behave unchanged.

Notes

Reproduced against a Hama DIT2010: with rapid track changes from a second controller the log shows the stale starts being rejected (stale notification for <id>#10 while expecting <id>#13 => ignored) and playback staying in sync; normal gapless transitions validate the successor and advance exactly once; full tracks play to the end.

This change is safe on its own for all existing callers; a small spotupnp PR follows that passes the started streamer's trackUnique at the UPnP call site.

The track queue is accessed from three threads: the mercury/session
thread (handleFrame -> updateTracks/skipTrack), the TrackPlayer task
(consumeTrack) and the queue task (runTask/processTrack), plus whatever
thread the integration notifies track starts from. tracksMutex is meant
to serialize them, but several paths bypassed it:

- runTask() read currentTracksIndex outside the mutex
- processTrack() called queueNextTrack(), which mutates preloadedTracks,
  from the queue task without holding the mutex
- consumeTrack() indexed preloadedTracks[0] without an empty check, and
  updateTracks() dereferenced preloadedTracks[0] the same way

Bring those reads and mutations under tracksMutex and guard the two
empty-deque dereferences. Groundwork for making notification handling a
single locked queue transaction.
notifyAudioReachedPlayback() advanced the track queue blindly: it
either consumed the notifyPending flag or popped the queue head,
without checking which track the notification was actually for. A
notification from a stream created before the last queue rebuild (it
races the player reload that follows a Load frame, e.g. when another
Connect controller changes tracks) consumed the notifyPending meant for
the new head. From then on every legitimate track start popped the
queue one entry early, TrackPlayer's previous-track pointer was never
found in preloadedTracks again, and every EOF restarted playback from
position 0 via trackLoaded(), cutting off everything still buffered in
the renderer. There is no resync path, so playback stayed corrupted
(every song cut short, UI one track ahead of the audio) until the
process was restarted.

Two ingredients make the validation sound:

- Queue entries get an identity, not just a file id: a process-unique
  sequence number is appended to the identifier at construction. A file
  id alone cannot prove which playback instance started (same track
  queued twice in a row, PREV restarting the current track as a new
  object), and as a bonus integrations that detect track boundaries by
  identifier change now see one for consecutive plays of the same file.

- Validation and advancement are one atomic queue transaction:
  TrackQueue::notifyTrackReached() checks the identifier against the
  head (pending-notify case) or the head's successor (advance case) and
  performs the pop under a single tracksMutex hold, so a Load/Replace
  frame rebuilding the queue from the mercury thread cannot interleave
  between check and pop.

Callers that cannot identify the playing track (flow mode, spotraop,
the CLI player) pass an empty identifier and keep the legacy behavior;
the parameter is a std::string_view with an empty default, so untouched
call sites neither change behavior nor allocate.
Two more paths read queue state without tracksMutex:

- getTrackInfo() iterated preloadedTracks while the mercury thread can
  clear and repopulate the deque in updateTracks(); concurrent
  iteration and mutation of the deque is undefined behavior. The
  per-entry identifiers make this path hotter, since consecutive plays
  of the same file now trigger the integrations' track handler.

- the nanopb encode callback registered for innerFrame copied
  currentTracks whenever a frame was encoded (any notify(), from any
  thread) while updateTracks() can assign a new vector concurrently.
  The callback argument now carries the list together with its guard
  and the encoder holds it for the duration of the memory-only encode.

Frame sends happen strictly after queue calls return, never under
tracksMutex, so no lock-order cycle is introduced.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant