The client read the distributed network but never carried it: a peer dialling with a D connection was logged and dropped, and AcceptChildren was hardcoded false, so this client took from the tree without ever relaying a search for anyone else. With accept_children set, peers hang from us: each is told where our branch sits when it connects and again when the branch moves, every search that reaches us is passed down, and a child that goes away loses its slot. Ten at a time, matching Nicotine+, since each costs a socket and a copy of the network's whole search stream — which is also why it is opt-in, through the new accept_children config key (ignored without a listener, since a child would have nothing to dial).
410 lines
13 KiB
Rust
410 lines
13 KiB
Rust
//! Server-independent end-to-end coverage for downloads that resume.
|
|
//!
|
|
//! The subject is a real `Client` with its real peer listener and actor stack.
|
|
//! A loopback socket stands in for a Soulseek peer and speaks the public wire
|
|
//! format. The configured server is deliberately idle: peer transfers do not
|
|
//! require a login once the control connection is registered.
|
|
|
|
use std::fs;
|
|
use std::io::{Read, Write};
|
|
use std::net::{Shutdown, TcpListener, TcpStream};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::mpsc::Receiver;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use soulseek_rs::message::Message;
|
|
use soulseek_rs::{
|
|
Client, ClientSettings, ClientVersion, DownloadStatus, PeerAddress,
|
|
};
|
|
|
|
const IO_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
struct ScratchDir(PathBuf);
|
|
|
|
impl ScratchDir {
|
|
fn new(label: &str) -> Self {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"soulseek-resume-e2e-{}-{label}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&path);
|
|
fs::create_dir_all(&path).expect("create scratch directory");
|
|
Self(path)
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for ScratchDir {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
struct Subject {
|
|
client: Client,
|
|
peer_addr: String,
|
|
// Keeping a server listener alive is enough for ServerActor's TCP connect.
|
|
// No login or server messages are needed by the peer-transfer path.
|
|
_idle_server: TcpListener,
|
|
}
|
|
|
|
impl Subject {
|
|
fn new(username: &str) -> Self {
|
|
let idle_server =
|
|
TcpListener::bind("127.0.0.1:0").expect("bind idle server");
|
|
let server_addr =
|
|
idle_server.local_addr().expect("idle server address");
|
|
let mut client = Client::with_settings(ClientSettings {
|
|
username: username.to_string(),
|
|
password: "unused".to_string(),
|
|
server_address: PeerAddress::new(
|
|
"127.0.0.1".to_string(),
|
|
server_addr.port(),
|
|
),
|
|
enable_listen: true,
|
|
listen_port: 0,
|
|
shared_directories: Vec::new(),
|
|
accept_children: false,
|
|
version: ClientVersion::default(),
|
|
});
|
|
client.connect().expect("connect subject");
|
|
let port = client.listen_port().expect("subject listener port");
|
|
Self {
|
|
client,
|
|
peer_addr: format!("127.0.0.1:{port}"),
|
|
_idle_server: idle_server,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ControlPeer {
|
|
username: String,
|
|
subject_addr: String,
|
|
stream: TcpStream,
|
|
}
|
|
|
|
impl ControlPeer {
|
|
fn register(subject: &Subject, username: &str) -> Self {
|
|
let mut stream = TcpStream::connect(&subject.peer_addr)
|
|
.expect("connect peer control socket");
|
|
stream
|
|
.set_read_timeout(Some(IO_TIMEOUT))
|
|
.expect("control read timeout");
|
|
stream
|
|
.set_write_timeout(Some(IO_TIMEOUT))
|
|
.expect("control write timeout");
|
|
|
|
// Code 15/16 is a positive registration fence. Receiving the response
|
|
// proves the inbound P connection has reached the peer actor and can
|
|
// receive QueueUpload messages from `Client::download`.
|
|
stream
|
|
.write_all(&peer_init_bytes(username, "P", 0))
|
|
.expect("send P PeerInit");
|
|
stream
|
|
.write_all(&Message::new().write_int32(15).get_buffer())
|
|
.expect("send UserInfoRequest registration fence");
|
|
stream.flush().expect("flush registration fence");
|
|
let _ = expect_code(&mut stream, 16);
|
|
|
|
Self {
|
|
username: username.to_string(),
|
|
subject_addr: subject.peer_addr.clone(),
|
|
stream,
|
|
}
|
|
}
|
|
|
|
fn accept_queue_and_offer(&mut self, filename: &str, size: u64) -> u32 {
|
|
let mut queue = expect_code(&mut self.stream, 43);
|
|
queue.set_pointer(8);
|
|
assert_eq!(queue.read_string(), filename, "queued remote filename");
|
|
|
|
let token = 700_000;
|
|
let mut request = Message::new();
|
|
request
|
|
.write_int32(40)
|
|
.write_int32(1) // upload from the peer to the subject
|
|
.write_int32(token)
|
|
.write_string(filename)
|
|
.write_int64(size);
|
|
self.stream
|
|
.write_all(&request.get_buffer())
|
|
.expect("send TransferRequest");
|
|
self.stream.flush().expect("flush TransferRequest");
|
|
|
|
let mut response = expect_code(&mut self.stream, 41);
|
|
response.set_pointer(8);
|
|
assert_eq!(response.read_int32(), token, "response transfer token");
|
|
assert_eq!(response.read_int8(), 1, "transfer should be allowed");
|
|
token
|
|
}
|
|
|
|
fn open_file_transfer(&self, token: u32) -> (TcpStream, u64) {
|
|
let mut stream = TcpStream::connect(&self.subject_addr)
|
|
.expect("connect peer file socket");
|
|
stream
|
|
.set_read_timeout(Some(IO_TIMEOUT))
|
|
.expect("file read timeout");
|
|
stream
|
|
.set_write_timeout(Some(IO_TIMEOUT))
|
|
.expect("file write timeout");
|
|
|
|
let mut init = peer_init_bytes(&self.username, "F", token);
|
|
init.extend_from_slice(&token.to_le_bytes());
|
|
stream.write_all(&init).expect("send F PeerInit and token");
|
|
stream.flush().expect("flush F PeerInit and token");
|
|
|
|
let mut offset = [0u8; 8];
|
|
stream
|
|
.read_exact(&mut offset)
|
|
.expect("read START_DOWNLOAD offset");
|
|
(stream, u64::from_le_bytes(offset))
|
|
}
|
|
}
|
|
|
|
fn peer_init_bytes(
|
|
username: &str,
|
|
connection_type: &str,
|
|
token: u32,
|
|
) -> Vec<u8> {
|
|
Message::new()
|
|
.write_int8(1)
|
|
.write_string(username)
|
|
.write_string(connection_type)
|
|
.write_int32(token)
|
|
.get_buffer()
|
|
}
|
|
|
|
fn read_framed(stream: &mut TcpStream) -> std::io::Result<Message> {
|
|
let mut len = [0u8; 4];
|
|
stream.read_exact(&mut len)?;
|
|
let mut payload = vec![0u8; u32::from_le_bytes(len) as usize];
|
|
stream.read_exact(&mut payload)?;
|
|
let mut bytes = len.to_vec();
|
|
bytes.extend_from_slice(&payload);
|
|
Ok(Message::new_with_data(bytes))
|
|
}
|
|
|
|
fn expect_code(stream: &mut TcpStream, expected: u32) -> Message {
|
|
let deadline = Instant::now() + IO_TIMEOUT;
|
|
loop {
|
|
let message = read_framed(stream).unwrap_or_else(|error| {
|
|
panic!("read peer code {expected}: {error}")
|
|
});
|
|
if message.get_message_code() == expected {
|
|
return message;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"timed out waiting for peer code {expected}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn start_download(
|
|
subject: &Subject,
|
|
control: &mut ControlPeer,
|
|
filename: &str,
|
|
content_len: usize,
|
|
destination: &Path,
|
|
) -> (Receiver<DownloadStatus>, TcpStream, u64) {
|
|
let (_download, statuses) = subject
|
|
.client
|
|
.download(
|
|
filename.to_string(),
|
|
control.username.clone(),
|
|
content_len as u64,
|
|
destination.display().to_string(),
|
|
)
|
|
.expect("queue download");
|
|
let token = control.accept_queue_and_offer(filename, content_len as u64);
|
|
let (file_stream, offset) = control.open_file_transfer(token);
|
|
(statuses, file_stream, offset)
|
|
}
|
|
|
|
fn wait_for_status(
|
|
statuses: &Receiver<DownloadStatus>,
|
|
description: &str,
|
|
wanted: impl Fn(&DownloadStatus) -> bool,
|
|
) -> DownloadStatus {
|
|
let deadline = Instant::now() + IO_TIMEOUT;
|
|
loop {
|
|
if let Ok(status) = statuses.recv_timeout(Duration::from_millis(100))
|
|
&& wanted(&status)
|
|
{
|
|
return status;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"status channel did not report {description}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn assert_stored_status(
|
|
subject: &Subject,
|
|
filename: &str,
|
|
wanted: impl Fn(&DownloadStatus) -> bool,
|
|
) {
|
|
let status = subject
|
|
.client
|
|
.get_all_downloads()
|
|
.into_iter()
|
|
.find(|download| download.filename == filename)
|
|
.unwrap_or_else(|| panic!("download {filename} is missing from store"))
|
|
.status;
|
|
assert!(wanted(&status), "unexpected stored status: {status:?}");
|
|
}
|
|
|
|
fn expect_downloader_close(stream: &mut TcpStream) {
|
|
let mut unexpected = [0u8; 1];
|
|
assert_eq!(
|
|
stream
|
|
.read(&mut unexpected)
|
|
.expect("wait for downloader to close the F socket"),
|
|
0,
|
|
"the downloader sends no bytes after its offset"
|
|
);
|
|
}
|
|
|
|
fn binary_payload(len: usize) -> Vec<u8> {
|
|
(0..len).map(|index| index as u8).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn an_actual_short_transfer_resumes_opaque_bytes() {
|
|
let subject = Subject::new("resume_binary_subject");
|
|
let mut control = ControlPeer::register(&subject, "resume_binary_peer");
|
|
let destination = ScratchDir::new("binary-tracer");
|
|
let content = binary_payload(3 * 64 * 1024 + 257);
|
|
let filename = "opaque.bin";
|
|
let interrupt_at = 64 * 1024 + 1;
|
|
let part_path = destination.path().join(format!("{filename}.part"));
|
|
let final_path = destination.path().join(filename);
|
|
|
|
assert!(content.contains(&0));
|
|
assert!(content.contains(&u8::MAX));
|
|
assert!(std::str::from_utf8(&content).is_err());
|
|
|
|
let (first_statuses, mut first, first_offset) = start_download(
|
|
&subject,
|
|
&mut control,
|
|
filename,
|
|
content.len(),
|
|
destination.path(),
|
|
);
|
|
assert_eq!(first_offset, 0);
|
|
first
|
|
.write_all(&content[..interrupt_at])
|
|
.expect("write short first transfer");
|
|
first.flush().expect("flush short first transfer");
|
|
first
|
|
.shutdown(Shutdown::Write)
|
|
.expect("interrupt first transfer");
|
|
drop(first);
|
|
|
|
let _ = wait_for_status(&first_statuses, "Failed", |status| {
|
|
matches!(status, DownloadStatus::Failed(_))
|
|
});
|
|
assert_stored_status(&subject, filename, |status| {
|
|
matches!(status, DownloadStatus::Failed(_))
|
|
});
|
|
assert!(!final_path.exists());
|
|
assert_eq!(
|
|
fs::read(&part_path).expect("read retained partial file"),
|
|
content[..interrupt_at]
|
|
);
|
|
|
|
let (retry_statuses, mut retry, retry_offset) = start_download(
|
|
&subject,
|
|
&mut control,
|
|
filename,
|
|
content.len(),
|
|
destination.path(),
|
|
);
|
|
assert_eq!(retry_offset, interrupt_at as u64);
|
|
retry
|
|
.write_all(&content[interrupt_at..])
|
|
.expect("write retry tail");
|
|
retry.flush().expect("flush retry tail");
|
|
expect_downloader_close(&mut retry);
|
|
drop(retry);
|
|
|
|
let _ = wait_for_status(&retry_statuses, "Completed", |status| {
|
|
matches!(status, DownloadStatus::Completed)
|
|
});
|
|
assert_stored_status(&subject, filename, |status| {
|
|
matches!(status, DownloadStatus::Completed)
|
|
});
|
|
assert_eq!(fs::read(&final_path).unwrap(), content);
|
|
assert!(!part_path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn a_live_download_pauses_and_resumes_on_the_same_connection() {
|
|
let subject = Subject::new("resume_pause_subject");
|
|
let mut control = ControlPeer::register(&subject, "resume_pause_peer");
|
|
let destination = ScratchDir::new("pause-resume");
|
|
let filename = "paused.bin";
|
|
let content = binary_payload(5 * 64 * 1024 + 257);
|
|
let part_path = destination.path().join(format!("{filename}.part"));
|
|
|
|
let (statuses, mut peer, offset) = start_download(
|
|
&subject,
|
|
&mut control,
|
|
filename,
|
|
content.len(),
|
|
destination.path(),
|
|
);
|
|
assert_eq!(offset, 0);
|
|
|
|
let first_chunk = 2 * 64 * 1024;
|
|
peer.write_all(&content[..first_chunk])
|
|
.expect("send first chunk");
|
|
peer.flush().expect("flush first chunk");
|
|
let _ = wait_for_status(&statuses, "initial progress", |status| {
|
|
matches!(
|
|
status,
|
|
DownloadStatus::InProgress {
|
|
bytes_downloaded,
|
|
..
|
|
} if *bytes_downloaded >= first_chunk as u64
|
|
)
|
|
});
|
|
|
|
assert!(subject.client.pause_download(&control.username, filename));
|
|
let _ = wait_for_status(&statuses, "Paused", |status| {
|
|
matches!(status, DownloadStatus::Paused { .. })
|
|
});
|
|
|
|
// Give a read already in flight one timeout cycle to observe the pause.
|
|
std::thread::sleep(Duration::from_millis(1_200));
|
|
let stable_len = fs::metadata(&part_path).expect("paused partial").len();
|
|
let queued_while_paused = 8 * 1024;
|
|
peer.write_all(&content[first_chunk..first_chunk + queued_while_paused])
|
|
.expect("queue bytes while paused");
|
|
peer.flush().expect("flush bytes while paused");
|
|
std::thread::sleep(Duration::from_millis(300));
|
|
assert_eq!(
|
|
fs::metadata(&part_path).expect("paused partial").len(),
|
|
stable_len,
|
|
"a paused transfer must stop consuming its socket"
|
|
);
|
|
|
|
assert!(subject.client.resume_download(&control.username, filename));
|
|
peer.write_all(&content[first_chunk + queued_while_paused..])
|
|
.expect("send tail after resume");
|
|
peer.flush().expect("flush resumed tail");
|
|
expect_downloader_close(&mut peer);
|
|
|
|
let _ = wait_for_status(&statuses, "Completed", |status| {
|
|
matches!(status, DownloadStatus::Completed)
|
|
});
|
|
assert_eq!(
|
|
fs::read(destination.path().join(filename)).unwrap(),
|
|
content
|
|
);
|
|
}
|