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:
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user