Skip to content

fix(spotify): resume a paused Connect room, and guard the stream-gone adopt (#390) - #391

Closed
tobsch wants to merge 2 commits into
sonn-audio:devfrom
tobsch:fix/connect-resume-and-adopt-guard
Closed

tobsch wants to merge 2 commits into
sonn-audio:devfrom
tobsch:fix/connect-resume-and-adopt-guard

Conversation

@tobsch

@tobsch tobsch commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Closes #390. Against current dev (1dfec51), and only the two things from #383 that 661824e did not carry — that commit's own half (adopt when the stream is gone, and teardown forgetting the track) is untouched here.

1. A room the app pauses is never resumed

paused calls pausePlayback unconditionally. Nothing calls resumePlayback — the Soloist input never has, though DLNA and AirPlay both do and the port has always had it.

A pause does not go through finishTrack, which is the only thing that nulls runner.stream. So on a resume the uri has not moved and the stream is alive, which is precisely the case 661824e leaves alone, and the branch returns having done nothing. The room stays paused in core while Soloist plays on: the app shows the bar moving, the position keeps arriving, nothing sounds until a track change happens to take another path.

Measured on 4.0.0-beta.21 carrying 661824e as a dist patch, on the zone that found #383:

transfer -> Wohnzimmer    zone: playing, card RUNNING
pause                     zone: paused  pos=29        <- obeyed
play                      zone: paused  pos=30        <- ignored, card closed
                          soloist: playing [0:30 / 3:33], [0:40 / 3:33] ...

With this commit the same sequence reads paused 42 → playing 49, card back to RUNNING.

Sent on every playing rather than tracked separately: audioManager.resumePlayback returns early on a session that is already playing, so the runner does not need a second copy of the zone's state to know when to send it.

2. The adopt is level-triggered and nothing guards it

!runner.stream stays true for as long as adoption takes, adoption awaits waitForSpec before it assigns the stream, and playing keeps arriving throughout — so one takeover can start several adoptions.

That is not harmless, because takeStream() destroys the stream it replaces:

public takeStream(): Readable {
  this.stream?.destroy();

The second adoption tears down the stream the first has just handed to the zone. device_changed and track_changed are edge-triggered and never had this exposure; the condition added in 661824e does. A flag for the duration is enough, and the body moved to adoptConnectPlaybackInner so that stays a try/finally rather than a flag threaded through every early return.

Tests

Three added to tests/soloistBackend.test.ts, beside the ones from 661824e, and the shared withConnectRunner gained a controller that counts resumes:

  • a room the app paused is started again when the app resumes it;
  • resuming is sent on every playing, because a playing session ignores it;
  • a takeover announced four times still opens one stream — this one drives the real adoptConnectPlayback, since the guard lives inside it, so it has a second small harness.

All 20 in the file pass with both commits, and the three new ones fail on unmodified dev. The first commit alone is green on its own 19, so the two can be taken separately. playing the track that is already sounding is left alone still passes throughout — it asserts nothing is adopted, which remains true.

Typecheck, eslint and dependency-cruiser are clean. npm test as a whole I cannot run to the end here (that is the arm64 segfault from #384, fixed on dev in 3a8bc12 but not in the beta.21 tree I build against), so the file was run through a standalone runner.

tobsch and others added 2 commits September 21, 2026 10:57
… it (sonn-audio#390)

`paused` calls `pausePlayback` unconditionally. Nothing called
`resumePlayback` — the Soloist input never has, though DLNA and AirPlay
both do and the port has always had it.

A pause does not go through `finishTrack`, the only thing that nulls
`runner.stream`. So on a resume the uri has not moved and the stream is
alive, which is exactly the case 661824e leaves alone, and the branch
returns having done nothing. The room stays paused while Soloist plays
on: the app shows the bar moving, the position keeps arriving, and
nothing sounds until a track change happens to take another path.

Sent on every `playing` rather than tracked separately, because
`audioManager.resumePlayback` returns early on a session already
playing — the runner needs no second copy of the zone's state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vo1g71NXgnEdsdjmVhPWTx
…unced (sonn-audio#390)

The `!runner.stream` adopt added in 661824e is level-triggered where the
other two are edge-triggered: it stays true for as long as adoption
takes, adoption awaits `waitForSpec` before it assigns the stream, and
the app keeps saying `playing` throughout. So one takeover can start
several adoptions.

Not harmless, because `takeStream()` destroys the stream it replaces —
the second adoption tears down the one the first has just handed to the
zone. `device_changed` and `track_changed` never had this exposure.

The body moves to `adoptConnectPlaybackInner` so the flag stays a
try/finally rather than something threaded through every early return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vo1g71NXgnEdsdjmVhPWTx
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.

2 participants