End-to-end unit test (#90)
* First implementation of create_torrent_file * Test harness for e2e preparing * Saving * Continuing test harness * Continuing test harness * Continuing test harness * All servers are running * Full e2e harness done * Test e2e harness working fine * Remove lints * injecting faults * The e2e test runs continuously * e2e test * Add a test for create_torrent * Nothing * Nothing, just tweaking the numberes * Update tokio, remove custom tempfile shim
This commit is contained in:
parent
5d6ecb8065
commit
2778d46bb3
13 changed files with 634 additions and 20 deletions
5
Cargo.lock
generated
5
Cargo.lock
generated
|
|
@ -1292,6 +1292,7 @@ dependencies = [
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"sha1",
|
"sha1",
|
||||||
"size_format",
|
"size_format",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
|
|
@ -2483,9 +2484,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.35.1"
|
version = "1.36.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104"
|
checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
|
||||||
|
|
@ -75,3 +75,4 @@ async-stream = "0.3.5"
|
||||||
futures = {version = "0.3"}
|
futures = {version = "0.3"}
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ fn compute_chunk_status(lengths: &Lengths, needed_pieces: &BF) -> BF {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter_zeros()
|
.iter_zeros()
|
||||||
{
|
{
|
||||||
let offset = piece_index * lengths.default_chunks_per_piece() as usize;
|
let offset = piece_index * lengths.default_max_chunks_per_piece() as usize;
|
||||||
let chunks_per_piece = lengths
|
let chunks_per_piece = lengths
|
||||||
.chunks_per_piece(lengths.validate_piece_index(piece_index as u32).unwrap())
|
.chunks_per_piece(lengths.validate_piece_index(piece_index as u32).unwrap())
|
||||||
as usize;
|
as usize;
|
||||||
|
|
|
||||||
238
crates/librqbit/src/create_torrent_file.rs
Normal file
238
crates/librqbit/src/create_torrent_file.rs
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::io::{BufWriter, Read};
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use bencode::bencode_serialize_to_writer;
|
||||||
|
use buffers::ByteString;
|
||||||
|
use librqbit_core::torrent_metainfo::{TorrentMetaV1File, TorrentMetaV1Info, TorrentMetaV1Owned};
|
||||||
|
use librqbit_core::Id20;
|
||||||
|
use sha1w::{ISha1, Sha1};
|
||||||
|
|
||||||
|
use crate::spawn_utils::BlockingSpawner;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CreateTorrentOptions<'a> {
|
||||||
|
pub name: Option<&'a str>,
|
||||||
|
pub piece_length: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_dir_find_paths(dir: &Path, out: &mut Vec<Cow<'_, Path>>) -> anyhow::Result<()> {
|
||||||
|
let mut stack = vec![Cow::Borrowed(dir)];
|
||||||
|
while let Some(dir) = stack.pop() {
|
||||||
|
let rd = std::fs::read_dir(&dir).with_context(|| format!("error reading {:?}", dir))?;
|
||||||
|
for element in rd {
|
||||||
|
let element =
|
||||||
|
element.with_context(|| format!("error reading DirEntry from {:?}", dir))?;
|
||||||
|
let ft = element.file_type().with_context(|| {
|
||||||
|
format!(
|
||||||
|
"error determining filetype of DirEntry {:?} while reading {:?}",
|
||||||
|
element.file_name(),
|
||||||
|
dir
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let full_path = Cow::Owned(dir.join(element.file_name()));
|
||||||
|
if ft.is_dir() {
|
||||||
|
stack.push(full_path);
|
||||||
|
} else {
|
||||||
|
out.push(full_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_info_hash(t: &TorrentMetaV1Info<ByteString>) -> anyhow::Result<Id20> {
|
||||||
|
struct W {
|
||||||
|
hash: sha1w::Sha1,
|
||||||
|
}
|
||||||
|
impl std::io::Write for W {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
|
self.hash.update(buf);
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut writer = BufWriter::new(W { hash: Sha1::new() });
|
||||||
|
bencode_serialize_to_writer(t, &mut writer)?;
|
||||||
|
let hash = writer
|
||||||
|
.into_inner()
|
||||||
|
.map_err(|_| anyhow::anyhow!("into_inner errored"))?
|
||||||
|
.hash;
|
||||||
|
Ok(Id20::new(hash.finish()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_piece_length(_input_files: &[Cow<'_, Path>]) -> u32 {
|
||||||
|
// TODO: make this smarter or smth
|
||||||
|
2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_torrent_raw<'a>(
|
||||||
|
path: &'a Path,
|
||||||
|
options: CreateTorrentOptions<'a>,
|
||||||
|
) -> anyhow::Result<TorrentMetaV1Info<ByteString>> {
|
||||||
|
path.try_exists()
|
||||||
|
.with_context(|| format!("path {:?} doesn't exist", path))?;
|
||||||
|
let basename = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("cannot determine basename of {:?}", path))?;
|
||||||
|
let is_dir = path.is_dir();
|
||||||
|
let single_file_mode = !is_dir;
|
||||||
|
let name: ByteString = match options.name {
|
||||||
|
Some(name) => name.as_bytes().into(),
|
||||||
|
None => basename.as_bytes().into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut input_files: Vec<Cow<'a, Path>> = Default::default();
|
||||||
|
if is_dir {
|
||||||
|
walk_dir_find_paths(path, &mut input_files)
|
||||||
|
.with_context(|| format!("error walking {:?}", path))?;
|
||||||
|
} else {
|
||||||
|
input_files.push(Cow::Borrowed(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
let piece_length = options
|
||||||
|
.piece_length
|
||||||
|
.unwrap_or_else(|| choose_piece_length(&input_files));
|
||||||
|
|
||||||
|
// Calculate hashes etc.
|
||||||
|
const READ_SIZE: u32 = 8192; // todo: twea
|
||||||
|
let mut read_buf = vec![0; READ_SIZE as usize];
|
||||||
|
|
||||||
|
let mut length = 0;
|
||||||
|
let mut remaining_piece_length = piece_length;
|
||||||
|
let mut piece_checksum = sha1w::Sha1::new();
|
||||||
|
let mut piece_hashes = Vec::<u8>::new();
|
||||||
|
let mut output_files: Vec<TorrentMetaV1File<ByteString>> = Vec::new();
|
||||||
|
|
||||||
|
let spawner = BlockingSpawner::default();
|
||||||
|
|
||||||
|
'outer: for file in input_files {
|
||||||
|
let filename = &*file;
|
||||||
|
length = 0;
|
||||||
|
let mut fd = std::io::BufReader::new(
|
||||||
|
std::fs::File::open(&file).with_context(|| format!("error opening {:?}", filename))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let max_bytes_to_read = remaining_piece_length.min(READ_SIZE) as usize;
|
||||||
|
let size = spawner
|
||||||
|
.spawn_block_in_place(|| fd.read(&mut read_buf[..max_bytes_to_read]))
|
||||||
|
.with_context(|| format!("error reading {:?}", filename))?;
|
||||||
|
|
||||||
|
// EOF: swap file
|
||||||
|
if size == 0 {
|
||||||
|
let filename = filename
|
||||||
|
.strip_prefix(path)
|
||||||
|
.context("internal error, can't strip prefix")?;
|
||||||
|
let path = filename
|
||||||
|
.components()
|
||||||
|
.map(|c| c.as_os_str().as_bytes().into())
|
||||||
|
.collect();
|
||||||
|
output_files.push(TorrentMetaV1File { length, path });
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
length += size as u64;
|
||||||
|
piece_checksum.update(&read_buf[..size]);
|
||||||
|
|
||||||
|
remaining_piece_length -= size as u32;
|
||||||
|
if remaining_piece_length == 0 {
|
||||||
|
remaining_piece_length = piece_length;
|
||||||
|
piece_hashes.extend_from_slice(&piece_checksum.finish());
|
||||||
|
piece_checksum = sha1w::Sha1::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if remaining_piece_length > 0 && length > 0 {
|
||||||
|
piece_hashes.extend_from_slice(&piece_checksum.finish());
|
||||||
|
}
|
||||||
|
Ok(TorrentMetaV1Info {
|
||||||
|
name: Some(name),
|
||||||
|
pieces: piece_hashes.into(),
|
||||||
|
piece_length,
|
||||||
|
length: if single_file_mode { Some(length) } else { None },
|
||||||
|
md5sum: None,
|
||||||
|
files: if single_file_mode {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(output_files)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CreateTorrentResult {
|
||||||
|
meta: TorrentMetaV1Owned,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateTorrentResult {
|
||||||
|
pub fn as_info(&self) -> &TorrentMetaV1Owned {
|
||||||
|
&self.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info_hash(&self) -> Id20 {
|
||||||
|
self.meta.info_hash
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_bytes(&self) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let mut b = Vec::new();
|
||||||
|
bencode_serialize_to_writer(&self.meta, &mut b).context("error serializing torrent")?;
|
||||||
|
Ok(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_torrent<'a>(
|
||||||
|
path: &'a Path,
|
||||||
|
options: CreateTorrentOptions<'a>,
|
||||||
|
) -> anyhow::Result<CreateTorrentResult> {
|
||||||
|
let info = create_torrent_raw(path, options).await?;
|
||||||
|
let info_hash = compute_info_hash(&info).context("error computing info hash")?;
|
||||||
|
Ok(CreateTorrentResult {
|
||||||
|
meta: TorrentMetaV1Owned {
|
||||||
|
announce: b""[..].into(),
|
||||||
|
announce_list: Vec::new(),
|
||||||
|
info,
|
||||||
|
comment: None,
|
||||||
|
created_by: None,
|
||||||
|
encoding: Some(b"utf-8"[..].into()),
|
||||||
|
publisher: None,
|
||||||
|
publisher_url: None,
|
||||||
|
creation_date: None,
|
||||||
|
info_hash,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use buffers::ByteBuf;
|
||||||
|
use librqbit_core::torrent_metainfo::torrent_from_bytes;
|
||||||
|
|
||||||
|
use crate::create_torrent;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_torrent() {
|
||||||
|
use crate::tests::test_util;
|
||||||
|
|
||||||
|
let dir = test_util::create_default_random_dir_with_torrents(
|
||||||
|
3,
|
||||||
|
1000 * 1000,
|
||||||
|
Some("rqbit_test_create_torrent"),
|
||||||
|
);
|
||||||
|
let torrent = create_torrent(dir.path(), Default::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let bytes = torrent.as_bytes().unwrap();
|
||||||
|
|
||||||
|
let deserialized = torrent_from_bytes::<ByteBuf>(&bytes).unwrap();
|
||||||
|
assert_eq!(torrent.info_hash(), deserialized.info_hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
pub mod api;
|
pub mod api;
|
||||||
mod api_error;
|
mod api_error;
|
||||||
mod chunk_tracker;
|
mod chunk_tracker;
|
||||||
|
mod create_torrent_file;
|
||||||
mod dht_utils;
|
mod dht_utils;
|
||||||
mod file_ops;
|
mod file_ops;
|
||||||
pub mod http_api;
|
pub mod http_api;
|
||||||
|
|
@ -40,6 +41,7 @@ mod type_aliases;
|
||||||
|
|
||||||
pub use api::Api;
|
pub use api::Api;
|
||||||
pub use api_error::ApiError;
|
pub use api_error::ApiError;
|
||||||
|
pub use create_torrent_file::{create_torrent, CreateTorrentOptions};
|
||||||
pub use dht;
|
pub use dht;
|
||||||
pub use peer_connection::PeerConnectionOptions;
|
pub use peer_connection::PeerConnectionOptions;
|
||||||
pub use session::{
|
pub use session::{
|
||||||
|
|
@ -55,6 +57,9 @@ pub use librqbit_core::magnet::*;
|
||||||
pub use librqbit_core::peer_id::*;
|
pub use librqbit_core::peer_id::*;
|
||||||
pub use librqbit_core::torrent_metainfo::*;
|
pub use librqbit_core::torrent_metainfo::*;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
/// The cargo version of librqbit.
|
/// The cargo version of librqbit.
|
||||||
pub fn version() -> &'static str {
|
pub fn version() -> &'static str {
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,10 @@ impl<H: PeerConnectionHandler> PeerConnection<H> {
|
||||||
.await
|
.await
|
||||||
.context("error reading handshake")?;
|
.context("error reading handshake")?;
|
||||||
let h_supports_extended = h.supports_extended();
|
let h_supports_extended = h.supports_extended();
|
||||||
trace!("connected: id={:?}", try_decode_peer_id(Id20::new(h.peer_id)));
|
trace!(
|
||||||
|
"connected: id={:?}",
|
||||||
|
try_decode_peer_id(Id20::new(h.peer_id))
|
||||||
|
);
|
||||||
if h.info_hash != self.info_hash.0 {
|
if h.info_hash != self.info_hash.0 {
|
||||||
anyhow::bail!("info hash does not match");
|
anyhow::bail!("info hash does not match");
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +272,22 @@ impl<H: PeerConnectionHandler> PeerConnection<H> {
|
||||||
.and_then(|e| e.ut_metadata())
|
.and_then(|e| e.ut_metadata())
|
||||||
})?,
|
})?,
|
||||||
WriterRequest::ReadChunkRequest(chunk) => {
|
WriterRequest::ReadChunkRequest(chunk) => {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
// This is poor-mans fault injection for running e2e tests.
|
||||||
|
use crate::tests::test_util::TestPeerMetadata;
|
||||||
|
let tpm = TestPeerMetadata::from_peer_id(self.peer_id);
|
||||||
|
use rand::Rng;
|
||||||
|
if rand::thread_rng().gen_bool(tpm.disconnect_probability()) {
|
||||||
|
bail!("disconnecting, to simulate failure in tests");
|
||||||
|
}
|
||||||
|
|
||||||
|
let sleep_ms = (rand::thread_rng().gen::<f64>()
|
||||||
|
* (tpm.max_random_sleep_ms as f64))
|
||||||
|
as u64;
|
||||||
|
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
|
||||||
|
}
|
||||||
|
|
||||||
// this whole section is an optimization
|
// this whole section is an optimization
|
||||||
write_buf.resize(PIECE_MESSAGE_DEFAULT_LEN, 0);
|
write_buf.resize(PIECE_MESSAGE_DEFAULT_LEN, 0);
|
||||||
let preamble_len = serialize_piece_preamble(chunk, &mut write_buf);
|
let preamble_len = serialize_piece_preamble(chunk, &mut write_buf);
|
||||||
|
|
|
||||||
|
|
@ -1136,6 +1136,10 @@ impl Session {
|
||||||
handle.start(peer_rx, false, self.cancellation_token.child_token())?;
|
handle.start(peer_rx, false, self.cancellation_token.child_token())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn tcp_listen_port(&self) -> Option<u16> {
|
||||||
|
self.tcp_listen_port
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ad adapter for converting stats into the format that tracker_comms accepts.
|
// Ad adapter for converting stats into the format that tracker_comms accepts.
|
||||||
|
|
|
||||||
271
crates/librqbit/src/tests/e2e.rs
Normal file
271
crates/librqbit/src/tests/e2e.rs
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
|
net::{Ipv4Addr, SocketAddr},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::bail;
|
||||||
|
use futures::{stream::FuturesUnordered, StreamExt};
|
||||||
|
use rand::Rng;
|
||||||
|
use tokio::{
|
||||||
|
spawn,
|
||||||
|
time::{interval, timeout},
|
||||||
|
};
|
||||||
|
use tracing::{error_span, info, Instrument};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
create_torrent,
|
||||||
|
tests::test_util::{create_default_random_dir_with_torrents, TestPeerMetadata},
|
||||||
|
AddTorrentOptions, AddTorrentResponse, Session, SessionOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 64)]
|
||||||
|
async fn test_e2e() {
|
||||||
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
|
// 1. Create a torrent
|
||||||
|
// Ideally (for a more complicated test) with N files, and at least N pieces that span 2 files.
|
||||||
|
|
||||||
|
let piece_length: u32 = 16384 * 2; // TODO: figure out if this should be multiple of chunk size or not
|
||||||
|
let file_length: usize = 1000 * 1000;
|
||||||
|
let num_files: usize = 64;
|
||||||
|
|
||||||
|
let tempdir =
|
||||||
|
create_default_random_dir_with_torrents(num_files, file_length, Some("rqbit_e2e"));
|
||||||
|
let torrent_file = create_torrent(
|
||||||
|
dbg!(tempdir.path()),
|
||||||
|
crate::CreateTorrentOptions {
|
||||||
|
piece_length: Some(piece_length),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let num_servers = 128;
|
||||||
|
|
||||||
|
let torrent_file_bytes = torrent_file.as_bytes().unwrap();
|
||||||
|
let mut futs = FuturesUnordered::new();
|
||||||
|
|
||||||
|
// 2. Start N servers that are serving that torrent, and return their IP:port combos.
|
||||||
|
// Disable DHT on each.
|
||||||
|
for i in 0u8..num_servers {
|
||||||
|
let torrent_file_bytes = torrent_file_bytes.clone();
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let tempdir = tempdir.path().to_owned();
|
||||||
|
spawn(
|
||||||
|
async move {
|
||||||
|
let peer_id = TestPeerMetadata {
|
||||||
|
server_id: i,
|
||||||
|
max_random_sleep_ms: rand::thread_rng().gen_range(0u8..128),
|
||||||
|
}
|
||||||
|
.as_peer_id();
|
||||||
|
let session = crate::Session::new_with_opts(
|
||||||
|
std::env::temp_dir().join("does_not_exist"),
|
||||||
|
SessionOptions {
|
||||||
|
disable_dht: true,
|
||||||
|
disable_dht_persistence: true,
|
||||||
|
dht_config: None,
|
||||||
|
persistence: false,
|
||||||
|
persistence_filename: None,
|
||||||
|
peer_id: Some(peer_id),
|
||||||
|
peer_opts: None,
|
||||||
|
listen_port_range: Some(15100..17000),
|
||||||
|
enable_upnp_port_forwarding: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("started session");
|
||||||
|
|
||||||
|
let handle = session
|
||||||
|
.add_torrent(
|
||||||
|
crate::AddTorrent::TorrentFileBytes(Cow::Owned(torrent_file_bytes)),
|
||||||
|
Some(AddTorrentOptions {
|
||||||
|
overwrite: true,
|
||||||
|
output_folder: Some(tempdir.to_str().unwrap().to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let h = handle.into_handle().unwrap();
|
||||||
|
let mut interval = interval(Duration::from_millis(100));
|
||||||
|
|
||||||
|
info!("added torrent");
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let is_live = h
|
||||||
|
.with_state(|s| match s {
|
||||||
|
crate::ManagedTorrentState::Initializing(_) => Ok(false),
|
||||||
|
crate::ManagedTorrentState::Live(l) => {
|
||||||
|
if !l.is_finished() {
|
||||||
|
bail!("torrent went live, but expected it to be finished");
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
_ => bail!("broken state"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
if is_live {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("torrent is live");
|
||||||
|
tx.send(SocketAddr::new(
|
||||||
|
std::net::IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
|
||||||
|
session.tcp_listen_port().unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.instrument(error_span!("server", server = i)),
|
||||||
|
);
|
||||||
|
futs.push(timeout(Duration::from_secs(10), rx));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut peers = Vec::new();
|
||||||
|
while let Some(addr) = futs.next().await {
|
||||||
|
peers.push(addr.unwrap().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("started all servers, starting client");
|
||||||
|
|
||||||
|
let client_iters = std::env::var("E2E_CLIENT_ITERS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(1usize);
|
||||||
|
|
||||||
|
// 3. Start a client with the initial peers, and download the file.
|
||||||
|
for _ in 0..client_iters {
|
||||||
|
let outdir = tempfile::TempDir::with_prefix("rqbit_e2e_client").unwrap();
|
||||||
|
let session = Session::new_with_opts(
|
||||||
|
outdir.path().to_owned(),
|
||||||
|
SessionOptions {
|
||||||
|
disable_dht: true,
|
||||||
|
disable_dht_persistence: true,
|
||||||
|
dht_config: None,
|
||||||
|
persistence: false,
|
||||||
|
persistence_filename: None,
|
||||||
|
listen_port_range: None,
|
||||||
|
enable_upnp_port_forwarding: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("started client session");
|
||||||
|
|
||||||
|
let (id, handle) = {
|
||||||
|
let r = session
|
||||||
|
.add_torrent(
|
||||||
|
crate::AddTorrent::TorrentFileBytes(Cow::Owned(torrent_file_bytes.clone())),
|
||||||
|
Some(AddTorrentOptions {
|
||||||
|
initial_peers: Some(peers.clone()),
|
||||||
|
overwrite: false,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match r {
|
||||||
|
AddTorrentResponse::AlreadyManaged(_, _) => todo!(),
|
||||||
|
AddTorrentResponse::ListOnly(_) => todo!(),
|
||||||
|
AddTorrentResponse::Added(id, h) => (id, h),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("added handle");
|
||||||
|
|
||||||
|
{
|
||||||
|
let stats_printer = {
|
||||||
|
let handle = handle.clone();
|
||||||
|
async move {
|
||||||
|
let mut interval = interval(Duration::from_millis(100));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let stats = handle.stats();
|
||||||
|
let live = match &stats.live {
|
||||||
|
Some(live) => live,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let pstats = &live.snapshot.peer_stats;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
progress_percent =
|
||||||
|
format!("{}", stats.progress_percent_human_readable()),
|
||||||
|
peers = format!("{:?}", pstats),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.instrument(error_span!("stats_printer"));
|
||||||
|
|
||||||
|
let timeout = timeout(Duration::from_secs(60), handle.wait_until_completed());
|
||||||
|
|
||||||
|
tokio::pin!(stats_printer);
|
||||||
|
tokio::pin!(timeout);
|
||||||
|
|
||||||
|
let mut stats_finished = false;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
r = &mut timeout => {
|
||||||
|
r.unwrap().unwrap();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ = &mut stats_printer, if !stats_finished => {
|
||||||
|
stats_finished = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("handle is completed");
|
||||||
|
session.delete(id, false).unwrap();
|
||||||
|
|
||||||
|
info!("deleted handle");
|
||||||
|
|
||||||
|
// 4. After downloading, recheck its integrity.
|
||||||
|
let handle = session
|
||||||
|
.add_torrent(
|
||||||
|
crate::AddTorrent::TorrentFileBytes(Cow::Owned(torrent_file_bytes.clone())),
|
||||||
|
Some(AddTorrentOptions {
|
||||||
|
paused: true,
|
||||||
|
overwrite: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_handle()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("re-added handle");
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(10), async {
|
||||||
|
let mut interval = interval(Duration::from_millis(100));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let b = handle
|
||||||
|
.with_state(|s| match s {
|
||||||
|
crate::ManagedTorrentState::Initializing(_) => Ok(false),
|
||||||
|
crate::ManagedTorrentState::Paused(p) => {
|
||||||
|
assert_eq!(p.needed_bytes, 0);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
_ => bail!("bugged state"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
if b {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("all good");
|
||||||
|
}
|
||||||
|
}
|
||||||
2
crates/librqbit/src/tests/mod.rs
Normal file
2
crates/librqbit/src/tests/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
mod e2e;
|
||||||
|
pub mod test_util;
|
||||||
66
crates/librqbit/src/tests/test_util.rs
Normal file
66
crates/librqbit/src/tests/test_util.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
use std::{io::Write, path::Path};
|
||||||
|
|
||||||
|
use librqbit_core::Id20;
|
||||||
|
use rand::RngCore;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
pub fn create_new_file_with_random_content(path: &Path, mut size: usize) {
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(path)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
eprintln!("creating temp file {:?}", path);
|
||||||
|
|
||||||
|
const BUF_SIZE: usize = 8192 * 16;
|
||||||
|
let mut rng = rand::rngs::OsRng;
|
||||||
|
let mut write_buf = [0; BUF_SIZE];
|
||||||
|
while size > 0 {
|
||||||
|
rng.fill_bytes(&mut write_buf[..]);
|
||||||
|
let written = file.write(&write_buf[..size.min(BUF_SIZE)]).unwrap();
|
||||||
|
size -= written;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_default_random_dir_with_torrents(
|
||||||
|
num_files: usize,
|
||||||
|
file_size: usize,
|
||||||
|
tempdir_prefix: Option<&str>,
|
||||||
|
) -> TempDir {
|
||||||
|
let dir = TempDir::with_prefix(tempdir_prefix.unwrap_or("rqbit_test")).unwrap();
|
||||||
|
dbg!(dir.path());
|
||||||
|
for f in 0..num_files {
|
||||||
|
create_new_file_with_random_content(&dir.path().join(&format!("{f}.data")), file_size);
|
||||||
|
}
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TestPeerMetadata {
|
||||||
|
pub server_id: u8,
|
||||||
|
pub max_random_sleep_ms: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestPeerMetadata {
|
||||||
|
pub fn as_peer_id(&self) -> Id20 {
|
||||||
|
let mut peer_id = Id20::default();
|
||||||
|
peer_id.0[0] = self.server_id;
|
||||||
|
peer_id.0[1] = self.max_random_sleep_ms;
|
||||||
|
peer_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_peer_id(peer_id: Id20) -> Self {
|
||||||
|
Self {
|
||||||
|
server_id: peer_id.0[0],
|
||||||
|
max_random_sleep_ms: peer_id.0[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disconnect_probability(&self) -> f64 {
|
||||||
|
if self.server_id % 2 == 0 {
|
||||||
|
return 0.05f64;
|
||||||
|
}
|
||||||
|
0f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,7 @@ pub struct Lengths {
|
||||||
piece_length: u32,
|
piece_length: u32,
|
||||||
last_piece_id: u32,
|
last_piece_id: u32,
|
||||||
last_piece_length: u32,
|
last_piece_length: u32,
|
||||||
chunks_per_piece: u32,
|
max_chunks_per_piece: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
|
@ -84,7 +84,7 @@ impl Lengths {
|
||||||
}
|
}
|
||||||
if chunk_length > piece_length {
|
if chunk_length > piece_length {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"chunk length {} should be smaller than or equal to piece length {}",
|
"chunk length {} should be >= piece length {}",
|
||||||
chunk_length,
|
chunk_length,
|
||||||
piece_length
|
piece_length
|
||||||
);
|
);
|
||||||
|
|
@ -97,7 +97,7 @@ impl Lengths {
|
||||||
chunk_length,
|
chunk_length,
|
||||||
piece_length,
|
piece_length,
|
||||||
total_length,
|
total_length,
|
||||||
chunks_per_piece: ceil_div_u64(piece_length as u64, chunk_length as u64) as u32,
|
max_chunks_per_piece: ceil_div_u64(piece_length as u64, chunk_length as u64) as u32,
|
||||||
last_piece_id: total_pieces - 1,
|
last_piece_id: total_pieces - 1,
|
||||||
last_piece_length: last_element_size_u64(total_length, piece_length as u64) as u32,
|
last_piece_length: last_element_size_u64(total_length, piece_length as u64) as u32,
|
||||||
})
|
})
|
||||||
|
|
@ -123,8 +123,8 @@ impl Lengths {
|
||||||
pub const fn default_chunk_length(&self) -> u32 {
|
pub const fn default_chunk_length(&self) -> u32 {
|
||||||
self.chunk_length
|
self.chunk_length
|
||||||
}
|
}
|
||||||
pub const fn default_chunks_per_piece(&self) -> u32 {
|
pub const fn default_max_chunks_per_piece(&self) -> u32 {
|
||||||
self.chunks_per_piece
|
self.max_chunks_per_piece
|
||||||
}
|
}
|
||||||
pub const fn total_chunks(&self) -> u32 {
|
pub const fn total_chunks(&self) -> u32 {
|
||||||
ceil_div_u64(self.total_length, self.chunk_length as u64) as u32
|
ceil_div_u64(self.total_length, self.chunk_length as u64) as u32
|
||||||
|
|
@ -161,7 +161,7 @@ impl Lengths {
|
||||||
pub fn iter_chunk_infos(&self, index: ValidPieceIndex) -> impl Iterator<Item = ChunkInfo> {
|
pub fn iter_chunk_infos(&self, index: ValidPieceIndex) -> impl Iterator<Item = ChunkInfo> {
|
||||||
let mut remaining = self.piece_length(index);
|
let mut remaining = self.piece_length(index);
|
||||||
let chunk_size = self.chunk_length;
|
let chunk_size = self.chunk_length;
|
||||||
let absolute_offset = index.0 * self.chunks_per_piece;
|
let absolute_offset = index.0 * self.max_chunks_per_piece;
|
||||||
(0u32..).scan(0, move |offset, idx| {
|
(0u32..).scan(0, move |offset, idx| {
|
||||||
if remaining == 0 {
|
if remaining == 0 {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -195,7 +195,7 @@ impl Lengths {
|
||||||
if expected_chunk_size != chunk_size {
|
if expected_chunk_size != chunk_size {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let absolute_index = self.chunks_per_piece * piece_index.get() + index;
|
let absolute_index = self.max_chunks_per_piece * piece_index.get() + index;
|
||||||
Some(ChunkInfo {
|
Some(ChunkInfo {
|
||||||
piece_index,
|
piece_index,
|
||||||
chunk_index: index,
|
chunk_index: index,
|
||||||
|
|
@ -214,7 +214,7 @@ impl Lengths {
|
||||||
self.chunk_info_from_received_data(self.validate_piece_index(index)?, begin, block_len)
|
self.chunk_info_from_received_data(self.validate_piece_index(index)?, begin, block_len)
|
||||||
}
|
}
|
||||||
pub const fn chunk_range(&self, index: ValidPieceIndex) -> std::ops::Range<usize> {
|
pub const fn chunk_range(&self, index: ValidPieceIndex) -> std::ops::Range<usize> {
|
||||||
let start = index.0 * self.chunks_per_piece;
|
let start = index.0 * self.max_chunks_per_piece;
|
||||||
let end = start + self.chunks_per_piece(index);
|
let end = start + self.chunks_per_piece(index);
|
||||||
start as usize..end as usize
|
start as usize..end as usize
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +222,7 @@ impl Lengths {
|
||||||
if index.0 == self.last_piece_id {
|
if index.0 == self.last_piece_id {
|
||||||
return (self.last_piece_length + self.chunk_length - 1) / self.chunk_length;
|
return (self.last_piece_length + self.chunk_length - 1) / self.chunk_length;
|
||||||
}
|
}
|
||||||
self.chunks_per_piece
|
self.max_chunks_per_piece
|
||||||
}
|
}
|
||||||
pub const fn chunk_offset_in_piece(
|
pub const fn chunk_offset_in_piece(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
|
|
@ -27,20 +27,27 @@ pub fn torrent_from_bytes<'de, ByteBuf: Deserialize<'de>>(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A parsed .torrent file.
|
/// A parsed .torrent file.
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct TorrentMetaV1<BufType> {
|
pub struct TorrentMetaV1<BufType> {
|
||||||
pub announce: BufType,
|
pub announce: BufType,
|
||||||
#[serde(rename = "announce-list", default = "Vec::new")]
|
#[serde(
|
||||||
|
rename = "announce-list",
|
||||||
|
default = "Vec::new",
|
||||||
|
skip_serializing_if = "Vec::is_empty"
|
||||||
|
)]
|
||||||
pub announce_list: Vec<Vec<BufType>>,
|
pub announce_list: Vec<Vec<BufType>>,
|
||||||
pub info: TorrentMetaV1Info<BufType>,
|
pub info: TorrentMetaV1Info<BufType>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub comment: Option<BufType>,
|
pub comment: Option<BufType>,
|
||||||
#[serde(rename = "created by")]
|
#[serde(rename = "created by", skip_serializing_if = "Option::is_none")]
|
||||||
pub created_by: Option<BufType>,
|
pub created_by: Option<BufType>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub encoding: Option<BufType>,
|
pub encoding: Option<BufType>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub publisher: Option<BufType>,
|
pub publisher: Option<BufType>,
|
||||||
#[serde(rename = "publisher-url")]
|
#[serde(rename = "publisher-url", skip_serializing_if = "Option::is_none")]
|
||||||
pub publisher_url: Option<BufType>,
|
pub publisher_url: Option<BufType>,
|
||||||
#[serde(rename = "creation date")]
|
#[serde(rename = "creation date", skip_serializing_if = "Option::is_none")]
|
||||||
pub creation_date: Option<usize>,
|
pub creation_date: Option<usize>,
|
||||||
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
|
|
|
||||||
4
desktop/src-tauri/Cargo.lock
generated
4
desktop/src-tauri/Cargo.lock
generated
|
|
@ -3958,9 +3958,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.34.0"
|
version = "1.36.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9"
|
checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue