A peer actor multiplexes socket reads with its mailbox on one thread: tick() reads the socket while outgoing messages (e.g. a queued QueueUpload that starts a download) are delivered through the mailbox between ticks. Inbound connections accepted by the listener were left in blocking mode, so once such a peer went idle tick() parked inside read() and never drained the mailbox — a download queued against that peer never sent its QueueUpload and hung. Outbound connections already set non-blocking; do the same for inbound P connections. Covered by a new server-optional e2e test that downloads a file end to end from an in-process mock peer over real P (control) and F (transfer) connections, asserting the bytes written match the source. The mock speaks the library's own public Message wire format, so no dependencies are added.
570 lines
19 KiB
Rust
570 lines
19 KiB
Rust
//! End-to-end integration tests against a real Soulseek server (soulfind).
|
|
//!
|
|
//! These tests are SERVER-OPTIONAL so `cargo test` stays green everywhere:
|
|
//! * If `SOULSEEK_TEST_SERVER=host:port` is set, they connect to it.
|
|
//! * Else if a soulfind binary is found (via `SOULFIND_BIN`, or a sibling
|
|
//! `../soulfind/bin/soulfind` checkout), they spawn it on an ephemeral port
|
|
//! with a throwaway database.
|
|
//! * Else they SKIP with a printed notice (the test still passes).
|
|
//!
|
|
//! To run them against a locally built soulfind:
|
|
//! ```sh
|
|
//! SOULFIND_BIN=/path/to/soulfind \
|
|
//! cargo test -p soulseek-rs-lib --test e2e -- --nocapture
|
|
//! ```
|
|
//!
|
|
//! No external crates are used — the library forbids dependencies, so the
|
|
//! harness sticks to `std` and the library's own public API.
|
|
|
|
#![allow(clippy::doc_markdown)]
|
|
|
|
use std::io::{Read, Write};
|
|
use std::net::{TcpStream, ToSocketAddrs};
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::mpsc::Sender;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use soulseek_rs::message::Message;
|
|
use soulseek_rs::{Client, ClientSettings, DownloadStatus, PeerAddress};
|
|
|
|
/// A Soulseek server to test against: either a child soulfind process we
|
|
/// spawned, or an external server referenced by `SOULSEEK_TEST_SERVER`.
|
|
struct TestServer {
|
|
host: String,
|
|
port: u16,
|
|
child: Option<Child>,
|
|
db: Option<PathBuf>,
|
|
}
|
|
|
|
impl TestServer {
|
|
/// Resolve a server to test against, or `None` if the suite should skip.
|
|
fn resolve() -> Option<Self> {
|
|
if let Ok(addr) = std::env::var("SOULSEEK_TEST_SERVER") {
|
|
let (host, port) = split_host_port(&addr)?;
|
|
wait_until_listening(&host, port, Duration::from_secs(2))?;
|
|
return Some(Self {
|
|
host,
|
|
port,
|
|
child: None,
|
|
db: None,
|
|
});
|
|
}
|
|
Self::spawn()
|
|
}
|
|
|
|
/// Spawn a local soulfind on an ephemeral port with a throwaway database.
|
|
fn spawn() -> Option<Self> {
|
|
let bin = soulfind_binary()?;
|
|
let port = free_port()?;
|
|
let db = std::env::temp_dir().join(format!("soulfind-e2e-{port}.db"));
|
|
let _ = std::fs::remove_file(&db);
|
|
|
|
let mut child = Command::new(&bin)
|
|
.arg("-p")
|
|
.arg(port.to_string())
|
|
.arg("-d")
|
|
.arg(&db)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.ok()?;
|
|
|
|
if wait_until_listening("127.0.0.1", port, Duration::from_secs(5))
|
|
.is_none()
|
|
{
|
|
// Server never came up (e.g. a toolchain/SQLite issue); skip.
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return None;
|
|
}
|
|
|
|
Some(Self {
|
|
host: "127.0.0.1".to_string(),
|
|
port,
|
|
child: Some(child),
|
|
db: Some(db),
|
|
})
|
|
}
|
|
|
|
fn settings(&self, username: &str, password: &str) -> ClientSettings {
|
|
ClientSettings {
|
|
username: username.to_string(),
|
|
password: password.to_string(),
|
|
server_address: PeerAddress::new(self.host.clone(), self.port),
|
|
enable_listen: false,
|
|
listen_port: 0,
|
|
shared_directory: None,
|
|
}
|
|
}
|
|
|
|
/// Settings with the peer listener enabled on `port`, exercising the
|
|
/// `SetWaitPort` step of the post-login handshake.
|
|
fn listening_settings(
|
|
&self,
|
|
username: &str,
|
|
password: &str,
|
|
port: u16,
|
|
) -> ClientSettings {
|
|
ClientSettings {
|
|
enable_listen: true,
|
|
listen_port: port,
|
|
..self.settings(username, password)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for TestServer {
|
|
fn drop(&mut self) {
|
|
if let Some(child) = self.child.as_mut() {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
if let Some(db) = self.db.as_ref() {
|
|
let _ = std::fs::remove_file(db);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn split_host_port(addr: &str) -> Option<(String, u16)> {
|
|
let (host, port) = addr.rsplit_once(':')?;
|
|
Some((host.to_string(), port.parse().ok()?))
|
|
}
|
|
|
|
fn soulfind_binary() -> Option<PathBuf> {
|
|
if let Ok(p) = std::env::var("SOULFIND_BIN") {
|
|
let p = PathBuf::from(p);
|
|
return p.exists().then_some(p);
|
|
}
|
|
// Fall back to a `soulfind/bin/soulfind` checkout somewhere above this
|
|
// workspace (the workspace may be nested, e.g. under `.../src/rust/`).
|
|
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
manifest
|
|
.ancestors()
|
|
.map(|dir| dir.join("soulfind/bin/soulfind"))
|
|
.find(|candidate| candidate.exists())
|
|
}
|
|
|
|
fn free_port() -> Option<u16> {
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").ok()?;
|
|
listener.local_addr().ok().map(|addr| addr.port())
|
|
}
|
|
|
|
fn wait_until_listening(
|
|
host: &str,
|
|
port: u16,
|
|
timeout: Duration,
|
|
) -> Option<()> {
|
|
let addr = format!("{host}:{port}");
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
if let Ok(mut addrs) = addr.to_socket_addrs()
|
|
&& let Some(sa) = addrs.next()
|
|
&& TcpStream::connect_timeout(&sa, Duration::from_millis(200))
|
|
.is_ok()
|
|
{
|
|
return Some(());
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Resolve a test server or return early with a skip notice.
|
|
///
|
|
/// Set `SOULSEEK_E2E_REQUIRED=1` (as CI does) to turn a missing server into a
|
|
/// hard failure instead of a silent skip, so the suite genuinely runs there.
|
|
macro_rules! server_or_skip {
|
|
() => {
|
|
match TestServer::resolve() {
|
|
Some(server) => server,
|
|
None => {
|
|
let required = std::env::var("SOULSEEK_E2E_REQUIRED")
|
|
.is_ok_and(|v| v != "0" && !v.is_empty());
|
|
assert!(
|
|
!required,
|
|
"SOULSEEK_E2E_REQUIRED is set but no soulfind server could \
|
|
be started (set SOULFIND_BIN or SOULSEEK_TEST_SERVER)"
|
|
);
|
|
println!(
|
|
"e2e skipped: no soulfind server (set SOULFIND_BIN or \
|
|
SOULSEEK_TEST_SERVER to run)"
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
#[test]
|
|
fn connect_and_login_succeed() {
|
|
let server = server_or_skip!();
|
|
let mut client =
|
|
Client::with_settings(server.settings("e2e_user", "e2e_pw"));
|
|
client.connect().expect("connect to soulfind");
|
|
let logged_in = client.login().expect("login to soulfind");
|
|
assert!(logged_in, "login should succeed (soulfind auto-registers)");
|
|
}
|
|
|
|
#[test]
|
|
fn search_round_trips_without_error() {
|
|
let server = server_or_skip!();
|
|
let mut client =
|
|
Client::with_settings(server.settings("e2e_search", "e2e_pw"));
|
|
client.connect().expect("connect");
|
|
assert!(client.login().expect("login"));
|
|
|
|
// A fresh server has no shared files, so the search simply has to
|
|
// round-trip without error and leave an (empty) queryable result set.
|
|
let query = "nonexistent query xyzzy";
|
|
let _ = client.search(query, Duration::from_secs(2));
|
|
assert!(client.get_search_results(query).is_empty());
|
|
|
|
// The search must also be tracked in client state under its key, proving
|
|
// the request was actually registered and not silently dropped.
|
|
assert!(
|
|
client.get_all_searches().contains_key(query),
|
|
"the issued search should be registered under its query key"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_search_is_forwarded_to_a_connected_peer() {
|
|
let server = server_or_skip!();
|
|
|
|
// The server distributes each search to other connected users, so a second
|
|
// client exercises the *incoming* FileSearch handler. We can't observe that
|
|
// handler's state directly, but if it mishandled the forwarded bytes it
|
|
// would take the receiver's session down — so we prove the receiver is
|
|
// still alive afterwards by round-tripping its own search.
|
|
let mut searcher =
|
|
Client::with_settings(server.settings("e2e_searcher", "pw"));
|
|
let mut receiver =
|
|
Client::with_settings(server.settings("e2e_receiver", "pw"));
|
|
searcher.connect().expect("searcher connect");
|
|
receiver.connect().expect("receiver connect");
|
|
assert!(searcher.login().expect("searcher login"));
|
|
assert!(receiver.login().expect("receiver login"));
|
|
|
|
let _ = searcher.search("some shared song", Duration::from_secs(2));
|
|
|
|
// Receiver stays functional after handling the forwarded search.
|
|
let probe = "receiver still alive";
|
|
let _ = receiver.search(probe, Duration::from_secs(2));
|
|
assert!(receiver.get_all_searches().contains_key(probe));
|
|
}
|
|
|
|
#[test]
|
|
fn a_private_message_is_delivered_between_users() {
|
|
let server = server_or_skip!();
|
|
|
|
// Two logged-in users, one messages the other through the server.
|
|
let mut alice =
|
|
Client::with_settings(server.settings("e2e_alice_pm", "pw"));
|
|
let mut bob = Client::with_settings(server.settings("e2e_bob_pm", "pw"));
|
|
alice.connect().expect("alice connect");
|
|
bob.connect().expect("bob connect");
|
|
assert!(alice.login().expect("alice login"));
|
|
assert!(bob.login().expect("bob login"));
|
|
|
|
let body = "hello bob, this is alice";
|
|
alice
|
|
.send_private_message("e2e_bob_pm", body)
|
|
.expect("send private message");
|
|
|
|
// Delivery is asynchronous; poll Bob's inbox until the message arrives.
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let mut received = Vec::new();
|
|
while Instant::now() < deadline {
|
|
received.extend(bob.take_private_messages());
|
|
if !received.is_empty() {
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
|
|
let message = received
|
|
.iter()
|
|
.find(|m| m.message() == body)
|
|
.expect("bob should receive alice's message");
|
|
assert_eq!(message.username(), "e2e_alice_pm");
|
|
}
|
|
|
|
#[test]
|
|
fn login_succeeds_with_listener_enabled() {
|
|
let server = server_or_skip!();
|
|
|
|
// With the listener enabled the client also sends SetWaitPort during the
|
|
// post-login handshake; the server must accept it and keep the session.
|
|
let port = free_port().expect("free listener port");
|
|
let mut client = Client::with_settings(server.listening_settings(
|
|
"e2e_listener",
|
|
"pw",
|
|
port,
|
|
));
|
|
client.connect().expect("connect with listener");
|
|
assert!(
|
|
client.login().expect("login with listener enabled"),
|
|
"the handshake including SetWaitPort should still log in"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_password_is_rejected() {
|
|
let server = server_or_skip!();
|
|
|
|
// soulfind auto-registers a username on first login and binds it to that
|
|
// password, so a second login with a different password must be rejected.
|
|
let user = "e2e_pw_user";
|
|
let mut first =
|
|
Client::with_settings(server.settings(user, "correct-horse"));
|
|
first.connect().expect("connect (registering login)");
|
|
assert!(
|
|
first.login().expect("first login"),
|
|
"registration should log in"
|
|
);
|
|
drop(first);
|
|
|
|
let mut second =
|
|
Client::with_settings(server.settings(user, "wrong-password"));
|
|
second.connect().expect("connect (wrong password)");
|
|
// The server may signal rejection either as a non-success status or as an
|
|
// authentication error; both mean "not logged in", only `Ok(true)` accepts.
|
|
assert!(
|
|
!matches!(second.login(), Ok(true)),
|
|
"a mismatched password must not be accepted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn two_clients_can_be_logged_in_together() {
|
|
let server = server_or_skip!();
|
|
|
|
// The server must handle several independent sessions at once — this is
|
|
// the precondition for any peer-to-peer feature routed through it.
|
|
let mut alice = Client::with_settings(server.settings("e2e_alice", "pw_a"));
|
|
let mut bob = Client::with_settings(server.settings("e2e_bob", "pw_b"));
|
|
|
|
alice.connect().expect("alice connect");
|
|
bob.connect().expect("bob connect");
|
|
|
|
assert!(alice.login().expect("alice login"));
|
|
assert!(bob.login().expect("bob login"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Peer-to-peer download coverage.
|
|
//
|
|
// The client cannot serve files (the upload side is not implemented), so a real
|
|
// download is exercised with a minimal in-process "mock uploader" that speaks
|
|
// the peer protocol using the library's own public `Message` wire format. It
|
|
// drives the same path a real peer would after a search: a `P` control
|
|
// connection to our listener, a `QueueUpload` → `TransferRequest` →
|
|
// `TransferResponse` negotiation, then an `F` connection that streams the bytes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Configuration for a one-shot mock uploader.
|
|
struct MockUpload {
|
|
listen_addr: String,
|
|
peer_username: String,
|
|
filename: String,
|
|
content: Vec<u8>,
|
|
token: u32,
|
|
ready: Sender<()>,
|
|
}
|
|
|
|
/// Build a `PeerInit` (peer code 1) frame: `[len][1][username][conn_type][token]`.
|
|
fn peer_init_bytes(username: &str, conn_type: &str, token: u32) -> Vec<u8> {
|
|
let mut m = Message::new();
|
|
m.write_int8(1)
|
|
.write_string(username)
|
|
.write_string(conn_type)
|
|
.write_int32(token);
|
|
m.get_buffer()
|
|
}
|
|
|
|
/// Read one length-prefixed peer message (`[len:4 LE][payload]`) from a blocking
|
|
/// stream. The returned `Message` keeps the length prefix, so `get_message_code`
|
|
/// and `set_pointer(8)` behave exactly as they do inside the library.
|
|
fn read_framed(stream: &mut TcpStream) -> std::io::Result<Message> {
|
|
let mut len_buf = [0u8; 4];
|
|
stream.read_exact(&mut len_buf)?;
|
|
let len = u32::from_le_bytes(len_buf) as usize;
|
|
let mut payload = vec![0u8; len];
|
|
stream.read_exact(&mut payload)?;
|
|
let mut data = len_buf.to_vec();
|
|
data.extend_from_slice(&payload);
|
|
Ok(Message::new_with_data(data))
|
|
}
|
|
|
|
fn connect_retry(addr: &str, timeout: Duration) -> std::io::Result<TcpStream> {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
match TcpStream::connect(addr) {
|
|
Ok(stream) => return Ok(stream),
|
|
Err(e) => {
|
|
if Instant::now() >= deadline {
|
|
return Err(e);
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_mock_uploader(cfg: &MockUpload) -> std::io::Result<()> {
|
|
// 1. P (control) connection: register ourselves with the downloader.
|
|
let mut p = connect_retry(&cfg.listen_addr, Duration::from_secs(5))?;
|
|
p.set_read_timeout(Some(Duration::from_secs(10)))?;
|
|
p.write_all(&peer_init_bytes(&cfg.peer_username, "P", 0))?;
|
|
p.flush()?;
|
|
let _ = cfg.ready.send(());
|
|
|
|
// 2. Wait for the downloader's QueueUpload (peer code 43).
|
|
loop {
|
|
let mut msg = read_framed(&mut p)?;
|
|
if msg.get_message_code() == 43 {
|
|
msg.set_pointer(8);
|
|
let _requested = msg.read_string();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 3. Offer the transfer with a TransferRequest (peer code 40). The size here
|
|
// becomes the download's expected size, so it must match the content.
|
|
let mut tr = Message::new();
|
|
tr.write_int32(40)
|
|
.write_int32(1) // direction: upload
|
|
.write_int32(cfg.token)
|
|
.write_string(&cfg.filename)
|
|
.write_int64(cfg.content.len() as u64);
|
|
p.write_all(&tr.get_buffer())?;
|
|
p.flush()?;
|
|
|
|
// 4. Wait for the downloader to allow it (TransferResponse, peer code 41).
|
|
loop {
|
|
let msg = read_framed(&mut p)?;
|
|
if msg.get_message_code() == 41 {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 5. F (file) connection. PeerInit(F) and the 4-byte transfer token go in a
|
|
// single write so the token lands in the listener's read buffer, where
|
|
// the download is looked up by token.
|
|
let mut f = connect_retry(&cfg.listen_addr, Duration::from_secs(5))?;
|
|
f.set_read_timeout(Some(Duration::from_secs(10)))?;
|
|
let mut init = peer_init_bytes(&cfg.peer_username, "F", cfg.token);
|
|
init.extend_from_slice(&cfg.token.to_le_bytes());
|
|
f.write_all(&init)?;
|
|
f.flush()?;
|
|
|
|
// 6. The downloader replies with an 8-byte START_DOWNLOAD offset, then we
|
|
// stream the file bytes.
|
|
let mut start = [0u8; 8];
|
|
f.read_exact(&mut start)?;
|
|
f.write_all(&cfg.content)?;
|
|
f.flush()?;
|
|
|
|
// Keep the connection open briefly so the reader drains everything.
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
Ok(())
|
|
}
|
|
|
|
fn unique_download_dir() -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"soulseek-e2e-dl-{}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_downloads_from_a_peer_over_p_and_f_connections() {
|
|
let server = server_or_skip!();
|
|
|
|
// Downloader: connected to the server with its peer listener enabled.
|
|
let listen_port = free_port().expect("free listen port");
|
|
let mut client = Client::with_settings(server.listening_settings(
|
|
"e2e_downloader",
|
|
"pw",
|
|
listen_port,
|
|
));
|
|
client.connect().expect("connect");
|
|
assert!(client.login().expect("login"));
|
|
|
|
let filename = "mock_song.mp3";
|
|
let content: Vec<u8> = (0..2000u32).map(|i| (i % 251) as u8).collect();
|
|
let size = content.len() as u64;
|
|
let token = 424_242_u32;
|
|
let download_dir = unique_download_dir();
|
|
|
|
// Start the mock uploader; it signals once its P connection is established.
|
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
|
let cfg = MockUpload {
|
|
listen_addr: format!("127.0.0.1:{listen_port}"),
|
|
peer_username: "e2e_mockpeer".to_string(),
|
|
filename: filename.to_string(),
|
|
content: content.clone(),
|
|
token,
|
|
ready: ready_tx,
|
|
};
|
|
let uploader = std::thread::spawn(move || {
|
|
if let Err(e) = run_mock_uploader(&cfg) {
|
|
eprintln!("[mock uploader] {e}");
|
|
}
|
|
});
|
|
|
|
ready_rx
|
|
.recv_timeout(Duration::from_secs(5))
|
|
.expect("mock uploader P connection");
|
|
|
|
// The listener registers an incoming peer under "<username>:direct"; give
|
|
// that registration a moment to complete before queuing the download.
|
|
std::thread::sleep(Duration::from_millis(1500));
|
|
|
|
let (_download, status_rx) = client
|
|
.download(
|
|
filename.to_string(),
|
|
"e2e_mockpeer:direct".to_string(),
|
|
size,
|
|
download_dir.display().to_string(),
|
|
)
|
|
.expect("start download");
|
|
|
|
// Wait for completion via the per-download status channel.
|
|
let mut completed = false;
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
while Instant::now() < deadline {
|
|
match status_rx.recv_timeout(Duration::from_millis(500)) {
|
|
Ok(DownloadStatus::Completed) => {
|
|
completed = true;
|
|
break;
|
|
}
|
|
Ok(DownloadStatus::Failed | DownloadStatus::TimedOut) => {
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
if client
|
|
.get_all_downloads()
|
|
.iter()
|
|
.any(|d| matches!(d.status, DownloadStatus::Completed))
|
|
{
|
|
completed = true;
|
|
break;
|
|
}
|
|
}
|
|
let _ = uploader.join();
|
|
|
|
assert!(completed, "the download should reach Completed");
|
|
|
|
let written = std::fs::read(download_dir.join(filename))
|
|
.expect("downloaded file should exist");
|
|
assert_eq!(written, content, "downloaded bytes should match the source");
|
|
|
|
let _ = std::fs::remove_dir_all(&download_dir);
|
|
}
|