From 34d2fd94bdea958d525911674d925cd5adae0dd3 Mon Sep 17 00:00:00 2001 From: Antoine Cellerier Date: Sun, 8 Mar 2026 19:20:23 +0100 Subject: [PATCH 1/7] fix: dealer websocket reconnect leaving spirc hung on stale channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the dealer websocket connection drops and reconnects internally, spirc's tokio::select! loop remains blocked on subscription streams (connection_id_update, connect_state_update, etc.) that will never receive new messages. The mpsc senders in the SubscriberMap are not cleaned up on reconnect, so spirc hangs indefinitely — requiring a manual process restart. A second failure mode occurs when the dealer cannot reconnect because get_url() (which resolves the dealer endpoint and fetches an auth token via the session) hangs forever on a dead session TCP connection, with no timeout. Root cause analysis ------------------- The dealer's run() loop (core/src/dealer/mod.rs) coordinates reconnecting: when the websocket drops, it calls get_url() to resolve a new dealer endpoint, then connect(). However: 1. The subscription channels (mpsc::UnboundedSender) stored in DealerShared::message_handlers survive reconnects. Spirc's .next() calls on the receiver side never return None because the senders are still alive in the map — they just never send again. 2. get_url() calls session.apresolver().resolve("dealer") and session.login5().auth_token(), both of which need the session's TCP connection. When that connection is dead ("Connection to server closed"), these calls hang forever with no timeout. Before fix — log evidence of hangs requiring manual restart ----------------------------------------------------------- Feb 17 01:12 — "Websocket peer does not respond." [63.5 hour gap — process completely unresponsive] Feb 19 16:44 — Manual restart: "librespot 0.8.0 ..." Feb 23 08:41 — "Websocket peer does not respond." [32.2 hour gap — process completely unresponsive] Feb 24 16:51 — Manual restart: "librespot 0.8.0 ..." Dec 15 20:53-21:07 — Rapid reconnect storm: 12 "peer does not respond" in 50 minutes, with "starting dealer failed: Websocket couldn't be started because: Handshake not finished" errors. Feb 22 — Session TCP died at 05:55, spirc didn't notice for 7+ hours (no dealer reconnect signal), finally shut down at 22:11. Fix --- Add a watch::Sender generation counter shared between the dealer and its consumers. The dealer increments it when: - It successfully reconnects after a connection loss - get_url() times out (30s RECONNECT_URL_TIMEOUT) - get_url() returns an error Spirc subscribes to a watch::Receiver before dealer.start() to avoid a lost-wakeup race (watch retains state, unlike Notify which loses notifications if no one is awaiting). In its select! loop, spirc watches for changes and breaks out, triggering the existing "Spirc shut down unexpectedly" -> auto-reconnect path in main.rs. The get_url() error handling also fixes a pre-existing issue where get_url() failures would propagate via ? and terminate the dealer background task entirely, rather than retrying. Changes: - core/src/dealer/mod.rs: Add watch channel plumbing to Dealer, Builder, create_dealer! macro, and run(). Add 30s timeout on get_url(). Handle get_url() errors with retry+signal instead of fatal ? propagation. Signal consumers on reconnect. - core/src/dealer/manager.rs: Store watch::Sender in DealerManagerInner, pass to Builder::launch(), expose reconnect_receiver() for consumers. - connect/src/spirc.rs: Subscribe to reconnect watch before dealer.start(). Add select! branch to break on dealer reconnect. After fix — 9 days of logs showing automatic recovery ----------------------------------------------------- Websocket failures now recover in 2-7 seconds automatically: Mar 01 15:45 — "Websocket connection failed: Connection reset" Mar 01 15:45 — "Dealer reconnected; notifying consumers." Mar 01 15:45 — "Dealer reconnected; restarting spirc to refresh subscriptions." Mar 01 15:46 — "Spirc shut down unexpectedly" Mar 01 15:46 — "active device is <> with session <...>" [7s recovery] Mar 03 10:21 — "Websocket peer does not respond." Mar 03 10:21 — "Dealer reconnected; notifying consumers." Mar 03 10:21 — "restarting spirc to refresh subscriptions." Mar 03 10:21 — "active device is <> with session <...>" [7s recovery] Mar 06 09:42 — "Websocket peer does not respond." Mar 06 09:42 — "Error while connecting: Network is unreachable" Mar 06 09:43 — [retries for ~1 min while network recovers] Mar 06 09:43 — "Dealer reconnected; notifying consumers." Mar 06 09:43 — "active device is <> with session <...>" [91s recovery] Summary over 9 days post-fix (Feb 28 - Mar 8): - 0 manual restarts needed (vs 2 in 7 days before fix) - 9 dealer reconnect events, all recovered in 2-91 seconds - 14 session TCP closures also recovered (via existing path) - 0 get_url() timeouts fired (websocket errors caught first) - Process running continuously for 9+ days Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + connect/src/spirc.rs | 9 +++++ core/src/dealer/manager.rs | 11 ++++-- core/src/dealer/mod.rs | 72 +++++++++++++++++++++++++++++++++----- 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c418b6c..8798cead3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [main] Fixed `--volume-ctrl fixed` not disabling volume control - [core] Fix default permissions on credentials file and warn user if file is world readable - [core] Try all resolved addresses for the dealer connection instead of failing after the first one. +- [core] Fix dealer websocket reconnect leaving spirc hung on stale subscription channels. ## [0.8.0] - 2025-11-10 diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index fec6057c4..2a5c1f85c 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -458,6 +458,9 @@ impl SpircTask { }; } + // Subscribe before start() so we can't miss a reconnect notification. + let mut reconnect_rx = self.session.dealer().reconnect_receiver(); + if let Err(why) = self.session.dealer().start().await { error!("starting dealer failed: {why}"); return; @@ -585,6 +588,12 @@ impl SpircTask { } } }, + // dealer reconnected after a connection loss — our subscription + // streams are stale, so break out and let main.rs re-create spirc + Ok(()) = reconnect_rx.changed() => { + warn!("Dealer reconnected; restarting spirc to refresh subscriptions."); + break; + }, else => break } } diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs index 98ea0265f..06b5ba54b 100644 --- a/core/src/dealer/manager.rs +++ b/core/src/dealer/manager.rs @@ -2,7 +2,7 @@ use futures_core::Stream; use futures_util::StreamExt; use std::{pin::Pin, str::FromStr, sync::OnceLock}; use thiserror::Error; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, watch}; use tokio_stream::wrappers::UnboundedReceiverStream; use url::Url; @@ -16,6 +16,7 @@ component! { DealerManager: DealerManagerInner { builder: OnceLock = OnceLock::from(Builder::new()), dealer: OnceLock = OnceLock::new(), + reconnect_tx: watch::Sender = watch::Sender::new(0), } } @@ -153,10 +154,12 @@ impl DealerManager { // and the token is expired we will just get 401 error let get_url = move || Self::get_url(session.clone()); + let reconnect_tx = self.lock(|inner| inner.reconnect_tx.clone()); + let dealer = self .lock(move |inner| inner.builder.take()) .ok_or(DealerError::BuilderNotAvailable)? - .launch(get_url, None) + .launch(get_url, None, reconnect_tx) .await .map_err(DealerError::LaunchFailure)?; @@ -171,4 +174,8 @@ impl DealerManager { dealer.close().await } } + + pub fn reconnect_receiver(&self) -> watch::Receiver { + self.lock(|inner| inner.reconnect_tx.subscribe()) + } } diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 63ee6e72c..5f28bebb2 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -21,6 +21,7 @@ use tokio::{ sync::{ Semaphore, mpsc::{self, UnboundedReceiver}, + watch, }, task::JoinHandle, }; @@ -55,6 +56,7 @@ const PING_INTERVAL: Duration = Duration::from_secs(30); const PING_TIMEOUT: Duration = Duration::from_secs(3); const RECONNECT_INTERVAL: Duration = Duration::from_secs(10); +const RECONNECT_URL_TIMEOUT: Duration = Duration::from_secs(30); const DEALER_REQUEST_HANDLERS_POISON_MSG: &str = "dealer request handlers mutex should not be poisoned"; @@ -261,7 +263,7 @@ struct Builder { } macro_rules! create_dealer { - ($builder:expr, $shared:ident -> $body:expr) => { + ($builder:expr, $reconnect_tx:expr, $shared:ident -> $body:expr) => { match $builder { builder => { let shared = Arc::new(DealerShared { @@ -270,6 +272,8 @@ macro_rules! create_dealer { notify_drop: Semaphore::new(0), }); + let reconnect_tx: watch::Sender = $reconnect_tx; + let handle = { let $shared = Arc::clone(&shared); tokio::spawn($body) @@ -278,6 +282,7 @@ macro_rules! create_dealer { Dealer { shared, handle: TimeoutOnDrop::new(handle, WEBSOCKET_CLOSE_TIMEOUT), + reconnect_tx, } } } @@ -301,26 +306,38 @@ impl Builder { handles(&self.request_handlers, &self.message_handlers, uri) } - pub fn launch_in_background(self, get_url: F, proxy: Option) -> Dealer + pub fn launch_in_background( + self, + get_url: F, + proxy: Option, + reconnect_tx: watch::Sender, + ) -> Dealer where Fut: Future + Send + 'static, F: (Fn() -> Fut) + Send + 'static, { - create_dealer!(self, shared -> run(shared, None, get_url, proxy)) + let tx = reconnect_tx.clone(); + create_dealer!(self, reconnect_tx, shared -> run(shared, None, get_url, proxy, tx)) } - pub async fn launch(self, get_url: F, proxy: Option) -> WsResult + pub async fn launch( + self, + get_url: F, + proxy: Option, + reconnect_tx: watch::Sender, + ) -> WsResult where Fut: Future + Send + 'static, F: (Fn() -> Fut) + Send + 'static, { - let dealer = create_dealer!(self, shared -> { + let tx = reconnect_tx.clone(); + let dealer = create_dealer!(self, reconnect_tx, shared -> { // Try to connect. let url = get_url().await?; let tasks = connect(&url, proxy.as_ref(), &shared).await?; // If a connection is established, continue in a background task. - run(shared, Some(tasks), get_url, proxy) + run(shared, Some(tasks), get_url, proxy, tx) }); Ok(dealer) @@ -426,6 +443,7 @@ impl DealerShared { struct Dealer { shared: Arc, handle: TimeoutOnDrop>, + reconnect_tx: watch::Sender, } impl Dealer { @@ -482,6 +500,10 @@ impl Dealer { ) } + pub fn reconnect_receiver(&self) -> watch::Receiver { + self.reconnect_tx.subscribe() + } + pub async fn close(mut self) { debug!("closing dealer"); @@ -665,6 +687,7 @@ async fn run( initial_tasks: Option<(JoinHandle<()>, JoinHandle<()>)>, mut get_url: F, proxy: Option, + reconnect_tx: watch::Sender, ) -> Result<(), Error> where Fut: Future + Send + 'static, @@ -672,12 +695,16 @@ where { let init_task = |t| Some(TimeoutOnDrop::new(t, WEBSOCKET_CLOSE_TIMEOUT)); + let has_had_initial_connection = initial_tasks.is_some(); + let mut tasks = if let Some((s, r)) = initial_tasks { (init_task(s), init_task(r)) } else { (None, None) }; + let mut has_connected = has_had_initial_connection; + while !shared.is_closed() { match &mut tasks { (Some(t0), Some(t1)) => { @@ -702,11 +729,38 @@ where () = shared.closed() => { break }, - e = get_url() => e - }?; + result = tokio::time::timeout(RECONNECT_URL_TIMEOUT, get_url()) => { + match result { + Ok(Ok(url)) => url, + Ok(Err(e)) => { + error!("Failed to resolve dealer URL: {e}"); + if has_connected { + reconnect_tx.send_modify(|n| *n += 1); + } + tokio::time::sleep(RECONNECT_INTERVAL).await; + continue; + } + Err(_) => { + error!("Timed out resolving dealer URL."); + if has_connected { + reconnect_tx.send_modify(|n| *n += 1); + } + tokio::time::sleep(RECONNECT_INTERVAL).await; + continue; + } + } + } + }; match connect(&url, proxy.as_ref(), &shared).await { - Ok((s, r)) => tasks = (init_task(s), init_task(r)), + Ok((s, r)) => { + tasks = (init_task(s), init_task(r)); + if has_connected { + warn!("Dealer reconnected; notifying consumers."); + reconnect_tx.send_modify(|n| *n += 1); + } + has_connected = true; + } Err(e) => { error!("Error while connecting: {e}"); tokio::time::sleep(RECONNECT_INTERVAL).await; From 812c97252927bbfe595eca060c405852af4dce01 Mon Sep 17 00:00:00 2001 From: Antoine Cellerier Date: Fri, 13 Mar 2026 23:16:47 +0100 Subject: [PATCH 2/7] fix: handle dealer reconnect in-place without restarting spirc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptoms observed in logs: WARN librespot_core::dealer Websocket peer does not respond. WARN librespot_connect::spirc unexpected shutdown WARN librespot Spirc shut down unexpectedly When the dealer websocket drops (peer timeout or TLS close_notify), SpircTask broke out of its event loop so main.rs could tear down and recreate the entire Spirc. This caused playback to stop on every transient websocket drop — even though the dealer already auto-reconnects the websocket. The subscription streams survive reconnects because they are registered on the shared DealerShared instance. After reconnect, the server pushes a new connection_id which handle_connection_id_update already handles. Changes: reconnect_rx.changed() logs and continues instead of breaking. handle_connection_id_update errors are non-fatal (logged, not breaking). After fix — 6 days of logs (Mar 14-20) showing ~20 dealer reconnects handled in-place without restarting spirc or stopping playback: Mar 16 05:06 — "Dealer reconnected; awaiting new connection_id." Mar 16 05:06 — "re-registering with active playback state: Paused { ... }" [no restart, no "Spirc shut down unexpectedly"] Mar 18 12:14 — "Dealer reconnected; awaiting new connection_id." Mar 18 12:14 — "re-registering with active playback state: Playing { ... }" [playback continued uninterrupted] Mar 19 — 9 dealer reconnects in one day, all handled in-place Summary: 0 spirc restarts from dealer reconnects (vs ~1/day before fix). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- connect/src/spirc.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 2a5c1f85c..3cdf790b2 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -480,7 +480,6 @@ impl SpircTask { connection_id_update, match |connection_id| if let Err(why) = self.handle_connection_id_update(connection_id).await { error!("failed handling connection id update: {why}"); - break; } }, // main dealer update of any remote device updates @@ -588,11 +587,13 @@ impl SpircTask { } } }, - // dealer reconnected after a connection loss — our subscription - // streams are stale, so break out and let main.rs re-create spirc + // Dealer reconnected after a connection loss. Our subscription + // streams survive because they're registered on the shared + // DealerShared — the new websocket dispatches through the same + // handlers. A new connection_id will arrive via + // connection_id_update and re-register our device state. Ok(()) = reconnect_rx.changed() => { - warn!("Dealer reconnected; restarting spirc to refresh subscriptions."); - break; + info!("Dealer reconnected; awaiting new connection_id."); }, else => break } From 18eb5be2728a407be9fd8570442ef88a1ef9f274 Mon Sep 17 00:00:00 2001 From: Antoine Cellerier Date: Fri, 13 Mar 2026 23:18:14 +0100 Subject: [PATCH 3/7] fix: skip server cleanup on session loss to keep playback alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptoms observed in logs — the TCP session dies, then cleanup fails: ERROR librespot_core::session Connection to server closed. WARN librespot_connect::spirc unexpected shutdown ERROR librespot_core::session Broken pipe (os error 32) ERROR librespot_core::session Transport endpoint is not connected (os error 107) WARN librespot Spirc shut down unexpectedly When SpircTask exits because session.is_invalid(), the post-loop cleanup called handle_disconnect() (which sets play_status to Stopped and tries to notify Spotify), delete_connect_state_request(), and dealer().close(). All of these fail because the TCP connection is dead, and setting play_status to Stopped needlessly kills the Player. Now we detect session.is_invalid() and skip all server communication in the post-loop cleanup. The Player runs in a separate thread and continues playing from its audio buffer. main.rs will create a new session and Spirc. After fix — the "Broken pipe" and "Transport endpoint is not connected" errors no longer appear after session loss. The Player continues playing while the session reconnects (see next commit for state restoration evidence). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- connect/src/spirc.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 3cdf790b2..9ef513584 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -599,6 +599,14 @@ impl SpircTask { } } + if self.session.is_invalid() { + // Session TCP connection died — skip server communication that + // would fail anyway. The Player continues playing from its + // buffer independently; main.rs will create a new session. + warn!("session lost, skipping server cleanup"); + return; + } + if !self.shutdown && self.connect_state.is_active() { warn!("unexpected shutdown"); if let Err(why) = self.handle_disconnect().await { From f69778db7f5ea6f00bcc09d093ae66e07bb8954e Mon Sep 17 00:00:00 2001 From: Antoine Cellerier Date: Fri, 13 Mar 2026 23:34:30 +0100 Subject: [PATCH 4/7] fix: save and restore playback state across session reconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SpircTask exits due to session loss, it now saves its ConnectState, SpircPlayStatus, and play_request_id into a SavedPlaybackState. main.rs captures this and passes it to Spirc::with_saved_state() when creating the replacement Spirc. The restored SpircTask starts with the saved state. On the first connection_id_update, it updates the playback position to account for elapsed time and re-registers with Spotify showing the correct track and position. The Player is never interrupted. After fix — 6 days of logs (Mar 14-20) showing 5 TCP session losses all recovered with playback state preserved: Mar 18 11:49 — "Connection to server closed." Mar 18 11:52 — "session lost, saving playback state for recovery: Playing { nominal_start_time: 1773834169899, ... }" Mar 18 11:52 — "Spirc shut down with saved playback state, reconnecting" Mar 18 11:52 — "Spirc[1] restoring saved playback state" Mar 18 11:52 — "re-registering with active playback state: Playing { nominal_start_time: 1773834169899, ... }" [3 second recovery, playback never stopped] Mar 19 12:21-12:37 — Two session losses during active playback, both recovered in ~2 seconds with Playing state preserved. Summary over 6 days post-fix (Mar 14-20): - 0 "Spirc shut down unexpectedly" (vs ~2-3/day before fix) - 0 process restarts needed - 5 session TCP losses, all recovered with state preserved - ~20 dealer reconnects, all handled in-place - Process running continuously (Spirc counter reached Spirc[4]) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- connect/src/model.rs | 9 +++++ connect/src/spirc.rs | 73 +++++++++++++++++++++++++++++++++++----- examples/play_connect.rs | 2 +- src/main.rs | 32 ++++++++++++------ 4 files changed, 96 insertions(+), 20 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index 10f25f1bf..a6d892c4d 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -1,5 +1,6 @@ use crate::{ core::dealer::protocol::SkipTo, protocol::context_player_options::ContextPlayerOptionOverrides, + state::ConnectState, }; use std::ops::Deref; @@ -165,3 +166,11 @@ pub(super) enum SpircPlayStatus { preloading_of_next_track_triggered: bool, }, } + +/// Playback state saved across session reconnects so the new SpircTask +/// can resume where the old one left off. +pub struct SavedPlaybackState { + pub(super) connect_state: ConnectState, + pub(super) play_status: SpircPlayStatus, + pub(super) play_request_id: Option, +} diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 9ef513584..db9036412 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -11,7 +11,7 @@ use crate::{ session::UserAttributes, spclient::TransferRequest, }, - model::{LoadRequest, PlayingTrack, SpircPlayStatus}, + model::{LoadRequest, PlayingTrack, SavedPlaybackState, SpircPlayStatus}, playback::{ mixer::Mixer, player::{Player, PlayerEvent, PlayerEventChannel, QueueTrack}, @@ -37,6 +37,7 @@ use librespot_protocol::context_page::ContextPage; use protobuf::MessageField; use std::{ future::Future, + mem, sync::Arc, sync::atomic::{AtomicUsize, Ordering}, time::{Duration, SystemTime, UNIX_EPOCH}, @@ -163,7 +164,23 @@ impl Spirc { credentials: Credentials, player: Arc, mixer: Arc, - ) -> Result<(Spirc, impl Future), Error> { + ) -> Result<(Spirc, impl Future>), Error> { + Self::with_saved_state(config, session, credentials, player, mixer, None).await + } + + /// Like [`Spirc::new`], but restores playback state from a previous session. + /// + /// When `saved_state` is provided, the new SpircTask picks up where the + /// old one left off — same track, position, and connect state — so the + /// Player can continue without interruption after a session reconnect. + pub async fn with_saved_state( + config: ConnectConfig, + session: Session, + credentials: Credentials, + player: Arc, + mixer: Arc, + saved_state: Option, + ) -> Result<(Spirc, impl Future>), Error> { fn extract_connection_id(msg: Message) -> Result { let connection_id = msg .headers @@ -176,7 +193,21 @@ impl Spirc { debug!("new Spirc[{spirc_id}]"); let emit_set_queue_events = config.emit_set_queue_events; - let connect_state = ConnectState::new(config, &session); + + let (connect_state, play_status, play_request_id) = match saved_state { + Some(saved) => { + info!("Spirc[{spirc_id}] restoring saved playback state"); + let mut cs = saved.connect_state; + // Update to the new session's ID so Spotify sees us as the same device. + cs.set_session_id(session.session_id()); + (cs, saved.play_status, saved.play_request_id) + } + None => ( + ConnectState::new(config, &session), + SpircPlayStatus::Stopped, + None, + ), + }; let connection_id_update = session .dealer() @@ -235,8 +266,8 @@ impl Spirc { connect_state, connect_established: false, - play_request_id: None, - play_status: SpircPlayStatus::Stopped, + play_request_id, + play_status, connection_id_update, connect_state_update, @@ -438,7 +469,7 @@ impl Spirc { } impl SpircTask { - async fn run(mut self) { + async fn run(mut self) -> Option { // simplify unwrapping of received item or parsed result macro_rules! unwrap { ( $next:expr, |$some:ident| $use_some:expr ) => { @@ -463,7 +494,7 @@ impl SpircTask { if let Err(why) = self.session.dealer().start().await { error!("starting dealer failed: {why}"); - return; + return None; } while !self.session.is_invalid() && !self.shutdown { @@ -603,8 +634,15 @@ impl SpircTask { // Session TCP connection died — skip server communication that // would fail anyway. The Player continues playing from its // buffer independently; main.rs will create a new session. - warn!("session lost, skipping server cleanup"); - return; + warn!( + "session lost, saving playback state for recovery: {:?}", + self.play_status + ); + return Some(SavedPlaybackState { + connect_state: mem::take(&mut self.connect_state), + play_status: mem::replace(&mut self.play_status, SpircPlayStatus::Stopped), + play_request_id: self.play_request_id.take(), + }); } if !self.shutdown && self.connect_state.is_active() { @@ -620,6 +658,7 @@ impl SpircTask { }; self.session.dealer().close().await; + None } fn handle_next_context(&mut self, next_context: Result) -> bool { @@ -907,6 +946,22 @@ impl SpircTask { trace!("Received connection ID update: {connection_id:?}"); self.session.set_connection_id(&connection_id); + // If we have active playback (e.g. restored from saved state), + // update the position before registering so Spotify sees the + // correct track position. + if !matches!(self.play_status, SpircPlayStatus::Stopped) { + info!( + "re-registering with active playback state: {:?}", + self.play_status + ); + self.connect_state.set_status(&self.play_status); + if self.connect_state.is_playing() { + self.connect_state + .update_position_in_relation(self.now_ms()); + } + self.connect_state.set_now(self.now_ms() as u64); + } + let cluster = match self .connect_state .notify_new_device_appeared(&self.session) diff --git a/examples/play_connect.rs b/examples/play_connect.rs index 1be6345ba..044865cdb 100644 --- a/examples/play_connect.rs +++ b/examples/play_connect.rs @@ -71,7 +71,7 @@ async fn main() -> Result<(), Error> { spirc.play()?; // starting the connect device and processing the previously "queued" calls - spirc_task.await; + let _ = spirc_task.await; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 16ba1946f..aa917d7d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1899,6 +1899,7 @@ async fn main() { let mut discovery = None; let mut connecting = false; let mut _event_handler: Option = None; + let mut saved_playback_state = None; let mut session = Session::new(setup.session_config.clone(), setup.cache.clone()); @@ -2049,11 +2050,14 @@ async fn main() { let connect_config = setup.connect_config.clone(); - let (spirc_, spirc_task_) = match Spirc::new(connect_config, - session.clone(), - last_credentials.clone().unwrap_or_default(), - player.clone(), - mixer.clone()).await { + let (spirc_, spirc_task_) = match Spirc::with_saved_state( + connect_config, + session.clone(), + last_credentials.clone().unwrap_or_default(), + player.clone(), + mixer.clone(), + saved_playback_state.take(), + ).await { Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_), Err(e) => { error!("could not initialize spirc: {e}"); @@ -2065,14 +2069,20 @@ async fn main() { connecting = false; }, - _ = async { - if let Some(task) = spirc_task.as_mut() { - task.await; + saved = async { + match spirc_task.as_mut() { + Some(task) => task.await, + None => None, } }, if spirc_task.is_some() && !connecting => { spirc_task = None; + saved_playback_state = saved; - warn!("Spirc shut down unexpectedly"); + if saved_playback_state.is_some() { + info!("Spirc shut down with saved playback state, reconnecting"); + } else { + warn!("Spirc shut down unexpectedly"); + } let mut reconnect_exceeds_rate_limit = || { auto_connect_times.retain(|&t| t.elapsed() < RECONNECT_RATE_LIMIT_WINDOW); @@ -2112,7 +2122,9 @@ async fn main() { } if let Some(spirc_task) = spirc_task { - shutdown_tasks.spawn(spirc_task); + shutdown_tasks.spawn(async { + spirc_task.await; + }); } } From 2ac494bfe897c2001e859637adf0550a35849f2c Mon Sep 17 00:00:00 2001 From: Antoine Cellerier Date: Mon, 6 Apr 2026 20:38:56 +0200 Subject: [PATCH 5/7] fixup! fix: skip server cleanup on session loss to keep playback alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only save state and skip cleanup on unintentional session loss (not when shutdown was explicitly requested via spirc.shutdown()). Fixes account handover: when Discovery triggers a new account, session.shutdown() races with the Shutdown command — SpircTask would see session.is_invalid() and skip dealer().close(), leaving a stale dealer with closed command channels. --- connect/src/spirc.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index db9036412..2702b6ebb 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -630,10 +630,10 @@ impl SpircTask { } } - if self.session.is_invalid() { - // Session TCP connection died — skip server communication that - // would fail anyway. The Player continues playing from its - // buffer independently; main.rs will create a new session. + if self.session.is_invalid() && !self.shutdown { + // Session TCP connection died unexpectedly — skip server + // communication that would fail anyway. The Player continues + // playing from its buffer; main.rs will create a new session. warn!( "session lost, saving playback state for recovery: {:?}", self.play_status From 48adba09fae9071d3efdb8b1ec64795c41274f7e Mon Sep 17 00:00:00 2001 From: Antoine Cellerier Date: Mon, 6 Apr 2026 20:40:10 +0200 Subject: [PATCH 6/7] fixup! fix: save and restore playback state across session reconnects Clear saved_playback_state on Discovery credential change. Without this, a session loss under Account A saves state, then Account B takes over via Discovery, and when Account A reconnects later, it restores stale state from a different account/session. --- src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.rs b/src/main.rs index aa917d7d3..c3869ae9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2021,6 +2021,10 @@ async fn main() { last_credentials = Some(credentials.clone()); auto_connect_times.clear(); + // New account via Discovery — discard any saved + // playback state from the previous account. + saved_playback_state = None; + if let Some(spirc) = spirc.take() { if let Err(e) = spirc.shutdown() { error!("error sending spirc shutdown message: {e}"); From 303026bba2af4c31e710afefc3aad4a89e38c812 Mon Sep 17 00:00:00 2001 From: Antoine Cellerier Date: Mon, 6 Apr 2026 20:47:57 +0200 Subject: [PATCH 7/7] fixup! fix: handle dealer reconnect in-place without restarting spirc Restore break on initial handle_connection_id_update failure. Only tolerate errors after connect_established is true (re-registration after dealer reconnect). Without this, a failed initial registration leaves the device in connect_established=false where local commands are silently ignored. --- connect/src/spirc.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 2702b6ebb..497ab2eec 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -511,6 +511,13 @@ impl SpircTask { connection_id_update, match |connection_id| if let Err(why) = self.handle_connection_id_update(connection_id).await { error!("failed handling connection id update: {why}"); + if !self.connect_established { + // Initial registration failed — can't process + // commands without it, so restart spirc. + break; + } + // Re-registration after dealer reconnect failed — + // stay alive, next connection_id push will retry. } }, // main dealer update of any remote device updates