merge: soulseek protocol e2e coverage, stress fixes and coverage docs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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:?}"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
username,
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
),
|
||||
Err(e) => {
|
||||
error!("[client] UserStatsReceived write: {}", e);
|
||||
} => {
|
||||
// Stats about ourselves carry the speed the server
|
||||
// records for us — pushed whenever it changes, since
|
||||
// we watch ourselves — and the distributed child
|
||||
// limit is derived from it.
|
||||
let own = username == own_username;
|
||||
match client_context.write_safe() {
|
||||
Ok(mut ctx) => {
|
||||
ctx.apply_user_stats(
|
||||
username,
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
);
|
||||
if own {
|
||||
ctx.set_own_average_speed(average_speed);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"[client] UserStatsReceived write: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
if own {
|
||||
Self::announce_child_capacity(&client_context);
|
||||
}
|
||||
}
|
||||
ClientOperation::WatchedUserReceived {
|
||||
username,
|
||||
exists,
|
||||
@@ -452,19 +484,36 @@ impl Client {
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
} => match client_context.write_safe() {
|
||||
Ok(mut ctx) => ctx.apply_watched_user(
|
||||
username,
|
||||
exists,
|
||||
status,
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
),
|
||||
Err(e) => {
|
||||
error!("[client] WatchedUserReceived write: {}", e);
|
||||
} => {
|
||||
let own = username == own_username;
|
||||
match client_context.write_safe() {
|
||||
Ok(mut ctx) => {
|
||||
ctx.apply_watched_user(
|
||||
username,
|
||||
exists,
|
||||
status,
|
||||
average_speed,
|
||||
shared_files,
|
||||
shared_folders,
|
||||
);
|
||||
// Watching ourselves is how our own recorded
|
||||
// speed reaches us, and the child limit is
|
||||
// derived from it.
|
||||
if own && let Some(speed) = average_speed {
|
||||
ctx.set_own_average_speed(speed);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"[client] WatchedUserReceived write: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
if own {
|
||||
Self::announce_child_capacity(&client_context);
|
||||
}
|
||||
}
|
||||
ClientOperation::RoomEvent(event) => {
|
||||
match client_context.write_safe() {
|
||||
Ok(mut ctx) => ctx.apply_room_event(event),
|
||||
@@ -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,21 +805,26 @@ 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 {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
});
|
||||
if ctx.leaf.is_parent(&parent, link) {
|
||||
// The tree's stream is not ours to throttle: every
|
||||
// search goes down to our children. The budget
|
||||
// below bounds only the share scan we do for our
|
||||
// own reply.
|
||||
ctx.children
|
||||
.broadcast_search(&username, token, &query);
|
||||
if let Some(ops) = &ctx.operations {
|
||||
let _ =
|
||||
ops.send(ClientOperation::IncomingSearch {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
from_parent: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientOperation::ParentClosed { parent, link } => {
|
||||
@@ -739,10 +832,9 @@ impl Client {
|
||||
continue;
|
||||
};
|
||||
if ctx.leaf.closed(&parent, link) {
|
||||
super::distributed::announce(
|
||||
ctx.server_sender.as_ref(),
|
||||
&ctx.leaf.branch(),
|
||||
false,
|
||||
let branch = ctx.leaf.branch();
|
||||
super::distributed::announce_move(
|
||||
&mut ctx, &branch, false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -753,10 +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>>,
|
||||
|
||||
@@ -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,13 +263,17 @@ impl Client {
|
||||
.send_to_peer(username, PeerMessage::SendMessage(request));
|
||||
}
|
||||
} else {
|
||||
// Without a server there is nobody to resolve the peer's address,
|
||||
// so the request would sit in the queue forever: say so instead.
|
||||
let handle = self
|
||||
.server_handle
|
||||
.as_ref()
|
||||
.ok_or(SoulseekRs::NotConnected)?;
|
||||
self.context
|
||||
.write_safe()?
|
||||
.queue_peer_message(username, request);
|
||||
if let Some(handle) = &self.server_handle {
|
||||
let _ = handle
|
||||
.send(ServerMessage::GetPeerAddress(username.to_string()));
|
||||
}
|
||||
let _ = handle
|
||||
.send(ServerMessage::GetPeerAddress(username.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 {
|
||||
let _ = sender.send(ServerMessage::SendMessage(
|
||||
MessageFactory::build_message_acked(id),
|
||||
));
|
||||
}
|
||||
// Acknowledge every message, not only the ones flagged new. The flag
|
||||
// marks a message delivered as it was sent; one the server stored
|
||||
// while we were offline arrives with it false, and those are exactly
|
||||
// the messages the server keeps re-sending at every login until they
|
||||
// are acknowledged.
|
||||
let _ = sender.send(ServerMessage::SendMessage(
|
||||
MessageFactory::build_message_acked(id),
|
||||
));
|
||||
|
||||
// Surface the message to the client so it can be read via the API.
|
||||
let _ =
|
||||
sender.send(ServerMessage::PrivateMessageReceived(user_message));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::message::framed;
|
||||
|
||||
fn deliver(new_message: bool) -> Vec<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"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {}",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user