fix(daemon): reconnect after the server session drops

A long-running daemon that lost its TCP connection to the server reported
session_loss "disconnected" and answered every command from the dead session
until a manual restart. Two causes:

- the daemon owned one session for its lifetime with no reconnect at all; and
- the lib's ServerActor queued a Login sent while disconnected instead of
  dialing, so even asking it to log in again never re-established a connection.

Reconnect on a dedicated thread with 1s-60s capped backoff, only for a dropped
connection to a session this process owns (never a displaced or borrowed one,
which would start a login war or fight another daemon). Let a Login through the
disconnected guard so a parked actor dials again, and bound that dial with
connect_timeout so a blackholed route cannot park the actor thread. Reset the
message reader, the queued backlog and the login verdict on disconnect so a
partial frame or stale Some(false) cannot wedge the reconnected session.
Announce the drop once per outage and re-join rooms and re-watch users on
recovery; the daemon main loop is back to save-and-stop so `daemon stop` stays
responsive during an outage. Update the session_loss event summary, which no
longer means the session is gone for good.
This commit is contained in:
Michel de Graaf
2026-09-02 11:21:19 +02:00
parent 597f378f17
commit 67d979b809
8 changed files with 436 additions and 63 deletions
+7
View File
@@ -60,6 +60,9 @@ pub trait SessionApi: Send + Sync {
}
fn listen_port(&self) -> Option<u16>;
fn session_loss(&self) -> Option<SessionLoss>;
fn relogin(&self) -> Result<bool> {
Err(soulseek_rs::SoulseekRs::NotConnected)
}
/// Search and wait out the window. Every implementation is
/// `search_with_cancel` with nothing to cancel on.
@@ -191,6 +194,10 @@ impl SessionApi for Client {
Self::session_loss(self)
}
fn relogin(&self) -> Result<bool> {
Self::login(self)
}
fn search_with_cancel(
&self,
query: &str,
+8 -8
View File
@@ -15,7 +15,7 @@ use super::proto::{
SessionLossEvent, UploadInfoDto, UploadStatusDto, UserMessageDto,
};
use crate::api::SessionApi;
use soulseek_rs::DownloadStatus;
use soulseek_rs::{DownloadStatus, SessionLoss};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
@@ -175,9 +175,7 @@ struct DrainState {
/// Last upload state reported per (user, file), so an upload that has not
/// moved is not re-announced every tick.
uploads: HashMap<(String, String), UploadInfoDto>,
/// Whether the session-loss event has already gone out. It is terminal, so
/// it is worth saying exactly once.
reported_loss: bool,
last_loss: Option<SessionLoss>,
}
/// How long to keep asking whether a peer has answered a browse request.
@@ -273,10 +271,12 @@ fn tick(
publish_browses(session, hub, browses);
publish_downloads(hub, downloads);
if !state.reported_loss
&& let Some(loss) = session.session_loss()
{
state.reported_loss = true;
let loss = session.session_loss();
if loss == state.last_loss {
return;
}
state.last_loss = loss;
if let Some(loss) = loss {
hub.publish(
Event::SessionLoss,
serde_json::to_value(SessionLossEvent { loss: loss.into() })
+128 -6
View File
@@ -24,11 +24,12 @@ pub mod server;
use crate::api::SessionApi;
use crate::commands::{Ctx, Session};
use crate::output::{CliError, CliResult};
use crate::output::{CliError, CliResult, Out};
use crate::persist::state::{PersistedDownload, PersistedMessage, StateStore};
use hub::{Hub, PendingBrowses, PendingDownload};
use proto::ChatMessageDto;
use server::Daemon;
use soulseek_rs::SessionLoss;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -44,6 +45,8 @@ pub const TOKEN_NAME: &str = "daemon.token";
/// loses little, rare enough not to churn the disk on a busy queue.
const SAVE_INTERVAL: Duration = Duration::from_secs(5);
const RECONNECT_BACKOFF_CAP: Duration = Duration::from_mins(1);
#[must_use]
pub fn socket_path() -> Option<PathBuf> {
crate::persist::paths::state_dir().map(|dir| dir.join(SOCKET_NAME))
@@ -216,6 +219,17 @@ pub fn run(
})
};
{
let session = Arc::clone(&session.client);
let daemon = Arc::clone(&daemon);
let stop = Arc::clone(&stop);
let out = ctx.out.clone();
let address = ctx.settings.server_address.to_string();
std::thread::spawn(move || {
reconnect_loop(&session, &daemon, &out, &address, &stop);
});
}
for listener in listeners {
let daemon = Arc::clone(&daemon);
std::thread::spawn(move || listener.serve(daemon));
@@ -302,6 +316,89 @@ fn cleanup() {
}
}
const fn should_reconnect(loss: Option<SessionLoss>, owned: bool) -> bool {
owned && matches!(loss, Some(SessionLoss::Disconnected))
}
fn reconnect_loop(
session: &Arc<dyn SessionApi>,
daemon: &Daemon,
out: &Out,
address: &str,
stop: &AtomicBool,
) {
let owned = session.daemon_endpoint().is_none();
let mut failures = 0u32;
let mut announced = false;
while !stop.load(Ordering::Relaxed) {
std::thread::sleep(hub::POLL);
if !should_reconnect(session.session_loss(), owned) {
failures = 0;
announced = false;
continue;
}
if !announced {
out.status(&format!(
"the connection to {address} dropped; logging back in…"
));
announced = true;
}
match session.relogin() {
Ok(true) => {
out.status("logged back in");
rejoin(out, session.as_ref(), daemon);
failures = 0;
announced = false;
}
outcome => {
let wait = backoff(failures);
failures = failures.saturating_add(1);
out.warn(&format!(
"cannot log back in: {}; trying again in {}s",
relogin_error(outcome),
wait.as_secs()
));
wait_or_stop(wait, stop);
}
}
}
}
fn relogin_error(outcome: soulseek_rs::Result<bool>) -> String {
match outcome {
Ok(_) => "login rejected by the server".to_string(),
Err(e) => format!("login failed: {e}"),
}
}
fn backoff(failures: u32) -> Duration {
Duration::from_secs(1 << failures.min(6)).min(RECONNECT_BACKOFF_CAP)
}
fn wait_or_stop(total: Duration, stop: &AtomicBool) {
let deadline = Instant::now() + total;
while Instant::now() < deadline && !stop.load(Ordering::Relaxed) {
std::thread::sleep(hub::POLL);
}
}
fn rejoin(out: &Out, session: &dyn SessionApi, daemon: &Daemon) {
join_rooms(out, session, &daemon.rooms());
for user in session.watched_users() {
if let Err(e) = session.watch_user(&user) {
out.warn(&format!("cannot watch {user} again: {e}"));
}
}
}
fn join_rooms(out: &Out, session: &dyn SessionApi, rooms: &[String]) {
for room in rooms {
if let Err(e) = session.join_room(room) {
out.warn(&format!("cannot rejoin {room}: {e}"));
}
}
}
/// Bring back what the last run left: re-queue unfinished transfers, rejoin
/// rooms, and reload the conversation.
fn restore(
@@ -325,11 +422,7 @@ fn restore(
);
let rooms = store.load_rooms();
for room in &rooms {
if let Err(e) = session.join_room(room) {
ctx.out.warn(&format!("cannot rejoin {room}: {e}"));
}
}
join_rooms(&ctx.out, session, &rooms);
let unfinished: Vec<PersistedDownload> = store
.load_downloads()
@@ -425,6 +518,35 @@ mod tests {
assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn only_an_owned_dropped_session_reconnects() {
assert!(should_reconnect(Some(SessionLoss::Disconnected), true));
assert!(
!should_reconnect(Some(SessionLoss::Displaced), true),
"reconnecting a displaced name starts a login war with whoever \
took it"
);
assert!(
!should_reconnect(None, true),
"a live session is not retried"
);
assert!(
!should_reconnect(Some(SessionLoss::Disconnected), false),
"a borrowed session is the daemon it came from to mend"
);
}
#[test]
fn the_backoff_doubles_and_then_holds_at_the_cap() {
let seconds: Vec<u64> = (0..8).map(|n| backoff(n).as_secs()).collect();
assert_eq!(seconds, [1, 2, 4, 8, 16, 32, 60, 60]);
assert_eq!(
backoff(u32::MAX),
RECONNECT_BACKOFF_CAP,
"a very long outage must not overflow into a tiny wait"
);
}
#[test]
fn two_tokens_are_not_the_same() {
let dir = tempfile::tempdir().expect("a temp dir");
+3 -1
View File
@@ -226,7 +226,9 @@ fn event_summary(event: Event) -> &'static str {
Event::DownloadStatus => "A transfer moved.",
Event::Browse => "A peer answered a browse request.",
Event::SessionLoss => {
"The server session ended; nothing seen after this means anything."
"The server session was lost. A disconnected session is reconnected \
on its own and events resume; a displaced one is terminal. Read \
daemon.status for the live state."
}
}
}