merge: soulseek protocol e2e coverage, stress fixes and coverage docs

This commit is contained in:
Michel de Graaf
2026-09-09 19:45:19 +00:00
49 changed files with 7164 additions and 204 deletions
+23 -4
View File
@@ -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
@@ -533,6 +544,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 +560,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
+132
View File
@@ -0,0 +1,132 @@
# 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 — 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 |
| 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 | 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 | 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 |
| 143 / 144 | AddRoomOperator / Remove | yes | e2e — the guest is promoted |
| 145 / 146 | RoomOperatorshipGranted / Revoked | yes | e2e — the guest is told |
| 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 |
| 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.
## 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.
+11
View File
@@ -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()
@@ -1026,6 +1036,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");
+47 -6
View File
@@ -1,12 +1,13 @@
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, 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 +48,14 @@ pub enum PeerMessage {
ShareListRequested,
/// A peer we are browsing sent us their shared-file listing (code 5).
ShareListReceived(Vec<SharedDirectory>),
/// 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,
folder: String,
directories: Vec<SharedDirectory>,
},
/// 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,7 +208,9 @@ 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(UserInfoResponseHandler);
handlers.register_handler(UploadDeniedHandler);
handlers.register_handler(UploadFailedHandler);
handlers.register_handler(PlaceInQueueRequest);
@@ -310,6 +321,24 @@ impl PeerActor {
directories,
});
}
PeerMessage::UserInfoReceived(info) => {
self.forward(ClientOperation::PeerInfoReceived {
username: self.peer_username(),
info,
});
}
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(),
@@ -729,8 +758,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,
@@ -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:?}"),
+19 -1
View File
@@ -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::<Vec<_>>(),
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() {
@@ -427,7 +445,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();
+262 -2
View File
@@ -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;
@@ -24,16 +26,28 @@ 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,
};
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 +178,96 @@ 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<crate::types::RoomTicker>,
},
/// 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<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Server-wide recommendations (code 56).
GlobalRecommendationsReceived {
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Recommendations for one item (code 111).
ItemRecommendationsReceived {
item: String,
recommendations: Vec<Recommendation>,
},
/// Users similar to us (code 110).
SimilarUsersReceived {
users: Vec<SimilarUser>,
},
/// Users who like one item (code 112).
ItemSimilarUsersReceived {
item: String,
usernames: Vec<String>,
},
/// 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,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
/// 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,
users: Vec<String>,
},
/// Who runs a private room (code 148).
PrivateRoomOperators {
room: String,
users: Vec<String>,
},
/// 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 {
@@ -320,6 +424,28 @@ impl ServerActor {
handlers.register_handler(FileSearchHandler);
handlers.register_handler(GetPeerAddressHandler);
handlers.register_handler(ConnectToPeerHandler);
handlers.register_handler(CantConnectToPeerHandler);
handlers.register_handler(RoomMembersHandler);
handlers.register_handler(RoomOperatorsHandler);
// 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);
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 +627,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 +696,106 @@ 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,
});
}
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::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,
));
}
other => {
error!("[server] unroutable message: {:?}", other);
}
@@ -575,6 +832,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),
@@ -693,6 +952,7 @@ impl ServerActor {
) {
if let Err(e) =
self.client_channel.send(ClientOperation::IncomingSearch {
from_parent: false,
username,
token,
query,
@@ -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");
}
+430
View File
@@ -0,0 +1,430 @@
//! 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.
//!
//! 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.
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+ 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 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<u32>,
min_speed: u32,
ratio: u32,
) -> Option<usize> {
if ratio == 0 {
return None;
}
let speed = own_speed?;
if 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<String, TcpStream>,
/// 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 {
fn default() -> Self {
Self::new(false)
}
}
impl Children {
#[must_use]
pub fn new(accepting: bool) -> Self {
Self {
links: HashMap::new(),
max: MAX_CHILDREN,
accepting,
fed: false,
}
}
/// 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<u32>,
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;
}
#[must_use]
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]
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<String> {
let mut names: Vec<String> = self.links.keys().cloned().collect();
names.sort();
names
}
/// 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.has_room() {
return false;
}
// 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 carried",
username
);
return false;
}
self.links.insert(username.to_string(), stream);
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);
}
}
/// 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.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)
}
/// 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<u8> {
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);
children.set_fed(true);
assert!(!children.accept("alice", ours));
assert!(children.is_empty());
assert!(!children.has_room());
}
#[test]
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);
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();
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_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_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),
None,
"a server that never announced a ratio does not decide the limit"
);
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");
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);
}
#[test]
fn a_search_reaches_every_child() {
let mut children = serving();
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 = serving();
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 = serving();
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);
}
}
+98 -7
View File
@@ -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,
);
});
}
@@ -133,6 +142,42 @@ 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 = 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.
///
/// The wait is bounded: the actor only answers once it has a working
@@ -240,6 +285,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<RwLock<ClientContext>>,
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<RwLock<ClientContext>>,
@@ -282,7 +346,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 {:?} ",
@@ -291,11 +361,32 @@ 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,
);
}
}
}
}
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,
);
}
}
}
}
+26 -2
View File
@@ -297,15 +297,39 @@ pub fn announce(
server: Option<&Sender<ServerMessage>>,
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();
// What the server needs to know is whether it may offer us to another
// 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);
}
/// 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<ClientOperation>) {
@@ -668,7 +692,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<crate::message::Message> = Vec::new();
while let Ok(ServerMessage::SendMessage(m)) = rx.try_recv() {
sent.push(m);
+546 -9
View File
@@ -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::{
@@ -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<crate::message::Message> {
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<FileEntry> = 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,
@@ -191,6 +219,11 @@ pub struct ClientSettings {
/// Directories whose files are shared with (uploaded to) other peers.
/// Empty means nothing is shared.
pub shared_directories: Vec<String>,
/// 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 +256,7 @@ impl Default for ClientSettings {
enable_listen: true,
listen_port: DEFAULT_LISTEN_PORT,
shared_directories: Vec::new(),
accept_children: false,
version: ClientVersion::default(),
}
}
@@ -281,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.
@@ -306,6 +344,18 @@ 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,
token: u32,
folder: String,
directories: Vec<SharedDirectory>,
},
/// A peer we are browsing returned their shared-file listing.
BrowseResult {
username: String,
@@ -313,8 +363,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<u32>),
/// Something happened in the chat-room subsystem (list refreshed, a room
/// joined/left, a message said, a member joined/left).
RoomEvent(RoomEvent),
@@ -340,6 +393,46 @@ 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 {
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 {
global: bool,
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Recommendations for one item (code 111).
ItemRecommendations {
item: String,
recommendations: Vec<Recommendation>,
},
/// Users the server considers similar to us (code 110).
SimilarUsers(Vec<SimilarUser>),
/// Users who like one item (code 112).
ItemSimilarUsers {
item: String,
usernames: Vec<String>,
},
/// 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,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
/// 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 {
@@ -375,6 +468,18 @@ pub struct ClientContext {
operations: Option<Sender<ClientOperation>>,
/// 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,
/// 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<bool>,
/// The upload speed the server records for us, once it has told us. The
/// child limit is derived from it.
own_average_speed: Option<u32>,
/// 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<String, Search>,
private_messages: Vec<UserMessage>,
/// Correlation tokens for server-brokered (firewalled) connections, mapping
@@ -399,6 +504,10 @@ pub struct ClientContext {
pending_serves: HashMap<String, Vec<u32>>,
/// Shared-file listings received from peers we browsed.
browse_results: HashMap<String, Vec<SharedDirectory>>,
/// One-folder listings received from peers, keyed by peer and folder.
folder_contents: HashMap<(String, String), Vec<SharedDirectory>>,
/// What peers said about themselves (peer code 16), keyed by peer.
peer_infos: HashMap<String, crate::message::peer::PeerInfo>,
pending_browses: HashMap<String, Instant>,
/// Latest snapshot of the public chat-room list (from `RoomList`, code 64).
room_list: Vec<RoomInfo>,
@@ -410,6 +519,31 @@ 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<String, Vec<RoomUserStats>>,
/// Who may enter each private room we belong to (code 133), kept current
/// from the later roster changes.
private_room_members: HashMap<String, Vec<String>>,
/// Who runs each of those rooms (code 148).
private_room_operators: HashMap<String, Vec<String>>,
/// 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<String, Vec<RoomTicker>>,
/// The latest recommendations from our own interests (code 54), as
/// (recommended, recommended-against).
recommendations: Option<(Vec<Recommendation>, Vec<Recommendation>)>,
/// The latest server-wide recommendations (code 56).
global_recommendations: Option<(Vec<Recommendation>, Vec<Recommendation>)>,
/// Per-item recommendations (code 111), keyed by the item asked about.
item_recommendations: HashMap<String, Vec<Recommendation>>,
/// The latest similar-user answer (code 110).
similar_users: Vec<SimilarUser>,
/// Who likes an item (code 112), keyed by the item asked about.
item_similar_users: HashMap<String, Vec<String>>,
/// Interests of other users (code 57), keyed by username.
user_interests: HashMap<String, UserInterests>,
/// 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<String, UserInfo>,
@@ -425,6 +559,9 @@ pub struct ClientContext {
privileged_users: HashSet<String>,
/// Seconds of our own privileges left (code 92), once we have asked.
own_privileges: Option<u32>,
/// 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<String>,
/// Peers waiting for one of our upload slots.
upload_queue: Vec<QueuedUpload>,
/// Arrival counter for the queue's first-come tie-break.
@@ -441,6 +578,15 @@ pub struct ClientContext {
upload_events: Vec<crate::types::UploadInfo>,
actor_system: Arc<ActorSystem>,
}
/// 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<String> {
let mut users = users.to_vec();
users.sort();
users.dedup();
users
}
impl Default for ClientContext {
fn default() -> Self {
Self::new()
@@ -502,6 +648,11 @@ impl ClientContext {
server_sender: None,
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,
searches: HashMap::new(),
private_messages: Vec::new(),
pending_connect_tokens: HashMap::new(),
@@ -514,16 +665,29 @@ impl ClientContext {
active_uploads: HashMap::new(),
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(),
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(),
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,
privileged_users: HashSet::new(),
own_privileges: None,
excluded_search_phrases: Vec::new(),
upload_queue: Vec::new(),
upload_seq: 0,
upload_slots: DEFAULT_UPLOAD_SLOTS,
@@ -547,6 +711,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 =
@@ -562,11 +729,355 @@ 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::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,
} => {
// 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);
}
}
RoomEvent::Message { .. }
| RoomEvent::GlobalMessage { .. }
| RoomEvent::CantCreate { .. } => {}
}
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.
/// 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<String> {
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 !list.contains(&item) {
list.push(item.clone());
}
Some(item)
}
/// 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<String> {
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);
Some(item)
}
/// What we like and hate, as last set.
#[must_use]
pub fn own_interests(&self) -> UserInterests {
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<bool> {
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<u32> {
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) {
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 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<crate::message::peer::PeerInfo> {
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,
username: String,
folder: String,
directories: Vec<SharedDirectory>,
) {
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<Vec<SharedDirectory>> {
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<String> {
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<String> {
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<String> {
let mut rooms: Vec<String> =
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<RoomTicker> {
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<Recommendation>,
unrecommended: Vec<Recommendation>,
) {
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<Recommendation>, Vec<Recommendation>)> {
if global {
self.global_recommendations.clone()
} else {
self.recommendations.clone()
}
}
pub fn apply_item_recommendations(
&mut self,
item: String,
recommendations: Vec<Recommendation>,
) {
self.item_recommendations.insert(item, recommendations);
}
#[must_use]
pub fn item_recommendations(&self, item: &str) -> Vec<Recommendation> {
self.item_recommendations
.get(item)
.cloned()
.unwrap_or_default()
}
pub fn apply_similar_users(&mut self, users: Vec<SimilarUser>) {
self.similar_users = users;
}
#[must_use]
pub fn similar_users(&self) -> Vec<SimilarUser> {
self.similar_users.clone()
}
pub fn apply_item_similar_users(
&mut self,
item: String,
usernames: Vec<String>,
) {
self.item_similar_users.insert(item, usernames);
}
#[must_use]
pub fn item_similar_users(&self, item: &str) -> Vec<String> {
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<UserInterests> {
self.user_interests.get(username).cloned()
}
/// 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<String>) {
self.excluded_search_phrases =
phrases.iter().map(|p| p.to_lowercase()).collect();
}
/// The phrases the server refuses to search for.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.excluded_search_phrases.clone()
}
/// 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(
@@ -877,6 +1388,15 @@ pub struct Client {
server_handle: Option<ActorHandle<ServerMessage>>,
context: Arc<RwLock<ClientContext>>,
session: SessionWatch,
/// Tells the peer listener to stop, so a disconnected client releases the
/// port it bound.
listener_stopped: Arc<AtomicBool>,
}
impl Drop for Client {
fn drop(&mut self) {
self.disconnect();
}
}
impl Client {
@@ -890,20 +1410,35 @@ 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,
shared_directories: settings.shared_directories,
server_handle: None,
session: SessionWatch::default(),
listener_stopped: Arc::new(AtomicBool::new(false)),
}
}
/// 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<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.children.usernames(),
Err(e) => {
error!("[client] children: {}", e);
Vec::new()
}
}
}
@@ -1044,12 +1579,14 @@ impl Client {
}
}
mod children;
mod connection;
mod distributed;
mod downloads;
mod operations;
mod rooms;
mod search;
mod social;
mod upload_queue;
mod uploads;
+424 -40
View File
@@ -10,6 +10,17 @@ 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);
/// 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<ClientOperation>,
@@ -18,6 +29,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 {
@@ -29,14 +41,16 @@ 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,
);
}
}
}
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,
@@ -434,17 +448,35 @@ impl Client {
average_speed,
shared_files,
shared_folders,
} => match client_context.write_safe() {
Ok(mut ctx) => ctx.apply_user_stats(
} => {
// 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);
error!(
"[client] UserStatsReceived write: {}",
e
);
}
}
if own {
Self::announce_child_capacity(&client_context);
}
}
},
ClientOperation::WatchedUserReceived {
username,
exists,
@@ -452,19 +484,36 @@ impl Client {
average_speed,
shared_files,
shared_folders,
} => match client_context.write_safe() {
Ok(mut ctx) => ctx.apply_watched_user(
} => {
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);
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),
@@ -538,7 +587,49 @@ 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, 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);
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,
answer,
)
}
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 {
continue;
@@ -552,6 +643,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);
@@ -679,10 +771,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 +787,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,32 +805,36 @@ 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)
&& ctx.leaf.admit_search(Instant::now())
&& let Some(ops) = &ctx.operations
{
let _ = ops.send(ClientOperation::IncomingSearch {
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 } => {
let Ok(mut ctx) = client_context.write_safe() else {
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 +845,13 @@ impl Client {
continue;
};
ctx.leaf.reset();
super::distributed::announce(
ctx.server_sender.as_ref(),
&ctx.leaf.branch(),
false,
// 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,
);
}
ClientOperation::PrivilegedUsers(users) => {
@@ -777,6 +872,192 @@ 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::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;
}
// 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 {
continue; // we cannot hang from ourselves
}
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()
);
// 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::PeerInfoReceived { username, info } => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.store_peer_info(username, info);
}
}
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::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 };
// 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),
));
}
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);
}
}
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 +1156,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
@@ -926,6 +1258,58 @@ 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<RwLock<ClientContext>>) {
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::<Vec<_>>(),
),
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<RwLock<ClientContext>>) {
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 };
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<RwLock<ClientContext>>,
+21 -4
View File
@@ -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()
@@ -250,14 +263,18 @@ 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()));
}
}
Ok(())
}
+61 -10
View File
@@ -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 }
@@ -81,6 +72,66 @@ 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<u32> {
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)
}
/// 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<String> {
self.context
.read_safe()
.map(|ctx| ctx.excluded_search_phrases())
.unwrap_or_default()
}
/// Let responses accumulate for `timeout`, or until cancelled.
///
/// Peers answer a search over their own connections whenever they get round
+542
View File
@@ -0,0 +1,542 @@
//! 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())
}
/// 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<String> {
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<String> {
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<String> {
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
/// 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` 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<crate::message::peer::PeerInfo> {
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.
///
/// 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<Vec<crate::SharedDirectory>> {
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
/// 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.
///
/// # 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<RoomTicker> {
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.
///
/// 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<()> {
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))
}
/// Drop an interest (code 52).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_interest(&self, item: &str) -> Result<()> {
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,
))
}
/// Add a dislike (code 117).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn add_dislike(&self, item: &str) -> Result<()> {
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))
}
/// Drop a dislike (code 118).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_dislike(&self, item: &str) -> Result<()> {
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,
))
}
/// 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 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<u32> {
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.
#[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]
pub fn recommendations(
&self,
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
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<Recommendation>, Vec<Recommendation>)> {
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<Recommendation> {
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<SimilarUser> {
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<String> {
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<UserInterests> {
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,
))
}
}
+351 -5
View File
@@ -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);
@@ -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(
@@ -838,3 +838,349 @@ 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"
);
}
#[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_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(),
users: vec!["alice".into()],
});
ctx.apply_room_event(RoomEvent::PrivateOperators {
room: "club".into(),
users: vec!["alice".into(), "bob".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_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]
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"
);
// 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);
}
#[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).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);
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"
);
}
#[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");
}
+8 -6
View File
@@ -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,
};
+7 -2
View File
@@ -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<Message> {
pub fn stance(
root: &str,
level: u32,
has_parent: bool,
accept_children: bool,
) -> Vec<Message> {
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),
]
}
@@ -26,6 +26,37 @@ impl MessageHandler<PeerMessage> 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<PeerMessage> for FolderContentsResponseHandler {
fn get_code(&self) -> u32 {
37
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
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(
@@ -48,7 +79,7 @@ pub fn build_folder_contents(
pub fn parse_folder_contents(
message: &mut Message,
) -> Option<(u32, String, Vec<SharedDirectory>)> {
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)))
+7 -2
View File
@@ -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;
@@ -32,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,
};
@@ -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<SharedDirectory> {
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<SharedDirectory> {
dirs
}
/// Inflate a compressed peer payload positioned at the blob.
pub fn decompress_body(message: &mut Message) -> Option<Message> {
/// 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<Message> {
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)
}
@@ -15,6 +15,71 @@ impl MessageHandler<PeerMessage> 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<usize>,
/// 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<u32>,
}
/// `UserInfoResponse` (peer code 16): a peer's answer about itself.
pub struct UserInfoResponseHandler;
impl MessageHandler<PeerMessage> for UserInfoResponseHandler {
fn get_code(&self) -> u32 {
16
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
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");
}
}
@@ -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<ServerMessage> for CantConnectToPeerHandler {
fn get_code(&self) -> u32 {
1001
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -12,7 +12,7 @@ impl MessageHandler<ServerMessage> for ExcludedSearchPhrasesHandler {
160
}
fn handle(&self, message: &mut Message, _sender: Sender<ServerMessage>) {
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let item_count = message.read_int32();
let mut exluded_phrases: Vec<String> = Vec::new();
@@ -25,6 +25,11 @@ impl MessageHandler<ServerMessage> 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:?}"),
}
}
}
@@ -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<ServerMessage> for GlobalRoomMessageHandler {
fn get_code(&self) -> u32 {
152
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -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<Recommendation> {
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<String> {
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<ServerMessage> for RecommendationsHandler {
fn get_code(&self) -> u32 {
54
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for GlobalRecommendationsHandler {
fn get_code(&self) -> u32 {
56
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for ItemRecommendationsHandler {
fn get_code(&self) -> u32 {
111
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for SimilarUsersHandler {
fn get_code(&self) -> u32 {
110
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for ItemSimilarUsersHandler {
fn get_code(&self) -> u32 {
112
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for UserInterestsHandler {
fn get_code(&self) -> u32 {
57
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -301,6 +301,251 @@ 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()
}
/// 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.
#[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 +762,189 @@ fn an_upload_speed_report_carries_the_rate() {
decoded.set_pointer(8);
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();
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");
}
#[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);
}
@@ -28,16 +28,73 @@ impl MessageHandler<ServerMessage> 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 {
// 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<ServerMessage> {
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<u32> {
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"
)));
}
}
+19
View File
@@ -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;
@@ -11,9 +14,11 @@ 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;
mod room_tickers;
mod say_chatroom;
mod user_info;
mod user_joined_room;
@@ -22,6 +27,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 +35,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;
@@ -36,9 +48,16 @@ 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;
pub use room_tickers::{
RoomTickerAddedHandler, RoomTickerRemovedHandler, RoomTickersHandler,
};
pub use say_chatroom::SayChatroomHandler;
pub use user_info::{GetUserStatsHandler, GetUserStatusHandler};
pub use user_joined_room::UserJoinedRoomHandler;
@@ -14,8 +14,32 @@ impl MessageHandler<ServerMessage> for ParentMinSpeedHandler {
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -14,8 +14,31 @@ impl MessageHandler<ServerMessage> for ParentSpeedRatioHandler {
84
}
fn handle(&self, message: &mut Message, _sender: Sender<ServerMessage>) {
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -0,0 +1,234 @@
//! 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<String>) {
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<ServerMessage> for RoomMembersHandler {
fn get_code(&self) -> u32 {
133
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for RoomOperatorsHandler {
fn get_code(&self) -> u32 {
148
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let (room, users) = read_roster(message);
let _ =
sender.send(ServerMessage::PrivateRoomOperators { room, users });
}
}
/// 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<ServerMessage> for RoomRosterChangeHandler {
fn get_code(&self) -> u32 {
self.0
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let room = message.read_string();
let username = message.read_string();
let _ = sender.send(ServerMessage::PrivateRoomRosterChanged {
room,
username,
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(pub u32);
impl MessageHandler<ServerMessage> for OwnRoomStandingHandler {
fn get_code(&self) -> u32 {
self.0
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let room = message.read_string();
let _ = sender.send(ServerMessage::OwnRoomStandingChanged {
room,
members: matches!(self.0, 139 | 140),
granted: matches!(self.0, 139 | 145),
});
}
}
/// `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<ServerMessage> for CantCreateRoomHandler {
fn get_code(&self) -> u32 {
1003
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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(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| {
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(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| {
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:?}"),
}
}
}
@@ -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<ServerMessage> for RoomTickersHandler {
fn get_code(&self) -> u32 {
113
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for RoomTickerAddedHandler {
fn get_code(&self) -> u32 {
114
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for RoomTickerRemovedHandler {
fn get_code(&self) -> u32 {
115
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
+15 -1
View File
@@ -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;
@@ -378,9 +379,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,
});
}
}
}
@@ -414,6 +423,7 @@ impl Listen {
client_sender: Sender<ClientOperation>,
client_context: Arc<RwLock<ClientContext>>,
own_username: String,
stopped: Arc<AtomicBool>,
) {
info!("[listener] listening on {:?}", listener.local_addr());
@@ -425,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: {}",
+6
View File
@@ -64,6 +64,10 @@ pub struct Peer {
pub privileged: Option<u8>,
pub unknown: Option<u8>,
pub obfuscated_port: Option<u16>,
/// 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<Self> {
@@ -120,6 +125,7 @@ impl Peer {
privileged: Some(privileged),
unknown: Some(unknown),
obfuscated_port: Some(u16::from(obfuscated_port)),
brokered: true,
})
}
}
+21 -53
View File
@@ -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)
}
@@ -219,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);
@@ -285,14 +251,16 @@ mod tests {
}
#[test]
fn serve_file_cancels_while_waiting_for_the_downloader_to_close() {
let content: Vec<u8> = (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<u8> = (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 +268,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 +281,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();
+74
View File
@@ -393,6 +393,80 @@ 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<RoomTicker>,
},
/// `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,
},
/// Who may enter a private room (code 133).
PrivateMembers { room: String, users: Vec<String> },
/// Who runs a private room (code 148).
PrivateOperators { room: String, users: Vec<String> },
/// 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.
#[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<String>,
pub hates: Vec<String>,
}
impl Transfer {
+76 -7
View File
@@ -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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
"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<Vec<u8>, String> {
fn inflate_blocks(
r: &mut BitReader,
max_out: usize,
) -> std::result::Result<Vec<u8>, 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<Vec<u8>, String> {
fn inflate_block_no_compression(
r: &mut BitReader,
o: &mut Vec<u8>,
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<u8>,
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<u8>,
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<u8>,
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<u32> = (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");
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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");
+228
View File
@@ -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": {
+186
View File
@@ -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<RoomTickerDto>,
},
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<String>,
},
/// Who runs a private room.
PrivateOperators {
room: String,
users: Vec<String>,
},
/// 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<RoomTickerDto> 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<RoomEventDto> 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 },
}
}
}
+75
View File
@@ -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
+66 -5
View File
@@ -12,6 +12,11 @@ pub struct FileConfig {
pub server: Option<String>,
pub listener_port: Option<u16>,
pub disable_listener: Option<bool>,
/// 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<bool>,
pub download_dir: Option<String>,
/// 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<String>,
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,
+3
View File
@@ -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,
};
+1
View File
@@ -137,6 +137,7 @@ pub fn login(host: &str, port: u16, user: &str, shares: Vec<String>) -> 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");
+4 -2
View File
@@ -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.
</SectionHead>
<Cols start>
<Prose>
+2 -1
View File
@@ -227,7 +227,8 @@ const About = () => (
<Callout title="build your own">
<p>
Reach for <Code>soulseek-rs-lib</Code> 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.{' '}
<Link to="/install" className="text-link hover:text-link-hover">
Use the library
</Link>