soulseek-rs-lib 13.0.0

Library for Soulseek protocol implementation in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
//! 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;

/// 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;

/// 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,
                },
            );
        }

        // 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<UploadInfo> {
        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()
    }

    /// 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)),
            ]
        );
    }

    // 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"
        );
    }
}