feat!: wishlist, upload-queue privilege recognition, and search filters (#20)

Closes the last two gaps in Soulseek's list of features an alternative client
must implement to be tolerated, and adds the result filters Nicotine+ has had
all along.

Wishlist: server code 103 is sent and code 104's announced interval is kept
rather than logged and discarded, so the re-search timer runs off the schedule
the server set. `wish add|remove|list|run` plus `serve --wishlist`; wishes
persist in config.toml as a TOML array so a comma inside a query survives.
`wish run` prints search-shaped records, so it pipes into `download --stdin`.

Privilege recognition: the code-69 user list is parsed instead of counted and
dropped, and code 92 answers `whoami` as `privilege_seconds`. Uploads now go
through a queue with a slot cap (`--upload-slots`, default 2) that serves
privileged users first — the cap is what makes the ordering mean anything, so
they are one change. `PlaceInQueueRequest` (peer code 51) is answered, where it
previously had no handler at all, and a departed peer's slot is released so a
stalled peer cannot wedge uploads shut.

Search filters: `--exclude`, `--extension`, `--min-size`, `--max-size` beside
the existing `--min-bitrate` and `--free-slots`, in one flattened arg group
shared by `search`, `get` and `wish run`.

Also serialises the library e2e suite behind the same gate `cli_e2e` already
uses: the new queue tests hold peer sockets open on purpose, which starved
concurrently-spawned soulfinds and failed unrelated download tests with
`login: Timeout`.

BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive
match on it no longer compiles, and `cli::Commands` gains `Wish`.
This commit is contained in:
Michel de Graaf
2026-07-26 14:20:07 +02:00
committed by GitHub
parent eda762d067
commit 9b87bd6a0f
29 changed files with 2991 additions and 145 deletions
+56 -5
View File
@@ -22,7 +22,7 @@ use crate::{
utils::{lock::RwLockExt, md5, thread_pool::ThreadPool},
};
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
net::TcpStream,
sync::{
RwLock,
@@ -35,10 +35,24 @@ use std::{
sync::{Arc, mpsc},
time::{Duration, Instant},
};
use upload_queue::QueuedUpload;
use crate::{debug, error, info, trace, warn};
const DEFAULT_LISTEN_PORT: u16 = 2234;
/// What to assume between wishlist searches until the server announces its own.
///
/// Twelve minutes is what Soulseek servers give an unprivileged account (code
/// 104); guessing lower only gets the searches dropped.
pub const DEFAULT_WISHLIST_INTERVAL: Duration = Duration::from_mins(12);
/// How many uploads run at once by default.
///
/// Two is what makes the queue real: with unlimited slots there is nothing for
/// a privileged peer to jump, and a home connection split across every peer
/// that asks serves all of them badly.
pub const DEFAULT_UPLOAD_SLOTS: usize = 2;
/// How long to wait for a server-brokered (firewalled) peer to connect back
/// before giving up and failing the download. Matches the direct-dial timeout.
const BROKER_CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
@@ -248,6 +262,19 @@ pub enum ClientOperation {
/// Something happened in the chat-room subsystem (list refreshed, a room
/// joined/left, a message said, a member joined/left).
RoomEvent(RoomEvent),
/// The server announced how many seconds must pass between wishlist
/// searches.
WishlistInterval(u32),
/// Everyone the server counts as privileged; they sort ahead of others in
/// our upload queue.
PrivilegedUsers(Vec<String>),
/// Seconds of our own privileges left.
OwnPrivileges(u32),
/// A peer asked where their queued file sits.
PlaceInQueueRequested {
requester_key: String,
filename: String,
},
}
pub struct ClientContext {
pub peer_registry: Option<PeerRegistry>,
@@ -284,6 +311,20 @@ pub struct ClientContext {
/// What the server has told us about other users, merged across the
/// separate status and statistics replies.
user_info: HashMap<String, UserInfo>,
/// Seconds the server wants between wishlist searches (code 104), once it
/// has told us.
wishlist_interval: Option<u32>,
/// Everyone the server listed as privileged (code 69). They sort ahead of
/// other peers in [`Self::upload_queue`].
privileged_users: HashSet<String>,
/// Seconds of our own privileges left (code 92), once we have asked.
own_privileges: Option<u32>,
/// Peers waiting for one of our upload slots.
upload_queue: Vec<QueuedUpload>,
/// Arrival counter for the queue's first-come tie-break.
upload_seq: u64,
/// How many uploads may be in flight at once.
upload_slots: usize,
actor_system: Arc<ActorSystem>,
}
impl Default for ClientContext {
@@ -580,6 +621,12 @@ impl ClientContext {
room_events: Vec::new(),
room_members: HashMap::new(),
user_info: HashMap::new(),
wishlist_interval: None,
privileged_users: HashSet::new(),
own_privileges: None,
upload_queue: Vec::new(),
upload_seq: 0,
upload_slots: DEFAULT_UPLOAD_SLOTS,
downloads: DownloadStore::new(),
actor_system,
}
@@ -820,8 +867,9 @@ impl Client {
})
}
/// Snapshot of the uploads served this session (active and finished),
/// most recent last.
/// Every upload this session knows about: the ones streaming or finished,
/// followed by the peers still waiting for a slot, in the order they will
/// be served.
#[must_use]
pub fn uploads(&self) -> Vec<crate::types::UploadInfo> {
self.context.read_safe().map_or_else(
@@ -829,7 +877,7 @@ impl Client {
|ctx| {
let mut tokens: Vec<&u32> = ctx.active_uploads.keys().collect();
tokens.sort_unstable();
tokens
let mut all: Vec<crate::types::UploadInfo> = tokens
.into_iter()
.map(|token| {
let upload = &ctx.active_uploads[token];
@@ -849,7 +897,9 @@ impl Client {
status: upload.status.clone(),
}
})
.collect()
.collect();
all.extend(ctx.queued_uploads());
all
},
)
}
@@ -919,6 +969,7 @@ mod downloads;
mod operations;
mod rooms;
mod search;
mod upload_queue;
mod uploads;
#[cfg(test)]
+76 -28
View File
@@ -1,10 +1,12 @@
use super::uploads::downloader_of;
use super::{
Arc, BROKER_CONNECT_TIMEOUT, Client, ClientContext, ClientOperation,
ConnectionType, Download, DownloadPeer, DownloadStatus, Peer, PeerMessage,
PeerRegistry, Receiver, RwLock, RwLockExt, ServerMessage, UploadJob,
build_search_response, debug, error, info, next_connect_token,
next_upload_token, sleep, thread, trace, warn,
PeerRegistry, Receiver, RwLock, RwLockExt, ServerMessage,
build_search_response, debug, error, info, next_connect_token, sleep,
thread, trace, warn,
};
use crate::message::server::MessageFactory;
impl Client {
pub(crate) fn listen_to_client_operations(
@@ -102,6 +104,14 @@ impl Client {
None,
);
}
// A peer that leaves must not keep occupying an
// upload slot: with a slot cap, a couple of
// departed peers would otherwise wedge uploads
// shut for the rest of the session.
Self::release_upload_slots(
&client_context,
&username,
);
}
ClientOperation::PierceFireWall(peer) => {
Self::pierce_firewall(
@@ -531,6 +541,12 @@ impl Client {
),
}
}
ClientOperation::WishlistInterval(seconds) => {
if let Ok(mut ctx) = client_context.write_safe()
{
ctx.wishlist_interval = Some(seconds);
}
}
ClientOperation::PeerConnected(username) => {
// An outbound control connection just handshook.
// Flush any downloads that were queued for this
@@ -655,16 +671,10 @@ impl Client {
requester_key,
filename,
} => {
// A peer queued one of our shared files. Look it
// up, mint an upload token, and offer it.
let downloader = requester_key
.strip_suffix(":direct")
.unwrap_or(&requester_key)
.to_string();
let token = next_upload_token();
let (registry, size) = match client_context
.write_safe()
{
// The peer served next may not be this one.
let downloader =
downloader_of(&requester_key).to_string();
match client_context.write_safe() {
Ok(mut ctx) => {
let Some(file) =
ctx.shares.get(&filename)
@@ -677,16 +687,13 @@ impl Client {
};
let size = file.size;
let real_path = file.real_path.clone();
ctx.uploads.insert(
token,
UploadJob {
downloader: downloader.clone(),
real_path,
virtual_path: filename.clone(),
size,
},
ctx.enqueue_upload(
&requester_key,
&downloader,
&filename,
real_path,
size,
);
(ctx.peer_registry.clone(), size)
}
Err(e) => {
error!(
@@ -695,18 +702,59 @@ impl Client {
);
continue;
}
};
}
Self::pump_upload_queue(&client_context);
}
ClientOperation::PlaceInQueueRequested {
requester_key,
filename,
} => {
let downloader = downloader_of(&requester_key);
let (registry, place) =
match client_context.read_safe() {
Ok(ctx) => (
ctx.peer_registry.clone(),
ctx.place_in_queue(
downloader, &filename,
),
),
Err(_) => continue,
};
// Silence would leave the peer guessing; a file
// no longer queued is being served, which is
// place 0 by the same convention Nicotine+ uses.
if let Some(registry) = registry {
let _ = registry.send_to_peer(
&requester_key,
PeerMessage::ServeUpload {
token,
filename,
size,
},
PeerMessage::SendMessage(
MessageFactory::build_place_in_queue_response(
&filename,
place.unwrap_or(0),
),
),
);
}
}
ClientOperation::PrivilegedUsers(users) => {
if let Ok(mut ctx) = client_context.write_safe()
{
debug!(
"[client] {} privileged users listed",
users.len()
);
ctx.set_privileged_users(users);
}
// Someone already waiting may have just been
// outranked, but a slot that is free should
// still be filled.
Self::pump_upload_queue(&client_context);
}
ClientOperation::OwnPrivileges(seconds) => {
if let Ok(mut ctx) = client_context.write_safe()
{
ctx.own_privileges = Some(seconds);
}
}
ClientOperation::StartUpload { token } => {
// The peer accepted our offer: resolve their
// address (from the code-9 GetPeerAddress) and
+75 -33
View File
@@ -1,7 +1,7 @@
use super::{
Arc, AtomicBool, Client, Duration, HashMap, Instant, Ordering, Result,
RwLockExt, Search, SearchResult, ServerMessage, SoulseekRs, info, md5,
sleep,
Arc, AtomicBool, Client, DEFAULT_WISHLIST_INTERVAL, Duration, HashMap,
Instant, Ordering, Result, RwLockExt, Search, SearchResult, ServerMessage,
SoulseekRs, info, md5, sleep,
};
impl Client {
@@ -13,53 +13,95 @@ impl Client {
self.search_with_cancel(query, timeout, None)
}
/// Send `query` as a wishlist search (server code 103) and return at once.
///
/// Results accumulate under `query`, so the caller reads them back with
/// [`Client::get_search_results`] after waiting however long it wants to.
/// Starting several wishes and then waiting once is the whole point: waiting
/// per wish would cost one full search window each.
///
/// The server rate-limits these to the interval it announced — see
/// [`Client::wishlist_interval`] — so a wish re-sent sooner than that comes
/// back empty.
///
/// # Errors
/// [`SoulseekRs::NotConnected`] when there is no server connection.
pub fn start_wishlist_search(&self, query: &str) -> Result<()> {
self.send_search(query, true)
}
/// How long to wait between wishlist searches: what the server announced in
/// code 104, or [`DEFAULT_WISHLIST_INTERVAL`] until it has.
#[must_use]
pub fn wishlist_interval(&self) -> Duration {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.wishlist_interval)
.map_or(DEFAULT_WISHLIST_INTERVAL, |seconds| {
Duration::from_secs(u64::from(seconds))
})
}
pub fn search_with_cancel(
&self,
query: &str,
timeout: Duration,
cancel_flag: Option<Arc<AtomicBool>>,
) -> Result<Vec<SearchResult>> {
self.send_search(query, false)?;
Self::collect_for(timeout, cancel_flag);
Ok(self.get_search_results(query))
}
/// Register `query` and put its search on the wire. Returns as soon as the
/// message is queued; nothing has answered yet.
fn send_search(&self, query: &str, wishlist: bool) -> Result<()> {
info!("Searching for {}", query);
if let Some(handle) = &self.server_handle {
let hash = md5::md5(query);
let token = u32::from_str_radix(&hash[0..5], 16)?;
self.context.write_safe()?.searches.insert(
query.to_string(),
Search {
token,
results: Vec::new(),
},
);
let _ = handle.send(ServerMessage::FileSearch {
token,
query: query.to_string(),
});
} else {
let Some(handle) = &self.server_handle else {
return Err(SoulseekRs::NotConnected);
}
};
let hash = md5::md5(query);
let token = u32::from_str_radix(&hash[0..5], 16)?;
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 }
} else {
ServerMessage::FileSearch { token, query }
});
Ok(())
}
/// Let responses accumulate for `timeout`, or until cancelled.
///
/// Peers answer a search over their own connections whenever they get round
/// to it, so there is nothing to await — the window is the whole protocol.
/// Late responders are often the better sources, which is why this runs the
/// window out rather than returning on the first hit.
pub fn collect_for(
timeout: Duration,
cancel_flag: Option<Arc<AtomicBool>>,
) {
let start = Instant::now();
loop {
while start.elapsed() < timeout {
sleep(Duration::from_millis(100));
// Check if cancelled
if let Some(ref flag) = cancel_flag
if let Some(flag) = &cancel_flag
&& flag.load(Ordering::Relaxed)
{
info!("Search cancelled by user");
break;
}
// Check if timeout reached
if start.elapsed() >= timeout {
break;
return;
}
}
Ok(self.get_search_results(query))
}
#[must_use]
+495
View File
@@ -0,0 +1,495 @@
//! The queue peers wait in for one of our upload slots.
//!
//! Soulseek's rules ask an alternative client for "respect / recognition of
//! privileges": a user who has donated is meant to be served before one who
//! has not. That only means anything if there is a queue to jump — serving
//! every request the instant it arrives treats a privileged peer exactly like
//! everybody else — so the slot cap and the ordering are one feature.
//!
//! Ordering is privileged-first, then first-come. Nothing else: a fancier
//! policy (per-user fairness, small-file-first) would be a different promise
//! than the one the rules ask for.
use super::{ClientContext, PeerRegistry, UploadJob};
use crate::types::{UploadInfo, UploadStatus};
use std::path::PathBuf;
/// A queued upload that has just been given a slot, ready to be offered to the
/// peer that asked for it.
#[derive(Debug, Clone)]
pub struct UploadOffer {
pub requester_key: String,
pub token: u32,
pub virtual_path: String,
pub size: u64,
}
/// A file a peer has asked us for and is waiting on.
#[derive(Debug, Clone)]
pub struct QueuedUpload {
/// Registry key of the asking peer's actor, which may carry a `:direct`
/// suffix — not the same string as `downloader`.
pub requester_key: String,
pub downloader: String,
pub virtual_path: String,
pub real_path: PathBuf,
pub size: u64,
/// Whether the server listed this user as privileged when they asked.
pub privileged: bool,
/// Arrival order, so the tie-break among equals is first-come.
pub seq: u64,
}
/// What decides the order: donors first, then whoever asked first.
const fn rank(job: &QueuedUpload) -> (bool, u64) {
(!job.privileged, job.seq)
}
/// Indices of `queue` in the order they will be served.
#[must_use]
pub fn serve_order(queue: &[QueuedUpload]) -> Vec<usize> {
let mut order: Vec<usize> = (0..queue.len()).collect();
order.sort_by_key(|&i| rank(&queue[i]));
order
}
/// Which entry to serve next, or `None` when nobody is waiting.
#[must_use]
pub fn next_to_serve(queue: &[QueuedUpload]) -> Option<usize> {
queue
.iter()
.enumerate()
.min_by_key(|(_, job)| rank(job))
.map(|(index, _)| index)
}
/// A queue position as the protocol reports it: counting from 1.
fn as_place(zero_based: usize) -> u32 {
u32::try_from(zero_based + 1).unwrap_or(u32::MAX)
}
/// Where `filename` sits for `downloader`, counting from 1 as the protocol's
/// `PlaceInQueueResponse` does. `None` when that peer is not waiting for it,
/// which is also the answer once the file has been handed over.
#[must_use]
pub fn place_in_queue(
queue: &[QueuedUpload],
downloader: &str,
filename: &str,
) -> Option<u32> {
serve_order(queue)
.into_iter()
.position(|i| {
queue[i].downloader == downloader
&& queue[i].virtual_path == filename
})
.map(as_place)
}
/// The queue as the client drives it: taking a request, filling slots, and
/// answering where a peer sits.
impl ClientContext {
/// Take `filename` from a peer's request and put them in the queue. The
/// caller pumps afterwards; queueing and serving are separate so a request
/// for a file we do not have never reaches the queue.
pub fn enqueue_upload(
&mut self,
requester_key: &str,
downloader: &str,
filename: &str,
real_path: std::path::PathBuf,
size: u64,
) {
self.upload_seq += 1;
self.upload_queue.push(QueuedUpload {
requester_key: requester_key.to_string(),
downloader: downloader.to_string(),
virtual_path: filename.to_string(),
real_path,
size,
privileged: self.privileged_users.contains(downloader),
seq: self.upload_seq,
});
}
/// Move as many queued uploads into free slots as will fit, and return the
/// offers to send. Ordering is privileged-first, then first-come.
///
/// The registry comes back with them so the caller can send after dropping
/// the context lock: a peer actor's mailbox is not something to hold it
/// across.
pub fn pump_uploads(
&mut self,
mut next_token: impl FnMut() -> u32,
) -> (Option<PeerRegistry>, Vec<UploadOffer>) {
let mut offers = Vec::new();
while self.uploads_in_flight() < self.upload_slots {
let Some(index) = next_to_serve(&self.upload_queue) else {
break;
};
let job = self.upload_queue.remove(index);
let token = next_token();
offers.push(UploadOffer {
requester_key: job.requester_key,
token,
virtual_path: job.virtual_path.clone(),
size: job.size,
});
self.uploads.insert(
token,
UploadJob {
downloader: job.downloader,
real_path: job.real_path,
virtual_path: job.virtual_path,
size: job.size,
},
);
}
(self.peer_registry.clone(), offers)
}
/// Uploads occupying a slot: offered but not yet accepted, plus those
/// actually streaming.
///
/// `active_uploads` keeps finished transfers so `uploads()` can report them,
/// so only the in-progress ones count — counting the whole table would let
/// a few completed transfers wedge the queue shut forever.
fn uploads_in_flight(&self) -> usize {
self.uploads.len()
+ self
.active_uploads
.values()
.filter(|upload| {
matches!(upload.status, UploadStatus::InProgress)
})
.count()
}
/// The peers still waiting, in the order they will be served, each carrying
/// its own 1-based place.
pub(super) fn queued_uploads(&self) -> Vec<UploadInfo> {
serve_order(&self.upload_queue)
.into_iter()
.enumerate()
.map(|(place, index)| {
let job = &self.upload_queue[index];
UploadInfo {
username: job.downloader.clone(),
filename: job.virtual_path.clone(),
size: job.size,
bytes_sent: 0,
speed_bytes_per_sec: 0.0,
status: UploadStatus::Queued(as_place(place)),
}
})
.collect()
}
/// Where `downloader`'s queued `filename` sits, counting from 1. `None`
/// once it has left the queue for a slot.
#[must_use]
pub fn place_in_queue(
&self,
downloader: &str,
filename: &str,
) -> Option<u32> {
place_in_queue(&self.upload_queue, downloader, filename)
}
/// Replace the privileged set, re-ranking anyone already waiting: the list
/// arrives at login, which can be after a peer has queued something.
pub fn set_privileged_users(&mut self, users: Vec<String>) {
self.privileged_users = users.into_iter().collect();
for job in &mut self.upload_queue {
job.privileged = self.privileged_users.contains(&job.downloader);
}
}
#[must_use]
pub fn is_privileged(&self, username: &str) -> bool {
self.privileged_users.contains(username)
}
/// Forget everything `downloader` was waiting for or had been offered, and
/// report whether that freed anything.
///
/// An offer sits in `uploads` counting against the slot cap until the peer
/// accepts it. A peer that never will — because it has gone — would hold
/// that slot for the life of the process, so leaving is what releases it.
pub fn release_upload_slots(&mut self, downloader: &str) -> bool {
let before = self.upload_queue.len() + self.uploads.len();
self.upload_queue.retain(|job| job.downloader != downloader);
self.uploads.retain(|_, job| job.downloader != downloader);
before != self.upload_queue.len() + self.uploads.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn queued(user: &str, privileged: bool, seq: u64) -> QueuedUpload {
QueuedUpload {
requester_key: user.to_string(),
downloader: user.to_string(),
virtual_path: format!("@@share\\{user}.mp3"),
real_path: PathBuf::from("/tmp/x.mp3"),
size: 1,
privileged,
seq,
}
}
#[test]
fn an_empty_queue_serves_nobody() {
assert_eq!(next_to_serve(&[]), None);
}
#[test]
fn equals_are_served_in_the_order_they_asked() {
let queue = [queued("amy", false, 1), queued("bob", false, 2)];
assert_eq!(next_to_serve(&queue), Some(0));
assert_eq!(serve_order(&queue), [0, 1]);
}
#[test]
fn a_privileged_peer_is_served_before_one_that_asked_first() {
let queue = [
queued("early_plain", false, 1),
queued("late_donor", true, 2),
];
assert_eq!(
next_to_serve(&queue),
Some(1),
"the donor jumps the plain user who was already waiting"
);
}
#[test]
fn privileged_peers_keep_first_come_order_among_themselves() {
let queue = [
queued("donor_late", true, 9),
queued("plain", false, 2),
queued("donor_early", true, 5),
];
let order: Vec<&str> = serve_order(&queue)
.into_iter()
.map(|i| queue[i].downloader.as_str())
.collect();
assert_eq!(order, ["donor_early", "donor_late", "plain"]);
}
#[test]
fn the_reported_place_counts_from_one() {
let queue = [queued("amy", false, 1), queued("bob", false, 2)];
assert_eq!(place_in_queue(&queue, "amy", "@@share\\amy.mp3"), Some(1));
assert_eq!(place_in_queue(&queue, "bob", "@@share\\bob.mp3"), Some(2));
}
#[test]
fn the_reported_place_reflects_being_overtaken() {
let mut queue = vec![queued("plain", false, 1)];
assert_eq!(
place_in_queue(&queue, "plain", "@@share\\plain.mp3"),
Some(1)
);
queue.push(queued("donor", true, 2));
assert_eq!(
place_in_queue(&queue, "plain", "@@share\\plain.mp3"),
Some(2),
"a peer that got overtaken must be told so, not left on 1"
);
}
#[test]
fn a_file_nobody_queued_has_no_place() {
let queue = [queued("amy", false, 1)];
assert_eq!(place_in_queue(&queue, "amy", "@@share\\other.mp3"), None);
assert_eq!(place_in_queue(&queue, "zoe", "@@share\\amy.mp3"), None);
}
}
/// Tests of the queue as the client actually drives it: what a request, a
/// privileged-user list, and a finished transfer do to a real
/// [`super::ClientContext`].
///
/// These sit here rather than in the e2e suite because a Soulseek server only
/// ever names privileged users it knows about, and the soulfind used for e2e
/// declares nobody — so the one behaviour the rules are about would otherwise
/// go untested.
#[cfg(test)]
mod context_tests {
use crate::client::{ClientContext, DEFAULT_UPLOAD_SLOTS};
use crate::types::UploadStatus;
use std::path::PathBuf;
/// A context with `slots` upload slots and a token counter that hands out
/// 1, 2, 3… so an assertion can name a token.
fn context(slots: usize) -> (ClientContext, impl FnMut() -> u32) {
let mut ctx = ClientContext::new();
ctx.upload_slots = slots;
let mut next = 0;
(ctx, move || {
next += 1;
next
})
}
fn ask(ctx: &mut ClientContext, user: &str, file: &str) {
ctx.enqueue_upload(user, user, file, PathBuf::from("/tmp/x"), 4096);
}
/// Who the pump offered to, by the order it offered them.
fn offered(
ctx: &mut ClientContext,
token: &mut impl FnMut() -> u32,
) -> Vec<String> {
ctx.pump_uploads(token)
.1
.into_iter()
.map(|offer| offer.requester_key)
.collect()
}
#[test]
fn a_fresh_context_starts_with_the_default_cap() {
assert_eq!(ClientContext::new().upload_slots, DEFAULT_UPLOAD_SLOTS);
}
#[test]
fn the_pump_fills_the_slots_and_leaves_the_rest_queued() {
let (mut ctx, mut token) = context(2);
for user in ["a", "b", "c"] {
ask(&mut ctx, user, "f.mp3");
}
assert_eq!(offered(&mut ctx, &mut token), ["a", "b"]);
assert_eq!(
ctx.place_in_queue("c", "f.mp3"),
Some(1),
"the third asker waits, now at the front of the queue"
);
assert!(
offered(&mut ctx, &mut token).is_empty(),
"pumping again must not oversubscribe the slots"
);
}
#[test]
fn a_privileged_list_arriving_late_re_ranks_who_is_waiting() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "blocker", "f.mp3");
ask(&mut ctx, "plain", "f.mp3");
ask(&mut ctx, "donor", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["blocker"]);
// Before the server has said anything, first-come holds.
assert_eq!(ctx.place_in_queue("plain", "f.mp3"), Some(1));
assert_eq!(ctx.place_in_queue("donor", "f.mp3"), Some(2));
ctx.set_privileged_users(vec!["donor".to_string()]);
assert!(ctx.is_privileged("donor"));
assert!(!ctx.is_privileged("plain"));
assert_eq!(
ctx.place_in_queue("donor", "f.mp3"),
Some(1),
"the donor should overtake the peer that asked first"
);
assert_eq!(
ctx.place_in_queue("plain", "f.mp3"),
Some(2),
"and the plain peer should be told they were overtaken"
);
}
#[test]
fn the_privileged_peer_is_the_one_served_next() {
let (mut ctx, mut token) = context(1);
ctx.set_privileged_users(vec!["donor".to_string()]);
// Someone else already holds the only slot: a transfer in progress is
// never preempted, so the donor's turn is the *next* one.
ask(&mut ctx, "blocker", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["blocker"]);
ask(&mut ctx, "plain", "f.mp3");
ask(&mut ctx, "donor", "f.mp3");
ctx.uploads.clear(); // the blocker's transfer finished
assert_eq!(
offered(&mut ctx, &mut token),
["donor"],
"the freed slot goes to the donor, not the longer-waiting peer"
);
}
#[test]
fn a_finished_transfer_frees_its_slot_for_the_next_in_line() {
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "first", "f.mp3");
ask(&mut ctx, "second", "f.mp3");
assert_eq!(offered(&mut ctx, &mut token), ["first"]);
ctx.uploads.clear();
assert_eq!(offered(&mut ctx, &mut token), ["second"]);
assert_eq!(ctx.place_in_queue("second", "f.mp3"), None);
}
#[test]
fn completed_transfers_do_not_keep_occupying_slots() {
// active_uploads keeps finished transfers so `uploads()` can report
// them; counting those would wedge the queue shut for the session.
let (mut ctx, mut token) = context(1);
ask(&mut ctx, "first", "f.mp3");
offered(&mut ctx, &mut token);
let job = ctx.uploads.remove(&1).expect("the offer was recorded");
ctx.active_uploads.insert(
1,
crate::client::ActiveUpload {
username: job.downloader,
filename: job.virtual_path,
size: job.size,
bytes_sent: std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(job.size),
),
cancel: std::sync::Arc::new(
std::sync::atomic::AtomicBool::new(false),
),
status: UploadStatus::Completed,
started: std::time::Instant::now(),
},
);
ask(&mut ctx, "second", "f.mp3");
assert_eq!(
offered(&mut ctx, &mut token),
["second"],
"a completed upload must not hold its slot"
);
}
#[test]
fn queued_uploads_are_reported_in_the_order_they_will_be_served() {
let (mut ctx, mut token) = context(1);
ctx.set_privileged_users(vec!["donor".to_string()]);
ask(&mut ctx, "blocker", "f.mp3");
offered(&mut ctx, &mut token);
ask(&mut ctx, "plain", "f.mp3");
ask(&mut ctx, "donor", "f.mp3");
let reported: Vec<(String, UploadStatus)> = ctx
.queued_uploads()
.into_iter()
.map(|upload| (upload.username, upload.status))
.collect();
assert_eq!(
reported,
[
("donor".to_string(), UploadStatus::Queued(1)),
("plain".to_string(), UploadStatus::Queued(2)),
]
);
}
}
+98 -2
View File
@@ -1,11 +1,104 @@
use super::{
ActiveUpload, Arc, Client, ClientContext, DownloadStatus, RwLock,
RwLockExt, collect_failed_tokens, error, thread,
ActiveUpload, Arc, Client, ClientContext, DownloadStatus, PeerMessage,
RwLock, RwLockExt, collect_failed_tokens, error, next_upload_token, thread,
};
use crate::types::UploadStatus;
use std::sync::atomic::{AtomicBool, AtomicU64};
/// The peer behind a registry key, which may carry a `:direct` suffix the
/// server never sees.
pub fn downloader_of(requester_key: &str) -> &str {
requester_key
.strip_suffix(":direct")
.unwrap_or(requester_key)
}
impl Client {
/// Whether the server listed `username` as privileged. Privileged peers are
/// served before everyone else.
#[must_use]
pub fn is_privileged(&self, username: &str) -> bool {
self.context
.read_safe()
.is_ok_and(|ctx| ctx.is_privileged(username))
}
/// Ask the server how much of our own privilege time is left (code 92). The
/// answer arrives asynchronously; read it with
/// [`Client::own_privilege_seconds`].
///
/// # Errors
/// [`crate::error::SoulseekRs::NotConnected`] without a server connection.
pub fn check_privileges(&self) -> crate::error::Result<()> {
let Some(handle) = &self.server_handle else {
return Err(crate::error::SoulseekRs::NotConnected);
};
let _ = handle.send(super::ServerMessage::CheckPrivileges);
Ok(())
}
/// Seconds of our own privileges left, or `None` until the server has
/// answered a [`Client::check_privileges`].
#[must_use]
pub fn own_privilege_seconds(&self) -> Option<u32> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.own_privileges)
}
/// How many uploads run at once. Lowering it does not interrupt transfers
/// already in flight; the queue simply refills more slowly.
pub fn set_upload_slots(&self, slots: usize) {
if let Ok(mut ctx) = self.context.write_safe() {
ctx.upload_slots = slots.max(1);
}
}
/// Drop everything `username` was queued or offered, then hand their slot to
/// whoever is next.
pub(crate) fn release_upload_slots(
client_context: &Arc<RwLock<ClientContext>>,
username: &str,
) {
let freed = client_context
.write_safe()
.is_ok_and(|mut ctx| ctx.release_upload_slots(username));
if freed {
Self::pump_upload_queue(client_context);
}
}
/// Fill every free upload slot from the queue and send the offers.
///
/// Called whenever the queue or the slot count could have changed: a new
/// request, a finished transfer, or a privileged list that re-ranked who is
/// waiting.
pub(crate) fn pump_upload_queue(
client_context: &Arc<RwLock<ClientContext>>,
) {
let (registry, offers) = match client_context.write_safe() {
Ok(mut ctx) => ctx.pump_uploads(next_upload_token),
Err(e) => {
error!("[client] pump_upload_queue write: {}", e);
return;
}
};
let Some(registry) = registry else {
return;
};
for offer in offers {
let _ = registry.send_to_peer(
&offer.requester_key,
PeerMessage::ServeUpload {
token: offer.token,
filename: offer.virtual_path,
size: offer.size,
},
);
}
}
/// Consume the upload job for `token` and stream the file to `host:port`
/// on a background thread.
pub(crate) fn spawn_serve(
@@ -64,6 +157,9 @@ impl Client {
{
upload.status = status;
}
// The slot this transfer held is free now, so whoever is next in
// line gets it without waiting for another request to arrive.
Self::pump_upload_queue(&context);
});
}