//! 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, Duration, Instant, PeerRegistry, UploadJob}; use crate::types::{UploadInfo, UploadStatus}; use std::path::PathBuf; /// Cap on remembered queued-upload states, so a caller that never drains /// them cannot grow the list without bound. const MAX_UPLOAD_EVENTS: usize = 256; /// How long an offered slot waits for the peer's TransferResponse. A peer that /// never answers, having gone away or hung, would otherwise hold the slot for /// the life of the process; with two slots, two of them shut uploads down. const OFFER_TIMEOUT: Duration = Duration::from_secs(30); /// 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 { /// The asking peer's username — also the registry key of its actor. 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 { let mut order: Vec = (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 { 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 { 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, downloader: &str, filename: &str, real_path: std::path::PathBuf, size: u64, ) { self.upload_seq += 1; self.upload_queue.push(QueuedUpload { 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, Vec) { let mut offers = Vec::new(); while self.has_free_upload_slot() { 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.downloader.clone(), 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, offered: Some(Instant::now()), }, ); } // Whatever is still waiting had to wait. Record it, because a caller // polling `uploads()` on an interval cannot see a peer that queued and // was served between two polls — and with a small slot count that is // the common case, not the rare one. for waiting in self.queued_uploads() { let already = self.upload_events.iter().any(|seen| { seen.username == waiting.username && seen.filename == waiting.filename }); if !already { self.upload_events.push(waiting); } } // A caller that never drains must not grow this without bound. if self.upload_events.len() > MAX_UPLOAD_EVENTS { let excess = self.upload_events.len() - MAX_UPLOAD_EVENTS; self.upload_events.drain(..excess); } (self.peer_registry.clone(), offers) } /// Take the queued-upload states recorded since the last call. pub fn take_upload_events(&mut self) -> Vec { std::mem::take(&mut self.upload_events) } /// 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() } pub(super) fn has_free_upload_slot(&self) -> bool { self.uploads_in_flight() < self.upload_slots } /// 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 { 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 { 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) { 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) } /// Drop offers older than [`OFFER_TIMEOUT`], recording each as a failed /// upload so an operator sees why the peer never got its file. Returns /// whether anything was dropped, which is the caller's cue to pump again. pub fn expire_stale_offers(&mut self, now: Instant) -> bool { let stale: Vec = self .uploads .iter() .filter(|(_, job)| { job.offered.is_some_and(|sent| { now.duration_since(sent) >= OFFER_TIMEOUT }) }) .map(|(&token, _)| token) .collect(); for token in &stale { let Some(job) = self.uploads.remove(token) else { continue; }; self.upload_events.push(UploadInfo { username: job.downloader, filename: job.virtual_path, size: job.size, bytes_sent: 0, speed_bytes_per_sec: 0.0, status: UploadStatus::Failed( "no answer to the transfer offer".to_string(), ), }); } !stale.is_empty() } /// The peer accepted the offer for `token`; it may still take a while to /// resolve their address and connect, and that wait must not expire it. pub fn mark_offer_answered(&mut self, token: u32) { if let Some(job) = self.uploads.get_mut(&token) { job.offered = None; } } /// 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 { 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 super::OFFER_TIMEOUT; 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, 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 { 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 an_offer_nobody_answers_frees_its_slot_after_the_timeout() { let (mut ctx, mut token) = context(1); ask(&mut ctx, "gone", "f.mp3"); ask(&mut ctx, "next", "f.mp3"); assert_eq!(offered(&mut ctx, &mut token), ["gone"]); let now = std::time::Instant::now(); assert!(!ctx.expire_stale_offers(now), "a fresh offer stays"); assert!( ctx.expire_stale_offers(now + OFFER_TIMEOUT), "an unanswered offer expires" ); assert_eq!(offered(&mut ctx, &mut token), ["next"]); assert!(ctx.take_upload_events().iter().any(|event| { event.username == "gone" && matches!(event.status, UploadStatus::Failed(_)) })); } #[test] fn an_accepted_offer_waiting_on_an_address_does_not_expire() { let (mut ctx, mut token) = context(1); ask(&mut ctx, "slow", "f.mp3"); offered(&mut ctx, &mut token); ctx.mark_offer_answered(1); let later = std::time::Instant::now() + OFFER_TIMEOUT * 2; assert!(!ctx.expire_stale_offers(later), "an answered offer stays"); assert!(ctx.uploads.contains_key(&1)); } #[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)), ] ); } // A peer that waits and is then served inside one poll interval must still // be reportable as queued: `serve` samples on an interval, so a state that // only ever existed between two samples would otherwise vanish from the // transfer log entirely. #[test] fn a_queued_upload_is_recorded_even_after_it_leaves_the_queue() { let (mut ctx, mut token) = context(1); ask(&mut ctx, "amy", "@@share\\amy.mp3"); ask(&mut ctx, "bob", "@@share\\bob.mp3"); // One slot, so bob waits behind amy. let _ = ctx.pump_uploads(&mut token); assert!( ctx.queued_uploads().iter().any(|u| u.username == "bob"), "bob should be waiting while amy holds the only slot" ); // Amy finishes and bob is served, so nothing is waiting any more... ctx.uploads.clear(); ctx.active_uploads.clear(); let _ = ctx.pump_uploads(&mut token); assert!( ctx.queued_uploads().is_empty(), "the queue should have drained" ); // ...but the fact that bob waited survives for whoever asks next. let events = ctx.take_upload_events(); assert!( events.iter().any(|u| u.username == "bob" && matches!(u.status, UploadStatus::Queued(_))), "bob's wait should still be reportable, got {events:?}" ); assert!( ctx.take_upload_events().is_empty(), "draining twice must not repeat the same events" ); } }