test(daemon): drive the whole CLI suite against a daemon, and fix what it caught

SOULSEEK_E2E_MODE=daemon makes the existing e2e harness serve every session
from a daemon instead of the command's own login, so the same 83 tests assert
the same records and exit codes both ways. CI runs the suite twice.

Two defects it found:

serve judged whether anything was shared from this run's configuration
rather than from the session, so routed to a daemon it would start with
nothing to serve and report success.

The TUI showed an empty conversation when attached, because the daemon's
message history was never asked for. It is now a SessionApi method, empty
locally, which puts the difference behind the seam.

main.rs is now a thin entry point over the library rather than a second copy
of every module, so the program compiles once instead of twice.
This commit is contained in:
Michel de Graaf
2026-07-27 10:58:27 +02:00
parent 834fbcd35b
commit a69f52c657
12 changed files with 663 additions and 578 deletions
+40 -11
View File
@@ -20,7 +20,7 @@ use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::time::{Duration, Instant};
/// How often the drainer samples the session. The TUI already redraws on this
/// cadence, so events reach a remote UI as promptly as a local one.
@@ -157,22 +157,39 @@ struct DrainState {
reported_loss: bool,
}
/// Peers we have asked for a listing and are still waiting on. `take_browse_result`
/// is per-user and destructive, so the daemon has to know who to ask about.
/// How long to keep asking whether a peer has answered a browse request.
///
/// A peer that is offline or uninterested never replies, and the daemon would
/// otherwise poll for it until restart — so a script that browsed a few
/// thousand absent peers would leave the drainer doing thousands of pointless
/// lookups every tick. Comfortably longer than any client's own browse
/// timeout, so this expiry never beats a real answer.
const BROWSE_PATIENCE: Duration = Duration::from_mins(2);
/// Peers we have asked for a listing and are still waiting on.
/// `take_browse_result` is per-user and destructive, so the daemon has to know
/// who to ask about.
#[derive(Default)]
pub struct PendingBrowses(Mutex<Vec<String>>);
pub struct PendingBrowses(Mutex<Vec<(String, Instant)>>);
impl PendingBrowses {
pub fn expect(&self, username: &str) {
if let Ok(mut pending) = self.0.lock()
&& !pending.iter().any(|name| name == username)
{
pending.push(username.to_string());
}
let Ok(mut pending) = self.0.lock() else {
return;
};
// Asking again restarts the clock: it is a fresh request.
pending.retain(|(name, _)| name != username);
pending.push((username.to_string(), Instant::now()));
}
/// Who to ask about this tick, forgetting the ones that have run out of
/// patience.
fn snapshot(&self) -> Vec<String> {
self.0.lock().map_or_else(|_| Vec::new(), |pending| pending.clone())
let Ok(mut pending) = self.0.lock() else {
return Vec::new();
};
pending.retain(|(_, asked)| asked.elapsed() < BROWSE_PATIENCE);
pending.iter().map(|(name, _)| name.clone()).collect()
}
fn settled(&self, username: &str) {
@@ -182,7 +199,7 @@ impl PendingBrowses {
/// Stop waiting on a peer whose request never reached the wire.
pub fn forget(&self, username: &str) {
if let Ok(mut pending) = self.0.lock() {
pending.retain(|name| name != username);
pending.retain(|(name, _)| name != username);
}
}
}
@@ -446,4 +463,16 @@ mod tests {
pending.settled("bob");
assert!(pending.snapshot().is_empty());
}
#[test]
fn a_browse_that_is_never_answered_stops_being_polled() {
// A peer that is offline never replies; without an expiry the drainer
// would look for its answer on every tick until the daemon restarts.
let asked = Instant::now()
.checked_sub(BROWSE_PATIENCE + Duration::from_secs(1))
.expect("the clock is past the epoch");
let pending =
PendingBrowses(Mutex::new(vec![("ghost".to_string(), asked)]));
assert!(pending.snapshot().is_empty());
}
}