diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 904839be..43000bfa 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -33,6 +33,9 @@ jobs: - name: ðŸ”Ļ Build run: cargo build --verbose + - name: ðŸŠķ Build actor-only libtortillas + run: cargo check -p libtortillas --no-default-features + - name: Install latest nextest release uses: taiki-e/install-action@nextest diff --git a/Cargo.lock b/Cargo.lock index 9ff3141b..f11b9a57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1249,6 +1249,7 @@ dependencies = [ "thiserror", "tokio", "tokio-retry2", + "tokio-stream", "tokio-util", "tracing", "tracing-subscriber", @@ -2338,6 +2339,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.16" diff --git a/README.md b/README.md index acd17c9b..f841d6f7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ See our roadmap [here](https://github.com/users/artrixdotdev/projects/6). ### 📈 Future Plans -- Frontend TUI (Text User Interface) +- TUI (Text User Interface) ### ❌ Not Planned - WebTorrent connections: Due to the lack of clear documentation and complex, undocumented protocols WebTorrent support is not currently planned. @@ -70,7 +70,7 @@ Keep tests focused on one behavior, prefer deterministic fixtures with `include_ ## ðŸ“Ķ Installation ### Tortillas -Tortillas is the frontend TUI (Text User Interface) application (what most people want) +Tortillas is the TUI (Text User Interface) application most people will use. There are plans to publish tortillas to registries such as [crates.io](https://crates.io) and [the AUR](https://aur.archlinux.org). However, for now, you can install it from source using cargo: ```bash @@ -81,12 +81,23 @@ This will install `tortillas` to your local Rust toolchain. ### Libtortillas -Libtortillas is the library that powers the frontend TUI application. It is a library that can be used to build your own frontend application or integrate with existing frontend applications. +Libtortillas is the engine behind the TUI. It can also be embedded in other +applications that need BitTorrent downloads, seeding, and observable progress. ```bash cargo add --git https://github.com/artrixdotdev/tortillas libtortillas ``` +The `live` feature is enabled by default and provides views, metrics, event +streams, and listener handles. Applications that only need actor-backed +commands and direct state queries can remove that projection and publication +overhead: + +```toml +[dependencies] +libtortillas = { git = "https://github.com/artrixdotdev/tortillas", default-features = false } +``` + #### Runtime Contract `libtortillas` is a Tokio-first library. Applications that use it must run @@ -96,11 +107,17 @@ For the Tortillas TUI, the binary should own a single application runtime, typically through `#[tokio::main]`, and create `libtortillas::engine::Engine` inside that runtime. UI rendering or terminal input that blocks should run on a dedicated thread or, for bounded work, through Tokio blocking tasks, then send -commands into async engine tasks. Long-lived input loops should use a dedicated +application actions into async engine tasks. Long-lived input loops should use a dedicated thread because `spawn_blocking` tasks cannot be aborted once they start. The library does not currently support swapping in a different async runtime, HTTP client, clock, listener, or storage executor. +With the default `live` feature, use listeners for current state and +incremental updates, and call `Engine` and `Torrent` methods for operations. Do +not poll persistence snapshots to drive a display. See the +[`libtortillas::live` API documentation](https://docs.rs/libtortillas/latest/libtortillas/live/) and the +[`live` example](crates/libtortillas/examples/live.rs). + ## ðŸĪ Contributing We welcome contributions! If you'd like to help improve `tortillas`, please check out our [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines and tips. diff --git a/crates/libtortillas/Cargo.toml b/crates/libtortillas/Cargo.toml index cf48a606..384dbbc1 100644 --- a/crates/libtortillas/Cargo.toml +++ b/crates/libtortillas/Cargo.toml @@ -9,6 +9,9 @@ keywords = ["bittorrent", "p2p", "torrent"] categories = ["network-programming", "concurrency"] readme = "../../README.md" +[features] +default = ["live"] +live = ["dep:tokio-stream"] [dependencies] serde = { workspace = true } @@ -39,8 +42,29 @@ kameo_actors = "^0.5" dashmap = { version = "^6", features = ["serde"] } bon = "^3.9" tokio-util = "^0.7" +tokio-stream = { version = "^0.1", features = ["sync"], optional = true } [dev-dependencies] tracing-test = "0.2.6" tracing-subscriber = { workspace = true } serde_json = "^1" + +[[example]] +name = "live" +required-features = ["live"] + +[[test]] +name = "dht_network" +required-features = ["live"] + +[[test]] +name = "engine_lifecycle" +required-features = ["live"] + +[[test]] +name = "live" +required-features = ["live"] + +[[test]] +name = "persistence" +required-features = ["live"] diff --git a/crates/libtortillas/examples/live.rs b/crates/libtortillas/examples/live.rs new file mode 100644 index 00000000..675009cb --- /dev/null +++ b/crates/libtortillas/examples/live.rs @@ -0,0 +1,85 @@ +use std::path::PathBuf; + +use libtortillas::prelude::{ + Engine, EngineEventKind, EventStreamError, TorrentEventKind, TorrentSource, TorrentState, +}; +use tracing::{error, info, warn}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter("live=trace,off") + .init(); + let mut args = std::env::args_os().skip(1).map(PathBuf::from); + let Some(torrent_path) = args.next() else { + error!("pass a .torrent file path and optional session path to run the example"); + return Ok(()); + }; + + println!("Torrent path: {:?}", torrent_path); + let session_path = args.next(); + + let engine = Engine::default(); + let mut listener = engine.listener(); + let event_task = tokio::spawn(async move { + loop { + match listener.recv().await { + Ok(event) => { + let view = listener.view(); + info!( + sequence = event.sequence, + torrent_count = view.torrent_count(), + ?event.kind, + "received an engine event" + ); + if matches!(event.kind, EngineEventKind::Shutdown(_)) { + break; + } + } + Err(EventStreamError::Lagged(events)) => { + let view = listener.view(); + warn!( + events, + torrent_count = view.torrent_count(), + "refreshing current state after lag" + ); + } + Err(EventStreamError::Closed) => { + info!("engine event stream closed"); + break; + } + } + } + }); + + let torrent = engine + .add_torrent(TorrentSource::torrent_file_path(torrent_path)) + .await?; + + let mut torrent_listener = torrent.listener(); + torrent.pause().await?; + let paused = loop { + let event = torrent_listener.recv().await?; + if matches!( + event.kind, + TorrentEventKind::StateChanged { + current: TorrentState::Paused, + .. + } + ) { + break event; + } + }; + info!(sequence = paused.sequence, ?paused.kind, "torrent paused"); + torrent.start().await?; + + tokio::signal::ctrl_c().await?; + if let Some(path) = session_path { + let snapshot = engine.snapshot().await?; + tokio::fs::write(&path, serde_json::to_vec_pretty(&snapshot)?).await?; + info!(?path, "saved resumable engine state"); + } + engine.shutdown().await?; + event_task.await?; + Ok(()) +} diff --git a/crates/libtortillas/src/ARCHITECTURE.md b/crates/libtortillas/src/ARCHITECTURE.md deleted file mode 100644 index daf5890c..00000000 --- a/crates/libtortillas/src/ARCHITECTURE.md +++ /dev/null @@ -1,81 +0,0 @@ -# libtortillas Architecture - -The library is organized around a small actor hierarchy: - -- `EngineActor` owns global listeners, the shared UDP tracker socket, the torrent registry, and one optional `DhtActor`. -- `DhtActor` owns the engine-wide DHT socket, routing table, transaction matching, announce tokens, and scheduled torrent lookups. -- `TorrentActor` owns per-torrent state and coordinates peers, trackers, piece progress, and exports. -- `PeerActor` owns one peer connection and peer-local protocol state. -- `TrackerActor` owns one tracker announce loop and forwards discovered peers to its torrent supervisor. - -```text -EngineActor -├── DhtActor (one shared instance) -└── TorrentActor (one per torrent) - ├── TrackerActor (one per tracker) - └── PeerActor (one per connected peer) - -DhtActor ── discovered peers ──> TorrentActor -TrackerActor ── discovered peers ──> TorrentActor -``` - -Module facades should export stable public types while keeping actor internals private to the crate. -Domain types such as torrent state, storage strategy, exported snapshots, tracker model types, and tracker stats live outside actor files so actors can focus on orchestration. - -## Runtime Boundary - -`libtortillas` is intentionally tied to Tokio. The crate uses Tokio for actor -task execution, TCP and UDP sockets, timers, cancellation, channels, and -filesystem work. HTTP fetching is also part of the library runtime path through -`reqwest`. - -Frontend applications should treat Tokio as the runtime boundary. A frontend, -including the planned Tortillas TUI, should create one Tokio runtime at process -startup and run `Engine` plus all torrent handle operations on that runtime. If -the UI layer has blocking terminal rendering or input loops, those should be -isolated from async torrent work with channels and a dedicated UI thread. -`tokio::task::spawn_blocking` is suitable for bounded blocking operations, but -not a long-lived input loop: a blocking task cannot be aborted after it starts -and can delay runtime shutdown. - -Runtime independence is not a current API promise. The public facade should not -claim support for custom async runtimes, injected HTTP clients, injected clocks, -custom network listeners, or non-Tokio storage executors unless those extension -points are added explicitly. - -## DHT Peer Discovery - -`EngineActor` supervises a single `DhtActor` because [BEP 5] defines a DHT node -as a client-wide UDP service, rather than one service per torrent. When a public -torrent is added, the engine registers its info hash and `TorrentActor` with the -DHT actor. Private torrents are not registered because [BEP 27] limits their -peer discovery to declared trackers. - -The DHT actor bootstraps its routing table, performs iterative `get_peers` -lookups, and forwards results to the torrent through the same `Announce` event -used by tracker actors. Its `AnnounceFrom` value retains whether peers came -from DHT or a specific tracker. This keeps connection filtering, deduplication, -and `PeerActor` creation in `TorrentActor` regardless of where an endpoint was -discovered. Valid lookup tokens are used to announce the engine's peer port -back to the closest DHT nodes. - -[BEP 5]: https://www.bittorrent.org/beps/bep_0005.html -[BEP 27]: https://www.bittorrent.org/beps/bep_0027.html - -## Torrent Lifecycle - -`TorrentState` is the frontend-facing lifecycle contract exported in torrent snapshots. -New torrents start as `Added` when metadata is already available, or `ResolvingMetadata` when a source such as a magnet URI still needs an info dict. -Once metadata and the configured peer threshold are available, a torrent becomes `Ready` if autostart is disabled, or moves directly into `Downloading` when autostart/manual start begins transfer. - -Completed downloads transition to `Seeding`. -`Paused` is distinct from `Ready` and is not eligible for autostart, so frontends can intentionally hold a torrent without it being treated as merely inactive. -Shutdown and failure paths report `Stopping`, `Stopped`, or `Failed` instead of collapsing those cases into the same state as a paused or newly added torrent. - -## Choking - -`TorrentActor` owns the BEP 3 choking scheduler for its swarm. Active torrents run a rechoke round every 10 seconds, collect peer-local transfer stats from `PeerActor`, and keep at most four interested peers unchoked. - -While downloading, regular upload slots are assigned to interested peers with the highest recent download rate. While seeding, regular slots are assigned by recent upload rate. When more interested peers exist than upload slots, one slot is reserved for an optimistic unchoke and rotates every third rechoke round. - -`PeerActor` remains responsible for wire-level enforcement: it sends `Choke` and `Unchoke` messages when the torrent scheduler changes state, ignores piece requests from choked peers, and records uploaded bytes when serving piece data. diff --git a/crates/libtortillas/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index ac0184cc..af6772dc 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -14,6 +14,8 @@ use tokio::net::TcpListener; use tracing::{Span, error, instrument}; use super::commands; +#[cfg(feature = "live")] +use crate::live::{Hub, LiveHealthLevel}; use crate::{ dht::{DhtActor, DhtActorArgs}, errors::EngineError, @@ -30,6 +32,9 @@ use crate::{ /// also implements the [Actor] trait, and consequently behaves like an /// actor. pub struct EngineActor { + /// Live projection coordinator shared with managed torrents. + #[cfg(feature = "live")] + pub(super) hub: Hub, /// Engine-wide DHT service shared by every torrent. pub(super) dht: Option>, /// Listener to wait for incoming TCP connections from peers @@ -102,6 +107,10 @@ pub struct EngineActorArgs { /// /// If not provided, torrents will use their own default paths. pub default_base_path: Option, + + /// Projection hub shared by the engine handle and actor hierarchy. + #[cfg(feature = "live")] + pub(crate) hub: Hub, } impl Actor for EngineActor { @@ -130,23 +139,33 @@ impl Actor for EngineActor { piece_storage_strategy, settings, default_base_path, + #[cfg(feature = "live")] + hub, } = args; let tcp_addr = tcp_addr.unwrap_or(settings.engine.tcp_addr); let utp_addr = utp_addr.unwrap_or(settings.engine.utp_addr); let udp_addr = udp_addr.unwrap_or(settings.engine.udp_addr); - let tcp_socket = TcpListener::bind(tcp_addr) - .await - .map_err(|e| EngineError::NetworkSetupFailed(format!("tcp bind {tcp_addr}: {e}")))?; - let utp_socket = UtpSocketUdp::new_udp(utp_addr) - .await - .map_err(|e| EngineError::NetworkSetupFailed(format!("utp bind {utp_addr}: {e}")))?; + let tcp_socket = TcpListener::bind(tcp_addr).await.map_err(|error| { + let error = EngineError::NetworkSetupFailed(format!("tcp bind {tcp_addr}: {error}")); + crate::live_only!(hub.engine_start_failed(error.to_string())); + error + })?; + let utp_socket = UtpSocketUdp::new_udp(utp_addr).await.map_err(|error| { + let error = EngineError::NetworkSetupFailed(format!("utp bind {utp_addr}: {error}")); + crate::live_only!(hub.engine_start_failed(error.to_string())); + error + })?; let udp_server = UdpServer::new_with_receive_buffer_size( Some(udp_addr), settings.tracker.udp_receive_buffer_size, ) .await - .map_err(|e| EngineError::NetworkSetupFailed(format!("udp bind {udp_addr}: {e}")))?; + .map_err(|error| { + let error = EngineError::NetworkSetupFailed(format!("udp bind {udp_addr}: {error}")); + crate::live_only!(hub.engine_start_failed(error.to_string())); + error + })?; let peer_id = peer_id.unwrap_or_default(); let dht = if settings.dht.enabled { @@ -167,7 +186,11 @@ impl Actor for EngineActor { None }; + crate::live_only!(hub.engine_started()); + Ok(Self { + #[cfg(feature = "live")] + hub, dht, tcp_socket, utp_socket, @@ -186,6 +209,11 @@ impl Actor for EngineActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); + crate::live_only!(self.hub.emit_health( + None, + LiveHealthLevel::Error, + "an engine service stopped unexpectedly", + )); Ok(ControlFlow::Continue(())) } @@ -215,6 +243,11 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); + crate::live_only!(self.hub.emit_health( + None, + LiveHealthLevel::Warning, + "the TCP peer listener rejected an incoming connection", + )); None } }, @@ -238,6 +271,11 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); + crate::live_only!(self.hub.emit_health( + None, + LiveHealthLevel::Warning, + "the uTP peer listener rejected an incoming connection", + )); None } }, @@ -247,6 +285,7 @@ impl Actor for EngineActor { async fn on_stop( &mut self, _: WeakActorRef, _: ActorStopReason, ) -> Result<(), Self::Error> { + crate::live_only!(self.hub.engine_stopping()); let torrents = self .torrents .iter() @@ -259,6 +298,7 @@ impl Actor for EngineActor { } torrent.wait_for_shutdown().await; self.torrents.remove(&info_hash); + crate::live_only!(self.hub.remove_torrent_scope(info_hash)); } if let Some(dht) = self.dht.take() { @@ -266,6 +306,8 @@ impl Actor for EngineActor { dht.wait_for_shutdown().await; } + crate::live_only!(self.hub.engine_stopped()); + Ok(()) } } diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index bfd58e4c..214aa976 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -3,22 +3,57 @@ use kameo::{actor::Spawn, mailbox, messages, prelude::ActorRef, supervision::Res use tokio::time::timeout; use tracing::{error, warn}; -use super::{EngineActor, EngineSnapshot, EngineStatus}; +use super::{ENGINE_SNAPSHOT_VERSION, EngineActor, EngineSnapshot}; +#[cfg(feature = "live")] +use crate::torrent::Torrent; use crate::{ dht::messages::commands::{RegisterTorrent, UnregisterTorrent}, - errors::EngineError, + errors::{EngineError, map_torrent_send_error}, hashes::InfoHash, metainfo::MetaInfo, peer::Peer, protocol::stream::{PeerStream, validate_handshake_protocol}, - torrent::{self, TorrentActor, TorrentActorArgs, TorrentState}, + torrent::{ + self, RestoreVerification, TorrentActor, TorrentActorArgs, TorrentSnapshot, TorrentState, + ValidatedTorrentSnapshot, + }, }; -pub(crate) mod commands { - use anyhow::anyhow; +#[derive(Debug)] +pub(crate) enum CreateTorrentRequest { + New(Box), + Restore { + snapshot: RestoreSnapshotInput, + verification: RestoreVerification, + }, +} + +#[derive(Debug)] +pub(crate) enum RestoreSnapshotInput { + Unvalidated(Box), + Validated(Box), +} +pub(crate) mod commands { use super::*; + impl EngineActor { + async fn discard_failed_torrent( + &mut self, info_hash: InfoHash, torrent: &ActorRef, + ) { + if self.torrents.remove(&info_hash).is_some() + && let Some(dht) = &self.dht + && let Err(error) = dht.tell(UnregisterTorrent { info_hash }).await + { + warn!(error = %error, %info_hash, "Failed to unregister rejected restored torrent from DHT"); + } + if let Err(error) = torrent.stop_gracefully().await { + warn!(error = %error, %info_hash, "Failed to stop rejected restored torrent"); + } + crate::live_only!(self.hub.remove_torrent_scope(info_hash)); + } + } + #[messages] impl EngineActor { /// Handles an incoming peer connection. The peer has been neither @@ -72,7 +107,7 @@ pub(crate) mod commands { /// Starts all torrents managed by the engine. #[message] - pub(crate) async fn start_all(&self) { + pub(crate) async fn start_all(&self) -> Result<(), EngineError> { for torrent in self.torrents.iter() { if let Err(err) = torrent .tell(torrent::commands::SetState { @@ -83,6 +118,19 @@ pub(crate) mod commands { warn!(error = %err, "Failed to start torrent"); } } + Ok(()) + } + + /// Returns a managed torrent actor for public handle construction. + #[message] + pub(crate) fn get_torrent( + &self, info_hash: InfoHash, + ) -> Result, EngineError> { + self + .torrents + .get(&info_hash) + .map(|torrent| torrent.clone()) + .ok_or(EngineError::TorrentNotFound(info_hash)) } /// Removes a torrent actor from the engine and stops it gracefully. @@ -106,8 +154,41 @@ pub(crate) mod commands { /// Creates a new [`Torrent`](crate::torrent::Torrent) actor. #[message] pub(crate) async fn create_torrent( - &mut self, metainfo: Box, + &mut self, request: CreateTorrentRequest, ) -> Result, EngineError> { + let (metainfo, restore, piece_storage, base_path, resume) = match request { + CreateTorrentRequest::New(metainfo) => ( + metainfo, + None, + self.default_piece_storage_strategy.clone(), + self.default_base_path.clone(), + false, + ), + CreateTorrentRequest::Restore { + snapshot, + verification, + } => { + let snapshot = match snapshot { + RestoreSnapshotInput::Unvalidated(snapshot) => { + ValidatedTorrentSnapshot::try_from(*snapshot)? + } + RestoreSnapshotInput::Validated(snapshot) => *snapshot, + } + .reconcile_storage(verification) + .await?; + let piece_storage = snapshot.snapshot().piece_storage.clone(); + let base_path = snapshot.snapshot().output_path.clone(); + let resume = snapshot.snapshot().state.is_transfer_active(); + let (metainfo, state) = snapshot.into_restore_parts(); + ( + Box::new(metainfo), + Some(state), + piece_storage, + base_path, + resume, + ) + } + }; let info_hash = metainfo.info_hash().map_err(|e| { error!(error = %e, "Failed to unwrap info hash"); EngineError::Other(e) @@ -122,6 +203,7 @@ pub(crate) mod commands { return Err(EngineError::TorrentAlreadyExists(info_hash)); } + let restoring = restore.is_some(); let torrent_ref = TorrentActor::supervise( &self.actor_ref, TorrentActorArgs { @@ -130,11 +212,13 @@ pub(crate) mod commands { utp_server: self.utp_socket.clone(), tracker_server: self.udp_server.clone(), primary_addr: None, - piece_storage: self.default_piece_storage_strategy.clone(), - autostart: None, - sufficient_peers: None, - base_path: self.default_base_path.clone(), + piece_storage, + autostart: restoring.then_some(false), + sufficient_peers: restoring.then_some(usize::MAX), + base_path, settings: self.settings.clone(), + #[cfg(feature = "live")] + hub: self.hub.weak(), }, ) .restart_policy(RestartPolicy::Transient) @@ -154,6 +238,28 @@ pub(crate) mod commands { }) .await; + if let Some(snapshot) = restore { + match torrent_ref + .ask(torrent::commands::RestoreSnapshot { snapshot }) + .await + { + Ok(result) => match result.0 { + Ok(_) => {} + Err(error) => { + self.discard_failed_torrent(info_hash, &torrent_ref).await; + return Err(error.into()); + } + }, + Err(error) => { + self.discard_failed_torrent(info_hash, &torrent_ref).await; + return Err(EngineError::ActorCommunicationFailed { + operation: "restore torrent snapshot", + reason: error.to_string(), + }); + } + } + } + self.torrents.insert(info_hash, torrent_ref.clone()); // BEP 27 requires private torrents to use only their declared trackers: // https://www.bittorrent.org/beps/bep_0027.html @@ -176,10 +282,91 @@ pub(crate) mod commands { } } } + if resume + && let Err(error) = torrent_ref + .ask(torrent::commands::SetState { + state: TorrentState::Downloading, + }) + .await + { + self.discard_failed_torrent(info_hash, &torrent_ref).await; + return Err(EngineError::Torrent(map_torrent_send_error( + "resume restored torrent", + error, + ))); + } + crate::live_only! { + let initial_view = match torrent_ref.ask(torrent::commands::GetLiveView).await { + Ok(view) => *view, + Err(error) => { + self.discard_failed_torrent(info_hash, &torrent_ref).await; + return Err(EngineError::ActorCommunicationFailed { + operation: "initialize torrent live state", + reason: error.to_string(), + }); + } + }; + self.hub.register_torrent_scope(Torrent::new_with_hub( + info_hash, + torrent_ref.clone(), + &self.hub, + Some(initial_view), + )); + } Ok(torrent_ref) } - /// Snapshots the current state of the engine for frontends. + /// Atomically validates and restores an engine snapshot against the + /// authoritative actor state. + #[message] + pub(crate) async fn restore_engine( + &mut self, snapshot: EngineSnapshot, verification: RestoreVerification, + ) -> Result, EngineError> { + snapshot.validate()?; + if !self.torrents.is_empty() { + return Err(EngineError::InvalidSnapshot { + reason: "target engine already manages torrents".to_string(), + }); + } + + let mut restored = Vec::with_capacity(snapshot.torrents.len()); + for torrent in snapshot.torrents { + let info_hash = torrent.info_hash; + let result = self + .create_torrent(CreateTorrentRequest::Restore { + snapshot: RestoreSnapshotInput::Validated(Box::new( + ValidatedTorrentSnapshot::new_validated(torrent), + )), + verification, + }) + .await; + match result { + Ok(_) => restored.push(info_hash), + Err(error) => { + for info_hash in restored.drain(..) { + match self.remove_torrent(info_hash).await { + Ok(torrent) => { + torrent.kill(); + crate::live_only!(self.hub.remove_torrent_scope(info_hash)); + } + Err(remove_error) => { + warn!( + error = %remove_error, + %info_hash, + "Failed to roll back restored torrent" + ); + } + } + } + return Err(error); + } + } + } + + Ok(restored) + } + + /// Captures resumable state for every managed torrent. #[message] pub(crate) async fn snapshot_engine(&self) -> Result { let futures = self @@ -192,18 +379,18 @@ pub(crate) mod commands { .ask(torrent::commands::SnapshotState) .await .map(|snapshot| *snapshot) - .map_err(|err| { - EngineError::Other(anyhow!("failed to get torrent snapshot: {err}")) + .map_err(|error| { + EngineError::Torrent(map_torrent_send_error("snapshot torrent", error)) }) } }) .collect::>(); - let torrents = try_join_all(futures).await?; + let mut torrents = try_join_all(futures).await?; + torrents.sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); Ok(EngineSnapshot { - status: EngineStatus::Running, - torrent_count: u64::try_from(torrents.len()).unwrap_or(u64::MAX), + version: ENGINE_SNAPSHOT_VERSION, torrents, }) } diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 74ef8a34..c9946d4c 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -14,12 +14,26 @@ //! - Each torrent is represented by a [`Torrent`] handle, which can be used to //! interact with the torrent session. //! +//! ## Peer discovery +//! +//! One engine-owned DHT actor serves every public torrent because [BEP 5] +//! defines a DHT node as a client-wide UDP service. Private torrents are not +//! registered with it because [BEP 27] restricts their discovery to declared +//! trackers. +//! +//! DHT and tracker results enter a torrent through the same internal announce +//! event while retaining their discovery source. Connection filtering, +//! deduplication, and peer-actor creation therefore remain owned by the torrent +//! regardless of where an endpoint was discovered. Valid DHT lookup tokens are +//! used to announce the engine's peer port back to the closest nodes. +//! //! ## Runtime //! //! The engine is Tokio-only. Construct and use [`Engine`] from tasks running on -//! a Tokio runtime, such as a TUI binary with `#[tokio::main]`. `Engine` starts -//! actor tasks, binds Tokio TCP and uTP sockets, uses Tokio timers, and -//! performs async filesystem and HTTP work through the same runtime. +//! a Tokio runtime, such as an application binary with `#[tokio::main]`. +//! `Engine` starts actor tasks, binds Tokio TCP and uTP sockets, uses Tokio +//! timers, and performs async filesystem and HTTP work through the same +//! runtime. //! //! ## Example //! @@ -37,9 +51,12 @@ //! .await //! .expect("Failed to add torrent"); //! -//! println!("Started torrenting: {}", torrent.key()); +//! println!("Started torrenting: {}", torrent.info_hash()); //! } //! ``` +//! +//! [BEP 5]: https://www.bittorrent.org/beps/bep_0005.html +//! [BEP 27]: https://www.bittorrent.org/beps/bep_0027.html mod actor; mod messages; @@ -50,21 +67,23 @@ use std::{net::SocketAddr, path::PathBuf}; pub(crate) use actor::*; use bon; -use kameo::{ - actor::{ActorRef, Spawn}, - error::SendError, -}; +use kameo::actor::{ActorRef, Spawn}; pub(crate) use messages::*; pub use source::TorrentSource; -use self::commands::{CreateTorrent, RemoveTorrent, SnapshotEngine, StartAll}; -pub use self::snapshot::{EngineSnapshot, EngineStatus}; +pub use self::snapshot::{ENGINE_SNAPSHOT_VERSION, EngineSnapshot, EngineStatus}; +use self::{ + commands::{CreateTorrent, GetTorrent, RemoveTorrent, RestoreEngine, SnapshotEngine, StartAll}, + messages::{CreateTorrentRequest, RestoreSnapshotInput}, +}; +#[cfg(feature = "live")] +use crate::live::{EngineListener, EngineView, EventSubscription, Hub}; use crate::{ - errors::EngineError, + errors::{EngineError, map_engine_send_error}, hashes::InfoHash, peer::PeerId, settings::Settings, - torrent::{PieceStorageStrategy, Torrent}, + torrent::{PieceStorageStrategy, RestoreVerification, Torrent, TorrentActor, TorrentSnapshot}, }; /// The main entry point for managing torrents. @@ -75,8 +94,8 @@ use crate::{ /// - Managing peer connections and tracker communication /// /// `Engine` must be created and used from a Tokio runtime. Applications should -/// create one runtime at the frontend boundary and run all engine and torrent -/// operations on that runtime. +/// create one runtime at the application boundary and run all engine and +/// torrent operations on that runtime. /// /// Typically, you create a single `Engine` instance per application and attach /// multiple [`Torrent`] instances to it. @@ -108,7 +127,11 @@ use crate::{ /// } /// ``` #[derive(Debug, Clone)] -pub struct Engine(ActorRef); +pub struct Engine { + actor: ActorRef, + #[cfg(feature = "live")] + hub: Hub, +} #[bon::bon] impl Engine { @@ -198,13 +221,15 @@ impl Engine { path } else { std::env::current_dir() - .expect("Failed to get current dir") + .unwrap_or_else(|_| PathBuf::from(".")) .join(path) } } - None => std::env::current_dir().expect("Failed to get current dir"), + None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), }; + #[cfg(feature = "live")] + let hub = Hub::with_settings(settings.live); let args = EngineActorArgs { tcp_addr, utp_addr, @@ -213,23 +238,29 @@ impl Engine { piece_storage_strategy, settings, default_base_path: Some(output_path), + #[cfg(feature = "live")] + hub: hub.clone(), }; let actor = EngineActor::spawn(args); - Engine(actor) + Engine { + actor, + #[cfg(feature = "live")] + hub, + } } /// Just a helper function so we don't have to write `&self.0` all the time. fn actor(&self) -> &ActorRef { - &self.0 + &self.actor } /// Starts the torrenting process for a given torrent. This function /// automatically contacts trackers and connects to peers. The spawned /// [Torrent Actor](Torrent) will be controlled by the [Engine]. /// - /// This function accepts a typed [`TorrentSource`] so frontends can pass + /// This function accepts a typed [`TorrentSource`] so callers can pass /// explicit user intent instead of relying on string-prefix detection. /// /// @@ -248,7 +279,7 @@ impl Engine { /// .await /// .expect("Failed to add torrent"); /// - /// println!("Started torrenting: {}", torrent.key()); + /// println!("Started torrenting: {}", torrent.info_hash()); /// } /// ``` /// @@ -265,7 +296,7 @@ impl Engine { /// .await /// .expect("Failed to add torrent"); /// - /// println!("Started torrenting: {}", torrent.key()); + /// println!("Started torrenting: {}", torrent.info_hash()); /// } /// ``` pub async fn add_torrent(&self, source: TorrentSource) -> Result { @@ -275,67 +306,203 @@ impl Engine { let torrent_ref = self .actor() .ask(CreateTorrent { - metainfo: Box::new(metainfo), + request: CreateTorrentRequest::New(Box::new(metainfo)), }) .await - .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; + .map_err(|error| map_engine_send_error("add torrent", error))?; - Ok(Torrent::new(info_hash, torrent_ref)) + self.torrent_from_actor(info_hash, torrent_ref) // We don't need to assign link or insert the ref here because its already // done by the engine actor } + + /// Restores one torrent from a Serde-compatible persistence snapshot. + /// + /// Torrents that were downloading or seeding when captured resume after + /// their piece state and storage configuration have been restored. + pub async fn restore_torrent(&self, snapshot: TorrentSnapshot) -> Result { + self + .restore_torrent_with_verification(snapshot, RestoreVerification::Full) + .await + } + + /// Restores one torrent using an explicit durable-storage verification + /// policy. + pub async fn restore_torrent_with_verification( + &self, snapshot: TorrentSnapshot, verification: RestoreVerification, + ) -> Result { + let info_hash = snapshot.info_hash; + + let torrent_ref = self + .actor() + .ask(CreateTorrent { + request: CreateTorrentRequest::Restore { + snapshot: RestoreSnapshotInput::Unvalidated(Box::new(snapshot)), + verification, + }, + }) + .await + .map_err(|error| map_engine_send_error("restore torrent", error))?; + + self.torrent_from_actor(info_hash, torrent_ref) + } + + /// Restores all torrent sessions from an engine persistence snapshot. + /// + /// The target engine must be empty. If any torrent fails to restore, this + /// method removes the torrents already restored by this call before + /// returning the error. + pub async fn restore(&self, snapshot: EngineSnapshot) -> Result, EngineError> { + self + .restore_with_verification(snapshot, RestoreVerification::Full) + .await + } + + /// Restores an engine snapshot with an explicit storage verification + /// policy applied to every torrent. + pub async fn restore_with_verification( + &self, snapshot: EngineSnapshot, verification: RestoreVerification, + ) -> Result, EngineError> { + let info_hashes = self + .actor() + .ask(RestoreEngine { + snapshot, + verification, + }) + .await + .map_err(|error| map_engine_send_error("restore engine", error))?; + let mut torrents = Vec::with_capacity(info_hashes.len()); + for info_hash in info_hashes { + torrents.push(self.restored_torrent(info_hash).await?); + } + Ok(torrents) + } /// Starts all torrents managed by the engine. /// See [`Torrent::start`] for more information. pub async fn start_all(&self) -> Result<(), EngineError> { self .actor() - .tell(StartAll) + .ask(StartAll) .await - .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; + .map_err(|error| map_engine_send_error("start all torrents", error))?; Ok(()) } + /// Returns a public handle for a torrent managed by this engine. + pub async fn torrent(&self, info_hash: InfoHash) -> Result { + let torrent_ref = self + .actor() + .ask(GetTorrent { info_hash }) + .await + .map_err(|error| map_engine_send_error("get torrent", error))?; + + self.torrent_from_actor(info_hash, torrent_ref) + } + /// Removes a torrent from the engine and stops its actor gracefully. pub async fn remove_torrent(&self, info_hash: InfoHash) -> Result<(), EngineError> { - let torrent = match self.actor().ask(RemoveTorrent { info_hash }).await { - Ok(torrent) => torrent, - Err(SendError::HandlerError(err)) => return Err(err), - Err(err) => return Err(EngineError::Other(anyhow::anyhow!(err.to_string()))), - }; - - torrent - .stop_gracefully() + let torrent = self + .actor() + .ask(RemoveTorrent { info_hash }) .await - .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; - torrent.wait_for_shutdown().await; + .map_err(|error| map_engine_send_error("remove torrent", error))?; - Ok(()) + let stop_result = torrent.stop_gracefully().await; + torrent.wait_for_shutdown().await; + crate::live_only!(self.hub.remove_torrent_scope(info_hash)); + stop_result.map_err(|error| EngineError::ActorCommunicationFailed { + operation: "stop torrent", + reason: error.to_string(), + }) } /// Gracefully shuts down the engine and its managed torrent actors. pub async fn shutdown(&self) -> Result<(), EngineError> { - self - .actor() - .stop_gracefully() - .await - .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; + self.actor().stop_gracefully().await.map_err(|error| { + EngineError::ActorCommunicationFailed { + operation: "shut down engine", + reason: error.to_string(), + } + })?; self.actor().wait_for_shutdown().await; Ok(()) } - /// Exports the current engine state with frontend-ready torrent snapshots. - pub async fn export(&self) -> Result { - self.snapshot().await - } - - /// Snapshots the current engine state with frontend-ready torrent views. + /// Captures all managed torrent sessions in a Serde-compatible persistence + /// snapshot. + /// + /// With the `live` feature, use the engine listener for current state and + /// incremental updates. + /// Snapshot frequency is an application persistence decision, not a + /// live-update mechanism. pub async fn snapshot(&self) -> Result { self .actor() .ask(SnapshotEngine) .await - .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string()))) + .map_err(|error| map_engine_send_error("snapshot engine", error)) + } +} + +#[cfg(not(feature = "live"))] +impl Engine { + fn torrent_from_actor( + &self, info_hash: InfoHash, actor: ActorRef, + ) -> Result { + Ok(Torrent::new(info_hash, actor)) + } + + async fn restored_torrent(&self, info_hash: InfoHash) -> Result { + let actor = self + .actor() + .ask(GetTorrent { info_hash }) + .await + .map_err(|error| map_engine_send_error("get restored torrent", error))?; + self.torrent_from_actor(info_hash, actor) + } +} + +#[cfg(feature = "live")] +impl Engine { + /// Subscribes to typed engine and torrent events as they happen. + /// + /// The returned stream is bounded. A lagging consumer can read + /// [`Self::view`] to rebuild its current state and then continue + /// receiving events. + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + self.hub.subscribe() + } + + /// Creates a listener with typed events and coherent current state. + #[must_use] + pub fn listener(&self) -> EngineListener { + let hub = self.hub.clone(); + EngineListener::new(self.subscribe(), move || hub.view()) + } + + /// Returns the current engine state maintained by the projection tree. + #[must_use] + pub fn view(&self) -> EngineView { + self.hub.view() + } + + fn torrent_handle(&self, info_hash: InfoHash) -> Result { + self + .hub + .torrent_handle(info_hash) + .ok_or_else(|| EngineError::TorrentHandleMissing { info_hash }) + } + + fn torrent_from_actor( + &self, info_hash: InfoHash, _actor: ActorRef, + ) -> Result { + self.torrent_handle(info_hash) + } + + async fn restored_torrent(&self, info_hash: InfoHash) -> Result { + self.torrent_handle(info_hash) } } @@ -353,7 +520,7 @@ mod snapshot_tests { use crate::{settings::Settings, testing}; #[tokio::test] - async fn engine_when_torrent_is_added_then_snapshots_frontend_state() { + async fn engine_when_torrent_is_added_then_snapshots_persistence_state() { let mut settings = Settings::default(); settings.dht.enabled = false; let engine = Engine::builder() @@ -368,22 +535,33 @@ mod snapshot_tests { .unwrap(); let snapshot = engine.snapshot().await.unwrap(); - assert_eq!(snapshot.status, EngineStatus::Running); - assert_eq!(snapshot.torrent_count, 1); + assert_eq!(snapshot.version, ENGINE_SNAPSHOT_VERSION); assert_eq!(snapshot.torrents.len(), 1); assert_eq!(snapshot.torrents[0].info_hash, torrent.info_hash()); - assert_eq!(snapshot.torrents[0].name, testing::BIG_BUCK_BUNNY_NAME); - assert!(snapshot.torrents[0].progress.total_pieces > 0); - assert!(snapshot.torrents[0].has_metadata); + assert_eq!( + snapshot.torrents[0].version, + crate::torrent::TORRENT_SNAPSHOT_VERSION + ); + assert!(snapshot.torrents[0].resolved_magnet_info.is_none()); + assert!(snapshot.torrents[0].resolved_info().is_some()); + assert!(!snapshot.torrents[0].bitfield.is_empty()); let snapshot_str = to_string(&snapshot).unwrap(); let from_snapshot: EngineSnapshot = from_str(&snapshot_str).unwrap(); - assert_eq!(snapshot, from_snapshot); + assert_eq!(snapshot.version, from_snapshot.version); + assert_eq!( + snapshot.torrents[0].info_hash, + from_snapshot.torrents[0].info_hash + ); + assert_eq!( + snapshot.torrents[0].bitfield, + from_snapshot.torrents[0].bitfield + ); } } -#[cfg(test)] +#[cfg(all(test, feature = "live"))] mod tests { use std::time::Duration; @@ -397,13 +575,77 @@ mod tests { }, engine::{Engine, TorrentSource}, errors::EngineError, + live::{EngineEventKind, TorrentEventKind}, settings::{DhtSettings, Settings}, testing::{ - BIG_BUCK_BUNNY_INFO_HASH, BIG_BUCK_BUNNY_MAGNET, BIG_BUCK_BUNNY_TORRENT_FILE, LocalPeer, - peer_id, torrent_fixture_path, + BIG_BUCK_BUNNY_INFO_HASH, BIG_BUCK_BUNNY_TORRENT_FILE, LocalPeer, peer_id, + torrent_fixture_path, }, + torrent::TorrentState, }; + #[tokio::test] + async fn abnormal_torrent_restart_keeps_its_listener_usable() { + let mut settings = Settings::default(); + settings.dht.enabled = false; + let engine = Engine::builder() + .settings(settings) + .autostart(false) + .build(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_path(torrent_fixture_path( + BIG_BUCK_BUNNY_TORRENT_FILE, + ))) + .await + .unwrap(); + let mut listener = torrent.listener(); + let mut tracker_ids = torrent + .trackers() + .into_iter() + .map(|tracker| tracker.id()) + .collect::>(); + tracker_ids.sort_unstable(); + + torrent.actor().kill(); + + timeout(Duration::from_secs(10), async { + loop { + let event = listener.recv().await.unwrap(); + if matches!( + event.kind, + TorrentEventKind::StateChanged { + current: TorrentState::Restarting, + .. + } + ) { + break; + } + } + }) + .await + .unwrap(); + timeout(Duration::from_secs(2), async { + loop { + if torrent.state().await.is_ok() { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(torrent.view().is_some()); + let mut restarted_tracker_ids = torrent + .trackers() + .into_iter() + .map(|tracker| tracker.id()) + .collect::>(); + restarted_tracker_ids.sort_unstable(); + assert_eq!(restarted_tracker_ids, tracker_ids); + + engine.shutdown().await.unwrap(); + } + const DHT_TEST_BUFFER_SIZE: usize = 2048; const DHT_TEST_POLL_INTERVAL: Duration = Duration::from_millis(10); @@ -423,10 +665,10 @@ mod tests { TorrentSource::torrent_file_path(torrent_fixture_path(BIG_BUCK_BUNNY_TORRENT_FILE)); let torrent = engine.add_torrent(source).await.unwrap(); - let export = engine.export().await.unwrap(); + let snapshot = engine.snapshot().await.unwrap(); assert_eq!(torrent.info_hash().to_hex(), BIG_BUCK_BUNNY_INFO_HASH); - assert_eq!(export.torrents.len(), 1); + assert_eq!(snapshot.torrents.len(), 1); } #[tokio::test] @@ -435,13 +677,15 @@ mod tests { .settings(deterministic_settings()) .autostart(false) .build(); - let source = TorrentSource::magnet(BIG_BUCK_BUNNY_MAGNET); + let source = TorrentSource::magnet(format!( + "magnet:?xt=urn:btih:{BIG_BUCK_BUNNY_INFO_HASH}&dn=Big+Buck+Bunny" + )); let torrent = engine.add_torrent(source).await.unwrap(); - let export = engine.export().await.unwrap(); + let snapshot = engine.snapshot().await.unwrap(); assert_eq!(torrent.info_hash().to_hex(), BIG_BUCK_BUNNY_INFO_HASH); - assert_eq!(export.torrents.len(), 1); + assert_eq!(snapshot.torrents.len(), 1); } #[tokio::test] @@ -463,6 +707,77 @@ mod tests { )); } + #[tokio::test] + async fn torrent_removal_reconciles_projection_after_actor_shutdown_failure() { + let engine = Engine::builder() + .settings(deterministic_settings()) + .autostart(false) + .build(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_path(torrent_fixture_path( + BIG_BUCK_BUNNY_TORRENT_FILE, + ))) + .await + .unwrap(); + let info_hash = torrent.info_hash(); + torrent.actor().stop_gracefully().await.unwrap(); + torrent.actor().wait_for_shutdown().await; + + let result = engine.remove_torrent(info_hash).await; + + assert!(result.is_err()); + assert_eq!(engine.view().torrent_count(), 0); + assert!(torrent.view().is_none()); + engine.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn removed_torrent_rejects_late_actor_views() { + let engine = Engine::builder() + .settings(deterministic_settings()) + .autostart(false) + .build(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_path(torrent_fixture_path( + BIG_BUCK_BUNNY_TORRENT_FILE, + ))) + .await + .unwrap(); + let info_hash = torrent.info_hash(); + let late_view = torrent.view().unwrap(); + + engine.hub.remove_torrent_scope(info_hash); + engine + .hub + .replace_torrent_view_and_emit(late_view, crate::live::TorrentEventKind::Updated); + + assert!(torrent.view().is_none()); + assert_eq!(engine.view().torrent_count(), 0); + let _ = engine.remove_torrent(info_hash).await; + engine.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn buffered_torrent_events_do_not_retain_the_hub() { + let engine = Engine::builder() + .settings(deterministic_settings()) + .autostart(false) + .build(); + let hub = engine.hub.downgrade(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_path(torrent_fixture_path( + BIG_BUCK_BUNNY_TORRENT_FILE, + ))) + .await + .unwrap(); + + engine.shutdown().await.unwrap(); + drop(torrent); + drop(engine); + + assert!(hub.upgrade().is_none()); + } + #[tokio::test] async fn engine_when_dht_returns_peer_then_connects_torrent_swarm() { let info_hash = crate::hashes::InfoHash::from_hex(BIG_BUCK_BUNNY_INFO_HASH).unwrap(); @@ -535,6 +850,7 @@ mod tests { .autostart(false) .sufficient_peers(1) .build(); + let mut listener = engine.listener(); let magnet = format!("magnet:?xt=urn:btih:{BIG_BUCK_BUNNY_INFO_HASH}&dn=dht-test"); engine @@ -553,7 +869,30 @@ mod tests { .await .unwrap(); + let peer = timeout(Duration::from_secs(2), async { + loop { + let event = listener.recv().await.unwrap(); + if let EngineEventKind::Torrent { + torrent, + event: TorrentEventKind::PeerConnected(peer), + } = event.kind + { + break (torrent, peer); + } + } + }) + .await + .unwrap(); + let (event_torrent, peer) = peer; + assert_eq!(peer.torrent(), info_hash); + assert!(peer.view().address.is_some()); + assert!( + !peer.view().connected || event_torrent.view().is_some_and(|view| view.peer_count > 0) + ); + let _peer_listener = peer.listener(); + engine.shutdown().await.unwrap(); + assert!(!peer.view().connected); receive_task.abort(); seed.kill(); } diff --git a/crates/libtortillas/src/engine/snapshot.rs b/crates/libtortillas/src/engine/snapshot.rs index 2604447a..8b6e0ff2 100644 --- a/crates/libtortillas/src/engine/snapshot.rs +++ b/crates/libtortillas/src/engine/snapshot.rs @@ -1,17 +1,74 @@ -use serde::{Deserialize, Serialize}; +use std::collections::HashSet; -use crate::torrent::TorrentSnapshot; +use serde::{Deserialize, Deserializer, Serialize}; -/// Stable, frontend-ready view of the engine. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +use crate::{errors::EngineError, torrent::TorrentSnapshot}; + +/// Current persistence schema version for [`EngineSnapshot`]. +pub const ENGINE_SNAPSHOT_VERSION: u32 = 2; + +/// Serializable state required to restore an engine's torrent sessions. +#[derive(Debug, Clone, Serialize)] pub struct EngineSnapshot { - pub status: EngineStatus, - pub torrent_count: u64, + pub version: u32, pub torrents: Vec, } -/// Coarse engine status for frontend displays. +#[derive(Deserialize)] +struct EngineSnapshotWire { + version: u32, + torrents: Vec, +} + +impl<'de> Deserialize<'de> for EngineSnapshot { + fn deserialize>(deserializer: D) -> Result { + let wire = EngineSnapshotWire::deserialize(deserializer)?; + Ok(Self { + version: if wire.version == 1 { + ENGINE_SNAPSHOT_VERSION + } else { + wire.version + }, + torrents: wire.torrents, + }) + } +} + +impl EngineSnapshot { + /// Validates the engine schema and every contained torrent before restore. + pub fn validate(&self) -> Result<(), EngineError> { + if self.version != ENGINE_SNAPSHOT_VERSION { + return Err(EngineError::InvalidSnapshot { + reason: format!( + "unsupported version {}; expected {}", + self.version, ENGINE_SNAPSHOT_VERSION + ), + }); + } + let mut unique = HashSet::with_capacity(self.torrents.len()); + for torrent in &self.torrents { + torrent.validate()?; + if !unique.insert(torrent.info_hash) { + return Err(EngineError::InvalidSnapshot { + reason: "snapshot contains duplicate torrent info hashes".to_string(), + }); + } + } + Ok(()) + } +} + +/// Coarse engine status for live-state consumers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum EngineStatus { + /// Runtime resources are still being initialized. + Starting, + /// Runtime resource initialization failed and the engine cannot recover. + Failed, + /// The engine is accepting commands and managing torrents. Running, + /// Graceful shutdown is in progress. + Stopping, + /// The engine and its managed torrents have stopped. + Stopped, } diff --git a/crates/libtortillas/src/engine/source.rs b/crates/libtortillas/src/engine/source.rs index 8d335553..49af3e92 100644 --- a/crates/libtortillas/src/engine/source.rs +++ b/crates/libtortillas/src/engine/source.rs @@ -16,10 +16,10 @@ const MAX_TORRENT_FILE_SIZE: usize = 10 * 1024 * 1024; /// Explicit source type for adding a torrent to an [`Engine`](super::Engine). /// -/// Frontends should map user input into one of these variants before calling -/// [`Engine::add_torrent`](super::Engine::add_torrent). That keeps UI intent -/// separate from parsing and avoids guessing whether a string is a URL, path, -/// or magnet URI. +/// Applications should map user input into one of these variants before calling +/// [`Engine::add_torrent`](super::Engine::add_torrent). That keeps caller +/// intent separate from parsing and avoids guessing whether a string is a URL, +/// path, or magnet URI. /// /// ``` /// use std::path::PathBuf; @@ -48,7 +48,7 @@ pub enum TorrentSource { MagnetUri(String), /// A local `.torrent` file path selected by the user. TorrentFilePath(PathBuf), - /// Raw bytes of a `.torrent` file already loaded by the frontend. + /// Raw bytes of a `.torrent` file already loaded by the caller. TorrentFileBytes(Bytes), /// An HTTP or HTTPS URL pointing to a remote `.torrent` file. RemoteTorrentUrl(String), diff --git a/crates/libtortillas/src/errors.rs b/crates/libtortillas/src/errors.rs index 188ad024..2d09b0e0 100644 --- a/crates/libtortillas/src/errors.rs +++ b/crates/libtortillas/src/errors.rs @@ -22,6 +22,7 @@ use std::net::AddrParseError; +use kameo::error::SendError; use thiserror::Error; use crate::{hashes::InfoHash, peer::PeerId}; @@ -64,6 +65,25 @@ pub enum EngineError { #[error("Torrent not found: {0}")] TorrentNotFound(InfoHash), + /// Serialized engine state is incompatible or internally inconsistent. + #[error("Invalid engine snapshot: {reason}")] + InvalidSnapshot { reason: String }, + + /// A managed torrent command failed. + #[error(transparent)] + Torrent(#[from] TorrentError), + + /// Communication with an actor failed before its handler completed. + #[error("Actor communication failed during {operation}: {reason}")] + ActorCommunicationFailed { + operation: &'static str, + reason: String, + }, + + /// The actor owns a torrent that is missing its public torrent handle. + #[error("Torrent {info_hash} is missing its public torrent handle")] + TorrentHandleMissing { info_hash: InfoHash }, + /// Any other engine-level error wrapped in [`anyhow::Error`] #[error(transparent)] Other(#[from] anyhow::Error), @@ -277,6 +297,21 @@ pub enum TorrentError { #[error("Unsafe torrent output path: {path}")] UnsafeOutputPath { path: String }, + /// Serialized torrent state is incompatible or internally inconsistent. + #[error("Invalid torrent snapshot: {reason}")] + InvalidSnapshot { reason: String }, + + /// The current runtime configuration cannot be represented durably. + #[error("Torrent snapshot is unsupported: {reason}")] + SnapshotUnsupported { reason: SnapshotUnsupportedReason }, + + /// A public mutation is invalid for the torrent's current configuration. + #[error("Invalid operation {operation}: {reason}")] + InvalidOperation { + operation: &'static str, + reason: String, + }, + /// Bitfield operation failed #[error("Bitfield operation failed: {reason}")] BitfieldError { reason: String }, @@ -290,8 +325,11 @@ pub enum TorrentError { MissingInfoDict, /// Actor communication failed - #[error("Actor communication failed: {actor_type} - {reason}")] - ActorCommunicationFailed { actor_type: String, reason: String }, + #[error("Actor communication failed during {operation}: {reason}")] + ActorCommunicationFailed { + operation: &'static str, + reason: String, + }, /// IO error #[error(transparent)] @@ -317,6 +355,36 @@ pub enum TorrentError { #[error(transparent)] Other(#[from] anyhow::Error), } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum SnapshotUnsupportedReason { + #[error("custom piece managers do not have a persistence descriptor")] + CustomPieceManager, +} + +pub(crate) fn map_engine_send_error( + operation: &'static str, error: SendError, +) -> EngineError { + match error { + SendError::HandlerError(error) => error, + error => EngineError::ActorCommunicationFailed { + operation, + reason: error.to_string(), + }, + } +} + +pub(crate) fn map_torrent_send_error( + operation: &'static str, error: SendError, +) -> TorrentError { + match error { + SendError::HandlerError(error) => error, + error => TorrentError::ActorCommunicationFailed { + operation, + reason: error.to_string(), + }, + } +} // Conversion implementations for backward compatibility during transition impl From> for TrackerActorError { fn from(err: num_enum::TryFromPrimitiveError) -> Self { diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index b157a483..1c104d8d 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -1,127 +1,23 @@ -//! Frontend-facing facade for `libtortillas`. +//! Application-facing facade for `libtortillas`. //! -//! This module defines the stable surface that a TUI or another frontend should -//! prefer over actor, protocol, tracker, and storage internals. Lower-level -//! modules remain public for advanced integrations, but frontend code should be -//! able to model user intent, observe progress, and hold handles through the -//! types in this module. -//! -//! # Example -//! -//! ```no_run -//! use libtortillas::facade::{CoreCommand, EngineHandle, TorrentSource}; -//! -//! let engine = EngineHandle::default(); -//! let command = CoreCommand::AddTorrent { -//! source: TorrentSource::magnet("magnet:?xt=urn:btih:..."), -//! }; -//! ``` - -use std::{net::SocketAddr, path::PathBuf}; +//! Re-exports the handles, snapshots, and live types most applications need. -use crate::{engine::Engine, hashes::InfoHash, torrent::Torrent}; pub use crate::{ - engine::{EngineSnapshot, EngineStatus, TorrentSource}, - torrent::{TorrentProgressSnapshot, TorrentSnapshot, TorrentTransferSnapshot}, + engine::{Engine, EngineSnapshot, EngineStatus, TorrentSource}, + torrent::{RestoreVerification, Torrent, TorrentSnapshot}, +}; +#[cfg(feature = "live")] +pub use crate::{ + live::{ + EngineEvent, EngineEventKind, EngineListener, EngineView, EventListener, EventStreamError, + EventSubscription, LiveHealth, LiveHealthLevel, LivePublisher, PeerEvent, PeerEventKind, + PeerHandle, PeerListener, PeerView, SequencedEvent, TorrentEvent, TorrentEventKind, + TorrentListener, TorrentView, TrackerEvent, TrackerEventKind, TrackerHandle, TrackerId, + TrackerListener, TrackerStatus, TrackerView, + }, + metrics::{ + ByteCount, BytesPerSecond, ContentProgress, HasTransferMetrics, PeerMetrics, Seconds, + TorrentMetrics, TrackerMetrics, TrafficTotals, TransferMetrics, TransferRates, + TransferSample, + }, }; - -/// Stable handle used by frontends to manage the torrent engine. -/// -/// This is currently backed by [`Engine`]. Frontends should import the alias -/// from the facade so future internal handle changes do not require reaching -/// into the engine module directly. -pub type EngineHandle = Engine; - -/// Stable handle used by frontends to inspect and control one torrent. -/// -/// This is currently backed by [`Torrent`]. Frontends should import the alias -/// from the facade so future internal handle changes do not require reaching -/// into the torrent module directly. -pub type TorrentHandle = Torrent; - -/// Commands a frontend can model before sending work to the engine. -/// -/// The existing handle methods remain the runtime API today. This enum gives -/// future TUIs and tests a single typed vocabulary for user intent while -/// command dispatch is filled in by follow-up issues. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CoreCommand { - /// Add a torrent from an explicit source. - AddTorrent { source: TorrentSource }, - /// Start every torrent managed by the engine. - StartAll, - /// Start one torrent. - StartTorrent { torrent: InfoHash }, - /// Resume one torrent. - ResumeTorrent { torrent: InfoHash }, - /// Pause one torrent. - PauseTorrent { torrent: InfoHash }, - /// Stop one torrent. - StopTorrent { torrent: InfoHash }, - /// Remove one torrent from the engine. - RemoveTorrent { torrent: InfoHash }, - /// Gracefully shut down the engine. - Shutdown, - /// Change the output folder for one torrent. - SetTorrentOutputPath { torrent: InfoHash, path: PathBuf }, - /// Enable or disable autostart for one torrent. - SetAutostart { torrent: InfoHash, enabled: bool }, - /// Set the peer threshold required before autostart begins downloading. - SetSufficientPeers { torrent: InfoHash, peers: usize }, -} - -/// Events a frontend can subscribe to without depending on actor messages. -#[derive(Debug, Clone, PartialEq)] -pub enum CoreEvent { - /// The engine has an updated aggregate snapshot. - EngineUpdated(EngineSnapshot), - /// A torrent has been added. - TorrentAdded(TorrentSnapshot), - /// A torrent has changed state or metrics. - TorrentUpdated(TorrentSnapshot), - /// A torrent has been removed from the engine. - TorrentRemoved { torrent: InfoHash }, - /// A frontend-relevant error occurred. - Error { message: String }, -} - -/// Frontend snapshot of a connected or discovered peer. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PeerSnapshot { - /// Network address for the peer, when known. - pub address: Option, - /// Peer client identifier, redacted or formatted for display. - pub client: Option, - /// Whether the peer is currently connected. - pub connected: bool, - /// Bytes downloaded from this peer. - pub downloaded_bytes: u64, - /// Bytes uploaded to this peer. - pub uploaded_bytes: u64, -} - -/// Frontend snapshot of a tracker. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TrackerSnapshot { - /// Announce URL or frontend label for the tracker. - pub announce_url: String, - /// Last known tracker status. - pub status: TrackerStatus, - /// Number of peers most recently returned by this tracker. - pub peers_returned: Option, - /// Last frontend-safe error message reported by this tracker. - pub last_error: Option, -} - -/// Frontend status for tracker health. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TrackerStatus { - /// The tracker has not been contacted yet. - Pending, - /// The last tracker request succeeded. - Healthy, - /// The tracker is temporarily unreachable or returned an error. - Degraded, - /// The tracker is unsupported or permanently unusable. - Unusable, -} diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index 850937c3..869027c3 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -1,55 +1,222 @@ -//! Async BitTorrent engine for building Tortillas frontends. +//! Async BitTorrent library for downloading and seeding files. //! -//! # Runtime boundary +//! # Getting started //! -//! `libtortillas` is intentionally a Tokio-based library. Public handles such -//! as [`engine::Engine`] and [`torrent::Torrent`] expose async methods that -//! must be driven inside a Tokio runtime, and the crate uses Tokio tasks, -//! sockets, timers, channels, and filesystem APIs internally. +//! A basic downloader only needs an [`Engine`](engine::Engine) and a +//! [`TorrentSource`](engine::TorrentSource). Live updates are optional. //! -//! Frontends should create one application-level Tokio runtime and keep the -//! engine plus all torrent handles on work scheduled by that runtime. The crate -//! does not promise runtime independence, HTTP client injection, clock -//! injection, listener injection, or storage runtime abstraction. +//! Add the library and its Tokio runtime to a binary crate: //! -//! A TUI can use `#[tokio::main]` on its binary entry point, or create an -//! explicit Tokio runtime before initializing `Engine`. +//! ```text +//! cargo add libtortillas +//! cargo add tokio --features full +//! ``` +//! +//! The following complete program loads `example.torrent`, writes its payload +//! to `downloads/`, and continues downloading or seeding until Ctrl-C is +//! pressed: +//! +//! ```no_run +//! use libtortillas::prelude::{Engine, TorrentSource}; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let engine = Engine::builder().output_path("downloads").build(); +//! let torrent = engine +//! .add_torrent(TorrentSource::torrent_file_path("example.torrent")) +//! .await?; +//! +//! println!("torrenting {} — press Ctrl-C to stop", torrent.info_hash()); +//! tokio::signal::ctrl_c().await?; +//! +//! engine.shutdown().await?; +//! Ok(()) +//! } +//! ``` +//! +//! That is enough to start torrenting. By default, a newly added torrent starts +//! automatically after it discovers enough peers. The +//! [`Torrent`](torrent::Torrent) returned by +//! [`Engine::add_torrent`](engine::Engine::add_torrent) is a lightweight handle +//! for controlling that download. +//! +//! ## Torrent sources +//! +//! Use the constructor that matches the input your application already has: +//! +//! - [`TorrentSource::torrent_file_path`](engine::TorrentSource::torrent_file_path) +//! for a local `.torrent` file. +//! - [`TorrentSource::magnet`](engine::TorrentSource::magnet) for a magnet URI. +//! - [`TorrentSource::torrent_file_bytes`](engine::TorrentSource::torrent_file_bytes) +//! for bytes already in memory. +//! - [`TorrentSource::remote_torrent_url`](engine::TorrentSource::remote_torrent_url) +//! for an HTTP or HTTPS URL. +//! +//! Every source is passed to +//! [`Engine::add_torrent`](engine::Engine::add_torrent) in the same way. There +//! is no live-specific setup. //! -//! # Frontend facade +//! For example, downloading from a magnet link only changes the source: //! -//! Frontends should prefer [`facade`] or [`prelude`] imports. The facade names -//! the stable concepts a TUI or other UI needs: [`facade::EngineHandle`], -//! [`facade::TorrentHandle`], [`facade::TorrentSource`], -//! [`facade::CoreCommand`], [`facade::CoreEvent`], and snapshot types for -//! engine, torrent, peer, and tracker views. +//! ```no_run +//! use libtortillas::prelude::{Engine, TorrentSource}; +//! +//! async fn add_magnet(engine: &Engine) -> Result<(), Box> { +//! let torrent = engine +//! .add_torrent(TorrentSource::magnet( +//! "magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny", +//! )) +//! .await?; +//! +//! println!("torrenting {}", torrent.info_hash()); +//! Ok(()) +//! } +//! ``` +//! +//! ## Basic control +//! +//! The returned [`Torrent`](torrent::Torrent) can be +//! [`paused`](torrent::Torrent::pause), [`resumed`](torrent::Torrent::resume), +//! or inspected for its [`state`](torrent::Torrent::state). An engine can +//! manage multiple torrents; call +//! [`Engine::remove_torrent`](engine::Engine::remove_torrent) to remove one and +//! [`Engine::shutdown`](engine::Engine::shutdown) before exiting cleanly. //! //! ```no_run -//! use libtortillas::prelude::{CoreCommand, EngineHandle, TorrentSource}; +//! use libtortillas::prelude::Torrent; +//! +//! async fn pause_and_resume(torrent: &Torrent) -> Result<(), Box> { +//! torrent.pause().await?; +//! println!("state after pausing: {:?}", torrent.state().await?); //! -//! let _engine = EngineHandle::default(); -//! let _command = CoreCommand::AddTorrent { -//! source: TorrentSource::magnet("magnet:?xt=urn:btih:..."), -//! }; +//! torrent.resume().await?; +//! Ok(()) +//! } //! ``` //! -//! # Advanced APIs +//! ## Multiple torrents +//! +//! One engine can download and seed many torrents: +//! +//! ```no_run +//! use libtortillas::prelude::{Engine, TorrentSource}; +//! +//! async fn add_downloads(engine: &Engine) -> Result<(), Box> { +//! let sources = [ +//! TorrentSource::torrent_file_path("first.torrent"), +//! TorrentSource::torrent_file_path("second.torrent"), +//! ]; +//! +//! for source in sources { +//! let torrent = engine.add_torrent(source).await?; +//! println!("added {}", torrent.info_hash()); +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## More examples +//! +//! Browse the repository's +//! [examples directory](https://github.com/artrixdotdev/tortillas/tree/main/crates/libtortillas/examples) +//! for complete runnable programs, including event-driven progress reporting. +//! +//! # Observing live state +//! +//! Live observation is provided by the default `live` Cargo feature. Consumers +//! that only need actor-backed commands and direct queries can disable default +//! features to omit the projection tree, event publishers, listener handles, +//! and live metrics. +//! +//! See the `live` module for current views, listeners, and event streams. +//! +//! This helper waits for changes and prints verified payload progress until the +//! torrent finishes downloading: +//! +//! ```no_run +//! use libtortillas::prelude::{Torrent, TorrentState}; +//! +//! # #[cfg(feature = "live")] +//! async fn show_progress(torrent: &Torrent) -> Result<(), Box> { +//! let mut listener = torrent.listener(); +//! +//! loop { +//! if let Some(view) = listener.view() { +//! let progress = &view.metrics.progress; +//! if let Some(total) = progress.total_bytes { +//! println!("{} / {} bytes verified", progress.verified_bytes.0, total.0); +//! } +//! if view.state == TorrentState::Seeding { +//! break; +//! } +//! } +//! +//! listener.recv().await?; +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! # Runtime and advanced APIs +//! +//! `libtortillas` requires a Tokio runtime. Use `#[tokio::main]`, as above, or +//! create a runtime before initializing an [`Engine`](engine::Engine). //! //! The lower-level [`engine`], [`torrent`], [`metainfo`], [`peer`], //! [`tracker`], [`pieces`], and [`protocol`] modules remain public for advanced -//! integrations, tests, and protocol-level work. Frontend code should avoid +//! integrations, tests, and protocol-level work. Applications should avoid //! depending on actor messages, raw peer streams, tracker clients, or storage -//! internals when an equivalent facade type exists. +//! internals when an equivalent [`facade`] type exists. [`prelude`] re-exports +//! the types most applications need. +//! +//! # Internal architecture +//! +//! Most applications do not need these implementation details. They are +//! documented here for contributors and advanced integrations. +//! +//! The runtime is organized as a supervised actor tree: //! -//! Follow-up work will narrow the prelude and connect more commands, events, -//! snapshots, and typed errors to the facade without requiring frontend callers -//! to import implementation modules. +//! ```text +//! EngineActor +//! ├── DhtActor (one shared instance) +//! └── TorrentActor (one per torrent) +//! ├── TrackerActor (one per tracker) +//! └── PeerActor (one per connected peer) +//! +//! DhtActor ───── discovered peers ────> TorrentActor +//! TrackerActor ── discovered peers ────> TorrentActor +//! ``` +//! +//! Actors own operational protocol state. Public applications interact through +//! [`Engine`](engine::Engine), [`Torrent`](torrent::Torrent), and the +//! transport-agnostic live views and event streams. Durable state is +//! represented by [`EngineSnapshot`](engine::EngineSnapshot) and +//! [`TorrentSnapshot`](torrent::TorrentSnapshot), never by live views. +// `cfg!` type-checks both branches; this drops disabled live code before name +// resolution. +macro_rules! live_only { + ($($tokens:tt)*) => {{ + #[cfg(feature = "live")] + { + $($tokens)* + } + }}; +} + +pub(crate) use live_only; pub(crate) mod dht; pub mod engine; pub mod errors; pub mod facade; pub mod hashes; +#[cfg(feature = "live")] +pub mod live; pub mod metainfo; +#[cfg(feature = "live")] +pub mod metrics; pub mod peer; pub mod pieces; pub mod protocol; @@ -522,10 +689,11 @@ pub(crate) mod testing { /// use libtortillas::prelude::*; /// ``` pub mod prelude { + #[cfg(feature = "live")] + pub use crate::facade::*; pub use crate::{ engine::*, errors::*, - facade::*, hashes::InfoHash, metainfo::*, peer::{Peer, PeerId}, diff --git a/crates/libtortillas/src/live/event.rs b/crates/libtortillas/src/live/event.rs new file mode 100644 index 00000000..c27ec7b2 --- /dev/null +++ b/crates/libtortillas/src/live/event.rs @@ -0,0 +1,126 @@ +use serde::{Deserialize, Serialize}; + +use super::{EngineView, PeerHandle, TrackerHandle}; +use crate::{ + hashes::InfoHash, + metrics::{PeerMetrics, TorrentMetrics}, + torrent::{Torrent, TorrentState}, +}; + +/// A sequenced event emitted by a live publisher. +/// +/// Sequence numbers are local to one publisher and strictly increase for every +/// event it emits. A consumer can use them to preserve scoped event order or +/// detect a gap after reconnecting a consumer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SequencedEvent { + pub sequence: u64, + pub kind: E, +} + +pub type EngineEvent = SequencedEvent; +pub type TorrentEvent = SequencedEvent; +pub type PeerEvent = SequencedEvent; +pub type TrackerEvent = SequencedEvent; + +impl SequencedEvent { + /// Returns the torrent associated with this event, when applicable. + #[must_use] + pub fn torrent(&self) -> Option { + self.kind.torrent() + } +} + +/// Typed changes a consumer can react to without actor internals or polling. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum EngineEventKind { + /// The engine finished starting and is ready for operations. + EngineStarted(EngineView), + /// A change emitted by one managed torrent. + /// + /// The same canonical event is delivered to both the torrent listener and + /// the engine listener, avoiding parallel event vocabularies that can drift + /// apart as protocols are added. + Torrent { + torrent: Torrent, + event: TorrentEventKind, + }, + /// An engine-wide health report was emitted. + Health(LiveHealth), + /// The engine and its managed torrents stopped. + Shutdown(EngineView), +} + +/// Events emitted by one torrent's independent live publisher. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum TorrentEventKind { + Added, + Updated, + StateChanged { + previous: TorrentState, + current: TorrentState, + }, + MetadataResolved, + MetricsChanged(TorrentMetrics), + PeerConnected(PeerHandle), + PeerDisconnected(PeerHandle), + TrackerAnnounceSucceeded(TrackerHandle), + TrackerAnnounceFailed(TrackerHandle), + TrackerRestarting(TrackerHandle), + TrackerStopped(TrackerHandle), + Health(LiveHealth), + Removed, +} + +/// Events emitted by one peer's independent live publisher. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum PeerEventKind { + StateChanged, + MetricsChanged(PeerMetrics), + Disconnected, +} + +/// Events emitted by one tracker's independent live publisher. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TrackerEventKind { + AnnounceSucceeded { peers_returned: u64 }, + AnnounceFailed, + Restarting, + Stopped, +} + +impl EngineEventKind { + /// Returns the torrent associated with this event, when applicable. + #[must_use] + pub fn torrent(&self) -> Option { + match self { + Self::EngineStarted(_) | Self::Shutdown(_) => None, + Self::Torrent { torrent, .. } => Some(torrent.info_hash()), + Self::Health(health) => health.torrent, + } + } +} + +/// A recoverable or terminal runtime health report. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LiveHealth { + /// Torrent associated with the report, or `None` for engine-wide health. + pub torrent: Option, + /// Severity suitable for application filtering. + pub level: LiveHealthLevel, + /// Public description without internal actor details. + pub message: String, +} + +/// Severity of a runtime health report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LiveHealthLevel { + /// The operation recovered but may merit user attention. + Warning, + /// The engine or torrent could not recover the operation. + Error, +} diff --git a/crates/libtortillas/src/live/handle.rs b/crates/libtortillas/src/live/handle.rs new file mode 100644 index 00000000..b4b664fa --- /dev/null +++ b/crates/libtortillas/src/live/handle.rs @@ -0,0 +1,454 @@ +//! Identity-bearing peer and tracker access handles. +//! +//! Handles expose only identity, the current projection, and scoped event +//! access. Actor ownership and mutation commands remain outside this module. + +use std::{ + fmt, + net::SocketAddr, + sync::{Arc, Weak}, +}; + +use serde::{Deserialize, Serialize}; + +use super::{ + EventListener, EventSubscription, Hub, HubInner, LivePublisher, PeerEventKind, PeerView, + TrackerEventKind, TrackerStatus, TrackerView, +}; +use crate::{hashes::InfoHash, metrics::TrackerMetrics, peer::PeerId}; + +/// Shared live state behind an identity-bearing protocol handle. +pub(crate) struct LiveScope { + pub(crate) identity: I, + hub: Weak, + pub(crate) publisher: LivePublisher, +} + +impl LiveScope +where + V: Clone + Send + Sync + 'static, + E: Clone + Send + 'static, +{ + fn new(identity: I, view: V, hub: Weak, event_capacity: usize) -> Self { + Self { + identity, + hub, + publisher: LivePublisher::new(view, event_capacity), + } + } + + fn subscribe(&self) -> EventSubscription { + self.publisher.subscribe() + } + + fn listener(&self) -> EventListener { + self.publisher.listener() + } + + fn view(&self) -> V { + self.publisher.view() + } + + fn hub(&self) -> Option { + self.hub.upgrade().map(Hub::from_inner) + } +} + +impl fmt::Debug for LiveScope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LiveScope") + .field("identity", &self.identity) + .finish_non_exhaustive() + } +} + +// Peer + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct PeerIdentity { + pub(crate) torrent: InfoHash, + pub(crate) peer: PeerId, +} + +/// Public identity and current state for one connected peer. +#[derive(Clone)] +pub struct PeerHandle { + pub(crate) inner: Arc>, +} + +impl PeerHandle { + pub(crate) fn new( + identity: PeerIdentity, view: PeerView, hub: Weak, event_capacity: usize, + ) -> Self { + Self { + inner: Arc::new(LiveScope::new(identity, view, hub, event_capacity)), + } + } + + #[must_use] + pub fn torrent(&self) -> InfoHash { + self.inner.identity.torrent + } + + #[must_use] + pub fn id(&self) -> PeerId { + self.inner.identity.peer + } + + #[must_use] + pub fn address(&self) -> Option { + self.view().address + } + + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + self.inner.subscribe() + } + + #[must_use] + pub fn listener(&self) -> PeerListener { + self.inner.listener() + } + + #[must_use] + pub fn view(&self) -> PeerView { + self.inner.view() + } + + pub(crate) fn identity(&self) -> PeerIdentity { + self.inner.identity + } + + pub(crate) fn publish_state(&self, view: PeerView) { + let _ = self + .inner + .publisher + .replace_view_and_emit(view, PeerEventKind::StateChanged); + } + + pub(crate) fn publish_metrics(&self, view: PeerView) { + let metrics = view.metrics.clone(); + let _ = self + .inner + .publisher + .replace_view_and_emit(view, PeerEventKind::MetricsChanged(metrics)); + } + + pub(crate) fn disconnected(&self) { + let mut view = self.view(); + view.connected = false; + if self + .inner + .publisher + .close_with_terminal_event(view, PeerEventKind::Disconnected) + && let Some(hub) = self.inner.hub() + { + hub.mark_peer_disconnected(self); + } + } + + pub(crate) fn close_without_parent_event(&self) { + let mut view = self.view(); + view.connected = false; + let _ = self + .inner + .publisher + .close_with_terminal_event(view, PeerEventKind::Disconnected); + } +} + +impl fmt::Debug for PeerHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PeerHandle") + .field("torrent", &self.torrent()) + .field("peer", &self.id()) + .finish_non_exhaustive() + } +} + +impl PartialEq for PeerHandle { + fn eq(&self, other: &Self) -> bool { + self.identity() == other.identity() + } +} + +impl Eq for PeerHandle {} + +pub type PeerListener = EventListener; + +// Tracker + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize +)] +pub struct TrackerId(u64); + +impl TrackerId { + pub(crate) const fn new(value: u64) -> Self { + Self(value) + } +} + +impl fmt::Display for TrackerId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct TrackerIdentity { + pub(crate) torrent: InfoHash, + pub(crate) id: TrackerId, +} + +/// Public identity and current state for one tracker. +#[derive(Clone)] +pub struct TrackerHandle { + pub(crate) inner: Arc>, +} + +impl TrackerHandle { + pub(crate) fn new( + identity: TrackerIdentity, view: TrackerView, hub: Weak, event_capacity: usize, + ) -> Self { + Self { + inner: Arc::new(LiveScope::new(identity, view, hub, event_capacity)), + } + } + + #[must_use] + pub fn torrent(&self) -> InfoHash { + self.inner.identity.torrent + } + + #[must_use] + pub fn id(&self) -> TrackerId { + self.inner.identity.id + } + + #[must_use] + pub fn endpoint(&self) -> String { + self.view().endpoint + } + + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + self.inner.subscribe() + } + + #[must_use] + pub fn listener(&self) -> TrackerListener { + self.inner.listener() + } + + #[must_use] + pub fn view(&self) -> TrackerView { + self.inner.view() + } + + pub(crate) fn identity(&self) -> TrackerIdentity { + self.inner.identity + } + + pub(crate) fn publish_metrics(&self, metrics: TrackerMetrics) { + let mut view = self.view(); + view.metrics = metrics; + let _ = self.inner.publisher.replace_view(view); + } + + pub(crate) fn announce_succeeded(&self, metrics: TrackerMetrics) { + let mut view = self.view(); + view.status = TrackerStatus::Healthy; + let peers_returned = metrics.latest_peers_returned.unwrap_or_default(); + view.metrics = metrics; + let event = TrackerEventKind::AnnounceSucceeded { peers_returned }; + if self.inner.publisher.replace_view_and_emit(view, event) + && let Some(hub) = self.inner.hub() + { + hub.emit_tracker_event(self, event); + } + } + + pub(crate) fn announce_failed(&self, metrics: TrackerMetrics) { + let mut view = self.view(); + view.status = TrackerStatus::Degraded; + view.metrics = metrics; + if self + .inner + .publisher + .replace_view_and_emit(view, TrackerEventKind::AnnounceFailed) + && let Some(hub) = self.inner.hub() + { + hub.emit_tracker_event(self, TrackerEventKind::AnnounceFailed); + } + } + + pub(crate) fn restarting(&self) { + let mut view = self.view(); + view.status = TrackerStatus::Restarting; + if self + .inner + .publisher + .replace_view_and_emit(view, TrackerEventKind::Restarting) + && let Some(hub) = self.inner.hub() + { + hub.emit_tracker_event(self, TrackerEventKind::Restarting); + } + } + + pub(crate) fn stopped(&self) { + let mut view = self.view(); + view.status = TrackerStatus::Stopped; + let closed = self + .inner + .publisher + .close_with_terminal_event(view, TrackerEventKind::Stopped); + if let Some(hub) = self.inner.hub() { + hub.remove_tracker_scope(self); + if closed { + hub.emit_tracker_event(self, TrackerEventKind::Stopped); + } + } + } + + pub(crate) fn close_without_parent_event(&self) { + let mut view = self.view(); + view.status = TrackerStatus::Stopped; + let _ = self + .inner + .publisher + .close_with_terminal_event(view, TrackerEventKind::Stopped); + if let Some(hub) = self.inner.hub() { + hub.remove_tracker_scope(self); + } + } +} + +impl fmt::Debug for TrackerHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TrackerHandle") + .field("torrent", &self.torrent()) + .field("id", &self.id()) + .field("endpoint", &self.endpoint()) + .finish_non_exhaustive() + } +} + +impl PartialEq for TrackerHandle { + fn eq(&self, other: &Self) -> bool { + self.identity() == other.identity() + } +} + +impl Eq for TrackerHandle {} + +impl fmt::Display for TrackerHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.endpoint()) + } +} + +pub type TrackerListener = EventListener; + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, SocketAddr}; + + use super::{super::EventStreamError, *}; + use crate::{ + metrics::{ByteCount, PeerMetrics, TrafficTotals}, + peer::PeerId, + }; + + fn connected_peer_view() -> PeerView { + PeerView { + address: Some(SocketAddr::from((Ipv4Addr::LOCALHOST, 6881))), + client: Some("Unknown".to_string()), + connected: true, + metrics: PeerMetrics { + peer_choking: true, + client_choking: true, + ..Default::default() + }, + } + } + + fn peer_handle(hub: &Hub) -> PeerHandle { + hub.register_peer_scope( + PeerIdentity { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), + }, + connected_peer_view(), + ) + .unwrap() + } + + #[tokio::test] + async fn peer_handle_when_updated_then_only_its_listener_receives_event() { + let hub = Hub::new(); + let peer = peer_handle(&hub); + let mut listener = peer.listener(); + let mut updated = peer.view(); + updated.metrics.transfer.totals = TrafficTotals { + downloaded: ByteCount(16), + uploaded: ByteCount::ZERO, + }; + + peer.publish_metrics(updated); + + let event = listener.recv().await.unwrap(); + assert!(matches!(event.kind, PeerEventKind::MetricsChanged(_))); + assert_eq!( + listener.view().metrics.transfer.totals.downloaded, + ByteCount(16) + ); + } + + #[tokio::test] + async fn disconnected_peer_rejects_late_actor_updates() { + let hub = Hub::new(); + let peer = peer_handle(&hub); + let mut listener = peer.listener(); + let mut late = peer.view(); + + peer.disconnected(); + late.metrics.transfer.totals.downloaded = ByteCount(32); + peer.publish_metrics(late); + + assert_eq!( + listener.recv().await.unwrap().kind, + PeerEventKind::Disconnected + ); + assert_eq!(listener.recv().await, Err(EventStreamError::Closed)); + assert!(!listener.view().connected); + assert_eq!( + listener.view().metrics.transfer.totals.downloaded, + ByteCount::ZERO + ); + } + + #[test] + fn scoped_handles_do_not_keep_their_hub_alive() { + let hub = Hub::new(); + let weak_hub = hub.downgrade(); + let peer = peer_handle(&hub); + + drop(hub); + + assert!(weak_hub.upgrade().is_none()); + assert!(peer.view().connected); + } +} diff --git a/crates/libtortillas/src/live/hub.rs b/crates/libtortillas/src/live/hub.rs new file mode 100644 index 00000000..64b1f308 --- /dev/null +++ b/crates/libtortillas/src/live/hub.rs @@ -0,0 +1,800 @@ +//! Internal ownership and coordination for live projections. +//! +//! Read this file from top to bottom to follow the complete ownership path: +//! guard-free registry, scope tree, hub lifetime, then engine, torrent, peer, +//! and tracker publication operations. + +use std::{ + hash::Hash, + sync::{ + Arc, Mutex, MutexGuard, OnceLock, Weak, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, +}; + +use dashmap::DashMap; + +use super::{ + EngineEventKind, EngineView, EventSubscription, LiveHealth, LiveHealthLevel, LivePublisher, + PeerEventKind, PeerHandle, PeerView, TorrentEventKind, TorrentView, TrackerEventKind, + TrackerHandle, TrackerView, + handle::{LiveScope, PeerIdentity, TrackerId, TrackerIdentity}, +}; +use crate::{ + engine::EngineStatus, + hashes::InfoHash, + live::LiveSettings, + peer::PeerId, + torrent::{Torrent, TorrentInner}, + tracker::Tracker, +}; + +// Registry + +/// Guard-free facade over sharded keyed scope ownership. +/// +/// Registry guards never escape this type: callers receive cloned `Arc`s or +/// owned vectors, so actor communication and async work cannot accidentally +/// retain a DashMap shard lock. +#[derive(Debug)] +struct ScopeRegistry { + values: DashMap>, +} + +impl ScopeRegistry +where + K: Clone + Eq + Hash, +{ + fn new() -> Self { + Self { + values: DashMap::new(), + } + } + + fn insert(&self, key: K, value: &Arc) { + self.values.insert(key, Arc::clone(value)); + } + + fn get_or_insert_with(&self, key: K, create: impl FnOnce() -> V) -> Arc { + if let Some(value) = self.get(&key) { + return value; + } + + // Construct before entering the shard so arbitrary initialization never + // runs while a DashMap lock is held. A racing insertion may make this + // allocation unused, which is preferable to extending the lock lifetime. + let candidate = Arc::new(create()); + Arc::clone(self.values.entry(key).or_insert(candidate).value()) + } + + fn get(&self, key: &K) -> Option> { + self.values.get(key).map(|value| Arc::clone(value.value())) + } + + fn remove(&self, key: &K) -> Option> { + self.values.remove(key).map(|(_, value)| value) + } + + fn remove_value(&self, value: &Arc) -> bool { + let key = self + .values + .iter() + .find(|entry| Arc::ptr_eq(entry.value(), value)) + .map(|entry| entry.key().clone()); + key.is_some_and(|key| { + self + .values + .remove_if(&key, |_, current| Arc::ptr_eq(current, value)) + .is_some() + }) + } + + fn values(&self) -> Vec> { + self + .values + .iter() + .map(|value| Arc::clone(value.value())) + .collect() + } + + fn remove_all(&self) -> Vec> { + let keys = self + .values + .iter() + .map(|entry| entry.key().clone()) + .collect::>(); + keys + .into_iter() + .filter_map(|key| self.remove(&key)) + .collect() + } +} + +// Scope tree + +#[derive(Debug)] +struct EngineScope { + publisher: LivePublisher, +} + +/// One self-contained torrent projection tree. +#[derive(Debug)] +pub(crate) struct TorrentScope { + pub(crate) info_hash: InfoHash, + pub(crate) publisher: Arc, TorrentEventKind>>, + peers: ScopeRegistry>, + trackers: ScopeRegistry>, + torrent: OnceLock>, + registered: AtomicBool, + publication: Mutex<()>, +} + +impl TorrentScope { + fn new(info_hash: InfoHash, event_capacity: usize) -> Self { + Self { + info_hash, + publisher: Arc::new(LivePublisher::new(None, event_capacity)), + peers: ScopeRegistry::new(), + trackers: ScopeRegistry::new(), + torrent: OnceLock::new(), + registered: AtomicBool::new(false), + publication: Mutex::new(()), + } + } + + fn register(&self, torrent: &Torrent) -> bool { + if self.torrent.set(Arc::clone(&torrent.inner)).is_err() { + return false; + } + self.registered.store(true, Ordering::Release); + true + } + + fn is_registered(&self) -> bool { + self.registered.load(Ordering::Acquire) + } + + #[cfg(test)] + pub(super) fn mark_registered_for_benchmark(&self) { + self.registered.store(true, Ordering::Release); + } + + fn handle(&self) -> Option { + self.torrent.get().map(|inner| Torrent { + inner: Arc::clone(inner), + }) + } + + fn publication_lock(&self) -> MutexGuard<'_, ()> { + self + .publication + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// Ownership root for transport-agnostic live projections. +#[derive(Debug)] +pub(crate) struct HubInner { + engine: EngineScope, + torrents: ScopeRegistry, + settings: LiveSettings, + next_tracker_id: AtomicU64, +} + +impl HubInner { + fn torrent_handle(&self, info_hash: InfoHash) -> Option { + self + .torrents + .get(&info_hash) + .and_then(|scope| scope.handle()) + } +} + +// Hub lifetime + +#[derive(Debug, Clone)] +enum HubReference { + Strong(Arc), + Weak(Weak), +} + +impl HubReference { + fn inner(&self) -> Option> { + match self { + Self::Strong(inner) => Some(Arc::clone(inner)), + Self::Weak(inner) => inner.upgrade(), + } + } +} + +/// Cloneable coordinator for the complete live projection tree. +/// +/// The engine owns a strong instance. Supervised actors receive weak instances +/// so the projection tree cannot participate in an ownership cycle. +#[derive(Debug, Clone)] +pub(crate) struct Hub { + inner: HubReference, +} + +impl Hub { + // Engine projection + + pub(crate) fn new() -> Self { + Self::with_settings(LiveSettings::default()) + } + + pub(crate) fn with_settings(settings: LiveSettings) -> Self { + Self { + inner: HubReference::Strong(Arc::new(HubInner { + engine: EngineScope { + publisher: LivePublisher::new( + EngineStatus::Starting, + settings.engine_event_capacity, + ), + }, + torrents: ScopeRegistry::new(), + settings, + next_tracker_id: AtomicU64::new(1), + })), + } + } + + pub(crate) fn from_inner(inner: Arc) -> Self { + Self { + inner: HubReference::Strong(inner), + } + } + + pub(crate) fn weak(&self) -> Self { + Self { + inner: HubReference::Weak(self.downgrade()), + } + } + + pub(crate) fn downgrade(&self) -> Weak { + match &self.inner { + HubReference::Strong(inner) => Arc::downgrade(inner), + HubReference::Weak(inner) => inner.clone(), + } + } + + fn inner(&self) -> Option> { + self.inner.inner() + } + + pub(crate) fn subscribe(&self) -> EventSubscription { + self + .inner() + .map_or_else(EventSubscription::closed, |inner| { + inner.engine.publisher.subscribe() + }) + } + + /// Derives the root projection from engine lifecycle and registered child + /// scopes. The root never caches torrent views. + pub(crate) fn view(&self) -> EngineView { + let Some(inner) = self.inner() else { + return EngineView { + status: EngineStatus::Stopped, + torrents: Vec::new(), + }; + }; + let mut torrents = inner + .torrents + .values() + .into_iter() + .filter(|scope| scope.is_registered()) + .filter_map(|scope| scope.publisher.view()) + .collect::>(); + torrents.sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); + EngineView { + status: inner.engine.publisher.view(), + torrents, + } + } + + pub(crate) fn engine_started(&self) { + let Some(inner) = self.inner() else { + return; + }; + let _ = inner.engine.publisher.replace_view(EngineStatus::Running); + let _ = inner + .engine + .publisher + .emit_without_view_change(EngineEventKind::EngineStarted(self.view())); + } + + pub(crate) fn engine_start_failed(&self, message: impl Into) { + let Some(inner) = self.inner() else { + return; + }; + let health = LiveHealth { + torrent: None, + level: LiveHealthLevel::Error, + message: message.into(), + }; + let _ = inner + .engine + .publisher + .close_with_terminal_event(EngineStatus::Failed, EngineEventKind::Health(health)); + } + + pub(crate) fn engine_stopping(&self) { + let Some(inner) = self.inner() else { + return; + }; + let _ = inner.engine.publisher.replace_view(EngineStatus::Stopping); + } + + pub(crate) fn engine_stopped(&self) { + let Some(inner) = self.inner() else { + return; + }; + let mut view = self.view(); + view.status = EngineStatus::Stopped; + let _ = inner + .engine + .publisher + .close_with_terminal_event(EngineStatus::Stopped, EngineEventKind::Shutdown(view)); + } + + // Torrent scopes + + pub(crate) fn ensure_torrent_scope(&self, info_hash: InfoHash) -> Option> { + let inner = self.inner()?; + Some(inner.torrents.get_or_insert_with(info_hash, || { + TorrentScope::new(info_hash, inner.settings.torrent_event_capacity) + })) + } + + pub(crate) fn torrent_handle(&self, torrent: InfoHash) -> Option { + self.inner()?.torrent_handle(torrent) + } + + #[cfg(test)] + pub(crate) fn torrent_view(&self, torrent: InfoHash) -> Option { + self + .inner()? + .torrents + .get(&torrent) + .and_then(|scope| scope.publisher.view()) + } + + pub(crate) fn initialize_torrent_projection(&self, torrent: TorrentView) { + let Some(scope) = self.ensure_torrent_scope(torrent.info_hash) else { + return; + }; + let _ = scope.publisher.replace_view(Some(torrent)); + } + + pub(crate) fn register_torrent_scope(&self, torrent: Torrent) { + let info_hash = torrent.info_hash(); + let Some(inner) = self.inner() else { + return; + }; + let scope = inner.torrents.get_or_insert_with(info_hash, || { + TorrentScope::new(info_hash, inner.settings.torrent_event_capacity) + }); + if !scope.register(&torrent) || scope.publisher.view().is_none() { + return; + } + let _publication = scope.publication_lock(); + if !scope + .publisher + .emit_without_view_change(TorrentEventKind::Added) + { + return; + } + let _ = inner + .engine + .publisher + .emit_without_view_change(EngineEventKind::Torrent { + torrent, + event: TorrentEventKind::Added, + }); + } + + pub(crate) fn replace_torrent_view_and_emit( + &self, torrent: TorrentView, event: TorrentEventKind, + ) { + let info_hash = torrent.info_hash; + let Some(inner) = self.inner() else { + return; + }; + let Some(scope) = inner.torrents.get(&info_hash) else { + return; + }; + debug_assert_eq!(scope.info_hash, info_hash); + let _publication = scope.publication_lock(); + if !scope + .publisher + .replace_view_and_emit(Some(torrent), event.clone()) + { + return; + } + let Some(handle) = scope.handle() else { + return; + }; + let _ = inner + .engine + .publisher + .emit_without_view_change(EngineEventKind::Torrent { + torrent: handle, + event, + }); + } + + pub(crate) fn emit_health( + &self, torrent: Option, level: LiveHealthLevel, message: impl Into, + ) { + let Some(inner) = self.inner() else { + return; + }; + let health = LiveHealth { + torrent, + level, + message: message.into(), + }; + if let Some(info_hash) = torrent + && let Some(scope) = inner.torrents.get(&info_hash) + { + Self::emit_without_torrent_view_change(&inner, &scope, TorrentEventKind::Health(health)); + } else { + let _ = inner + .engine + .publisher + .emit_without_view_change(EngineEventKind::Health(health)); + } + } + + pub(crate) fn remove_torrent_scope(&self, info_hash: InfoHash) { + let Some(inner) = self.inner() else { + return; + }; + let Some(scope) = inner.torrents.get(&info_hash) else { + return; + }; + let torrent = scope.handle(); + let peers = scope + .peers + .values() + .into_iter() + .map(|inner| PeerHandle { inner }) + .collect::>(); + let trackers = scope + .trackers + .values() + .into_iter() + .map(|inner| TrackerHandle { inner }) + .collect::>(); + let publication = scope.publication_lock(); + + for peer in peers { + peer.close_without_parent_event(); + } + for tracker in trackers { + tracker.close_without_parent_event(); + } + + if !scope + .publisher + .close_with_terminal_event(None, TorrentEventKind::Removed) + { + return; + } + drop(publication); + inner.torrents.remove(&info_hash); + if let Some(torrent) = torrent { + let _ = inner + .engine + .publisher + .emit_without_view_change(EngineEventKind::Torrent { + torrent, + event: TorrentEventKind::Removed, + }); + } + } + + fn emit_without_torrent_view_change( + inner: &HubInner, scope: &TorrentScope, event: TorrentEventKind, + ) { + let Some(torrent) = scope.handle() else { + return; + }; + let _publication = scope.publication_lock(); + if !scope.publisher.emit_without_view_change(event.clone()) { + return; + } + let _ = inner + .engine + .publisher + .emit_without_view_change(EngineEventKind::Torrent { torrent, event }); + } + + // Peer scopes + + pub(crate) fn peer_handles(&self, torrent: InfoHash) -> Vec { + let Some(inner) = self.inner() else { + return Vec::new(); + }; + inner.torrents.get(&torrent).map_or_else(Vec::new, |scope| { + scope + .peers + .values() + .into_iter() + .map(|inner| PeerHandle { inner }) + .filter(|peer| peer.view().connected) + .collect() + }) + } + + pub(crate) fn register_peer_scope( + &self, identity: PeerIdentity, view: PeerView, + ) -> Option { + let inner = self.inner()?; + let scope = inner.torrents.get_or_insert_with(identity.torrent, || { + TorrentScope::new(identity.torrent, inner.settings.torrent_event_capacity) + }); + let peer = PeerHandle::new( + identity, + view, + self.downgrade(), + inner.settings.peer_event_capacity, + ); + scope.peers.insert(identity.peer, &peer.inner); + Some(peer) + } + + pub(crate) fn emit_peer_connected(&self, peer: &PeerHandle) { + let Some(inner) = self.inner() else { + return; + }; + let Some(scope) = inner.torrents.get(&peer.torrent()) else { + return; + }; + if scope.peers.get(&peer.id()).is_some() { + Self::emit_without_torrent_view_change( + &inner, + &scope, + TorrentEventKind::PeerConnected(peer.clone()), + ); + } + } + + pub(crate) fn mark_peer_disconnected(&self, peer: &PeerHandle) { + let Some(inner) = self.inner() else { + return; + }; + let Some(scope) = inner.torrents.get(&peer.torrent()) else { + return; + }; + if scope.peers.remove(&peer.id()).is_none() { + return; + } + Self::emit_without_torrent_view_change( + &inner, + &scope, + TorrentEventKind::PeerDisconnected(peer.clone()), + ); + } + + pub(crate) fn close_peer_scopes_for_torrent_restart(&self, torrent: InfoHash) { + let Some(inner) = self.inner() else { + return; + }; + let Some(scope) = inner.torrents.get(&torrent) else { + return; + }; + for inner in scope.peers.remove_all() { + PeerHandle { inner }.close_without_parent_event(); + } + } + + // Tracker scopes + + pub(crate) fn tracker_handles(&self, torrent: InfoHash) -> Vec { + let Some(inner) = self.inner() else { + return Vec::new(); + }; + inner.torrents.get(&torrent).map_or_else(Vec::new, |scope| { + scope + .trackers + .values() + .into_iter() + .map(|inner| TrackerHandle { inner }) + .collect() + }) + } + + pub(crate) fn register_tracker_scope( + &self, torrent: InfoHash, source: &Tracker, view: TrackerView, + ) -> Option { + let inner = self.inner()?; + let torrent_scope = inner.torrents.get_or_insert_with(torrent, || { + TorrentScope::new(torrent, inner.settings.torrent_event_capacity) + }); + if let Some(existing) = torrent_scope.trackers.get(source) { + if !existing.publisher.is_closed() { + return Some(TrackerHandle { inner: existing }); + } + let _ = torrent_scope.trackers.remove_value(&existing); + } + let id = TrackerId::new(inner.next_tracker_id.fetch_add(1, Ordering::Relaxed)); + let identity = TrackerIdentity { torrent, id }; + let tracker = TrackerHandle::new( + identity, + view, + self.downgrade(), + inner.settings.tracker_event_capacity, + ); + torrent_scope + .trackers + .insert(source.clone(), &tracker.inner); + Some(tracker) + } + + pub(crate) fn remove_tracker_scope(&self, tracker: &TrackerHandle) { + let Some(inner) = self.inner() else { + return; + }; + let Some(scope) = inner.torrents.get(&tracker.torrent()) else { + return; + }; + let _ = scope.trackers.remove_value(&tracker.inner); + } + + pub(crate) fn emit_tracker_event(&self, tracker: &TrackerHandle, event: TrackerEventKind) { + let Some(inner) = self.inner() else { + return; + }; + let Some(scope) = inner.torrents.get(&tracker.torrent()) else { + return; + }; + let torrent_event = match event { + TrackerEventKind::AnnounceSucceeded { .. } => { + TorrentEventKind::TrackerAnnounceSucceeded(tracker.clone()) + } + TrackerEventKind::AnnounceFailed => { + TorrentEventKind::TrackerAnnounceFailed(tracker.clone()) + } + TrackerEventKind::Restarting => TorrentEventKind::TrackerRestarting(tracker.clone()), + TrackerEventKind::Stopped => TorrentEventKind::TrackerStopped(tracker.clone()), + }; + Self::emit_without_torrent_view_change(&inner, &scope, torrent_event); + } +} + +impl Default for Hub { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, SocketAddr}; + + use super::{ + super::{EventStreamError, TrackerStatus}, + *, + }; + use crate::{ + metrics::{ + ByteCount, ContentProgress, PeerMetrics, TorrentMetrics, TrackerMetrics, TransferMetrics, + }, + peer::PeerId, + torrent::TorrentState, + }; + + fn connected_peer_view() -> PeerView { + PeerView { + address: Some(SocketAddr::from((Ipv4Addr::LOCALHOST, 6881))), + client: Some("Unknown".to_string()), + connected: true, + metrics: PeerMetrics { + peer_choking: true, + client_choking: true, + ..Default::default() + }, + } + } + + fn pending_tracker_view() -> TrackerView { + TrackerView { + endpoint: "https://tracker.example".to_string(), + status: TrackerStatus::Pending, + metrics: TrackerMetrics::default(), + } + } + + fn torrent_view(info_hash: InfoHash) -> TorrentView { + TorrentView { + info_hash, + name: "removed".to_string(), + state: TorrentState::Downloading, + auto_start: true, + sufficient_peers: 1, + peer_count: 0, + tracker_count: 0, + output_path: None, + metrics: TorrentMetrics::new( + TransferMetrics::default(), + ContentProgress { + total_bytes: Some(ByteCount(1_000)), + verified_bytes: ByteCount::ZERO, + remaining_bytes: Some(ByteCount(1_000)), + progress_fraction: Some(0.0), + completed_pieces: 0, + partial_pieces: 0, + total_pieces: 1, + }, + ), + } + } + + #[tokio::test] + async fn torrent_removal_closes_every_child_scope_exactly_once() { + let hub = Hub::new(); + let info_hash = InfoHash::from_bytes([4; 20]); + hub.initialize_torrent_projection(torrent_view(info_hash)); + let peer = hub + .register_peer_scope( + PeerIdentity { + torrent: info_hash, + peer: PeerId::Unknown([5; 20]), + }, + connected_peer_view(), + ) + .unwrap(); + let source = Tracker::Http("https://tracker.example/announce".to_string()); + let tracker = hub + .register_tracker_scope(info_hash, &source, pending_tracker_view()) + .unwrap(); + let mut peer_events = peer.subscribe(); + let mut tracker_events = tracker.subscribe(); + + hub.remove_torrent_scope(info_hash); + hub.remove_torrent_scope(info_hash); + + assert_eq!( + peer_events.recv().await.unwrap().kind, + PeerEventKind::Disconnected + ); + assert_eq!(peer_events.recv().await, Err(EventStreamError::Closed)); + assert_eq!( + tracker_events.recv().await.unwrap().kind, + TrackerEventKind::Stopped + ); + assert_eq!(tracker_events.recv().await, Err(EventStreamError::Closed)); + } + + #[test] + fn torrent_view_updates_before_handle_registration() { + let hub = Hub::new(); + let info_hash = InfoHash::from_bytes([6; 20]); + let mut updated = torrent_view(info_hash); + updated.name = "newer".to_string(); + + hub.initialize_torrent_projection(torrent_view(info_hash)); + hub.replace_torrent_view_and_emit(updated.clone(), TorrentEventKind::Updated); + + assert_eq!(hub.torrent_view(info_hash), Some(updated)); + } + + #[tokio::test] + async fn expired_weak_hub_publication_is_a_no_op() { + let hub = Hub::new(); + let weak = hub.weak(); + drop(hub); + + weak.emit_health(None, LiveHealthLevel::Error, "late"); + weak.engine_stopping(); + + assert_eq!(weak.view().status, EngineStatus::Stopped); + assert!(matches!( + weak.subscribe().recv().await, + Err(EventStreamError::Closed) + )); + } +} diff --git a/crates/libtortillas/src/live/mod.rs b/crates/libtortillas/src/live/mod.rs new file mode 100644 index 00000000..d9b7dadb --- /dev/null +++ b/crates/libtortillas/src/live/mod.rs @@ -0,0 +1,344 @@ +//! Current state and event streams for running engines and torrents. +//! +//! Use [`EventSubscription`] when only events are needed, or [`EventListener`] +//! when the consumer also needs a current view for initialization and lag +//! recovery. Engine and torrent handles remain the command API. +//! +//! # Listening to an engine +//! +//! ```no_run +//! use libtortillas::prelude::{Engine, EngineEventKind, EventStreamError}; +//! +//! # async fn run() -> Result<(), Box> { +//! let engine = Engine::default(); +//! let mut listener = engine.listener(); +//! +//! loop { +//! match listener.recv().await { +//! Ok(event) if matches!(event.kind, EngineEventKind::Shutdown(_)) => break, +//! Ok(_) => {} +//! Err(EventStreamError::Lagged(_)) => { +//! let _current = listener.view(); +//! } +//! Err(EventStreamError::Closed) => break, +//! } +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! Channels are bounded and allocated on first subscription. A slow consumer +//! receives [`EventStreamError::Lagged`] and can rebuild from its listener's +//! current view. Sequence numbers are local to each scope. +//! +//! # Internal invariants +//! +//! Actors own operational state; live scopes only own projections. Parent +//! views are derived from child scopes, and actor-to-hub references are weak. +//! Registry guards never cross actor calls or `.await`. Scope publication is +//! serialized before touching the event sender. +//! +//! Supervised torrents and trackers keep their streams open while restarting. +//! Final ownership teardown closes each scope once and rejects late updates. + +mod event; +mod handle; +mod hub; +mod stream; +mod view; + +/// Bounded event capacities for each live scope. +/// +/// Channels are allocated lazily when the first listener subscribes, so these +/// capacities do not impose a per-scope allocation on unobserved peers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LiveSettings { + pub engine_event_capacity: usize, + pub torrent_event_capacity: usize, + pub peer_event_capacity: usize, + pub tracker_event_capacity: usize, +} + +impl Default for LiveSettings { + fn default() -> Self { + Self { + engine_event_capacity: 256, + torrent_event_capacity: 256, + peer_event_capacity: 64, + tracker_event_capacity: 64, + } + } +} + +pub use event::{ + EngineEvent, EngineEventKind, LiveHealth, LiveHealthLevel, PeerEvent, PeerEventKind, + SequencedEvent, TorrentEvent, TorrentEventKind, TrackerEvent, TrackerEventKind, +}; +pub(crate) use handle::PeerIdentity; +pub use handle::{PeerHandle, PeerListener, TrackerHandle, TrackerId, TrackerListener}; +pub(crate) use hub::{Hub, HubInner}; +pub use stream::{ + EngineListener, EventListener, EventStreamError, EventSubscription, LivePublisher, + TorrentListener, +}; +pub use view::{EngineView, PeerView, TorrentView, TrackerStatus, TrackerView}; + +pub use crate::metrics::{ + ByteCount, BytesPerSecond, ContentProgress, HasTransferMetrics, PeerMetrics, Seconds, + TorrentMetrics, TrackerMetrics, TrafficTotals, TransferMetrics, TransferRates, TransferSample, +}; + +#[cfg(test)] +mod tests { + use std::{ + net::{Ipv4Addr, SocketAddr}, + time::Duration, + }; + + use super::*; + use crate::{hashes::InfoHash, peer::PeerId, torrent::TorrentState, tracker::Tracker}; + + fn connected_peer_view() -> PeerView { + PeerView { + address: Some(SocketAddr::from((Ipv4Addr::LOCALHOST, 6881))), + client: Some("Unknown".to_string()), + connected: true, + metrics: PeerMetrics { + peer_choking: true, + client_choking: true, + ..Default::default() + }, + } + } + + fn pending_tracker_view() -> TrackerView { + TrackerView { + endpoint: "https://tracker.example".to_string(), + status: TrackerStatus::Pending, + metrics: TrackerMetrics::default(), + } + } + + fn benchmark_torrent_view(info_hash: InfoHash, name: &str) -> TorrentView { + TorrentView { + info_hash, + name: name.to_string(), + state: TorrentState::Downloading, + auto_start: true, + sufficient_peers: 1, + peer_count: 0, + tracker_count: 0, + output_path: None, + metrics: TorrentMetrics::new( + TransferMetrics::default(), + ContentProgress { + total_bytes: Some(ByteCount(1_000)), + verified_bytes: ByteCount::ZERO, + remaining_bytes: Some(ByteCount(1_000)), + progress_fraction: Some(0.0), + completed_pieces: 0, + partial_pieces: 0, + total_pieces: 1, + }, + ), + } + } + + #[tokio::test] + async fn peer_metrics_do_not_republish_the_torrent_projection() { + let hub = Hub::new(); + let info_hash = InfoHash::from_bytes([1; 20]); + let torrent = benchmark_torrent_view(info_hash, "isolated"); + hub.initialize_torrent_projection(torrent.clone()); + let scope = hub.ensure_torrent_scope(info_hash).unwrap(); + let mut torrent_events = scope.publisher.subscribe(); + let peer = hub + .register_peer_scope( + PeerIdentity { + torrent: info_hash, + peer: PeerId::Unknown([2; 20]), + }, + connected_peer_view(), + ) + .unwrap(); + let mut peer_view = peer.view(); + peer_view.metrics.transfer.samples.push(TransferSample { + previous_totals: Default::default(), + current_totals: Default::default(), + elapsed: Duration::from_secs(1), + }); + + peer.publish_metrics(peer_view); + + assert_eq!(scope.publisher.view(), Some(torrent)); + assert!( + tokio::time::timeout(Duration::from_millis(20), torrent_events.recv()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn tracker_restart_keeps_listener_open_until_final_stop() { + let hub = Hub::new(); + let source = Tracker::Http("https://tracker.example/announce".to_string()); + let tracker = hub + .register_tracker_scope( + InfoHash::from_bytes([3; 20]), + &source, + pending_tracker_view(), + ) + .unwrap(); + let mut listener = tracker.listener(); + + tracker.restarting(); + assert_eq!( + listener.recv().await.unwrap().kind, + TrackerEventKind::Restarting + ); + assert_eq!(listener.view().status, TrackerStatus::Restarting); + + let restarted = hub + .register_tracker_scope( + InfoHash::from_bytes([3; 20]), + &source, + pending_tracker_view(), + ) + .unwrap(); + assert_eq!(restarted.id(), tracker.id()); + restarted.announce_succeeded(TrackerMetrics { + latest_peers_returned: Some(2), + ..Default::default() + }); + assert_eq!( + listener.recv().await.unwrap().kind, + TrackerEventKind::AnnounceSucceeded { peers_returned: 2 } + ); + + tracker.stopped(); + tracker.stopped(); + tracker.announce_failed(TrackerMetrics::default()); + assert_eq!( + listener.recv().await.unwrap().kind, + TrackerEventKind::Stopped + ); + assert_eq!(listener.recv().await, Err(EventStreamError::Closed)); + + let replacement = hub + .register_tracker_scope( + InfoHash::from_bytes([3; 20]), + &source, + pending_tracker_view(), + ) + .unwrap(); + assert_ne!(replacement.id(), tracker.id()); + assert_eq!(replacement.view().status, TrackerStatus::Pending); + } + + #[test] + #[ignore = "performance benchmark; run explicitly with --ignored --nocapture"] + fn large_scope_tree_benchmark() { + use std::time::Instant; + + let hub = Hub::new(); + let started = Instant::now(); + for torrent_index in 0_u16..100 { + let bytes = torrent_index.to_be_bytes(); + let mut hash = [0_u8; 20]; + hash[..2].copy_from_slice(&bytes); + hub.initialize_torrent_projection(benchmark_torrent_view( + InfoHash::from_bytes(hash), + &format!("torrent-{torrent_index}"), + )); + hub.ensure_torrent_scope(InfoHash::from_bytes(hash)) + .unwrap() + .mark_registered_for_benchmark(); + for peer_index in 0_u8..10 { + hub.register_peer_scope( + PeerIdentity { + torrent: InfoHash::from_bytes(hash), + peer: PeerId::Unknown([peer_index; 20]), + }, + connected_peer_view(), + ) + .unwrap(); + } + } + let construction = started.elapsed(); + + let started = Instant::now(); + for _ in 0..10 { + for torrent_index in 0_u16..100 { + let bytes = torrent_index.to_be_bytes(); + let mut hash = [0_u8; 20]; + hash[..2].copy_from_slice(&bytes); + for peer in hub.peer_handles(InfoHash::from_bytes(hash)) { + peer.publish_metrics(peer.view()); + } + } + } + let updates = started.elapsed(); + + let started = Instant::now(); + let view = hub.view(); + let view_construction = started.elapsed(); + assert_eq!(view.torrent_count(), 100); + assert!( + view + .torrents + .windows(2) + .all(|pair| { pair[0].info_hash.as_bytes() <= pair[1].info_hash.as_bytes() }) + ); + + let removal_hash = InfoHash::from_bytes([255; 20]); + hub.initialize_torrent_projection(benchmark_torrent_view(removal_hash, "removal")); + let removal_peers = (0_u16..1_000) + .map(|peer_index| { + let bytes = peer_index.to_be_bytes(); + let mut id = [0_u8; 20]; + id[..2].copy_from_slice(&bytes); + hub.register_peer_scope( + PeerIdentity { + torrent: removal_hash, + peer: PeerId::Unknown(id), + }, + connected_peer_view(), + ) + .unwrap() + }) + .collect::>(); + let zero_listener_slots = removal_peers + .iter() + .map(|peer| peer.inner.publisher.allocated_event_slots()) + .sum::(); + let zero_listener_memory_lower_bound = removal_peers + .iter() + .map(|peer| peer.inner.publisher.allocation_lower_bound_bytes()) + .sum::(); + let started = Instant::now(); + hub.remove_torrent_scope(removal_hash); + let removal = started.elapsed(); + + let burst = LivePublisher::new(0_u64, 8); + let mut lagging = burst.subscribe(); + let started = Instant::now(); + for value in 1..=10_000 { + burst.replace_view_and_emit(value, value); + } + let burst_publication = started.elapsed(); + let lagged_by = match futures::executor::block_on(lagging.recv()) { + Err(EventStreamError::Lagged(skipped)) => skipped, + result => panic!("expected a lagged subscription, got {result:?}"), + }; + + assert_eq!(zero_listener_slots, 0); + assert!(lagged_by > 0); + eprintln!( + "100 torrents / 1,000 peers: {construction:?}; 10,000 peer updates: \ + {updates:?}; engine view: {view_construction:?}; remove 1,000 children: \ + {removal:?}; zero-listener allocated event slots: {zero_listener_slots}; \ + zero-listener publisher memory lower bound: {zero_listener_memory_lower_bound} bytes; \ + 10,000-event burst: {burst_publication:?}; lagged by: {lagged_by}" + ); + } +} diff --git a/crates/libtortillas/src/live/stream.rs b/crates/libtortillas/src/live/stream.rs new file mode 100644 index 00000000..8a8d30a7 --- /dev/null +++ b/crates/libtortillas/src/live/stream.rs @@ -0,0 +1,405 @@ +//! Generic current-view and future-event stream machinery. +//! +//! This module owns the complete stream lifecycle: lazy channel allocation, +//! ordered publication, terminal closure, subscriptions, and listeners. + +use std::{ + fmt, + pin::Pin, + sync::{Arc, Mutex, MutexGuard}, + task::{Context, Poll}, +}; + +use futures::{Stream, future::poll_fn}; +use thiserror::Error; +use tokio::sync::broadcast; +use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; + +use super::{EngineEventKind, EngineView, SequencedEvent, TorrentEventKind, TorrentView}; + +fn mutex_lock(lock: &Mutex) -> MutexGuard<'_, T> { + lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Generic publisher for coherent current state and incremental events. +/// +/// The same primitive backs engine, torrent, peer, and tracker listeners. It +/// can also be reused by future protocol integrations without introducing +/// another channel or listener implementation. +#[derive(Debug, Clone)] +pub struct LivePublisher { + state: Arc>>, + channel: Arc>, +} + +#[derive(Debug)] +struct LiveState { + view: V, + sequence: u64, + closed: bool, +} + +#[derive(Debug)] +struct LiveChannel { + capacity: usize, + sender: Mutex>>>, +} + +impl LivePublisher +where + V: Clone + Send + Sync + 'static, + E: Clone + Send + 'static, +{ + /// A zero capacity is normalized to one. + #[must_use] + pub fn new(initial_view: V, event_capacity: usize) -> Self { + Self { + state: Arc::new(Mutex::new(LiveState { + view: initial_view, + sequence: 0, + closed: false, + })), + channel: Arc::new(LiveChannel { + capacity: event_capacity.max(1), + sender: Mutex::new(None), + }), + } + } + + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + let state = mutex_lock(&self.state); + if state.closed { + return EventSubscription::closed(); + } + let mut slot = mutex_lock(&self.channel.sender); + let sender = slot.get_or_insert_with(|| { + let (sender, _) = broadcast::channel(self.channel.capacity); + sender + }); + EventSubscription::from_receiver(sender.subscribe(), sender.downgrade()) + } + + #[cfg(test)] + pub(crate) fn has_event_channel(&self) -> bool { + mutex_lock(&self.channel.sender).is_some() + } + + #[cfg(test)] + pub(crate) fn allocated_event_slots(&self) -> usize { + usize::from(self.has_event_channel()) * self.channel.capacity + } + + #[cfg(test)] + pub(crate) fn allocation_lower_bound_bytes(&self) -> usize { + std::mem::size_of_val(self.state.as_ref()) + + std::mem::size_of_val(self.channel.as_ref()) + + self + .allocated_event_slots() + .saturating_mul(std::mem::size_of::>()) + } + + #[must_use] + pub fn listener(&self) -> EventListener { + let state = Arc::clone(&self.state); + EventListener::new(self.subscribe(), move || mutex_lock(&state).view.clone()) + } + + #[must_use] + pub fn view(&self) -> V { + mutex_lock(&self.state).view.clone() + } + + pub(crate) fn is_closed(&self) -> bool { + mutex_lock(&self.state).closed + } + + /// Replaces the current view without emitting an event. + /// + /// Returns `false` when the publisher has already closed. + pub(crate) fn replace_view(&self, view: V) -> bool { + let mut state = mutex_lock(&self.state); + if state.closed { + return false; + } + state.view = view; + true + } + + /// Replaces the current view and emits the corresponding event. + /// + /// Returns `false` when the publisher has already closed. + pub(crate) fn replace_view_and_emit(&self, view: V, event: E) -> bool { + self.apply_and_emit(|current| *current = view, event) + } + + /// Emits an event using this publisher's monotonic sequence. + /// + /// Returns `false` when the publisher has already closed. + pub(crate) fn emit_without_view_change(&self, event: E) -> bool { + self.apply_and_emit(|_| {}, event) + } + + /// Atomically updates the view and permanently closes this publisher after + /// delivering one terminal event. + /// + /// Returns `false` if another caller already closed the publisher. + pub(crate) fn close_with_terminal_event(&self, view: V, event: E) -> bool { + let mut state = mutex_lock(&self.state); + if state.closed { + return false; + } + state.view = view; + state.sequence = state.sequence.saturating_add(1); + state.closed = true; + let mut sender = mutex_lock(&self.channel.sender); + if let Some(sender) = sender.take() { + let _ = sender.send(SequencedEvent { + sequence: state.sequence, + kind: event, + }); + } + true + } + + fn apply_and_emit(&self, edit: impl FnOnce(&mut V), event: E) -> bool { + let mut state = mutex_lock(&self.state); + if state.closed { + return false; + } + edit(&mut state.view); + state.sequence = state.sequence.saturating_add(1); + self.send_if_subscribed(&state, event); + true + } + + fn send_if_subscribed(&self, state: &LiveState, event: E) { + if let Some(sender) = mutex_lock(&self.channel.sender).as_ref() { + let _ = sender.send(SequencedEvent { + sequence: state.sequence, + kind: event, + }); + } + } +} + +impl LivePublisher, E> +where + V: Clone + Send + Sync + 'static, + E: Clone + Send + 'static, +{ + pub(crate) fn install_initial_view(&self, view: V) -> bool { + let mut state = mutex_lock(&self.state); + if state.closed || state.view.is_some() { + return false; + } + state.view = Some(view); + true + } +} + +// Subscription + +/// A generic, lag-aware subscription to events from a live publisher. +/// +/// `EventSubscription` implements [`Stream`], so applications can use the +/// standard async stream combinators from `futures` or `tokio-stream`. The +/// inherent [`Self::recv`] method remains available for Tokio-style loops. +pub struct EventSubscription { + sender: broadcast::WeakSender>, + stream: BroadcastStream>, +} + +impl EventSubscription { + pub(crate) fn new(sender: broadcast::Sender>) -> Self { + Self::from_receiver(sender.subscribe(), sender.downgrade()) + } + + pub(crate) fn from_receiver( + receiver: broadcast::Receiver>, + sender: broadcast::WeakSender>, + ) -> Self { + Self { + stream: BroadcastStream::new(receiver), + sender, + } + } + + pub(crate) fn closed() -> Self { + let (sender, receiver) = broadcast::channel(1); + let weak = sender.downgrade(); + drop(sender); + Self::from_receiver(receiver, weak) + } + + pub async fn recv(&mut self) -> Result, EventStreamError> { + poll_fn(|context| Pin::new(&mut *self).poll_next(context)) + .await + .unwrap_or(Err(EventStreamError::Closed)) + } + + /// Creates another subscription beginning at the publisher's current + /// event position. + #[must_use] + pub fn resubscribe(&self) -> Self { + self.sender.upgrade().map_or_else(Self::closed, Self::new) + } +} + +impl Stream for EventSubscription { + type Item = Result, EventStreamError>; + + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + match Pin::new(&mut self.stream).poll_next(context) { + Poll::Ready(Some(Ok(event))) => Poll::Ready(Some(Ok(event))), + Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(events)))) => { + Poll::Ready(Some(Err(EventStreamError::Lagged(events)))) + } + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +impl fmt::Debug for EventSubscription { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EventSubscription") + .field( + "receiver_count", + &self + .sender + .upgrade() + .map_or(0, |sender| sender.receiver_count()), + ) + .finish_non_exhaustive() + } +} + +/// Errors produced while receiving live events. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum EventStreamError { + /// This consumer fell behind and the specified number of events were + /// dropped. The subscription remains usable. + #[error("live event subscriber lagged by {0} events")] + Lagged(u64), + /// The publisher closed the event stream. + #[error("live event stream closed")] + Closed, +} + +// Listener + +/// A generic event stream paired with a synchronous current-state reader. +/// +/// The listener itself implements [`Stream`]. Its view type and event type are +/// generic so engine, torrent, peer, tracker, and future protocol integrations +/// all reuse the same implementation. +pub struct EventListener { + events: EventSubscription, + read_view: Arc V + Send + Sync>, +} + +impl EventListener { + pub(crate) fn new( + events: EventSubscription, read_view: impl Fn() -> V + Send + Sync + 'static, + ) -> Self { + Self { + events, + read_view: Arc::new(read_view), + } + } + + /// Waits for the next live event. + pub async fn recv(&mut self) -> Result, EventStreamError> { + self.events.recv().await + } + + /// Reads the latest coherent state without creating a persistence snapshot. + pub fn view(&self) -> V { + (self.read_view)() + } + + /// Returns the underlying event subscription. + #[must_use] + pub const fn subscription(&self) -> &EventSubscription { + &self.events + } +} + +impl Stream for EventListener { + type Item = Result, EventStreamError>; + + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.events).poll_next(context) + } +} + +impl fmt::Debug for EventListener { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EventListener") + .field("events", &self.events) + .finish_non_exhaustive() + } +} + +/// Engine listener with typed events and current state. +pub type EngineListener = EventListener; + +/// Live listener scoped to one torrent. +pub type TorrentListener = EventListener, TorrentEventKind>; + +#[cfg(test)] +mod tests { + use std::{sync::Arc, thread}; + + use super::*; + + #[test] + fn concurrent_update_and_close_never_accepts_an_update_after_terminal() { + for _ in 0..100 { + let publisher = Arc::new(LivePublisher::new(0_u64, 8)); + let update = Arc::clone(&publisher); + let close = Arc::clone(&publisher); + let update_thread = thread::spawn(move || update.replace_view_and_emit(1, "updated")); + let close_thread = thread::spawn(move || close.close_with_terminal_event(2, "closed")); + let update_accepted = update_thread.join().unwrap(); + let close_accepted = close_thread.join().unwrap(); + + assert!(close_accepted); + assert!(!publisher.replace_view_and_emit(3, "late")); + assert_eq!(publisher.view(), 2); + if update_accepted { + assert_eq!(publisher.view(), 2); + } + } + } + + #[test] + fn zero_capacity_is_normalized_without_panicking() { + let publisher = LivePublisher::new(0_u8, 0); + let _subscription = publisher.subscribe(); + assert!(publisher.emit_without_view_change("event")); + } + + #[test] + fn publishers_without_listeners_do_not_allocate_event_channels() { + let publisher = LivePublisher::<_, ()>::new(0_u8, 8); + + assert!(!publisher.has_event_channel()); + let _listener = publisher.listener(); + assert!(publisher.has_event_channel()); + } + + #[test] + fn initial_view_installation_does_not_overwrite_a_published_view() { + let publisher = LivePublisher::<_, ()>::new(None, 8); + + assert!(publisher.install_initial_view(1_u8)); + assert!(!publisher.install_initial_view(2)); + assert_eq!(publisher.view(), Some(1)); + } +} diff --git a/crates/libtortillas/src/live/view.rs b/crates/libtortillas/src/live/view.rs new file mode 100644 index 00000000..e1b43f3b --- /dev/null +++ b/crates/libtortillas/src/live/view.rs @@ -0,0 +1,172 @@ +use std::{net::SocketAddr, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + engine::EngineStatus, + hashes::InfoHash, + metrics::{ + HasTransferMetrics, PeerMetrics, TorrentMetrics, TrackerMetrics, TransferMetrics, + TransferSample, + }, + peer::Peer, + torrent::TorrentState, +}; + +/// Current engine state maintained by a listener. +/// +/// Unlike persistence snapshots, views are current projections updated by the +/// actor hierarchy. A listener always reads the projection directly, including +/// after a lagged event subscription. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EngineView { + pub status: EngineStatus, + pub torrents: Vec, +} + +impl EngineView { + #[must_use] + pub fn torrent_count(&self) -> usize { + self.torrents.len() + } +} + +/// Current state of one torrent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TorrentView { + pub info_hash: InfoHash, + pub name: String, + pub state: TorrentState, + pub auto_start: bool, + pub sufficient_peers: u64, + pub peer_count: u64, + pub tracker_count: u64, + pub output_path: Option, + pub metrics: TorrentMetrics, +} + +impl TorrentView { + #[must_use] + pub const fn has_metadata(&self) -> bool { + self.metrics.progress.total_bytes.is_some() + } + + #[must_use] + pub const fn is_ready(&self) -> bool { + matches!(self.state, TorrentState::Ready) + } +} + +/// Current state of a connected or recently disconnected peer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerView { + pub address: Option, + pub client: Option, + pub connected: bool, + pub metrics: PeerMetrics, +} + +impl PeerView { + pub(crate) fn from_peer(peer: &Peer, connected: bool) -> Self { + Self::from_peer_with_samples(peer, connected, Vec::new()) + } + + pub(crate) fn from_peer_with_samples( + peer: &Peer, connected: bool, samples: Vec, + ) -> Self { + let mut metrics = peer.metrics(); + metrics.transfer.samples = samples; + Self::from_peer_with_metrics(peer, connected, metrics) + } + + pub(crate) fn from_peer_with_metrics( + peer: &Peer, connected: bool, metrics: PeerMetrics, + ) -> Self { + Self { + address: Some(peer.socket_addr()), + client: peer.id.map(|id| id.client_name().to_string()), + connected, + metrics, + } + } +} + +impl HasTransferMetrics for PeerView { + fn transfer_metrics(&self) -> &TransferMetrics { + self.metrics.transfer_metrics() + } +} + +/// Public tracker identity and latest announce outcome. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TrackerView { + /// Tracker URL with credentials removed. + pub endpoint: String, + pub status: TrackerStatus, + pub metrics: TrackerMetrics, +} + +impl HasTransferMetrics for TrackerView { + fn transfer_metrics(&self) -> &TransferMetrics { + self.metrics.transfer_metrics() + } +} + +/// Lifecycle and latest announce outcome for a tracker. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TrackerStatus { + /// The tracker actor is running but has not completed an announce. + Pending, + /// The latest announce completed successfully. + Healthy, + /// The latest announce failed while the actor remained available. + Degraded, + /// The actor stopped abnormally and supervision may restart it. + Restarting, + /// The tracker actor stopped and will emit no more events. + Stopped, +} + +impl TrackerStatus { + /// Whether the tracker actor can still produce announcements. + #[must_use] + pub const fn is_active(self) -> bool { + !matches!(self, Self::Stopped) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::metrics::{ByteCount, BytesPerSecond, TrafficTotals, TransferRates, TransferSample}; + + #[test] + fn peer_view_uses_canonical_byte_units() { + let peer = PeerView { + address: None, + client: None, + connected: true, + metrics: PeerMetrics { + peer_interested: true, + available_pieces: 1, + transfer: TransferMetrics::from_sample(TransferSample { + previous_totals: TrafficTotals::default(), + current_totals: TrafficTotals { + downloaded: ByteCount(3), + uploaded: ByteCount(2), + }, + elapsed: Duration::from_secs(1), + }), + ..Default::default() + }, + }; + + let peers = [peer.clone(), peer]; + let rates = TransferRates::aggregate(&peers).unwrap(); + + assert_eq!(rates.download, BytesPerSecond(6)); + assert_eq!(rates.upload, BytesPerSecond(4)); + } +} diff --git a/crates/libtortillas/src/metainfo/file.rs b/crates/libtortillas/src/metainfo/file.rs index 124d2b63..d4fb3f90 100644 --- a/crates/libtortillas/src/metainfo/file.rs +++ b/crates/libtortillas/src/metainfo/file.rs @@ -20,12 +20,12 @@ pub struct TorrentFile { pub announce: Option, /// Secondary announce URIs for different trackers, and protocols. Also can /// be used as a backup - #[serde(rename(deserialize = "announce-list"))] + #[serde(rename = "announce-list")] pub announce_list: Option>>, // Note: This is a list of lists pub comment: Option, - #[serde(rename(deserialize = "created by"))] + #[serde(rename = "created by")] pub created_by: Option, - #[serde(rename(deserialize = "creation date"))] + #[serde(rename = "creation date")] pub creation_date: Option, // Typically stored as unix timestamp pub encoding: Option, pub info: Info, @@ -117,7 +117,7 @@ pub enum InfoKeys { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InfoFile { /// The length of the file, in bytes. - pub length: usize, + pub length: u64, /// Subdirectory names for this file, the last of which is the actual file /// name (a zero length list is an error case). @@ -145,7 +145,10 @@ impl Info { pub fn total_length(&self) -> usize { match &self.file { InfoKeys::Single { length, .. } => *length as usize, - InfoKeys::Multi { files } => files.iter().map(|f| f.length).sum(), + InfoKeys::Multi { files } => files + .iter() + .map(|file| usize::try_from(file.length).unwrap_or(usize::MAX)) + .fold(0, usize::saturating_add), } } } diff --git a/crates/libtortillas/src/metrics.rs b/crates/libtortillas/src/metrics.rs new file mode 100644 index 00000000..f5739975 --- /dev/null +++ b/crates/libtortillas/src/metrics.rs @@ -0,0 +1,535 @@ +//! Canonical transfer and verified-content measurements shared by actors and +//! presentation views. +//! +//! Bytes are the canonical unit. [`TransferMetrics`] is shared by peer state, +//! peer statistics, live views, and torrent aggregation, so presentation code +//! never performs a KiB/s conversion. [`TrafficTotals`] measures wire traffic +//! and can include duplicate or rejected data; [`ContentProgress`] measures +//! verified torrent payload and must remain separate. +//! +//! Rates are never stored as mutable metrics. [`TransferMetrics`] retains raw +//! cumulative byte counters and the raw counter samples needed to derive a +//! rate. An absent sample means no interval has been collected. A present +//! sample whose counters did not change is a known zero-rate interval. ETA is +//! likewise derived from verified remaining bytes and the sampled download +//! rate rather than stored independently. + +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +/// A quantity of bytes. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize +)] +#[serde(transparent)] +pub struct ByteCount(pub u64); + +impl ByteCount { + pub const ZERO: Self = Self(0); + + #[must_use] + pub const fn saturating_add(self, other: Self) -> Self { + Self(self.0.saturating_add(other.0)) + } + + #[must_use] + pub const fn saturating_sub(self, other: Self) -> Self { + Self(self.0.saturating_sub(other.0)) + } +} + +/// A byte rate measured over one second. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize +)] +#[serde(transparent)] +pub struct BytesPerSecond(pub u64); + +impl BytesPerSecond { + pub const ZERO: Self = Self(0); + + #[must_use] + pub const fn saturating_add(self, other: Self) -> Self { + Self(self.0.saturating_add(other.0)) + } +} + +/// A duration represented as whole seconds. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize +)] +#[serde(transparent)] +pub struct Seconds(pub u64); + +/// Wire traffic totals. These values may include duplicate or rejected data +/// and must not be interpreted as verified torrent content. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TrafficTotals { + pub downloaded: ByteCount, + pub uploaded: ByteCount, +} + +impl TrafficTotals { + #[must_use] + pub const fn saturating_add(self, other: Self) -> Self { + Self { + downloaded: self.downloaded.saturating_add(other.downloaded), + uploaded: self.uploaded.saturating_add(other.uploaded), + } + } +} + +/// Measured download and upload rates. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransferRates { + pub download: BytesPerSecond, + pub upload: BytesPerSecond, +} + +impl TransferRates { + /// Aggregates every available sample while preserving unknown-versus-zero + /// semantics. + #[must_use] + pub fn aggregate<'a, T: HasTransferMetrics + ?Sized + 'a>( + sources: impl IntoIterator, + ) -> Option { + let mut aggregate = None::; + for source in sources { + let Some(rates) = source.transfer_rates() else { + continue; + }; + let current = aggregate.get_or_insert_default(); + current.download = current.download.saturating_add(rates.download); + current.upload = current.upload.saturating_add(rates.upload); + } + aggregate + } + + #[must_use] + fn between(previous: TrafficTotals, current: TrafficTotals, elapsed: Duration) -> Self { + fn rate(previous: ByteCount, current: ByteCount, elapsed: Duration) -> BytesPerSecond { + let elapsed_nanos = elapsed.as_nanos(); + if elapsed_nanos == 0 || current < previous { + return BytesPerSecond::ZERO; + } + let bytes = u128::from(current.0.saturating_sub(previous.0)); + let per_second = bytes + .saturating_mul(1_000_000_000) + .checked_div(elapsed_nanos) + .unwrap_or(0); + BytesPerSecond(u64::try_from(per_second).unwrap_or(u64::MAX)) + } + + Self { + download: rate(previous.downloaded, current.downloaded, elapsed), + upload: rate(previous.uploaded, current.uploaded, elapsed), + } + } +} + +/// A raw pair of cumulative counter observations and the time between them. +/// +/// The sample deliberately stores totals and elapsed time rather than bytes per +/// second. Consumers derive the rate through [`TransferSample::rates`] or +/// [`HasTransferMetrics::transfer_rates`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransferSample { + pub previous_totals: TrafficTotals, + pub current_totals: TrafficTotals, + pub elapsed: Duration, +} + +impl TransferSample { + #[must_use] + pub fn rates(self) -> TransferRates { + TransferRates::between(self.previous_totals, self.current_totals, self.elapsed) + } +} + +/// Cumulative traffic totals and the raw intervals available for deriving a +/// current rate. +/// +/// A leaf actor normally publishes one sample. Aggregated scopes retain one +/// sample per child because each child can have a different sampling interval. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransferMetrics { + pub totals: TrafficTotals, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub samples: Vec, +} + +impl TransferMetrics { + #[must_use] + pub fn from_sample(sample: TransferSample) -> Self { + Self { + totals: sample.current_totals, + samples: vec![sample], + } + } + + /// Derives the latest aggregate rate from raw byte-counter samples. + /// + /// `None` means no interval has been sampled. `Some(default())` means at + /// least one interval was measured and no traffic occurred. + #[must_use] + pub fn rates(&self) -> Option { + let mut aggregate = None::; + for sample in &self.samples { + let rates = sample.rates(); + let current = aggregate.get_or_insert_default(); + current.download = current.download.saturating_add(rates.download); + current.upload = current.upload.saturating_add(rates.upload); + } + aggregate + } + + /// Combines raw counters and samples from every child metric scope. + #[must_use] + pub fn aggregate<'a, T: HasTransferMetrics + ?Sized + 'a>( + sources: impl IntoIterator, + ) -> Self { + let mut aggregate = Self::default(); + for source in sources { + let metrics = source.transfer_metrics(); + aggregate.totals = aggregate.totals.saturating_add(metrics.totals); + aggregate.samples.extend_from_slice(&metrics.samples); + } + aggregate + } +} + +/// Peer-specific metrics layered on top of the shared transfer measurements. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerMetrics { + pub transfer: TransferMetrics, + pub peer_choking: bool, + pub peer_interested: bool, + pub client_choking: bool, + pub client_interested: bool, + pub available_pieces: u64, +} + +impl HasTransferMetrics for PeerMetrics { + fn transfer_metrics(&self) -> &TransferMetrics { + &self.transfer + } +} + +/// Tracker-specific metrics layered on top of the shared transfer +/// measurements. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TrackerMetrics { + pub transfer: TransferMetrics, + pub announce_attempts: u64, + pub announce_successes: u64, + pub total_peers_received: u64, + pub latest_peers_returned: Option, +} + +impl HasTransferMetrics for TrackerMetrics { + fn transfer_metrics(&self) -> &TransferMetrics { + &self.transfer + } +} + +/// Verified torrent payload progress, deliberately separate from peer wire +/// traffic. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ContentProgress { + pub total_bytes: Option, + pub verified_bytes: ByteCount, + pub remaining_bytes: Option, + pub progress_fraction: Option, + pub completed_pieces: u64, + pub partial_pieces: u64, + pub total_pieces: u64, +} + +/// A coherent metrics publication unit for any application adapter. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TorrentMetrics { + pub traffic: TransferMetrics, + pub progress: ContentProgress, + pub eta: Option, +} + +impl TorrentMetrics { + #[must_use] + pub fn new(traffic: TransferMetrics, progress: ContentProgress) -> Self { + let eta = Self::calculate_eta(&progress, traffic.rates()); + Self { + traffic, + progress, + eta, + } + } + + #[must_use] + pub fn calculate_eta( + progress: &ContentProgress, rates: Option, + ) -> Option { + let remaining = progress.remaining_bytes?; + let download_rate = rates?.download; + (download_rate.0 > 0).then(|| Seconds(remaining.0.div_ceil(download_rate.0))) + } +} + +impl HasTransferMetrics for TorrentMetrics { + fn transfer_metrics(&self) -> &TransferMetrics { + &self.traffic + } +} + +/// Narrow capability used by transfer aggregation algorithms. +pub trait HasTransferMetrics { + fn transfer_metrics(&self) -> &TransferMetrics; + + /// Calculates transfer rates from raw cumulative counter samples. + #[must_use] + fn transfer_rates(&self) -> Option { + self.transfer_metrics().rates() + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct TimedTransferSample { + at: Instant, + totals: TrafficTotals, +} + +impl TimedTransferSample { + #[must_use] + pub(crate) fn new(at: Instant, totals: TrafficTotals) -> Self { + Self { at, totals } + } + + #[must_use] + pub(crate) fn sample_since(self, previous: Self) -> TransferSample { + TransferSample { + previous_totals: previous.totals, + current_totals: self.totals, + elapsed: self.at.saturating_duration_since(previous.at), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct Source(TransferMetrics); + + impl HasTransferMetrics for Source { + fn transfer_metrics(&self) -> &TransferMetrics { + &self.0 + } + } + + fn one_second_sample(downloaded: u64, uploaded: u64) -> TransferSample { + TransferSample { + previous_totals: TrafficTotals::default(), + current_totals: TrafficTotals { + downloaded: ByteCount(downloaded), + uploaded: ByteCount(uploaded), + }, + elapsed: Duration::from_secs(1), + } + } + + #[test] + fn aggregate_rates_when_no_peers_are_sampled_then_returns_unknown() { + let peers = [Source(TransferMetrics::default())]; + assert_eq!(TransferRates::aggregate(&peers), None); + } + + #[test] + fn transfer_rates_when_sample_is_zero_then_are_known_zero() { + let sample = TimedTransferSample::new( + Instant::now() + Duration::from_secs(1), + TrafficTotals::default(), + ) + .sample_since(TimedTransferSample::new( + Instant::now(), + TrafficTotals::default(), + )); + + assert_eq!(sample.rates(), TransferRates::default()); + assert_eq!( + TransferRates::aggregate(&[Source(TransferMetrics::from_sample(sample))]), + Some(TransferRates::default()) + ); + } + + #[test] + fn aggregate_rates_when_some_peers_are_unsampled_then_ignores_them() { + let peers = [ + Source(TransferMetrics::default()), + Source(TransferMetrics::from_sample(one_second_sample(10, 4))), + ]; + + assert_eq!( + TransferRates::aggregate(&peers), + Some(TransferRates { + download: BytesPerSecond(10), + upload: BytesPerSecond(4), + }) + ); + } + + #[test] + fn aggregate_rates_when_metric_scopes_differ_then_uses_shared_transfer_metrics() { + let peer = PeerMetrics { + transfer: TransferMetrics::from_sample(one_second_sample(10, 4)), + ..Default::default() + }; + let tracker = TrackerMetrics { + transfer: TransferMetrics::from_sample(one_second_sample(2, 1)), + ..Default::default() + }; + let torrent = TorrentMetrics::new( + TransferMetrics::from_sample(one_second_sample(3, 0)), + ContentProgress { + total_bytes: None, + verified_bytes: ByteCount::ZERO, + remaining_bytes: None, + progress_fraction: None, + completed_pieces: 0, + partial_pieces: 0, + total_pieces: 0, + }, + ); + let scopes: [&dyn HasTransferMetrics; 3] = [&peer, &tracker, &torrent]; + let aggregate = TransferMetrics::aggregate(scopes.iter().copied()); + + assert_eq!( + aggregate.totals, + TrafficTotals { + downloaded: ByteCount(15), + uploaded: ByteCount(5), + } + ); + assert_eq!( + aggregate.rates(), + Some(TransferRates { + download: BytesPerSecond(15), + upload: BytesPerSecond(5), + }) + ); + } + + #[test] + fn transfer_rates_when_counters_increase_then_use_bytes_per_second() { + let rates = TransferRates::between( + TrafficTotals::default(), + TrafficTotals { + downloaded: ByteCount(1_500), + uploaded: ByteCount(500), + }, + Duration::from_millis(500), + ); + + assert_eq!(rates.download, BytesPerSecond(3_000)); + assert_eq!(rates.upload, BytesPerSecond(1_000)); + } + + #[test] + fn transfer_rates_when_counters_reset_then_do_not_underflow() { + let rates = TransferRates::between( + TrafficTotals { + downloaded: ByteCount(10), + uploaded: ByteCount(10), + }, + TrafficTotals::default(), + Duration::from_secs(1), + ); + + assert_eq!(rates, TransferRates::default()); + } + + #[test] + fn eta_when_remaining_bytes_are_known_then_rounds_up() { + let progress = ContentProgress { + total_bytes: Some(ByteCount(13)), + verified_bytes: ByteCount::ZERO, + remaining_bytes: Some(ByteCount(13)), + progress_fraction: Some(0.0), + completed_pieces: 0, + partial_pieces: 0, + total_pieces: 1, + }; + + assert_eq!( + TorrentMetrics::calculate_eta( + &progress, + Some(TransferRates { + download: BytesPerSecond(6), + upload: BytesPerSecond::ZERO, + }) + ), + Some(Seconds(3)) + ); + assert_eq!( + TorrentMetrics::calculate_eta(&progress, Some(TransferRates::default())), + None + ); + } + + #[test] + fn serialized_metrics_round_trip_without_unit_conversion() { + let metrics = TransferMetrics::from_sample(TransferSample { + previous_totals: TrafficTotals { + downloaded: ByteCount(724), + uploaded: ByteCount(412), + }, + current_totals: TrafficTotals { + downloaded: ByteCount(1_024), + uploaded: ByteCount(512), + }, + elapsed: Duration::from_secs(1), + }); + + let json = serde_json::to_string(&metrics).unwrap(); + assert!(!json.contains("rates")); + assert!(!json.contains("bytes_per_second")); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + metrics + ); + assert_eq!( + metrics.rates(), + Some(TransferRates { + download: BytesPerSecond(300), + upload: BytesPerSecond(100), + }) + ); + } +} diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index 05a86287..4e6a1381 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -28,18 +28,31 @@ use crate::{ settings::PeerSettings, torrent::{self, BLOCK_SIZE, TorrentActor}, }; +#[cfg(feature = "live")] +use crate::{ + live::{PeerHandle, PeerView}, + metrics::{HasTransferMetrics, PeerMetrics, TimedTransferSample, TransferMetrics}, +}; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PeerStats { pub(crate) id: PeerId, pub(crate) interested: bool, - pub(crate) choked: bool, - pub(crate) download_rate: usize, - pub(crate) upload_rate: usize, - pub(crate) bytes_downloaded: usize, - pub(crate) bytes_uploaded: usize, + pub(crate) client_choking: bool, + pub(crate) download_rate: u64, + pub(crate) upload_rate: u64, + #[cfg(feature = "live")] + pub(crate) metrics: PeerMetrics, +} + +#[cfg(feature = "live")] +impl HasTransferMetrics for PeerStats { + fn transfer_metrics(&self) -> &TransferMetrics { + self.metrics.transfer_metrics() + } } +#[cfg(not(feature = "live"))] #[derive(Clone, Copy, Debug)] struct RateSample { at: Instant, @@ -47,6 +60,7 @@ struct RateSample { bytes_uploaded: usize, } +#[cfg(not(feature = "live"))] impl RateSample { fn new(peer: &Peer) -> Self { Self { @@ -68,8 +82,23 @@ pub(crate) struct PeerActor { pending_block_requests: HashSet<(usize, usize, usize)>, pending_message_requests: VecDeque, + #[cfg(feature = "live")] + last_rate_sample: TimedTransferSample, + #[cfg(not(feature = "live"))] last_rate_sample: RateSample, settings: PeerSettings, + #[cfg(feature = "live")] + live_handle: PeerHandle, +} + +pub(crate) struct PeerActorArgs { + pub(crate) peer: Peer, + pub(crate) stream: PeerStream, + pub(crate) supervisor: ActorRef, + pub(crate) info_hash: InfoHash, + pub(crate) settings: PeerSettings, + #[cfg(feature = "live")] + pub(crate) live_handle: PeerHandle, } impl PeerActor { @@ -332,7 +361,42 @@ impl PeerActor { self.stream.send(msg).await } +} +#[cfg(feature = "live")] +impl PeerActor { + fn snapshot_stats(&mut self) -> Option { + let id = self.peer.id?; + let now = Instant::now(); + let totals = self.peer.traffic_totals(); + let sample = TimedTransferSample::new(now, totals); + let transfer_sample = sample.sample_since(self.last_rate_sample); + self.last_rate_sample = sample; + let transfer = TransferMetrics::from_sample(transfer_sample); + let mut metrics = self.peer.metrics(); + metrics.transfer = transfer; + self + .live_handle + .publish_metrics(PeerView::from_peer_with_metrics( + &self.peer, + true, + metrics.clone(), + )); + + let rates = metrics.transfer.rates().unwrap_or_default(); + Some(PeerStats { + id, + interested: metrics.peer_interested, + client_choking: metrics.client_choking, + download_rate: rates.download.0, + upload_rate: rates.upload.0, + metrics, + }) + } +} + +#[cfg(not(feature = "live"))] +impl PeerActor { fn snapshot_stats(&mut self) -> Option { let id = self.peer.id?; let now = Instant::now(); @@ -342,15 +406,10 @@ impl PeerActor { .duration_since(self.last_rate_sample.at) .as_secs() .max(1) as usize; - - let download_rate = bytes_downloaded.saturating_sub(self.last_rate_sample.bytes_downloaded) - / 1024 - / elapsed_secs; + let download_rate = + bytes_downloaded.saturating_sub(self.last_rate_sample.bytes_downloaded) / elapsed_secs; let upload_rate = - bytes_uploaded.saturating_sub(self.last_rate_sample.bytes_uploaded) / 1024 / elapsed_secs; - - self.peer.set_download_rate(download_rate); - self.peer.set_upload_rate(upload_rate); + bytes_uploaded.saturating_sub(self.last_rate_sample.bytes_uploaded) / elapsed_secs; self.last_rate_sample = RateSample { at: now, bytes_downloaded, @@ -360,29 +419,30 @@ impl PeerActor { Some(PeerStats { id, interested: self.peer.interested(), - choked: self.peer.choked(), - download_rate, - upload_rate, - bytes_downloaded, - bytes_uploaded, + client_choking: self.peer.choked(), + download_rate: u64::try_from(download_rate).unwrap_or(u64::MAX), + upload_rate: u64::try_from(upload_rate).unwrap_or(u64::MAX), }) } } impl Actor for PeerActor { - type Args = ( - Peer, - PeerStream, - ActorRef, - InfoHash, - PeerSettings, - ); + type Args = PeerActorArgs; type Error = PeerActorError; /// At this point, the peer has already been handshaked with. No other /// messages have been sent or received from the peer. async fn on_start(args: Self::Args, _: ActorRef) -> Result { - let (peer, mut stream, supervisor, info_hash, settings) = args; + let PeerActorArgs { + mut peer, + mut stream, + supervisor, + info_hash, + settings, + #[cfg(feature = "live")] + live_handle, + } = args; + peer.share_traffic_with(&stream.peer_state()); info!(peer_id = %peer.id.unwrap(), peer_addr = %stream, torrent_id = %info_hash, "Peer connected"); let bitfield = match supervisor.ask(torrent::commands::GetBitfield).await { @@ -402,11 +462,15 @@ impl Actor for PeerActor { supervisor .tell(torrent::events::PeerReady { id: peer.id.unwrap(), + available_pieces: peer.pieces.clone(), }) .await .map_err(|e| PeerActorError::SupervisorCommunicationFailed(e.to_string()))?; Ok(Self { + #[cfg(feature = "live")] + last_rate_sample: TimedTransferSample::new(Instant::now(), peer.traffic_totals()), + #[cfg(not(feature = "live"))] last_rate_sample: RateSample::new(&peer), peer, stream, @@ -414,6 +478,8 @@ impl Actor for PeerActor { pending_block_requests: HashSet::new(), pending_message_requests: VecDeque::with_capacity(settings.pending_message_capacity), settings, + #[cfg(feature = "live")] + live_handle, }) } @@ -423,7 +489,11 @@ impl Actor for PeerActor { if let Some(peer_id) = self.peer.id && let Err(err) = self .supervisor - .tell(torrent::commands::KillPeer { id: peer_id }) + .tell(torrent::commands::KillPeer { + id: peer_id, + #[cfg(feature = "live")] + handle: self.live_handle.clone(), + }) .await { warn!(error = %err, %peer_id, "Failed to notify torrent actor about stopped peer"); @@ -450,7 +520,11 @@ impl Actor for PeerActor { let id = self.peer.id.expect("Peer ID should exist"); if let Err(err) = self .supervisor - .tell(torrent::commands::KillPeer { id }) + .tell(torrent::commands::KillPeer { + id, + #[cfg(feature = "live")] + handle: self.live_handle.clone(), + }) .await { warn!(error = %err, "Failed to tell supervisor to kill peer"); @@ -526,7 +600,6 @@ impl Message for PeerActor { warn!("Received piece from peer without id; ignoring"); return; }; - self.peer.increment_bytes_downloaded(data.len()); let supervisor_msg = torrent::events::IncomingPiece { peer_id, index: index as usize, @@ -628,13 +701,11 @@ impl Message for PeerActor { match data { Some(data) => { - let uploaded_bytes = data.len(); self .stream .send(PeerMessages::Piece(index as u32, offset as u32, data)) .await .expect("Failed to send piece"); - self.peer.increment_bytes_uploaded(uploaded_bytes); } None => { warn!( @@ -671,6 +742,12 @@ impl Message for PeerActor { warn!("Received unexpected handshake from peer"); } } + crate::live_only! { + let samples = self.live_handle.view().metrics.transfer.samples; + self + .live_handle + .publish_state(PeerView::from_peer_with_samples(&self.peer, true, samples)); + } } } @@ -682,7 +759,10 @@ impl PeerActor { if let Err(err) = self .supervisor - .tell(torrent::events::PeerReady { id: peer_id }) + .tell(torrent::events::PeerReady { + id: peer_id, + available_pieces: self.peer.pieces.clone(), + }) .await { trace!(error = %err, %peer_id, "Failed to notify torrent actor that peer is ready"); @@ -690,9 +770,13 @@ impl PeerActor { } async fn reject_piece_request(&self, index: usize, begin: usize) { + let Some(peer_id) = self.peer.id else { + return; + }; if let Err(err) = self .supervisor .tell(torrent::events::PeerRejectedRequest { + peer_id, index, offset: begin, }) @@ -816,11 +900,10 @@ pub(crate) mod commands { #[message(derive(Clone, Debug))] pub(crate) async fn have_info_dict(&mut self, bitfield: Arc>) { - self - .send_message(PeerMessages::Bitfield(bitfield)) - .await - .expect("Failed to send bitfield"); - trace!("Sent bitfield to peer"); + match self.send_message(PeerMessages::Bitfield(bitfield)).await { + Ok(()) => trace!("Sent bitfield to peer"), + Err(error) => warn!(%error, "Failed to send bitfield"), + } } #[message(derive(Clone, Debug))] diff --git a/crates/libtortillas/src/peer/mod.rs b/crates/libtortillas/src/peer/mod.rs index a4fd2a30..326b8156 100644 --- a/crates/libtortillas/src/peer/mod.rs +++ b/crates/libtortillas/src/peer/mod.rs @@ -26,8 +26,8 @@ pub type PeerKey = SocketAddr; pub const MAGIC_STRING: &[u8] = b"BitTorrent protocol"; -/// Represents a BitTorrent peer with connection state and statistics -/// Download rate and upload rate are measured in kilobytes per second. +/// Represents a BitTorrent peer with connection state and statistics. +/// Traffic totals and rates use bytes and bytes per second respectively. #[derive(Clone)] pub struct Peer { pub ip: IpAddr, diff --git a/crates/libtortillas/src/peer/state.rs b/crates/libtortillas/src/peer/state.rs index 554aa20c..ab2bdedf 100644 --- a/crates/libtortillas/src/peer/state.rs +++ b/crates/libtortillas/src/peer/state.rs @@ -9,6 +9,8 @@ use std::{ use atomic_time::AtomicOptionInstant; use super::Peer; +#[cfg(feature = "live")] +use crate::metrics::{ByteCount, PeerMetrics, TrafficTotals, TransferMetrics}; /// A helper struct for Peer that maintains a given peers state. This state /// includes both the state defined in [BEP 0003](https://www.bittorrent.org/beps/bep_0003.html) and our own state which we @@ -29,10 +31,6 @@ use super::Peer; /// that everything is contained in an Arc. #[derive(Clone)] pub struct PeerState { - /// Download rate measured in kilobytes per second - download_rate: Arc, - /// Upload rate measured in kilobytes per second - upload_rate: Arc, /// Whether we are choking the remote peer am_choking: Arc, /// Whether the remote peer is interested in us @@ -49,9 +47,9 @@ pub struct PeerState { /// Defaults to None. Does not update on initial handshake, initial sending /// of bitfield, or initial sending of Interested message. last_message_received: Arc, - /// Total bytes downloaded + /// Total bytes downloaded. bytes_downloaded: Arc, - /// Total bytes uploaded + /// Total bytes uploaded. bytes_uploaded: Arc, } @@ -68,8 +66,6 @@ impl PeerState { peer_interested: Arc::new(false.into()), peer_choking: Arc::new(true.into()), am_interested: Arc::new(false.into()), - download_rate: Arc::new(0.into()), - upload_rate: Arc::new(0.into()), last_optimistic_unchoke: Arc::new(AtomicOptionInstant::none()), last_message_received: Arc::new(AtomicOptionInstant::none()), last_message_sent: Arc::new(AtomicOptionInstant::none()), @@ -77,6 +73,33 @@ impl PeerState { bytes_uploaded: Arc::new(0.into()), } } + + pub(crate) fn increment_bytes_downloaded(&self, bytes: usize) { + self.bytes_downloaded.fetch_add(bytes, Ordering::Relaxed); + } + + pub(crate) fn increment_bytes_uploaded(&self, bytes: usize) { + self.bytes_uploaded.fetch_add(bytes, Ordering::Relaxed); + } + + pub(crate) fn share_traffic_with(&mut self, state: &Self) { + self.bytes_downloaded = state.bytes_downloaded.clone(); + self.bytes_uploaded = state.bytes_uploaded.clone(); + } +} + +#[cfg(feature = "live")] +impl PeerState { + pub(crate) fn traffic_totals(&self) -> TrafficTotals { + TrafficTotals { + downloaded: ByteCount( + u64::try_from(self.bytes_downloaded.load(Ordering::Relaxed)).unwrap_or(u64::MAX), + ), + uploaded: ByteCount( + u64::try_from(self.bytes_uploaded.load(Ordering::Relaxed)).unwrap_or(u64::MAX), + ), + } + } } /// A bunch of helper methods (basically getter and setter wrappers) @@ -104,14 +127,6 @@ impl Peer { .store(is_interested, Ordering::Release); } - pub(crate) fn set_download_rate(&self, rate_kbps: usize) { - self.state.download_rate.store(rate_kbps, Ordering::Release); - } - - pub(crate) fn set_upload_rate(&self, rate_kbps: usize) { - self.state.upload_rate.store(rate_kbps, Ordering::Release); - } - pub(crate) fn update_last_optimistic_unchoke(&self) { self .state @@ -133,18 +148,8 @@ impl Peer { .store(Some(Instant::now()), Ordering::Release); } - pub(crate) fn increment_bytes_downloaded(&self, bytes: usize) { - self - .state - .bytes_downloaded - .fetch_add(bytes, Ordering::Relaxed); - } - - pub(crate) fn increment_bytes_uploaded(&self, bytes: usize) { - self - .state - .bytes_uploaded - .fetch_add(bytes, Ordering::Relaxed); + pub(crate) fn share_traffic_with(&mut self, state: &PeerState) { + self.state.share_traffic_with(state); } pub(crate) fn choked(&self) -> bool { @@ -163,14 +168,6 @@ impl Peer { self.state.am_interested.load(Ordering::Acquire) } - pub fn download_rate(&self) -> usize { - self.state.download_rate.load(Ordering::Acquire) - } - - pub fn upload_rate(&self) -> usize { - self.state.upload_rate.load(Ordering::Acquire) - } - pub(crate) fn last_optimistic_unchoke(&self) -> Option { self.state.last_optimistic_unchoke.load(Ordering::Acquire) } @@ -191,3 +188,24 @@ impl Peer { self.state.bytes_uploaded.load(Ordering::Relaxed) } } + +#[cfg(feature = "live")] +impl Peer { + pub(crate) fn traffic_totals(&self) -> TrafficTotals { + self.state.traffic_totals() + } + + pub(crate) fn metrics(&self) -> PeerMetrics { + PeerMetrics { + transfer: TransferMetrics { + totals: self.traffic_totals(), + samples: Vec::new(), + }, + peer_choking: self.am_choked(), + peer_interested: self.interested(), + client_choking: self.choked(), + client_interested: self.am_interested(), + available_pieces: u64::try_from(self.pieces.count_ones()).unwrap_or(u64::MAX), + } + } +} diff --git a/crates/libtortillas/src/pieces/piece_manager.rs b/crates/libtortillas/src/pieces/piece_manager.rs index ffc473c0..78f12e36 100644 --- a/crates/libtortillas/src/pieces/piece_manager.rs +++ b/crates/libtortillas/src/pieces/piece_manager.rs @@ -4,7 +4,7 @@ use std::{ path::{Component, Path, PathBuf}, }; -use anyhow::ensure; +use anyhow::{Context, ensure}; use async_trait::async_trait; use bytes::Bytes; use tokio::{ @@ -83,7 +83,8 @@ pub trait PieceManager: Send + Sync { /// underlying file storage layout. fn piece_to_paths(&self, index: usize) -> anyhow::Result> { let info = self.info().ok_or_else(|| anyhow::anyhow!("info not set"))?; - let piece_len = info.piece_length as usize; + let piece_len = usize::try_from(info.piece_length) + .context("piece length cannot be represented on this platform")?; let total_len = info.total_length(); let piece_start = index @@ -107,7 +108,8 @@ pub trait PieceManager: Send + Sync { match &info.file { InfoKeys::Single { length, .. } => { // Single-file torrents just map to a single path = `name` - let file_len = *length as usize; + let file_len = usize::try_from(*length) + .context("single-file length cannot be represented on this platform")?; if piece_start < file_len { let offset_in_file = piece_start; @@ -124,7 +126,8 @@ pub trait PieceManager: Send + Sync { } InfoKeys::Multi { files } => { for file in files { - let file_len = file.length; + let file_len = usize::try_from(file.length) + .context("file length cannot be represented on this platform")?; // Skip files before the piece if piece_start >= acc + file_len { diff --git a/crates/libtortillas/src/pieces/piece_scheduler.rs b/crates/libtortillas/src/pieces/piece_scheduler.rs index d1cb02c2..187b1303 100644 --- a/crates/libtortillas/src/pieces/piece_scheduler.rs +++ b/crates/libtortillas/src/pieces/piece_scheduler.rs @@ -1,11 +1,12 @@ -use std::collections::HashMap; +use std::{ + collections::HashMap, + sync::{Arc, atomic::AtomicU8}, + time::{Duration, Instant}, +}; use bitvec::vec::BitVec; -use crate::{ - peer::PeerId, - torrent::{BLOCK_SIZE, BlockMap}, -}; +use crate::{peer::PeerId, torrent::BLOCK_SIZE}; #[derive(Debug)] pub(crate) struct BlockRequest { @@ -24,16 +25,24 @@ impl BlockRequest { pub(crate) struct PieceScheduler { completed_pieces: BitVec, completed_blocks: HashMap, - in_flight: HashMap<(usize, usize), PeerId>, + in_flight: HashMap<(usize, usize), InFlightBlock>, + peer_availability: HashMap>>, next_piece: usize, } +#[derive(Debug)] +struct InFlightBlock { + peer_id: PeerId, + requested_at: Instant, +} + impl PieceScheduler { pub(crate) fn new(piece_count: usize) -> Self { Self { completed_pieces: BitVec::repeat(false, piece_count), completed_blocks: HashMap::new(), in_flight: HashMap::new(), + peer_availability: HashMap::new(), next_piece: 0, } } @@ -57,14 +66,14 @@ impl PieceScheduler { self.completed_blocks.insert(index, blocks); } - #[cfg(test)] + #[cfg(all(test, feature = "live"))] pub(crate) fn set_piece_blocks(&mut self, index: usize, blocks: BitVec) { self.completed_blocks.insert(index, blocks); } pub(crate) fn mark_block_complete( &mut self, piece_index: usize, block_index: usize, total_blocks: usize, - ) { + ) -> Option { let blocks = self.completed_blocks.entry(piece_index).or_insert_with(|| { let mut blocks = BitVec::with_capacity(total_blocks); blocks.resize(total_blocks, false); @@ -73,7 +82,10 @@ impl PieceScheduler { if block_index < blocks.len() { blocks.set(block_index, true); } - self.in_flight.remove(&(piece_index, block_index)); + self + .in_flight + .remove(&(piece_index, block_index)) + .map(|request| request.peer_id) } pub(crate) fn remove_piece_blocks(&mut self, piece_index: usize) -> Option { @@ -106,6 +118,10 @@ impl PieceScheduler { return requests; } + let Some(available_pieces) = self.peer_availability.get(&peer_id) else { + return requests; + }; + let last_piece_index = self.completed_pieces.len().saturating_sub(1); let last_piece_len = if total_length.is_multiple_of(piece_length) { piece_length @@ -114,7 +130,13 @@ impl PieceScheduler { }; for piece_index in self.next_piece..self.completed_pieces.len() { - if self.completed_pieces[piece_index] { + if self.completed_pieces[piece_index] + || !available_pieces + .get(piece_index) + .as_deref() + .copied() + .unwrap_or(false) + { continue; } @@ -138,7 +160,13 @@ impl PieceScheduler { continue; } - self.in_flight.insert(key, peer_id); + self.in_flight.insert( + key, + InFlightBlock { + peer_id, + requested_at: Instant::now(), + }, + ); requests.push(self.block_request(piece_index, block_index, piece_len)); if requests.len() >= limit { return requests; @@ -149,20 +177,51 @@ impl PieceScheduler { requests } + pub(crate) fn in_flight_for_peer(&self, peer_id: PeerId) -> usize { + self + .in_flight + .values() + .filter(|request| request.peer_id == peer_id) + .count() + } + + pub(crate) fn update_peer_availability( + &mut self, peer_id: PeerId, available_pieces: Arc>, + ) { + self.peer_availability.insert(peer_id, available_pieces); + } + pub(crate) fn peer_disconnected(&mut self, peer_id: PeerId) { - self.in_flight.retain(|_, owner| *owner != peer_id); + self + .in_flight + .retain(|_, request| request.peer_id != peer_id); + self.peer_availability.remove(&peer_id); } - pub(crate) fn release_request(&mut self, piece_index: usize, offset: usize) { - self.in_flight.remove(&(piece_index, offset / BLOCK_SIZE)); + pub(crate) fn release_stale_requests(&mut self, timeout: Duration) -> usize { + let before = self.in_flight.len(); + let now = Instant::now(); + self + .in_flight + .retain(|_, request| now.saturating_duration_since(request.requested_at) < timeout); + before.saturating_sub(self.in_flight.len()) } - pub(crate) fn block_map_export(&self) -> BlockMap { - let block_map = BlockMap::new(); - for (piece, blocks) in &self.completed_blocks { - block_map.insert(*piece, blocks.clone()); + pub(crate) fn release_peer_request( + &mut self, peer_id: PeerId, piece_index: usize, offset: usize, + ) { + let key = (piece_index, offset / BLOCK_SIZE); + if self + .in_flight + .get(&key) + .is_some_and(|request| request.peer_id == peer_id) + { + self.in_flight.remove(&key); } - block_map + } + + pub(crate) fn completed_blocks(&self) -> &HashMap { + &self.completed_blocks } fn block_request( @@ -182,3 +241,71 @@ impl PieceScheduler { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scheduler_assigns_only_pieces_available_from_peer() { + let peer_id = PeerId::Unknown([1; 20]); + let mut scheduler = PieceScheduler::new(3); + scheduler.update_peer_availability( + peer_id, + Arc::new([false, true, false].into_iter().collect()), + ); + + let requests = scheduler.requests_for_peer(peer_id, 4, BLOCK_SIZE, BLOCK_SIZE * 3); + + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].piece_index, 1); + } + + #[test] + fn scheduler_releases_unanswered_requests_after_timeout() { + let peer_id = PeerId::Unknown([2; 20]); + let mut scheduler = PieceScheduler::new(1); + scheduler.update_peer_availability(peer_id, Arc::new([true].into_iter().collect())); + assert_eq!( + scheduler + .requests_for_peer(peer_id, 1, BLOCK_SIZE, BLOCK_SIZE) + .len(), + 1 + ); + + assert_eq!(scheduler.release_stale_requests(Duration::ZERO), 1); + assert_eq!(scheduler.in_flight_for_peer(peer_id), 0); + } + + #[test] + fn late_rejection_does_not_release_reassigned_request() { + let original_peer = PeerId::Unknown([3; 20]); + let replacement_peer = PeerId::Unknown([4; 20]); + let mut scheduler = PieceScheduler::new(1); + let availability: Arc> = Arc::new([true].into_iter().collect()); + scheduler.update_peer_availability(original_peer, availability.clone()); + scheduler.update_peer_availability(replacement_peer, availability); + assert_eq!( + scheduler + .requests_for_peer(original_peer, 1, BLOCK_SIZE, BLOCK_SIZE) + .len(), + 1 + ); + scheduler.release_stale_requests(Duration::ZERO); + assert_eq!( + scheduler + .requests_for_peer(replacement_peer, 1, BLOCK_SIZE, BLOCK_SIZE) + .len(), + 1 + ); + + scheduler.release_peer_request(original_peer, 0, 0); + + assert_eq!(scheduler.in_flight_for_peer(original_peer), 0); + assert_eq!(scheduler.in_flight_for_peer(replacement_peer), 1); + assert_eq!( + scheduler.mark_block_complete(0, 0, 1), + Some(replacement_peer) + ); + } +} diff --git a/crates/libtortillas/src/protocol/messages.rs b/crates/libtortillas/src/protocol/messages.rs index b02aa30b..6d165d95 100644 --- a/crates/libtortillas/src/protocol/messages.rs +++ b/crates/libtortillas/src/protocol/messages.rs @@ -3,10 +3,7 @@ use std::{ collections::HashMap, fmt::Display, net::{IpAddr, Ipv4Addr, Ipv6Addr}, - sync::{ - Arc, - atomic::{AtomicU8, Ordering}, - }, + sync::{Arc, atomic::AtomicU8}, }; use anyhow::{Error, Result, bail, ensure}; @@ -171,9 +168,7 @@ impl PeerMessages { PeerMessages::Interested => create_message_with_id(2, &[]), PeerMessages::NotInterested => create_message_with_id(3, &[]), PeerMessages::Have(index) => create_message_with_id(4, &index.to_be_bytes()), - // This code is wildly confusing, but all it does is maps the array of AtomicU8s to an - // vector of u8s - PeerMessages::Bitfield(bits) => create_message_with_id(5, &bits.as_raw_slice().iter().map(|byte| byte.load(Ordering::Acquire)).collect::>()), + PeerMessages::Bitfield(bits) => create_message_with_id(5, &encode_bitfield(bits)), PeerMessages::Request(index, begin, length) // Code is identical | PeerMessages::Cancel(index, begin, length) => { let id = match self { @@ -270,11 +265,7 @@ impl PeerMessages { let index = payload_buf.get_u32(); Ok(PeerMessages::Have(index)) } - 5 => { - let bitvec: BitVec = payload.into_iter().map(AtomicU8::new).collect(); - - Ok(PeerMessages::Bitfield(Arc::new(bitvec))) - } + 5 => Ok(PeerMessages::Bitfield(Arc::new(decode_bitfield(&payload)))), 6 => { if payload.len() != 12 { return Err(PeerActorError::InvalidMessagePayload { @@ -622,6 +613,29 @@ fn create_message_with_id(id: u8, payload: &[u8]) -> Bytes { message.freeze() } +/// Encodes the canonical piece-index order using BEP 3's most-significant-bit +/// first wire representation. Internal bit vectors intentionally keep their +/// default ordering; unit conversion belongs at the protocol boundary. +fn encode_bitfield(bits: &BitVec) -> Vec { + let mut payload = vec![0; bits.len().div_ceil(8)]; + for (piece_index, available) in bits.iter().by_vals().enumerate() { + if available { + payload[piece_index / 8] |= 1 << (7 - piece_index % 8); + } + } + payload +} + +fn decode_bitfield(payload: &[u8]) -> BitVec { + let mut bits = BitVec::with_capacity(payload.len() * 8); + for byte in payload { + for shift in (0..8).rev() { + bits.push(byte & (1 << shift) != 0); + } + } + bits +} + /// Helper to parse u32 triplets fn parse_triplet(payload: &Bytes) -> Result<(u32, u32, u32), PeerActorError> { if payload.len() != 12 { @@ -767,3 +781,26 @@ mod ipaddr_serde { deserializer.deserialize_option(Visitor) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn peer_bitfield_uses_most_significant_bit_first_piece_order() { + let mut wire = vec![u8::MAX; 132]; + // A 1,055-piece torrent has seven real bits in its final byte. BEP 3 + // requires the unused least-significant padding bit to be zero. + wire[131] = 0b1111_1110; + + let bits = decode_bitfield(&wire); + + assert!( + bits[1048], + "first piece in final byte must remain available" + ); + assert!(bits[1054], "last real piece bit must remain available"); + assert!(!bits[1055], "wire padding must not become a real piece"); + assert_eq!(encode_bitfield(&bits), wire); + } +} diff --git a/crates/libtortillas/src/protocol/stream.rs b/crates/libtortillas/src/protocol/stream.rs index 0c8c1c0b..c4010371 100644 --- a/crates/libtortillas/src/protocol/stream.rs +++ b/crates/libtortillas/src/protocol/stream.rs @@ -22,22 +22,20 @@ use super::messages::{Handshake, PeerMessages}; use crate::{ errors::PeerActorError, hashes::InfoHash, - peer::{MAGIC_STRING, PeerId}, + peer::{MAGIC_STRING, PeerId, PeerState}, }; -/// A very simple enum to help differentiate between streams. TcpStream and -/// UtpStream are so incredibly similar in functionality that it's ususally -/// possible to simply make a blanket function as it implements both [AsyncRead] -/// and [AsyncWrite] -pub enum PeerStream { - Tcp { - stream: TcpStream, - read_buffer: BytesMut, - }, - Utp { - stream: UtpStream, - read_buffer: BytesMut, - }, +enum PeerTransport { + Tcp(TcpStream), + Utp(UtpStream), +} + +/// A TCP or uTP peer connection with buffered protocol reads and traffic +/// accounting. +pub struct PeerStream { + transport: PeerTransport, + read_buffer: BytesMut, + peer_state: PeerState, } #[async_trait] @@ -132,19 +130,25 @@ pub trait PeerRecv: AsyncRead + Unpin { impl PeerStream { pub fn tcp(stream: TcpStream) -> Self { - Self::Tcp { - stream, + Self { + transport: PeerTransport::Tcp(stream), read_buffer: BytesMut::new(), + peer_state: PeerState::default(), } } pub fn utp(stream: UtpStream) -> Self { - Self::Utp { - stream, + Self { + transport: PeerTransport::Utp(stream), read_buffer: BytesMut::new(), + peer_state: PeerState::default(), } } + pub(crate) fn peer_state(&self) -> PeerState { + self.peer_state.clone() + } + /// Connect to a peer with the given peer_addr (ip & port in the form of a /// [SocketAddr]) /// @@ -215,49 +219,46 @@ impl PeerStream { /// Returns the addr of the connected peer pub fn remote_addr(&self) -> Result { - match self { - PeerStream::Tcp { stream, .. } => Ok(stream.peer_addr()?), - PeerStream::Utp { stream, .. } => Ok(stream.remote_addr()), + match &self.transport { + PeerTransport::Tcp(stream) => Ok(stream.peer_addr()?), + PeerTransport::Utp(stream) => Ok(stream.remote_addr()), } } /// Splits the PeerStream into separate reader and writer halves. /// - /// Panics if `read_buffer` contains bytes buffered by `PeerRecv::recv()`. - /// Callers must split before using buffered reads, or ensure - /// `recv_handshake_message()` and other direct reads did not leave data for - /// `PeerRecv::recv()` to process. + /// Any bytes already buffered by [`PeerRecv::recv`] are transferred to the + /// reader and returned before it reads from the transport. pub fn split(self) -> (PeerReader, PeerWriter) { - match self { - PeerStream::Tcp { - stream, - read_buffer, - } => { - assert!( - read_buffer.is_empty(), - "PeerStream::split would discard buffered read data" - ); + let Self { + transport, + read_buffer, + peer_state, + } = self; + let (reader, writer) = match transport { + PeerTransport::Tcp(stream) => { let (reader, writer) = stream.into_split(); - (PeerReader::Tcp(reader), PeerWriter::Tcp(writer)) + (PeerReadHalf::Tcp(reader), PeerWriteHalf::Tcp(writer)) } - PeerStream::Utp { - stream, - read_buffer, - } => { - assert!( - read_buffer.is_empty(), - "PeerStream::split would discard buffered read data" - ); + PeerTransport::Utp(stream) => { let (reader, writer) = stream.split(); - (PeerReader::Utp(reader), PeerWriter::Utp(writer)) + (PeerReadHalf::Utp(reader), PeerWriteHalf::Utp(writer)) } - } + }; + ( + PeerReader { + reader, + read_buffer, + peer_state: peer_state.clone(), + }, + PeerWriter { writer, peer_state }, + ) } - pub fn protocol(&self) -> String { - match self { - PeerStream::Tcp { .. } => "TCP".to_string(), - PeerStream::Utp { .. } => "uTP".to_string(), + pub fn protocol(&self) -> &'static str { + match &self.transport { + PeerTransport::Tcp(_) => "TCP", + PeerTransport::Utp(_) => "uTP", } } } @@ -276,36 +277,20 @@ impl PeerSend for PeerStream {} impl PeerRecv for PeerStream { async fn recv(&mut self) -> Result { loop { - match self { - PeerStream::Tcp { - stream, - read_buffer, - } => { - if let Some(message) = buffered_message(read_buffer) { - return message; - } - if stream.read_buf(read_buffer).await? == 0 { - return Err(PeerActorError::ReceiveFailed(io::Error::new( - io::ErrorKind::UnexpectedEof, - "peer closed connection", - ))); - } - } - PeerStream::Utp { - stream, - read_buffer, - } => { - if let Some(message) = buffered_message(read_buffer) { - return message; - } - if stream.read_buf(read_buffer).await? == 0 { - return Err(PeerActorError::ReceiveFailed(io::Error::new( - io::ErrorKind::UnexpectedEof, - "peer closed connection", - ))); - } - } + if let Some(message) = buffered_message(&mut self.read_buffer) { + return message; } + let bytes_read = match &mut self.transport { + PeerTransport::Tcp(stream) => stream.read_buf(&mut self.read_buffer).await?, + PeerTransport::Utp(stream) => stream.read_buf(&mut self.read_buffer).await?, + }; + if bytes_read == 0 { + return Err(PeerActorError::ReceiveFailed(io::Error::new( + io::ErrorKind::UnexpectedEof, + "peer closed connection", + ))); + } + self.peer_state.increment_bytes_downloaded(bytes_read); } } } @@ -339,10 +324,17 @@ impl AsyncRead for PeerStream { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { - match &mut *self { - PeerStream::Tcp { stream, .. } => Pin::new(stream).poll_read(cx, buf), - PeerStream::Utp { stream, .. } => Pin::new(stream).poll_read(cx, buf), + let before = buf.filled().len(); + let result = match &mut self.transport { + PeerTransport::Tcp(stream) => Pin::new(stream).poll_read(cx, buf), + PeerTransport::Utp(stream) => Pin::new(stream).poll_read(cx, buf), + }; + if matches!(&result, Poll::Ready(Ok(()))) { + self + .peer_state + .increment_bytes_downloaded(buf.filled().len().saturating_sub(before)); } + result } } @@ -350,45 +342,72 @@ impl AsyncWrite for PeerStream { fn poll_write( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { - match &mut *self { - PeerStream::Tcp { stream, .. } => Pin::new(stream).poll_write(cx, buf), - PeerStream::Utp { stream, .. } => Pin::new(stream).poll_write(cx, buf), + let result = match &mut self.transport { + PeerTransport::Tcp(stream) => Pin::new(stream).poll_write(cx, buf), + PeerTransport::Utp(stream) => Pin::new(stream).poll_write(cx, buf), + }; + if let Poll::Ready(Ok(bytes_written)) = &result { + self.peer_state.increment_bytes_uploaded(*bytes_written); } + result } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match &mut *self { - PeerStream::Tcp { stream, .. } => Pin::new(stream).poll_flush(cx), - PeerStream::Utp { stream, .. } => Pin::new(stream).poll_flush(cx), + match &mut self.transport { + PeerTransport::Tcp(stream) => Pin::new(stream).poll_flush(cx), + PeerTransport::Utp(stream) => Pin::new(stream).poll_flush(cx), } } fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match &mut *self { - PeerStream::Tcp { stream, .. } => Pin::new(stream).poll_shutdown(cx), - PeerStream::Utp { stream, .. } => Pin::new(stream).poll_shutdown(cx), + match &mut self.transport { + PeerTransport::Tcp(stream) => Pin::new(stream).poll_shutdown(cx), + PeerTransport::Utp(stream) => Pin::new(stream).poll_shutdown(cx), } } } -pub enum PeerReader { +enum PeerReadHalf { Tcp(tcp::OwnedReadHalf), Utp(UtpStreamReadHalf), } -pub enum PeerWriter { +enum PeerWriteHalf { Tcp(tcp::OwnedWriteHalf), Utp(UtpStreamWriteHalf), } +pub struct PeerReader { + reader: PeerReadHalf, + read_buffer: BytesMut, + peer_state: PeerState, +} + +pub struct PeerWriter { + writer: PeerWriteHalf, + peer_state: PeerState, +} + impl AsyncRead for PeerReader { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { - match &mut *self { - PeerReader::Tcp(s) => Pin::new(s).poll_read(cx, buf), - PeerReader::Utp(s) => Pin::new(s).poll_read(cx, buf), + if !self.read_buffer.is_empty() { + let length = buf.remaining().min(self.read_buffer.len()); + buf.put_slice(&self.read_buffer.split_to(length)); + return Poll::Ready(Ok(())); } + let before = buf.filled().len(); + let result = match &mut self.reader { + PeerReadHalf::Tcp(stream) => Pin::new(stream).poll_read(cx, buf), + PeerReadHalf::Utp(stream) => Pin::new(stream).poll_read(cx, buf), + }; + if matches!(&result, Poll::Ready(Ok(()))) { + self + .peer_state + .increment_bytes_downloaded(buf.filled().len().saturating_sub(before)); + } + result } } @@ -396,23 +415,27 @@ impl AsyncWrite for PeerWriter { fn poll_write( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { - match &mut *self { - PeerWriter::Tcp(s) => Pin::new(s).poll_write(cx, buf), - PeerWriter::Utp(s) => Pin::new(s).poll_write(cx, buf), + let result = match &mut self.writer { + PeerWriteHalf::Tcp(stream) => Pin::new(stream).poll_write(cx, buf), + PeerWriteHalf::Utp(stream) => Pin::new(stream).poll_write(cx, buf), + }; + if let Poll::Ready(Ok(bytes_written)) = &result { + self.peer_state.increment_bytes_uploaded(*bytes_written); } + result } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match &mut *self { - PeerWriter::Tcp(s) => Pin::new(s).poll_flush(cx), - PeerWriter::Utp(s) => Pin::new(s).poll_flush(cx), + match &mut self.writer { + PeerWriteHalf::Tcp(stream) => Pin::new(stream).poll_flush(cx), + PeerWriteHalf::Utp(stream) => Pin::new(stream).poll_flush(cx), } } fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match &mut *self { - PeerWriter::Tcp(s) => Pin::new(s).poll_shutdown(cx), - PeerWriter::Utp(s) => Pin::new(s).poll_shutdown(cx), + match &mut self.writer { + PeerWriteHalf::Tcp(stream) => Pin::new(stream).poll_shutdown(cx), + PeerWriteHalf::Utp(stream) => Pin::new(stream).poll_shutdown(cx), } } } @@ -521,6 +544,75 @@ mod tests { assert_eq!(incoming_id, client_id); } + #[cfg(feature = "live")] + #[tokio::test] + async fn peer_stream_when_frames_are_exchanged_then_counts_every_wire_byte() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let info_hash = Arc::new(Hash::new([1u8; 20])); + let client_id = PeerId::new(); + let server_id = PeerId::new(); + let handshake_len = Handshake::new(info_hash.clone(), client_id) + .to_bytes() + .len(); + let interested_len = PeerMessages::Interested.to_bytes().unwrap().len(); + let piece = PeerMessages::Piece(2, 4, b"payload".as_slice().into()); + let piece_len = piece.to_bytes().unwrap().len(); + + let server_info_hash = info_hash.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut stream = PeerStream::tcp(stream); + stream.recv_handshake_message().await.unwrap(); + stream + .send_handshake(server_id, server_info_hash) + .await + .unwrap(); + assert_eq!(stream.recv().await.unwrap(), PeerMessages::Interested); + stream.send(piece).await.unwrap(); + stream.peer_state().traffic_totals() + }); + + let mut client = PeerStream::tcp(TcpStream::connect(addr).await.unwrap()); + client.send_handshake(client_id, info_hash).await.unwrap(); + client.recv_handshake_message().await.unwrap(); + client.send(PeerMessages::Interested).await.unwrap(); + assert!(matches!( + client.recv().await.unwrap(), + PeerMessages::Piece(2, 4, _) + )); + + let client_totals = client.peer_state().traffic_totals(); + let server_totals = server.await.unwrap(); + assert_eq!( + client_totals.uploaded.0, + (handshake_len + interested_len) as u64 + ); + assert_eq!( + client_totals.downloaded.0, + (handshake_len + piece_len) as u64 + ); + assert_eq!(server_totals.uploaded, client_totals.downloaded); + assert_eq!(server_totals.downloaded, client_totals.uploaded); + } + + #[tokio::test] + async fn peer_stream_transfers_buffered_data_to_split_reader() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let connect = tokio::spawn(TcpStream::connect(address)); + let (stream, _) = listener.accept().await.unwrap(); + let _client = connect.await.unwrap().unwrap(); + let mut stream = PeerStream::tcp(stream); + stream.read_buffer.extend_from_slice(&[0, 0, 0, 0]); + let (mut reader, _writer) = stream.split(); + let mut buffered = [1; 4]; + + reader.read_exact(&mut buffered).await.unwrap(); + + assert_eq!(buffered, [0; 4]); + } + #[tokio::test] async fn peer_stream_when_local_peer_is_available_then_completes_handshake() { let remote_peer_id = PeerId::new(); diff --git a/crates/libtortillas/src/settings.rs b/crates/libtortillas/src/settings.rs index 5d5fdfad..90238400 100644 --- a/crates/libtortillas/src/settings.rs +++ b/crates/libtortillas/src/settings.rs @@ -1,3 +1,10 @@ +//! Runtime policy for the engine and its supervised protocol scopes. +//! +//! Settings control application-tunable behavior such as actor mailbox sizes, +//! event capacities, request windows, timeouts, and supervision. Wire-format +//! constants and BEP-mandated values remain next to the protocol code that +//! implements them. + use std::{net::SocketAddr, time::Duration}; const DEFAULT_DHT_BOOTSTRAP_NODES: [&str; 3] = [ @@ -26,6 +33,9 @@ pub struct Settings { pub dht: DhtSettings, /// Engine actor and incoming socket settings. pub engine: EngineSettings, + /// Live view and event-channel settings. + #[cfg(feature = "live")] + pub live: LiveSettings, /// Per-torrent actor settings. pub torrent: TorrentSettings, /// Per-peer actor settings. @@ -34,6 +44,13 @@ pub struct Settings { pub tracker: TrackerSettings, } +/// Bounded event capacities for each live scope. +/// +/// Channels are allocated lazily when the first listener subscribes, so these +/// capacities do not impose a per-scope allocation on unobserved peers. +#[cfg(feature = "live")] +pub use crate::live::LiveSettings; + /// Mainline [BEP 5] DHT networking and lookup settings. /// /// [BEP 5]: https://www.bittorrent.org/beps/bep_0005.html @@ -139,6 +156,9 @@ pub struct TorrentSettings { pub initial_peer_request_window: usize, /// Maximum in-flight block requests filled for a ready peer. pub max_in_flight_per_peer: usize, + /// Maximum age of an unanswered block request before it can be assigned + /// again. Duplicate late responses are safely ignored. + pub peer_request_timeout: Duration, /// Peer actor mailbox size. `0` means unbounded. pub peer_mailbox_size: usize, /// Maximum concurrent sends when broadcasting a message to peers. @@ -170,6 +190,7 @@ impl Default for TorrentSettings { sufficient_peers: 6, initial_peer_request_window: 32, max_in_flight_per_peer: 32, + peer_request_timeout: Duration::from_secs(15), peer_mailbox_size: 120, peer_broadcast_concurrency: 32, tracker_broadcast_concurrency: 8, diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index fb7302b9..1fab81cb 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -25,20 +25,28 @@ use tracing::{debug, error, info, instrument, trace, warn}; use super::{choking::ChokingScheduler, util}; use crate::{ - errors::TorrentError, + errors::{SnapshotUnsupportedReason, TorrentError}, hashes::InfoHash, metainfo::{Info, MetaInfo}, peer::{PeerActor, PeerId, commands::SetChoked}, pieces::{FilePieceManager, PieceManager, PieceScheduler, PieceStoreActor}, settings::Settings, torrent::{ - BLOCK_SIZE, PieceStorageStrategy, TorrentExport, TorrentProgressSnapshot, TorrentSnapshot, - TorrentState, TorrentTransferSnapshot, + BLOCK_SIZE, PieceBlockSnapshot, PieceStorageStrategy, TORRENT_SNAPSHOT_VERSION, + TorrentSnapshot, TorrentState, }, tracker::{ Announce, Event, Tracker, TrackerActor, TrackerActorArgs, TrackerUpdate, udp::UdpServer, }, }; +#[cfg(feature = "live")] +use crate::{ + live::{Hub, LiveHealthLevel, TorrentEventKind, TorrentView, TrackerStatus, TrackerView}, + metrics::{ + ByteCount, ContentProgress, HasTransferMetrics, TorrentMetrics, TrackerMetrics, + TransferMetrics, + }, +}; /// A hook that is called when the torrent is ready to start downloading. /// This is used to implement @@ -95,12 +103,16 @@ impl PieceManager for PieceManagerProxy { } pub(crate) struct TorrentActor { + #[cfg(feature = "live")] + pub(super) hub: Hub, pub(crate) peers: HashMap>, pub(crate) trackers: HashMap>, pub(crate) bitfield: BitVec, pub(super) id: PeerId, - pub(super) info: Option, + /// Metadata resolved from a magnet source. `.torrent` metadata remains + /// canonical inside `metainfo`. + pub(super) resolved_magnet_info: Option, pub(super) metainfo: MetaInfo, #[allow(dead_code)] pub(super) tracker_server: UdpServer, @@ -147,13 +159,9 @@ impl fmt::Display for TorrentActor { impl TorrentActor { pub fn info_dict(&self) -> Option<&Info> { - if let Some(info) = &self.info { - Some(info) - } else { - match &self.metainfo { - MetaInfo::Torrent(t) => Some(&t.info), - _ => None, - } + match &self.metainfo { + MetaInfo::Torrent(torrent) => Some(&torrent.info), + MetaInfo::MagnetUri(_) => self.resolved_magnet_info.as_ref(), } } @@ -191,7 +199,7 @@ impl TorrentActor { trace!("Autostarting torrent"); self.start().await; } else { - self.state = TorrentState::Ready; + self.transition_state(TorrentState::Ready); self.send_ready_hooks(); } } @@ -207,26 +215,31 @@ impl TorrentActor { self.send_ready_hooks(); - let Some(info) = self.info.clone() else { - self.state = TorrentState::ResolvingMetadata; + let Some(info) = self.info_dict().cloned() else { + self.transition_state(TorrentState::ResolvingMetadata); warn!(id = %self.info_hash(), "Start requested before info dict is available; deferring"); return; }; // Pre-start the piece manager before transitioning state if let Err(err) = self.piece_manager.pre_start(info.clone()).await { - self.state = TorrentState::Failed; + self.transition_state(TorrentState::Failed); + crate::live_only!(self.hub.emit_health( + Some(self.info_hash()), + LiveHealthLevel::Error, + "torrent storage could not be initialized", + )); error!(?err, "Failed to pre-start piece manager; aborting start"); return; } - self.sync_tracker_announce_progress().await; + self.update_tracker_progress().await; if self.is_full() { - self.state = TorrentState::Seeding; + self.transition_state(TorrentState::Seeding); info!(id = %self.info_hash(), "Torrent is now seeding"); } else { - self.state = TorrentState::Downloading; + self.transition_state(TorrentState::Downloading); info!(id = %self.info_hash(), "Torrent is now downloading"); self.start_time = Some(Instant::now()); }; @@ -236,9 +249,7 @@ impl TorrentActor { if self.state == TorrentState::Downloading { let peer_ids: Vec<_> = self.peers.keys().copied().collect(); for peer_id in peer_ids { - self - .request_blocks_from_peer(peer_id, self.settings.torrent.initial_peer_request_window) - .await; + self.fill_initial_peer_request_window(peer_id); } } @@ -265,7 +276,7 @@ impl TorrentActor { } let was_active = self.state.is_transfer_active(); - self.state = TorrentState::Paused; + self.transition_state(TorrentState::Paused); self.start_time = None; if let Some(next_rechoke) = self.next_rechoke.take() { @@ -277,6 +288,7 @@ impl TorrentActor { } self.broadcast_to_peers(SetChoked { choked: true }).await; + self.update_tracker_progress().await; self .update_trackers(TrackerUpdate::Event(Event::Stopped)) .await; @@ -355,10 +367,7 @@ impl TorrentActor { } } - let block_map = self.piece_scheduler.block_map_export(); - for entry in block_map.iter() { - let piece_idx = *entry.key(); - let block = entry.value(); + for (&piece_idx, block) in self.piece_scheduler.completed_blocks() { if piece_idx < num_pieces && !self.bitfield[piece_idx] { let piece_size = if piece_idx == num_pieces - 1 { last_piece_len @@ -391,7 +400,7 @@ impl TorrentActor { }) } - pub(super) async fn sync_tracker_announce_progress(&mut self) { + pub(super) async fn update_tracker_progress(&mut self) { let Some(progress) = self.tracker_announce_progress() else { return; }; @@ -404,6 +413,15 @@ impl TorrentActor { .await; } + pub(super) fn try_update_tracker_progress(&mut self) { + let Some(progress) = self.tracker_announce_progress() else { + return; + }; + + self.update_trackers_best_effort(TrackerUpdate::Downloaded(progress.downloaded)); + self.update_trackers_best_effort(TrackerUpdate::Left(progress.left)); + } + pub(super) async fn announce_tracker_event(&mut self, event: Event) { self.update_trackers(TrackerUpdate::Event(event)).await; self.broadcast_to_trackers(Announce).await; @@ -412,55 +430,192 @@ impl TorrentActor { .await; } - pub fn export(&self) -> TorrentExport { - TorrentExport { + pub fn snapshot(&self) -> Result { + if self.piece_manager.is_custom() { + return Err(TorrentError::SnapshotUnsupported { + reason: SnapshotUnsupportedReason::CustomPieceManager, + }); + } + Ok(TorrentSnapshot { + version: TORRENT_SNAPSHOT_VERSION, info_hash: self.info_hash(), state: self.state, auto_start: self.autostart, - sufficient_peers: self.sufficient_peers, + sufficient_peers: Self::snapshot_u64(self.sufficient_peers), output_path: match &self.piece_manager { PieceManagerProxy::Default(manager) => manager.path().cloned(), _ => None, }, metainfo: self.metainfo.clone(), piece_storage: self.piece_storage.clone(), - info_dict: self.info_dict().cloned(), - bitfield: self.bitfield.clone(), - block_map: self.piece_scheduler.block_map_export(), + resolved_magnet_info: self.resolved_magnet_info.clone(), + bitfield: self.bitfield.iter().by_vals().collect(), + block_map: { + let mut blocks = self + .piece_scheduler + .completed_blocks() + .iter() + .map(|(&piece_index, blocks)| PieceBlockSnapshot { + piece_index: Self::snapshot_u64(piece_index), + blocks: blocks.iter().by_vals().collect(), + }) + .collect::>(); + blocks.sort_by_key(|entry| entry.piece_index); + blocks + }, + }) + } + + #[inline] + pub(super) fn publish_updated(&self) { + crate::live_only!(self.publish_live_view(|_| crate::live::TorrentEventKind::Updated)); + } + + #[inline] + pub(super) fn publish_metrics_changed(&self) { + crate::live_only!(self.publish_live_view(|view| { + crate::live::TorrentEventKind::MetricsChanged(view.metrics.clone()) + })); + } + + #[inline] + pub(super) fn publish_metadata_resolved(&self) { + crate::live_only!( + self.publish_live_view(|_| crate::live::TorrentEventKind::MetadataResolved) + ); + } + + pub(super) fn remove_peer(&mut self, id: PeerId) { + self.piece_scheduler.peer_disconnected(id); + if let Some(actor) = self.peers.remove(&id) { + actor.kill(); } } - pub fn snapshot(&self) -> TorrentSnapshot { + pub(super) fn transition_state(&mut self, state: TorrentState) { + let previous = self.state; + if previous == state { + return; + } + + self.state = state; + crate::live_only!(self.publish_live_view(|_| TorrentEventKind::StateChanged { + previous, + current: state, + })); + } + + fn snapshot_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) + } + + pub fn is_full(&self) -> bool { + self.bitfield.count_ones() == self.bitfield.len() + } + + pub fn is_ready(&self) -> bool { + self.info_dict().is_some() && self.peers.len() >= self.sufficient_peers + } + + pub fn is_ready_to_start(&self) -> bool { + self.is_ready() && self.state.can_become_ready() + } +} + +#[cfg(feature = "live")] +impl TorrentActor { + fn total_verified_bytes(&self) -> Option { + let info = self.info_dict()?; + let total_length = info.total_length(); + let piece_length = usize::try_from(info.piece_length).unwrap_or(usize::MAX); + let last_piece = self.bitfield.len().saturating_sub(1); + Some( + self + .bitfield + .iter_ones() + .map(|index| { + if index == last_piece { + total_length.saturating_sub(piece_length.saturating_mul(last_piece)) + } else { + piece_length + } + }) + .fold(0_usize, usize::saturating_add) + .min(total_length), + ) + } + + fn display_name(&self) -> &str { + match &self.metainfo { + MetaInfo::Torrent(torrent) => &torrent.info.name, + MetaInfo::MagnetUri(magnet) => &magnet.name, + } + } + + /// Builds the current state exposed through listeners. + pub fn live_view(&self) -> TorrentView { let info = self.info_dict(); - let total_bytes = info.map(Info::total_length).map(Self::snapshot_u64); - let downloaded_bytes = Self::snapshot_u64(self.total_bytes_downloaded().unwrap_or(0)); - let bytes_remaining = - total_bytes.map(|bytes| bytes.saturating_sub(downloaded_bytes.min(bytes))); + let total_bytes = info + .map(Info::total_length) + .map(Self::snapshot_u64) + .map(ByteCount); + let verified_bytes = ByteCount(Self::snapshot_u64(self.total_verified_bytes().unwrap_or(0))); + let remaining_bytes = total_bytes.map(|bytes| bytes.saturating_sub(verified_bytes)); let progress_fraction = total_bytes.map(|bytes| { - if bytes == 0 { + if bytes.0 == 0 { 1.0 } else { - downloaded_bytes.min(bytes) as f64 / bytes as f64 + verified_bytes.0.min(bytes.0) as f64 / bytes.0 as f64 } }); let completed_pieces = self.bitfield.count_ones(); let total_pieces = self.bitfield.len(); let partial_pieces = self .piece_scheduler - .block_map_export() + .completed_blocks() .iter() - .filter(|entry| { - let piece_idx = *entry.key(); - piece_idx < total_pieces && !self.bitfield[piece_idx] && entry.value().count_ones() > 0 + .filter(|(piece_index, blocks)| { + **piece_index < total_pieces && !self.bitfield[**piece_index] && blocks.count_ones() > 0 }) .count(); + let peers = self + .hub + .peer_handles(self.info_hash()) + .into_iter() + .map(|peer| peer.view()) + .collect::>(); + let trackers = self + .hub + .tracker_handles(self.info_hash()) + .into_iter() + .map(|tracker| tracker.view()) + .collect::>(); + let metric_sources = peers + .iter() + .map(|peer| peer as &dyn HasTransferMetrics) + .chain( + trackers + .iter() + .map(|tracker| tracker as &dyn HasTransferMetrics), + ) + .collect::>(); + let metrics = TorrentMetrics::new( + TransferMetrics::aggregate(metric_sources), + ContentProgress { + total_bytes, + verified_bytes, + remaining_bytes, + progress_fraction, + completed_pieces: Self::snapshot_u64(completed_pieces), + partial_pieces: Self::snapshot_u64(partial_pieces), + total_pieces: Self::snapshot_u64(total_pieces), + }, + ); - TorrentSnapshot { + TorrentView { info_hash: self.info_hash(), name: self.display_name().to_string(), state: self.state, - has_metadata: info.is_some(), - is_ready: self.state == TorrentState::Ready && self.is_ready(), auto_start: self.autostart, sufficient_peers: Self::snapshot_u64(self.sufficient_peers), peer_count: Self::snapshot_u64(self.peers.len()), @@ -469,44 +624,15 @@ impl TorrentActor { PieceManagerProxy::Default(manager) => manager.path().cloned(), PieceManagerProxy::Custom(_) => None, }, - progress: TorrentProgressSnapshot { - total_bytes, - downloaded_bytes, - bytes_remaining, - progress_fraction, - completed_pieces: Self::snapshot_u64(completed_pieces), - partial_pieces: Self::snapshot_u64(partial_pieces), - total_pieces: Self::snapshot_u64(total_pieces), - }, - transfer: TorrentTransferSnapshot { - download_rate_bytes_per_second: None, - upload_rate_bytes_per_second: None, - eta_seconds: None, - }, + metrics, } } - fn snapshot_u64(value: usize) -> u64 { - u64::try_from(value).unwrap_or(u64::MAX) - } - - fn display_name(&self) -> &str { - match &self.metainfo { - MetaInfo::Torrent(torrent) => &torrent.info.name, - MetaInfo::MagnetUri(magnet) => &magnet.name, - } - } - - pub fn is_full(&self) -> bool { - self.bitfield.count_ones() == self.bitfield.len() - } - - pub fn is_ready(&self) -> bool { - self.info.is_some() && self.peers.len() >= self.sufficient_peers - } - - pub fn is_ready_to_start(&self) -> bool { - self.is_ready() && self.state.can_become_ready() + /// The single publication entry point for torrent projection changes. + pub(super) fn publish_live_view(&self, event: impl FnOnce(&TorrentView) -> TorrentEventKind) { + let view = self.live_view(); + let event = event(&view); + self.hub.replace_torrent_view_and_emit(view, event); } } @@ -565,6 +691,10 @@ pub struct TorrentActorArgs { /// Runtime behavior settings. pub settings: Settings, + + /// Projection hub shared with the owning engine. + #[cfg(feature = "live")] + pub(crate) hub: Hub, } impl Actor for TorrentActor { @@ -588,6 +718,8 @@ impl Actor for TorrentActor { sufficient_peers, base_path, settings, + #[cfg(feature = "live")] + hub, } = args; let torrent_id = metainfo.info_hash()?; @@ -615,8 +747,8 @@ impl Actor for TorrentActor { .await; let info = match &metainfo { - MetaInfo::Torrent(t) => Some(t.info.clone()), - _ => None, + MetaInfo::Torrent(torrent) => Some(&torrent.info), + MetaInfo::MagnetUri(_) => None, }; if info.is_none() { debug!(torrent_id = %torrent_id, "No info dict found in metainfo, you're probably using a magnet uri"); @@ -633,12 +765,29 @@ impl Actor for TorrentActor { TorrentState::ResolvingMetadata }; let bitfield = BitVec::repeat(false, piece_count); - let initial_left = info.as_ref().map(Info::total_length); + let initial_left = info.map(Info::total_length); // Create tracker actors let tracker_list = metainfo.announce_list(); let mut trackers = HashMap::new(); for tracker in tracker_list { + #[cfg(feature = "live")] + let endpoint = tracker.redacted_endpoint(); + #[cfg(feature = "live")] + let Some(tracker_handle) = hub.register_tracker_scope( + torrent_id, + &tracker, + TrackerView { + endpoint, + status: TrackerStatus::Pending, + metrics: TrackerMetrics::default(), + }, + ) else { + return Err(TorrentError::ActorCommunicationFailed { + operation: "register tracker live scope", + reason: "live hub is no longer available".to_string(), + }); + }; let actor = TrackerActor::supervise( &us, TrackerActorArgs { @@ -650,6 +799,8 @@ impl Actor for TorrentActor { supervisor: us.clone(), scheduler: scheduler.clone(), settings: settings.tracker.clone(), + #[cfg(feature = "live")] + live_handle: tracker_handle, }, ) .restart_policy(RestartPolicy::Transient) @@ -662,7 +813,7 @@ impl Actor for TorrentActor { trackers.insert(tracker, actor); } - let default_manager = FilePieceManager(base_path, info.clone()); + let default_manager = FilePieceManager(base_path, info.cloned()); let piece_store = PieceStoreActor::supervise(&us, ()) .restart_policy(RestartPolicy::Permanent) .restart_limit( @@ -672,7 +823,9 @@ impl Actor for TorrentActor { .spawn() .await; - Ok(Self { + let actor = Self { + #[cfg(feature = "live")] + hub, peers: HashMap::new(), bitfield, tracker_server, @@ -681,7 +834,7 @@ impl Actor for TorrentActor { trackers, id: peer_id, metainfo, - info, + resolved_magnet_info: None, actor_ref: us, piece_storage, piece_store, @@ -699,7 +852,10 @@ impl Actor for TorrentActor { ready_hook: Vec::new(), piece_manager: PieceManagerProxy::Default(default_manager), settings, - }) + }; + crate::live_only!(actor.hub.initialize_torrent_projection(actor.live_view())); + + Ok(actor) } async fn next( @@ -715,7 +871,18 @@ impl Actor for TorrentActor { async fn on_stop( &mut self, _: WeakActorRef, reason: ActorStopReason, ) -> Result<(), Self::Error> { - self.state = TorrentState::Stopping; + if reason.is_normal() { + self.transition_state(TorrentState::Stopping); + } else { + // The engine supervises torrent actors transiently. Preserve the + // live scope and make the temporary state explicit. + self.transition_state(TorrentState::Restarting); + crate::live_only!( + self + .hub + .close_peer_scopes_for_torrent_restart(self.info_hash()) + ); + } info!(reason = %reason, "Torrent stopped"); for peer in self.peers.values() { peer.kill(); @@ -728,7 +895,9 @@ impl Actor for TorrentActor { } self.piece_store.kill(); self.scheduler.kill(); - self.state = TorrentState::Stopped; + if reason.is_normal() { + self.transition_state(TorrentState::Stopped); + } Ok(()) } @@ -738,12 +907,21 @@ impl Actor for TorrentActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); + crate::live_only! { + if !reason.is_normal() { + self.hub.emit_health( + Some(self.info_hash()), + LiveHealthLevel::Error, + "a torrent service stopped unexpectedly", + ); + } + } Ok(ControlFlow::Continue(())) } } -#[cfg(test)] +#[cfg(all(test, feature = "live"))] mod tests { use std::{path::PathBuf, time::Duration}; @@ -760,17 +938,87 @@ mod tests { use super::*; use crate::{ hashes::HashVec, + live::{PeerIdentity, PeerView}, metainfo::{InfoKeys, MetaInfo, TorrentFile}, + metrics::{BytesPerSecond, PeerMetrics, TrafficTotals, TransferRates, TransferSample}, + protocol::{ + messages::{Handshake, PeerMessages}, + stream::{PeerRecv, PeerSend, PeerStream}, + }, settings::Settings, testing, torrent::{ - BLOCK_SIZE, Torrent, TorrentExport, TorrentSnapshot, - commands::{ExportState, GetState, HasInfoDict, SetState}, - events::IncomingPiece, + BLOCK_SIZE, Torrent, TorrentSnapshot, + commands::{GetState, HasInfoDict, SetState, SnapshotState}, + events::{AddPeer, IncomingPiece}, }, tracker::Tracker, }; + struct LocalSeed { + address: SocketAddr, + task: tokio::task::JoinHandle<()>, + } + + impl LocalSeed { + async fn start(peer_id: PeerId, payload: Vec, piece_length: usize) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut stream = PeerStream::tcp(stream); + let handshake = stream.recv_handshake_message().await.unwrap(); + stream + .send(PeerMessages::Handshake(Handshake::new( + handshake.info_hash, + peer_id, + ))) + .await + .unwrap(); + + let piece_count = payload.len().div_ceil(piece_length); + stream + .send(PeerMessages::Bitfield(Arc::new( + BitVec::::repeat(true, piece_count), + ))) + .await + .unwrap(); + stream.send(PeerMessages::Unchoke).await.unwrap(); + + while let Ok(message) = stream.recv().await { + let PeerMessages::Request(index, begin, length) = message else { + continue; + }; + let start = index as usize * piece_length + begin as usize; + let end = start + length as usize; + assert!( + end <= payload.len(), + "client requested bytes outside payload" + ); + stream + .send(PeerMessages::Piece( + index, + begin, + Bytes::copy_from_slice(&payload[start..end]), + )) + .await + .unwrap(); + } + }); + Self { address, task } + } + + fn peer(&self) -> crate::peer::Peer { + crate::peer::Peer::from_socket_addr(self.address) + } + } + + impl Drop for LocalSeed { + fn drop(&mut self) { + self.task.abort(); + } + } + fn empty_torrent(tracker: Tracker) -> MetaInfo { torrent_with_info( tracker, @@ -892,6 +1140,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path), settings, + hub: Hub::default(), }); actor .tell(SetState { @@ -911,9 +1160,9 @@ mod tests { assert!(query.contains(&format!("left={}", info.total_length()))); assert!(query.contains("compact=0")); - let export = actor.ask(ExportState).await.unwrap(); - assert_eq!(export.info_hash, info_hash); - assert_eq!(export.state, TorrentState::Downloading); + let snapshot = actor.ask(SnapshotState).await.unwrap(); + assert_eq!(snapshot.info_hash, info_hash); + assert_eq!(snapshot.state, TorrentState::Downloading); actor.stop_gracefully().await.unwrap(); } @@ -942,6 +1191,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(testing::torrent_temp_path()), settings, + hub: Hub::default(), }); actor .tell(SetState { @@ -989,6 +1239,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(base_path.clone()), settings, + hub: Hub::default(), }); actor .tell(SetState { @@ -1038,6 +1289,93 @@ mod tests { fs::remove_dir_all(base_path).await.unwrap(); } + #[tokio::test(flavor = "multi_thread")] + async fn torrent_actor_when_request_window_is_smaller_than_torrent_then_downloads_every_piece() { + let piece_length = BLOCK_SIZE; + let piece_count = 12; + let payload = (0..piece_length * piece_count) + .map(|index| (index % 251) as u8) + .collect::>(); + let mut pieces = HashVec::new(); + for piece in payload.chunks(piece_length) { + pieces.push(testing::piece_hash(piece)); + } + let info = Info { + name: "complete-download.bin".to_string(), + piece_length: piece_length as u64, + pieces, + file: InfoKeys::Single { + length: payload.len() as u64, + md5sum: None, + }, + is_private: Some(true), + publisher: None, + publisher_url: None, + source: None, + }; + let info_hash = info.hash().unwrap(); + let metainfo = MetaInfo::Torrent(TorrentFile { + announce: None, + announce_list: None, + comment: None, + created_by: None, + creation_date: None, + encoding: None, + info, + url_list: None, + }); + let fixture = testing::storage_fixture("complete-download").await.unwrap(); + let seed = LocalSeed::start(PeerId::Unknown([7; 20]), payload.clone(), piece_length).await; + let mut settings = Settings::default(); + settings.torrent.initial_peer_request_window = 4; + settings.torrent.max_in_flight_per_peer = 4; + + let actor = TorrentActor::spawn(TorrentActorArgs { + peer_id: testing::peer_id(), + metainfo, + utp_server: UtpSocket::new_udp(testing::ephemeral_socket_addr()) + .await + .unwrap(), + tracker_server: testing::udp_server().await, + primary_addr: None, + piece_storage: PieceStorageStrategy::InFile, + autostart: Some(false), + sufficient_peers: Some(1), + base_path: Some(fixture.path().to_path_buf()), + settings, + hub: Hub::default(), + }); + let torrent = Torrent::new(info_hash, actor.clone()); + actor.tell(AddPeer { peer: seed.peer() }).await.unwrap(); + + timeout(Duration::from_secs(5), torrent.poll_ready()) + .await + .expect("local seed should make torrent ready") + .unwrap(); + torrent.start().await.unwrap(); + timeout(Duration::from_secs(10), async { + loop { + if torrent.state().await.unwrap() == TorrentState::Seeding { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("torrent should replenish its request window through completion"); + + assert_eq!( + fs::read(fixture.child("complete-download.bin")) + .await + .unwrap(), + payload + ); + let snapshot = torrent.snapshot().await.unwrap(); + assert!(snapshot.bitfield.iter().all(|complete| *complete)); + + actor.stop_gracefully().await.unwrap(); + } + #[tokio::test(flavor = "multi_thread")] #[ignore = "external-network test: reaches public trackers and peers"] async fn torrent_actor_when_public_torrent_is_available_then_reaches_ready_state() { @@ -1066,6 +1404,7 @@ mod tests { sufficient_peers: Some(sufficient_peers), base_path: None, settings: Settings::default(), + hub: Hub::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); @@ -1099,6 +1438,7 @@ mod tests { sufficient_peers: None, base_path: None, settings: Settings::default(), + hub: Hub::default(), }); // Blocking loop that runs until we get an info dict @@ -1138,6 +1478,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), + hub: Hub::default(), }); assert_eq!(actor.ask(GetState).await.unwrap(), TorrentState::Ready); @@ -1162,6 +1503,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), + hub: Hub::default(), }); assert_eq!( @@ -1190,6 +1532,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), + hub: Hub::default(), }); actor @@ -1249,6 +1592,7 @@ mod tests { sufficient_peers: None, base_path: Some(file_path), settings: Settings::default(), + hub: Hub::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); @@ -1257,12 +1601,12 @@ mod tests { let wrote_piece_block = timeout(Duration::from_secs(60), async { loop { - let export = actor.ask(ExportState).await.unwrap(); - let has_persisted_progress = export.bitfield.count_ones() > 0 - || export + let snapshot = actor.ask(SnapshotState).await.unwrap(); + let has_persisted_progress = snapshot.bitfield.iter().any(|complete| *complete) + || snapshot .block_map .iter() - .any(|entry| entry.value().count_ones() > 0); + .any(|entry| entry.blocks.iter().any(|block| *block)); if has_persisted_progress { let mut entries = fs::read_dir(&piece_path).await.unwrap(); @@ -1293,7 +1637,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn torrent_actor_when_pieces_are_marked_complete_then_exports_progress_correctly() { + async fn torrent_actor_when_pieces_are_marked_complete_then_snapshots_progress_correctly() { testing::init_tracing(); let metainfo = testing::read_torrent_fixture(testing::BIG_BUCK_BUNNY_TORRENT_FILE).await; let info_dict = match &metainfo { @@ -1312,7 +1656,7 @@ mod tests { .unwrap(); // Spawn the actor first so we get an ActorRef, then immediately stop it - // and reconstruct state for direct export testing. + // and reconstruct state for direct snapshot testing. let actor_ref = TorrentActor::spawn(TorrentActorArgs { peer_id, metainfo: metainfo.clone(), @@ -1324,6 +1668,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), + hub: Hub::default(), }); // Build the bitfield with fake completed pieces @@ -1345,13 +1690,14 @@ mod tests { let mut piece_scheduler = PieceScheduler::new(info_dict.piece_count()); piece_scheduler.set_piece_blocks(partial_piece_index, blocks); - // Construct the actor manually for export testing + // Construct the actor manually for snapshot testing let test_actor = TorrentActor { + hub: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield, id: peer_id, - info: Some(info_dict.clone()), + resolved_magnet_info: None, metainfo: metainfo.clone(), tracker_server: udp_server.clone(), scheduler: Scheduler::spawn(Scheduler::new()), @@ -1375,20 +1721,42 @@ mod tests { settings: Settings::default(), }; - let export = test_actor.export(); + let snapshot = test_actor.snapshot().unwrap(); - // Verify export contents - assert_eq!(export.info_hash, info_hash); - assert_eq!(export.state, TorrentState::Added); - assert!(!export.auto_start); - assert_eq!(export.sufficient_peers, 6); - assert!(export.info_dict.is_some(), "Info dict should be present"); - assert_eq!(export.bitfield.count_ones(), fake_completed); - assert_eq!(export.bitfield.len(), piece_count); - assert_eq!(export.block_map.len(), 1); + // Verify snapshot contents + assert_eq!(snapshot.info_hash, info_hash); + assert_eq!(snapshot.state, TorrentState::Added); + assert!(!snapshot.auto_start); + assert_eq!(snapshot.sufficient_peers, 6); + assert!( + snapshot.resolved_magnet_info.is_none(), + "torrent metainfo already contains its info dict" + ); + assert!(snapshot.resolved_info().is_some()); + assert_eq!( + snapshot + .bitfield + .iter() + .filter(|complete| **complete) + .count(), + fake_completed + ); + assert_eq!(snapshot.bitfield.len(), piece_count); + assert_eq!(snapshot.block_map.len(), 1); - let partial_entry = export.block_map.get(&partial_piece_index).unwrap(); - assert_eq!(partial_entry.count_ones(), partial_blocks_received); + let partial_entry = snapshot + .block_map + .iter() + .find(|entry| entry.piece_index == partial_piece_index as u64) + .unwrap(); + assert_eq!( + partial_entry + .blocks + .iter() + .filter(|received| **received) + .count(), + partial_blocks_received + ); let announce_progress = test_actor .tracker_announce_progress() @@ -1401,31 +1769,33 @@ mod tests { info_dict.total_length() - expected_downloaded ); - match &export.piece_storage { - PieceStorageStrategy::Disk(p) => assert_eq!(p, &piece_path), + match &snapshot.piece_storage { + PieceStorageStrategy::Disk(path) => { + assert_eq!(path.as_path(), piece_path.as_path()); + } _ => panic!("Expected Disk storage strategy"), } // Test serialization round-trip use serde_json::{from_str, to_string}; - let export_str = to_string(&export).unwrap(); - let from_export: TorrentExport = from_str(&export_str).unwrap(); + let snapshot_json = to_string(&snapshot).unwrap(); + let round_trip: TorrentSnapshot = from_str(&snapshot_json).unwrap(); - assert_eq!(export.info_hash, from_export.info_hash); - assert_eq!(export.state, from_export.state); - assert_eq!(export.auto_start, from_export.auto_start); - assert_eq!(export.sufficient_peers, from_export.sufficient_peers); - assert_eq!(export.output_path, from_export.output_path); - assert_eq!(export.bitfield, from_export.bitfield); - assert_eq!(export.block_map.len(), from_export.block_map.len()); + assert_eq!(snapshot.info_hash, round_trip.info_hash); + assert_eq!(snapshot.state, round_trip.state); + assert_eq!(snapshot.auto_start, round_trip.auto_start); + assert_eq!(snapshot.sufficient_peers, round_trip.sufficient_peers); + assert_eq!(snapshot.output_path, round_trip.output_path); + assert_eq!(snapshot.bitfield, round_trip.bitfield); + assert_eq!(snapshot.block_map.len(), round_trip.block_map.len()); - trace!("Export: {export_str}"); + trace!("Snapshot: {snapshot_json}"); actor_ref.stop_gracefully().await.unwrap(); } #[tokio::test(flavor = "multi_thread")] - async fn torrent_actor_when_pieces_are_marked_complete_then_snapshots_progress_correctly() { + async fn torrent_actor_when_pieces_are_marked_complete_then_updates_live_progress() { testing::init_tracing(); let mut metainfo = testing::read_torrent_fixture(testing::BIG_BUCK_BUNNY_TORRENT_FILE).await; if let MetaInfo::Torrent(torrent_file) = &mut metainfo { @@ -1446,6 +1816,7 @@ mod tests { let utp_server = UtpSocket::new_udp(testing::ephemeral_socket_addr()) .await .unwrap(); + let hub = Hub::default(); let actor_ref = TorrentActor::spawn(TorrentActorArgs { peer_id, metainfo: metainfo.clone(), @@ -1457,12 +1828,10 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), + hub: hub.clone(), }); - let live_snapshot = Torrent::new(info_hash, actor_ref.clone()) - .snapshot() - .await - .unwrap(); - assert_eq!(live_snapshot.tracker_count, 1); + actor_ref.ask(GetState).await.unwrap(); + assert_eq!(hub.torrent_view(info_hash).unwrap().tracker_count, 1); let piece_count = info_dict.piece_count(); let bitfield: BitVec = BitVec::repeat(false, piece_count); @@ -1482,11 +1851,12 @@ mod tests { piece_scheduler.set_piece_blocks(partial_piece_index, blocks); let mut test_actor = TorrentActor { + hub: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield, id: peer_id, - info: Some(info_dict.clone()), + resolved_magnet_info: None, metainfo: metainfo.clone(), tracker_server: udp_server, scheduler: Scheduler::spawn(Scheduler::new()), @@ -1510,46 +1880,150 @@ mod tests { settings: Settings::default(), }; - let snapshot = test_actor.snapshot(); - - assert_eq!(snapshot.info_hash, info_hash); - assert_eq!(snapshot.name, testing::BIG_BUCK_BUNNY_NAME); - assert_eq!(snapshot.state, TorrentState::Ready); - assert!(snapshot.has_metadata); - assert!(snapshot.is_ready); - assert!(!snapshot.auto_start); - assert_eq!(snapshot.sufficient_peers, 0); - assert_eq!(snapshot.output_path, Some(file_path)); + let verified_content = test_actor.live_view().metrics.progress.verified_bytes; + let sampled_peer = test_actor + .hub + .register_peer_scope( + PeerIdentity { + torrent: info_hash, + peer: PeerId::Unknown([9; 20]), + }, + PeerView { + address: None, + client: None, + connected: true, + metrics: PeerMetrics { + peer_interested: true, + available_pieces: 1, + transfer: TransferMetrics::from_sample(TransferSample { + previous_totals: TrafficTotals { + downloaded: ByteCount(49_900), + uploaded: ByteCount(4_980), + }, + current_totals: TrafficTotals { + downloaded: ByteCount(50_000), + uploaded: ByteCount(5_000), + }, + elapsed: Duration::from_secs(1), + }), + ..Default::default() + }, + }, + ) + .unwrap(); + let _sampled_tracker = test_actor + .hub + .register_tracker_scope( + info_hash, + &Tracker::Http("http://tracker.example/announce".to_string()), + TrackerView { + endpoint: "http://tracker.example".to_string(), + status: TrackerStatus::Healthy, + metrics: TrackerMetrics { + latest_peers_returned: Some(1), + transfer: TransferMetrics::from_sample(TransferSample { + previous_totals: TrafficTotals { + downloaded: ByteCount(248), + uploaded: ByteCount(49), + }, + current_totals: TrafficTotals { + downloaded: ByteCount(250), + uploaded: ByteCount(50), + }, + elapsed: Duration::from_secs(1), + }), + ..Default::default() + }, + }, + ) + .unwrap(); + let view = test_actor.live_view(); + + assert_eq!(view.info_hash, info_hash); + assert_eq!(view.name, testing::BIG_BUCK_BUNNY_NAME); + assert_eq!(view.state, TorrentState::Ready); + assert!(view.has_metadata()); + assert!(view.is_ready()); + assert!(!view.auto_start); + assert_eq!(view.sufficient_peers, 0); + assert_eq!(view.output_path, Some(file_path.clone())); assert_eq!( - snapshot.progress.total_bytes, - Some(u64::try_from(info_dict.total_length()).unwrap()) + view.metrics.progress.total_bytes, + Some(ByteCount(u64::try_from(info_dict.total_length()).unwrap())) ); assert_eq!( - snapshot.progress.completed_pieces, + view.metrics.progress.completed_pieces, u64::try_from(completed_pieces).unwrap() ); - assert_eq!(snapshot.progress.partial_pieces, 1); + assert_eq!(view.metrics.progress.partial_pieces, 1); assert_eq!( - snapshot.progress.total_pieces, + view.metrics.progress.total_pieces, u64::try_from(piece_count).unwrap() ); - assert!(snapshot.progress.downloaded_bytes > 0); + assert!(view.metrics.progress.verified_bytes > ByteCount::ZERO); assert!( - snapshot.progress.bytes_remaining.unwrap() - < u64::try_from(info_dict.total_length()).unwrap() + view.metrics.progress.remaining_bytes.unwrap() + < ByteCount(u64::try_from(info_dict.total_length()).unwrap()) + ); + assert!(view.metrics.progress.progress_fraction.unwrap() > 0.0); + assert_eq!( + view.metrics.traffic.rates(), + Some(TransferRates { + download: BytesPerSecond(102), + upload: BytesPerSecond(21), + }) ); - assert!(snapshot.progress.progress_fraction.unwrap() > 0.0); - assert_eq!(snapshot.transfer.download_rate_bytes_per_second, None); - assert_eq!(snapshot.transfer.upload_rate_bytes_per_second, None); - assert_eq!(snapshot.transfer.eta_seconds, None); + assert_eq!(view.metrics.traffic.totals.downloaded, ByteCount(50_250)); + assert_eq!(view.metrics.traffic.totals.uploaded, ByteCount(5_050)); + assert_eq!(view.metrics.progress.verified_bytes, verified_content); + assert!(view.metrics.eta.is_some()); + test_actor.state = TorrentState::Seeding; + assert_eq!( + test_actor + .live_view() + .metrics + .traffic + .rates() + .unwrap() + .upload, + BytesPerSecond(21) + ); + sampled_peer.disconnected(); + assert_eq!( + test_actor.live_view().metrics.traffic.rates(), + Some(TransferRates { + download: BytesPerSecond(2), + upload: BytesPerSecond(1), + }) + ); + test_actor.state = TorrentState::Ready; + + let snapshot = test_actor.snapshot().unwrap(); + assert_eq!(snapshot.version, TORRENT_SNAPSHOT_VERSION); + assert_eq!(snapshot.info_hash, info_hash); + assert_eq!(snapshot.state, TorrentState::Ready); + assert_eq!(snapshot.output_path, Some(file_path)); + assert_eq!( + snapshot + .bitfield + .iter() + .filter(|complete| **complete) + .count(), + completed_pieces + ); + assert_eq!(snapshot.block_map.len(), 1); let snapshot_str = serde_json::to_string(&snapshot).unwrap(); let from_snapshot: TorrentSnapshot = serde_json::from_str(&snapshot_str).unwrap(); - assert_eq!(snapshot, from_snapshot); + assert_eq!(snapshot.version, from_snapshot.version); + assert_eq!(snapshot.info_hash, from_snapshot.info_hash); + assert_eq!(snapshot.state, from_snapshot.state); + assert_eq!(snapshot.bitfield, from_snapshot.bitfield); + assert_eq!(snapshot.block_map.len(), from_snapshot.block_map.len()); test_actor.state = TorrentState::Paused; - assert!(!test_actor.snapshot().is_ready); + assert!(!test_actor.live_view().is_ready()); test_actor.bitfield.fill(false); test_actor.bitfield.set_aliased(piece_count - 1, true); @@ -1557,8 +2031,8 @@ mod tests { let last_piece_bytes = info_dict.total_length() - ((piece_count - 1) * usize::try_from(info_dict.piece_length).unwrap()); assert_eq!( - test_actor.snapshot().progress.downloaded_bytes, - u64::try_from(last_piece_bytes).unwrap() + test_actor.live_view().metrics.progress.verified_bytes, + ByteCount(u64::try_from(last_piece_bytes).unwrap()) ); actor_ref.stop_gracefully().await.unwrap(); @@ -1593,14 +2067,16 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), + hub: Hub::default(), }); let mut actor = TorrentActor { + hub: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield: BitVec::repeat(false, piece_count), id: peer_id, - info: Some(info_dict.clone()), + resolved_magnet_info: None, metainfo, tracker_server: udp_server, scheduler: Scheduler::spawn(Scheduler::new()), diff --git a/crates/libtortillas/src/torrent/choking.rs b/crates/libtortillas/src/torrent/choking.rs index cc43f209..39e7c2bb 100644 --- a/crates/libtortillas/src/torrent/choking.rs +++ b/crates/libtortillas/src/torrent/choking.rs @@ -69,7 +69,7 @@ pub(crate) fn select_unchoked_peers( let mut candidates: Vec<_> = peers .iter() .filter(|peer| peer.interested) - .copied() + .cloned() .collect(); candidates.sort_by(|left, right| { rate_for(right, torrent_state) @@ -112,7 +112,7 @@ pub(crate) fn select_unchoked_peers( } } -fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> usize { +fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> u64 { match torrent_state { TorrentState::Downloading => peer.download_rate, TorrentState::Seeding => peer.upload_rate, @@ -120,6 +120,7 @@ fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> usize { | TorrentState::ResolvingMetadata | TorrentState::Ready | TorrentState::Paused + | TorrentState::Restarting | TorrentState::Stopping | TorrentState::Stopped | TorrentState::Failed => 0, @@ -128,7 +129,12 @@ fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> usize { #[cfg(test)] mod tests { + #[cfg(feature = "live")] + use std::time::Duration; + use super::*; + #[cfg(feature = "live")] + use crate::metrics::{ByteCount, PeerMetrics, TrafficTotals, TransferMetrics, TransferSample}; fn peer_id(value: u8) -> PeerId { PeerId::from([value; 20]) @@ -138,20 +144,39 @@ mod tests { PeerStats { id: peer_id(id), interested: true, - choked: true, + client_choking: true, download_rate: 0, upload_rate: 0, - bytes_downloaded: 0, - bytes_uploaded: 0, + #[cfg(feature = "live")] + metrics: PeerMetrics { + peer_interested: true, + client_choking: true, + transfer: TransferMetrics::from_sample(TransferSample { + previous_totals: TrafficTotals::default(), + current_totals: TrafficTotals::default(), + elapsed: Duration::from_secs(1), + }), + ..Default::default() + }, } } - fn with_rates(id: u8, download_rate: usize, upload_rate: usize) -> PeerStats { - PeerStats { - download_rate, - upload_rate, - ..stats(id) + fn with_rates(id: u8, download_rate: u64, upload_rate: u64) -> PeerStats { + let mut stats = stats(id); + stats.download_rate = download_rate; + stats.upload_rate = upload_rate; + #[cfg(feature = "live")] + { + stats.metrics.transfer = TransferMetrics::from_sample(TransferSample { + previous_totals: TrafficTotals::default(), + current_totals: TrafficTotals { + downloaded: ByteCount(download_rate), + uploaded: ByteCount(upload_rate), + }, + elapsed: Duration::from_secs(1), + }); } + stats } #[test] diff --git a/crates/libtortillas/src/torrent/choking_flow.rs b/crates/libtortillas/src/torrent/choking_flow.rs index 4f68659e..3203b707 100644 --- a/crates/libtortillas/src/torrent/choking_flow.rs +++ b/crates/libtortillas/src/torrent/choking_flow.rs @@ -7,7 +7,7 @@ use tracing::{trace, warn}; use super::TorrentActor; use crate::peer::{ - PeerActor, PeerStats, + PeerActor, PeerId, PeerStats, commands::{SetChoked, Stats}, }; @@ -19,6 +19,16 @@ impl TorrentActor { } let peer_stats = self.peer_stats().await; + let expired_requests = self + .piece_scheduler + .release_stale_requests(self.settings.torrent.peer_request_timeout); + if expired_requests > 0 { + trace!(expired_requests, "Released unanswered peer requests"); + } + // Peer actors publish their own high-frequency samples. The torrent + // publishes one coalesced aggregate after the collection interval. + self.publish_metrics_changed(); + self.try_update_tracker_progress(); let decision = self.choking_scheduler.decide(&peer_stats, self.state); let unchoked: HashSet<_> = decision.unchoked.iter().copied().collect(); @@ -30,7 +40,7 @@ impl TorrentActor { for stats in peer_stats { let choked = !unchoked.contains(&stats.id); - if stats.choked == choked { + if stats.client_choking == choked { continue; } @@ -42,12 +52,17 @@ impl TorrentActor { warn!(?err, peer_id = %stats.id, choked, "Failed to update peer choke state"); } } + + // Recover work released by peers that rejected requests or disconnected + // between collection intervals. Filling to a target size is idempotent, + // so this cannot grow a peer beyond its configured request window. + self.fill_all_peer_request_windows(); } async fn peer_stats(&self) -> Vec { let peer_stats_timeout = self.settings.torrent.peer_stats_timeout; let peer_stats_concurrency = self.settings.torrent.peer_stats_concurrency.max(1); - let actor_refs: Vec<(crate::peer::PeerId, ActorRef)> = self + let actor_refs: Vec<(PeerId, ActorRef)> = self .peers .iter() .filter(|(_, actor)| actor.is_alive()) diff --git a/crates/libtortillas/src/torrent/export.rs b/crates/libtortillas/src/torrent/export.rs deleted file mode 100644 index 0a2b9cdf..00000000 --- a/crates/libtortillas/src/torrent/export.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::{path::PathBuf, sync::atomic::AtomicU8}; - -use bitvec::vec::BitVec; -use serde::{Deserialize, Serialize}; - -use super::{BlockMap, PieceStorageStrategy, TorrentState}; -use crate::{ - hashes::InfoHash, - metainfo::{Info, MetaInfo}, -}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TorrentExport { - pub info_hash: InfoHash, - pub state: TorrentState, - pub auto_start: bool, - pub sufficient_peers: usize, - pub output_path: Option, - pub metainfo: MetaInfo, - pub piece_storage: PieceStorageStrategy, - pub info_dict: Option, - pub bitfield: BitVec, - pub block_map: BlockMap, -} diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index b62a9994..34d442ee 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -1,6 +1,7 @@ -use std::path::PathBuf; +#[cfg(feature = "live")] +use std::sync::Weak; +use std::{fmt, path::PathBuf, sync::Arc}; -use anyhow::Result; use kameo::actor::ActorRef; use tokio::sync::oneshot; use tracing::error; @@ -12,131 +13,256 @@ use super::{ SetSufficientPeers, SnapshotState, }, }; -use crate::{hashes::InfoHash, pieces::PieceManager}; +#[cfg(feature = "live")] +use crate::live::{ + EventSubscription, Hub, HubInner, LivePublisher, PeerHandle, TorrentEventKind, TorrentListener, + TorrentView, TrackerHandle, +}; +use crate::{ + errors::{TorrentError, map_torrent_send_error}, + hashes::InfoHash, + pieces::PieceManager, +}; + +#[derive(Debug)] +pub(crate) struct TorrentInner { + pub(crate) info_hash: InfoHash, + pub(crate) actor: ActorRef, + #[cfg(feature = "live")] + pub(crate) hub: Weak, + #[cfg(feature = "live")] + pub(crate) publisher: Arc, TorrentEventKind>>, +} /// A handle to a torrent managed by the engine. /// -/// This struct acts as the primary interface for controlling and configuring -/// a torrent after it has been added to the [`Engine`](crate::engine::Engine). -#[allow(dead_code)] -#[derive(Debug, Clone)] -pub struct Torrent(InfoHash, ActorRef); +/// This struct acts as the primary interface for controlling, observing, and +/// configuring a torrent after it has been added to the +/// [`Engine`](crate::engine::Engine). +#[derive(Clone)] +pub struct Torrent { + pub(crate) inner: Arc, +} -impl Torrent { - /// Creates a new [`Torrent`] handle from an [`InfoHash`] and a reference - /// to its underlying [`TorrentActor`]. - pub(crate) fn new(info_hash: InfoHash, actor_ref: ActorRef) -> Self { - Torrent(info_hash, actor_ref) +impl fmt::Debug for Torrent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Torrent") + .field("info_hash", &self.info_hash()) + .finish_non_exhaustive() } +} +impl Torrent { pub(crate) fn actor(&self) -> &ActorRef { - &self.1 + &self.inner.actor } /// Returns the [`InfoHash`] that uniquely identifies this torrent. pub fn info_hash(&self) -> InfoHash { - self.0 - } - - /// Alias for [`Self::info_hash`]. - pub fn key(&self) -> InfoHash { - self.info_hash() + self.inner.info_hash } - pub async fn set_piece_storage(&self, piece_storage: PieceStorageStrategy) -> Result<()> { + pub async fn set_piece_storage( + &self, piece_storage: PieceStorageStrategy, + ) -> Result<(), TorrentError> { self .actor() - .tell(SetPieceStorage { + .ask(SetPieceStorage { strategy: piece_storage, }) - .await?; + .await + .map_err(|error| map_torrent_send_error("set piece storage", error))?; Ok(()) } - pub async fn with_output_folder(&self, folder: impl Into) -> Result<()> { + /// Sets the output folder used by the default file piece manager. + pub async fn set_output_folder(&self, folder: impl Into) -> Result<(), TorrentError> { self .actor() .ask(SetOutputPath { path: folder.into(), }) - .await?; + .await + .map_err(|error| map_torrent_send_error("set output folder", error))?; Ok(()) } - pub async fn with_piece_manager<'a>( + pub async fn set_piece_manager<'a>( &'a self, piece_manager: impl PieceManager + 'a + 'static, - ) -> Result<()> { + ) -> Result<(), TorrentError> { self .actor() - .tell(SetPieceManager { + .ask(SetPieceManager { manager: Box::new(piece_manager), }) - .await?; + .await + .map_err(|error| map_torrent_send_error("set piece manager", error))?; Ok(()) } - pub async fn start(&self) -> Result<()> { + pub async fn start(&self) -> Result<(), TorrentError> { self.set_state(TorrentState::Downloading, "start").await } /// Resumes downloading or seeding this torrent. - pub async fn resume(&self) -> Result<()> { + pub async fn resume(&self) -> Result<(), TorrentError> { self.start().await } /// Pauses this torrent while preserving its downloaded data and metadata. - pub async fn pause(&self) -> Result<()> { + pub async fn pause(&self) -> Result<(), TorrentError> { self.set_state(TorrentState::Paused, "pause").await } /// Stops this torrent's active transfers. - pub async fn stop(&self) -> Result<()> { + pub async fn stop(&self) -> Result<(), TorrentError> { self.set_state(TorrentState::Paused, "stop").await } - async fn set_state(&self, state: TorrentState, operation: &'static str) -> Result<()> { - let msg = SetState { state }; - + async fn set_state( + &self, state: TorrentState, operation: &'static str, + ) -> Result<(), TorrentError> { self .actor() - .ask(msg) + .ask(SetState { state }) .await - .inspect_err(|e| error!(error = %e, operation, "Failed to change torrent state"))?; - + .inspect_err(|error| { + error!(%error, operation, "Failed to change torrent state"); + }) + .map_err(|error| map_torrent_send_error(operation, error))?; Ok(()) } - pub async fn state(&self) -> Result { - Ok(self.actor().ask(GetState).await?) + pub async fn state(&self) -> Result { + self + .actor() + .ask(GetState) + .await + .map_err(|error| map_torrent_send_error("get state", error)) } - /// Returns a stable, frontend-ready snapshot of this torrent. - pub async fn export(&self) -> Result { - self.snapshot().await + /// Captures this torrent's metadata, storage configuration, and verified or + /// partial piece state in a Serde-compatible persistence snapshot. + /// + /// With the `live` feature, use the torrent listener for current state and + /// incremental updates. + pub async fn snapshot(&self) -> Result { + self + .actor() + .ask(SnapshotState) + .await + .map(|snapshot| *snapshot) + .map_err(|error| map_torrent_send_error("snapshot torrent", error)) } - pub async fn snapshot(&self) -> Result { - Ok(*self.actor().ask(SnapshotState).await?) + pub async fn set_auto_start(&self, auto: bool) -> Result<(), TorrentError> { + self + .actor() + .ask(SetAutoStart { auto }) + .await + .map_err(|error| map_torrent_send_error("set auto start", error))?; + Ok(()) } - pub async fn set_auto_start(&self, auto: bool) -> Result<()> { - let msg = SetAutoStart { auto }; - self.actor().tell(msg).await?; + pub async fn set_sufficient_peers(&self, peers: usize) -> Result<(), TorrentError> { + self + .actor() + .ask(SetSufficientPeers { peers }) + .await + .map_err(|error| map_torrent_send_error("set sufficient peers", error))?; Ok(()) } - pub async fn set_sufficient_peers(&self, peers: usize) -> Result<()> { - let msg = SetSufficientPeers { peers }; - self.actor().tell(msg).await?; + pub async fn poll_ready(&self) -> Result<(), TorrentError> { + let (hook, hook_rx) = oneshot::channel(); + self + .actor() + .ask(ReadyHook { hook }) + .await + .map_err(|error| map_torrent_send_error("register ready hook", error))?; + hook_rx + .await + .map_err(|error| TorrentError::ActorCommunicationFailed { + operation: "wait for readiness", + reason: error.to_string(), + })?; Ok(()) } +} - pub async fn poll_ready(&self) -> Result<()> { - let (hook, hook_rx) = oneshot::channel(); - let msg = ReadyHook { hook }; - self.actor().tell(msg).await?; - hook_rx.await?; +#[cfg(not(feature = "live"))] +impl Torrent { + pub(crate) fn new(info_hash: InfoHash, actor: ActorRef) -> Self { + Self { + inner: Arc::new(TorrentInner { info_hash, actor }), + } + } +} - Ok(()) +#[cfg(feature = "live")] +impl Torrent { + #[cfg(test)] + pub(crate) fn new(info_hash: InfoHash, actor_ref: ActorRef) -> Self { + Self::new_with_hub(info_hash, actor_ref, &Hub::default(), None) + } + + pub(crate) fn new_with_hub( + info_hash: InfoHash, actor: ActorRef, hub: &Hub, + initial_view: Option, + ) -> Self { + let scope = hub + .ensure_torrent_scope(info_hash) + .expect("torrent handles require a live engine hub"); + if let Some(view) = initial_view { + let _ = scope.publisher.install_initial_view(view); + } + let inner = Arc::new(TorrentInner { + info_hash, + actor, + hub: hub.downgrade(), + publisher: Arc::clone(&scope.publisher), + }); + Self { inner } + } + + /// Subscribes to live events for this torrent only. + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + self.inner.publisher.subscribe() + } + + /// Creates a live listener scoped to this torrent. + #[must_use] + pub fn listener(&self) -> TorrentListener { + self.inner.publisher.listener() + } + + /// Returns the latest state maintained for this torrent. + /// + /// This returns `None` after the torrent has been removed from its engine. + #[must_use] + pub fn view(&self) -> Option { + self.inner.publisher.view() + } + + /// Returns handles for this torrent's currently connected peers. + #[must_use] + pub fn peers(&self) -> Vec { + self + .hub() + .map_or_else(Vec::new, |hub| hub.peer_handles(self.info_hash())) + } + + /// Returns handles for this torrent's configured trackers. + #[must_use] + pub fn trackers(&self) -> Vec { + self + .hub() + .map_or_else(Vec::new, |live| live.tracker_handles(self.info_hash())) + } + + fn hub(&self) -> Option { + self.inner.hub.upgrade().map(Hub::from_inner) } } diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index 08a4dfec..53b0c87c 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -5,25 +5,31 @@ use std::{ use bitvec::vec::BitVec; use bytes::Bytes; -use kameo::messages; +use kameo::{Reply, messages}; use sha1::{Digest, Sha1}; use tracing::{info, instrument, trace, warn}; use super::{ - AnnounceFrom, BLOCK_SIZE, PieceStorageStrategy, TorrentActor, TorrentExport, TorrentSnapshot, - TorrentState, + AnnounceFrom, BLOCK_SIZE, PieceStorageStrategy, TorrentActor, TorrentSnapshot, TorrentState, + ValidatedTorrentState, actor::{PieceManagerProxy, ReadyHookSender}, util, }; +#[cfg(feature = "live")] +use crate::live::TorrentView; use crate::{ + errors::TorrentError, hashes::InfoHash, metainfo::Info, peer::{Peer, PeerId, commands::HaveInfoDict}, - pieces::PieceManager, + pieces::{PieceManager, PieceScheduler}, protocol::stream::PeerStream, tracker::Tracker, }; +#[derive(Debug, Reply)] +pub(crate) struct SnapshotRestoreResult(pub(crate) Result<(), TorrentError>); + pub(crate) mod events { use super::*; @@ -80,8 +86,11 @@ pub(crate) mod events { /// Release a scheduler entry for a request a peer could not accept. #[message(derive(Debug, Clone, Copy))] #[instrument(skip(self), fields(torrent_id = %self.info_hash()))] - pub(crate) fn peer_rejected_request(&mut self, index: usize, offset: usize) { - self.piece_scheduler.release_request(index, offset); + pub(crate) fn peer_rejected_request(&mut self, peer_id: PeerId, index: usize, offset: usize) { + self + .piece_scheduler + .release_peer_request(peer_id, index, offset); + self.fill_peer_request_window(peer_id); } /// Bytes for the [`Info`] dict from a peer. These info bytes are expected @@ -89,7 +98,7 @@ pub(crate) mod events { #[message(derive(Debug))] #[instrument(skip(self, bytes), fields(torrent_id = %self.info_hash()))] pub(crate) async fn info_bytes(&mut self, bytes: Bytes) { - if self.info.is_some() { + if self.info_dict().is_some() { trace!( dict = %String::from_utf8_lossy(&bytes), "Received info dict when we already have one" @@ -102,12 +111,19 @@ pub(crate) mod events { let hash = hex::encode(hasher.finalize()); if hash == self.info_hash().to_hex() { info!("Received valid info dict, starting torrent process..."); - let info: Info = serde_bencode::from_bytes(&bytes).expect("Failed to parse info dict"); + let info: Info = match serde_bencode::from_bytes(&bytes) { + Ok(info) => info, + Err(error) => { + warn!(%error, "Peer supplied an invalid info dictionary"); + return; + } + }; self.bitfield = BitVec::repeat(false, info.piece_count()); - self.info = Some(info); + self.resolved_magnet_info = Some(info); if self.state == TorrentState::ResolvingMetadata { - self.state = TorrentState::Added; + self.transition_state(TorrentState::Added); } + self.publish_metadata_resolved(); self .broadcast_to_peers(HaveInfoDict { bitfield: Arc::new(self.bitfield.clone()), @@ -122,23 +138,34 @@ pub(crate) mod events { } /// Sent after `PeerActor::on_start` runs. - #[message(derive(Debug, Clone, Copy))] + #[message(derive(Debug, Clone))] #[instrument(skip(self), fields(torrent_id = %self.info_hash()))] - pub(crate) async fn peer_ready(&mut self, id: PeerId) { + pub(crate) fn peer_ready(&mut self, id: PeerId, available_pieces: Arc>) { + self + .piece_scheduler + .update_peer_availability(id, available_pieces); if let Some(actor) = self.peers.get(&id) && actor.is_alive() && self.state == TorrentState::Downloading && self.is_ready() { - self - .request_blocks_from_peer(id, self.settings.torrent.max_in_flight_per_peer) - .await; + self.fill_peer_request_window(id); trace!(peer_id = %id, "Filled peer request window"); } else { trace!(peer_id = %id, state = ?self.state, ready = self.is_ready(), "Ignoring PeerReady: peer unknown, dead, or torrent not in download state"); } } } + + #[cfg(feature = "live")] + #[messages] + impl TorrentActor { + /// Publishes tracker traffic after an announce attempt. + #[message] + pub(crate) fn tracker_metrics_changed(&self) { + self.publish_metrics_changed(); + } + } } pub(crate) mod commands { @@ -146,96 +173,202 @@ pub(crate) mod commands { #[messages] impl TorrentActor { - #[message] - pub(crate) fn kill_peer(&mut self, id: PeerId) { - self.piece_scheduler.peer_disconnected(id); - // Kill the actor quietly. - if let Some(actor) = self.peers.get(&id) { - actor.kill(); - self.peers.remove(&id); - } else { - warn!("Received kill peer message for unknown peer"); - } - } - #[message] pub(crate) fn kill_tracker(&mut self, tracker: Tracker) { // Kill the actor quietly. if let Some(actor) = self.trackers.get(&tracker) { actor.kill(); self.trackers.remove(&tracker); + self.publish_updated(); } else { warn!("Received kill tracker message for unknown tracker"); } } #[message] - pub(crate) async fn set_piece_storage(&mut self, strategy: PieceStorageStrategy) { + pub(crate) async fn set_piece_storage( + &mut self, strategy: PieceStorageStrategy, + ) -> Result<(), TorrentError> { if !self.is_empty() { - // Intentional panic because this is unintended behavior. - panic!("Cannot change piece storage strategy after we've already received pieces"); + return Err(TorrentError::InvalidOperation { + operation: "set piece storage", + reason: "piece storage cannot change after data has been received".to_string(), + }); + } + if matches!(&self.piece_manager, PieceManagerProxy::Custom(_)) + && !matches!(&strategy, PieceStorageStrategy::Disk(_)) + { + return Err(TorrentError::InvalidOperation { + operation: "set piece storage", + reason: "custom piece managers require disk piece storage".to_string(), + }); } if let PieceStorageStrategy::Disk(dir) = &strategy { - util::create_dir(dir).await.unwrap(); // Intended panic + util::create_dir(dir) + .await + .map_err(|error| TorrentError::FileIoError { + operation: "create piece storage directory".to_string(), + reason: error.to_string(), + })?; } self.piece_storage = strategy; + if self.state == TorrentState::Failed { + self.transition_state(TorrentState::Paused); + } + self.publish_updated(); + Ok(()) } /// Sets the current piece manager to a custom implementation. #[message] - pub(crate) async fn set_piece_manager(&mut self, manager: Box) { - // Intentional panic, the program should not run if this is not the case. - assert!( - matches!(self.piece_storage, PieceStorageStrategy::Disk(_)), - "Storage strategy **must** be set to disk before the piece manager is changed", - ); - - self.piece_manager = PieceManagerProxy::Custom(manager); + pub(crate) async fn set_piece_manager( + &mut self, mut manager: Box, + ) -> Result<(), TorrentError> { + if !self.is_empty() { + return Err(TorrentError::InvalidOperation { + operation: "set piece manager", + reason: "piece manager cannot change after data has been received".to_string(), + }); + } + if !matches!(self.piece_storage, PieceStorageStrategy::Disk(_)) { + return Err(TorrentError::InvalidOperation { + operation: "set piece manager", + reason: "custom piece managers require disk piece storage".to_string(), + }); + } // If we already have metadata, initialize the replacement manager now. - if let Some(info) = self.info.clone() - && let Err(err) = self.piece_manager.pre_start(info).await + if let Some(info) = self.info_dict().cloned() + && let Err(error) = manager.pre_start(info).await { - warn!(?err, "Failed to pre-start custom piece manager"); + return Err(TorrentError::InvalidOperation { + operation: "set piece manager", + reason: format!("custom piece manager initialization failed: {error}"), + }); } + self.piece_manager = PieceManagerProxy::Custom(manager); + self.publish_updated(); + Ok(()) } /// Sets the output path, should only be used when the `FilePieceManager` /// is used. #[message] - pub(crate) fn set_output_path(&mut self, path: PathBuf) { - match &mut self.piece_manager { - PieceManagerProxy::Default(manager) => manager.set_path(path), - _ => { - warn!(path = ?path, "Cannot set output path when using a custom piece manager; ignoring.") - } + pub(crate) async fn set_output_path(&mut self, path: PathBuf) -> Result<(), TorrentError> { + if !self.is_empty() { + return Err(TorrentError::InvalidOperation { + operation: "set output folder", + reason: "output folder cannot change after data has been received".to_string(), + }); + } + if matches!(&self.piece_manager, PieceManagerProxy::Custom(_)) { + return Err(TorrentError::InvalidOperation { + operation: "set output folder", + reason: "a custom piece manager owns its output paths".to_string(), + }); + } + util::create_dir(&path) + .await + .map_err(|error| TorrentError::FileIoError { + operation: "create output folder".to_string(), + reason: error.to_string(), + })?; + if let PieceManagerProxy::Default(manager) = &mut self.piece_manager { + manager.set_path(path); } + if self.state == TorrentState::Failed { + self.transition_state(TorrentState::Paused); + } + self.publish_updated(); + Ok(()) } /// Start the torrenting process & actually start downloading /// pieces/seeding. #[message] - pub(crate) async fn set_state(&mut self, state: TorrentState) { + pub(crate) async fn set_state(&mut self, state: TorrentState) -> Result<(), TorrentError> { match state { TorrentState::Downloading | TorrentState::Seeding => self.start().await, TorrentState::Paused => self.stop_transfer().await, - state => self.state = state, + state => self.transition_state(state), } + Ok(()) } #[message] - pub(crate) async fn set_auto_start(&mut self, auto: bool) { + pub(crate) async fn set_auto_start(&mut self, auto: bool) -> Result<(), TorrentError> { self.autostart = auto; if !self.pending_start { self.autostart().await; } + self.publish_updated(); + Ok(()) } #[message] - pub(crate) async fn set_sufficient_peers(&mut self, peers: usize) { + pub(crate) async fn set_sufficient_peers( + &mut self, peers: usize, + ) -> Result<(), TorrentError> { self.sufficient_peers = peers; if !self.pending_start { self.autostart().await; } + self.publish_updated(); + Ok(()) + } + + /// Restores persisted piece and lifecycle state before exposing a resumed + /// torrent to callers. + #[message] + pub(crate) fn restore_snapshot( + &mut self, snapshot: ValidatedTorrentState, + ) -> SnapshotRestoreResult { + let result = (|| -> Result<(), TorrentError> { + self.resolved_magnet_info = snapshot.resolved_magnet_info; + let piece_count = self + .info_dict() + .map_or(0, crate::metainfo::Info::piece_count); + + let restored_state = match snapshot.state { + TorrentState::Downloading + | TorrentState::Seeding + | TorrentState::Restarting + | TorrentState::Stopping + | TorrentState::Stopped => TorrentState::Paused, + state => state, + }; + let mut scheduler = PieceScheduler::new(piece_count); + for (index, complete) in snapshot.bitfield.iter().copied().enumerate() { + if !complete { + continue; + } + scheduler.mark_piece_complete(index); + } + for entry in &snapshot.block_map { + let index = usize::try_from(entry.piece_index).map_err(|_| { + TorrentError::InvalidSnapshot { + reason: "partial piece index cannot be represented on this platform" + .to_string(), + } + })?; + scheduler.restore_piece_blocks(index, entry.blocks.iter().copied().collect()); + } + + self.bitfield = snapshot.bitfield.iter().copied().collect(); + self.piece_scheduler = scheduler; + self.autostart = snapshot.auto_start; + self.sufficient_peers = usize::try_from(snapshot.sufficient_peers).map_err(|_| { + TorrentError::InvalidSnapshot { + reason: "sufficient peer count cannot be represented on this platform" + .to_string(), + } + })?; + self.transition_state(restored_state); + self.publish_updated(); + + Ok(()) + })(); + + SnapshotRestoreResult(result) } #[message(derive(Debug, Clone, Copy))] @@ -250,10 +383,10 @@ pub(crate) mod commands { /// /// Only should be used internally. #[message] - pub(crate) async fn ready_hook(&mut self, hook: ReadyHookSender) { + pub(crate) async fn ready_hook(&mut self, hook: ReadyHookSender) -> Result<(), TorrentError> { if self.state == TorrentState::Ready || self.state.is_transfer_active() { let _ = hook.send(()); - return; + return Ok(()); } let is_ready = self.is_ready_to_start(); @@ -263,6 +396,7 @@ pub(crate) mod commands { self.ready_hook.push(hook); self.autostart().await; } + Ok(()) } /// Bitfield of the torrent. @@ -304,7 +438,7 @@ pub(crate) mod commands { /// Sends the current info dict if we have it. #[message] pub(crate) fn has_info_dict(&self) -> Option { - self.info.clone() + self.info_dict().cloned() } /// Requests a piece from the torrent. @@ -312,7 +446,7 @@ pub(crate) mod commands { pub(crate) async fn request_piece( &mut self, index: usize, offset: usize, length: usize, ) -> (usize, usize, Option) { - let Some(info) = self.info.as_ref() else { + let Some(info) = self.info_dict() else { warn!( index, offset, length, "Peer requested block before info dict was available" @@ -399,18 +533,40 @@ pub(crate) mod commands { } #[message] - pub(crate) fn get_state(&self) -> TorrentState { - self.state + pub(crate) fn get_state(&self) -> Result { + Ok(self.state) + } + + #[message] + pub(crate) fn snapshot_state(&self) -> Result, TorrentError> { + self.snapshot().map(Box::new) + } + } + + #[cfg(feature = "live")] + #[messages] + impl TorrentActor { + #[message] + pub(crate) fn kill_peer(&mut self, id: PeerId, handle: crate::live::PeerHandle) { + self.remove_peer(id); + handle.disconnected(); + self.publish_updated(); + self.fill_all_peer_request_windows(); } #[message] - pub(crate) fn export_state(&self) -> Box { - Box::new(self.export()) + pub(crate) fn get_live_view(&self) -> Box { + Box::new(self.live_view()) } + } + #[cfg(not(feature = "live"))] + #[messages] + impl TorrentActor { #[message] - pub(crate) fn snapshot_state(&self) -> Box { - Box::new(self.snapshot()) + pub(crate) fn kill_peer(&mut self, id: PeerId) { + self.remove_peer(id); + self.fill_all_peer_request_windows(); } } } diff --git a/crates/libtortillas/src/torrent/mod.rs b/crates/libtortillas/src/torrent/mod.rs index 3145c083..14d49aac 100644 --- a/crates/libtortillas/src/torrent/mod.rs +++ b/crates/libtortillas/src/torrent/mod.rs @@ -1,9 +1,43 @@ +//! Torrent lifecycle, transfer coordination, storage, and persistence. +//! +//! # Lifecycle +//! +//! Torrents with metadata begin as [`TorrentState::Added`]; magnet sources +//! without metadata begin as [`TorrentState::ResolvingMetadata`]. Once metadata +//! and the configured peer threshold are available, a torrent becomes +//! [`TorrentState::Ready`] when autostart is disabled or moves into +//! [`TorrentState::Downloading`] when transfer begins. +//! +//! Completed downloads transition to [`TorrentState::Seeding`], while paused +//! torrents remain paused until explicitly resumed. +//! +//! # Persistence boundary +//! +//! Live views and persistence snapshots are separate. Restoration runs in this +//! order: +//! +//! ```text +//! schema validation +//! -> storage reconciliation +//! -> actor-state installation +//! -> optional transfer resumption +//! ``` +//! +//! [`RestoreVerification::Full`] is the safe default. It verifies completed +//! payload hashes, demotes missing or corrupt pieces, and clears partial-block +//! bits whose referenced bytes are absent. +//! [`RestoreVerification::TrustSnapshot`] skips payload verification and must +//! only be used when the application can independently guarantee storage +//! integrity. +//! +//! Custom piece managers cannot be snapshotted unless they have a durable +//! representation. + mod actor; mod block; mod choking; mod choking_flow; mod discovery; -mod export; mod handle; mod messages; mod piece_flow; @@ -15,10 +49,14 @@ mod swarm; pub(crate) use actor::{TorrentActor, TorrentActorArgs}; pub use block::{BLOCK_SIZE, BlockMap}; pub use discovery::AnnounceFrom; -pub(crate) use export::TorrentExport; pub use handle::Torrent; +#[cfg(feature = "live")] +pub(crate) use handle::TorrentInner; pub(crate) use messages::*; -pub use snapshot::{TorrentProgressSnapshot, TorrentSnapshot, TorrentTransferSnapshot}; +pub use snapshot::{ + PieceBlockSnapshot, RestoreVerification, TORRENT_SNAPSHOT_VERSION, TorrentSnapshot, +}; +pub(crate) use snapshot::{ValidatedTorrentSnapshot, ValidatedTorrentState}; pub use state::TorrentState; pub use storage::PieceStorageStrategy; diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index cacab9c7..72ebcf64 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -8,6 +8,8 @@ use tokio::{ use tracing::{debug, info, trace, warn}; use super::{TorrentActor, util}; +#[cfg(all(test, feature = "live"))] +use crate::live::Hub; use crate::{ errors::TorrentError, peer::commands::{CancelPiece, Have, NeedPiece}, @@ -21,7 +23,7 @@ impl TorrentActor { pub async fn handle_incoming_piece( &mut self, peer_id: crate::peer::PeerId, index: usize, offset: usize, block: Bytes, ) { - let info_dict = match &self.info { + let info_dict = match self.info_dict() { Some(info) => info, None => { warn!("Received piece block before info dict was available"); @@ -31,7 +33,15 @@ impl TorrentActor { let piece_length = info_dict.piece_length as usize; let total_length = info_dict.total_length(); - let last_piece_index = info_dict.piece_count().saturating_sub(1); + let piece_count = info_dict.piece_count(); + if index >= piece_count { + warn!( + index, + piece_count, "Received piece block outside the piece range" + ); + return; + } + let last_piece_index = piece_count.saturating_sub(1); // Compute concrete length for this specific piece let concrete_piece_len = if index == last_piece_index { @@ -91,49 +101,92 @@ impl TorrentActor { return; } + let expected_block_len = (concrete_piece_len - offset).min(BLOCK_SIZE); + if block.len() != expected_block_len { + warn!( + index, + offset, + actual = block.len(), + expected = expected_block_len, + "Received piece block with unexpected length" + ); + return; + } + if self.is_duplicate_block(index, block_index) { trace!("Received duplicate piece block"); return; } - let block_len = block.len(); if !self.write_block_to_storage(index, offset, block).await { return; } // Only mark block complete after successful write - self - .piece_scheduler - .mark_block_complete(index, block_index, expected_blocks); - - self - .broadcast_to_peers(CancelPiece { - index, - begin: offset, - length: block_len, - }) - .await; + let assigned_peer = + self + .piece_scheduler + .mark_block_complete(index, block_index, expected_blocks); + if let Some(assigned_peer) = assigned_peer + && assigned_peer != peer_id + && let Some(peer) = self.peers.get(&assigned_peer) + { + let _ = peer + .tell(CancelPiece { + index, + begin: offset, + length: expected_block_len, + }) + .try_send(); + } if self.is_piece_complete(index) { self.piece_completed(peer_id, index).await; } else { - self.request_blocks_from_peer(peer_id, 1).await; + self.fill_peer_request_window(peer_id); trace!(%peer_id, "Requested replacement block from peer"); } } - pub(super) async fn request_blocks_from_peer( - &mut self, peer_id: crate::peer::PeerId, limit: usize, - ) { - let Some(info) = self.info.as_ref() else { + pub(super) fn fill_initial_peer_request_window(&mut self, peer_id: crate::peer::PeerId) { + self.fill_peer_request_window_to(peer_id, self.settings.torrent.initial_peer_request_window); + } + + pub(super) fn fill_peer_request_window(&mut self, peer_id: crate::peer::PeerId) { + self.fill_peer_request_window_to(peer_id, self.settings.torrent.max_in_flight_per_peer); + } + + pub(super) fn fill_all_peer_request_windows(&mut self) { + if self.state != TorrentState::Downloading || !self.is_ready() { + return; + } + + let peer_ids: Vec<_> = self.peers.keys().copied().collect(); + for peer_id in peer_ids { + self.fill_peer_request_window(peer_id); + } + } + + fn fill_peer_request_window_to(&mut self, peer_id: crate::peer::PeerId, target_size: usize) { + if self.state != TorrentState::Downloading || !self.is_ready() { + return; + } + let Some(info) = self.info_dict() else { return; }; let Some(peer) = self.peers.get(&peer_id).cloned() else { return; }; + + let current_size = self.piece_scheduler.in_flight_for_peer(peer_id); + let available_slots = target_size.saturating_sub(current_size); + if available_slots == 0 { + return; + } + let requests = self.piece_scheduler.requests_for_peer( peer_id, - limit, + available_slots, info.piece_length as usize, info.total_length(), ); @@ -144,12 +197,14 @@ impl TorrentActor { begin: request.offset(), length: request.length, }) - .await + .try_send() { - self - .piece_scheduler - .release_request(request.piece_index, request.offset()); - warn!(?err, %peer_id, "Failed to request block from peer"); + self.piece_scheduler.release_peer_request( + peer_id, + request.piece_index, + request.offset(), + ); + trace!(?err, %peer_id, "Peer mailbox unavailable for block request"); } } } @@ -213,16 +268,15 @@ impl TorrentActor { } async fn piece_completed(&mut self, peer_id: crate::peer::PeerId, index: usize) { - let previous_blocks = self.piece_scheduler.remove_piece_blocks(index); + self.piece_scheduler.remove_piece_blocks(index); let info_dict = self .info_dict() .expect("Can't receive piece without info dict"); let piece_count = info_dict.piece_count(); - if !self - .validate_and_send_piece(peer_id, index, previous_blocks) - .await - { + if !self.validate_and_commit_piece(index).await { + self.publish_metrics_changed(); + self.fill_peer_request_window(peer_id); return; } @@ -235,23 +289,28 @@ impl TorrentActor { "Piece is now complete" ); - self.broadcast_to_peers(Have { piece: index }).await; + self.broadcast_to_peers_best_effort(Have { piece: index }); - self.sync_tracker_announce_progress().await; + // Piece completion is the meaningful progress boundary. Publishing for + // every 16 KiB block creates an event storm without improving the view. + self.publish_metrics_changed(); if self.piece_scheduler.next_piece() >= piece_count { - self.state = TorrentState::Seeding; + self.update_tracker_progress().await; + self.transition_state(TorrentState::Seeding); self.announce_tracker_event(Event::Completed).await; info!("Torrenting process completed, switching to seeding mode"); self.rechoke_peers().await; self.schedule_next_rechoke().await; + } else { + // The block that completed this piece consumed one request-window + // slot. Refill it just like every other accepted block so the + // pipeline cannot drain by one slot per completed piece. + self.fill_peer_request_window(peer_id); } } - async fn validate_and_send_piece( - &mut self, peer_id: crate::peer::PeerId, index: usize, - previous_blocks: Option, - ) -> bool { + async fn validate_and_commit_piece(&mut self, index: usize) -> bool { let info_dict = self .info_dict() .expect("Can't receive piece without info dict"); @@ -262,12 +321,6 @@ impl TorrentActor { // the piece manager to populate the final output files. let Ok(path) = self.get_piece_path(index) else { warn!(index, "Failed to get piece path; re-requesting"); - if let Some(blocks) = previous_blocks.as_ref() { - self - .piece_scheduler - .restore_piece_blocks(index, blocks.clone()); - } - self.request_blocks_from_peer(peer_id, 1).await; return false; }; @@ -282,21 +335,11 @@ impl TorrentActor { Ok(data) => data, Err(err) => { warn!(?err, index, path = %path.display(), "Failed to validate piece through piece store actor; re-requesting"); - if let Some(blocks) = previous_blocks.as_ref() { - self - .piece_scheduler - .restore_piece_blocks(index, blocks.clone()); - } - self.request_blocks_from_peer(peer_id, 1).await; return false; } }; if let Err(err) = self.piece_manager.recv(index, data).await { warn!(?err, index, path = %path.display(), "Piece manager rejected piece; re-requesting"); - if let Some(blocks) = previous_blocks { - self.piece_scheduler.restore_piece_blocks(index, blocks); - } - self.request_blocks_from_peer(peer_id, 1).await; return false; } } @@ -315,12 +358,6 @@ impl TorrentActor { Ok(data) => data, Err(err) => { warn!(?err, index, "Failed to read in-file piece; re-requesting"); - if let Some(blocks) = previous_blocks.as_ref() { - self - .piece_scheduler - .restore_piece_blocks(index, blocks.clone()); - } - self.request_blocks_from_peer(peer_id, 1).await; return false; } }; @@ -330,10 +367,6 @@ impl TorrentActor { ?err, index, "Failed to validate in-file piece; re-requesting" ); - if let Some(blocks) = previous_blocks { - self.piece_scheduler.restore_piece_blocks(index, blocks); - } - self.request_blocks_from_peer(peer_id, 1).await; return false; } } @@ -401,7 +434,6 @@ mod tests { use crate::{ hashes::HashVec, metainfo::{Info, InfoKeys, MetaInfo, TorrentFile}, - peer::PeerId, pieces::{FilePieceManager, PieceScheduler, PieceStoreActor}, settings::Settings, testing, @@ -463,14 +495,18 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(base_path.clone()), settings: Settings::default(), + #[cfg(feature = "live")] + hub: Hub::default(), }); TorrentActor { + #[cfg(feature = "live")] + hub: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield: BitVec::::repeat(false, info.piece_count()), id: peer_id, - info: Some(info.clone()), + resolved_magnet_info: None, metainfo, tracker_server, scheduler: Scheduler::spawn(Scheduler::new()), @@ -507,11 +543,7 @@ mod tests { .write_block_to_storage(0, 2, Bytes::from_static(b"cd")) .await ); - assert!( - actor - .validate_and_send_piece(PeerId::default(), 0, None) - .await - ); + assert!(actor.validate_and_commit_piece(0).await); actor.bitfield.set_aliased(0, true); assert_eq!( @@ -546,11 +578,7 @@ mod tests { .write_block_to_storage(0, 2, Bytes::from_static(b"cd")) .await ); - assert!( - actor - .validate_and_send_piece(PeerId::default(), 0, None) - .await - ); + assert!(actor.validate_and_commit_piece(0).await); actor.bitfield.set_aliased(0, true); assert_eq!( diff --git a/crates/libtortillas/src/torrent/snapshot.rs b/crates/libtortillas/src/torrent/snapshot.rs index 6b044953..a7de2e95 100644 --- a/crates/libtortillas/src/torrent/snapshot.rs +++ b/crates/libtortillas/src/torrent/snapshot.rs @@ -1,43 +1,538 @@ -use std::path::PathBuf; +use std::{ + collections::{BTreeMap, HashSet}, + path::PathBuf, + sync::atomic::AtomicU8, +}; -use serde::{Deserialize, Serialize}; +use bitvec::vec::BitVec; +use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; +use tokio::fs; -use super::TorrentState; -use crate::hashes::InfoHash; +use super::{BLOCK_SIZE, PieceStorageStrategy, TorrentState, util}; +use crate::{ + errors::TorrentError, + hashes::InfoHash, + metainfo::{Info, MetaInfo}, + pieces::FilePieceManager, +}; -/// Stable, frontend-ready view of a torrent. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// Current persistence schema version for [`TorrentSnapshot`]. +pub const TORRENT_SNAPSHOT_VERSION: u32 = 2; + +/// Amount of durable storage reconciliation performed before actor state is +/// installed. Full verification is the safe default. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum RestoreVerification { + #[default] + Full, + FileMetadata, + /// Trusts bitfields and block maps without checking referenced payload. + /// Missing or corrupt data may not be detected until it is served or used. + TrustSnapshot, +} + +/// Portable partial-piece scheduler state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PieceBlockSnapshot { + pub piece_index: u64, + pub blocks: Vec, +} + +/// Serializable state required to restore a torrent session. +#[derive(Debug, Clone, Serialize)] pub struct TorrentSnapshot { + pub version: u32, pub info_hash: InfoHash, - pub name: String, pub state: TorrentState, - pub has_metadata: bool, - pub is_ready: bool, pub auto_start: bool, pub sufficient_peers: u64, - pub peer_count: u64, - pub tracker_count: u64, pub output_path: Option, - pub progress: TorrentProgressSnapshot, - pub transfer: TorrentTransferSnapshot, + pub metainfo: MetaInfo, + pub piece_storage: PieceStorageStrategy, + /// Resolved metadata exists here only for magnet sources. `.torrent` + /// sources already store the same `Info` in `metainfo`. + #[serde(default, alias = "info_dict")] + pub resolved_magnet_info: Option, + pub bitfield: Vec, + pub block_map: Vec, } -/// Display-ready torrent progress fields. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TorrentProgressSnapshot { - pub total_bytes: Option, - pub downloaded_bytes: u64, - pub bytes_remaining: Option, - pub progress_fraction: Option, - pub completed_pieces: u64, - pub partial_pieces: u64, - pub total_pieces: u64, +#[derive(Deserialize)] +struct TorrentSnapshotWire { + version: u32, + info_hash: InfoHash, + state: TorrentState, + auto_start: bool, + sufficient_peers: u64, + output_path: Option, + metainfo: MetaInfo, + piece_storage: PieceStorageStrategy, + #[serde(default, alias = "info_dict")] + resolved_magnet_info: Option, + bitfield: SnapshotBitfieldWire, + block_map: PieceBlockMapWire, } -/// Transfer-rate fields reserved for frontend displays. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TorrentTransferSnapshot { - pub download_rate_bytes_per_second: Option, - pub upload_rate_bytes_per_second: Option, - pub eta_seconds: Option, +#[derive(Deserialize)] +#[serde(untagged)] +enum PieceBlockMapWire { + Portable(Vec), + VersionOne(BTreeMap>), +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum SnapshotBitfieldWire { + Portable(Vec), + VersionOne(BitVec), +} + +impl<'de> Deserialize<'de> for TorrentSnapshot { + fn deserialize>(deserializer: D) -> Result { + let wire = TorrentSnapshotWire::deserialize(deserializer)?; + let (version, block_map) = match (wire.version, wire.block_map) { + (1, PieceBlockMapWire::VersionOne(entries)) => ( + TORRENT_SNAPSHOT_VERSION, + entries + .into_iter() + .map(|(piece_index, blocks)| PieceBlockSnapshot { + piece_index, + blocks: blocks.iter().by_vals().collect(), + }) + .collect(), + ), + (1, PieceBlockMapWire::Portable(entries)) + | (TORRENT_SNAPSHOT_VERSION, PieceBlockMapWire::Portable(entries)) => { + (TORRENT_SNAPSHOT_VERSION, entries) + } + (_, PieceBlockMapWire::VersionOne(_)) => { + return Err(D::Error::custom( + "map-shaped block state is only supported by snapshot version 1", + )); + } + (version, PieceBlockMapWire::Portable(entries)) => (version, entries), + }; + let resolved_magnet_info = match &wire.metainfo { + MetaInfo::Torrent(_) if wire.version == 1 => None, + _ => wire.resolved_magnet_info, + }; + let bitfield = match (wire.version, wire.bitfield) { + (1, SnapshotBitfieldWire::VersionOne(bits)) => bits.iter().by_vals().collect(), + (1, SnapshotBitfieldWire::Portable(bits)) + | (TORRENT_SNAPSHOT_VERSION, SnapshotBitfieldWire::Portable(bits)) => bits, + (_, SnapshotBitfieldWire::VersionOne(_)) => { + return Err(D::Error::custom( + "bitvec-shaped bitfields are only supported by snapshot version 1", + )); + } + (_, SnapshotBitfieldWire::Portable(bits)) => bits, + }; + + Ok(Self { + version, + info_hash: wire.info_hash, + state: wire.state, + auto_start: wire.auto_start, + sufficient_peers: wire.sufficient_peers, + output_path: wire.output_path, + metainfo: wire.metainfo, + piece_storage: wire.piece_storage, + resolved_magnet_info, + bitfield, + block_map, + }) + } +} + +impl TorrentSnapshot { + /// Validates schema compatibility and structural integrity. + pub fn validate(&self) -> Result<(), TorrentError> { + if self.version != TORRENT_SNAPSHOT_VERSION { + return Err(self.invalid(format!( + "unsupported version {}; expected {}", + self.version, TORRENT_SNAPSHOT_VERSION + ))); + } + let metainfo_hash = self + .metainfo + .info_hash() + .map_err(|error| self.invalid(format!("failed to hash metainfo: {error}")))?; + if metainfo_hash != self.info_hash { + return Err(self.invalid("info hash does not match metainfo")); + } + if matches!(self.metainfo, MetaInfo::Torrent(_)) && self.resolved_magnet_info.is_some() { + return Err(self.invalid("torrent metainfo must not duplicate its info dictionary")); + } + if self.output_path.is_none() { + return Err(self.invalid("snapshot does not contain an output path")); + } + if let Some(info) = &self.resolved_magnet_info { + let restored_hash = info + .hash() + .map_err(|error| self.invalid(format!("failed to hash info dictionary: {error}")))?; + if restored_hash != self.info_hash { + return Err(self.invalid("restored info dictionary does not match the info hash")); + } + } + + let info = self.resolved_info(); + let piece_count = info.map_or(0, Info::piece_count); + if self.bitfield.len() != piece_count { + return Err(self.invalid(format!( + "bitfield has {} pieces but metadata declares {piece_count}", + self.bitfield.len() + ))); + } + let mut partial_piece_indices = HashSet::with_capacity(self.block_map.len()); + for entry in &self.block_map { + let index = usize::try_from(entry.piece_index) + .map_err(|_| self.invalid("partial piece index cannot be represented"))?; + if !partial_piece_indices.insert(index) { + return Err(self.invalid(format!( + "partial piece index {index} appears more than once" + ))); + } + if index >= piece_count { + return Err(self.invalid("partial piece index is outside the metadata piece range")); + } + if self.bitfield[index] { + return Err(self.invalid("completed piece also contains partial block state")); + } + + let Some(info) = info else { + return Err(self.invalid("partial block state requires resolved metadata")); + }; + let expected_blocks = piece_length(info, index)?.div_ceil(BLOCK_SIZE); + if entry.blocks.len() != expected_blocks { + return Err(self.invalid(format!( + "partial piece {index} has {} blocks; expected {expected_blocks}", + entry.blocks.len() + ))); + } + } + + Ok(()) + } + + #[must_use] + pub fn resolved_info(&self) -> Option<&Info> { + match &self.metainfo { + MetaInfo::Torrent(torrent) => Some(&torrent.info), + MetaInfo::MagnetUri(_) => self.resolved_magnet_info.as_ref(), + } + } + + fn invalid(&self, reason: impl Into) -> TorrentError { + TorrentError::InvalidSnapshot { + reason: reason.into(), + } + } +} + +/// Structurally validated persistence input. Only the engine restore boundary +/// can construct this wrapper. +#[derive(Debug)] +pub(crate) struct ValidatedTorrentSnapshot(TorrentSnapshot); + +/// Validated actor state after the durable metainfo has been moved into the +/// actor constructor. This prevents restoration from cloning `MetaInfo` merely +/// to keep using the original snapshot container. +#[derive(Debug)] +pub(crate) struct ValidatedTorrentState { + pub(crate) state: TorrentState, + pub(crate) auto_start: bool, + pub(crate) sufficient_peers: u64, + pub(crate) resolved_magnet_info: Option, + pub(crate) bitfield: Vec, + pub(crate) block_map: Vec, +} + +impl TryFrom for ValidatedTorrentSnapshot { + type Error = TorrentError; + + fn try_from(snapshot: TorrentSnapshot) -> Result { + snapshot.validate()?; + Ok(Self(snapshot)) + } +} + +impl ValidatedTorrentSnapshot { + pub(crate) fn new_validated(snapshot: TorrentSnapshot) -> Self { + Self(snapshot) + } + + pub(crate) fn snapshot(&self) -> &TorrentSnapshot { + &self.0 + } + + pub(crate) fn into_restore_parts(self) -> (MetaInfo, ValidatedTorrentState) { + let TorrentSnapshot { + state, + auto_start, + sufficient_peers, + metainfo, + resolved_magnet_info, + bitfield, + block_map, + .. + } = self.0; + ( + metainfo, + ValidatedTorrentState { + state, + auto_start, + sufficient_peers, + resolved_magnet_info, + bitfield, + block_map, + }, + ) + } + + pub(crate) async fn reconcile_storage( + mut self, verification: RestoreVerification, + ) -> Result { + if verification == RestoreVerification::TrustSnapshot { + return Ok(self); + } + let output_path = self + .0 + .output_path + .as_ref() + .ok_or_else(|| self.0.invalid("snapshot does not contain an output path"))?; + let output_metadata = + fs::metadata(output_path) + .await + .map_err(|error| TorrentError::FileIoError { + operation: "reconcile restored output folder".to_string(), + reason: error.to_string(), + })?; + if !output_metadata.is_dir() { + return Err(TorrentError::InvalidSnapshot { + reason: "restored output path is not a directory".to_string(), + }); + } + let Some(info) = self.0.resolved_info().cloned() else { + return Ok(self); + }; + let output_manager = FilePieceManager(self.0.output_path.clone(), Some(info.clone())); + + for index in self + .0 + .bitfield + .iter() + .enumerate() + .filter_map(|(index, complete)| complete.then_some(index)) + .collect::>() + { + let valid = match &self.0.piece_storage { + PieceStorageStrategy::Disk(directory) => { + let path = directory.join(format!("{}.piece", info.pieces[index])); + match verification { + RestoreVerification::Full => util::validate_piece_file(path, info.pieces[index]) + .await + .is_ok(), + RestoreVerification::FileMetadata => { + let expected_length = + u64::try_from(piece_length(&info, index)?).map_err(|_| { + TorrentError::InvalidSnapshot { + reason: "piece length cannot be represented as u64".to_string(), + } + })?; + fs::metadata(path) + .await + .is_ok_and(|metadata| metadata.len() >= expected_length) + } + RestoreVerification::TrustSnapshot => true, + } + } + PieceStorageStrategy::InFile => match output_manager.read_piece(index).await { + Ok(bytes) if verification == RestoreVerification::FileMetadata => { + bytes.len() == piece_length(&info, index)? + } + Ok(bytes) => util::validate_piece_bytes(&bytes, info.pieces[index]).is_ok(), + Err(_) => false, + }, + }; + if !valid { + self.0.bitfield[index] = false; + } + } + + for entry in &mut self.0.block_map { + let index = + usize::try_from(entry.piece_index).map_err(|_| TorrentError::InvalidSnapshot { + reason: "partial piece index cannot be represented".to_string(), + })?; + let piece_len = piece_length(&info, index)?; + for block_index in entry + .blocks + .iter() + .enumerate() + .filter_map(|(index, complete)| complete.then_some(index)) + .collect::>() + { + let offset = block_index.saturating_mul(BLOCK_SIZE); + let length = piece_len.saturating_sub(offset).min(BLOCK_SIZE); + let exists = match &self.0.piece_storage { + PieceStorageStrategy::Disk(directory) => { + let path = directory.join(format!("{}.piece", info.pieces[index])); + fs::metadata(path).await.is_ok_and(|metadata| { + metadata.len() + >= u64::try_from(offset.saturating_add(length)).unwrap_or(u64::MAX) + }) + } + PieceStorageStrategy::InFile => output_manager + .read_piece_block(index, offset, length) + .await + .is_ok_and(|bytes| bytes.len() == length), + }; + if !exists { + entry.blocks[block_index] = false; + } + } + } + self + .0 + .block_map + .retain(|entry| entry.blocks.iter().any(|block| *block)); + + Ok(self) + } +} + +fn piece_length(info: &Info, index: usize) -> Result { + let standard = + usize::try_from(info.piece_length).map_err(|_| TorrentError::InvalidSnapshot { + reason: "piece length cannot be represented on this platform".to_string(), + })?; + if standard == 0 { + return Err(TorrentError::InvalidSnapshot { + reason: "piece length must be greater than zero".to_string(), + }); + } + let last_piece = info.piece_count().saturating_sub(1); + if index == last_piece { + Ok(info + .total_length() + .saturating_sub(standard.saturating_mul(last_piece))) + } else { + Ok(standard) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{testing, torrent::TorrentState}; + + async fn snapshot_with_storage(piece_storage: PieceStorageStrategy) -> TorrentSnapshot { + let metainfo = testing::read_torrent_fixture(testing::BIG_BUCK_BUNNY_TORRENT_FILE).await; + let info_hash = metainfo.info_hash().unwrap(); + let piece_count = match &metainfo { + MetaInfo::Torrent(torrent) => torrent.info.piece_count(), + MetaInfo::MagnetUri(_) => unreachable!(), + }; + TorrentSnapshot { + version: TORRENT_SNAPSHOT_VERSION, + info_hash, + state: TorrentState::Seeding, + auto_start: false, + sufficient_peers: 0, + output_path: Some(std::env::temp_dir()), + metainfo, + piece_storage, + resolved_magnet_info: None, + bitfield: vec![true; piece_count], + block_map: Vec::new(), + } + } + + #[tokio::test] + async fn missing_completed_piece_storage_is_demoted_before_restore() { + let snapshot = snapshot_with_storage(PieceStorageStrategy::InFile).await; + let validated = ValidatedTorrentSnapshot::try_from(snapshot) + .unwrap() + .reconcile_storage(RestoreVerification::Full) + .await + .unwrap(); + + assert!( + validated + .snapshot() + .bitfield + .iter() + .all(|complete| !complete) + ); + } + + #[tokio::test] + async fn missing_partial_piece_data_clears_block_bits() { + let fixture = testing::storage_fixture("snapshot-missing-partial") + .await + .unwrap(); + let mut snapshot = + snapshot_with_storage(PieceStorageStrategy::Disk(fixture.path().to_path_buf())).await; + snapshot.bitfield.fill(false); + let info = snapshot.resolved_info().unwrap(); + let block_count = piece_length(info, 0).unwrap().div_ceil(BLOCK_SIZE); + let mut blocks = bitvec::vec::BitVec::::repeat(false, block_count); + blocks.set(0, true); + snapshot.block_map.push(PieceBlockSnapshot { + piece_index: 0, + blocks: blocks.iter().by_vals().collect(), + }); + + let validated = ValidatedTorrentSnapshot::try_from(snapshot) + .unwrap() + .reconcile_storage(RestoreVerification::Full) + .await + .unwrap(); + + assert!(validated.snapshot().block_map.is_empty()); + } + + #[tokio::test] + async fn duplicate_partial_piece_indices_are_rejected() { + let mut snapshot = snapshot_with_storage(PieceStorageStrategy::InFile).await; + snapshot.bitfield.fill(false); + let block_count = piece_length(snapshot.resolved_info().unwrap(), 0) + .unwrap() + .div_ceil(BLOCK_SIZE); + let entry = PieceBlockSnapshot { + piece_index: 0, + blocks: vec![false; block_count], + }; + snapshot.block_map = vec![entry.clone(), entry]; + + let error = snapshot.validate().unwrap_err(); + + assert!( + matches!(error, TorrentError::InvalidSnapshot { reason } if reason.contains("appears more than once")) + ); + } + + #[tokio::test] + async fn corrupted_completed_piece_is_demoted_before_restore() { + let fixture = testing::storage_fixture("snapshot-corrupt-piece") + .await + .unwrap(); + let mut snapshot = + snapshot_with_storage(PieceStorageStrategy::Disk(fixture.path().to_path_buf())).await; + snapshot.bitfield.fill(false); + snapshot.bitfield[0] = true; + let info = snapshot.resolved_info().unwrap(); + let path = fixture.path().join(format!("{}.piece", info.pieces[0])); + tokio::fs::write(path, vec![0_u8; piece_length(info, 0).unwrap()]) + .await + .unwrap(); + + let validated = ValidatedTorrentSnapshot::try_from(snapshot) + .unwrap() + .reconcile_storage(RestoreVerification::Full) + .await + .unwrap(); + + assert!(!validated.snapshot().bitfield[0]); + } } diff --git a/crates/libtortillas/src/torrent/state.rs b/crates/libtortillas/src/torrent/state.rs index b0fdc5db..7ea2a739 100644 --- a/crates/libtortillas/src/torrent/state.rs +++ b/crates/libtortillas/src/torrent/state.rs @@ -5,9 +5,9 @@ use serde::{Deserialize, Serialize}; /// /// Expected transition shape: /// `Added` or `ResolvingMetadata` -> `Ready` -> `Downloading` -> `Seeding`. -/// Future frontend commands may also move a torrent through `Paused`, -/// `Stopping`, `Stopped`, or `Failed` without collapsing those states into a -/// generic inactive bucket. +/// Future commands may also move a torrent through `Paused`, +/// `Restarting`, `Stopping`, `Stopped`, or `Failed` without collapsing those +/// states into a generic inactive bucket. #[derive( Debug, Default, @@ -32,10 +32,12 @@ pub enum TorrentState { Ready, /// Torrent is downloading new pieces actively. Downloading, - /// Torrent is intentionally paused by a frontend or caller. + /// Torrent is intentionally paused by the caller. Paused, /// Torrent is seeding and has already completed the file. Seeding, + /// The actor stopped abnormally and supervision may reconstruct it. + Restarting, /// Torrent is shutting down actors, peers, or trackers. Stopping, /// Torrent has completed shutdown. @@ -96,6 +98,7 @@ mod tests { assert!(!TorrentState::Downloading.can_start()); assert!(!TorrentState::Seeding.can_start()); + assert!(!TorrentState::Restarting.can_start()); assert!(!TorrentState::Stopping.can_start()); assert!(!TorrentState::Stopped.can_start()); } diff --git a/crates/libtortillas/src/torrent/storage.rs b/crates/libtortillas/src/torrent/storage.rs index 6ebbe309..650d9bcc 100644 --- a/crates/libtortillas/src/torrent/storage.rs +++ b/crates/libtortillas/src/torrent/storage.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; /// Defines how torrent pieces are stored and accessed. -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "strategy", content = "piece_output_path")] pub enum PieceStorageStrategy { /// Reference pieces directly from the downloaded files themselves. diff --git a/crates/libtortillas/src/torrent/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index c57e3979..ea65daf5 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -9,8 +9,10 @@ use kameo::{ use tracing::{debug, instrument, trace, warn}; use super::TorrentActor; +#[cfg(feature = "live")] +use crate::live::{PeerIdentity, PeerView}; use crate::{ - peer::{Peer, PeerActor, PeerId}, + peer::{Peer, PeerActor, PeerActorArgs, PeerId}, protocol::{ messages::{Handshake, PeerMessages}, stream::{PeerSend, PeerStream, validate_handshake}, @@ -104,16 +106,40 @@ impl TorrentActor { let info_hash = self.info_hash(); let peer_settings = self.settings.peer.clone(); let peer_mailbox_size = self.settings.torrent.peer_mailbox_size; + if self.peers.contains_key(&id) { + return; + } - self.peers.entry(id).or_insert_with(|| { - PeerActor::spawn_with_mailbox( - (peer, stream, actor_ref, info_hash, peer_settings), - match peer_mailbox_size { - 0 => mailbox::unbounded(), - size => mailbox::bounded(size), - }, - ) - }); + #[cfg(feature = "live")] + let Some(peer_handle) = self.hub.register_peer_scope( + PeerIdentity { + torrent: info_hash, + peer: id, + }, + PeerView::from_peer(&peer, true), + ) else { + return; + }; + + let peer_args = PeerActorArgs { + peer, + stream, + supervisor: actor_ref, + info_hash, + settings: peer_settings, + #[cfg(feature = "live")] + live_handle: peer_handle.clone(), + }; + let peer_actor = PeerActor::spawn_with_mailbox( + peer_args, + match peer_mailbox_size { + 0 => mailbox::unbounded(), + size => mailbox::bounded(size), + }, + ); + self.peers.insert(id, peer_actor); + self.publish_updated(); + crate::live_only!(self.hub.emit_peer_connected(&peer_handle)); } #[instrument(skip(self, tell), fields(torrent_id = %self.info_hash(), msg = ?tell))] @@ -152,8 +178,38 @@ impl TorrentActor { dead_peers.push(*id); } } + let removed_dead_peers = !dead_peers.is_empty(); + for id in dead_peers { + self.peers.remove(&id); + } + if removed_dead_peers { + self.publish_updated(); + } + } + + /// Enqueues an advisory peer message without allowing a slow peer mailbox + /// to block the torrent actor's piece-processing loop. + pub(super) fn broadcast_to_peers_best_effort(&mut self, message: M) + where + PeerActor: Message, + M: Clone + std::fmt::Debug + Send + 'static, + { + let mut dead_peers = Vec::new(); + + for (id, actor) in &self.peers { + if !actor.is_alive() { + dead_peers.push(*id); + continue; + } + + if let Err(error) = actor.tell(message.clone()).try_send() { + trace!(%error, peer_id = %id, "Peer mailbox unavailable for advisory message"); + } + } + for id in dead_peers { self.peers.remove(&id); + self.piece_scheduler.peer_disconnected(id); } } @@ -191,6 +247,28 @@ impl TorrentActor { } } + /// Enqueues coalescible tracker state without allowing a slow announce + /// actor to stop piece processing. Lifecycle and explicit announce + /// messages continue to use the reliable async broadcast path. + pub(super) fn update_trackers_best_effort(&mut self, message: TrackerUpdate) { + let mut dead_trackers = Vec::new(); + + for (tracker, actor) in &self.trackers { + if !actor.is_alive() { + dead_trackers.push(tracker.clone()); + continue; + } + + if let Err(error) = actor.tell(message.clone()).try_send() { + trace!(%error, tracker_uri = ?tracker, "Tracker mailbox unavailable for progress update"); + } + } + + for tracker in dead_trackers { + self.trackers.remove(&tracker); + } + } + pub(super) async fn broadcast_to_trackers(&mut self, tell: M) where TrackerActor: Message, diff --git a/crates/libtortillas/src/tracker/actor.rs b/crates/libtortillas/src/tracker/actor.rs index 52a38a72..8627a35c 100644 --- a/crates/libtortillas/src/tracker/actor.rs +++ b/crates/libtortillas/src/tracker/actor.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "live")] +use std::time::Instant; use std::{net::SocketAddr, time::Duration}; use anyhow::Result; @@ -23,6 +25,11 @@ use crate::{ settings::TrackerSettings, torrent::{self, TorrentActor}, }; +#[cfg(feature = "live")] +use crate::{ + live::TrackerHandle, + metrics::{TimedTransferSample, TrackerMetrics, TransferMetrics}, +}; /// The actor that handles all communication with a given tracker. pub(crate) struct TrackerActor { @@ -33,6 +40,10 @@ pub(crate) struct TrackerActor { next_announce: Option, actor_ref: ActorRef, settings: TrackerSettings, + #[cfg(feature = "live")] + live_handle: TrackerHandle, + #[cfg(feature = "live")] + last_rate_sample: TimedTransferSample, } #[derive(Clone)] @@ -45,6 +56,8 @@ pub(crate) struct TrackerActorArgs { pub(crate) supervisor: ActorRef, pub(crate) scheduler: ActorRef, pub(crate) settings: TrackerSettings, + #[cfg(feature = "live")] + pub(crate) live_handle: TrackerHandle, } impl Actor for TrackerActor { @@ -61,6 +74,8 @@ impl Actor for TrackerActor { supervisor, scheduler, settings, + #[cfg(feature = "live")] + live_handle, } = state; let info_hash = supervisor @@ -115,6 +130,19 @@ impl Actor for TrackerActor { if let Some(left) = initial_left { tracker.update(TrackerUpdate::Left(left)).await?; } + #[cfg(feature = "live")] + let totals = { + let initial_metrics = tracker.stats().metrics(); + let totals = initial_metrics.transfer.totals; + live_handle.publish_metrics(initial_metrics); + if let Err(e) = supervisor + .tell(torrent::events::TrackerMetricsChanged) + .await + { + warn!(error = %e, "Failed to publish initial tracker metrics"); + } + totals + }; let next_announce = scheduler .ask(SetTimeout::new( @@ -133,11 +161,15 @@ impl Actor for TrackerActor { next_announce: Some(next_announce), actor_ref, settings, + #[cfg(feature = "live")] + live_handle, + #[cfg(feature = "live")] + last_rate_sample: TimedTransferSample::new(Instant::now(), totals), }) } async fn on_stop( - &mut self, _: WeakActorRef, _: ActorStopReason, + &mut self, _: WeakActorRef, _reason: ActorStopReason, ) -> Result<(), Self::Error> { if let Some(next_announce) = self.next_announce.take() { next_announce.abort(); @@ -146,11 +178,44 @@ impl Actor for TrackerActor { let _ = timeout(self.settings.stop_timeout, self.tracker.stop()) .await .inspect_err(|e| warn!(e = %e.to_string(), "Tracker stop timed out")); + crate::live_only! { + let metrics = self.snapshot_metrics(self.live_handle.view().metrics.latest_peers_returned); + self.live_handle.publish_metrics(metrics); + if let Err(e) = self + .supervisor + .tell(torrent::events::TrackerMetricsChanged) + .await + { + warn!(error = %e, "Failed to publish final tracker metrics"); + } + + if _reason.is_normal() { + self.live_handle.stopped(); + } else { + // Transient supervision may reconstruct this actor with the same + // live scope. Keep the listener open until its owning torrent + // performs final tree cleanup. + self.live_handle.restarting(); + } + } Ok(()) } } +#[cfg(feature = "live")] +impl TrackerActor { + fn snapshot_metrics(&mut self, latest_peers_returned: Option) -> TrackerMetrics { + let mut metrics = self.tracker.stats().metrics(); + let totals = metrics.transfer.totals; + let sample = TimedTransferSample::new(Instant::now(), totals); + metrics.transfer = TransferMetrics::from_sample(sample.sample_since(self.last_rate_sample)); + self.last_rate_sample = sample; + metrics.latest_peers_returned = latest_peers_returned; + metrics + } +} + #[messages] impl TrackerActor { async fn schedule_next_announce(&mut self) { @@ -183,8 +248,17 @@ impl TrackerActor { /// Forces the tracker to make an announce request. #[message(derive(Debug, Clone, Copy))] pub(crate) async fn announce(&mut self) -> Option { - match self.tracker.announce().await { + let result = self.tracker.announce().await; + #[cfg(feature = "live")] + let latest_peers_returned = result + .as_ref() + .ok() + .map(|peers| u64::try_from(peers.len()).unwrap_or(u64::MAX)); + #[cfg(feature = "live")] + let metrics = self.snapshot_metrics(latest_peers_returned); + match result { Ok(peers) => { + crate::live_only!(self.live_handle.announce_succeeded(metrics)); if let Err(e) = self .supervisor .tell(torrent::events::Announce { @@ -196,7 +270,19 @@ impl TrackerActor { error!(error = %e, "Failed to send announce to supervisor"); } } - Err(e) => error!(error = %e, "Announce request failed"), + Err(e) => { + error!(error = %e, "Announce request failed"); + crate::live_only!(self.live_handle.announce_failed(metrics)); + } + } + crate::live_only! { + if let Err(e) = self + .supervisor + .tell(torrent::events::TrackerMetricsChanged) + .await + { + error!(error = %e, "Failed to publish tracker metrics"); + } } self.schedule_next_announce().await; None diff --git a/crates/libtortillas/src/tracker/http.rs b/crates/libtortillas/src/tracker/http.rs index a2583eba..b7ae854a 100644 --- a/crates/libtortillas/src/tracker/http.rs +++ b/crates/libtortillas/src/tracker/http.rs @@ -236,16 +236,13 @@ impl TrackerBase for HttpTracker { // HTTP request phase let request_start = Instant::now(); - let response_bytes = reqwest::get(&uri) - .await - .map_err(TrackerActorError::Http)? - .bytes() - .await - .map_err(TrackerActorError::Http)?; + let response = reqwest::get(&uri).await.map_err(TrackerActorError::Http)?; + self.stats.increment_bytes_sent(uri.len()); + + let response_bytes = response.bytes().await.map_err(TrackerActorError::Http)?; let request_duration = request_start.elapsed(); - self.stats.increment_bytes_sent(uri.len()); // Approximate bytes sent self.stats.increment_bytes_received(response_bytes.len()); // Response parsing phase @@ -549,6 +546,7 @@ mod tests { assert_eq!(peer.id, None); } + #[cfg(feature = "live")] #[tokio::test] async fn http_tracker_when_local_tracker_is_available_then_returns_ipv4_peer() { let expected_peer = Peer::from_ipv4(Ipv4Addr::LOCALHOST, 6881); @@ -561,6 +559,8 @@ mod tests { assert_eq!(peers, vec![expected_peer]); assert_eq!(http_tracker.interval(), 1800); + assert!(http_tracker.stats().traffic_totals().uploaded.0 > 0); + assert!(http_tracker.stats().traffic_totals().downloaded.0 > 0); } #[tokio::test] diff --git a/crates/libtortillas/src/tracker/model.rs b/crates/libtortillas/src/tracker/model.rs index 0c1945c7..50fbaa33 100644 --- a/crates/libtortillas/src/tracker/model.rs +++ b/crates/libtortillas/src/tracker/model.rs @@ -119,6 +119,37 @@ impl Tracker { } } +#[cfg(feature = "live")] +impl Tracker { + /// Returns a credential-free endpoint label for public views. + pub(crate) fn redacted_endpoint(&self) -> String { + let uri = self.uri(); + let Ok(url) = reqwest::Url::parse(&uri) else { + return self.scheme().to_string(); + }; + let Some(host) = url.host_str() else { + return self.scheme().to_string(); + }; + let host = if host.contains(':') { + format!("[{host}]") + } else { + host.to_string() + }; + let port = url + .port() + .map_or_else(String::new, |port| format!(":{port}")); + format!("{}://{host}{port}/", url.scheme()) + } + + fn scheme(&self) -> &'static str { + match self { + Self::Http(_) => "http", + Self::Udp(_) => "udp", + Self::Websocket(_) => "websocket", + } + } +} + /// Trait for HTTP and UDP trackers. #[async_trait] pub trait TrackerBase: Send + Sync { @@ -257,3 +288,44 @@ fn tracker_from_uri(uri: String) -> Result { _ => Err(format!("unsupported tracker scheme: {scheme}")), } } + +#[cfg(all(test, feature = "live"))] +mod tests { + use super::*; + + #[test] + fn redacted_endpoint_removes_tracker_credentials_and_paths() { + let tracker = Tracker::Http( + "https://alice:password@tracker.example/secret-passkey/announce?token=secret".to_string(), + ); + + let endpoint = tracker.redacted_endpoint(); + + assert_eq!(endpoint, "https://tracker.example/"); + assert!(!endpoint.contains("alice")); + assert!(!endpoint.contains("password")); + assert!(!endpoint.contains("passkey")); + assert!(!endpoint.contains("token")); + } + + #[test] + fn udp_redacted_endpoint_removes_tracker_credentials() { + let tracker = Tracker::Udp( + "udp://alice:password@tracker.example:6969/announce?token=secret".to_string(), + ); + + let endpoint = tracker.redacted_endpoint(); + + assert_eq!(endpoint, "udp://tracker.example:6969/"); + assert!(!endpoint.contains("alice")); + assert!(!endpoint.contains("password")); + assert!(!endpoint.contains("token")); + } + + #[test] + fn invalid_tracker_endpoint_falls_back_to_protocol_only() { + let tracker = Tracker::Udp("udp://[invalid".to_string()); + + assert_eq!(tracker.redacted_endpoint(), "udp"); + } +} diff --git a/crates/libtortillas/src/tracker/stats.rs b/crates/libtortillas/src/tracker/stats.rs index 68ed07ef..551cb8ca 100644 --- a/crates/libtortillas/src/tracker/stats.rs +++ b/crates/libtortillas/src/tracker/stats.rs @@ -9,6 +9,9 @@ use std::{ use atomic_time::{AtomicInstant, AtomicOptionInstant}; use tokio::time::Instant; +#[cfg(feature = "live")] +use crate::metrics::{ByteCount, TrackerMetrics, TrafficTotals, TransferMetrics}; + /// Tracker statistics. /// /// All usages of [`AtomicOptionInstant`] or [`AtomicInstant`] are a bit hacky, @@ -110,6 +113,33 @@ impl TrackerStats { self.bytes_received.fetch_add(value, Ordering::AcqRel); } + /// Returns all application bytes exchanged with this tracker. + #[cfg(feature = "live")] + #[must_use] + pub(crate) fn traffic_totals(&self) -> TrafficTotals { + TrafficTotals { + downloaded: ByteCount(u64::try_from(self.get_bytes_received()).unwrap_or(u64::MAX)), + uploaded: ByteCount(u64::try_from(self.get_bytes_sent()).unwrap_or(u64::MAX)), + } + } + + /// Creates a typed snapshot with shared transfer metrics and tracker-only + /// counters. + #[cfg(feature = "live")] + #[must_use] + pub(crate) fn metrics(&self) -> TrackerMetrics { + TrackerMetrics { + transfer: TransferMetrics { + totals: self.traffic_totals(), + samples: Vec::new(), + }, + announce_attempts: u64::try_from(self.get_announce_attempts()).unwrap_or(u64::MAX), + announce_successes: u64::try_from(self.get_announce_successes()).unwrap_or(u64::MAX), + total_peers_received: u64::try_from(self.get_total_peers_received()).unwrap_or(u64::MAX), + latest_peers_returned: None, + } + } + pub fn get_last_interaction(&self) -> Option { Some( self @@ -136,3 +166,33 @@ impl TrackerStats { .store(Instant::now().into_std(), Ordering::Release) } } + +#[cfg(all(test, feature = "live"))] +mod tests { + use super::*; + + #[test] + fn tracker_stats_when_wire_bytes_are_recorded_then_exposes_canonical_totals() { + let stats = TrackerStats::default(); + + stats.increment_bytes_sent(12); + stats.increment_bytes_received(34); + + assert_eq!( + stats.traffic_totals(), + TrafficTotals { + downloaded: ByteCount(34), + uploaded: ByteCount(12), + } + ); + stats.increment_announce_attempts(); + stats.increment_announce_successes(); + stats.increment_total_peers_received(7); + let mut metrics = stats.metrics(); + metrics.latest_peers_returned = Some(3); + assert_eq!(metrics.announce_attempts, 1); + assert_eq!(metrics.announce_successes, 1); + assert_eq!(metrics.total_peers_received, 7); + assert_eq!(metrics.latest_peers_returned, Some(3)); + } +} diff --git a/crates/libtortillas/src/tracker/udp.rs b/crates/libtortillas/src/tracker/udp.rs index 5c17cc9f..36943e0b 100644 --- a/crates/libtortillas/src/tracker/udp.rs +++ b/crates/libtortillas/src/tracker/udp.rs @@ -449,16 +449,14 @@ impl UdpServer { } /// Send a message through the shared socket - pub async fn send_message(&self, message: &Bytes, tracker_addr: SocketAddr) -> Result<()> { + pub async fn send_message(&self, message: &Bytes, tracker_addr: SocketAddr) -> Result { self .socket .send_to(message, tracker_addr) .await .map_err(|e| TrackerActorError::InvalidResponse { reason: e.to_string(), - })?; - - Ok(()) + }) } } @@ -740,14 +738,14 @@ impl UdpTracker { let registration = TransactionRegistration::new(self.server.clone(), *transaction_id); // Send the message through the shared message receiver - let send_result = self.server.send_message(&message_bytes, self.addr).await; - - if let Err(err) = send_result { - registration.unregister(); - return Err(RetryError::Permanent(err)); - } - - self.stats.increment_bytes_sent(message_bytes.len()); + let bytes_sent = match self.server.send_message(&message_bytes, self.addr).await { + Ok(bytes_sent) => bytes_sent, + Err(err) => { + registration.unregister(); + return Err(RetryError::Permanent(err)); + } + }; + self.stats.increment_bytes_sent(bytes_sent); trace!( message = %message, diff --git a/crates/libtortillas/tests/dht_network.rs b/crates/libtortillas/tests/dht_network.rs index b948aba1..15baba52 100644 --- a/crates/libtortillas/tests/dht_network.rs +++ b/crates/libtortillas/tests/dht_network.rs @@ -2,14 +2,12 @@ use std::{env, path::PathBuf, process, time::Duration}; use libtortillas::{ engine::{Engine, TorrentSource}, + live::EventStreamError, metainfo::{MetaInfo, TorrentFile}, settings::Settings, }; use rand::random; -use tokio::{ - fs, - time::{sleep, timeout}, -}; +use tokio::{fs, time::timeout}; const ARCH_LINUX_TORRENT: &str = "archlinux-2026.07.01-x86_64.iso.torrent"; const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60); @@ -44,14 +42,20 @@ async fn arch_linux_torrent_when_public_dht_is_available_then_downloads_data() { .add_torrent(TorrentSource::torrent_file_bytes(dht_only_torrent)) .await .unwrap(); + let mut listener = torrent.listener(); let download = timeout(DOWNLOAD_TIMEOUT, async { loop { - let snapshot = torrent.snapshot().await.unwrap(); - if snapshot.progress.downloaded_bytes > 0 { - return snapshot; + let view = listener.view().unwrap(); + if view.metrics.progress.verified_bytes.0 > 0 { + return view; + } + match timeout(POLL_INTERVAL, listener.recv()).await { + Ok(Err(EventStreamError::Closed)) => { + panic!("torrent event stream closed before any data was downloaded"); + } + Ok(Ok(_)) | Ok(Err(EventStreamError::Lagged(_))) | Err(_) => {} } - sleep(POLL_INTERVAL).await; } }) .await; @@ -59,7 +63,7 @@ async fn arch_linux_torrent_when_public_dht_is_available_then_downloads_data() { engine.shutdown().await.unwrap(); fs::remove_dir_all(&output_root).await.unwrap(); - let snapshot = download.expect("Arch Linux did not download data through DHT in time"); - assert!(snapshot.has_metadata); - assert!(snapshot.peer_count > 0); + let view = download.expect("Arch Linux did not download data through DHT in time"); + assert!(view.has_metadata()); + assert!(view.peer_count > 0); } diff --git a/crates/libtortillas/tests/engine_lifecycle.rs b/crates/libtortillas/tests/engine_lifecycle.rs index f6d37d29..aebd3e32 100644 --- a/crates/libtortillas/tests/engine_lifecycle.rs +++ b/crates/libtortillas/tests/engine_lifecycle.rs @@ -17,7 +17,7 @@ use libtortillas::{ use tokio::fs; #[tokio::test(flavor = "multi_thread")] -async fn engine_remove_torrent_drops_it_from_exports() { +async fn engine_remove_torrent_drops_it_from_snapshot_and_live_view() { let (path, info_hash) = write_http_torrent_fixture().await; let engine = Engine::builder() .settings(test_settings()) @@ -30,10 +30,12 @@ async fn engine_remove_torrent_drops_it_from_exports() { .await .unwrap(); assert_eq!(torrent.info_hash(), info_hash); - assert_eq!(engine.export().await.unwrap().torrents.len(), 1); + assert_eq!(engine.snapshot().await.unwrap().torrents.len(), 1); + assert_eq!(engine.view().torrent_count(), 1); engine.remove_torrent(info_hash).await.unwrap(); - assert!(engine.export().await.unwrap().torrents.is_empty()); + assert!(engine.snapshot().await.unwrap().torrents.is_empty()); + assert_eq!(engine.view().torrent_count(), 0); assert!(torrent.state().await.is_err()); let err = engine.remove_torrent(info_hash).await.unwrap_err(); @@ -95,6 +97,46 @@ async fn torrent_controls_complete_their_state_transitions() { let _ = fs::remove_file(path).await; } +#[tokio::test(flavor = "multi_thread")] +async fn engine_manages_multiple_distinct_torrents_through_the_public_api() { + let (first_path, first_hash) = write_http_torrent_fixture_named("first-download.bin").await; + let (second_path, second_hash) = write_http_torrent_fixture_named("second-download.bin").await; + let output_path = unique_temp_path("documented-downloads"); + let engine = Engine::builder() + .settings(test_settings()) + .autostart(false) + .sufficient_peers(usize::MAX) + .output_path(&output_path) + .build(); + + let first = engine + .add_torrent(TorrentSource::torrent_file_path(&first_path)) + .await + .unwrap(); + let second = engine + .add_torrent(TorrentSource::torrent_file_path(&second_path)) + .await + .unwrap(); + + assert_eq!(first.info_hash(), first_hash); + assert_eq!(second.info_hash(), second_hash); + assert_ne!(first.info_hash(), second.info_hash()); + assert_eq!(engine.view().torrent_count(), 2); + assert_eq!(engine.snapshot().await.unwrap().torrents.len(), 2); + assert!( + engine + .view() + .torrents + .iter() + .all(|view| view.output_path.as_ref() == Some(&output_path)) + ); + + engine.shutdown().await.unwrap(); + let _ = fs::remove_file(first_path).await; + let _ = fs::remove_file(second_path).await; + let _ = fs::remove_dir_all(output_path).await; +} + fn test_settings() -> Settings { let mut settings = Settings::default(); settings.dht.enabled = false; @@ -104,10 +146,14 @@ fn test_settings() -> Settings { } async fn write_http_torrent_fixture() -> (PathBuf, InfoHash) { + write_http_torrent_fixture_named("engine-lifecycle.bin").await +} + +async fn write_http_torrent_fixture_named(name: &str) -> (PathBuf, InfoHash) { let mut pieces = HashVec::new(); pieces.push(Hash::from_bytes([1; 20])); let info = Info { - name: "engine-lifecycle.bin".to_string(), + name: name.to_string(), piece_length: 16, pieces, file: InfoKeys::Single { @@ -131,15 +177,20 @@ async fn write_http_torrent_fixture() -> (PathBuf, InfoHash) { }; let info_hash = torrent.info.hash().unwrap(); let bytes = serde_bencode::to_bytes(&torrent).unwrap(); - let path = env::temp_dir().join(format!( - "tortillas-engine-lifecycle-{}-{}.torrent", + let path = unique_temp_path(name).with_extension("torrent"); + + fs::write(&path, bytes).await.unwrap(); + (path, info_hash) +} + +fn unique_temp_path(name: &str) -> PathBuf { + env::temp_dir().join(format!( + "tortillas-engine-lifecycle-{}-{}-{}", process::id(), + name, SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() - )); - - fs::write(&path, bytes).await.unwrap(); - (path, info_hash) + )) } diff --git a/crates/libtortillas/tests/facade.rs b/crates/libtortillas/tests/facade.rs index 3d6d3e7d..f57896ee 100644 --- a/crates/libtortillas/tests/facade.rs +++ b/crates/libtortillas/tests/facade.rs @@ -1,55 +1,31 @@ -use std::path::PathBuf; - use libtortillas::{ - facade::{EngineSnapshot, TorrentSnapshot, TrackerStatus}, - hashes::InfoHash, - prelude::{CoreCommand, EngineHandle, TorrentSource}, + facade::{EngineSnapshot, TorrentSnapshot}, + prelude::Engine, }; +#[cfg(feature = "live")] #[test] -fn prelude_exposes_frontend_facade_types() { - let command = CoreCommand::AddTorrent { - source: TorrentSource::TorrentFilePath(PathBuf::from("ubuntu.torrent")), +fn prelude_exposes_live_facade_types() { + use libtortillas::prelude::{ + EventSubscription, PeerEventKind, TorrentEventKind, TrackerEventKind, }; - match command { - CoreCommand::AddTorrent { - source: TorrentSource::TorrentFilePath(path), - } => assert_eq!(path, PathBuf::from("ubuntu.torrent")), - other => panic!("unexpected command: {other:?}"), - } + fn accepts_torrent_events(_: Option>) {} + fn accepts_peer_events(_: Option>) {} + fn accepts_tracker_events(_: Option>) {} + + accepts_torrent_events(None); + accepts_peer_events(None); + accepts_tracker_events(None); } #[test] fn facade_engine_handle_matches_existing_engine_type() { - fn accepts_engine_handle(_: Option) {} + fn accepts_engine_handle(_: Option) {} accepts_engine_handle(None); } -#[test] -fn command_variants_identify_torrents_by_info_hash() { - let torrent = InfoHash::from_bytes([7; 20]); - let command = CoreCommand::StartTorrent { torrent }; - - assert_eq!(command, CoreCommand::StartTorrent { torrent }); - - let command = CoreCommand::PauseTorrent { torrent }; - assert_eq!(command, CoreCommand::PauseTorrent { torrent }); - - let command = CoreCommand::ResumeTorrent { torrent }; - assert_eq!(command, CoreCommand::ResumeTorrent { torrent }); - - let command = CoreCommand::StopTorrent { torrent }; - assert_eq!(command, CoreCommand::StopTorrent { torrent }); - - let command = CoreCommand::RemoveTorrent { torrent }; - assert_eq!(command, CoreCommand::RemoveTorrent { torrent }); - - let command = CoreCommand::Shutdown; - assert_eq!(command, CoreCommand::Shutdown); -} - #[test] fn facade_reexports_canonical_snapshot_types() { fn accepts_engine_snapshot(_: Option) {} @@ -60,5 +36,17 @@ fn facade_reexports_canonical_snapshot_types() { accepts_engine_snapshot(engine_snapshot); accepts_torrent_snapshot(torrent_snapshot); - assert_eq!(TrackerStatus::Pending, TrackerStatus::Pending); +} + +#[cfg(not(feature = "live"))] +#[test] +fn actor_only_build_keeps_command_and_query_methods() { + use libtortillas::prelude::Torrent; + + let _ = Engine::start_all; + let _ = Engine::torrent; + let _ = Engine::snapshot; + let _ = Torrent::state; + let _ = Torrent::pause; + let _ = Torrent::snapshot; } diff --git a/crates/libtortillas/tests/fixtures/engine-snapshot-v1.json b/crates/libtortillas/tests/fixtures/engine-snapshot-v1.json new file mode 100644 index 00000000..44511459 --- /dev/null +++ b/crates/libtortillas/tests/fixtures/engine-snapshot-v1.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "torrents": [] +} diff --git a/crates/libtortillas/tests/fixtures/engine-snapshot-v2.json b/crates/libtortillas/tests/fixtures/engine-snapshot-v2.json new file mode 100644 index 00000000..cc0da88b --- /dev/null +++ b/crates/libtortillas/tests/fixtures/engine-snapshot-v2.json @@ -0,0 +1,4 @@ +{ + "version": 2, + "torrents": [] +} diff --git a/crates/libtortillas/tests/fixtures/torrent-snapshot-v1.json b/crates/libtortillas/tests/fixtures/torrent-snapshot-v1.json new file mode 100644 index 00000000..58aa71c9 --- /dev/null +++ b/crates/libtortillas/tests/fixtures/torrent-snapshot-v1.json @@ -0,0 +1,73 @@ +{ + "version": 1, + "info_hash": [ + 80, + 196, + 197, + 59, + 251, + 100, + 119, + 186, + 79, + 16, + 98, + 23, + 107, + 183, + 109, + 198, + 61, + 63, + 253, + 98 + ], + "state": "Paused", + "auto_start": false, + "sufficient_peers": 6, + "output_path": ".", + "metainfo": { + "announce": null, + "announce-list": null, + "comment": "snapshot-v2", + "created by": "libtortillas", + "creation date": 0, + "encoding": "UTF-8", + "info": { + "name": "fixture.bin", + "piece length": 4, + "pieces": [], + "length": 0, + "md5sum": null, + "private": 1, + "publisher": null, + "publisher-url": null, + "source": null + }, + "url_list": null + }, + "piece_storage": { + "strategy": "InFile" + }, + "info_dict": { + "name": "fixture.bin", + "piece length": 4, + "pieces": [], + "length": 0, + "md5sum": null, + "private": 1, + "publisher": null, + "publisher-url": null, + "source": null + }, + "bitfield": { + "order": "bitvec::order::Lsb0", + "head": { + "width": 8, + "index": 0 + }, + "bits": 0, + "data": [] + }, + "block_map": {} +} diff --git a/crates/libtortillas/tests/fixtures/torrent-snapshot-v2.json b/crates/libtortillas/tests/fixtures/torrent-snapshot-v2.json new file mode 100644 index 00000000..bece8265 --- /dev/null +++ b/crates/libtortillas/tests/fixtures/torrent-snapshot-v2.json @@ -0,0 +1,55 @@ +{ + "version": 2, + "info_hash": [ + 80, + 196, + 197, + 59, + 251, + 100, + 119, + 186, + 79, + 16, + 98, + 23, + 107, + 183, + 109, + 198, + 61, + 63, + 253, + 98 + ], + "state": "Paused", + "auto_start": false, + "sufficient_peers": 6, + "output_path": ".", + "metainfo": { + "announce": null, + "announce-list": null, + "comment": "snapshot-v2", + "created by": "libtortillas", + "creation date": 0, + "encoding": "UTF-8", + "info": { + "name": "fixture.bin", + "piece length": 4, + "pieces": [], + "length": 0, + "md5sum": null, + "private": 1, + "publisher": null, + "publisher-url": null, + "source": null + }, + "url_list": null + }, + "piece_storage": { + "strategy": "InFile" + }, + "resolved_magnet_info": null, + "bitfield": [], + "block_map": [] +} diff --git a/crates/libtortillas/tests/live.rs b/crates/libtortillas/tests/live.rs new file mode 100644 index 00000000..5ec63972 --- /dev/null +++ b/crates/libtortillas/tests/live.rs @@ -0,0 +1,336 @@ +use std::time::Duration; + +use futures::StreamExt; +use libtortillas::{ + engine::EngineStatus, + errors::EngineError, + live::{ + EngineEventKind, EventStreamError, LiveHealthLevel, LivePublisher, TorrentEventKind, + TrackerEventKind, TrackerStatus, + }, + prelude::{Engine, Settings, TorrentSource, TorrentState}, +}; +use tokio::{ + net::TcpListener, + time::{sleep, timeout}, +}; + +const BIG_BUCK_BUNNY: &[u8] = include_bytes!("torrents/big-buck-bunny.torrent"); + +fn deterministic_engine() -> Engine { + let mut settings = Settings::default(); + settings.dht.enabled = false; + Engine::builder() + .settings(settings) + .autostart(false) + .build() +} + +#[tokio::test] +async fn engine_listener_receives_live_torrent_lifecycle() { + let engine = deterministic_engine(); + let mut engine_listener = engine.listener(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + + let added = timeout(Duration::from_secs(2), async { + loop { + let event = engine_listener.next().await.unwrap().unwrap(); + if matches!( + event.kind, + EngineEventKind::Torrent { + event: TorrentEventKind::Added, + .. + } + ) { + break event; + } + } + }) + .await + .unwrap(); + assert_eq!(added.torrent(), Some(torrent.info_hash())); + let EngineEventKind::Torrent { + torrent: added_torrent, + event: TorrentEventKind::Added, + } = added.kind + else { + unreachable!(); + }; + assert_eq!(added_torrent.info_hash(), torrent.info_hash()); + assert_eq!(added_torrent.view(), torrent.view()); + assert_eq!(engine_listener.view().torrent_count(), 1); + + let mut torrent_listener = torrent.listener(); + torrent.pause().await.unwrap(); + let paused = timeout(Duration::from_secs(2), async { + loop { + let event = torrent_listener.recv().await.unwrap(); + if matches!( + event.kind, + TorrentEventKind::StateChanged { + current: TorrentState::Paused, + .. + } + ) { + break event; + } + } + }) + .await + .unwrap(); + assert!(matches!( + paused.kind, + TorrentEventKind::StateChanged { + current: TorrentState::Paused, + .. + } + )); + assert_eq!(torrent_listener.view().unwrap().state, TorrentState::Paused); + assert_eq!( + engine_listener.view().torrents.first(), + torrent_listener.view().as_ref() + ); + + engine.remove_torrent(torrent.info_hash()).await.unwrap(); + let removed = timeout(Duration::from_secs(2), async { + loop { + let event = torrent_listener.recv().await.unwrap(); + if matches!(event.kind, TorrentEventKind::Removed) { + break event; + } + } + }) + .await + .unwrap(); + assert!(removed.sequence > paused.sequence); + assert!(matches!( + torrent_listener.recv().await, + Err(EventStreamError::Closed) + )); + assert!(torrent_listener.view().is_none()); + assert_eq!(engine_listener.view().torrent_count(), 0); + + engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn live_listener_closes_when_its_publisher_is_dropped() { + let publisher = LivePublisher::<_, &'static str>::new(0_u8, 4); + let mut listener = publisher.listener(); + + drop(publisher); + + assert!(matches!( + listener.recv().await, + Err(EventStreamError::Closed) + )); + assert_eq!(listener.view(), 0); +} + +#[tokio::test] +async fn engine_startup_failure_publishes_terminal_failed_status() { + let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = occupied.local_addr().unwrap(); + let engine = Engine::builder() + .tcp_addr(address) + .settings({ + let mut settings = Settings::default(); + settings.dht.enabled = false; + settings + }) + .build(); + let mut listener = engine.listener(); + + let event = timeout(Duration::from_secs(2), listener.recv()) + .await + .expect("engine startup failure was not published") + .unwrap(); + + let EngineEventKind::Health(health) = event.kind else { + panic!("startup failure should publish a health event"); + }; + assert_eq!(health.level, LiveHealthLevel::Error); + assert_eq!(listener.view().status, EngineStatus::Failed); + assert!(matches!( + listener.recv().await, + Err(EventStreamError::Closed) + )); +} + +#[tokio::test] +async fn tracker_handle_exposes_its_own_live_listener() { + let engine = deterministic_engine(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let tracker = torrent.trackers().into_iter().next().unwrap(); + let mut listener = tracker.listener(); + + assert!(tracker.view().status.is_active()); + engine.shutdown().await.unwrap(); + + let stopped = timeout(Duration::from_secs(2), async { + loop { + let event = listener.recv().await.unwrap(); + if matches!(event.kind, TrackerEventKind::Stopped) { + break event; + } + } + }) + .await + .unwrap(); + assert!(stopped.sequence > 0); + assert_eq!(listener.view().status, TrackerStatus::Stopped); + assert!(matches!( + timeout(Duration::from_secs(2), listener.recv()) + .await + .expect("tracker event stream did not close"), + Err(EventStreamError::Closed) + )); +} + +#[tokio::test] +async fn engine_listener_receives_graceful_shutdown() { + let engine = deterministic_engine(); + let mut listener = engine.listener(); + + engine.shutdown().await.unwrap(); + let shutdown = timeout(Duration::from_secs(2), async { + loop { + let event = listener.recv().await.unwrap(); + if matches!(event.kind, EngineEventKind::Shutdown(_)) { + break event; + } + } + }) + .await + .unwrap(); + + let EngineEventKind::Shutdown(view) = shutdown.kind else { + unreachable!(); + }; + assert_eq!(view.status, EngineStatus::Stopped); + assert_eq!(listener.view().status, EngineStatus::Stopped); + assert!(matches!( + listener.recv().await, + Err(EventStreamError::Closed) + )); +} + +#[tokio::test] +async fn engine_methods_return_typed_unknown_torrent_errors() { + let engine = deterministic_engine(); + let unknown = libtortillas::hashes::InfoHash::from_bytes([42; 20]); + + let error = engine.torrent(unknown).await.unwrap_err(); + + assert!(matches!(error, EngineError::TorrentNotFound(torrent) if torrent == unknown)); + engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn stopped_engine_reports_typed_actor_communication_errors() { + let engine = deterministic_engine(); + engine.shutdown().await.unwrap(); + + let error = engine.snapshot().await.unwrap_err(); + + assert!(matches!( + error, + EngineError::ActorCommunicationFailed { + operation: "snapshot engine", + .. + } + )); +} + +#[tokio::test] +async fn stopped_torrent_reports_typed_actor_communication_errors() { + let engine = deterministic_engine(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + engine.remove_torrent(torrent.info_hash()).await.unwrap(); + + let error = torrent.state().await.unwrap_err(); + + assert!(matches!( + error, + libtortillas::errors::TorrentError::ActorCommunicationFailed { + operation: "get state", + .. + } + )); + engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn lagging_listener_recovers_from_current_live_view() { + let engine = deterministic_engine(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let mut listener = torrent.listener(); + + for peers in 1..=300 { + torrent.set_sufficient_peers(peers).await.unwrap(); + } + timeout(Duration::from_secs(2), async { + loop { + if listener + .view() + .is_some_and(|view| view.sufficient_peers == 300) + { + break; + } + sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + + let error = listener.recv().await.unwrap_err(); + assert!(matches!(error, EventStreamError::Lagged(events) if events > 0)); + assert_eq!(listener.view().unwrap().sufficient_peers, 300); + + engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn live_views_are_serde_compatible() { + let engine = deterministic_engine(); + let mut listener = engine.listener(); + let _ = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + timeout(Duration::from_secs(2), async { + loop { + let event = listener.recv().await.unwrap(); + if matches!( + event.kind, + EngineEventKind::Torrent { + event: TorrentEventKind::Added, + .. + } + ) { + break; + } + } + }) + .await + .unwrap(); + + let view = listener.view(); + let encoded_view = serde_json::to_string(&view).unwrap(); + let decoded_view = serde_json::from_str(&encoded_view).unwrap(); + assert_eq!(view, decoded_view); + + engine.shutdown().await.unwrap(); +} diff --git a/crates/libtortillas/tests/persistence.rs b/crates/libtortillas/tests/persistence.rs new file mode 100644 index 00000000..e67a1d39 --- /dev/null +++ b/crates/libtortillas/tests/persistence.rs @@ -0,0 +1,470 @@ +use async_trait::async_trait; +use bytes::Bytes; +use libtortillas::{ + engine::Engine, + errors::{EngineError, SnapshotUnsupportedReason, TorrentError}, + metainfo::Info, + pieces::PieceManager, + prelude::{Settings, TorrentSource, TorrentState}, + torrent::{PieceStorageStrategy, RestoreVerification, TorrentSnapshot}, +}; + +const BIG_BUCK_BUNNY: &[u8] = include_bytes!("torrents/big-buck-bunny.torrent"); +const WIRED_CD: &[u8] = include_bytes!("torrents/wired-cd.torrent"); +const SNAPSHOT_V1: &str = include_str!("fixtures/torrent-snapshot-v1.json"); +const SNAPSHOT_V2: &str = include_str!("fixtures/torrent-snapshot-v2.json"); +const ENGINE_SNAPSHOT_V1: &str = include_str!("fixtures/engine-snapshot-v1.json"); +const ENGINE_SNAPSHOT_V2: &str = include_str!("fixtures/engine-snapshot-v2.json"); + +fn deterministic_engine() -> Engine { + let mut settings = Settings::default(); + settings.dht.enabled = false; + Engine::builder() + .settings(settings) + .autostart(false) + .build() +} + +#[derive(Default)] +struct CustomPieceManager { + info: Option, +} + +#[async_trait] +impl PieceManager for CustomPieceManager { + fn info(&self) -> Option<&Info> { + self.info.as_ref() + } + + async fn pre_start(&mut self, info: Info) -> anyhow::Result<()> { + self.info = Some(info); + Ok(()) + } + + async fn recv(&self, _index: usize, _data: Bytes) -> anyhow::Result<()> { + Ok(()) + } +} + +#[tokio::test] +async fn torrent_snapshot_when_serialized_then_restores_session_state() { + let engine = deterministic_engine(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + torrent.pause().await.unwrap(); + torrent.set_auto_start(false).await.unwrap(); + torrent.set_sufficient_peers(4).await.unwrap(); + let snapshot = torrent.snapshot().await.unwrap(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + engine.shutdown().await.unwrap(); + + let restored_snapshot: TorrentSnapshot = serde_json::from_slice(&bytes).unwrap(); + let restored_engine = deterministic_engine(); + let restored = restored_engine + .restore_torrent(restored_snapshot) + .await + .unwrap(); + let view = restored.view().unwrap(); + + assert_eq!(restored.info_hash(), torrent.info_hash()); + assert_eq!(view.state, TorrentState::Paused); + assert!(!view.auto_start); + assert_eq!(view.sufficient_peers, 4); + + let round_trip = restored.snapshot().await.unwrap(); + assert_eq!(round_trip.info_hash, snapshot.info_hash); + assert_eq!(round_trip.output_path, snapshot.output_path); + assert_eq!(round_trip.piece_storage, snapshot.piece_storage); + assert_eq!(round_trip.bitfield, snapshot.bitfield); + assert_eq!(round_trip.block_map.len(), snapshot.block_map.len()); + restored_engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn version_one_torrent_metainfo_with_null_info_dict_restores_metadata() { + let source = deterministic_engine(); + let torrent = source + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let snapshot = torrent.snapshot().await.unwrap(); + let mut wire = serde_json::to_value(snapshot).unwrap(); + wire["version"] = serde_json::json!(1); + wire["info_dict"] = serde_json::Value::Null; + wire.as_object_mut().unwrap().remove("resolved_magnet_info"); + wire["block_map"] = serde_json::json!({}); + source.shutdown().await.unwrap(); + + let migrated: TorrentSnapshot = serde_json::from_value(wire).unwrap(); + let target = deterministic_engine(); + let restored = target.restore_torrent(migrated).await.unwrap(); + + assert!(restored.view().unwrap().has_metadata()); + target.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn missing_completed_payload_never_restores_as_seeding() { + let source = deterministic_engine(); + let torrent = source + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let mut snapshot = torrent.snapshot().await.unwrap(); + snapshot.state = libtortillas::torrent::TorrentState::Seeding; + snapshot.bitfield.fill(true); + let output_path = std::env::temp_dir().join(format!( + "libtortillas-missing-restore-{}", + std::process::id() + )); + tokio::fs::create_dir_all(&output_path).await.unwrap(); + snapshot.output_path = Some(output_path.clone()); + source.shutdown().await.unwrap(); + + let target = deterministic_engine(); + let restored = target.restore_torrent(snapshot).await.unwrap(); + + assert_ne!( + restored.state().await.unwrap(), + libtortillas::torrent::TorrentState::Seeding + ); + assert_eq!( + restored + .snapshot() + .await + .unwrap() + .bitfield + .iter() + .filter(|complete| **complete) + .count(), + 0 + ); + target.shutdown().await.unwrap(); + tokio::fs::remove_dir_all(output_path).await.unwrap(); +} + +#[tokio::test] +async fn active_torrent_snapshot_when_restored_then_resumes_transfer_state() { + let engine = deterministic_engine(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + torrent.set_sufficient_peers(0).await.unwrap(); + torrent.start().await.unwrap(); + assert_eq!(torrent.state().await.unwrap(), TorrentState::Downloading); + let snapshot = torrent.snapshot().await.unwrap(); + let expected_output_path = snapshot.output_path.clone(); + let expected_piece_storage = snapshot.piece_storage.clone(); + engine.shutdown().await.unwrap(); + + let restored_engine = deterministic_engine(); + let restored = restored_engine.restore_torrent(snapshot).await.unwrap(); + + assert_eq!(restored.state().await.unwrap(), TorrentState::Downloading); + let round_trip = restored.snapshot().await.unwrap(); + assert_eq!(round_trip.output_path, expected_output_path); + assert_eq!(round_trip.piece_storage, expected_piece_storage); + restored_engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn torrent_snapshot_when_version_is_unknown_then_returns_typed_error() { + let engine = deterministic_engine(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let mut snapshot = torrent.snapshot().await.unwrap(); + engine.remove_torrent(torrent.info_hash()).await.unwrap(); + snapshot.version += 1; + + let error = engine.restore_torrent(snapshot).await.unwrap_err(); + + assert!(matches!( + error, + EngineError::Torrent(TorrentError::InvalidSnapshot { .. }) + )); + engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn engine_snapshot_when_serialized_then_restores_all_torrents() { + let engine = deterministic_engine(); + let first = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let second = engine + .add_torrent(TorrentSource::torrent_file_bytes(WIRED_CD)) + .await + .unwrap(); + let expected_hashes = [first.info_hash(), second.info_hash()]; + let engine_snapshot = engine.snapshot().await.unwrap(); + assert!( + engine_snapshot + .torrents + .windows(2) + .all(|pair| { pair[0].info_hash.as_bytes() <= pair[1].info_hash.as_bytes() }) + ); + let snapshot_bytes = serde_json::to_vec(&engine_snapshot).unwrap(); + engine.shutdown().await.unwrap(); + + let snapshot = serde_json::from_slice(&snapshot_bytes).unwrap(); + let restored_engine = deterministic_engine(); + let restored = restored_engine.restore(snapshot).await.unwrap(); + let restored_hashes = restored + .iter() + .map(libtortillas::torrent::Torrent::info_hash) + .collect::>(); + + assert_eq!(restored.len(), 2); + assert!( + expected_hashes + .iter() + .all(|hash| restored_hashes.contains(hash)) + ); + assert_eq!(restored_engine.view().torrent_count(), 2); + restored_engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn engine_snapshot_when_version_is_unknown_then_restores_nothing() { + let source_engine = deterministic_engine(); + let mut snapshot = source_engine.snapshot().await.unwrap(); + source_engine.shutdown().await.unwrap(); + snapshot.version += 1; + let target_engine = deterministic_engine(); + + let error = target_engine.restore(snapshot).await.unwrap_err(); + + assert!(matches!(error, EngineError::InvalidSnapshot { .. })); + assert_eq!(target_engine.view().torrent_count(), 0); + target_engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn engine_restore_checks_authoritative_actor_state_before_mutating() { + let source_engine = deterministic_engine(); + source_engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let snapshot = source_engine.snapshot().await.unwrap(); + source_engine.shutdown().await.unwrap(); + + let target_engine = deterministic_engine(); + let existing = target_engine + .add_torrent(TorrentSource::torrent_file_bytes(WIRED_CD)) + .await + .unwrap(); + + let error = target_engine.restore(snapshot).await.unwrap_err(); + + assert!(matches!(error, EngineError::InvalidSnapshot { .. })); + assert_eq!(target_engine.view().torrent_count(), 1); + assert!(target_engine.torrent(existing.info_hash()).await.is_ok()); + target_engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn torrent_snapshot_when_piece_state_is_inconsistent_then_is_rejected_cleanly() { + let source_engine = deterministic_engine(); + let torrent = source_engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let mut snapshot = torrent.snapshot().await.unwrap(); + source_engine.shutdown().await.unwrap(); + snapshot.bitfield.pop(); + let target_engine = deterministic_engine(); + + let error = target_engine.restore_torrent(snapshot).await.unwrap_err(); + + assert!(matches!( + error, + EngineError::Torrent(TorrentError::InvalidSnapshot { .. }) + )); + assert_eq!(target_engine.view().torrent_count(), 0); + target_engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn torrent_snapshot_without_output_path_returns_typed_error() { + let source = deterministic_engine(); + let torrent = source + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let mut snapshot = torrent.snapshot().await.unwrap(); + snapshot.output_path = None; + source.shutdown().await.unwrap(); + + let target = deterministic_engine(); + let error = target.restore_torrent(snapshot).await.unwrap_err(); + + assert!(matches!( + error, + EngineError::Torrent(TorrentError::InvalidSnapshot { .. }) + )); + target.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn duplicate_add_preserves_typed_domain_error() { + let engine = deterministic_engine(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + + let error = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap_err(); + + assert!(matches!( + error, + EngineError::TorrentAlreadyExists(info_hash) if info_hash == torrent.info_hash() + )); + engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn custom_piece_manager_snapshot_returns_typed_unsupported_error() { + let engine = deterministic_engine(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let storage = std::env::temp_dir().join(format!( + "libtortillas-custom-manager-{}", + std::process::id() + )); + torrent + .set_piece_storage(PieceStorageStrategy::Disk(storage.clone())) + .await + .unwrap(); + torrent + .set_piece_manager(CustomPieceManager::default()) + .await + .unwrap(); + let storage_error = torrent + .set_piece_storage(PieceStorageStrategy::InFile) + .await + .unwrap_err(); + assert!(matches!( + storage_error, + TorrentError::InvalidOperation { + operation: "set piece storage", + .. + } + )); + + let error = torrent.snapshot().await.unwrap_err(); + + assert!(matches!( + error, + TorrentError::SnapshotUnsupported { + reason: SnapshotUnsupportedReason::CustomPieceManager + } + )); + engine.shutdown().await.unwrap(); + let _ = tokio::fs::remove_dir_all(storage).await; +} + +#[tokio::test] +async fn invalid_output_folder_returns_typed_filesystem_error() { + let engine = deterministic_engine(); + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let fixture = + std::env::temp_dir().join(format!("libtortillas-output-file-{}", std::process::id())); + tokio::fs::write(&fixture, b"not a directory") + .await + .unwrap(); + + let error = torrent + .set_output_folder(fixture.join("child")) + .await + .unwrap_err(); + + assert!(matches!(error, TorrentError::FileIoError { .. })); + tokio::fs::remove_file(fixture).await.unwrap(); + engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn piece_storage_change_after_restored_data_returns_invalid_operation() { + let source = deterministic_engine(); + let torrent = source + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) + .await + .unwrap(); + let mut snapshot = torrent.snapshot().await.unwrap(); + snapshot.bitfield[0] = true; + source.shutdown().await.unwrap(); + + let target = deterministic_engine(); + let restored = target + .restore_torrent_with_verification(snapshot, RestoreVerification::TrustSnapshot) + .await + .unwrap(); + let error = restored + .set_piece_storage(PieceStorageStrategy::Disk( + std::env::temp_dir().join("libtortillas-rejected-storage-change"), + )) + .await + .unwrap_err(); + + assert!(matches!( + error, + TorrentError::InvalidOperation { + operation: "set piece storage", + .. + } + )); + target.shutdown().await.unwrap(); +} + +#[test] +fn torrent_snapshot_v2_golden_fixture_round_trips_every_field() { + let snapshot: TorrentSnapshot = serde_json::from_str(SNAPSHOT_V2).unwrap(); + snapshot.validate().unwrap(); + + let expected: serde_json::Value = serde_json::from_str(SNAPSHOT_V2).unwrap(); + let actual = serde_json::to_value(snapshot).unwrap(); + + assert_eq!(actual, expected); +} + +#[test] +fn torrent_snapshot_v1_golden_fixture_migrates_to_canonical_v2() { + let snapshot: TorrentSnapshot = serde_json::from_str(SNAPSHOT_V1).unwrap(); + + assert_eq!( + snapshot.version, + libtortillas::torrent::TORRENT_SNAPSHOT_VERSION + ); + assert!(snapshot.resolved_magnet_info.is_none()); + assert!(snapshot.block_map.is_empty()); + snapshot.validate().unwrap(); +} + +#[test] +fn engine_snapshot_golden_fixtures_migrate_and_round_trip() { + let migrated: libtortillas::engine::EngineSnapshot = + serde_json::from_str(ENGINE_SNAPSHOT_V1).unwrap(); + assert_eq!( + migrated.version, + libtortillas::engine::ENGINE_SNAPSHOT_VERSION + ); + migrated.validate().unwrap(); + + let expected: serde_json::Value = serde_json::from_str(ENGINE_SNAPSHOT_V2).unwrap(); + assert_eq!(serde_json::to_value(migrated).unwrap(), expected); +}