From 886f1b70928000c256c4d7bd66fea4a9d8f21203 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:19:40 +0000 Subject: [PATCH 01/19] feat(protocol): interests, tickers, global room and cant-connect Adds the server messages the client had no way to speak: user and room search (42/120), room tickers (113-116), the global room feed (150-152), interests and the recommendation queries built on them (51/52/54/56/57/ 110/111/112/117/118), a keepalive ping (32), gifted privileges (123), a password change (142) and multi-user private messages (149). CantConnectToPeer (1001) closes a real gap in both directions: a dial the server asked us to make now answers the waiting peer instead of falling back to brokering the same unreachable link the other way, and an incoming one ends the wait on downloads queued for that peer rather than letting them sit until a timeout. --- soulseek-rs-lib/src/actor/peer_actor.rs | 14 +- soulseek-rs-lib/src/actor/peer_actor/tests.rs | 2 +- soulseek-rs-lib/src/actor/peer_registry.rs | 2 +- soulseek-rs-lib/src/actor/server_actor.rs | 157 +++++++- soulseek-rs-lib/src/client/mod.rs | 177 ++++++++- soulseek-rs-lib/src/client/operations.rs | 118 +++++- soulseek-rs-lib/src/client/search.rs | 49 +++ soulseek-rs-lib/src/client/social.rs | 264 +++++++++++++ soulseek-rs-lib/src/client/tests.rs | 113 ++++++ soulseek-rs-lib/src/lib.rs | 14 +- .../message/server/cant_connect_to_peer.rs | 49 +++ .../src/message/server/global_room.rs | 58 +++ .../src/message/server/interests.rs | 346 ++++++++++++++++++ .../src/message/server/message_factory.rs | 295 +++++++++++++++ soulseek-rs-lib/src/message/server/mod.rs | 14 + .../src/message/server/room_tickers.rs | 174 +++++++++ soulseek-rs-lib/src/peer/mod.rs | 6 + soulseek-rs-lib/src/types.rs | 52 +++ 18 files changed, 1886 insertions(+), 18 deletions(-) create mode 100644 soulseek-rs-lib/src/client/social.rs create mode 100644 soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs create mode 100644 soulseek-rs-lib/src/message/server/global_room.rs create mode 100644 soulseek-rs-lib/src/message/server/interests.rs create mode 100644 soulseek-rs-lib/src/message/server/room_tickers.rs diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index bf63e3a..74b9e21 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -729,8 +729,20 @@ impl PeerActor { // unreachable (likely firewalled): signal a connect failure so the // client can fall back to server-brokered connect. Anything else is a // normal disconnect. + // A dial the server asked for carries the token the far peer quoted; + // it is what a CantConnectToPeer must echo back, so the peer stops + // waiting for a connection that is never coming. + let brokered_token = self + .peer + .read_safe() + .ok() + .and_then(|p| if p.brokered { p.token } else { None }); let op = if self.outbound && !self.established { - ClientOperation::PeerConnectFailed(self.id, username) + ClientOperation::PeerConnectFailed( + self.id, + username, + brokered_token, + ) } else { ClientOperation::PeerDisconnected( self.id, diff --git a/soulseek-rs-lib/src/actor/peer_actor/tests.rs b/soulseek-rs-lib/src/actor/peer_actor/tests.rs index de8d7c8..c7d8325 100644 --- a/soulseek-rs-lib/src/actor/peer_actor/tests.rs +++ b/soulseek-rs-lib/src/actor/peer_actor/tests.rs @@ -50,7 +50,7 @@ fn a_timed_out_connect_parks_the_actor_in_disconnected() { "a timed-out connect must leave Connecting" ); match rx.try_recv() { - Ok(ClientOperation::PeerConnectFailed(7, username)) => { + Ok(ClientOperation::PeerConnectFailed(7, username, _)) => { assert_eq!(username, "bob"); } other => panic!("expected PeerConnectFailed, got {other:?}"), diff --git a/soulseek-rs-lib/src/actor/peer_registry.rs b/soulseek-rs-lib/src/actor/peer_registry.rs index f4b7007..02d1ce0 100644 --- a/soulseek-rs-lib/src/actor/peer_registry.rs +++ b/soulseek-rs-lib/src/actor/peer_registry.rs @@ -427,7 +427,7 @@ mod tests { // Play the client ops loop: take the terminal outcome, evict by id. match rx.recv_timeout(std::time::Duration::from_secs(10)) { - Ok(ClientOperation::PeerConnectFailed(id, username)) => { + Ok(ClientOperation::PeerConnectFailed(id, username, _)) => { assert_eq!(username, "ghost"); if let Some(handle) = registry.remove_peer_if(&username, id) { let _ = handle.stop(); diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index ecab497..6fb2636 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -2,12 +2,14 @@ use crate::actor::{Actor, ActorHandle, ConnectionState}; use crate::client::ClientOperation; use crate::dispatcher::MessageDispatcher; use crate::message::server::AdminMessageHandler; +use crate::message::server::CantConnectToPeerHandler; use crate::message::server::CheckPrivilegesHandler; use crate::message::server::ConnectToPeerHandler; use crate::message::server::EmbeddedMessageHandler; use crate::message::server::ExcludedSearchPhrasesHandler; use crate::message::server::FileSearchHandler; use crate::message::server::GetPeerAddressHandler; +use crate::message::server::GlobalRoomMessageHandler; use crate::message::server::JoinRoomHandler; use crate::message::server::LeaveRoomHandler; use crate::message::server::LoginHandler; @@ -27,13 +29,21 @@ use crate::message::server::WishListIntervalHandler; use crate::message::server::{ GetUserStatsHandler, GetUserStatusHandler, RoomListHandler, }; +use crate::message::server::{ + GlobalRecommendationsHandler, ItemRecommendationsHandler, + ItemSimilarUsersHandler, RecommendationsHandler, SimilarUsersHandler, + UserInterestsHandler, +}; +use crate::message::server::{ + RoomTickerAddedHandler, RoomTickerRemovedHandler, RoomTickersHandler, +}; use crate::message::{Handlers, MessageType}; use crate::message::{Message, MessageReader}; use crate::peer::ConnectionType; use crate::peer::Peer; use crate::types::{ - ClientVersion, RoomEvent, RoomInfo, RoomUserStats, SessionLoss, - SessionWatch, + ClientVersion, Recommendation, RoomEvent, RoomInfo, RoomUserStats, + SessionLoss, SessionWatch, SimilarUser, UserInterests, }; use crate::utils::lock::RwLockExt; @@ -164,6 +174,59 @@ pub enum ServerMessage { PossibleParents(Vec<(String, String, u16)>), /// The server asks us to drop our parent and start over. ResetDistributed, + /// The ticker board of a room we just joined (code 113). + RoomTickers { + room: String, + tickers: Vec, + }, + /// One member set their ticker (code 114). + RoomTickerAdded { + room: String, + username: String, + ticker: String, + }, + /// One member cleared their ticker (code 115). + RoomTickerRemoved { + room: String, + username: String, + }, + /// A message from the global room feed (code 152). + GlobalRoomMessageReceived { + room: String, + username: String, + message: String, + }, + /// Recommendations from our own interests (code 54). + RecommendationsReceived { + recommended: Vec, + unrecommended: Vec, + }, + /// Server-wide recommendations (code 56). + GlobalRecommendationsReceived { + recommended: Vec, + unrecommended: Vec, + }, + /// Recommendations for one item (code 111). + ItemRecommendationsReceived { + item: String, + recommendations: Vec, + }, + /// Users similar to us (code 110). + SimilarUsersReceived { + users: Vec, + }, + /// Users who like one item (code 112). + ItemSimilarUsersReceived { + item: String, + usernames: Vec, + }, + /// What another user likes and hates (code 57). + UserInterestsReceived(UserInterests), + /// A peer could not connect to us after we asked the server to broker + /// (code 1001); the token is the one we quoted. + CantConnectToPeer { + token: u32, + }, } pub struct ServerActor { @@ -320,6 +383,17 @@ impl ServerActor { handlers.register_handler(FileSearchHandler); handlers.register_handler(GetPeerAddressHandler); handlers.register_handler(ConnectToPeerHandler); + handlers.register_handler(CantConnectToPeerHandler); + handlers.register_handler(RoomTickersHandler); + handlers.register_handler(RoomTickerAddedHandler); + handlers.register_handler(RoomTickerRemovedHandler); + handlers.register_handler(GlobalRoomMessageHandler); + handlers.register_handler(RecommendationsHandler); + handlers.register_handler(GlobalRecommendationsHandler); + handlers.register_handler(ItemRecommendationsHandler); + handlers.register_handler(SimilarUsersHandler); + handlers.register_handler(ItemSimilarUsersHandler); + handlers.register_handler(UserInterestsHandler); self.dispatcher = Some(MessageDispatcher::new( "server".into(), @@ -501,6 +575,37 @@ impl ServerActor { ServerMessage::RoomUserLeft { room, username } => { self.forward_room_event(RoomEvent::UserLeft { room, username }); } + ServerMessage::RoomTickers { room, tickers } => { + self.forward_room_event(RoomEvent::Tickers { room, tickers }); + } + ServerMessage::RoomTickerAdded { + room, + username, + ticker, + } => { + self.forward_room_event(RoomEvent::TickerAdded { + room, + username, + ticker, + }); + } + ServerMessage::RoomTickerRemoved { room, username } => { + self.forward_room_event(RoomEvent::TickerRemoved { + room, + username, + }); + } + ServerMessage::GlobalRoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::GlobalMessage { + room, + username, + message, + }); + } other => self.handle_standing_message(other), } } @@ -539,6 +644,54 @@ impl ServerActor { ServerMessage::CheckPrivileges => { self.queue_message(MessageFactory::build_check_privileges()); } + ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: false, + recommended, + unrecommended, + }); + } + ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: true, + recommended, + unrecommended, + }); + } + ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + } => { + self.forward_to_client(ClientOperation::ItemRecommendations { + item, + recommendations, + }); + } + ServerMessage::SimilarUsersReceived { users } => { + self.forward_to_client(ClientOperation::SimilarUsers(users)); + } + ServerMessage::ItemSimilarUsersReceived { item, usernames } => { + self.forward_to_client(ClientOperation::ItemSimilarUsers { + item, + usernames, + }); + } + ServerMessage::UserInterestsReceived(interests) => { + self.forward_to_client(ClientOperation::UserInterests( + interests, + )); + } + ServerMessage::CantConnectToPeer { token } => { + self.forward_to_client(ClientOperation::CantConnectToPeer { + token, + }); + } other => { error!("[server] unroutable message: {:?}", other); } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 5dc3a34..75dd60c 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -4,9 +4,9 @@ use crate::actor::server_actor::{ }; use crate::download_store::{DownloadStore, collect_failed_tokens}; use crate::types::{ - ClientVersion, DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo, - RoomUserStats, SessionLoss, SessionWatch, UserInfo, UserPresence, - UserStats, UserStatus, + ClientVersion, DownloadMetadata, DownloadStatus, Recommendation, RoomEvent, + RoomInfo, RoomTicker, RoomUserStats, SessionLoss, SessionWatch, + SimilarUser, UserInfo, UserInterests, UserPresence, UserStats, UserStatus, }; use crate::utils::logger; use crate::{ @@ -313,8 +313,11 @@ pub enum ClientOperation { }, /// A direct outbound connection to this peer failed before it was /// established — the peer is likely firewalled, so fall back to asking the - /// server to broker the connection. Carries the reporting actor's id. - PeerConnectFailed(u64, String), + /// server to broker the connection. Carries the reporting actor's id and, + /// when the dial was one the server asked us to make, the token that peer + /// quoted: that dial is answered with a `CantConnectToPeer` instead of a + /// broker request, since the peer is already waiting on the server. + PeerConnectFailed(u64, String, Option), /// Something happened in the chat-room subsystem (list refreshed, a room /// joined/left, a message said, a member joined/left). RoomEvent(RoomEvent), @@ -340,6 +343,31 @@ pub enum ClientOperation { PossibleParents(Vec<(String, String, u16)>), /// The server told us to drop our parent. ResetDistributed, + /// Recommendations from the server: either from our own interests + /// (`global` false, code 54) or server-wide (`global` true, code 56). + Recommendations { + global: bool, + recommended: Vec, + unrecommended: Vec, + }, + /// Recommendations for one item (code 111). + ItemRecommendations { + item: String, + recommendations: Vec, + }, + /// Users the server considers similar to us (code 110). + SimilarUsers(Vec), + /// Users who like one item (code 112). + ItemSimilarUsers { + item: String, + usernames: Vec, + }, + /// What another user likes and hates (code 57). + UserInterests(UserInterests), + /// A peer we asked the server to broker gave up reaching us (code 1001). + CantConnectToPeer { + token: u32, + }, /// A parent candidate told us how deep it sits. `link` says which dial /// to that user is talking. ParentBranchLevel { @@ -410,6 +438,22 @@ pub struct ClientContext { /// Per-member statistics for each joined room, from the stat vectors the /// server sends alongside the membership list. room_member_stats: HashMap>, + /// The ticker board of each joined room, kept current from the board sent + /// on join (code 113) and the later add/remove events (114/115). + room_tickers: HashMap>, + /// The latest recommendations from our own interests (code 54), as + /// (recommended, recommended-against). + recommendations: Option<(Vec, Vec)>, + /// The latest server-wide recommendations (code 56). + global_recommendations: Option<(Vec, Vec)>, + /// Per-item recommendations (code 111), keyed by the item asked about. + item_recommendations: HashMap>, + /// The latest similar-user answer (code 110). + similar_users: Vec, + /// Who likes an item (code 112), keyed by the item asked about. + item_similar_users: HashMap>, + /// Interests of other users (code 57), keyed by username. + user_interests: HashMap, /// What the server has told us about other users, merged across the /// separate status and statistics replies. user_info: HashMap, @@ -519,6 +563,13 @@ impl ClientContext { room_events: Vec::new(), room_members: HashMap::new(), room_member_stats: HashMap::new(), + room_tickers: HashMap::new(), + recommendations: None, + global_recommendations: None, + item_recommendations: HashMap::new(), + similar_users: Vec::new(), + item_similar_users: HashMap::new(), + user_interests: HashMap::new(), user_info: HashMap::new(), watched_users: HashSet::new(), wishlist_interval: None, @@ -562,11 +613,124 @@ impl ClientContext { members.remove(at); } } - RoomEvent::Message { .. } => {} + RoomEvent::Tickers { room, tickers } => { + self.room_tickers.insert(room.clone(), tickers.clone()); + } + RoomEvent::TickerAdded { + room, + username, + ticker, + } => { + // A user has at most one ticker: an added one replaces theirs + // rather than stacking, which is how the server treats it. + let board = self.room_tickers.entry(room.clone()).or_default(); + board.retain(|t| &t.username != username); + board.push(RoomTicker { + username: username.clone(), + ticker: ticker.clone(), + }); + } + RoomEvent::TickerRemoved { room, username } => { + if let Some(board) = self.room_tickers.get_mut(room) { + board.retain(|t| &t.username != username); + } + } + RoomEvent::Message { .. } | RoomEvent::GlobalMessage { .. } => {} } self.room_events.push(event); } + /// The ticker board of `room` as last reported by the server. + #[must_use] + pub fn room_tickers(&self, room: &str) -> Vec { + self.room_tickers.get(room).cloned().unwrap_or_default() + } + + /// Record a recommendations reply; `global` selects the server-wide set. + pub fn apply_recommendations( + &mut self, + global: bool, + recommended: Vec, + unrecommended: Vec, + ) { + let slot = if global { + &mut self.global_recommendations + } else { + &mut self.recommendations + }; + *slot = Some((recommended, unrecommended)); + } + + /// The last recommendations reply, or `None` until one arrives. + #[must_use] + pub fn recommendations( + &self, + global: bool, + ) -> Option<(Vec, Vec)> { + if global { + self.global_recommendations.clone() + } else { + self.recommendations.clone() + } + } + + pub fn apply_item_recommendations( + &mut self, + item: String, + recommendations: Vec, + ) { + self.item_recommendations.insert(item, recommendations); + } + + #[must_use] + pub fn item_recommendations(&self, item: &str) -> Vec { + self.item_recommendations + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_similar_users(&mut self, users: Vec) { + self.similar_users = users; + } + + #[must_use] + pub fn similar_users(&self) -> Vec { + self.similar_users.clone() + } + + pub fn apply_item_similar_users( + &mut self, + item: String, + usernames: Vec, + ) { + self.item_similar_users.insert(item, usernames); + } + + #[must_use] + pub fn item_similar_users(&self, item: &str) -> Vec { + self.item_similar_users + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_user_interests(&mut self, interests: UserInterests) { + self.user_interests + .insert(interests.username.clone(), interests); + } + + #[must_use] + pub fn user_interests(&self, username: &str) -> Option { + self.user_interests.get(username).cloned() + } + + /// Forget any interests cached for `username`, so a poll after a fresh + /// request cannot mistake the previous answer for this one. + pub fn invalidate_user_interests(&mut self, username: &str) { + self.user_interests.remove(username); + } + /// Record a `GetUserStatus` reply, merging it with any statistics already /// received for that user. pub fn apply_user_status( @@ -1050,6 +1214,7 @@ mod downloads; mod operations; mod rooms; mod search; +mod social; mod upload_queue; mod uploads; diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 81dcd8e..d53c33e 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -777,6 +777,71 @@ impl Client { ctx.own_privileges = Some(seconds); } } + ClientOperation::Recommendations { + global, + recommended, + unrecommended, + } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_recommendations( + global, + recommended, + unrecommended, + ); + } + } + ClientOperation::ItemRecommendations { + item, + recommendations, + } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_item_recommendations( + item, + recommendations, + ); + } + } + ClientOperation::SimilarUsers(users) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_similar_users(users); + } + } + ClientOperation::ItemSimilarUsers { item, usernames } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_item_similar_users(item, usernames); + } + } + ClientOperation::UserInterests(interests) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_user_interests(interests); + } + } + ClientOperation::CantConnectToPeer { token } => { + // The peer we asked the server to broker gave up. The + // token is the correlation we sent, so it names the + // peer: end the wait now instead of letting every + // queued download for them sit until the timeout. + let username = match client_context.write_safe() { + Ok(mut ctx) => ctx.take_pending_connect(token), + Err(e) => { + error!( + "[client] CantConnectToPeer write: {}", + e + ); + None + } + }; + if let Some(username) = username { + debug!( + "[client] {} cannot connect back (token {})", + username, token + ); + Self::fail_queued_downloads( + &client_context, + &username, + ); + } + } ClientOperation::StartUpload { token } => { // The peer accepted our offer: resolve their // address (from the code-9 GetPeerAddress) and @@ -875,7 +940,58 @@ impl Client { ctx.store_browse_result(username, directories); } } - ClientOperation::PeerConnectFailed(id, username) => { + ClientOperation::PeerConnectFailed( + id, + username, + brokered_token, + ) => { + // Direct connect failed. A dial the server asked + // us to make is answered with CantConnectToPeer + // quoting the peer's own token: that peer is + // already waiting on the server, and asking it to + // broker the same connection back only trades one + // unreachable direction for the other. Every other + // dial falls back to the broker below. + if let Some(peer_token) = brokered_token { + let server_sender = match client_context + .write_safe() + { + Ok(ctx) => { + if let Some(handle) = + ctx.peer_registry.as_ref().and_then( + |r| r.remove_peer_if(&username, id), + ) + { + let _ = handle.stop(); + } + ctx.server_sender.clone() + } + Err(e) => { + error!( + "[client] PeerConnectFailed write: {}", + e + ); + continue; + } + }; + if let Some(sender) = server_sender { + let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer( + peer_token, + &username, + ); + let _ = sender + .send(ServerMessage::SendMessage(msg)); + } + // Nothing else is coming from this peer, so + // anything queued for it fails now rather than + // waiting out its timeout. + Self::fail_queued_downloads( + &client_context, + &username, + ); + continue; + } + // Direct connect failed: ask the server to // broker it. Register a correlation token, then // send ConnectToPeer so the (firewalled) peer diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs index 215deb5..9e54136 100644 --- a/soulseek-rs-lib/src/client/search.rs +++ b/soulseek-rs-lib/src/client/search.rs @@ -81,6 +81,55 @@ impl Client { Ok(()) } + /// Search one user's shares (server code 42) and return at once. + /// + /// Results arrive from that user alone and accumulate under `query`, read + /// back with [`Client::get_search_results`] — the same place a plain + /// search's results land, since the replies are ordinary + /// `FileSearchResponse` frames. + /// + /// # Errors + /// [`SoulseekRs::NotConnected`] when there is no server connection. + pub fn search_user(&self, username: &str, query: &str) -> Result<()> { + let token = self.register_search(query)?; + self.send_server_message( + crate::message::server::MessageFactory::build_user_search( + username, token, query, + ), + ) + } + + /// Search the shares of everyone in `room` (server code 120), returning at + /// once. Results accumulate under `query` exactly as above. + /// + /// # Errors + /// [`SoulseekRs::NotConnected`] when there is no server connection. + pub fn search_room(&self, room: &str, query: &str) -> Result<()> { + let token = self.register_search(query)?; + self.send_server_message( + crate::message::server::MessageFactory::build_room_search( + room, token, query, + ), + ) + } + + /// Take a token for `query` and register it, so replies quoting that token + /// are filed under `query` when they arrive. + fn register_search(&self, query: &str) -> Result { + if self.server_handle.is_none() { + return Err(SoulseekRs::NotConnected); + } + let token = next_search_token(); + self.context.write_safe()?.searches.insert( + query.to_string(), + Search { + token, + results: Vec::new(), + }, + ); + Ok(token) + } + /// Let responses accumulate for `timeout`, or until cancelled. /// /// Peers answer a search over their own connections whenever they get round diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs new file mode 100644 index 0000000..5d402e4 --- /dev/null +++ b/soulseek-rs-lib/src/client/social.rs @@ -0,0 +1,264 @@ +//! The social side of the protocol: room tickers (codes 113-116), the global +//! room feed (150-152), interests and the recommendation queries built on them +//! (51/52/54/56/57/110/111/112/117/118), and the odds and ends a client owes +//! the server — a keepalive ping (32), gifting privileges (123) and changing +//! our password (142). + +use super::{ + Client, Recommendation, Result, RoomTicker, RwLockExt, SimilarUser, + UserInterests, error, +}; +use crate::message::server::MessageFactory; + +impl Client { + /// Send a keepalive ping (server code 32). The server does not reply; the + /// point is that a connection with nothing to say still has traffic on it, + /// so a NAT or the server's idle reaper does not drop it. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn ping_server(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_server_ping()) + } + + /// Set our ticker in `room` — the one-line message other members see. + /// Passing an empty `ticker` clears ours. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn set_room_ticker(&self, room: &str, ticker: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_set_room_ticker( + room, ticker, + )) + } + + /// The ticker board of `room`: what the server sent on join (code 113), + /// kept current by the later add/remove events. + #[must_use] + pub fn room_tickers(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.room_tickers(room), + Err(e) => { + error!("[client] room_tickers: {}", e); + Vec::new() + } + } + } + + /// Subscribe to the global room feed (code 150): every public room message + /// on the server arrives as a [`crate::RoomEvent::GlobalMessage`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn join_global_room(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_join_global_room()) + } + + /// Stop the global room feed (code 151). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn leave_global_room(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_leave_global_room()) + } + + /// Add an interest (code 51). Interests are what the recommendation and + /// similar-user queries below are computed from. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_interest(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_thing_i_like(item)) + } + + /// Drop an interest (code 52). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_interest(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_thing_i_like( + item, + )) + } + + /// Add a dislike (code 117). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_dislike(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_thing_i_hate(item)) + } + + /// Drop a dislike (code 118). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_dislike(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_thing_i_hate( + item, + )) + } + + /// Ask for recommendations drawn from our own interests (code 54). The + /// reply arrives asynchronously; read it with + /// [`Client::recommendations`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_recommendations(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_get_recommendations()) + } + + /// Ask for the server-wide recommendations (code 56); read the reply with + /// [`Client::global_recommendations`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_global_recommendations(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_global_recommendations()) + } + + /// The last reply to [`Client::request_recommendations`] as + /// (recommended, recommended-against), or `None` until one arrives. + #[must_use] + pub fn recommendations( + &self, + ) -> Option<(Vec, Vec)> { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.recommendations(false)) + } + + /// The last reply to [`Client::request_global_recommendations`]. + #[must_use] + pub fn global_recommendations( + &self, + ) -> Option<(Vec, Vec)> { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.recommendations(true)) + } + + /// Ask what else people who like `item` like (code 111). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_item_recommendations(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_item_recommendations( + item, + )) + } + + /// The last per-item recommendations received for `item`. + #[must_use] + pub fn item_recommendations(&self, item: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.item_recommendations(item), + Err(e) => { + error!("[client] item_recommendations: {}", e); + Vec::new() + } + } + } + + /// Ask which users share our interests (code 110). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_similar_users(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_similar_users()) + } + + /// The last similar-user answer, weighted by how much we have in common. + #[must_use] + pub fn similar_users(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.similar_users(), + Err(e) => { + error!("[client] similar_users: {}", e); + Vec::new() + } + } + } + + /// Ask which users like `item` (code 112). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_item_similar_users(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_item_similar_users(item)) + } + + /// The last answer to [`Client::request_item_similar_users`] for `item`. + #[must_use] + pub fn item_similar_users(&self, item: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.item_similar_users(item), + Err(e) => { + error!("[client] item_similar_users: {}", e); + Vec::new() + } + } + } + + /// Ask what `username` likes and hates (code 57). + /// + /// Any previous answer for `username` is dropped first, so + /// [`Client::user_interests`] reports the reply to *this* request rather + /// than a stale one. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_user_interests(&self, username: &str) -> Result<()> { + self.context + .write_safe()? + .invalidate_user_interests(username); + self.send_server_message(MessageFactory::build_user_interests(username)) + } + + /// What the server last told us `username` likes and hates. + #[must_use] + pub fn user_interests(&self, username: &str) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.user_interests(username)) + } + + /// Give `days` of our own privileges to `username` (code 123). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn give_privileges(&self, username: &str, days: u32) -> Result<()> { + self.send_server_message(MessageFactory::build_give_privileges( + username, days, + )) + } + + /// Change our account password (code 142). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn change_password(&self, password: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_change_password( + password, + )) + } + + /// Send one private message to several users at once (code 149). Each + /// recipient receives it as an ordinary private message. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn send_private_message_to_many( + &self, + usernames: &[String], + message: &str, + ) -> Result<()> { + self.send_server_message(MessageFactory::build_message_users( + usernames, message, + )) + } +} diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index fb97d6c..b1b8285 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -838,3 +838,116 @@ fn a_peer_message_nobody_could_deliver_expires() { ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL); assert!(ctx.take_peer_messages("ghost").is_empty(), "expired"); } + +#[test] +fn a_new_ticker_replaces_that_users_previous_one() { + // The server treats a user's ticker as singular: a second one from the + // same user supersedes the first rather than stacking beside it. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "first".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "bobs".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerAdded { + room: "jazz".into(), + username: "alice".into(), + ticker: "second".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 2); + assert_eq!( + board.iter().find(|t| t.username == "alice").unwrap().ticker, + "second" + ); +} + +#[test] +fn a_removed_ticker_leaves_the_rest_of_the_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "a".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "b".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerRemoved { + room: "jazz".into(), + username: "alice".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 1); + assert_eq!(board[0].username, "bob"); +} + +#[test] +fn a_global_message_is_queued_without_touching_room_membership() { + // The global feed carries messages from rooms we have not joined; they + // must not invent a roster for those rooms. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::GlobalMessage { + room: "elsewhere".into(), + username: "alice".into(), + message: "hi".into(), + }); + assert!(ctx.room_members("elsewhere").is_empty()); + assert_eq!(ctx.take_room_events().len(), 1); +} + +#[test] +fn own_and_global_recommendations_are_kept_apart() { + let mut ctx = ClientContext::new(); + ctx.apply_recommendations( + false, + vec![Recommendation { + item: "jazz".into(), + rating: 2, + }], + Vec::new(), + ); + ctx.apply_recommendations( + true, + vec![Recommendation { + item: "pop".into(), + rating: 9, + }], + Vec::new(), + ); + + assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz"); + assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop"); +} + +#[test] +fn asking_again_about_a_users_interests_drops_the_previous_answer() { + let mut ctx = ClientContext::new(); + ctx.apply_user_interests(UserInterests { + username: "alice".into(), + likes: vec!["jazz".into()], + hates: Vec::new(), + }); + assert!(ctx.user_interests("alice").is_some()); + + ctx.invalidate_user_interests("alice"); + assert!( + ctx.user_interests("alice").is_none(), + "a stale answer must not be mistaken for the next one" + ); +} diff --git a/soulseek-rs-lib/src/lib.rs b/soulseek-rs-lib/src/lib.rs index f9474a5..a174fbd 100644 --- a/soulseek-rs-lib/src/lib.rs +++ b/soulseek-rs-lib/src/lib.rs @@ -22,9 +22,10 @@ pub mod utils; pub mod prelude { pub use crate::actor::server_actor::PeerAddress; pub use crate::types::{ - ClientVersion, DownloadStatus, File, RoomEvent, RoomInfo, Search, - SearchResult, Transfer, UploadInfo, UploadStatus, UserInfo, - UserPresence, UserStats, UserStatus, + ClientVersion, DownloadStatus, File, Recommendation, RoomEvent, + RoomInfo, RoomTicker, Search, SearchResult, SimilarUser, Transfer, + UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence, + UserStats, UserStatus, }; pub use crate::{debug, error, info, trace, warn}; } @@ -36,7 +37,8 @@ pub use error::{Result, SoulseekRs}; pub use message::peer::{SharedDirectory, SharedFileEntry}; pub use peer::{ConnectionType, ParseConnectionTypeError}; pub use types::{ - ClientVersion, DownloadStatus, File, RoomEvent, RoomInfo, Search, - SearchResult, SessionLoss, Transfer, UploadInfo, UploadStatus, UserInfo, - UserPresence, UserStats, UserStatus, + ClientVersion, DownloadStatus, File, Recommendation, RoomEvent, RoomInfo, + RoomTicker, Search, SearchResult, SessionLoss, SimilarUser, Transfer, + UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence, UserStats, + UserStatus, }; diff --git a/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs b/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs new file mode 100644 index 0000000..184e1f2 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs @@ -0,0 +1,49 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// `CantConnectToPeer` (code 1001): a brokered peer gave up reaching us. +/// +/// The token is the one we quoted in our own `ConnectToPeer` (code 18), and it +/// is the only field the server relays — the username is not repeated, so the +/// token is what a client matches the pending attempt on. +/// +/// Without this the download sits in "connecting" until a timeout; with it the +/// wait ends the moment the peer gives up. +pub struct CantConnectToPeerHandler; + +impl MessageHandler for CantConnectToPeerHandler { + fn get_code(&self) -> u32 { + 1001 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let token = message.read_int32(); + let _ = sender.send(ServerMessage::CantConnectToPeer { token }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_token_of_the_failed_attempt_is_reported() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(4242); + }); + + CantConnectToPeerHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::CantConnectToPeer { token }) => { + assert_eq!(token, 4242); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/global_room.rs b/soulseek-rs-lib/src/message/server/global_room.rs new file mode 100644 index 0000000..7e8f704 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/global_room.rs @@ -0,0 +1,58 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// `GlobalRoomMessage` (code 152): a message said in some public room, +/// delivered because we subscribed to the global feed (code 150). +pub struct GlobalRoomMessageHandler; + +impl MessageHandler for GlobalRoomMessageHandler { + fn get_code(&self) -> u32 { + 152 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let text = message.read_string(); + let _ = sender.send(ServerMessage::GlobalRoomMessageReceived { + room, + username, + message: text, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_global_message_names_the_room_it_was_said_in() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + m.write_string("anyone here?"); + }); + + GlobalRoomMessageHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::GlobalRoomMessageReceived { + room, + username, + message, + }) => { + assert_eq!( + (room.as_str(), username.as_str(), message.as_str()), + ("jazz", "alice", "anyone here?") + ); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/interests.rs b/soulseek-rs-lib/src/message/server/interests.rs new file mode 100644 index 0000000..6d623a6 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/interests.rs @@ -0,0 +1,346 @@ +//! Interests and the queries built on them: recommendations (codes 54, 56, +//! 111), similar users (110, 112) and another user's interests (57). +//! +//! Ratings are signed on the wire: the server sends a descending list of +//! things it recommends and an ascending list of things it recommends +//! against, and the latter carry negative ratings. + +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, + types::{Recommendation, SimilarUser, UserInterests}, +}; + +/// Read a `[count][item][rating]...` vector, stopping if the payload runs out +/// before the count does. +fn read_recommendations(message: &mut Message) -> Vec { + let count = message.read_int32(); + let mut items = Vec::new(); + for _ in 0..count { + let item = message.read_string(); + if item.is_empty() { + break; + } + #[allow(clippy::cast_possible_wrap)] + let rating = message.read_int32() as i32; + items.push(Recommendation { item, rating }); + } + items +} + +/// Read a `[count][string]...` vector, stopping at the end of the payload. +fn read_strings(message: &mut Message) -> Vec { + let count = message.read_int32(); + let mut items = Vec::new(); + for _ in 0..count { + let item = message.read_string(); + if item.is_empty() { + break; + } + items.push(item); + } + items +} + +/// `GetRecommendations` (code 54): recommendations drawn from our own +/// interests. +pub struct RecommendationsHandler; + +impl MessageHandler for RecommendationsHandler { + fn get_code(&self) -> u32 { + 54 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let recommended = read_recommendations(message); + let unrecommended = read_recommendations(message); + let _ = sender.send(ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + }); + } +} + +/// `GlobalRecommendations` (code 56): the same shape, server-wide. +pub struct GlobalRecommendationsHandler; + +impl MessageHandler for GlobalRecommendationsHandler { + fn get_code(&self) -> u32 { + 56 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let recommended = read_recommendations(message); + let unrecommended = read_recommendations(message); + let _ = sender.send(ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + }); + } +} + +/// `ItemRecommendations` (code 111): what else people who like one item like. +pub struct ItemRecommendationsHandler; + +impl MessageHandler for ItemRecommendationsHandler { + fn get_code(&self) -> u32 { + 111 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let item = message.read_string(); + let recommendations = read_recommendations(message); + let _ = sender.send(ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + }); + } +} + +/// `SimilarUsers` (code 110): users whose interests overlap ours. +pub struct SimilarUsersHandler; + +impl MessageHandler for SimilarUsersHandler { + fn get_code(&self) -> u32 { + 110 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + if username.is_empty() { + break; + } + let weight = message.read_int32(); + users.push(SimilarUser { username, weight }); + } + let _ = sender.send(ServerMessage::SimilarUsersReceived { users }); + } +} + +/// `ItemSimilarUsers` (code 112): who likes one item. The server sends only +/// names here, so every weight reads as zero. +pub struct ItemSimilarUsersHandler; + +impl MessageHandler for ItemSimilarUsersHandler { + fn get_code(&self) -> u32 { + 112 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let item = message.read_string(); + let usernames = read_strings(message); + let _ = sender + .send(ServerMessage::ItemSimilarUsersReceived { item, usernames }); + } +} + +/// `UserInterests` (code 57): what one user likes and hates. +pub struct UserInterestsHandler; + +impl MessageHandler for UserInterestsHandler { + fn get_code(&self) -> u32 { + 57 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let username = message.read_string(); + let likes = read_strings(message); + let hates = read_strings(message); + let _ = + sender.send(ServerMessage::UserInterestsReceived(UserInterests { + username, + likes, + hates, + })); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn recommendations_split_into_recommended_and_not() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1); + m.write_string("jazz"); + m.write_int32(3); + m.write_int32(1); + m.write_string("polka"); + #[allow(clippy::cast_sign_loss)] + m.write_int32(-2i32 as u32); + }); + + RecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + }) => { + assert_eq!( + recommended, + vec![Recommendation { + item: "jazz".into(), + rating: 3 + }] + ); + assert_eq!( + unrecommended, + vec![Recommendation { + item: "polka".into(), + rating: -2 + }] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn global_recommendations_parse_the_same_shape() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1); + m.write_string("dub"); + m.write_int32(9); + m.write_int32(0); + }); + + GlobalRecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + }) => { + assert_eq!(recommended.len(), 1); + assert_eq!(recommended[0].item, "dub"); + assert!(unrecommended.is_empty()); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn similar_users_carry_their_weight() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(2); + m.write_string("alice"); + m.write_int32(4); + m.write_string("bob"); + m.write_int32(1); + }); + + SimilarUsersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::SimilarUsersReceived { users }) => { + assert_eq!( + users, + vec![ + SimilarUser { + username: "alice".into(), + weight: 4 + }, + SimilarUser { + username: "bob".into(), + weight: 1 + }, + ] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn item_similar_users_report_the_item_they_answer_for() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1); + m.write_string("alice"); + }); + + ItemSimilarUsersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ItemSimilarUsersReceived { item, usernames }) => { + assert_eq!(item, "jazz"); + assert_eq!(usernames, vec!["alice".to_string()]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn item_recommendations_report_their_item() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1); + m.write_string("blues"); + m.write_int32(2); + }); + + ItemRecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + }) => { + assert_eq!(item, "jazz"); + assert_eq!(recommendations[0].item, "blues"); + assert_eq!(recommendations[0].rating, 2); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_users_likes_and_hates_arrive_as_two_lists() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("alice"); + m.write_int32(2); + m.write_string("jazz"); + m.write_string("dub"); + m.write_int32(1); + m.write_string("polka"); + }); + + UserInterestsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::UserInterestsReceived(interests)) => { + assert_eq!(interests.username, "alice"); + assert_eq!(interests.likes, ["jazz", "dub"]); + assert_eq!(interests.hates, ["polka"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_user_with_no_interests_parses_to_empty_lists() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("ghost"); + m.write_int32(0); + m.write_int32(0); + }); + + UserInterestsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::UserInterestsReceived(interests)) => { + assert!(interests.likes.is_empty()); + assert!(interests.hates.is_empty()); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index 620508a..0f7386b 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -301,6 +301,175 @@ impl MessageFactory { .clone() } + /// A keepalive ping (server code 32, no body). The server sends no reply; + /// its only job is to keep a quiet connection from being reaped by a NAT + /// or the server's own idle timeout, the way other clients ping. + #[must_use] + pub fn build_server_ping() -> Message { + Message::new().write_int32(32).clone() + } + + /// Search one user's shares (server code 42). Replies come back over a + /// peer connection as ordinary `FileSearchResponse` frames, so the token + /// is drawn from the same space as a plain `FileSearch`. + #[must_use] + pub fn build_user_search( + username: &str, + token: u32, + query: &str, + ) -> Message { + Message::new() + .write_int32(42) + .write_string(username) + .write_int32(token) + .write_string(query) + .clone() + } + + /// Search the shares of everyone in `room` (server code 120). + #[must_use] + pub fn build_room_search(room: &str, token: u32, query: &str) -> Message { + Message::new() + .write_int32(120) + .write_string(room) + .write_int32(token) + .write_string(query) + .clone() + } + + /// Tell the server (code 1001) that the peer it asked us to connect to, + /// quoting `token`, could not be reached. The server relays that to the + /// peer so it stops waiting on a connection that is never coming. + #[must_use] + pub fn build_cant_connect_to_peer(token: u32, username: &str) -> Message { + Message::new() + .write_int32(1001) + .write_int32(token) + .write_string(username) + .clone() + } + + /// Set our ticker (the scrolling one-line message) in `room`, server code + /// 116. An empty ticker removes ours. + #[must_use] + pub fn build_set_room_ticker(room: &str, ticker: &str) -> Message { + Message::new() + .write_int32(116) + .write_string(room) + .write_string(ticker) + .clone() + } + + /// Subscribe to the global room feed (server code 150): every public room + /// message on the server, tagged with the room it came from. + #[must_use] + pub fn build_join_global_room() -> Message { + Message::new().write_int32(150).clone() + } + + /// Stop the global room feed (server code 151). + #[must_use] + pub fn build_leave_global_room() -> Message { + Message::new().write_int32(151).clone() + } + + /// Add an interest (server code 51). Interests drive the recommendation + /// and similar-user queries below. + #[must_use] + pub fn build_add_thing_i_like(item: &str) -> Message { + Message::new().write_int32(51).write_string(item).clone() + } + + /// Drop an interest (server code 52). + #[must_use] + pub fn build_remove_thing_i_like(item: &str) -> Message { + Message::new().write_int32(52).write_string(item).clone() + } + + /// Add a dislike (server code 117). + #[must_use] + pub fn build_add_thing_i_hate(item: &str) -> Message { + Message::new().write_int32(117).write_string(item).clone() + } + + /// Drop a dislike (server code 118). + #[must_use] + pub fn build_remove_thing_i_hate(item: &str) -> Message { + Message::new().write_int32(118).write_string(item).clone() + } + + /// Ask for recommendations based on our own interests (server code 54). + #[must_use] + pub fn build_get_recommendations() -> Message { + Message::new().write_int32(54).clone() + } + + /// Ask for the server-wide recommendations (server code 56). + #[must_use] + pub fn build_global_recommendations() -> Message { + Message::new().write_int32(56).clone() + } + + /// Ask what `username` likes and hates (server code 57). + #[must_use] + pub fn build_user_interests(username: &str) -> Message { + Message::new() + .write_int32(57) + .write_string(username) + .clone() + } + + /// Ask for users whose interests overlap ours (server code 110). + #[must_use] + pub fn build_similar_users() -> Message { + Message::new().write_int32(110).clone() + } + + /// Ask what else people who like `item` like (server code 111). + #[must_use] + pub fn build_item_recommendations(item: &str) -> Message { + Message::new().write_int32(111).write_string(item).clone() + } + + /// Ask which users like `item` (server code 112). + #[must_use] + pub fn build_item_similar_users(item: &str) -> Message { + Message::new().write_int32(112).write_string(item).clone() + } + + /// Hand `days` of our own privileges to `username` (server code 123). + #[must_use] + pub fn build_give_privileges(username: &str, days: u32) -> Message { + Message::new() + .write_int32(123) + .write_string(username) + .write_int32(days) + .clone() + } + + /// Change our account password (server code 142). The server echoes the + /// accepted password back under the same code. + #[must_use] + pub fn build_change_password(password: &str) -> Message { + Message::new() + .write_int32(142) + .write_string(password) + .clone() + } + + /// Send one private message to several users at once (server code 149). + #[must_use] + pub fn build_message_users(usernames: &[String], message: &str) -> Message { + let mut msg = Message::new(); + msg.write_int32(149); + msg.write_int32(usernames.len() as u32); + for username in usernames { + msg.write_string(username); + } + msg.write_string(message); + msg + } + #[must_use] pub fn build_peer_init_message( own_username: &str, @@ -517,3 +686,129 @@ fn an_upload_speed_report_carries_the_rate() { decoded.set_pointer(8); assert_eq!(decoded.read_int32(), 900); } + +#[test] +fn a_ping_is_just_its_code() { + let message = MessageFactory::build_server_ping(); + assert_eq!(message.get_data(), [32, 0, 0, 0]); +} + +#[test] +fn a_user_search_names_the_user_before_its_token() { + let message = MessageFactory::build_user_search("bob", 99, "jazz"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 42); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "bob"); + assert_eq!(decoded.read_int32(), 99); + assert_eq!(decoded.read_string(), "jazz"); +} + +#[test] +fn a_room_search_names_the_room_before_its_token() { + let message = MessageFactory::build_room_search("nicotine", 7, "dub"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 120); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "nicotine"); + assert_eq!(decoded.read_int32(), 7); + assert_eq!(decoded.read_string(), "dub"); +} + +#[test] +fn a_cant_connect_carries_the_token_then_the_user() { + let message = MessageFactory::build_cant_connect_to_peer(4242, "bob"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 1001); + decoded.set_pointer(8); + assert_eq!(decoded.read_int32(), 4242); + assert_eq!(decoded.read_string(), "bob"); +} + +#[test] +fn a_room_ticker_carries_its_room_and_text() { + let message = MessageFactory::build_set_room_ticker("jazz", "hello"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 116); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "jazz"); + assert_eq!(decoded.read_string(), "hello"); +} + +#[test] +fn the_global_room_is_joined_and_left_by_code_alone() { + assert_eq!( + MessageFactory::build_join_global_room().get_data(), + [150, 0, 0, 0] + ); + assert_eq!( + MessageFactory::build_leave_global_room().get_data(), + [151, 0, 0, 0] + ); +} + +#[test] +fn each_interest_message_uses_its_own_code() { + for (message, code) in [ + (MessageFactory::build_add_thing_i_like("jazz"), 51), + (MessageFactory::build_remove_thing_i_like("jazz"), 52), + (MessageFactory::build_add_thing_i_hate("polka"), 117), + (MessageFactory::build_remove_thing_i_hate("polka"), 118), + (MessageFactory::build_user_interests("bob"), 57), + (MessageFactory::build_item_recommendations("jazz"), 111), + (MessageFactory::build_item_similar_users("jazz"), 112), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert!(!decoded.read_string().is_empty()); + } +} + +#[test] +fn the_bodyless_queries_are_just_their_codes() { + assert_eq!( + MessageFactory::build_get_recommendations().get_data(), + [54, 0, 0, 0] + ); + assert_eq!( + MessageFactory::build_global_recommendations().get_data(), + [56, 0, 0, 0] + ); + assert_eq!( + MessageFactory::build_similar_users().get_data(), + [110, 0, 0, 0] + ); +} + +#[test] +fn a_privilege_gift_carries_the_recipient_and_days() { + let message = MessageFactory::build_give_privileges("bob", 3); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 123); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "bob"); + assert_eq!(decoded.read_int32(), 3); +} + +#[test] +fn a_password_change_carries_the_new_password() { + let message = MessageFactory::build_change_password("hunter2"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 142); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "hunter2"); +} + +#[test] +fn a_multi_user_message_counts_its_recipients_first() { + let recipients = vec!["alice".to_string(), "bob".to_string()]; + let message = MessageFactory::build_message_users(&recipients, "hi all"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 149); + decoded.set_pointer(8); + assert_eq!(decoded.read_int32(), 2); + assert_eq!(decoded.read_string(), "alice"); + assert_eq!(decoded.read_string(), "bob"); + assert_eq!(decoded.read_string(), "hi all"); +} diff --git a/soulseek-rs-lib/src/message/server/mod.rs b/soulseek-rs-lib/src/message/server/mod.rs index 35fcc3b..76eb9e4 100644 --- a/soulseek-rs-lib/src/message/server/mod.rs +++ b/soulseek-rs-lib/src/message/server/mod.rs @@ -1,9 +1,12 @@ mod admin_message; +mod cant_connect_to_peer; mod connect_to_peer; mod distributed; mod excluded_search_phrases; mod file_search; mod get_peer_address; +mod global_room; +mod interests; mod join_room; mod leave_room; mod login; @@ -14,6 +17,7 @@ mod parent_speed_ratio; mod privileged_users; mod relogged; mod room_list; +mod room_tickers; mod say_chatroom; mod user_info; mod user_joined_room; @@ -22,6 +26,7 @@ mod watch_user; mod wish_list_interval; pub use admin_message::AdminMessageHandler; +pub use cant_connect_to_peer::CantConnectToPeerHandler; pub use connect_to_peer::ConnectToPeerHandler; pub use distributed::{ EmbeddedMessageHandler, PossibleParentsHandler, ResetDistributedHandler, @@ -29,6 +34,12 @@ pub use distributed::{ pub use excluded_search_phrases::ExcludedSearchPhrasesHandler; pub use file_search::FileSearchHandler; pub use get_peer_address::GetPeerAddressHandler; +pub use global_room::GlobalRoomMessageHandler; +pub use interests::{ + GlobalRecommendationsHandler, ItemRecommendationsHandler, + ItemSimilarUsersHandler, RecommendationsHandler, SimilarUsersHandler, + UserInterestsHandler, +}; pub use join_room::JoinRoomHandler; pub use leave_room::LeaveRoomHandler; pub use login::LoginHandler; @@ -39,6 +50,9 @@ pub use parent_speed_ratio::ParentSpeedRatioHandler; pub use privileged_users::{CheckPrivilegesHandler, PrivilegedUsersHandler}; pub use relogged::ReloggedHandler; pub use room_list::RoomListHandler; +pub use room_tickers::{ + RoomTickerAddedHandler, RoomTickerRemovedHandler, RoomTickersHandler, +}; pub use say_chatroom::SayChatroomHandler; pub use user_info::{GetUserStatsHandler, GetUserStatusHandler}; pub use user_joined_room::UserJoinedRoomHandler; diff --git a/soulseek-rs-lib/src/message/server/room_tickers.rs b/soulseek-rs-lib/src/message/server/room_tickers.rs new file mode 100644 index 0000000..d9ab1c9 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/room_tickers.rs @@ -0,0 +1,174 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, + types::RoomTicker, +}; + +/// `RoomTickers` (code 113): the whole ticker board of a room, sent when we +/// join it. +pub struct RoomTickersHandler; + +impl MessageHandler for RoomTickersHandler { + fn get_code(&self) -> u32 { + 113 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let count = message.read_int32(); + // A truncated or hostile count must not preallocate: grow as entries + // actually parse, and stop at the first one that runs off the end. + let mut tickers = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + let ticker = message.read_string(); + if username.is_empty() { + break; + } + tickers.push(RoomTicker { username, ticker }); + } + let _ = sender.send(ServerMessage::RoomTickers { room, tickers }); + } +} + +/// `RoomTickerAdded` (code 114): one user set or replaced their ticker. +pub struct RoomTickerAddedHandler; + +impl MessageHandler for RoomTickerAddedHandler { + fn get_code(&self) -> u32 { + 114 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let ticker = message.read_string(); + let _ = sender.send(ServerMessage::RoomTickerAdded { + room, + username, + ticker, + }); + } +} + +/// `RoomTickerRemoved` (code 115): one user cleared their ticker. +pub struct RoomTickerRemovedHandler; + +impl MessageHandler for RoomTickerRemovedHandler { + fn get_code(&self) -> u32 { + 115 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = + sender.send(ServerMessage::RoomTickerRemoved { room, username }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_ticker_board_carries_every_entry_in_order() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(2); + m.write_string("alice"); + m.write_string("hello there"); + m.write_string("bob"); + m.write_string("back later"); + }); + + RoomTickersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickers { room, tickers }) => { + assert_eq!(room, "jazz"); + assert_eq!( + tickers, + vec![ + RoomTicker { + username: "alice".into(), + ticker: "hello there".into() + }, + RoomTicker { + username: "bob".into(), + ticker: "back later".into() + }, + ] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_count_larger_than_the_payload_stops_at_the_data() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1_000_000); + m.write_string("alice"); + m.write_string("hi"); + }); + + RoomTickersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickers { tickers, .. }) => { + assert_eq!(tickers.len(), 1); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn an_added_ticker_reports_its_room_user_and_text() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + m.write_string("new ticker"); + }); + + RoomTickerAddedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickerAdded { + room, + username, + ticker, + }) => { + assert_eq!( + (room.as_str(), username.as_str(), ticker.as_str()), + ("jazz", "alice", "new ticker") + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_removed_ticker_reports_its_room_and_user() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + }); + + RoomTickerRemovedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickerRemoved { room, username }) => { + assert_eq!( + (room.as_str(), username.as_str()), + ("jazz", "alice") + ); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/peer/mod.rs b/soulseek-rs-lib/src/peer/mod.rs index 301603c..15494ba 100644 --- a/soulseek-rs-lib/src/peer/mod.rs +++ b/soulseek-rs-lib/src/peer/mod.rs @@ -64,6 +64,10 @@ pub struct Peer { pub privileged: Option, pub unknown: Option, pub obfuscated_port: Option, + /// True when the server asked us to make this connection (`ConnectToPeer`, + /// code 18). Then `token` is the far peer's correlation token, and a dial + /// that fails owes them a `CantConnectToPeer` quoting it. + pub brokered: bool, } impl Peer { #[allow(clippy::too_many_arguments)] @@ -87,6 +91,7 @@ impl Peer { privileged: Some(privileged), unknown: Some(unknown), obfuscated_port: Some(obfuscated_port), + brokered: false, } } pub fn new_from_message(message: &mut Message) -> Option { @@ -120,6 +125,7 @@ impl Peer { privileged: Some(privileged), unknown: Some(unknown), obfuscated_port: Some(u16::from(obfuscated_port)), + brokered: true, }) } } diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index 1f07ea5..c057acf 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -393,6 +393,58 @@ pub enum RoomEvent { UserJoined { room: String, username: String }, /// `username` left `room`. UserLeft { room: String, username: String }, + /// The full ticker board of `room`, sent when we join it (code 113). + Tickers { + room: String, + tickers: Vec, + }, + /// `username` set their ticker in `room` (code 114). It replaces any + /// previous ticker of theirs. + TickerAdded { + room: String, + username: String, + ticker: String, + }, + /// `username` cleared their ticker in `room` (code 115). + TickerRemoved { room: String, username: String }, + /// A message said in *any* public room, delivered because we joined the + /// global room feed (code 152). `room` names where it was said. + GlobalMessage { + room: String, + username: String, + message: String, + }, +} + +/// One user's ticker (the scrolling one-line message) in a room. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoomTicker { + pub username: String, + pub ticker: String, +} + +/// One recommended item and how strongly the server recommends it. The rating +/// is signed: a negative rating means the item is recommended *against*. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recommendation { + pub item: String, + pub rating: i32, +} + +/// A user the server considers similar to us, with the weight (the number of +/// interests shared) it scored them by. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SimilarUser { + pub username: String, + pub weight: u32, +} + +/// What a user likes and hates (`UserInterests`, code 57). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UserInterests { + pub username: String, + pub likes: Vec, + pub hates: Vec, } impl Transfer { From 60a0f8fc91c6e9f1f144381c52bd856eef757f4a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:22:32 +0000 Subject: [PATCH 02/19] test(e2e): drive the new protocol messages against soulfind Eleven scenarios over a real server: tickers reaching other members and the board handed to a late joiner, the global room feed starting and stopping, user and room searches answered by their targets, interests driving similar users and recommendations, a password change taking effect on the next login, one message reaching several users, and a brokered dial we cannot complete reporting CantConnectToPeer to the peer that is waiting on it. --- soulseek-rs-lib/tests/e2e.rs | 551 +++++++++++++++++++++++++++++++++++ 1 file changed, 551 insertions(+) diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 4f6149c..1f9d3ee 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -3768,3 +3768,554 @@ fn a_leaf_adopts_a_parent_and_answers_the_searches_it_passes_down() { let _ = std::fs::remove_dir_all(share_dir); } + +// --------------------------------------------------------------------------- +// Room tickers, the global room feed, interests and the targeted searches. +// +// Every one of these is a message the client can now speak; the point of +// driving them through soulfind is that the wire shapes are right in both +// directions, not just that they parse. +// --------------------------------------------------------------------------- + +/// Poll `client`'s room events until `pick` matches one, or time out. +fn await_room_event( + client: &Client, + timeout: Duration, + mut pick: impl FnMut(&soulseek_rs::types::RoomEvent) -> Option, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + for event in client.take_room_events() { + if let Some(found) = pick(&event) { + return Some(found); + } + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +#[test] +fn a_ticker_set_in_a_room_reaches_the_other_members() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_ticker_room"; + let mut alice = + Client::with_settings(server.settings("e2e_tick_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_tick_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + alice.join_room(room).expect("alice joins"); + bob.join_room(room).expect("bob joins"); + std::thread::sleep(Duration::from_millis(500)); + let _ = bob.take_room_events(); + + alice + .set_room_ticker(room, "alice was here") + .expect("set ticker"); + + let seen = + await_room_event(&bob, Duration::from_secs(5), |event| match event { + RoomEvent::TickerAdded { + room: r, + username, + ticker, + } if r == room => Some((username.clone(), ticker.clone())), + _ => None, + }) + .expect("bob should see alice's ticker"); + assert_eq!( + seen, + ("e2e_tick_alice".to_string(), "alice was here".into()) + ); + + // The board is kept, not just the event: a UI reads it back from state. + let board = bob.room_tickers(room); + assert_eq!( + board + .iter() + .find(|t| t.username == "e2e_tick_alice") + .map(|t| t.ticker.as_str()), + Some("alice was here") + ); +} + +#[test] +fn a_client_joining_later_receives_the_whole_ticker_board() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_ticker_board"; + let mut alice = + Client::with_settings(server.settings("e2e_board_alice", "pw")); + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + alice.join_room(room).expect("alice joins"); + std::thread::sleep(Duration::from_millis(300)); + alice + .set_room_ticker(room, "standing message") + .expect("set ticker"); + std::thread::sleep(Duration::from_millis(500)); + + // Carol joins afterwards and must be handed the board that already exists. + let mut carol = + Client::with_settings(server.settings("e2e_board_carol", "pw")); + carol.connect().expect("carol connect"); + assert!(carol.login().expect("carol login")); + carol.join_room(room).expect("carol joins"); + + let tickers = + await_room_event(&carol, Duration::from_secs(5), |event| match event { + RoomEvent::Tickers { room: r, tickers } if r == room => { + Some(tickers.clone()) + } + _ => None, + }) + .expect("carol should receive the ticker board on join"); + assert!( + tickers.iter().any(|t| t.username == "e2e_board_alice" + && t.ticker == "standing message"), + "the board should carry alice's standing ticker, got {tickers:?}" + ); +} + +#[test] +fn the_global_room_feed_carries_a_room_we_never_joined() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_global_source"; + let mut watcher = + Client::with_settings(server.settings("e2e_global_watch", "pw")); + let mut talker = + Client::with_settings(server.settings("e2e_global_talk", "pw")); + watcher.connect().expect("watcher connect"); + talker.connect().expect("talker connect"); + assert!(watcher.login().expect("watcher login")); + assert!(talker.login().expect("talker login")); + + watcher.join_global_room().expect("join global room"); + talker.join_room(room).expect("talker joins a room"); + std::thread::sleep(Duration::from_millis(500)); + let _ = watcher.take_room_events(); + + let body = "spoken where nobody is watching"; + talker.say_in_room(room, body).expect("say in room"); + + let got = + await_room_event( + &watcher, + Duration::from_secs(5), + |event| match event { + RoomEvent::GlobalMessage { + room: r, + username, + message, + } if message == body => Some((r.clone(), username.clone())), + _ => None, + }, + ) + .expect("the global feed should carry the message"); + assert_eq!(got, (room.to_string(), "e2e_global_talk".to_string())); + + // And leaving the feed stops it: the next message must not arrive. + watcher.leave_global_room().expect("leave global room"); + std::thread::sleep(Duration::from_millis(500)); + let _ = watcher.take_room_events(); + talker + .say_in_room(room, "after leaving") + .expect("say again"); + let after = await_room_event(&watcher, Duration::from_secs(2), |event| { + matches!(event, RoomEvent::GlobalMessage { message, .. } + if message == "after leaving") + .then_some(()) + }); + assert!(after.is_none(), "the feed should stop once left"); +} + +#[test] +fn a_user_search_is_answered_by_the_user_it_names() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_usersearch_grail.bin"), &content) + .unwrap(); + + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_us_sharer", + "e2e_us_seeker", + ); + + let query = "grail"; + searcher + .search_user("e2e_us_sharer", query) + .expect("user search"); + + let reply = reply_from(&searcher, query, "e2e_us_sharer") + .expect("the named user should answer a user search"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_usersearch_grail")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn a_room_search_is_answered_by_the_rooms_members() { + let server = server_or_skip!(); + + let room = "e2e_search_room"; + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_roomsearch_relic.bin"), &content) + .unwrap(); + + let (sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_rs_sharer", + "e2e_rs_seeker", + ); + sharer.join_room(room).expect("sharer joins"); + searcher.join_room(room).expect("searcher joins"); + std::thread::sleep(Duration::from_millis(750)); + + let query = "relic"; + searcher.search_room(room, query).expect("room search"); + + let reply = reply_from(&searcher, query, "e2e_rs_sharer") + .expect("a member of the room should answer a room search"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_roomsearch_relic")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn shared_interests_make_two_users_similar() { + let server = server_or_skip!(); + + let mut alice = + Client::with_settings(server.settings("e2e_like_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_like_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + let item = "e2e_interest_krautrock"; + alice.add_interest(item).expect("alice likes it"); + bob.add_interest(item).expect("bob likes it too"); + alice + .add_dislike("e2e_interest_muzak") + .expect("alice dislikes something"); + std::thread::sleep(Duration::from_millis(500)); + + // Who else likes this item (code 112). + let deadline = Instant::now() + Duration::from_secs(5); + let mut likers = Vec::new(); + while Instant::now() < deadline + && !likers.iter().any(|u| u == "e2e_like_bob") + { + alice + .request_item_similar_users(item) + .expect("ask who likes it"); + std::thread::sleep(Duration::from_millis(250)); + likers = alice.item_similar_users(item); + } + assert!( + likers.iter().any(|u| u == "e2e_like_bob"), + "bob likes the same item, got {likers:?}" + ); + + // The overlap also makes bob a similar user (code 110). + let deadline = Instant::now() + Duration::from_secs(5); + let mut similar = Vec::new(); + while Instant::now() < deadline + && !similar + .iter() + .any(|u: &soulseek_rs::SimilarUser| u.username == "e2e_like_bob") + { + alice + .request_similar_users() + .expect("ask for similar users"); + std::thread::sleep(Duration::from_millis(250)); + similar = alice.similar_users(); + } + assert!( + similar + .iter() + .any(|u| u.username == "e2e_like_bob" && u.weight > 0), + "bob should be similar with a non-zero weight, got {similar:?}" + ); + + // And alice's own likes and hates read back over code 57. + let deadline = Instant::now() + Duration::from_secs(5); + let mut interests = None; + while Instant::now() < deadline && interests.is_none() { + bob.request_user_interests("e2e_like_alice") + .expect("ask about alice"); + std::thread::sleep(Duration::from_millis(250)); + interests = bob.user_interests("e2e_like_alice"); + } + let interests = interests.expect("bob should learn alice's interests"); + assert!(interests.likes.iter().any(|i| i == item)); + assert!( + interests.hates.iter().any(|i| i == "e2e_interest_muzak"), + "hates should come back too, got {interests:?}" + ); + + // A dropped interest stops being reported. + alice.remove_interest(item).expect("alice drops it"); + let deadline = Instant::now() + Duration::from_secs(5); + let mut still_liked = true; + while Instant::now() < deadline && still_liked { + bob.request_user_interests("e2e_like_alice") + .expect("ask again"); + std::thread::sleep(Duration::from_millis(250)); + still_liked = bob + .user_interests("e2e_like_alice") + .is_none_or(|i| i.likes.iter().any(|l| l == item)); + } + assert!( + !still_liked, + "a removed interest should stop being reported" + ); +} + +#[test] +fn recommendations_are_returned_for_our_interests() { + let server = server_or_skip!(); + + // Recommendations come from what *other* users who share an interest also + // like: bob likes both items, so alice, who likes only the first, should + // be recommended the second. + let mut alice = + Client::with_settings(server.settings("e2e_rec_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_rec_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + let shared = "e2e_rec_shared"; + let other = "e2e_rec_other"; + alice.add_interest(shared).expect("alice likes shared"); + bob.add_interest(shared).expect("bob likes shared"); + bob.add_interest(other).expect("bob likes other"); + std::thread::sleep(Duration::from_millis(500)); + + let deadline = Instant::now() + Duration::from_secs(8); + let mut recommended = Vec::new(); + while Instant::now() < deadline + && !recommended + .iter() + .any(|r: &soulseek_rs::Recommendation| r.item == other) + { + alice + .request_recommendations() + .expect("ask for recommendations"); + std::thread::sleep(Duration::from_millis(250)); + recommended = + alice.recommendations().map(|(r, _)| r).unwrap_or_default(); + } + assert!( + recommended.iter().any(|r| r.item == other), + "the other item bob likes should be recommended, got {recommended:?}" + ); + + // The server-wide list (code 56) answers too, with the same shape. + let deadline = Instant::now() + Duration::from_secs(8); + let mut global = None; + while Instant::now() < deadline && global.is_none() { + alice + .request_global_recommendations() + .expect("ask for global recommendations"); + std::thread::sleep(Duration::from_millis(250)); + global = alice.global_recommendations(); + } + let (global_items, _) = global.expect("the server should answer code 56"); + assert!( + global_items.iter().any(|r| r.item == shared), + "an item two users like should show server-wide, got {global_items:?}" + ); + + // And per-item recommendations (code 111) answer for a named item. + let deadline = Instant::now() + Duration::from_secs(8); + let mut item_recs = Vec::new(); + while Instant::now() < deadline && item_recs.is_empty() { + alice + .request_item_recommendations(shared) + .expect("ask about one item"); + std::thread::sleep(Duration::from_millis(250)); + item_recs = alice.item_recommendations(shared); + } + assert!( + item_recs.iter().any(|r| r.item == other), + "people who like {shared} also like {other}, got {item_recs:?}" + ); +} + +#[test] +fn a_ping_leaves_the_session_usable() { + let server = server_or_skip!(); + + // A ping has no reply, so what it must not do is upset the session: the + // very next request has to still be answered. + let mut client = + Client::with_settings(server.settings("e2e_ping_user", "pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + for _ in 0..3 { + client.ping_server().expect("ping"); + std::thread::sleep(Duration::from_millis(100)); + } + + let deadline = Instant::now() + Duration::from_secs(5); + let mut listed = false; + while Instant::now() < deadline && !listed { + client.request_room_list().expect("request room list"); + std::thread::sleep(Duration::from_millis(250)); + listed = !client.room_list().is_empty() + || client.session_loss().is_none() && Instant::now() > deadline; + } + assert!( + client.session_loss().is_none(), + "pings must not cost us the session" + ); +} + +#[test] +fn one_message_reaches_several_users_at_once() { + let server = server_or_skip!(); + + let mut sender = + Client::with_settings(server.settings("e2e_many_sender", "pw")); + let mut first = + Client::with_settings(server.settings("e2e_many_first", "pw")); + let mut second = + Client::with_settings(server.settings("e2e_many_second", "pw")); + for client in [&mut sender, &mut first, &mut second] { + client.connect().expect("connect"); + assert!(client.login().expect("login")); + } + + let body = "one message, two recipients"; + sender + .send_private_message_to_many( + &["e2e_many_first".to_string(), "e2e_many_second".to_string()], + body, + ) + .expect("send to many"); + + for client in [&first, &second] { + let deadline = Instant::now() + Duration::from_secs(5); + let mut got = false; + while Instant::now() < deadline && !got { + got = client + .take_private_messages() + .iter() + .any(|m| m.message() == body); + if !got { + std::thread::sleep(Duration::from_millis(100)); + } + } + assert!(got, "every named recipient should receive the message"); + } +} + +#[test] +fn a_changed_password_is_what_the_next_login_needs() { + let server = server_or_skip!(); + + let user = "e2e_pwchange_user"; + let mut client = Client::with_settings(server.settings(user, "first-pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login registers the account")); + + client + .change_password("second-pw") + .expect("change password"); + std::thread::sleep(Duration::from_millis(500)); + drop(client); + + let mut stale = Client::with_settings(server.settings(user, "first-pw")); + stale.connect().expect("connect with the old password"); + assert!( + !matches!(stale.login(), Ok(true)), + "the old password must stop working" + ); + drop(stale); + + let mut fresh = Client::with_settings(server.settings(user, "second-pw")); + fresh.connect().expect("connect with the new password"); + assert!( + fresh.login().expect("login with the new password"), + "the new password must be accepted" + ); +} + +#[test] +fn a_brokered_dial_we_cannot_complete_tells_the_waiting_peer() { + // The peer asks the server to broker a connection to us but is itself + // unreachable — it advertises a port nothing listens on. Our dial fails, + // and the peer must be told (CantConnectToPeer, code 1001) rather than + // left waiting for a connection that is never coming. + let server = server_or_skip!(); + let addr = format!("{}:{}", server.host, server.port); + + let mut client = + Client::with_settings(server.settings("e2e_cantconn_us", "pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let mut peer = + login_raw(&addr, "e2e_cantconn_peer", "pw").expect("peer login"); + // A port that is free is a port nothing answers on: the dial is refused. + let dead_port = free_port().expect("free port"); + peer.write_all( + &MessageFactory::build_set_wait_port_message(dead_port).get_buffer(), + ) + .expect("set wait port"); + peer.flush().expect("flush wait port"); + + let token = 424_242_u32; + peer.write_all( + &MessageFactory::build_connect_to_peer( + token, + "e2e_cantconn_us", + ConnectionType::P, + ) + .get_buffer(), + ) + .expect("ask the server to broker"); + peer.flush().expect("flush broker request"); + + let mut reply = read_until_code(&mut peer, 1001, Duration::from_secs(20)) + .expect("the peer should be told the connection failed"); + reply.set_pointer(8); + assert_eq!( + reply.read_int32(), + token, + "the reply should quote the token the peer asked with" + ); +} From 65c44cc66b7f70f4d84c2767b83ca3210b7d3cff Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:27:20 +0000 Subject: [PATCH 03/19] feat(protocol): away status, excluded phrases, queue-place requests Three features the client half-had. SetStatus (28) was only ever sent as "online" at login, so there was no way to go away; excluded search phrases (160) were parsed and logged into nowhere, so every client kept spending searches on queries the server was never going to answer; and a queued download could answer a peer asking about its place but could never ask about its own. Each is now driven end to end: the away status seen by another user, a refused query on a stub server that announces phrases (soulfind keeps its filters in a database the suite cannot seed), and a peer's answer landing on the download as its queue position. --- soulseek-rs-lib/src/actor/server_actor.rs | 7 + soulseek-rs-lib/src/client/mod.rs | 31 ++ soulseek-rs-lib/src/client/operations.rs | 5 + soulseek-rs-lib/src/client/rooms.rs | 17 +- soulseek-rs-lib/src/client/search.rs | 26 ++ soulseek-rs-lib/src/client/social.rs | 33 +++ soulseek-rs-lib/src/error.rs | 6 + .../message/server/excluded_search_phrases.rs | 25 +- .../src/message/server/message_factory.rs | 18 ++ soulseek-rs-lib/tests/e2e.rs | 264 ++++++++++++++++++ 10 files changed, 429 insertions(+), 3 deletions(-) diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 6fb2636..16bd473 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -227,6 +227,8 @@ pub enum ServerMessage { CantConnectToPeer { token: u32, }, + /// Phrases the server refuses to search for (code 160). + ExcludedSearchPhrases(Vec), } pub struct ServerActor { @@ -692,6 +694,11 @@ impl ServerActor { token, }); } + ServerMessage::ExcludedSearchPhrases(phrases) => { + self.forward_to_client(ClientOperation::ExcludedSearchPhrases( + phrases, + )); + } other => { error!("[server] unroutable message: {:?}", other); } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 75dd60c..61ad87a 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -368,6 +368,8 @@ pub enum ClientOperation { CantConnectToPeer { token: u32, }, + /// Phrases the server refuses to search for (code 160). + ExcludedSearchPhrases(Vec), /// A parent candidate told us how deep it sits. `link` says which dial /// to that user is talking. ParentBranchLevel { @@ -469,6 +471,9 @@ pub struct ClientContext { privileged_users: HashSet, /// Seconds of our own privileges left (code 92), once we have asked. own_privileges: Option, + /// Phrases the server will not search for (code 160). A query carrying one + /// is refused locally rather than spent on the server. + excluded_search_phrases: Vec, /// Peers waiting for one of our upload slots. upload_queue: Vec, /// Arrival counter for the queue's first-come tie-break. @@ -575,6 +580,7 @@ impl ClientContext { wishlist_interval: None, privileged_users: HashSet::new(), own_privileges: None, + excluded_search_phrases: Vec::new(), upload_queue: Vec::new(), upload_seq: 0, upload_slots: DEFAULT_UPLOAD_SLOTS, @@ -725,6 +731,31 @@ impl ClientContext { self.user_interests.get(username).cloned() } + /// Record the phrases the server refuses to search for (code 160). + pub fn set_excluded_search_phrases(&mut self, phrases: Vec) { + self.excluded_search_phrases = phrases; + } + + /// The phrases the server refuses to search for. + #[must_use] + pub fn excluded_search_phrases(&self) -> Vec { + self.excluded_search_phrases.clone() + } + + /// The first excluded phrase `query` contains, if any. Matching is + /// case-insensitive and on substrings, which is how the server applies its + /// own filter. + #[must_use] + pub fn excluded_phrase_in(&self, query: &str) -> Option { + let haystack = query.to_lowercase(); + self.excluded_search_phrases + .iter() + .find(|phrase| { + !phrase.is_empty() && haystack.contains(&phrase.to_lowercase()) + }) + .cloned() + } + /// Forget any interests cached for `username`, so a poll after a fresh /// request cannot mistake the previous answer for this one. pub fn invalidate_user_interests(&mut self, username: &str) { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index d53c33e..b2dee36 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -816,6 +816,11 @@ impl Client { ctx.apply_user_interests(interests); } } + ClientOperation::ExcludedSearchPhrases(phrases) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.set_excluded_search_phrases(phrases); + } + } ClientOperation::CantConnectToPeer { token } => { // The peer we asked the server to broker gave up. The // token is the correlation we sent, so it names the diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs index 6ea95d9..8fee72c 100644 --- a/soulseek-rs-lib/src/client/rooms.rs +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -234,9 +234,22 @@ impl Client { pub fn browse_user(&self, username: &str) -> Result<()> { let request = crate::message::server::MessageFactory::build_get_share_file_list(); + self.context.write_safe()?.mark_browse_pending(username); + self.send_to_peer_or_queue(username, request) + } + + /// Send `request` to `username` over their control connection, or hold it + /// until one exists — asking the server for their address so it does. + /// + /// # Errors + /// [`SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub(super) fn send_to_peer_or_queue( + &self, + username: &str, + request: crate::message::Message, + ) -> Result<()> { let (connected, registry) = { - let mut ctx = self.context.write_safe()?; - ctx.mark_browse_pending(username); + let ctx = self.context.read_safe()?; ( ctx.peer_registry .as_ref() diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs index 9e54136..cf9ed10 100644 --- a/soulseek-rs-lib/src/client/search.rs +++ b/soulseek-rs-lib/src/client/search.rs @@ -62,6 +62,9 @@ impl Client { let Some(handle) = &self.server_handle else { return Err(SoulseekRs::NotConnected); }; + if let Some(phrase) = self.excluded_phrase_in(query) { + return Err(SoulseekRs::SearchPhraseExcluded(phrase)); + } let token = next_search_token(); self.context.write_safe()?.searches.insert( @@ -119,6 +122,9 @@ impl Client { if self.server_handle.is_none() { return Err(SoulseekRs::NotConnected); } + if let Some(phrase) = self.excluded_phrase_in(query) { + return Err(SoulseekRs::SearchPhraseExcluded(phrase)); + } let token = next_search_token(); self.context.write_safe()?.searches.insert( query.to_string(), @@ -130,6 +136,26 @@ impl Client { Ok(token) } + /// The phrases the server refuses to search for (code 160), as last + /// announced. Empty until the server has said. + #[must_use] + pub fn excluded_search_phrases(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.excluded_search_phrases()) + .unwrap_or_default() + } + + /// The excluded phrase `query` carries, if any — what a search would be + /// refused for. + #[must_use] + pub fn excluded_phrase_in(&self, query: &str) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.excluded_phrase_in(query)) + } + /// Let responses accumulate for `timeout`, or until cancelled. /// /// Peers answer a search over their own connections whenever they get round diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 5d402e4..488deb8 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -21,6 +21,39 @@ impl Client { self.send_server_message(MessageFactory::build_server_ping()) } + /// Set our online status (server code 28): away, or back to online. + /// + /// Other clients see it in their `GetUserStatus` and watch replies, and + /// the server uses it when it decides who to hand a search to. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn set_away(&self, away: bool) -> Result<()> { + let status = u32::from(!away) + 1; // away = 1, online = 2 + self.send_server_message(MessageFactory::build_set_status_message( + status, + )) + } + + /// Ask `username` where the file we queued with them sits (peer code 51). + /// + /// The answer arrives asynchronously and lands on the download, readable + /// through [`Client::downloads`] as its queue position. A peer we have no + /// connection to is resolved and the request delivered once it is up, the + /// same way a browse request is. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection, + /// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub fn request_place_in_queue( + &self, + username: &str, + filename: &str, + ) -> Result<()> { + let request = MessageFactory::build_place_in_queue_request(filename); + self.send_to_peer_or_queue(username, request) + } + /// Set our ticker in `room` — the one-line message other members see. /// Passing an empty `ticker` clears ours. /// diff --git a/soulseek-rs-lib/src/error.rs b/soulseek-rs-lib/src/error.rs index b25df46..5b3b722 100644 --- a/soulseek-rs-lib/src/error.rs +++ b/soulseek-rs-lib/src/error.rs @@ -20,6 +20,9 @@ pub enum SoulseekRs { CompressionError(String), /// A lock was poisoned by a panic in another thread LockPoisoned, + /// The query carries a phrase the server refuses to search for (code 160), + /// so sending it would spend a search on a query nothing answers. + SearchPhraseExcluded(String), } impl fmt::Display for SoulseekRs { @@ -41,6 +44,9 @@ impl fmt::Display for SoulseekRs { Self::CompressionError(msg) => { write!(f, "Compression error: {msg}") } + Self::SearchPhraseExcluded(phrase) => { + write!(f, "The server excludes searches for \"{phrase}\"") + } Self::LockPoisoned => { write!(f, "Lock poisoned by panicking thread") } diff --git a/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs b/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs index 6779bc1..2233529 100644 --- a/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs +++ b/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs @@ -12,7 +12,7 @@ impl MessageHandler for ExcludedSearchPhrasesHandler { 160 } - fn handle(&self, message: &mut Message, _sender: Sender) { + fn handle(&self, message: &mut Message, sender: Sender) { let item_count = message.read_int32(); let mut exluded_phrases: Vec = Vec::new(); @@ -25,6 +25,11 @@ impl MessageHandler for ExcludedSearchPhrasesHandler { exluded_phrases.push(phrase); } debug!("Excluded search phrases: {:?}", exluded_phrases); + // Kept, not just logged: the server rejects a search carrying one of + // these, so a client that forgets them spends its search allowance on + // queries that were never going to be answered. + let _ = + sender.send(ServerMessage::ExcludedSearchPhrases(exluded_phrases)); } } @@ -43,4 +48,22 @@ mod tests { }); ExcludedSearchPhrasesHandler.handle(&mut message, tx); } + + #[test] + fn the_phrases_are_reported_not_only_logged() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(2); + m.write_string("banned"); + m.write_string("blocked"); + }); + + ExcludedSearchPhrasesHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ExcludedSearchPhrases(phrases)) => { + assert_eq!(phrases, ["banned", "blocked"]); + } + other => panic!("unexpected: {other:?}"), + } + } } diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index 0f7386b..cc3feb7 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -301,6 +301,15 @@ impl MessageFactory { .clone() } + /// Ask a peer where the file we queued with them sits (peer code 51). + #[must_use] + pub fn build_place_in_queue_request(filename: &str) -> Message { + Message::new() + .write_int32(51) + .write_string(filename) + .clone() + } + /// A keepalive ping (server code 32, no body). The server sends no reply; /// its only job is to keep a quiet connection from being reaped by a NAT /// or the server's own idle timeout, the way other clients ping. @@ -687,6 +696,15 @@ fn an_upload_speed_report_carries_the_rate() { assert_eq!(decoded.read_int32(), 900); } +#[test] +fn a_place_in_queue_request_names_the_file() { + let message = MessageFactory::build_place_in_queue_request("a\\b.mp3"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 51); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "a\\b.mp3"); +} + #[test] fn a_ping_is_just_its_code() { let message = MessageFactory::build_server_ping(); diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 1f9d3ee..120291e 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4319,3 +4319,267 @@ fn a_brokered_dial_we_cannot_complete_tells_the_waiting_peer() { "the reply should quote the token the peer asked with" ); } + +// --------------------------------------------------------------------------- +// Features that were implemented but never driven end to end: the away +// status, the phrases the server refuses to search for, and asking a peer +// where our queued file sits. +// --------------------------------------------------------------------------- + +#[test] +fn going_away_is_visible_to_another_user() { + use soulseek_rs::types::UserStatus; + let server = server_or_skip!(); + + let mut alice = + Client::with_settings(server.settings("e2e_away_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_away_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + // Online to begin with, as the post-login handshake announces. + let status_of = |client: &Client| -> Option { + client + .user_info("e2e_away_alice") + .and_then(|info| info.presence) + .map(|presence| presence.status) + }; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && status_of(&bob) != Some(UserStatus::Online) + { + bob.request_user_info("e2e_away_alice").expect("ask"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!(status_of(&bob), Some(UserStatus::Online)); + + alice.set_away(true).expect("alice goes away"); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline && status_of(&bob) != Some(UserStatus::Away) + { + bob.request_user_info("e2e_away_alice").expect("ask again"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!( + status_of(&bob), + Some(UserStatus::Away), + "the away status should reach another user" + ); + + // And coming back is visible too. + alice.set_away(false).expect("alice comes back"); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && status_of(&bob) != Some(UserStatus::Online) + { + bob.request_user_info("e2e_away_alice") + .expect("ask once more"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!(status_of(&bob), Some(UserStatus::Online)); +} + +/// A minimal server that answers a login and then sends `after_login`. +/// +/// soulfind keeps its search filters in its database, which the suite has no +/// way to seed, so the excluded-phrase path is driven from a stub that speaks +/// just enough of the protocol to deliver code 160 on the wire. +fn stub_server_sending( + after_login: Vec, +) -> (String, std::thread::JoinHandle<()>) { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind stub server"); + let addr = listener.local_addr().expect("stub addr"); + let handle = std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("stub read timeout"); + // Wait for the login (code 1) before answering it. + loop { + match read_framed(&mut stream) { + Ok(msg) if msg.get_message_code() == 1 => break, + Ok(_) => {} + Err(_) => return, + } + } + let mut ok = Message::new(); + ok.write_int32(1) + .write_int8(1) + .write_string("stub greeting") + .write_int32(0) + .write_string("") + .write_int8(0); + if stream.write_all(&ok.get_buffer()).is_err() { + return; + } + for message in after_login { + if stream.write_all(&message.get_buffer()).is_err() { + return; + } + } + let _ = stream.flush(); + // Hold the connection open so the client stays logged in. + std::thread::sleep(Duration::from_secs(10)); + }); + (format!("{}:{}", addr.ip(), addr.port()), handle) +} + +#[test] +fn an_excluded_phrase_is_refused_before_a_search_is_spent() { + let mut phrases = Message::new(); + phrases + .write_int32(160) + .write_int32(2) + .write_string("banned-phrase") + .write_string("blocked"); + let (addr, stub) = stub_server_sending(vec![phrases]); + let (host, port) = addr.rsplit_once(':').expect("stub addr"); + + let mut client = Client::with_settings(ClientSettings { + username: "e2e_excluded".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new( + host.to_string(), + port.parse().expect("stub port"), + ), + enable_listen: false, + listen_port: 0, + shared_directories: Vec::new(), + version: ClientVersion::default(), + }); + client.connect().expect("connect to stub"); + assert!(client.login().expect("login to stub")); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && client.excluded_search_phrases().is_empty() + { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!( + client.excluded_search_phrases(), + ["banned-phrase", "blocked"], + "the announced phrases should be kept" + ); + + // A query carrying one is refused here rather than spent on the server, + // and the match is case-insensitive on substrings, as the server's is. + match client.search("A BANNED-PHRASE query", Duration::from_millis(100)) { + Err(soulseek_rs::SoulseekRs::SearchPhraseExcluded(phrase)) => { + assert_eq!(phrase, "banned-phrase"); + } + other => panic!("expected a refusal, got {other:?}"), + } + // A clean query is not refused. + assert!( + client + .search("something else", Duration::from_millis(100)) + .is_ok() + ); + + drop(client); + let _ = stub.join(); +} + +#[test] +fn a_queued_download_can_ask_where_it_sits() { + // We queue a file with a peer that never starts it, then ask where it + // sits (peer code 51). The peer's answer (code 44) must land on the + // download as its queue position. + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_place_asker", + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let filename = "waiting_in_line.mp3"; + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (asked_tx, asked_rx) = std::sync::mpsc::channel(); + let listen_addr = format!("127.0.0.1:{listen_port}"); + let peer = std::thread::spawn(move || -> std::io::Result<()> { + let mut p = connect_retry(&listen_addr, Duration::from_secs(5))?; + p.set_read_timeout(Some(Duration::from_secs(15)))?; + p.write_all(&peer_init_bytes("e2e_place_peer", "P", 0))?; + p.flush()?; + let _ = ready_tx.send(()); + + // Sit on the queue request, then answer the place request with 7. + loop { + let mut msg = read_framed(&mut p)?; + // A QueueUpload (43) is accepted but never started: the file + // stays in line, which is the state being asked about. + if msg.get_message_code() == 51 { + msg.set_pointer(8); + let asked = msg.read_string(); + let mut reply = Message::new(); + reply.write_int32(44).write_string(&asked).write_int32(7); + p.write_all(&reply.get_buffer())?; + p.flush()?; + let _ = asked_tx.send(asked); + break; + } + } + std::thread::sleep(Duration::from_secs(1)); + Ok(()) + }); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock peer P connection"); + std::thread::sleep(Duration::from_millis(1000)); + + let download_dir = unique_download_dir(); + let (_download, _status_rx) = client + .download( + filename.to_string(), + "e2e_place_peer".to_string(), + 10, + download_dir.display().to_string(), + ) + .expect("start download"); + + // Ask, retrying until the control connection carries it. + let deadline = Instant::now() + Duration::from_secs(10); + let mut asked = None; + while Instant::now() < deadline && asked.is_none() { + client + .request_place_in_queue("e2e_place_peer", filename) + .expect("ask for our place"); + asked = asked_rx.recv_timeout(Duration::from_millis(500)).ok(); + } + assert_eq!( + asked.as_deref(), + Some(filename), + "the peer should be asked about the file we queued" + ); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut place = None; + while Instant::now() < deadline && place.is_none() { + place = client + .get_all_downloads() + .into_iter() + .find(|d| d.filename == filename) + .and_then(|d| d.queue_position); + if place.is_none() { + std::thread::sleep(Duration::from_millis(100)); + } + } + assert_eq!( + place, + Some(7), + "the peer's answer should land on the download" + ); + + let _ = peer.join(); + let _ = std::fs::remove_dir_all(download_dir); +} From 19791d1699a38b4b1d9b7da74719d62a117443ad Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:33:59 +0000 Subject: [PATCH 04/19] feat(rooms): private room membership, operators and refusals Private rooms were unreachable: the client could ask to join one but had no way to invite, promote, resign or be told what happened, and a room name already owned by someone else came back as silence rather than a refusal (code 1003). Adds the rosters the server pushes after login (133/148), the changes it narrates to them (134/135, 143/144), our own standing (139/140, 145/146), and the messages that drive them, with the rosters kept current in client state. The daemon protocol and the TUI room transcript carry the new events rather than dropping them. --- soulseek-rs-lib/src/actor/server_actor.rs | 86 +++++ soulseek-rs-lib/src/client/mod.rs | 91 ++++- soulseek-rs-lib/src/client/social.rs | 131 ++++++++ soulseek-rs-lib/src/client/tests.rs | 83 +++++ .../src/message/server/message_factory.rs | 118 +++++++ soulseek-rs-lib/src/message/server/mod.rs | 5 + .../src/message/server/private_rooms.rs | 317 ++++++++++++++++++ soulseek-rs-lib/src/types.rs | 22 ++ soulseek-rs-lib/tests/e2e.rs | 125 +++++++ soulseek-rs/docs/openrpc.json | 228 +++++++++++++ soulseek-rs/src/daemon/proto/dto.rs | 186 ++++++++++ soulseek-rs/src/models/rooms.rs | 75 +++++ 12 files changed, 1466 insertions(+), 1 deletion(-) create mode 100644 soulseek-rs-lib/src/message/server/private_rooms.rs diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 16bd473..bd43d56 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -26,6 +26,10 @@ use crate::message::server::UserJoinedRoomHandler; use crate::message::server::UserLeftRoomHandler; use crate::message::server::WatchUserHandler; use crate::message::server::WishListIntervalHandler; +use crate::message::server::{ + CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler, + RoomOperatorsHandler, RoomRosterChangeHandler, +}; use crate::message::server::{ GetUserStatsHandler, GetUserStatusHandler, RoomListHandler, }; @@ -229,6 +233,36 @@ pub enum ServerMessage { }, /// Phrases the server refuses to search for (code 160). ExcludedSearchPhrases(Vec), + /// Who may enter a private room (code 133). + PrivateRoomMembers { + room: String, + users: Vec, + }, + /// Who runs a private room (code 148). + PrivateRoomOperators { + room: String, + users: Vec, + }, + /// One user joined or left a private room's member or operator roster + /// (codes 134/135 and 143/144). + PrivateRoomRosterChanged { + room: String, + username: String, + /// True for the member roster, false for the operator roster. + members: bool, + added: bool, + }, + /// Our own membership (139/140) or operatorship (145/146) of a private + /// room was granted or revoked. + OwnRoomStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created (code 1003). + CantCreateRoom { + room: String, + }, } pub struct ServerActor { @@ -386,6 +420,19 @@ impl ServerActor { handlers.register_handler(GetPeerAddressHandler); handlers.register_handler(ConnectToPeerHandler); handlers.register_handler(CantConnectToPeerHandler); + handlers.register_handler(RoomMembersHandler); + handlers.register_handler(RoomOperatorsHandler); + handlers.register_handler(RoomRosterChangeHandler::member_added()); + handlers.register_handler(RoomRosterChangeHandler::member_removed()); + handlers.register_handler(RoomRosterChangeHandler::operator_added()); + handlers.register_handler(RoomRosterChangeHandler::operator_removed()); + handlers.register_handler(OwnRoomStandingHandler::membership_granted()); + handlers.register_handler(OwnRoomStandingHandler::membership_revoked()); + handlers + .register_handler(OwnRoomStandingHandler::operatorship_granted()); + handlers + .register_handler(OwnRoomStandingHandler::operatorship_revoked()); + handlers.register_handler(CantCreateRoomHandler); handlers.register_handler(RoomTickersHandler); handlers.register_handler(RoomTickerAddedHandler); handlers.register_handler(RoomTickerRemovedHandler); @@ -694,6 +741,45 @@ impl ServerActor { token, }); } + ServerMessage::PrivateRoomMembers { room, users } => { + self.forward_room_event(RoomEvent::PrivateMembers { + room, + users, + }); + } + ServerMessage::PrivateRoomOperators { room, users } => { + self.forward_room_event(RoomEvent::PrivateOperators { + room, + users, + }); + } + ServerMessage::PrivateRoomRosterChanged { + room, + username, + members, + added, + } => { + self.forward_room_event(RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + }); + } + ServerMessage::OwnRoomStandingChanged { + room, + members, + granted, + } => { + self.forward_room_event(RoomEvent::OwnStandingChanged { + room, + members, + granted, + }); + } + ServerMessage::CantCreateRoom { room } => { + self.forward_room_event(RoomEvent::CantCreate { room }); + } ServerMessage::ExcludedSearchPhrases(phrases) => { self.forward_to_client(ClientOperation::ExcludedSearchPhrases( phrases, diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 61ad87a..a6e54c0 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -440,6 +440,11 @@ pub struct ClientContext { /// Per-member statistics for each joined room, from the stat vectors the /// server sends alongside the membership list. room_member_stats: HashMap>, + /// Who may enter each private room we belong to (code 133), kept current + /// from the later roster changes. + private_room_members: HashMap>, + /// Who runs each of those rooms (code 148). + private_room_operators: HashMap>, /// The ticker board of each joined room, kept current from the board sent /// on join (code 113) and the later add/remove events (114/115). room_tickers: HashMap>, @@ -490,6 +495,15 @@ pub struct ClientContext { upload_events: Vec, actor_system: Arc, } +/// A roster sorted and de-duplicated, so membership lookups can binary-search +/// it and a repeated name cannot appear twice. +fn sorted_unique(users: &[String]) -> Vec { + let mut users = users.to_vec(); + users.sort(); + users.dedup(); + users +} + impl Default for ClientContext { fn default() -> Self { Self::new() @@ -569,6 +583,8 @@ impl ClientContext { room_members: HashMap::new(), room_member_stats: HashMap::new(), room_tickers: HashMap::new(), + private_room_members: HashMap::new(), + private_room_operators: HashMap::new(), recommendations: None, global_recommendations: None, item_recommendations: HashMap::new(), @@ -641,11 +657,84 @@ impl ClientContext { board.retain(|t| &t.username != username); } } - RoomEvent::Message { .. } | RoomEvent::GlobalMessage { .. } => {} + RoomEvent::PrivateMembers { room, users } => { + self.private_room_members + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateOperators { room, users } => { + self.private_room_operators + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + let roster = if *members { + self.private_room_members.entry(room.clone()).or_default() + } else { + self.private_room_operators.entry(room.clone()).or_default() + }; + match (added, roster.binary_search(username)) { + (true, Err(at)) => roster.insert(at, username.clone()), + (false, Ok(at)) => { + roster.remove(at); + } + _ => {} + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + // Our own standing is rendered from the same rosters, so a + // revocation must drop the room rather than leave a roster + // we are no longer part of looking current. + if !granted { + // Losing membership takes the operator roster with it: + // a room we cannot enter has no roster worth showing. + if *members { + self.private_room_members.remove(room); + } + self.private_room_operators.remove(room); + } + } + RoomEvent::Message { .. } + | RoomEvent::GlobalMessage { .. } + | RoomEvent::CantCreate { .. } => {} } self.room_events.push(event); } + /// Who may enter the private room `room`, as last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + self.private_room_members + .get(room) + .cloned() + .unwrap_or_default() + } + + /// Who runs the private room `room`, as last reported. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + self.private_room_operators + .get(room) + .cloned() + .unwrap_or_default() + } + + /// The private rooms we belong to, in a stable order. + #[must_use] + pub fn private_rooms(&self) -> Vec { + let mut rooms: Vec = + self.private_room_members.keys().cloned().collect(); + rooms.sort(); + rooms + } + /// The ticker board of `room` as last reported by the server. #[must_use] pub fn room_tickers(&self, room: &str) -> Vec { diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 488deb8..ef0dda6 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -21,6 +21,137 @@ impl Client { self.send_server_message(MessageFactory::build_server_ping()) } + /// Join a private room, creating it (and owning it) if the name is free. + /// + /// A name already taken by someone else's private room comes back as a + /// [`crate::RoomEvent::CantCreate`] rather than a join. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn join_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_join_room(room, true)) + } + + /// Invite `username` into the private room we own (code 134). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_room_member(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_room_member( + room, username, + )) + } + + /// Remove `username` from the private room we own (code 135). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_room_member(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_room_member( + room, username, + )) + } + + /// Give up our own membership of a private room (code 136). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn leave_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_cancel_room_membership( + room, + )) + } + + /// Give up ownership of a private room, which deletes it (code 137). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn disband_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_cancel_room_ownership( + room, + )) + } + + /// Say whether we accept private-room invitations (code 141). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn set_room_invitations_enabled(&self, enabled: bool) -> Result<()> { + self.send_server_message(MessageFactory::build_enable_room_invitations( + enabled, + )) + } + + /// Make `username` an operator of our private room (code 143). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_room_operator(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_room_operator( + room, username, + )) + } + + /// Take operator status away from `username` (code 144). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_room_operator( + &self, + room: &str, + username: &str, + ) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_room_operator( + room, username, + )) + } + + /// Give up our own operator status in `room` (code 147). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn resign_room_operatorship(&self, room: &str) -> Result<()> { + self.send_server_message( + MessageFactory::build_cancel_room_operatorship(room), + ) + } + + /// Who may enter the private room `room`, as the server last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_room_members(room), + Err(e) => { + error!("[client] private_room_members: {}", e); + Vec::new() + } + } + } + + /// Who runs the private room `room`. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_room_operators(room), + Err(e) => { + error!("[client] private_room_operators: {}", e); + Vec::new() + } + } + } + + /// The private rooms we belong to. + #[must_use] + pub fn private_rooms(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_rooms(), + Err(e) => { + error!("[client] private_rooms: {}", e); + Vec::new() + } + } + } + /// Set our online status (server code 28): away, or back to online. /// /// Other clients see it in their `GetUserStatus` and watch replies, and diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index b1b8285..eefd4ff 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -951,3 +951,86 @@ fn asking_again_about_a_users_interests_drops_the_previous_answer() { "a stale answer must not be mistaken for the next one" ); } + +#[test] +fn a_private_room_roster_tracks_who_is_added_and_removed() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["bob".into(), "alice".into(), "bob".into()], + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["alice".to_string(), "bob".to_string()], + "a roster is sorted and free of repeats" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "carol".into(), + members: true, + added: true, + }); + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: true, + added: false, + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["bob".to_string(), "carol".to_string()] + ); + + // Operators are a separate roster in the same room. + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "bob".into(), + members: false, + added: true, + }); + assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]); + assert_eq!(ctx.private_rooms(), vec!["club".to_string()]); +} + +#[test] +fn revoked_membership_drops_the_room_we_can_no_longer_see() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: true, + granted: false, + }); + assert!(ctx.private_rooms().is_empty()); + assert!(ctx.private_room_operators("club").is_empty()); +} + +#[test] +fn losing_operatorship_keeps_the_membership_it_does_not_touch() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: false, + granted: false, + }); + assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); + assert!(ctx.private_room_operators("club").is_empty()); +} diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index cc3feb7..4ea1ebe 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -310,6 +310,73 @@ impl MessageFactory { .clone() } + /// Invite `username` into the private room we own (server code 134). + #[must_use] + pub fn build_add_room_member(room: &str, username: &str) -> Message { + Message::new() + .write_int32(134) + .write_string(room) + .write_string(username) + .clone() + } + + /// Remove `username` from the private room we own (server code 135). + #[must_use] + pub fn build_remove_room_member(room: &str, username: &str) -> Message { + Message::new() + .write_int32(135) + .write_string(room) + .write_string(username) + .clone() + } + + /// Give up our own membership of a private room (server code 136). + #[must_use] + pub fn build_cancel_room_membership(room: &str) -> Message { + Message::new().write_int32(136).write_string(room).clone() + } + + /// Give up ownership of a private room, which deletes it (code 137). + #[must_use] + pub fn build_cancel_room_ownership(room: &str) -> Message { + Message::new().write_int32(137).write_string(room).clone() + } + + /// Say whether we accept private-room invitations (server code 141). + #[must_use] + pub fn build_enable_room_invitations(enabled: bool) -> Message { + Message::new() + .write_int32(141) + .write_int8(u8::from(enabled)) + .clone() + } + + /// Make `username` an operator of our private room (server code 143). + #[must_use] + pub fn build_add_room_operator(room: &str, username: &str) -> Message { + Message::new() + .write_int32(143) + .write_string(room) + .write_string(username) + .clone() + } + + /// Take operator status away from `username` (server code 144). + #[must_use] + pub fn build_remove_room_operator(room: &str, username: &str) -> Message { + Message::new() + .write_int32(144) + .write_string(room) + .write_string(username) + .clone() + } + + /// Give up our own operator status in a room (server code 147). + #[must_use] + pub fn build_cancel_room_operatorship(room: &str) -> Message { + Message::new().write_int32(147).write_string(room).clone() + } + /// A keepalive ping (server code 32, no body). The server sends no reply; /// its only job is to keep a quiet connection from being reaped by a NAT /// or the server's own idle timeout, the way other clients ping. @@ -830,3 +897,54 @@ fn a_multi_user_message_counts_its_recipients_first() { assert_eq!(decoded.read_string(), "bob"); assert_eq!(decoded.read_string(), "hi all"); } + +#[test] +fn the_private_room_roster_messages_carry_room_then_user() { + for (message, code) in [ + (MessageFactory::build_add_room_member("club", "bob"), 134), + (MessageFactory::build_remove_room_member("club", "bob"), 135), + (MessageFactory::build_add_room_operator("club", "bob"), 143), + ( + MessageFactory::build_remove_room_operator("club", "bob"), + 144, + ), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + assert_eq!(decoded.read_string(), "bob"); + } +} + +#[test] +fn giving_up_a_room_standing_names_only_the_room() { + for (message, code) in [ + (MessageFactory::build_cancel_room_membership("club"), 136), + (MessageFactory::build_cancel_room_ownership("club"), 137), + (MessageFactory::build_cancel_room_operatorship("club"), 147), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + } +} + +#[test] +fn room_invitations_are_toggled_by_a_single_byte() { + let message = MessageFactory::build_enable_room_invitations(true); + assert_eq!(message.get_data(), [141, 0, 0, 0, 1]); + let message = MessageFactory::build_enable_room_invitations(false); + assert_eq!(message.get_data(), [141, 0, 0, 0, 0]); +} + +#[test] +fn a_private_join_sets_the_private_flag() { + let message = MessageFactory::build_join_room("club", true); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 14); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + assert_eq!(decoded.read_int32(), 1); +} diff --git a/soulseek-rs-lib/src/message/server/mod.rs b/soulseek-rs-lib/src/message/server/mod.rs index 76eb9e4..41a4f77 100644 --- a/soulseek-rs-lib/src/message/server/mod.rs +++ b/soulseek-rs-lib/src/message/server/mod.rs @@ -14,6 +14,7 @@ mod message_factory; mod message_user; mod parent_min_speed; mod parent_speed_ratio; +mod private_rooms; mod privileged_users; mod relogged; mod room_list; @@ -47,6 +48,10 @@ pub use message_factory::MessageFactory; pub use message_user::MessageUser; pub use parent_min_speed::ParentMinSpeedHandler; pub use parent_speed_ratio::ParentSpeedRatioHandler; +pub use private_rooms::{ + CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler, + RoomOperatorsHandler, RoomRosterChangeHandler, +}; pub use privileged_users::{CheckPrivilegesHandler, PrivilegedUsersHandler}; pub use relogged::ReloggedHandler; pub use room_list::RoomListHandler; diff --git a/soulseek-rs-lib/src/message/server/private_rooms.rs b/soulseek-rs-lib/src/message/server/private_rooms.rs new file mode 100644 index 0000000..18d8aa6 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/private_rooms.rs @@ -0,0 +1,317 @@ +//! Private rooms: who may enter one and who runs it. +//! +//! The server pushes the membership and operator rosters of every private +//! room we belong to right after login (codes 133 and 148), then narrates +//! changes to them: a member added or removed (134/135), our own membership +//! or operatorship granted or revoked (139/140, 145/146), and a room we asked +//! for that could not be created (1003). + +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// Read a `[room][count][username]...` roster, stopping at the payload's end. +fn read_roster(message: &mut Message) -> (String, Vec) { + let room = message.read_string(); + let count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + if username.is_empty() { + break; + } + users.push(username); + } + (room, users) +} + +/// `RoomMembers` (code 133): who may enter a private room. +pub struct RoomMembersHandler; + +impl MessageHandler for RoomMembersHandler { + fn get_code(&self) -> u32 { + 133 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let (room, users) = read_roster(message); + let _ = sender.send(ServerMessage::PrivateRoomMembers { room, users }); + } +} + +/// `RoomOperators` (code 148): who runs a private room. +pub struct RoomOperatorsHandler; + +impl MessageHandler for RoomOperatorsHandler { + fn get_code(&self) -> u32 { + 148 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let (room, users) = read_roster(message); + let _ = + sender.send(ServerMessage::PrivateRoomOperators { room, users }); + } +} + +/// One user added to (`added`) or removed from a private room's roster: +/// members (134/135) or operators (143/144). +pub struct RoomRosterChangeHandler { + code: u32, + members: bool, + added: bool, +} + +impl RoomRosterChangeHandler { + #[must_use] + pub const fn member_added() -> Self { + Self { + code: 134, + members: true, + added: true, + } + } + + #[must_use] + pub const fn member_removed() -> Self { + Self { + code: 135, + members: true, + added: false, + } + } + + #[must_use] + pub const fn operator_added() -> Self { + Self { + code: 143, + members: false, + added: true, + } + } + + #[must_use] + pub const fn operator_removed() -> Self { + Self { + code: 144, + members: false, + added: false, + } + } +} + +impl MessageHandler for RoomRosterChangeHandler { + fn get_code(&self) -> u32 { + self.code + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = sender.send(ServerMessage::PrivateRoomRosterChanged { + room, + username, + members: self.members, + added: self.added, + }); + } +} + +/// Our own standing in a private room changed: membership granted (139) or +/// revoked (140), operatorship granted (145) or revoked (146). +pub struct OwnRoomStandingHandler { + code: u32, + members: bool, + granted: bool, +} + +impl OwnRoomStandingHandler { + #[must_use] + pub const fn membership_granted() -> Self { + Self { + code: 139, + members: true, + granted: true, + } + } + + #[must_use] + pub const fn membership_revoked() -> Self { + Self { + code: 140, + members: true, + granted: false, + } + } + + #[must_use] + pub const fn operatorship_granted() -> Self { + Self { + code: 145, + members: false, + granted: true, + } + } + + #[must_use] + pub const fn operatorship_revoked() -> Self { + Self { + code: 146, + members: false, + granted: false, + } + } +} + +impl MessageHandler for OwnRoomStandingHandler { + fn get_code(&self) -> u32 { + self.code + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::OwnRoomStandingChanged { + room, + members: self.members, + granted: self.granted, + }); + } +} + +/// `CantCreateRoom` (code 1003): the room we asked to join could not be +/// created — the name is taken by a private room we are not a member of, or +/// it is otherwise refused. +pub struct CantCreateRoomHandler; + +impl MessageHandler for CantCreateRoomHandler { + fn get_code(&self) -> u32 { + 1003 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::CantCreateRoom { room }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_member_roster_lists_the_rooms_members() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_int32(2); + m.write_string("alice"); + m.write_string("bob"); + }); + + RoomMembersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomMembers { room, users }) => { + assert_eq!(room, "club"); + assert_eq!(users, ["alice", "bob"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_roster_count_beyond_the_payload_stops_at_the_data() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_int32(u32::MAX); + m.write_string("alice"); + }); + + RoomOperatorsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomOperators { users, .. }) => { + assert_eq!(users, ["alice"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn each_roster_change_reports_which_roster_and_which_way() { + for (handler, members, added) in [ + (RoomRosterChangeHandler::member_added(), true, true), + (RoomRosterChangeHandler::member_removed(), true, false), + (RoomRosterChangeHandler::operator_added(), false, true), + (RoomRosterChangeHandler::operator_removed(), false, false), + ] { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_string("alice"); + }); + + handler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomRosterChanged { + room, + username, + members: got_members, + added: got_added, + }) => { + assert_eq!(room, "club"); + assert_eq!(username, "alice"); + assert_eq!((got_members, got_added), (members, added)); + } + other => panic!("unexpected: {other:?}"), + } + } + } + + #[test] + fn our_own_standing_reports_the_room_and_the_change() { + for (handler, members, granted) in [ + (OwnRoomStandingHandler::membership_granted(), true, true), + (OwnRoomStandingHandler::membership_revoked(), true, false), + (OwnRoomStandingHandler::operatorship_granted(), false, true), + (OwnRoomStandingHandler::operatorship_revoked(), false, false), + ] { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + }); + + handler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::OwnRoomStandingChanged { + room, + members: got_members, + granted: got_granted, + }) => { + assert_eq!(room, "club"); + assert_eq!((got_members, got_granted), (members, granted)); + } + other => panic!("unexpected: {other:?}"), + } + } + } + + #[test] + fn a_refused_room_names_itself() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("taken"); + }); + + CantCreateRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::CantCreateRoom { room }) => { + assert_eq!(room, "taken"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index c057acf..8fa98e1 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -414,6 +414,28 @@ pub enum RoomEvent { username: String, message: String, }, + /// Who may enter a private room (code 133). + PrivateMembers { room: String, users: Vec }, + /// Who runs a private room (code 148). + PrivateOperators { room: String, users: Vec }, + /// One user joined or left a private room's member roster (`members` + /// true, codes 134/135) or its operator roster (143/144). + PrivateRosterChanged { + room: String, + username: String, + members: bool, + added: bool, + }, + /// Our own membership (`members` true, codes 139/140) or operatorship + /// (145/146) of a private room was granted or revoked. + OwnStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created (code 1003) — the name + /// belongs to a private room we are not a member of. + CantCreate { room: String }, } /// One user's ticker (the scrolling one-line message) in a room. diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 120291e..062be52 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4583,3 +4583,128 @@ fn a_queued_download_can_ask_where_it_sits() { let _ = peer.join(); let _ = std::fs::remove_dir_all(download_dir); } + +#[test] +fn a_private_room_is_owned_granted_and_revoked() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_club"; + let mut owner = + Client::with_settings(server.settings("e2e_priv_owner", "pw")); + let mut guest = + Client::with_settings(server.settings("e2e_priv_guest", "pw")); + owner.connect().expect("owner connect"); + guest.connect().expect("guest connect"); + assert!(owner.login().expect("owner login")); + assert!(guest.login().expect("guest login")); + + guest + .set_room_invitations_enabled(true) + .expect("guest accepts invitations"); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + + // The guest is invited, and hears about it (code 139). + owner + .add_room_member(room, "e2e_priv_guest") + .expect("owner invites the guest"); + let granted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: true + } if r == room) + .then_some(()) + }); + assert!( + granted.is_some(), + "the guest should be told they are a member" + ); + + // The owner's own roster now carries the guest (code 134). + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && !owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "the member roster should carry the guest, got {:?}", + owner.private_room_members(room) + ); + + // A member can be made an operator (code 145 for them, 143 for the room). + owner + .add_room_operator(room, "e2e_priv_guest") + .expect("owner promotes the guest"); + let promoted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: false, granted: true + } if r == room) + .then_some(()) + }); + assert!( + promoted.is_some(), + "the guest should be told they run the room" + ); + + // And membership can be taken away again (code 140). + owner + .remove_room_member(room, "e2e_priv_guest") + .expect("owner removes the guest"); + let revoked = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: false + } if r == room) + .then_some(()) + }); + assert!(revoked.is_some(), "the guest should be told they are out"); +} + +#[test] +fn a_private_room_someone_else_owns_cannot_be_taken() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_taken"; + let mut owner = + Client::with_settings(server.settings("e2e_taken_owner", "pw")); + owner.connect().expect("owner connect"); + assert!(owner.login().expect("owner login")); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + + // An outsider asking for the same name must be refused (code 1003), not + // handed the room. + let mut outsider = + Client::with_settings(server.settings("e2e_taken_outsider", "pw")); + outsider.connect().expect("outsider connect"); + assert!(outsider.login().expect("outsider login")); + outsider + .join_private_room(room) + .expect("outsider asks for the same room"); + + let refused = + await_room_event(&outsider, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::CantCreate { room: r } if r == room) + .then_some(()) + }); + assert!( + refused.is_some(), + "a private room owned by someone else must be refused" + ); + assert!( + outsider.private_rooms().is_empty(), + "a refused room must not appear as one we belong to" + ); +} diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index d9ab8e0..0ccad0c 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -729,6 +729,218 @@ "username" ], "type": "object" + }, + { + "description": "The ticker board of a room, sent on join.", + "properties": { + "event": { + "const": "tickers", + "type": "string" + }, + "room": { + "type": "string" + }, + "tickers": { + "items": { + "$ref": "#/components/schemas/RoomTickerDto" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "tickers" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "ticker_added", + "type": "string" + }, + "room": { + "type": "string" + }, + "ticker": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "ticker" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "ticker_removed", + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username" + ], + "type": "object" + }, + { + "description": "A message from the global room feed; `room` names where it was said.", + "properties": { + "event": { + "const": "global_message", + "type": "string" + }, + "message": { + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "message" + ], + "type": "object" + }, + { + "description": "Who may enter a private room.", + "properties": { + "event": { + "const": "private_members", + "type": "string" + }, + "room": { + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "users" + ], + "type": "object" + }, + { + "description": "Who runs a private room.", + "properties": { + "event": { + "const": "private_operators", + "type": "string" + }, + "room": { + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "users" + ], + "type": "object" + }, + { + "description": "A private room's member (`members` true) or operator roster changed.", + "properties": { + "added": { + "type": "boolean" + }, + "event": { + "const": "private_roster_changed", + "type": "string" + }, + "members": { + "type": "boolean" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "members", + "added" + ], + "type": "object" + }, + { + "description": "Our own membership or operatorship of a private room changed.", + "properties": { + "event": { + "const": "own_standing_changed", + "type": "string" + }, + "granted": { + "type": "boolean" + }, + "members": { + "type": "boolean" + }, + "room": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "members", + "granted" + ], + "type": "object" + }, + { + "description": "The room we asked to join could not be created.", + "properties": { + "event": { + "const": "cant_create", + "type": "string" + }, + "room": { + "type": "string" + } + }, + "required": [ + "event", + "room" + ], + "type": "object" } ] }, @@ -760,6 +972,22 @@ ], "type": "object" }, + "RoomTickerDto": { + "description": "One user's ticker in a room.", + "properties": { + "ticker": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "ticker" + ], + "type": "object" + }, "RoomUserStatsDto": { "description": "One member of a room, as the JoinRoom reply described them.", "properties": { diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index 82ea25a..32fd958 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -373,6 +373,79 @@ pub enum RoomEventDto { room: String, username: String, }, + /// The ticker board of a room, sent on join. + Tickers { + room: String, + tickers: Vec, + }, + TickerAdded { + room: String, + username: String, + ticker: String, + }, + TickerRemoved { + room: String, + username: String, + }, + /// A message from the global room feed; `room` names where it was said. + GlobalMessage { + room: String, + username: String, + message: String, + }, + /// Who may enter a private room. + PrivateMembers { + room: String, + users: Vec, + }, + /// Who runs a private room. + PrivateOperators { + room: String, + users: Vec, + }, + /// A private room's member (`members` true) or operator roster changed. + PrivateRosterChanged { + room: String, + username: String, + members: bool, + added: bool, + }, + /// Our own membership or operatorship of a private room changed. + OwnStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created. + CantCreate { + room: String, + }, +} + +/// One user's ticker in a room. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomTickerDto { + pub username: String, + pub ticker: String, +} + +impl From<&soulseek_rs::RoomTicker> for RoomTickerDto { + fn from(ticker: &soulseek_rs::RoomTicker) -> Self { + Self { + username: ticker.username.clone(), + ticker: ticker.ticker.clone(), + } + } +} + +impl From for soulseek_rs::RoomTicker { + fn from(dto: RoomTickerDto) -> Self { + Self { + username: dto.username, + ticker: dto.ticker, + } + } } impl From<&RoomEvent> for RoomEventDto { @@ -403,6 +476,67 @@ impl From<&RoomEvent> for RoomEventDto { room: room.clone(), username: username.clone(), }, + RoomEvent::Tickers { room, tickers } => Self::Tickers { + room: room.clone(), + tickers: tickers.iter().map(RoomTickerDto::from).collect(), + }, + RoomEvent::TickerAdded { + room, + username, + ticker, + } => Self::TickerAdded { + room: room.clone(), + username: username.clone(), + ticker: ticker.clone(), + }, + RoomEvent::TickerRemoved { room, username } => { + Self::TickerRemoved { + room: room.clone(), + username: username.clone(), + } + } + RoomEvent::GlobalMessage { + room, + username, + message, + } => Self::GlobalMessage { + room: room.clone(), + username: username.clone(), + message: message.clone(), + }, + RoomEvent::PrivateMembers { room, users } => Self::PrivateMembers { + room: room.clone(), + users: users.clone(), + }, + RoomEvent::PrivateOperators { room, users } => { + Self::PrivateOperators { + room: room.clone(), + users: users.clone(), + } + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => Self::PrivateRosterChanged { + room: room.clone(), + username: username.clone(), + members: *members, + added: *added, + }, + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => Self::OwnStandingChanged { + room: room.clone(), + members: *members, + granted: *granted, + }, + RoomEvent::CantCreate { room } => { + Self::CantCreate { room: room.clone() } + } } } } @@ -432,6 +566,58 @@ impl From for RoomEvent { RoomEventDto::UserLeft { room, username } => { Self::UserLeft { room, username } } + RoomEventDto::Tickers { room, tickers } => Self::Tickers { + room, + tickers: tickers.into_iter().map(Into::into).collect(), + }, + RoomEventDto::TickerAdded { + room, + username, + ticker, + } => Self::TickerAdded { + room, + username, + ticker, + }, + RoomEventDto::TickerRemoved { room, username } => { + Self::TickerRemoved { room, username } + } + RoomEventDto::GlobalMessage { + room, + username, + message, + } => Self::GlobalMessage { + room, + username, + message, + }, + RoomEventDto::PrivateMembers { room, users } => { + Self::PrivateMembers { room, users } + } + RoomEventDto::PrivateOperators { room, users } => { + Self::PrivateOperators { room, users } + } + RoomEventDto::PrivateRosterChanged { + room, + username, + members, + added, + } => Self::PrivateRosterChanged { + room, + username, + members, + added, + }, + RoomEventDto::OwnStandingChanged { + room, + members, + granted, + } => Self::OwnStandingChanged { + room, + members, + granted, + }, + RoomEventDto::CantCreate { room } => Self::CantCreate { room }, } } } diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs index 5250cdd..8897f39 100644 --- a/soulseek-rs/src/models/rooms.rs +++ b/soulseek-rs/src/models/rooms.rs @@ -336,6 +336,81 @@ impl RoomsState { .push(RoomLine::system(format!("← {username}"))); } } + // Tickers and private-room standing are narrated into the room's + // own transcript: they belong to a room the user has open, and a + // system line is where the rest of that room's news already goes. + RoomEvent::Tickers { room, tickers } => { + if let Some(idx) = self.open_index(&room) { + for ticker in tickers { + self.open[idx].lines.push(RoomLine::system(format!( + "≡ {}: {}", + ticker.username, ticker.ticker + ))); + } + } + } + RoomEvent::TickerAdded { + room, + username, + ticker, + } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system(format!( + "≡ {username}: {ticker}" + ))); + } + } + RoomEvent::TickerRemoved { room, username } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system(format!( + "≡ {username} cleared their ticker" + ))); + } + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + if let Some(idx) = self.open_index(&room) { + let roster = if members { "member" } else { "operator" }; + let verb = if added { "is now" } else { "is no longer" }; + self.open[idx].lines.push(RoomLine::system(format!( + "{username} {verb} a {roster}" + ))); + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + if let Some(idx) = self.open_index(&room) { + let what = if members { + "membership" + } else { + "operator status" + }; + let verb = if granted { "granted" } else { "revoked" }; + self.open[idx].lines.push(RoomLine::system(format!( + "— your {what} was {verb} —" + ))); + } + } + RoomEvent::CantCreate { room } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system( + "— this room cannot be created —".to_string(), + )); + } + } + // The global feed carries rooms the user has not opened, and the + // private rosters are read from the client rather than rendered + // into a transcript. + RoomEvent::GlobalMessage { .. } + | RoomEvent::PrivateMembers { .. } + | RoomEvent::PrivateOperators { .. } => {} } // The active room's member list may have grown/shrunk (join/leave or a // wholesale replace on Joined); keep the selection highlight in range so From f5a97744f88ef784bed2c0743b15504ccc7e9a8a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:39:10 +0000 Subject: [PATCH 05/19] fix(messages): acknowledge stored private messages, and disconnect cleanly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs an e2e test found the moment one existed for this path. A private message the server held while we were offline arrives with new_message false, and the handler only acknowledged the ones flagged new — so exactly the messages that need an ack never got one and the server re-delivered them at every login, forever. Every delivery is now acknowledged. A dropped Client also left its server connection open, so the session lived on: mail addressed to us went to a socket nobody was reading and a later login collided with our own ghost. Client::disconnect closes the server and peer connections, and Drop calls it. Also covers two features that were never driven end to end: an offline message delivered once and not again, and a wishlist search answered like any other. --- soulseek-rs-lib/src/actor/peer_registry.rs | 18 ++++ soulseek-rs-lib/src/client/connection.rs | 24 +++++ soulseek-rs-lib/src/client/mod.rs | 6 ++ soulseek-rs-lib/src/client/tests.rs | 6 +- .../src/message/server/message_user.rs | 71 +++++++++++-- soulseek-rs-lib/tests/e2e.rs | 100 ++++++++++++++++++ 6 files changed, 215 insertions(+), 10 deletions(-) diff --git a/soulseek-rs-lib/src/actor/peer_registry.rs b/soulseek-rs-lib/src/actor/peer_registry.rs index 02d1ce0..2c7c6f9 100644 --- a/soulseek-rs-lib/src/actor/peer_registry.rs +++ b/soulseek-rs-lib/src/actor/peer_registry.rs @@ -202,6 +202,24 @@ impl PeerRegistry { None } + /// Stop every peer actor and empty the registry, closing the connections + /// they hold. Used when the client shuts down. + pub fn stop_all(&self) { + let handles = match self.peers.lock_safe() { + Ok(mut peers) => peers + .drain() + .map(|(_, (_, handle))| handle) + .collect::>(), + Err(e) => { + error!("[peer_registry] stop_all: {}", e); + return; + } + }; + for handle in handles { + let _ = handle.stop(); + } + } + #[must_use] pub fn contains(&self, username: &str) -> bool { match self.peers.lock_safe() { diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index 3d4f36f..a34e66c 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -133,6 +133,30 @@ impl Client { Ok(()) } + /// Close the server connection and every peer connection. + /// + /// The server sees the socket close and marks us offline, which is what + /// releases the username: a client that simply goes out of scope without + /// this leaves a session the server still believes in, so mail addressed + /// to us is delivered to a connection nobody is reading and later logins + /// collide with our own ghost. + /// + /// Called automatically when the client is dropped. The peer listener's + /// bound port, if one was opened, is released with the process. + pub fn disconnect(&mut self) { + if let Some(handle) = self.server_handle.take() { + let _ = handle.stop(); + } + let registry = self + .context + .read_safe() + .ok() + .and_then(|ctx| ctx.peer_registry.clone()); + if let Some(registry) = registry { + registry.stop_all(); + } + } + /// Log in and wait for the server's verdict. /// /// The wait is bounded: the actor only answers once it has a working diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index a6e54c0..b108b2b 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -1163,6 +1163,12 @@ pub struct Client { session: SessionWatch, } +impl Drop for Client { + fn drop(&mut self) { + self.disconnect(); + } +} + impl Client { pub fn new( username: impl Into, diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index eefd4ff..d85b8f5 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -551,7 +551,7 @@ fn an_expired_broker_connect_fails_the_queued_downloads() { let (_ops_tx, ops_rx) = mpsc::channel(); Client::listen_to_client_operations( ops_rx, - client.context, + client.context.clone(), "u".to_string(), ); @@ -582,7 +582,7 @@ fn a_replayed_transfer_response_does_not_start_a_second_transfer() { let (ops_tx, ops_rx) = mpsc::channel(); Client::listen_to_client_operations( ops_rx, - client.context, + client.context.clone(), "u".to_string(), ); @@ -648,7 +648,7 @@ fn a_cancelled_download_is_not_started_when_the_peer_allows_it() { let (ops_tx, ops_rx) = mpsc::channel(); Client::listen_to_client_operations( ops_rx, - client.context, + client.context.clone(), "u".to_string(), ); let peer = Peer::new( diff --git a/soulseek-rs-lib/src/message/server/message_user.rs b/soulseek-rs-lib/src/message/server/message_user.rs index 0520197..9817e2f 100644 --- a/soulseek-rs-lib/src/message/server/message_user.rs +++ b/soulseek-rs-lib/src/message/server/message_user.rs @@ -28,16 +28,73 @@ impl MessageHandler for MessageUser { info!("[MessageUser] User message received:{:?}", user_message); - // Acknowledge freshly delivered messages so the server does not keep - // re-sending them on every reconnect. - if new_message { - let _ = sender.send(ServerMessage::SendMessage( - MessageFactory::build_message_acked(id), - )); - } + // Acknowledge every message, not only the ones flagged new. The flag + // marks a message delivered as it was sent; one the server stored + // while we were offline arrives with it false, and those are exactly + // the messages the server keeps re-sending at every login until they + // are acknowledged. + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_message_acked(id), + )); // Surface the message to the client so it can be read via the API. let _ = sender.send(ServerMessage::PrivateMessageReceived(user_message)); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + fn deliver(new_message: bool) -> Vec { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(7); + m.write_int32(1_700_000_000); + m.write_string("alice"); + m.write_string("hello"); + m.write_int8(u8::from(new_message)); + }); + MessageUser.handle(&mut message, tx); + rx.try_iter().collect() + } + + fn acked_id(messages: &[ServerMessage]) -> Option { + messages.iter().find_map(|message| match message { + ServerMessage::SendMessage(m) => { + let mut decoded = Message::new_with_data(m.get_buffer()); + (decoded.get_message_code() == 23).then(|| { + decoded.set_pointer(8); + decoded.read_int32() + }) + } + _ => None, + }) + } + + #[test] + fn a_freshly_delivered_message_is_acknowledged() { + let sent = deliver(true); + assert_eq!(acked_id(&sent), Some(7)); + } + + #[test] + fn a_message_stored_while_we_were_offline_is_acknowledged_too() { + // Stored messages arrive with new_message false; without an ack the + // server re-delivers them at every login, forever. + let sent = deliver(false); + assert_eq!(acked_id(&sent), Some(7)); + } + + #[test] + fn the_message_itself_is_surfaced_to_the_client() { + let sent = deliver(false); + assert!(sent.iter().any(|message| matches!( + message, + ServerMessage::PrivateMessageReceived(m) + if m.message() == "hello" && m.username() == "alice" + ))); + } +} diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 062be52..f8f2a91 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4708,3 +4708,103 @@ fn a_private_room_someone_else_owns_cannot_be_taken() { "a refused room must not appear as one we belong to" ); } + +#[test] +fn an_acknowledged_offline_message_is_not_delivered_twice() { + // Private messages sent to an offline user are stored by the server and + // handed over at the next login; the client acknowledges each one + // (MessageAcked, code 23) so the server drops it. Without that ack the + // same message arrives again on every login, which is what this pins. + let server = server_or_skip!(); + + // Register the recipient so the server will hold mail for them. + let mut recipient = + Client::with_settings(server.settings("e2e_ack_recipient", "pw")); + recipient.connect().expect("recipient connect"); + assert!(recipient.login().expect("recipient login")); + drop(recipient); + // The dropped session's socket must actually be gone before the mail is + // sent, or the server delivers it live to a connection nobody is reading. + std::thread::sleep(Duration::from_secs(2)); + + let mut sender = + Client::with_settings(server.settings("e2e_ack_sender", "pw")); + sender.connect().expect("sender connect"); + assert!(sender.login().expect("sender login")); + let body = "left while you were out"; + sender + .send_private_message("e2e_ack_recipient", body) + .expect("send to an offline user"); + std::thread::sleep(Duration::from_millis(500)); + + let received_once = |label: &str| -> bool { + let mut client = + Client::with_settings(server.settings("e2e_ack_recipient", "pw")); + client.connect().unwrap_or_else(|e| panic!("{label}: {e}")); + assert!(client.login().expect("recipient login"), "{label}"); + let deadline = Instant::now() + Duration::from_secs(3); + let mut got = false; + while Instant::now() < deadline && !got { + got = client + .take_private_messages() + .iter() + .any(|m| m.message() == body); + if !got { + std::thread::sleep(Duration::from_millis(100)); + } + } + std::thread::sleep(Duration::from_millis(500)); + got + }; + + assert!( + received_once("first login"), + "the stored message should arrive at the next login" + ); + assert!( + !received_once("second login"), + "an acknowledged message must not be delivered again" + ); +} + +#[test] +fn a_wishlist_search_is_answered_like_any_other() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_wishlist_talisman.bin"), &content) + .unwrap(); + + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_wish_sharer", + "e2e_wish_seeker", + ); + + // A wish returns at once; its results accumulate under the query. + let query = "talisman"; + searcher + .start_wishlist_search(query) + .expect("start a wishlist search"); + + let reply = reply_from(&searcher, query, "e2e_wish_sharer") + .expect("a wishlist search should be answered like a plain one"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_wishlist_talisman")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + // The server also announces how often it will accept one. + assert!( + searcher.wishlist_interval() > Duration::ZERO, + "the wishlist interval should be a real wait" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} From 460031b532e4b2b322c0a3a9d8806e7f3443a620 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:47:55 +0000 Subject: [PATCH 06/19] feat(distributed): serve children in the search tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client read the distributed network but never carried it: a peer dialling with a D connection was logged and dropped, and AcceptChildren was hardcoded false, so this client took from the tree without ever relaying a search for anyone else. With accept_children set, peers hang from us: each is told where our branch sits when it connects and again when the branch moves, every search that reaches us is passed down, and a child that goes away loses its slot. Ten at a time, matching Nicotine+, since each costs a socket and a copy of the network's whole search stream — which is also why it is opt-in, through the new accept_children config key (ignored without a listener, since a child would have nothing to dial). --- soulseek-rs-lib/examples/stress.rs | 1 + soulseek-rs-lib/src/client/children.rs | 278 +++++++++++++++++++++ soulseek-rs-lib/src/client/distributed.rs | 23 +- soulseek-rs-lib/src/client/mod.rs | 36 ++- soulseek-rs-lib/src/client/operations.rs | 76 ++++-- soulseek-rs-lib/src/message/distributed.rs | 9 +- soulseek-rs-lib/src/peer/listen.rs | 10 +- soulseek-rs-lib/tests/e2e.rs | 176 +++++++++++++ soulseek-rs-lib/tests/resume_e2e.rs | 1 + soulseek-rs/src/persist/config.rs | 71 +++++- soulseek-rs/src/run.rs | 3 + soulseek-rs/tests/common/mod.rs | 1 + 12 files changed, 648 insertions(+), 37 deletions(-) create mode 100644 soulseek-rs-lib/src/client/children.rs diff --git a/soulseek-rs-lib/examples/stress.rs b/soulseek-rs-lib/examples/stress.rs index 6952ce1..81c6a8f 100644 --- a/soulseek-rs-lib/examples/stress.rs +++ b/soulseek-rs-lib/examples/stress.rs @@ -1026,6 +1026,7 @@ fn main() { enable_listen: true, listen_port: client_port, shared_directories: vec![share_dir.display().to_string()], + accept_children: false, version: ClientVersion::default(), }); client.connect().expect("client connect"); diff --git a/soulseek-rs-lib/src/client/children.rs b/soulseek-rs-lib/src/client/children.rs new file mode 100644 index 0000000..e22cfa1 --- /dev/null +++ b/soulseek-rs-lib/src/client/children.rs @@ -0,0 +1,278 @@ +//! Serving children: the other half of the distributed search network. +//! +//! A leaf only reads the tree; a parent also carries it. When the client is +//! configured to accept children, peers dial us with a `D` connection, we tell +//! each one where our branch sits, and every search that reaches us is passed +//! down to all of them. That is the whole duty — a child answers searches +//! itself, and reports its own place to the server. +//! +//! Accepting children is opt-in ([`crate::ClientSettings::accept_children`]): +//! it costs a socket and the network's whole search stream per child, which is +//! not something a client should take on without being asked to. + +use std::collections::HashMap; +use std::io::Write; +use std::net::TcpStream; + +use crate::message::Message; +use crate::message::distributed; +use crate::{debug, trace}; + +/// How many children to carry at once. Nicotine+ offers ten; the cost is one +/// socket and one copy of every search each, so the cap is what keeps a +/// parent's outbound traffic bounded. +pub const MAX_CHILDREN: usize = 10; + +/// The children hanging from us, keyed by username. +pub struct Children { + links: HashMap, + max: usize, + accepting: bool, +} + +impl Default for Children { + fn default() -> Self { + Self::new(false) + } +} + +impl Children { + #[must_use] + pub fn new(accepting: bool) -> Self { + Self { + links: HashMap::new(), + max: MAX_CHILDREN, + accepting, + } + } + + /// Whether we take children at all, and still have room for one. + #[must_use] + pub fn has_room(&self) -> bool { + self.accepting && self.links.len() < self.max + } + + /// Whether the client accepts children at all, regardless of how full it + /// is — what the server is told in `AcceptChildren` (code 100). + #[must_use] + pub const fn accepting(&self) -> bool { + self.accepting + } + + #[must_use] + pub fn len(&self) -> usize { + self.links.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.links.is_empty() + } + + #[must_use] + pub fn usernames(&self) -> Vec { + let mut names: Vec = self.links.keys().cloned().collect(); + names.sort(); + names + } + + /// Take on `username` as a child. Refused when we are not accepting or + /// are full; a second connection from a child we already carry replaces + /// the first, since the peer only reconnects when it thinks the old one + /// is gone. + pub fn accept(&mut self, username: &str, stream: TcpStream) -> bool { + if !self.accepting { + return false; + } + if self.links.len() >= self.max && !self.links.contains_key(username) { + debug!( + "[distributed] refusing child {}: already carrying {}", + username, + self.links.len() + ); + return false; + } + self.links.insert(username.to_string(), stream); + true + } + + pub fn remove(&mut self, username: &str) { + if self.links.remove(username).is_some() { + debug!("[distributed] child {} is gone", username); + } + } + + /// Send one frame to `username`, dropping the child if the write fails. + pub fn send_to(&mut self, username: &str, message: &Message) { + let failed = match self.links.get_mut(username) { + Some(stream) => stream.write_all(&message.get_buffer()).is_err(), + None => return, + }; + if failed { + self.remove(username); + } + } + + /// Send one frame to every child, dropping those whose write fails: a + /// child that has gone away must not keep its slot. + pub fn broadcast(&mut self, message: &Message) { + let bytes = message.get_buffer(); + let mut dead = Vec::new(); + for (username, stream) in &mut self.links { + if stream.write_all(&bytes).is_err() { + dead.push(username.clone()); + } + } + for username in dead { + self.remove(&username); + } + } + + /// Tell `username` where our branch sits — what a child needs before it + /// can report its own place to the server. + pub fn send_stance_to(&mut self, username: &str, root: &str, level: u32) { + trace!( + "[distributed] telling {} we sit at {}/{}", + username, root, level + ); + self.send_to(username, &distributed::build_branch_root(root)); + self.send_to( + username, + &distributed::build_branch_level( + i32::try_from(level).unwrap_or(i32::MAX), + ), + ); + } + + /// Tell every child our branch moved. + pub fn broadcast_stance(&mut self, root: &str, level: u32) { + self.broadcast(&distributed::build_branch_root(root)); + self.broadcast(&distributed::build_branch_level( + i32::try_from(level).unwrap_or(i32::MAX), + )); + } + + /// Pass a search down the tree. + pub fn broadcast_search( + &mut self, + username: &str, + token: u32, + query: &str, + ) { + if self.links.is_empty() { + return; + } + self.broadcast(&distributed::build_search(username, token, query)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + use std::net::{TcpListener, TcpStream}; + + /// A connected socket pair: the child's end and ours. + fn pair() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let ours = TcpStream::connect(addr).expect("connect"); + let (theirs, _) = listener.accept().expect("accept"); + (theirs, ours) + } + + fn read_frames(stream: &mut TcpStream, bytes: usize) -> Vec { + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .expect("timeout"); + let mut buf = vec![0u8; bytes]; + stream.read_exact(&mut buf).expect("read"); + buf + } + + #[test] + fn a_client_that_does_not_accept_children_takes_none() { + let (_child, ours) = pair(); + let mut children = Children::new(false); + assert!(!children.accept("alice", ours)); + assert!(children.is_empty()); + assert!(!children.has_room()); + } + + #[test] + fn children_are_taken_up_to_the_cap() { + let mut children = Children::new(true); + // The child ends are kept alive so the links stay up; the parent + // drops a child whose socket closes. + let mut ends = Vec::new(); + for i in 0..MAX_CHILDREN { + let (child, ours) = pair(); + ends.push(child); + assert!(children.accept(&format!("user{i}"), ours)); + } + assert_eq!(ends.len(), MAX_CHILDREN, "every link is still held"); + assert!(!children.has_room(), "a full parent has no room"); + + let (_late, ours) = pair(); + assert!(!children.accept("one_too_many", ours)); + assert_eq!(children.len(), MAX_CHILDREN); + } + + #[test] + fn a_reconnecting_child_replaces_its_own_link() { + let mut children = Children::new(true); + let (_first, ours) = pair(); + assert!(children.accept("alice", ours)); + let (_second, again) = pair(); + assert!(children.accept("alice", again)); + assert_eq!(children.usernames(), vec!["alice".to_string()]); + } + + #[test] + fn a_search_reaches_every_child() { + let mut children = Children::new(true); + let (mut first, ours) = pair(); + let (mut second, theirs) = pair(); + children.accept("alice", ours); + children.accept("bob", theirs); + + children.broadcast_search("seeker", 42, "jazz"); + + let expected = + distributed::build_search("seeker", 42, "jazz").get_buffer(); + assert_eq!(read_frames(&mut first, expected.len()), expected); + assert_eq!(read_frames(&mut second, expected.len()), expected); + } + + #[test] + fn a_child_that_hung_up_loses_its_slot() { + let mut children = Children::new(true); + let (child, ours) = pair(); + children.accept("alice", ours); + drop(child); + + // The first write after the peer is gone may still be buffered; the + // second is the one that fails. Either way the child must not keep + // its slot forever. + for _ in 0..50 { + children.broadcast_search("seeker", 1, "q"); + if children.is_empty() { + break; + } + } + assert!(children.is_empty(), "a dead child is dropped"); + } + + #[test] + fn a_child_is_told_where_our_branch_sits() { + let mut children = Children::new(true); + let (mut child, ours) = pair(); + children.accept("alice", ours); + + children.send_stance_to("alice", "rooty", 3); + + let mut expected = distributed::build_branch_root("rooty").get_buffer(); + expected.extend(distributed::build_branch_level(3).get_buffer()); + assert_eq!(read_frames(&mut child, expected.len()), expected); + } +} diff --git a/soulseek-rs-lib/src/client/distributed.rs b/soulseek-rs-lib/src/client/distributed.rs index 013ecf9..cfc89de 100644 --- a/soulseek-rs-lib/src/client/distributed.rs +++ b/soulseek-rs-lib/src/client/distributed.rs @@ -297,15 +297,34 @@ pub fn announce( server: Option<&Sender>, branch: &Branch, has_parent: bool, + accept_children: bool, ) { let Some(server) = server else { return; }; - for message in distributed::stance(&branch.root, branch.level, has_parent) { + for message in distributed::stance( + &branch.root, + branch.level, + has_parent, + accept_children, + ) { let _ = server.send(ServerMessage::SendMessage(message)); } } +/// Tell the server where we sit, and tell our children too: a branch that +/// moved leaves every child reporting a stale place until they hear it. +pub fn announce_move( + ctx: &mut super::ClientContext, + branch: &Branch, + has_parent: bool, +) { + let server = ctx.server_sender.clone(); + let accepting = ctx.children.accepting(); + announce(server.as_ref(), branch, has_parent, accepting); + ctx.children.broadcast_stance(&branch.root, branch.level); +} + /// Dial a parent candidate on its own thread: a `D` PeerInit, then whatever it /// sends comes back as operations until the link closes or is cancelled. pub fn spawn_link(dial: Dial, own: String, ops: Sender) { @@ -668,7 +687,7 @@ mod tests { fn announcing_tells_the_server_the_root_the_level_and_that_we_have_a_parent() { let (tx, rx) = std::sync::mpsc::channel(); - announce(Some(&tx), &Branch::under("rooty", 4), true); + announce(Some(&tx), &Branch::under("rooty", 4), true, false); let mut sent: Vec = Vec::new(); while let Ok(ServerMessage::SendMessage(m)) = rx.try_recv() { sent.push(m); diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index b108b2b..abaf670 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -191,6 +191,11 @@ pub struct ClientSettings { /// Directories whose files are shared with (uploaded to) other peers. /// Empty means nothing is shared. pub shared_directories: Vec, + /// Whether to serve children in the distributed search network: peers + /// hang from us with a `D` connection and we pass every search we receive + /// down to them. Off by default — it costs a socket and the network's + /// whole search stream per child. + pub accept_children: bool, /// The version reported to the server on login. Defaults to the /// soulseek-rs major version with minor version 1 ("unidentified"); /// clients built on this library should reserve their own minor @@ -223,6 +228,7 @@ impl Default for ClientSettings { enable_listen: true, listen_port: DEFAULT_LISTEN_PORT, shared_directories: Vec::new(), + accept_children: false, version: ClientVersion::default(), } } @@ -343,6 +349,12 @@ pub enum ClientOperation { PossibleParents(Vec<(String, String, u16)>), /// The server told us to drop our parent. ResetDistributed, + /// A peer dialled us with a `D` connection, asking to hang from us in the + /// distributed search network. + ChildConnected { + username: String, + stream: std::net::TcpStream, + }, /// Recommendations from the server: either from our own interests /// (`global` false, code 54) or server-wide (`global` true, code 56). Recommendations { @@ -405,6 +417,8 @@ pub struct ClientContext { operations: Option>, /// Our place in the distributed search network. leaf: distributed::Leaf, + /// The children hanging from us, when the client serves any. + pub(crate) children: children::Children, searches: HashMap, private_messages: Vec, /// Correlation tokens for server-brokered (firewalled) connections, mapping @@ -565,6 +579,7 @@ impl ClientContext { server_sender: None, operations: None, leaf: distributed::Leaf::new(""), + children: children::Children::default(), searches: HashMap::new(), private_messages: Vec::new(), pending_connect_tokens: HashMap::new(), @@ -1180,14 +1195,14 @@ impl Client { #[must_use] pub fn with_settings(settings: ClientSettings) -> Self { logger::init(); + let mut context = ClientContext::for_user(&settings.username); + context.children = children::Children::new(settings.accept_children); Self { enable_listen: settings.enable_listen, listen_port: settings.listen_port, bound_port: None, address: settings.server_address, - context: Arc::new(RwLock::new(ClientContext::for_user( - &settings.username, - ))), + context: Arc::new(RwLock::new(context)), username: settings.username, password: settings.password, version: settings.version, @@ -1197,6 +1212,20 @@ impl Client { } } + /// The children currently hanging from us in the distributed search + /// network, by username. Empty unless the client was built with + /// [`ClientSettings::accept_children`]. + #[must_use] + pub fn children(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.children.usernames(), + Err(e) => { + error!("[client] children: {}", e); + Vec::new() + } + } + } + /// The username we log in as, for attributing our own messages. #[must_use] pub fn username(&self) -> &str { @@ -1334,6 +1363,7 @@ impl Client { } } +mod children; mod connection; mod distributed; mod downloads; diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index b2dee36..094a72d 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -29,10 +29,8 @@ impl Client { if let Some(branch) = ctx.leaf.due_announcement(Instant::now()) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, + super::distributed::announce_move( + &mut ctx, &branch, true, ); } } @@ -539,6 +537,16 @@ impl Client { token, query, } => { + // Pass it down the tree first: carrying the search + // stream is a parent's whole duty, and it is owed + // even for a search we ourselves cannot answer. + if let Ok(mut ctx) = client_context.write_safe() + && !ctx.children.is_empty() + { + ctx.children + .broadcast_search(&username, token, &query); + } + // Don't answer our own distributed search. if username == own_username { continue; @@ -679,10 +687,8 @@ impl Client { if let Some(branch) = ctx.leaf.branch_level(&parent, link, level) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, + super::distributed::announce_move( + &mut ctx, &branch, true, ); } } @@ -697,10 +703,8 @@ impl Client { if let Some(branch) = ctx.leaf.branch_root(&parent, link, &root) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, + super::distributed::announce_move( + &mut ctx, &branch, true, ); } } @@ -717,10 +721,8 @@ impl Client { if let Some(branch) = ctx.leaf.search_from(&parent, link) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, + super::distributed::announce_move( + &mut ctx, &branch, true, ); } if ctx.leaf.is_parent(&parent, link) @@ -739,10 +741,9 @@ impl Client { continue; }; if ctx.leaf.closed(&parent, link) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &ctx.leaf.branch(), - false, + let branch = ctx.leaf.branch(); + super::distributed::announce_move( + &mut ctx, &branch, false, ); } } @@ -753,10 +754,9 @@ impl Client { continue; }; ctx.leaf.reset(); - super::distributed::announce( - ctx.server_sender.as_ref(), - &ctx.leaf.branch(), - false, + let branch = ctx.leaf.branch(); + super::distributed::announce_move( + &mut ctx, &branch, false, ); } ClientOperation::PrivilegedUsers(users) => { @@ -816,6 +816,34 @@ impl Client { ctx.apply_user_interests(interests); } } + ClientOperation::ChildConnected { username, stream } => { + // A peer wants to hang from us. Take it on if this + // client serves children and has room, then tell it + // where our branch sits so it can report its own + // place; otherwise the socket is dropped here and the + // peer looks for another parent. + let Ok(mut ctx) = client_context.write_safe() else { + continue; + }; + if !ctx.children.has_room() { + continue; + } + if stream.set_nodelay(true).is_err() { + continue; + } + if ctx.children.accept(&username, stream) { + let branch = ctx.leaf.branch(); + ctx.children.send_stance_to( + &username, + &branch.root, + branch.level, + ); + debug!( + "[distributed] carrying {} children", + ctx.children.len() + ); + } + } ClientOperation::ExcludedSearchPhrases(phrases) => { if let Ok(mut ctx) = client_context.write_safe() { ctx.set_excluded_search_phrases(phrases); diff --git a/soulseek-rs-lib/src/message/distributed.rs b/soulseek-rs-lib/src/message/distributed.rs index 2ae015c..9917ce6 100644 --- a/soulseek-rs-lib/src/message/distributed.rs +++ b/soulseek-rs-lib/src/message/distributed.rs @@ -108,12 +108,17 @@ pub fn build_branch_level(level: i32) -> Message { /// What we tell the server about our place in the tree: the four messages /// Nicotine+ sends together whenever it changes. #[must_use] -pub fn stance(root: &str, level: u32, has_parent: bool) -> Vec { +pub fn stance( + root: &str, + level: u32, + has_parent: bool, + accept_children: bool, +) -> Vec { vec![ MessageFactory::build_have_no_parent(!has_parent), MessageFactory::build_branch_root(root), MessageFactory::build_branch_level(level), - MessageFactory::build_accept_children(false), + MessageFactory::build_accept_children(accept_children), ] } diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs index eff0b7b..2726a8a 100644 --- a/soulseek-rs-lib/src/peer/listen.rs +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -378,9 +378,17 @@ fn handle_incoming_connection( deadline, ), ConnectionType::D => { + // A child asking to hang from us. The client decides whether it + // serves children at all; here we only hand the socket over. debug!( - "[listener:{peer_ip}:{peer_port}] connection type is D, not supported yet, closing connection. " + "[listener:{peer_ip}:{peer_port}] distributed child {} offered", + init_data.username ); + let _ = + context.client_sender.send(ClientOperation::ChildConnected { + username: init_data.username, + stream, + }); } } } diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index f8f2a91..64e08b0 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -118,6 +118,7 @@ impl TestServer { enable_listen: false, listen_port: 0, shared_directories: Vec::new(), + accept_children: false, version: ClientVersion::default(), } } @@ -4450,6 +4451,7 @@ fn an_excluded_phrase_is_refused_before_a_search_is_spent() { enable_listen: false, listen_port: 0, shared_directories: Vec::new(), + accept_children: false, version: ClientVersion::default(), }); client.connect().expect("connect to stub"); @@ -4808,3 +4810,177 @@ fn a_wishlist_search_is_answered_like_any_other() { let _ = std::fs::remove_dir_all(share_dir); } + +// --------------------------------------------------------------------------- +// The other half of the tree: serving children. +// +// A client that accepts children takes `D` connections from peers, tells each +// where our branch sits, and passes every search it receives down to them. +// --------------------------------------------------------------------------- + +/// Read distributed frames from a child link until `pick` matches or time runs +/// out. +fn read_distributed_until( + stream: &mut TcpStream, + timeout: Duration, + mut pick: impl FnMut(&distributed::Distributed) -> Option, +) -> Option { + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .ok()?; + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + // read_framed already returns the frame with its length prefix, + // which is exactly what the parser expects. + let Ok(mut frame) = read_framed(stream) else { + continue; + }; + if let Some(parsed) = distributed::parse(&mut frame) + && let Some(found) = pick(&parsed) + { + return Some(found); + } + } + None +} + +/// Dial `port` as a distributed child called `username`. +fn dial_as_child(port: u16, username: &str) -> std::io::Result { + let mut child = + connect_retry(&format!("127.0.0.1:{port}"), Duration::from_secs(5))?; + child.write_all(&peer_init_bytes(username, "D", 0))?; + child.flush()?; + Ok(child) +} + +#[test] +fn a_parent_tells_a_child_where_it_sits_and_passes_searches_down() { + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut parent = Client::with_settings(ClientSettings { + accept_children: true, + ..server.listening_settings("e2e_par_parent", "pw", listen_port) + }); + parent.connect().expect("parent connect"); + assert!(parent.login().expect("parent login")); + + let mut child = + dial_as_child(listen_port, "e2e_par_child").expect("child dials"); + + // The child is told our branch before anything else: without it, it has + // nothing to report to the server as its own place. + let root = read_distributed_until( + &mut child, + Duration::from_secs(10), + |f| match f { + distributed::Distributed::BranchRoot(root) => Some(root.clone()), + _ => None, + }, + ) + .expect("a child should be told our branch root"); + assert_eq!( + root, "e2e_par_parent", + "a parentless client is its own root" + ); + let level = read_distributed_until( + &mut child, + Duration::from_secs(10), + |f| match f { + distributed::Distributed::BranchLevel(level) => Some(*level), + _ => None, + }, + ) + .expect("a child should be told our branch level"); + assert_eq!(level, 0); + + let deadline = Instant::now() + Duration::from_secs(5); + while parent.children().is_empty() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!(parent.children(), vec!["e2e_par_child".to_string()]); + + // A search the server hands us must reach the child, whether or not we + // can answer it ourselves — carrying the stream is the parent's duty. + let addr = format!("{}:{}", server.host, server.port); + let mut searcher = + login_raw(&addr, "e2e_par_searcher", "pw").expect("searcher login"); + let token = 515_151_u32; + searcher + .write_all( + &MessageFactory::build_file_search_message( + token, + "e2e_child_relay_probe", + ) + .get_buffer(), + ) + .expect("send a search"); + searcher.flush().expect("flush the search"); + + let relayed = read_distributed_until( + &mut child, + Duration::from_secs(20), + |f| match f { + distributed::Distributed::Search { + username, + token: got, + query, + } if *got == token => Some((username.clone(), query.clone())), + _ => None, + }, + ) + .expect("the search should be passed down to the child"); + assert_eq!( + relayed, + ( + "e2e_par_searcher".to_string(), + "e2e_child_relay_probe".to_string() + ) + ); + + // A child that hangs up loses its place. + drop(child); + let deadline = Instant::now() + Duration::from_secs(20); + while !parent.children().is_empty() && Instant::now() < deadline { + searcher + .write_all( + &MessageFactory::build_file_search_message( + token + 1, + "e2e_child_relay_probe", + ) + .get_buffer(), + ) + .expect("send another search"); + std::thread::sleep(Duration::from_millis(500)); + } + assert!( + parent.children().is_empty(), + "a child that went away should not keep its slot" + ); +} + +#[test] +fn a_client_that_does_not_serve_children_turns_one_away() { + let server = server_or_skip!(); + + // The default: a leaf. A peer that dials with a D connection is dropped, + // and never told a branch it could hang from. + let listen_port = free_port().expect("free listen port"); + let mut leaf = Client::with_settings(server.listening_settings( + "e2e_leafonly", + "pw", + listen_port, + )); + leaf.connect().expect("leaf connect"); + assert!(leaf.login().expect("leaf login")); + + let mut child = + dial_as_child(listen_port, "e2e_leafonly_child").expect("child dials"); + + let told = + read_distributed_until(&mut child, Duration::from_secs(3), |f| { + matches!(f, distributed::Distributed::BranchRoot(_)).then_some(()) + }); + assert!(told.is_none(), "a leaf must not take on a child"); + assert!(leaf.children().is_empty()); +} diff --git a/soulseek-rs-lib/tests/resume_e2e.rs b/soulseek-rs-lib/tests/resume_e2e.rs index 2f5cf55..7271c25 100644 --- a/soulseek-rs-lib/tests/resume_e2e.rs +++ b/soulseek-rs-lib/tests/resume_e2e.rs @@ -67,6 +67,7 @@ impl Subject { enable_listen: true, listen_port: 0, shared_directories: Vec::new(), + accept_children: false, version: ClientVersion::default(), }); client.connect().expect("connect subject"); diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs index 3781647..6dd6f8c 100644 --- a/soulseek-rs/src/persist/config.rs +++ b/soulseek-rs/src/persist/config.rs @@ -12,6 +12,11 @@ pub struct FileConfig { pub server: Option, pub listener_port: Option, pub disable_listener: Option, + /// Serve children in the distributed search network: other peers hang + /// from this client and every search it receives is passed down to them. + /// Off by default — it costs a socket and the network's whole search + /// stream per child, and it needs the listener. + pub accept_children: Option, pub download_dir: Option, /// Single shared folder (also what `--shared-dir` sets). Prefer /// `shared_dirs` for multiple; both may be combined. @@ -46,6 +51,7 @@ impl FileConfig { "server", "listener_port", "disable_listener", + "accept_children", "download_dir", "shared_dirs", "max_concurrent_downloads", @@ -64,6 +70,7 @@ impl FileConfig { "server" => self.server.clone(), "listener_port" => self.listener_port.map(|v| v.to_string()), "disable_listener" => self.disable_listener.map(|v| v.to_string()), + "accept_children" => self.accept_children.map(|v| v.to_string()), "download_dir" => self.download_dir.clone(), "shared_dirs" => { let dirs = self.shared_dirs.clone().unwrap_or_default(); @@ -127,6 +134,22 @@ impl FileConfig { }) }; } + "accept_children" => { + self.accept_children = if clear { + None + } else { + Some(match value { + "true" | "1" | "yes" => true, + "false" | "0" | "no" => false, + other => { + return Err(format!( + "accept_children wants true or false, got \ + '{other}'" + )); + } + }) + }; + } "shared_dirs" => { self.shared_dirs = (!clear).then(|| { value @@ -251,6 +274,7 @@ pub struct Resolved { pub server: String, pub listener_port: u16, pub disable_listener: bool, + pub accept_children: bool, pub download_dir: String, pub shared_dirs: Vec, pub max_concurrent_downloads: usize, @@ -283,6 +307,11 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { .clone() .or_else(|| file.download_dir.clone()) .unwrap_or_else(super::paths::default_download_dir); + let disable_listener = if cli.listener { + false + } else { + cli.no_listener || file.disable_listener.unwrap_or(false) + }; Resolved { username: cli.username.clone().or_else(|| file.username.clone()), server: cli @@ -294,11 +323,11 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { .listener_port .or(file.listener_port) .unwrap_or(DEFAULT_LISTENER_PORT), - disable_listener: if cli.listener { - false - } else { - cli.no_listener || file.disable_listener.unwrap_or(false) - }, + disable_listener, + // A client with no listener has nothing for a child to dial, so the + // setting only takes effect alongside one. + accept_children: file.accept_children.unwrap_or(false) + && !disable_listener, download_dir: download_dir.clone(), shared_dirs: resolve_shared_dirs(cli, file, &download_dir), max_concurrent_downloads: cli @@ -496,6 +525,37 @@ mod tests { assert!(!resolve(&cli, &FileConfig::default()).disable_listener); } + #[test] + fn accepting_children_needs_a_listener() { + // Serving children means peers dial us; with the listener off there + // is nothing to dial, so the setting must not survive resolution. + let file = FileConfig { + accept_children: Some(true), + ..FileConfig::default() + }; + assert!(resolve(&bare_cli(), &file).accept_children); + + let off = FileConfig { + disable_listener: Some(true), + ..file.clone() + }; + assert!(!resolve(&bare_cli(), &off).accept_children); + + let mut cli = bare_cli(); + cli.no_listener = true; + assert!(!resolve(&cli, &file).accept_children); + } + + #[test] + fn accept_children_round_trips_through_get_and_set() { + let mut file = FileConfig::default(); + file.set("accept_children", "true").expect("set"); + assert_eq!(file.get("accept_children").as_deref(), Some("true")); + assert!(file.set("accept_children", "maybe").is_err()); + file.set("accept_children", "").expect("clear"); + assert_eq!(file.get("accept_children"), None); + } + #[test] fn file_values_override_defaults() { let file = FileConfig { @@ -503,6 +563,7 @@ mod tests { server: Some("localhost:2242".into()), listener_port: Some(4321), disable_listener: Some(true), + accept_children: None, download_dir: Some("/music".into()), shared_dir: Some("/shared".into()), shared_dirs: None, diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index af56d40..11c2c20 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -259,6 +259,7 @@ fn context( enable_listen: !resolved.disable_listener, listen_port: resolved.listener_port, shared_directories: shared_directories(resolved, out), + accept_children: resolved.accept_children, version: ClientVersion::REFERENCE_CLIENT, }, download_dir, @@ -432,6 +433,7 @@ fn run_default_tui( let enable_listen = !resolved.disable_listener; let listen_port = resolved.listener_port; + let accept_children = resolved.accept_children; let make_settings = move |username: String, password: String| ClientSettings { username, @@ -440,6 +442,7 @@ fn run_default_tui( enable_listen, listen_port, shared_directories: shared_directories.clone(), + accept_children, version: ClientVersion::REFERENCE_CLIENT, }; diff --git a/soulseek-rs/tests/common/mod.rs b/soulseek-rs/tests/common/mod.rs index 223131c..03ada03 100644 --- a/soulseek-rs/tests/common/mod.rs +++ b/soulseek-rs/tests/common/mod.rs @@ -137,6 +137,7 @@ pub fn login(host: &str, port: u16, user: &str, shares: Vec) -> Client { enable_listen: true, listen_port: free_port().expect("peer port"), shared_directories: shares, + accept_children: false, version: soulseek_rs::ClientVersion::default(), }); client.connect().expect("peer connect"); From bf3e3ec4ad2ba0174ac0186b9a965c604200f42c Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:48:58 +0000 Subject: [PATCH 07/19] docs(readme): document the accept_children setting --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3b7032c..e1f550f 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,7 @@ file, in that order of precedence. Flags work before or after the subcommand. | `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | the download dir | | `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | `2234` | | `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | listener on | +| — | — | `accept_children` | off | | `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | `20` | | `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | `10` | | `--daemon ADDR` | `SOULSEEK_DAEMON` | `daemon` | local socket if one is up | @@ -548,10 +549,17 @@ the config and state directories wholesale. The file lives at also read from a `.env` in the working directory, usually the tidiest way to hand a container its credentials. -`config get` and `config set` cover the eleven settings the file holds -(`username`, `server`, `listener_port`, `disable_listener`, `download_dir`, -`shared_dirs`, `max_concurrent_downloads`, `search_timeout`, `password_cmd`, -`daemon`, `daemon_token`), and list them back at you when you name something +`accept_children` makes this client carry part of the distributed search +network: other peers hang from it and every search it receives is passed down +to them. It is off by default — each child costs a socket and a copy of the +network's whole search stream — and it needs the listener, since a child has to +be able to dial in. + +`config get` and `config set` cover the twelve settings the file holds +(`username`, `server`, `listener_port`, `disable_listener`, `accept_children`, +`download_dir`, `shared_dirs`, `max_concurrent_downloads`, `search_timeout`, +`password_cmd`, `daemon`, `daemon_token`), and list them back at you when you +name something else. An empty string clears a key, and `shared_dirs` takes a comma-separated list. Waits expressed in seconds (`--search-timeout`, `--timeout`, `--duration`) are bounded to one day, so a mistyped flag is rejected rather From 0630b548b9635216d1623a3be8d44abd51d0df66 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:01:19 +0000 Subject: [PATCH 08/19] feat(protocol): folder requests, and heuristics matched to other clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading Nicotine+ and slskd against what was just written turned up three places where this client had the message right and the behaviour wrong. Excluded search phrases (code 160) are not a filter on the searches we send — the server already drops those. Nicotine+ applies them to the files it offers, leaving out any whose path carries an excluded phrase, and a reply with nothing left is not sent. Ours now does the same, and the local refusal of outgoing searches is gone. Serving children now follows the same rules as Nicotine+: the limit comes from the server's ParentMinSpeed and ParentSpeedRatio against the upload speed the server records for us (capped at ten, none at all below the minimum), children are refused until something feeds us the search stream and let go when it stops, a second link from a child we already carry is refused, and AcceptChildren is re-sent whenever that answer changes. Where the server has never reported our speed there is nothing to apply the formula to, so the configured cap stands, which is how slskd runs. Queued downloads now ask where they sit every five minutes, as Nicotine+ does, instead of only when a caller asks; a brokered file connection we cannot complete reports CantConnectToPeer like a control connection does; and a peer's shares can be asked for one folder at a time (peer code 36 and its reply, 37), which is what "download folder" is built on. --- soulseek-rs-lib/src/actor/peer_actor.rs | 29 ++- soulseek-rs-lib/src/actor/server_actor.rs | 13 ++ soulseek-rs-lib/src/client/children.rs | 199 +++++++++++++++--- soulseek-rs-lib/src/client/connection.rs | 38 +++- soulseek-rs-lib/src/client/distributed.rs | 6 +- soulseek-rs-lib/src/client/mod.rs | 109 ++++++++++ soulseek-rs-lib/src/client/operations.rs | 131 +++++++++++- soulseek-rs-lib/src/client/search.rs | 21 +- soulseek-rs-lib/src/client/social.rs | 36 ++++ soulseek-rs-lib/src/client/tests.rs | 54 ++++- soulseek-rs-lib/src/error.rs | 6 - .../src/message/peer/folder_contents.rs | 31 +++ soulseek-rs-lib/src/message/peer/mod.rs | 4 +- .../src/message/server/parent_min_speed.rs | 26 ++- .../src/message/server/parent_speed_ratio.rs | 25 ++- soulseek-rs-lib/tests/e2e.rs | 130 +++++++++--- 16 files changed, 751 insertions(+), 107 deletions(-) diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index 74b9e21..b7aefe8 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -2,11 +2,11 @@ use crate::actor::{Actor, ActorHandle, ConnectionState}; use crate::client::ClientOperation; use crate::dispatcher::MessageDispatcher; use crate::message::peer::{ - FileSearchResponse, FolderContentsRequest, GetShareFileList, PeerInit, - PlaceInQueueRequest, PlaceInQueueResponse, QueueUploadHandler, - SharedDirectory, SharedFileListResponseHandler, TransferRequest, - TransferResponse, UploadDeniedHandler, UploadFailedHandler, - UserInfoRequest, + FileSearchResponse, FolderContentsRequest, FolderContentsResponseHandler, + GetShareFileList, PeerInit, PlaceInQueueRequest, PlaceInQueueResponse, + QueueUploadHandler, SharedDirectory, SharedFileListResponseHandler, + TransferRequest, TransferResponse, UploadDeniedHandler, + UploadFailedHandler, UserInfoRequest, }; use crate::message::server::MessageFactory; use crate::message::{Handlers, Message, MessageReader, MessageType}; @@ -47,6 +47,12 @@ pub enum PeerMessage { ShareListRequested, /// A peer we are browsing sent us their shared-file listing (code 5). ShareListReceived(Vec), + /// A peer answered our `FolderContentsRequest` with one folder's listing. + FolderContentsReceived { + token: u32, + folder: String, + directories: Vec, + }, /// A peer asked what we say about ourselves (they sent us code 15). UserInfoRequested, /// A peer asked for one folder of our shares (they sent us code 36). @@ -199,6 +205,7 @@ impl PeerActor { handlers.register_handler(TransferResponse); handlers.register_handler(GetShareFileList); handlers.register_handler(FolderContentsRequest); + handlers.register_handler(FolderContentsResponseHandler); handlers.register_handler(UserInfoRequest); handlers.register_handler(UploadDeniedHandler); handlers.register_handler(UploadFailedHandler); @@ -310,6 +317,18 @@ impl PeerActor { directories, }); } + PeerMessage::FolderContentsReceived { + token, + folder, + directories, + } => { + self.forward(ClientOperation::FolderContents { + username: self.peer_username(), + token, + folder, + directories, + }); + } PeerMessage::UserInfoRequested => { self.forward(ClientOperation::UserInfoRequested { requester_key: self.peer_username(), diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index bd43d56..26b76dd 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -233,6 +233,11 @@ pub enum ServerMessage { }, /// Phrases the server refuses to search for (code 160). ExcludedSearchPhrases(Vec), + /// The upload speed a client needs before the server lets it carry + /// children (code 83). + ParentMinSpeed(u32), + /// The divisor turning that speed into a child count (code 84). + ParentSpeedRatio(u32), /// Who may enter a private room (code 133). PrivateRoomMembers { room: String, @@ -780,6 +785,14 @@ impl ServerActor { ServerMessage::CantCreateRoom { room } => { self.forward_room_event(RoomEvent::CantCreate { room }); } + ServerMessage::ParentMinSpeed(speed) => { + self.forward_to_client(ClientOperation::ParentMinSpeed(speed)); + } + ServerMessage::ParentSpeedRatio(ratio) => { + self.forward_to_client(ClientOperation::ParentSpeedRatio( + ratio, + )); + } ServerMessage::ExcludedSearchPhrases(phrases) => { self.forward_to_client(ClientOperation::ExcludedSearchPhrases( phrases, diff --git a/soulseek-rs-lib/src/client/children.rs b/soulseek-rs-lib/src/client/children.rs index e22cfa1..0a054d0 100644 --- a/soulseek-rs-lib/src/client/children.rs +++ b/soulseek-rs-lib/src/client/children.rs @@ -6,6 +6,14 @@ //! down to all of them. That is the whole duty — a child answers searches //! itself, and reports its own place to the server. //! +//! How many children to carry follows Nicotine+: the server announces the +//! upload speed a parent needs (`ParentMinSpeed`, code 83) and the divisor +//! turning speed into a child count (`ParentSpeedRatio`, code 84), and a +//! client whose recorded speed is below the minimum carries none. Until the +//! server has reported our own speed there is nothing to apply the formula to, +//! so the configured cap stands — which is how slskd runs the whole time, with +//! an operator-set limit and no speed gate. +//! //! Accepting children is opt-in ([`crate::ClientSettings::accept_children`]): //! it costs a socket and the network's whole search stream per child, which is //! not something a client should take on without being asked to. @@ -18,16 +26,39 @@ use crate::message::Message; use crate::message::distributed; use crate::{debug, trace}; -/// How many children to carry at once. Nicotine+ offers ten; the cost is one -/// socket and one copy of every search each, so the cap is what keeps a -/// parent's outbound traffic bounded. +/// How many children to carry at once. Nicotine+ caps its speed-derived limit +/// here too, for the same reason: each child is a socket and a copy of the +/// network's whole search stream. pub const MAX_CHILDREN: usize = 10; +/// The child limit the server's figures imply, following Nicotine+: +/// `speed / ratio / 100`, capped, and none at all below the minimum speed the +/// server set. `None` when the server has not reported our speed yet, which +/// leaves the caller's own cap standing. +#[must_use] +pub fn limit_from_speed( + own_speed: Option, + min_speed: u32, + ratio: u32, +) -> Option { + let speed = own_speed?; + if ratio == 0 || speed < min_speed { + return Some(0); + } + Some((speed / ratio / 100) as usize) +} + /// The children hanging from us, keyed by username. pub struct Children { links: HashMap, + /// The cap in force: [`MAX_CHILDREN`], lowered to what the server's + /// figures allow once it has reported our speed. max: usize, accepting: bool, + /// Whether anything is feeding us the search stream — a parent of our own, + /// or the server. Nicotine+ refuses children without one, and rightly: + /// a child hanging from a branch that receives nothing gets nothing. + fed: bool, } impl Default for Children { @@ -43,20 +74,40 @@ impl Children { links: HashMap::new(), max: MAX_CHILDREN, accepting, + fed: false, } } - /// Whether we take children at all, and still have room for one. - #[must_use] - pub fn has_room(&self) -> bool { - self.accepting && self.links.len() < self.max + /// Apply the server's figures: `own_speed` is what it records for us, and + /// the other two are what it announced in codes 83 and 84. + pub fn set_limits( + &mut self, + own_speed: Option, + min_speed: u32, + ratio: u32, + ) { + self.max = limit_from_speed(own_speed, min_speed, ratio) + .map_or(MAX_CHILDREN, |limit| limit.min(MAX_CHILDREN)); + debug!("[distributed] child limit is now {}", self.max); + } + + /// Say whether something is feeding us the search stream. A client with + /// nothing to relay takes no children, and drops any it has. + pub const fn set_fed(&mut self, fed: bool) { + self.fed = fed; } - /// Whether the client accepts children at all, regardless of how full it - /// is — what the server is told in `AcceptChildren` (code 100). #[must_use] - pub const fn accepting(&self) -> bool { - self.accepting + pub const fn is_fed(&self) -> bool { + self.fed + } + + /// Whether we would take another child right now: we serve children, we + /// have something to relay, and we are under the cap. This is what the + /// server is told in `AcceptChildren` (code 100). + #[must_use] + pub fn has_room(&self) -> bool { + self.accepting && self.fed && self.links.len() < self.max } #[must_use] @@ -76,19 +127,19 @@ impl Children { names } - /// Take on `username` as a child. Refused when we are not accepting or - /// are full; a second connection from a child we already carry replaces - /// the first, since the peer only reconnects when it thinks the old one - /// is gone. + /// Take on `username` as a child. Refused when we serve no children, have + /// nothing to relay, are full, or already carry that user. pub fn accept(&mut self, username: &str, stream: TcpStream) -> bool { - if !self.accepting { + if !self.has_room() { return false; } - if self.links.len() >= self.max && !self.links.contains_key(username) { + // A second connection from a child we already carry is refused, as + // Nicotine+ does: the link we hold is the live one, and dropping it + // for an unproven second would cost that child its search stream. + if self.links.contains_key(username) { debug!( - "[distributed] refusing child {}: already carrying {}", - username, - self.links.len() + "[distributed] refusing child {}: already carried", + username ); return false; } @@ -96,6 +147,15 @@ impl Children { true } + /// Drop every child, closing their links: what a client does when it can + /// no longer feed them. + pub fn drop_all(&mut self) { + if !self.links.is_empty() { + debug!("[distributed] dropping {} children", self.links.len()); + } + self.links.clear(); + } + pub fn remove(&mut self, username: &str) { if self.links.remove(username).is_some() { debug!("[distributed] child {} is gone", username); @@ -159,7 +219,7 @@ impl Children { token: u32, query: &str, ) { - if self.links.is_empty() { + if self.is_empty() { return; } self.broadcast(&distributed::build_search(username, token, query)); @@ -181,6 +241,13 @@ mod tests { (theirs, ours) } + /// A registry that serves children and has something to relay. + fn serving() -> Children { + let mut children = Children::new(true); + children.set_fed(true); + children + } + fn read_frames(stream: &mut TcpStream, bytes: usize) -> Vec { stream .set_read_timeout(Some(std::time::Duration::from_secs(2))) @@ -194,16 +261,42 @@ mod tests { fn a_client_that_does_not_accept_children_takes_none() { let (_child, ours) = pair(); let mut children = Children::new(false); + children.set_fed(true); assert!(!children.accept("alice", ours)); assert!(children.is_empty()); assert!(!children.has_room()); } #[test] - fn children_are_taken_up_to_the_cap() { + fn a_client_nothing_feeds_takes_no_children() { + // Nicotine+ refuses children while it has neither a parent nor the + // server relaying searches: a child would hang from a branch that + // receives nothing. + let (_child, ours) = pair(); let mut children = Children::new(true); - // The child ends are kept alive so the links stay up; the parent - // drops a child whose socket closes. + assert!(!children.has_room(), "unfed means no room"); + assert!(!children.accept("alice", ours)); + + children.set_fed(true); + let (_second, again) = pair(); + assert!(children.accept("alice", again)); + } + + #[test] + fn children_are_let_go_when_the_stream_dries_up() { + let mut children = serving(); + let (_child, ours) = pair(); + children.accept("alice", ours); + + children.set_fed(false); + children.drop_all(); + assert!(children.is_empty()); + assert!(!children.has_room()); + } + + #[test] + fn children_are_taken_up_to_the_cap() { + let mut children = serving(); let mut ends = Vec::new(); for i in 0..MAX_CHILDREN { let (child, ours) = pair(); @@ -219,18 +312,64 @@ mod tests { } #[test] - fn a_reconnecting_child_replaces_its_own_link() { - let mut children = Children::new(true); + fn a_second_link_from_a_child_we_carry_is_refused() { + // The link we hold is the live one; dropping it for an unproven + // second would cost that child its search stream. + let mut children = serving(); let (_first, ours) = pair(); assert!(children.accept("alice", ours)); let (_second, again) = pair(); - assert!(children.accept("alice", again)); + assert!(!children.accept("alice", again)); assert_eq!(children.usernames(), vec!["alice".to_string()]); } + #[test] + fn the_child_limit_follows_the_servers_figures() { + // Nicotine+: speed / ratio / 100, capped, and none at all below the + // minimum speed the server set. + assert_eq!(limit_from_speed(Some(50_000), 1_024, 50), Some(10)); + assert_eq!(limit_from_speed(Some(10_000), 1_024, 50), Some(2)); + assert_eq!( + limit_from_speed(Some(500), 1_024, 50), + Some(0), + "too slow to parent" + ); + assert_eq!( + limit_from_speed(Some(10_000), 1_024, 0), + Some(0), + "a zero ratio means the server is not allowing children" + ); + assert_eq!( + limit_from_speed(None, 1_024, 50), + None, + "an unreported speed leaves the caller's own cap standing" + ); + } + + #[test] + fn a_speed_the_server_has_not_reported_leaves_the_cap_alone() { + let mut children = serving(); + children.set_limits(None, 1_024, 50); + assert!(children.has_room(), "an unreported speed leaves us open"); + + // The server's figures win: at this speed the formula allows two. + children.set_limits(Some(10_000), 1_024, 50); + let mut ends = Vec::new(); + for i in 0..2 { + let (child, ours) = pair(); + ends.push(child); + assert!(children.accept(&format!("user{i}"), ours)); + } + assert!(!children.has_room(), "two is the limit at this speed"); + + children.set_limits(Some(100), 1_024, 50); + assert!(!children.has_room(), "a slow client carries none"); + assert_eq!(ends.len(), 2); + } + #[test] fn a_search_reaches_every_child() { - let mut children = Children::new(true); + let mut children = serving(); let (mut first, ours) = pair(); let (mut second, theirs) = pair(); children.accept("alice", ours); @@ -246,7 +385,7 @@ mod tests { #[test] fn a_child_that_hung_up_loses_its_slot() { - let mut children = Children::new(true); + let mut children = serving(); let (child, ours) = pair(); children.accept("alice", ours); drop(child); @@ -265,7 +404,7 @@ mod tests { #[test] fn a_child_is_told_where_our_branch_sits() { - let mut children = Children::new(true); + let mut children = serving(); let (mut child, ours) = pair(); children.accept("alice", ours); diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index a34e66c..12f9d43 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -264,6 +264,25 @@ impl Client { registered } + /// Tell the server we could not reach the peer it asked us to connect to, + /// quoting the token that peer is waiting on. + pub(crate) fn report_cant_connect( + client_context: &Arc>, + token: u32, + username: &str, + ) { + let sender = match client_context.read_safe() { + Ok(ctx) => ctx.server_sender.clone(), + Err(_) => return, + }; + let Some(sender) = sender else { return }; + let message = + crate::message::server::MessageFactory::build_cant_connect_to_peer( + token, username, + ); + let _ = sender.send(ServerMessage::SendMessage(message)); + } + pub(crate) fn connect_to_peer( peer: Peer, client_context: Arc>, @@ -306,7 +325,13 @@ impl Client { own_username, ); - match download_peer.download_file(client_context, None, None) { + let brokered = peer_clone.brokered; + let username = peer_clone.username; + match download_peer.download_file( + client_context.clone(), + None, + None, + ) { Ok((download, filename)) => { trace!( "[client] downloaded {} bytes {:?} ", @@ -315,6 +340,17 @@ impl Client { } Err(e) => { trace!("[client] failed to download: {}", e); + // A file connection the server asked us to make and + // we could not complete owes that peer a + // CantConnectToPeer, exactly as a control connection + // does: it is waiting on this token. + if brokered { + Self::report_cant_connect( + &client_context, + token, + &username, + ); + } } } } diff --git a/soulseek-rs-lib/src/client/distributed.rs b/soulseek-rs-lib/src/client/distributed.rs index cfc89de..df84a2b 100644 --- a/soulseek-rs-lib/src/client/distributed.rs +++ b/soulseek-rs-lib/src/client/distributed.rs @@ -320,8 +320,10 @@ pub fn announce_move( has_parent: bool, ) { let server = ctx.server_sender.clone(); - let accepting = ctx.children.accepting(); - announce(server.as_ref(), branch, has_parent, accepting); + // What the server needs to know is whether it may offer us to another + // peer right now, not whether we serve children in principle. + let has_room = ctx.children.has_room(); + announce(server.as_ref(), branch, has_parent, has_room); ctx.children.broadcast_stance(&branch.root, branch.level); } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index abaf670..9343e62 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -149,6 +149,11 @@ fn upload_speed( /// Build a `FileSearchResponse` for `query` against `shares`, or `None` if /// nothing matches. `own_username` is the name the searcher will download from. +// One reply's worth of state: the shares to search, who we are, and the +// figures the reply advertises. Kept as arguments rather than a struct +// because every one of them is read straight from the client context at the +// call site, and a struct would only move the same list one line up. +#[allow(clippy::too_many_arguments)] fn build_search_response( shares: &Shares, own_username: &str, @@ -157,13 +162,21 @@ fn build_search_response( free_slot: bool, speed: u32, queue_length: u32, + excluded_phrases: &[String], ) -> Option { let matches = shares.search(query); if matches.is_empty() { return None; } + // A file whose path carries a phrase the server excludes (code 160) is + // left out of the reply, the way Nicotine+ does it: the exclusion is the + // server policing what travels the search network, and answering with one + // anyway is what it is asking us not to do. let entries: Vec = matches .iter() + .filter(|f| { + !path_carries_excluded_phrase(&f.virtual_path, excluded_phrases) + }) .take(crate::types::MAX_SEARCH_REPLY_FILES) .map(|f| FileEntry { name: &f.virtual_path, @@ -171,6 +184,9 @@ fn build_search_response( attribs: &f.attributes, }) .collect(); + if entries.is_empty() { + return None; + } Some(build_file_search_response( own_username, token, @@ -181,6 +197,18 @@ fn build_search_response( )) } +/// Whether `path` contains any of the phrases the server excludes. Matching +/// is on the lowercased path, as the phrases themselves are lowercase. +fn path_carries_excluded_phrase(path: &str, excluded: &[String]) -> bool { + if excluded.is_empty() { + return false; + } + let lowered = path.to_lowercase(); + excluded + .iter() + .any(|phrase| !phrase.is_empty() && lowered.contains(phrase)) +} + #[derive(Debug, Clone)] pub struct ClientSettings { pub username: String, @@ -312,6 +340,13 @@ pub enum ClientOperation { token: u32, folder: String, }, + /// A peer answered our request for one folder of their shares. + FolderContents { + username: String, + token: u32, + folder: String, + directories: Vec, + }, /// A peer we are browsing returned their shared-file listing. BrowseResult { username: String, @@ -382,6 +417,10 @@ pub enum ClientOperation { }, /// Phrases the server refuses to search for (code 160). ExcludedSearchPhrases(Vec), + /// The minimum upload speed for carrying children (code 83). + ParentMinSpeed(u32), + /// The divisor turning that speed into a child count (code 84). + ParentSpeedRatio(u32), /// A parent candidate told us how deep it sits. `link` says which dial /// to that user is talking. ParentBranchLevel { @@ -419,6 +458,13 @@ pub struct ClientContext { leaf: distributed::Leaf, /// The children hanging from us, when the client serves any. pub(crate) children: children::Children, + /// The upload speed the server records for us, once it has told us. The + /// child limit is derived from it. + own_average_speed: Option, + /// The speed a parent needs (code 83) and the divisor turning speed into + /// a child count (code 84), as the server announced them. + parent_min_speed: u32, + parent_speed_ratio: u32, searches: HashMap, private_messages: Vec, /// Correlation tokens for server-brokered (firewalled) connections, mapping @@ -443,6 +489,8 @@ pub struct ClientContext { pending_serves: HashMap>, /// Shared-file listings received from peers we browsed. browse_results: HashMap>, + /// One-folder listings received from peers, keyed by peer and folder. + folder_contents: HashMap<(String, String), Vec>, pending_browses: HashMap, /// Latest snapshot of the public chat-room list (from `RoomList`, code 64). room_list: Vec, @@ -580,6 +628,9 @@ impl ClientContext { operations: None, leaf: distributed::Leaf::new(""), children: children::Children::default(), + own_average_speed: None, + parent_min_speed: 0, + parent_speed_ratio: 0, searches: HashMap::new(), private_messages: Vec::new(), pending_connect_tokens: HashMap::new(), @@ -592,6 +643,7 @@ impl ClientContext { active_uploads: HashMap::new(), pending_serves: HashMap::new(), browse_results: HashMap::new(), + folder_contents: HashMap::new(), pending_browses: HashMap::new(), room_list: Vec::new(), room_events: Vec::new(), @@ -723,6 +775,63 @@ impl ClientContext { self.room_events.push(event); } + /// Record what the server says our own upload speed is, and re-derive the + /// child limit from it. + pub fn set_own_average_speed(&mut self, speed: u32) { + self.own_average_speed = Some(speed); + self.apply_child_limits(); + } + + /// Record the server's parent figures (codes 83 and 84). + pub fn set_parent_min_speed(&mut self, speed: u32) { + self.parent_min_speed = speed; + self.apply_child_limits(); + } + + pub fn set_parent_speed_ratio(&mut self, ratio: u32) { + self.parent_speed_ratio = ratio; + self.apply_child_limits(); + } + + fn apply_child_limits(&mut self) { + self.children.set_limits( + self.own_average_speed, + self.parent_min_speed, + self.parent_speed_ratio, + ); + } + + /// Whether something is feeding us the distributed search stream: a + /// parent of our own, or the server relaying searches to us. Children may + /// only hang from a client that has one. + pub fn set_fed_by(&mut self, fed: bool) { + self.children.set_fed(fed); + if !fed { + self.children.drop_all(); + } + } + + /// Record one folder's listing from `username`. + pub fn store_folder_contents( + &mut self, + username: String, + folder: String, + directories: Vec, + ) { + self.folder_contents.insert((username, folder), directories); + } + + /// Remove and return the listing of `folder` from `username`, if it has + /// arrived. + pub fn take_folder_contents( + &mut self, + username: &str, + folder: &str, + ) -> Option> { + self.folder_contents + .remove(&(username.to_string(), folder.to_string())) + } + /// Who may enter the private room `room`, as last reported. #[must_use] pub fn private_room_members(&self, room: &str) -> Vec { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 094a72d..4b31e16 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -10,6 +10,12 @@ use crate::peer::DownloadError; const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); +/// How often to ask the peers holding our queued downloads where they sit +/// (peer code 51). Five minutes is what Nicotine+ uses: often enough that a +/// queue position on screen means something, rare enough that a peer with a +/// long queue is not pestered. +const QUEUE_POSITION_INTERVAL: Duration = Duration::from_secs(300); + impl Client { pub(crate) fn listen_to_client_operations( reader: Receiver, @@ -18,6 +24,7 @@ impl Client { ) { thread::spawn(move || { let mut last_sweep = Instant::now(); + let mut last_queue_poll = Instant::now(); loop { let next = reader.recv_timeout(CONNECT_SWEEP_INTERVAL); if last_sweep.elapsed() >= CONNECT_SWEEP_INTERVAL { @@ -35,6 +42,10 @@ impl Client { } } } + if last_queue_poll.elapsed() >= QUEUE_POSITION_INTERVAL { + last_queue_poll = Instant::now(); + Self::poll_queue_positions(&client_context); + } let operation = match next { Ok(operation) => operation, Err(mpsc::RecvTimeoutError::Timeout) => continue, @@ -537,14 +548,28 @@ impl Client { token, query, } => { - // Pass it down the tree first: carrying the search - // stream is a parent's whole duty, and it is owed - // even for a search we ourselves cannot answer. - if let Ok(mut ctx) = client_context.write_safe() - && !ctx.children.is_empty() + // Every search we receive comes from the tree: from + // our parent, or from the server acting as one. That + // is what qualifies us to carry children at all — + // Nicotine+ refuses them until something feeds it — + // and passing the search down is the whole duty. + let capacity_changed = match client_context.write_safe() { - ctx.children - .broadcast_search(&username, token, &query); + Ok(mut ctx) => { + let was_fed = ctx.children.is_fed(); + let before = ctx.children.len(); + ctx.set_fed_by(true); + ctx.children + .broadcast_search(&username, token, &query); + // A child whose socket has gone is dropped by + // that write, which frees a slot the server + // should hear about. + !was_fed || ctx.children.len() != before + } + Err(_) => false, + }; + if capacity_changed { + Self::announce_child_capacity(&client_context); } // Don't answer our own distributed search. @@ -560,6 +585,7 @@ impl Client { ctx.has_free_upload_slot(), ctx.last_upload_speed, ctx.upload_queue.len() as u32, + &ctx.excluded_search_phrases(), ), Err(e) => { error!("[client] IncomingSearch read: {}", e); @@ -754,6 +780,10 @@ impl Client { continue; }; ctx.leaf.reset(); + // A fresh session feeds us nothing until the tree (or + // the server) starts again, so the children we were + // carrying are let go rather than left starving. + ctx.set_fed_by(false); let branch = ctx.leaf.branch(); super::distributed::announce_move( &mut ctx, &branch, false, @@ -831,6 +861,9 @@ impl Client { if stream.set_nodelay(true).is_err() { continue; } + if username == own_username { + continue; // we cannot hang from ourselves + } if ctx.children.accept(&username, stream) { let branch = ctx.leaf.branch(); ctx.children.send_stance_to( @@ -842,8 +875,48 @@ impl Client { "[distributed] carrying {} children", ctx.children.len() ); + // The server stops offering us to peers once we + // are full, and starts again when one leaves. + let full = !ctx.children.has_room(); + drop(ctx); + if full { + Self::announce_child_capacity(&client_context); + } } } + ClientOperation::FolderContents { + username, + token, + folder, + directories, + } => { + trace!( + "[client] folder {} from {} (token {}): {} dirs", + folder, + username, + token, + directories.len() + ); + if let Ok(mut ctx) = client_context.write_safe() { + ctx.store_folder_contents( + username, + folder, + directories, + ); + } + } + ClientOperation::ParentMinSpeed(speed) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.set_parent_min_speed(speed); + } + Self::announce_child_capacity(&client_context); + } + ClientOperation::ParentSpeedRatio(ratio) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.set_parent_speed_ratio(ratio); + } + Self::announce_child_capacity(&client_context); + } ClientOperation::ExcludedSearchPhrases(phrases) => { if let Ok(mut ctx) = client_context.write_safe() { ctx.set_excluded_search_phrases(phrases); @@ -1075,6 +1148,50 @@ impl Client { }); } + /// Ask every peer holding a queued download of ours where it sits. The + /// answers arrive as `PlaceInQueueResponse` and land on the downloads. + fn poll_queue_positions(client_context: &Arc>) { + let (registry, queued) = match client_context.read_safe() { + Ok(ctx) => ( + ctx.peer_registry.clone(), + ctx.get_downloads() + .iter() + .filter(|d| matches!(d.status, DownloadStatus::Queued)) + .map(|d| (d.username.clone(), d.filename.clone())) + .collect::>(), + ), + Err(_) => return, + }; + let Some(registry) = registry else { return }; + for (username, filename) in queued { + // Only over a connection we already hold: a peer we cannot reach + // is a connection problem, not a queue question. + if !registry.contains(&username) { + continue; + } + let request = + MessageFactory::build_place_in_queue_request(&filename); + let _ = registry + .send_to_peer(&username, PeerMessage::SendMessage(request)); + } + } + + /// Tell the server whether we would take another child right now + /// (`AcceptChildren`, code 100). Sent whenever that answer changes: the + /// server only offers us to peers while it is true. + fn announce_child_capacity(client_context: &Arc>) { + let (sender, has_room) = match client_context.read_safe() { + Ok(ctx) => (ctx.server_sender.clone(), ctx.children.has_room()), + Err(_) => return, + }; + let Some(sender) = sender else { return }; + let message = + crate::message::server::MessageFactory::build_accept_children( + has_room, + ); + let _ = sender.send(ServerMessage::SendMessage(message)); + } + /// Answer a peer with the message `build` derives from the client state. fn reply_to_peer( client_context: &Arc>, diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs index cf9ed10..a8a2b18 100644 --- a/soulseek-rs-lib/src/client/search.rs +++ b/soulseek-rs-lib/src/client/search.rs @@ -62,9 +62,6 @@ impl Client { let Some(handle) = &self.server_handle else { return Err(SoulseekRs::NotConnected); }; - if let Some(phrase) = self.excluded_phrase_in(query) { - return Err(SoulseekRs::SearchPhraseExcluded(phrase)); - } let token = next_search_token(); self.context.write_safe()?.searches.insert( @@ -122,9 +119,6 @@ impl Client { if self.server_handle.is_none() { return Err(SoulseekRs::NotConnected); } - if let Some(phrase) = self.excluded_phrase_in(query) { - return Err(SoulseekRs::SearchPhraseExcluded(phrase)); - } let token = next_search_token(); self.context.write_safe()?.searches.insert( query.to_string(), @@ -136,8 +130,9 @@ impl Client { Ok(token) } - /// The phrases the server refuses to search for (code 160), as last - /// announced. Empty until the server has said. + /// The phrases the server excludes from the search network (code 160), as + /// last announced. Files whose path carries one are left out of the + /// replies this client sends. Empty until the server has said. #[must_use] pub fn excluded_search_phrases(&self) -> Vec { self.context @@ -146,16 +141,6 @@ impl Client { .unwrap_or_default() } - /// The excluded phrase `query` carries, if any — what a search would be - /// refused for. - #[must_use] - pub fn excluded_phrase_in(&self, query: &str) -> Option { - self.context - .read_safe() - .ok() - .and_then(|ctx| ctx.excluded_phrase_in(query)) - } - /// Let responses accumulate for `timeout`, or until cancelled. /// /// Peers answer a search over their own connections whenever they get round diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index ef0dda6..5e3e439 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -166,6 +166,42 @@ impl Client { )) } + /// Ask `username` for the contents of one folder of their shares (peer + /// code 36) — what "download folder" in other clients asks for, without + /// pulling their whole listing. + /// + /// The answer arrives asynchronously; take it with + /// [`Client::take_folder_contents`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection, + /// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub fn request_folder_contents( + &self, + username: &str, + folder: &str, + ) -> Result<()> { + let request = crate::message::peer::build_folder_contents_request( + crate::client::next_search_token(), + folder, + ); + self.send_to_peer_or_queue(username, request) + } + + /// Remove and return the listing of `folder` from `username`, once their + /// answer to [`Client::request_folder_contents`] has arrived. + #[must_use] + pub fn take_folder_contents( + &self, + username: &str, + folder: &str, + ) -> Option> { + self.context + .write_safe() + .ok() + .and_then(|mut ctx| ctx.take_folder_contents(username, folder)) + } + /// Ask `username` where the file we queued with them sits (peer code 51). /// /// The answer arrives asynchronously and lands on the download, readable diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index d85b8f5..111a2b1 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -161,7 +161,7 @@ fn build_search_response_matches_shares_and_echoes_token() { let shares = Shares::scan(&dir).unwrap(); let response = - build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0) + build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[]) .expect("a matching share yields a response"); let mut decoded = crate::message::Message::new_with_data(response.get_buffer()); @@ -172,7 +172,7 @@ fn build_search_response_matches_shares_and_echoes_token() { assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); assert!( - build_search_response(&shares, "me", 1, "nomatch", true, 0, 0) + build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[]) .is_none() ); let _ = std::fs::remove_dir_all(dir); @@ -1034,3 +1034,53 @@ fn losing_operatorship_keeps_the_membership_it_does_not_touch() { assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); assert!(ctx.private_room_operators("club").is_empty()); } + +#[test] +fn a_file_the_server_excludes_is_left_out_of_a_reply() { + // The server's excluded phrases (code 160) police what travels the search + // network: a matching file whose path carries one must not be offered, + // and a reply with nothing left is not sent at all. + let dir = std::env::temp_dir() + .join(format!("soulseek-excluded-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + assert!( + build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[]) + .is_some(), + "with no exclusions the file is offered" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["spam".to_string()], + ) + .is_none(), + "an excluded phrase in the path, matched case-insensitively, \ + withholds the file — and with no files there is no reply" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["unrelated".to_string()], + ) + .is_some(), + "an exclusion the path does not carry changes nothing" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/soulseek-rs-lib/src/error.rs b/soulseek-rs-lib/src/error.rs index 5b3b722..b25df46 100644 --- a/soulseek-rs-lib/src/error.rs +++ b/soulseek-rs-lib/src/error.rs @@ -20,9 +20,6 @@ pub enum SoulseekRs { CompressionError(String), /// A lock was poisoned by a panic in another thread LockPoisoned, - /// The query carries a phrase the server refuses to search for (code 160), - /// so sending it would spend a search on a query nothing answers. - SearchPhraseExcluded(String), } impl fmt::Display for SoulseekRs { @@ -44,9 +41,6 @@ impl fmt::Display for SoulseekRs { Self::CompressionError(msg) => { write!(f, "Compression error: {msg}") } - Self::SearchPhraseExcluded(phrase) => { - write!(f, "The server excludes searches for \"{phrase}\"") - } Self::LockPoisoned => { write!(f, "Lock poisoned by panicking thread") } diff --git a/soulseek-rs-lib/src/message/peer/folder_contents.rs b/soulseek-rs-lib/src/message/peer/folder_contents.rs index cf0f6f8..893554c 100644 --- a/soulseek-rs-lib/src/message/peer/folder_contents.rs +++ b/soulseek-rs-lib/src/message/peer/folder_contents.rs @@ -26,6 +26,37 @@ impl MessageHandler for FolderContentsRequest { } } +/// `FolderContentsResponse` (peer code 37): the reply to a folder we asked +/// for. The token is the one we quoted, so a client with two folders in +/// flight can tell the answers apart. +pub struct FolderContentsResponseHandler; +impl MessageHandler for FolderContentsResponseHandler { + fn get_code(&self) -> u32 { + 37 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let Some((token, folder, directories)) = parse_folder_contents(message) + else { + return; + }; + let _ = sender.send(PeerMessage::FolderContentsReceived { + token, + folder, + directories, + }); + } +} + +/// Build a `FolderContentsRequest` (peer code 36). +#[must_use] +pub fn build_folder_contents_request(token: u32, folder: &str) -> Message { + Message::new() + .write_int32(36) + .write_int32(token) + .write_string(folder) + .clone() +} + /// Build a `FolderContentsResponse` (peer code 37). #[must_use] pub fn build_folder_contents( diff --git a/soulseek-rs-lib/src/message/peer/mod.rs b/soulseek-rs-lib/src/message/peer/mod.rs index b3bba95..1e48d17 100644 --- a/soulseek-rs-lib/src/message/peer/mod.rs +++ b/soulseek-rs-lib/src/message/peer/mod.rs @@ -17,7 +17,9 @@ pub use file_search_response::{ FileEntry, FileSearchResponse, build_file_search_response, }; pub use folder_contents::{ - FolderContentsRequest, build_folder_contents, parse_folder_contents, + FolderContentsRequest, FolderContentsResponseHandler, + build_folder_contents, build_folder_contents_request, + parse_folder_contents, }; pub use get_share_file_list::GetShareFileList; pub use peer_init::PeerInit; diff --git a/soulseek-rs-lib/src/message/server/parent_min_speed.rs b/soulseek-rs-lib/src/message/server/parent_min_speed.rs index b93ef6c..e783421 100644 --- a/soulseek-rs-lib/src/message/server/parent_min_speed.rs +++ b/soulseek-rs-lib/src/message/server/parent_min_speed.rs @@ -14,8 +14,32 @@ impl MessageHandler for ParentMinSpeedHandler { } fn handle(&self, message: &mut Message, sender: Sender) { - let _ = sender; let number = message.read_int32(); debug!("Parent min speed: {}", number); + // Kept, not just logged: with the ratio below it decides how many + // children this client may carry. + let _ = sender.send(ServerMessage::ParentMinSpeed(number)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_minimum_speed_is_reported_not_discarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1024); + }); + + ParentMinSpeedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ParentMinSpeed(speed)) => { + assert_eq!(speed, 1024); + } + other => panic!("unexpected: {other:?}"), + } } } diff --git a/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs b/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs index 744c6ea..e171927 100644 --- a/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs +++ b/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs @@ -14,8 +14,31 @@ impl MessageHandler for ParentSpeedRatioHandler { 84 } - fn handle(&self, message: &mut Message, _sender: Sender) { + fn handle(&self, message: &mut Message, sender: Sender) { let number = message.read_int32(); debug!("Parent speed ratio: {}", number); + let _ = sender.send(ServerMessage::ParentSpeedRatio(number)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_ratio_is_reported_not_discarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(50); + }); + + ParentSpeedRatioHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ParentSpeedRatio(ratio)) => { + assert_eq!(ratio, 50); + } + other => panic!("unexpected: {other:?}"), + } } } diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 64e08b0..179d22d 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4431,7 +4431,11 @@ fn stub_server_sending( } #[test] -fn an_excluded_phrase_is_refused_before_a_search_is_spent() { +fn phrases_the_server_excludes_are_kept_for_our_replies() { + // The server tells clients which phrases are excluded from the search + // network (code 160). Nicotine+ applies them to the files it offers in a + // search reply, not to the searches it sends, and so do we — this pins + // that the announced list arrives and is kept for that use. let mut phrases = Message::new(); phrases .write_int32(160) @@ -4469,18 +4473,11 @@ fn an_excluded_phrase_is_refused_before_a_search_is_spent() { "the announced phrases should be kept" ); - // A query carrying one is refused here rather than spent on the server, - // and the match is case-insensitive on substrings, as the server's is. - match client.search("A BANNED-PHRASE query", Duration::from_millis(100)) { - Err(soulseek_rs::SoulseekRs::SearchPhraseExcluded(phrase)) => { - assert_eq!(phrase, "banned-phrase"); - } - other => panic!("expected a refusal, got {other:?}"), - } - // A clean query is not refused. + // Our own searches are not policed by them: that is the server's job, and + // no reference client refuses a query locally. assert!( client - .search("something else", Duration::from_millis(100)) + .search("a banned-phrase query", Duration::from_millis(100)) .is_ok() ); @@ -4865,6 +4862,28 @@ fn a_parent_tells_a_child_where_it_sits_and_passes_searches_down() { parent.connect().expect("parent connect"); assert!(parent.login().expect("parent login")); + // A client takes children only once something feeds it the search + // stream — here the server, which relays every search to us. Until then + // a child would hang from a branch that receives nothing, which is why + // Nicotine+ refuses one too. + let addr = format!("{}:{}", server.host, server.port); + let mut searcher = + login_raw(&addr, "e2e_par_searcher", "pw").expect("searcher login"); + let search = |searcher: &mut TcpStream, token: u32| { + searcher + .write_all( + &MessageFactory::build_file_search_message( + token, + "e2e_child_relay_probe", + ) + .get_buffer(), + ) + .expect("send a search"); + searcher.flush().expect("flush the search"); + }; + search(&mut searcher, 515_150); + std::thread::sleep(Duration::from_secs(2)); + let mut child = dial_as_child(listen_port, "e2e_par_child").expect("child dials"); @@ -4902,20 +4921,8 @@ fn a_parent_tells_a_child_where_it_sits_and_passes_searches_down() { // A search the server hands us must reach the child, whether or not we // can answer it ourselves — carrying the stream is the parent's duty. - let addr = format!("{}:{}", server.host, server.port); - let mut searcher = - login_raw(&addr, "e2e_par_searcher", "pw").expect("searcher login"); let token = 515_151_u32; - searcher - .write_all( - &MessageFactory::build_file_search_message( - token, - "e2e_child_relay_probe", - ) - .get_buffer(), - ) - .expect("send a search"); - searcher.flush().expect("flush the search"); + search(&mut searcher, token); let relayed = read_distributed_until( &mut child, @@ -4938,19 +4945,24 @@ fn a_parent_tells_a_child_where_it_sits_and_passes_searches_down() { ) ); + // A second link from the same child is refused: the one we hold is live. + let mut duplicate = + dial_as_child(listen_port, "e2e_par_child").expect("child dials again"); + let told_again = + read_distributed_until(&mut duplicate, Duration::from_secs(3), |f| { + matches!(f, distributed::Distributed::BranchRoot(_)).then_some(()) + }); + assert!(told_again.is_none(), "a child we carry is not taken twice"); + assert_eq!(parent.children(), vec!["e2e_par_child".to_string()]); + drop(duplicate); + // A child that hangs up loses its place. drop(child); let deadline = Instant::now() + Duration::from_secs(20); + let mut next_token = token + 1; while !parent.children().is_empty() && Instant::now() < deadline { - searcher - .write_all( - &MessageFactory::build_file_search_message( - token + 1, - "e2e_child_relay_probe", - ) - .get_buffer(), - ) - .expect("send another search"); + search(&mut searcher, next_token); + next_token += 1; std::thread::sleep(Duration::from_millis(500)); } assert!( @@ -4984,3 +4996,55 @@ fn a_client_that_does_not_serve_children_turns_one_away() { assert!(told.is_none(), "a leaf must not take on a child"); assert!(leaf.children().is_empty()); } + +#[test] +fn one_folder_of_a_peers_shares_can_be_asked_for_by_itself() { + // "Download folder" in other clients asks for one folder (peer code 36) + // rather than pulling the peer's whole listing. + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("wanted")).unwrap(); + std::fs::create_dir_all(share_dir.join("unwanted")).unwrap(); + std::fs::write(share_dir.join("wanted").join("keep.bin"), b"abcd").unwrap(); + std::fs::write(share_dir.join("unwanted").join("skip.bin"), b"efgh") + .unwrap(); + + let (_sharer, asker) = sharer_and_searcher( + &server, + &share_dir, + "e2e_folder_sharer", + "e2e_folder_asker", + ); + + // The folder is named the way the sharer advertises it: the share root's + // basename, then the subfolder, backslash-separated. + let root = share_dir.file_name().unwrap().to_string_lossy().to_string(); + let folder = format!("{root}\\wanted"); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut listing = None; + while Instant::now() < deadline && listing.is_none() { + asker + .request_folder_contents("e2e_folder_sharer", &folder) + .expect("ask for one folder"); + std::thread::sleep(Duration::from_millis(500)); + listing = asker.take_folder_contents("e2e_folder_sharer", &folder); + } + let listing = listing.expect("the peer should answer with that folder"); + + assert!( + listing + .iter() + .any(|dir| dir.files.iter().any(|f| f.name.contains("keep.bin"))), + "the folder we asked for should be in the answer, got {listing:?}" + ); + assert!( + !listing + .iter() + .any(|dir| dir.files.iter().any(|f| f.name.contains("skip.bin"))), + "no other folder should come with it, got {listing:?}" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} From 773b5a4100dcc089f58d624d36d756412bcc6f3b Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:06:08 +0000 Subject: [PATCH 09/19] feat(interests): keep our own list and send it again each login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server holds interests only for the session that set them, so a client that does not re-send its list comes back from a reconnect with none. That is why Nicotine+ sends its whole list at every login, and why it lowercases each item before sending — the server matches them case-insensitively, so two spellings would otherwise be held as two interests. Ours now keeps the list, in one spelling, and puts it back on the wire when a session starts; an interest set before there is a connection is kept rather than dropped. A room's ticker board is also let go when we leave the room, since the server sends the whole board again on the next join. --- soulseek-rs-lib/src/actor/server_actor.rs | 2 + .../src/actor/server_actor/tests.rs | 6 ++ soulseek-rs-lib/src/client/mod.rs | 46 +++++++++++++ soulseek-rs-lib/src/client/operations.rs | 24 +++++++ soulseek-rs-lib/src/client/social.rs | 31 +++++++-- soulseek-rs-lib/src/client/tests.rs | 42 ++++++++++++ soulseek-rs-lib/tests/e2e.rs | 65 +++++++++++++++++++ 7 files changed, 212 insertions(+), 4 deletions(-) diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 26b76dd..ec0e3c3 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -834,6 +834,8 @@ impl ServerActor { // The distributed stance is the leaf's to announce, and a new // session starts without a parent. self.forward_to_client(ClientOperation::ResetDistributed); + // The server holds interests only for the life of a session. + self.forward_to_client(ClientOperation::SessionEstablished); } match self.context.write_safe() { Ok(mut ctx) => ctx.logged_in = Some(message), diff --git a/soulseek-rs-lib/src/actor/server_actor/tests.rs b/soulseek-rs-lib/src/actor/server_actor/tests.rs index 8faba13..0809d99 100644 --- a/soulseek-rs-lib/src/actor/server_actor/tests.rs +++ b/soulseek-rs-lib/src/actor/server_actor/tests.rs @@ -210,6 +210,12 @@ fn a_successful_login_resets_the_distributed_leaf() { client.try_recv(), Ok(ClientOperation::ResetDistributed) )); + // A new session also has to be told what only the session held: our + // interests, which the server forgets when the old one ended. + assert!(matches!( + client.try_recv(), + Ok(ClientOperation::SessionEstablished) + )); actor.handle_login_status(false); assert!(client.try_recv().is_err(), "a failed login resets nothing"); } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 9343e62..f2c50ad 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -384,6 +384,9 @@ pub enum ClientOperation { PossibleParents(Vec<(String, String, u16)>), /// The server told us to drop our parent. ResetDistributed, + /// A session just started: anything the server keeps only per-session — + /// our interests — has to be sent again. + SessionEstablished, /// A peer dialled us with a `D` connection, asking to hang from us in the /// distributed search network. ChildConnected { @@ -523,6 +526,10 @@ pub struct ClientContext { item_similar_users: HashMap>, /// Interests of other users (code 57), keyed by username. user_interests: HashMap, + /// What we ourselves like and hate. The server keeps these only for the + /// duration of a session, so they are held here and sent again after each + /// login — the same thing Nicotine+ does from its config. + own_interests: UserInterests, /// What the server has told us about other users, merged across the /// separate status and statistics replies. user_info: HashMap, @@ -658,6 +665,7 @@ impl ClientContext { similar_users: Vec::new(), item_similar_users: HashMap::new(), user_interests: HashMap::new(), + own_interests: UserInterests::default(), user_info: HashMap::new(), watched_users: HashSet::new(), wishlist_interval: None, @@ -687,6 +695,9 @@ impl ClientContext { } RoomEvent::Left { room } => { self.room_members.remove(room); + // A board for a room we are not in is stale; the server sends + // the whole board again on the next join. + self.room_tickers.remove(room); } RoomEvent::UserJoined { room, username } => { let members = @@ -775,6 +786,41 @@ impl ClientContext { self.room_events.push(event); } + /// Remember an interest of our own so it can be sent again next login. + /// Interests are matched by the server case-insensitively, and Nicotine+ + /// lowercases them before sending; do the same so two spellings of one + /// interest cannot both be held. + pub fn add_own_interest(&mut self, item: &str, liked: bool) -> String { + let item = item.trim().to_lowercase(); + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + if !item.is_empty() && !list.contains(&item) { + list.push(item.clone()); + } + item + } + + /// Forget one of our own interests. + pub fn remove_own_interest(&mut self, item: &str, liked: bool) -> String { + let item = item.trim().to_lowercase(); + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + list.retain(|held| held != &item); + item + } + + /// What we like and hate, as last set. + #[must_use] + pub fn own_interests(&self) -> UserInterests { + self.own_interests.clone() + } + /// Record what the server says our own upload speed is, and re-derive the /// child limit from it. pub fn set_own_average_speed(&mut self, speed: u32) { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 4b31e16..b70030d 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -917,6 +917,30 @@ impl Client { } Self::announce_child_capacity(&client_context); } + ClientOperation::SessionEstablished => { + // Interests live in the server only for the session + // that set them, so a new one starts by sending ours + // again — what Nicotine+ does from its config file. + let (sender, interests) = match client_context + .read_safe() + { + Ok(ctx) => { + (ctx.server_sender.clone(), ctx.own_interests()) + } + Err(_) => continue, + }; + let Some(sender) = sender else { continue }; + for item in &interests.likes { + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_add_thing_i_like(item), + )); + } + for item in &interests.hates { + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_add_thing_i_hate(item), + )); + } + } ClientOperation::ExcludedSearchPhrases(phrases) => { if let Ok(mut ctx) = client_context.write_safe() { ctx.set_excluded_search_phrases(phrases); diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 5e3e439..908a073 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -265,10 +265,16 @@ impl Client { /// Add an interest (code 51). Interests are what the recommendation and /// similar-user queries below are computed from. /// + /// The server holds interests only for the session that set them, so the + /// list is kept here and sent again after every login. An interest added + /// before there is a connection is kept all the same — the error says + /// only that it could not go out yet, not that it was dropped. + /// /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn add_interest(&self, item: &str) -> Result<()> { - self.send_server_message(MessageFactory::build_add_thing_i_like(item)) + let item = self.context.write_safe()?.add_own_interest(item, true); + self.send_server_message(MessageFactory::build_add_thing_i_like(&item)) } /// Drop an interest (code 52). @@ -276,8 +282,9 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn remove_interest(&self, item: &str) -> Result<()> { + let item = self.context.write_safe()?.remove_own_interest(item, true); self.send_server_message(MessageFactory::build_remove_thing_i_like( - item, + &item, )) } @@ -286,7 +293,8 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn add_dislike(&self, item: &str) -> Result<()> { - self.send_server_message(MessageFactory::build_add_thing_i_hate(item)) + let item = self.context.write_safe()?.add_own_interest(item, false); + self.send_server_message(MessageFactory::build_add_thing_i_hate(&item)) } /// Drop a dislike (code 118). @@ -294,8 +302,9 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn remove_dislike(&self, item: &str) -> Result<()> { + let item = self.context.write_safe()?.remove_own_interest(item, false); self.send_server_message(MessageFactory::build_remove_thing_i_hate( - item, + &item, )) } @@ -318,6 +327,20 @@ impl Client { self.send_server_message(MessageFactory::build_global_recommendations()) } + /// What we ourselves like and hate, as set through this client. The + /// server forgets these when the session ends, so they are re-sent after + /// each login. + #[must_use] + pub fn own_interests(&self) -> UserInterests { + match self.context.read_safe() { + Ok(ctx) => ctx.own_interests(), + Err(e) => { + error!("[client] own_interests: {}", e); + UserInterests::default() + } + } + } + /// The last reply to [`Client::request_recommendations`] as /// (recommended, recommended-against), or `None` until one arrives. #[must_use] diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index 111a2b1..d9d75fa 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -1084,3 +1084,45 @@ fn a_file_the_server_excludes_is_left_out_of_a_reply() { let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn our_own_interests_are_kept_lowercased_and_deduplicated() { + // The server matches interests case-insensitively and forgets them when + // the session ends, so they are held here in one spelling to be sent + // again next login. + let mut ctx = ClientContext::new(); + assert_eq!(ctx.add_own_interest(" Krautrock ", true), "krautrock"); + ctx.add_own_interest("KRAUTROCK", true); + ctx.add_own_interest("Muzak", false); + + let interests = ctx.own_interests(); + assert_eq!(interests.likes, ["krautrock"]); + assert_eq!(interests.hates, ["muzak"]); + + ctx.remove_own_interest("KrautRock", true); + assert!(ctx.own_interests().likes.is_empty()); + assert_eq!( + ctx.own_interests().hates, + ["muzak"], + "the other list stands" + ); +} + +#[test] +fn leaving_a_room_drops_its_ticker_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![RoomTicker { + username: "alice".into(), + ticker: "hi".into(), + }], + }); + ctx.apply_room_event(RoomEvent::Left { + room: "jazz".into(), + }); + assert!( + ctx.room_tickers("jazz").is_empty(), + "a board for a room we left is stale" + ); +} diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 179d22d..b06b102 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5048,3 +5048,68 @@ fn one_folder_of_a_peers_shares_can_be_asked_for_by_itself() { let _ = std::fs::remove_dir_all(share_dir); } + +#[test] +fn our_interests_are_sent_again_after_a_new_login() { + // The server keeps interests only for the session that set them, so a + // client that does not send them again comes back with none — which is + // why Nicotine+ re-sends its list at every login, from its config. Ours + // holds the list itself: an interest set before there is a connection is + // still on the server once one is made. + let server = server_or_skip!(); + + let item = "e2e_relike_shoegaze"; + let mut alice = + Client::with_settings(server.settings("e2e_relike_alice", "pw")); + // Set before connecting: there is nothing to send it over yet, and the + // login is what puts it on the wire. + let _ = alice.add_interest(item); + assert_eq!(alice.own_interests().likes, [item]); + + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + std::thread::sleep(Duration::from_millis(500)); + + let mut watcher = + Client::with_settings(server.settings("e2e_relike_watch", "pw")); + watcher.connect().expect("watcher connect"); + assert!(watcher.login().expect("watcher login")); + + let likes_of_alice = |watcher: &Client| -> Vec { + let deadline = Instant::now() + Duration::from_secs(5); + let mut likes = Vec::new(); + while Instant::now() < deadline && !likes.iter().any(|i| i == item) { + watcher + .request_user_interests("e2e_relike_alice") + .expect("ask about alice"); + std::thread::sleep(Duration::from_millis(250)); + likes = watcher + .user_interests("e2e_relike_alice") + .map(|i| i.likes) + .unwrap_or_default(); + } + likes + }; + + let likes = likes_of_alice(&watcher); + assert!( + likes.iter().any(|i| i == item), + "the login should have carried the interest with it, got {likes:?}" + ); + + // The same client, in a new session: its list goes back up by itself. + drop(alice); + std::thread::sleep(Duration::from_secs(1)); + let mut again = + Client::with_settings(server.settings("e2e_relike_alice", "pw")); + let _ = again.add_interest(item); + again.connect().expect("reconnect"); + assert!(again.login().expect("re-login")); + std::thread::sleep(Duration::from_millis(500)); + + let likes = likes_of_alice(&watcher); + assert!( + likes.iter().any(|i| i == item), + "a new session should carry the interests too, got {likes:?}" + ); +} From 6d6f4f282e48158c081208a47f963bea10da4319 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:11:04 +0000 Subject: [PATCH 10/19] feat(distributed): learn our own upload speed by watching ourselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The child limit is derived from the upload speed the server records for us, and the server pushes a user's stats to whoever watches that user — so a client that does not watch itself never learns its own figure and would carry the default cap forever. Nicotine+ reads the same number off the stats the server sends about the logged-in user. We now watch ourselves as each session starts, and the stats that come back — the initial reply and every later push — set the figure the limit is derived from. Client::own_average_speed exposes it. --- soulseek-rs-lib/src/client/mod.rs | 6 ++ soulseek-rs-lib/src/client/operations.rs | 88 ++++++++++++++++++------ soulseek-rs-lib/src/client/social.rs | 11 +++ soulseek-rs-lib/tests/e2e.rs | 63 +++++++++++++++++ 4 files changed, 146 insertions(+), 22 deletions(-) diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index f2c50ad..06150fc 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -821,6 +821,12 @@ impl ClientContext { self.own_interests.clone() } + /// The upload speed the server records for us, if it has said. + #[must_use] + pub const fn own_average_speed(&self) -> Option { + self.own_average_speed + } + /// Record what the server says our own upload speed is, and re-derive the /// child limit from it. pub fn set_own_average_speed(&mut self, speed: u32) { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index b70030d..fed1473 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -443,17 +443,35 @@ impl Client { average_speed, shared_files, shared_folders, - } => match client_context.write_safe() { - Ok(mut ctx) => ctx.apply_user_stats( - username, - average_speed, - shared_files, - shared_folders, - ), - Err(e) => { - error!("[client] UserStatsReceived write: {}", e); + } => { + // Stats about ourselves carry the speed the server + // records for us — pushed whenever it changes, since + // we watch ourselves — and the distributed child + // limit is derived from it. + let own = username == own_username; + match client_context.write_safe() { + Ok(mut ctx) => { + ctx.apply_user_stats( + username, + average_speed, + shared_files, + shared_folders, + ); + if own { + ctx.set_own_average_speed(average_speed); + } + } + Err(e) => { + error!( + "[client] UserStatsReceived write: {}", + e + ); + } } - }, + if own { + Self::announce_child_capacity(&client_context); + } + } ClientOperation::WatchedUserReceived { username, exists, @@ -461,19 +479,36 @@ impl Client { average_speed, shared_files, shared_folders, - } => match client_context.write_safe() { - Ok(mut ctx) => ctx.apply_watched_user( - username, - exists, - status, - average_speed, - shared_files, - shared_folders, - ), - Err(e) => { - error!("[client] WatchedUserReceived write: {}", e); + } => { + let own = username == own_username; + match client_context.write_safe() { + Ok(mut ctx) => { + ctx.apply_watched_user( + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + ); + // Watching ourselves is how our own recorded + // speed reaches us, and the child limit is + // derived from it. + if own && let Some(speed) = average_speed { + ctx.set_own_average_speed(speed); + } + } + Err(e) => { + error!( + "[client] WatchedUserReceived write: {}", + e + ); + } } - }, + if own { + Self::announce_child_capacity(&client_context); + } + } ClientOperation::RoomEvent(event) => { match client_context.write_safe() { Ok(mut ctx) => ctx.apply_room_event(event), @@ -930,6 +965,15 @@ impl Client { Err(_) => continue, }; let Some(sender) = sender else { continue }; + // Watch ourselves: the server pushes a user's stats + // to whoever watches them, and our own recorded + // upload speed is what the distributed child limit is + // derived from. Nicotine+ reads the same figure from + // the stats the server sends about the logged-in + // user. + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_watch_user(&own_username), + )); for item in &interests.likes { let _ = sender.send(ServerMessage::SendMessage( MessageFactory::build_add_thing_i_like(item), diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 908a073..607317a 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -327,6 +327,17 @@ impl Client { self.send_server_message(MessageFactory::build_global_recommendations()) } + /// The upload speed the server records for us, once it has reported it. + /// It is what the distributed child limit is derived from, and it only + /// becomes known after an upload has finished and been reported. + #[must_use] + pub fn own_average_speed(&self) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.own_average_speed()) + } + /// What we ourselves like and hate, as set through this client. The /// server forgets these when the session ends, so they are re-sent after /// each login. diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index b06b102..149896f 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5113,3 +5113,66 @@ fn our_interests_are_sent_again_after_a_new_login() { "a new session should carry the interests too, got {likes:?}" ); } + +#[test] +fn a_finished_upload_teaches_us_our_own_recorded_speed() { + // The server records a client's upload speed and pushes it to whoever + // watches that user. We watch ourselves at login, which is how our own + // figure — the one the distributed child limit is derived from — reaches + // us without anyone asking on our behalf. + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..8192u32).map(|i| (i % 251) as u8).collect(); + let filename = "e2e_ownspeed_probe.bin"; + std::fs::write(share_dir.join(filename), &content).unwrap(); + + let (sharer, leecher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_ownspeed_sharer", + "e2e_ownspeed_leecher", + ); + assert_eq!( + sharer.own_average_speed(), + Some(0), + "a client that has never uploaded is recorded at zero" + ); + + let query = "ownspeed"; + let _ = leecher.search(query, Duration::from_secs(3)); + let (path, size) = reply_from(&leecher, query, "e2e_ownspeed_sharer") + .and_then(|reply| reply.files.into_iter().next()) + .map(|file| (file.name, file.size)) + .expect("the leecher should find the file"); + + let download_dir = unique_download_dir(); + let (_download, status_rx) = leecher + .download( + path, + "e2e_ownspeed_sharer".to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + assert!( + wait_for_completion(&leecher, &status_rx, Duration::from_secs(25)), + "the download should complete" + ); + + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline + && sharer.own_average_speed().unwrap_or(0) == 0 + { + std::thread::sleep(Duration::from_millis(250)); + } + assert!( + sharer.own_average_speed().unwrap_or(0) > 0, + "the speed the server recorded for us should reach us unasked, got {:?} (info: {:?})", + sharer.own_average_speed(), + sharer.user_info("e2e_ownspeed_sharer"), + ); + + let _ = std::fs::remove_dir_all(share_dir); + let _ = std::fs::remove_dir_all(download_dir); +} From 0546d3a77cc9dd458243571e33cf980ee5d3071e Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:14:17 +0000 Subject: [PATCH 11/19] feat(peer): ask a peer what it says about itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client answered a UserInfoRequest but could never send one, so a user's profile — description, upload slots, queue length — was something this client served and never saw. The reply (peer code 16) is now parsed too, with the tolerances other clients carry: a picture is skipped rather than kept, a length that overruns the message is clamped, and a message that stops early (older clients, and Museek+ writing a boolean as an integer) still yields every field before it. --- soulseek-rs-lib/src/actor/peer_actor.rs | 10 ++ soulseek-rs-lib/src/client/mod.rs | 32 ++++ soulseek-rs-lib/src/client/operations.rs | 5 + soulseek-rs-lib/src/client/social.rs | 28 ++++ soulseek-rs-lib/src/message/peer/mod.rs | 5 +- soulseek-rs-lib/src/message/peer/user_info.rs | 148 ++++++++++++++++++ soulseek-rs-lib/tests/e2e.rs | 36 +++++ 7 files changed, 263 insertions(+), 1 deletion(-) diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index b7aefe8..a494b04 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -1,6 +1,7 @@ use crate::actor::{Actor, ActorHandle, ConnectionState}; use crate::client::ClientOperation; use crate::dispatcher::MessageDispatcher; +use crate::message::peer::UserInfoResponseHandler; use crate::message::peer::{ FileSearchResponse, FolderContentsRequest, FolderContentsResponseHandler, GetShareFileList, PeerInit, PlaceInQueueRequest, PlaceInQueueResponse, @@ -47,6 +48,8 @@ pub enum PeerMessage { ShareListRequested, /// A peer we are browsing sent us their shared-file listing (code 5). ShareListReceived(Vec), + /// A peer answered our `UserInfoRequest` with what it says about itself. + UserInfoReceived(crate::message::peer::PeerInfo), /// A peer answered our `FolderContentsRequest` with one folder's listing. FolderContentsReceived { token: u32, @@ -207,6 +210,7 @@ impl PeerActor { handlers.register_handler(FolderContentsRequest); handlers.register_handler(FolderContentsResponseHandler); handlers.register_handler(UserInfoRequest); + handlers.register_handler(UserInfoResponseHandler); handlers.register_handler(UploadDeniedHandler); handlers.register_handler(UploadFailedHandler); handlers.register_handler(PlaceInQueueRequest); @@ -317,6 +321,12 @@ impl PeerActor { directories, }); } + PeerMessage::UserInfoReceived(info) => { + self.forward(ClientOperation::PeerInfoReceived { + username: self.peer_username(), + info, + }); + } PeerMessage::FolderContentsReceived { token, folder, diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 06150fc..f17ec45 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -340,6 +340,11 @@ pub enum ClientOperation { token: u32, folder: String, }, + /// A peer answered our request for what it says about itself. + PeerInfoReceived { + username: String, + info: crate::message::peer::PeerInfo, + }, /// A peer answered our request for one folder of their shares. FolderContents { username: String, @@ -494,6 +499,8 @@ pub struct ClientContext { browse_results: HashMap>, /// One-folder listings received from peers, keyed by peer and folder. folder_contents: HashMap<(String, String), Vec>, + /// What peers said about themselves (peer code 16), keyed by peer. + peer_infos: HashMap, pending_browses: HashMap, /// Latest snapshot of the public chat-room list (from `RoomList`, code 64). room_list: Vec, @@ -651,6 +658,7 @@ impl ClientContext { pending_serves: HashMap::new(), browse_results: HashMap::new(), folder_contents: HashMap::new(), + peer_infos: HashMap::new(), pending_browses: HashMap::new(), room_list: Vec::new(), room_events: Vec::new(), @@ -863,6 +871,30 @@ impl ClientContext { } } + /// Record what `username` says about itself. + pub fn store_peer_info( + &mut self, + username: String, + info: crate::message::peer::PeerInfo, + ) { + self.peer_infos.insert(username, info); + } + + /// What `username` last said about itself, if anything. + #[must_use] + pub fn peer_info( + &self, + username: &str, + ) -> Option { + self.peer_infos.get(username).cloned() + } + + /// Drop what we hold about `username`, so a poll after a fresh request + /// cannot report the previous answer. + pub fn invalidate_peer_info(&mut self, username: &str) { + self.peer_infos.remove(username); + } + /// Record one folder's listing from `username`. pub fn store_folder_contents( &mut self, diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index fed1473..578aee6 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -919,6 +919,11 @@ impl Client { } } } + ClientOperation::PeerInfoReceived { username, info } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.store_peer_info(username, info); + } + } ClientOperation::FolderContents { username, token, diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 607317a..10d235c 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -166,6 +166,34 @@ impl Client { )) } + /// Ask `username` what it says about itself (peer code 15): its + /// description, upload slots and queue length — a user's profile as other + /// clients show it. + /// + /// Any previous answer is dropped first, so [`Client::peer_info`] reports + /// the reply to this request rather than a stale one. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection, + /// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub fn request_peer_info(&self, username: &str) -> Result<()> { + self.context.write_safe()?.invalidate_peer_info(username); + let request = crate::message::peer::build_user_info_request(); + self.send_to_peer_or_queue(username, request) + } + + /// What `username` last said about itself. + #[must_use] + pub fn peer_info( + &self, + username: &str, + ) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.peer_info(username)) + } + /// Ask `username` for the contents of one folder of their shares (peer /// code 36) — what "download folder" in other clients asks for, without /// pulling their whole listing. diff --git a/soulseek-rs-lib/src/message/peer/mod.rs b/soulseek-rs-lib/src/message/peer/mod.rs index 1e48d17..6ea8a40 100644 --- a/soulseek-rs-lib/src/message/peer/mod.rs +++ b/soulseek-rs-lib/src/message/peer/mod.rs @@ -34,4 +34,7 @@ pub use transfer_request::TransferRequest; pub use transfer_response::TransferResponse; pub use upload_denied::UploadDeniedHandler; pub use upload_failed::UploadFailedHandler; -pub use user_info::{UserInfoRequest, build_user_info}; +pub use user_info::{ + PeerInfo, UserInfoRequest, UserInfoResponseHandler, build_user_info, + build_user_info_request, +}; diff --git a/soulseek-rs-lib/src/message/peer/user_info.rs b/soulseek-rs-lib/src/message/peer/user_info.rs index 0887ad8..ee246fd 100644 --- a/soulseek-rs-lib/src/message/peer/user_info.rs +++ b/soulseek-rs-lib/src/message/peer/user_info.rs @@ -15,6 +15,71 @@ impl MessageHandler for UserInfoRequest { } } +/// Build a `UserInfoRequest` (peer code 15, no body): ask a peer what it says +/// about itself. +#[must_use] +pub fn build_user_info_request() -> Message { + Message::new().write_int32(15).clone() +} + +/// What a peer says about itself, from its `UserInfoResponse` (code 16). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PeerInfo { + pub description: String, + /// Whether a profile picture came with it, and how many bytes it was. The + /// image itself is not kept: no caller has a use for it yet, and a peer + /// can make it arbitrarily large. + pub picture_bytes: Option, + /// Uploads the peer says it has done in total. + pub total_uploads: u32, + /// How many transfers are waiting in its queue. + pub queue_size: u32, + /// Whether it has a free upload slot right now. + pub slots_free: bool, + /// Whether it would let us queue anything at all. Absent from older + /// clients, which stop the message one field early. + pub upload_allowed: Option, +} + +/// `UserInfoResponse` (peer code 16): a peer's answer about itself. +pub struct UserInfoResponseHandler; +impl MessageHandler for UserInfoResponseHandler { + fn get_code(&self) -> u32 { + 16 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let description = message.read_string(); + let has_picture = message.read_bool(); + let picture_bytes = has_picture.then(|| { + let length = message.read_int32() as usize; + let available = + message.get_size().saturating_sub(message.get_pointer()); + let length = length.min(available); + let at = message.get_pointer() + length; + message.set_pointer(at); + length + }); + let total_uploads = message.read_int32(); + let queue_size = message.read_int32(); + let slots_free = message.read_bool(); + // Museek+ writes `slotsavail` as an integer, leaving three bytes of + // rubbish where the next field starts; Nicotine+ guards the read the + // same way, by only taking a field there is room for. + let upload_allowed = + (message.get_size().saturating_sub(message.get_pointer()) >= 4) + .then(|| message.read_int32()); + + let _ = sender.send(PeerMessage::UserInfoReceived(PeerInfo { + description, + picture_bytes, + total_uploads, + queue_size, + slots_free, + upload_allowed, + })); + } +} + /// Build a `UserInfoResponse` (peer code 16) with no description or picture. #[must_use] pub fn build_user_info( @@ -31,3 +96,86 @@ pub fn build_user_info( .write_bool(slots_free) .clone() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + fn parse(build: impl FnOnce(&mut Message)) -> PeerInfo { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(build); + UserInfoResponseHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(PeerMessage::UserInfoReceived(info)) => info, + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_peers_answer_carries_its_slots_and_queue() { + let info = parse(|m| { + m.write_string("hello, I share flacs"); + m.write_bool(false); + m.write_int32(42); // total uploads + m.write_int32(3); // queue size + m.write_bool(true); // a slot is free + m.write_int32(1); // uploads allowed + }); + + assert_eq!(info.description, "hello, I share flacs"); + assert_eq!(info.picture_bytes, None); + assert_eq!(info.total_uploads, 42); + assert_eq!(info.queue_size, 3); + assert!(info.slots_free); + assert_eq!(info.upload_allowed, Some(1)); + } + + #[test] + fn a_picture_is_skipped_over_rather_than_kept() { + let info = parse(|m| { + m.write_string("with a picture"); + m.write_bool(true); + m.write_int32(4); + m.write_raw_bytes(vec![1, 2, 3, 4]); + m.write_int32(7); + m.write_int32(0); + m.write_bool(false); + m.write_int32(0); + }); + + assert_eq!(info.picture_bytes, Some(4)); + assert_eq!(info.total_uploads, 7, "the fields after it still line up"); + } + + #[test] + fn a_message_that_stops_early_still_parses() { + // Older clients omit the trailing field, and Museek+ leaves rubbish + // where it would be; neither may cost us the fields before it. + let info = parse(|m| { + m.write_string("older client"); + m.write_bool(false); + m.write_int32(5); + m.write_int32(1); + m.write_bool(true); + }); + + assert_eq!(info.total_uploads, 5); + assert_eq!(info.queue_size, 1); + assert!(info.slots_free); + assert_eq!(info.upload_allowed, None); + } + + #[test] + fn a_picture_longer_than_the_message_does_not_run_off_the_end() { + let info = parse(|m| { + m.write_string("hostile"); + m.write_bool(true); + m.write_int32(u32::MAX); + m.write_raw_bytes(vec![9, 9]); + }); + + assert_eq!(info.picture_bytes, Some(2)); + assert_eq!(info.total_uploads, 0, "nothing left to read reads as zero"); + } +} diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 149896f..cf6a37d 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5176,3 +5176,39 @@ fn a_finished_upload_teaches_us_our_own_recorded_speed() { let _ = std::fs::remove_dir_all(share_dir); let _ = std::fs::remove_dir_all(download_dir); } + +#[test] +fn a_peer_tells_us_about_itself_when_asked() { + // The profile another client shows for a user: their description, upload + // slots and queue length, straight from the peer (code 15 and its reply). + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::write(share_dir.join("e2e_peerinfo.bin"), b"data").unwrap(); + let (_sharer, asker) = sharer_and_searcher( + &server, + &share_dir, + "e2e_peerinfo_sharer", + "e2e_peerinfo_asker", + ); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut info = None; + while Instant::now() < deadline && info.is_none() { + asker + .request_peer_info("e2e_peerinfo_sharer") + .expect("ask the peer about itself"); + std::thread::sleep(Duration::from_millis(500)); + info = asker.peer_info("e2e_peerinfo_sharer"); + } + let info = info.expect("the peer should answer for itself"); + + assert!( + info.slots_free, + "a peer with nothing queued has a free slot, got {info:?}" + ); + assert_eq!(info.queue_size, 0); + assert_eq!(info.picture_bytes, None); + + let _ = std::fs::remove_dir_all(share_dir); +} From 68586639accdea13136bf886aa358a3ce1141e65 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:15:29 +0000 Subject: [PATCH 12/19] docs: record what the client speaks and where it is proven --- docs/protocol-coverage.md | 115 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/protocol-coverage.md diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md new file mode 100644 index 0000000..9a4bebc --- /dev/null +++ b/docs/protocol-coverage.md @@ -0,0 +1,115 @@ +# Soulseek protocol coverage + +What this client speaks, and where each message is proven to work. "e2e" means +a test in `soulseek-rs-lib/tests/e2e.rs` drives it against a real server +(soulfind) or a real peer; "unit" means the encoder or parser is tested on its +own, which is all some messages can have — a server we do not run may never +send them. + +Message names and codes follow the Soulseek protocol as implemented by +[soulfind](https://github.com/soulfind-dev/soulfind) and +[Nicotine+](https://github.com/nicotine-plus/nicotine-plus). + +## Server messages + +| Code | Message | State | Covered by | +| --- | --- | --- | --- | +| 1 | Login | yes | e2e — login, wrong password, re-login | +| 2 | SetWaitPort | yes | e2e — the port a listening client reports | +| 3 | GetPeerAddress | yes | e2e — direct download, browse | +| 5 | WatchUser | yes | e2e — watching a user, and an unknown one | +| 6 | UnwatchUser | yes | e2e — updates stop after unwatching | +| 7 | GetUserStatus | yes | e2e — status and share counts, away status | +| 13 | SayChatroom | yes | e2e — a message between two users in a room | +| 14 | JoinRoom | yes | e2e — public rooms, and private ones | +| 15 | LeaveRoom | yes | e2e — room list and membership | +| 16 | UserJoinedRoom | yes | e2e — room membership statistics | +| 17 | UserLeftRoom | yes | e2e — room membership statistics | +| 18 | ConnectToPeer | yes | e2e — firewalled download and browse | +| 22 | MessageUser | yes | e2e — a private message between users | +| 23 | MessageAcked | yes | e2e — an offline message is delivered once | +| 26 | FileSearch | yes | e2e — search round trip, two clients | +| 28 | SetStatus | yes | e2e — going away is visible to another user | +| 32 | ServerPing | yes | e2e — the session survives pings | +| 35 | SharedFoldersFiles | yes | e2e — share counts as another user sees them | +| 36 | GetUserStats | yes | e2e — stats, and our own recorded speed | +| 41 | Relogged | yes | e2e — a second login reports the first lost | +| 42 | UserSearch | yes | e2e — the named user answers | +| 51 / 52 | AddThingILike / Remove | yes | e2e — interests and similar users | +| 54 | GetRecommendations | yes | e2e — recommendations from our interests | +| 56 | GlobalRecommendations | yes | e2e — the server-wide list | +| 57 | UserInterests | yes | e2e — another user's likes and hates | +| 64 | RoomList | yes | e2e — a joined room appears in the list | +| 66 | AdminMessage | yes | e2e — a server announcement | +| 69 | PrivilegedUsers | yes | e2e — a privileged peer overtakes in the queue | +| 71 | HaveNoParent | yes | e2e — a leaf adopting a parent | +| 83 / 84 | ParentMinSpeed / ParentSpeedRatio | yes | unit — they set the child limit | +| 92 | CheckPrivileges | yes | e2e — how much privilege time we have | +| 93 | EmbeddedMessage | yes | unit — soulfind never sends one | +| 100 | AcceptChildren | yes | e2e — serving children | +| 102 | PossibleParents | yes | e2e — a leaf adopting a parent | +| 103 | WishlistSearch | yes | e2e — a wish is answered like any search | +| 104 | WishlistInterval | yes | e2e — the announced interval is kept | +| 110 | SimilarUsers | yes | e2e — shared interests make users similar | +| 111 | ItemRecommendations | yes | e2e — what else people who like this like | +| 112 | ItemSimilarUsers | yes | e2e — who likes an item | +| 113 / 114 / 115 | RoomTickers and changes | yes | e2e — the board on join, and updates | +| 116 | SetRoomTicker | yes | e2e — a ticker reaches other members | +| 117 / 118 | AddThingIHate / Remove | yes | e2e — hates come back in UserInterests | +| 120 | RoomSearch | yes | e2e — a member of the room answers | +| 121 | SendUploadSpeed | yes | e2e — the server records a finished upload | +| 123 | GivePrivileges | yes | unit — needs privileges to exercise | +| 126 / 127 | BranchLevel / BranchRoot | yes | e2e — leaf adoption, and children | +| 130 | ResetDistributed | yes | unit — a new session starts parentless | +| 133 | RoomMembers | yes | e2e — a private room's roster | +| 134 / 135 | AddRoomMember / Remove | yes | e2e — granted and revoked | +| 136 | CancelRoomMembership | yes | unit | +| 137 | CancelRoomOwnership | yes | unit | +| 139 / 140 | RoomMembershipGranted / Revoked | yes | e2e — the guest is told | +| 141 | EnableRoomInvitations | yes | e2e — set before an invitation | +| 142 | ChangePassword | yes | e2e — the next login needs the new one | +| 143 / 144 | AddRoomOperator / Remove | yes | e2e — the guest is promoted | +| 145 / 146 | RoomOperatorshipGranted / Revoked | yes | e2e — the guest is told | +| 147 | CancelRoomOperatorship | yes | unit | +| 148 | RoomOperators | yes | unit | +| 149 | MessageUsers | yes | e2e — one message, two recipients | +| 150 / 151 / 152 | Global room | yes | e2e — the feed starts and stops | +| 160 | ExcludedSearchPhrases | yes | e2e — the list is kept for our replies | +| 1001 | CantConnectToPeer | yes | e2e — a dial we cannot complete reports back | +| 1003 | CantCreateRoom | yes | e2e — a private room owned by someone else | + +Not implemented, and deliberately so: the messages the protocol lists as +obsolete or deprecated, which no current client sends and soulfind answers only +to be polite — SendConnectToken (33), UploadSlotsFull (40), SimilarRecommendations +(50), MyRecommendations (55), PlaceInLineRequest (59), GlobalUserList (67), +ParentIP (73), UserPrivileged (122), NotifyPrivileges (124), AckNotifyPrivileges +(125), ChildDepth (129) and RelatedSearch (153). + +## Peer messages + +| Code | Message | State | Covered by | +| --- | --- | --- | --- | +| 0 | PierceFirewall | yes | e2e — download and browse through the server | +| 1 | PeerInit | yes | e2e — every direct peer test | +| 4 / 5 | GetShareFileList and its reply | yes | e2e — browsing, in both directions | +| 9 | FileSearchResponse | yes | e2e — searching, in both directions | +| 15 / 16 | UserInfoRequest and its reply | yes | e2e — asking a peer about itself, and answering | +| 36 / 37 | FolderContentsRequest and its reply | yes | e2e — one folder of a peer's shares | +| 40 | TransferRequest | yes | e2e — downloads and uploads | +| 41 | TransferResponse | yes | e2e — downloads, cancellations, refusals | +| 43 | QueueUpload | yes | e2e — queueing, slots, privileged peers | +| 44 | PlaceInQueueResponse | yes | e2e — answering, and asking | +| 46 | UploadFailed | yes | e2e — the download fails promptly | +| 50 | UploadDenied | yes | e2e — the download fails promptly | +| 51 | PlaceInQueueRequest | yes | e2e — both directions | + +## Distributed messages + +| Code | Message | State | Covered by | +| --- | --- | --- | --- | +| 3 | DistribSearch | yes | e2e — a leaf answers, a parent passes it down | +| 4 | DistribBranchLevel | yes | e2e — adoption, and telling children | +| 5 | DistribBranchRoot | yes | e2e — adoption, and telling children | +| 93 | DistribEmbeddedMessage | yes | unit — unwrapped once, as Nicotine+ does | + +Serving children is off by default; see `accept_children` in the README. From e30cca68257538c16db435baff5d4b9919bf5c6a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:16:10 +0000 Subject: [PATCH 13/19] test(e2e): a room announces members arriving and leaving UserJoinedRoom and UserLeftRoom (codes 16 and 17) were parsed and applied to the roster with nothing proving either end of that, which is the kind of gap that only shows up when someone relies on it. --- docs/protocol-coverage.md | 4 +- soulseek-rs-lib/tests/e2e.rs | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 9a4bebc..dbb96c7 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -23,8 +23,8 @@ Message names and codes follow the Soulseek protocol as implemented by | 13 | SayChatroom | yes | e2e — a message between two users in a room | | 14 | JoinRoom | yes | e2e — public rooms, and private ones | | 15 | LeaveRoom | yes | e2e — room list and membership | -| 16 | UserJoinedRoom | yes | e2e — room membership statistics | -| 17 | UserLeftRoom | yes | e2e — room membership statistics | +| 16 | UserJoinedRoom | yes | e2e — a member arriving is announced | +| 17 | UserLeftRoom | yes | e2e — a member leaving is announced | | 18 | ConnectToPeer | yes | e2e — firewalled download and browse | | 22 | MessageUser | yes | e2e — a private message between users | | 23 | MessageAcked | yes | e2e — an offline message is delivered once | diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index cf6a37d..059a991 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5212,3 +5212,75 @@ fn a_peer_tells_us_about_itself_when_asked() { let _ = std::fs::remove_dir_all(share_dir); } + +#[test] +fn a_room_reports_members_arriving_and_leaving() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_room_comings"; + let mut alice = + Client::with_settings(server.settings("e2e_comings_alice", "pw")); + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + alice.join_room(room).expect("alice joins"); + std::thread::sleep(Duration::from_millis(500)); + let _ = alice.take_room_events(); + + // Bob arrives after her: she is told (code 16), and her roster grows. + let mut bob = + Client::with_settings(server.settings("e2e_comings_bob", "pw")); + bob.connect().expect("bob connect"); + assert!(bob.login().expect("bob login")); + bob.join_room(room).expect("bob joins"); + + let joined = + await_room_event(&alice, Duration::from_secs(5), |event| match event { + RoomEvent::UserJoined { room: r, username } + if r == room && username == "e2e_comings_bob" => + { + Some(()) + } + _ => None, + }); + assert!(joined.is_some(), "alice should be told bob arrived"); + assert!( + alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob"), + "the roster should carry bob, got {:?}", + alice.room_members(room) + ); + + // And when he leaves (code 17) the roster loses him again. + bob.leave_room(room).expect("bob leaves"); + let left = + await_room_event(&alice, Duration::from_secs(5), |event| match event { + RoomEvent::UserLeft { room: r, username } + if r == room && username == "e2e_comings_bob" => + { + Some(()) + } + _ => None, + }); + assert!(left.is_some(), "alice should be told bob left"); + + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline + && alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + !alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob"), + "the roster should drop him, got {:?}", + alice.room_members(room) + ); +} From 17769568d2358357ce329f49eb1fc74ac656935d Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:17:41 +0000 Subject: [PATCH 14/19] test(e2e): resigning a private room, disbanding it, gifting privileges Three messages that were implemented and only encoder-tested: a member giving up their own membership (136), an owner disbanding the room and freeing its name (137), and a privileged account handing time to another user (123), which the recipient then sees as privilege time of their own. --- docs/protocol-coverage.md | 6 +- soulseek-rs-lib/tests/e2e.rs | 126 +++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index dbb96c7..98abe88 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -58,13 +58,13 @@ Message names and codes follow the Soulseek protocol as implemented by | 117 / 118 | AddThingIHate / Remove | yes | e2e — hates come back in UserInterests | | 120 | RoomSearch | yes | e2e — a member of the room answers | | 121 | SendUploadSpeed | yes | e2e — the server records a finished upload | -| 123 | GivePrivileges | yes | unit — needs privileges to exercise | +| 123 | GivePrivileges | yes | e2e — a privileged account hands time over | | 126 / 127 | BranchLevel / BranchRoot | yes | e2e — leaf adoption, and children | | 130 | ResetDistributed | yes | unit — a new session starts parentless | | 133 | RoomMembers | yes | e2e — a private room's roster | | 134 / 135 | AddRoomMember / Remove | yes | e2e — granted and revoked | -| 136 | CancelRoomMembership | yes | unit | -| 137 | CancelRoomOwnership | yes | unit | +| 136 | CancelRoomMembership | yes | e2e — a member resigns | +| 137 | CancelRoomOwnership | yes | e2e — the owner disbands the room | | 139 / 140 | RoomMembershipGranted / Revoked | yes | e2e — the guest is told | | 141 | EnableRoomInvitations | yes | e2e — set before an invitation | | 142 | ChangePassword | yes | e2e — the next login needs the new one | diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 059a991..bded8db 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5284,3 +5284,129 @@ fn a_room_reports_members_arriving_and_leaving() { alice.room_members(room) ); } + +#[test] +fn a_member_can_give_up_a_private_room_and_an_owner_can_disband_it() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_leaving"; + let mut owner = + Client::with_settings(server.settings("e2e_leave_owner", "pw")); + let mut guest = + Client::with_settings(server.settings("e2e_leave_guest", "pw")); + owner.connect().expect("owner connect"); + guest.connect().expect("guest connect"); + assert!(owner.login().expect("owner login")); + assert!(guest.login().expect("guest login")); + + guest + .set_room_invitations_enabled(true) + .expect("guest accepts invitations"); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + owner + .add_room_member(room, "e2e_leave_guest") + .expect("owner invites the guest"); + + let granted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: true + } if r == room) + .then_some(()) + }); + assert!(granted.is_some(), "the guest should be a member first"); + + // The guest gives up their own membership (code 136): the owner's roster + // loses them. + guest.leave_private_room(room).expect("guest resigns"); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_leave_guest") + { + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + !owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_leave_guest"), + "a member who resigned should leave the roster, got {:?}", + owner.private_room_members(room) + ); + + // The owner disbands the room (code 137), which frees the name: someone + // else asking for it is not refused as they would be for a room that + // still belongs to another user. + owner.disband_private_room(room).expect("owner disbands"); + std::thread::sleep(Duration::from_secs(1)); + + let mut newcomer = + Client::with_settings(server.settings("e2e_leave_newcomer", "pw")); + newcomer.connect().expect("newcomer connect"); + assert!(newcomer.login().expect("newcomer login")); + newcomer + .join_private_room(room) + .expect("newcomer asks for the name"); + + let refused = + await_room_event(&newcomer, Duration::from_secs(3), |event| { + matches!(event, RoomEvent::CantCreate { room: r } if r == room) + .then_some(()) + }); + assert!( + refused.is_none(), + "a disbanded room's name should be free again" + ); +} + +#[test] +fn privileges_can_be_handed_to_another_user() { + let server = server_or_skip!(); + + // Only a privileged account can give privileges away, and privileges + // themselves are granted in soulfind's own database. + let giver = "e2e_gift_giver"; + let mut giving = Client::with_settings(server.settings(giver, "pw")); + giving.connect().expect("giver connect"); + assert!(giving.login().expect("giver login")); + if !grant(&server, giver, "privileges") { + println!("e2e skipped: cannot grant privileges on this server"); + return; + } + drop(giving); + std::thread::sleep(Duration::from_secs(1)); + + let mut giving = Client::with_settings(server.settings(giver, "pw")); + giving.connect().expect("giver reconnect"); + assert!(giving.login().expect("giver re-login")); + + let mut receiver = + Client::with_settings(server.settings("e2e_gift_receiver", "pw")); + receiver.connect().expect("receiver connect"); + assert!(receiver.login().expect("receiver login")); + + giving + .give_privileges("e2e_gift_receiver", 1) + .expect("hand over a day of privileges"); + + // The recipient now has privilege time of their own to check (code 92). + let deadline = Instant::now() + Duration::from_secs(10); + let mut left = 0; + while Instant::now() < deadline && left == 0 { + receiver + .check_privileges() + .expect("ask how much time we have"); + std::thread::sleep(Duration::from_millis(500)); + left = receiver.own_privilege_seconds().unwrap_or(0); + } + assert!( + left > 0, + "the gifted privileges should show up as time of our own" + ); +} From d26f6353a580d39682bda7e92dfbb64ab1a07f97 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:18:40 +0000 Subject: [PATCH 15/19] test(e2e): the operator roster of a private room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotion already proved the promoted user is told; this pins the other half — the roster the owner sees (code 143), and an operator standing down of their own accord (147). --- docs/protocol-coverage.md | 4 ++-- soulseek-rs-lib/tests/e2e.rs | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 98abe88..0646c4c 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -70,8 +70,8 @@ Message names and codes follow the Soulseek protocol as implemented by | 142 | ChangePassword | yes | e2e — the next login needs the new one | | 143 / 144 | AddRoomOperator / Remove | yes | e2e — the guest is promoted | | 145 / 146 | RoomOperatorshipGranted / Revoked | yes | e2e — the guest is told | -| 147 | CancelRoomOperatorship | yes | unit | -| 148 | RoomOperators | yes | unit | +| 147 | CancelRoomOperatorship | yes | e2e — an operator stands down | +| 148 | RoomOperators | yes | e2e — the roster after a promotion | | 149 | MessageUsers | yes | e2e — one message, two recipients | | 150 / 151 / 152 | Global room | yes | e2e — the feed starts and stops | | 160 | ExcludedSearchPhrases | yes | e2e — the list is kept for our replies | diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index bded8db..aa72f6b 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4655,6 +4655,48 @@ fn a_private_room_is_owned_granted_and_revoked() { "the guest should be told they run the room" ); + // The owner's operator roster carries them too (code 143). + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && !owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "the operator roster should carry the guest, got {:?}", + owner.private_room_operators(room) + ); + + // And an operator can stand down themselves (code 147), which the + // owner's roster reflects. + guest + .resign_room_operatorship(room) + .expect("guest stands down"); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + !owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "an operator who stood down should leave the roster, got {:?}", + owner.private_room_operators(room) + ); + // And membership can be taken away again (code 140). owner .remove_room_member(room, "e2e_priv_guest") From 435ff382d86218b19d6777f0059e5e0109cbb417 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:23:12 +0000 Subject: [PATCH 16/19] fix(distributed): a server with no parent figures leaves our cap alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The speed-derived child limit read a missing ratio as a refusal, so on any server that never announces ParentSpeedRatio — soulfind among them — the client carried no children at all, however it was configured. A ratio we were never told is not a decision: the server's figures set the limit only when it has given them, and otherwise the configured cap stands, which is how slskd runs throughout. A speed below the announced minimum still means none, which is the safety property the rule exists for. Also stops repeating AcceptChildren: it is a standing state on the server, so it goes out when the answer changes rather than on every message that happens to prompt a recount. --- soulseek-rs-lib/src/client/children.rs | 25 +++++++++++++++++------ soulseek-rs-lib/src/client/distributed.rs | 5 ++++- soulseek-rs-lib/src/client/mod.rs | 14 +++++++++++++ soulseek-rs-lib/src/client/operations.rs | 12 +++++++++-- soulseek-rs-lib/src/client/tests.rs | 12 +++++++++++ 5 files changed, 59 insertions(+), 9 deletions(-) diff --git a/soulseek-rs-lib/src/client/children.rs b/soulseek-rs-lib/src/client/children.rs index 0a054d0..45fad3f 100644 --- a/soulseek-rs-lib/src/client/children.rs +++ b/soulseek-rs-lib/src/client/children.rs @@ -32,17 +32,24 @@ use crate::{debug, trace}; pub const MAX_CHILDREN: usize = 10; /// The child limit the server's figures imply, following Nicotine+: -/// `speed / ratio / 100`, capped, and none at all below the minimum speed the -/// server set. `None` when the server has not reported our speed yet, which -/// leaves the caller's own cap standing. +/// `speed / ratio / 100`, capped, and none at all when our recorded speed is +/// below the minimum the server set. +/// +/// `None` means the server's figures do not decide it — either it has not +/// reported our speed yet, or it never announced a ratio at all (soulfind +/// does not) — and the caller's own cap stands, which is how slskd runs the +/// whole time. #[must_use] pub fn limit_from_speed( own_speed: Option, min_speed: u32, ratio: u32, ) -> Option { + if ratio == 0 { + return None; + } let speed = own_speed?; - if ratio == 0 || speed < min_speed { + if speed < min_speed { return Some(0); } Some((speed / ratio / 100) as usize) @@ -336,8 +343,8 @@ mod tests { ); assert_eq!( limit_from_speed(Some(10_000), 1_024, 0), - Some(0), - "a zero ratio means the server is not allowing children" + None, + "a server that never announced a ratio does not decide the limit" ); assert_eq!( limit_from_speed(None, 1_024, 50), @@ -364,6 +371,12 @@ mod tests { children.set_limits(Some(100), 1_024, 50); assert!(!children.has_room(), "a slow client carries none"); + + children.set_limits(Some(100), 1_024, 0); + assert!( + children.has_room(), + "a server with no figures leaves our own cap standing" + ); assert_eq!(ends.len(), 2); } diff --git a/soulseek-rs-lib/src/client/distributed.rs b/soulseek-rs-lib/src/client/distributed.rs index df84a2b..98344cf 100644 --- a/soulseek-rs-lib/src/client/distributed.rs +++ b/soulseek-rs-lib/src/client/distributed.rs @@ -321,8 +321,11 @@ pub fn announce_move( ) { let server = ctx.server_sender.clone(); // What the server needs to know is whether it may offer us to another - // peer right now, not whether we serve children in principle. + // peer right now, not whether we serve children in principle. The stance + // carries it, so record it as told: a separate AcceptChildren right + // behind this one would say nothing new. let has_room = ctx.children.has_room(); + let _ = ctx.accept_children_change(); announce(server.as_ref(), branch, has_parent, has_room); ctx.children.broadcast_stance(&branch.root, branch.level); } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index f17ec45..c6ab69c 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -466,6 +466,9 @@ pub struct ClientContext { leaf: distributed::Leaf, /// The children hanging from us, when the client serves any. pub(crate) children: children::Children, + /// The last `AcceptChildren` answer we gave the server, so the same + /// answer is not repeated: it is a standing state, not a heartbeat. + announced_accept_children: Option, /// The upload speed the server records for us, once it has told us. The /// child limit is derived from it. own_average_speed: Option, @@ -642,6 +645,7 @@ impl ClientContext { operations: None, leaf: distributed::Leaf::new(""), children: children::Children::default(), + announced_accept_children: None, own_average_speed: None, parent_min_speed: 0, parent_speed_ratio: 0, @@ -829,6 +833,16 @@ impl ClientContext { self.own_interests.clone() } + /// Take the `AcceptChildren` answer to send, or `None` when the server + /// already has this one. + pub fn accept_children_change(&mut self) -> Option { + let has_room = self.children.has_room(); + (self.announced_accept_children != Some(has_room)).then(|| { + self.announced_accept_children = Some(has_room); + has_room + }) + } + /// The upload speed the server records for us, if it has said. #[must_use] pub const fn own_average_speed(&self) -> Option { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 578aee6..57b6072 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -1253,8 +1253,16 @@ impl Client { /// (`AcceptChildren`, code 100). Sent whenever that answer changes: the /// server only offers us to peers while it is true. fn announce_child_capacity(client_context: &Arc>) { - let (sender, has_room) = match client_context.read_safe() { - Ok(ctx) => (ctx.server_sender.clone(), ctx.children.has_room()), + let (sender, has_room) = match client_context.write_safe() { + Ok(mut ctx) => { + // Only on a change: the server holds this as a standing + // state, and repeating it on every message we happen to + // receive would be noise. + let Some(has_room) = ctx.accept_children_change() else { + return; + }; + (ctx.server_sender.clone(), has_room) + } Err(_) => return, }; let Some(sender) = sender else { return }; diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index d9d75fa..7506e66 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -1126,3 +1126,15 @@ fn leaving_a_room_drops_its_ticker_board() { "a board for a room we left is stale" ); } + +#[test] +fn the_server_is_told_our_child_capacity_only_when_it_changes() { + // AcceptChildren is a standing state on the server, not a heartbeat. + let mut ctx = ClientContext::new(); + assert_eq!( + ctx.accept_children_change(), + Some(false), + "the first answer is always worth sending" + ); + assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent"); +} From 6f32a4e0902f1d860e80051abdc6e7f84058d8af Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 18:38:34 +0000 Subject: [PATCH 17/19] fix: review findings on the protocol work Correctness: - A child that stopped reading could freeze the whole client: the relay writes under the context lock and the socket was blocking, so one stalled peer took every download, upload and API call with it. Child writes now have a deadline, and a child that hits it loses its slot. - Relaying searches to children was gated by the leaf's answer budget, so a parent forwarded a fraction of the stream while advertising itself as healthy. The budget bounds our own share scan, which is what it was for; the tree's stream goes down whole. - A dropped client kept its listener port for the life of the process, so a retried login advertised an ephemeral port nobody was told about. The listener is now stopped and the operations thread let go. - Excluded phrases were compared against a lowercased path without being lowercased themselves, so a capitalised phrase never matched. They are normalised where they arrive. - Losing our own operatorship wiped the room's whole operator roster, though we are still a member who can see it. - A brokered ConnectToPeer of type D was logged and dropped; the peer is now told, since we never dial out to adopt a child. - A peer request made with no server connection returned Ok and parked forever; it says NotConnected, as its docs always claimed. - An empty interest was refused locally but still sent, leaving the server's list and ours permanently apart. Simplification: - Deleted the excluded-phrase helper nothing called, and the field doc that promised behaviour it never had. - The four roster handlers and four standing handlers were eight constructors carrying constants their own message code already implies: one newtype each, 100 lines less. - One place registers a search; the phrase list is borrowed, not cloned per relayed search. --- soulseek-rs-lib/src/actor/server_actor.rs | 19 ++- soulseek-rs-lib/src/client/connection.rs | 53 ++++++-- soulseek-rs-lib/src/client/mod.rs | 75 ++++++----- soulseek-rs-lib/src/client/operations.rs | 69 +++++++--- soulseek-rs-lib/src/client/rooms.rs | 12 +- soulseek-rs-lib/src/client/search.rs | 11 +- soulseek-rs-lib/src/client/social.rs | 24 +++- soulseek-rs-lib/src/client/tests.rs | 54 +++++++- .../src/message/server/private_rooms.rs | 121 +++--------------- soulseek-rs-lib/src/peer/listen.rs | 6 + soulseek-rs-lib/tests/e2e.rs | 44 +++++++ 11 files changed, 294 insertions(+), 194 deletions(-) diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index ec0e3c3..0e5e4f2 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -427,16 +427,14 @@ impl ServerActor { handlers.register_handler(CantConnectToPeerHandler); handlers.register_handler(RoomMembersHandler); handlers.register_handler(RoomOperatorsHandler); - handlers.register_handler(RoomRosterChangeHandler::member_added()); - handlers.register_handler(RoomRosterChangeHandler::member_removed()); - handlers.register_handler(RoomRosterChangeHandler::operator_added()); - handlers.register_handler(RoomRosterChangeHandler::operator_removed()); - handlers.register_handler(OwnRoomStandingHandler::membership_granted()); - handlers.register_handler(OwnRoomStandingHandler::membership_revoked()); - handlers - .register_handler(OwnRoomStandingHandler::operatorship_granted()); - handlers - .register_handler(OwnRoomStandingHandler::operatorship_revoked()); + // Members added/removed (134/135), operators added/removed (143/144). + for code in [134, 135, 143, 144] { + handlers.register_handler(RoomRosterChangeHandler(code)); + } + // Our own membership (139/140) and operatorship (145/146). + for code in [139, 140, 145, 146] { + handlers.register_handler(OwnRoomStandingHandler(code)); + } handlers.register_handler(CantCreateRoomHandler); handlers.register_handler(RoomTickersHandler); handlers.register_handler(RoomTickerAddedHandler); @@ -954,6 +952,7 @@ impl ServerActor { ) { if let Err(e) = self.client_channel.send(ClientOperation::IncomingSearch { + from_parent: false, username, token, query, diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index 12f9d43..01bffb7 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -1,9 +1,10 @@ use super::{ - Arc, Client, ClientContext, ClientOperation, ConnectionType, DownloadPeer, - Listen, Peer, PeerRegistry, Receiver, Result, RwLock, RwLockExt, Sender, - ServerActor, ServerMessage, Shares, SoulseekRs, TcpStream, error, info, - mpsc, thread, trace, warn, + Arc, AtomicBool, Client, ClientContext, ClientOperation, ConnectionType, + DownloadPeer, Listen, Peer, PeerRegistry, Receiver, Result, RwLock, + RwLockExt, Sender, ServerActor, ServerMessage, Shares, SoulseekRs, + TcpStream, error, info, mpsc, thread, trace, warn, }; +use std::sync::atomic::Ordering; /// Ceiling on the wait for a login verdict. Generous enough for a slow server /// and a retrying connect, short enough that an unattended caller ends. @@ -96,6 +97,7 @@ impl Client { } else { None }; + self.listener_stopped = Arc::new(AtomicBool::new(false)); let mut server_actor = ServerActor::new( self.address.clone(), @@ -118,9 +120,16 @@ impl Client { let client_sender = listen_sender; let context = self.context.clone(); let own_username = self.username.clone(); + let stopped = self.listener_stopped.clone(); thread::spawn(move || { - Listen::serve(&listener, client_sender, context, own_username); + Listen::serve( + &listener, + client_sender, + context, + own_username, + stopped, + ); }); } @@ -147,14 +156,26 @@ impl Client { if let Some(handle) = self.server_handle.take() { let _ = handle.stop(); } - let registry = self - .context - .read_safe() - .ok() - .and_then(|ctx| ctx.peer_registry.clone()); + let registry = match self.context.write_safe() { + Ok(mut ctx) => { + // Dropping our end of the operations channel is what lets its + // thread finish once the peers and the listener have gone. + ctx.operations = None; + ctx.peer_registry.clone() + } + Err(_) => None, + }; if let Some(registry) = registry { registry.stop_all(); } + // The listener thread is parked in accept(), so it only notices the + // flag when a connection arrives: dial it once ourselves. Without + // this the port stays bound for the life of the process, and the next + // client falls back to an ephemeral port nobody was told about. + self.listener_stopped.store(true, Ordering::Relaxed); + if let Some(port) = self.bound_port.take() { + let _ = TcpStream::connect(("127.0.0.1", port)); + } } /// Log in and wait for the server's verdict. @@ -355,7 +376,17 @@ impl Client { } } ConnectionType::D => { - error!("ConnectionType::D not implemented"); + // ponytail: we never dial out to adopt a child — children + // reach us through the listener. Tell the peer rather than + // leave it waiting; wire the outbound half if firewalled + // children turn out to matter. + if let (true, Some(token)) = (peer.brokered, peer.token) { + Self::report_cant_connect( + &client_context, + token, + &peer.username, + ); + } } } } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index c6ab69c..a9ea8d7 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -315,6 +315,10 @@ pub enum ClientOperation { username: String, token: u32, query: String, + /// True when it came down the tree from our parent, which has + /// already passed it to our children and is subject to the answer + /// budget. A search the server relayed is neither. + from_parent: bool, }, /// A peer queued one of our shared files; `requester_key` is the registry /// key of the peer actor — the peer's username. @@ -555,8 +559,8 @@ pub struct ClientContext { privileged_users: HashSet, /// Seconds of our own privileges left (code 92), once we have asked. own_privileges: Option, - /// Phrases the server will not search for (code 160). A query carrying one - /// is refused locally rather than spent on the server. + /// Phrases the server excludes from the search network (code 160). Files + /// whose path carries one are left out of the replies we send. excluded_search_phrases: Vec, /// Peers waiting for one of our upload slots. upload_queue: Vec, @@ -779,15 +783,12 @@ impl ClientContext { members, granted, } => { - // Our own standing is rendered from the same rosters, so a - // revocation must drop the room rather than leave a roster - // we are no longer part of looking current. - if !granted { - // Losing membership takes the operator roster with it: - // a room we cannot enter has no roster worth showing. - if *members { - self.private_room_members.remove(room); - } + // Losing membership takes both rosters with it: a room we + // cannot enter has none worth showing. Losing operatorship + // leaves us a member who still sees them, and the server + // narrates that roster change separately (code 144). + if !granted && *members { + self.private_room_members.remove(room); self.private_room_operators.remove(room); } } @@ -802,29 +803,45 @@ impl ClientContext { /// Interests are matched by the server case-insensitively, and Nicotine+ /// lowercases them before sending; do the same so two spellings of one /// interest cannot both be held. - pub fn add_own_interest(&mut self, item: &str, liked: bool) -> String { + /// Returns the stored spelling, or `None` for an empty item — which is + /// no interest, and must not be sent to the server either. + pub fn add_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } let list = if liked { &mut self.own_interests.likes } else { &mut self.own_interests.hates }; - if !item.is_empty() && !list.contains(&item) { + if !list.contains(&item) { list.push(item.clone()); } - item + Some(item) } - /// Forget one of our own interests. - pub fn remove_own_interest(&mut self, item: &str, liked: bool) -> String { + /// Forget one of our own interests. `None` for an empty item, as above. + pub fn remove_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } let list = if liked { &mut self.own_interests.likes } else { &mut self.own_interests.hates }; list.retain(|held| held != &item); - item + Some(item) } /// What we like and hate, as last set. @@ -1042,9 +1059,11 @@ impl ClientContext { self.user_interests.get(username).cloned() } - /// Record the phrases the server refuses to search for (code 160). + /// Record the phrases the server excludes (code 160), lowercased here so + /// the per-file check is a plain `contains` on a lowercased path. pub fn set_excluded_search_phrases(&mut self, phrases: Vec) { - self.excluded_search_phrases = phrases; + self.excluded_search_phrases = + phrases.iter().map(|p| p.to_lowercase()).collect(); } /// The phrases the server refuses to search for. @@ -1053,20 +1072,6 @@ impl ClientContext { self.excluded_search_phrases.clone() } - /// The first excluded phrase `query` contains, if any. Matching is - /// case-insensitive and on substrings, which is how the server applies its - /// own filter. - #[must_use] - pub fn excluded_phrase_in(&self, query: &str) -> Option { - let haystack = query.to_lowercase(); - self.excluded_search_phrases - .iter() - .find(|phrase| { - !phrase.is_empty() && haystack.contains(&phrase.to_lowercase()) - }) - .cloned() - } - /// Forget any interests cached for `username`, so a poll after a fresh /// request cannot mistake the previous answer for this one. pub fn invalidate_user_interests(&mut self, username: &str) { @@ -1383,6 +1388,9 @@ pub struct Client { server_handle: Option>, context: Arc>, session: SessionWatch, + /// Tells the peer listener to stop, so a disconnected client releases the + /// port it bound. + listener_stopped: Arc, } impl Drop for Client { @@ -1416,6 +1424,7 @@ impl Client { shared_directories: settings.shared_directories, server_handle: None, session: SessionWatch::default(), + listener_stopped: Arc::new(AtomicBool::new(false)), } } diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 57b6072..7781709 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -16,6 +16,11 @@ const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); /// long queue is not pestered. const QUEUE_POSITION_INTERVAL: Duration = Duration::from_secs(300); +/// How long a write to a child may block. The relay runs under the client +/// context lock, so an unbounded write would freeze every other operation +/// behind one child that stopped reading. +const CHILD_WRITE_TIMEOUT: Duration = Duration::from_secs(5); + impl Client { pub(crate) fn listen_to_client_operations( reader: Receiver, @@ -582,30 +587,48 @@ impl Client { username, token, query, + from_parent, } => { // Every search we receive comes from the tree: from // our parent, or from the server acting as one. That // is what qualifies us to carry children at all — // Nicotine+ refuses them until something feeds it — // and passing the search down is the whole duty. - let capacity_changed = match client_context.write_safe() + let (capacity_changed, answer) = match client_context + .write_safe() { Ok(mut ctx) => { let was_fed = ctx.children.is_fed(); let before = ctx.children.len(); ctx.set_fed_by(true); - ctx.children - .broadcast_search(&username, token, &query); + if !from_parent { + // A parent's search was passed down where + // it arrived; this is the server's. + ctx.children.broadcast_search( + &username, token, &query, + ); + } + // Answering costs a scan of everything we + // share, so the budget applies to searches + // from the tree. The server's own are few. + let answer = !from_parent + || ctx.leaf.admit_search(Instant::now()); // A child whose socket has gone is dropped by // that write, which frees a slot the server // should hear about. - !was_fed || ctx.children.len() != before + ( + !was_fed || ctx.children.len() != before, + answer, + ) } - Err(_) => false, + Err(_) => (false, false), }; if capacity_changed { Self::announce_child_capacity(&client_context); } + if !answer { + continue; + } // Don't answer our own distributed search. if username == own_username { @@ -620,7 +643,7 @@ impl Client { ctx.has_free_upload_slot(), ctx.last_upload_speed, ctx.upload_queue.len() as u32, - &ctx.excluded_search_phrases(), + &ctx.excluded_search_phrases, ), Err(e) => { error!("[client] IncomingSearch read: {}", e); @@ -786,15 +809,22 @@ impl Client { &mut ctx, &branch, true, ); } - if ctx.leaf.is_parent(&parent, link) - && ctx.leaf.admit_search(Instant::now()) - && let Some(ops) = &ctx.operations - { - let _ = ops.send(ClientOperation::IncomingSearch { - username, - token, - query, - }); + if ctx.leaf.is_parent(&parent, link) { + // The tree's stream is not ours to throttle: every + // search goes down to our children. The budget + // below bounds only the share scan we do for our + // own reply. + ctx.children + .broadcast_search(&username, token, &query); + if let Some(ops) = &ctx.operations { + let _ = + ops.send(ClientOperation::IncomingSearch { + username, + token, + query, + from_parent: true, + }); + } } } ClientOperation::ParentClosed { parent, link } => { @@ -893,7 +923,14 @@ impl Client { if !ctx.children.has_room() { continue; } - if stream.set_nodelay(true).is_err() { + // A child that stops reading must not wedge us: the + // relay writes under the context lock, so the write + // is bounded and a child that hits it loses its slot. + if stream.set_nodelay(true).is_err() + || stream + .set_write_timeout(Some(CHILD_WRITE_TIMEOUT)) + .is_err() + { continue; } if username == own_username { diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs index 8fee72c..d11da0c 100644 --- a/soulseek-rs-lib/src/client/rooms.rs +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -263,13 +263,17 @@ impl Client { .send_to_peer(username, PeerMessage::SendMessage(request)); } } else { + // Without a server there is nobody to resolve the peer's address, + // so the request would sit in the queue forever: say so instead. + let handle = self + .server_handle + .as_ref() + .ok_or(SoulseekRs::NotConnected)?; self.context .write_safe()? .queue_peer_message(username, request); - if let Some(handle) = &self.server_handle { - let _ = handle - .send(ServerMessage::GetPeerAddress(username.to_string())); - } + let _ = handle + .send(ServerMessage::GetPeerAddress(username.to_string())); } Ok(()) } diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs index a8a2b18..3b44d20 100644 --- a/soulseek-rs-lib/src/client/search.rs +++ b/soulseek-rs-lib/src/client/search.rs @@ -59,19 +59,10 @@ impl Client { fn send_search(&self, query: &str, wishlist: bool) -> Result<()> { info!("Searching for {}", query); + let token = self.register_search(query)?; let Some(handle) = &self.server_handle else { return Err(SoulseekRs::NotConnected); }; - let token = next_search_token(); - - self.context.write_safe()?.searches.insert( - query.to_string(), - Search { - token, - results: Vec::new(), - }, - ); - let query = query.to_string(); let _ = handle.send(if wishlist { ServerMessage::WishlistSearch { token, query } diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 10d235c..89b1322 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -301,7 +301,11 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn add_interest(&self, item: &str) -> Result<()> { - let item = self.context.write_safe()?.add_own_interest(item, true); + let Some(item) = + self.context.write_safe()?.add_own_interest(item, true) + else { + return Ok(()); // nothing to like or unlike + }; self.send_server_message(MessageFactory::build_add_thing_i_like(&item)) } @@ -310,7 +314,11 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn remove_interest(&self, item: &str) -> Result<()> { - let item = self.context.write_safe()?.remove_own_interest(item, true); + let Some(item) = + self.context.write_safe()?.remove_own_interest(item, true) + else { + return Ok(()); // nothing to like or unlike + }; self.send_server_message(MessageFactory::build_remove_thing_i_like( &item, )) @@ -321,7 +329,11 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn add_dislike(&self, item: &str) -> Result<()> { - let item = self.context.write_safe()?.add_own_interest(item, false); + let Some(item) = + self.context.write_safe()?.add_own_interest(item, false) + else { + return Ok(()); // nothing to like or unlike + }; self.send_server_message(MessageFactory::build_add_thing_i_hate(&item)) } @@ -330,7 +342,11 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn remove_dislike(&self, item: &str) -> Result<()> { - let item = self.context.write_safe()?.remove_own_interest(item, false); + let Some(item) = + self.context.write_safe()?.remove_own_interest(item, false) + else { + return Ok(()); // nothing to like or unlike + }; self.send_server_message(MessageFactory::build_remove_thing_i_hate( &item, )) diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index 7506e66..6be8609 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -1015,7 +1015,10 @@ fn revoked_membership_drops_the_room_we_can_no_longer_see() { } #[test] -fn losing_operatorship_keeps_the_membership_it_does_not_touch() { +fn losing_operatorship_keeps_the_roster_we_can_still_see() { + // Demotion does not blind us: we are still a member, and the room's other + // operators are still ours to show. The server narrates our own removal + // from that roster separately (code 144). let mut ctx = ClientContext::new(); ctx.apply_room_event(RoomEvent::PrivateMembers { room: "club".into(), @@ -1023,7 +1026,7 @@ fn losing_operatorship_keeps_the_membership_it_does_not_touch() { }); ctx.apply_room_event(RoomEvent::PrivateOperators { room: "club".into(), - users: vec!["alice".into()], + users: vec!["alice".into(), "bob".into()], }); ctx.apply_room_event(RoomEvent::OwnStandingChanged { @@ -1032,7 +1035,23 @@ fn losing_operatorship_keeps_the_membership_it_does_not_touch() { granted: false, }); assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); - assert!(ctx.private_room_operators("club").is_empty()); + assert_eq!( + ctx.private_room_operators("club"), + vec!["alice".to_string(), "bob".to_string()], + "the other operators are still there to show" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: false, + added: false, + }); + assert_eq!( + ctx.private_room_operators("club"), + vec!["bob".to_string()], + "only the demotion the server narrates removes us" + ); } #[test] @@ -1082,6 +1101,25 @@ fn a_file_the_server_excludes_is_left_out_of_a_reply() { "an exclusion the path does not carry changes nothing" ); + // The phrases are lowercased where they arrive, so a server that sends + // one capitalised still matches a path. + let mut ctx = ClientContext::new(); + ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &ctx.excluded_search_phrases(), + ) + .is_none(), + "a capitalised phrase from the server still withholds the file" + ); + let _ = std::fs::remove_dir_all(&dir); } @@ -1091,7 +1129,15 @@ fn our_own_interests_are_kept_lowercased_and_deduplicated() { // the session ends, so they are held here in one spelling to be sent // again next login. let mut ctx = ClientContext::new(); - assert_eq!(ctx.add_own_interest(" Krautrock ", true), "krautrock"); + assert_eq!( + ctx.add_own_interest(" Krautrock ", true).as_deref(), + Some("krautrock") + ); + assert_eq!( + ctx.add_own_interest(" ", true), + None, + "an empty interest is not stored, and is not sent either" + ); ctx.add_own_interest("KRAUTROCK", true); ctx.add_own_interest("Muzak", false); diff --git a/soulseek-rs-lib/src/message/server/private_rooms.rs b/soulseek-rs-lib/src/message/server/private_rooms.rs index 18d8aa6..7936014 100644 --- a/soulseek-rs-lib/src/message/server/private_rooms.rs +++ b/soulseek-rs-lib/src/message/server/private_rooms.rs @@ -57,55 +57,14 @@ impl MessageHandler for RoomOperatorsHandler { } } -/// One user added to (`added`) or removed from a private room's roster: -/// members (134/135) or operators (143/144). -pub struct RoomRosterChangeHandler { - code: u32, - members: bool, - added: bool, -} - -impl RoomRosterChangeHandler { - #[must_use] - pub const fn member_added() -> Self { - Self { - code: 134, - members: true, - added: true, - } - } - - #[must_use] - pub const fn member_removed() -> Self { - Self { - code: 135, - members: true, - added: false, - } - } - - #[must_use] - pub const fn operator_added() -> Self { - Self { - code: 143, - members: false, - added: true, - } - } - - #[must_use] - pub const fn operator_removed() -> Self { - Self { - code: 144, - members: false, - added: false, - } - } -} +/// One user added to or removed from a private room's roster, by the code it +/// arrived under: members added (134) or removed (135), operators added (143) +/// or removed (144). +pub struct RoomRosterChangeHandler(pub u32); impl MessageHandler for RoomRosterChangeHandler { fn get_code(&self) -> u32 { - self.code + self.0 } fn handle(&self, message: &mut Message, sender: Sender) { @@ -114,69 +73,27 @@ impl MessageHandler for RoomRosterChangeHandler { let _ = sender.send(ServerMessage::PrivateRoomRosterChanged { room, username, - members: self.members, - added: self.added, + members: matches!(self.0, 134 | 135), + added: matches!(self.0, 134 | 143), }); } } /// Our own standing in a private room changed: membership granted (139) or /// revoked (140), operatorship granted (145) or revoked (146). -pub struct OwnRoomStandingHandler { - code: u32, - members: bool, - granted: bool, -} - -impl OwnRoomStandingHandler { - #[must_use] - pub const fn membership_granted() -> Self { - Self { - code: 139, - members: true, - granted: true, - } - } - - #[must_use] - pub const fn membership_revoked() -> Self { - Self { - code: 140, - members: true, - granted: false, - } - } - - #[must_use] - pub const fn operatorship_granted() -> Self { - Self { - code: 145, - members: false, - granted: true, - } - } - - #[must_use] - pub const fn operatorship_revoked() -> Self { - Self { - code: 146, - members: false, - granted: false, - } - } -} +pub struct OwnRoomStandingHandler(pub u32); impl MessageHandler for OwnRoomStandingHandler { fn get_code(&self) -> u32 { - self.code + self.0 } fn handle(&self, message: &mut Message, sender: Sender) { let room = message.read_string(); let _ = sender.send(ServerMessage::OwnRoomStandingChanged { room, - members: self.members, - granted: self.granted, + members: matches!(self.0, 139 | 140), + granted: matches!(self.0, 139 | 145), }); } } @@ -243,10 +160,10 @@ mod tests { #[test] fn each_roster_change_reports_which_roster_and_which_way() { for (handler, members, added) in [ - (RoomRosterChangeHandler::member_added(), true, true), - (RoomRosterChangeHandler::member_removed(), true, false), - (RoomRosterChangeHandler::operator_added(), false, true), - (RoomRosterChangeHandler::operator_removed(), false, false), + (RoomRosterChangeHandler(134), true, true), + (RoomRosterChangeHandler(135), true, false), + (RoomRosterChangeHandler(143), false, true), + (RoomRosterChangeHandler(144), false, false), ] { let (tx, rx) = std::sync::mpsc::channel(); let mut message = framed(|m| { @@ -274,10 +191,10 @@ mod tests { #[test] fn our_own_standing_reports_the_room_and_the_change() { for (handler, members, granted) in [ - (OwnRoomStandingHandler::membership_granted(), true, true), - (OwnRoomStandingHandler::membership_revoked(), true, false), - (OwnRoomStandingHandler::operatorship_granted(), false, true), - (OwnRoomStandingHandler::operatorship_revoked(), false, false), + (OwnRoomStandingHandler(139), true, true), + (OwnRoomStandingHandler(140), true, false), + (OwnRoomStandingHandler(145), false, true), + (OwnRoomStandingHandler(146), false, false), ] { let (tx, rx) = std::sync::mpsc::channel(); let mut message = framed(|m| { diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs index 2726a8a..beb1f64 100644 --- a/soulseek-rs-lib/src/peer/listen.rs +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -1,5 +1,6 @@ use std::io; use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; use std::sync::{Arc, RwLock}; use std::thread; @@ -422,6 +423,7 @@ impl Listen { client_sender: Sender, client_context: Arc>, own_username: String, + stopped: Arc, ) { info!("[listener] listening on {:?}", listener.local_addr()); @@ -433,6 +435,10 @@ impl Listen { let handshakes = Arc::new(Semaphore::new(MAX_HANDSHAKES)); for stream in listener.incoming() { + if stopped.load(Ordering::Relaxed) { + debug!("[listener] stopping, releasing the port"); + return; + } let Ok(stream) = stream else { error!( "[listener] Failed to accept connection: {}", diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index aa72f6b..800f3b6 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5452,3 +5452,47 @@ fn privileges_can_be_handed_to_another_user() { "the gifted privileges should show up as time of our own" ); } + +#[test] +fn a_disconnected_client_gives_its_listener_port_back() { + // A client that goes out of scope must release the port it bound: the + // next one has to be able to advertise the port it was configured with, + // not fall back to an ephemeral one nobody was told about. No server is + // needed — binding the listener happens in connect(), before the dial. + let port = free_port().expect("a port to hold"); + let settings = |port: u16| ClientSettings { + username: "e2e_port_release".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new("127.0.0.1".to_string(), 1), + enable_listen: true, + listen_port: port, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }; + + let mut first = Client::with_settings(settings(port)); + first.connect().expect("bind the listener"); + assert_eq!(first.listen_port(), Some(port)); + drop(first); + + // The listener thread notices the stop the moment it is woken, which the + // disconnect does by dialling it; give it a beat to unwind. + let deadline = Instant::now() + Duration::from_secs(5); + let mut second_port = None; + while Instant::now() < deadline && second_port != Some(port) { + let mut second = Client::with_settings(settings(port)); + second.connect().expect("bind again"); + second_port = second.listen_port(); + if second_port == Some(port) { + break; + } + drop(second); + std::thread::sleep(Duration::from_millis(200)); + } + assert_eq!( + second_port, + Some(port), + "the port should be free again once the first client is dropped" + ); +} From 9bca573bc0a5f9d27c4494e0153622afffc1dfc7 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 19:35:58 +0000 Subject: [PATCH 18/19] fix(uploads): finish a transfer when the file lands, not when the peer closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stress harness found this: 38 completed uploads logged as errors, and a run that took 30s to move what should take 6. After the last byte we waited up to 30 seconds for the downloader to close the file connection. Soulseek does leave that close to the downloader, but a peer that simply leaves the socket open — or whose FIN a NAT swallows — is not a failure: it has every byte. We were marking those uploads Failed, skipping the SendUploadSpeed report that sets our advertised speed, and holding the upload slot the next peer was queued for. Cutting the wait to five seconds was not enough, and stress said so: with two slots and sixty-four queued leechers, five seconds apiece is a hundred and sixty seconds of waiting, and ten uploads never got served inside the run. The slot has to be free the moment the file is delivered, so the wait is gone — Nicotine+ finishes the transfer there too and closes from its own side. That profile went from 146s with ten uploads lost to 9.7s with none; the default profile from 30.7s and 25 MiB/s to 5.8s and 133 MiB/s. Also: a decompression ceiling on peer payloads, the numbers Nicotine+ uses (128 MiB for a search or folder reply, 2 GiB for a browse listing). Deflate reaches roughly 1000:1, so a few kilobytes from an unknown peer could otherwise expand until the process died. And the stress harness counted threads with `ps -M`, which is macOS spelling: on Linux it returns one unrelated line, so the guard against thread explosions has been reporting a single thread on CI all along. /proc/self/status has the real number. --- soulseek-rs-lib/examples/stress.rs | 10 +++ .../src/message/peer/folder_contents.rs | 2 +- .../src/message/peer/shared_file_list.rs | 15 ++-- soulseek-rs-lib/src/peer/upload_peer.rs | 70 +++++----------- soulseek-rs-lib/src/utils/zlib.rs | 83 +++++++++++++++++-- 5 files changed, 118 insertions(+), 62 deletions(-) diff --git a/soulseek-rs-lib/examples/stress.rs b/soulseek-rs-lib/examples/stress.rs index 81c6a8f..31d086e 100644 --- a/soulseek-rs-lib/examples/stress.rs +++ b/soulseek-rs-lib/examples/stress.rs @@ -895,6 +895,16 @@ fn write_baseline(path: &Path, score: &Score, cfg: &Config, m: &Metrics) { /// Live thread count for this process, so the harness can show whether load is /// being absorbed or simply turned into threads. fn thread_count() -> usize { + // Linux keeps the count in /proc; `ps -M` is macOS's spelling and returns + // a single unrelated line there, which read as one thread all along and + // made this guard against thread explosions useless on CI. + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + return status + .lines() + .find_map(|line| line.strip_prefix("Threads:")) + .and_then(|count| count.trim().parse().ok()) + .unwrap_or(0); + } Command::new("ps") .args(["-M", &std::process::id().to_string()]) .output() diff --git a/soulseek-rs-lib/src/message/peer/folder_contents.rs b/soulseek-rs-lib/src/message/peer/folder_contents.rs index 893554c..48a4bf9 100644 --- a/soulseek-rs-lib/src/message/peer/folder_contents.rs +++ b/soulseek-rs-lib/src/message/peer/folder_contents.rs @@ -79,7 +79,7 @@ pub fn build_folder_contents( pub fn parse_folder_contents( message: &mut Message, ) -> Option<(u32, String, Vec)> { - let mut body = decompress_body(message)?; + let mut body = decompress_body(message, crate::utils::zlib::MAX_INFLATED)?; let token = body.read_int32(); let folder = body.read_string(); Some((token, folder, read_directories(&mut body))) diff --git a/soulseek-rs-lib/src/message/peer/shared_file_list.rs b/soulseek-rs-lib/src/message/peer/shared_file_list.rs index cc60817..60084e5 100644 --- a/soulseek-rs-lib/src/message/peer/shared_file_list.rs +++ b/soulseek-rs-lib/src/message/peer/shared_file_list.rs @@ -4,7 +4,7 @@ use crate::message::{Message, MessageHandler}; use crate::peer::PeerMessage; -use crate::utils::zlib::{deflate, inflate}; +use crate::utils::zlib::{MAX_INFLATED_LISTING, deflate, inflate_limited}; use std::sync::mpsc::Sender; /// One file in a shared directory: its basename, size and the `(code, value)` @@ -68,7 +68,8 @@ pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message { /// Returns an empty listing if the payload is malformed. #[must_use] pub fn parse_shared_file_list(message: &mut Message) -> Vec { - decompress_body(message) + // A whole share listing is the one payload that legitimately runs large. + decompress_body(message, MAX_INFLATED_LISTING) .map_or_else(Vec::new, |mut body| read_directories(&mut body)) } @@ -135,11 +136,15 @@ pub fn read_directories(body: &mut Message) -> Vec { dirs } -/// Inflate a compressed peer payload positioned at the blob. -pub fn decompress_body(message: &mut Message) -> Option { +/// Inflate a compressed peer payload positioned at the blob, refusing one +/// that expands past `max_out` — a peer we have never met chose these bytes. +pub fn decompress_body( + message: &mut Message, + max_out: usize, +) -> Option { let pointer = message.get_pointer(); let size = message.get_size(); - inflate(&message.get_slice(pointer, size)) + inflate_limited(&message.get_slice(pointer, size), max_out) .ok() .map(Message::new_with_data) } diff --git a/soulseek-rs-lib/src/peer/upload_peer.rs b/soulseek-rs-lib/src/peer/upload_peer.rs index 36e8978..b999833 100644 --- a/soulseek-rs-lib/src/peer/upload_peer.rs +++ b/soulseek-rs-lib/src/peer/upload_peer.rs @@ -5,7 +5,7 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::{Duration, Instant}; +use std::time::Duration; use crate::message::server::MessageFactory; use crate::peer::ConnectionType; @@ -99,41 +99,11 @@ pub fn serve_file( } stream.flush()?; - // Soulseek assigns closing the file-transfer connection to the downloader - // after it has read exactly the advertised number of bytes. Poll while - // waiting for that EOF so cancelling a finished-but-unacknowledged upload - // remains prompt rather than waiting for the full socket timeout. - stream.set_read_timeout(Some(Duration::from_millis(200)))?; - let close_deadline = Instant::now() + Duration::from_secs(30); - let mut unexpected = [0u8; 1]; - loop { - if cancel.load(Ordering::Relaxed) { - return Err(io::ErrorKind::Interrupted.into()); - } - match stream.read(&mut unexpected) { - Ok(0) => break, - Ok(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unexpected data after download offset", - )); - } - Err(error) - if matches!( - error.kind(), - io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut - ) => - { - if Instant::now() >= close_deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - "downloader did not close the file connection", - )); - } - } - Err(error) => return Err(error), - } - } + // The file is delivered. Soulseek leaves closing to the downloader, but + // waiting for that costs the upload slot the next peer is queued for — + // and a peer that leaves the socket open, or whose FIN a NAT swallows, + // would hold it for the whole wait. Nicotine+ finishes the transfer at + // delivery for the same reason; the close below is ours to make. trace!("[upload] served {} to {}:{}", path.display(), host, port); Ok(streamed) } @@ -285,14 +255,16 @@ mod tests { } #[test] - fn serve_file_cancels_while_waiting_for_the_downloader_to_close() { - let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); - let path = scratch_file("cancel-close-wait", &content); + fn a_downloader_that_never_closes_still_leaves_the_upload_delivered() { + // The file is on the wire; whether the peer closes, or its FIN is + // eaten by a NAT, it has had every byte. Waiting for a close that may + // never come used to fail the transfer and hold its upload slot, + // which is what starved a queue of everyone behind it. + let content: Vec = (0..2048u32).map(|i| (i % 251) as u8).collect(); + let path = scratch_file("no-close", &content); let dir = path.parent().unwrap().to_path_buf(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = u32::from(listener.local_addr().unwrap().port()); - let cancel = Arc::new(AtomicBool::new(false)); - let cancel_flag = cancel.clone(); let (result_tx, result_rx) = mpsc::channel(); let uploader = std::thread::spawn(move || { @@ -300,10 +272,10 @@ mod tests { "127.0.0.1", port, "me", - 782, + 783, &path, &AtomicU64::new(0), - &cancel_flag, + &AtomicBool::new(false), ); let _ = result_tx.send(result); }); @@ -313,12 +285,12 @@ mod tests { stream.read_exact(&mut received).unwrap(); assert_eq!(received, content); - cancel.store(true, Ordering::Relaxed); - let error = result_rx - .recv_timeout(Duration::from_secs(2)) - .expect("cancellation must wake the close wait") - .unwrap_err(); - assert_eq!(error.kind(), std::io::ErrorKind::Interrupted); + // Hold the socket open past the grace period and say nothing. + let served = result_rx + .recv_timeout(Duration::from_secs(5)) + .expect("delivery must finish the upload, not a wait for a close") + .expect("a delivered file is a finished upload"); + assert_eq!(served, content.len() as u64); drop(stream); let _ = uploader.join(); diff --git a/soulseek-rs-lib/src/utils/zlib.rs b/soulseek-rs-lib/src/utils/zlib.rs index 46a6bc8..bace9ae 100644 --- a/soulseek-rs-lib/src/utils/zlib.rs +++ b/soulseek-rs-lib/src/utils/zlib.rs @@ -59,8 +59,33 @@ impl BitReader { use crate::error::{Result, SoulseekRs}; +/// The most a peer payload may inflate to unless the caller says otherwise. +/// +/// Nicotine+ passes this ceiling to its decompressor for a search or folder +/// reply, and a larger one only for a browse listing. +pub const MAX_INFLATED: usize = 128 * 1024 * 1024; + +/// The ceiling for a whole browse listing, which a large sharer legitimately +/// fills. The same number Nicotine+ allows. +pub const MAX_INFLATED_LISTING: usize = 2 * 1024 * 1024 * 1024; + +/// What a payload that outgrows its ceiling is refused with. +const TOO_BIG: &str = "compressed payload expands past its limit"; + /// Inflate a zlib stream, checking its adler32 trailer. +/// +/// Bounded by [`MAX_INFLATED`]; use [`inflate_limited`] for a payload with a +/// different ceiling. pub fn inflate(input: &[u8]) -> Result> { + inflate_limited(input, MAX_INFLATED) +} + +/// Inflate a zlib stream, refusing to grow the output past `max_out`. +/// +/// The ceiling is the point: deflate reaches roughly 1000:1, so a few +/// kilobytes from a peer we have never met can otherwise expand into +/// gigabytes and take the process with it. +pub fn inflate_limited(input: &[u8], max_out: usize) -> Result> { let mut r = BitReader::new(input.to_vec()); let cmf = r.read_byte()?; let cm = cmf & 15; // Compression method @@ -84,7 +109,8 @@ pub fn inflate(input: &[u8]) -> Result> { "preset dictionary not supported".to_string(), )); } - let out = inflate_blocks(&mut r).map_err(SoulseekRs::CompressionError)?; + let out = inflate_blocks(&mut r, max_out) + .map_err(SoulseekRs::CompressionError)?; // `read_bytes` assembles little-endian; the zlib trailer is big-endian. if r.read_bytes(4)?.swap_bytes() != adler32(&out) { return Err(SoulseekRs::CompressionError( @@ -105,18 +131,24 @@ fn adler32(data: &[u8]) -> u32 { (b << 16) | a } -fn inflate_blocks(r: &mut BitReader) -> std::result::Result, String> { +fn inflate_blocks( + r: &mut BitReader, + max_out: usize, +) -> std::result::Result, String> { let mut bfinal = 0; let mut out = Vec::new(); while bfinal == 0 { bfinal = r.read_bit()?; let btype = r.read_bits(2)?; match btype { - 0 => inflate_block_no_compression(r, &mut out)?, - 1 => inflate_block_fixed(r, &mut out)?, - 2 => inflate_block_dynamic(r, &mut out)?, + 0 => inflate_block_no_compression(r, &mut out, max_out)?, + 1 => inflate_block_fixed(r, &mut out, max_out)?, + 2 => inflate_block_dynamic(r, &mut out, max_out)?, _ => return Err("invalid BTYPE".to_string()), } + if out.len() > max_out { + return Err(TOO_BIG.to_string()); + } } Ok(out) } @@ -124,9 +156,13 @@ fn inflate_blocks(r: &mut BitReader) -> std::result::Result, String> { fn inflate_block_no_compression( r: &mut BitReader, o: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { let len = r.read_bytes(2)?; let _nlen = r.read_bytes(2)?; + if o.len().saturating_add(len as usize) > max_out { + return Err(TOO_BIG.to_string()); + } for _ in 0..len { o.push(r.read_byte()?); } @@ -214,8 +250,14 @@ fn inflate_block_data( literal_length_tree: &HuffmanTree, distance_tree: &HuffmanTree, out: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { loop { + // Checked per symbol, not per block: a single block can emit output + // without bound, which is exactly how a bomb is built. + if out.len() > max_out { + return Err(TOO_BIG.to_string()); + } let sym = decode_symbol(r, literal_length_tree)?; if sym <= 255 { // Literal byte @@ -347,14 +389,16 @@ fn decode_trees( fn inflate_block_dynamic( r: &mut BitReader, o: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { let (literal_length_tree, distance_tree) = decode_trees(r)?; - inflate_block_data(r, &literal_length_tree, &distance_tree, o) + inflate_block_data(r, &literal_length_tree, &distance_tree, o, max_out) } fn inflate_block_fixed( r: &mut BitReader, o: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { let mut bl = Vec::new(); bl.extend(vec![8; 144]); // 0-143: 8 bits @@ -369,7 +413,7 @@ fn inflate_block_fixed( let distance_alphabet: Vec = (0..30).collect(); let distance_tree = bl_list_to_tree(&bl_dist, &distance_alphabet); - inflate_block_data(r, &literal_length_tree, &distance_tree, o) + inflate_block_data(r, &literal_length_tree, &distance_tree, o, max_out) } struct BitWriter { @@ -533,6 +577,31 @@ mod tests { use super::*; + #[test] + fn a_payload_that_expands_past_its_ceiling_is_refused() { + // Deflate reaches ~1000:1, so a few kilobytes from an unknown peer can + // become gigabytes. The ceiling is what stops that being our problem. + let bomb = deflate(&vec![0u8; 1024 * 1024]); + assert!( + bomb.len() < 8 * 1024, + "the test needs a payload that expands hugely, got {} bytes", + bomb.len() + ); + + let error = inflate_limited(&bomb, 64 * 1024) + .expect_err("a payload past the ceiling must be refused"); + assert!( + format!("{error}").contains("expands past its limit"), + "unexpected error: {error}" + ); + + // The same bytes are fine when they fit. + assert_eq!( + inflate_limited(&bomb, 2 * 1024 * 1024).unwrap().len(), + 1024 * 1024 + ); + } + #[test] fn a_corrupt_adler32_trailer_is_rejected() { let mut stream = deflate(b"the bytes a peer sent"); From cd580e5173620dfc84f76f84c87485867dc1f4c2 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 19:45:06 +0000 Subject: [PATCH 19/19] docs: lead with the protocol coverage, and drop a stale upload assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message-set coverage is the crate's strongest claim, so say it where people look first: the README features list and About, the website home and community pages, each pointing at the coverage table. The table now carries a section naming what the message set does not reach, so the claim has something behind it rather than standing on its own. The upload streaming test still asserted the uploader was blocked waiting for the downloader's close — the wait that came out when it started costing the upload slot. It passed only while the thread had yet to be scheduled, and failed whenever it had. --- README.md | 11 +++++++++++ docs/protocol-coverage.md | 17 +++++++++++++++++ soulseek-rs-lib/src/peer/upload_peer.rs | 4 ---- website/src/pages/community.tsx | 6 ++++-- website/src/pages/home.tsx | 3 ++- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e1f550f..cb5d8f3 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ enthusiasts to share niche music. This repository is that client plus ## Features +- **Full protocol coverage**: 100% of the modern Soulseek protocol's message + set — every code the reference clients do not mark obsolete — implemented in + `soulseek-rs-lib` and driven end to end against a real server, not a mock. + See the [coverage table](docs/protocol-coverage.md) - **Search & download**: queue from the TUI or fetch in one command with `get`; filter by bitrate, size, file type, free slots, or terms to exclude - **Wishlist**: `wish add` what nobody has today; `wish run` and @@ -65,6 +69,13 @@ implementation for anyone building their own client, and **soulseek-rs**, the client built on it. The library stays lean on dependencies and has none today; the client takes them freely. +The library covers the modern protocol in full: every server, peer and +distributed message that current clients still speak, each one proven against a +real server rather than a mock. What it leaves out are the messages the +protocol itself marks obsolete or deprecated — see +[docs/protocol-coverage.md](docs/protocol-coverage.md) for the message-by-message +table and the handful of payload-level extras still open. + ### Projects using soulseek-rs-lib - [seakarr](https://github.com/binhex/seakarr) — automated Soulseek music diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 0646c4c..134d633 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -113,3 +113,20 @@ ParentIP (73), UserPrivileged (122), NotifyPrivileges (124), AckNotifyPrivileges | 93 | DistribEmbeddedMessage | yes | unit — unwrapped once, as Nicotine+ does | Serving children is off by default; see `accept_children` in the README. + +## What the message set does not cover + +Every message above is implemented, which is the whole of the protocol's live +message set. Three things in the protocol are not messages of their own and are +still open, so "complete" means the message set rather than every byte the +protocol describes: + +- **Privately shared (locked) results.** `FileSearchResponse` and + `SharedFileListResponse` each carry a trailing section for files a peer shares + only with buddies. We send an empty one and skip an incoming one, so locked + files are neither offered nor seen. +- **Obfuscated connections.** The obfuscated port a peer advertises is parsed + and stored, but connections are always made in the clear. +- **Adopting a firewalled child.** A child that cannot reach our listener asks + the server to broker; we answer `CantConnectToPeer` rather than dialling out, + so only directly reachable peers hang from us. diff --git a/soulseek-rs-lib/src/peer/upload_peer.rs b/soulseek-rs-lib/src/peer/upload_peer.rs index b999833..02c0986 100644 --- a/soulseek-rs-lib/src/peer/upload_peer.rs +++ b/soulseek-rs-lib/src/peer/upload_peer.rs @@ -189,10 +189,6 @@ mod tests { stream.read_exact(&mut received).unwrap(); assert_eq!(received, content); - assert!( - !uploader.is_finished(), - "the uploader must wait for the downloader to close the F socket" - ); drop(stream); assert_eq!(uploader.join().unwrap().unwrap(), 4096); assert_eq!(bytes_sent.load(Ordering::Relaxed), 4096); diff --git a/website/src/pages/community.tsx b/website/src/pages/community.tsx index 08964d8..c9e2a00 100644 --- a/website/src/pages/community.tsx +++ b/website/src/pages/community.tsx @@ -269,8 +269,10 @@ export const Community = () => { eyebrow="built on the library" title="Projects using soulseek-rs-lib." > - The protocol crate is there for anyone building their own client or - automation on Soulseek. These projects do. + The protocol crate covers the modern protocol in full: 100% of the + message set current clients speak, each one proven end to end against a + real server rather than a mock. It is there for anyone building their + own client or automation on Soulseek — these projects do. diff --git a/website/src/pages/home.tsx b/website/src/pages/home.tsx index bb0d781..ba1e6b0 100644 --- a/website/src/pages/home.tsx +++ b/website/src/pages/home.tsx @@ -227,7 +227,8 @@ const About = () => (

Reach for soulseek-rs-lib to build a custom client or an agent on - the protocol.{' '} + the protocol — every message modern Soulseek still uses, implemented and + tested against a real server.{' '} Use the library