Merge pull request #86 from ikatson/less-spawns-async-stream
Reduce incremental compile times a bit + small refactorings
This commit is contained in:
commit
cbbbce2ec4
19 changed files with 594 additions and 475 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
|
@ -1257,6 +1257,7 @@ name = "librqbit"
|
||||||
version = "5.5.0"
|
version = "5.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-stream",
|
||||||
"axum 0.7.4",
|
"axum 0.7.4",
|
||||||
"backoff",
|
"backoff",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1277,6 +1278,7 @@ dependencies = [
|
||||||
"librqbit-dht",
|
"librqbit-dht",
|
||||||
"librqbit-peer-protocol",
|
"librqbit-peer-protocol",
|
||||||
"librqbit-sha1-wrapper",
|
"librqbit-sha1-wrapper",
|
||||||
|
"librqbit-tracker-comms",
|
||||||
"librqbit-upnp",
|
"librqbit-upnp",
|
||||||
"openssl",
|
"openssl",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
|
@ -1396,6 +1398,26 @@ dependencies = [
|
||||||
"sha1",
|
"sha1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "librqbit-tracker-comms"
|
||||||
|
version = "1.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-stream",
|
||||||
|
"byteorder",
|
||||||
|
"futures",
|
||||||
|
"librqbit-bencode",
|
||||||
|
"librqbit-buffers",
|
||||||
|
"librqbit-core",
|
||||||
|
"rand",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"urlencoding",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "librqbit-upnp"
|
name = "librqbit-upnp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ members = [
|
||||||
"crates/librqbit_core",
|
"crates/librqbit_core",
|
||||||
"crates/peer_binary_protocol",
|
"crates/peer_binary_protocol",
|
||||||
"crates/dht",
|
"crates/dht",
|
||||||
"crates/upnp"
|
"crates/upnp",
|
||||||
|
"crates/tracker_comms",
|
||||||
]
|
]
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ use anyhow::{bail, Context};
|
||||||
use backoff::{backoff::Backoff, ExponentialBackoffBuilder};
|
use backoff::{backoff::Backoff, ExponentialBackoffBuilder};
|
||||||
use bencode::ByteString;
|
use bencode::ByteString;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use futures::{stream::FuturesUnordered, Stream, StreamExt, TryFutureExt};
|
use futures::{
|
||||||
|
future::BoxFuture, stream::FuturesUnordered, FutureExt, Stream, StreamExt, TryFutureExt,
|
||||||
|
};
|
||||||
|
|
||||||
use leaky_bucket::RateLimiter;
|
use leaky_bucket::RateLimiter;
|
||||||
use librqbit_core::{
|
use librqbit_core::{
|
||||||
|
|
@ -232,6 +234,7 @@ impl Drop for RequestPeersStream {
|
||||||
impl Stream for RequestPeersStream {
|
impl Stream for RequestPeersStream {
|
||||||
type Item = SocketAddr;
|
type Item = SocketAddr;
|
||||||
|
|
||||||
|
#[inline(never)]
|
||||||
fn poll_next(
|
fn poll_next(
|
||||||
mut self: std::pin::Pin<&mut Self>,
|
mut self: std::pin::Pin<&mut Self>,
|
||||||
cx: &mut std::task::Context<'_>,
|
cx: &mut std::task::Context<'_>,
|
||||||
|
|
@ -1144,49 +1147,54 @@ impl DhtState {
|
||||||
&self.cancellation_token
|
&self.cancellation_token
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn with_config(mut config: DhtConfig) -> anyhow::Result<Arc<Self>> {
|
#[inline(never)]
|
||||||
let socket = match config.listen_addr {
|
pub fn with_config(mut config: DhtConfig) -> BoxFuture<'static, anyhow::Result<Arc<Self>>> {
|
||||||
Some(addr) => UdpSocket::bind(addr)
|
async move {
|
||||||
.await
|
let socket = match config.listen_addr {
|
||||||
.with_context(|| format!("error binding socket, address {addr}")),
|
Some(addr) => UdpSocket::bind(addr)
|
||||||
None => UdpSocket::bind("0.0.0.0:0")
|
.await
|
||||||
.await
|
.with_context(|| format!("error binding socket, address {addr}")),
|
||||||
.context("error binding socket, address 0.0.0.0:0"),
|
None => UdpSocket::bind("0.0.0.0:0")
|
||||||
}?;
|
.await
|
||||||
|
.context("error binding socket, address 0.0.0.0:0"),
|
||||||
|
}?;
|
||||||
|
|
||||||
let listen_addr = socket
|
let listen_addr = socket
|
||||||
.local_addr()
|
.local_addr()
|
||||||
.context("cannot determine UDP listen addr")?;
|
.context("cannot determine UDP listen addr")?;
|
||||||
info!("DHT listening on {:?}", listen_addr);
|
info!("DHT listening on {:?}", listen_addr);
|
||||||
|
|
||||||
let peer_id = config.peer_id.unwrap_or_else(generate_peer_id);
|
let peer_id = config.peer_id.unwrap_or_else(generate_peer_id);
|
||||||
info!("starting up DHT with peer id {:?}", peer_id);
|
info!("starting up DHT with peer id {:?}", peer_id);
|
||||||
let bootstrap_addrs = config
|
let bootstrap_addrs = config
|
||||||
.bootstrap_addrs
|
.bootstrap_addrs
|
||||||
.unwrap_or_else(|| crate::DHT_BOOTSTRAP.iter().map(|v| v.to_string()).collect());
|
.unwrap_or_else(|| crate::DHT_BOOTSTRAP.iter().map(|v| v.to_string()).collect());
|
||||||
|
|
||||||
let token = config.cancellation_token.take().unwrap_or_default();
|
let token = config.cancellation_token.take().unwrap_or_default();
|
||||||
|
|
||||||
let (in_tx, in_rx) = unbounded_channel();
|
let (in_tx, in_rx) = unbounded_channel();
|
||||||
let state = Arc::new(Self::new_internal(
|
let state = Arc::new(Self::new_internal(
|
||||||
peer_id,
|
peer_id,
|
||||||
in_tx,
|
in_tx,
|
||||||
config.routing_table,
|
config.routing_table,
|
||||||
listen_addr,
|
listen_addr,
|
||||||
config.peer_store.unwrap_or_else(|| PeerStore::new(peer_id)),
|
config.peer_store.unwrap_or_else(|| PeerStore::new(peer_id)),
|
||||||
token,
|
token,
|
||||||
));
|
));
|
||||||
|
|
||||||
spawn_with_cancel(error_span!("dht"), state.cancellation_token.clone(), {
|
spawn_with_cancel(error_span!("dht"), state.cancellation_token.clone(), {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
async move {
|
async move {
|
||||||
let worker = DhtWorker { socket, dht: state };
|
let worker = DhtWorker { socket, dht: state };
|
||||||
worker.start(in_rx, &bootstrap_addrs).await
|
worker.start(in_rx, &bootstrap_addrs).await
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Ok(state)
|
Ok(state)
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline(never)]
|
||||||
pub fn get_peers(
|
pub fn get_peers(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
info_hash: Id20,
|
info_hash: Id20,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
// TODO: this now stores only the routing table, but we also need AT LEAST the same socket address...
|
// TODO: this now stores only the routing table, but we also need AT LEAST the same socket address...
|
||||||
|
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use futures::FutureExt;
|
||||||
use librqbit_core::directories::get_configuration_directory;
|
use librqbit_core::directories::get_configuration_directory;
|
||||||
use librqbit_core::spawn_utils::spawn_with_cancel;
|
use librqbit_core::spawn_utils::spawn_with_cancel;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -75,94 +77,102 @@ impl PersistentDht {
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(
|
#[inline(never)]
|
||||||
|
pub fn create(
|
||||||
config: Option<PersistentDhtConfig>,
|
config: Option<PersistentDhtConfig>,
|
||||||
cancellation_token: Option<CancellationToken>,
|
cancellation_token: Option<CancellationToken>,
|
||||||
) -> anyhow::Result<Dht> {
|
) -> BoxFuture<'static, anyhow::Result<Dht>> {
|
||||||
let mut config = config.unwrap_or_default();
|
async move {
|
||||||
let config_filename = match config.config_filename.take() {
|
let mut config = config.unwrap_or_default();
|
||||||
Some(config_filename) => config_filename,
|
let config_filename = match config.config_filename.take() {
|
||||||
None => Self::default_persistence_filename()?,
|
Some(config_filename) => config_filename,
|
||||||
};
|
None => Self::default_persistence_filename()?,
|
||||||
|
};
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
filename=?config_filename,
|
filename=?config_filename,
|
||||||
"will store DHT routing table periodically",
|
"will store DHT routing table periodically",
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(parent) = config_filename.parent() {
|
if let Some(parent) = config_filename.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.with_context(|| format!("error creating dir {:?}", &parent))?;
|
.with_context(|| format!("error creating dir {:?}", &parent))?;
|
||||||
}
|
|
||||||
|
|
||||||
let de = match OpenOptions::new().read(true).open(&config_filename) {
|
|
||||||
Ok(dht_json) => {
|
|
||||||
let reader = BufReader::new(dht_json);
|
|
||||||
match serde_json::from_reader::<_, DhtSerialize<RoutingTable, PeerStore>>(reader) {
|
|
||||||
Ok(r) => {
|
|
||||||
info!(filename=?config_filename, "loaded DHT routing table from");
|
|
||||||
Some(r)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
filename=?config_filename,
|
|
||||||
"cannot deserialize routing table: {:#}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => match e.kind() {
|
|
||||||
std::io::ErrorKind::NotFound => None,
|
|
||||||
_ => return Err(e).with_context(|| format!("error reading {config_filename:?}")),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let (listen_addr, routing_table, peer_store) = de
|
|
||||||
.map(|de| (Some(de.addr), Some(de.table), de.peer_store))
|
|
||||||
.unwrap_or((None, None, None));
|
|
||||||
let peer_id = routing_table.as_ref().map(|r| r.id());
|
|
||||||
|
|
||||||
let dht_config = DhtConfig {
|
let de = match OpenOptions::new().read(true).open(&config_filename) {
|
||||||
peer_id,
|
Ok(dht_json) => {
|
||||||
routing_table,
|
let reader = BufReader::new(dht_json);
|
||||||
listen_addr,
|
match serde_json::from_reader::<_, DhtSerialize<RoutingTable, PeerStore>>(
|
||||||
peer_store,
|
reader,
|
||||||
cancellation_token,
|
) {
|
||||||
..Default::default()
|
Ok(r) => {
|
||||||
};
|
info!(filename=?config_filename, "loaded DHT routing table from");
|
||||||
let dht = DhtState::with_config(dht_config).await?;
|
Some(r)
|
||||||
spawn_with_cancel(
|
}
|
||||||
error_span!("dht_persistence"),
|
Err(e) => {
|
||||||
dht.cancellation_token().clone(),
|
warn!(
|
||||||
{
|
filename=?config_filename,
|
||||||
let dht = dht.clone();
|
"cannot deserialize routing table: {:#}",
|
||||||
let dump_interval = config
|
e
|
||||||
.dump_interval
|
);
|
||||||
.unwrap_or_else(|| Duration::from_secs(3));
|
None
|
||||||
async move {
|
|
||||||
let tempfile_name = {
|
|
||||||
let file_name = format!("dht.json.tmp.{}", std::process::id());
|
|
||||||
let mut tmp = config_filename.clone();
|
|
||||||
tmp.set_file_name(file_name);
|
|
||||||
tmp
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
|
||||||
trace!("sleeping for {:?}", &dump_interval);
|
|
||||||
tokio::time::sleep(dump_interval).await;
|
|
||||||
|
|
||||||
match dump_dht(&dht, &config_filename, &tempfile_name) {
|
|
||||||
Ok(_) => trace!(filename=?config_filename, "dumped DHT"),
|
|
||||||
Err(e) => {
|
|
||||||
error!(filename=?config_filename, "error dumping DHT: {:#}", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
Err(e) => match e.kind() {
|
||||||
);
|
std::io::ErrorKind::NotFound => None,
|
||||||
|
_ => {
|
||||||
|
return Err(e).with_context(|| format!("error reading {config_filename:?}"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let (listen_addr, routing_table, peer_store) = de
|
||||||
|
.map(|de| (Some(de.addr), Some(de.table), de.peer_store))
|
||||||
|
.unwrap_or((None, None, None));
|
||||||
|
let peer_id = routing_table.as_ref().map(|r| r.id());
|
||||||
|
|
||||||
Ok(dht)
|
let dht_config = DhtConfig {
|
||||||
|
peer_id,
|
||||||
|
routing_table,
|
||||||
|
listen_addr,
|
||||||
|
peer_store,
|
||||||
|
cancellation_token,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let dht = DhtState::with_config(dht_config).await?;
|
||||||
|
spawn_with_cancel(
|
||||||
|
error_span!("dht_persistence"),
|
||||||
|
dht.cancellation_token().clone(),
|
||||||
|
{
|
||||||
|
let dht = dht.clone();
|
||||||
|
let dump_interval = config
|
||||||
|
.dump_interval
|
||||||
|
.unwrap_or_else(|| Duration::from_secs(3));
|
||||||
|
async move {
|
||||||
|
let tempfile_name = {
|
||||||
|
let file_name = format!("dht.json.tmp.{}", std::process::id());
|
||||||
|
let mut tmp = config_filename.clone();
|
||||||
|
tmp.set_file_name(file_name);
|
||||||
|
tmp
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
trace!("sleeping for {:?}", &dump_interval);
|
||||||
|
tokio::time::sleep(dump_interval).await;
|
||||||
|
|
||||||
|
match dump_dht(&dht, &config_filename, &tempfile_name) {
|
||||||
|
Ok(_) => trace!(filename=?config_filename, "dumped DHT"),
|
||||||
|
Err(e) => {
|
||||||
|
error!(filename=?config_filename, "error dumping DHT: {:#}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(dht)
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ rust-tls = ["reqwest/rustls-tls"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bencode = {path = "../bencode", default-features=false, package="librqbit-bencode", version="2.2.1"}
|
bencode = {path = "../bencode", default-features=false, package="librqbit-bencode", version="2.2.1"}
|
||||||
|
tracker_comms = {path = "../tracker_comms", default-features=false, package="librqbit-tracker-comms", version="1.0.0"}
|
||||||
buffers = {path = "../buffers", package="librqbit-buffers", version = "2.2.1"}
|
buffers = {path = "../buffers", package="librqbit-buffers", version = "2.2.1"}
|
||||||
librqbit-core = {path = "../librqbit_core", version = "3.5.0"}
|
librqbit-core = {path = "../librqbit_core", version = "3.5.0"}
|
||||||
clone_to_owned = {path = "../clone_to_owned", package="librqbit-clone-to-owned", version = "2.2.1"}
|
clone_to_owned = {path = "../clone_to_owned", package="librqbit-clone-to-owned", version = "2.2.1"}
|
||||||
|
|
@ -68,6 +69,7 @@ serde_with = "3.4.0"
|
||||||
tokio-util = "0.7.10"
|
tokio-util = "0.7.10"
|
||||||
bytes = "1.5.0"
|
bytes = "1.5.0"
|
||||||
rlimit = "0.10.1"
|
rlimit = "0.10.1"
|
||||||
|
async-stream = "0.3.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
futures = {version = "0.3"}
|
futures = {version = "0.3"}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ use axum::body::Bytes;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use futures::TryStreamExt;
|
use futures::future::BoxFuture;
|
||||||
|
use futures::{FutureExt, TryStreamExt};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -44,7 +45,8 @@ impl HttpApi {
|
||||||
|
|
||||||
/// Run the HTTP server forever on the given address.
|
/// Run the HTTP server forever on the given address.
|
||||||
/// If read_only is passed, no state-modifying methods will be exposed.
|
/// If read_only is passed, no state-modifying methods will be exposed.
|
||||||
pub async fn make_http_api_and_run(self, addr: SocketAddr) -> anyhow::Result<()> {
|
#[inline(never)]
|
||||||
|
pub fn make_http_api_and_run(self, addr: SocketAddr) -> BoxFuture<'static, anyhow::Result<()>> {
|
||||||
let state = self.inner;
|
let state = self.inner;
|
||||||
|
|
||||||
async fn api_root() -> impl IntoResponse {
|
async fn api_root() -> impl IntoResponse {
|
||||||
|
|
@ -288,11 +290,15 @@ impl HttpApi {
|
||||||
info!(%addr, "starting HTTP server");
|
info!(%addr, "starting HTTP server");
|
||||||
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
let listener = TcpListener::bind(&addr)
|
|
||||||
.await
|
async move {
|
||||||
.with_context(|| format!("error binding to {addr}"))?;
|
let listener = TcpListener::bind(&addr)
|
||||||
axum::serve(listener, app).await?;
|
.await
|
||||||
Ok(())
|
.with_context(|| format!("error binding to {addr}"))?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use futures::{future::BoxFuture, FutureExt};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
@ -59,6 +60,7 @@ async fn json_response<T: serde::de::DeserializeOwned + std::any::Any>(
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpApiClient {
|
impl HttpApiClient {
|
||||||
|
#[inline(never)]
|
||||||
pub fn new(url: &str) -> anyhow::Result<Self> {
|
pub fn new(url: &str) -> anyhow::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
base_url: reqwest::Url::parse(url)?,
|
base_url: reqwest::Url::parse(url)?,
|
||||||
|
|
@ -70,40 +72,47 @@ impl HttpApiClient {
|
||||||
&self.base_url
|
&self.base_url
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn validate_rqbit_server(&self) -> anyhow::Result<()> {
|
#[inline(never)]
|
||||||
let response = self.client.get(self.base_url.clone()).send().await?;
|
pub fn validate_rqbit_server(&self) -> BoxFuture<'_, anyhow::Result<()>> {
|
||||||
let root: ApiRoot = json_response(response).await?;
|
async move {
|
||||||
if root.server == "rqbit" {
|
let response = self.client.get(self.base_url.clone()).send().await?;
|
||||||
return Ok(());
|
let root: ApiRoot = json_response(response).await?;
|
||||||
|
if root.server == "rqbit" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
anyhow::bail!("not an rqbit server at {}", &self.base_url)
|
||||||
}
|
}
|
||||||
anyhow::bail!("not an rqbit server at {}", &self.base_url)
|
.boxed()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_torrent(
|
pub fn add_torrent<'a>(
|
||||||
&self,
|
&'a self,
|
||||||
torrent: AddTorrent<'_>,
|
torrent: AddTorrent<'a>,
|
||||||
opts: Option<AddTorrentOptions>,
|
opts: Option<AddTorrentOptions>,
|
||||||
) -> anyhow::Result<ApiAddTorrentResponse> {
|
) -> BoxFuture<'a, anyhow::Result<ApiAddTorrentResponse>> {
|
||||||
let opts = opts.unwrap_or_default();
|
async move {
|
||||||
let params = TorrentAddQueryParams {
|
let opts = opts.unwrap_or_default();
|
||||||
overwrite: Some(opts.overwrite),
|
let params = TorrentAddQueryParams {
|
||||||
only_files_regex: opts.only_files_regex,
|
overwrite: Some(opts.overwrite),
|
||||||
only_files: None,
|
only_files_regex: opts.only_files_regex,
|
||||||
output_folder: opts.output_folder,
|
only_files: None,
|
||||||
sub_folder: opts.sub_folder,
|
output_folder: opts.output_folder,
|
||||||
list_only: Some(opts.list_only),
|
sub_folder: opts.sub_folder,
|
||||||
..Default::default()
|
list_only: Some(opts.list_only),
|
||||||
};
|
..Default::default()
|
||||||
let qs = serde_urlencoded::to_string(¶ms).unwrap();
|
};
|
||||||
let url = format!("{}torrents?{}", &self.base_url, qs);
|
let qs = serde_urlencoded::to_string(¶ms).unwrap();
|
||||||
let response = check_response(
|
let url = format!("{}torrents?{}", &self.base_url, qs);
|
||||||
self.client
|
let response = check_response(
|
||||||
.post(&url)
|
self.client
|
||||||
.body(torrent.into_bytes())
|
.post(&url)
|
||||||
.send()
|
.body(torrent.into_bytes())
|
||||||
.await?,
|
.send()
|
||||||
)
|
.await?,
|
||||||
.await?;
|
)
|
||||||
json_response(response).await
|
.await?;
|
||||||
|
json_response(response).await
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@ mod session;
|
||||||
mod spawn_utils;
|
mod spawn_utils;
|
||||||
mod torrent_state;
|
mod torrent_state;
|
||||||
pub mod tracing_subscriber_config_utils;
|
pub mod tracing_subscriber_config_utils;
|
||||||
pub mod tracker_comms;
|
|
||||||
pub mod tracker_comms_http;
|
|
||||||
pub mod tracker_comms_udp;
|
|
||||||
mod type_aliases;
|
mod type_aliases;
|
||||||
|
|
||||||
pub use api::Api;
|
pub use api::Api;
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,22 @@ use std::{
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
dht_utils::{read_metainfo_from_peer_receiver, ReadMetainfoResult},
|
||||||
|
peer_connection::PeerConnectionOptions,
|
||||||
|
read_buf::ReadBuf,
|
||||||
|
spawn_utils::BlockingSpawner,
|
||||||
|
torrent_state::{
|
||||||
|
ManagedTorrentBuilder, ManagedTorrentHandle, ManagedTorrentState, TorrentStateLive,
|
||||||
|
},
|
||||||
|
type_aliases::PeerStream,
|
||||||
|
};
|
||||||
use anyhow::{bail, Context};
|
use anyhow::{bail, Context};
|
||||||
use bencode::{bencode_serialize_to_writer, BencodeDeserializer};
|
use bencode::{bencode_serialize_to_writer, BencodeDeserializer};
|
||||||
use buffers::{ByteBuf, ByteBufT, ByteString};
|
use buffers::{ByteBuf, ByteBufT, ByteString};
|
||||||
use clone_to_owned::CloneToOwned;
|
use clone_to_owned::CloneToOwned;
|
||||||
use dht::{Dht, DhtBuilder, DhtConfig, Id20, PersistentDht, PersistentDhtConfig};
|
use dht::{Dht, DhtBuilder, DhtConfig, Id20, PersistentDht, PersistentDhtConfig};
|
||||||
use futures::{stream::FuturesUnordered, TryFutureExt};
|
use futures::{future::BoxFuture, stream::FuturesUnordered, FutureExt, TryFutureExt};
|
||||||
use librqbit_core::{
|
use librqbit_core::{
|
||||||
directories::get_configuration_directory,
|
directories::get_configuration_directory,
|
||||||
magnet::Magnet,
|
magnet::Magnet,
|
||||||
|
|
@ -32,18 +42,7 @@ use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use tokio_util::sync::{CancellationToken, DropGuard};
|
use tokio_util::sync::{CancellationToken, DropGuard};
|
||||||
use tracing::{debug, error, error_span, info, trace, warn, Instrument};
|
use tracing::{debug, error, error_span, info, trace, warn, Instrument};
|
||||||
|
use tracker_comms::TrackerComms;
|
||||||
use crate::{
|
|
||||||
dht_utils::{read_metainfo_from_peer_receiver, ReadMetainfoResult},
|
|
||||||
peer_connection::PeerConnectionOptions,
|
|
||||||
read_buf::ReadBuf,
|
|
||||||
spawn_utils::BlockingSpawner,
|
|
||||||
torrent_state::{
|
|
||||||
ManagedTorrentBuilder, ManagedTorrentHandle, ManagedTorrentState, TorrentStateLive,
|
|
||||||
},
|
|
||||||
tracker_comms::TrackerComms,
|
|
||||||
type_aliases::PeerStream,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const SUPPORTED_SCHEMES: [&str; 3] = ["http:", "https:", "magnet:"];
|
pub const SUPPORTED_SCHEMES: [&str; 3] = ["http:", "https:", "magnet:"];
|
||||||
|
|
||||||
|
|
@ -298,6 +297,7 @@ pub enum AddTorrent<'a> {
|
||||||
|
|
||||||
impl<'a> AddTorrent<'a> {
|
impl<'a> AddTorrent<'a> {
|
||||||
// Don't call this from HTTP API.
|
// Don't call this from HTTP API.
|
||||||
|
#[inline(never)]
|
||||||
pub fn from_cli_argument(path: &'a str) -> anyhow::Result<Self> {
|
pub fn from_cli_argument(path: &'a str) -> anyhow::Result<Self> {
|
||||||
if SUPPORTED_SCHEMES.iter().any(|s| path.starts_with(s)) {
|
if SUPPORTED_SCHEMES.iter().any(|s| path.starts_with(s)) {
|
||||||
return Ok(Self::Url(Cow::Borrowed(path)));
|
return Ok(Self::Url(Cow::Borrowed(path)));
|
||||||
|
|
@ -314,6 +314,7 @@ impl<'a> AddTorrent<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't call this from HTTP API.
|
// Don't call this from HTTP API.
|
||||||
|
#[inline(never)]
|
||||||
pub fn from_local_filename(filename: &str) -> anyhow::Result<Self> {
|
pub fn from_local_filename(filename: &str) -> anyhow::Result<Self> {
|
||||||
let file = read_local_file_including_stdin(filename)
|
let file = read_local_file_including_stdin(filename)
|
||||||
.with_context(|| format!("error reading local file {filename:?}"))?;
|
.with_context(|| format!("error reading local file {filename:?}"))?;
|
||||||
|
|
@ -378,8 +379,9 @@ pub(crate) struct CheckedIncomingConnection {
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
/// Create a new session. The passed in folder will be used as a default unless overriden per torrent.
|
/// Create a new session. The passed in folder will be used as a default unless overriden per torrent.
|
||||||
pub async fn new(output_folder: PathBuf) -> anyhow::Result<Arc<Self>> {
|
#[inline(never)]
|
||||||
Self::new_with_opts(output_folder, SessionOptions::default()).await
|
pub fn new(output_folder: PathBuf) -> BoxFuture<'static, anyhow::Result<Arc<Self>>> {
|
||||||
|
Self::new_with_opts(output_folder, SessionOptions::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_persistence_filename() -> anyhow::Result<PathBuf> {
|
pub fn default_persistence_filename() -> anyhow::Result<PathBuf> {
|
||||||
|
|
@ -392,93 +394,97 @@ impl Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new session with options.
|
/// Create a new session with options.
|
||||||
pub async fn new_with_opts(
|
#[inline(never)]
|
||||||
|
pub fn new_with_opts(
|
||||||
output_folder: PathBuf,
|
output_folder: PathBuf,
|
||||||
mut opts: SessionOptions,
|
mut opts: SessionOptions,
|
||||||
) -> anyhow::Result<Arc<Self>> {
|
) -> BoxFuture<'static, anyhow::Result<Arc<Self>>> {
|
||||||
let peer_id = opts.peer_id.unwrap_or_else(generate_peer_id);
|
async move {
|
||||||
let token = CancellationToken::new();
|
let peer_id = opts.peer_id.unwrap_or_else(generate_peer_id);
|
||||||
|
let token = CancellationToken::new();
|
||||||
|
|
||||||
let (tcp_listener, tcp_listen_port) = if let Some(port_range) = opts.listen_port_range {
|
let (tcp_listener, tcp_listen_port) = if let Some(port_range) = opts.listen_port_range {
|
||||||
let (l, p) = create_tcp_listener(port_range)
|
let (l, p) = create_tcp_listener(port_range)
|
||||||
.await
|
|
||||||
.context("error listening on TCP")?;
|
|
||||||
info!("Listening on 0.0.0.0:{p} for incoming peer connections");
|
|
||||||
(Some(l), Some(p))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let dht = if opts.disable_dht {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let dht = if opts.disable_dht_persistence {
|
|
||||||
DhtBuilder::with_config(DhtConfig {
|
|
||||||
cancellation_token: Some(token.child_token()),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.context("error initializing DHT")?
|
|
||||||
} else {
|
|
||||||
let pdht_config = opts.dht_config.take().unwrap_or_default();
|
|
||||||
PersistentDht::create(Some(pdht_config), Some(token.clone()))
|
|
||||||
.await
|
.await
|
||||||
.context("error initializing persistent DHT")?
|
.context("error listening on TCP")?;
|
||||||
|
info!("Listening on 0.0.0.0:{p} for incoming peer connections");
|
||||||
|
(Some(l), Some(p))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(dht)
|
let dht = if opts.disable_dht {
|
||||||
};
|
None
|
||||||
let peer_opts = opts.peer_opts.unwrap_or_default();
|
} else {
|
||||||
let persistence_filename = match opts.persistence_filename {
|
let dht = if opts.disable_dht_persistence {
|
||||||
Some(filename) => filename,
|
DhtBuilder::with_config(DhtConfig {
|
||||||
None => Self::default_persistence_filename()?,
|
cancellation_token: Some(token.child_token()),
|
||||||
};
|
..Default::default()
|
||||||
let spawner = BlockingSpawner::default();
|
})
|
||||||
|
.await
|
||||||
|
.context("error initializing DHT")?
|
||||||
|
} else {
|
||||||
|
let pdht_config = opts.dht_config.take().unwrap_or_default();
|
||||||
|
PersistentDht::create(Some(pdht_config), Some(token.clone()))
|
||||||
|
.await
|
||||||
|
.context("error initializing persistent DHT")?
|
||||||
|
};
|
||||||
|
|
||||||
let session = Arc::new(Self {
|
Some(dht)
|
||||||
persistence_filename,
|
};
|
||||||
peer_id,
|
let peer_opts = opts.peer_opts.unwrap_or_default();
|
||||||
dht,
|
let persistence_filename = match opts.persistence_filename {
|
||||||
peer_opts,
|
Some(filename) => filename,
|
||||||
spawner,
|
None => Self::default_persistence_filename()?,
|
||||||
output_folder,
|
};
|
||||||
db: RwLock::new(Default::default()),
|
let spawner = BlockingSpawner::default();
|
||||||
_cancellation_token_drop_guard: token.clone().drop_guard(),
|
|
||||||
cancellation_token: token,
|
|
||||||
tcp_listen_port,
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(tcp_listener) = tcp_listener {
|
let session = Arc::new(Self {
|
||||||
session.spawn(
|
persistence_filename,
|
||||||
error_span!("tcp_listen", port = tcp_listen_port),
|
peer_id,
|
||||||
session.clone().task_tcp_listener(tcp_listener),
|
dht,
|
||||||
);
|
peer_opts,
|
||||||
}
|
spawner,
|
||||||
|
output_folder,
|
||||||
|
db: RwLock::new(Default::default()),
|
||||||
|
_cancellation_token_drop_guard: token.clone().drop_guard(),
|
||||||
|
cancellation_token: token,
|
||||||
|
tcp_listen_port,
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(listen_port) = tcp_listen_port {
|
if let Some(tcp_listener) = tcp_listener {
|
||||||
if opts.enable_upnp_port_forwarding {
|
|
||||||
session.spawn(
|
session.spawn(
|
||||||
error_span!("upnp_forward", port = listen_port),
|
error_span!("tcp_listen", port = tcp_listen_port),
|
||||||
session.clone().task_upnp_port_forwarder(listen_port),
|
session.clone().task_tcp_listener(tcp_listener),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if opts.persistence {
|
if let Some(listen_port) = tcp_listen_port {
|
||||||
info!(
|
if opts.enable_upnp_port_forwarding {
|
||||||
"will use {:?} for session persistence",
|
session.spawn(
|
||||||
session.persistence_filename
|
error_span!("upnp_forward", port = listen_port),
|
||||||
);
|
session.clone().task_upnp_port_forwarder(listen_port),
|
||||||
if let Some(parent) = session.persistence_filename.parent() {
|
);
|
||||||
std::fs::create_dir_all(parent).with_context(|| {
|
}
|
||||||
format!("couldn't create directory {:?} for session storage", parent)
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
let persistence_task = session.clone().task_persistence();
|
|
||||||
session.spawn(error_span!("session_persistence"), persistence_task);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(session)
|
if opts.persistence {
|
||||||
|
info!(
|
||||||
|
"will use {:?} for session persistence",
|
||||||
|
session.persistence_filename
|
||||||
|
);
|
||||||
|
if let Some(parent) = session.persistence_filename.parent() {
|
||||||
|
std::fs::create_dir_all(parent).with_context(|| {
|
||||||
|
format!("couldn't create directory {:?} for session storage", parent)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let persistence_task = session.clone().task_persistence();
|
||||||
|
session.spawn(error_span!("session_persistence"), persistence_task);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn task_persistence(self: Arc<Self>) -> anyhow::Result<()> {
|
async fn task_persistence(self: Arc<Self>) -> anyhow::Result<()> {
|
||||||
|
|
@ -739,149 +745,140 @@ impl Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a torrent to the session.
|
/// Add a torrent to the session.
|
||||||
pub async fn add_torrent(
|
#[inline(never)]
|
||||||
&self,
|
pub fn add_torrent<'a>(
|
||||||
add: AddTorrent<'_>,
|
&'a self,
|
||||||
|
add: AddTorrent<'a>,
|
||||||
opts: Option<AddTorrentOptions>,
|
opts: Option<AddTorrentOptions>,
|
||||||
) -> anyhow::Result<AddTorrentResponse> {
|
) -> BoxFuture<'a, anyhow::Result<AddTorrentResponse>> {
|
||||||
// Magnet links are different in that we first need to discover the metadata.
|
async move {
|
||||||
let span = error_span!("add_torrent");
|
// Magnet links are different in that we first need to discover the metadata.
|
||||||
let _ = span.enter();
|
let span = error_span!("add_torrent");
|
||||||
|
let _ = span.enter();
|
||||||
|
|
||||||
let opts = opts.unwrap_or_default();
|
let opts = opts.unwrap_or_default();
|
||||||
|
|
||||||
let announce_port = if opts.list_only {
|
let announce_port = if opts.list_only {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
self.tcp_listen_port
|
self.tcp_listen_port
|
||||||
};
|
};
|
||||||
|
|
||||||
let cancellation_token = self.cancellation_token.child_token();
|
let paused = opts.list_only || opts.paused;
|
||||||
let cancellation_token_drop_guard = cancellation_token.clone().drop_guard();
|
|
||||||
let paused = opts.list_only || opts.paused;
|
|
||||||
|
|
||||||
// The main difference between magnet link and torrent file, is that we need to resolve the magnet link
|
// The main difference between magnet link and torrent file, is that we need to resolve the magnet link
|
||||||
// into a torrent file by connecting to peers that support extended handshakes.
|
// into a torrent file by connecting to peers that support extended handshakes.
|
||||||
// So we must discover at least one peer and connect to it to be able to proceed further.
|
// So we must discover at least one peer and connect to it to be able to proceed further.
|
||||||
|
|
||||||
let (info_hash, info, trackers, peer_rx, initial_peers, cancellation_token) = match add {
|
let (info_hash, info, trackers, peer_rx, initial_peers) = match add {
|
||||||
AddTorrent::Url(magnet) if magnet.starts_with("magnet:") => {
|
AddTorrent::Url(magnet) if magnet.starts_with("magnet:") => {
|
||||||
let magnet =
|
let magnet = Magnet::parse(&magnet)
|
||||||
Magnet::parse(&magnet).context("provided path is not a valid magnet URL")?;
|
.context("provided path is not a valid magnet URL")?;
|
||||||
let info_hash = magnet
|
let info_hash = magnet
|
||||||
.as_id20()
|
.as_id20()
|
||||||
.context("magnet link didn't contain a BTv1 infohash")?;
|
.context("magnet link didn't contain a BTv1 infohash")?;
|
||||||
|
|
||||||
let peer_token = cancellation_token.child_token();
|
let peer_rx = self.make_peer_rx(
|
||||||
let peer_rx = self.make_peer_rx(
|
info_hash,
|
||||||
info_hash,
|
magnet.trackers.clone(),
|
||||||
magnet.trackers.clone(),
|
|
||||||
peer_token.clone(),
|
|
||||||
announce_port,
|
|
||||||
opts.force_tracker_interval,
|
|
||||||
)?;
|
|
||||||
let peer_rx = match peer_rx {
|
|
||||||
Some(peer_rx) => peer_rx,
|
|
||||||
None => bail!("can't find peers: DHT disabled and no trackers in magnet"),
|
|
||||||
};
|
|
||||||
|
|
||||||
debug!(?info_hash, "querying DHT");
|
|
||||||
let (info, peer_rx, initial_peers) = match read_metainfo_from_peer_receiver(
|
|
||||||
self.peer_id,
|
|
||||||
info_hash,
|
|
||||||
opts.initial_peers.clone().unwrap_or_default(),
|
|
||||||
peer_rx,
|
|
||||||
Some(self.merge_peer_opts(opts.peer_opts)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
ReadMetainfoResult::Found { info, rx, seen } => (info, rx, seen),
|
|
||||||
ReadMetainfoResult::ChannelClosed { .. } => {
|
|
||||||
anyhow::bail!("DHT died, no way to discover torrent metainfo")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if paused {
|
|
||||||
peer_token.cancel();
|
|
||||||
}
|
|
||||||
debug!(?info, "received result from DHT");
|
|
||||||
(
|
|
||||||
info_hash,
|
|
||||||
info,
|
|
||||||
magnet.trackers,
|
|
||||||
Some(peer_rx),
|
|
||||||
initial_peers,
|
|
||||||
cancellation_token,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
let torrent = match other {
|
|
||||||
AddTorrent::Url(url)
|
|
||||||
if url.starts_with("http://") || url.starts_with("https://") =>
|
|
||||||
{
|
|
||||||
torrent_from_url(&url).await?
|
|
||||||
}
|
|
||||||
AddTorrent::Url(url) => {
|
|
||||||
bail!(
|
|
||||||
"unsupported URL {:?}. Supporting magnet:, http:, and https",
|
|
||||||
url
|
|
||||||
)
|
|
||||||
}
|
|
||||||
AddTorrent::TorrentFileBytes(bytes) => {
|
|
||||||
torrent_from_bytes(&bytes).context("error decoding torrent")?
|
|
||||||
}
|
|
||||||
AddTorrent::TorrentInfo(t) => *t,
|
|
||||||
};
|
|
||||||
|
|
||||||
let trackers = torrent
|
|
||||||
.iter_announce()
|
|
||||||
.filter_map(|tracker| match std::str::from_utf8(tracker.as_ref()) {
|
|
||||||
Ok(url) => Some(url.to_owned()),
|
|
||||||
Err(_) => {
|
|
||||||
warn!("cannot parse tracker url as utf-8, ignoring");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let peer_rx = if paused {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
self.make_peer_rx(
|
|
||||||
torrent.info_hash,
|
|
||||||
trackers.clone(),
|
|
||||||
cancellation_token.clone(),
|
|
||||||
announce_port,
|
announce_port,
|
||||||
opts.force_tracker_interval,
|
opts.force_tracker_interval,
|
||||||
)?
|
)?;
|
||||||
};
|
let peer_rx = match peer_rx {
|
||||||
|
Some(peer_rx) => peer_rx,
|
||||||
|
None => bail!("can't find peers: DHT disabled and no trackers in magnet"),
|
||||||
|
};
|
||||||
|
|
||||||
(
|
debug!(?info_hash, "querying DHT");
|
||||||
torrent.info_hash,
|
let (info, peer_rx, initial_peers) = match read_metainfo_from_peer_receiver(
|
||||||
torrent.info,
|
self.peer_id,
|
||||||
trackers,
|
info_hash,
|
||||||
peer_rx,
|
opts.initial_peers.clone().unwrap_or_default(),
|
||||||
opts.initial_peers
|
peer_rx,
|
||||||
.clone()
|
Some(self.merge_peer_opts(opts.peer_opts)),
|
||||||
.unwrap_or_default()
|
)
|
||||||
.into_iter()
|
.await
|
||||||
.collect(),
|
{
|
||||||
cancellation_token,
|
ReadMetainfoResult::Found { info, rx, seen } => (info, rx, seen),
|
||||||
)
|
ReadMetainfoResult::ChannelClosed { .. } => {
|
||||||
}
|
anyhow::bail!("DHT died, no way to discover torrent metainfo")
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
debug!(?info, "received result from DHT");
|
||||||
|
(
|
||||||
|
info_hash,
|
||||||
|
info,
|
||||||
|
magnet.trackers,
|
||||||
|
Some(peer_rx),
|
||||||
|
initial_peers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let torrent = match other {
|
||||||
|
AddTorrent::Url(url)
|
||||||
|
if url.starts_with("http://") || url.starts_with("https://") =>
|
||||||
|
{
|
||||||
|
torrent_from_url(&url).await?
|
||||||
|
}
|
||||||
|
AddTorrent::Url(url) => {
|
||||||
|
bail!(
|
||||||
|
"unsupported URL {:?}. Supporting magnet:, http:, and https",
|
||||||
|
url
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AddTorrent::TorrentFileBytes(bytes) => {
|
||||||
|
torrent_from_bytes(&bytes).context("error decoding torrent")?
|
||||||
|
}
|
||||||
|
AddTorrent::TorrentInfo(t) => *t,
|
||||||
|
};
|
||||||
|
|
||||||
cancellation_token_drop_guard.disarm();
|
let trackers = torrent
|
||||||
|
.iter_announce()
|
||||||
|
.filter_map(|tracker| match std::str::from_utf8(tracker.as_ref()) {
|
||||||
|
Ok(url) => Some(url.to_owned()),
|
||||||
|
Err(_) => {
|
||||||
|
warn!("cannot parse tracker url as utf-8, ignoring");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
self.main_torrent_info(
|
let peer_rx = if paused {
|
||||||
info_hash,
|
None
|
||||||
info,
|
} else {
|
||||||
trackers,
|
self.make_peer_rx(
|
||||||
peer_rx,
|
torrent.info_hash,
|
||||||
initial_peers.into_iter().collect(),
|
trackers.clone(),
|
||||||
opts,
|
announce_port,
|
||||||
cancellation_token,
|
opts.force_tracker_interval,
|
||||||
)
|
)?
|
||||||
.await
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
torrent.info_hash,
|
||||||
|
torrent.info,
|
||||||
|
trackers,
|
||||||
|
peer_rx,
|
||||||
|
opts.initial_peers
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.main_torrent_info(
|
||||||
|
info_hash,
|
||||||
|
info,
|
||||||
|
trackers,
|
||||||
|
peer_rx,
|
||||||
|
initial_peers.into_iter().collect(),
|
||||||
|
opts,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|
@ -893,12 +890,9 @@ impl Session {
|
||||||
peer_rx: Option<PeerStream>,
|
peer_rx: Option<PeerStream>,
|
||||||
initial_peers: Vec<SocketAddr>,
|
initial_peers: Vec<SocketAddr>,
|
||||||
opts: AddTorrentOptions,
|
opts: AddTorrentOptions,
|
||||||
cancellation_token: CancellationToken,
|
|
||||||
) -> anyhow::Result<AddTorrentResponse> {
|
) -> anyhow::Result<AddTorrentResponse> {
|
||||||
debug!("Torrent info: {:#?}", &info);
|
debug!("Torrent info: {:#?}", &info);
|
||||||
|
|
||||||
let drop_guard = cancellation_token.clone().drop_guard();
|
|
||||||
|
|
||||||
let get_only_files =
|
let get_only_files =
|
||||||
|only_files: Option<Vec<usize>>, only_files_regex: Option<String>, list_only: bool| {
|
|only_files: Option<Vec<usize>>, only_files_regex: Option<String>, list_only: bool| {
|
||||||
match (only_files, only_files_regex) {
|
match (only_files, only_files_regex) {
|
||||||
|
|
@ -1016,20 +1010,16 @@ impl Session {
|
||||||
let span = managed_torrent.info.span.clone();
|
let span = managed_torrent.info.span.clone();
|
||||||
let _ = span.enter();
|
let _ = span.enter();
|
||||||
|
|
||||||
// Just in case, cancel all tasks started for this torrent so far.
|
|
||||||
// This is defensive, and not proven necessary.
|
|
||||||
let token = if opts.paused {
|
|
||||||
cancellation_token.cancel();
|
|
||||||
self.cancellation_token.child_token()
|
|
||||||
} else {
|
|
||||||
cancellation_token
|
|
||||||
};
|
|
||||||
managed_torrent
|
managed_torrent
|
||||||
.start(initial_peers, peer_rx, opts.paused, token)
|
.start(
|
||||||
|
initial_peers,
|
||||||
|
peer_rx,
|
||||||
|
opts.paused,
|
||||||
|
self.cancellation_token.child_token(),
|
||||||
|
)
|
||||||
.context("error starting torrent")?;
|
.context("error starting torrent")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
drop_guard.disarm();
|
|
||||||
Ok(AddTorrentResponse::Added(id, managed_torrent))
|
Ok(AddTorrentResponse::Added(id, managed_torrent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1080,7 +1070,6 @@ impl Session {
|
||||||
&self,
|
&self,
|
||||||
info_hash: Id20,
|
info_hash: Id20,
|
||||||
trackers: Vec<String>,
|
trackers: Vec<String>,
|
||||||
cancel: CancellationToken,
|
|
||||||
announce_port: Option<u16>,
|
announce_port: Option<u16>,
|
||||||
force_tracker_interval: Option<Duration>,
|
force_tracker_interval: Option<Duration>,
|
||||||
) -> anyhow::Result<Option<PeerStream>> {
|
) -> anyhow::Result<Option<PeerStream>> {
|
||||||
|
|
@ -1097,7 +1086,6 @@ impl Session {
|
||||||
// TODO: report actual bytes, not zeroes.
|
// TODO: report actual bytes, not zeroes.
|
||||||
Box::new(()),
|
Box::new(()),
|
||||||
force_tracker_interval,
|
force_tracker_interval,
|
||||||
cancel,
|
|
||||||
announce_port,
|
announce_port,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1111,15 +1099,18 @@ impl Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unpause(&self, handle: &ManagedTorrentHandle) -> anyhow::Result<()> {
|
pub fn unpause(&self, handle: &ManagedTorrentHandle) -> anyhow::Result<()> {
|
||||||
let token = self.cancellation_token.child_token();
|
|
||||||
let peer_rx = self.make_peer_rx(
|
let peer_rx = self.make_peer_rx(
|
||||||
handle.info_hash(),
|
handle.info_hash(),
|
||||||
handle.info().trackers.clone().into_iter().collect(),
|
handle.info().trackers.clone().into_iter().collect(),
|
||||||
token.clone(),
|
|
||||||
self.tcp_listen_port,
|
self.tcp_listen_port,
|
||||||
handle.info().options.force_tracker_interval,
|
handle.info().options.force_tracker_interval,
|
||||||
)?;
|
)?;
|
||||||
handle.start(Default::default(), peer_rx, false, token)?;
|
handle.start(
|
||||||
|
Default::default(),
|
||||||
|
peer_rx,
|
||||||
|
false,
|
||||||
|
self.cancellation_token.child_token(),
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ use std::time::Duration;
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use buffers::ByteString;
|
use buffers::ByteString;
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use futures::FutureExt;
|
||||||
use librqbit_core::hash_id::Id20;
|
use librqbit_core::hash_id::Id20;
|
||||||
use librqbit_core::lengths::Lengths;
|
use librqbit_core::lengths::Lengths;
|
||||||
use librqbit_core::peer_id::generate_peer_id;
|
use librqbit_core::peer_id::generate_peer_id;
|
||||||
|
|
@ -395,23 +397,29 @@ impl ManagedTorrent {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn wait_until_completed(&self) -> anyhow::Result<()> {
|
#[inline(never)]
|
||||||
// TODO: rewrite, this polling is horrible
|
pub fn wait_until_completed(&self) -> BoxFuture<'_, anyhow::Result<()>> {
|
||||||
let live = loop {
|
async move {
|
||||||
let live = self.with_state(|s| match s {
|
// TODO: rewrite, this polling is horrible
|
||||||
ManagedTorrentState::Initializing(_) | ManagedTorrentState::Paused(_) => Ok(None),
|
let live = loop {
|
||||||
ManagedTorrentState::Live(l) => Ok(Some(l.clone())),
|
let live = self.with_state(|s| match s {
|
||||||
ManagedTorrentState::Error(e) => bail!("{:?}", e),
|
ManagedTorrentState::Initializing(_) | ManagedTorrentState::Paused(_) => {
|
||||||
ManagedTorrentState::None => bail!("bug: torrent state is None"),
|
Ok(None)
|
||||||
})?;
|
}
|
||||||
if let Some(live) = live {
|
ManagedTorrentState::Live(l) => Ok(Some(l.clone())),
|
||||||
break live;
|
ManagedTorrentState::Error(e) => bail!("{:?}", e),
|
||||||
}
|
ManagedTorrentState::None => bail!("bug: torrent state is None"),
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
})?;
|
||||||
};
|
if let Some(live) = live {
|
||||||
|
break live;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
};
|
||||||
|
|
||||||
live.wait_until_completed().await;
|
live.wait_until_completed().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ pub struct InitLoggingResult {
|
||||||
pub line_broadcast: LineBroadcast,
|
pub line_broadcast: LineBroadcast,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline(never)]
|
||||||
pub fn init_logging(opts: InitLoggingOptions) -> anyhow::Result<InitLoggingResult> {
|
pub fn init_logging(opts: InitLoggingOptions) -> anyhow::Result<InitLoggingResult> {
|
||||||
let stderr_filter = EnvFilter::builder()
|
let stderr_filter = EnvFilter::builder()
|
||||||
.with_default_directive(
|
.with_default_directive(
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,4 @@ use futures::Stream;
|
||||||
pub type BF = bitvec::vec::BitVec<u8, bitvec::order::Msb0>;
|
pub type BF = bitvec::vec::BitVec<u8, bitvec::order::Msb0>;
|
||||||
|
|
||||||
pub type PeerHandle = SocketAddr;
|
pub type PeerHandle = SocketAddr;
|
||||||
pub type PeerStream = Box<dyn Stream<Item = SocketAddr> + Unpin + Send + Sync + 'static>;
|
pub type PeerStream = Box<dyn Stream<Item = SocketAddr> + Unpin + Send + 'static>;
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ impl<BufType: AsRef<[u8]>> TorrentMetaV1Info<BufType> {
|
||||||
Some(expected_hash == hash)
|
Some(expected_hash == hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline(never)]
|
||||||
pub fn iter_filenames_and_lengths(
|
pub fn iter_filenames_and_lengths(
|
||||||
&self,
|
&self,
|
||||||
) -> anyhow::Result<impl Iterator<Item = (FileIteratorName<'_, BufType>, u64)>> {
|
) -> anyhow::Result<impl Iterator<Item = (FileIteratorName<'_, BufType>, u64)>> {
|
||||||
|
|
|
||||||
27
crates/tracker_comms/Cargo.toml
Normal file
27
crates/tracker_comms/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[package]
|
||||||
|
name = "librqbit-tracker-comms"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2018"
|
||||||
|
description = "Common interface around various sha1 implementations used in rqbit torrent client."
|
||||||
|
license = "Apache-2.0"
|
||||||
|
documentation = "https://docs.rs/librqbit-tracker-comms"
|
||||||
|
repository = "https://github.com/ikatson/rqbit"
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = "1"
|
||||||
|
anyhow = "1"
|
||||||
|
futures = "0.3"
|
||||||
|
async-stream = "0.3.5"
|
||||||
|
buffers = {path = "../buffers", package="librqbit-buffers", version = "2.2.1"}
|
||||||
|
librqbit-core = {path = "../librqbit_core", version = "3.5.0"}
|
||||||
|
byteorder = "1.5"
|
||||||
|
serde = {version = "1", features=["derive"]}
|
||||||
|
urlencoding = "2"
|
||||||
|
rand = "0.8"
|
||||||
|
tracing = "0.1.40"
|
||||||
|
reqwest = {version="0.11.22", default-features=false, features = ["json"]}
|
||||||
|
bencode = {path = "../bencode", default-features=false, package="librqbit-bencode", version="2.2.1"}
|
||||||
|
url = "2"
|
||||||
Binary file not shown.
5
crates/tracker_comms/src/lib.rs
Normal file
5
crates/tracker_comms/src/lib.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod tracker_comms;
|
||||||
|
mod tracker_comms_http;
|
||||||
|
mod tracker_comms_udp;
|
||||||
|
|
||||||
|
pub use tracker_comms::*;
|
||||||
|
|
@ -4,13 +4,15 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use futures::Stream;
|
use futures::future::Either;
|
||||||
use librqbit_core::spawn_utils::spawn_with_cancel;
|
use futures::stream::BoxStream;
|
||||||
use tokio_util::sync::CancellationToken;
|
use futures::stream::FuturesUnordered;
|
||||||
|
use futures::FutureExt;
|
||||||
|
use futures::StreamExt;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
use tracing::error_span;
|
use tracing::error_span;
|
||||||
use tracing::info;
|
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
|
use tracing::Instrument;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::tracker_comms_http;
|
use crate::tracker_comms_http;
|
||||||
|
|
@ -22,7 +24,6 @@ pub struct TrackerComms {
|
||||||
peer_id: Id20,
|
peer_id: Id20,
|
||||||
stats: Box<dyn TorrentStatsProvider>,
|
stats: Box<dyn TorrentStatsProvider>,
|
||||||
force_tracker_interval: Option<Duration>,
|
force_tracker_interval: Option<Duration>,
|
||||||
cancellation_token: CancellationToken,
|
|
||||||
tx: Sender,
|
tx: Sender,
|
||||||
tcp_listen_port: Option<u16>,
|
tcp_listen_port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +58,11 @@ impl TorrentStatsProvider for () {
|
||||||
|
|
||||||
type Sender = tokio::sync::mpsc::Sender<SocketAddr>;
|
type Sender = tokio::sync::mpsc::Sender<SocketAddr>;
|
||||||
|
|
||||||
|
enum SupportedTracker {
|
||||||
|
Udp(Url),
|
||||||
|
Http(Url),
|
||||||
|
}
|
||||||
|
|
||||||
impl TrackerComms {
|
impl TrackerComms {
|
||||||
pub fn start(
|
pub fn start(
|
||||||
info_hash: Id20,
|
info_hash: Id20,
|
||||||
|
|
@ -64,69 +70,94 @@ impl TrackerComms {
|
||||||
trackers: Vec<String>,
|
trackers: Vec<String>,
|
||||||
stats: Box<dyn TorrentStatsProvider>,
|
stats: Box<dyn TorrentStatsProvider>,
|
||||||
force_interval: Option<Duration>,
|
force_interval: Option<Duration>,
|
||||||
cancellation_token: CancellationToken,
|
|
||||||
tcp_listen_port: Option<u16>,
|
tcp_listen_port: Option<u16>,
|
||||||
) -> Option<impl Stream<Item = SocketAddr> + Send + Sync + Unpin + 'static> {
|
) -> Option<BoxStream<'static, SocketAddr>> {
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel::<SocketAddr>(16);
|
let trackers = trackers
|
||||||
let comms = Arc::new(Self {
|
.into_iter()
|
||||||
info_hash,
|
.filter_map(|t| match Url::parse(&t) {
|
||||||
peer_id,
|
Ok(parsed) => match parsed.scheme() {
|
||||||
stats,
|
"http" | "https" => Some(SupportedTracker::Http(parsed)),
|
||||||
force_tracker_interval: force_interval,
|
"udp" => Some(SupportedTracker::Udp(parsed)),
|
||||||
cancellation_token,
|
_ => {
|
||||||
tx,
|
debug!("unsuppoted tracker URL: {}", t);
|
||||||
tcp_listen_port,
|
None
|
||||||
});
|
}
|
||||||
let mut added = false;
|
},
|
||||||
for tracker in trackers {
|
Err(e) => {
|
||||||
if let Err(e) = comms.clone().add_tracker(&tracker) {
|
debug!("error parsing tracker URL {}: {}", t, e);
|
||||||
info!(tracker = tracker, "error adding tracker: {:#}", e)
|
None
|
||||||
} else {
|
}
|
||||||
added = true;
|
})
|
||||||
}
|
.collect::<Vec<_>>();
|
||||||
}
|
if trackers.is_empty() {
|
||||||
if !added {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(tokio_stream::wrappers::ReceiverStream::new(rx))
|
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<SocketAddr>(16);
|
||||||
|
|
||||||
|
let s = async_stream::stream! {
|
||||||
|
use futures::StreamExt;
|
||||||
|
let comms = Arc::new(Self {
|
||||||
|
info_hash,
|
||||||
|
peer_id,
|
||||||
|
stats,
|
||||||
|
force_tracker_interval: force_interval,
|
||||||
|
tx,
|
||||||
|
tcp_listen_port,
|
||||||
|
});
|
||||||
|
let mut futures = FuturesUnordered::new();
|
||||||
|
for tracker in trackers {
|
||||||
|
futures.push(comms.add_tracker(tracker))
|
||||||
|
}
|
||||||
|
while !(futures.is_empty()) {
|
||||||
|
tokio::select! {
|
||||||
|
addr = rx.recv() => {
|
||||||
|
if let Some(addr) = addr {
|
||||||
|
yield addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e = futures.next(), if !futures.is_empty() => {
|
||||||
|
if let Some(Err(e)) = e {
|
||||||
|
debug!("error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(s.boxed())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_tracker(self: Arc<Self>, tracker: &str) -> anyhow::Result<()> {
|
fn add_tracker(
|
||||||
if tracker.starts_with("http://") || tracker.starts_with("https://") {
|
&self,
|
||||||
spawn_with_cancel(
|
url: SupportedTracker,
|
||||||
error_span!(
|
) -> Either<
|
||||||
|
impl std::future::Future<Output = anyhow::Result<()>> + '_ + Send,
|
||||||
|
impl std::future::Future<Output = anyhow::Result<()>> + '_ + Send,
|
||||||
|
> {
|
||||||
|
let info_hash = self.info_hash;
|
||||||
|
match url {
|
||||||
|
SupportedTracker::Udp(url) => {
|
||||||
|
let span = error_span!(parent: None, "udp_tracker", tracker = %url, info_hash = ?info_hash);
|
||||||
|
self.task_single_tracker_monitor_udp(url)
|
||||||
|
.instrument(span)
|
||||||
|
.right_future()
|
||||||
|
}
|
||||||
|
SupportedTracker::Http(url) => {
|
||||||
|
let span = error_span!(
|
||||||
parent: None,
|
parent: None,
|
||||||
"http_tracker",
|
"http_tracker",
|
||||||
tracker = tracker,
|
tracker = %url,
|
||||||
info_hash = ?self.info_hash
|
info_hash = ?info_hash
|
||||||
),
|
);
|
||||||
self.cancellation_token.clone(),
|
self.task_single_tracker_monitor_http(url)
|
||||||
{
|
.instrument(span)
|
||||||
let comms = self;
|
.left_future()
|
||||||
let url = Url::parse(tracker).context("can't parse URL")?;
|
}
|
||||||
async move { comms.task_single_tracker_monitor_http(url).await }
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if tracker.starts_with("udp://") {
|
|
||||||
spawn_with_cancel(
|
|
||||||
error_span!(parent: None, "udp_tracker", tracker = tracker, info_hash = ?self.info_hash),
|
|
||||||
self.cancellation_token.clone(),
|
|
||||||
{
|
|
||||||
let comms = self;
|
|
||||||
let url = Url::parse(tracker).context("can't parse URL")?;
|
|
||||||
async move { comms.task_single_tracker_monitor_udp(url).await }
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
bail!("unsupported tracker url {}", tracker)
|
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn task_single_tracker_monitor_http(
|
async fn task_single_tracker_monitor_http(&self, mut tracker_url: Url) -> anyhow::Result<()> {
|
||||||
self: Arc<Self>,
|
|
||||||
mut tracker_url: Url,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let mut event = Some(tracker_comms_http::TrackerRequestEvent::Started);
|
let mut event = Some(tracker_comms_http::TrackerRequestEvent::Started);
|
||||||
loop {
|
loop {
|
||||||
let stats = self.stats.get();
|
let stats = self.stats.get();
|
||||||
|
|
@ -12,9 +12,9 @@ const ACTION_ANNOUNCE: u32 = 1;
|
||||||
// const ACTION_ERROR: u32 = 3;
|
// const ACTION_ERROR: u32 = 3;
|
||||||
|
|
||||||
pub const EVENT_NONE: u32 = 0;
|
pub const EVENT_NONE: u32 = 0;
|
||||||
pub const EVENT_COMPLETED: u32 = 1;
|
// pub const EVENT_COMPLETED: u32 = 1;
|
||||||
pub const EVENT_STARTED: u32 = 2;
|
// pub const EVENT_STARTED: u32 = 2;
|
||||||
pub const EVENT_STOPPED: u32 = 3;
|
// pub const EVENT_STOPPED: u32 = 3;
|
||||||
|
|
||||||
pub type ConnectionId = u64;
|
pub type ConnectionId = u64;
|
||||||
const CONNECTION_ID_MAGIC: ConnectionId = 0x41727101980;
|
const CONNECTION_ID_MAGIC: ConnectionId = 0x41727101980;
|
||||||
|
|
@ -293,7 +293,7 @@ mod tests {
|
||||||
Response::Connect(connection_id) => {
|
Response::Connect(connection_id) => {
|
||||||
dbg!(connection_id)
|
dbg!(connection_id)
|
||||||
}
|
}
|
||||||
other => panic!("unexpected response {other:?}"),
|
other => panic!("unexpected response {:?}", other),
|
||||||
};
|
};
|
||||||
|
|
||||||
let hash = Id20::from_str("775459190aa65566591634203f8d9f17d341f969").unwrap();
|
let hash = Id20::from_str("775459190aa65566591634203f8d9f17d341f969").unwrap();
|
||||||
|
|
@ -335,7 +335,7 @@ mod tests {
|
||||||
Response::Announce(r) => {
|
Response::Announce(r) => {
|
||||||
dbg!(r);
|
dbg!(r);
|
||||||
}
|
}
|
||||||
other => panic!("unexpected response {other:?}"),
|
other => panic!("unexpected response {:?}", other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue