Skip to content

spotupnp: do not mistake our own stop acknowledgment for a user stop - #68

Open
007hacky007 wants to merge 1 commit into
philippe44:masterfrom
007hacky007:fix/spotupnp-expect-stop-race
Open

spotupnp: do not mistake our own stop acknowledgment for a user stop#68
007hacky007 wants to merge 1 commit into
philippe44:masterfrom
007hacky007:fix/spotupnp-expect-stop-race

Conversation

@007hacky007

Copy link
Copy Markdown
Contributor

Hit this on my setup: changing track while paused kicked the renderer off my Spotify account - I had to re-select the device and press play. Reproduced at will with pause, wait a few seconds, change track.

What happens

A track change (spirc Load frame) restarts the renderer with Stop, SetURI and Play within ~100 ms. SPOT_STOP arms ExpectStop so the STOPPED that acknowledges our own AVTStop is not mistaken for a user stop - but SPOT_PLAY clears the flag as soon as the play command is sent, not when the renderer has been observed past the stop. When the renderer is slow to execute (typical when it was paused), the next transport-state poll still returns STOPPED after the play:

12:09:15.965  AVTStop            ExpectStop = true
12:09:16.054  AVTPlay            ExpectStop = false (too early)
12:09:16.5xx  poll -> STOPPED    SpotState == SPOT_PLAY, !ExpectStop
12:09:16.594  "Disconnecting"    session freed, device gone from the account

ActionHandler treats the stale STOPPED as user-initiated and the "disconnect on unexpected STOP" logic releases the player. It usually goes unnoticed because the poll tends to land inside the short SpotState == SPOT_STOP window, or the renderer transitions fast enough that STOPPED is never polled.

Fix

  • keep ExpectStop armed until ActionHandler actually observes a transport state: an expected STOPPED is consumed silently, and TRANSITIONING / PLAYING / PAUSED_PLAYBACK observations disarm it
  • those observations only fire on a state change, so a renderer that coalesces states could leave the flag armed and a later genuine stop would be swallowed; the suppression therefore only honors the flag within 5 s of the stop command (ExpectStopTime). Past the window a STOPPED is genuine again - worst case is one absorbed stop right after a track change, instead of either permanent failure
  • SPOT_LOAD now frees any pending NextStreamUrl when it does a full SetTrackURI load; a suppressed expected stop skips the branch that used to free it, and a stale gapped-next URL must not survive into the new track

Notes

With the fix, the same repro shows the late acknowledgment being absorbed (AVTStop .203, AVTPlay .284, STOPPED polled .604 - no disconnect) and playback continues; a genuine renderer stop still frees the session as designed. Independent of the stale-notification PR.

shadowRequest(SPOT_STOP) arms ExpectStop when sending AVTStop, but
SPOT_PLAY cleared it as soon as the follow-up play command was sent.
A track change (spirc Load frame) issues Stop, SetURI and Play within
about 100 ms; when the renderer is slow to execute them (typically when
the change happens while paused), the next transport-state poll still
returns STOPPED after the play was sent. With ExpectStop already
cleared, ActionHandler treats that stale STOPPED as a user-initiated
stop and sends SHADOW_STOP, which aborts the Spotify session
(disconnect on unexpected STOP) - the device drops from the user's
account and they have to reconnect and press play. Depending on
playlistEnd it can instead reset the new track to paused.

Keep ExpectStop armed until ActionHandler actually observes a transport
state: an expected STOPPED is ignored and consumes the flag, and
TRANSITIONING, PLAYING or PAUSED_PLAYBACK observations disarm it.

Because those observations only fire on a state *change*, a renderer
that coalesces states could leave the flag armed indefinitely, which
would swallow a later genuine user stop. The suppression therefore only
honors the flag within 5 seconds of the stop command (ExpectStopTime);
past the window a STOPPED is treated as genuine again, bounding the
worst case instead of trading one permanent failure for another.

A suppressed expected stop also skips the branch that would have freed
a pending gapped-next URL, so a stale NextStreamUrl could survive a
track change and divert the end of the newly loaded track. SPOT_LOAD
now clears NextStreamUrl whenever it performs a full SetTrackURI load,
which makes that state impossible regardless of suppression timing.

Reproduced deterministically against a Hama DIT2010 by changing track
while paused; with this change the late STOPPED ack is consumed
silently and the session survives.
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