From f6d8e2310bafeb5c44b62d63b0175d082ea51561 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:33:21 -0700 Subject: [PATCH 01/77] feat: define live frontend events --- crates/libtortillas/src/facade.rs | 61 +--------- crates/libtortillas/src/frontend/event.rs | 142 ++++++++++++++++++++++ crates/libtortillas/src/frontend/mod.rs | 11 ++ crates/libtortillas/src/lib.rs | 1 + crates/libtortillas/tests/facade.rs | 3 +- 5 files changed, 159 insertions(+), 59 deletions(-) create mode 100644 crates/libtortillas/src/frontend/event.rs create mode 100644 crates/libtortillas/src/frontend/mod.rs diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index b157a483..b190b0a6 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -17,11 +17,14 @@ //! }; //! ``` -use std::{net::SocketAddr, path::PathBuf}; +use std::path::PathBuf; use crate::{engine::Engine, hashes::InfoHash, torrent::Torrent}; pub use crate::{ engine::{EngineSnapshot, EngineStatus, TorrentSource}, + frontend::{ + CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, PeerSnapshot, TrackerSnapshot, + }, torrent::{TorrentProgressSnapshot, TorrentSnapshot, TorrentTransferSnapshot}, }; @@ -69,59 +72,3 @@ pub enum CoreCommand { /// 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/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs new file mode 100644 index 00000000..23190b85 --- /dev/null +++ b/crates/libtortillas/src/frontend/event.rs @@ -0,0 +1,142 @@ +use std::net::SocketAddr; + +use serde::{Deserialize, Serialize}; + +use crate::{ + engine::EngineSnapshot, + hashes::InfoHash, + torrent::{TorrentProgressSnapshot, TorrentSnapshot, TorrentState}, +}; + +/// A sequenced event emitted by the live frontend API. +/// +/// Sequence numbers are engine-local and strictly increase for every event. +/// A frontend can use them to preserve event order or detect a gap after +/// reconnecting a consumer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CoreEvent { + /// Engine-local sequence number for this event. + pub sequence: u64, + /// The typed change represented by this event. + pub kind: CoreEventKind, +} + +impl CoreEvent { + /// Returns the torrent associated with this event, when applicable. + #[must_use] + pub const fn torrent(&self) -> Option { + self.kind.torrent() + } +} + +/// Typed changes a frontend can react to without actor internals or polling. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum CoreEventKind { + /// The engine finished starting and is ready for commands. + EngineStarted(EngineSnapshot), + /// A torrent was added to the engine. + TorrentAdded(TorrentSnapshot), + /// A torrent was removed from the engine. + TorrentRemoved { torrent: InfoHash }, + /// A torrent changed lifecycle state. + TorrentStateChanged { + torrent: InfoHash, + previous: TorrentState, + current: TorrentState, + }, + /// Metadata for a magnet torrent was resolved. + MetadataResolved(TorrentSnapshot), + /// Download progress changed. + ProgressChanged { + torrent: InfoHash, + progress: TorrentProgressSnapshot, + }, + /// A peer connection became available to a torrent. + PeerConnected { + torrent: InfoHash, + peer: PeerSnapshot, + }, + /// A peer connection was removed from a torrent. + PeerDisconnected { + torrent: InfoHash, + peer: PeerSnapshot, + }, + /// A tracker announce completed successfully. + TrackerAnnounceSucceeded { + torrent: InfoHash, + tracker: TrackerSnapshot, + }, + /// A tracker announce failed. + TrackerAnnounceFailed { + torrent: InfoHash, + tracker: TrackerSnapshot, + }, + /// A frontend-relevant health report was emitted. + Health(FrontendHealth), + /// The engine and its managed torrents stopped. + Shutdown(EngineSnapshot), +} + +impl CoreEventKind { + /// Returns the torrent associated with this event, when applicable. + #[must_use] + pub const fn torrent(&self) -> Option { + match self { + Self::EngineStarted(_) | Self::Shutdown(_) => None, + Self::TorrentAdded(snapshot) | Self::MetadataResolved(snapshot) => { + Some(snapshot.info_hash) + } + Self::TorrentRemoved { torrent } + | Self::TorrentStateChanged { torrent, .. } + | Self::ProgressChanged { torrent, .. } + | Self::PeerConnected { torrent, .. } + | Self::PeerDisconnected { torrent, .. } + | Self::TrackerAnnounceSucceeded { torrent, .. } + | Self::TrackerAnnounceFailed { torrent, .. } => Some(*torrent), + Self::Health(health) => health.torrent, + } + } +} + +/// Frontend snapshot of a connected or recently disconnected peer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerSnapshot { + /// Network address for the peer, when known. + pub address: Option, + /// Parsed peer-client family, when known. + pub client: Option, + /// Whether this peer is currently connected. + pub connected: bool, +} + +/// Frontend-safe tracker identity and latest announce outcome. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TrackerSnapshot { + /// Credential-free tracker endpoint label. + pub endpoint: String, + /// Whether the latest announce succeeded. + pub healthy: bool, + /// Number of peers returned by the latest successful announce. + pub peers_returned: Option, +} + +/// A recoverable or terminal health report intended for user interfaces. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrontendHealth { + /// Torrent associated with the report, or `None` for engine-wide health. + pub torrent: Option, + /// Severity suitable for presentation and filtering. + pub level: FrontendHealthLevel, + /// Frontend-safe description without internal actor details. + pub message: String, +} + +/// Severity of a frontend health report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FrontendHealthLevel { + /// 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/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs new file mode 100644 index 00000000..088b48f3 --- /dev/null +++ b/crates/libtortillas/src/frontend/mod.rs @@ -0,0 +1,11 @@ +//! Live, frontend-facing API contracts. +//! +//! This module contains the typed events, commands, subscriptions, and +//! snapshots intended for application and UI integrations. Frontends should +//! prefer these types over actor messages and protocol internals. + +mod event; + +pub use event::{ + CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, PeerSnapshot, TrackerSnapshot, +}; diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index 850937c3..16a18520 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -48,6 +48,7 @@ pub(crate) mod dht; pub mod engine; pub mod errors; pub mod facade; +pub mod frontend; pub mod hashes; pub mod metainfo; pub mod peer; diff --git a/crates/libtortillas/tests/facade.rs b/crates/libtortillas/tests/facade.rs index 3d6d3e7d..7cb2e558 100644 --- a/crates/libtortillas/tests/facade.rs +++ b/crates/libtortillas/tests/facade.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use libtortillas::{ - facade::{EngineSnapshot, TorrentSnapshot, TrackerStatus}, + facade::{EngineSnapshot, TorrentSnapshot}, hashes::InfoHash, prelude::{CoreCommand, EngineHandle, TorrentSource}, }; @@ -60,5 +60,4 @@ fn facade_reexports_canonical_snapshot_types() { accepts_engine_snapshot(engine_snapshot); accepts_torrent_snapshot(torrent_snapshot); - assert_eq!(TrackerStatus::Pending, TrackerStatus::Pending); } From 9dca2cfdb0cebd30294f3e60875c37606fd48821 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:33:58 -0700 Subject: [PATCH 02/77] feat: add lag-aware event subscriptions --- crates/libtortillas/src/facade.rs | 3 +- crates/libtortillas/src/frontend/mod.rs | 2 + .../libtortillas/src/frontend/subscription.rs | 78 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 crates/libtortillas/src/frontend/subscription.rs diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index b190b0a6..c7e6a503 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -23,7 +23,8 @@ use crate::{engine::Engine, hashes::InfoHash, torrent::Torrent}; pub use crate::{ engine::{EngineSnapshot, EngineStatus, TorrentSource}, frontend::{ - CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, PeerSnapshot, TrackerSnapshot, + CoreEvent, CoreEventKind, EventStreamError, EventSubscription, FrontendHealth, + FrontendHealthLevel, PeerSnapshot, TrackerSnapshot, }, torrent::{TorrentProgressSnapshot, TorrentSnapshot, TorrentTransferSnapshot}, }; diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index 088b48f3..ed3142b4 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -5,7 +5,9 @@ //! prefer these types over actor messages and protocol internals. mod event; +mod subscription; pub use event::{ CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, PeerSnapshot, TrackerSnapshot, }; +pub use subscription::{EventStreamError, EventSubscription}; diff --git a/crates/libtortillas/src/frontend/subscription.rs b/crates/libtortillas/src/frontend/subscription.rs new file mode 100644 index 00000000..ab779dd7 --- /dev/null +++ b/crates/libtortillas/src/frontend/subscription.rs @@ -0,0 +1,78 @@ +use thiserror::Error; +use tokio::sync::broadcast; + +use super::CoreEvent; +use crate::hashes::InfoHash; + +/// A lag-aware subscription to the engine's typed frontend events. +/// +/// The stream is bounded so a stalled UI cannot cause unbounded memory use. +/// If [`Self::recv`] reports [`EventStreamError::Lagged`], redraw from the +/// latest watched snapshot and continue receiving events. +#[derive(Debug)] +pub struct EventSubscription { + receiver: broadcast::Receiver, + torrent: Option, +} + +impl EventSubscription { + pub(crate) fn engine(receiver: broadcast::Receiver) -> Self { + Self { + receiver, + torrent: None, + } + } + + pub(crate) fn torrent(receiver: broadcast::Receiver, torrent: InfoHash) -> Self { + Self { + receiver, + torrent: Some(torrent), + } + } + + /// Waits for the next event in this subscription. + /// + /// Torrent subscriptions skip unrelated events while preserving the + /// original engine-local sequence numbers. + pub async fn recv(&mut self) -> Result { + loop { + let event = self.receiver.recv().await.map_err(EventStreamError::from)?; + if self + .torrent + .is_none_or(|torrent| event.torrent() == Some(torrent)) + { + return Ok(event); + } + } + } + + /// Creates another subscription beginning at the current event position. + #[must_use] + pub fn resubscribe(&self) -> Self { + Self { + receiver: self.receiver.resubscribe(), + torrent: self.torrent, + } + } +} + +/// Errors produced while receiving live frontend 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("frontend event subscriber lagged by {0} events")] + Lagged(u64), + /// The engine closed the event stream. + #[error("frontend event stream closed")] + Closed, +} + +impl From for EventStreamError { + fn from(error: broadcast::error::RecvError) -> Self { + match error { + broadcast::error::RecvError::Closed => Self::Closed, + broadcast::error::RecvError::Lagged(events) => Self::Lagged(events), + } + } +} From 949f3d56278ebff433cbabfa350a012f67ec0a66 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:34:10 -0700 Subject: [PATCH 03/77] feat: model engine lifecycle snapshots --- crates/libtortillas/src/engine/snapshot.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/libtortillas/src/engine/snapshot.rs b/crates/libtortillas/src/engine/snapshot.rs index 2604447a..dcffbdc9 100644 --- a/crates/libtortillas/src/engine/snapshot.rs +++ b/crates/libtortillas/src/engine/snapshot.rs @@ -13,5 +13,12 @@ pub struct EngineSnapshot { /// Coarse engine status for frontend displays. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum EngineStatus { + /// Runtime resources are still being initialized. + Starting, + /// The engine is accepting commands and managing torrents. Running, + /// Graceful shutdown is in progress. + Stopping, + /// The engine and its managed torrents have stopped. + Stopped, } From bd856b7c245e2dd08a1cd470eea3ebf0d460167e Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:36:01 -0700 Subject: [PATCH 04/77] refactor: separate live views from snapshots --- crates/libtortillas/src/facade.rs | 4 +- crates/libtortillas/src/frontend/event.rs | 55 ++++------------- crates/libtortillas/src/frontend/mod.rs | 6 +- crates/libtortillas/src/frontend/view.rs | 75 +++++++++++++++++++++++ 4 files changed, 91 insertions(+), 49 deletions(-) create mode 100644 crates/libtortillas/src/frontend/view.rs diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index c7e6a503..982c1145 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -23,8 +23,8 @@ use crate::{engine::Engine, hashes::InfoHash, torrent::Torrent}; pub use crate::{ engine::{EngineSnapshot, EngineStatus, TorrentSource}, frontend::{ - CoreEvent, CoreEventKind, EventStreamError, EventSubscription, FrontendHealth, - FrontendHealthLevel, PeerSnapshot, TrackerSnapshot, + CoreEvent, CoreEventKind, EngineView, EventStreamError, EventSubscription, FrontendHealth, + FrontendHealthLevel, PeerView, TorrentProgress, TorrentTransfer, TorrentView, TrackerView, }, torrent::{TorrentProgressSnapshot, TorrentSnapshot, TorrentTransferSnapshot}, }; diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index 23190b85..a6f62f60 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -1,12 +1,7 @@ -use std::net::SocketAddr; - use serde::{Deserialize, Serialize}; -use crate::{ - engine::EngineSnapshot, - hashes::InfoHash, - torrent::{TorrentProgressSnapshot, TorrentSnapshot, TorrentState}, -}; +use super::{EngineView, PeerView, TorrentProgress, TorrentView, TrackerView}; +use crate::{hashes::InfoHash, torrent::TorrentState}; /// A sequenced event emitted by the live frontend API. /// @@ -34,9 +29,9 @@ impl CoreEvent { #[non_exhaustive] pub enum CoreEventKind { /// The engine finished starting and is ready for commands. - EngineStarted(EngineSnapshot), + EngineStarted(EngineView), /// A torrent was added to the engine. - TorrentAdded(TorrentSnapshot), + TorrentAdded(TorrentView), /// A torrent was removed from the engine. TorrentRemoved { torrent: InfoHash }, /// A torrent changed lifecycle state. @@ -46,36 +41,30 @@ pub enum CoreEventKind { current: TorrentState, }, /// Metadata for a magnet torrent was resolved. - MetadataResolved(TorrentSnapshot), + MetadataResolved(TorrentView), /// Download progress changed. ProgressChanged { torrent: InfoHash, - progress: TorrentProgressSnapshot, + progress: TorrentProgress, }, /// A peer connection became available to a torrent. - PeerConnected { - torrent: InfoHash, - peer: PeerSnapshot, - }, + PeerConnected { torrent: InfoHash, peer: PeerView }, /// A peer connection was removed from a torrent. - PeerDisconnected { - torrent: InfoHash, - peer: PeerSnapshot, - }, + PeerDisconnected { torrent: InfoHash, peer: PeerView }, /// A tracker announce completed successfully. TrackerAnnounceSucceeded { torrent: InfoHash, - tracker: TrackerSnapshot, + tracker: TrackerView, }, /// A tracker announce failed. TrackerAnnounceFailed { torrent: InfoHash, - tracker: TrackerSnapshot, + tracker: TrackerView, }, /// A frontend-relevant health report was emitted. Health(FrontendHealth), /// The engine and its managed torrents stopped. - Shutdown(EngineSnapshot), + Shutdown(EngineView), } impl CoreEventKind { @@ -99,28 +88,6 @@ impl CoreEventKind { } } -/// Frontend snapshot of a connected or recently disconnected peer. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PeerSnapshot { - /// Network address for the peer, when known. - pub address: Option, - /// Parsed peer-client family, when known. - pub client: Option, - /// Whether this peer is currently connected. - pub connected: bool, -} - -/// Frontend-safe tracker identity and latest announce outcome. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TrackerSnapshot { - /// Credential-free tracker endpoint label. - pub endpoint: String, - /// Whether the latest announce succeeded. - pub healthy: bool, - /// Number of peers returned by the latest successful announce. - pub peers_returned: Option, -} - /// A recoverable or terminal health report intended for user interfaces. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FrontendHealth { diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index ed3142b4..36fff7ad 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -6,8 +6,8 @@ mod event; mod subscription; +mod view; -pub use event::{ - CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, PeerSnapshot, TrackerSnapshot, -}; +pub use event::{CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel}; pub use subscription::{EventStreamError, EventSubscription}; +pub use view::{EngineView, PeerView, TorrentProgress, TorrentTransfer, TorrentView, TrackerView}; diff --git a/crates/libtortillas/src/frontend/view.rs b/crates/libtortillas/src/frontend/view.rs new file mode 100644 index 00000000..1d0ec1bb --- /dev/null +++ b/crates/libtortillas/src/frontend/view.rs @@ -0,0 +1,75 @@ +use std::{net::SocketAddr, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::{engine::EngineStatus, hashes::InfoHash, torrent::TorrentState}; + +/// Current live engine state maintained by a frontend listener. +/// +/// Unlike persistence snapshots, views are display-oriented and updated by +/// applying live [`CoreEvent`](super::CoreEvent) values. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EngineView { + pub status: EngineStatus, + pub torrent_count: u64, + pub torrents: Vec, +} + +/// Current live state of one torrent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TorrentView { + 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: TorrentProgress, + pub transfer: TorrentTransfer, +} + +/// Live torrent progress intended for frontend rendering. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TorrentProgress { + 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, +} + +/// Live torrent transfer metrics intended for frontend rendering. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TorrentTransfer { + pub download_rate_bytes_per_second: Option, + pub upload_rate_bytes_per_second: Option, + pub eta_seconds: Option, +} + +/// Live view of a connected or recently disconnected peer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerView { + /// Network address for the peer, when known. + pub address: Option, + /// Parsed peer-client family, when known. + pub client: Option, + /// Whether this peer is currently connected. + pub connected: bool, +} + +/// Frontend-safe live tracker identity and latest announce outcome. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TrackerView { + /// Credential-free tracker endpoint label. + pub endpoint: String, + /// Whether the latest announce succeeded. + pub healthy: bool, + /// Number of peers returned by the latest successful announce. + pub peers_returned: Option, +} From 7d8f0ca20af5d38852a1d74d150b3792811e7084 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:37:24 -0700 Subject: [PATCH 05/77] feat: maintain live frontend state --- crates/libtortillas/src/facade.rs | 5 +- crates/libtortillas/src/frontend/mod.rs | 3 + crates/libtortillas/src/frontend/publisher.rs | 143 ++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 crates/libtortillas/src/frontend/publisher.rs diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index 982c1145..de801877 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -23,8 +23,9 @@ use crate::{engine::Engine, hashes::InfoHash, torrent::Torrent}; pub use crate::{ engine::{EngineSnapshot, EngineStatus, TorrentSource}, frontend::{ - CoreEvent, CoreEventKind, EngineView, EventStreamError, EventSubscription, FrontendHealth, - FrontendHealthLevel, PeerView, TorrentProgress, TorrentTransfer, TorrentView, TrackerView, + CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, EngineView, EventStreamError, + EventSubscription, FrontendHealth, FrontendHealthLevel, PeerView, TorrentProgress, + TorrentTransfer, TorrentView, TrackerView, }, torrent::{TorrentProgressSnapshot, TorrentSnapshot, TorrentTransferSnapshot}, }; diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index 36fff7ad..fd16749c 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -5,9 +5,12 @@ //! prefer these types over actor messages and protocol internals. mod event; +mod publisher; mod subscription; mod view; pub use event::{CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel}; +pub use publisher::DEFAULT_EVENT_CAPACITY; +pub(crate) use publisher::FrontendPublisher; pub use subscription::{EventStreamError, EventSubscription}; pub use view::{EngineView, PeerView, TorrentProgress, TorrentTransfer, TorrentView, TrackerView}; diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs new file mode 100644 index 00000000..c4c79577 --- /dev/null +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -0,0 +1,143 @@ +use std::sync::{ + Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, + atomic::{AtomicU64, Ordering}, +}; + +use tokio::sync::broadcast; + +use super::{CoreEvent, CoreEventKind, EngineView, EventSubscription, TorrentView}; +use crate::{engine::EngineStatus, hashes::InfoHash}; + +/// Number of discrete frontend events retained for each listener. +pub const DEFAULT_EVENT_CAPACITY: usize = 256; + +/// Shared live-state publisher used by the engine actor hierarchy. +#[derive(Debug, Clone)] +pub(crate) struct FrontendPublisher { + inner: Arc, +} + +#[derive(Debug)] +struct PublisherInner { + events: broadcast::Sender, + view: RwLock, + sequence: AtomicU64, +} + +impl FrontendPublisher { + pub(crate) fn new() -> Self { + Self::with_event_capacity(DEFAULT_EVENT_CAPACITY) + } + + fn with_event_capacity(event_capacity: usize) -> Self { + let (events, _) = broadcast::channel(event_capacity); + Self { + inner: Arc::new(PublisherInner { + events, + view: RwLock::new(EngineView { + status: EngineStatus::Starting, + torrent_count: 0, + torrents: Vec::new(), + }), + sequence: AtomicU64::new(0), + }), + } + } + + pub(crate) fn subscribe(&self) -> EventSubscription { + EventSubscription::engine(self.inner.events.subscribe()) + } + + pub(crate) fn subscribe_torrent(&self, torrent: InfoHash) -> EventSubscription { + EventSubscription::torrent(self.inner.events.subscribe(), torrent) + } + + pub(crate) fn view(&self) -> EngineView { + self.read_view().clone() + } + + pub(crate) fn torrent_view(&self, torrent: InfoHash) -> Option { + self + .read_view() + .torrents + .iter() + .find(|view| view.info_hash == torrent) + .cloned() + } + + pub(crate) fn engine_started(&self) { + let view = self.set_engine_status(EngineStatus::Running); + self.publish(CoreEventKind::EngineStarted(view)); + } + + pub(crate) fn engine_stopping(&self) { + self.set_engine_status(EngineStatus::Stopping); + } + + pub(crate) fn engine_stopped(&self) { + let view = self.set_engine_status(EngineStatus::Stopped); + self.publish(CoreEventKind::Shutdown(view)); + } + + pub(crate) fn torrent_added(&self, torrent: TorrentView) { + self.replace_torrent(torrent.clone()); + self.publish(CoreEventKind::TorrentAdded(torrent)); + } + + pub(crate) fn update_torrent(&self, torrent: TorrentView) { + self.replace_torrent(torrent); + } + + pub(crate) fn torrent_removed(&self, torrent: InfoHash) { + let mut view = self.write_view(); + view + .torrents + .retain(|candidate| candidate.info_hash != torrent); + view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); + drop(view); + self.publish(CoreEventKind::TorrentRemoved { torrent }); + } + + pub(crate) fn publish(&self, kind: CoreEventKind) { + let sequence = self.inner.sequence.fetch_add(1, Ordering::Relaxed) + 1; + let _ = self.inner.events.send(CoreEvent { sequence, kind }); + } + + fn set_engine_status(&self, status: EngineStatus) -> EngineView { + let mut view = self.write_view(); + view.status = status; + view.clone() + } + + fn replace_torrent(&self, torrent: TorrentView) { + let mut view = self.write_view(); + match view + .torrents + .iter_mut() + .find(|candidate| candidate.info_hash == torrent.info_hash) + { + Some(current) => *current = torrent, + None => view.torrents.push(torrent), + } + view + .torrents + .sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); + view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); + } + + fn read_view(&self) -> RwLockReadGuard<'_, EngineView> { + self + .inner + .view + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write_view(&self) -> RwLockWriteGuard<'_, EngineView> { + self + .inner + .view + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} From 2658378ac01d57f570be6df2f81eb8f2d7de2592 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:37:58 -0700 Subject: [PATCH 06/77] feat: attach live state to the engine --- crates/libtortillas/src/engine/actor.rs | 13 +++++++++++++ crates/libtortillas/src/engine/mod.rs | 12 +++++++++--- crates/libtortillas/src/frontend/publisher.rs | 6 ++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/libtortillas/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index ac0184cc..c2279d0f 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -17,6 +17,7 @@ use super::commands; use crate::{ dht::{DhtActor, DhtActorArgs}, errors::EngineError, + frontend::FrontendPublisher, hashes::InfoHash, peer::PeerId, protocol::stream::PeerStream, @@ -30,6 +31,8 @@ use crate::{ /// also implements the [Actor] trait, and consequently behaves like an /// actor. pub struct EngineActor { + /// Live frontend event and view publisher shared with managed torrents. + pub(super) frontend: FrontendPublisher, /// Engine-wide DHT service shared by every torrent. pub(super) dht: Option>, /// Listener to wait for incoming TCP connections from peers @@ -102,6 +105,9 @@ pub struct EngineActorArgs { /// /// If not provided, torrents will use their own default paths. pub default_base_path: Option, + + /// Live frontend state shared by the engine handle and actor hierarchy. + pub(crate) frontend: FrontendPublisher, } impl Actor for EngineActor { @@ -130,6 +136,7 @@ impl Actor for EngineActor { piece_storage_strategy, settings, default_base_path, + frontend, } = args; let tcp_addr = tcp_addr.unwrap_or(settings.engine.tcp_addr); @@ -167,7 +174,10 @@ impl Actor for EngineActor { None }; + frontend.engine_started(); + Ok(Self { + frontend, dht, tcp_socket, utp_socket, @@ -247,6 +257,7 @@ impl Actor for EngineActor { async fn on_stop( &mut self, _: WeakActorRef, _: ActorStopReason, ) -> Result<(), Self::Error> { + self.frontend.engine_stopping(); let torrents = self .torrents .iter() @@ -266,6 +277,8 @@ impl Actor for EngineActor { dht.wait_for_shutdown().await; } + self.frontend.engine_stopped(); + Ok(()) } } diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 74ef8a34..2f643505 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -61,6 +61,7 @@ use self::commands::{CreateTorrent, RemoveTorrent, SnapshotEngine, StartAll}; pub use self::snapshot::{EngineSnapshot, EngineStatus}; use crate::{ errors::EngineError, + frontend::FrontendPublisher, hashes::InfoHash, peer::PeerId, settings::Settings, @@ -108,7 +109,10 @@ use crate::{ /// } /// ``` #[derive(Debug, Clone)] -pub struct Engine(ActorRef); +pub struct Engine { + actor: ActorRef, + frontend: FrontendPublisher, +} #[bon::bon] impl Engine { @@ -205,6 +209,7 @@ impl Engine { None => std::env::current_dir().expect("Failed to get current dir"), }; + let frontend = FrontendPublisher::new(); let args = EngineActorArgs { tcp_addr, utp_addr, @@ -213,16 +218,17 @@ impl Engine { piece_storage_strategy, settings, default_base_path: Some(output_path), + frontend: frontend.clone(), }; let actor = EngineActor::spawn(args); - Engine(actor) + Engine { actor, frontend } } /// 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 diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index c4c79577..5386c369 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -141,3 +141,9 @@ impl FrontendPublisher { .unwrap_or_else(std::sync::PoisonError::into_inner) } } + +impl Default for FrontendPublisher { + fn default() -> Self { + Self::new() + } +} From 8808a153778dcd13ae8862b16023d66238cd5c5e Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:40:11 -0700 Subject: [PATCH 07/77] feat: attach live state to torrents --- crates/libtortillas/src/engine/messages.rs | 1 + crates/libtortillas/src/torrent/actor.rs | 86 ++++++++++++++++++- crates/libtortillas/src/torrent/piece_flow.rs | 4 + 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index bfd58e4c..757c7e0b 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -135,6 +135,7 @@ pub(crate) mod commands { sufficient_peers: None, base_path: self.default_base_path.clone(), settings: self.settings.clone(), + frontend: self.frontend.clone(), }, ) .restart_policy(RestartPolicy::Transient) diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index fb7302b9..14031919 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -26,6 +26,7 @@ use tracing::{debug, error, info, instrument, trace, warn}; use super::{choking::ChokingScheduler, util}; use crate::{ errors::TorrentError, + frontend::{FrontendPublisher, TorrentProgress, TorrentTransfer, TorrentView}, hashes::InfoHash, metainfo::{Info, MetaInfo}, peer::{PeerActor, PeerId, commands::SetChoked}, @@ -95,6 +96,7 @@ impl PieceManager for PieceManagerProxy { } pub(crate) struct TorrentActor { + pub(super) frontend: FrontendPublisher, pub(crate) peers: HashMap>, pub(crate) trackers: HashMap>, @@ -486,6 +488,63 @@ impl TorrentActor { } } + /// Builds the display-oriented state used by live frontend 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 progress_fraction = total_bytes.map(|bytes| { + if bytes == 0 { + 1.0 + } else { + downloaded_bytes.min(bytes) as f64 / bytes as f64 + } + }); + let completed_pieces = self.bitfield.count_ones(); + let total_pieces = self.bitfield.len(); + let partial_pieces = self + .piece_scheduler + .block_map_export() + .iter() + .filter(|entry| { + let piece_idx = *entry.key(); + piece_idx < total_pieces && !self.bitfield[piece_idx] && entry.value().count_ones() > 0 + }) + .count(); + + 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()), + tracker_count: Self::snapshot_u64(self.trackers.len()), + output_path: match &self.piece_manager { + PieceManagerProxy::Default(manager) => manager.path().cloned(), + PieceManagerProxy::Custom(_) => None, + }, + progress: TorrentProgress { + 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: TorrentTransfer { + download_rate_bytes_per_second: None, + upload_rate_bytes_per_second: None, + eta_seconds: None, + }, + } + } + fn snapshot_u64(value: usize) -> u64 { u64::try_from(value).unwrap_or(u64::MAX) } @@ -565,6 +624,9 @@ pub struct TorrentActorArgs { /// Runtime behavior settings. pub settings: Settings, + + /// Live frontend state shared with the owning engine. + pub(crate) frontend: FrontendPublisher, } impl Actor for TorrentActor { @@ -588,6 +650,7 @@ impl Actor for TorrentActor { sufficient_peers, base_path, settings, + frontend, } = args; let torrent_id = metainfo.info_hash()?; @@ -672,7 +735,8 @@ impl Actor for TorrentActor { .spawn() .await; - Ok(Self { + let actor = Self { + frontend, peers: HashMap::new(), bitfield, tracker_server, @@ -699,7 +763,10 @@ impl Actor for TorrentActor { ready_hook: Vec::new(), piece_manager: PieceManagerProxy::Default(default_manager), settings, - }) + }; + actor.frontend.torrent_added(actor.live_view()); + + Ok(actor) } async fn next( @@ -892,6 +959,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path), settings, + frontend: FrontendPublisher::default(), }); actor .tell(SetState { @@ -942,6 +1010,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(testing::torrent_temp_path()), settings, + frontend: FrontendPublisher::default(), }); actor .tell(SetState { @@ -989,6 +1058,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(base_path.clone()), settings, + frontend: FrontendPublisher::default(), }); actor .tell(SetState { @@ -1066,6 +1136,7 @@ mod tests { sufficient_peers: Some(sufficient_peers), base_path: None, settings: Settings::default(), + frontend: FrontendPublisher::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); @@ -1099,6 +1170,7 @@ mod tests { sufficient_peers: None, base_path: None, settings: Settings::default(), + frontend: FrontendPublisher::default(), }); // Blocking loop that runs until we get an info dict @@ -1138,6 +1210,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), + frontend: FrontendPublisher::default(), }); assert_eq!(actor.ask(GetState).await.unwrap(), TorrentState::Ready); @@ -1162,6 +1235,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), + frontend: FrontendPublisher::default(), }); assert_eq!( @@ -1190,6 +1264,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), + frontend: FrontendPublisher::default(), }); actor @@ -1249,6 +1324,7 @@ mod tests { sufficient_peers: None, base_path: Some(file_path), settings: Settings::default(), + frontend: FrontendPublisher::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); @@ -1324,6 +1400,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), + frontend: FrontendPublisher::default(), }); // Build the bitfield with fake completed pieces @@ -1347,6 +1424,7 @@ mod tests { // Construct the actor manually for export testing let test_actor = TorrentActor { + frontend: FrontendPublisher::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield, @@ -1457,6 +1535,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), + frontend: FrontendPublisher::default(), }); let live_snapshot = Torrent::new(info_hash, actor_ref.clone()) .snapshot() @@ -1482,6 +1561,7 @@ mod tests { piece_scheduler.set_piece_blocks(partial_piece_index, blocks); let mut test_actor = TorrentActor { + frontend: FrontendPublisher::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield, @@ -1593,9 +1673,11 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), + frontend: FrontendPublisher::default(), }); let mut actor = TorrentActor { + frontend: FrontendPublisher::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield: BitVec::repeat(false, piece_count), diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index cacab9c7..85f8d423 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(test)] +use crate::frontend::FrontendPublisher; use crate::{ errors::TorrentError, peer::commands::{CancelPiece, Have, NeedPiece}, @@ -463,9 +465,11 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(base_path.clone()), settings: Settings::default(), + frontend: FrontendPublisher::default(), }); TorrentActor { + frontend: FrontendPublisher::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield: BitVec::::repeat(false, info.piece_count()), From dabbc990d7a5a881795e861a5a66242cdd419ff2 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:40:45 -0700 Subject: [PATCH 08/77] feat: expose subscriptions on public handles --- crates/libtortillas/src/engine/mod.rs | 25 ++++++++++++-- crates/libtortillas/src/torrent/handle.rs | 42 ++++++++++++++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 2f643505..99646bb8 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -61,7 +61,7 @@ use self::commands::{CreateTorrent, RemoveTorrent, SnapshotEngine, StartAll}; pub use self::snapshot::{EngineSnapshot, EngineStatus}; use crate::{ errors::EngineError, - frontend::FrontendPublisher, + frontend::{EngineView, EventSubscription, FrontendPublisher}, hashes::InfoHash, peer::PeerId, settings::Settings, @@ -286,7 +286,11 @@ impl Engine { .await .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; - Ok(Torrent::new(info_hash, torrent_ref)) + Ok(Torrent::new_with_frontend( + info_hash, + torrent_ref, + self.frontend.clone(), + )) // We don't need to assign link or insert the ref here because its already // done by the engine actor } @@ -343,6 +347,23 @@ impl Engine { .await .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string()))) } + + /// Subscribes to typed engine and torrent events as they happen. + /// + /// The returned stream is bounded. A lagging frontend can read + /// [`Self::live_view`] to rebuild its display state and then continue + /// receiving events. + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + self.frontend.subscribe() + } + + /// Returns the current display-oriented engine state maintained by the live + /// event publisher. + #[must_use] + pub fn live_view(&self) -> EngineView { + self.frontend.view() + } } impl Default for Engine { diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index b62a9994..31ed143f 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -12,7 +12,11 @@ use super::{ SetSufficientPeers, SnapshotState, }, }; -use crate::{hashes::InfoHash, pieces::PieceManager}; +use crate::{ + frontend::{EventSubscription, FrontendPublisher, TorrentView}, + hashes::InfoHash, + pieces::PieceManager, +}; /// A handle to a torrent managed by the engine. /// @@ -20,22 +24,36 @@ use crate::{hashes::InfoHash, pieces::PieceManager}; /// a torrent after it has been added to the [`Engine`](crate::engine::Engine). #[allow(dead_code)] #[derive(Debug, Clone)] -pub struct Torrent(InfoHash, ActorRef); +pub struct Torrent { + info_hash: InfoHash, + actor: ActorRef, + frontend: FrontendPublisher, +} 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) + Self::new_with_frontend(info_hash, actor_ref, FrontendPublisher::default()) + } + + pub(crate) fn new_with_frontend( + info_hash: InfoHash, actor: ActorRef, frontend: FrontendPublisher, + ) -> Self { + Self { + info_hash, + actor, + frontend, + } } pub(crate) fn actor(&self) -> &ActorRef { - &self.1 + &self.actor } /// Returns the [`InfoHash`] that uniquely identifies this torrent. pub fn info_hash(&self) -> InfoHash { - self.0 + self.info_hash } /// Alias for [`Self::info_hash`]. @@ -139,4 +157,18 @@ impl Torrent { Ok(()) } + + /// Subscribes to live events for this torrent only. + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + self.frontend.subscribe_torrent(self.info_hash) + } + + /// Returns the latest display-oriented state maintained for this torrent. + /// + /// This returns `None` after the torrent has been removed from its engine. + #[must_use] + pub fn live_view(&self) -> Option { + self.frontend.torrent_view(self.info_hash) + } } From 818387ce35f211b386b81d8f06cad48f5c40daf0 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:41:10 -0700 Subject: [PATCH 09/77] feat: publish torrent removal events --- crates/libtortillas/src/engine/actor.rs | 1 + crates/libtortillas/src/engine/mod.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/libtortillas/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index c2279d0f..c0870497 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -270,6 +270,7 @@ impl Actor for EngineActor { } torrent.wait_for_shutdown().await; self.torrents.remove(&info_hash); + self.frontend.torrent_removed(info_hash); } if let Some(dht) = self.dht.take() { diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 99646bb8..47e761b5 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -318,6 +318,7 @@ impl Engine { .await .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; torrent.wait_for_shutdown().await; + self.frontend.torrent_removed(info_hash); Ok(()) } From f710206cb6b96aa75f631cf75100708055c7d0aa Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:41:44 -0700 Subject: [PATCH 10/77] feat: publish torrent state transitions --- crates/libtortillas/src/frontend/publisher.rs | 13 ++++++++- crates/libtortillas/src/torrent/actor.rs | 28 +++++++++++++------ crates/libtortillas/src/torrent/messages.rs | 4 +-- crates/libtortillas/src/torrent/piece_flow.rs | 2 +- 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 5386c369..a12b3253 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -6,7 +6,7 @@ use std::sync::{ use tokio::sync::broadcast; use super::{CoreEvent, CoreEventKind, EngineView, EventSubscription, TorrentView}; -use crate::{engine::EngineStatus, hashes::InfoHash}; +use crate::{engine::EngineStatus, hashes::InfoHash, torrent::TorrentState}; /// Number of discrete frontend events retained for each listener. pub const DEFAULT_EVENT_CAPACITY: usize = 256; @@ -88,6 +88,17 @@ impl FrontendPublisher { self.replace_torrent(torrent); } + pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { + let info_hash = torrent.info_hash; + let current = torrent.state; + self.replace_torrent(torrent); + self.publish(CoreEventKind::TorrentStateChanged { + torrent: info_hash, + previous, + current, + }); + } + pub(crate) fn torrent_removed(&self, torrent: InfoHash) { let mut view = self.write_view(); view diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 14031919..2a7b5de6 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -193,7 +193,7 @@ impl TorrentActor { trace!("Autostarting torrent"); self.start().await; } else { - self.state = TorrentState::Ready; + self.transition_state(TorrentState::Ready); self.send_ready_hooks(); } } @@ -210,14 +210,14 @@ impl TorrentActor { self.send_ready_hooks(); let Some(info) = self.info.clone() else { - self.state = TorrentState::ResolvingMetadata; + 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); error!(?err, "Failed to pre-start piece manager; aborting start"); return; } @@ -225,10 +225,10 @@ impl TorrentActor { self.sync_tracker_announce_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()); }; @@ -267,7 +267,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() { @@ -545,6 +545,18 @@ impl TorrentActor { } } + pub(super) fn transition_state(&mut self, state: TorrentState) { + let previous = self.state; + if previous == state { + return; + } + + self.state = state; + self + .frontend + .torrent_state_changed(previous, self.live_view()); + } + fn snapshot_u64(value: usize) -> u64 { u64::try_from(value).unwrap_or(u64::MAX) } @@ -782,7 +794,7 @@ impl Actor for TorrentActor { async fn on_stop( &mut self, _: WeakActorRef, reason: ActorStopReason, ) -> Result<(), Self::Error> { - self.state = TorrentState::Stopping; + self.transition_state(TorrentState::Stopping); info!(reason = %reason, "Torrent stopped"); for peer in self.peers.values() { peer.kill(); @@ -795,7 +807,7 @@ impl Actor for TorrentActor { } self.piece_store.kill(); self.scheduler.kill(); - self.state = TorrentState::Stopped; + self.transition_state(TorrentState::Stopped); Ok(()) } diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index 08a4dfec..b5c6ac29 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -106,7 +106,7 @@ pub(crate) mod events { self.bitfield = BitVec::repeat(false, info.piece_count()); self.info = Some(info); if self.state == TorrentState::ResolvingMetadata { - self.state = TorrentState::Added; + self.transition_state(TorrentState::Added); } self .broadcast_to_peers(HaveInfoDict { @@ -218,7 +218,7 @@ pub(crate) mod commands { match state { TorrentState::Downloading | TorrentState::Seeding => self.start().await, TorrentState::Paused => self.stop_transfer().await, - state => self.state = state, + state => self.transition_state(state), } } diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index 85f8d423..4d7d3d9a 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -242,7 +242,7 @@ impl TorrentActor { self.sync_tracker_announce_progress().await; if self.piece_scheduler.next_piece() >= piece_count { - self.state = TorrentState::Seeding; + self.transition_state(TorrentState::Seeding); self.announce_tracker_event(Event::Completed).await; info!("Torrenting process completed, switching to seeding mode"); self.rechoke_peers().await; From 07504a1e10320d6c0ed67c814b1e38b5ddb48e62 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:42:00 -0700 Subject: [PATCH 11/77] feat: publish metadata resolution events --- crates/libtortillas/src/frontend/publisher.rs | 5 +++++ crates/libtortillas/src/torrent/messages.rs | 1 + 2 files changed, 6 insertions(+) diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index a12b3253..df58ea0f 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -88,6 +88,11 @@ impl FrontendPublisher { self.replace_torrent(torrent); } + pub(crate) fn metadata_resolved(&self, torrent: TorrentView) { + self.replace_torrent(torrent.clone()); + self.publish(CoreEventKind::MetadataResolved(torrent)); + } + pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { let info_hash = torrent.info_hash; let current = torrent.state; diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index b5c6ac29..a33c832c 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -108,6 +108,7 @@ pub(crate) mod events { if self.state == TorrentState::ResolvingMetadata { self.transition_state(TorrentState::Added); } + self.frontend.metadata_resolved(self.live_view()); self .broadcast_to_peers(HaveInfoDict { bitfield: Arc::new(self.bitfield.clone()), From cd9a5756bda1663447ee45272e186bab724acafd Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:42:15 -0700 Subject: [PATCH 12/77] feat: publish live progress events --- crates/libtortillas/src/frontend/publisher.rs | 10 ++++++++++ crates/libtortillas/src/torrent/piece_flow.rs | 2 ++ 2 files changed, 12 insertions(+) diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index df58ea0f..4cc85914 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -93,6 +93,16 @@ impl FrontendPublisher { self.publish(CoreEventKind::MetadataResolved(torrent)); } + pub(crate) fn progress_changed(&self, torrent: TorrentView) { + let info_hash = torrent.info_hash; + let progress = torrent.progress.clone(); + self.replace_torrent(torrent); + self.publish(CoreEventKind::ProgressChanged { + torrent: info_hash, + progress, + }); + } + pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { let info_hash = torrent.info_hash; let current = torrent.state; diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index 4d7d3d9a..ac35fb04 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -122,6 +122,8 @@ impl TorrentActor { self.request_blocks_from_peer(peer_id, 1).await; trace!(%peer_id, "Requested replacement block from peer"); } + + self.frontend.progress_changed(self.live_view()); } pub(super) async fn request_blocks_from_peer( From efcc681b0fe19feb2321ae940418ebaf3a44b83f Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:42:48 -0700 Subject: [PATCH 13/77] feat: publish peer lifecycle events --- crates/libtortillas/src/frontend/publisher.rs | 20 ++++++++++- crates/libtortillas/src/torrent/messages.rs | 9 +++++ crates/libtortillas/src/torrent/swarm.rs | 36 ++++++++++++++----- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 4cc85914..88cf797d 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -5,7 +5,7 @@ use std::sync::{ use tokio::sync::broadcast; -use super::{CoreEvent, CoreEventKind, EngineView, EventSubscription, TorrentView}; +use super::{CoreEvent, CoreEventKind, EngineView, EventSubscription, PeerView, TorrentView}; use crate::{engine::EngineStatus, hashes::InfoHash, torrent::TorrentState}; /// Number of discrete frontend events retained for each listener. @@ -103,6 +103,24 @@ impl FrontendPublisher { }); } + pub(crate) fn peer_connected(&self, torrent: TorrentView, peer: PeerView) { + let info_hash = torrent.info_hash; + self.replace_torrent(torrent); + self.publish(CoreEventKind::PeerConnected { + torrent: info_hash, + peer, + }); + } + + pub(crate) fn peer_disconnected(&self, torrent: TorrentView, peer: PeerView) { + let info_hash = torrent.info_hash; + self.replace_torrent(torrent); + self.publish(CoreEventKind::PeerDisconnected { + torrent: info_hash, + peer, + }); + } + pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { let info_hash = torrent.info_hash; let current = torrent.state; diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index a33c832c..05c6c578 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -16,6 +16,7 @@ use super::{ util, }; use crate::{ + frontend::PeerView, hashes::InfoHash, metainfo::Info, peer::{Peer, PeerId, commands::HaveInfoDict}, @@ -154,6 +155,14 @@ pub(crate) mod commands { if let Some(actor) = self.peers.get(&id) { actor.kill(); self.peers.remove(&id); + self.frontend.peer_disconnected( + self.live_view(), + PeerView { + address: None, + client: Some(id.client_name().to_string()), + connected: false, + }, + ); } else { warn!("Received kill peer message for unknown peer"); } diff --git a/crates/libtortillas/src/torrent/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index c57e3979..271e9b2f 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -10,6 +10,7 @@ use tracing::{debug, instrument, trace, warn}; use super::TorrentActor; use crate::{ + frontend::PeerView, peer::{Peer, PeerActor, PeerId}, protocol::{ messages::{Handshake, PeerMessages}, @@ -104,16 +105,25 @@ 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; + let peer_view = PeerView { + address: Some(peer.socket_addr()), + client: Some(id.client_name().to_string()), + connected: true, + }; - 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), - }, - ) - }); + if self.peers.contains_key(&id) { + return; + } + + let peer_actor = PeerActor::spawn_with_mailbox( + (peer, stream, actor_ref, info_hash, peer_settings), + match peer_mailbox_size { + 0 => mailbox::unbounded(), + size => mailbox::bounded(size), + }, + ); + self.peers.insert(id, peer_actor); + self.frontend.peer_connected(self.live_view(), peer_view); } #[instrument(skip(self, tell), fields(torrent_id = %self.info_hash(), msg = ?tell))] @@ -154,6 +164,14 @@ impl TorrentActor { } for id in dead_peers { self.peers.remove(&id); + self.frontend.peer_disconnected( + self.live_view(), + PeerView { + address: None, + client: Some(id.client_name().to_string()), + connected: false, + }, + ); } } From dc02096482ed672a1dd2a010c80661995098b3fc Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:43:26 -0700 Subject: [PATCH 14/77] feat: publish tracker announce events --- crates/libtortillas/src/frontend/publisher.rs | 22 +++++++++++++++- crates/libtortillas/src/torrent/messages.rs | 25 ++++++++++++++++++- crates/libtortillas/src/tracker/actor.rs | 13 +++++++++- crates/libtortillas/src/tracker/model.rs | 23 +++++++++++++++++ 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 88cf797d..c80125eb 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -5,7 +5,9 @@ use std::sync::{ use tokio::sync::broadcast; -use super::{CoreEvent, CoreEventKind, EngineView, EventSubscription, PeerView, TorrentView}; +use super::{ + CoreEvent, CoreEventKind, EngineView, EventSubscription, PeerView, TorrentView, TrackerView, +}; use crate::{engine::EngineStatus, hashes::InfoHash, torrent::TorrentState}; /// Number of discrete frontend events retained for each listener. @@ -121,6 +123,24 @@ impl FrontendPublisher { }); } + pub(crate) fn tracker_announce_succeeded(&self, torrent: TorrentView, tracker: TrackerView) { + let info_hash = torrent.info_hash; + self.replace_torrent(torrent); + self.publish(CoreEventKind::TrackerAnnounceSucceeded { + torrent: info_hash, + tracker, + }); + } + + pub(crate) fn tracker_announce_failed(&self, torrent: TorrentView, tracker: TrackerView) { + let info_hash = torrent.info_hash; + self.replace_torrent(torrent); + self.publish(CoreEventKind::TrackerAnnounceFailed { + torrent: info_hash, + tracker, + }); + } + pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { let info_hash = torrent.info_hash; let current = torrent.state; diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index 05c6c578..c765bebd 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -16,7 +16,7 @@ use super::{ util, }; use crate::{ - frontend::PeerView, + frontend::{PeerView, TrackerView}, hashes::InfoHash, metainfo::Info, peer::{Peer, PeerId, commands::HaveInfoDict}, @@ -35,11 +35,34 @@ pub(crate) mod events { #[instrument(skip(self, peers, from), fields(torrent_id = %self.info_hash(), announce_from = from.kind()))] pub(crate) fn announce(&mut self, peers: Vec, from: AnnounceFrom) { trace!(peer_count = peers.len(), "Received announce message"); + if let AnnounceFrom::Tracker(tracker) = &from { + self.frontend.tracker_announce_succeeded( + self.live_view(), + TrackerView { + endpoint: tracker.frontend_endpoint(), + healthy: true, + peers_returned: Some(u64::try_from(peers.len()).unwrap_or(u64::MAX)), + }, + ); + } for peer in peers { self.append_peer(peer, None); } } + /// Reports a failed tracker announce to live frontend listeners. + #[message(derive(Debug))] + pub(crate) fn tracker_announce_failed(&mut self, tracker: Tracker) { + self.frontend.tracker_announce_failed( + self.live_view(), + TrackerView { + endpoint: tracker.frontend_endpoint(), + healthy: false, + peers_returned: None, + }, + ); + } + /// Sent after an incoming peer initializes a handshake. /// The handshake will be preverified and routed to this torrent instance. /// diff --git a/crates/libtortillas/src/tracker/actor.rs b/crates/libtortillas/src/tracker/actor.rs index 52a38a72..83830620 100644 --- a/crates/libtortillas/src/tracker/actor.rs +++ b/crates/libtortillas/src/tracker/actor.rs @@ -196,7 +196,18 @@ 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"); + if let Err(send_error) = self + .supervisor + .tell(torrent::events::TrackerAnnounceFailed { + tracker: self.source.clone(), + }) + .await + { + error!(error = %send_error, "Failed to report tracker announce failure"); + } + } } self.schedule_next_announce().await; None diff --git a/crates/libtortillas/src/tracker/model.rs b/crates/libtortillas/src/tracker/model.rs index 0c1945c7..0639b023 100644 --- a/crates/libtortillas/src/tracker/model.rs +++ b/crates/libtortillas/src/tracker/model.rs @@ -117,6 +117,29 @@ impl Tracker { Tracker::Http(uri) | Tracker::Udp(uri) | Tracker::Websocket(uri) => uri.clone(), } } + + /// Returns a credential-free endpoint label for frontend events. + pub(crate) fn frontend_endpoint(&self) -> String { + let uri = self.uri(); + let Ok(mut url) = reqwest::Url::parse(&uri) else { + return self.scheme().to_string(); + }; + + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_path(""); + url.set_query(None); + url.set_fragment(None); + url.to_string() + } + + fn scheme(&self) -> &'static str { + match self { + Self::Http(_) => "http", + Self::Udp(_) => "udp", + Self::Websocket(_) => "websocket", + } + } } /// Trait for HTTP and UDP trackers. From 23a33200502a06958a817a4452d981f46e7ffdcb Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:43:50 -0700 Subject: [PATCH 15/77] feat: publish frontend health events --- crates/libtortillas/src/engine/actor.rs | 17 ++++++++++++++++- crates/libtortillas/src/frontend/publisher.rs | 13 ++++++++++++- crates/libtortillas/src/torrent/actor.rs | 14 +++++++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/libtortillas/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index c0870497..8d02987c 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -17,7 +17,7 @@ use super::commands; use crate::{ dht::{DhtActor, DhtActorArgs}, errors::EngineError, - frontend::FrontendPublisher, + frontend::{FrontendHealthLevel, FrontendPublisher}, hashes::InfoHash, peer::PeerId, protocol::stream::PeerStream, @@ -196,6 +196,11 @@ impl Actor for EngineActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); + self.frontend.health( + None, + FrontendHealthLevel::Error, + "an engine service stopped unexpectedly", + ); Ok(ControlFlow::Continue(())) } @@ -225,6 +230,11 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); + self.frontend.health( + None, + FrontendHealthLevel::Warning, + "the TCP peer listener rejected an incoming connection", + ); None } }, @@ -248,6 +258,11 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); + self.frontend.health( + None, + FrontendHealthLevel::Warning, + "the uTP peer listener rejected an incoming connection", + ); None } }, diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index c80125eb..65a9d2ad 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -6,7 +6,8 @@ use std::sync::{ use tokio::sync::broadcast; use super::{ - CoreEvent, CoreEventKind, EngineView, EventSubscription, PeerView, TorrentView, TrackerView, + CoreEvent, CoreEventKind, EngineView, EventSubscription, FrontendHealth, FrontendHealthLevel, + PeerView, TorrentView, TrackerView, }; use crate::{engine::EngineStatus, hashes::InfoHash, torrent::TorrentState}; @@ -141,6 +142,16 @@ impl FrontendPublisher { }); } + pub(crate) fn health( + &self, torrent: Option, level: FrontendHealthLevel, message: impl Into, + ) { + self.publish(CoreEventKind::Health(FrontendHealth { + torrent, + level, + message: message.into(), + })); + } + pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { let info_hash = torrent.info_hash; let current = torrent.state; diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 2a7b5de6..c35a67aa 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -26,7 +26,9 @@ use tracing::{debug, error, info, instrument, trace, warn}; use super::{choking::ChokingScheduler, util}; use crate::{ errors::TorrentError, - frontend::{FrontendPublisher, TorrentProgress, TorrentTransfer, TorrentView}, + frontend::{ + FrontendHealthLevel, FrontendPublisher, TorrentProgress, TorrentTransfer, TorrentView, + }, hashes::InfoHash, metainfo::{Info, MetaInfo}, peer::{PeerActor, PeerId, commands::SetChoked}, @@ -218,6 +220,11 @@ impl TorrentActor { // Pre-start the piece manager before transitioning state if let Err(err) = self.piece_manager.pre_start(info.clone()).await { self.transition_state(TorrentState::Failed); + self.frontend.health( + Some(self.info_hash()), + FrontendHealthLevel::Error, + "torrent storage could not be initialized", + ); error!(?err, "Failed to pre-start piece manager; aborting start"); return; } @@ -817,6 +824,11 @@ impl Actor for TorrentActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); + self.frontend.health( + Some(self.info_hash()), + FrontendHealthLevel::Error, + "a torrent service stopped unexpectedly", + ); Ok(ControlFlow::Continue(())) } From 44e6b945c637c473dedd17bc95703c3e4296748b Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:44:12 -0700 Subject: [PATCH 16/77] feat: publish live torrent updates --- crates/libtortillas/src/frontend/event.rs | 8 +++++--- crates/libtortillas/src/frontend/publisher.rs | 3 ++- crates/libtortillas/src/torrent/messages.rs | 6 ++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index a6f62f60..dc99a294 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -40,6 +40,8 @@ pub enum CoreEventKind { previous: TorrentState, current: TorrentState, }, + /// Display-oriented torrent configuration or counts changed. + TorrentUpdated(TorrentView), /// Metadata for a magnet torrent was resolved. MetadataResolved(TorrentView), /// Download progress changed. @@ -73,9 +75,9 @@ impl CoreEventKind { pub const fn torrent(&self) -> Option { match self { Self::EngineStarted(_) | Self::Shutdown(_) => None, - Self::TorrentAdded(snapshot) | Self::MetadataResolved(snapshot) => { - Some(snapshot.info_hash) - } + Self::TorrentAdded(snapshot) + | Self::TorrentUpdated(snapshot) + | Self::MetadataResolved(snapshot) => Some(snapshot.info_hash), Self::TorrentRemoved { torrent } | Self::TorrentStateChanged { torrent, .. } | Self::ProgressChanged { torrent, .. } diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 65a9d2ad..7115a10e 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -88,7 +88,8 @@ impl FrontendPublisher { } pub(crate) fn update_torrent(&self, torrent: TorrentView) { - self.replace_torrent(torrent); + self.replace_torrent(torrent.clone()); + self.publish(CoreEventKind::TorrentUpdated(torrent)); } pub(crate) fn metadata_resolved(&self, torrent: TorrentView) { diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index c765bebd..aba91610 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -197,6 +197,7 @@ pub(crate) mod commands { if let Some(actor) = self.trackers.get(&tracker) { actor.kill(); self.trackers.remove(&tracker); + self.frontend.update_torrent(self.live_view()); } else { warn!("Received kill tracker message for unknown tracker"); } @@ -212,6 +213,7 @@ pub(crate) mod commands { util::create_dir(dir).await.unwrap(); // Intended panic } self.piece_storage = strategy; + self.frontend.update_torrent(self.live_view()); } /// Sets the current piece manager to a custom implementation. @@ -230,6 +232,7 @@ pub(crate) mod commands { { warn!(?err, "Failed to pre-start custom piece manager"); } + self.frontend.update_torrent(self.live_view()); } /// Sets the output path, should only be used when the `FilePieceManager` @@ -242,6 +245,7 @@ pub(crate) mod commands { warn!(path = ?path, "Cannot set output path when using a custom piece manager; ignoring.") } } + self.frontend.update_torrent(self.live_view()); } /// Start the torrenting process & actually start downloading @@ -261,6 +265,7 @@ pub(crate) mod commands { if !self.pending_start { self.autostart().await; } + self.frontend.update_torrent(self.live_view()); } #[message] @@ -269,6 +274,7 @@ pub(crate) mod commands { if !self.pending_start { self.autostart().await; } + self.frontend.update_torrent(self.live_view()); } #[message(derive(Debug, Clone, Copy))] From 1886d7db6cdd0f8cc4ad219240415eedbd4d8008 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:45:16 -0700 Subject: [PATCH 17/77] feat: add stateful frontend listeners --- crates/libtortillas/src/engine/mod.rs | 9 ++- crates/libtortillas/src/facade.rs | 6 +- crates/libtortillas/src/frontend/listener.rs | 64 ++++++++++++++++++++ crates/libtortillas/src/frontend/mod.rs | 2 + crates/libtortillas/src/torrent/handle.rs | 8 ++- 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 crates/libtortillas/src/frontend/listener.rs diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 47e761b5..51628d3f 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -61,7 +61,7 @@ use self::commands::{CreateTorrent, RemoveTorrent, SnapshotEngine, StartAll}; pub use self::snapshot::{EngineSnapshot, EngineStatus}; use crate::{ errors::EngineError, - frontend::{EngineView, EventSubscription, FrontendPublisher}, + frontend::{EngineListener, EngineView, EventSubscription, FrontendPublisher}, hashes::InfoHash, peer::PeerId, settings::Settings, @@ -359,6 +359,13 @@ impl Engine { self.frontend.subscribe() } + /// Creates a live listener with typed events and coherent current display + /// state. + #[must_use] + pub fn listener(&self) -> EngineListener { + EngineListener::new(self.frontend.clone()) + } + /// Returns the current display-oriented engine state maintained by the live /// event publisher. #[must_use] diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index de801877..9a9fc60d 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -23,9 +23,9 @@ use crate::{engine::Engine, hashes::InfoHash, torrent::Torrent}; pub use crate::{ engine::{EngineSnapshot, EngineStatus, TorrentSource}, frontend::{ - CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, EngineView, EventStreamError, - EventSubscription, FrontendHealth, FrontendHealthLevel, PeerView, TorrentProgress, - TorrentTransfer, TorrentView, TrackerView, + CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, EngineListener, EngineView, + EventStreamError, EventSubscription, FrontendHealth, FrontendHealthLevel, PeerView, + TorrentListener, TorrentProgress, TorrentTransfer, TorrentView, TrackerView, }, torrent::{TorrentProgressSnapshot, TorrentSnapshot, TorrentTransferSnapshot}, }; diff --git a/crates/libtortillas/src/frontend/listener.rs b/crates/libtortillas/src/frontend/listener.rs new file mode 100644 index 00000000..4a3e96c2 --- /dev/null +++ b/crates/libtortillas/src/frontend/listener.rs @@ -0,0 +1,64 @@ +use super::{ + CoreEvent, EngineView, EventStreamError, EventSubscription, FrontendPublisher, TorrentView, +}; +use crate::hashes::InfoHash; + +/// Live engine listener with typed events and current display state. +/// +/// [`Self::recv`] waits for discrete changes. [`Self::view`] reads the latest +/// coherent live state directly from the engine publisher, including after a +/// lag report. +#[derive(Debug)] +pub struct EngineListener { + events: EventSubscription, + frontend: FrontendPublisher, +} + +impl EngineListener { + pub(crate) fn new(frontend: FrontendPublisher) -> Self { + Self { + events: frontend.subscribe(), + frontend, + } + } + + /// Waits for the next live engine or torrent event. + pub async fn recv(&mut self) -> Result { + self.events.recv().await + } + + /// Returns the latest coherent engine view without persistence snapshots. + #[must_use] + pub fn view(&self) -> EngineView { + self.frontend.view() + } +} + +/// Live listener scoped to one torrent. +#[derive(Debug)] +pub struct TorrentListener { + torrent: InfoHash, + events: EventSubscription, + frontend: FrontendPublisher, +} + +impl TorrentListener { + pub(crate) fn new(frontend: FrontendPublisher, torrent: InfoHash) -> Self { + Self { + torrent, + events: frontend.subscribe_torrent(torrent), + frontend, + } + } + + /// Waits for the next live event associated with this torrent. + pub async fn recv(&mut self) -> Result { + self.events.recv().await + } + + /// Returns the latest torrent view, or `None` after removal. + #[must_use] + pub fn view(&self) -> Option { + self.frontend.torrent_view(self.torrent) + } +} diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index fd16749c..7c91329d 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -5,11 +5,13 @@ //! prefer these types over actor messages and protocol internals. mod event; +mod listener; mod publisher; mod subscription; mod view; pub use event::{CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel}; +pub use listener::{EngineListener, TorrentListener}; pub use publisher::DEFAULT_EVENT_CAPACITY; pub(crate) use publisher::FrontendPublisher; pub use subscription::{EventStreamError, EventSubscription}; diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 31ed143f..491093af 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -13,7 +13,7 @@ use super::{ }, }; use crate::{ - frontend::{EventSubscription, FrontendPublisher, TorrentView}, + frontend::{EventSubscription, FrontendPublisher, TorrentListener, TorrentView}, hashes::InfoHash, pieces::PieceManager, }; @@ -164,6 +164,12 @@ impl Torrent { self.frontend.subscribe_torrent(self.info_hash) } + /// Creates a live listener scoped to this torrent. + #[must_use] + pub fn listener(&self) -> TorrentListener { + TorrentListener::new(self.frontend.clone(), self.info_hash) + } + /// Returns the latest display-oriented state maintained for this torrent. /// /// This returns `None` after the torrent has been removed from its engine. From e3825da87bd464f6ec77dcc495ce7ff09edf00a9 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:45:36 -0700 Subject: [PATCH 18/77] feat: resolve torrents through engine handles --- crates/libtortillas/src/engine/messages.rs | 12 ++++++++++++ crates/libtortillas/src/engine/mod.rs | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 757c7e0b..ed6f658e 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -85,6 +85,18 @@ pub(crate) mod commands { } } + /// 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. #[message] pub(crate) async fn remove_torrent( diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 51628d3f..e5bd74f5 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -57,7 +57,7 @@ use kameo::{ pub(crate) use messages::*; pub use source::TorrentSource; -use self::commands::{CreateTorrent, RemoveTorrent, SnapshotEngine, StartAll}; +use self::commands::{CreateTorrent, GetTorrent, RemoveTorrent, SnapshotEngine, StartAll}; pub use self::snapshot::{EngineSnapshot, EngineStatus}; use crate::{ errors::EngineError, @@ -305,6 +305,21 @@ impl Engine { Ok(()) } + /// Returns a public handle for a torrent managed by this engine. + pub async fn torrent(&self, info_hash: InfoHash) -> Result { + let actor = match self.actor().ask(GetTorrent { info_hash }).await { + Ok(actor) => actor, + Err(SendError::HandlerError(err)) => return Err(err), + Err(err) => return Err(EngineError::Other(anyhow::anyhow!(err.to_string()))), + }; + + Ok(Torrent::new_with_frontend( + info_hash, + actor, + self.frontend.clone(), + )) + } + /// 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 { From eaad2afaf4b4214577b39354a2c68938fcd20432 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:46:33 -0700 Subject: [PATCH 19/77] feat: define frontend command messages --- crates/libtortillas/src/facade.rs | 42 +++----------------- crates/libtortillas/src/frontend/command.rs | 43 +++++++++++++++++++++ crates/libtortillas/src/frontend/mod.rs | 2 + 3 files changed, 50 insertions(+), 37 deletions(-) create mode 100644 crates/libtortillas/src/frontend/command.rs diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index 9a9fc60d..6cb1b9c9 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -17,15 +17,14 @@ //! }; //! ``` -use std::path::PathBuf; - -use crate::{engine::Engine, hashes::InfoHash, torrent::Torrent}; +use crate::{engine::Engine, torrent::Torrent}; pub use crate::{ engine::{EngineSnapshot, EngineStatus, TorrentSource}, frontend::{ - CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, EngineListener, EngineView, - EventStreamError, EventSubscription, FrontendHealth, FrontendHealthLevel, PeerView, - TorrentListener, TorrentProgress, TorrentTransfer, TorrentView, TrackerView, + CoreCommand, CoreCommandResult, CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, + EngineListener, EngineView, EventStreamError, EventSubscription, FrontendHealth, + FrontendHealthLevel, PeerView, TorrentCommand, TorrentListener, TorrentProgress, + TorrentTransfer, TorrentView, TrackerView, }, torrent::{TorrentProgressSnapshot, TorrentSnapshot, TorrentTransferSnapshot}, }; @@ -43,34 +42,3 @@ pub type EngineHandle = Engine; /// 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 }, -} diff --git a/crates/libtortillas/src/frontend/command.rs b/crates/libtortillas/src/frontend/command.rs new file mode 100644 index 00000000..78de6af8 --- /dev/null +++ b/crates/libtortillas/src/frontend/command.rs @@ -0,0 +1,43 @@ +use std::path::PathBuf; + +use crate::{engine::TorrentSource, hashes::InfoHash, torrent::Torrent}; + +/// Typed message accepted by [`Engine::send`](crate::engine::Engine::send). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum CoreCommand { + AddTorrent { source: TorrentSource }, + StartAll, + StartTorrent { torrent: InfoHash }, + ResumeTorrent { torrent: InfoHash }, + PauseTorrent { torrent: InfoHash }, + StopTorrent { torrent: InfoHash }, + RemoveTorrent { torrent: InfoHash }, + Shutdown, + SetTorrentOutputPath { torrent: InfoHash, path: PathBuf }, + SetAutostart { torrent: InfoHash, enabled: bool }, + SetSufficientPeers { torrent: InfoHash, peers: usize }, +} + +/// Typed message accepted by [`Torrent::send`](crate::torrent::Torrent::send). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum TorrentCommand { + Start, + Resume, + Pause, + Stop, + SetOutputPath(PathBuf), + SetAutostart(bool), + SetSufficientPeers(usize), +} + +/// Result of sending a [`CoreCommand`] to an engine. +#[derive(Debug, Clone)] +#[must_use] +pub enum CoreCommandResult { + /// The command was applied and has no new handle to return. + Applied, + /// An add command created this torrent handle. + TorrentAdded(Torrent), +} diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index 7c91329d..90546db1 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -4,12 +4,14 @@ //! snapshots intended for application and UI integrations. Frontends should //! prefer these types over actor messages and protocol internals. +mod command; mod event; mod listener; mod publisher; mod subscription; mod view; +pub use command::{CoreCommand, CoreCommandResult, TorrentCommand}; pub use event::{CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel}; pub use listener::{EngineListener, TorrentListener}; pub use publisher::DEFAULT_EVENT_CAPACITY; From 34985faaa955fd7ecfe8a36b9140ed12c80eabe8 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:47:18 -0700 Subject: [PATCH 20/77] refactor: return typed torrent handle errors --- crates/libtortillas/src/errors.rs | 4 ++ crates/libtortillas/src/torrent/handle.rs | 86 ++++++++++++++++------- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/crates/libtortillas/src/errors.rs b/crates/libtortillas/src/errors.rs index 188ad024..3fcf6c41 100644 --- a/crates/libtortillas/src/errors.rs +++ b/crates/libtortillas/src/errors.rs @@ -64,6 +64,10 @@ pub enum EngineError { #[error("Torrent not found: {0}")] TorrentNotFound(InfoHash), + /// A managed torrent command failed. + #[error(transparent)] + Torrent(#[from] TorrentError), + /// Any other engine-level error wrapped in [`anyhow::Error`] #[error(transparent)] Other(#[from] anyhow::Error), diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 491093af..41094cb8 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -1,6 +1,5 @@ use std::path::PathBuf; -use anyhow::Result; use kameo::actor::ActorRef; use tokio::sync::oneshot; use tracing::error; @@ -13,6 +12,7 @@ use super::{ }, }; use crate::{ + errors::TorrentError, frontend::{EventSubscription, FrontendPublisher, TorrentListener, TorrentView}, hashes::InfoHash, pieces::PieceManager, @@ -61,99 +61,128 @@ impl Torrent { self.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 { strategy: piece_storage, }) - .await?; + .await + .map_err(Self::communication_error)?; Ok(()) } - pub async fn with_output_folder(&self, folder: impl Into) -> Result<()> { + pub async fn with_output_folder(&self, folder: impl Into) -> Result<(), TorrentError> { self .actor() .ask(SetOutputPath { path: folder.into(), }) - .await?; + .await + .map_err(Self::communication_error)?; Ok(()) } pub async fn with_piece_manager<'a>( &'a self, piece_manager: impl PieceManager + 'a + 'static, - ) -> Result<()> { + ) -> Result<(), TorrentError> { self .actor() .tell(SetPieceManager { manager: Box::new(piece_manager), }) - .await?; + .await + .map_err(Self::communication_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<()> { + async fn set_state( + &self, state: TorrentState, operation: &'static str, + ) -> Result<(), TorrentError> { let msg = SetState { state }; self .actor() .ask(msg) .await - .inspect_err(|e| error!(error = %e, operation, "Failed to change torrent state"))?; + .inspect_err(|e| error!(error = %e, operation, "Failed to change torrent state")) + .map_err(Self::communication_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(Self::communication_error) } /// Returns a stable, frontend-ready snapshot of this torrent. - pub async fn export(&self) -> Result { + pub async fn export(&self) -> Result { self.snapshot().await } - pub async fn snapshot(&self) -> Result { - Ok(*self.actor().ask(SnapshotState).await?) + pub async fn snapshot(&self) -> Result { + self + .actor() + .ask(SnapshotState) + .await + .map(|snapshot| *snapshot) + .map_err(Self::communication_error) } - pub async fn set_auto_start(&self, auto: bool) -> Result<()> { + pub async fn set_auto_start(&self, auto: bool) -> Result<(), TorrentError> { let msg = SetAutoStart { auto }; - self.actor().tell(msg).await?; + self + .actor() + .tell(msg) + .await + .map_err(Self::communication_error)?; Ok(()) } - pub async fn set_sufficient_peers(&self, peers: usize) -> Result<()> { + pub async fn set_sufficient_peers(&self, peers: usize) -> Result<(), TorrentError> { let msg = SetSufficientPeers { peers }; - self.actor().tell(msg).await?; + self + .actor() + .tell(msg) + .await + .map_err(Self::communication_error)?; Ok(()) } - pub async fn poll_ready(&self) -> Result<()> { + pub async fn poll_ready(&self) -> Result<(), TorrentError> { let (hook, hook_rx) = oneshot::channel(); let msg = ReadyHook { hook }; - self.actor().tell(msg).await?; - hook_rx.await?; + self + .actor() + .tell(msg) + .await + .map_err(Self::communication_error)?; + hook_rx.await.map_err(Self::communication_error)?; Ok(()) } @@ -177,4 +206,11 @@ impl Torrent { pub fn live_view(&self) -> Option { self.frontend.torrent_view(self.info_hash) } + + fn communication_error(error: impl std::fmt::Display) -> TorrentError { + TorrentError::ActorCommunicationFailed { + actor_type: "torrent".to_string(), + reason: error.to_string(), + } + } } From fc6dc9d00a4485ba94edcc4f5602597f39229840 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:47:32 -0700 Subject: [PATCH 21/77] feat: route typed torrent commands --- crates/libtortillas/src/torrent/handle.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 41094cb8..3ec691bd 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -13,7 +13,7 @@ use super::{ }; use crate::{ errors::TorrentError, - frontend::{EventSubscription, FrontendPublisher, TorrentListener, TorrentView}, + frontend::{EventSubscription, FrontendPublisher, TorrentCommand, TorrentListener, TorrentView}, hashes::InfoHash, pieces::PieceManager, }; @@ -187,6 +187,19 @@ impl Torrent { Ok(()) } + /// Sends a typed frontend command directly to this torrent. + pub async fn send(&self, command: TorrentCommand) -> Result<(), TorrentError> { + match command { + TorrentCommand::Start => self.start().await, + TorrentCommand::Resume => self.resume().await, + TorrentCommand::Pause => self.pause().await, + TorrentCommand::Stop => self.stop().await, + TorrentCommand::SetOutputPath(path) => self.with_output_folder(path).await, + TorrentCommand::SetAutostart(enabled) => self.set_auto_start(enabled).await, + TorrentCommand::SetSufficientPeers(peers) => self.set_sufficient_peers(peers).await, + } + } + /// Subscribes to live events for this torrent only. #[must_use] pub fn subscribe(&self) -> EventSubscription { From 32143044d793f657afb950c0a85835002a4675ed Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:47:56 -0700 Subject: [PATCH 22/77] feat: route typed engine commands --- crates/libtortillas/src/engine/mod.rs | 83 ++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index e5bd74f5..a5fe3bac 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -61,7 +61,10 @@ use self::commands::{CreateTorrent, GetTorrent, RemoveTorrent, SnapshotEngine, S pub use self::snapshot::{EngineSnapshot, EngineStatus}; use crate::{ errors::EngineError, - frontend::{EngineListener, EngineView, EventSubscription, FrontendPublisher}, + frontend::{ + CoreCommand, CoreCommandResult, EngineListener, EngineView, EventSubscription, + FrontendPublisher, TorrentCommand, + }, hashes::InfoHash, peer::PeerId, settings::Settings, @@ -364,6 +367,84 @@ impl Engine { .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string()))) } + /// Sends a typed frontend command to the engine or one of its torrents. + pub async fn send(&self, command: CoreCommand) -> Result { + match command { + CoreCommand::AddTorrent { source } => self + .add_torrent(source) + .await + .map(CoreCommandResult::TorrentAdded), + CoreCommand::StartAll => { + self.start_all().await?; + Ok(CoreCommandResult::Applied) + } + CoreCommand::StartTorrent { torrent } => { + self + .torrent(torrent) + .await? + .send(TorrentCommand::Start) + .await?; + Ok(CoreCommandResult::Applied) + } + CoreCommand::ResumeTorrent { torrent } => { + self + .torrent(torrent) + .await? + .send(TorrentCommand::Resume) + .await?; + Ok(CoreCommandResult::Applied) + } + CoreCommand::PauseTorrent { torrent } => { + self + .torrent(torrent) + .await? + .send(TorrentCommand::Pause) + .await?; + Ok(CoreCommandResult::Applied) + } + CoreCommand::StopTorrent { torrent } => { + self + .torrent(torrent) + .await? + .send(TorrentCommand::Stop) + .await?; + Ok(CoreCommandResult::Applied) + } + CoreCommand::RemoveTorrent { torrent } => { + self.remove_torrent(torrent).await?; + Ok(CoreCommandResult::Applied) + } + CoreCommand::Shutdown => { + self.shutdown().await?; + Ok(CoreCommandResult::Applied) + } + CoreCommand::SetTorrentOutputPath { torrent, path } => { + self + .torrent(torrent) + .await? + .send(TorrentCommand::SetOutputPath(path)) + .await?; + Ok(CoreCommandResult::Applied) + } + CoreCommand::SetAutostart { torrent, enabled } => { + self + .torrent(torrent) + .await? + .send(TorrentCommand::SetAutostart(enabled)) + .await?; + Ok(CoreCommandResult::Applied) + } + CoreCommand::SetSufficientPeers { torrent, peers } => { + self + .torrent(torrent) + .await? + .send(TorrentCommand::SetSufficientPeers(peers)) + .await?; + Ok(CoreCommandResult::Applied) + } + } + } + /// Subscribes to typed engine and torrent events as they happen. /// /// The returned stream is bounded. A lagging frontend can read From 623edd74e551a5ca2fe898d7f5b945c9d12c1e27 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:48:10 -0700 Subject: [PATCH 23/77] refactor: isolate the test torrent constructor --- crates/libtortillas/src/torrent/handle.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 3ec691bd..5d908e5e 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -33,6 +33,7 @@ pub struct Torrent { impl Torrent { /// Creates a new [`Torrent`] handle from an [`InfoHash`] and a reference /// to its underlying [`TorrentActor`]. + #[cfg(test)] pub(crate) fn new(info_hash: InfoHash, actor_ref: ActorRef) -> Self { Self::new_with_frontend(info_hash, actor_ref, FrontendPublisher::default()) } From 1e4bf3ebed5c8b2495d74c54e28f1abaaabfc6d3 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:49:18 -0700 Subject: [PATCH 24/77] test: cover live frontend lifecycle --- crates/libtortillas/tests/live_frontend.rs | 124 +++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 crates/libtortillas/tests/live_frontend.rs diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs new file mode 100644 index 00000000..618a9662 --- /dev/null +++ b/crates/libtortillas/tests/live_frontend.rs @@ -0,0 +1,124 @@ +use std::time::Duration; + +use libtortillas::{ + engine::EngineStatus, + errors::EngineError, + frontend::{CoreCommand, CoreCommandResult, CoreEventKind, TorrentCommand}, + prelude::{Engine, Settings, TorrentSource, TorrentState}, +}; +use tokio::time::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 result = engine + .send(CoreCommand::AddTorrent { + source: TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY), + }) + .await + .unwrap(); + let CoreCommandResult::TorrentAdded(torrent) = result else { + panic!("add command should return a torrent handle"); + }; + + let added = timeout(Duration::from_secs(2), async { + loop { + let event = engine_listener.recv().await.unwrap(); + if matches!(event.kind, CoreEventKind::TorrentAdded(_)) { + break event; + } + } + }) + .await + .unwrap(); + assert_eq!(added.torrent(), Some(torrent.info_hash())); + assert_eq!(engine_listener.view().torrent_count, 1); + + let mut torrent_listener = torrent.listener(); + torrent.send(TorrentCommand::Pause).await.unwrap(); + let paused = timeout(Duration::from_secs(2), async { + loop { + let event = torrent_listener.recv().await.unwrap(); + if matches!( + event.kind, + CoreEventKind::TorrentStateChanged { + current: TorrentState::Paused, + .. + } + ) { + break event; + } + } + }) + .await + .unwrap(); + assert!(matches!( + paused.kind, + CoreEventKind::TorrentStateChanged { + current: TorrentState::Paused, + .. + } + )); + assert_eq!(torrent_listener.view().unwrap().state, TorrentState::Paused); + + let _ = engine + .send(CoreCommand::RemoveTorrent { + torrent: torrent.info_hash(), + }) + .await + .unwrap(); + assert!(torrent_listener.view().is_none()); + assert_eq!(engine_listener.view().torrent_count, 0); + + engine.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn engine_listener_receives_graceful_shutdown() { + let engine = deterministic_engine(); + let mut listener = engine.listener(); + + let _ = engine.send(CoreCommand::Shutdown).await.unwrap(); + let shutdown = timeout(Duration::from_secs(2), async { + loop { + let event = listener.recv().await.unwrap(); + if matches!(event.kind, CoreEventKind::Shutdown(_)) { + break event; + } + } + }) + .await + .unwrap(); + + let CoreEventKind::Shutdown(view) = shutdown.kind else { + unreachable!(); + }; + assert_eq!(view.status, EngineStatus::Stopped); + assert_eq!(listener.view().status, EngineStatus::Stopped); +} + +#[tokio::test] +async fn engine_commands_return_typed_unknown_torrent_errors() { + let engine = deterministic_engine(); + let unknown = libtortillas::hashes::InfoHash::from_bytes([42; 20]); + + let error = engine + .send(CoreCommand::PauseTorrent { torrent: unknown }) + .await + .unwrap_err(); + + assert!(matches!(error, EngineError::TorrentNotFound(torrent) if torrent == unknown)); + engine.shutdown().await.unwrap(); +} From d719a8504a274a3e3f7fef2084b099bfde3d3fa4 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:50:03 -0700 Subject: [PATCH 25/77] test: cover listener lag recovery --- crates/libtortillas/tests/live_frontend.rs | 78 +++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index 618a9662..233adc50 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -3,10 +3,10 @@ use std::time::Duration; use libtortillas::{ engine::EngineStatus, errors::EngineError, - frontend::{CoreCommand, CoreCommandResult, CoreEventKind, TorrentCommand}, + frontend::{CoreCommand, CoreCommandResult, CoreEventKind, EventStreamError, TorrentCommand}, prelude::{Engine, Settings, TorrentSource, TorrentState}, }; -use tokio::time::timeout; +use tokio::time::{sleep, timeout}; const BIG_BUCK_BUNNY: &[u8] = include_bytes!("torrents/big-buck-bunny.torrent"); @@ -122,3 +122,77 @@ async fn engine_commands_return_typed_unknown_torrent_errors() { assert!(matches!(error, EngineError::TorrentNotFound(torrent) if torrent == unknown)); engine.shutdown().await.unwrap(); } + +#[tokio::test] +async fn lagging_listener_recovers_from_current_live_view() { + let engine = deterministic_engine(); + let result = engine + .send(CoreCommand::AddTorrent { + source: TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY), + }) + .await + .unwrap(); + let CoreCommandResult::TorrentAdded(torrent) = result else { + panic!("add command should return a torrent handle"); + }; + let mut listener = torrent.listener(); + + for peers in 1..=300 { + torrent + .send(TorrentCommand::SetSufficientPeers(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_and_events_are_serde_compatible() { + let engine = deterministic_engine(); + let mut listener = engine.listener(); + let _ = engine + .send(CoreCommand::AddTorrent { + source: TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY), + }) + .await + .unwrap(); + let added = timeout(Duration::from_secs(2), async { + loop { + let event = listener.recv().await.unwrap(); + if matches!(event.kind, CoreEventKind::TorrentAdded(_)) { + break event; + } + } + }) + .await + .unwrap(); + + let encoded_event = serde_json::to_string(&added).unwrap(); + let decoded_event = serde_json::from_str(&encoded_event).unwrap(); + assert_eq!(added, decoded_event); + + 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(); +} From da90187d8782928820923c40bbe20a7414676fa0 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:50:32 -0700 Subject: [PATCH 26/77] test: protect tracker event credentials --- crates/libtortillas/src/tracker/model.rs | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/libtortillas/src/tracker/model.rs b/crates/libtortillas/src/tracker/model.rs index 0639b023..170ffd88 100644 --- a/crates/libtortillas/src/tracker/model.rs +++ b/crates/libtortillas/src/tracker/model.rs @@ -280,3 +280,30 @@ fn tracker_from_uri(uri: String) -> Result { _ => Err(format!("unsupported tracker scheme: {scheme}")), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frontend_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.frontend_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 invalid_tracker_endpoint_falls_back_to_protocol_only() { + let tracker = Tracker::Udp("udp://[invalid".to_string()); + + assert_eq!(tracker.frontend_endpoint(), "udp"); + } +} From fba6d0630e87ed7249b5c789a08815956deb2020 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:51:14 -0700 Subject: [PATCH 27/77] docs: add live frontend example --- crates/libtortillas/examples/live_frontend.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 crates/libtortillas/examples/live_frontend.rs diff --git a/crates/libtortillas/examples/live_frontend.rs b/crates/libtortillas/examples/live_frontend.rs new file mode 100644 index 00000000..812cb95a --- /dev/null +++ b/crates/libtortillas/examples/live_frontend.rs @@ -0,0 +1,71 @@ +use std::{io, path::PathBuf}; + +use libtortillas::prelude::{ + CoreCommand, CoreCommandResult, CoreEventKind, Engine, TorrentCommand, TorrentSource, + TorrentState, +}; +use tracing::{error, info}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let Some(torrent_path) = std::env::args_os().nth(1).map(PathBuf::from) else { + error!("pass a .torrent file path to run the live frontend example"); + return Ok(()); + }; + + let engine = Engine::default(); + let mut listener = engine.listener(); + let frontend = 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, + "frontend received a live engine event" + ); + if matches!(event.kind, CoreEventKind::Shutdown(_)) { + break; + } + } + Err(error) => { + error!(%error, "frontend listener stopped"); + break; + } + } + } + }); + + let result = engine + .send(CoreCommand::AddTorrent { + source: TorrentSource::torrent_file_path(torrent_path), + }) + .await?; + let CoreCommandResult::TorrentAdded(torrent) = result else { + return Err(io::Error::other("add command did not return a torrent handle").into()); + }; + + let mut torrent_listener = torrent.listener(); + torrent.send(TorrentCommand::Pause).await?; + let paused = loop { + let event = torrent_listener.recv().await?; + if matches!( + event.kind, + CoreEventKind::TorrentStateChanged { + current: TorrentState::Paused, + .. + } + ) { + break event; + } + }; + info!(sequence = paused.sequence, ?paused.kind, "torrent paused"); + torrent.send(TorrentCommand::Resume).await?; + + tokio::signal::ctrl_c().await?; + let _ = engine.send(CoreCommand::Shutdown).await?; + frontend.await?; + Ok(()) +} From 5cdc465702a9641091f1a0a220f4693653d8e744 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:53:07 -0700 Subject: [PATCH 28/77] docs: explain live frontend integration --- README.md | 5 +++ crates/libtortillas/src/frontend/mod.rs | 6 +-- crates/libtortillas/src/lib.rs | 10 ++--- docs/frontend-integration.md | 60 +++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 docs/frontend-integration.md diff --git a/README.md b/README.md index acd17c9b..6b668a72 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ 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. +Frontends should use the live listeners and typed command API rather than +polling persistence snapshots. See the +[frontend integration guide](docs/frontend-integration.md) and the +[`live_frontend` example](crates/libtortillas/examples/live_frontend.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/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index 90546db1..c8581e86 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -1,8 +1,8 @@ //! Live, frontend-facing API contracts. //! -//! This module contains the typed events, commands, subscriptions, and -//! snapshots intended for application and UI integrations. Frontends should -//! prefer these types over actor messages and protocol internals. +//! This module contains the typed events, commands, listeners, and live views +//! intended for application and UI integrations. Frontends should prefer these +//! types over actor messages, protocol internals, or snapshot polling. mod command; mod event; diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index 16a18520..1e1bdabd 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -20,8 +20,8 @@ //! 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. +//! [`facade::CoreCommand`], [`facade::CoreEvent`], and live engine, torrent, +//! peer, and tracker views. //! //! ```no_run //! use libtortillas::prelude::{CoreCommand, EngineHandle, TorrentSource}; @@ -40,9 +40,9 @@ //! depending on actor messages, raw peer streams, tracker clients, or storage //! internals when an equivalent facade type exists. //! -//! 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. +//! Engine and torrent handles expose listeners for live UI updates. Persistence +//! snapshots are intentionally separate and should not be polled for display +//! changes. pub(crate) mod dht; pub mod engine; diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md new file mode 100644 index 00000000..1e253fcf --- /dev/null +++ b/docs/frontend-integration.md @@ -0,0 +1,60 @@ +# Frontend integration + +`libtortillas` exposes live frontend behavior directly on `Engine` and +`Torrent`. Applications do not need actor references, protocol messages, or a +polling loop. + +## Live listeners + +Call `Engine::listener()` before sending commands. The listener combines two +related capabilities: + +- `recv().await` yields sequenced `CoreEvent` values as changes happen. +- `view()` returns the latest display-oriented `EngineView` held by the live + publisher. + +Every `Torrent` returned by an add command similarly has `listener()` and +`subscribe()` methods. A torrent listener receives only events associated with +that torrent and exposes its latest `TorrentView`. + +The event channel retains 256 events per listener by default. Slow listeners +receive `EventStreamError::Lagged` instead of causing unbounded memory growth. +After lagging, redraw from `listener.view()` and continue calling `recv()`. +Sequence numbers remain engine-local and monotonic. + +Use `subscribe()` when only discrete events are needed. Use `listener()` when +the frontend also needs a coherent current view for initial rendering or lag +recovery. + +## Commands + +`Engine::send(CoreCommand)` is the application-level message boundary. It can +add, start, pause, resume, configure, remove, and stop torrents, or shut down +the engine. Add commands return a `CoreCommandResult::TorrentAdded` handle. + +`Torrent::send(TorrentCommand)` provides the same typed pattern when a +frontend already owns a torrent handle. Both handles retain their existing +explicit convenience methods. + +## Views and persistence snapshots + +`EngineView`, `TorrentView`, and `CoreEvent` are live presentation contracts. +They are updated by listeners and are suitable for rendering. + +`Engine::snapshot()` and `Torrent::snapshot()` are not the live frontend path. +Snapshots are the persistence boundary for serializing resumable engine and +torrent state in an application-selected Serde format. Frontends should never +poll snapshots to refresh the UI. + +## Runtime and shutdown + +The library is Tokio-based. Keep the engine, torrent handles, command tasks, +and listener tasks on the application runtime. Terminal or UI operations that +block should run separately from those async tasks. + +Send `CoreCommand::Shutdown` or call `Engine::shutdown()` and keep the engine +listener alive until it receives `CoreEventKind::Shutdown`. This ensures the +frontend observes the terminal state after managed torrents stop. + +See [`live_frontend.rs`](../crates/libtortillas/examples/live_frontend.rs) for a +compiling command/listener loop. From 406ec945c31600ac82a33465a3271a60709ef0de Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 22:57:47 -0700 Subject: [PATCH 29/77] refactor: reserve snapshots for persistence --- crates/libtortillas/src/engine/messages.rs | 3 +- crates/libtortillas/src/engine/mod.rs | 26 +++- crates/libtortillas/src/engine/snapshot.rs | 8 +- crates/libtortillas/src/facade.rs | 2 +- crates/libtortillas/src/torrent/actor.rs | 126 +++++++------------- crates/libtortillas/src/torrent/export.rs | 24 ---- crates/libtortillas/src/torrent/mod.rs | 5 +- crates/libtortillas/src/torrent/snapshot.rs | 55 ++++----- crates/libtortillas/tests/dht_network.rs | 20 ++-- 9 files changed, 103 insertions(+), 166 deletions(-) delete mode 100644 crates/libtortillas/src/torrent/export.rs diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index ed6f658e..00ff3502 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -3,7 +3,7 @@ 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, EngineStatus}; use crate::{ dht::messages::commands::{RegisterTorrent, UnregisterTorrent}, errors::EngineError, @@ -215,6 +215,7 @@ pub(crate) mod commands { let torrents = try_join_all(futures).await?; Ok(EngineSnapshot { + version: ENGINE_SNAPSHOT_VERSION, status: EngineStatus::Running, torrent_count: u64::try_from(torrents.len()).unwrap_or(u64::MAX), torrents, diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index a5fe3bac..e0785f2e 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -58,7 +58,7 @@ pub(crate) use messages::*; pub use source::TorrentSource; use self::commands::{CreateTorrent, GetTorrent, RemoveTorrent, SnapshotEngine, StartAll}; -pub use self::snapshot::{EngineSnapshot, EngineStatus}; +pub use self::snapshot::{ENGINE_SNAPSHOT_VERSION, EngineSnapshot, EngineStatus}; use crate::{ errors::EngineError, frontend::{ @@ -484,7 +484,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() @@ -500,17 +500,31 @@ mod snapshot_tests { let snapshot = engine.snapshot().await.unwrap(); assert_eq!(snapshot.status, EngineStatus::Running); + assert_eq!(snapshot.version, ENGINE_SNAPSHOT_VERSION); assert_eq!(snapshot.torrent_count, 1); 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].info_dict.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.status, from_snapshot.status); + assert_eq!(snapshot.torrent_count, from_snapshot.torrent_count); + assert_eq!( + snapshot.torrents[0].info_hash, + from_snapshot.torrents[0].info_hash + ); + assert_eq!( + snapshot.torrents[0].bitfield, + from_snapshot.torrents[0].bitfield + ); } } diff --git a/crates/libtortillas/src/engine/snapshot.rs b/crates/libtortillas/src/engine/snapshot.rs index dcffbdc9..1452e674 100644 --- a/crates/libtortillas/src/engine/snapshot.rs +++ b/crates/libtortillas/src/engine/snapshot.rs @@ -2,9 +2,13 @@ use serde::{Deserialize, Serialize}; use crate::torrent::TorrentSnapshot; -/// Stable, frontend-ready view of the engine. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// Current persistence schema version for [`EngineSnapshot`]. +pub const ENGINE_SNAPSHOT_VERSION: u32 = 1; + +/// Serializable state required to restore an engine's torrent sessions. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct EngineSnapshot { + pub version: u32, pub status: EngineStatus, pub torrent_count: u64, pub torrents: Vec, diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index 6cb1b9c9..e306a6c8 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -26,7 +26,7 @@ pub use crate::{ FrontendHealthLevel, PeerView, TorrentCommand, TorrentListener, TorrentProgress, TorrentTransfer, TorrentView, TrackerView, }, - torrent::{TorrentProgressSnapshot, TorrentSnapshot, TorrentTransferSnapshot}, + torrent::TorrentSnapshot, }; /// Stable handle used by frontends to manage the torrent engine. diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index c35a67aa..1a63de06 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -35,8 +35,8 @@ use crate::{ pieces::{FilePieceManager, PieceManager, PieceScheduler, PieceStoreActor}, settings::Settings, torrent::{ - BLOCK_SIZE, PieceStorageStrategy, TorrentExport, TorrentProgressSnapshot, TorrentSnapshot, - TorrentState, TorrentTransferSnapshot, + BLOCK_SIZE, PieceStorageStrategy, TORRENT_SNAPSHOT_VERSION, TorrentExport, TorrentSnapshot, + TorrentState, }, tracker::{ Announce, Event, Tracker, TrackerActor, TrackerActorArgs, TrackerUpdate, udp::UdpServer, @@ -423,6 +423,7 @@ impl TorrentActor { pub fn export(&self) -> TorrentExport { TorrentExport { + version: TORRENT_SNAPSHOT_VERSION, info_hash: self.info_hash(), state: self.state, auto_start: self.autostart, @@ -440,59 +441,7 @@ impl TorrentActor { } pub fn snapshot(&self) -> TorrentSnapshot { - 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 progress_fraction = total_bytes.map(|bytes| { - if bytes == 0 { - 1.0 - } else { - downloaded_bytes.min(bytes) as f64 / bytes as f64 - } - }); - let completed_pieces = self.bitfield.count_ones(); - let total_pieces = self.bitfield.len(); - let partial_pieces = self - .piece_scheduler - .block_map_export() - .iter() - .filter(|entry| { - let piece_idx = *entry.key(); - piece_idx < total_pieces && !self.bitfield[piece_idx] && entry.value().count_ones() > 0 - }) - .count(); - - TorrentSnapshot { - 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()), - tracker_count: Self::snapshot_u64(self.trackers.len()), - output_path: match &self.piece_manager { - 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, - }, - } + self.export() } /// Builds the display-oriented state used by live frontend listeners. @@ -1527,7 +1476,7 @@ mod tests { } #[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 { @@ -1548,6 +1497,7 @@ mod tests { let utp_server = UtpSocket::new_udp(testing::ephemeral_socket_addr()) .await .unwrap(); + let frontend = FrontendPublisher::default(); let actor_ref = TorrentActor::spawn(TorrentActorArgs { peer_id, metainfo: metainfo.clone(), @@ -1559,13 +1509,9 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), - frontend: FrontendPublisher::default(), + frontend: frontend.clone(), }); - let live_snapshot = Torrent::new(info_hash, actor_ref.clone()) - .snapshot() - .await - .unwrap(); - assert_eq!(live_snapshot.tracker_count, 1); + assert_eq!(frontend.torrent_view(info_hash).unwrap().tracker_count, 1); let piece_count = info_dict.piece_count(); let bitfield: BitVec = BitVec::repeat(false, piece_count); @@ -1614,46 +1560,56 @@ mod tests { settings: Settings::default(), }; - let snapshot = test_actor.snapshot(); + let view = test_actor.live_view(); - 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)); + 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, + view.progress.total_bytes, Some(u64::try_from(info_dict.total_length()).unwrap()) ); assert_eq!( - snapshot.progress.completed_pieces, + view.progress.completed_pieces, u64::try_from(completed_pieces).unwrap() ); - assert_eq!(snapshot.progress.partial_pieces, 1); + assert_eq!(view.progress.partial_pieces, 1); assert_eq!( - snapshot.progress.total_pieces, + view.progress.total_pieces, u64::try_from(piece_count).unwrap() ); - assert!(snapshot.progress.downloaded_bytes > 0); + assert!(view.progress.downloaded_bytes > 0); assert!( - snapshot.progress.bytes_remaining.unwrap() - < u64::try_from(info_dict.total_length()).unwrap() + view.progress.bytes_remaining.unwrap() < u64::try_from(info_dict.total_length()).unwrap() ); - 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!(view.progress.progress_fraction.unwrap() > 0.0); + assert_eq!(view.transfer.download_rate_bytes_per_second, None); + assert_eq!(view.transfer.upload_rate_bytes_per_second, None); + assert_eq!(view.transfer.eta_seconds, None); + let snapshot = test_actor.snapshot(); + 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.count_ones(), 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); @@ -1661,7 +1617,7 @@ 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, + test_actor.live_view().progress.downloaded_bytes, u64::try_from(last_piece_bytes).unwrap() ); 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/mod.rs b/crates/libtortillas/src/torrent/mod.rs index 3145c083..469bc228 100644 --- a/crates/libtortillas/src/torrent/mod.rs +++ b/crates/libtortillas/src/torrent/mod.rs @@ -3,7 +3,6 @@ mod block; mod choking; mod choking_flow; mod discovery; -mod export; mod handle; mod messages; mod piece_flow; @@ -15,10 +14,10 @@ mod swarm; pub(crate) use actor::{TorrentActor, TorrentActorArgs}; pub use block::{BLOCK_SIZE, BlockMap}; pub use discovery::AnnounceFrom; -pub(crate) use export::TorrentExport; +pub(crate) type TorrentExport = TorrentSnapshot; pub use handle::Torrent; pub(crate) use messages::*; -pub use snapshot::{TorrentProgressSnapshot, TorrentSnapshot, TorrentTransferSnapshot}; +pub use snapshot::{TORRENT_SNAPSHOT_VERSION, TorrentSnapshot}; pub use state::TorrentState; pub use storage::PieceStorageStrategy; diff --git a/crates/libtortillas/src/torrent/snapshot.rs b/crates/libtortillas/src/torrent/snapshot.rs index 6b044953..3ca4f403 100644 --- a/crates/libtortillas/src/torrent/snapshot.rs +++ b/crates/libtortillas/src/torrent/snapshot.rs @@ -1,43 +1,32 @@ -use std::path::PathBuf; +use std::{path::PathBuf, sync::atomic::AtomicU8}; +use bitvec::vec::BitVec; use serde::{Deserialize, Serialize}; -use super::TorrentState; -use crate::hashes::InfoHash; +use super::{BlockMap, PieceStorageStrategy, TorrentState}; +use crate::{ + hashes::InfoHash, + metainfo::{Info, MetaInfo}, +}; -/// 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 = 1; + +/// Serializable state required to restore a torrent session. +/// +/// Frontends choose the Serde format and storage location. Live UI rendering +/// should use [`Torrent::listener`](super::Torrent::listener), not this type. +#[derive(Debug, Clone, Serialize, Deserialize)] 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 sufficient_peers: usize, pub output_path: Option, - pub progress: TorrentProgressSnapshot, - pub transfer: TorrentTransferSnapshot, -} - -/// 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, -} - -/// 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, + pub metainfo: MetaInfo, + pub piece_storage: PieceStorageStrategy, + pub info_dict: Option, + pub bitfield: BitVec, + pub block_map: BlockMap, } diff --git a/crates/libtortillas/tests/dht_network.rs b/crates/libtortillas/tests/dht_network.rs index b948aba1..179a1ed0 100644 --- a/crates/libtortillas/tests/dht_network.rs +++ b/crates/libtortillas/tests/dht_network.rs @@ -6,10 +6,7 @@ use libtortillas::{ 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 +41,15 @@ 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.progress.downloaded_bytes > 0 { + return view; } - sleep(POLL_INTERVAL).await; + timeout(POLL_INTERVAL, listener.recv()).await.ok(); } }) .await; @@ -59,7 +57,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); } From abeebe846caa59426cfb113ea3f76fe75da7b5ad Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:00:50 -0700 Subject: [PATCH 30/77] feat: restore torrents from snapshots --- crates/libtortillas/src/engine/messages.rs | 63 ++++++++++++-- crates/libtortillas/src/engine/mod.rs | 51 ++++++++++++ crates/libtortillas/src/errors.rs | 4 + crates/libtortillas/src/torrent/messages.rs | 76 ++++++++++++++++- crates/libtortillas/tests/persistence.rs | 91 +++++++++++++++++++++ 5 files changed, 276 insertions(+), 9 deletions(-) create mode 100644 crates/libtortillas/tests/persistence.rs diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 00ff3502..6de7a200 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -11,7 +11,7 @@ use crate::{ metainfo::MetaInfo, peer::Peer, protocol::stream::{PeerStream, validate_handshake_protocol}, - torrent::{self, TorrentActor, TorrentActorArgs, TorrentState}, + torrent::{self, TorrentActor, TorrentActorArgs, TorrentSnapshot, TorrentState}, }; pub(crate) mod commands { @@ -118,7 +118,7 @@ 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, metainfo: Box, restore: Option>, ) -> Result, EngineError> { let info_hash = metainfo.info_hash().map_err(|e| { error!(error = %e, "Failed to unwrap info hash"); @@ -134,6 +134,15 @@ pub(crate) mod commands { return Err(EngineError::TorrentAlreadyExists(info_hash)); } + let restoring = restore.is_some(); + let piece_storage = restore.as_ref().map_or_else( + || self.default_piece_storage_strategy.clone(), + |snapshot| snapshot.piece_storage.clone(), + ); + let base_path = restore + .as_ref() + .and_then(|snapshot| snapshot.output_path.clone()) + .or_else(|| self.default_base_path.clone()); let torrent_ref = TorrentActor::supervise( &self.actor_ref, TorrentActorArgs { @@ -142,10 +151,10 @@ 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(), frontend: self.frontend.clone(), }, @@ -167,6 +176,37 @@ pub(crate) mod commands { }) .await; + let resume = if let Some(snapshot) = restore { + match torrent_ref + .ask(torrent::commands::RestoreSnapshot { + snapshot: *snapshot, + }) + .await + { + Ok(resume) => resume, + Err(kameo::error::SendError::HandlerError(error)) => { + if let Err(stop_error) = torrent_ref.stop_gracefully().await { + warn!(error = %stop_error, %info_hash, "Failed to stop rejected restored torrent"); + } + torrent_ref.wait_for_shutdown().await; + self.frontend.torrent_removed(info_hash); + return Err(error.into()); + } + Err(error) => { + if let Err(stop_error) = torrent_ref.stop_gracefully().await { + warn!(error = %stop_error, %info_hash, "Failed to stop rejected restored torrent"); + } + torrent_ref.wait_for_shutdown().await; + self.frontend.torrent_removed(info_hash); + return Err(EngineError::Other(anyhow!( + "failed to restore torrent snapshot: {error}" + ))); + } + } + } else { + false + }; + 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 @@ -189,6 +229,17 @@ pub(crate) mod commands { } } } + if resume + && let Err(error) = torrent_ref + .ask(torrent::commands::SetState { + state: TorrentState::Downloading, + }) + .await + { + return Err(EngineError::Other(anyhow!( + "failed to resume restored torrent: {error}" + ))); + } Ok(torrent_ref) } diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index e0785f2e..fc3c7593 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -285,6 +285,7 @@ impl Engine { .actor() .ask(CreateTorrent { metainfo: Box::new(metainfo), + restore: None, }) .await .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; @@ -297,6 +298,56 @@ impl Engine { // 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: crate::torrent::TorrentSnapshot, + ) -> Result { + if snapshot.version != crate::torrent::TORRENT_SNAPSHOT_VERSION { + return Err( + crate::errors::TorrentError::InvalidSnapshot { + reason: format!( + "unsupported version {}; expected {}", + snapshot.version, + crate::torrent::TORRENT_SNAPSHOT_VERSION + ), + } + .into(), + ); + } + let info_hash = snapshot.info_hash; + let metainfo_hash = snapshot.metainfo.info_hash()?; + if metainfo_hash != info_hash { + return Err( + crate::errors::TorrentError::InvalidSnapshot { + reason: "info hash does not match metainfo".to_string(), + } + .into(), + ); + } + + let torrent_ref = match self + .actor() + .ask(CreateTorrent { + metainfo: Box::new(snapshot.metainfo.clone()), + restore: Some(Box::new(snapshot)), + }) + .await + { + Ok(torrent) => torrent, + Err(SendError::HandlerError(error)) => return Err(error), + Err(error) => return Err(EngineError::Other(anyhow::anyhow!(error.to_string()))), + }; + + Ok(Torrent::new_with_frontend( + info_hash, + torrent_ref, + self.frontend.clone(), + )) + } /// Starts all torrents managed by the engine. /// See [`Torrent::start`] for more information. pub async fn start_all(&self) -> Result<(), EngineError> { diff --git a/crates/libtortillas/src/errors.rs b/crates/libtortillas/src/errors.rs index 3fcf6c41..bf9487ee 100644 --- a/crates/libtortillas/src/errors.rs +++ b/crates/libtortillas/src/errors.rs @@ -281,6 +281,10 @@ 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 }, + /// Bitfield operation failed #[error("Bitfield operation failed: {reason}")] BitfieldError { reason: String }, diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index aba91610..7fc60a7f 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -10,8 +10,8 @@ use sha1::{Digest, Sha1}; use tracing::{info, instrument, trace, warn}; use super::{ - AnnounceFrom, BLOCK_SIZE, PieceStorageStrategy, TorrentActor, TorrentExport, TorrentSnapshot, - TorrentState, + AnnounceFrom, BLOCK_SIZE, PieceStorageStrategy, TORRENT_SNAPSHOT_VERSION, TorrentActor, + TorrentExport, TorrentSnapshot, TorrentState, actor::{PieceManagerProxy, ReadyHookSender}, util, }; @@ -20,7 +20,7 @@ use crate::{ hashes::InfoHash, metainfo::Info, peer::{Peer, PeerId, commands::HaveInfoDict}, - pieces::PieceManager, + pieces::{PieceManager, PieceScheduler}, protocol::stream::PeerStream, tracker::Tracker, }; @@ -277,6 +277,76 @@ pub(crate) mod commands { self.frontend.update_torrent(self.live_view()); } + /// Restores persisted piece and lifecycle state before exposing a resumed + /// torrent to callers. + #[message] + pub(crate) fn restore_snapshot( + &mut self, snapshot: TorrentSnapshot, + ) -> Result { + if snapshot.version != TORRENT_SNAPSHOT_VERSION { + return Err(crate::errors::TorrentError::InvalidSnapshot { + reason: format!( + "unsupported version {}; expected {}", + snapshot.version, TORRENT_SNAPSHOT_VERSION + ), + }); + } + if snapshot.info_hash != self.info_hash() { + return Err(crate::errors::TorrentError::InvalidSnapshot { + reason: "info hash does not match metainfo".to_string(), + }); + } + + let piece_count = snapshot + .info_dict + .as_ref() + .or_else(|| self.info_dict()) + .map_or(0, Info::piece_count); + if snapshot.bitfield.len() != piece_count { + return Err(crate::errors::TorrentError::InvalidSnapshot { + reason: format!( + "bitfield has {} pieces but metadata declares {piece_count}", + snapshot.bitfield.len() + ), + }); + } + if snapshot + .block_map + .iter() + .any(|entry| *entry.key() >= piece_count) + { + return Err(crate::errors::TorrentError::InvalidSnapshot { + reason: "partial piece index is outside the metadata piece range".to_string(), + }); + } + + let resume = snapshot.state.is_transfer_active(); + let restored_state = match snapshot.state { + TorrentState::Downloading + | TorrentState::Seeding + | TorrentState::Stopping + | TorrentState::Stopped => TorrentState::Paused, + state => state, + }; + let mut scheduler = PieceScheduler::new(piece_count); + for index in snapshot.bitfield.iter_ones() { + scheduler.mark_piece_complete(index); + } + for entry in &snapshot.block_map { + scheduler.restore_piece_blocks(*entry.key(), entry.value().clone()); + } + + self.info = snapshot.info_dict; + self.bitfield = snapshot.bitfield; + self.piece_scheduler = scheduler; + self.autostart = snapshot.auto_start; + self.sufficient_peers = snapshot.sufficient_peers; + self.transition_state(restored_state); + self.frontend.update_torrent(self.live_view()); + + Ok(resume) + } + #[message(derive(Debug, Clone, Copy))] pub(crate) async fn rechoke(&mut self) { self.rechoke_peers().await; diff --git a/crates/libtortillas/tests/persistence.rs b/crates/libtortillas/tests/persistence.rs new file mode 100644 index 00000000..3d595868 --- /dev/null +++ b/crates/libtortillas/tests/persistence.rs @@ -0,0 +1,91 @@ +use libtortillas::{ + engine::Engine, + errors::{EngineError, TorrentError}, + prelude::{Settings, TorrentSource, TorrentState}, + torrent::TorrentSnapshot, +}; + +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 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.live_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.bitfield, snapshot.bitfield); + assert_eq!(round_trip.block_map.len(), snapshot.block_map.len()); + restored_engine.shutdown().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(); + 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); + 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(); +} From 0c39e495fc001b4689c22902179cf2b4eb293e6d Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:01:54 -0700 Subject: [PATCH 31/77] feat: restore engines from snapshots --- crates/libtortillas/src/engine/mod.rs | 59 +++++++++++++++++++++++- crates/libtortillas/src/errors.rs | 4 ++ crates/libtortillas/tests/persistence.rs | 49 ++++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index fc3c7593..246c7ec6 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -46,7 +46,7 @@ mod messages; mod snapshot; mod source; -use std::{net::SocketAddr, path::PathBuf}; +use std::{collections::HashSet, net::SocketAddr, path::PathBuf}; pub(crate) use actor::*; use bon; @@ -348,6 +348,63 @@ impl Engine { self.frontend.clone(), )) } + + /// 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> { + if snapshot.version != ENGINE_SNAPSHOT_VERSION { + return Err(EngineError::InvalidSnapshot { + reason: format!( + "unsupported version {}; expected {}", + snapshot.version, ENGINE_SNAPSHOT_VERSION + ), + }); + } + if snapshot.torrent_count != u64::try_from(snapshot.torrents.len()).unwrap_or(u64::MAX) { + return Err(EngineError::InvalidSnapshot { + reason: "torrent count does not match serialized torrent entries".to_string(), + }); + } + if self.live_view().torrent_count != 0 { + return Err(EngineError::InvalidSnapshot { + reason: "target engine already manages torrents".to_string(), + }); + } + let mut unique = HashSet::with_capacity(snapshot.torrents.len()); + if snapshot + .torrents + .iter() + .any(|torrent| !unique.insert(torrent.info_hash)) + { + return Err(EngineError::InvalidSnapshot { + reason: "snapshot contains duplicate torrent info hashes".to_string(), + }); + } + + let mut restored = Vec::with_capacity(snapshot.torrents.len()); + for torrent_snapshot in snapshot.torrents { + match self.restore_torrent(torrent_snapshot).await { + Ok(torrent) => restored.push(torrent), + Err(error) => { + for torrent in &restored { + if let Err(remove_error) = self.remove_torrent(torrent.info_hash()).await { + tracing::warn!( + error = %remove_error, + torrent = %torrent.info_hash(), + "Failed to roll back restored torrent" + ); + } + } + return Err(error); + } + } + } + + Ok(restored) + } /// Starts all torrents managed by the engine. /// See [`Torrent::start`] for more information. pub async fn start_all(&self) -> Result<(), EngineError> { diff --git a/crates/libtortillas/src/errors.rs b/crates/libtortillas/src/errors.rs index bf9487ee..efcfba6e 100644 --- a/crates/libtortillas/src/errors.rs +++ b/crates/libtortillas/src/errors.rs @@ -64,6 +64,10 @@ 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), diff --git a/crates/libtortillas/tests/persistence.rs b/crates/libtortillas/tests/persistence.rs index 3d595868..0004fd9f 100644 --- a/crates/libtortillas/tests/persistence.rs +++ b/crates/libtortillas/tests/persistence.rs @@ -6,6 +6,7 @@ use libtortillas::{ }; const BIG_BUCK_BUNNY: &[u8] = include_bytes!("torrents/big-buck-bunny.torrent"); +const WIRED_CD: &[u8] = include_bytes!("torrents/wired-cd.torrent"); fn deterministic_engine() -> Engine { let mut settings = Settings::default(); @@ -89,3 +90,51 @@ async fn torrent_snapshot_when_version_is_unknown_then_returns_typed_error() { )); 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 snapshot_bytes = serde_json::to_vec(&engine.snapshot().await.unwrap()).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.live_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.live_view().torrent_count, 0); + target_engine.shutdown().await.unwrap(); +} From 4009993077f5ab444fd4cd00ae1dc5a507bb8d98 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:03:25 -0700 Subject: [PATCH 32/77] docs: define snapshot persistence boundary --- crates/libtortillas/src/ARCHITECTURE.md | 17 ++++++++++++++++- crates/libtortillas/src/engine/messages.rs | 2 +- crates/libtortillas/src/engine/mod.rs | 8 ++++++-- crates/libtortillas/src/torrent/handle.rs | 6 +++++- docs/frontend-integration.md | 11 +++++++++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/libtortillas/src/ARCHITECTURE.md b/crates/libtortillas/src/ARCHITECTURE.md index daf5890c..34eb6251 100644 --- a/crates/libtortillas/src/ARCHITECTURE.md +++ b/crates/libtortillas/src/ARCHITECTURE.md @@ -22,6 +22,21 @@ 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. +## Frontend Boundary + +`Engine` and `Torrent` own the stable application boundary. Typed commands are +routed through their `send` methods, while `listener` combines a bounded event +subscription with current `EngineView` or `TorrentView` state. The shared +frontend publisher is independent from tracing and is propagated through the +engine, torrent, peer, and tracker actor hierarchy. + +Live views are intentionally distinct from `EngineSnapshot` and +`TorrentSnapshot`. Views are display-oriented and continuously updated by +events. Snapshots are versioned, Serde-compatible persistence records that +capture metadata, storage configuration, lifecycle intent, and piece progress +for later restoration. Frontends must not poll persistence snapshots to render +live state. + ## Runtime Boundary `libtortillas` is intentionally tied to Tokio. The crate uses Tokio for actor @@ -64,7 +79,7 @@ back to the closest DHT nodes. ## Torrent Lifecycle -`TorrentState` is the frontend-facing lifecycle contract exported in torrent snapshots. +`TorrentState` is the frontend-facing lifecycle contract carried by live views and persistence 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. diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 6de7a200..4cdbbbdb 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -243,7 +243,7 @@ pub(crate) mod commands { Ok(torrent_ref) } - /// Snapshots the current state of the engine for frontends. + /// Captures resumable state for every managed torrent. #[message] pub(crate) async fn snapshot_engine(&self) -> Result { let futures = self diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 246c7ec6..4ea7c903 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -461,12 +461,16 @@ impl Engine { Ok(()) } - /// Exports the current engine state with frontend-ready torrent snapshots. + /// Exports the current resumable engine state for application persistence. 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. + /// + /// Use [`Self::listener`] for live frontend state. Snapshot frequency is an + /// application persistence decision, not a UI refresh mechanism. pub async fn snapshot(&self) -> Result { self .actor() diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 5d908e5e..169db20c 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -141,11 +141,15 @@ impl Torrent { .map_err(Self::communication_error) } - /// Returns a stable, frontend-ready snapshot of this torrent. + /// Exports the current resumable torrent state for application persistence. 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. + /// + /// Use [`Self::listener`] for live frontend state. pub async fn snapshot(&self) -> Result { self .actor() diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 1e253fcf..52a4a0a2 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -46,6 +46,17 @@ Snapshots are the persistence boundary for serializing resumable engine and torrent state in an application-selected Serde format. Frontends should never poll snapshots to refresh the UI. +For example, an application can serialize `engine.snapshot().await?` with +`serde_json`, `postcard`, `rmp-serde`, or another format, then deserialize it +and call `engine.restore(snapshot).await?` in a later process. Use +`engine.restore_torrent(snapshot).await?` for one torrent. Snapshot schemas are +versioned so incompatible data returns a typed error. + +Snapshots retain metadata, lifecycle intent, storage paths and strategy, +verified pieces, and partial blocks. Downloaded bytes remain in the referenced +storage paths; the snapshot does not duplicate payload data into frontend +state. + ## Runtime and shutdown The library is Tokio-based. Keep the engine, torrent handles, command tasks, From ddb81222d2e5b0ac4f03626f654a05c8ee17e201 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:04:26 -0700 Subject: [PATCH 33/77] test: await live view initialization --- crates/libtortillas/src/torrent/actor.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 1a63de06..aba5cb91 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -1511,6 +1511,7 @@ mod tests { settings: Settings::default(), frontend: frontend.clone(), }); + actor_ref.ask(GetState).await.unwrap(); assert_eq!(frontend.torrent_view(info_hash).unwrap().tracker_count, 1); let piece_count = info_dict.piece_count(); From 157b43122ce6c2eddf20797ca6d848a83c5a6417 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:13:34 -0700 Subject: [PATCH 34/77] fix: ignore updates after torrent removal --- crates/libtortillas/src/frontend/publisher.rs | 91 ++++++++++++------- 1 file changed, 56 insertions(+), 35 deletions(-) diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 7115a10e..3cd58f22 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -88,59 +88,66 @@ impl FrontendPublisher { } pub(crate) fn update_torrent(&self, torrent: TorrentView) { - self.replace_torrent(torrent.clone()); - self.publish(CoreEventKind::TorrentUpdated(torrent)); + if self.update_torrent_entry(torrent.clone()) { + self.publish(CoreEventKind::TorrentUpdated(torrent)); + } } pub(crate) fn metadata_resolved(&self, torrent: TorrentView) { - self.replace_torrent(torrent.clone()); - self.publish(CoreEventKind::MetadataResolved(torrent)); + if self.update_torrent_entry(torrent.clone()) { + self.publish(CoreEventKind::MetadataResolved(torrent)); + } } pub(crate) fn progress_changed(&self, torrent: TorrentView) { let info_hash = torrent.info_hash; let progress = torrent.progress.clone(); - self.replace_torrent(torrent); - self.publish(CoreEventKind::ProgressChanged { - torrent: info_hash, - progress, - }); + if self.update_torrent_entry(torrent) { + self.publish(CoreEventKind::ProgressChanged { + torrent: info_hash, + progress, + }); + } } pub(crate) fn peer_connected(&self, torrent: TorrentView, peer: PeerView) { let info_hash = torrent.info_hash; - self.replace_torrent(torrent); - self.publish(CoreEventKind::PeerConnected { - torrent: info_hash, - peer, - }); + if self.update_torrent_entry(torrent) { + self.publish(CoreEventKind::PeerConnected { + torrent: info_hash, + peer, + }); + } } pub(crate) fn peer_disconnected(&self, torrent: TorrentView, peer: PeerView) { let info_hash = torrent.info_hash; - self.replace_torrent(torrent); - self.publish(CoreEventKind::PeerDisconnected { - torrent: info_hash, - peer, - }); + if self.update_torrent_entry(torrent) { + self.publish(CoreEventKind::PeerDisconnected { + torrent: info_hash, + peer, + }); + } } pub(crate) fn tracker_announce_succeeded(&self, torrent: TorrentView, tracker: TrackerView) { let info_hash = torrent.info_hash; - self.replace_torrent(torrent); - self.publish(CoreEventKind::TrackerAnnounceSucceeded { - torrent: info_hash, - tracker, - }); + if self.update_torrent_entry(torrent) { + self.publish(CoreEventKind::TrackerAnnounceSucceeded { + torrent: info_hash, + tracker, + }); + } } pub(crate) fn tracker_announce_failed(&self, torrent: TorrentView, tracker: TrackerView) { let info_hash = torrent.info_hash; - self.replace_torrent(torrent); - self.publish(CoreEventKind::TrackerAnnounceFailed { - torrent: info_hash, - tracker, - }); + if self.update_torrent_entry(torrent) { + self.publish(CoreEventKind::TrackerAnnounceFailed { + torrent: info_hash, + tracker, + }); + } } pub(crate) fn health( @@ -156,12 +163,13 @@ impl FrontendPublisher { pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { let info_hash = torrent.info_hash; let current = torrent.state; - self.replace_torrent(torrent); - self.publish(CoreEventKind::TorrentStateChanged { - torrent: info_hash, - previous, - current, - }); + if self.update_torrent_entry(torrent) { + self.publish(CoreEventKind::TorrentStateChanged { + torrent: info_hash, + previous, + current, + }); + } } pub(crate) fn torrent_removed(&self, torrent: InfoHash) { @@ -201,6 +209,19 @@ impl FrontendPublisher { view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); } + fn update_torrent_entry(&self, torrent: TorrentView) -> bool { + let mut view = self.write_view(); + let Some(current) = view + .torrents + .iter_mut() + .find(|candidate| candidate.info_hash == torrent.info_hash) + else { + return false; + }; + *current = torrent; + true + } + fn read_view(&self) -> RwLockReadGuard<'_, EngineView> { self .inner From e9f98aabcdfbfad56b560ac05d8f0eec9d8b93a3 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:13:34 -0700 Subject: [PATCH 35/77] fix: reject inconsistent torrent snapshots --- crates/libtortillas/src/engine/messages.rs | 18 +- crates/libtortillas/src/torrent/messages.rs | 176 +++++++++++++------- crates/libtortillas/tests/persistence.rs | 22 +++ 3 files changed, 147 insertions(+), 69 deletions(-) diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 4cdbbbdb..dd176d2e 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -183,20 +183,20 @@ pub(crate) mod commands { }) .await { - Ok(resume) => resume, - Err(kameo::error::SendError::HandlerError(error)) => { - if let Err(stop_error) = torrent_ref.stop_gracefully().await { - warn!(error = %stop_error, %info_hash, "Failed to stop rejected restored torrent"); + Ok(result) => match result.0 { + Ok(resume) => resume, + Err(error) => { + if let Err(stop_error) = torrent_ref.stop_gracefully().await { + warn!(error = %stop_error, %info_hash, "Failed to stop rejected restored torrent"); + } + self.frontend.torrent_removed(info_hash); + return Err(error.into()); } - torrent_ref.wait_for_shutdown().await; - self.frontend.torrent_removed(info_hash); - return Err(error.into()); - } + }, Err(error) => { if let Err(stop_error) = torrent_ref.stop_gracefully().await { warn!(error = %stop_error, %info_hash, "Failed to stop rejected restored torrent"); } - torrent_ref.wait_for_shutdown().await; self.frontend.torrent_removed(info_hash); return Err(EngineError::Other(anyhow!( "failed to restore torrent snapshot: {error}" diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index 7fc60a7f..e7204ebb 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -5,7 +5,7 @@ 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}; @@ -16,6 +16,7 @@ use super::{ util, }; use crate::{ + errors::TorrentError, frontend::{PeerView, TrackerView}, hashes::InfoHash, metainfo::Info, @@ -25,6 +26,9 @@ use crate::{ tracker::Tracker, }; +#[derive(Debug, Reply)] +pub(crate) struct SnapshotRestoreResult(pub(crate) Result); + pub(crate) mod events { use super::*; @@ -282,69 +286,121 @@ pub(crate) mod commands { #[message] pub(crate) fn restore_snapshot( &mut self, snapshot: TorrentSnapshot, - ) -> Result { - if snapshot.version != TORRENT_SNAPSHOT_VERSION { - return Err(crate::errors::TorrentError::InvalidSnapshot { - reason: format!( - "unsupported version {}; expected {}", - snapshot.version, TORRENT_SNAPSHOT_VERSION - ), - }); - } - if snapshot.info_hash != self.info_hash() { - return Err(crate::errors::TorrentError::InvalidSnapshot { - reason: "info hash does not match metainfo".to_string(), - }); - } + ) -> SnapshotRestoreResult { + let result = (|| -> Result { + if snapshot.version != TORRENT_SNAPSHOT_VERSION { + return Err(TorrentError::InvalidSnapshot { + reason: format!( + "unsupported version {}; expected {}", + snapshot.version, TORRENT_SNAPSHOT_VERSION + ), + }); + } + if snapshot.info_hash != self.info_hash() { + return Err(TorrentError::InvalidSnapshot { + reason: "info hash does not match metainfo".to_string(), + }); + } - let piece_count = snapshot - .info_dict - .as_ref() - .or_else(|| self.info_dict()) - .map_or(0, Info::piece_count); - if snapshot.bitfield.len() != piece_count { - return Err(crate::errors::TorrentError::InvalidSnapshot { - reason: format!( - "bitfield has {} pieces but metadata declares {piece_count}", - snapshot.bitfield.len() - ), - }); - } - if snapshot - .block_map - .iter() - .any(|entry| *entry.key() >= piece_count) - { - return Err(crate::errors::TorrentError::InvalidSnapshot { - reason: "partial piece index is outside the metadata piece range".to_string(), - }); - } + if let Some(info) = &snapshot.info_dict { + let restored_hash = info.hash().map_err(|error| TorrentError::InvalidSnapshot { + reason: format!("failed to hash restored info dictionary: {error}"), + })?; + if restored_hash != snapshot.info_hash { + return Err(TorrentError::InvalidSnapshot { + reason: "restored info dictionary does not match the info hash".to_string(), + }); + } + } - let resume = snapshot.state.is_transfer_active(); - let restored_state = match snapshot.state { - TorrentState::Downloading - | TorrentState::Seeding - | TorrentState::Stopping - | TorrentState::Stopped => TorrentState::Paused, - state => state, - }; - let mut scheduler = PieceScheduler::new(piece_count); - for index in snapshot.bitfield.iter_ones() { - scheduler.mark_piece_complete(index); - } - for entry in &snapshot.block_map { - scheduler.restore_piece_blocks(*entry.key(), entry.value().clone()); - } + let info = snapshot.info_dict.as_ref().or_else(|| self.info_dict()); + let piece_count = info.map_or(0, Info::piece_count); + if snapshot.bitfield.len() != piece_count { + return Err(TorrentError::InvalidSnapshot { + reason: format!( + "bitfield has {} pieces but metadata declares {piece_count}", + snapshot.bitfield.len() + ), + }); + } + for entry in &snapshot.block_map { + let index = *entry.key(); + if index >= piece_count { + return Err(TorrentError::InvalidSnapshot { + reason: "partial piece index is outside the metadata piece range".to_string(), + }); + } + if snapshot.bitfield[index] { + return Err(TorrentError::InvalidSnapshot { + reason: "completed piece also contains partial block state".to_string(), + }); + } - self.info = snapshot.info_dict; - self.bitfield = snapshot.bitfield; - self.piece_scheduler = scheduler; - self.autostart = snapshot.auto_start; - self.sufficient_peers = snapshot.sufficient_peers; - self.transition_state(restored_state); - self.frontend.update_torrent(self.live_view()); + let Some(info) = info else { + return Err(TorrentError::InvalidSnapshot { + reason: "partial block state requires resolved metadata".to_string(), + }); + }; + let piece_length = usize::try_from(info.piece_length).map_err(|_| { + TorrentError::InvalidSnapshot { + reason: "piece length cannot be represented on this platform".to_string(), + } + })?; + if piece_length == 0 { + return Err(TorrentError::InvalidSnapshot { + reason: "piece length must be greater than zero".to_string(), + }); + } + let last_piece = piece_count.saturating_sub(1); + let concrete_length = if index == last_piece { + let remainder = info.total_length() % piece_length; + if remainder == 0 { + piece_length + } else { + remainder + } + } else { + piece_length + }; + let expected_blocks = concrete_length.div_ceil(BLOCK_SIZE); + if entry.value().len() != expected_blocks { + return Err(TorrentError::InvalidSnapshot { + reason: format!( + "partial piece {index} has {} blocks; expected {expected_blocks}", + entry.value().len() + ), + }); + } + } + + let resume = snapshot.state.is_transfer_active(); + let restored_state = match snapshot.state { + TorrentState::Downloading + | TorrentState::Seeding + | TorrentState::Stopping + | TorrentState::Stopped => TorrentState::Paused, + state => state, + }; + let mut scheduler = PieceScheduler::new(piece_count); + for index in snapshot.bitfield.iter_ones() { + scheduler.mark_piece_complete(index); + } + for entry in &snapshot.block_map { + scheduler.restore_piece_blocks(*entry.key(), entry.value().clone()); + } + + self.info = snapshot.info_dict; + self.bitfield = snapshot.bitfield; + self.piece_scheduler = scheduler; + self.autostart = snapshot.auto_start; + self.sufficient_peers = snapshot.sufficient_peers; + self.transition_state(restored_state); + self.frontend.update_torrent(self.live_view()); + + Ok(resume) + })(); - Ok(resume) + SnapshotRestoreResult(result) } #[message(derive(Debug, Clone, Copy))] diff --git a/crates/libtortillas/tests/persistence.rs b/crates/libtortillas/tests/persistence.rs index 0004fd9f..7bf17d4b 100644 --- a/crates/libtortillas/tests/persistence.rs +++ b/crates/libtortillas/tests/persistence.rs @@ -138,3 +138,25 @@ async fn engine_snapshot_when_version_is_unknown_then_restores_nothing() { assert_eq!(target_engine.live_view().torrent_count, 0); 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.live_view().torrent_count, 0); + target_engine.shutdown().await.unwrap(); +} From 55981de5eed890beadeb9908342419d846d517d6 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:18:36 -0700 Subject: [PATCH 36/77] docs: clarify live listener recovery --- crates/libtortillas/examples/live_frontend.rs | 20 +++++++++++++------ crates/libtortillas/src/frontend/event.rs | 6 +++--- .../libtortillas/src/frontend/subscription.rs | 2 +- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/crates/libtortillas/examples/live_frontend.rs b/crates/libtortillas/examples/live_frontend.rs index 812cb95a..d7736cdd 100644 --- a/crates/libtortillas/examples/live_frontend.rs +++ b/crates/libtortillas/examples/live_frontend.rs @@ -1,10 +1,10 @@ use std::{io, path::PathBuf}; use libtortillas::prelude::{ - CoreCommand, CoreCommandResult, CoreEventKind, Engine, TorrentCommand, TorrentSource, - TorrentState, + CoreCommand, CoreCommandResult, CoreEventKind, Engine, EventStreamError, TorrentCommand, + TorrentSource, TorrentState, }; -use tracing::{error, info}; +use tracing::{error, info, warn}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -30,8 +30,16 @@ async fn main() -> Result<(), Box> { break; } } - Err(error) => { - error!(%error, "frontend listener stopped"); + Err(EventStreamError::Lagged(events)) => { + let view = listener.view(); + warn!( + events, + torrent_count = view.torrent_count, + "redrawing live state after lag" + ); + } + Err(EventStreamError::Closed) => { + info!("frontend event stream closed"); break; } } @@ -62,7 +70,7 @@ async fn main() -> Result<(), Box> { } }; info!(sequence = paused.sequence, ?paused.kind, "torrent paused"); - torrent.send(TorrentCommand::Resume).await?; + torrent.send(TorrentCommand::Start).await?; tokio::signal::ctrl_c().await?; let _ = engine.send(CoreCommand::Shutdown).await?; diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index dc99a294..ca2d6652 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -75,9 +75,9 @@ impl CoreEventKind { pub const fn torrent(&self) -> Option { match self { Self::EngineStarted(_) | Self::Shutdown(_) => None, - Self::TorrentAdded(snapshot) - | Self::TorrentUpdated(snapshot) - | Self::MetadataResolved(snapshot) => Some(snapshot.info_hash), + Self::TorrentAdded(view) | Self::TorrentUpdated(view) | Self::MetadataResolved(view) => { + Some(view.info_hash) + } Self::TorrentRemoved { torrent } | Self::TorrentStateChanged { torrent, .. } | Self::ProgressChanged { torrent, .. } diff --git a/crates/libtortillas/src/frontend/subscription.rs b/crates/libtortillas/src/frontend/subscription.rs index ab779dd7..bd89509b 100644 --- a/crates/libtortillas/src/frontend/subscription.rs +++ b/crates/libtortillas/src/frontend/subscription.rs @@ -8,7 +8,7 @@ use crate::hashes::InfoHash; /// /// The stream is bounded so a stalled UI cannot cause unbounded memory use. /// If [`Self::recv`] reports [`EventStreamError::Lagged`], redraw from the -/// latest watched snapshot and continue receiving events. +/// latest live view and continue receiving events. #[derive(Debug)] pub struct EventSubscription { receiver: broadcast::Receiver, From 716afb69ab92f34a4f8877df543c72f6c409a110 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:18:36 -0700 Subject: [PATCH 37/77] refactor: share torrent commands across handles --- crates/libtortillas/src/engine/mod.rs | 58 ++------------------- crates/libtortillas/src/frontend/command.rs | 22 ++++---- crates/libtortillas/src/torrent/handle.rs | 2 - crates/libtortillas/tests/facade.rs | 26 ++++----- crates/libtortillas/tests/live_frontend.rs | 5 +- docs/frontend-integration.md | 15 +++--- 6 files changed, 40 insertions(+), 88 deletions(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 4ea7c903..63fd8812 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -63,7 +63,7 @@ use crate::{ errors::EngineError, frontend::{ CoreCommand, CoreCommandResult, EngineListener, EngineView, EventSubscription, - FrontendPublisher, TorrentCommand, + FrontendPublisher, }, hashes::InfoHash, peer::PeerId, @@ -490,36 +490,8 @@ impl Engine { self.start_all().await?; Ok(CoreCommandResult::Applied) } - CoreCommand::StartTorrent { torrent } => { - self - .torrent(torrent) - .await? - .send(TorrentCommand::Start) - .await?; - Ok(CoreCommandResult::Applied) - } - CoreCommand::ResumeTorrent { torrent } => { - self - .torrent(torrent) - .await? - .send(TorrentCommand::Resume) - .await?; - Ok(CoreCommandResult::Applied) - } - CoreCommand::PauseTorrent { torrent } => { - self - .torrent(torrent) - .await? - .send(TorrentCommand::Pause) - .await?; - Ok(CoreCommandResult::Applied) - } - CoreCommand::StopTorrent { torrent } => { - self - .torrent(torrent) - .await? - .send(TorrentCommand::Stop) - .await?; + CoreCommand::Torrent { torrent, command } => { + self.torrent(torrent).await?.send(command).await?; Ok(CoreCommandResult::Applied) } CoreCommand::RemoveTorrent { torrent } => { @@ -530,30 +502,6 @@ impl Engine { self.shutdown().await?; Ok(CoreCommandResult::Applied) } - CoreCommand::SetTorrentOutputPath { torrent, path } => { - self - .torrent(torrent) - .await? - .send(TorrentCommand::SetOutputPath(path)) - .await?; - Ok(CoreCommandResult::Applied) - } - CoreCommand::SetAutostart { torrent, enabled } => { - self - .torrent(torrent) - .await? - .send(TorrentCommand::SetAutostart(enabled)) - .await?; - Ok(CoreCommandResult::Applied) - } - CoreCommand::SetSufficientPeers { torrent, peers } => { - self - .torrent(torrent) - .await? - .send(TorrentCommand::SetSufficientPeers(peers)) - .await?; - Ok(CoreCommandResult::Applied) - } } } diff --git a/crates/libtortillas/src/frontend/command.rs b/crates/libtortillas/src/frontend/command.rs index 78de6af8..203c6245 100644 --- a/crates/libtortillas/src/frontend/command.rs +++ b/crates/libtortillas/src/frontend/command.rs @@ -6,17 +6,19 @@ use crate::{engine::TorrentSource, hashes::InfoHash, torrent::Torrent}; #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum CoreCommand { - AddTorrent { source: TorrentSource }, + AddTorrent { + source: TorrentSource, + }, StartAll, - StartTorrent { torrent: InfoHash }, - ResumeTorrent { torrent: InfoHash }, - PauseTorrent { torrent: InfoHash }, - StopTorrent { torrent: InfoHash }, - RemoveTorrent { torrent: InfoHash }, + /// Sends an existing torrent command through the engine by info hash. + Torrent { + torrent: InfoHash, + command: TorrentCommand, + }, + RemoveTorrent { + torrent: InfoHash, + }, Shutdown, - SetTorrentOutputPath { torrent: InfoHash, path: PathBuf }, - SetAutostart { torrent: InfoHash, enabled: bool }, - SetSufficientPeers { torrent: InfoHash, peers: usize }, } /// Typed message accepted by [`Torrent::send`](crate::torrent::Torrent::send). @@ -24,9 +26,7 @@ pub enum CoreCommand { #[non_exhaustive] pub enum TorrentCommand { Start, - Resume, Pause, - Stop, SetOutputPath(PathBuf), SetAutostart(bool), SetSufficientPeers(usize), diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 169db20c..ef77ea29 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -196,9 +196,7 @@ impl Torrent { pub async fn send(&self, command: TorrentCommand) -> Result<(), TorrentError> { match command { TorrentCommand::Start => self.start().await, - TorrentCommand::Resume => self.resume().await, TorrentCommand::Pause => self.pause().await, - TorrentCommand::Stop => self.stop().await, TorrentCommand::SetOutputPath(path) => self.with_output_folder(path).await, TorrentCommand::SetAutostart(enabled) => self.set_auto_start(enabled).await, TorrentCommand::SetSufficientPeers(peers) => self.set_sufficient_peers(peers).await, diff --git a/crates/libtortillas/tests/facade.rs b/crates/libtortillas/tests/facade.rs index 7cb2e558..55fdb48b 100644 --- a/crates/libtortillas/tests/facade.rs +++ b/crates/libtortillas/tests/facade.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use libtortillas::{ facade::{EngineSnapshot, TorrentSnapshot}, hashes::InfoHash, - prelude::{CoreCommand, EngineHandle, TorrentSource}, + prelude::{CoreCommand, EngineHandle, TorrentCommand, TorrentSource}, }; #[test] @@ -28,20 +28,20 @@ fn facade_engine_handle_matches_existing_engine_type() { } #[test] -fn command_variants_identify_torrents_by_info_hash() { +fn engine_command_routes_the_shared_torrent_command_type() { 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::Torrent { + torrent, + command: TorrentCommand::Pause, + }; - let command = CoreCommand::StopTorrent { torrent }; - assert_eq!(command, CoreCommand::StopTorrent { torrent }); + assert_eq!( + command, + CoreCommand::Torrent { + torrent, + command: TorrentCommand::Pause, + } + ); let command = CoreCommand::RemoveTorrent { torrent }; assert_eq!(command, CoreCommand::RemoveTorrent { torrent }); diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index 233adc50..643e2278 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -115,7 +115,10 @@ async fn engine_commands_return_typed_unknown_torrent_errors() { let unknown = libtortillas::hashes::InfoHash::from_bytes([42; 20]); let error = engine - .send(CoreCommand::PauseTorrent { torrent: unknown }) + .send(CoreCommand::Torrent { + torrent: unknown, + command: TorrentCommand::Pause, + }) .await .unwrap_err(); diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 52a4a0a2..ff00fe3b 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -29,12 +29,15 @@ recovery. ## Commands `Engine::send(CoreCommand)` is the application-level message boundary. It can -add, start, pause, resume, configure, remove, and stop torrents, or shut down -the engine. Add commands return a `CoreCommandResult::TorrentAdded` handle. - -`Torrent::send(TorrentCommand)` provides the same typed pattern when a -frontend already owns a torrent handle. Both handles retain their existing -explicit convenience methods. +add and remove torrents, start all torrents, or shut down the engine. The +`CoreCommand::Torrent` variant routes the same `TorrentCommand` type accepted +by `Torrent::send`, so the engine and torrent APIs do not maintain parallel +command lists. Add commands return a `CoreCommandResult::TorrentAdded` handle. + +`Torrent::send(TorrentCommand)` starts, pauses, or configures a torrent when a +frontend already owns its handle. Both handles retain their explicit +convenience methods; `resume()` aliases `start()` and `stop()` aliases `pause()` +because those pairs currently produce the same engine transition. ## Views and persistence snapshots From 6e15184621113eec42661ebb1c15c98a744f0be1 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:19:46 -0700 Subject: [PATCH 38/77] docs: demonstrate snapshot persistence --- crates/libtortillas/examples/live_frontend.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/libtortillas/examples/live_frontend.rs b/crates/libtortillas/examples/live_frontend.rs index d7736cdd..d809ea99 100644 --- a/crates/libtortillas/examples/live_frontend.rs +++ b/crates/libtortillas/examples/live_frontend.rs @@ -8,10 +8,12 @@ use tracing::{error, info, warn}; #[tokio::main] async fn main() -> Result<(), Box> { - let Some(torrent_path) = std::env::args_os().nth(1).map(PathBuf::from) else { - error!("pass a .torrent file path to run the live frontend example"); + 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(()); }; + let session_path = args.next(); let engine = Engine::default(); let mut listener = engine.listener(); @@ -73,6 +75,11 @@ async fn main() -> Result<(), Box> { torrent.send(TorrentCommand::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"); + } let _ = engine.send(CoreCommand::Shutdown).await?; frontend.await?; Ok(()) From 0b98a49f65b695714df47a98c19f4200749bc4d4 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:29:59 -0700 Subject: [PATCH 39/77] refactor: generalize live frontend channels --- Cargo.lock | 13 ++ crates/libtortillas/Cargo.toml | 1 + crates/libtortillas/src/engine/mod.rs | 3 +- crates/libtortillas/src/frontend/event.rs | 11 +- crates/libtortillas/src/frontend/listener.rs | 92 ++++---- crates/libtortillas/src/frontend/mod.rs | 6 +- crates/libtortillas/src/frontend/publisher.rs | 219 ++++++++++++------ .../libtortillas/src/frontend/subscription.rs | 115 +++++---- crates/libtortillas/src/torrent/handle.rs | 4 +- 9 files changed, 295 insertions(+), 169 deletions(-) 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/crates/libtortillas/Cargo.toml b/crates/libtortillas/Cargo.toml index cf48a606..312f9b8c 100644 --- a/crates/libtortillas/Cargo.toml +++ b/crates/libtortillas/Cargo.toml @@ -39,6 +39,7 @@ kameo_actors = "^0.5" dashmap = { version = "^6", features = ["serde"] } bon = "^3.9" tokio-util = "^0.7" +tokio-stream = { version = "^0.1", features = ["sync"] } [dev-dependencies] tracing-test = "0.2.6" diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 63fd8812..16a8bc4e 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -519,7 +519,8 @@ impl Engine { /// state. #[must_use] pub fn listener(&self) -> EngineListener { - EngineListener::new(self.frontend.clone()) + let frontend = self.frontend.clone(); + EngineListener::new(self.subscribe(), move || frontend.view()) } /// Returns the current display-oriented engine state maintained by the live diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index ca2d6652..8eba0678 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -3,20 +3,23 @@ use serde::{Deserialize, Serialize}; use super::{EngineView, PeerView, TorrentProgress, TorrentView, TrackerView}; use crate::{hashes::InfoHash, torrent::TorrentState}; -/// A sequenced event emitted by the live frontend API. +/// A sequenced event emitted by a live publisher. /// /// Sequence numbers are engine-local and strictly increase for every event. /// A frontend can use them to preserve event order or detect a gap after /// reconnecting a consumer. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CoreEvent { +pub struct Sequenced { /// Engine-local sequence number for this event. pub sequence: u64, /// The typed change represented by this event. - pub kind: CoreEventKind, + pub kind: E, } -impl CoreEvent { +/// A sequenced event emitted by the engine's frontend publisher. +pub type CoreEvent = Sequenced; + +impl Sequenced { /// Returns the torrent associated with this event, when applicable. #[must_use] pub const fn torrent(&self) -> Option { diff --git a/crates/libtortillas/src/frontend/listener.rs b/crates/libtortillas/src/frontend/listener.rs index 4a3e96c2..aed7a069 100644 --- a/crates/libtortillas/src/frontend/listener.rs +++ b/crates/libtortillas/src/frontend/listener.rs @@ -1,64 +1,72 @@ +use std::{ + fmt, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use futures::Stream; + use super::{ - CoreEvent, EngineView, EventStreamError, EventSubscription, FrontendPublisher, TorrentView, + CoreEventKind, EngineView, EventStreamError, EventSubscription, Sequenced, TorrentView, }; -use crate::hashes::InfoHash; -/// Live engine listener with typed events and current display state. +/// A generic event stream paired with a synchronous current-state reader. /// -/// [`Self::recv`] waits for discrete changes. [`Self::view`] reads the latest -/// coherent live state directly from the engine publisher, including after a -/// lag report. -#[derive(Debug)] -pub struct EngineListener { - events: EventSubscription, - frontend: FrontendPublisher, +/// 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 EngineListener { - pub(crate) fn new(frontend: FrontendPublisher) -> Self { +impl EventListener { + pub(crate) fn new( + events: EventSubscription, read_view: impl Fn() -> V + Send + Sync + 'static, + ) -> Self { Self { - events: frontend.subscribe(), - frontend, + events, + read_view: Arc::new(read_view), } } - /// Waits for the next live engine or torrent event. - pub async fn recv(&mut self) -> Result { + /// Waits for the next live event. + pub async fn recv(&mut self) -> Result, EventStreamError> { self.events.recv().await } - /// Returns the latest coherent engine view without persistence snapshots. + /// 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 fn view(&self) -> EngineView { - self.frontend.view() + pub const fn subscription(&self) -> &EventSubscription { + &self.events } } -/// Live listener scoped to one torrent. -#[derive(Debug)] -pub struct TorrentListener { - torrent: InfoHash, - events: EventSubscription, - frontend: FrontendPublisher, -} +impl Stream for EventListener { + type Item = Result, EventStreamError>; -impl TorrentListener { - pub(crate) fn new(frontend: FrontendPublisher, torrent: InfoHash) -> Self { - Self { - torrent, - events: frontend.subscribe_torrent(torrent), - frontend, - } - } - - /// Waits for the next live event associated with this torrent. - pub async fn recv(&mut self) -> Result { - self.events.recv().await + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.events).poll_next(context) } +} - /// Returns the latest torrent view, or `None` after removal. - #[must_use] - pub fn view(&self) -> Option { - self.frontend.torrent_view(self.torrent) +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() } } + +/// Live engine listener with typed events and current display state. +pub type EngineListener = EventListener; + +/// Live listener scoped to one torrent. +pub type TorrentListener = EventListener>; diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index c8581e86..2565d63b 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -12,9 +12,9 @@ mod subscription; mod view; pub use command::{CoreCommand, CoreCommandResult, TorrentCommand}; -pub use event::{CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel}; -pub use listener::{EngineListener, TorrentListener}; -pub use publisher::DEFAULT_EVENT_CAPACITY; +pub use event::{CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, Sequenced}; +pub use listener::{EngineListener, EventListener, TorrentListener}; pub(crate) use publisher::FrontendPublisher; +pub use publisher::{DEFAULT_EVENT_CAPACITY, LivePublisher}; pub use subscription::{EventStreamError, EventSubscription}; pub use view::{EngineView, PeerView, TorrentProgress, TorrentTransfer, TorrentView, TrackerView}; diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 3cd58f22..b8a100c9 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -6,66 +6,157 @@ use std::sync::{ use tokio::sync::broadcast; use super::{ - CoreEvent, CoreEventKind, EngineView, EventSubscription, FrontendHealth, FrontendHealthLevel, - PeerView, TorrentView, TrackerView, + CoreEventKind, EngineView, EventListener, EventSubscription, FrontendHealth, + FrontendHealthLevel, PeerView, Sequenced, TorrentView, TrackerView, }; use crate::{engine::EngineStatus, hashes::InfoHash, torrent::TorrentState}; /// Number of discrete frontend events retained for each listener. pub const DEFAULT_EVENT_CAPACITY: usize = 256; -/// Shared live-state publisher used by the engine actor hierarchy. +/// Generic current-state and event publisher for live application APIs. +/// +/// 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(crate) struct FrontendPublisher { - inner: Arc, +pub struct LivePublisher { + inner: Arc>, } #[derive(Debug)] -struct PublisherInner { - events: broadcast::Sender, - view: RwLock, +struct LivePublisherInner { + events: broadcast::Sender>, + view: RwLock, sequence: AtomicU64, } +impl LivePublisher +where + V: Clone + Send + Sync + 'static, + E: Clone + Send + 'static, +{ + /// Creates a publisher with an initial view and bounded event capacity. + #[must_use] + pub fn new(initial_view: V, event_capacity: usize) -> Self { + let (events, _) = broadcast::channel(event_capacity); + Self { + inner: Arc::new(LivePublisherInner { + events, + view: RwLock::new(initial_view), + sequence: AtomicU64::new(0), + }), + } + } + + /// Subscribes to all future events from this publisher. + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + EventSubscription::new(self.inner.events.clone(), None) + } + + pub(crate) fn subscribe_where( + &self, filter: impl Fn(&E) -> bool + Send + Sync + 'static, + ) -> EventSubscription { + EventSubscription::new(self.inner.events.clone(), Some(Arc::new(filter))) + } + + /// Creates a stream-compatible listener paired with the current view. + #[must_use] + pub fn listener(&self) -> EventListener { + let publisher = self.clone(); + EventListener::new(self.subscribe(), move || publisher.view()) + } + + /// Clones the latest coherent view. + #[must_use] + pub fn view(&self) -> V { + self.read_view().clone() + } + + /// Replaces the current view without emitting an event. + pub fn set_view(&self, view: V) { + *self.write_view() = view; + } + + /// Replaces the current view and emits the corresponding event. + pub fn update(&self, view: V, event: E) { + self.set_view(view); + self.publish(event); + } + + /// Emits an event using this publisher's monotonic sequence. + pub fn publish(&self, kind: E) { + let sequence = self.inner.sequence.fetch_add(1, Ordering::Relaxed) + 1; + let _ = self.inner.events.send(Sequenced { sequence, kind }); + } + + pub(crate) fn edit_view(&self, edit: impl FnOnce(&mut V) -> R) -> R { + edit(&mut self.write_view()) + } + + fn read_view(&self) -> RwLockReadGuard<'_, V> { + self + .inner + .view + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write_view(&self) -> RwLockWriteGuard<'_, V> { + self + .inner + .view + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// Shared live-state publisher used by the engine actor hierarchy. +#[derive(Debug, Clone)] +pub(crate) struct FrontendPublisher { + live: LivePublisher, +} + impl FrontendPublisher { pub(crate) fn new() -> Self { Self::with_event_capacity(DEFAULT_EVENT_CAPACITY) } fn with_event_capacity(event_capacity: usize) -> Self { - let (events, _) = broadcast::channel(event_capacity); Self { - inner: Arc::new(PublisherInner { - events, - view: RwLock::new(EngineView { + live: LivePublisher::new( + EngineView { status: EngineStatus::Starting, torrent_count: 0, torrents: Vec::new(), - }), - sequence: AtomicU64::new(0), - }), + }, + event_capacity, + ), } } pub(crate) fn subscribe(&self) -> EventSubscription { - EventSubscription::engine(self.inner.events.subscribe()) + self.live.subscribe() } pub(crate) fn subscribe_torrent(&self, torrent: InfoHash) -> EventSubscription { - EventSubscription::torrent(self.inner.events.subscribe(), torrent) + self + .live + .subscribe_where(move |event| event.torrent() == Some(torrent)) } pub(crate) fn view(&self) -> EngineView { - self.read_view().clone() + self.live.view() } pub(crate) fn torrent_view(&self, torrent: InfoHash) -> Option { self - .read_view() + .live + .view() .torrents - .iter() + .into_iter() .find(|view| view.info_hash == torrent) - .cloned() } pub(crate) fn engine_started(&self) { @@ -173,69 +264,55 @@ impl FrontendPublisher { } pub(crate) fn torrent_removed(&self, torrent: InfoHash) { - let mut view = self.write_view(); - view - .torrents - .retain(|candidate| candidate.info_hash != torrent); - view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); - drop(view); + self.live.edit_view(|view| { + view + .torrents + .retain(|candidate| candidate.info_hash != torrent); + view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); + }); self.publish(CoreEventKind::TorrentRemoved { torrent }); } pub(crate) fn publish(&self, kind: CoreEventKind) { - let sequence = self.inner.sequence.fetch_add(1, Ordering::Relaxed) + 1; - let _ = self.inner.events.send(CoreEvent { sequence, kind }); + self.live.publish(kind); } fn set_engine_status(&self, status: EngineStatus) -> EngineView { - let mut view = self.write_view(); - view.status = status; - view.clone() + self.live.edit_view(|view| { + view.status = status; + view.clone() + }) } fn replace_torrent(&self, torrent: TorrentView) { - let mut view = self.write_view(); - match view - .torrents - .iter_mut() - .find(|candidate| candidate.info_hash == torrent.info_hash) - { - Some(current) => *current = torrent, - None => view.torrents.push(torrent), - } - view - .torrents - .sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); - view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); + self.live.edit_view(|view| { + match view + .torrents + .iter_mut() + .find(|candidate| candidate.info_hash == torrent.info_hash) + { + Some(current) => *current = torrent, + None => view.torrents.push(torrent), + } + view + .torrents + .sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); + view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); + }); } fn update_torrent_entry(&self, torrent: TorrentView) -> bool { - let mut view = self.write_view(); - let Some(current) = view - .torrents - .iter_mut() - .find(|candidate| candidate.info_hash == torrent.info_hash) - else { - return false; - }; - *current = torrent; - true - } - - fn read_view(&self) -> RwLockReadGuard<'_, EngineView> { - self - .inner - .view - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - fn write_view(&self) -> RwLockWriteGuard<'_, EngineView> { - self - .inner - .view - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) + self.live.edit_view(|view| { + let Some(current) = view + .torrents + .iter_mut() + .find(|candidate| candidate.info_hash == torrent.info_hash) + else { + return false; + }; + *current = torrent; + true + }) } } diff --git a/crates/libtortillas/src/frontend/subscription.rs b/crates/libtortillas/src/frontend/subscription.rs index bd89509b..d562c2e8 100644 --- a/crates/libtortillas/src/frontend/subscription.rs +++ b/crates/libtortillas/src/frontend/subscription.rs @@ -1,58 +1,88 @@ +use std::{ + fmt, + pin::Pin, + sync::Arc, + 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::{CoreEventKind, Sequenced}; -use super::CoreEvent; -use crate::hashes::InfoHash; +type EventFilter = Arc bool + Send + Sync>; -/// A lag-aware subscription to the engine's typed frontend events. +/// A generic, lag-aware subscription to events from a live publisher. /// -/// The stream is bounded so a stalled UI cannot cause unbounded memory use. -/// If [`Self::recv`] reports [`EventStreamError::Lagged`], redraw from the -/// latest live view and continue receiving events. -#[derive(Debug)] -pub struct EventSubscription { - receiver: broadcast::Receiver, - torrent: Option, +/// `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::Sender>, + stream: BroadcastStream>, + filter: Option>, } -impl EventSubscription { - pub(crate) fn engine(receiver: broadcast::Receiver) -> Self { +impl EventSubscription { + pub(crate) fn new( + sender: broadcast::Sender>, filter: Option>, + ) -> Self { Self { - receiver, - torrent: None, + stream: BroadcastStream::new(sender.subscribe()), + sender, + filter, } } - pub(crate) fn torrent(receiver: broadcast::Receiver, torrent: InfoHash) -> Self { - Self { - receiver, - torrent: Some(torrent), - } + /// Waits for the next event in this subscription. + pub async fn recv(&mut self) -> Result, EventStreamError> { + poll_fn(|context| Pin::new(&mut *self).poll_next(context)) + .await + .unwrap_or(Err(EventStreamError::Closed)) } - /// Waits for the next event in this subscription. - /// - /// Torrent subscriptions skip unrelated events while preserving the - /// original engine-local sequence numbers. - pub async fn recv(&mut self) -> Result { + /// Creates another subscription beginning at the publisher's current + /// event position and retaining this subscription's filter. + #[must_use] + pub fn resubscribe(&self) -> Self { + Self::new(self.sender.clone(), self.filter.clone()) + } +} + +impl Stream for EventSubscription { + type Item = Result, EventStreamError>; + + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { loop { - let event = self.receiver.recv().await.map_err(EventStreamError::from)?; - if self - .torrent - .is_none_or(|torrent| event.torrent() == Some(torrent)) - { - return Ok(event); + match Pin::new(&mut self.stream).poll_next(context) { + Poll::Ready(Some(Ok(event))) => { + if self + .filter + .as_ref() + .is_none_or(|filter| filter(&event.kind)) + { + return Poll::Ready(Some(Ok(event))); + } + } + Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(events)))) => { + return Poll::Ready(Some(Err(EventStreamError::Lagged(events)))); + } + Poll::Ready(None) => return Poll::Ready(None), + Poll::Pending => return Poll::Pending, } } } +} - /// Creates another subscription beginning at the current event position. - #[must_use] - pub fn resubscribe(&self) -> Self { - Self { - receiver: self.receiver.resubscribe(), - torrent: self.torrent, - } +impl fmt::Debug for EventSubscription { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EventSubscription") + .field("receiver_count", &self.sender.receiver_count()) + .field("filtered", &self.filter.is_some()) + .finish_non_exhaustive() } } @@ -63,16 +93,7 @@ pub enum EventStreamError { /// dropped. The subscription remains usable. #[error("frontend event subscriber lagged by {0} events")] Lagged(u64), - /// The engine closed the event stream. + /// The publisher closed the event stream. #[error("frontend event stream closed")] Closed, } - -impl From for EventStreamError { - fn from(error: broadcast::error::RecvError) -> Self { - match error { - broadcast::error::RecvError::Closed => Self::Closed, - broadcast::error::RecvError::Lagged(events) => Self::Lagged(events), - } - } -} diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index ef77ea29..43951656 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -212,7 +212,9 @@ impl Torrent { /// Creates a live listener scoped to this torrent. #[must_use] pub fn listener(&self) -> TorrentListener { - TorrentListener::new(self.frontend.clone(), self.info_hash) + let frontend = self.frontend.clone(); + let info_hash = self.info_hash; + TorrentListener::new(self.subscribe(), move || frontend.torrent_view(info_hash)) } /// Returns the latest display-oriented state maintained for this torrent. From 59a1322778281506d11cfda3879641b1fa996078 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:43:49 -0700 Subject: [PATCH 40/77] feat: add hierarchical live handles --- crates/libtortillas/src/engine/messages.rs | 7 +- crates/libtortillas/src/engine/mod.rs | 17 ++ crates/libtortillas/src/facade.rs | 7 +- crates/libtortillas/src/frontend/event.rs | 70 +++-- crates/libtortillas/src/frontend/handle.rs | 204 ++++++++++++++ crates/libtortillas/src/frontend/mod.rs | 3 + crates/libtortillas/src/frontend/publisher.rs | 251 +++++++++++++++--- crates/libtortillas/src/frontend/view.rs | 36 ++- crates/libtortillas/src/peer/actor.rs | 9 +- crates/libtortillas/src/torrent/actor.rs | 17 +- crates/libtortillas/src/torrent/handle.rs | 30 ++- crates/libtortillas/src/torrent/messages.rs | 33 +-- crates/libtortillas/src/torrent/swarm.rs | 37 +-- crates/libtortillas/src/tracker/actor.rs | 19 +- crates/libtortillas/tests/live_frontend.rs | 63 ++++- 15 files changed, 665 insertions(+), 138 deletions(-) create mode 100644 crates/libtortillas/src/frontend/handle.rs diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index dd176d2e..35a51c15 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -11,7 +11,7 @@ use crate::{ metainfo::MetaInfo, peer::Peer, protocol::stream::{PeerStream, validate_handshake_protocol}, - torrent::{self, TorrentActor, TorrentActorArgs, TorrentSnapshot, TorrentState}, + torrent::{self, Torrent, TorrentActor, TorrentActorArgs, TorrentSnapshot, TorrentState}, }; pub(crate) mod commands { @@ -240,6 +240,11 @@ pub(crate) mod commands { "failed to resume restored torrent: {error}" ))); } + self.frontend.torrent_added(Torrent::new_with_frontend( + info_hash, + torrent_ref.clone(), + self.frontend.clone(), + )); Ok(torrent_ref) } diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 16a8bc4e..8dd0ce71 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -603,6 +603,7 @@ mod tests { }, engine::{Engine, TorrentSource}, errors::EngineError, + frontend::CoreEventKind, settings::{DhtSettings, Settings}, testing::{ BIG_BUCK_BUNNY_INFO_HASH, BIG_BUCK_BUNNY_MAGNET, BIG_BUCK_BUNNY_TORRENT_FILE, LocalPeer, @@ -741,6 +742,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 @@ -759,7 +761,22 @@ mod tests { .await .unwrap(); + let peer = timeout(Duration::from_secs(2), async { + loop { + let event = listener.recv().await.unwrap(); + if let CoreEventKind::PeerConnected { peer, .. } = event.kind { + break peer; + } + } + }) + .await + .unwrap(); + assert_eq!(peer.torrent(), info_hash); + assert!(peer.live_view().is_some()); + let _peer_listener = peer.listener(); + engine.shutdown().await.unwrap(); + assert!(peer.live_view().is_none()); receive_task.abort(); seed.kill(); } diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index e306a6c8..f3e42d1a 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -22,9 +22,10 @@ pub use crate::{ engine::{EngineSnapshot, EngineStatus, TorrentSource}, frontend::{ CoreCommand, CoreCommandResult, CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, - EngineListener, EngineView, EventStreamError, EventSubscription, FrontendHealth, - FrontendHealthLevel, PeerView, TorrentCommand, TorrentListener, TorrentProgress, - TorrentTransfer, TorrentView, TrackerView, + EngineListener, EngineView, EventListener, EventStreamError, EventSubscription, + FrontendHealth, FrontendHealthLevel, LivePublisher, PeerHandle, PeerListener, PeerView, + Sequenced, TorrentCommand, TorrentListener, TorrentProgress, TorrentTransfer, TorrentView, + TrackerHandle, TrackerListener, TrackerView, }, torrent::TorrentSnapshot, }; diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index 8eba0678..03e463bb 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -1,7 +1,10 @@ use serde::{Deserialize, Serialize}; -use super::{EngineView, PeerView, TorrentProgress, TorrentView, TrackerView}; -use crate::{hashes::InfoHash, torrent::TorrentState}; +use super::{EngineView, PeerHandle, TorrentProgress, TrackerHandle}; +use crate::{ + hashes::InfoHash, + torrent::{Torrent, TorrentState}, +}; /// A sequenced event emitted by a live publisher. /// @@ -22,21 +25,21 @@ pub type CoreEvent = Sequenced; impl Sequenced { /// Returns the torrent associated with this event, when applicable. #[must_use] - pub const fn torrent(&self) -> Option { + pub fn torrent(&self) -> Option { self.kind.torrent() } } /// Typed changes a frontend can react to without actor internals or polling. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone)] #[non_exhaustive] pub enum CoreEventKind { /// The engine finished starting and is ready for commands. EngineStarted(EngineView), /// A torrent was added to the engine. - TorrentAdded(TorrentView), + TorrentAdded(Torrent), /// A torrent was removed from the engine. - TorrentRemoved { torrent: InfoHash }, + TorrentRemoved(Torrent), /// A torrent changed lifecycle state. TorrentStateChanged { torrent: InfoHash, @@ -44,27 +47,34 @@ pub enum CoreEventKind { current: TorrentState, }, /// Display-oriented torrent configuration or counts changed. - TorrentUpdated(TorrentView), + TorrentUpdated(Torrent), /// Metadata for a magnet torrent was resolved. - MetadataResolved(TorrentView), + MetadataResolved(Torrent), /// Download progress changed. ProgressChanged { torrent: InfoHash, progress: TorrentProgress, }, /// A peer connection became available to a torrent. - PeerConnected { torrent: InfoHash, peer: PeerView }, + PeerConnected { torrent: InfoHash, peer: PeerHandle }, + /// A connected peer's protocol state or transfer metrics changed. + PeerUpdated { torrent: InfoHash, peer: PeerHandle }, /// A peer connection was removed from a torrent. - PeerDisconnected { torrent: InfoHash, peer: PeerView }, + PeerDisconnected { torrent: InfoHash, peer: PeerHandle }, /// A tracker announce completed successfully. TrackerAnnounceSucceeded { torrent: InfoHash, - tracker: TrackerView, + tracker: TrackerHandle, }, /// A tracker announce failed. TrackerAnnounceFailed { torrent: InfoHash, - tracker: TrackerView, + tracker: TrackerHandle, + }, + /// A tracker actor stopped. + TrackerStopped { + torrent: InfoHash, + tracker: TrackerHandle, }, /// A frontend-relevant health report was emitted. Health(FrontendHealth), @@ -75,22 +85,44 @@ pub enum CoreEventKind { impl CoreEventKind { /// Returns the torrent associated with this event, when applicable. #[must_use] - pub const fn torrent(&self) -> Option { + pub fn torrent(&self) -> Option { match self { Self::EngineStarted(_) | Self::Shutdown(_) => None, - Self::TorrentAdded(view) | Self::TorrentUpdated(view) | Self::MetadataResolved(view) => { - Some(view.info_hash) - } - Self::TorrentRemoved { torrent } - | Self::TorrentStateChanged { torrent, .. } + Self::TorrentAdded(torrent) + | Self::TorrentUpdated(torrent) + | Self::TorrentRemoved(torrent) + | Self::MetadataResolved(torrent) => Some(torrent.info_hash()), + Self::TorrentStateChanged { torrent, .. } | Self::ProgressChanged { torrent, .. } | Self::PeerConnected { torrent, .. } + | Self::PeerUpdated { torrent, .. } | Self::PeerDisconnected { torrent, .. } | Self::TrackerAnnounceSucceeded { torrent, .. } - | Self::TrackerAnnounceFailed { torrent, .. } => Some(*torrent), + | Self::TrackerAnnounceFailed { torrent, .. } + | Self::TrackerStopped { torrent, .. } => Some(*torrent), Self::Health(health) => health.torrent, } } + + pub(crate) fn is_peer(&self, scope: super::handle::PeerScope) -> bool { + matches!( + self, + Self::PeerConnected { peer, .. } + | Self::PeerUpdated { peer, .. } + | Self::PeerDisconnected { peer, .. } + if peer.scope() == scope + ) + } + + pub(crate) fn is_tracker(&self, scope: &super::handle::TrackerScope) -> bool { + matches!( + self, + Self::TrackerAnnounceSucceeded { tracker, .. } + | Self::TrackerAnnounceFailed { tracker, .. } + | Self::TrackerStopped { tracker, .. } + if tracker.scope() == scope + ) + } } /// A recoverable or terminal health report intended for user interfaces. diff --git a/crates/libtortillas/src/frontend/handle.rs b/crates/libtortillas/src/frontend/handle.rs new file mode 100644 index 00000000..0e098ee5 --- /dev/null +++ b/crates/libtortillas/src/frontend/handle.rs @@ -0,0 +1,204 @@ +use std::{fmt, net::SocketAddr}; + +use super::{EventListener, EventSubscription, FrontendPublisher, PeerView, TrackerView}; +use crate::{hashes::InfoHash, peer::PeerId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct PeerScope { + pub(crate) torrent: InfoHash, + pub(crate) peer: PeerId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct TrackerScope { + pub(crate) torrent: InfoHash, + pub(crate) endpoint: String, +} + +/// Public identity and live frontend access for one connected peer. +#[derive(Clone)] +pub struct PeerHandle { + scope: PeerScope, + frontend: FrontendPublisher, +} + +impl fmt::Debug for PeerHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PeerHandle") + .field("torrent", &self.scope.torrent) + .field("peer", &self.scope.peer) + .finish_non_exhaustive() + } +} + +impl PeerHandle { + pub(crate) const fn new(scope: PeerScope, frontend: FrontendPublisher) -> Self { + Self { scope, frontend } + } + + /// Torrent that owns this peer connection. + #[must_use] + pub const fn torrent(&self) -> InfoHash { + self.scope.torrent + } + + /// Handshaked peer identifier. + #[must_use] + pub const fn id(&self) -> PeerId { + self.scope.peer + } + + /// Latest known network address. + #[must_use] + pub fn address(&self) -> Option { + self.live_view().and_then(|view| view.address) + } + + /// Subscribes to events for this peer only. + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + self.frontend.subscribe_peer(self.scope) + } + + /// Creates a stream-compatible listener for this peer. + #[must_use] + pub fn listener(&self) -> PeerListener { + let frontend = self.frontend.clone(); + let scope = self.scope; + PeerListener::new(self.subscribe(), move || frontend.peer_view(scope)) + } + + /// Returns the latest peer view, or `None` after its torrent is removed. + #[must_use] + pub fn live_view(&self) -> Option { + self.frontend.peer_view(self.scope) + } + + pub(crate) const fn scope(&self) -> PeerScope { + self.scope + } + + pub(crate) fn update(&self, view: PeerView) { + self.frontend.peer_updated(self, view); + } + + pub(crate) fn disconnected(&self) { + self.frontend.peer_disconnected(self.clone()); + } +} + +impl PartialEq for PeerHandle { + fn eq(&self, other: &Self) -> bool { + self.scope == other.scope + } +} + +impl Eq for PeerHandle {} + +/// Public identity and live frontend access for one tracker. +#[derive(Clone)] +pub struct TrackerHandle { + scope: TrackerScope, + frontend: FrontendPublisher, +} + +impl fmt::Debug for TrackerHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TrackerHandle") + .field("torrent", &self.scope.torrent) + .field("endpoint", &self.scope.endpoint) + .finish_non_exhaustive() + } +} + +impl TrackerHandle { + pub(crate) fn new(scope: TrackerScope, frontend: FrontendPublisher) -> Self { + Self { scope, frontend } + } + + /// Torrent that owns this tracker. + #[must_use] + pub const fn torrent(&self) -> InfoHash { + self.scope.torrent + } + + /// Credential-free tracker endpoint. + #[must_use] + pub fn endpoint(&self) -> &str { + &self.scope.endpoint + } + + /// Subscribes to events for this tracker only. + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + self.frontend.subscribe_tracker(self.scope.clone()) + } + + /// Creates a stream-compatible listener for this tracker. + #[must_use] + pub fn listener(&self) -> TrackerListener { + let frontend = self.frontend.clone(); + let scope = self.scope.clone(); + TrackerListener::new(self.subscribe(), move || frontend.tracker_view(&scope)) + } + + /// Returns the current tracker view, or `None` after removal. + #[must_use] + pub fn live_view(&self) -> Option { + self.frontend.tracker_view(&self.scope) + } + + pub(crate) fn scope(&self) -> &TrackerScope { + &self.scope + } + + pub(crate) fn announce_succeeded(&self, peers_returned: u64) { + self.frontend.tracker_announce_succeeded( + self, + TrackerView { + endpoint: self.scope.endpoint.clone(), + active: true, + healthy: true, + peers_returned: Some(peers_returned), + }, + ); + } + + pub(crate) fn announce_failed(&self) { + self.frontend.tracker_announce_failed( + self, + TrackerView { + endpoint: self.scope.endpoint.clone(), + active: true, + healthy: false, + peers_returned: None, + }, + ); + } + + pub(crate) fn stopped(&self) { + self.frontend.tracker_stopped(self); + } +} + +impl PartialEq for TrackerHandle { + fn eq(&self, other: &Self) -> bool { + self.scope == other.scope + } +} + +impl Eq for TrackerHandle {} + +impl fmt::Display for TrackerHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.endpoint()) + } +} + +/// Live listener scoped to one peer. +pub type PeerListener = EventListener>; + +/// Live listener scoped to one tracker. +pub type TrackerListener = EventListener>; diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index 2565d63b..4e2a3d2f 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -6,6 +6,7 @@ mod command; mod event; +mod handle; mod listener; mod publisher; mod subscription; @@ -13,6 +14,8 @@ mod view; pub use command::{CoreCommand, CoreCommandResult, TorrentCommand}; pub use event::{CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, Sequenced}; +pub use handle::{PeerHandle, PeerListener, TrackerHandle, TrackerListener}; +pub(crate) use handle::{PeerScope, TrackerScope}; pub use listener::{EngineListener, EventListener, TorrentListener}; pub(crate) use publisher::FrontendPublisher; pub use publisher::{DEFAULT_EVENT_CAPACITY, LivePublisher}; diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index b8a100c9..f251d9d8 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -1,19 +1,39 @@ -use std::sync::{ - Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, - atomic::{AtomicU64, Ordering}, +use std::{ + collections::HashMap, + sync::{ + Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, + atomic::{AtomicU64, Ordering}, + }, }; use tokio::sync::broadcast; use super::{ CoreEventKind, EngineView, EventListener, EventSubscription, FrontendHealth, - FrontendHealthLevel, PeerView, Sequenced, TorrentView, TrackerView, + FrontendHealthLevel, PeerHandle, PeerView, Sequenced, TorrentView, TrackerHandle, TrackerView, + handle::{PeerScope, TrackerScope}, +}; +use crate::{ + engine::EngineStatus, + hashes::InfoHash, + torrent::{Torrent, TorrentState}, }; -use crate::{engine::EngineStatus, hashes::InfoHash, torrent::TorrentState}; /// Number of discrete frontend events retained for each listener. pub const DEFAULT_EVENT_CAPACITY: usize = 256; +fn read_lock(lock: &RwLock) -> RwLockReadGuard<'_, T> { + lock + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn write_lock(lock: &RwLock) -> RwLockWriteGuard<'_, T> { + lock + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// Generic current-state and event publisher for live application APIs. /// /// The same primitive backs engine, torrent, peer, and tracker listeners. It @@ -96,19 +116,11 @@ where } fn read_view(&self) -> RwLockReadGuard<'_, V> { - self - .inner - .view - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) + read_lock(&self.inner.view) } fn write_view(&self) -> RwLockWriteGuard<'_, V> { - self - .inner - .view - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) + write_lock(&self.inner.view) } } @@ -116,6 +128,9 @@ where #[derive(Debug, Clone)] pub(crate) struct FrontendPublisher { live: LivePublisher, + torrents: Arc>>, + peers: Arc>>, + trackers: Arc>>, } impl FrontendPublisher { @@ -133,6 +148,9 @@ impl FrontendPublisher { }, event_capacity, ), + torrents: Arc::new(RwLock::new(HashMap::new())), + peers: Arc::new(RwLock::new(HashMap::new())), + trackers: Arc::new(RwLock::new(HashMap::new())), } } @@ -146,6 +164,16 @@ impl FrontendPublisher { .subscribe_where(move |event| event.torrent() == Some(torrent)) } + pub(crate) fn subscribe_peer(&self, scope: PeerScope) -> EventSubscription { + self.live.subscribe_where(move |event| event.is_peer(scope)) + } + + pub(crate) fn subscribe_tracker(&self, scope: TrackerScope) -> EventSubscription { + self + .live + .subscribe_where(move |event| event.is_tracker(&scope)) + } + pub(crate) fn view(&self) -> EngineView { self.live.view() } @@ -159,6 +187,30 @@ impl FrontendPublisher { .find(|view| view.info_hash == torrent) } + pub(crate) fn peer_view(&self, scope: PeerScope) -> Option { + read_lock(&self.peers).get(&scope).cloned() + } + + pub(crate) fn tracker_view(&self, scope: &TrackerScope) -> Option { + read_lock(&self.trackers).get(scope).cloned() + } + + pub(crate) fn peer_handles(&self, torrent: InfoHash) -> Vec { + read_lock(&self.peers) + .iter() + .filter(|(scope, view)| scope.torrent == torrent && view.connected) + .map(|(scope, _)| PeerHandle::new(*scope, self.clone())) + .collect() + } + + pub(crate) fn tracker_handles(&self, torrent: InfoHash) -> Vec { + read_lock(&self.trackers) + .keys() + .filter(|scope| scope.torrent == torrent) + .map(|scope| TrackerHandle::new(scope.clone(), self.clone())) + .collect() + } + pub(crate) fn engine_started(&self) { let view = self.set_engine_status(EngineStatus::Running); self.publish(CoreEventKind::EngineStarted(view)); @@ -173,19 +225,25 @@ impl FrontendPublisher { self.publish(CoreEventKind::Shutdown(view)); } - pub(crate) fn torrent_added(&self, torrent: TorrentView) { - self.replace_torrent(torrent.clone()); + pub(crate) fn initialize_torrent(&self, torrent: TorrentView) { + self.replace_torrent(torrent); + } + + pub(crate) fn torrent_added(&self, torrent: Torrent) { + self + .write_torrents() + .insert(torrent.info_hash(), torrent.clone()); self.publish(CoreEventKind::TorrentAdded(torrent)); } pub(crate) fn update_torrent(&self, torrent: TorrentView) { - if self.update_torrent_entry(torrent.clone()) { + if let Some(torrent) = self.update_torrent_entry(torrent) { self.publish(CoreEventKind::TorrentUpdated(torrent)); } } pub(crate) fn metadata_resolved(&self, torrent: TorrentView) { - if self.update_torrent_entry(torrent.clone()) { + if let Some(torrent) = self.update_torrent_entry(torrent) { self.publish(CoreEventKind::MetadataResolved(torrent)); } } @@ -193,7 +251,7 @@ impl FrontendPublisher { pub(crate) fn progress_changed(&self, torrent: TorrentView) { let info_hash = torrent.info_hash; let progress = torrent.progress.clone(); - if self.update_torrent_entry(torrent) { + if self.update_torrent_entry(torrent).is_some() { self.publish(CoreEventKind::ProgressChanged { torrent: info_hash, progress, @@ -201,42 +259,77 @@ impl FrontendPublisher { } } - pub(crate) fn peer_connected(&self, torrent: TorrentView, peer: PeerView) { + pub(crate) fn peer_connected( + &self, torrent: TorrentView, scope: PeerScope, view: PeerView, + ) -> PeerHandle { let info_hash = torrent.info_hash; - if self.update_torrent_entry(torrent) { + write_lock(&self.peers).insert(scope, view); + let peer = PeerHandle::new(scope, self.clone()); + if self.update_torrent_entry(torrent).is_some() { self.publish(CoreEventKind::PeerConnected { torrent: info_hash, - peer, + peer: peer.clone(), }); } + peer } - pub(crate) fn peer_disconnected(&self, torrent: TorrentView, peer: PeerView) { - let info_hash = torrent.info_hash; - if self.update_torrent_entry(torrent) { + pub(crate) fn peer_updated(&self, peer: &PeerHandle, view: PeerView) { + let scope = peer.scope(); + if let Some(current) = write_lock(&self.peers).get_mut(&scope) { + *current = view; + self.publish(CoreEventKind::PeerUpdated { + torrent: scope.torrent, + peer: peer.clone(), + }); + } + } + + pub(crate) fn peer_disconnected(&self, peer: PeerHandle) { + let scope = peer.scope(); + if let Some(view) = write_lock(&self.peers).get_mut(&scope) { + view.connected = false; self.publish(CoreEventKind::PeerDisconnected { - torrent: info_hash, + torrent: scope.torrent, peer, }); } } - pub(crate) fn tracker_announce_succeeded(&self, torrent: TorrentView, tracker: TrackerView) { - let info_hash = torrent.info_hash; - if self.update_torrent_entry(torrent) { + pub(crate) fn tracker(&self, scope: TrackerScope, view: TrackerView) -> TrackerHandle { + write_lock(&self.trackers).insert(scope.clone(), view); + TrackerHandle::new(scope, self.clone()) + } + + pub(crate) fn tracker_announce_succeeded(&self, tracker: &TrackerHandle, view: TrackerView) { + let scope = tracker.scope(); + if let Some(current) = write_lock(&self.trackers).get_mut(scope) { + *current = view; self.publish(CoreEventKind::TrackerAnnounceSucceeded { - torrent: info_hash, - tracker, + torrent: scope.torrent, + tracker: tracker.clone(), }); } } - pub(crate) fn tracker_announce_failed(&self, torrent: TorrentView, tracker: TrackerView) { - let info_hash = torrent.info_hash; - if self.update_torrent_entry(torrent) { + pub(crate) fn tracker_announce_failed(&self, tracker: &TrackerHandle, view: TrackerView) { + let scope = tracker.scope(); + if let Some(current) = write_lock(&self.trackers).get_mut(scope) { + *current = view; self.publish(CoreEventKind::TrackerAnnounceFailed { - torrent: info_hash, - tracker, + torrent: scope.torrent, + tracker: tracker.clone(), + }); + } + } + + pub(crate) fn tracker_stopped(&self, tracker: &TrackerHandle) { + let scope = tracker.scope(); + if let Some(view) = write_lock(&self.trackers).get_mut(scope) { + view.active = false; + self.publish(CoreEventKind::TrackerStopped { + torrent: scope.torrent, + tracker: tracker.clone(), }); } } @@ -254,7 +347,7 @@ impl FrontendPublisher { pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { let info_hash = torrent.info_hash; let current = torrent.state; - if self.update_torrent_entry(torrent) { + if self.update_torrent_entry(torrent).is_some() { self.publish(CoreEventKind::TorrentStateChanged { torrent: info_hash, previous, @@ -270,7 +363,27 @@ impl FrontendPublisher { .retain(|candidate| candidate.info_hash != torrent); view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); }); - self.publish(CoreEventKind::TorrentRemoved { torrent }); + let peers = read_lock(&self.peers) + .iter() + .filter(|(scope, view)| scope.torrent == torrent && view.connected) + .map(|(scope, _)| *scope) + .collect::>(); + for scope in peers { + self.peer_disconnected(PeerHandle::new(scope, self.clone())); + } + write_lock(&self.peers).retain(|scope, _| scope.torrent != torrent); + let trackers = read_lock(&self.trackers) + .keys() + .filter(|scope| scope.torrent == torrent) + .cloned() + .collect::>(); + for scope in &trackers { + self.tracker_stopped(&TrackerHandle::new(scope.clone(), self.clone())); + } + write_lock(&self.trackers).retain(|scope, _| scope.torrent != torrent); + if let Some(torrent) = self.write_torrents().remove(&torrent) { + self.publish(CoreEventKind::TorrentRemoved(torrent)); + } } pub(crate) fn publish(&self, kind: CoreEventKind) { @@ -301,8 +414,9 @@ impl FrontendPublisher { }); } - fn update_torrent_entry(&self, torrent: TorrentView) -> bool { - self.live.edit_view(|view| { + fn update_torrent_entry(&self, torrent: TorrentView) -> Option { + let info_hash = torrent.info_hash; + let updated = self.live.edit_view(|view| { let Some(current) = view .torrents .iter_mut() @@ -312,7 +426,18 @@ impl FrontendPublisher { }; *current = torrent; true - }) + }); + updated + .then(|| self.read_torrents().get(&info_hash).cloned()) + .flatten() + } + + fn read_torrents(&self) -> RwLockReadGuard<'_, HashMap> { + read_lock(&self.torrents) + } + + fn write_torrents(&self) -> RwLockWriteGuard<'_, HashMap> { + write_lock(&self.torrents) } } @@ -321,3 +446,45 @@ impl Default for FrontendPublisher { Self::new() } } + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, SocketAddr}; + + use super::*; + use crate::peer::PeerId; + + #[tokio::test] + async fn peer_handle_when_updated_then_only_its_listener_receives_event() { + let frontend = FrontendPublisher::new(); + let scope = PeerScope { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), + }; + let view = PeerView { + address: Some(SocketAddr::from((Ipv4Addr::LOCALHOST, 6881))), + client: Some("Unknown".to_string()), + connected: true, + peer_choking: true, + peer_interested: false, + client_choking: true, + client_interested: false, + available_pieces: 0, + download_rate_bytes_per_second: 0, + upload_rate_bytes_per_second: 0, + downloaded_bytes: 0, + uploaded_bytes: 0, + }; + write_lock(&frontend.peers).insert(scope, view.clone()); + let peer = PeerHandle::new(scope, frontend); + let mut listener = peer.listener(); + let mut updated = view; + updated.downloaded_bytes = 16; + + peer.update(updated); + + let event = listener.recv().await.unwrap(); + assert!(matches!(event.kind, CoreEventKind::PeerUpdated { .. })); + assert_eq!(listener.view().unwrap().downloaded_bytes, 16); + } +} diff --git a/crates/libtortillas/src/frontend/view.rs b/crates/libtortillas/src/frontend/view.rs index 1d0ec1bb..9f0b45aa 100644 --- a/crates/libtortillas/src/frontend/view.rs +++ b/crates/libtortillas/src/frontend/view.rs @@ -2,7 +2,7 @@ use std::{net::SocketAddr, path::PathBuf}; use serde::{Deserialize, Serialize}; -use crate::{engine::EngineStatus, hashes::InfoHash, torrent::TorrentState}; +use crate::{engine::EngineStatus, hashes::InfoHash, peer::Peer, torrent::TorrentState}; /// Current live engine state maintained by a frontend listener. /// @@ -61,6 +61,38 @@ pub struct PeerView { pub client: Option, /// Whether this peer is currently connected. pub connected: bool, + pub peer_choking: bool, + pub peer_interested: bool, + pub client_choking: bool, + pub client_interested: bool, + pub available_pieces: u64, + pub download_rate_bytes_per_second: u64, + pub upload_rate_bytes_per_second: u64, + pub downloaded_bytes: u64, + pub uploaded_bytes: u64, +} + +impl PeerView { + pub(crate) fn from_peer(peer: &Peer, connected: bool) -> Self { + Self { + address: Some(peer.socket_addr()), + client: peer.id.map(|id| id.client_name().to_string()), + connected, + peer_choking: peer.am_choked(), + peer_interested: peer.interested(), + client_choking: peer.choked(), + client_interested: peer.am_interested(), + available_pieces: u64::try_from(peer.pieces.count_ones()).unwrap_or(u64::MAX), + download_rate_bytes_per_second: u64::try_from(peer.download_rate()) + .unwrap_or(u64::MAX) + .saturating_mul(1024), + upload_rate_bytes_per_second: u64::try_from(peer.upload_rate()) + .unwrap_or(u64::MAX) + .saturating_mul(1024), + downloaded_bytes: u64::try_from(peer.bytes_downloaded()).unwrap_or(u64::MAX), + uploaded_bytes: u64::try_from(peer.bytes_uploaded()).unwrap_or(u64::MAX), + } + } } /// Frontend-safe live tracker identity and latest announce outcome. @@ -68,6 +100,8 @@ pub struct PeerView { pub struct TrackerView { /// Credential-free tracker endpoint label. pub endpoint: String, + /// Whether the tracker actor is running. + pub active: bool, /// Whether the latest announce succeeded. pub healthy: bool, /// Number of peers returned by the latest successful announce. diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index 05a86287..03b232fd 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -22,6 +22,7 @@ use tracing::{Span, debug, info, instrument, trace, warn}; use crate::{ errors::PeerActorError, + frontend::{PeerHandle, PeerView}, hashes::InfoHash, peer::{Peer, PeerId}, protocol::{stream::PeerRecv, *}, @@ -70,6 +71,7 @@ pub(crate) struct PeerActor { pending_message_requests: VecDeque, last_rate_sample: RateSample, settings: PeerSettings, + frontend: PeerHandle, } impl PeerActor { @@ -356,6 +358,7 @@ impl PeerActor { bytes_downloaded, bytes_uploaded, }; + self.frontend.update(PeerView::from_peer(&self.peer, true)); Some(PeerStats { id, @@ -376,13 +379,14 @@ impl Actor for PeerActor { ActorRef, InfoHash, PeerSettings, + PeerHandle, ); 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 (peer, mut stream, supervisor, info_hash, settings, frontend) = args; info!(peer_id = %peer.id.unwrap(), peer_addr = %stream, torrent_id = %info_hash, "Peer connected"); let bitfield = match supervisor.ask(torrent::commands::GetBitfield).await { @@ -414,12 +418,14 @@ impl Actor for PeerActor { pending_block_requests: HashSet::new(), pending_message_requests: VecDeque::with_capacity(settings.pending_message_capacity), settings, + frontend, }) } async fn on_stop( &mut self, _: WeakActorRef, _: ActorStopReason, ) -> Result<(), Self::Error> { + self.frontend.disconnected(); if let Some(peer_id) = self.peer.id && let Err(err) = self .supervisor @@ -671,6 +677,7 @@ impl Message for PeerActor { warn!("Received unexpected handshake from peer"); } } + self.frontend.update(PeerView::from_peer(&self.peer, true)); } } diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index aba5cb91..7515fa0c 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -28,6 +28,7 @@ use crate::{ errors::TorrentError, frontend::{ FrontendHealthLevel, FrontendPublisher, TorrentProgress, TorrentTransfer, TorrentView, + TrackerScope, TrackerView, }, hashes::InfoHash, metainfo::{Info, MetaInfo}, @@ -670,6 +671,19 @@ impl Actor for TorrentActor { let tracker_list = metainfo.announce_list(); let mut trackers = HashMap::new(); for tracker in tracker_list { + let endpoint = tracker.frontend_endpoint(); + let tracker_frontend = frontend.tracker( + TrackerScope { + torrent: torrent_id, + endpoint: endpoint.clone(), + }, + TrackerView { + endpoint, + active: true, + healthy: false, + peers_returned: None, + }, + ); let actor = TrackerActor::supervise( &us, TrackerActorArgs { @@ -681,6 +695,7 @@ impl Actor for TorrentActor { supervisor: us.clone(), scheduler: scheduler.clone(), settings: settings.tracker.clone(), + frontend: tracker_frontend, }, ) .restart_policy(RestartPolicy::Transient) @@ -732,7 +747,7 @@ impl Actor for TorrentActor { piece_manager: PieceManagerProxy::Default(default_manager), settings, }; - actor.frontend.torrent_added(actor.live_view()); + actor.frontend.initialize_torrent(actor.live_view()); Ok(actor) } diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 43951656..8823302c 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{fmt, path::PathBuf}; use kameo::actor::ActorRef; use tokio::sync::oneshot; @@ -13,7 +13,10 @@ use super::{ }; use crate::{ errors::TorrentError, - frontend::{EventSubscription, FrontendPublisher, TorrentCommand, TorrentListener, TorrentView}, + frontend::{ + EventSubscription, FrontendPublisher, PeerHandle, TorrentCommand, TorrentListener, + TorrentView, TrackerHandle, + }, hashes::InfoHash, pieces::PieceManager, }; @@ -23,13 +26,22 @@ use crate::{ /// 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)] +#[derive(Clone)] pub struct Torrent { info_hash: InfoHash, actor: ActorRef, frontend: FrontendPublisher, } +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 { /// Creates a new [`Torrent`] handle from an [`InfoHash`] and a reference /// to its underlying [`TorrentActor`]. @@ -225,6 +237,18 @@ impl Torrent { self.frontend.torrent_view(self.info_hash) } + /// Returns handles for this torrent's currently connected peers. + #[must_use] + pub fn peers(&self) -> Vec { + self.frontend.peer_handles(self.info_hash) + } + + /// Returns handles for this torrent's configured trackers. + #[must_use] + pub fn trackers(&self) -> Vec { + self.frontend.tracker_handles(self.info_hash) + } + fn communication_error(error: impl std::fmt::Display) -> TorrentError { TorrentError::ActorCommunicationFailed { actor_type: "torrent".to_string(), diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index e7204ebb..babc9c8f 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -17,7 +17,6 @@ use super::{ }; use crate::{ errors::TorrentError, - frontend::{PeerView, TrackerView}, hashes::InfoHash, metainfo::Info, peer::{Peer, PeerId, commands::HaveInfoDict}, @@ -39,34 +38,11 @@ pub(crate) mod events { #[instrument(skip(self, peers, from), fields(torrent_id = %self.info_hash(), announce_from = from.kind()))] pub(crate) fn announce(&mut self, peers: Vec, from: AnnounceFrom) { trace!(peer_count = peers.len(), "Received announce message"); - if let AnnounceFrom::Tracker(tracker) = &from { - self.frontend.tracker_announce_succeeded( - self.live_view(), - TrackerView { - endpoint: tracker.frontend_endpoint(), - healthy: true, - peers_returned: Some(u64::try_from(peers.len()).unwrap_or(u64::MAX)), - }, - ); - } for peer in peers { self.append_peer(peer, None); } } - /// Reports a failed tracker announce to live frontend listeners. - #[message(derive(Debug))] - pub(crate) fn tracker_announce_failed(&mut self, tracker: Tracker) { - self.frontend.tracker_announce_failed( - self.live_view(), - TrackerView { - endpoint: tracker.frontend_endpoint(), - healthy: false, - peers_returned: None, - }, - ); - } - /// Sent after an incoming peer initializes a handshake. /// The handshake will be preverified and routed to this torrent instance. /// @@ -182,14 +158,7 @@ pub(crate) mod commands { if let Some(actor) = self.peers.get(&id) { actor.kill(); self.peers.remove(&id); - self.frontend.peer_disconnected( - self.live_view(), - PeerView { - address: None, - client: Some(id.client_name().to_string()), - connected: false, - }, - ); + self.frontend.update_torrent(self.live_view()); } else { warn!("Received kill peer message for unknown peer"); } diff --git a/crates/libtortillas/src/torrent/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index 271e9b2f..72837663 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -10,7 +10,7 @@ use tracing::{debug, instrument, trace, warn}; use super::TorrentActor; use crate::{ - frontend::PeerView, + frontend::{PeerScope, PeerView}, peer::{Peer, PeerActor, PeerId}, protocol::{ messages::{Handshake, PeerMessages}, @@ -105,25 +105,35 @@ 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; - let peer_view = PeerView { - address: Some(peer.socket_addr()), - client: Some(id.client_name().to_string()), - connected: true, - }; - if self.peers.contains_key(&id) { return; } + let peer_frontend = self.frontend.peer_connected( + self.live_view(), + PeerScope { + torrent: info_hash, + peer: id, + }, + PeerView::from_peer(&peer, true), + ); + let peer_actor = PeerActor::spawn_with_mailbox( - (peer, stream, actor_ref, info_hash, peer_settings), + ( + peer, + stream, + actor_ref, + info_hash, + peer_settings, + peer_frontend, + ), match peer_mailbox_size { 0 => mailbox::unbounded(), size => mailbox::bounded(size), }, ); self.peers.insert(id, peer_actor); - self.frontend.peer_connected(self.live_view(), peer_view); + self.frontend.update_torrent(self.live_view()); } #[instrument(skip(self, tell), fields(torrent_id = %self.info_hash(), msg = ?tell))] @@ -164,14 +174,7 @@ impl TorrentActor { } for id in dead_peers { self.peers.remove(&id); - self.frontend.peer_disconnected( - self.live_view(), - PeerView { - address: None, - client: Some(id.client_name().to_string()), - connected: false, - }, - ); + self.frontend.update_torrent(self.live_view()); } } diff --git a/crates/libtortillas/src/tracker/actor.rs b/crates/libtortillas/src/tracker/actor.rs index 83830620..078bfb01 100644 --- a/crates/libtortillas/src/tracker/actor.rs +++ b/crates/libtortillas/src/tracker/actor.rs @@ -19,6 +19,7 @@ use super::{ }; use crate::{ errors::TrackerActorError, + frontend::TrackerHandle, peer::PeerId, settings::TrackerSettings, torrent::{self, TorrentActor}, @@ -33,6 +34,7 @@ pub(crate) struct TrackerActor { next_announce: Option, actor_ref: ActorRef, settings: TrackerSettings, + frontend: TrackerHandle, } #[derive(Clone)] @@ -45,6 +47,7 @@ pub(crate) struct TrackerActorArgs { pub(crate) supervisor: ActorRef, pub(crate) scheduler: ActorRef, pub(crate) settings: TrackerSettings, + pub(crate) frontend: TrackerHandle, } impl Actor for TrackerActor { @@ -61,6 +64,7 @@ impl Actor for TrackerActor { supervisor, scheduler, settings, + frontend, } = state; let info_hash = supervisor @@ -133,12 +137,14 @@ impl Actor for TrackerActor { next_announce: Some(next_announce), actor_ref, settings, + frontend, }) } async fn on_stop( &mut self, _: WeakActorRef, _: ActorStopReason, ) -> Result<(), Self::Error> { + self.frontend.stopped(); if let Some(next_announce) = self.next_announce.take() { next_announce.abort(); } @@ -185,6 +191,9 @@ impl TrackerActor { pub(crate) async fn announce(&mut self) -> Option { match self.tracker.announce().await { Ok(peers) => { + self + .frontend + .announce_succeeded(u64::try_from(peers.len()).unwrap_or(u64::MAX)); if let Err(e) = self .supervisor .tell(torrent::events::Announce { @@ -198,15 +207,7 @@ impl TrackerActor { } Err(e) => { error!(error = %e, "Announce request failed"); - if let Err(send_error) = self - .supervisor - .tell(torrent::events::TrackerAnnounceFailed { - tracker: self.source.clone(), - }) - .await - { - error!(error = %send_error, "Failed to report tracker announce failure"); - } + self.frontend.announce_failed(); } } self.schedule_next_announce().await; diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index 643e2278..54c93e57 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -1,9 +1,13 @@ use std::time::Duration; +use futures::StreamExt; use libtortillas::{ engine::EngineStatus, errors::EngineError, - frontend::{CoreCommand, CoreCommandResult, CoreEventKind, EventStreamError, TorrentCommand}, + frontend::{ + CoreCommand, CoreCommandResult, CoreEventKind, EventStreamError, LivePublisher, + TorrentCommand, + }, prelude::{Engine, Settings, TorrentSource, TorrentState}, }; use tokio::time::{sleep, timeout}; @@ -35,7 +39,7 @@ async fn engine_listener_receives_live_torrent_lifecycle() { let added = timeout(Duration::from_secs(2), async { loop { - let event = engine_listener.recv().await.unwrap(); + let event = engine_listener.next().await.unwrap().unwrap(); if matches!(event.kind, CoreEventKind::TorrentAdded(_)) { break event; } @@ -44,6 +48,11 @@ async fn engine_listener_receives_live_torrent_lifecycle() { .await .unwrap(); assert_eq!(added.torrent(), Some(torrent.info_hash())); + let CoreEventKind::TorrentAdded(added_torrent) = added.kind else { + unreachable!(); + }; + assert_eq!(added_torrent.info_hash(), torrent.info_hash()); + assert_eq!(added_torrent.live_view(), torrent.live_view()); assert_eq!(engine_listener.view().torrent_count, 1); let mut torrent_listener = torrent.listener(); @@ -85,6 +94,46 @@ async fn engine_listener_receives_live_torrent_lifecycle() { engine.shutdown().await.unwrap(); } +#[tokio::test] +async fn generic_live_publisher_implements_async_stream() { + let publisher = LivePublisher::new(0_u8, 4); + let mut listener = publisher.listener(); + + publisher.update(1, "changed"); + + let event = listener.next().await.unwrap().unwrap(); + assert_eq!(event.sequence, 1); + assert_eq!(event.kind, "changed"); + assert_eq!(listener.view(), 1); +} + +#[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.live_view().is_some_and(|view| view.active)); + engine.shutdown().await.unwrap(); + + let stopped = timeout(Duration::from_secs(2), async { + loop { + let event = listener.recv().await.unwrap(); + if matches!(event.kind, CoreEventKind::TrackerStopped { .. }) { + break event; + } + } + }) + .await + .unwrap(); + assert_eq!(stopped.torrent(), Some(torrent.info_hash())); + assert!(listener.view().is_none()); +} + #[tokio::test] async fn engine_listener_receives_graceful_shutdown() { let engine = deterministic_engine(); @@ -168,7 +217,7 @@ async fn lagging_listener_recovers_from_current_live_view() { } #[tokio::test] -async fn live_views_and_events_are_serde_compatible() { +async fn live_views_are_serde_compatible() { let engine = deterministic_engine(); let mut listener = engine.listener(); let _ = engine @@ -177,21 +226,17 @@ async fn live_views_and_events_are_serde_compatible() { }) .await .unwrap(); - let added = timeout(Duration::from_secs(2), async { + timeout(Duration::from_secs(2), async { loop { let event = listener.recv().await.unwrap(); if matches!(event.kind, CoreEventKind::TorrentAdded(_)) { - break event; + break; } } }) .await .unwrap(); - let encoded_event = serde_json::to_string(&added).unwrap(); - let decoded_event = serde_json::from_str(&encoded_event).unwrap(); - assert_eq!(added, decoded_event); - let view = listener.view(); let encoded_view = serde_json::to_string(&view).unwrap(); let decoded_view = serde_json::from_str(&encoded_view).unwrap(); From d4306008d668ba6a2bcbcceac2381ca95709fc57 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:55:16 -0700 Subject: [PATCH 41/77] refactor: simplify live frontend boundaries --- README.md | 5 +- crates/libtortillas/examples/live_frontend.rs | 22 +-- crates/libtortillas/src/engine/mod.rs | 75 ++----- crates/libtortillas/src/facade.rs | 17 +- crates/libtortillas/src/frontend/command.rs | 43 ---- crates/libtortillas/src/frontend/event.rs | 66 +++++-- crates/libtortillas/src/frontend/handle.rs | 144 ++++++++------ crates/libtortillas/src/frontend/listener.rs | 5 +- crates/libtortillas/src/frontend/mod.rs | 9 +- crates/libtortillas/src/frontend/publisher.rs | 184 ++++++++++-------- .../libtortillas/src/frontend/subscription.rs | 36 +--- crates/libtortillas/src/lib.rs | 11 +- crates/libtortillas/src/torrent/handle.rs | 35 ++-- crates/libtortillas/tests/facade.rs | 43 +--- crates/libtortillas/tests/live_frontend.rs | 65 ++----- docs/frontend-integration.md | 62 ++++-- 16 files changed, 376 insertions(+), 446 deletions(-) delete mode 100644 crates/libtortillas/src/frontend/command.rs diff --git a/README.md b/README.md index 6b668a72..683a7c0e 100644 --- a/README.md +++ b/README.md @@ -96,12 +96,13 @@ 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. -Frontends should use the live listeners and typed command API rather than +Frontends should use live listeners for updates and direct `Engine` and +`Torrent` methods for operations rather than polling persistence snapshots. See the [frontend integration guide](docs/frontend-integration.md) and the [`live_frontend` example](crates/libtortillas/examples/live_frontend.rs). diff --git a/crates/libtortillas/examples/live_frontend.rs b/crates/libtortillas/examples/live_frontend.rs index d809ea99..5f52d3d6 100644 --- a/crates/libtortillas/examples/live_frontend.rs +++ b/crates/libtortillas/examples/live_frontend.rs @@ -1,8 +1,7 @@ -use std::{io, path::PathBuf}; +use std::path::PathBuf; use libtortillas::prelude::{ - CoreCommand, CoreCommandResult, CoreEventKind, Engine, EventStreamError, TorrentCommand, - TorrentSource, TorrentState, + CoreEventKind, Engine, EventStreamError, TorrentEventKind, TorrentSource, TorrentState, }; use tracing::{error, info, warn}; @@ -48,22 +47,17 @@ async fn main() -> Result<(), Box> { } }); - let result = engine - .send(CoreCommand::AddTorrent { - source: TorrentSource::torrent_file_path(torrent_path), - }) + let torrent = engine + .add_torrent(TorrentSource::torrent_file_path(torrent_path)) .await?; - let CoreCommandResult::TorrentAdded(torrent) = result else { - return Err(io::Error::other("add command did not return a torrent handle").into()); - }; let mut torrent_listener = torrent.listener(); - torrent.send(TorrentCommand::Pause).await?; + torrent.pause().await?; let paused = loop { let event = torrent_listener.recv().await?; if matches!( event.kind, - CoreEventKind::TorrentStateChanged { + TorrentEventKind::StateChanged { current: TorrentState::Paused, .. } @@ -72,7 +66,7 @@ async fn main() -> Result<(), Box> { } }; info!(sequence = paused.sequence, ?paused.kind, "torrent paused"); - torrent.send(TorrentCommand::Start).await?; + torrent.start().await?; tokio::signal::ctrl_c().await?; if let Some(path) = session_path { @@ -80,7 +74,7 @@ async fn main() -> Result<(), Box> { tokio::fs::write(&path, serde_json::to_vec_pretty(&snapshot)?).await?; info!(?path, "saved resumable engine state"); } - let _ = engine.send(CoreCommand::Shutdown).await?; + engine.shutdown().await?; frontend.await?; Ok(()) } diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 8dd0ce71..580d1b1c 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -61,10 +61,7 @@ use self::commands::{CreateTorrent, GetTorrent, RemoveTorrent, SnapshotEngine, S pub use self::snapshot::{ENGINE_SNAPSHOT_VERSION, EngineSnapshot, EngineStatus}; use crate::{ errors::EngineError, - frontend::{ - CoreCommand, CoreCommandResult, EngineListener, EngineView, EventSubscription, - FrontendPublisher, - }, + frontend::{EngineListener, EngineView, EventSubscription, FrontendPublisher}, hashes::InfoHash, peer::PeerId, settings::Settings, @@ -281,7 +278,7 @@ impl Engine { let metainfo = source.into_metainfo().await?; let info_hash = metainfo.info_hash()?; - let torrent_ref = self + self .actor() .ask(CreateTorrent { metainfo: Box::new(metainfo), @@ -290,11 +287,7 @@ impl Engine { .await .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; - Ok(Torrent::new_with_frontend( - info_hash, - torrent_ref, - self.frontend.clone(), - )) + self.frontend_torrent(info_hash) // We don't need to assign link or insert the ref here because its already // done by the engine actor } @@ -329,7 +322,7 @@ impl Engine { ); } - let torrent_ref = match self + match self .actor() .ask(CreateTorrent { metainfo: Box::new(snapshot.metainfo.clone()), @@ -337,16 +330,12 @@ impl Engine { }) .await { - Ok(torrent) => torrent, + Ok(_) => {} Err(SendError::HandlerError(error)) => return Err(error), Err(error) => return Err(EngineError::Other(anyhow::anyhow!(error.to_string()))), - }; + } - Ok(Torrent::new_with_frontend( - info_hash, - torrent_ref, - self.frontend.clone(), - )) + self.frontend_torrent(info_hash) } /// Restores all torrent sessions from an engine persistence snapshot. @@ -418,17 +407,13 @@ impl Engine { /// Returns a public handle for a torrent managed by this engine. pub async fn torrent(&self, info_hash: InfoHash) -> Result { - let actor = match self.actor().ask(GetTorrent { info_hash }).await { - Ok(actor) => actor, + match self.actor().ask(GetTorrent { info_hash }).await { + Ok(_) => {} Err(SendError::HandlerError(err)) => return Err(err), Err(err) => return Err(EngineError::Other(anyhow::anyhow!(err.to_string()))), - }; + } - Ok(Torrent::new_with_frontend( - info_hash, - actor, - self.frontend.clone(), - )) + self.frontend_torrent(info_hash) } /// Removes a torrent from the engine and stops its actor gracefully. @@ -479,32 +464,6 @@ impl Engine { .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string()))) } - /// Sends a typed frontend command to the engine or one of its torrents. - pub async fn send(&self, command: CoreCommand) -> Result { - match command { - CoreCommand::AddTorrent { source } => self - .add_torrent(source) - .await - .map(CoreCommandResult::TorrentAdded), - CoreCommand::StartAll => { - self.start_all().await?; - Ok(CoreCommandResult::Applied) - } - CoreCommand::Torrent { torrent, command } => { - self.torrent(torrent).await?.send(command).await?; - Ok(CoreCommandResult::Applied) - } - CoreCommand::RemoveTorrent { torrent } => { - self.remove_torrent(torrent).await?; - Ok(CoreCommandResult::Applied) - } - CoreCommand::Shutdown => { - self.shutdown().await?; - Ok(CoreCommandResult::Applied) - } - } - } - /// Subscribes to typed engine and torrent events as they happen. /// /// The returned stream is bounded. A lagging frontend can read @@ -529,6 +488,14 @@ impl Engine { pub fn live_view(&self) -> EngineView { self.frontend.view() } + + fn frontend_torrent(&self, info_hash: InfoHash) -> Result { + self.frontend.torrent_handle(info_hash).ok_or_else(|| { + EngineError::Other(anyhow::anyhow!( + "torrent {info_hash} is missing its frontend handle" + )) + }) + } } impl Default for Engine { @@ -772,11 +739,11 @@ mod tests { .await .unwrap(); assert_eq!(peer.torrent(), info_hash); - assert!(peer.live_view().is_some()); + assert!(peer.live_view().connected); let _peer_listener = peer.listener(); engine.shutdown().await.unwrap(); - assert!(peer.live_view().is_none()); + assert!(!peer.live_view().connected); receive_task.abort(); seed.kill(); } diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index f3e42d1a..e118dacb 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -9,23 +9,22 @@ //! # Example //! //! ```no_run -//! use libtortillas::facade::{CoreCommand, EngineHandle, TorrentSource}; +//! use libtortillas::facade::{EngineHandle, TorrentSource}; //! //! let engine = EngineHandle::default(); -//! let command = CoreCommand::AddTorrent { -//! source: TorrentSource::magnet("magnet:?xt=urn:btih:..."), -//! }; +//! let source = TorrentSource::magnet("magnet:?xt=urn:btih:..."); +//! # let _ = (engine, source); //! ``` use crate::{engine::Engine, torrent::Torrent}; pub use crate::{ engine::{EngineSnapshot, EngineStatus, TorrentSource}, frontend::{ - CoreCommand, CoreCommandResult, CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, - EngineListener, EngineView, EventListener, EventStreamError, EventSubscription, - FrontendHealth, FrontendHealthLevel, LivePublisher, PeerHandle, PeerListener, PeerView, - Sequenced, TorrentCommand, TorrentListener, TorrentProgress, TorrentTransfer, TorrentView, - TrackerHandle, TrackerListener, TrackerView, + CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, EngineListener, EngineView, EventListener, + EventStreamError, EventSubscription, FrontendHealth, FrontendHealthLevel, LivePublisher, + PeerEvent, PeerEventKind, PeerHandle, PeerListener, PeerView, Sequenced, TorrentEvent, + TorrentEventKind, TorrentListener, TorrentProgress, TorrentTransfer, TorrentView, + TrackerEvent, TrackerEventKind, TrackerHandle, TrackerListener, TrackerView, }, torrent::TorrentSnapshot, }; diff --git a/crates/libtortillas/src/frontend/command.rs b/crates/libtortillas/src/frontend/command.rs deleted file mode 100644 index 203c6245..00000000 --- a/crates/libtortillas/src/frontend/command.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::path::PathBuf; - -use crate::{engine::TorrentSource, hashes::InfoHash, torrent::Torrent}; - -/// Typed message accepted by [`Engine::send`](crate::engine::Engine::send). -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum CoreCommand { - AddTorrent { - source: TorrentSource, - }, - StartAll, - /// Sends an existing torrent command through the engine by info hash. - Torrent { - torrent: InfoHash, - command: TorrentCommand, - }, - RemoveTorrent { - torrent: InfoHash, - }, - Shutdown, -} - -/// Typed message accepted by [`Torrent::send`](crate::torrent::Torrent::send). -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum TorrentCommand { - Start, - Pause, - SetOutputPath(PathBuf), - SetAutostart(bool), - SetSufficientPeers(usize), -} - -/// Result of sending a [`CoreCommand`] to an engine. -#[derive(Debug, Clone)] -#[must_use] -pub enum CoreCommandResult { - /// The command was applied and has no new handle to return. - Applied, - /// An add command created this torrent handle. - TorrentAdded(Torrent), -} diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index 03e463bb..ddae82ad 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -21,6 +21,12 @@ pub struct Sequenced { /// A sequenced event emitted by the engine's frontend publisher. pub type CoreEvent = Sequenced; +/// A sequenced event emitted by a torrent's live publisher. +pub type TorrentEvent = Sequenced; +/// A sequenced event emitted by a peer's live publisher. +pub type PeerEvent = Sequenced; +/// A sequenced event emitted by a tracker's live publisher. +pub type TrackerEvent = Sequenced; impl Sequenced { /// Returns the torrent associated with this event, when applicable. @@ -34,7 +40,7 @@ impl Sequenced { #[derive(Debug, Clone)] #[non_exhaustive] pub enum CoreEventKind { - /// The engine finished starting and is ready for commands. + /// The engine finished starting and is ready for operations. EngineStarted(EngineView), /// A torrent was added to the engine. TorrentAdded(Torrent), @@ -82,6 +88,44 @@ pub enum CoreEventKind { Shutdown(EngineView), } +/// Events emitted by one torrent's independent live publisher. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum TorrentEventKind { + Updated, + StateChanged { + previous: TorrentState, + current: TorrentState, + }, + MetadataResolved, + ProgressChanged(TorrentProgress), + PeerConnected(PeerHandle), + PeerUpdated(PeerHandle), + PeerDisconnected(PeerHandle), + TrackerAnnounceSucceeded(TrackerHandle), + TrackerAnnounceFailed(TrackerHandle), + TrackerStopped(TrackerHandle), + Health(FrontendHealth), + Removed, +} + +/// Events emitted by one peer's independent live publisher. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PeerEventKind { + Updated, + 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, + Stopped, +} + impl CoreEventKind { /// Returns the torrent associated with this event, when applicable. #[must_use] @@ -103,26 +147,6 @@ impl CoreEventKind { Self::Health(health) => health.torrent, } } - - pub(crate) fn is_peer(&self, scope: super::handle::PeerScope) -> bool { - matches!( - self, - Self::PeerConnected { peer, .. } - | Self::PeerUpdated { peer, .. } - | Self::PeerDisconnected { peer, .. } - if peer.scope() == scope - ) - } - - pub(crate) fn is_tracker(&self, scope: &super::handle::TrackerScope) -> bool { - matches!( - self, - Self::TrackerAnnounceSucceeded { tracker, .. } - | Self::TrackerAnnounceFailed { tracker, .. } - | Self::TrackerStopped { tracker, .. } - if tracker.scope() == scope - ) - } } /// A recoverable or terminal health report intended for user interfaces. diff --git a/crates/libtortillas/src/frontend/handle.rs b/crates/libtortillas/src/frontend/handle.rs index 0e098ee5..a8038f2e 100644 --- a/crates/libtortillas/src/frontend/handle.rs +++ b/crates/libtortillas/src/frontend/handle.rs @@ -1,6 +1,9 @@ use std::{fmt, net::SocketAddr}; -use super::{EventListener, EventSubscription, FrontendPublisher, PeerView, TrackerView}; +use super::{ + DEFAULT_EVENT_CAPACITY, EventListener, EventSubscription, FrontendPublisher, LivePublisher, + PeerEventKind, PeerView, TrackerEventKind, TrackerView, +}; use crate::{hashes::InfoHash, peer::PeerId}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -20,21 +23,16 @@ pub(crate) struct TrackerScope { pub struct PeerHandle { scope: PeerScope, frontend: FrontendPublisher, -} - -impl fmt::Debug for PeerHandle { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("PeerHandle") - .field("torrent", &self.scope.torrent) - .field("peer", &self.scope.peer) - .finish_non_exhaustive() - } + live: LivePublisher, } impl PeerHandle { - pub(crate) const fn new(scope: PeerScope, frontend: FrontendPublisher) -> Self { - Self { scope, frontend } + pub(crate) fn new(scope: PeerScope, view: PeerView, frontend: FrontendPublisher) -> Self { + Self { + scope, + frontend, + live: LivePublisher::new(view, DEFAULT_EVENT_CAPACITY), + } } /// Torrent that owns this peer connection. @@ -52,27 +50,25 @@ impl PeerHandle { /// Latest known network address. #[must_use] pub fn address(&self) -> Option { - self.live_view().and_then(|view| view.address) + self.live_view().address } /// Subscribes to events for this peer only. #[must_use] - pub fn subscribe(&self) -> EventSubscription { - self.frontend.subscribe_peer(self.scope) + pub fn subscribe(&self) -> EventSubscription { + self.live.subscribe() } /// Creates a stream-compatible listener for this peer. #[must_use] pub fn listener(&self) -> PeerListener { - let frontend = self.frontend.clone(); - let scope = self.scope; - PeerListener::new(self.subscribe(), move || frontend.peer_view(scope)) + self.live.listener() } - /// Returns the latest peer view, or `None` after its torrent is removed. + /// Returns the latest peer view, including its terminal disconnected state. #[must_use] - pub fn live_view(&self) -> Option { - self.frontend.peer_view(self.scope) + pub fn live_view(&self) -> PeerView { + self.live.view() } pub(crate) const fn scope(&self) -> PeerScope { @@ -80,14 +76,31 @@ impl PeerHandle { } pub(crate) fn update(&self, view: PeerView) { - self.frontend.peer_updated(self, view); + self.live.update(view, PeerEventKind::Updated); + self.frontend.peer_updated(self); } pub(crate) fn disconnected(&self) { + let mut view = self.live_view(); + if !view.connected { + return; + } + view.connected = false; + self.live.update(view, PeerEventKind::Disconnected); self.frontend.peer_disconnected(self.clone()); } } +impl fmt::Debug for PeerHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PeerHandle") + .field("torrent", &self.scope.torrent) + .field("peer", &self.scope.peer) + .finish_non_exhaustive() + } +} + impl PartialEq for PeerHandle { fn eq(&self, other: &Self) -> bool { self.scope == other.scope @@ -101,21 +114,16 @@ impl Eq for PeerHandle {} pub struct TrackerHandle { scope: TrackerScope, frontend: FrontendPublisher, -} - -impl fmt::Debug for TrackerHandle { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("TrackerHandle") - .field("torrent", &self.scope.torrent) - .field("endpoint", &self.scope.endpoint) - .finish_non_exhaustive() - } + live: LivePublisher, } impl TrackerHandle { - pub(crate) fn new(scope: TrackerScope, frontend: FrontendPublisher) -> Self { - Self { scope, frontend } + pub(crate) fn new(scope: TrackerScope, view: TrackerView, frontend: FrontendPublisher) -> Self { + Self { + scope, + frontend, + live: LivePublisher::new(view, DEFAULT_EVENT_CAPACITY), + } } /// Torrent that owns this tracker. @@ -132,22 +140,20 @@ impl TrackerHandle { /// Subscribes to events for this tracker only. #[must_use] - pub fn subscribe(&self) -> EventSubscription { - self.frontend.subscribe_tracker(self.scope.clone()) + pub fn subscribe(&self) -> EventSubscription { + self.live.subscribe() } /// Creates a stream-compatible listener for this tracker. #[must_use] pub fn listener(&self) -> TrackerListener { - let frontend = self.frontend.clone(); - let scope = self.scope.clone(); - TrackerListener::new(self.subscribe(), move || frontend.tracker_view(&scope)) + self.live.listener() } - /// Returns the current tracker view, or `None` after removal. + /// Returns the latest tracker view, including its terminal stopped state. #[must_use] - pub fn live_view(&self) -> Option { - self.frontend.tracker_view(&self.scope) + pub fn live_view(&self) -> TrackerView { + self.live.view() } pub(crate) fn scope(&self) -> &TrackerScope { @@ -155,34 +161,46 @@ impl TrackerHandle { } pub(crate) fn announce_succeeded(&self, peers_returned: u64) { - self.frontend.tracker_announce_succeeded( - self, - TrackerView { - endpoint: self.scope.endpoint.clone(), - active: true, - healthy: true, - peers_returned: Some(peers_returned), - }, - ); + let mut view = self.live_view(); + view.active = true; + view.healthy = true; + view.peers_returned = Some(peers_returned); + self + .live + .update(view, TrackerEventKind::AnnounceSucceeded { peers_returned }); + self.frontend.tracker_announce_succeeded(self); } pub(crate) fn announce_failed(&self) { - self.frontend.tracker_announce_failed( - self, - TrackerView { - endpoint: self.scope.endpoint.clone(), - active: true, - healthy: false, - peers_returned: None, - }, - ); + let mut view = self.live_view(); + view.active = true; + view.healthy = false; + view.peers_returned = None; + self.live.update(view, TrackerEventKind::AnnounceFailed); + self.frontend.tracker_announce_failed(self); } pub(crate) fn stopped(&self) { + let mut view = self.live_view(); + if !view.active { + return; + } + view.active = false; + self.live.update(view, TrackerEventKind::Stopped); self.frontend.tracker_stopped(self); } } +impl fmt::Debug for TrackerHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TrackerHandle") + .field("torrent", &self.scope.torrent) + .field("endpoint", &self.scope.endpoint) + .finish_non_exhaustive() + } +} + impl PartialEq for TrackerHandle { fn eq(&self, other: &Self) -> bool { self.scope == other.scope @@ -198,7 +216,7 @@ impl fmt::Display for TrackerHandle { } /// Live listener scoped to one peer. -pub type PeerListener = EventListener>; +pub type PeerListener = EventListener; /// Live listener scoped to one tracker. -pub type TrackerListener = EventListener>; +pub type TrackerListener = EventListener; diff --git a/crates/libtortillas/src/frontend/listener.rs b/crates/libtortillas/src/frontend/listener.rs index aed7a069..4f2ad84f 100644 --- a/crates/libtortillas/src/frontend/listener.rs +++ b/crates/libtortillas/src/frontend/listener.rs @@ -8,7 +8,8 @@ use std::{ use futures::Stream; use super::{ - CoreEventKind, EngineView, EventStreamError, EventSubscription, Sequenced, TorrentView, + CoreEventKind, EngineView, EventStreamError, EventSubscription, Sequenced, TorrentEventKind, + TorrentView, }; /// A generic event stream paired with a synchronous current-state reader. @@ -69,4 +70,4 @@ impl fmt::Debug for EventListener { pub type EngineListener = EventListener; /// Live listener scoped to one torrent. -pub type TorrentListener = EventListener>; +pub type TorrentListener = EventListener, TorrentEventKind>; diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index 4e2a3d2f..728571bf 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -1,10 +1,9 @@ //! Live, frontend-facing API contracts. //! -//! This module contains the typed events, commands, listeners, and live views +//! This module contains typed events, listeners, publishers, and live views //! intended for application and UI integrations. Frontends should prefer these //! types over actor messages, protocol internals, or snapshot polling. -mod command; mod event; mod handle; mod listener; @@ -12,8 +11,10 @@ mod publisher; mod subscription; mod view; -pub use command::{CoreCommand, CoreCommandResult, TorrentCommand}; -pub use event::{CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, Sequenced}; +pub use event::{ + CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, PeerEvent, PeerEventKind, + Sequenced, TorrentEvent, TorrentEventKind, TrackerEvent, TrackerEventKind, +}; pub use handle::{PeerHandle, PeerListener, TrackerHandle, TrackerListener}; pub(crate) use handle::{PeerScope, TrackerScope}; pub use listener::{EngineListener, EventListener, TorrentListener}; diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index f251d9d8..f8815d13 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -10,7 +10,8 @@ use tokio::sync::broadcast; use super::{ CoreEventKind, EngineView, EventListener, EventSubscription, FrontendHealth, - FrontendHealthLevel, PeerHandle, PeerView, Sequenced, TorrentView, TrackerHandle, TrackerView, + FrontendHealthLevel, PeerHandle, PeerView, Sequenced, TorrentEventKind, TorrentView, + TrackerHandle, TrackerView, handle::{PeerScope, TrackerScope}, }; use crate::{ @@ -72,13 +73,7 @@ where /// Subscribes to all future events from this publisher. #[must_use] pub fn subscribe(&self) -> EventSubscription { - EventSubscription::new(self.inner.events.clone(), None) - } - - pub(crate) fn subscribe_where( - &self, filter: impl Fn(&E) -> bool + Send + Sync + 'static, - ) -> EventSubscription { - EventSubscription::new(self.inner.events.clone(), Some(Arc::new(filter))) + EventSubscription::new(self.inner.events.clone()) } /// Creates a stream-compatible listener paired with the current view. @@ -129,8 +124,8 @@ where pub(crate) struct FrontendPublisher { live: LivePublisher, torrents: Arc>>, - peers: Arc>>, - trackers: Arc>>, + peers: Arc>>, + trackers: Arc>>, } impl FrontendPublisher { @@ -158,22 +153,6 @@ impl FrontendPublisher { self.live.subscribe() } - pub(crate) fn subscribe_torrent(&self, torrent: InfoHash) -> EventSubscription { - self - .live - .subscribe_where(move |event| event.torrent() == Some(torrent)) - } - - pub(crate) fn subscribe_peer(&self, scope: PeerScope) -> EventSubscription { - self.live.subscribe_where(move |event| event.is_peer(scope)) - } - - pub(crate) fn subscribe_tracker(&self, scope: TrackerScope) -> EventSubscription { - self - .live - .subscribe_where(move |event| event.is_tracker(&scope)) - } - pub(crate) fn view(&self) -> EngineView { self.live.view() } @@ -187,27 +166,23 @@ impl FrontendPublisher { .find(|view| view.info_hash == torrent) } - pub(crate) fn peer_view(&self, scope: PeerScope) -> Option { - read_lock(&self.peers).get(&scope).cloned() - } - - pub(crate) fn tracker_view(&self, scope: &TrackerScope) -> Option { - read_lock(&self.trackers).get(scope).cloned() + pub(crate) fn torrent_handle(&self, torrent: InfoHash) -> Option { + self.read_torrents().get(&torrent).cloned() } pub(crate) fn peer_handles(&self, torrent: InfoHash) -> Vec { read_lock(&self.peers) - .iter() - .filter(|(scope, view)| scope.torrent == torrent && view.connected) - .map(|(scope, _)| PeerHandle::new(*scope, self.clone())) + .values() + .filter(|peer| peer.torrent() == torrent && peer.live_view().connected) + .cloned() .collect() } pub(crate) fn tracker_handles(&self, torrent: InfoHash) -> Vec { read_lock(&self.trackers) - .keys() - .filter(|scope| scope.torrent == torrent) - .map(|scope| TrackerHandle::new(scope.clone(), self.clone())) + .values() + .filter(|tracker| tracker.torrent() == torrent) + .cloned() .collect() } @@ -237,13 +212,13 @@ impl FrontendPublisher { } pub(crate) fn update_torrent(&self, torrent: TorrentView) { - if let Some(torrent) = self.update_torrent_entry(torrent) { + if let Some(torrent) = self.publish_torrent(torrent, TorrentEventKind::Updated) { self.publish(CoreEventKind::TorrentUpdated(torrent)); } } pub(crate) fn metadata_resolved(&self, torrent: TorrentView) { - if let Some(torrent) = self.update_torrent_entry(torrent) { + if let Some(torrent) = self.publish_torrent(torrent, TorrentEventKind::MetadataResolved) { self.publish(CoreEventKind::MetadataResolved(torrent)); } } @@ -251,7 +226,10 @@ impl FrontendPublisher { pub(crate) fn progress_changed(&self, torrent: TorrentView) { let info_hash = torrent.info_hash; let progress = torrent.progress.clone(); - if self.update_torrent_entry(torrent).is_some() { + if self + .publish_torrent(torrent, TorrentEventKind::ProgressChanged(progress.clone())) + .is_some() + { self.publish(CoreEventKind::ProgressChanged { torrent: info_hash, progress, @@ -263,9 +241,12 @@ impl FrontendPublisher { &self, torrent: TorrentView, scope: PeerScope, view: PeerView, ) -> PeerHandle { let info_hash = torrent.info_hash; - write_lock(&self.peers).insert(scope, view); - let peer = PeerHandle::new(scope, self.clone()); - if self.update_torrent_entry(torrent).is_some() { + let peer = PeerHandle::new(scope, view, self.clone()); + write_lock(&self.peers).insert(scope, peer.clone()); + if self + .publish_torrent(torrent, TorrentEventKind::PeerConnected(peer.clone())) + .is_some() + { self.publish(CoreEventKind::PeerConnected { torrent: info_hash, peer: peer.clone(), @@ -274,37 +255,51 @@ impl FrontendPublisher { peer } - pub(crate) fn peer_updated(&self, peer: &PeerHandle, view: PeerView) { - let scope = peer.scope(); - if let Some(current) = write_lock(&self.peers).get_mut(&scope) { - *current = view; + pub(crate) fn peer_updated(&self, peer: &PeerHandle) { + if read_lock(&self.peers).contains_key(&peer.scope()) { + if let Some(torrent) = self.read_torrents().get(&peer.torrent()).cloned() + && let Some(view) = self.torrent_view(peer.torrent()) + { + torrent.publish(view, TorrentEventKind::PeerUpdated(peer.clone())); + } self.publish(CoreEventKind::PeerUpdated { - torrent: scope.torrent, + torrent: peer.torrent(), peer: peer.clone(), }); } } pub(crate) fn peer_disconnected(&self, peer: PeerHandle) { - let scope = peer.scope(); - if let Some(view) = write_lock(&self.peers).get_mut(&scope) { - view.connected = false; + if read_lock(&self.peers).contains_key(&peer.scope()) { + if let Some(torrent) = self.read_torrents().get(&peer.torrent()).cloned() + && let Some(view) = self.torrent_view(peer.torrent()) + { + torrent.publish(view, TorrentEventKind::PeerDisconnected(peer.clone())); + } self.publish(CoreEventKind::PeerDisconnected { - torrent: scope.torrent, + torrent: peer.torrent(), peer, }); } } pub(crate) fn tracker(&self, scope: TrackerScope, view: TrackerView) -> TrackerHandle { - write_lock(&self.trackers).insert(scope.clone(), view); - TrackerHandle::new(scope, self.clone()) + let tracker = TrackerHandle::new(scope.clone(), view, self.clone()); + write_lock(&self.trackers).insert(scope, tracker.clone()); + tracker } - pub(crate) fn tracker_announce_succeeded(&self, tracker: &TrackerHandle, view: TrackerView) { + pub(crate) fn tracker_announce_succeeded(&self, tracker: &TrackerHandle) { let scope = tracker.scope(); - if let Some(current) = write_lock(&self.trackers).get_mut(scope) { - *current = view; + if read_lock(&self.trackers).contains_key(scope) { + if let Some(torrent) = self.read_torrents().get(&scope.torrent).cloned() + && let Some(view) = self.torrent_view(scope.torrent) + { + torrent.publish( + view, + TorrentEventKind::TrackerAnnounceSucceeded(tracker.clone()), + ); + } self.publish(CoreEventKind::TrackerAnnounceSucceeded { torrent: scope.torrent, tracker: tracker.clone(), @@ -312,10 +307,17 @@ impl FrontendPublisher { } } - pub(crate) fn tracker_announce_failed(&self, tracker: &TrackerHandle, view: TrackerView) { + pub(crate) fn tracker_announce_failed(&self, tracker: &TrackerHandle) { let scope = tracker.scope(); - if let Some(current) = write_lock(&self.trackers).get_mut(scope) { - *current = view; + if read_lock(&self.trackers).contains_key(scope) { + if let Some(torrent) = self.read_torrents().get(&scope.torrent).cloned() + && let Some(view) = self.torrent_view(scope.torrent) + { + torrent.publish( + view, + TorrentEventKind::TrackerAnnounceFailed(tracker.clone()), + ); + } self.publish(CoreEventKind::TrackerAnnounceFailed { torrent: scope.torrent, tracker: tracker.clone(), @@ -325,8 +327,12 @@ impl FrontendPublisher { pub(crate) fn tracker_stopped(&self, tracker: &TrackerHandle) { let scope = tracker.scope(); - if let Some(view) = write_lock(&self.trackers).get_mut(scope) { - view.active = false; + if read_lock(&self.trackers).contains_key(scope) { + if let Some(torrent) = self.read_torrents().get(&scope.torrent).cloned() + && let Some(view) = self.torrent_view(scope.torrent) + { + torrent.publish(view, TorrentEventKind::TrackerStopped(tracker.clone())); + } self.publish(CoreEventKind::TrackerStopped { torrent: scope.torrent, tracker: tracker.clone(), @@ -337,17 +343,30 @@ impl FrontendPublisher { pub(crate) fn health( &self, torrent: Option, level: FrontendHealthLevel, message: impl Into, ) { - self.publish(CoreEventKind::Health(FrontendHealth { + let health = FrontendHealth { torrent, level, message: message.into(), - })); + }; + if let Some(info_hash) = torrent + && let Some(handle) = self.read_torrents().get(&info_hash).cloned() + && let Some(view) = self.torrent_view(info_hash) + { + handle.publish(view, TorrentEventKind::Health(health.clone())); + } + self.publish(CoreEventKind::Health(health)); } pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { let info_hash = torrent.info_hash; let current = torrent.state; - if self.update_torrent_entry(torrent).is_some() { + if self + .publish_torrent( + torrent, + TorrentEventKind::StateChanged { previous, current }, + ) + .is_some() + { self.publish(CoreEventKind::TorrentStateChanged { torrent: info_hash, previous, @@ -364,24 +383,25 @@ impl FrontendPublisher { view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); }); let peers = read_lock(&self.peers) - .iter() - .filter(|(scope, view)| scope.torrent == torrent && view.connected) - .map(|(scope, _)| *scope) + .values() + .filter(|peer| peer.torrent() == torrent && peer.live_view().connected) + .cloned() .collect::>(); - for scope in peers { - self.peer_disconnected(PeerHandle::new(scope, self.clone())); + for peer in peers { + peer.disconnected(); } write_lock(&self.peers).retain(|scope, _| scope.torrent != torrent); let trackers = read_lock(&self.trackers) - .keys() - .filter(|scope| scope.torrent == torrent) + .values() + .filter(|tracker| tracker.torrent() == torrent && tracker.live_view().active) .cloned() .collect::>(); - for scope in &trackers { - self.tracker_stopped(&TrackerHandle::new(scope.clone(), self.clone())); + for tracker in &trackers { + tracker.stopped(); } write_lock(&self.trackers).retain(|scope, _| scope.torrent != torrent); if let Some(torrent) = self.write_torrents().remove(&torrent) { + torrent.removed(); self.publish(CoreEventKind::TorrentRemoved(torrent)); } } @@ -432,6 +452,12 @@ impl FrontendPublisher { .flatten() } + fn publish_torrent(&self, view: TorrentView, event: TorrentEventKind) -> Option { + let torrent = self.update_torrent_entry(view.clone())?; + torrent.publish(view, event); + Some(torrent) + } + fn read_torrents(&self) -> RwLockReadGuard<'_, HashMap> { read_lock(&self.torrents) } @@ -475,8 +501,8 @@ mod tests { downloaded_bytes: 0, uploaded_bytes: 0, }; - write_lock(&frontend.peers).insert(scope, view.clone()); - let peer = PeerHandle::new(scope, frontend); + let peer = PeerHandle::new(scope, view.clone(), frontend.clone()); + write_lock(&frontend.peers).insert(scope, peer.clone()); let mut listener = peer.listener(); let mut updated = view; updated.downloaded_bytes = 16; @@ -484,7 +510,7 @@ mod tests { peer.update(updated); let event = listener.recv().await.unwrap(); - assert!(matches!(event.kind, CoreEventKind::PeerUpdated { .. })); - assert_eq!(listener.view().unwrap().downloaded_bytes, 16); + assert_eq!(event.kind, super::super::PeerEventKind::Updated); + assert_eq!(listener.view().downloaded_bytes, 16); } } diff --git a/crates/libtortillas/src/frontend/subscription.rs b/crates/libtortillas/src/frontend/subscription.rs index d562c2e8..d5fca982 100644 --- a/crates/libtortillas/src/frontend/subscription.rs +++ b/crates/libtortillas/src/frontend/subscription.rs @@ -1,7 +1,6 @@ use std::{ fmt, pin::Pin, - sync::Arc, task::{Context, Poll}, }; @@ -12,8 +11,6 @@ use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; use super::{CoreEventKind, Sequenced}; -type EventFilter = Arc bool + Send + Sync>; - /// A generic, lag-aware subscription to events from a live publisher. /// /// `EventSubscription` implements [`Stream`], so applications can use the @@ -22,17 +19,13 @@ type EventFilter = Arc bool + Send + Sync>; pub struct EventSubscription { sender: broadcast::Sender>, stream: BroadcastStream>, - filter: Option>, } impl EventSubscription { - pub(crate) fn new( - sender: broadcast::Sender>, filter: Option>, - ) -> Self { + pub(crate) fn new(sender: broadcast::Sender>) -> Self { Self { stream: BroadcastStream::new(sender.subscribe()), sender, - filter, } } @@ -44,10 +37,10 @@ impl EventSubscription { } /// Creates another subscription beginning at the publisher's current - /// event position and retaining this subscription's filter. + /// event position. #[must_use] pub fn resubscribe(&self) -> Self { - Self::new(self.sender.clone(), self.filter.clone()) + Self::new(self.sender.clone()) } } @@ -55,23 +48,13 @@ impl Stream for EventSubscription { type Item = Result, EventStreamError>; fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { - loop { - match Pin::new(&mut self.stream).poll_next(context) { - Poll::Ready(Some(Ok(event))) => { - if self - .filter - .as_ref() - .is_none_or(|filter| filter(&event.kind)) - { - return Poll::Ready(Some(Ok(event))); - } - } - Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(events)))) => { - return Poll::Ready(Some(Err(EventStreamError::Lagged(events)))); - } - Poll::Ready(None) => return Poll::Ready(None), - Poll::Pending => return Poll::Pending, + 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, } } } @@ -81,7 +64,6 @@ impl fmt::Debug for EventSubscription { formatter .debug_struct("EventSubscription") .field("receiver_count", &self.sender.receiver_count()) - .field("filtered", &self.filter.is_some()) .finish_non_exhaustive() } } diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index 1e1bdabd..eded16bc 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -20,16 +20,15 @@ //! 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 live engine, torrent, +//! [`facade::CoreEvent`], and live engine, torrent, //! peer, and tracker views. //! //! ```no_run -//! use libtortillas::prelude::{CoreCommand, EngineHandle, TorrentSource}; +//! use libtortillas::prelude::{EngineHandle, TorrentSource}; //! -//! let _engine = EngineHandle::default(); -//! let _command = CoreCommand::AddTorrent { -//! source: TorrentSource::magnet("magnet:?xt=urn:btih:..."), -//! }; +//! let engine = EngineHandle::default(); +//! let source = TorrentSource::magnet("magnet:?xt=urn:btih:..."); +//! # let _ = (engine, source); //! ``` //! //! # Advanced APIs diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 8823302c..e85afc3d 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -14,8 +14,8 @@ use super::{ use crate::{ errors::TorrentError, frontend::{ - EventSubscription, FrontendPublisher, PeerHandle, TorrentCommand, TorrentListener, - TorrentView, TrackerHandle, + DEFAULT_EVENT_CAPACITY, EventSubscription, FrontendPublisher, LivePublisher, PeerHandle, + TorrentEventKind, TorrentListener, TorrentView, TrackerHandle, }, hashes::InfoHash, pieces::PieceManager, @@ -31,6 +31,7 @@ pub struct Torrent { info_hash: InfoHash, actor: ActorRef, frontend: FrontendPublisher, + live: LivePublisher, TorrentEventKind>, } impl fmt::Debug for Torrent { @@ -56,6 +57,7 @@ impl Torrent { Self { info_hash, actor, + live: LivePublisher::new(frontend.torrent_view(info_hash), DEFAULT_EVENT_CAPACITY), frontend, } } @@ -204,29 +206,16 @@ impl Torrent { Ok(()) } - /// Sends a typed frontend command directly to this torrent. - pub async fn send(&self, command: TorrentCommand) -> Result<(), TorrentError> { - match command { - TorrentCommand::Start => self.start().await, - TorrentCommand::Pause => self.pause().await, - TorrentCommand::SetOutputPath(path) => self.with_output_folder(path).await, - TorrentCommand::SetAutostart(enabled) => self.set_auto_start(enabled).await, - TorrentCommand::SetSufficientPeers(peers) => self.set_sufficient_peers(peers).await, - } - } - /// Subscribes to live events for this torrent only. #[must_use] - pub fn subscribe(&self) -> EventSubscription { - self.frontend.subscribe_torrent(self.info_hash) + pub fn subscribe(&self) -> EventSubscription { + self.live.subscribe() } /// Creates a live listener scoped to this torrent. #[must_use] pub fn listener(&self) -> TorrentListener { - let frontend = self.frontend.clone(); - let info_hash = self.info_hash; - TorrentListener::new(self.subscribe(), move || frontend.torrent_view(info_hash)) + self.live.listener() } /// Returns the latest display-oriented state maintained for this torrent. @@ -234,7 +223,7 @@ impl Torrent { /// This returns `None` after the torrent has been removed from its engine. #[must_use] pub fn live_view(&self) -> Option { - self.frontend.torrent_view(self.info_hash) + self.live.view() } /// Returns handles for this torrent's currently connected peers. @@ -249,6 +238,14 @@ impl Torrent { self.frontend.tracker_handles(self.info_hash) } + pub(crate) fn publish(&self, view: TorrentView, event: TorrentEventKind) { + self.live.update(Some(view), event); + } + + pub(crate) fn removed(&self) { + self.live.update(None, TorrentEventKind::Removed); + } + fn communication_error(error: impl std::fmt::Display) -> TorrentError { TorrentError::ActorCommunicationFailed { actor_type: "torrent".to_string(), diff --git a/crates/libtortillas/tests/facade.rs b/crates/libtortillas/tests/facade.rs index 55fdb48b..6a1b4f71 100644 --- a/crates/libtortillas/tests/facade.rs +++ b/crates/libtortillas/tests/facade.rs @@ -1,23 +1,17 @@ -use std::path::PathBuf; - use libtortillas::{ facade::{EngineSnapshot, TorrentSnapshot}, - hashes::InfoHash, - prelude::{CoreCommand, EngineHandle, TorrentCommand, TorrentSource}, + prelude::{EngineHandle, EventSubscription, PeerEventKind, TorrentEventKind, TrackerEventKind}, }; #[test] fn prelude_exposes_frontend_facade_types() { - let command = CoreCommand::AddTorrent { - source: TorrentSource::TorrentFilePath(PathBuf::from("ubuntu.torrent")), - }; + fn accepts_torrent_events(_: Option>) {} + fn accepts_peer_events(_: Option>) {} + fn accepts_tracker_events(_: Option>) {} - match command { - CoreCommand::AddTorrent { - source: TorrentSource::TorrentFilePath(path), - } => assert_eq!(path, PathBuf::from("ubuntu.torrent")), - other => panic!("unexpected command: {other:?}"), - } + accepts_torrent_events(None); + accepts_peer_events(None); + accepts_tracker_events(None); } #[test] @@ -27,29 +21,6 @@ fn facade_engine_handle_matches_existing_engine_type() { accepts_engine_handle(None); } -#[test] -fn engine_command_routes_the_shared_torrent_command_type() { - let torrent = InfoHash::from_bytes([7; 20]); - let command = CoreCommand::Torrent { - torrent, - command: TorrentCommand::Pause, - }; - - assert_eq!( - command, - CoreCommand::Torrent { - torrent, - command: TorrentCommand::Pause, - } - ); - - 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) {} diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index 54c93e57..3b669343 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -4,10 +4,7 @@ use futures::StreamExt; use libtortillas::{ engine::EngineStatus, errors::EngineError, - frontend::{ - CoreCommand, CoreCommandResult, CoreEventKind, EventStreamError, LivePublisher, - TorrentCommand, - }, + frontend::{CoreEventKind, EventStreamError, LivePublisher, TorrentEventKind, TrackerEventKind}, prelude::{Engine, Settings, TorrentSource, TorrentState}, }; use tokio::time::{sleep, timeout}; @@ -27,15 +24,10 @@ fn deterministic_engine() -> Engine { async fn engine_listener_receives_live_torrent_lifecycle() { let engine = deterministic_engine(); let mut engine_listener = engine.listener(); - let result = engine - .send(CoreCommand::AddTorrent { - source: TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY), - }) + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) .await .unwrap(); - let CoreCommandResult::TorrentAdded(torrent) = result else { - panic!("add command should return a torrent handle"); - }; let added = timeout(Duration::from_secs(2), async { loop { @@ -56,13 +48,13 @@ async fn engine_listener_receives_live_torrent_lifecycle() { assert_eq!(engine_listener.view().torrent_count, 1); let mut torrent_listener = torrent.listener(); - torrent.send(TorrentCommand::Pause).await.unwrap(); + torrent.pause().await.unwrap(); let paused = timeout(Duration::from_secs(2), async { loop { let event = torrent_listener.recv().await.unwrap(); if matches!( event.kind, - CoreEventKind::TorrentStateChanged { + TorrentEventKind::StateChanged { current: TorrentState::Paused, .. } @@ -75,19 +67,14 @@ async fn engine_listener_receives_live_torrent_lifecycle() { .unwrap(); assert!(matches!( paused.kind, - CoreEventKind::TorrentStateChanged { + TorrentEventKind::StateChanged { current: TorrentState::Paused, .. } )); assert_eq!(torrent_listener.view().unwrap().state, TorrentState::Paused); - let _ = engine - .send(CoreCommand::RemoveTorrent { - torrent: torrent.info_hash(), - }) - .await - .unwrap(); + engine.remove_torrent(torrent.info_hash()).await.unwrap(); assert!(torrent_listener.view().is_none()); assert_eq!(engine_listener.view().torrent_count, 0); @@ -117,21 +104,21 @@ async fn tracker_handle_exposes_its_own_live_listener() { let tracker = torrent.trackers().into_iter().next().unwrap(); let mut listener = tracker.listener(); - assert!(tracker.live_view().is_some_and(|view| view.active)); + assert!(tracker.live_view().active); engine.shutdown().await.unwrap(); let stopped = timeout(Duration::from_secs(2), async { loop { let event = listener.recv().await.unwrap(); - if matches!(event.kind, CoreEventKind::TrackerStopped { .. }) { + if matches!(event.kind, TrackerEventKind::Stopped) { break event; } } }) .await .unwrap(); - assert_eq!(stopped.torrent(), Some(torrent.info_hash())); - assert!(listener.view().is_none()); + assert!(stopped.sequence > 0); + assert!(!listener.view().active); } #[tokio::test] @@ -139,7 +126,7 @@ async fn engine_listener_receives_graceful_shutdown() { let engine = deterministic_engine(); let mut listener = engine.listener(); - let _ = engine.send(CoreCommand::Shutdown).await.unwrap(); + engine.shutdown().await.unwrap(); let shutdown = timeout(Duration::from_secs(2), async { loop { let event = listener.recv().await.unwrap(); @@ -159,17 +146,11 @@ async fn engine_listener_receives_graceful_shutdown() { } #[tokio::test] -async fn engine_commands_return_typed_unknown_torrent_errors() { +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 - .send(CoreCommand::Torrent { - torrent: unknown, - command: TorrentCommand::Pause, - }) - .await - .unwrap_err(); + let error = engine.torrent(unknown).await.unwrap_err(); assert!(matches!(error, EngineError::TorrentNotFound(torrent) if torrent == unknown)); engine.shutdown().await.unwrap(); @@ -178,22 +159,14 @@ async fn engine_commands_return_typed_unknown_torrent_errors() { #[tokio::test] async fn lagging_listener_recovers_from_current_live_view() { let engine = deterministic_engine(); - let result = engine - .send(CoreCommand::AddTorrent { - source: TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY), - }) + let torrent = engine + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) .await .unwrap(); - let CoreCommandResult::TorrentAdded(torrent) = result else { - panic!("add command should return a torrent handle"); - }; let mut listener = torrent.listener(); for peers in 1..=300 { - torrent - .send(TorrentCommand::SetSufficientPeers(peers)) - .await - .unwrap(); + torrent.set_sufficient_peers(peers).await.unwrap(); } timeout(Duration::from_secs(2), async { loop { @@ -221,9 +194,7 @@ async fn live_views_are_serde_compatible() { let engine = deterministic_engine(); let mut listener = engine.listener(); let _ = engine - .send(CoreCommand::AddTorrent { - source: TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY), - }) + .add_torrent(TorrentSource::torrent_file_bytes(BIG_BUCK_BUNNY)) .await .unwrap(); timeout(Duration::from_secs(2), async { diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index ff00fe3b..5177b917 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -6,43 +6,65 @@ polling loop. ## Live listeners -Call `Engine::listener()` before sending commands. The listener combines two +Call `Engine::listener()` before invoking operations. The listener combines two related capabilities: - `recv().await` yields sequenced `CoreEvent` values as changes happen. - `view()` returns the latest display-oriented `EngineView` held by the live publisher. -Every `Torrent` returned by an add command similarly has `listener()` and -`subscribe()` methods. A torrent listener receives only events associated with -that torrent and exposes its latest `TorrentView`. +Every `Torrent` returned by `Engine::add_torrent()` similarly has `listener()` +and `subscribe()` methods. A torrent listener has its own publisher, receives +typed `TorrentEvent` values for that torrent only, and exposes its latest +`TorrentView`. It does not filter the engine's global event stream. + +Peers and trackers returned by `Torrent::peers()` and `Torrent::trackers()` +follow the same pattern. Each `PeerHandle` and `TrackerHandle` owns an +independent typed listener and current view, including a terminal disconnected +or stopped view. Engine events carry the public `Torrent`, `PeerHandle`, and +`TrackerHandle` values so a frontend can descend into more detailed streams +only when needed. The event channel retains 256 events per listener by default. Slow listeners receive `EventStreamError::Lagged` instead of causing unbounded memory growth. After lagging, redraw from `listener.view()` and continue calling `recv()`. -Sequence numbers remain engine-local and monotonic. +Sequence numbers are monotonic within each publisher. Use `subscribe()` when only discrete events are needed. Use `listener()` when the frontend also needs a coherent current view for initial rendering or lag recovery. -## Commands +## Operations + +`Engine` and `Torrent` methods are the sole public command API. Call +`engine.add_torrent(...)`, `engine.remove_torrent(...)`, or +`engine.shutdown()` directly; call `torrent.start()`, `torrent.pause()`, and +configuration methods directly on a `Torrent` handle. There is no parallel +command enum or generic `send` method duplicating these operations. + +Applications that need to funnel UI actions through a task can put their own +application command type on a Tokio channel and call these methods in its +consumer. That keeps application-specific routing outside the engine without +making the library maintain two representations of every operation. -`Engine::send(CoreCommand)` is the application-level message boundary. It can -add and remove torrents, start all torrents, or shut down the engine. The -`CoreCommand::Torrent` variant routes the same `TorrentCommand` type accepted -by `Torrent::send`, so the engine and torrent APIs do not maintain parallel -command lists. Add commands return a `CoreCommandResult::TorrentAdded` handle. +`resume()` aliases `start()` and `stop()` aliases `pause()` because those pairs +currently produce the same engine transition. -`Torrent::send(TorrentCommand)` starts, pauses, or configures a torrent when a -frontend already owns its handle. Both handles retain their explicit -convenience methods; `resume()` aliases `start()` and `stop()` aliases `pause()` -because those pairs currently produce the same engine transition. +## Reusable live publishers + +`LivePublisher`, `EventListener`, and `EventSubscription` are +generic over the current view and event type. `EventListener` and +`EventSubscription` implement `futures::Stream`, while `recv()` supports the +usual Tokio-style loop. Engine, torrent, peer, and tracker APIs all reuse these +types; future protocols can expose the same behavior without another listener +implementation. ## Views and persistence snapshots -`EngineView`, `TorrentView`, and `CoreEvent` are live presentation contracts. -They are updated by listeners and are suitable for rendering. +`EngineView`, `TorrentView`, `PeerView`, and `TrackerView` are live presentation +contracts. They are updated by their publishers, are suitable for rendering, +and are Serde-compatible where a frontend wants to store or transmit display +state. `Engine::snapshot()` and `Torrent::snapshot()` are not the live frontend path. Snapshots are the persistence boundary for serializing resumable engine and @@ -62,13 +84,13 @@ state. ## Runtime and shutdown -The library is Tokio-based. Keep the engine, torrent handles, command tasks, +The library is Tokio-based. Keep the engine, torrent handles, application tasks, and listener tasks on the application runtime. Terminal or UI operations that block should run separately from those async tasks. -Send `CoreCommand::Shutdown` or call `Engine::shutdown()` and keep the engine +Call `Engine::shutdown()` and keep the engine listener alive until it receives `CoreEventKind::Shutdown`. This ensures the frontend observes the terminal state after managed torrents stop. See [`live_frontend.rs`](../crates/libtortillas/examples/live_frontend.rs) for a -compiling command/listener loop. +compiling operation/listener loop. From fcbdd4d80e4c3b161ec63c05de110133d03b8ec6 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:56:02 -0700 Subject: [PATCH 42/77] test: preserve terminal peer state --- crates/libtortillas/src/engine/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 580d1b1c..bca5dfdc 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -739,7 +739,7 @@ mod tests { .await .unwrap(); assert_eq!(peer.torrent(), info_hash); - assert!(peer.live_view().connected); + assert!(peer.live_view().address.is_some()); let _peer_listener = peer.listener(); engine.shutdown().await.unwrap(); From 6b109abc40839654ea5e836adb51b0808be11a40 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:57:00 -0700 Subject: [PATCH 43/77] fix: serialize live publisher updates --- crates/libtortillas/src/frontend/publisher.rs | 178 +++++++++++------- crates/libtortillas/tests/live_frontend.rs | 20 ++ 2 files changed, 130 insertions(+), 68 deletions(-) diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index f8815d13..897dee58 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, sync::{ - Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, + Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard, atomic::{AtomicU64, Ordering}, }, }; @@ -35,6 +35,12 @@ fn write_lock(lock: &RwLock) -> RwLockWriteGuard<'_, T> { .unwrap_or_else(std::sync::PoisonError::into_inner) } +fn mutex_lock(lock: &Mutex) -> MutexGuard<'_, T> { + lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// Generic current-state and event publisher for live application APIs. /// /// The same primitive backs engine, torrent, peer, and tracker listeners. It @@ -50,6 +56,7 @@ struct LivePublisherInner { events: broadcast::Sender>, view: RwLock, sequence: AtomicU64, + ordering: Mutex<()>, } impl LivePublisher @@ -66,6 +73,7 @@ where events, view: RwLock::new(initial_view), sequence: AtomicU64::new(0), + ordering: Mutex::new(()), }), } } @@ -91,22 +99,46 @@ where /// Replaces the current view without emitting an event. pub fn set_view(&self, view: V) { + let _ordering = mutex_lock(&self.inner.ordering); *self.write_view() = view; } /// Replaces the current view and emits the corresponding event. pub fn update(&self, view: V, event: E) { - self.set_view(view); - self.publish(event); + let _ordering = mutex_lock(&self.inner.ordering); + *self.write_view() = view; + self.publish_ordered(event); } /// Emits an event using this publisher's monotonic sequence. pub fn publish(&self, kind: E) { + let _ordering = mutex_lock(&self.inner.ordering); + self.publish_ordered(kind); + } + + pub(crate) fn edit_and_publish(&self, edit: impl FnOnce(&mut V) -> (R, E)) -> R { + let _ordering = mutex_lock(&self.inner.ordering); + let (result, event) = edit(&mut self.write_view()); + self.publish_ordered(event); + result + } + + pub(crate) fn edit_if_and_publish(&self, edit: impl FnOnce(&mut V) -> bool, event: E) -> bool { + let _ordering = mutex_lock(&self.inner.ordering); + if !edit(&mut self.write_view()) { + return false; + } + self.publish_ordered(event); + true + } + + fn publish_ordered(&self, kind: E) { let sequence = self.inner.sequence.fetch_add(1, Ordering::Relaxed) + 1; let _ = self.inner.events.send(Sequenced { sequence, kind }); } pub(crate) fn edit_view(&self, edit: impl FnOnce(&mut V) -> R) -> R { + let _ordering = mutex_lock(&self.inner.ordering); edit(&mut self.write_view()) } @@ -187,8 +219,11 @@ impl FrontendPublisher { } pub(crate) fn engine_started(&self) { - let view = self.set_engine_status(EngineStatus::Running); - self.publish(CoreEventKind::EngineStarted(view)); + self.live.edit_and_publish(|view| { + view.status = EngineStatus::Running; + let view = view.clone(); + ((), CoreEventKind::EngineStarted(view)) + }); } pub(crate) fn engine_stopping(&self) { @@ -196,8 +231,11 @@ impl FrontendPublisher { } pub(crate) fn engine_stopped(&self) { - let view = self.set_engine_status(EngineStatus::Stopped); - self.publish(CoreEventKind::Shutdown(view)); + self.live.edit_and_publish(|view| { + view.status = EngineStatus::Stopped; + let view = view.clone(); + ((), CoreEventKind::Shutdown(view)) + }); } pub(crate) fn initialize_torrent(&self, torrent: TorrentView) { @@ -212,29 +250,28 @@ impl FrontendPublisher { } pub(crate) fn update_torrent(&self, torrent: TorrentView) { - if let Some(torrent) = self.publish_torrent(torrent, TorrentEventKind::Updated) { - self.publish(CoreEventKind::TorrentUpdated(torrent)); - } + self.publish_torrent(torrent, TorrentEventKind::Updated, |torrent| { + CoreEventKind::TorrentUpdated(torrent.clone()) + }); } pub(crate) fn metadata_resolved(&self, torrent: TorrentView) { - if let Some(torrent) = self.publish_torrent(torrent, TorrentEventKind::MetadataResolved) { - self.publish(CoreEventKind::MetadataResolved(torrent)); - } + self.publish_torrent(torrent, TorrentEventKind::MetadataResolved, |torrent| { + CoreEventKind::MetadataResolved(torrent.clone()) + }); } pub(crate) fn progress_changed(&self, torrent: TorrentView) { let info_hash = torrent.info_hash; let progress = torrent.progress.clone(); - if self - .publish_torrent(torrent, TorrentEventKind::ProgressChanged(progress.clone())) - .is_some() - { - self.publish(CoreEventKind::ProgressChanged { + self.publish_torrent( + torrent, + TorrentEventKind::ProgressChanged(progress.clone()), + |_| CoreEventKind::ProgressChanged { torrent: info_hash, progress, - }); - } + }, + ); } pub(crate) fn peer_connected( @@ -243,15 +280,14 @@ impl FrontendPublisher { let info_hash = torrent.info_hash; let peer = PeerHandle::new(scope, view, self.clone()); write_lock(&self.peers).insert(scope, peer.clone()); - if self - .publish_torrent(torrent, TorrentEventKind::PeerConnected(peer.clone())) - .is_some() - { - self.publish(CoreEventKind::PeerConnected { + self.publish_torrent( + torrent, + TorrentEventKind::PeerConnected(peer.clone()), + |_| CoreEventKind::PeerConnected { torrent: info_hash, peer: peer.clone(), - }); - } + }, + ); peer } @@ -360,28 +396,19 @@ impl FrontendPublisher { pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { let info_hash = torrent.info_hash; let current = torrent.state; - if self - .publish_torrent( - torrent, - TorrentEventKind::StateChanged { previous, current }, - ) - .is_some() - { - self.publish(CoreEventKind::TorrentStateChanged { + self.publish_torrent( + torrent, + TorrentEventKind::StateChanged { previous, current }, + |_| CoreEventKind::TorrentStateChanged { torrent: info_hash, previous, current, - }); - } + }, + ); } pub(crate) fn torrent_removed(&self, torrent: InfoHash) { - self.live.edit_view(|view| { - view - .torrents - .retain(|candidate| candidate.info_hash != torrent); - view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); - }); + let removed = self.write_torrents().remove(&torrent); let peers = read_lock(&self.peers) .values() .filter(|peer| peer.torrent() == torrent && peer.live_view().connected) @@ -400,9 +427,16 @@ impl FrontendPublisher { tracker.stopped(); } write_lock(&self.trackers).retain(|scope, _| scope.torrent != torrent); - if let Some(torrent) = self.write_torrents().remove(&torrent) { - torrent.removed(); - self.publish(CoreEventKind::TorrentRemoved(torrent)); + if let Some(handle) = removed { + handle.removed(); + self.live.edit_and_publish(|view| { + Self::remove_torrent_view(view, torrent); + ((), CoreEventKind::TorrentRemoved(handle)) + }); + } else { + self + .live + .edit_view(|view| Self::remove_torrent_view(view, torrent)); } } @@ -434,28 +468,36 @@ impl FrontendPublisher { }); } - fn update_torrent_entry(&self, torrent: TorrentView) -> Option { - let info_hash = torrent.info_hash; - let updated = self.live.edit_view(|view| { - let Some(current) = view - .torrents - .iter_mut() - .find(|candidate| candidate.info_hash == torrent.info_hash) - else { - return false; - }; - *current = torrent; - true - }); - updated - .then(|| self.read_torrents().get(&info_hash).cloned()) - .flatten() - } - - fn publish_torrent(&self, view: TorrentView, event: TorrentEventKind) -> Option { - let torrent = self.update_torrent_entry(view.clone())?; - torrent.publish(view, event); - Some(torrent) + fn publish_torrent( + &self, view: TorrentView, event: TorrentEventKind, + core_event: impl FnOnce(&Torrent) -> CoreEventKind, + ) { + let info_hash = view.info_hash; + let Some(torrent) = self.read_torrents().get(&info_hash).cloned() else { + return; + }; + torrent.publish(view.clone(), event); + self.live.edit_if_and_publish( + |engine| { + let Some(current) = engine + .torrents + .iter_mut() + .find(|candidate| candidate.info_hash == info_hash) + else { + return false; + }; + *current = view; + true + }, + core_event(&torrent), + ); + } + + fn remove_torrent_view(view: &mut EngineView, torrent: InfoHash) { + view + .torrents + .retain(|candidate| candidate.info_hash != torrent); + view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); } fn read_torrents(&self) -> RwLockReadGuard<'_, HashMap> { diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index 3b669343..eafec06c 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -94,6 +94,26 @@ async fn generic_live_publisher_implements_async_stream() { assert_eq!(listener.view(), 1); } +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_live_updates_are_delivered_in_sequence_order() { + const UPDATE_COUNT: u64 = 64; + let publisher = LivePublisher::new(0_u64, UPDATE_COUNT as usize); + let mut events = publisher.subscribe(); + let updates = (1..=UPDATE_COUNT) + .map(|view| { + let publisher = publisher.clone(); + tokio::spawn(async move { publisher.update(view, view) }) + }) + .collect::>(); + + for update in updates { + update.await.unwrap(); + } + for sequence in 1..=UPDATE_COUNT { + assert_eq!(events.recv().await.unwrap().sequence, sequence); + } +} + #[tokio::test] async fn tracker_handle_exposes_its_own_live_listener() { let engine = deterministic_engine(); From 7b705db3eed6223ffb018baa98fd50772e94f0fe Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:58:00 -0700 Subject: [PATCH 44/77] fix: clean up failed torrent restores --- crates/libtortillas/src/engine/messages.rs | 28 +++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 35a51c15..42e06a55 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -19,6 +19,23 @@ pub(crate) mod commands { use super::*; + impl EngineActor { + async fn discard_restored_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"); + } + self.frontend.torrent_removed(info_hash); + } + } + #[messages] impl EngineActor { /// Handles an incoming peer connection. The peer has been neither @@ -186,18 +203,12 @@ pub(crate) mod commands { Ok(result) => match result.0 { Ok(resume) => resume, Err(error) => { - if let Err(stop_error) = torrent_ref.stop_gracefully().await { - warn!(error = %stop_error, %info_hash, "Failed to stop rejected restored torrent"); - } - self.frontend.torrent_removed(info_hash); + self.discard_restored_torrent(info_hash, &torrent_ref).await; return Err(error.into()); } }, Err(error) => { - if let Err(stop_error) = torrent_ref.stop_gracefully().await { - warn!(error = %stop_error, %info_hash, "Failed to stop rejected restored torrent"); - } - self.frontend.torrent_removed(info_hash); + self.discard_restored_torrent(info_hash, &torrent_ref).await; return Err(EngineError::Other(anyhow!( "failed to restore torrent snapshot: {error}" ))); @@ -236,6 +247,7 @@ pub(crate) mod commands { }) .await { + self.discard_restored_torrent(info_hash, &torrent_ref).await; return Err(EngineError::Other(anyhow!( "failed to resume restored torrent: {error}" ))); From b3e142e43698a27a8433dd4771e437fb27d26032 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:59:00 -0700 Subject: [PATCH 45/77] fix: redact udp tracker credentials --- crates/libtortillas/src/tracker/model.rs | 35 ++++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/libtortillas/src/tracker/model.rs b/crates/libtortillas/src/tracker/model.rs index 170ffd88..743203cb 100644 --- a/crates/libtortillas/src/tracker/model.rs +++ b/crates/libtortillas/src/tracker/model.rs @@ -121,16 +121,21 @@ impl Tracker { /// Returns a credential-free endpoint label for frontend events. pub(crate) fn frontend_endpoint(&self) -> String { let uri = self.uri(); - let Ok(mut url) = reqwest::Url::parse(&uri) else { + let Ok(url) = reqwest::Url::parse(&uri) else { return self.scheme().to_string(); }; - - let _ = url.set_username(""); - let _ = url.set_password(None); - url.set_path(""); - url.set_query(None); - url.set_fragment(None); - url.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 { @@ -300,6 +305,20 @@ mod tests { assert!(!endpoint.contains("token")); } + #[test] + fn udp_frontend_endpoint_removes_tracker_credentials() { + let tracker = Tracker::Udp( + "udp://alice:password@tracker.example:6969/announce?token=secret".to_string(), + ); + + let endpoint = tracker.frontend_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()); From 459abe8cc136c541af81716db3a6b90f32c28da0 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:59:30 -0700 Subject: [PATCH 46/77] test: verify restored storage configuration --- crates/libtortillas/src/torrent/storage.rs | 2 +- crates/libtortillas/tests/persistence.rs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) 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/tests/persistence.rs b/crates/libtortillas/tests/persistence.rs index 7bf17d4b..02c46051 100644 --- a/crates/libtortillas/tests/persistence.rs +++ b/crates/libtortillas/tests/persistence.rs @@ -46,6 +46,8 @@ async fn torrent_snapshot_when_serialized_then_restores_session_state() { 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(); @@ -62,12 +64,17 @@ async fn active_torrent_snapshot_when_restored_then_resumes_transfer_state() { 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(); } From 2a7f3527480d4125e1e1da4f0efecc31c6809320 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Thu, 23 Jul 2026 23:59:40 -0700 Subject: [PATCH 47/77] fix: retain restoring torrent updates --- crates/libtortillas/src/frontend/publisher.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 897dee58..1260203b 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -474,6 +474,15 @@ impl FrontendPublisher { ) { let info_hash = view.info_hash; let Some(torrent) = self.read_torrents().get(&info_hash).cloned() else { + self.live.edit_view(|engine| { + if let Some(current) = engine + .torrents + .iter_mut() + .find(|candidate| candidate.info_hash == info_hash) + { + *current = view; + } + }); return; }; torrent.publish(view.clone(), event); From 53b90f5cd402e637ba3f41e60b77f4da1c9a08ed Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:19:34 -0700 Subject: [PATCH 48/77] fix: close live subscriptions with publishers --- crates/libtortillas/src/frontend/publisher.rs | 185 ++++++++++++------ .../libtortillas/src/frontend/subscription.rs | 27 ++- crates/libtortillas/tests/live_frontend.rs | 24 +++ 3 files changed, 169 insertions(+), 67 deletions(-) diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 1260203b..9d57c552 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -1,9 +1,6 @@ use std::{ collections::HashMap, - sync::{ - Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard, - atomic::{AtomicU64, Ordering}, - }, + sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}, }; use tokio::sync::broadcast; @@ -48,15 +45,20 @@ fn mutex_lock(lock: &Mutex) -> MutexGuard<'_, T> { /// another channel or listener implementation. #[derive(Debug, Clone)] pub struct LivePublisher { - inner: Arc>, + state: Arc>>, + channel: Arc>, } #[derive(Debug)] -struct LivePublisherInner { - events: broadcast::Sender>, - view: RwLock, - sequence: AtomicU64, - ordering: Mutex<()>, +struct LiveState { + view: V, + sequence: u64, + closed: bool, +} + +#[derive(Debug)] +struct LiveChannel { + sender: Mutex>>>, } impl LivePublisher @@ -65,15 +67,22 @@ where E: Clone + Send + 'static, { /// Creates a publisher with an initial view and bounded event capacity. + /// + /// # Panics + /// + /// Panics when `event_capacity` is zero. #[must_use] pub fn new(initial_view: V, event_capacity: usize) -> Self { + assert!(event_capacity > 0, "event capacity must be non-zero"); let (events, _) = broadcast::channel(event_capacity); Self { - inner: Arc::new(LivePublisherInner { - events, - view: RwLock::new(initial_view), - sequence: AtomicU64::new(0), - ordering: Mutex::new(()), + state: Arc::new(Mutex::new(LiveState { + view: initial_view, + sequence: 0, + closed: false, + })), + channel: Arc::new(LiveChannel { + sender: Mutex::new(Some(events)), }), } } @@ -81,73 +90,123 @@ where /// Subscribes to all future events from this publisher. #[must_use] pub fn subscribe(&self) -> EventSubscription { - EventSubscription::new(self.inner.events.clone()) + let sender = mutex_lock(&self.channel.sender); + match sender.as_ref() { + Some(sender) => EventSubscription::from_receiver(sender.subscribe(), sender.downgrade()), + None => { + let (sender, receiver) = broadcast::channel(1); + let weak = sender.downgrade(); + drop(sender); + EventSubscription::from_receiver(receiver, weak) + } + } } /// Creates a stream-compatible listener paired with the current view. #[must_use] pub fn listener(&self) -> EventListener { - let publisher = self.clone(); - EventListener::new(self.subscribe(), move || publisher.view()) + let state = Arc::clone(&self.state); + EventListener::new(self.subscribe(), move || mutex_lock(&state).view.clone()) } /// Clones the latest coherent view. #[must_use] pub fn view(&self) -> V { - self.read_view().clone() + mutex_lock(&self.state).view.clone() } /// Replaces the current view without emitting an event. - pub fn set_view(&self, view: V) { - let _ordering = mutex_lock(&self.inner.ordering); - *self.write_view() = view; + /// + /// Returns `false` when the publisher has already closed. + pub fn set_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. - pub fn update(&self, view: V, event: E) { - let _ordering = mutex_lock(&self.inner.ordering); - *self.write_view() = view; - self.publish_ordered(event); + /// + /// Returns `false` when the publisher has already closed. + pub fn update(&self, view: V, event: E) -> bool { + self.mutate(|current| *current = view, event) } /// Emits an event using this publisher's monotonic sequence. - pub fn publish(&self, kind: E) { - let _ordering = mutex_lock(&self.inner.ordering); - self.publish_ordered(kind); + /// + /// Returns `false` when the publisher has already closed. + pub fn publish(&self, kind: E) -> bool { + self.mutate(|_| {}, kind) + } + + /// Atomically updates the view and permanently closes this publisher after + /// delivering one terminal event. + /// + /// Returns `false` if another caller already closed the publisher. + pub fn close(&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(Sequenced { + sequence: state.sequence, + kind: event, + }); + } + true } - pub(crate) fn edit_and_publish(&self, edit: impl FnOnce(&mut V) -> (R, E)) -> R { - let _ordering = mutex_lock(&self.inner.ordering); - let (result, event) = edit(&mut self.write_view()); - self.publish_ordered(event); - result + pub(crate) fn edit_and_publish(&self, edit: impl FnOnce(&mut V) -> E) -> bool { + let mut state = mutex_lock(&self.state); + if state.closed { + return false; + } + let event = edit(&mut state.view); + state.sequence = state.sequence.saturating_add(1); + self.send(&state, event); + true } pub(crate) fn edit_if_and_publish(&self, edit: impl FnOnce(&mut V) -> bool, event: E) -> bool { - let _ordering = mutex_lock(&self.inner.ordering); - if !edit(&mut self.write_view()) { + let mut state = mutex_lock(&self.state); + if state.closed || !edit(&mut state.view) { return false; } - self.publish_ordered(event); + state.sequence = state.sequence.saturating_add(1); + self.send(&state, event); true } - fn publish_ordered(&self, kind: E) { - let sequence = self.inner.sequence.fetch_add(1, Ordering::Relaxed) + 1; - let _ = self.inner.events.send(Sequenced { sequence, kind }); - } - - pub(crate) fn edit_view(&self, edit: impl FnOnce(&mut V) -> R) -> R { - let _ordering = mutex_lock(&self.inner.ordering); - edit(&mut self.write_view()) + pub(crate) fn edit_view(&self, edit: impl FnOnce(&mut V) -> R) -> Option { + let mut state = mutex_lock(&self.state); + (!state.closed).then(|| edit(&mut state.view)) } - fn read_view(&self) -> RwLockReadGuard<'_, V> { - read_lock(&self.inner.view) + fn mutate(&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(&state, event); + true } - fn write_view(&self) -> RwLockWriteGuard<'_, V> { - write_lock(&self.inner.view) + fn send(&self, state: &LiveState, event: E) { + if let Some(sender) = mutex_lock(&self.channel.sender).as_ref() { + let _ = sender.send(Sequenced { + sequence: state.sequence, + kind: event, + }); + } } } @@ -221,8 +280,7 @@ impl FrontendPublisher { pub(crate) fn engine_started(&self) { self.live.edit_and_publish(|view| { view.status = EngineStatus::Running; - let view = view.clone(); - ((), CoreEventKind::EngineStarted(view)) + CoreEventKind::EngineStarted(view.clone()) }); } @@ -231,11 +289,9 @@ impl FrontendPublisher { } pub(crate) fn engine_stopped(&self) { - self.live.edit_and_publish(|view| { - view.status = EngineStatus::Stopped; - let view = view.clone(); - ((), CoreEventKind::Shutdown(view)) - }); + let mut view = self.live.view(); + view.status = EngineStatus::Stopped; + self.live.close(view.clone(), CoreEventKind::Shutdown(view)); } pub(crate) fn initialize_torrent(&self, torrent: TorrentView) { @@ -431,7 +487,7 @@ impl FrontendPublisher { handle.removed(); self.live.edit_and_publish(|view| { Self::remove_torrent_view(view, torrent); - ((), CoreEventKind::TorrentRemoved(handle)) + CoreEventKind::TorrentRemoved(handle) }); } else { self @@ -445,14 +501,17 @@ impl FrontendPublisher { } fn set_engine_status(&self, status: EngineStatus) -> EngineView { - self.live.edit_view(|view| { - view.status = status; - view.clone() - }) + self + .live + .edit_view(|view| { + view.status = status; + view.clone() + }) + .unwrap_or_else(|| self.live.view()) } fn replace_torrent(&self, torrent: TorrentView) { - self.live.edit_view(|view| { + let _ = self.live.edit_view(|view| { match view .torrents .iter_mut() @@ -474,7 +533,7 @@ impl FrontendPublisher { ) { let info_hash = view.info_hash; let Some(torrent) = self.read_torrents().get(&info_hash).cloned() else { - self.live.edit_view(|engine| { + let _ = self.live.edit_view(|engine| { if let Some(current) = engine .torrents .iter_mut() diff --git a/crates/libtortillas/src/frontend/subscription.rs b/crates/libtortillas/src/frontend/subscription.rs index d5fca982..8b3b0e53 100644 --- a/crates/libtortillas/src/frontend/subscription.rs +++ b/crates/libtortillas/src/frontend/subscription.rs @@ -17,18 +17,31 @@ use super::{CoreEventKind, Sequenced}; /// 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::Sender>, + 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(sender.subscribe()), + stream: BroadcastStream::new(receiver), sender, } } + fn closed() -> Self { + let (sender, receiver) = broadcast::channel(1); + let weak = sender.downgrade(); + drop(sender); + Self::from_receiver(receiver, weak) + } + /// Waits for the next event in this subscription. pub async fn recv(&mut self) -> Result, EventStreamError> { poll_fn(|context| Pin::new(&mut *self).poll_next(context)) @@ -40,7 +53,7 @@ impl EventSubscription { /// event position. #[must_use] pub fn resubscribe(&self) -> Self { - Self::new(self.sender.clone()) + self.sender.upgrade().map_or_else(Self::closed, Self::new) } } @@ -63,7 +76,13 @@ impl fmt::Debug for EventSubscription { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("EventSubscription") - .field("receiver_count", &self.sender.receiver_count()) + .field( + "receiver_count", + &self + .sender + .upgrade() + .map_or(0, |sender| sender.receiver_count()), + ) .finish_non_exhaustive() } } diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index eafec06c..55aa1515 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -94,6 +94,30 @@ async fn generic_live_publisher_implements_async_stream() { assert_eq!(listener.view(), 1); } +#[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_eq!(listener.recv().await, Err(EventStreamError::Closed)); + assert_eq!(listener.view(), 0); +} + +#[tokio::test] +async fn closed_live_publisher_rejects_late_updates() { + let publisher = LivePublisher::new(0_u8, 4); + let mut listener = publisher.listener(); + + assert!(publisher.close(1, "closed")); + assert!(!publisher.update(2, "late")); + + assert_eq!(listener.recv().await.unwrap().kind, "closed"); + assert_eq!(listener.recv().await, Err(EventStreamError::Closed)); + assert_eq!(listener.view(), 1); +} + #[tokio::test(flavor = "multi_thread")] async fn concurrent_live_updates_are_delivered_in_sequence_order() { const UPDATE_COUNT: u64 = 64; From d784c5367e089ca76b7d14450dc53305e42d3648 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:34:00 -0700 Subject: [PATCH 49/77] refactor: compose hierarchical live scopes --- crates/libtortillas/src/engine/messages.rs | 12 +- crates/libtortillas/src/engine/mod.rs | 6 +- crates/libtortillas/src/facade.rs | 2 +- crates/libtortillas/src/frontend/event.rs | 63 +-- crates/libtortillas/src/frontend/handle.rs | 211 +++++--- crates/libtortillas/src/frontend/mod.rs | 6 +- crates/libtortillas/src/frontend/publisher.rs | 489 +++++++++--------- crates/libtortillas/src/torrent/actor.rs | 34 +- crates/libtortillas/src/torrent/handle.rs | 112 ++-- crates/libtortillas/src/torrent/messages.rs | 7 +- crates/libtortillas/src/torrent/mod.rs | 2 +- crates/libtortillas/src/torrent/swarm.rs | 9 +- crates/libtortillas/tests/live_frontend.rs | 22 +- 13 files changed, 547 insertions(+), 428 deletions(-) diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 42e06a55..3e631b85 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -252,10 +252,20 @@ pub(crate) mod commands { "failed to resume restored torrent: {error}" ))); } + let initial_view = match torrent_ref.ask(torrent::commands::GetLiveView).await { + Ok(view) => *view, + Err(error) => { + self.discard_restored_torrent(info_hash, &torrent_ref).await; + return Err(EngineError::Other(anyhow!( + "failed to initialize torrent frontend: {error}" + ))); + } + }; self.frontend.torrent_added(Torrent::new_with_frontend( info_hash, torrent_ref.clone(), - self.frontend.clone(), + &self.frontend, + Some(initial_view), )); Ok(torrent_ref) } diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index bca5dfdc..6fb2d499 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -731,7 +731,11 @@ mod tests { let peer = timeout(Duration::from_secs(2), async { loop { let event = listener.recv().await.unwrap(); - if let CoreEventKind::PeerConnected { peer, .. } = event.kind { + if let CoreEventKind::Torrent { + event: crate::frontend::TorrentEventKind::PeerConnected(peer), + .. + } = event.kind + { break peer; } } diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index e118dacb..33851038 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -24,7 +24,7 @@ pub use crate::{ EventStreamError, EventSubscription, FrontendHealth, FrontendHealthLevel, LivePublisher, PeerEvent, PeerEventKind, PeerHandle, PeerListener, PeerView, Sequenced, TorrentEvent, TorrentEventKind, TorrentListener, TorrentProgress, TorrentTransfer, TorrentView, - TrackerEvent, TrackerEventKind, TrackerHandle, TrackerListener, TrackerView, + TrackerEvent, TrackerEventKind, TrackerHandle, TrackerId, TrackerListener, TrackerView, }, torrent::TorrentSnapshot, }; diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index ddae82ad..6d0868b9 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -42,47 +42,16 @@ impl Sequenced { pub enum CoreEventKind { /// The engine finished starting and is ready for operations. EngineStarted(EngineView), - /// A torrent was added to the engine. - TorrentAdded(Torrent), - /// A torrent was removed from the engine. - TorrentRemoved(Torrent), - /// A torrent changed lifecycle state. - TorrentStateChanged { - torrent: InfoHash, - previous: TorrentState, - current: TorrentState, - }, - /// Display-oriented torrent configuration or counts changed. - TorrentUpdated(Torrent), - /// Metadata for a magnet torrent was resolved. - MetadataResolved(Torrent), - /// Download progress changed. - ProgressChanged { - torrent: InfoHash, - progress: TorrentProgress, - }, - /// A peer connection became available to a torrent. - PeerConnected { torrent: InfoHash, peer: PeerHandle }, - /// A connected peer's protocol state or transfer metrics changed. - PeerUpdated { torrent: InfoHash, peer: PeerHandle }, - /// A peer connection was removed from a torrent. - PeerDisconnected { torrent: InfoHash, peer: PeerHandle }, - /// A tracker announce completed successfully. - TrackerAnnounceSucceeded { - torrent: InfoHash, - tracker: TrackerHandle, - }, - /// A tracker announce failed. - TrackerAnnounceFailed { - torrent: InfoHash, - tracker: TrackerHandle, - }, - /// A tracker actor stopped. - TrackerStopped { - torrent: InfoHash, - tracker: TrackerHandle, + /// 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, }, - /// A frontend-relevant health report was emitted. + /// An engine-wide frontend health report was emitted. Health(FrontendHealth), /// The engine and its managed torrents stopped. Shutdown(EngineView), @@ -92,6 +61,7 @@ pub enum CoreEventKind { #[derive(Debug, Clone)] #[non_exhaustive] pub enum TorrentEventKind { + Added, Updated, StateChanged { previous: TorrentState, @@ -132,18 +102,7 @@ impl CoreEventKind { pub fn torrent(&self) -> Option { match self { Self::EngineStarted(_) | Self::Shutdown(_) => None, - Self::TorrentAdded(torrent) - | Self::TorrentUpdated(torrent) - | Self::TorrentRemoved(torrent) - | Self::MetadataResolved(torrent) => Some(torrent.info_hash()), - Self::TorrentStateChanged { torrent, .. } - | Self::ProgressChanged { torrent, .. } - | Self::PeerConnected { torrent, .. } - | Self::PeerUpdated { torrent, .. } - | Self::PeerDisconnected { torrent, .. } - | Self::TrackerAnnounceSucceeded { torrent, .. } - | Self::TrackerAnnounceFailed { torrent, .. } - | Self::TrackerStopped { torrent, .. } => Some(*torrent), + Self::Torrent { torrent, .. } => Some(torrent.info_hash()), Self::Health(health) => health.torrent, } } diff --git a/crates/libtortillas/src/frontend/handle.rs b/crates/libtortillas/src/frontend/handle.rs index a8038f2e..3fa0836f 100644 --- a/crates/libtortillas/src/frontend/handle.rs +++ b/crates/libtortillas/src/frontend/handle.rs @@ -1,8 +1,15 @@ -use std::{fmt, net::SocketAddr}; +use std::{ + fmt, + hash::Hash, + net::SocketAddr, + sync::{Arc, Weak}, +}; + +use serde::{Deserialize, Serialize}; use super::{ - DEFAULT_EVENT_CAPACITY, EventListener, EventSubscription, FrontendPublisher, LivePublisher, - PeerEventKind, PeerView, TrackerEventKind, TrackerView, + DEFAULT_EVENT_CAPACITY, EventListener, EventSubscription, FrontendHub, FrontendPublisher, + LivePublisher, PeerEventKind, PeerView, TrackerEventKind, TrackerView, }; use crate::{hashes::InfoHash, peer::PeerId}; @@ -12,39 +19,108 @@ pub(crate) struct PeerScope { pub(crate) peer: PeerId, } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// Opaque identity for one tracker actor within an engine. +/// +/// Tracker URLs can contain private passkeys and are not suitable identifiers: +/// sanitized URLs can collide while complete URLs must not be exposed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, 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 TrackerScope { pub(crate) torrent: InfoHash, - pub(crate) endpoint: String, + pub(crate) id: TrackerId, +} + +/// Shared implementation for identity-bearing live protocol handles. +pub(crate) struct LiveHandle { + identity: I, + hub: Weak, + live: LivePublisher, +} + +impl LiveHandle +where + V: Clone + Send + Sync + 'static, + E: Clone + Send + 'static, +{ + fn new(identity: I, view: V, hub: Weak) -> Self { + Self { + identity, + hub, + live: LivePublisher::new(view, DEFAULT_EVENT_CAPACITY), + } + } + + fn subscribe(&self) -> EventSubscription { + self.live.subscribe() + } + + fn listener(&self) -> EventListener { + self.live.listener() + } + + fn view(&self) -> V { + self.live.view() + } + + fn update(&self, view: V, event: E) -> bool { + self.live.update(view, event) + } + + fn close(&self, view: V, event: E) -> bool { + self.live.close(view, event) + } + + fn frontend(&self) -> Option { + self.hub.upgrade().map(FrontendPublisher::from_hub) + } +} + +impl fmt::Debug for LiveHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LiveHandle") + .field("identity", &self.identity) + .finish_non_exhaustive() + } } /// Public identity and live frontend access for one connected peer. #[derive(Clone)] pub struct PeerHandle { - scope: PeerScope, - frontend: FrontendPublisher, - live: LivePublisher, + pub(crate) inner: Arc>, } impl PeerHandle { - pub(crate) fn new(scope: PeerScope, view: PeerView, frontend: FrontendPublisher) -> Self { + pub(crate) fn new(scope: PeerScope, view: PeerView, hub: Weak) -> Self { Self { - scope, - frontend, - live: LivePublisher::new(view, DEFAULT_EVENT_CAPACITY), + inner: Arc::new(LiveHandle::new(scope, view, hub)), } } /// Torrent that owns this peer connection. #[must_use] - pub const fn torrent(&self) -> InfoHash { - self.scope.torrent + pub fn torrent(&self) -> InfoHash { + self.inner.identity.torrent } /// Handshaked peer identifier. #[must_use] - pub const fn id(&self) -> PeerId { - self.scope.peer + pub fn id(&self) -> PeerId { + self.inner.identity.peer } /// Latest known network address. @@ -56,38 +132,41 @@ impl PeerHandle { /// Subscribes to events for this peer only. #[must_use] pub fn subscribe(&self) -> EventSubscription { - self.live.subscribe() + self.inner.subscribe() } /// Creates a stream-compatible listener for this peer. #[must_use] pub fn listener(&self) -> PeerListener { - self.live.listener() + self.inner.listener() } /// Returns the latest peer view, including its terminal disconnected state. #[must_use] pub fn live_view(&self) -> PeerView { - self.live.view() + self.inner.view() } - pub(crate) const fn scope(&self) -> PeerScope { - self.scope + pub(crate) fn scope(&self) -> PeerScope { + self.inner.identity } pub(crate) fn update(&self, view: PeerView) { - self.live.update(view, PeerEventKind::Updated); - self.frontend.peer_updated(self); + if self.inner.update(view, PeerEventKind::Updated) + && let Some(frontend) = self.inner.frontend() + { + frontend.peer_event(self, PeerEventKind::Updated); + } } pub(crate) fn disconnected(&self) { let mut view = self.live_view(); - if !view.connected { - return; - } view.connected = false; - self.live.update(view, PeerEventKind::Disconnected); - self.frontend.peer_disconnected(self.clone()); + if self.inner.close(view, PeerEventKind::Disconnected) + && let Some(frontend) = self.inner.frontend() + { + frontend.peer_event(self, PeerEventKind::Disconnected); + } } } @@ -95,15 +174,15 @@ impl fmt::Debug for PeerHandle { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("PeerHandle") - .field("torrent", &self.scope.torrent) - .field("peer", &self.scope.peer) + .field("torrent", &self.torrent()) + .field("peer", &self.id()) .finish_non_exhaustive() } } impl PartialEq for PeerHandle { fn eq(&self, other: &Self) -> bool { - self.scope == other.scope + self.scope() == other.scope() } } @@ -112,52 +191,54 @@ impl Eq for PeerHandle {} /// Public identity and live frontend access for one tracker. #[derive(Clone)] pub struct TrackerHandle { - scope: TrackerScope, - frontend: FrontendPublisher, - live: LivePublisher, + pub(crate) inner: Arc>, } impl TrackerHandle { - pub(crate) fn new(scope: TrackerScope, view: TrackerView, frontend: FrontendPublisher) -> Self { + pub(crate) fn new(scope: TrackerScope, view: TrackerView, hub: Weak) -> Self { Self { - scope, - frontend, - live: LivePublisher::new(view, DEFAULT_EVENT_CAPACITY), + inner: Arc::new(LiveHandle::new(scope, view, hub)), } } /// Torrent that owns this tracker. #[must_use] - pub const fn torrent(&self) -> InfoHash { - self.scope.torrent + pub fn torrent(&self) -> InfoHash { + self.inner.identity.torrent + } + + /// Opaque identity that remains distinct when sanitized endpoints collide. + #[must_use] + pub fn id(&self) -> TrackerId { + self.inner.identity.id } /// Credential-free tracker endpoint. #[must_use] - pub fn endpoint(&self) -> &str { - &self.scope.endpoint + pub fn endpoint(&self) -> String { + self.live_view().endpoint } /// Subscribes to events for this tracker only. #[must_use] pub fn subscribe(&self) -> EventSubscription { - self.live.subscribe() + self.inner.subscribe() } /// Creates a stream-compatible listener for this tracker. #[must_use] pub fn listener(&self) -> TrackerListener { - self.live.listener() + self.inner.listener() } /// Returns the latest tracker view, including its terminal stopped state. #[must_use] pub fn live_view(&self) -> TrackerView { - self.live.view() + self.inner.view() } - pub(crate) fn scope(&self) -> &TrackerScope { - &self.scope + pub(crate) fn scope(&self) -> TrackerScope { + self.inner.identity } pub(crate) fn announce_succeeded(&self, peers_returned: u64) { @@ -165,10 +246,12 @@ impl TrackerHandle { view.active = true; view.healthy = true; view.peers_returned = Some(peers_returned); - self - .live - .update(view, TrackerEventKind::AnnounceSucceeded { peers_returned }); - self.frontend.tracker_announce_succeeded(self); + let event = TrackerEventKind::AnnounceSucceeded { peers_returned }; + if self.inner.update(view, event) + && let Some(frontend) = self.inner.frontend() + { + frontend.tracker_event(self, event); + } } pub(crate) fn announce_failed(&self) { @@ -176,18 +259,21 @@ impl TrackerHandle { view.active = true; view.healthy = false; view.peers_returned = None; - self.live.update(view, TrackerEventKind::AnnounceFailed); - self.frontend.tracker_announce_failed(self); + if self.inner.update(view, TrackerEventKind::AnnounceFailed) + && let Some(frontend) = self.inner.frontend() + { + frontend.tracker_event(self, TrackerEventKind::AnnounceFailed); + } } pub(crate) fn stopped(&self) { let mut view = self.live_view(); - if !view.active { - return; - } view.active = false; - self.live.update(view, TrackerEventKind::Stopped); - self.frontend.tracker_stopped(self); + if self.inner.close(view, TrackerEventKind::Stopped) + && let Some(frontend) = self.inner.frontend() + { + frontend.tracker_event(self, TrackerEventKind::Stopped); + } } } @@ -195,15 +281,16 @@ impl fmt::Debug for TrackerHandle { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("TrackerHandle") - .field("torrent", &self.scope.torrent) - .field("endpoint", &self.scope.endpoint) + .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.scope == other.scope + self.scope() == other.scope() } } @@ -211,7 +298,7 @@ impl Eq for TrackerHandle {} impl fmt::Display for TrackerHandle { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.endpoint()) + formatter.write_str(&self.endpoint()) } } diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index 728571bf..acbe77f0 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -15,10 +15,10 @@ pub use event::{ CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, PeerEvent, PeerEventKind, Sequenced, TorrentEvent, TorrentEventKind, TrackerEvent, TrackerEventKind, }; -pub use handle::{PeerHandle, PeerListener, TrackerHandle, TrackerListener}; -pub(crate) use handle::{PeerScope, TrackerScope}; +pub(crate) use handle::PeerScope; +pub use handle::{PeerHandle, PeerListener, TrackerHandle, TrackerId, TrackerListener}; pub use listener::{EngineListener, EventListener, TorrentListener}; -pub(crate) use publisher::FrontendPublisher; pub use publisher::{DEFAULT_EVENT_CAPACITY, LivePublisher}; +pub(crate) use publisher::{FrontendHub, FrontendPublisher}; pub use subscription::{EventStreamError, EventSubscription}; pub use view::{EngineView, PeerView, TorrentProgress, TorrentTransfer, TorrentView, TrackerView}; diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 9d57c552..b360971d 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -1,20 +1,24 @@ use std::{ collections::HashMap, - sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}, + hash::Hash, + sync::{ + Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak, + atomic::{AtomicU64, Ordering}, + }, }; use tokio::sync::broadcast; use super::{ CoreEventKind, EngineView, EventListener, EventSubscription, FrontendHealth, - FrontendHealthLevel, PeerHandle, PeerView, Sequenced, TorrentEventKind, TorrentView, - TrackerHandle, TrackerView, - handle::{PeerScope, TrackerScope}, + FrontendHealthLevel, PeerEventKind, PeerHandle, PeerView, Sequenced, TorrentEventKind, + TorrentView, TrackerEventKind, TrackerHandle, TrackerView, + handle::{LiveHandle, PeerScope, TrackerId, TrackerScope}, }; use crate::{ engine::EngineStatus, hashes::InfoHash, - torrent::{Torrent, TorrentState}, + torrent::{Torrent, TorrentInner, TorrentState}, }; /// Number of discrete frontend events retained for each listener. @@ -210,13 +214,56 @@ where } } -/// Shared live-state publisher used by the engine actor hierarchy. +#[derive(Debug)] +struct ScopeRegistry { + values: RwLock>>, +} + +impl ScopeRegistry +where + K: Copy + Eq + Hash, +{ + fn new() -> Self { + Self { + values: RwLock::new(HashMap::new()), + } + } + + fn insert(&self, key: K, value: &Arc) { + write_lock(&self.values).insert(key, Arc::clone(value)); + } + + fn get(&self, key: &K) -> Option> { + read_lock(&self.values).get(key).cloned() + } + + fn remove(&self, key: &K) -> Option> { + write_lock(&self.values).remove(key) + } + + fn values(&self) -> Vec> { + read_lock(&self.values).values().cloned().collect() + } + + fn retain(&self, keep: impl Fn(K) -> bool) { + write_lock(&self.values).retain(|key, _| keep(*key)); + } +} + +/// Shared live-state hub used by the engine actor hierarchy. +#[derive(Debug)] +pub(crate) struct FrontendHub { + live: LivePublisher, + torrents: ScopeRegistry, + peers: ScopeRegistry>, + trackers: ScopeRegistry>, + next_tracker_id: AtomicU64, +} + +/// Cloneable owner of one frontend hub. #[derive(Debug, Clone)] pub(crate) struct FrontendPublisher { - live: LivePublisher, - torrents: Arc>>, - peers: Arc>>, - trackers: Arc>>, + hub: Arc, } impl FrontendPublisher { @@ -226,31 +273,41 @@ impl FrontendPublisher { fn with_event_capacity(event_capacity: usize) -> Self { Self { - live: LivePublisher::new( - EngineView { - status: EngineStatus::Starting, - torrent_count: 0, - torrents: Vec::new(), - }, - event_capacity, - ), - torrents: Arc::new(RwLock::new(HashMap::new())), - peers: Arc::new(RwLock::new(HashMap::new())), - trackers: Arc::new(RwLock::new(HashMap::new())), + hub: Arc::new(FrontendHub { + live: LivePublisher::new( + EngineView { + status: EngineStatus::Starting, + torrent_count: 0, + torrents: Vec::new(), + }, + event_capacity, + ), + torrents: ScopeRegistry::new(), + peers: ScopeRegistry::new(), + trackers: ScopeRegistry::new(), + next_tracker_id: AtomicU64::new(1), + }), } } + pub(crate) fn from_hub(hub: Arc) -> Self { + Self { hub } + } + + pub(crate) fn downgrade(&self) -> Weak { + Arc::downgrade(&self.hub) + } + pub(crate) fn subscribe(&self) -> EventSubscription { - self.live.subscribe() + self.hub.live.subscribe() } pub(crate) fn view(&self) -> EngineView { - self.live.view() + self.hub.live.view() } pub(crate) fn torrent_view(&self, torrent: InfoHash) -> Option { self - .live .view() .torrents .into_iter() @@ -258,177 +315,142 @@ impl FrontendPublisher { } pub(crate) fn torrent_handle(&self, torrent: InfoHash) -> Option { - self.read_torrents().get(&torrent).cloned() + self + .hub + .torrents + .get(&torrent) + .map(|inner| Torrent { inner }) } pub(crate) fn peer_handles(&self, torrent: InfoHash) -> Vec { - read_lock(&self.peers) + self + .hub + .peers .values() + .into_iter() + .map(|inner| PeerHandle { inner }) .filter(|peer| peer.torrent() == torrent && peer.live_view().connected) - .cloned() .collect() } pub(crate) fn tracker_handles(&self, torrent: InfoHash) -> Vec { - read_lock(&self.trackers) + self + .hub + .trackers .values() + .into_iter() + .map(|inner| TrackerHandle { inner }) .filter(|tracker| tracker.torrent() == torrent) - .cloned() .collect() } pub(crate) fn engine_started(&self) { - self.live.edit_and_publish(|view| { + self.hub.live.edit_and_publish(|view| { view.status = EngineStatus::Running; CoreEventKind::EngineStarted(view.clone()) }); } pub(crate) fn engine_stopping(&self) { - self.set_engine_status(EngineStatus::Stopping); + let _ = self.hub.live.edit_view(|view| { + view.status = EngineStatus::Stopping; + }); } pub(crate) fn engine_stopped(&self) { - let mut view = self.live.view(); + let mut view = self.view(); view.status = EngineStatus::Stopped; - self.live.close(view.clone(), CoreEventKind::Shutdown(view)); + self + .hub + .live + .close(view.clone(), CoreEventKind::Shutdown(view)); } pub(crate) fn initialize_torrent(&self, torrent: TorrentView) { - self.replace_torrent(torrent); + let _ = self.hub.live.edit_view(|view| { + Self::replace_torrent_view(view, torrent); + }); } pub(crate) fn torrent_added(&self, torrent: Torrent) { + let _routing = torrent.routing_lock(); self - .write_torrents() - .insert(torrent.info_hash(), torrent.clone()); - self.publish(CoreEventKind::TorrentAdded(torrent)); + .hub + .torrents + .insert(torrent.info_hash(), &torrent.inner); + if let Some(view) = torrent.live_view() { + self.hub.live.edit_and_publish(|engine| { + Self::replace_torrent_view(engine, view); + CoreEventKind::Torrent { + torrent: torrent.clone(), + event: TorrentEventKind::Added, + } + }); + } } pub(crate) fn update_torrent(&self, torrent: TorrentView) { - self.publish_torrent(torrent, TorrentEventKind::Updated, |torrent| { - CoreEventKind::TorrentUpdated(torrent.clone()) - }); + self.publish_torrent(torrent, TorrentEventKind::Updated); } pub(crate) fn metadata_resolved(&self, torrent: TorrentView) { - self.publish_torrent(torrent, TorrentEventKind::MetadataResolved, |torrent| { - CoreEventKind::MetadataResolved(torrent.clone()) - }); + self.publish_torrent(torrent, TorrentEventKind::MetadataResolved); } pub(crate) fn progress_changed(&self, torrent: TorrentView) { - let info_hash = torrent.info_hash; let progress = torrent.progress.clone(); - self.publish_torrent( - torrent, - TorrentEventKind::ProgressChanged(progress.clone()), - |_| CoreEventKind::ProgressChanged { - torrent: info_hash, - progress, - }, - ); + self.publish_torrent(torrent, TorrentEventKind::ProgressChanged(progress)); } - pub(crate) fn peer_connected( - &self, torrent: TorrentView, scope: PeerScope, view: PeerView, - ) -> PeerHandle { - let info_hash = torrent.info_hash; - let peer = PeerHandle::new(scope, view, self.clone()); - write_lock(&self.peers).insert(scope, peer.clone()); - self.publish_torrent( - torrent, - TorrentEventKind::PeerConnected(peer.clone()), - |_| CoreEventKind::PeerConnected { - torrent: info_hash, - peer: peer.clone(), - }, - ); + pub(crate) fn peer(&self, scope: PeerScope, view: PeerView) -> PeerHandle { + let peer = PeerHandle::new(scope, view, self.downgrade()); + self.hub.peers.insert(scope, &peer.inner); peer } - pub(crate) fn peer_updated(&self, peer: &PeerHandle) { - if read_lock(&self.peers).contains_key(&peer.scope()) { - if let Some(torrent) = self.read_torrents().get(&peer.torrent()).cloned() - && let Some(view) = self.torrent_view(peer.torrent()) - { - torrent.publish(view, TorrentEventKind::PeerUpdated(peer.clone())); - } - self.publish(CoreEventKind::PeerUpdated { - torrent: peer.torrent(), - peer: peer.clone(), - }); - } + pub(crate) fn peer_connected(&self, torrent: TorrentView, peer: &PeerHandle) { + self.publish_torrent(torrent, TorrentEventKind::PeerConnected(peer.clone())); } - pub(crate) fn peer_disconnected(&self, peer: PeerHandle) { - if read_lock(&self.peers).contains_key(&peer.scope()) { - if let Some(torrent) = self.read_torrents().get(&peer.torrent()).cloned() - && let Some(view) = self.torrent_view(peer.torrent()) - { - torrent.publish(view, TorrentEventKind::PeerDisconnected(peer.clone())); - } - self.publish(CoreEventKind::PeerDisconnected { - torrent: peer.torrent(), - peer, - }); + pub(crate) fn peer_event(&self, peer: &PeerHandle, event: PeerEventKind) { + if self.hub.peers.get(&peer.scope()).is_none() { + return; + } + let torrent_event = match event { + PeerEventKind::Updated => TorrentEventKind::PeerUpdated(peer.clone()), + PeerEventKind::Disconnected => TorrentEventKind::PeerDisconnected(peer.clone()), + }; + if let Some(view) = self.torrent_view(peer.torrent()) { + self.publish_torrent(view, torrent_event); + } + if matches!(event, PeerEventKind::Disconnected) { + self.hub.peers.remove(&peer.scope()); } } - pub(crate) fn tracker(&self, scope: TrackerScope, view: TrackerView) -> TrackerHandle { - let tracker = TrackerHandle::new(scope.clone(), view, self.clone()); - write_lock(&self.trackers).insert(scope, tracker.clone()); + pub(crate) fn tracker(&self, torrent: InfoHash, view: TrackerView) -> TrackerHandle { + let id = TrackerId::new(self.hub.next_tracker_id.fetch_add(1, Ordering::Relaxed)); + let scope = TrackerScope { torrent, id }; + let tracker = TrackerHandle::new(scope, view, self.downgrade()); + self.hub.trackers.insert(scope, &tracker.inner); tracker } - pub(crate) fn tracker_announce_succeeded(&self, tracker: &TrackerHandle) { - let scope = tracker.scope(); - if read_lock(&self.trackers).contains_key(scope) { - if let Some(torrent) = self.read_torrents().get(&scope.torrent).cloned() - && let Some(view) = self.torrent_view(scope.torrent) - { - torrent.publish( - view, - TorrentEventKind::TrackerAnnounceSucceeded(tracker.clone()), - ); - } - self.publish(CoreEventKind::TrackerAnnounceSucceeded { - torrent: scope.torrent, - tracker: tracker.clone(), - }); + pub(crate) fn tracker_event(&self, tracker: &TrackerHandle, event: TrackerEventKind) { + if self.hub.trackers.get(&tracker.scope()).is_none() { + return; } - } - - pub(crate) fn tracker_announce_failed(&self, tracker: &TrackerHandle) { - let scope = tracker.scope(); - if read_lock(&self.trackers).contains_key(scope) { - if let Some(torrent) = self.read_torrents().get(&scope.torrent).cloned() - && let Some(view) = self.torrent_view(scope.torrent) - { - torrent.publish( - view, - TorrentEventKind::TrackerAnnounceFailed(tracker.clone()), - ); + let torrent_event = match event { + TrackerEventKind::AnnounceSucceeded { .. } => { + TorrentEventKind::TrackerAnnounceSucceeded(tracker.clone()) } - self.publish(CoreEventKind::TrackerAnnounceFailed { - torrent: scope.torrent, - tracker: tracker.clone(), - }); - } - } - - pub(crate) fn tracker_stopped(&self, tracker: &TrackerHandle) { - let scope = tracker.scope(); - if read_lock(&self.trackers).contains_key(scope) { - if let Some(torrent) = self.read_torrents().get(&scope.torrent).cloned() - && let Some(view) = self.torrent_view(scope.torrent) - { - torrent.publish(view, TorrentEventKind::TrackerStopped(tracker.clone())); + TrackerEventKind::AnnounceFailed => { + TorrentEventKind::TrackerAnnounceFailed(tracker.clone()) } - self.publish(CoreEventKind::TrackerStopped { - torrent: scope.torrent, - tracker: tracker.clone(), - }); + TrackerEventKind::Stopped => TorrentEventKind::TrackerStopped(tracker.clone()), + }; + if let Some(view) = self.torrent_view(tracker.torrent()) { + self.publish_torrent(view, torrent_event); } } @@ -441,139 +463,113 @@ impl FrontendPublisher { message: message.into(), }; if let Some(info_hash) = torrent - && let Some(handle) = self.read_torrents().get(&info_hash).cloned() && let Some(view) = self.torrent_view(info_hash) { - handle.publish(view, TorrentEventKind::Health(health.clone())); + self.publish_torrent(view, TorrentEventKind::Health(health)); + } else { + self.hub.live.publish(CoreEventKind::Health(health)); } - self.publish(CoreEventKind::Health(health)); } pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { - let info_hash = torrent.info_hash; let current = torrent.state; self.publish_torrent( torrent, TorrentEventKind::StateChanged { previous, current }, - |_| CoreEventKind::TorrentStateChanged { - torrent: info_hash, - previous, - current, - }, ); } - pub(crate) fn torrent_removed(&self, torrent: InfoHash) { - let removed = self.write_torrents().remove(&torrent); - let peers = read_lock(&self.peers) - .values() - .filter(|peer| peer.torrent() == torrent && peer.live_view().connected) - .cloned() - .collect::>(); - for peer in peers { + pub(crate) fn torrent_removed(&self, info_hash: InfoHash) { + let removed = self + .hub + .torrents + .remove(&info_hash) + .map(|inner| Torrent { inner }); + + for peer in self + .peer_handles(info_hash) + .into_iter() + .filter(|peer| peer.live_view().connected) + { peer.disconnected(); } - write_lock(&self.peers).retain(|scope, _| scope.torrent != torrent); - let trackers = read_lock(&self.trackers) - .values() - .filter(|tracker| tracker.torrent() == torrent && tracker.live_view().active) - .cloned() - .collect::>(); - for tracker in &trackers { + self.hub.peers.retain(|scope| scope.torrent != info_hash); + + for tracker in self + .tracker_handles(info_hash) + .into_iter() + .filter(|tracker| tracker.live_view().active) + { tracker.stopped(); } - write_lock(&self.trackers).retain(|scope, _| scope.torrent != torrent); - if let Some(handle) = removed { - handle.removed(); - self.live.edit_and_publish(|view| { - Self::remove_torrent_view(view, torrent); - CoreEventKind::TorrentRemoved(handle) - }); - } else { - self - .live - .edit_view(|view| Self::remove_torrent_view(view, torrent)); - } - } - - pub(crate) fn publish(&self, kind: CoreEventKind) { - self.live.publish(kind); - } + self.hub.trackers.retain(|scope| scope.torrent != info_hash); - fn set_engine_status(&self, status: EngineStatus) -> EngineView { - self - .live - .edit_view(|view| { - view.status = status; - view.clone() - }) - .unwrap_or_else(|| self.live.view()) - } - - fn replace_torrent(&self, torrent: TorrentView) { - let _ = self.live.edit_view(|view| { - match view - .torrents - .iter_mut() - .find(|candidate| candidate.info_hash == torrent.info_hash) - { - Some(current) => *current = torrent, - None => view.torrents.push(torrent), - } - view - .torrents - .sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); - view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); - }); - } + let Some(torrent) = removed else { + let _ = self.hub.live.edit_view(|view| { + Self::remove_torrent_view(view, info_hash); + }); + return; + }; - fn publish_torrent( - &self, view: TorrentView, event: TorrentEventKind, - core_event: impl FnOnce(&Torrent) -> CoreEventKind, - ) { - let info_hash = view.info_hash; - let Some(torrent) = self.read_torrents().get(&info_hash).cloned() else { - let _ = self.live.edit_view(|engine| { - if let Some(current) = engine - .torrents - .iter_mut() - .find(|candidate| candidate.info_hash == info_hash) - { - *current = view; + let _routing = torrent.routing_lock(); + if torrent.removed() { + self.hub.live.edit_and_publish(|view| { + Self::remove_torrent_view(view, info_hash); + CoreEventKind::Torrent { + torrent: torrent.clone(), + event: TorrentEventKind::Removed, } }); + } + } + + fn publish_torrent(&self, view: TorrentView, event: TorrentEventKind) { + let Some(torrent) = self.torrent_handle(view.info_hash) else { return; }; - torrent.publish(view.clone(), event); - self.live.edit_if_and_publish( + let _routing = torrent.routing_lock(); + if !torrent.publish(view.clone(), event.clone()) { + return; + } + self.hub.live.edit_if_and_publish( |engine| { let Some(current) = engine .torrents .iter_mut() - .find(|candidate| candidate.info_hash == info_hash) + .find(|candidate| candidate.info_hash == view.info_hash) else { return false; }; *current = view; true }, - core_event(&torrent), + CoreEventKind::Torrent { + torrent: torrent.clone(), + event, + }, ); } - fn remove_torrent_view(view: &mut EngineView, torrent: InfoHash) { + fn replace_torrent_view(view: &mut EngineView, torrent: TorrentView) { + match view + .torrents + .iter_mut() + .find(|candidate| candidate.info_hash == torrent.info_hash) + { + Some(current) => *current = torrent, + None => view.torrents.push(torrent), + } view .torrents - .retain(|candidate| candidate.info_hash != torrent); + .sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); } - fn read_torrents(&self) -> RwLockReadGuard<'_, HashMap> { - read_lock(&self.torrents) - } - - fn write_torrents(&self) -> RwLockWriteGuard<'_, HashMap> { - write_lock(&self.torrents) + fn remove_torrent_view(view: &mut EngineView, torrent: InfoHash) { + view + .torrents + .retain(|candidate| candidate.info_hash != torrent); + view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); } } @@ -611,8 +607,7 @@ mod tests { downloaded_bytes: 0, uploaded_bytes: 0, }; - let peer = PeerHandle::new(scope, view.clone(), frontend.clone()); - write_lock(&frontend.peers).insert(scope, peer.clone()); + let peer = frontend.peer(scope, view.clone()); let mut listener = peer.listener(); let mut updated = view; updated.downloaded_bytes = 16; @@ -623,4 +618,36 @@ mod tests { assert_eq!(event.kind, super::super::PeerEventKind::Updated); assert_eq!(listener.view().downloaded_bytes, 16); } + + #[test] + fn live_handles_do_not_keep_their_frontend_hub_alive() { + let frontend = FrontendPublisher::new(); + let hub = frontend.downgrade(); + let scope = PeerScope { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), + }; + let peer = frontend.peer( + scope, + PeerView { + address: None, + client: None, + connected: true, + peer_choking: true, + peer_interested: false, + client_choking: true, + client_interested: false, + available_pieces: 0, + download_rate_bytes_per_second: 0, + upload_rate_bytes_per_second: 0, + downloaded_bytes: 0, + uploaded_bytes: 0, + }, + ); + + drop(frontend); + + assert!(hub.upgrade().is_none()); + assert!(peer.live_view().connected); + } } diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 7515fa0c..2f6ba31c 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -28,7 +28,7 @@ use crate::{ errors::TorrentError, frontend::{ FrontendHealthLevel, FrontendPublisher, TorrentProgress, TorrentTransfer, TorrentView, - TrackerScope, TrackerView, + TrackerView, }, hashes::InfoHash, metainfo::{Info, MetaInfo}, @@ -36,8 +36,7 @@ use crate::{ pieces::{FilePieceManager, PieceManager, PieceScheduler, PieceStoreActor}, settings::Settings, torrent::{ - BLOCK_SIZE, PieceStorageStrategy, TORRENT_SNAPSHOT_VERSION, TorrentExport, TorrentSnapshot, - TorrentState, + BLOCK_SIZE, PieceStorageStrategy, TORRENT_SNAPSHOT_VERSION, TorrentSnapshot, TorrentState, }, tracker::{ Announce, Event, Tracker, TrackerActor, TrackerActorArgs, TrackerUpdate, udp::UdpServer, @@ -422,8 +421,8 @@ impl TorrentActor { .await; } - pub fn export(&self) -> TorrentExport { - TorrentExport { + pub fn snapshot(&self) -> TorrentSnapshot { + TorrentSnapshot { version: TORRENT_SNAPSHOT_VERSION, info_hash: self.info_hash(), state: self.state, @@ -441,10 +440,6 @@ impl TorrentActor { } } - pub fn snapshot(&self) -> TorrentSnapshot { - self.export() - } - /// Builds the display-oriented state used by live frontend listeners. pub fn live_view(&self) -> TorrentView { let info = self.info_dict(); @@ -673,10 +668,7 @@ impl Actor for TorrentActor { for tracker in tracker_list { let endpoint = tracker.frontend_endpoint(); let tracker_frontend = frontend.tracker( - TrackerScope { - torrent: torrent_id, - endpoint: endpoint.clone(), - }, + torrent_id, TrackerView { endpoint, active: true, @@ -819,8 +811,8 @@ mod tests { settings::Settings, testing, torrent::{ - BLOCK_SIZE, Torrent, TorrentExport, TorrentSnapshot, - commands::{ExportState, GetState, HasInfoDict, SetState}, + BLOCK_SIZE, Torrent, TorrentSnapshot, + commands::{GetState, HasInfoDict, SetState, SnapshotState}, events::IncomingPiece, }, tracker::Tracker, @@ -967,7 +959,7 @@ mod tests { assert!(query.contains(&format!("left={}", info.total_length()))); assert!(query.contains("compact=0")); - let export = actor.ask(ExportState).await.unwrap(); + let export = actor.ask(SnapshotState).await.unwrap(); assert_eq!(export.info_hash, info_hash); assert_eq!(export.state, TorrentState::Downloading); @@ -1321,7 +1313,7 @@ mod tests { let wrote_piece_block = timeout(Duration::from_secs(60), async { loop { - let export = actor.ask(ExportState).await.unwrap(); + let export = actor.ask(SnapshotState).await.unwrap(); let has_persisted_progress = export.bitfield.count_ones() > 0 || export .block_map @@ -1441,7 +1433,7 @@ mod tests { settings: Settings::default(), }; - let export = test_actor.export(); + let export = test_actor.snapshot(); // Verify export contents assert_eq!(export.info_hash, info_hash); @@ -1468,14 +1460,16 @@ mod tests { ); match &export.piece_storage { - PieceStorageStrategy::Disk(p) => assert_eq!(p, &piece_path), + 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 from_export: TorrentSnapshot = from_str(&export_str).unwrap(); assert_eq!(export.info_hash, from_export.info_hash); assert_eq!(export.state, from_export.state); diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index e85afc3d..fd099138 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -1,4 +1,8 @@ -use std::{fmt, path::PathBuf}; +use std::{ + fmt, + path::PathBuf, + sync::{Arc, Mutex, MutexGuard, Weak}, +}; use kameo::actor::ActorRef; use tokio::sync::oneshot; @@ -14,31 +18,37 @@ use super::{ use crate::{ errors::TorrentError, frontend::{ - DEFAULT_EVENT_CAPACITY, EventSubscription, FrontendPublisher, LivePublisher, PeerHandle, - TorrentEventKind, TorrentListener, TorrentView, TrackerHandle, + DEFAULT_EVENT_CAPACITY, EventSubscription, FrontendHub, FrontendPublisher, LivePublisher, + PeerHandle, TorrentEventKind, TorrentListener, TorrentView, TrackerHandle, }, hashes::InfoHash, pieces::PieceManager, }; +#[derive(Debug)] +pub(crate) struct TorrentInner { + pub(crate) info_hash: InfoHash, + pub(crate) actor: ActorRef, + pub(crate) hub: Weak, + pub(crate) live: LivePublisher, TorrentEventKind>, + routing: Mutex<()>, +} + /// 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)] +/// 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 { - info_hash: InfoHash, - actor: ActorRef, - frontend: FrontendPublisher, - live: LivePublisher, TorrentEventKind>, + pub(crate) inner: Arc, } impl fmt::Debug for Torrent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("Torrent") - .field("info_hash", &self.info_hash) + .field("info_hash", &self.info_hash()) .finish_non_exhaustive() } } @@ -48,27 +58,31 @@ impl Torrent { /// to its underlying [`TorrentActor`]. #[cfg(test)] pub(crate) fn new(info_hash: InfoHash, actor_ref: ActorRef) -> Self { - Self::new_with_frontend(info_hash, actor_ref, FrontendPublisher::default()) + Self::new_with_frontend(info_hash, actor_ref, &FrontendPublisher::default(), None) } pub(crate) fn new_with_frontend( - info_hash: InfoHash, actor: ActorRef, frontend: FrontendPublisher, + info_hash: InfoHash, actor: ActorRef, frontend: &FrontendPublisher, + initial_view: Option, ) -> Self { Self { - info_hash, - actor, - live: LivePublisher::new(frontend.torrent_view(info_hash), DEFAULT_EVENT_CAPACITY), - frontend, + inner: Arc::new(TorrentInner { + info_hash, + actor, + hub: frontend.downgrade(), + live: LivePublisher::new(initial_view, DEFAULT_EVENT_CAPACITY), + routing: Mutex::new(()), + }), } } pub(crate) fn actor(&self) -> &ActorRef { - &self.actor + &self.inner.actor } /// Returns the [`InfoHash`] that uniquely identifies this torrent. pub fn info_hash(&self) -> InfoHash { - self.info_hash + self.inner.info_hash } /// Alias for [`Self::info_hash`]. @@ -135,15 +149,14 @@ impl Torrent { async fn set_state( &self, state: TorrentState, operation: &'static str, ) -> Result<(), TorrentError> { - let msg = SetState { state }; - 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(Self::communication_error)?; - Ok(()) } @@ -155,11 +168,6 @@ impl Torrent { .map_err(Self::communication_error) } - /// Exports the current resumable torrent state for application persistence. - 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. /// @@ -174,20 +182,18 @@ impl Torrent { } pub async fn set_auto_start(&self, auto: bool) -> Result<(), TorrentError> { - let msg = SetAutoStart { auto }; self .actor() - .tell(msg) + .tell(SetAutoStart { auto }) .await .map_err(Self::communication_error)?; Ok(()) } pub async fn set_sufficient_peers(&self, peers: usize) -> Result<(), TorrentError> { - let msg = SetSufficientPeers { peers }; self .actor() - .tell(msg) + .tell(SetSufficientPeers { peers }) .await .map_err(Self::communication_error)?; Ok(()) @@ -195,27 +201,25 @@ impl Torrent { pub async fn poll_ready(&self) -> Result<(), TorrentError> { let (hook, hook_rx) = oneshot::channel(); - let msg = ReadyHook { hook }; self .actor() - .tell(msg) + .tell(ReadyHook { hook }) .await .map_err(Self::communication_error)?; hook_rx.await.map_err(Self::communication_error)?; - Ok(()) } /// Subscribes to live events for this torrent only. #[must_use] pub fn subscribe(&self) -> EventSubscription { - self.live.subscribe() + self.inner.live.subscribe() } /// Creates a live listener scoped to this torrent. #[must_use] pub fn listener(&self) -> TorrentListener { - self.live.listener() + self.inner.live.listener() } /// Returns the latest display-oriented state maintained for this torrent. @@ -223,30 +227,46 @@ impl Torrent { /// This returns `None` after the torrent has been removed from its engine. #[must_use] pub fn live_view(&self) -> Option { - self.live.view() + self.inner.live.view() } /// Returns handles for this torrent's currently connected peers. #[must_use] pub fn peers(&self) -> Vec { - self.frontend.peer_handles(self.info_hash) + self + .frontend() + .map_or_else(Vec::new, |frontend| frontend.peer_handles(self.info_hash())) } /// Returns handles for this torrent's configured trackers. #[must_use] pub fn trackers(&self) -> Vec { - self.frontend.tracker_handles(self.info_hash) + self.frontend().map_or_else(Vec::new, |frontend| { + frontend.tracker_handles(self.info_hash()) + }) + } + + pub(crate) fn publish(&self, view: TorrentView, event: TorrentEventKind) -> bool { + self.inner.live.update(Some(view), event) } - pub(crate) fn publish(&self, view: TorrentView, event: TorrentEventKind) { - self.live.update(Some(view), event); + pub(crate) fn removed(&self) -> bool { + self.inner.live.close(None, TorrentEventKind::Removed) + } + + pub(crate) fn routing_lock(&self) -> MutexGuard<'_, ()> { + self + .inner + .routing + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) } - pub(crate) fn removed(&self) { - self.live.update(None, TorrentEventKind::Removed); + fn frontend(&self) -> Option { + self.inner.hub.upgrade().map(FrontendPublisher::from_hub) } - fn communication_error(error: impl std::fmt::Display) -> TorrentError { + fn communication_error(error: impl fmt::Display) -> TorrentError { TorrentError::ActorCommunicationFailed { actor_type: "torrent".to_string(), reason: error.to_string(), diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index babc9c8f..59e9c11e 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -11,12 +11,13 @@ use tracing::{info, instrument, trace, warn}; use super::{ AnnounceFrom, BLOCK_SIZE, PieceStorageStrategy, TORRENT_SNAPSHOT_VERSION, TorrentActor, - TorrentExport, TorrentSnapshot, TorrentState, + TorrentSnapshot, TorrentState, actor::{PieceManagerProxy, ReadyHookSender}, util, }; use crate::{ errors::TorrentError, + frontend::TorrentView, hashes::InfoHash, metainfo::Info, peer::{Peer, PeerId, commands::HaveInfoDict}, @@ -538,8 +539,8 @@ pub(crate) mod commands { } #[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()) } #[message] diff --git a/crates/libtortillas/src/torrent/mod.rs b/crates/libtortillas/src/torrent/mod.rs index 469bc228..01c37e18 100644 --- a/crates/libtortillas/src/torrent/mod.rs +++ b/crates/libtortillas/src/torrent/mod.rs @@ -14,8 +14,8 @@ mod swarm; pub(crate) use actor::{TorrentActor, TorrentActorArgs}; pub use block::{BLOCK_SIZE, BlockMap}; pub use discovery::AnnounceFrom; -pub(crate) type TorrentExport = TorrentSnapshot; pub use handle::Torrent; +pub(crate) use handle::TorrentInner; pub(crate) use messages::*; pub use snapshot::{TORRENT_SNAPSHOT_VERSION, TorrentSnapshot}; pub use state::TorrentState; diff --git a/crates/libtortillas/src/torrent/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index 72837663..fb2aec98 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -109,8 +109,7 @@ impl TorrentActor { return; } - let peer_frontend = self.frontend.peer_connected( - self.live_view(), + let peer_frontend = self.frontend.peer( PeerScope { torrent: info_hash, peer: id, @@ -125,7 +124,7 @@ impl TorrentActor { actor_ref, info_hash, peer_settings, - peer_frontend, + peer_frontend.clone(), ), match peer_mailbox_size { 0 => mailbox::unbounded(), @@ -133,7 +132,9 @@ impl TorrentActor { }, ); self.peers.insert(id, peer_actor); - self.frontend.update_torrent(self.live_view()); + self + .frontend + .peer_connected(self.live_view(), &peer_frontend); } #[instrument(skip(self, tell), fields(torrent_id = %self.info_hash(), msg = ?tell))] diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index 55aa1515..28e2a9e5 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -32,7 +32,13 @@ async fn engine_listener_receives_live_torrent_lifecycle() { let added = timeout(Duration::from_secs(2), async { loop { let event = engine_listener.next().await.unwrap().unwrap(); - if matches!(event.kind, CoreEventKind::TorrentAdded(_)) { + if matches!( + event.kind, + CoreEventKind::Torrent { + event: TorrentEventKind::Added, + .. + } + ) { break event; } } @@ -40,7 +46,11 @@ async fn engine_listener_receives_live_torrent_lifecycle() { .await .unwrap(); assert_eq!(added.torrent(), Some(torrent.info_hash())); - let CoreEventKind::TorrentAdded(added_torrent) = added.kind else { + let CoreEventKind::Torrent { + torrent: added_torrent, + event: TorrentEventKind::Added, + } = added.kind + else { unreachable!(); }; assert_eq!(added_torrent.info_hash(), torrent.info_hash()); @@ -244,7 +254,13 @@ async fn live_views_are_serde_compatible() { timeout(Duration::from_secs(2), async { loop { let event = listener.recv().await.unwrap(); - if matches!(event.kind, CoreEventKind::TorrentAdded(_)) { + if matches!( + event.kind, + CoreEventKind::Torrent { + event: TorrentEventKind::Added, + .. + } + ) { break; } } From 9c58b57699e2a55b834f4472be26ae6c8ff0b0a5 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:34:53 -0700 Subject: [PATCH 50/77] fix: separate tracker identity from display labels --- crates/libtortillas/src/facade.rs | 3 ++- crates/libtortillas/src/frontend/handle.rs | 10 +++---- crates/libtortillas/src/frontend/mod.rs | 4 ++- crates/libtortillas/src/frontend/publisher.rs | 20 +++++++++++++- crates/libtortillas/src/frontend/view.rs | 27 ++++++++++++++++--- crates/libtortillas/src/torrent/actor.rs | 5 ++-- crates/libtortillas/tests/live_frontend.rs | 9 ++++--- 7 files changed, 59 insertions(+), 19 deletions(-) diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index 33851038..6ab49fc4 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -24,7 +24,8 @@ pub use crate::{ EventStreamError, EventSubscription, FrontendHealth, FrontendHealthLevel, LivePublisher, PeerEvent, PeerEventKind, PeerHandle, PeerListener, PeerView, Sequenced, TorrentEvent, TorrentEventKind, TorrentListener, TorrentProgress, TorrentTransfer, TorrentView, - TrackerEvent, TrackerEventKind, TrackerHandle, TrackerId, TrackerListener, TrackerView, + TrackerEvent, TrackerEventKind, TrackerHandle, TrackerId, TrackerListener, TrackerStatus, + TrackerView, }, torrent::TorrentSnapshot, }; diff --git a/crates/libtortillas/src/frontend/handle.rs b/crates/libtortillas/src/frontend/handle.rs index 3fa0836f..4870ba96 100644 --- a/crates/libtortillas/src/frontend/handle.rs +++ b/crates/libtortillas/src/frontend/handle.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use super::{ DEFAULT_EVENT_CAPACITY, EventListener, EventSubscription, FrontendHub, FrontendPublisher, - LivePublisher, PeerEventKind, PeerView, TrackerEventKind, TrackerView, + LivePublisher, PeerEventKind, PeerView, TrackerEventKind, TrackerStatus, TrackerView, }; use crate::{hashes::InfoHash, peer::PeerId}; @@ -243,8 +243,7 @@ impl TrackerHandle { pub(crate) fn announce_succeeded(&self, peers_returned: u64) { let mut view = self.live_view(); - view.active = true; - view.healthy = true; + view.status = TrackerStatus::Healthy; view.peers_returned = Some(peers_returned); let event = TrackerEventKind::AnnounceSucceeded { peers_returned }; if self.inner.update(view, event) @@ -256,8 +255,7 @@ impl TrackerHandle { pub(crate) fn announce_failed(&self) { let mut view = self.live_view(); - view.active = true; - view.healthy = false; + view.status = TrackerStatus::Degraded; view.peers_returned = None; if self.inner.update(view, TrackerEventKind::AnnounceFailed) && let Some(frontend) = self.inner.frontend() @@ -268,7 +266,7 @@ impl TrackerHandle { pub(crate) fn stopped(&self) { let mut view = self.live_view(); - view.active = false; + view.status = TrackerStatus::Stopped; if self.inner.close(view, TrackerEventKind::Stopped) && let Some(frontend) = self.inner.frontend() { diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index acbe77f0..a180c5a9 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -21,4 +21,6 @@ pub use listener::{EngineListener, EventListener, TorrentListener}; pub use publisher::{DEFAULT_EVENT_CAPACITY, LivePublisher}; pub(crate) use publisher::{FrontendHub, FrontendPublisher}; pub use subscription::{EventStreamError, EventSubscription}; -pub use view::{EngineView, PeerView, TorrentProgress, TorrentTransfer, TorrentView, TrackerView}; +pub use view::{ + EngineView, PeerView, TorrentProgress, TorrentTransfer, TorrentView, TrackerStatus, TrackerView, +}; diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index b360971d..27bceeb4 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -498,7 +498,7 @@ impl FrontendPublisher { for tracker in self .tracker_handles(info_hash) .into_iter() - .filter(|tracker| tracker.live_view().active) + .filter(|tracker| tracker.live_view().status.is_active()) { tracker.stopped(); } @@ -650,4 +650,22 @@ mod tests { assert!(hub.upgrade().is_none()); assert!(peer.live_view().connected); } + + #[test] + fn trackers_with_the_same_public_endpoint_keep_distinct_identities() { + let frontend = FrontendPublisher::new(); + let torrent = InfoHash::from_bytes([3; 20]); + let view = TrackerView { + endpoint: "https://tracker.example".to_string(), + status: super::super::TrackerStatus::Pending, + peers_returned: None, + }; + + let first = frontend.tracker(torrent, view.clone()); + let second = frontend.tracker(torrent, view); + + assert_ne!(first.id(), second.id()); + assert_ne!(first, second); + assert_eq!(frontend.tracker_handles(torrent).len(), 2); + } } diff --git a/crates/libtortillas/src/frontend/view.rs b/crates/libtortillas/src/frontend/view.rs index 9f0b45aa..f049a30a 100644 --- a/crates/libtortillas/src/frontend/view.rs +++ b/crates/libtortillas/src/frontend/view.rs @@ -100,10 +100,29 @@ impl PeerView { pub struct TrackerView { /// Credential-free tracker endpoint label. pub endpoint: String, - /// Whether the tracker actor is running. - pub active: bool, - /// Whether the latest announce succeeded. - pub healthy: bool, + /// Current actor and announce lifecycle. + pub status: TrackerStatus, /// Number of peers returned by the latest successful announce. pub peers_returned: Option, } + +/// 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 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) + } +} diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 2f6ba31c..dbaf36af 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -28,7 +28,7 @@ use crate::{ errors::TorrentError, frontend::{ FrontendHealthLevel, FrontendPublisher, TorrentProgress, TorrentTransfer, TorrentView, - TrackerView, + TrackerStatus, TrackerView, }, hashes::InfoHash, metainfo::{Info, MetaInfo}, @@ -671,8 +671,7 @@ impl Actor for TorrentActor { torrent_id, TrackerView { endpoint, - active: true, - healthy: false, + status: TrackerStatus::Pending, peers_returned: None, }, ); diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index 28e2a9e5..e8affbff 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -4,7 +4,10 @@ use futures::StreamExt; use libtortillas::{ engine::EngineStatus, errors::EngineError, - frontend::{CoreEventKind, EventStreamError, LivePublisher, TorrentEventKind, TrackerEventKind}, + frontend::{ + CoreEventKind, EventStreamError, LivePublisher, TorrentEventKind, TrackerEventKind, + TrackerStatus, + }, prelude::{Engine, Settings, TorrentSource, TorrentState}, }; use tokio::time::{sleep, timeout}; @@ -158,7 +161,7 @@ async fn tracker_handle_exposes_its_own_live_listener() { let tracker = torrent.trackers().into_iter().next().unwrap(); let mut listener = tracker.listener(); - assert!(tracker.live_view().active); + assert!(tracker.live_view().status.is_active()); engine.shutdown().await.unwrap(); let stopped = timeout(Duration::from_secs(2), async { @@ -172,7 +175,7 @@ async fn tracker_handle_exposes_its_own_live_listener() { .await .unwrap(); assert!(stopped.sequence > 0); - assert!(!listener.view().active); + assert_eq!(listener.view().status, TrackerStatus::Stopped); } #[tokio::test] From fa8a4e1c4209f5a3efedf6631b9c811a61748f1b Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:36:02 -0700 Subject: [PATCH 51/77] fix: make peer disconnection terminal --- crates/libtortillas/src/engine/mod.rs | 10 ++- crates/libtortillas/src/frontend/handle.rs | 6 +- crates/libtortillas/src/frontend/publisher.rs | 61 ++++++++++++++++--- crates/libtortillas/src/peer/actor.rs | 11 +++- crates/libtortillas/src/torrent/messages.rs | 9 +-- 5 files changed, 74 insertions(+), 23 deletions(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 6fb2d499..3d2bef9e 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -732,18 +732,24 @@ mod tests { loop { let event = listener.recv().await.unwrap(); if let CoreEventKind::Torrent { + torrent, event: crate::frontend::TorrentEventKind::PeerConnected(peer), - .. } = event.kind { - break peer; + break (torrent, peer); } } }) .await .unwrap(); + let (event_torrent, peer) = peer; assert_eq!(peer.torrent(), info_hash); assert!(peer.live_view().address.is_some()); + assert!( + event_torrent + .live_view() + .is_some_and(|view| view.peer_count > 0) + ); let _peer_listener = peer.listener(); engine.shutdown().await.unwrap(); diff --git a/crates/libtortillas/src/frontend/handle.rs b/crates/libtortillas/src/frontend/handle.rs index 4870ba96..8f70c63a 100644 --- a/crates/libtortillas/src/frontend/handle.rs +++ b/crates/libtortillas/src/frontend/handle.rs @@ -155,17 +155,17 @@ impl PeerHandle { if self.inner.update(view, PeerEventKind::Updated) && let Some(frontend) = self.inner.frontend() { - frontend.peer_event(self, PeerEventKind::Updated); + frontend.peer_updated(self); } } - pub(crate) fn disconnected(&self) { + pub(crate) fn disconnected(&self, torrent: Option) { let mut view = self.live_view(); view.connected = false; if self.inner.close(view, PeerEventKind::Disconnected) && let Some(frontend) = self.inner.frontend() { - frontend.peer_event(self, PeerEventKind::Disconnected); + frontend.peer_disconnected(self, torrent); } } } diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 27bceeb4..36276e74 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -412,19 +412,21 @@ impl FrontendPublisher { self.publish_torrent(torrent, TorrentEventKind::PeerConnected(peer.clone())); } - pub(crate) fn peer_event(&self, peer: &PeerHandle, event: PeerEventKind) { + pub(crate) fn peer_updated(&self, peer: &PeerHandle) { if self.hub.peers.get(&peer.scope()).is_none() { return; } - let torrent_event = match event { - PeerEventKind::Updated => TorrentEventKind::PeerUpdated(peer.clone()), - PeerEventKind::Disconnected => TorrentEventKind::PeerDisconnected(peer.clone()), - }; if let Some(view) = self.torrent_view(peer.torrent()) { - self.publish_torrent(view, torrent_event); + self.publish_torrent(view, TorrentEventKind::PeerUpdated(peer.clone())); } - if matches!(event, PeerEventKind::Disconnected) { - self.hub.peers.remove(&peer.scope()); + } + + pub(crate) fn peer_disconnected(&self, peer: &PeerHandle, torrent: Option) { + if self.hub.peers.remove(&peer.scope()).is_none() { + return; + } + if let Some(view) = torrent { + self.publish_torrent(view, TorrentEventKind::PeerDisconnected(peer.clone())); } } @@ -491,7 +493,7 @@ impl FrontendPublisher { .into_iter() .filter(|peer| peer.live_view().connected) { - peer.disconnected(); + peer.disconnected(None); } self.hub.peers.retain(|scope| scope.torrent != info_hash); @@ -619,6 +621,47 @@ mod tests { assert_eq!(listener.view().downloaded_bytes, 16); } + #[tokio::test] + async fn disconnected_peer_rejects_late_actor_updates() { + let frontend = FrontendPublisher::new(); + let scope = PeerScope { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), + }; + let view = PeerView { + address: None, + client: None, + connected: true, + peer_choking: true, + peer_interested: false, + client_choking: true, + client_interested: false, + available_pieces: 0, + download_rate_bytes_per_second: 0, + upload_rate_bytes_per_second: 0, + downloaded_bytes: 0, + uploaded_bytes: 0, + }; + let peer = frontend.peer(scope, view.clone()); + let mut listener = peer.listener(); + + peer.disconnected(None); + let mut late = view; + late.downloaded_bytes = 32; + peer.update(late); + + assert_eq!( + listener.recv().await.unwrap().kind, + PeerEventKind::Disconnected + ); + assert_eq!( + listener.recv().await, + Err(super::super::EventStreamError::Closed) + ); + assert!(!listener.view().connected); + assert_eq!(listener.view().downloaded_bytes, 0); + } + #[test] fn live_handles_do_not_keep_their_frontend_hub_alive() { let frontend = FrontendPublisher::new(); diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index 03b232fd..e504f8cb 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -425,11 +425,13 @@ impl Actor for PeerActor { async fn on_stop( &mut self, _: WeakActorRef, _: ActorStopReason, ) -> Result<(), Self::Error> { - self.frontend.disconnected(); 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, + frontend: self.frontend.clone(), + }) .await { warn!(error = %err, %peer_id, "Failed to notify torrent actor about stopped peer"); @@ -456,7 +458,10 @@ 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, + frontend: self.frontend.clone(), + }) .await { warn!(error = %err, "Failed to tell supervisor to kill peer"); diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index 59e9c11e..90113b4b 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -153,16 +153,13 @@ pub(crate) mod commands { #[messages] impl TorrentActor { #[message] - pub(crate) fn kill_peer(&mut self, id: PeerId) { + pub(crate) fn kill_peer(&mut self, id: PeerId, frontend: crate::frontend::PeerHandle) { self.piece_scheduler.peer_disconnected(id); // Kill the actor quietly. - if let Some(actor) = self.peers.get(&id) { + if let Some(actor) = self.peers.remove(&id) { actor.kill(); - self.peers.remove(&id); - self.frontend.update_torrent(self.live_view()); - } else { - warn!("Received kill peer message for unknown peer"); } + frontend.disconnected(Some(self.live_view())); } #[message] From 0f55236eec98c1fee2298f7dff4b34f282209c9b Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:36:43 -0700 Subject: [PATCH 52/77] feat: aggregate live torrent transfer metrics --- crates/libtortillas/src/frontend/view.rs | 53 ++++++++++++++++++++++++ crates/libtortillas/src/torrent/actor.rs | 18 ++++---- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/libtortillas/src/frontend/view.rs b/crates/libtortillas/src/frontend/view.rs index f049a30a..6a5dfc94 100644 --- a/crates/libtortillas/src/frontend/view.rs +++ b/crates/libtortillas/src/frontend/view.rs @@ -52,6 +52,30 @@ pub struct TorrentTransfer { pub eta_seconds: Option, } +impl TorrentTransfer { + pub(crate) fn from_peers( + peers: impl IntoIterator, bytes_remaining: Option, + ) -> Self { + let (download_rate, upload_rate) = + peers + .into_iter() + .fold((0_u64, 0_u64), |(download, upload), peer| { + ( + download.saturating_add(peer.download_rate_bytes_per_second), + upload.saturating_add(peer.upload_rate_bytes_per_second), + ) + }); + let eta_seconds = bytes_remaining + .and_then(|remaining| (download_rate > 0).then(|| remaining.div_ceil(download_rate))); + + Self { + download_rate_bytes_per_second: Some(download_rate), + upload_rate_bytes_per_second: Some(upload_rate), + eta_seconds, + } + } +} + /// Live view of a connected or recently disconnected peer. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PeerView { @@ -126,3 +150,32 @@ impl TrackerStatus { !matches!(self, Self::Stopped) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn torrent_transfer_aggregates_peer_rates_and_estimates_completion() { + let peer = PeerView { + address: None, + client: None, + connected: true, + peer_choking: false, + peer_interested: true, + client_choking: false, + client_interested: true, + available_pieces: 1, + download_rate_bytes_per_second: 3, + upload_rate_bytes_per_second: 2, + downloaded_bytes: 0, + uploaded_bytes: 0, + }; + + let transfer = TorrentTransfer::from_peers([peer.clone(), peer], Some(13)); + + assert_eq!(transfer.download_rate_bytes_per_second, Some(6)); + assert_eq!(transfer.upload_rate_bytes_per_second, Some(4)); + assert_eq!(transfer.eta_seconds, Some(3)); + } +} diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index dbaf36af..ef0c982a 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -465,6 +465,14 @@ impl TorrentActor { piece_idx < total_pieces && !self.bitfield[piece_idx] && entry.value().count_ones() > 0 }) .count(); + let transfer = TorrentTransfer::from_peers( + self + .frontend + .peer_handles(self.info_hash()) + .into_iter() + .map(|peer| peer.live_view()), + bytes_remaining, + ); TorrentView { info_hash: self.info_hash(), @@ -489,11 +497,7 @@ impl TorrentActor { partial_pieces: Self::snapshot_u64(partial_pieces), total_pieces: Self::snapshot_u64(total_pieces), }, - transfer: TorrentTransfer { - download_rate_bytes_per_second: None, - upload_rate_bytes_per_second: None, - eta_seconds: None, - }, + transfer, } } @@ -1597,8 +1601,8 @@ mod tests { view.progress.bytes_remaining.unwrap() < u64::try_from(info_dict.total_length()).unwrap() ); assert!(view.progress.progress_fraction.unwrap() > 0.0); - assert_eq!(view.transfer.download_rate_bytes_per_second, None); - assert_eq!(view.transfer.upload_rate_bytes_per_second, None); + assert_eq!(view.transfer.download_rate_bytes_per_second, Some(0)); + assert_eq!(view.transfer.upload_rate_bytes_per_second, Some(0)); assert_eq!(view.transfer.eta_seconds, None); let snapshot = test_actor.snapshot(); From 6e25683d0579581fbe785f27261582c3047e4816 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:41:59 -0700 Subject: [PATCH 53/77] refactor: centralize transactional snapshot restore --- crates/libtortillas/src/engine/messages.rs | 52 ++++++++- crates/libtortillas/src/engine/mod.rs | 107 +++--------------- crates/libtortillas/src/engine/snapshot.rs | 30 ++++- crates/libtortillas/src/torrent/messages.rs | 85 +------------- crates/libtortillas/src/torrent/snapshot.rs | 90 ++++++++++++++- crates/libtortillas/tests/engine_lifecycle.rs | 4 +- crates/libtortillas/tests/persistence.rs | 24 ++++ 7 files changed, 214 insertions(+), 178 deletions(-) diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 3e631b85..768f636c 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -3,7 +3,7 @@ use kameo::{actor::Spawn, mailbox, messages, prelude::ActorRef, supervision::Res use tokio::time::timeout; use tracing::{error, warn}; -use super::{ENGINE_SNAPSHOT_VERSION, EngineActor, EngineSnapshot, EngineStatus}; +use super::{ENGINE_SNAPSHOT_VERSION, EngineActor, EngineSnapshot}; use crate::{ dht::messages::commands::{RegisterTorrent, UnregisterTorrent}, errors::EngineError, @@ -137,6 +137,9 @@ pub(crate) mod commands { pub(crate) async fn create_torrent( &mut self, metainfo: Box, restore: Option>, ) -> Result, EngineError> { + if let Some(snapshot) = restore.as_ref() { + snapshot.validate()?; + } let info_hash = metainfo.info_hash().map_err(|e| { error!(error = %e, "Failed to unwrap info hash"); EngineError::Other(e) @@ -270,6 +273,51 @@ pub(crate) mod commands { Ok(torrent_ref) } + /// Atomically validates and restores an engine snapshot against the + /// authoritative actor state. + #[message] + pub(crate) async fn restore_engine( + &mut self, snapshot: EngineSnapshot, + ) -> 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(Box::new(torrent.metainfo.clone()), Some(Box::new(torrent))) + .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(); + self.frontend.torrent_removed(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 { @@ -294,8 +342,6 @@ pub(crate) mod commands { Ok(EngineSnapshot { version: ENGINE_SNAPSHOT_VERSION, - status: EngineStatus::Running, - torrent_count: u64::try_from(torrents.len()).unwrap_or(u64::MAX), torrents, }) } diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 3d2bef9e..353f3283 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -46,7 +46,7 @@ mod messages; mod snapshot; mod source; -use std::{collections::HashSet, net::SocketAddr, path::PathBuf}; +use std::{net::SocketAddr, path::PathBuf}; pub(crate) use actor::*; use bon; @@ -57,7 +57,9 @@ use kameo::{ pub(crate) use messages::*; pub use source::TorrentSource; -use self::commands::{CreateTorrent, GetTorrent, RemoveTorrent, SnapshotEngine, StartAll}; +use self::commands::{ + CreateTorrent, GetTorrent, RemoveTorrent, RestoreEngine, SnapshotEngine, StartAll, +}; pub use self::snapshot::{ENGINE_SNAPSHOT_VERSION, EngineSnapshot, EngineStatus}; use crate::{ errors::EngineError, @@ -299,28 +301,8 @@ impl Engine { pub async fn restore_torrent( &self, snapshot: crate::torrent::TorrentSnapshot, ) -> Result { - if snapshot.version != crate::torrent::TORRENT_SNAPSHOT_VERSION { - return Err( - crate::errors::TorrentError::InvalidSnapshot { - reason: format!( - "unsupported version {}; expected {}", - snapshot.version, - crate::torrent::TORRENT_SNAPSHOT_VERSION - ), - } - .into(), - ); - } + snapshot.validate()?; let info_hash = snapshot.info_hash; - let metainfo_hash = snapshot.metainfo.info_hash()?; - if metainfo_hash != info_hash { - return Err( - crate::errors::TorrentError::InvalidSnapshot { - reason: "info hash does not match metainfo".to_string(), - } - .into(), - ); - } match self .actor() @@ -344,55 +326,15 @@ impl Engine { /// method removes the torrents already restored by this call before /// returning the error. pub async fn restore(&self, snapshot: EngineSnapshot) -> Result, EngineError> { - if snapshot.version != ENGINE_SNAPSHOT_VERSION { - return Err(EngineError::InvalidSnapshot { - reason: format!( - "unsupported version {}; expected {}", - snapshot.version, ENGINE_SNAPSHOT_VERSION - ), - }); - } - if snapshot.torrent_count != u64::try_from(snapshot.torrents.len()).unwrap_or(u64::MAX) { - return Err(EngineError::InvalidSnapshot { - reason: "torrent count does not match serialized torrent entries".to_string(), - }); - } - if self.live_view().torrent_count != 0 { - return Err(EngineError::InvalidSnapshot { - reason: "target engine already manages torrents".to_string(), - }); - } - let mut unique = HashSet::with_capacity(snapshot.torrents.len()); - if snapshot - .torrents - .iter() - .any(|torrent| !unique.insert(torrent.info_hash)) - { - return Err(EngineError::InvalidSnapshot { - reason: "snapshot contains duplicate torrent info hashes".to_string(), - }); - } - - let mut restored = Vec::with_capacity(snapshot.torrents.len()); - for torrent_snapshot in snapshot.torrents { - match self.restore_torrent(torrent_snapshot).await { - Ok(torrent) => restored.push(torrent), - Err(error) => { - for torrent in &restored { - if let Err(remove_error) = self.remove_torrent(torrent.info_hash()).await { - tracing::warn!( - error = %remove_error, - torrent = %torrent.info_hash(), - "Failed to roll back restored torrent" - ); - } - } - return Err(error); - } - } - } - - Ok(restored) + let info_hashes = match self.actor().ask(RestoreEngine { snapshot }).await { + Ok(info_hashes) => info_hashes, + Err(SendError::HandlerError(error)) => return Err(error), + Err(error) => return Err(EngineError::Other(anyhow::anyhow!(error.to_string()))), + }; + info_hashes + .into_iter() + .map(|info_hash| self.frontend_torrent(info_hash)) + .collect() } /// Starts all torrents managed by the engine. /// See [`Torrent::start`] for more information. @@ -424,14 +366,10 @@ impl Engine { Err(err) => return Err(EngineError::Other(anyhow::anyhow!(err.to_string()))), }; - torrent - .stop_gracefully() - .await - .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; + let stop_result = torrent.stop_gracefully().await; torrent.wait_for_shutdown().await; self.frontend.torrent_removed(info_hash); - - Ok(()) + stop_result.map_err(|error| EngineError::Other(anyhow::anyhow!(error.to_string()))) } /// Gracefully shuts down the engine and its managed torrent actors. @@ -446,11 +384,6 @@ impl Engine { Ok(()) } - /// Exports the current resumable engine state for application persistence. - pub async fn export(&self) -> Result { - self.snapshot().await - } - /// Captures all managed torrent sessions in a Serde-compatible persistence /// snapshot. /// @@ -527,9 +460,7 @@ mod snapshot_tests { .unwrap(); let snapshot = engine.snapshot().await.unwrap(); - assert_eq!(snapshot.status, EngineStatus::Running); assert_eq!(snapshot.version, ENGINE_SNAPSHOT_VERSION); - assert_eq!(snapshot.torrent_count, 1); assert_eq!(snapshot.torrents.len(), 1); assert_eq!(snapshot.torrents[0].info_hash, torrent.info_hash()); assert_eq!( @@ -543,8 +474,6 @@ mod snapshot_tests { let from_snapshot: EngineSnapshot = from_str(&snapshot_str).unwrap(); assert_eq!(snapshot.version, from_snapshot.version); - assert_eq!(snapshot.status, from_snapshot.status); - assert_eq!(snapshot.torrent_count, from_snapshot.torrent_count); assert_eq!( snapshot.torrents[0].info_hash, from_snapshot.torrents[0].info_hash @@ -597,7 +526,7 @@ 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 export = engine.snapshot().await.unwrap(); assert_eq!(torrent.info_hash().to_hex(), BIG_BUCK_BUNNY_INFO_HASH); assert_eq!(export.torrents.len(), 1); @@ -612,7 +541,7 @@ mod tests { let source = TorrentSource::magnet(BIG_BUCK_BUNNY_MAGNET); let torrent = engine.add_torrent(source).await.unwrap(); - let export = engine.export().await.unwrap(); + let export = engine.snapshot().await.unwrap(); assert_eq!(torrent.info_hash().to_hex(), BIG_BUCK_BUNNY_INFO_HASH); assert_eq!(export.torrents.len(), 1); diff --git a/crates/libtortillas/src/engine/snapshot.rs b/crates/libtortillas/src/engine/snapshot.rs index 1452e674..afaa22c5 100644 --- a/crates/libtortillas/src/engine/snapshot.rs +++ b/crates/libtortillas/src/engine/snapshot.rs @@ -1,6 +1,8 @@ +use std::collections::HashSet; + use serde::{Deserialize, Serialize}; -use crate::torrent::TorrentSnapshot; +use crate::{errors::EngineError, torrent::TorrentSnapshot}; /// Current persistence schema version for [`EngineSnapshot`]. pub const ENGINE_SNAPSHOT_VERSION: u32 = 1; @@ -9,11 +11,33 @@ pub const ENGINE_SNAPSHOT_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EngineSnapshot { pub version: u32, - pub status: EngineStatus, - pub torrent_count: u64, pub torrents: Vec, } +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 frontend displays. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum EngineStatus { diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index 90113b4b..37507622 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -10,8 +10,7 @@ use sha1::{Digest, Sha1}; use tracing::{info, instrument, trace, warn}; use super::{ - AnnounceFrom, BLOCK_SIZE, PieceStorageStrategy, TORRENT_SNAPSHOT_VERSION, TorrentActor, - TorrentSnapshot, TorrentState, + AnnounceFrom, BLOCK_SIZE, PieceStorageStrategy, TorrentActor, TorrentSnapshot, TorrentState, actor::{PieceManagerProxy, ReadyHookSender}, util, }; @@ -255,90 +254,16 @@ pub(crate) mod commands { &mut self, snapshot: TorrentSnapshot, ) -> SnapshotRestoreResult { let result = (|| -> Result { - if snapshot.version != TORRENT_SNAPSHOT_VERSION { - return Err(TorrentError::InvalidSnapshot { - reason: format!( - "unsupported version {}; expected {}", - snapshot.version, TORRENT_SNAPSHOT_VERSION - ), - }); - } + snapshot.validate()?; if snapshot.info_hash != self.info_hash() { return Err(TorrentError::InvalidSnapshot { reason: "info hash does not match metainfo".to_string(), }); } - if let Some(info) = &snapshot.info_dict { - let restored_hash = info.hash().map_err(|error| TorrentError::InvalidSnapshot { - reason: format!("failed to hash restored info dictionary: {error}"), - })?; - if restored_hash != snapshot.info_hash { - return Err(TorrentError::InvalidSnapshot { - reason: "restored info dictionary does not match the info hash".to_string(), - }); - } - } - - let info = snapshot.info_dict.as_ref().or_else(|| self.info_dict()); - let piece_count = info.map_or(0, Info::piece_count); - if snapshot.bitfield.len() != piece_count { - return Err(TorrentError::InvalidSnapshot { - reason: format!( - "bitfield has {} pieces but metadata declares {piece_count}", - snapshot.bitfield.len() - ), - }); - } - for entry in &snapshot.block_map { - let index = *entry.key(); - if index >= piece_count { - return Err(TorrentError::InvalidSnapshot { - reason: "partial piece index is outside the metadata piece range".to_string(), - }); - } - if snapshot.bitfield[index] { - return Err(TorrentError::InvalidSnapshot { - reason: "completed piece also contains partial block state".to_string(), - }); - } - - let Some(info) = info else { - return Err(TorrentError::InvalidSnapshot { - reason: "partial block state requires resolved metadata".to_string(), - }); - }; - let piece_length = usize::try_from(info.piece_length).map_err(|_| { - TorrentError::InvalidSnapshot { - reason: "piece length cannot be represented on this platform".to_string(), - } - })?; - if piece_length == 0 { - return Err(TorrentError::InvalidSnapshot { - reason: "piece length must be greater than zero".to_string(), - }); - } - let last_piece = piece_count.saturating_sub(1); - let concrete_length = if index == last_piece { - let remainder = info.total_length() % piece_length; - if remainder == 0 { - piece_length - } else { - remainder - } - } else { - piece_length - }; - let expected_blocks = concrete_length.div_ceil(BLOCK_SIZE); - if entry.value().len() != expected_blocks { - return Err(TorrentError::InvalidSnapshot { - reason: format!( - "partial piece {index} has {} blocks; expected {expected_blocks}", - entry.value().len() - ), - }); - } - } + let piece_count = snapshot + .resolved_info() + .map_or(0, crate::metainfo::Info::piece_count); let resume = snapshot.state.is_transfer_active(); let restored_state = match snapshot.state { diff --git a/crates/libtortillas/src/torrent/snapshot.rs b/crates/libtortillas/src/torrent/snapshot.rs index 3ca4f403..8454666a 100644 --- a/crates/libtortillas/src/torrent/snapshot.rs +++ b/crates/libtortillas/src/torrent/snapshot.rs @@ -3,8 +3,9 @@ use std::{path::PathBuf, sync::atomic::AtomicU8}; use bitvec::vec::BitVec; use serde::{Deserialize, Serialize}; -use super::{BlockMap, PieceStorageStrategy, TorrentState}; +use super::{BLOCK_SIZE, BlockMap, PieceStorageStrategy, TorrentState}; use crate::{ + errors::TorrentError, hashes::InfoHash, metainfo::{Info, MetaInfo}, }; @@ -30,3 +31,90 @@ pub struct TorrentSnapshot { pub bitfield: BitVec, pub block_map: BlockMap, } + +impl TorrentSnapshot { + /// Validates schema compatibility and all redundant integrity fields. + 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 let Some(info) = &self.info_dict { + 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() + ))); + } + for entry in &self.block_map { + let index = *entry.key(); + 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 piece_length = usize::try_from(info.piece_length) + .map_err(|_| self.invalid("piece length cannot be represented on this platform"))?; + if piece_length == 0 { + return Err(self.invalid("piece length must be greater than zero")); + } + let last_piece = piece_count.saturating_sub(1); + let concrete_length = if index == last_piece { + let remainder = info.total_length() % piece_length; + if remainder == 0 { + piece_length + } else { + remainder + } + } else { + piece_length + }; + let expected_blocks = concrete_length.div_ceil(BLOCK_SIZE); + if entry.value().len() != expected_blocks { + return Err(self.invalid(format!( + "partial piece {index} has {} blocks; expected {expected_blocks}", + entry.value().len() + ))); + } + } + + Ok(()) + } + + pub(crate) fn resolved_info(&self) -> Option<&Info> { + self.info_dict.as_ref().or_else(|| match &self.metainfo { + MetaInfo::Torrent(torrent) => Some(&torrent.info), + MetaInfo::MagnetUri(_) => None, + }) + } + + fn invalid(&self, reason: impl Into) -> TorrentError { + TorrentError::InvalidSnapshot { + reason: reason.into(), + } + } +} diff --git a/crates/libtortillas/tests/engine_lifecycle.rs b/crates/libtortillas/tests/engine_lifecycle.rs index f6d37d29..7b48177f 100644 --- a/crates/libtortillas/tests/engine_lifecycle.rs +++ b/crates/libtortillas/tests/engine_lifecycle.rs @@ -30,10 +30,10 @@ 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); engine.remove_torrent(info_hash).await.unwrap(); - assert!(engine.export().await.unwrap().torrents.is_empty()); + assert!(engine.snapshot().await.unwrap().torrents.is_empty()); assert!(torrent.state().await.is_err()); let err = engine.remove_torrent(info_hash).await.unwrap_err(); diff --git a/crates/libtortillas/tests/persistence.rs b/crates/libtortillas/tests/persistence.rs index 02c46051..9fc37272 100644 --- a/crates/libtortillas/tests/persistence.rs +++ b/crates/libtortillas/tests/persistence.rs @@ -146,6 +146,30 @@ async fn engine_snapshot_when_version_is_unknown_then_restores_nothing() { 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.live_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(); From aac1106df4e0710df71544c1c9f89ad9d1be4faa Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:43:44 -0700 Subject: [PATCH 54/77] refactor: preserve typed actor communication errors --- crates/libtortillas/src/engine/mod.rs | 36 +++++++++++++--------- crates/libtortillas/src/errors.rs | 18 +++++++++-- crates/libtortillas/src/torrent/handle.rs | 36 ++++++++++++---------- crates/libtortillas/tests/live_frontend.rs | 16 ++++++++++ 4 files changed, 72 insertions(+), 34 deletions(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 353f3283..ef134282 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -233,6 +233,13 @@ impl Engine { &self.actor } + fn communication_error(operation: &'static str, error: impl std::fmt::Display) -> EngineError { + EngineError::ActorCommunicationFailed { + operation, + reason: error.to_string(), + } + } + /// 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]. @@ -287,7 +294,7 @@ impl Engine { restore: None, }) .await - .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; + .map_err(|error| Self::communication_error("add torrent", error))?; self.frontend_torrent(info_hash) // We don't need to assign link or insert the ref here because its already @@ -314,7 +321,7 @@ impl Engine { { Ok(_) => {} Err(SendError::HandlerError(error)) => return Err(error), - Err(error) => return Err(EngineError::Other(anyhow::anyhow!(error.to_string()))), + Err(error) => return Err(Self::communication_error("restore torrent", error)), } self.frontend_torrent(info_hash) @@ -329,7 +336,7 @@ impl Engine { let info_hashes = match self.actor().ask(RestoreEngine { snapshot }).await { Ok(info_hashes) => info_hashes, Err(SendError::HandlerError(error)) => return Err(error), - Err(error) => return Err(EngineError::Other(anyhow::anyhow!(error.to_string()))), + Err(error) => return Err(Self::communication_error("restore engine", error)), }; info_hashes .into_iter() @@ -341,9 +348,9 @@ impl Engine { 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| Self::communication_error("start all torrents", error))?; Ok(()) } @@ -352,7 +359,7 @@ impl Engine { match self.actor().ask(GetTorrent { info_hash }).await { Ok(_) => {} Err(SendError::HandlerError(err)) => return Err(err), - Err(err) => return Err(EngineError::Other(anyhow::anyhow!(err.to_string()))), + Err(error) => return Err(Self::communication_error("get torrent", error)), } self.frontend_torrent(info_hash) @@ -363,13 +370,13 @@ impl Engine { 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()))), + Err(error) => return Err(Self::communication_error("remove torrent", error)), }; let stop_result = torrent.stop_gracefully().await; torrent.wait_for_shutdown().await; self.frontend.torrent_removed(info_hash); - stop_result.map_err(|error| EngineError::Other(anyhow::anyhow!(error.to_string()))) + stop_result.map_err(|error| Self::communication_error("stop torrent", error)) } /// Gracefully shuts down the engine and its managed torrent actors. @@ -378,7 +385,7 @@ impl Engine { .actor() .stop_gracefully() .await - .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string())))?; + .map_err(|error| Self::communication_error("shut down engine", error))?; self.actor().wait_for_shutdown().await; Ok(()) @@ -394,7 +401,7 @@ impl Engine { .actor() .ask(SnapshotEngine) .await - .map_err(|e| EngineError::Other(anyhow::anyhow!(e.to_string()))) + .map_err(|error| Self::communication_error("snapshot engine", error)) } /// Subscribes to typed engine and torrent events as they happen. @@ -423,11 +430,10 @@ impl Engine { } fn frontend_torrent(&self, info_hash: InfoHash) -> Result { - self.frontend.torrent_handle(info_hash).ok_or_else(|| { - EngineError::Other(anyhow::anyhow!( - "torrent {info_hash} is missing its frontend handle" - )) - }) + self + .frontend + .torrent_handle(info_hash) + .ok_or_else(|| EngineError::FrontendHandleMissing { info_hash }) } } diff --git a/crates/libtortillas/src/errors.rs b/crates/libtortillas/src/errors.rs index efcfba6e..83cca204 100644 --- a/crates/libtortillas/src/errors.rs +++ b/crates/libtortillas/src/errors.rs @@ -72,6 +72,17 @@ pub enum EngineError { #[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 live public handle. + #[error("Torrent {info_hash} is missing its frontend handle")] + FrontendHandleMissing { info_hash: InfoHash }, + /// Any other engine-level error wrapped in [`anyhow::Error`] #[error(transparent)] Other(#[from] anyhow::Error), @@ -302,8 +313,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)] diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index fd099138..39478971 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -95,11 +95,11 @@ impl Torrent { ) -> Result<(), TorrentError> { self .actor() - .tell(SetPieceStorage { + .ask(SetPieceStorage { strategy: piece_storage, }) .await - .map_err(Self::communication_error)?; + .map_err(|error| Self::communication_error("set piece storage", error))?; Ok(()) } @@ -110,7 +110,7 @@ impl Torrent { path: folder.into(), }) .await - .map_err(Self::communication_error)?; + .map_err(|error| Self::communication_error("set output path", error))?; Ok(()) } @@ -119,11 +119,11 @@ impl Torrent { ) -> Result<(), TorrentError> { self .actor() - .tell(SetPieceManager { + .ask(SetPieceManager { manager: Box::new(piece_manager), }) .await - .map_err(Self::communication_error)?; + .map_err(|error| Self::communication_error("set piece manager", error))?; Ok(()) } @@ -156,7 +156,7 @@ impl Torrent { .inspect_err(|error| { error!(%error, operation, "Failed to change torrent state"); }) - .map_err(Self::communication_error)?; + .map_err(|error| Self::communication_error(operation, error))?; Ok(()) } @@ -165,7 +165,7 @@ impl Torrent { .actor() .ask(GetState) .await - .map_err(Self::communication_error) + .map_err(|error| Self::communication_error("get state", error)) } /// Captures this torrent's metadata, storage configuration, and verified or @@ -178,24 +178,24 @@ impl Torrent { .ask(SnapshotState) .await .map(|snapshot| *snapshot) - .map_err(Self::communication_error) + .map_err(|error| Self::communication_error("snapshot torrent", error)) } pub async fn set_auto_start(&self, auto: bool) -> Result<(), TorrentError> { self .actor() - .tell(SetAutoStart { auto }) + .ask(SetAutoStart { auto }) .await - .map_err(Self::communication_error)?; + .map_err(|error| Self::communication_error("set auto start", error))?; Ok(()) } pub async fn set_sufficient_peers(&self, peers: usize) -> Result<(), TorrentError> { self .actor() - .tell(SetSufficientPeers { peers }) + .ask(SetSufficientPeers { peers }) .await - .map_err(Self::communication_error)?; + .map_err(|error| Self::communication_error("set sufficient peers", error))?; Ok(()) } @@ -203,10 +203,12 @@ impl Torrent { let (hook, hook_rx) = oneshot::channel(); self .actor() - .tell(ReadyHook { hook }) + .ask(ReadyHook { hook }) .await - .map_err(Self::communication_error)?; - hook_rx.await.map_err(Self::communication_error)?; + .map_err(|error| Self::communication_error("register ready hook", error))?; + hook_rx + .await + .map_err(|error| Self::communication_error("wait for readiness", error))?; Ok(()) } @@ -266,9 +268,9 @@ impl Torrent { self.inner.hub.upgrade().map(FrontendPublisher::from_hub) } - fn communication_error(error: impl fmt::Display) -> TorrentError { + fn communication_error(operation: &'static str, error: impl fmt::Display) -> TorrentError { TorrentError::ActorCommunicationFailed { - actor_type: "torrent".to_string(), + operation, reason: error.to_string(), } } diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index e8affbff..4ea4ac47 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -213,6 +213,22 @@ async fn engine_methods_return_typed_unknown_torrent_errors() { 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 lagging_listener_recovers_from_current_live_view() { let engine = deterministic_engine(); From ec660fed881adfd9fc2958879ecbf2e7f907694f Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:45:30 -0700 Subject: [PATCH 55/77] test: verify terminal frontend reconciliation --- crates/libtortillas/src/engine/mod.rs | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index ef134282..6e0d3e99 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -572,6 +572,54 @@ mod tests { )); } + #[tokio::test] + async fn torrent_removal_reconciles_frontend_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.live_view().torrent_count, 0); + assert!(torrent.live_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.live_view().unwrap(); + + engine.frontend.torrent_removed(info_hash); + engine.frontend.update_torrent(late_view); + + assert!(torrent.live_view().is_none()); + assert_eq!(engine.live_view().torrent_count, 0); + let _ = engine.remove_torrent(info_hash).await; + engine.shutdown().await.unwrap(); + } + #[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(); From 90d073dc6dd44561956b305f7de3ec8866a9eb4a Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:51:38 -0700 Subject: [PATCH 56/77] fix: break actor frontend ownership cycles --- crates/libtortillas/src/ARCHITECTURE.md | 15 ++- crates/libtortillas/src/engine/messages.rs | 2 +- crates/libtortillas/src/engine/mod.rs | 21 ++++ crates/libtortillas/src/facade.rs | 12 +- crates/libtortillas/src/frontend/event.rs | 8 +- crates/libtortillas/src/frontend/publisher.rs | 119 +++++++++++++----- crates/libtortillas/tests/live_frontend.rs | 29 ++++- docs/frontend-integration.md | 14 ++- 8 files changed, 163 insertions(+), 57 deletions(-) diff --git a/crates/libtortillas/src/ARCHITECTURE.md b/crates/libtortillas/src/ARCHITECTURE.md index 34eb6251..a8703537 100644 --- a/crates/libtortillas/src/ARCHITECTURE.md +++ b/crates/libtortillas/src/ARCHITECTURE.md @@ -24,11 +24,16 @@ Domain types such as torrent state, storage strategy, exported snapshots, tracke ## Frontend Boundary -`Engine` and `Torrent` own the stable application boundary. Typed commands are -routed through their `send` methods, while `listener` combines a bounded event -subscription with current `EngineView` or `TorrentView` state. The shared -frontend publisher is independent from tracing and is propagated through the -engine, torrent, peer, and tracker actor hierarchy. +`Engine` and `Torrent` own the stable application boundary. Their direct +methods are the only public command API, while `listener` combines a bounded +event subscription with current `EngineView` or `TorrentView` state. A shared +frontend hub coordinates the engine, torrent, peer, and tracker hierarchy. +Public handles hold weak back-references to that hub, and each scope has an +irreversible terminal state so actor updates cannot resurrect removed objects. + +Engine events project the canonical `TorrentEventKind` hierarchy through +`CoreEventKind::Torrent`; they do not duplicate every torrent, peer, and +tracker event in a second vocabulary. Live views are intentionally distinct from `EngineSnapshot` and `TorrentSnapshot`. Views are display-oriented and continuously updated by diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 768f636c..3a98323a 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -176,7 +176,7 @@ pub(crate) mod commands { sufficient_peers: restoring.then_some(usize::MAX), base_path, settings: self.settings.clone(), - frontend: self.frontend.clone(), + frontend: self.frontend.weak(), }, ) .restart_policy(RestartPolicy::Transient) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 6e0d3e99..641cd763 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -620,6 +620,27 @@ mod tests { engine.shutdown().await.unwrap(); } + #[tokio::test] + async fn buffered_torrent_events_do_not_retain_the_frontend_hub() { + let engine = Engine::builder() + .settings(deterministic_settings()) + .autostart(false) + .build(); + let hub = engine.frontend.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(); diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index 6ab49fc4..23a46e0b 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -30,16 +30,8 @@ pub use crate::{ torrent::TorrentSnapshot, }; -/// 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. +/// Facade-level name for the public [`Engine`] handle. 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. +/// Facade-level name for the public [`Torrent`] handle. pub type TorrentHandle = Torrent; diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index 6d0868b9..9f531bde 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -8,12 +8,12 @@ use crate::{ /// A sequenced event emitted by a live publisher. /// -/// Sequence numbers are engine-local and strictly increase for every event. -/// A frontend can use them to preserve event order or detect a gap after -/// reconnecting a consumer. +/// Sequence numbers are local to one publisher and strictly increase for every +/// event it emits. A frontend can use them to preserve scoped event order or +/// detect a gap after reconnecting a consumer. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Sequenced { - /// Engine-local sequence number for this event. + /// Publisher-local sequence number for this event. pub sequence: u64, /// The typed change represented by this event. pub kind: E, diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 36276e74..bc08c3ab 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -21,7 +21,7 @@ use crate::{ torrent::{Torrent, TorrentInner, TorrentState}, }; -/// Number of discrete frontend events retained for each listener. +/// Number of discrete frontend events retained by each live publisher. pub const DEFAULT_EVENT_CAPACITY: usize = 256; fn read_lock(lock: &RwLock) -> RwLockReadGuard<'_, T> { @@ -260,10 +260,16 @@ pub(crate) struct FrontendHub { next_tracker_id: AtomicU64, } -/// Cloneable owner of one frontend hub. +#[derive(Debug, Clone)] +enum HubReference { + Strong(Arc), + Weak(Weak), +} + +/// Cloneable access to one frontend hub. #[derive(Debug, Clone)] pub(crate) struct FrontendPublisher { - hub: Arc, + hub: HubReference, } impl FrontendPublisher { @@ -273,7 +279,7 @@ impl FrontendPublisher { fn with_event_capacity(event_capacity: usize) -> Self { Self { - hub: Arc::new(FrontendHub { + hub: HubReference::Strong(Arc::new(FrontendHub { live: LivePublisher::new( EngineView { status: EngineStatus::Starting, @@ -286,24 +292,44 @@ impl FrontendPublisher { peers: ScopeRegistry::new(), trackers: ScopeRegistry::new(), next_tracker_id: AtomicU64::new(1), - }), + })), } } pub(crate) fn from_hub(hub: Arc) -> Self { - Self { hub } + Self { + hub: HubReference::Strong(hub), + } + } + + pub(crate) fn weak(&self) -> Self { + Self { + hub: HubReference::Weak(self.downgrade()), + } } pub(crate) fn downgrade(&self) -> Weak { - Arc::downgrade(&self.hub) + match &self.hub { + HubReference::Strong(hub) => Arc::downgrade(hub), + HubReference::Weak(hub) => hub.clone(), + } + } + + fn hub(&self) -> Arc { + match &self.hub { + HubReference::Strong(hub) => Arc::clone(hub), + HubReference::Weak(hub) => hub + .upgrade() + .expect("frontend hub outlived by its actor hierarchy"), + } } pub(crate) fn subscribe(&self) -> EventSubscription { - self.hub.live.subscribe() + self.hub().live.subscribe() } pub(crate) fn view(&self) -> EngineView { - self.hub.live.view() + self.hub().live.view() } pub(crate) fn torrent_view(&self, torrent: InfoHash) -> Option { @@ -316,7 +342,7 @@ impl FrontendPublisher { pub(crate) fn torrent_handle(&self, torrent: InfoHash) -> Option { self - .hub + .hub() .torrents .get(&torrent) .map(|inner| Torrent { inner }) @@ -324,7 +350,7 @@ impl FrontendPublisher { pub(crate) fn peer_handles(&self, torrent: InfoHash) -> Vec { self - .hub + .hub() .peers .values() .into_iter() @@ -335,7 +361,7 @@ impl FrontendPublisher { pub(crate) fn tracker_handles(&self, torrent: InfoHash) -> Vec { self - .hub + .hub() .trackers .values() .into_iter() @@ -345,14 +371,14 @@ impl FrontendPublisher { } pub(crate) fn engine_started(&self) { - self.hub.live.edit_and_publish(|view| { + self.hub().live.edit_and_publish(|view| { view.status = EngineStatus::Running; CoreEventKind::EngineStarted(view.clone()) }); } pub(crate) fn engine_stopping(&self) { - let _ = self.hub.live.edit_view(|view| { + let _ = self.hub().live.edit_view(|view| { view.status = EngineStatus::Stopping; }); } @@ -361,13 +387,13 @@ impl FrontendPublisher { let mut view = self.view(); view.status = EngineStatus::Stopped; self - .hub + .hub() .live .close(view.clone(), CoreEventKind::Shutdown(view)); } pub(crate) fn initialize_torrent(&self, torrent: TorrentView) { - let _ = self.hub.live.edit_view(|view| { + let _ = self.hub().live.edit_view(|view| { Self::replace_torrent_view(view, torrent); }); } @@ -375,11 +401,11 @@ impl FrontendPublisher { pub(crate) fn torrent_added(&self, torrent: Torrent) { let _routing = torrent.routing_lock(); self - .hub + .hub() .torrents .insert(torrent.info_hash(), &torrent.inner); if let Some(view) = torrent.live_view() { - self.hub.live.edit_and_publish(|engine| { + self.hub().live.edit_and_publish(|engine| { Self::replace_torrent_view(engine, view); CoreEventKind::Torrent { torrent: torrent.clone(), @@ -404,7 +430,7 @@ impl FrontendPublisher { pub(crate) fn peer(&self, scope: PeerScope, view: PeerView) -> PeerHandle { let peer = PeerHandle::new(scope, view, self.downgrade()); - self.hub.peers.insert(scope, &peer.inner); + self.hub().peers.insert(scope, &peer.inner); peer } @@ -413,7 +439,7 @@ impl FrontendPublisher { } pub(crate) fn peer_updated(&self, peer: &PeerHandle) { - if self.hub.peers.get(&peer.scope()).is_none() { + if self.hub().peers.get(&peer.scope()).is_none() { return; } if let Some(view) = self.torrent_view(peer.torrent()) { @@ -422,7 +448,7 @@ impl FrontendPublisher { } pub(crate) fn peer_disconnected(&self, peer: &PeerHandle, torrent: Option) { - if self.hub.peers.remove(&peer.scope()).is_none() { + if self.hub().peers.remove(&peer.scope()).is_none() { return; } if let Some(view) = torrent { @@ -431,15 +457,15 @@ impl FrontendPublisher { } pub(crate) fn tracker(&self, torrent: InfoHash, view: TrackerView) -> TrackerHandle { - let id = TrackerId::new(self.hub.next_tracker_id.fetch_add(1, Ordering::Relaxed)); + let id = TrackerId::new(self.hub().next_tracker_id.fetch_add(1, Ordering::Relaxed)); let scope = TrackerScope { torrent, id }; let tracker = TrackerHandle::new(scope, view, self.downgrade()); - self.hub.trackers.insert(scope, &tracker.inner); + self.hub().trackers.insert(scope, &tracker.inner); tracker } pub(crate) fn tracker_event(&self, tracker: &TrackerHandle, event: TrackerEventKind) { - if self.hub.trackers.get(&tracker.scope()).is_none() { + if self.hub().trackers.get(&tracker.scope()).is_none() { return; } let torrent_event = match event { @@ -469,7 +495,7 @@ impl FrontendPublisher { { self.publish_torrent(view, TorrentEventKind::Health(health)); } else { - self.hub.live.publish(CoreEventKind::Health(health)); + self.hub().live.publish(CoreEventKind::Health(health)); } } @@ -483,7 +509,7 @@ impl FrontendPublisher { pub(crate) fn torrent_removed(&self, info_hash: InfoHash) { let removed = self - .hub + .hub() .torrents .remove(&info_hash) .map(|inner| Torrent { inner }); @@ -495,7 +521,7 @@ impl FrontendPublisher { { peer.disconnected(None); } - self.hub.peers.retain(|scope| scope.torrent != info_hash); + self.hub().peers.retain(|scope| scope.torrent != info_hash); for tracker in self .tracker_handles(info_hash) @@ -504,10 +530,13 @@ impl FrontendPublisher { { tracker.stopped(); } - self.hub.trackers.retain(|scope| scope.torrent != info_hash); + self + .hub() + .trackers + .retain(|scope| scope.torrent != info_hash); let Some(torrent) = removed else { - let _ = self.hub.live.edit_view(|view| { + let _ = self.hub().live.edit_view(|view| { Self::remove_torrent_view(view, info_hash); }); return; @@ -515,7 +544,7 @@ impl FrontendPublisher { let _routing = torrent.routing_lock(); if torrent.removed() { - self.hub.live.edit_and_publish(|view| { + self.hub().live.edit_and_publish(|view| { Self::remove_torrent_view(view, info_hash); CoreEventKind::Torrent { torrent: torrent.clone(), @@ -533,7 +562,7 @@ impl FrontendPublisher { if !torrent.publish(view.clone(), event.clone()) { return; } - self.hub.live.edit_if_and_publish( + self.hub().live.edit_if_and_publish( |engine| { let Some(current) = engine .torrents @@ -711,4 +740,32 @@ mod tests { assert_ne!(first, second); assert_eq!(frontend.tracker_handles(torrent).len(), 2); } + + #[tokio::test] + async fn stopped_tracker_rejects_late_announces() { + let frontend = FrontendPublisher::new(); + let tracker = frontend.tracker( + InfoHash::from_bytes([3; 20]), + TrackerView { + endpoint: "https://tracker.example".to_string(), + status: super::super::TrackerStatus::Pending, + peers_returned: None, + }, + ); + let mut listener = tracker.listener(); + + tracker.stopped(); + tracker.announce_succeeded(10); + + assert_eq!( + listener.recv().await.unwrap().kind, + TrackerEventKind::Stopped + ); + assert_eq!( + listener.recv().await, + Err(super::super::EventStreamError::Closed) + ); + assert_eq!(listener.view().status, super::super::TrackerStatus::Stopped); + assert_eq!(listener.view().peers_returned, None); + } } diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index 4ea4ac47..c3cd6ca5 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -88,6 +88,21 @@ async fn engine_listener_receives_live_torrent_lifecycle() { assert_eq!(torrent_listener.view().unwrap().state, TorrentState::Paused); 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); @@ -114,7 +129,10 @@ async fn live_listener_closes_when_its_publisher_is_dropped() { drop(publisher); - assert_eq!(listener.recv().await, Err(EventStreamError::Closed)); + assert!(matches!( + listener.recv().await, + Err(EventStreamError::Closed) + )); assert_eq!(listener.view(), 0); } @@ -127,7 +145,10 @@ async fn closed_live_publisher_rejects_late_updates() { assert!(!publisher.update(2, "late")); assert_eq!(listener.recv().await.unwrap().kind, "closed"); - assert_eq!(listener.recv().await, Err(EventStreamError::Closed)); + assert!(matches!( + listener.recv().await, + Err(EventStreamError::Closed) + )); assert_eq!(listener.view(), 1); } @@ -200,6 +221,10 @@ async fn engine_listener_receives_graceful_shutdown() { }; assert_eq!(view.status, EngineStatus::Stopped); assert_eq!(listener.view().status, EngineStatus::Stopped); + assert!(matches!( + listener.recv().await, + Err(EventStreamError::Closed) + )); } #[tokio::test] diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 5177b917..04a0349e 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -21,11 +21,13 @@ typed `TorrentEvent` values for that torrent only, and exposes its latest Peers and trackers returned by `Torrent::peers()` and `Torrent::trackers()` follow the same pattern. Each `PeerHandle` and `TrackerHandle` owns an independent typed listener and current view, including a terminal disconnected -or stopped view. Engine events carry the public `Torrent`, `PeerHandle`, and -`TrackerHandle` values so a frontend can descend into more detailed streams -only when needed. +or stopped view. Engine listeners receive +`CoreEventKind::Torrent { torrent, event }`, where `event` uses the same +`TorrentEventKind` vocabulary as the torrent listener. Peer and tracker +changes carry their public handles inside that nested event, so a frontend can +descend into more detailed streams only when needed. -The event channel retains 256 events per listener by default. Slow listeners +Each publisher's shared event channel retains 256 events by default. Slow listeners receive `EventStreamError::Lagged` instead of causing unbounded memory growth. After lagging, redraw from `listener.view()` and continue calling `recv()`. Sequence numbers are monotonic within each publisher. @@ -77,6 +79,10 @@ and call `engine.restore(snapshot).await?` in a later process. Use `engine.restore_torrent(snapshot).await?` for one torrent. Snapshot schemas are versioned so incompatible data returns a typed error. +Engine restore is validated and applied as one engine-actor operation. The +target must be empty when that operation begins, and a failed multi-torrent +restore removes everything created by that operation. + Snapshots retain metadata, lifecycle intent, storage paths and strategy, verified pieces, and partial blocks. Downloaded bytes remain in the referenced storage paths; the snapshot does not duplicate payload data into frontend From a7e9f6073a1372d9a48c543e02c317488b353f4a Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:54:29 -0700 Subject: [PATCH 57/77] refactor: isolate generic live publishing --- crates/libtortillas/src/frontend/live.rs | 198 +++++++++++++++++ crates/libtortillas/src/frontend/mod.rs | 3 +- crates/libtortillas/src/frontend/publisher.rs | 202 +----------------- 3 files changed, 204 insertions(+), 199 deletions(-) create mode 100644 crates/libtortillas/src/frontend/live.rs diff --git a/crates/libtortillas/src/frontend/live.rs b/crates/libtortillas/src/frontend/live.rs new file mode 100644 index 00000000..2a473f3a --- /dev/null +++ b/crates/libtortillas/src/frontend/live.rs @@ -0,0 +1,198 @@ +use std::sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use tokio::sync::broadcast; + +use super::{EventListener, EventSubscription, Sequenced}; + +/// Number of discrete frontend events retained by each live publisher. +pub const DEFAULT_EVENT_CAPACITY: usize = 256; + +pub(crate) fn read_lock(lock: &RwLock) -> RwLockReadGuard<'_, T> { + lock + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +pub(crate) fn write_lock(lock: &RwLock) -> RwLockWriteGuard<'_, T> { + lock + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn mutex_lock(lock: &Mutex) -> MutexGuard<'_, T> { + lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Generic current-state and event publisher for live application APIs. +/// +/// 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 { + sender: Mutex>>>, +} + +impl LivePublisher +where + V: Clone + Send + Sync + 'static, + E: Clone + Send + 'static, +{ + /// Creates a publisher with an initial view and bounded event capacity. + /// + /// # Panics + /// + /// Panics when `event_capacity` is zero. + #[must_use] + pub fn new(initial_view: V, event_capacity: usize) -> Self { + assert!(event_capacity > 0, "event capacity must be non-zero"); + let (events, _) = broadcast::channel(event_capacity); + Self { + state: Arc::new(Mutex::new(LiveState { + view: initial_view, + sequence: 0, + closed: false, + })), + channel: Arc::new(LiveChannel { + sender: Mutex::new(Some(events)), + }), + } + } + + /// Subscribes to all future events from this publisher. + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + let sender = mutex_lock(&self.channel.sender); + match sender.as_ref() { + Some(sender) => EventSubscription::from_receiver(sender.subscribe(), sender.downgrade()), + None => { + let (sender, receiver) = broadcast::channel(1); + let weak = sender.downgrade(); + drop(sender); + EventSubscription::from_receiver(receiver, weak) + } + } + } + + /// Creates a stream-compatible listener paired with the current view. + #[must_use] + pub fn listener(&self) -> EventListener { + let state = Arc::clone(&self.state); + EventListener::new(self.subscribe(), move || mutex_lock(&state).view.clone()) + } + + /// Clones the latest coherent view. + #[must_use] + pub fn view(&self) -> V { + mutex_lock(&self.state).view.clone() + } + + /// Replaces the current view without emitting an event. + /// + /// Returns `false` when the publisher has already closed. + pub fn set_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 fn update(&self, view: V, event: E) -> bool { + self.mutate(|current| *current = view, event) + } + + /// Emits an event using this publisher's monotonic sequence. + /// + /// Returns `false` when the publisher has already closed. + pub fn publish(&self, kind: E) -> bool { + self.mutate(|_| {}, kind) + } + + /// Atomically updates the view and permanently closes this publisher after + /// delivering one terminal event. + /// + /// Returns `false` if another caller already closed the publisher. + pub fn close(&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(Sequenced { + sequence: state.sequence, + kind: event, + }); + } + true + } + + pub(crate) fn edit_and_publish(&self, edit: impl FnOnce(&mut V) -> E) -> bool { + let mut state = mutex_lock(&self.state); + if state.closed { + return false; + } + let event = edit(&mut state.view); + state.sequence = state.sequence.saturating_add(1); + self.send(&state, event); + true + } + + pub(crate) fn edit_if_and_publish(&self, edit: impl FnOnce(&mut V) -> bool, event: E) -> bool { + let mut state = mutex_lock(&self.state); + if state.closed || !edit(&mut state.view) { + return false; + } + state.sequence = state.sequence.saturating_add(1); + self.send(&state, event); + true + } + + pub(crate) fn edit_view(&self, edit: impl FnOnce(&mut V) -> R) -> Option { + let mut state = mutex_lock(&self.state); + (!state.closed).then(|| edit(&mut state.view)) + } + + fn mutate(&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(&state, event); + true + } + + fn send(&self, state: &LiveState, event: E) { + if let Some(sender) = mutex_lock(&self.channel.sender).as_ref() { + let _ = sender.send(Sequenced { + sequence: state.sequence, + kind: event, + }); + } + } +} diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index a180c5a9..db6297d5 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -7,6 +7,7 @@ mod event; mod handle; mod listener; +mod live; mod publisher; mod subscription; mod view; @@ -18,7 +19,7 @@ pub use event::{ pub(crate) use handle::PeerScope; pub use handle::{PeerHandle, PeerListener, TrackerHandle, TrackerId, TrackerListener}; pub use listener::{EngineListener, EventListener, TorrentListener}; -pub use publisher::{DEFAULT_EVENT_CAPACITY, LivePublisher}; +pub use live::{DEFAULT_EVENT_CAPACITY, LivePublisher}; pub(crate) use publisher::{FrontendHub, FrontendPublisher}; pub use subscription::{EventStreamError, EventSubscription}; pub use view::{ diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index bc08c3ab..81e9bd4d 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -2,18 +2,17 @@ use std::{ collections::HashMap, hash::Hash, sync::{ - Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak, + Arc, RwLock, Weak, atomic::{AtomicU64, Ordering}, }, }; -use tokio::sync::broadcast; - use super::{ - CoreEventKind, EngineView, EventListener, EventSubscription, FrontendHealth, - FrontendHealthLevel, PeerEventKind, PeerHandle, PeerView, Sequenced, TorrentEventKind, + CoreEventKind, DEFAULT_EVENT_CAPACITY, EngineView, EventSubscription, FrontendHealth, + FrontendHealthLevel, LivePublisher, PeerEventKind, PeerHandle, PeerView, TorrentEventKind, TorrentView, TrackerEventKind, TrackerHandle, TrackerView, handle::{LiveHandle, PeerScope, TrackerId, TrackerScope}, + live::{read_lock, write_lock}, }; use crate::{ engine::EngineStatus, @@ -21,199 +20,6 @@ use crate::{ torrent::{Torrent, TorrentInner, TorrentState}, }; -/// Number of discrete frontend events retained by each live publisher. -pub const DEFAULT_EVENT_CAPACITY: usize = 256; - -fn read_lock(lock: &RwLock) -> RwLockReadGuard<'_, T> { - lock - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) -} - -fn write_lock(lock: &RwLock) -> RwLockWriteGuard<'_, T> { - lock - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) -} - -fn mutex_lock(lock: &Mutex) -> MutexGuard<'_, T> { - lock - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) -} - -/// Generic current-state and event publisher for live application APIs. -/// -/// 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 { - sender: Mutex>>>, -} - -impl LivePublisher -where - V: Clone + Send + Sync + 'static, - E: Clone + Send + 'static, -{ - /// Creates a publisher with an initial view and bounded event capacity. - /// - /// # Panics - /// - /// Panics when `event_capacity` is zero. - #[must_use] - pub fn new(initial_view: V, event_capacity: usize) -> Self { - assert!(event_capacity > 0, "event capacity must be non-zero"); - let (events, _) = broadcast::channel(event_capacity); - Self { - state: Arc::new(Mutex::new(LiveState { - view: initial_view, - sequence: 0, - closed: false, - })), - channel: Arc::new(LiveChannel { - sender: Mutex::new(Some(events)), - }), - } - } - - /// Subscribes to all future events from this publisher. - #[must_use] - pub fn subscribe(&self) -> EventSubscription { - let sender = mutex_lock(&self.channel.sender); - match sender.as_ref() { - Some(sender) => EventSubscription::from_receiver(sender.subscribe(), sender.downgrade()), - None => { - let (sender, receiver) = broadcast::channel(1); - let weak = sender.downgrade(); - drop(sender); - EventSubscription::from_receiver(receiver, weak) - } - } - } - - /// Creates a stream-compatible listener paired with the current view. - #[must_use] - pub fn listener(&self) -> EventListener { - let state = Arc::clone(&self.state); - EventListener::new(self.subscribe(), move || mutex_lock(&state).view.clone()) - } - - /// Clones the latest coherent view. - #[must_use] - pub fn view(&self) -> V { - mutex_lock(&self.state).view.clone() - } - - /// Replaces the current view without emitting an event. - /// - /// Returns `false` when the publisher has already closed. - pub fn set_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 fn update(&self, view: V, event: E) -> bool { - self.mutate(|current| *current = view, event) - } - - /// Emits an event using this publisher's monotonic sequence. - /// - /// Returns `false` when the publisher has already closed. - pub fn publish(&self, kind: E) -> bool { - self.mutate(|_| {}, kind) - } - - /// Atomically updates the view and permanently closes this publisher after - /// delivering one terminal event. - /// - /// Returns `false` if another caller already closed the publisher. - pub fn close(&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(Sequenced { - sequence: state.sequence, - kind: event, - }); - } - true - } - - pub(crate) fn edit_and_publish(&self, edit: impl FnOnce(&mut V) -> E) -> bool { - let mut state = mutex_lock(&self.state); - if state.closed { - return false; - } - let event = edit(&mut state.view); - state.sequence = state.sequence.saturating_add(1); - self.send(&state, event); - true - } - - pub(crate) fn edit_if_and_publish(&self, edit: impl FnOnce(&mut V) -> bool, event: E) -> bool { - let mut state = mutex_lock(&self.state); - if state.closed || !edit(&mut state.view) { - return false; - } - state.sequence = state.sequence.saturating_add(1); - self.send(&state, event); - true - } - - pub(crate) fn edit_view(&self, edit: impl FnOnce(&mut V) -> R) -> Option { - let mut state = mutex_lock(&self.state); - (!state.closed).then(|| edit(&mut state.view)) - } - - fn mutate(&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(&state, event); - true - } - - fn send(&self, state: &LiveState, event: E) { - if let Some(sender) = mutex_lock(&self.channel.sender).as_ref() { - let _ = sender.send(Sequenced { - sequence: state.sequence, - kind: event, - }); - } - } -} - #[derive(Debug)] struct ScopeRegistry { values: RwLock>>, From 3952831c69f48ecc0444a0b488e38b9739258544 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:55:31 -0700 Subject: [PATCH 58/77] test: account for terminal peer views --- crates/libtortillas/src/engine/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 641cd763..37cbd531 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -750,9 +750,10 @@ mod tests { assert_eq!(peer.torrent(), info_hash); assert!(peer.live_view().address.is_some()); assert!( - event_torrent - .live_view() - .is_some_and(|view| view.peer_count > 0) + !peer.live_view().connected + || event_torrent + .live_view() + .is_some_and(|view| view.peer_count > 0) ); let _peer_listener = peer.listener(); From e7bc20d4e79ba98655ecc9e3dd1b2518b4e36baa Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 00:56:09 -0700 Subject: [PATCH 59/77] fix: simplify snapshot metadata fallback --- crates/libtortillas/src/torrent/snapshot.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/libtortillas/src/torrent/snapshot.rs b/crates/libtortillas/src/torrent/snapshot.rs index 8454666a..8c04ba9c 100644 --- a/crates/libtortillas/src/torrent/snapshot.rs +++ b/crates/libtortillas/src/torrent/snapshot.rs @@ -106,7 +106,7 @@ impl TorrentSnapshot { } pub(crate) fn resolved_info(&self) -> Option<&Info> { - self.info_dict.as_ref().or_else(|| match &self.metainfo { + self.info_dict.as_ref().or(match &self.metainfo { MetaInfo::Torrent(torrent) => Some(&torrent.info), MetaInfo::MagnetUri(_) => None, }) From 1eadb44f377c517798e59e440f93b6751374668d Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 17:30:27 -0700 Subject: [PATCH 60/77] refactor: complete frontend and restore invariants --- crates/libtortillas/examples/live_frontend.rs | 4 +- crates/libtortillas/src/engine/actor.rs | 8 +- crates/libtortillas/src/engine/messages.rs | 138 ++-- crates/libtortillas/src/engine/mod.rs | 234 ++++-- crates/libtortillas/src/engine/snapshot.rs | 26 +- crates/libtortillas/src/errors.rs | 42 + crates/libtortillas/src/facade.rs | 36 +- crates/libtortillas/src/frontend/event.rs | 10 +- crates/libtortillas/src/frontend/handle.rs | 307 -------- .../libtortillas/src/frontend/handle/mod.rs | 65 ++ .../libtortillas/src/frontend/handle/peer.rs | 121 +++ .../src/frontend/handle/tracker.rs | 180 +++++ .../libtortillas/src/frontend/hub/engine.rs | 49 ++ crates/libtortillas/src/frontend/hub/mod.rs | 88 +++ crates/libtortillas/src/frontend/hub/peer.rs | 67 ++ .../libtortillas/src/frontend/hub/torrent.rs | 142 ++++ .../libtortillas/src/frontend/hub/tracker.rs | 67 ++ crates/libtortillas/src/frontend/listener.rs | 2 +- crates/libtortillas/src/frontend/live.rs | 119 +-- crates/libtortillas/src/frontend/metrics.rs | 360 +++++++++ crates/libtortillas/src/frontend/mod.rs | 15 +- crates/libtortillas/src/frontend/publisher.rs | 733 +++++++----------- crates/libtortillas/src/frontend/registry.rs | 68 ++ crates/libtortillas/src/frontend/view.rs | 136 ++-- crates/libtortillas/src/lib.rs | 12 +- crates/libtortillas/src/metainfo/file.rs | 13 +- crates/libtortillas/src/peer/actor.rs | 85 +- crates/libtortillas/src/peer/state.rs | 22 - .../libtortillas/src/pieces/piece_manager.rs | 5 +- crates/libtortillas/src/settings.rs | 25 + crates/libtortillas/src/torrent/actor.rs | 316 +++++--- crates/libtortillas/src/torrent/choking.rs | 36 +- .../libtortillas/src/torrent/choking_flow.rs | 5 + crates/libtortillas/src/torrent/handle.rs | 93 +-- crates/libtortillas/src/torrent/messages.rs | 187 +++-- crates/libtortillas/src/torrent/mod.rs | 5 +- crates/libtortillas/src/torrent/piece_flow.rs | 20 +- crates/libtortillas/src/torrent/snapshot.rs | 460 ++++++++++- crates/libtortillas/src/torrent/state.rs | 7 +- crates/libtortillas/src/torrent/swarm.rs | 9 +- crates/libtortillas/src/tracker/actor.rs | 11 +- crates/libtortillas/tests/dht_network.rs | 4 +- crates/libtortillas/tests/facade.rs | 4 +- .../tests/fixtures/engine-snapshot-v1.json | 4 + .../tests/fixtures/engine-snapshot-v2.json | 4 + .../tests/fixtures/torrent-snapshot-v1.json | 73 ++ .../tests/fixtures/torrent-snapshot-v2.json | 55 ++ crates/libtortillas/tests/live_frontend.rs | 52 +- crates/libtortillas/tests/persistence.rs | 283 ++++++- 49 files changed, 3407 insertions(+), 1400 deletions(-) delete mode 100644 crates/libtortillas/src/frontend/handle.rs create mode 100644 crates/libtortillas/src/frontend/handle/mod.rs create mode 100644 crates/libtortillas/src/frontend/handle/peer.rs create mode 100644 crates/libtortillas/src/frontend/handle/tracker.rs create mode 100644 crates/libtortillas/src/frontend/hub/engine.rs create mode 100644 crates/libtortillas/src/frontend/hub/mod.rs create mode 100644 crates/libtortillas/src/frontend/hub/peer.rs create mode 100644 crates/libtortillas/src/frontend/hub/torrent.rs create mode 100644 crates/libtortillas/src/frontend/hub/tracker.rs create mode 100644 crates/libtortillas/src/frontend/metrics.rs create mode 100644 crates/libtortillas/src/frontend/registry.rs create mode 100644 crates/libtortillas/tests/fixtures/engine-snapshot-v1.json create mode 100644 crates/libtortillas/tests/fixtures/engine-snapshot-v2.json create mode 100644 crates/libtortillas/tests/fixtures/torrent-snapshot-v1.json create mode 100644 crates/libtortillas/tests/fixtures/torrent-snapshot-v2.json diff --git a/crates/libtortillas/examples/live_frontend.rs b/crates/libtortillas/examples/live_frontend.rs index 5f52d3d6..a527d1b9 100644 --- a/crates/libtortillas/examples/live_frontend.rs +++ b/crates/libtortillas/examples/live_frontend.rs @@ -23,7 +23,7 @@ async fn main() -> Result<(), Box> { let view = listener.view(); info!( sequence = event.sequence, - torrent_count = view.torrent_count, + torrent_count = view.torrent_count(), ?event.kind, "frontend received a live engine event" ); @@ -35,7 +35,7 @@ async fn main() -> Result<(), Box> { let view = listener.view(); warn!( events, - torrent_count = view.torrent_count, + torrent_count = view.torrent_count(), "redrawing live state after lag" ); } diff --git a/crates/libtortillas/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index 8d02987c..449a6fbe 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -196,7 +196,7 @@ impl Actor for EngineActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); - self.frontend.health( + self.frontend.emit_health( None, FrontendHealthLevel::Error, "an engine service stopped unexpectedly", @@ -230,7 +230,7 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); - self.frontend.health( + self.frontend.emit_health( None, FrontendHealthLevel::Warning, "the TCP peer listener rejected an incoming connection", @@ -258,7 +258,7 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); - self.frontend.health( + self.frontend.emit_health( None, FrontendHealthLevel::Warning, "the uTP peer listener rejected an incoming connection", @@ -285,7 +285,7 @@ impl Actor for EngineActor { } torrent.wait_for_shutdown().await; self.torrents.remove(&info_hash); - self.frontend.torrent_removed(info_hash); + self.frontend.remove_torrent_scope(info_hash); } if let Some(dht) = self.dht.take() { diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 3a98323a..37e8a4bf 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -6,17 +6,33 @@ use tracing::{error, warn}; use super::{ENGINE_SNAPSHOT_VERSION, EngineActor, EngineSnapshot}; 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, Torrent, TorrentActor, TorrentActorArgs, TorrentSnapshot, TorrentState}, + torrent::{ + self, RestoreVerification, Torrent, 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 { @@ -32,7 +48,7 @@ pub(crate) mod commands { if let Err(error) = torrent.stop_gracefully().await { warn!(error = %error, %info_hash, "Failed to stop rejected restored torrent"); } - self.frontend.torrent_removed(info_hash); + self.frontend.remove_torrent_scope(info_hash); } } @@ -89,7 +105,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 { @@ -100,6 +116,7 @@ pub(crate) mod commands { warn!(error = %err, "Failed to start torrent"); } } + Ok(()) } /// Returns a managed torrent actor for public handle construction. @@ -135,11 +152,41 @@ pub(crate) mod commands { /// Creates a new [`Torrent`](crate::torrent::Torrent) actor. #[message] pub(crate) async fn create_torrent( - &mut self, metainfo: Box, restore: Option>, + &mut self, request: CreateTorrentRequest, ) -> Result, EngineError> { - if let Some(snapshot) = restore.as_ref() { - snapshot.validate()?; - } + 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) @@ -155,14 +202,6 @@ pub(crate) mod commands { } let restoring = restore.is_some(); - let piece_storage = restore.as_ref().map_or_else( - || self.default_piece_storage_strategy.clone(), - |snapshot| snapshot.piece_storage.clone(), - ); - let base_path = restore - .as_ref() - .and_then(|snapshot| snapshot.output_path.clone()) - .or_else(|| self.default_base_path.clone()); let torrent_ref = TorrentActor::supervise( &self.actor_ref, TorrentActorArgs { @@ -196,15 +235,13 @@ pub(crate) mod commands { }) .await; - let resume = if let Some(snapshot) = restore { + if let Some(snapshot) = restore { match torrent_ref - .ask(torrent::commands::RestoreSnapshot { - snapshot: *snapshot, - }) + .ask(torrent::commands::RestoreSnapshot { snapshot }) .await { Ok(result) => match result.0 { - Ok(resume) => resume, + Ok(_) => {} Err(error) => { self.discard_restored_torrent(info_hash, &torrent_ref).await; return Err(error.into()); @@ -212,14 +249,13 @@ pub(crate) mod commands { }, Err(error) => { self.discard_restored_torrent(info_hash, &torrent_ref).await; - return Err(EngineError::Other(anyhow!( - "failed to restore torrent snapshot: {error}" - ))); + return Err(EngineError::ActorCommunicationFailed { + operation: "restore torrent snapshot", + reason: error.to_string(), + }); } } - } else { - false - }; + } self.torrents.insert(info_hash, torrent_ref.clone()); // BEP 27 requires private torrents to use only their declared trackers: @@ -251,25 +287,29 @@ pub(crate) mod commands { .await { self.discard_restored_torrent(info_hash, &torrent_ref).await; - return Err(EngineError::Other(anyhow!( - "failed to resume restored torrent: {error}" + return Err(EngineError::Torrent(map_torrent_send_error( + "resume restored torrent", + error, ))); } let initial_view = match torrent_ref.ask(torrent::commands::GetLiveView).await { Ok(view) => *view, Err(error) => { self.discard_restored_torrent(info_hash, &torrent_ref).await; - return Err(EngineError::Other(anyhow!( - "failed to initialize torrent frontend: {error}" - ))); + return Err(EngineError::ActorCommunicationFailed { + operation: "initialize torrent frontend", + reason: error.to_string(), + }); } }; - self.frontend.torrent_added(Torrent::new_with_frontend( - info_hash, - torrent_ref.clone(), - &self.frontend, - Some(initial_view), - )); + self + .frontend + .register_torrent_scope(Torrent::new_with_frontend( + info_hash, + torrent_ref.clone(), + &self.frontend, + Some(initial_view), + )); Ok(torrent_ref) } @@ -277,7 +317,7 @@ pub(crate) mod commands { /// authoritative actor state. #[message] pub(crate) async fn restore_engine( - &mut self, snapshot: EngineSnapshot, + &mut self, snapshot: EngineSnapshot, verification: RestoreVerification, ) -> Result, EngineError> { snapshot.validate()?; if !self.torrents.is_empty() { @@ -290,7 +330,12 @@ pub(crate) mod commands { for torrent in snapshot.torrents { let info_hash = torrent.info_hash; let result = self - .create_torrent(Box::new(torrent.metainfo.clone()), Some(Box::new(torrent))) + .create_torrent(CreateTorrentRequest::Restore { + snapshot: RestoreSnapshotInput::Validated(Box::new( + ValidatedTorrentSnapshot::new_validated(torrent), + )), + verification, + }) .await; match result { Ok(_) => restored.push(info_hash), @@ -299,7 +344,7 @@ pub(crate) mod commands { match self.remove_torrent(info_hash).await { Ok(torrent) => { torrent.kill(); - self.frontend.torrent_removed(info_hash); + self.frontend.remove_torrent_scope(info_hash); } Err(remove_error) => { warn!( @@ -331,14 +376,15 @@ 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 { version: ENGINE_SNAPSHOT_VERSION, diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 37cbd531..3384d162 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -17,9 +17,10 @@ //! ## 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,7 +38,7 @@ //! .await //! .expect("Failed to add torrent"); //! -//! println!("Started torrenting: {}", torrent.key()); +//! println!("Started torrenting: {}", torrent.info_hash()); //! } //! ``` @@ -50,24 +51,22 @@ 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, GetTorrent, RemoveTorrent, RestoreEngine, SnapshotEngine, StartAll, -}; pub use self::snapshot::{ENGINE_SNAPSHOT_VERSION, EngineSnapshot, EngineStatus}; +use self::{ + commands::{CreateTorrent, GetTorrent, RemoveTorrent, RestoreEngine, SnapshotEngine, StartAll}, + messages::{CreateTorrentRequest, RestoreSnapshotInput}, +}; use crate::{ - errors::EngineError, + errors::{EngineError, map_engine_send_error}, frontend::{EngineListener, EngineView, EventSubscription, FrontendPublisher}, hashes::InfoHash, peer::PeerId, settings::Settings, - torrent::{PieceStorageStrategy, Torrent}, + torrent::{PieceStorageStrategy, RestoreVerification, Torrent}, }; /// The main entry point for managing torrents. @@ -204,14 +203,14 @@ 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(".")), }; - let frontend = FrontendPublisher::new(); + let frontend = FrontendPublisher::with_settings(settings.frontend); let args = EngineActorArgs { tcp_addr, utp_addr, @@ -233,13 +232,6 @@ impl Engine { &self.actor } - fn communication_error(operation: &'static str, error: impl std::fmt::Display) -> EngineError { - EngineError::ActorCommunicationFailed { - operation, - reason: error.to_string(), - } - } - /// 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]. @@ -263,7 +255,7 @@ impl Engine { /// .await /// .expect("Failed to add torrent"); /// - /// println!("Started torrenting: {}", torrent.key()); + /// println!("Started torrenting: {}", torrent.info_hash()); /// } /// ``` /// @@ -280,7 +272,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 { @@ -290,11 +282,10 @@ impl Engine { self .actor() .ask(CreateTorrent { - metainfo: Box::new(metainfo), - restore: None, + request: CreateTorrentRequest::New(Box::new(metainfo)), }) .await - .map_err(|error| Self::communication_error("add torrent", error))?; + .map_err(|error| map_engine_send_error("add torrent", error))?; self.frontend_torrent(info_hash) // We don't need to assign link or insert the ref here because its already @@ -308,21 +299,28 @@ impl Engine { pub async fn restore_torrent( &self, snapshot: crate::torrent::TorrentSnapshot, ) -> Result { - snapshot.validate()?; + 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: crate::torrent::TorrentSnapshot, verification: RestoreVerification, + ) -> Result { let info_hash = snapshot.info_hash; - match self + self .actor() .ask(CreateTorrent { - metainfo: Box::new(snapshot.metainfo.clone()), - restore: Some(Box::new(snapshot)), + request: CreateTorrentRequest::Restore { + snapshot: RestoreSnapshotInput::Unvalidated(Box::new(snapshot)), + verification, + }, }) .await - { - Ok(_) => {} - Err(SendError::HandlerError(error)) => return Err(error), - Err(error) => return Err(Self::communication_error("restore torrent", error)), - } + .map_err(|error| map_engine_send_error("restore torrent", error))?; self.frontend_torrent(info_hash) } @@ -333,11 +331,24 @@ impl Engine { /// method removes the torrents already restored by this call before /// returning the error. pub async fn restore(&self, snapshot: EngineSnapshot) -> Result, EngineError> { - let info_hashes = match self.actor().ask(RestoreEngine { snapshot }).await { - Ok(info_hashes) => info_hashes, - Err(SendError::HandlerError(error)) => return Err(error), - Err(error) => return Err(Self::communication_error("restore engine", error)), - }; + 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))?; info_hashes .into_iter() .map(|info_hash| self.frontend_torrent(info_hash)) @@ -350,42 +361,46 @@ impl Engine { .actor() .ask(StartAll) .await - .map_err(|error| Self::communication_error("start all torrents", error))?; + .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 { - match self.actor().ask(GetTorrent { info_hash }).await { - Ok(_) => {} - Err(SendError::HandlerError(err)) => return Err(err), - Err(error) => return Err(Self::communication_error("get torrent", error)), - } + self + .actor() + .ask(GetTorrent { info_hash }) + .await + .map_err(|error| map_engine_send_error("get torrent", error))?; self.frontend_torrent(info_hash) } /// 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(error) => return Err(Self::communication_error("remove torrent", error)), - }; + let torrent = self + .actor() + .ask(RemoveTorrent { info_hash }) + .await + .map_err(|error| map_engine_send_error("remove torrent", error))?; let stop_result = torrent.stop_gracefully().await; torrent.wait_for_shutdown().await; - self.frontend.torrent_removed(info_hash); - stop_result.map_err(|error| Self::communication_error("stop torrent", error)) + self.frontend.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(|error| Self::communication_error("shut down engine", error))?; + 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(()) @@ -401,13 +416,13 @@ impl Engine { .actor() .ask(SnapshotEngine) .await - .map_err(|error| Self::communication_error("snapshot engine", error)) + .map_err(|error| map_engine_send_error("snapshot engine", error)) } /// Subscribes to typed engine and torrent events as they happen. /// /// The returned stream is bounded. A lagging frontend can read - /// [`Self::live_view`] to rebuild its display state and then continue + /// [`Self::view`] to rebuild its display state and then continue /// receiving events. #[must_use] pub fn subscribe(&self) -> EventSubscription { @@ -425,7 +440,7 @@ impl Engine { /// Returns the current display-oriented engine state maintained by the live /// event publisher. #[must_use] - pub fn live_view(&self) -> EngineView { + pub fn view(&self) -> EngineView { self.frontend.view() } @@ -473,7 +488,8 @@ mod snapshot_tests { snapshot.torrents[0].version, crate::torrent::TORRENT_SNAPSHOT_VERSION ); - assert!(snapshot.torrents[0].info_dict.is_some()); + 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(); @@ -505,14 +521,77 @@ mod tests { }, engine::{Engine, TorrentSource}, errors::EngineError, - frontend::CoreEventKind, + frontend::{CoreEventKind, 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, }, + 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); @@ -591,8 +670,8 @@ mod tests { let result = engine.remove_torrent(info_hash).await; assert!(result.is_err()); - assert_eq!(engine.live_view().torrent_count, 0); - assert!(torrent.live_view().is_none()); + assert_eq!(engine.view().torrent_count(), 0); + assert!(torrent.view().is_none()); engine.shutdown().await.unwrap(); } @@ -609,13 +688,15 @@ mod tests { .await .unwrap(); let info_hash = torrent.info_hash(); - let late_view = torrent.live_view().unwrap(); + let late_view = torrent.view().unwrap(); - engine.frontend.torrent_removed(info_hash); - engine.frontend.update_torrent(late_view); + engine.frontend.remove_torrent_scope(info_hash); + engine + .frontend + .replace_torrent_view_and_emit(late_view, crate::frontend::TorrentEventKind::Updated); - assert!(torrent.live_view().is_none()); - assert_eq!(engine.live_view().torrent_count, 0); + assert!(torrent.view().is_none()); + assert_eq!(engine.view().torrent_count(), 0); let _ = engine.remove_torrent(info_hash).await; engine.shutdown().await.unwrap(); } @@ -748,17 +829,14 @@ mod tests { .unwrap(); let (event_torrent, peer) = peer; assert_eq!(peer.torrent(), info_hash); - assert!(peer.live_view().address.is_some()); + assert!(peer.view().address.is_some()); assert!( - !peer.live_view().connected - || event_torrent - .live_view() - .is_some_and(|view| view.peer_count > 0) + !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.live_view().connected); + 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 afaa22c5..21a8a6cc 100644 --- a/crates/libtortillas/src/engine/snapshot.rs +++ b/crates/libtortillas/src/engine/snapshot.rs @@ -1,19 +1,39 @@ use std::collections::HashSet; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{errors::EngineError, torrent::TorrentSnapshot}; /// Current persistence schema version for [`EngineSnapshot`]. -pub const ENGINE_SNAPSHOT_VERSION: u32 = 1; +pub const ENGINE_SNAPSHOT_VERSION: u32 = 2; /// Serializable state required to restore an engine's torrent sessions. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct EngineSnapshot { pub version: u32, pub torrents: Vec, } +#[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> { diff --git a/crates/libtortillas/src/errors.rs b/crates/libtortillas/src/errors.rs index 83cca204..ae5d12b2 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}; @@ -300,6 +301,17 @@ pub enum TorrentError { #[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 }, @@ -343,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 23a46e0b..04713dfd 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -1,37 +1,31 @@ //! Frontend-facing facade for `libtortillas`. //! -//! This module defines the stable surface that a TUI or another frontend should +//! This module defines the stable surface that application adapters 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. +//! modules remain public for advanced integrations, but a terminal UI, web +//! server, browser backend, desktop app, or other consumer can model user +//! intent, observe progress, and hold handles through the same types. //! //! # Example //! //! ```no_run -//! use libtortillas::facade::{EngineHandle, TorrentSource}; +//! use libtortillas::facade::{Engine, TorrentSource}; //! -//! let engine = EngineHandle::default(); +//! let engine = Engine::default(); //! let source = TorrentSource::magnet("magnet:?xt=urn:btih:..."); //! # let _ = (engine, source); //! ``` -use crate::{engine::Engine, torrent::Torrent}; pub use crate::{ - engine::{EngineSnapshot, EngineStatus, TorrentSource}, + engine::{Engine, EngineSnapshot, EngineStatus, TorrentSource}, frontend::{ - CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, EngineListener, EngineView, EventListener, - EventStreamError, EventSubscription, FrontendHealth, FrontendHealthLevel, LivePublisher, - PeerEvent, PeerEventKind, PeerHandle, PeerListener, PeerView, Sequenced, TorrentEvent, - TorrentEventKind, TorrentListener, TorrentProgress, TorrentTransfer, TorrentView, - TrackerEvent, TrackerEventKind, TrackerHandle, TrackerId, TrackerListener, TrackerStatus, - TrackerView, + ByteCount, BytesPerSecond, ContentProgress, CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, + EngineListener, EngineView, EventListener, EventStreamError, EventSubscription, + FrontendHealth, FrontendHealthLevel, HasTransferMetrics, LivePublisher, PeerEvent, + PeerEventKind, PeerHandle, PeerListener, PeerView, Seconds, Sequenced, TorrentEvent, + TorrentEventKind, TorrentListener, TorrentMetrics, TorrentView, TrackerEvent, + TrackerEventKind, TrackerHandle, TrackerId, TrackerListener, TrackerStatus, TrackerView, + TrafficTotals, TransferMetrics, TransferRates, }, - torrent::TorrentSnapshot, + torrent::{RestoreVerification, Torrent, TorrentSnapshot}, }; - -/// Facade-level name for the public [`Engine`] handle. -pub type EngineHandle = Engine; - -/// Facade-level name for the public [`Torrent`] handle. -pub type TorrentHandle = Torrent; diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index 9f531bde..71b2f75e 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use super::{EngineView, PeerHandle, TorrentProgress, TrackerHandle}; +use super::{EngineView, PeerHandle, TorrentMetrics, TrackerHandle, TransferMetrics}; use crate::{ hashes::InfoHash, torrent::{Torrent, TorrentState}, @@ -68,12 +68,12 @@ pub enum TorrentEventKind { current: TorrentState, }, MetadataResolved, - ProgressChanged(TorrentProgress), + MetricsChanged(TorrentMetrics), PeerConnected(PeerHandle), - PeerUpdated(PeerHandle), PeerDisconnected(PeerHandle), TrackerAnnounceSucceeded(TrackerHandle), TrackerAnnounceFailed(TrackerHandle), + TrackerRestarting(TrackerHandle), TrackerStopped(TrackerHandle), Health(FrontendHealth), Removed, @@ -83,7 +83,8 @@ pub enum TorrentEventKind { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum PeerEventKind { - Updated, + StateChanged, + MetricsChanged(TransferMetrics), Disconnected, } @@ -93,6 +94,7 @@ pub enum PeerEventKind { pub enum TrackerEventKind { AnnounceSucceeded { peers_returned: u64 }, AnnounceFailed, + Restarting, Stopped, } diff --git a/crates/libtortillas/src/frontend/handle.rs b/crates/libtortillas/src/frontend/handle.rs deleted file mode 100644 index 8f70c63a..00000000 --- a/crates/libtortillas/src/frontend/handle.rs +++ /dev/null @@ -1,307 +0,0 @@ -use std::{ - fmt, - hash::Hash, - net::SocketAddr, - sync::{Arc, Weak}, -}; - -use serde::{Deserialize, Serialize}; - -use super::{ - DEFAULT_EVENT_CAPACITY, EventListener, EventSubscription, FrontendHub, FrontendPublisher, - LivePublisher, PeerEventKind, PeerView, TrackerEventKind, TrackerStatus, TrackerView, -}; -use crate::{hashes::InfoHash, peer::PeerId}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) struct PeerScope { - pub(crate) torrent: InfoHash, - pub(crate) peer: PeerId, -} - -/// Opaque identity for one tracker actor within an engine. -/// -/// Tracker URLs can contain private passkeys and are not suitable identifiers: -/// sanitized URLs can collide while complete URLs must not be exposed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, 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 TrackerScope { - pub(crate) torrent: InfoHash, - pub(crate) id: TrackerId, -} - -/// Shared implementation for identity-bearing live protocol handles. -pub(crate) struct LiveHandle { - identity: I, - hub: Weak, - live: LivePublisher, -} - -impl LiveHandle -where - V: Clone + Send + Sync + 'static, - E: Clone + Send + 'static, -{ - fn new(identity: I, view: V, hub: Weak) -> Self { - Self { - identity, - hub, - live: LivePublisher::new(view, DEFAULT_EVENT_CAPACITY), - } - } - - fn subscribe(&self) -> EventSubscription { - self.live.subscribe() - } - - fn listener(&self) -> EventListener { - self.live.listener() - } - - fn view(&self) -> V { - self.live.view() - } - - fn update(&self, view: V, event: E) -> bool { - self.live.update(view, event) - } - - fn close(&self, view: V, event: E) -> bool { - self.live.close(view, event) - } - - fn frontend(&self) -> Option { - self.hub.upgrade().map(FrontendPublisher::from_hub) - } -} - -impl fmt::Debug for LiveHandle { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("LiveHandle") - .field("identity", &self.identity) - .finish_non_exhaustive() - } -} - -/// Public identity and live frontend access for one connected peer. -#[derive(Clone)] -pub struct PeerHandle { - pub(crate) inner: Arc>, -} - -impl PeerHandle { - pub(crate) fn new(scope: PeerScope, view: PeerView, hub: Weak) -> Self { - Self { - inner: Arc::new(LiveHandle::new(scope, view, hub)), - } - } - - /// Torrent that owns this peer connection. - #[must_use] - pub fn torrent(&self) -> InfoHash { - self.inner.identity.torrent - } - - /// Handshaked peer identifier. - #[must_use] - pub fn id(&self) -> PeerId { - self.inner.identity.peer - } - - /// Latest known network address. - #[must_use] - pub fn address(&self) -> Option { - self.live_view().address - } - - /// Subscribes to events for this peer only. - #[must_use] - pub fn subscribe(&self) -> EventSubscription { - self.inner.subscribe() - } - - /// Creates a stream-compatible listener for this peer. - #[must_use] - pub fn listener(&self) -> PeerListener { - self.inner.listener() - } - - /// Returns the latest peer view, including its terminal disconnected state. - #[must_use] - pub fn live_view(&self) -> PeerView { - self.inner.view() - } - - pub(crate) fn scope(&self) -> PeerScope { - self.inner.identity - } - - pub(crate) fn update(&self, view: PeerView) { - if self.inner.update(view, PeerEventKind::Updated) - && let Some(frontend) = self.inner.frontend() - { - frontend.peer_updated(self); - } - } - - pub(crate) fn disconnected(&self, torrent: Option) { - let mut view = self.live_view(); - view.connected = false; - if self.inner.close(view, PeerEventKind::Disconnected) - && let Some(frontend) = self.inner.frontend() - { - frontend.peer_disconnected(self, torrent); - } - } -} - -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.scope() == other.scope() - } -} - -impl Eq for PeerHandle {} - -/// Public identity and live frontend access for one tracker. -#[derive(Clone)] -pub struct TrackerHandle { - pub(crate) inner: Arc>, -} - -impl TrackerHandle { - pub(crate) fn new(scope: TrackerScope, view: TrackerView, hub: Weak) -> Self { - Self { - inner: Arc::new(LiveHandle::new(scope, view, hub)), - } - } - - /// Torrent that owns this tracker. - #[must_use] - pub fn torrent(&self) -> InfoHash { - self.inner.identity.torrent - } - - /// Opaque identity that remains distinct when sanitized endpoints collide. - #[must_use] - pub fn id(&self) -> TrackerId { - self.inner.identity.id - } - - /// Credential-free tracker endpoint. - #[must_use] - pub fn endpoint(&self) -> String { - self.live_view().endpoint - } - - /// Subscribes to events for this tracker only. - #[must_use] - pub fn subscribe(&self) -> EventSubscription { - self.inner.subscribe() - } - - /// Creates a stream-compatible listener for this tracker. - #[must_use] - pub fn listener(&self) -> TrackerListener { - self.inner.listener() - } - - /// Returns the latest tracker view, including its terminal stopped state. - #[must_use] - pub fn live_view(&self) -> TrackerView { - self.inner.view() - } - - pub(crate) fn scope(&self) -> TrackerScope { - self.inner.identity - } - - pub(crate) fn announce_succeeded(&self, peers_returned: u64) { - let mut view = self.live_view(); - view.status = TrackerStatus::Healthy; - view.peers_returned = Some(peers_returned); - let event = TrackerEventKind::AnnounceSucceeded { peers_returned }; - if self.inner.update(view, event) - && let Some(frontend) = self.inner.frontend() - { - frontend.tracker_event(self, event); - } - } - - pub(crate) fn announce_failed(&self) { - let mut view = self.live_view(); - view.status = TrackerStatus::Degraded; - view.peers_returned = None; - if self.inner.update(view, TrackerEventKind::AnnounceFailed) - && let Some(frontend) = self.inner.frontend() - { - frontend.tracker_event(self, TrackerEventKind::AnnounceFailed); - } - } - - pub(crate) fn stopped(&self) { - let mut view = self.live_view(); - view.status = TrackerStatus::Stopped; - if self.inner.close(view, TrackerEventKind::Stopped) - && let Some(frontend) = self.inner.frontend() - { - frontend.tracker_event(self, TrackerEventKind::Stopped); - } - } -} - -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.scope() == other.scope() - } -} - -impl Eq for TrackerHandle {} - -impl fmt::Display for TrackerHandle { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.endpoint()) - } -} - -/// Live listener scoped to one peer. -pub type PeerListener = EventListener; - -/// Live listener scoped to one tracker. -pub type TrackerListener = EventListener; diff --git a/crates/libtortillas/src/frontend/handle/mod.rs b/crates/libtortillas/src/frontend/handle/mod.rs new file mode 100644 index 00000000..ed046b7d --- /dev/null +++ b/crates/libtortillas/src/frontend/handle/mod.rs @@ -0,0 +1,65 @@ +use std::{fmt, sync::Weak}; + +use super::{EventListener, EventSubscription, FrontendHub, FrontendPublisher, LivePublisher}; + +mod peer; +mod tracker; + +pub(crate) use peer::PeerScope; +pub use peer::{PeerHandle, PeerListener}; +pub(crate) use tracker::TrackerScope; +pub use tracker::{TrackerHandle, TrackerId, TrackerListener}; + +/// Shared guard-free storage for identity-bearing live protocol handles. +pub(crate) struct LiveHandle { + pub(crate) identity: I, + hub: Weak, + pub(crate) live: LivePublisher, +} + +impl LiveHandle +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, + live: LivePublisher::new(view, event_capacity), + } + } + + fn subscribe(&self) -> EventSubscription { + self.live.subscribe() + } + + fn listener(&self) -> EventListener { + self.live.listener() + } + + fn view(&self) -> V { + self.live.view() + } + + fn replace_view_and_emit(&self, view: V, event: E) -> bool { + self.live.update(view, event) + } + + fn close_with_terminal_event(&self, view: V, event: E) -> bool { + self.live.close(view, event) + } + + fn frontend(&self) -> Option { + self.hub.upgrade().map(FrontendPublisher::from_hub) + } +} + +impl fmt::Debug for LiveHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LiveHandle") + .field("identity", &self.identity) + .finish_non_exhaustive() + } +} diff --git a/crates/libtortillas/src/frontend/handle/peer.rs b/crates/libtortillas/src/frontend/handle/peer.rs new file mode 100644 index 00000000..4bdc667b --- /dev/null +++ b/crates/libtortillas/src/frontend/handle/peer.rs @@ -0,0 +1,121 @@ +use std::{ + fmt, + net::SocketAddr, + sync::{Arc, Weak}, +}; + +use super::LiveHandle; +use crate::{ + frontend::{EventListener, EventSubscription, FrontendHub, PeerEventKind, PeerView}, + hashes::InfoHash, + peer::PeerId, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct PeerScope { + pub(crate) torrent: InfoHash, + pub(crate) peer: PeerId, +} + +/// Public identity and live frontend access for one connected peer. +#[derive(Clone)] +pub struct PeerHandle { + pub(crate) inner: Arc>, +} + +impl PeerHandle { + pub(crate) fn new( + scope: PeerScope, view: PeerView, hub: Weak, event_capacity: usize, + ) -> Self { + Self { + inner: Arc::new(LiveHandle::new(scope, 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 scope(&self) -> PeerScope { + self.inner.identity + } + + pub(crate) fn publish_state(&self, view: PeerView) { + let _ = self + .inner + .replace_view_and_emit(view, PeerEventKind::StateChanged); + } + + pub(crate) fn publish_metrics(&self, view: PeerView) { + let metrics = view.transfer; + let _ = self + .inner + .replace_view_and_emit(view, PeerEventKind::MetricsChanged(metrics)); + } + + pub(crate) fn disconnected(&self) { + let mut view = self.view(); + view.connected = false; + if self + .inner + .close_with_terminal_event(view, PeerEventKind::Disconnected) + && let Some(frontend) = self.inner.frontend() + { + frontend.mark_peer_disconnected(self); + } + } + + pub(crate) fn close_without_parent_event(&self) { + let mut view = self.view(); + view.connected = false; + let _ = self + .inner + .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.scope() == other.scope() + } +} + +impl Eq for PeerHandle {} + +pub type PeerListener = EventListener; diff --git a/crates/libtortillas/src/frontend/handle/tracker.rs b/crates/libtortillas/src/frontend/handle/tracker.rs new file mode 100644 index 00000000..6ef7e1bf --- /dev/null +++ b/crates/libtortillas/src/frontend/handle/tracker.rs @@ -0,0 +1,180 @@ +use std::{ + fmt, + sync::{Arc, Weak}, +}; + +use serde::{Deserialize, Serialize}; + +use super::LiveHandle; +use crate::{ + frontend::{ + EventListener, EventSubscription, FrontendHub, TrackerEventKind, TrackerStatus, TrackerView, + }, + hashes::InfoHash, +}; + +#[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 TrackerScope { + pub(crate) torrent: InfoHash, + pub(crate) id: TrackerId, +} + +/// Public identity and live frontend access for one tracker. +#[derive(Clone)] +pub struct TrackerHandle { + pub(crate) inner: Arc>, +} + +impl TrackerHandle { + pub(crate) fn new( + scope: TrackerScope, view: TrackerView, hub: Weak, event_capacity: usize, + ) -> Self { + Self { + inner: Arc::new(LiveHandle::new(scope, 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 scope(&self) -> TrackerScope { + self.inner.identity + } + + pub(crate) fn announce_succeeded(&self, peers_returned: u64) { + let mut view = self.view(); + view.status = TrackerStatus::Healthy; + view.peers_returned = Some(peers_returned); + let event = TrackerEventKind::AnnounceSucceeded { peers_returned }; + if self.inner.replace_view_and_emit(view, event) + && let Some(frontend) = self.inner.frontend() + { + frontend.emit_tracker_event(self, event); + } + } + + pub(crate) fn announce_failed(&self) { + let mut view = self.view(); + view.status = TrackerStatus::Degraded; + view.peers_returned = None; + if self + .inner + .replace_view_and_emit(view, TrackerEventKind::AnnounceFailed) + && let Some(frontend) = self.inner.frontend() + { + frontend.emit_tracker_event(self, TrackerEventKind::AnnounceFailed); + } + } + + pub(crate) fn restarting(&self) { + let mut view = self.view(); + view.status = TrackerStatus::Restarting; + if self + .inner + .replace_view_and_emit(view, TrackerEventKind::Restarting) + && let Some(frontend) = self.inner.frontend() + { + frontend.emit_tracker_event(self, TrackerEventKind::Restarting); + } + } + + pub(crate) fn stopped(&self) { + let mut view = self.view(); + view.status = TrackerStatus::Stopped; + if self + .inner + .close_with_terminal_event(view, TrackerEventKind::Stopped) + && let Some(frontend) = self.inner.frontend() + { + frontend.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 + .close_with_terminal_event(view, TrackerEventKind::Stopped); + } +} + +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.scope() == other.scope() + } +} + +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; diff --git a/crates/libtortillas/src/frontend/hub/engine.rs b/crates/libtortillas/src/frontend/hub/engine.rs new file mode 100644 index 00000000..09e0237f --- /dev/null +++ b/crates/libtortillas/src/frontend/hub/engine.rs @@ -0,0 +1,49 @@ +use super::super::{CoreEventKind, EngineView, EventSubscription, FrontendPublisher}; +use crate::engine::EngineStatus; + +impl FrontendPublisher { + pub(crate) fn subscribe(&self) -> EventSubscription { + self.hub().engine.live.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 hub = self.hub(); + let mut torrents = hub + .torrents + .values() + .into_iter() + .filter(|scope| scope.is_registered()) + .filter_map(|scope| scope.live.view()) + .collect::>(); + torrents.sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); + EngineView { + status: hub.engine.live.view(), + torrents, + } + } + + pub(crate) fn engine_started(&self) { + let hub = self.hub(); + let _ = hub.engine.live.set_view(EngineStatus::Running); + let _ = hub + .engine + .live + .publish(CoreEventKind::EngineStarted(self.view())); + } + + pub(crate) fn engine_stopping(&self) { + let _ = self.hub().engine.live.set_view(EngineStatus::Stopping); + } + + pub(crate) fn engine_stopped(&self) { + let mut view = self.view(); + view.status = EngineStatus::Stopped; + let _ = self + .hub() + .engine + .live + .close(EngineStatus::Stopped, CoreEventKind::Shutdown(view)); + } +} diff --git a/crates/libtortillas/src/frontend/hub/mod.rs b/crates/libtortillas/src/frontend/hub/mod.rs new file mode 100644 index 00000000..8e226d18 --- /dev/null +++ b/crates/libtortillas/src/frontend/hub/mod.rs @@ -0,0 +1,88 @@ +use std::sync::{ + Mutex, MutexGuard, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; + +use super::{ + CoreEventKind, LivePublisher, PeerEventKind, PeerView, TorrentEventKind, TorrentView, + TrackerEventKind, TrackerView, + handle::{LiveHandle, PeerScope, TrackerId, TrackerScope}, + registry::ScopeRegistry, +}; +use crate::{ + engine::EngineStatus, + hashes::InfoHash, + peer::PeerId, + settings::FrontendSettings, + torrent::{Torrent, TorrentInner}, + tracker::Tracker, +}; + +mod engine; +mod peer; +mod torrent; +mod tracker; + +#[derive(Debug)] +pub(crate) struct EngineScope { + pub(crate) live: LivePublisher, +} + +/// One self-contained torrent projection tree. +#[derive(Debug)] +pub(crate) struct TorrentScope { + pub(crate) info_hash: InfoHash, + pub(crate) live: LivePublisher, TorrentEventKind>, + pub(crate) peers: ScopeRegistry>, + pub(crate) trackers: + ScopeRegistry>, + pub(crate) tracker_sources: + ScopeRegistry>, + registered: AtomicBool, + publication: Mutex<()>, +} + +impl TorrentScope { + pub(crate) fn new(info_hash: InfoHash, event_capacity: usize) -> Self { + Self { + info_hash, + live: LivePublisher::new(None, event_capacity), + peers: ScopeRegistry::new(), + trackers: ScopeRegistry::new(), + tracker_sources: ScopeRegistry::new(), + registered: AtomicBool::new(false), + publication: Mutex::new(()), + } + } + + pub(crate) fn register(&self) { + self.registered.store(true, Ordering::Release); + } + + pub(crate) fn is_registered(&self) -> bool { + self.registered.load(Ordering::Acquire) + } + + pub(crate) 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 FrontendHub { + pub(crate) engine: EngineScope, + pub(crate) torrents: ScopeRegistry, + pub(crate) handles: ScopeRegistry, + pub(crate) settings: FrontendSettings, + pub(crate) next_tracker_id: AtomicU64, +} + +impl FrontendHub { + pub(crate) fn torrent_handle(&self, info_hash: InfoHash) -> Option { + self.handles.get(&info_hash).map(|inner| Torrent { inner }) + } +} diff --git a/crates/libtortillas/src/frontend/hub/peer.rs b/crates/libtortillas/src/frontend/hub/peer.rs new file mode 100644 index 00000000..a9772d38 --- /dev/null +++ b/crates/libtortillas/src/frontend/hub/peer.rs @@ -0,0 +1,67 @@ +use super::super::{FrontendPublisher, PeerHandle, PeerView, TorrentEventKind, handle::PeerScope}; +use crate::hashes::InfoHash; + +impl FrontendPublisher { + pub(crate) fn peer_handles(&self, torrent: InfoHash) -> Vec { + self + .hub() + .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: PeerScope, view: PeerView) -> PeerHandle { + let hub = self.hub(); + let scope = self.ensure_torrent_scope(identity.torrent); + let peer = PeerHandle::new( + identity, + view, + self.downgrade(), + hub.settings.peer_event_capacity, + ); + scope.peers.insert(identity.peer, &peer.inner); + peer + } + + pub(crate) fn emit_peer_connected(&self, peer: &PeerHandle) { + let Some(scope) = self.hub().torrents.get(&peer.torrent()) else { + return; + }; + if scope.peers.get(&peer.id()).is_some() { + self.emit_without_torrent_view_change( + &scope, + TorrentEventKind::PeerConnected(peer.clone()), + ); + } + } + + pub(crate) fn mark_peer_disconnected(&self, peer: &PeerHandle) { + let Some(scope) = self.hub().torrents.get(&peer.torrent()) else { + return; + }; + if scope.peers.remove(&peer.id()).is_none() { + return; + } + self.emit_without_torrent_view_change( + &scope, + TorrentEventKind::PeerDisconnected(peer.clone()), + ); + } + + pub(crate) fn close_peer_scopes_for_torrent_restart(&self, torrent: InfoHash) { + let Some(scope) = self.hub().torrents.get(&torrent) else { + return; + }; + for inner in scope.peers.remove_all() { + PeerHandle { inner }.close_without_parent_event(); + } + } +} diff --git a/crates/libtortillas/src/frontend/hub/torrent.rs b/crates/libtortillas/src/frontend/hub/torrent.rs new file mode 100644 index 00000000..d190774f --- /dev/null +++ b/crates/libtortillas/src/frontend/hub/torrent.rs @@ -0,0 +1,142 @@ +use std::sync::Arc; + +use super::super::{ + CoreEventKind, FrontendHealth, FrontendHealthLevel, FrontendPublisher, PeerHandle, + TorrentEventKind, TorrentScope, TorrentView, TrackerHandle, +}; +use crate::{hashes::InfoHash, torrent::Torrent}; + +impl FrontendPublisher { + pub(crate) fn ensure_torrent_scope(&self, info_hash: InfoHash) -> Arc { + let hub = self.hub(); + hub.torrents.get_or_insert_with(info_hash, || { + TorrentScope::new(info_hash, hub.settings.torrent_event_capacity) + }) + } + + pub(crate) fn torrent_handle(&self, torrent: InfoHash) -> Option { + self.hub().torrent_handle(torrent) + } + + #[cfg(test)] + pub(crate) fn torrent_view(&self, torrent: InfoHash) -> Option { + self + .hub() + .torrents + .get(&torrent) + .and_then(|scope| scope.live.view()) + } + + pub(crate) fn initialize_torrent_projection(&self, torrent: TorrentView) { + let scope = self.ensure_torrent_scope(torrent.info_hash); + let _ = scope.live.set_view(Some(torrent)); + } + + pub(crate) fn register_torrent_scope(&self, torrent: Torrent) { + let info_hash = torrent.info_hash(); + let scope = self.ensure_torrent_scope(info_hash); + self.hub().handles.insert(info_hash, &torrent.inner); + scope.register(); + if let Some(view) = scope.live.view() { + self.replace_torrent_view_and_emit(view, TorrentEventKind::Added); + } + } + + pub(crate) fn replace_torrent_view_and_emit( + &self, torrent: TorrentView, event: TorrentEventKind, + ) { + let info_hash = torrent.info_hash; + let Some(scope) = self.hub().torrents.get(&info_hash) else { + return; + }; + let Some(handle) = self.torrent_handle(info_hash) else { + return; + }; + let _publication = scope.publication_lock(); + if !scope.live.update(Some(torrent), event.clone()) { + return; + } + let _ = self.hub().engine.live.publish(CoreEventKind::Torrent { + torrent: handle, + event, + }); + } + + pub(crate) fn emit_health( + &self, torrent: Option, level: FrontendHealthLevel, message: impl Into, + ) { + let health = FrontendHealth { + torrent, + level, + message: message.into(), + }; + if let Some(info_hash) = torrent + && let Some(scope) = self.hub().torrents.get(&info_hash) + { + self.emit_without_torrent_view_change(&scope, TorrentEventKind::Health(health)); + } else { + let _ = self + .hub() + .engine + .live + .publish(CoreEventKind::Health(health)); + } + } + + pub(crate) fn remove_torrent_scope(&self, info_hash: InfoHash) { + let Some(scope) = self.hub().torrents.get(&info_hash) else { + return; + }; + let torrent = self.torrent_handle(info_hash); + 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.live.close(None, TorrentEventKind::Removed) { + return; + } + drop(publication); + self.hub().torrents.remove(&info_hash); + self.hub().handles.remove(&info_hash); + if let Some(torrent) = torrent { + let _ = self.hub().engine.live.publish(CoreEventKind::Torrent { + torrent, + event: TorrentEventKind::Removed, + }); + } + } + + pub(super) fn emit_without_torrent_view_change( + &self, scope: &TorrentScope, event: TorrentEventKind, + ) { + let Some(torrent) = self.torrent_handle(scope.info_hash) else { + return; + }; + let _publication = scope.publication_lock(); + if !scope.live.publish(event.clone()) { + return; + } + let _ = self + .hub() + .engine + .live + .publish(CoreEventKind::Torrent { torrent, event }); + } +} diff --git a/crates/libtortillas/src/frontend/hub/tracker.rs b/crates/libtortillas/src/frontend/hub/tracker.rs new file mode 100644 index 00000000..c0fd73c5 --- /dev/null +++ b/crates/libtortillas/src/frontend/hub/tracker.rs @@ -0,0 +1,67 @@ +use std::sync::atomic::Ordering; + +use super::super::{ + FrontendPublisher, TorrentEventKind, TrackerEventKind, TrackerHandle, TrackerView, + handle::{TrackerId, TrackerScope}, +}; +use crate::{hashes::InfoHash, tracker::Tracker}; + +impl FrontendPublisher { + pub(crate) fn tracker_handles(&self, torrent: InfoHash) -> Vec { + self + .hub() + .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, + ) -> TrackerHandle { + let hub = self.hub(); + let torrent_scope = self.ensure_torrent_scope(torrent); + if let Some(inner) = torrent_scope.tracker_sources.get(source) { + return TrackerHandle { inner }; + } + let id = TrackerId::new(hub.next_tracker_id.fetch_add(1, Ordering::Relaxed)); + let identity = TrackerScope { torrent, id }; + let tracker = TrackerHandle::new( + identity, + view, + self.downgrade(), + hub.settings.tracker_event_capacity, + ); + torrent_scope.trackers.insert(id, &tracker.inner); + torrent_scope + .tracker_sources + .insert(source.clone(), &tracker.inner); + tracker + } + + pub(crate) fn emit_tracker_event(&self, tracker: &TrackerHandle, event: TrackerEventKind) { + let Some(scope) = self.hub().torrents.get(&tracker.torrent()) else { + return; + }; + if scope.trackers.get(&tracker.id()).is_none() { + 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(&scope, torrent_event); + } +} diff --git a/crates/libtortillas/src/frontend/listener.rs b/crates/libtortillas/src/frontend/listener.rs index 4f2ad84f..a4ffceea 100644 --- a/crates/libtortillas/src/frontend/listener.rs +++ b/crates/libtortillas/src/frontend/listener.rs @@ -66,7 +66,7 @@ impl fmt::Debug for EventListener { } } -/// Live engine listener with typed events and current display state. +/// Live engine listener with typed events and current presentation state. pub type EngineListener = EventListener; /// Live listener scoped to one torrent. diff --git a/crates/libtortillas/src/frontend/live.rs b/crates/libtortillas/src/frontend/live.rs index 2a473f3a..02e99190 100644 --- a/crates/libtortillas/src/frontend/live.rs +++ b/crates/libtortillas/src/frontend/live.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use std::sync::{Arc, Mutex, MutexGuard}; use tokio::sync::broadcast; @@ -7,18 +7,6 @@ use super::{EventListener, EventSubscription, Sequenced}; /// Number of discrete frontend events retained by each live publisher. pub const DEFAULT_EVENT_CAPACITY: usize = 256; -pub(crate) fn read_lock(lock: &RwLock) -> RwLockReadGuard<'_, T> { - lock - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) -} - -pub(crate) fn write_lock(lock: &RwLock) -> RwLockWriteGuard<'_, T> { - lock - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) -} - fn mutex_lock(lock: &Mutex) -> MutexGuard<'_, T> { lock .lock() @@ -45,6 +33,7 @@ struct LiveState { #[derive(Debug)] struct LiveChannel { + capacity: usize, sender: Mutex>>>, } @@ -55,13 +44,10 @@ where { /// Creates a publisher with an initial view and bounded event capacity. /// - /// # Panics - /// - /// Panics when `event_capacity` is zero. + /// A zero capacity is normalized to one so configuration mistakes cannot + /// panic a public operation. #[must_use] pub fn new(initial_view: V, event_capacity: usize) -> Self { - assert!(event_capacity > 0, "event capacity must be non-zero"); - let (events, _) = broadcast::channel(event_capacity); Self { state: Arc::new(Mutex::new(LiveState { view: initial_view, @@ -69,7 +55,8 @@ where closed: false, })), channel: Arc::new(LiveChannel { - sender: Mutex::new(Some(events)), + capacity: event_capacity.max(1), + sender: Mutex::new(None), }), } } @@ -77,16 +64,40 @@ where /// Subscribes to all future events from this publisher. #[must_use] pub fn subscribe(&self) -> EventSubscription { - let sender = mutex_lock(&self.channel.sender); - match sender.as_ref() { - Some(sender) => EventSubscription::from_receiver(sender.subscribe(), sender.downgrade()), - None => { + let state = mutex_lock(&self.state); + if state.closed { + return { let (sender, receiver) = broadcast::channel(1); let weak = sender.downgrade(); drop(sender); EventSubscription::from_receiver(receiver, weak) - } + }; } + 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::>()) } /// Creates a stream-compatible listener paired with the current view. @@ -150,32 +161,6 @@ where true } - pub(crate) fn edit_and_publish(&self, edit: impl FnOnce(&mut V) -> E) -> bool { - let mut state = mutex_lock(&self.state); - if state.closed { - return false; - } - let event = edit(&mut state.view); - state.sequence = state.sequence.saturating_add(1); - self.send(&state, event); - true - } - - pub(crate) fn edit_if_and_publish(&self, edit: impl FnOnce(&mut V) -> bool, event: E) -> bool { - let mut state = mutex_lock(&self.state); - if state.closed || !edit(&mut state.view) { - return false; - } - state.sequence = state.sequence.saturating_add(1); - self.send(&state, event); - true - } - - pub(crate) fn edit_view(&self, edit: impl FnOnce(&mut V) -> R) -> Option { - let mut state = mutex_lock(&self.state); - (!state.closed).then(|| edit(&mut state.view)) - } - fn mutate(&self, edit: impl FnOnce(&mut V), event: E) -> bool { let mut state = mutex_lock(&self.state); if state.closed { @@ -196,3 +181,37 @@ where } } } + +#[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 live = Arc::new(LivePublisher::new(0_u64, 8)); + let update = Arc::clone(&live); + let close = Arc::clone(&live); + let update_thread = thread::spawn(move || update.update(1, "updated")); + let close_thread = thread::spawn(move || close.close(2, "closed")); + let update_accepted = update_thread.join().unwrap(); + let close_accepted = close_thread.join().unwrap(); + + assert!(close_accepted); + assert!(!live.update(3, "late")); + assert_eq!(live.view(), 2); + if update_accepted { + assert_eq!(live.view(), 2); + } + } + } + + #[test] + fn zero_capacity_is_normalized_without_panicking() { + let publisher = LivePublisher::new(0_u8, 0); + let _subscription = publisher.subscribe(); + assert!(publisher.publish("event")); + } +} diff --git a/crates/libtortillas/src/frontend/metrics.rs b/crates/libtortillas/src/frontend/metrics.rs new file mode 100644 index 00000000..f701e7cb --- /dev/null +++ b/crates/libtortillas/src/frontend/metrics.rs @@ -0,0 +1,360 @@ +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 + 'a>( + sources: impl IntoIterator, + ) -> Option { + let mut aggregate = None::; + for source in sources { + let Some(rates) = source.transfer_metrics().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] + pub(crate) 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), + } + } +} + +/// Traffic totals and the latest interval rate sample. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransferMetrics { + pub totals: TrafficTotals, + /// `None` means no sample has been collected. `Some(default())` is a known + /// zero-rate sample. + pub rates: Option, +} + +/// 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))) + } +} + +/// Narrow capability used by transfer aggregation algorithms. +pub trait HasTransferMetrics { + fn transfer_metrics(&self) -> &TransferMetrics; +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct TransferSample { + at: Instant, + totals: TrafficTotals, +} + +impl TransferSample { + #[must_use] + pub(crate) fn new(at: Instant, totals: TrafficTotals) -> Self { + Self { at, totals } + } + + #[must_use] + pub(crate) fn rates_since(self, previous: Self) -> TransferRates { + TransferRates::between( + previous.totals, + self.totals, + 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 + } + } + + #[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 rates = TransferSample::new( + Instant::now() + Duration::from_secs(1), + TrafficTotals::default(), + ) + .rates_since(TransferSample::new( + Instant::now(), + TrafficTotals::default(), + )); + + assert_eq!(rates, TransferRates::default()); + assert_eq!( + TransferRates::aggregate(&[Source(TransferMetrics { + rates: Some(rates), + ..TransferMetrics::default() + })]), + Some(TransferRates::default()) + ); + } + + #[test] + fn aggregate_rates_when_some_peers_are_unsampled_then_ignores_them() { + let peers = [ + Source(TransferMetrics::default()), + Source(TransferMetrics { + rates: Some(TransferRates { + download: BytesPerSecond(10), + upload: BytesPerSecond(4), + }), + ..TransferMetrics::default() + }), + ]; + + assert_eq!( + TransferRates::aggregate(&peers), + Some(TransferRates { + download: BytesPerSecond(10), + upload: BytesPerSecond(4), + }) + ); + } + + #[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 { + totals: TrafficTotals { + downloaded: ByteCount(1_024), + uploaded: ByteCount(512), + }, + rates: Some(TransferRates { + download: BytesPerSecond(300), + upload: BytesPerSecond(100), + }), + }; + + let json = serde_json::to_string(&metrics).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + metrics + ); + } +} diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index db6297d5..d0782c6f 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -6,9 +6,12 @@ mod event; mod handle; +mod hub; mod listener; mod live; +mod metrics; mod publisher; +mod registry; mod subscription; mod view; @@ -18,10 +21,14 @@ pub use event::{ }; pub(crate) use handle::PeerScope; pub use handle::{PeerHandle, PeerListener, TrackerHandle, TrackerId, TrackerListener}; +pub(crate) use hub::{FrontendHub, TorrentScope}; pub use listener::{EngineListener, EventListener, TorrentListener}; pub use live::{DEFAULT_EVENT_CAPACITY, LivePublisher}; -pub(crate) use publisher::{FrontendHub, FrontendPublisher}; -pub use subscription::{EventStreamError, EventSubscription}; -pub use view::{ - EngineView, PeerView, TorrentProgress, TorrentTransfer, TorrentView, TrackerStatus, TrackerView, +pub(crate) use metrics::TransferSample; +pub use metrics::{ + ByteCount, BytesPerSecond, ContentProgress, HasTransferMetrics, Seconds, TorrentMetrics, + TrafficTotals, TransferMetrics, TransferRates, }; +pub(crate) use publisher::FrontendPublisher; +pub use subscription::{EventStreamError, EventSubscription}; +pub use view::{EngineView, PeerView, TorrentView, TrackerStatus, TrackerView}; diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs index 81e9bd4d..43f2fe4a 100644 --- a/crates/libtortillas/src/frontend/publisher.rs +++ b/crates/libtortillas/src/frontend/publisher.rs @@ -1,70 +1,11 @@ -use std::{ - collections::HashMap, - hash::Hash, - sync::{ - Arc, RwLock, Weak, - atomic::{AtomicU64, Ordering}, - }, -}; +use std::sync::{Arc, Weak, atomic::AtomicU64}; use super::{ - CoreEventKind, DEFAULT_EVENT_CAPACITY, EngineView, EventSubscription, FrontendHealth, - FrontendHealthLevel, LivePublisher, PeerEventKind, PeerHandle, PeerView, TorrentEventKind, - TorrentView, TrackerEventKind, TrackerHandle, TrackerView, - handle::{LiveHandle, PeerScope, TrackerId, TrackerScope}, - live::{read_lock, write_lock}, -}; -use crate::{ - engine::EngineStatus, - hashes::InfoHash, - torrent::{Torrent, TorrentInner, TorrentState}, + LivePublisher, + hub::{EngineScope, FrontendHub}, + registry::ScopeRegistry, }; - -#[derive(Debug)] -struct ScopeRegistry { - values: RwLock>>, -} - -impl ScopeRegistry -where - K: Copy + Eq + Hash, -{ - fn new() -> Self { - Self { - values: RwLock::new(HashMap::new()), - } - } - - fn insert(&self, key: K, value: &Arc) { - write_lock(&self.values).insert(key, Arc::clone(value)); - } - - fn get(&self, key: &K) -> Option> { - read_lock(&self.values).get(key).cloned() - } - - fn remove(&self, key: &K) -> Option> { - write_lock(&self.values).remove(key) - } - - fn values(&self) -> Vec> { - read_lock(&self.values).values().cloned().collect() - } - - fn retain(&self, keep: impl Fn(K) -> bool) { - write_lock(&self.values).retain(|key, _| keep(*key)); - } -} - -/// Shared live-state hub used by the engine actor hierarchy. -#[derive(Debug)] -pub(crate) struct FrontendHub { - live: LivePublisher, - torrents: ScopeRegistry, - peers: ScopeRegistry>, - trackers: ScopeRegistry>, - next_tracker_id: AtomicU64, -} +use crate::{engine::EngineStatus, settings::FrontendSettings}; #[derive(Debug, Clone)] enum HubReference { @@ -80,23 +21,18 @@ pub(crate) struct FrontendPublisher { impl FrontendPublisher { pub(crate) fn new() -> Self { - Self::with_event_capacity(DEFAULT_EVENT_CAPACITY) + Self::with_settings(FrontendSettings::default()) } - fn with_event_capacity(event_capacity: usize) -> Self { + pub(crate) fn with_settings(settings: FrontendSettings) -> Self { Self { hub: HubReference::Strong(Arc::new(FrontendHub { - live: LivePublisher::new( - EngineView { - status: EngineStatus::Starting, - torrent_count: 0, - torrents: Vec::new(), - }, - event_capacity, - ), + engine: EngineScope { + live: LivePublisher::new(EngineStatus::Starting, settings.engine_event_capacity), + }, torrents: ScopeRegistry::new(), - peers: ScopeRegistry::new(), - trackers: ScopeRegistry::new(), + handles: ScopeRegistry::new(), + settings, next_tracker_id: AtomicU64::new(1), })), } @@ -121,7 +57,7 @@ impl FrontendPublisher { } } - fn hub(&self) -> Arc { + pub(crate) fn hub(&self) -> Arc { match &self.hub { HubReference::Strong(hub) => Arc::clone(hub), HubReference::Weak(hub) => hub @@ -129,285 +65,6 @@ impl FrontendPublisher { .expect("frontend hub outlived by its actor hierarchy"), } } - - pub(crate) fn subscribe(&self) -> EventSubscription { - self.hub().live.subscribe() - } - - pub(crate) fn view(&self) -> EngineView { - self.hub().live.view() - } - - pub(crate) fn torrent_view(&self, torrent: InfoHash) -> Option { - self - .view() - .torrents - .into_iter() - .find(|view| view.info_hash == torrent) - } - - pub(crate) fn torrent_handle(&self, torrent: InfoHash) -> Option { - self - .hub() - .torrents - .get(&torrent) - .map(|inner| Torrent { inner }) - } - - pub(crate) fn peer_handles(&self, torrent: InfoHash) -> Vec { - self - .hub() - .peers - .values() - .into_iter() - .map(|inner| PeerHandle { inner }) - .filter(|peer| peer.torrent() == torrent && peer.live_view().connected) - .collect() - } - - pub(crate) fn tracker_handles(&self, torrent: InfoHash) -> Vec { - self - .hub() - .trackers - .values() - .into_iter() - .map(|inner| TrackerHandle { inner }) - .filter(|tracker| tracker.torrent() == torrent) - .collect() - } - - pub(crate) fn engine_started(&self) { - self.hub().live.edit_and_publish(|view| { - view.status = EngineStatus::Running; - CoreEventKind::EngineStarted(view.clone()) - }); - } - - pub(crate) fn engine_stopping(&self) { - let _ = self.hub().live.edit_view(|view| { - view.status = EngineStatus::Stopping; - }); - } - - pub(crate) fn engine_stopped(&self) { - let mut view = self.view(); - view.status = EngineStatus::Stopped; - self - .hub() - .live - .close(view.clone(), CoreEventKind::Shutdown(view)); - } - - pub(crate) fn initialize_torrent(&self, torrent: TorrentView) { - let _ = self.hub().live.edit_view(|view| { - Self::replace_torrent_view(view, torrent); - }); - } - - pub(crate) fn torrent_added(&self, torrent: Torrent) { - let _routing = torrent.routing_lock(); - self - .hub() - .torrents - .insert(torrent.info_hash(), &torrent.inner); - if let Some(view) = torrent.live_view() { - self.hub().live.edit_and_publish(|engine| { - Self::replace_torrent_view(engine, view); - CoreEventKind::Torrent { - torrent: torrent.clone(), - event: TorrentEventKind::Added, - } - }); - } - } - - pub(crate) fn update_torrent(&self, torrent: TorrentView) { - self.publish_torrent(torrent, TorrentEventKind::Updated); - } - - pub(crate) fn metadata_resolved(&self, torrent: TorrentView) { - self.publish_torrent(torrent, TorrentEventKind::MetadataResolved); - } - - pub(crate) fn progress_changed(&self, torrent: TorrentView) { - let progress = torrent.progress.clone(); - self.publish_torrent(torrent, TorrentEventKind::ProgressChanged(progress)); - } - - pub(crate) fn peer(&self, scope: PeerScope, view: PeerView) -> PeerHandle { - let peer = PeerHandle::new(scope, view, self.downgrade()); - self.hub().peers.insert(scope, &peer.inner); - peer - } - - pub(crate) fn peer_connected(&self, torrent: TorrentView, peer: &PeerHandle) { - self.publish_torrent(torrent, TorrentEventKind::PeerConnected(peer.clone())); - } - - pub(crate) fn peer_updated(&self, peer: &PeerHandle) { - if self.hub().peers.get(&peer.scope()).is_none() { - return; - } - if let Some(view) = self.torrent_view(peer.torrent()) { - self.publish_torrent(view, TorrentEventKind::PeerUpdated(peer.clone())); - } - } - - pub(crate) fn peer_disconnected(&self, peer: &PeerHandle, torrent: Option) { - if self.hub().peers.remove(&peer.scope()).is_none() { - return; - } - if let Some(view) = torrent { - self.publish_torrent(view, TorrentEventKind::PeerDisconnected(peer.clone())); - } - } - - pub(crate) fn tracker(&self, torrent: InfoHash, view: TrackerView) -> TrackerHandle { - let id = TrackerId::new(self.hub().next_tracker_id.fetch_add(1, Ordering::Relaxed)); - let scope = TrackerScope { torrent, id }; - let tracker = TrackerHandle::new(scope, view, self.downgrade()); - self.hub().trackers.insert(scope, &tracker.inner); - tracker - } - - pub(crate) fn tracker_event(&self, tracker: &TrackerHandle, event: TrackerEventKind) { - if self.hub().trackers.get(&tracker.scope()).is_none() { - return; - } - let torrent_event = match event { - TrackerEventKind::AnnounceSucceeded { .. } => { - TorrentEventKind::TrackerAnnounceSucceeded(tracker.clone()) - } - TrackerEventKind::AnnounceFailed => { - TorrentEventKind::TrackerAnnounceFailed(tracker.clone()) - } - TrackerEventKind::Stopped => TorrentEventKind::TrackerStopped(tracker.clone()), - }; - if let Some(view) = self.torrent_view(tracker.torrent()) { - self.publish_torrent(view, torrent_event); - } - } - - pub(crate) fn health( - &self, torrent: Option, level: FrontendHealthLevel, message: impl Into, - ) { - let health = FrontendHealth { - torrent, - level, - message: message.into(), - }; - if let Some(info_hash) = torrent - && let Some(view) = self.torrent_view(info_hash) - { - self.publish_torrent(view, TorrentEventKind::Health(health)); - } else { - self.hub().live.publish(CoreEventKind::Health(health)); - } - } - - pub(crate) fn torrent_state_changed(&self, previous: TorrentState, torrent: TorrentView) { - let current = torrent.state; - self.publish_torrent( - torrent, - TorrentEventKind::StateChanged { previous, current }, - ); - } - - pub(crate) fn torrent_removed(&self, info_hash: InfoHash) { - let removed = self - .hub() - .torrents - .remove(&info_hash) - .map(|inner| Torrent { inner }); - - for peer in self - .peer_handles(info_hash) - .into_iter() - .filter(|peer| peer.live_view().connected) - { - peer.disconnected(None); - } - self.hub().peers.retain(|scope| scope.torrent != info_hash); - - for tracker in self - .tracker_handles(info_hash) - .into_iter() - .filter(|tracker| tracker.live_view().status.is_active()) - { - tracker.stopped(); - } - self - .hub() - .trackers - .retain(|scope| scope.torrent != info_hash); - - let Some(torrent) = removed else { - let _ = self.hub().live.edit_view(|view| { - Self::remove_torrent_view(view, info_hash); - }); - return; - }; - - let _routing = torrent.routing_lock(); - if torrent.removed() { - self.hub().live.edit_and_publish(|view| { - Self::remove_torrent_view(view, info_hash); - CoreEventKind::Torrent { - torrent: torrent.clone(), - event: TorrentEventKind::Removed, - } - }); - } - } - - fn publish_torrent(&self, view: TorrentView, event: TorrentEventKind) { - let Some(torrent) = self.torrent_handle(view.info_hash) else { - return; - }; - let _routing = torrent.routing_lock(); - if !torrent.publish(view.clone(), event.clone()) { - return; - } - self.hub().live.edit_if_and_publish( - |engine| { - let Some(current) = engine - .torrents - .iter_mut() - .find(|candidate| candidate.info_hash == view.info_hash) - else { - return false; - }; - *current = view; - true - }, - CoreEventKind::Torrent { - torrent: torrent.clone(), - event, - }, - ); - } - - fn replace_torrent_view(view: &mut EngineView, torrent: TorrentView) { - match view - .torrents - .iter_mut() - .find(|candidate| candidate.info_hash == torrent.info_hash) - { - Some(current) => *current = torrent, - None => view.torrents.push(torrent), - } - view - .torrents - .sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); - view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); - } - - fn remove_torrent_view(view: &mut EngineView, torrent: InfoHash) { - view - .torrents - .retain(|candidate| candidate.info_hash != torrent); - view.torrent_count = u64::try_from(view.torrents.len()).unwrap_or(u64::MAX); - } } impl Default for FrontendPublisher { @@ -418,19 +75,26 @@ impl Default for FrontendPublisher { #[cfg(test)] mod tests { - use std::net::{Ipv4Addr, SocketAddr}; + use std::{ + net::{Ipv4Addr, SocketAddr}, + time::Duration, + }; use super::*; - use crate::peer::PeerId; - - #[tokio::test] - async fn peer_handle_when_updated_then_only_its_listener_receives_event() { - let frontend = FrontendPublisher::new(); - let scope = PeerScope { - torrent: InfoHash::from_bytes([1; 20]), - peer: PeerId::Unknown([2; 20]), - }; - let view = PeerView { + use crate::{ + frontend::{ + ByteCount, ContentProgress, EventStreamError, PeerEventKind, PeerScope, PeerView, + TorrentMetrics, TorrentView, TrackerEventKind, TrackerView, TrafficTotals, + TransferMetrics, + }, + 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, @@ -439,21 +103,67 @@ mod tests { client_choking: true, client_interested: false, available_pieces: 0, - download_rate_bytes_per_second: 0, - upload_rate_bytes_per_second: 0, - downloaded_bytes: 0, - uploaded_bytes: 0, + transfer: TransferMetrics::default(), + } + } + + fn pending_tracker_view() -> TrackerView { + TrackerView { + endpoint: "https://tracker.example".to_string(), + status: super::super::TrackerStatus::Pending, + peers_returned: None, + } + } + + #[tokio::test] + async fn peer_handle_when_updated_then_only_its_listener_receives_event() { + let frontend = FrontendPublisher::new(); + let scope = PeerScope { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), }; - let peer = frontend.peer(scope, view.clone()); + let view = connected_peer_view(); + let peer = frontend.register_peer_scope(scope, view.clone()); let mut listener = peer.listener(); let mut updated = view; - updated.downloaded_bytes = 16; + updated.transfer.totals = TrafficTotals { + downloaded: ByteCount(16), + uploaded: ByteCount::ZERO, + }; - peer.update(updated); + peer.publish_metrics(updated); let event = listener.recv().await.unwrap(); - assert_eq!(event.kind, super::super::PeerEventKind::Updated); - assert_eq!(listener.view().downloaded_bytes, 16); + assert!(matches!(event.kind, PeerEventKind::MetricsChanged(_))); + assert_eq!(listener.view().transfer.totals.downloaded, ByteCount(16)); + } + + #[tokio::test] + async fn peer_metrics_do_not_republish_the_torrent_projection() { + let frontend = FrontendPublisher::new(); + let info_hash = InfoHash::from_bytes([1; 20]); + let torrent = benchmark_torrent_view(info_hash, "isolated"); + frontend.initialize_torrent_projection(torrent.clone()); + let scope = frontend.ensure_torrent_scope(info_hash); + let mut torrent_events = scope.live.subscribe(); + let peer = frontend.register_peer_scope( + PeerScope { + torrent: info_hash, + peer: PeerId::Unknown([2; 20]), + }, + connected_peer_view(), + ); + let mut peer_view = peer.view(); + peer_view.transfer.rates = Some(Default::default()); + + peer.publish_metrics(peer_view); + + assert_eq!(scope.live.view(), Some(torrent)); + assert!( + tokio::time::timeout(Duration::from_millis(20), torrent_events.recv()) + .await + .is_err() + ); } #[tokio::test] @@ -463,27 +173,14 @@ mod tests { torrent: InfoHash::from_bytes([1; 20]), peer: PeerId::Unknown([2; 20]), }; - let view = PeerView { - address: None, - client: None, - connected: true, - peer_choking: true, - peer_interested: false, - client_choking: true, - client_interested: false, - available_pieces: 0, - download_rate_bytes_per_second: 0, - upload_rate_bytes_per_second: 0, - downloaded_bytes: 0, - uploaded_bytes: 0, - }; - let peer = frontend.peer(scope, view.clone()); + let view = connected_peer_view(); + let peer = frontend.register_peer_scope(scope, view.clone()); let mut listener = peer.listener(); - peer.disconnected(None); + peer.disconnected(); let mut late = view; - late.downloaded_bytes = 32; - peer.update(late); + late.transfer.totals.downloaded = ByteCount(32); + peer.publish_metrics(late); assert_eq!( listener.recv().await.unwrap().kind, @@ -494,7 +191,7 @@ mod tests { Err(super::super::EventStreamError::Closed) ); assert!(!listener.view().connected); - assert_eq!(listener.view().downloaded_bytes, 0); + assert_eq!(listener.view().transfer.totals.downloaded, ByteCount::ZERO); } #[test] @@ -505,64 +202,66 @@ mod tests { torrent: InfoHash::from_bytes([1; 20]), peer: PeerId::Unknown([2; 20]), }; - let peer = frontend.peer( - scope, - PeerView { - address: None, - client: None, - connected: true, - peer_choking: true, - peer_interested: false, - client_choking: true, - client_interested: false, - available_pieces: 0, - download_rate_bytes_per_second: 0, - upload_rate_bytes_per_second: 0, - downloaded_bytes: 0, - uploaded_bytes: 0, - }, - ); + let peer = frontend.register_peer_scope(scope, connected_peer_view()); drop(frontend); assert!(hub.upgrade().is_none()); - assert!(peer.live_view().connected); + assert!(peer.view().connected); } #[test] - fn trackers_with_the_same_public_endpoint_keep_distinct_identities() { + fn publishers_without_listeners_do_not_allocate_event_channels() { let frontend = FrontendPublisher::new(); - let torrent = InfoHash::from_bytes([3; 20]); - let view = TrackerView { - endpoint: "https://tracker.example".to_string(), - status: super::super::TrackerStatus::Pending, - peers_returned: None, - }; - - let first = frontend.tracker(torrent, view.clone()); - let second = frontend.tracker(torrent, view); + let peer = frontend.register_peer_scope( + PeerScope { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), + }, + connected_peer_view(), + ); - assert_ne!(first.id(), second.id()); - assert_ne!(first, second); - assert_eq!(frontend.tracker_handles(torrent).len(), 2); + assert!(!peer.inner.live.has_event_channel()); + let _listener = peer.listener(); + assert!(peer.inner.live.has_event_channel()); } #[tokio::test] - async fn stopped_tracker_rejects_late_announces() { + async fn tracker_restart_keeps_listener_open_until_final_stop() { let frontend = FrontendPublisher::new(); - let tracker = frontend.tracker( + let source = Tracker::Http("https://tracker.example/announce".to_string()); + let tracker = frontend.register_tracker_scope( InfoHash::from_bytes([3; 20]), - TrackerView { - endpoint: "https://tracker.example".to_string(), - status: super::super::TrackerStatus::Pending, - peers_returned: None, - }, + &source, + pending_tracker_view(), ); let mut listener = tracker.listener(); - tracker.stopped(); - tracker.announce_succeeded(10); + tracker.restarting(); + assert_eq!( + listener.recv().await.unwrap().kind, + TrackerEventKind::Restarting + ); + assert_eq!( + listener.view().status, + super::super::TrackerStatus::Restarting + ); + let restarted = frontend.register_tracker_scope( + InfoHash::from_bytes([3; 20]), + &source, + pending_tracker_view(), + ); + assert_eq!(restarted.id(), tracker.id()); + restarted.announce_succeeded(2); + assert_eq!( + listener.recv().await.unwrap().kind, + TrackerEventKind::AnnounceSucceeded { peers_returned: 2 } + ); + + tracker.stopped(); + tracker.stopped(); + tracker.announce_failed(); assert_eq!( listener.recv().await.unwrap().kind, TrackerEventKind::Stopped @@ -571,7 +270,167 @@ mod tests { listener.recv().await, Err(super::super::EventStreamError::Closed) ); - assert_eq!(listener.view().status, super::super::TrackerStatus::Stopped); - assert_eq!(listener.view().peers_returned, None); + } + + #[tokio::test] + async fn torrent_removal_closes_every_child_scope_exactly_once() { + let frontend = FrontendPublisher::new(); + let info_hash = InfoHash::from_bytes([4; 20]); + frontend.initialize_torrent_projection(benchmark_torrent_view(info_hash, "removed")); + let peer = frontend.register_peer_scope( + PeerScope { + torrent: info_hash, + peer: PeerId::Unknown([5; 20]), + }, + connected_peer_view(), + ); + let source = Tracker::Http("https://tracker.example/announce".to_string()); + let tracker = frontend.register_tracker_scope(info_hash, &source, pending_tracker_view()); + let mut peer_events = peer.subscribe(); + let mut tracker_events = tracker.subscribe(); + + frontend.remove_torrent_scope(info_hash); + frontend.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)); + } + + 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, + }, + ), + } + } + + #[test] + #[ignore = "performance benchmark; run explicitly with --ignored --nocapture"] + fn large_scope_tree_benchmark() { + use std::time::Instant; + + let frontend = FrontendPublisher::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); + frontend.initialize_torrent_projection(benchmark_torrent_view( + InfoHash::from_bytes(hash), + &format!("torrent-{torrent_index}"), + )); + frontend + .ensure_torrent_scope(InfoHash::from_bytes(hash)) + .register(); + for peer_index in 0_u8..10 { + frontend.register_peer_scope( + PeerScope { + torrent: InfoHash::from_bytes(hash), + peer: PeerId::Unknown([peer_index; 20]), + }, + connected_peer_view(), + ); + } + } + 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 frontend.peer_handles(InfoHash::from_bytes(hash)) { + peer.publish_metrics(peer.view()); + } + } + } + let updates = started.elapsed(); + + let started = Instant::now(); + let view = frontend.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]); + frontend.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); + frontend.register_peer_scope( + PeerScope { + torrent: removal_hash, + peer: PeerId::Unknown(id), + }, + connected_peer_view(), + ) + }) + .collect::>(); + let zero_listener_slots = removal_peers + .iter() + .map(|peer| peer.inner.live.allocated_event_slots()) + .sum::(); + let zero_listener_memory_lower_bound = removal_peers + .iter() + .map(|peer| peer.inner.live.allocation_lower_bound_bytes()) + .sum::(); + let started = Instant::now(); + frontend.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.update(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/frontend/registry.rs b/crates/libtortillas/src/frontend/registry.rs new file mode 100644 index 00000000..ed3c2542 --- /dev/null +++ b/crates/libtortillas/src/frontend/registry.rs @@ -0,0 +1,68 @@ +use std::{hash::Hash, sync::Arc}; + +use dashmap::DashMap; + +/// 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)] +pub(crate) struct ScopeRegistry { + values: DashMap>, +} + +impl ScopeRegistry +where + K: Clone + Eq + Hash, +{ + pub(crate) fn new() -> Self { + Self { + values: DashMap::new(), + } + } + + pub(crate) fn insert(&self, key: K, value: &Arc) { + self.values.insert(key, Arc::clone(value)); + } + + pub(crate) 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()) + } + + pub(crate) fn get(&self, key: &K) -> Option> { + self.values.get(key).map(|value| Arc::clone(value.value())) + } + + pub(crate) fn remove(&self, key: &K) -> Option> { + self.values.remove(key).map(|(_, value)| value) + } + + pub(crate) fn values(&self) -> Vec> { + self + .values + .iter() + .map(|value| Arc::clone(value.value())) + .collect() + } + + pub(crate) 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() + } +} diff --git a/crates/libtortillas/src/frontend/view.rs b/crates/libtortillas/src/frontend/view.rs index 6a5dfc94..176ac6ab 100644 --- a/crates/libtortillas/src/frontend/view.rs +++ b/crates/libtortillas/src/frontend/view.rs @@ -2,77 +2,53 @@ use std::{net::SocketAddr, path::PathBuf}; use serde::{Deserialize, Serialize}; +use super::{ + ByteCount, HasTransferMetrics, TorrentMetrics, TrafficTotals, TransferMetrics, TransferRates, +}; use crate::{engine::EngineStatus, hashes::InfoHash, peer::Peer, torrent::TorrentState}; /// Current live engine state maintained by a frontend listener. /// -/// Unlike persistence snapshots, views are display-oriented and updated by -/// applying live [`CoreEvent`](super::CoreEvent) values. +/// Unlike persistence snapshots, views are presentation-oriented and updated +/// by applying live [`CoreEvent`](super::CoreEvent) values. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EngineView { pub status: EngineStatus, - pub torrent_count: u64, pub torrents: Vec, } +impl EngineView { + #[must_use] + pub fn torrent_count(&self) -> usize { + self.torrents.len() + } +} + /// Current live state of one torrent. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TorrentView { 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: TorrentProgress, - pub transfer: TorrentTransfer, -} - -/// Live torrent progress intended for frontend rendering. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TorrentProgress { - 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, + pub metrics: TorrentMetrics, } -/// Live torrent transfer metrics intended for frontend rendering. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TorrentTransfer { - pub download_rate_bytes_per_second: Option, - pub upload_rate_bytes_per_second: Option, - pub eta_seconds: Option, -} - -impl TorrentTransfer { - pub(crate) fn from_peers( - peers: impl IntoIterator, bytes_remaining: Option, - ) -> Self { - let (download_rate, upload_rate) = - peers - .into_iter() - .fold((0_u64, 0_u64), |(download, upload), peer| { - ( - download.saturating_add(peer.download_rate_bytes_per_second), - upload.saturating_add(peer.upload_rate_bytes_per_second), - ) - }); - let eta_seconds = bytes_remaining - .and_then(|remaining| (download_rate > 0).then(|| remaining.div_ceil(download_rate))); +impl TorrentView { + /// Whether the torrent has resolved payload metadata. + #[must_use] + pub const fn has_metadata(&self) -> bool { + self.metrics.progress.total_bytes.is_some() + } - Self { - download_rate_bytes_per_second: Some(download_rate), - upload_rate_bytes_per_second: Some(upload_rate), - eta_seconds, - } + /// Whether the torrent has reached its ready lifecycle state. + #[must_use] + pub const fn is_ready(&self) -> bool { + matches!(self.state, TorrentState::Ready) } } @@ -90,14 +66,33 @@ pub struct PeerView { pub client_choking: bool, pub client_interested: bool, pub available_pieces: u64, - pub download_rate_bytes_per_second: u64, - pub upload_rate_bytes_per_second: u64, - pub downloaded_bytes: u64, - pub uploaded_bytes: u64, + pub transfer: TransferMetrics, } impl PeerView { pub(crate) fn from_peer(peer: &Peer, connected: bool) -> Self { + Self::from_peer_with_rates(peer, connected, None) + } + + pub(crate) fn from_peer_with_rates( + peer: &Peer, connected: bool, rates: Option, + ) -> Self { + Self::from_peer_with_transfer( + peer, + connected, + TransferMetrics { + totals: TrafficTotals { + downloaded: ByteCount(u64::try_from(peer.bytes_downloaded()).unwrap_or(u64::MAX)), + uploaded: ByteCount(u64::try_from(peer.bytes_uploaded()).unwrap_or(u64::MAX)), + }, + rates, + }, + ) + } + + pub(crate) fn from_peer_with_transfer( + peer: &Peer, connected: bool, transfer: TransferMetrics, + ) -> Self { Self { address: Some(peer.socket_addr()), client: peer.id.map(|id| id.client_name().to_string()), @@ -107,18 +102,17 @@ impl PeerView { client_choking: peer.choked(), client_interested: peer.am_interested(), available_pieces: u64::try_from(peer.pieces.count_ones()).unwrap_or(u64::MAX), - download_rate_bytes_per_second: u64::try_from(peer.download_rate()) - .unwrap_or(u64::MAX) - .saturating_mul(1024), - upload_rate_bytes_per_second: u64::try_from(peer.upload_rate()) - .unwrap_or(u64::MAX) - .saturating_mul(1024), - downloaded_bytes: u64::try_from(peer.bytes_downloaded()).unwrap_or(u64::MAX), - uploaded_bytes: u64::try_from(peer.bytes_uploaded()).unwrap_or(u64::MAX), + transfer, } } } +impl HasTransferMetrics for PeerView { + fn transfer_metrics(&self) -> &TransferMetrics { + &self.transfer + } +} + /// Frontend-safe live tracker identity and latest announce outcome. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TrackerView { @@ -139,6 +133,8 @@ pub enum TrackerStatus { 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, } @@ -154,9 +150,10 @@ impl TrackerStatus { #[cfg(test)] mod tests { use super::*; + use crate::frontend::BytesPerSecond; #[test] - fn torrent_transfer_aggregates_peer_rates_and_estimates_completion() { + fn peer_view_uses_canonical_byte_units() { let peer = PeerView { address: None, client: None, @@ -166,16 +163,19 @@ mod tests { client_choking: false, client_interested: true, available_pieces: 1, - download_rate_bytes_per_second: 3, - upload_rate_bytes_per_second: 2, - downloaded_bytes: 0, - uploaded_bytes: 0, + transfer: TransferMetrics { + totals: TrafficTotals::default(), + rates: Some(TransferRates { + download: BytesPerSecond(3), + upload: BytesPerSecond(2), + }), + }, }; - let transfer = TorrentTransfer::from_peers([peer.clone(), peer], Some(13)); + let peers = [peer.clone(), peer]; + let rates = TransferRates::aggregate(&peers).unwrap(); - assert_eq!(transfer.download_rate_bytes_per_second, Some(6)); - assert_eq!(transfer.upload_rate_bytes_per_second, Some(4)); - assert_eq!(transfer.eta_seconds, Some(3)); + assert_eq!(rates.download, BytesPerSecond(6)); + assert_eq!(rates.upload, BytesPerSecond(4)); } } diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index eded16bc..53dc7dd7 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -12,21 +12,21 @@ //! does not promise runtime independence, HTTP client injection, clock //! injection, listener injection, or storage runtime abstraction. //! -//! A TUI can use `#[tokio::main]` on its binary entry point, or create an -//! explicit Tokio runtime before initializing `Engine`. +//! An application can use `#[tokio::main]` on its binary entry point, or create +//! an explicit Tokio runtime before initializing `Engine`. //! //! # Frontend facade //! //! 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`], +//! the stable concepts any application adapter needs: [`engine::Engine`], +//! [`torrent::Torrent`], [`facade::TorrentSource`], //! [`facade::CoreEvent`], and live engine, torrent, //! peer, and tracker views. //! //! ```no_run -//! use libtortillas::prelude::{EngineHandle, TorrentSource}; +//! use libtortillas::prelude::{Engine, TorrentSource}; //! -//! let engine = EngineHandle::default(); +//! let engine = Engine::default(); //! let source = TorrentSource::magnet("magnet:?xt=urn:btih:..."); //! # let _ = (engine, source); //! ``` 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/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index e504f8cb..2c5e4537 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -22,7 +22,10 @@ use tracing::{Span, debug, info, instrument, trace, warn}; use crate::{ errors::PeerActorError, - frontend::{PeerHandle, PeerView}, + frontend::{ + ByteCount, HasTransferMetrics, PeerHandle, PeerView, TrafficTotals, TransferMetrics, + TransferSample, + }, hashes::InfoHash, peer::{Peer, PeerId}, protocol::{stream::PeerRecv, *}, @@ -35,26 +38,19 @@ 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) transfer: TransferMetrics, } -#[derive(Clone, Copy, Debug)] -struct RateSample { - at: Instant, - bytes_downloaded: usize, - bytes_uploaded: usize, +impl HasTransferMetrics for PeerStats { + fn transfer_metrics(&self) -> &TransferMetrics { + &self.transfer + } } -impl RateSample { - fn new(peer: &Peer) -> Self { - Self { - at: Instant::now(), - bytes_downloaded: peer.bytes_downloaded(), - bytes_uploaded: peer.bytes_uploaded(), - } +fn peer_traffic_totals(peer: &Peer) -> TrafficTotals { + TrafficTotals { + downloaded: ByteCount(u64::try_from(peer.bytes_downloaded()).unwrap_or(u64::MAX)), + uploaded: ByteCount(u64::try_from(peer.bytes_uploaded()).unwrap_or(u64::MAX)), } } @@ -69,7 +65,7 @@ pub(crate) struct PeerActor { pending_block_requests: HashSet<(usize, usize, usize)>, pending_message_requests: VecDeque, - last_rate_sample: RateSample, + last_rate_sample: TransferSample, settings: PeerSettings, frontend: PeerHandle, } @@ -338,36 +334,25 @@ impl PeerActor { fn snapshot_stats(&mut self) -> Option { let id = self.peer.id?; let now = Instant::now(); - let bytes_downloaded = self.peer.bytes_downloaded(); - let bytes_uploaded = self.peer.bytes_uploaded(); - let elapsed_secs = now - .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 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); - self.last_rate_sample = RateSample { - at: now, - bytes_downloaded, - bytes_uploaded, + let totals = peer_traffic_totals(&self.peer); + let sample = TransferSample::new(now, totals); + let rates = sample.rates_since(self.last_rate_sample); + self.last_rate_sample = sample; + let transfer = TransferMetrics { + totals, + rates: Some(rates), }; - self.frontend.update(PeerView::from_peer(&self.peer, true)); + self + .frontend + .publish_metrics(PeerView::from_peer_with_transfer( + &self.peer, true, transfer, + )); Some(PeerStats { id, interested: self.peer.interested(), choked: self.peer.choked(), - download_rate, - upload_rate, - bytes_downloaded, - bytes_uploaded, + transfer, }) } } @@ -411,7 +396,7 @@ impl Actor for PeerActor { .map_err(|e| PeerActorError::SupervisorCommunicationFailed(e.to_string()))?; Ok(Self { - last_rate_sample: RateSample::new(&peer), + last_rate_sample: TransferSample::new(Instant::now(), peer_traffic_totals(&peer)), peer, stream, supervisor, @@ -682,7 +667,10 @@ impl Message for PeerActor { warn!("Received unexpected handshake from peer"); } } - self.frontend.update(PeerView::from_peer(&self.peer, true)); + let rates = self.frontend.view().transfer.rates; + self + .frontend + .publish_state(PeerView::from_peer_with_rates(&self.peer, true, rates)); } } @@ -828,11 +816,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/state.rs b/crates/libtortillas/src/peer/state.rs index 554aa20c..481971f0 100644 --- a/crates/libtortillas/src/peer/state.rs +++ b/crates/libtortillas/src/peer/state.rs @@ -29,10 +29,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 @@ -68,8 +64,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()), @@ -104,14 +98,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 @@ -163,14 +149,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) } diff --git a/crates/libtortillas/src/pieces/piece_manager.rs b/crates/libtortillas/src/pieces/piece_manager.rs index ffc473c0..1d757330 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::{ @@ -124,7 +124,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/settings.rs b/crates/libtortillas/src/settings.rs index 5d5fdfad..1b2cc237 100644 --- a/crates/libtortillas/src/settings.rs +++ b/crates/libtortillas/src/settings.rs @@ -26,6 +26,8 @@ pub struct Settings { pub dht: DhtSettings, /// Engine actor and incoming socket settings. pub engine: EngineSettings, + /// Live frontend event-channel settings. + pub frontend: FrontendSettings, /// Per-torrent actor settings. pub torrent: TorrentSettings, /// Per-peer actor settings. @@ -34,6 +36,29 @@ pub struct Settings { pub tracker: TrackerSettings, } +/// Bounded event capacities for each frontend 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 FrontendSettings { + pub engine_event_capacity: usize, + pub torrent_event_capacity: usize, + pub peer_event_capacity: usize, + pub tracker_event_capacity: usize, +} + +impl Default for FrontendSettings { + fn default() -> Self { + Self { + engine_event_capacity: 256, + torrent_event_capacity: 256, + peer_event_capacity: 64, + tracker_event_capacity: 64, + } + } +} + /// Mainline [BEP 5] DHT networking and lookup settings. /// /// [BEP 5]: https://www.bittorrent.org/beps/bep_0005.html diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index ef0c982a..593cb91b 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -25,10 +25,11 @@ use tracing::{debug, error, info, instrument, trace, warn}; use super::{choking::ChokingScheduler, util}; use crate::{ - errors::TorrentError, + errors::{SnapshotUnsupportedReason, TorrentError}, frontend::{ - FrontendHealthLevel, FrontendPublisher, TorrentProgress, TorrentTransfer, TorrentView, - TrackerStatus, TrackerView, + ByteCount, ContentProgress, FrontendHealthLevel, FrontendPublisher, HasTransferMetrics, + TorrentMetrics, TorrentView, TrackerStatus, TrackerView, TrafficTotals, TransferMetrics, + TransferRates, }, hashes::InfoHash, metainfo::{Info, MetaInfo}, @@ -36,7 +37,8 @@ use crate::{ pieces::{FilePieceManager, PieceManager, PieceScheduler, PieceStoreActor}, settings::Settings, torrent::{ - BLOCK_SIZE, PieceStorageStrategy, TORRENT_SNAPSHOT_VERSION, TorrentSnapshot, TorrentState, + BLOCK_SIZE, PieceBlockSnapshot, PieceStorageStrategy, TORRENT_SNAPSHOT_VERSION, + TorrentSnapshot, TorrentState, }, tracker::{ Announce, Event, Tracker, TrackerActor, TrackerActorArgs, TrackerUpdate, udp::UdpServer, @@ -104,7 +106,9 @@ pub(crate) struct TorrentActor { 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, @@ -151,13 +155,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(), } } @@ -211,7 +211,7 @@ impl TorrentActor { self.send_ready_hooks(); - let Some(info) = self.info.clone() else { + 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; @@ -220,7 +220,7 @@ impl TorrentActor { // Pre-start the piece manager before transitioning state if let Err(err) = self.piece_manager.pre_start(info.clone()).await { self.transition_state(TorrentState::Failed); - self.frontend.health( + self.frontend.emit_health( Some(self.info_hash()), FrontendHealthLevel::Error, "torrent storage could not be initialized", @@ -390,6 +390,27 @@ impl TorrentActor { Some(total_bytes) } + 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), + ) + } + pub(super) fn tracker_announce_progress(&self) -> Option { let info = self.info_dict()?; let total_length = info.total_length(); @@ -421,37 +442,55 @@ impl TorrentActor { .await; } - pub fn snapshot(&self) -> TorrentSnapshot { - TorrentSnapshot { + 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 map = self.piece_scheduler.block_map_export(); + let mut blocks = map + .iter() + .map(|entry| PieceBlockSnapshot { + piece_index: Self::snapshot_u64(*entry.key()), + blocks: entry.value().iter().by_vals().collect(), + }) + .collect::>(); + blocks.sort_by_key(|entry| entry.piece_index); + blocks + }, + }) } - /// Builds the display-oriented state used by live frontend listeners. + /// Builds the presentation state used by live application 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(); @@ -465,21 +504,35 @@ impl TorrentActor { piece_idx < total_pieces && !self.bitfield[piece_idx] && entry.value().count_ones() > 0 }) .count(); - let transfer = TorrentTransfer::from_peers( - self - .frontend - .peer_handles(self.info_hash()) - .into_iter() - .map(|peer| peer.live_view()), - bytes_remaining, + let peers = self + .frontend + .peer_handles(self.info_hash()) + .into_iter() + .map(|peer| peer.view()) + .collect::>(); + let rates = TransferRates::aggregate(&peers); + let totals = peers + .iter() + .map(HasTransferMetrics::transfer_metrics) + .map(|transfer| transfer.totals) + .fold(TrafficTotals::default(), TrafficTotals::saturating_add); + let metrics = TorrentMetrics::new( + TransferMetrics { totals, rates }, + 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), + }, ); 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()), @@ -488,19 +541,19 @@ impl TorrentActor { PieceManagerProxy::Default(manager) => manager.path().cloned(), PieceManagerProxy::Custom(_) => None, }, - progress: TorrentProgress { - 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, + metrics, } } + /// The single publication entry point for torrent projection changes. + pub(super) fn publish_live_view( + &self, event: impl FnOnce(&TorrentView) -> crate::frontend::TorrentEventKind, + ) { + let view = self.live_view(); + let event = event(&view); + self.frontend.replace_torrent_view_and_emit(view, event); + } + pub(super) fn transition_state(&mut self, state: TorrentState) { let previous = self.state; if previous == state { @@ -508,9 +561,10 @@ impl TorrentActor { } self.state = state; - self - .frontend - .torrent_state_changed(previous, self.live_view()); + self.publish_live_view(|_| crate::frontend::TorrentEventKind::StateChanged { + previous, + current: state, + }); } fn snapshot_u64(value: usize) -> u64 { @@ -529,7 +583,7 @@ impl TorrentActor { } pub fn is_ready(&self) -> bool { - self.info.is_some() && self.peers.len() >= self.sufficient_peers + self.info_dict().is_some() && self.peers.len() >= self.sufficient_peers } pub fn is_ready_to_start(&self) -> bool { @@ -646,8 +700,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"); @@ -664,15 +718,16 @@ 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 { let endpoint = tracker.frontend_endpoint(); - let tracker_frontend = frontend.tracker( + let tracker_frontend = frontend.register_tracker_scope( torrent_id, + &tracker, TrackerView { endpoint, status: TrackerStatus::Pending, @@ -703,7 +758,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( @@ -723,7 +778,7 @@ impl Actor for TorrentActor { trackers, id: peer_id, metainfo, - info, + resolved_magnet_info: None, actor_ref: us, piece_storage, piece_store, @@ -742,7 +797,9 @@ impl Actor for TorrentActor { piece_manager: PieceManagerProxy::Default(default_manager), settings, }; - actor.frontend.initialize_torrent(actor.live_view()); + actor + .frontend + .initialize_torrent_projection(actor.live_view()); Ok(actor) } @@ -760,7 +817,16 @@ impl Actor for TorrentActor { async fn on_stop( &mut self, _: WeakActorRef, reason: ActorStopReason, ) -> Result<(), Self::Error> { - self.transition_state(TorrentState::Stopping); + if reason.is_normal() { + self.transition_state(TorrentState::Stopping); + } else { + // The engine supervises torrent actors transiently. Preserve the + // frontend scope and make the temporary state explicit. + self.transition_state(TorrentState::Restarting); + self + .frontend + .close_peer_scopes_for_torrent_restart(self.info_hash()); + } info!(reason = %reason, "Torrent stopped"); for peer in self.peers.values() { peer.kill(); @@ -773,7 +839,9 @@ impl Actor for TorrentActor { } self.piece_store.kill(); self.scheduler.kill(); - self.transition_state(TorrentState::Stopped); + if reason.is_normal() { + self.transition_state(TorrentState::Stopped); + } Ok(()) } @@ -783,7 +851,7 @@ impl Actor for TorrentActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); - self.frontend.health( + self.frontend.emit_health( Some(self.info_hash()), FrontendHealthLevel::Error, "a torrent service stopped unexpectedly", @@ -809,6 +877,7 @@ mod tests { use super::*; use crate::{ + frontend::{BytesPerSecond, PeerScope, PeerView}, hashes::HashVec, metainfo::{InfoKeys, MetaInfo, TorrentFile}, settings::Settings, @@ -1317,11 +1386,11 @@ mod tests { let wrote_piece_block = timeout(Duration::from_secs(60), async { loop { let export = actor.ask(SnapshotState).await.unwrap(); - let has_persisted_progress = export.bitfield.count_ones() > 0 + let has_persisted_progress = export.bitfield.iter().any(|complete| *complete) || export .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(); @@ -1412,7 +1481,7 @@ mod tests { 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()), @@ -1436,20 +1505,38 @@ mod tests { settings: Settings::default(), }; - let export = test_actor.snapshot(); + let export = 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!( + export.resolved_magnet_info.is_none(), + "torrent metainfo already contains its info dict" + ); + assert!(export.resolved_info().is_some()); + assert_eq!( + export.bitfield.iter().filter(|complete| **complete).count(), + fake_completed + ); assert_eq!(export.bitfield.len(), piece_count); assert_eq!(export.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 = export + .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() @@ -1549,7 +1636,7 @@ mod tests { 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()), @@ -1573,44 +1660,95 @@ mod tests { settings: Settings::default(), }; + let verified_content = test_actor.live_view().metrics.progress.verified_bytes; + let sampled_peer = test_actor.frontend.register_peer_scope( + PeerScope { + torrent: info_hash, + peer: PeerId::Unknown([9; 20]), + }, + PeerView { + address: None, + client: None, + connected: true, + peer_choking: false, + peer_interested: true, + client_choking: false, + client_interested: true, + available_pieces: 1, + transfer: TransferMetrics { + totals: TrafficTotals { + downloaded: ByteCount(50_000), + uploaded: ByteCount(5_000), + }, + rates: Some(TransferRates { + download: BytesPerSecond(100), + upload: BytesPerSecond(20), + }), + }, + }, + ); 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.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!( - view.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!( - view.progress.completed_pieces, + view.metrics.progress.completed_pieces, u64::try_from(completed_pieces).unwrap() ); - assert_eq!(view.progress.partial_pieces, 1); + assert_eq!(view.metrics.progress.partial_pieces, 1); assert_eq!( - view.progress.total_pieces, + view.metrics.progress.total_pieces, u64::try_from(piece_count).unwrap() ); - assert!(view.progress.downloaded_bytes > 0); + assert!(view.metrics.progress.verified_bytes > ByteCount::ZERO); assert!( - view.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.progress.progress_fraction.unwrap() > 0.0); - assert_eq!(view.transfer.download_rate_bytes_per_second, Some(0)); - assert_eq!(view.transfer.upload_rate_bytes_per_second, Some(0)); - assert_eq!(view.transfer.eta_seconds, None); + assert!(view.metrics.progress.progress_fraction.unwrap() > 0.0); + assert_eq!( + view.metrics.traffic.rates, + Some(TransferRates { + download: BytesPerSecond(100), + upload: BytesPerSecond(20), + }) + ); + assert_eq!(view.metrics.traffic.totals.downloaded, ByteCount(50_000)); + 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(20) + ); + sampled_peer.disconnected(); + assert_eq!(test_actor.live_view().metrics.traffic.rates, None); + test_actor.state = TorrentState::Ready; - let snapshot = test_actor.snapshot(); + 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.count_ones(), completed_pieces); + 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(); @@ -1622,7 +1760,7 @@ mod tests { assert_eq!(snapshot.block_map.len(), from_snapshot.block_map.len()); test_actor.state = TorrentState::Paused; - assert!(!test_actor.live_view().is_ready); + assert!(!test_actor.live_view().is_ready()); test_actor.bitfield.fill(false); test_actor.bitfield.set_aliased(piece_count - 1, true); @@ -1630,8 +1768,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.live_view().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(); @@ -1675,7 +1813,7 @@ mod tests { 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..7f2b37a5 100644 --- a/crates/libtortillas/src/torrent/choking.rs +++ b/crates/libtortillas/src/torrent/choking.rs @@ -1,4 +1,5 @@ use crate::{ + frontend::BytesPerSecond, peer::{PeerId, PeerStats}, settings::Settings, torrent::TorrentState, @@ -112,23 +113,31 @@ pub(crate) fn select_unchoked_peers( } } -fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> usize { +fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> BytesPerSecond { match torrent_state { - TorrentState::Downloading => peer.download_rate, - TorrentState::Seeding => peer.upload_rate, + TorrentState::Downloading => peer + .transfer + .rates + .map_or(BytesPerSecond::ZERO, |rates| rates.download), + TorrentState::Seeding => peer + .transfer + .rates + .map_or(BytesPerSecond::ZERO, |rates| rates.upload), TorrentState::Added | TorrentState::ResolvingMetadata | TorrentState::Ready | TorrentState::Paused + | TorrentState::Restarting | TorrentState::Stopping | TorrentState::Stopped - | TorrentState::Failed => 0, + | TorrentState::Failed => BytesPerSecond::ZERO, } } #[cfg(test)] mod tests { use super::*; + use crate::frontend::{TransferMetrics, TransferRates}; fn peer_id(value: u8) -> PeerId { PeerId::from([value; 20]) @@ -139,17 +148,22 @@ mod tests { id: peer_id(id), interested: true, choked: true, - download_rate: 0, - upload_rate: 0, - bytes_downloaded: 0, - bytes_uploaded: 0, + transfer: TransferMetrics { + totals: Default::default(), + rates: Some(TransferRates::default()), + }, } } - fn with_rates(id: u8, download_rate: usize, upload_rate: usize) -> PeerStats { + fn with_rates(id: u8, download_rate: u64, upload_rate: u64) -> PeerStats { PeerStats { - download_rate, - upload_rate, + transfer: TransferMetrics { + rates: Some(TransferRates { + download: BytesPerSecond(download_rate), + upload: BytesPerSecond(upload_rate), + }), + ..Default::default() + }, ..stats(id) } } diff --git a/crates/libtortillas/src/torrent/choking_flow.rs b/crates/libtortillas/src/torrent/choking_flow.rs index 4f68659e..71c8cd76 100644 --- a/crates/libtortillas/src/torrent/choking_flow.rs +++ b/crates/libtortillas/src/torrent/choking_flow.rs @@ -19,6 +19,11 @@ impl TorrentActor { } let peer_stats = self.peer_stats().await; + // Peer actors publish their own high-frequency samples. The torrent + // publishes one coalesced aggregate after the collection interval. + self.publish_live_view(|view| { + crate::frontend::TorrentEventKind::MetricsChanged(view.metrics.clone()) + }); let decision = self.choking_scheduler.decide(&peer_stats, self.state); let unchoked: HashSet<_> = decision.unchoked.iter().copied().collect(); diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 39478971..8db9cdf9 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -1,7 +1,7 @@ use std::{ fmt, path::PathBuf, - sync::{Arc, Mutex, MutexGuard, Weak}, + sync::{Arc, Weak}, }; use kameo::actor::ActorRef; @@ -16,10 +16,10 @@ use super::{ }, }; use crate::{ - errors::TorrentError, + errors::{TorrentError, map_torrent_send_error}, frontend::{ - DEFAULT_EVENT_CAPACITY, EventSubscription, FrontendHub, FrontendPublisher, LivePublisher, - PeerHandle, TorrentEventKind, TorrentListener, TorrentView, TrackerHandle, + EventSubscription, FrontendHub, FrontendPublisher, PeerHandle, TorrentEventKind, + TorrentListener, TorrentScope, TorrentView, TrackerHandle, }, hashes::InfoHash, pieces::PieceManager, @@ -30,8 +30,7 @@ pub(crate) struct TorrentInner { pub(crate) info_hash: InfoHash, pub(crate) actor: ActorRef, pub(crate) hub: Weak, - pub(crate) live: LivePublisher, TorrentEventKind>, - routing: Mutex<()>, + pub(crate) scope: Arc, } /// A handle to a torrent managed by the engine. @@ -65,15 +64,17 @@ impl Torrent { info_hash: InfoHash, actor: ActorRef, frontend: &FrontendPublisher, initial_view: Option, ) -> Self { - Self { - inner: Arc::new(TorrentInner { - info_hash, - actor, - hub: frontend.downgrade(), - live: LivePublisher::new(initial_view, DEFAULT_EVENT_CAPACITY), - routing: Mutex::new(()), - }), + let scope = frontend.ensure_torrent_scope(info_hash); + if let Some(view) = initial_view { + let _ = scope.live.set_view(Some(view)); } + let inner = Arc::new(TorrentInner { + info_hash, + actor, + hub: frontend.downgrade(), + scope: Arc::clone(&scope), + }); + Self { inner } } pub(crate) fn actor(&self) -> &ActorRef { @@ -85,11 +86,6 @@ impl Torrent { self.inner.info_hash } - /// Alias for [`Self::info_hash`]. - pub fn key(&self) -> InfoHash { - self.info_hash() - } - pub async fn set_piece_storage( &self, piece_storage: PieceStorageStrategy, ) -> Result<(), TorrentError> { @@ -99,22 +95,23 @@ impl Torrent { strategy: piece_storage, }) .await - .map_err(|error| Self::communication_error("set piece storage", error))?; + .map_err(|error| map_torrent_send_error("set piece storage", error))?; Ok(()) } - pub async fn with_output_folder(&self, folder: impl Into) -> Result<(), TorrentError> { + /// 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 - .map_err(|error| Self::communication_error("set output path", error))?; + .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<(), TorrentError> { self @@ -123,7 +120,7 @@ impl Torrent { manager: Box::new(piece_manager), }) .await - .map_err(|error| Self::communication_error("set piece manager", error))?; + .map_err(|error| map_torrent_send_error("set piece manager", error))?; Ok(()) } @@ -156,7 +153,7 @@ impl Torrent { .inspect_err(|error| { error!(%error, operation, "Failed to change torrent state"); }) - .map_err(|error| Self::communication_error(operation, error))?; + .map_err(|error| map_torrent_send_error(operation, error))?; Ok(()) } @@ -165,7 +162,7 @@ impl Torrent { .actor() .ask(GetState) .await - .map_err(|error| Self::communication_error("get state", error)) + .map_err(|error| map_torrent_send_error("get state", error)) } /// Captures this torrent's metadata, storage configuration, and verified or @@ -178,7 +175,7 @@ impl Torrent { .ask(SnapshotState) .await .map(|snapshot| *snapshot) - .map_err(|error| Self::communication_error("snapshot torrent", error)) + .map_err(|error| map_torrent_send_error("snapshot torrent", error)) } pub async fn set_auto_start(&self, auto: bool) -> Result<(), TorrentError> { @@ -186,7 +183,7 @@ impl Torrent { .actor() .ask(SetAutoStart { auto }) .await - .map_err(|error| Self::communication_error("set auto start", error))?; + .map_err(|error| map_torrent_send_error("set auto start", error))?; Ok(()) } @@ -195,7 +192,7 @@ impl Torrent { .actor() .ask(SetSufficientPeers { peers }) .await - .map_err(|error| Self::communication_error("set sufficient peers", error))?; + .map_err(|error| map_torrent_send_error("set sufficient peers", error))?; Ok(()) } @@ -205,31 +202,34 @@ impl Torrent { .actor() .ask(ReadyHook { hook }) .await - .map_err(|error| Self::communication_error("register ready hook", error))?; + .map_err(|error| map_torrent_send_error("register ready hook", error))?; hook_rx .await - .map_err(|error| Self::communication_error("wait for readiness", error))?; + .map_err(|error| TorrentError::ActorCommunicationFailed { + operation: "wait for readiness", + reason: error.to_string(), + })?; Ok(()) } /// Subscribes to live events for this torrent only. #[must_use] pub fn subscribe(&self) -> EventSubscription { - self.inner.live.subscribe() + self.inner.scope.live.subscribe() } /// Creates a live listener scoped to this torrent. #[must_use] pub fn listener(&self) -> TorrentListener { - self.inner.live.listener() + self.inner.scope.live.listener() } /// Returns the latest display-oriented state maintained for this torrent. /// /// This returns `None` after the torrent has been removed from its engine. #[must_use] - pub fn live_view(&self) -> Option { - self.inner.live.view() + pub fn view(&self) -> Option { + self.inner.scope.live.view() } /// Returns handles for this torrent's currently connected peers. @@ -248,30 +248,7 @@ impl Torrent { }) } - pub(crate) fn publish(&self, view: TorrentView, event: TorrentEventKind) -> bool { - self.inner.live.update(Some(view), event) - } - - pub(crate) fn removed(&self) -> bool { - self.inner.live.close(None, TorrentEventKind::Removed) - } - - pub(crate) fn routing_lock(&self) -> MutexGuard<'_, ()> { - self - .inner - .routing - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - fn frontend(&self) -> Option { self.inner.hub.upgrade().map(FrontendPublisher::from_hub) } - - fn communication_error(operation: &'static str, error: impl fmt::Display) -> TorrentError { - TorrentError::ActorCommunicationFailed { - operation, - reason: error.to_string(), - } - } } diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index 37507622..f75dcc6a 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -11,12 +11,13 @@ use tracing::{info, instrument, trace, warn}; use super::{ AnnounceFrom, BLOCK_SIZE, PieceStorageStrategy, TorrentActor, TorrentSnapshot, TorrentState, + ValidatedTorrentState, actor::{PieceManagerProxy, ReadyHookSender}, util, }; use crate::{ errors::TorrentError, - frontend::TorrentView, + frontend::{TorrentEventKind, TorrentView}, hashes::InfoHash, metainfo::Info, peer::{Peer, PeerId, commands::HaveInfoDict}, @@ -26,7 +27,7 @@ use crate::{ }; #[derive(Debug, Reply)] -pub(crate) struct SnapshotRestoreResult(pub(crate) Result); +pub(crate) struct SnapshotRestoreResult(pub(crate) Result<(), TorrentError>); pub(crate) mod events { use super::*; @@ -93,7 +94,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" @@ -106,13 +107,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.transition_state(TorrentState::Added); } - self.frontend.metadata_resolved(self.live_view()); + self.publish_live_view(|_| TorrentEventKind::MetadataResolved); self .broadcast_to_peers(HaveInfoDict { bitfield: Arc::new(self.bitfield.clone()), @@ -158,7 +165,8 @@ pub(crate) mod commands { if let Some(actor) = self.peers.remove(&id) { actor.kill(); } - frontend.disconnected(Some(self.live_view())); + frontend.disconnected(); + self.publish_live_view(|_| TorrentEventKind::Updated); } #[message] @@ -167,129 +175,179 @@ pub(crate) mod commands { if let Some(actor) = self.trackers.get(&tracker) { actor.kill(); self.trackers.remove(&tracker); - self.frontend.update_torrent(self.live_view()); + self.publish_live_view(|_| TorrentEventKind::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 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; - self.frontend.update_torrent(self.live_view()); + self.publish_live_view(|_| TorrentEventKind::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.frontend.update_torrent(self.live_view()); + self.piece_manager = PieceManagerProxy::Custom(manager); + self.publish_live_view(|_| TorrentEventKind::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(), + }); } - self.frontend.update_torrent(self.live_view()); + 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); + } + self.publish_live_view(|_| TorrentEventKind::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.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.frontend.update_torrent(self.live_view()); + self.publish_live_view(|_| TorrentEventKind::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.frontend.update_torrent(self.live_view()); + self.publish_live_view(|_| TorrentEventKind::Updated); + Ok(()) } /// Restores persisted piece and lifecycle state before exposing a resumed /// torrent to callers. #[message] pub(crate) fn restore_snapshot( - &mut self, snapshot: TorrentSnapshot, + &mut self, snapshot: ValidatedTorrentState, ) -> SnapshotRestoreResult { - let result = (|| -> Result { - snapshot.validate()?; - if snapshot.info_hash != self.info_hash() { - return Err(TorrentError::InvalidSnapshot { - reason: "info hash does not match metainfo".to_string(), - }); - } - - let piece_count = snapshot - .resolved_info() + 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 resume = snapshot.state.is_transfer_active(); 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 in snapshot.bitfield.iter_ones() { + for (index, complete) in snapshot.bitfield.iter().copied().enumerate() { + if !complete { + continue; + } scheduler.mark_piece_complete(index); } for entry in &snapshot.block_map { - scheduler.restore_piece_blocks(*entry.key(), entry.value().clone()); + 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.info = snapshot.info_dict; - self.bitfield = snapshot.bitfield; + self.bitfield = snapshot.bitfield.iter().copied().collect(); self.piece_scheduler = scheduler; self.autostart = snapshot.auto_start; - self.sufficient_peers = snapshot.sufficient_peers; + 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.frontend.update_torrent(self.live_view()); + self.publish_live_view(|_| TorrentEventKind::Updated); - Ok(resume) + Ok(()) })(); SnapshotRestoreResult(result) @@ -307,10 +365,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(); @@ -320,6 +378,7 @@ pub(crate) mod commands { self.ready_hook.push(hook); self.autostart().await; } + Ok(()) } /// Bitfield of the torrent. @@ -361,7 +420,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. @@ -369,7 +428,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" @@ -456,8 +515,8 @@ 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] @@ -466,8 +525,8 @@ pub(crate) mod commands { } #[message] - pub(crate) fn snapshot_state(&self) -> Box { - Box::new(self.snapshot()) + pub(crate) fn snapshot_state(&self) -> Result, TorrentError> { + self.snapshot().map(Box::new) } } } diff --git a/crates/libtortillas/src/torrent/mod.rs b/crates/libtortillas/src/torrent/mod.rs index 01c37e18..7f8f448e 100644 --- a/crates/libtortillas/src/torrent/mod.rs +++ b/crates/libtortillas/src/torrent/mod.rs @@ -17,7 +17,10 @@ pub use discovery::AnnounceFrom; pub use handle::Torrent; pub(crate) use handle::TorrentInner; pub(crate) use messages::*; -pub use snapshot::{TORRENT_SNAPSHOT_VERSION, TorrentSnapshot}; +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 ac35fb04..dec6408f 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -23,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"); @@ -33,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 { @@ -123,13 +131,15 @@ impl TorrentActor { trace!(%peer_id, "Requested replacement block from peer"); } - self.frontend.progress_changed(self.live_view()); + self.publish_live_view(|view| { + crate::frontend::TorrentEventKind::MetricsChanged(view.metrics.clone()) + }); } 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 { + let Some(info) = self.info_dict() else { return; }; let Some(peer) = self.peers.get(&peer_id).cloned() else { @@ -476,7 +486,7 @@ mod tests { 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()), diff --git a/crates/libtortillas/src/torrent/snapshot.rs b/crates/libtortillas/src/torrent/snapshot.rs index 8c04ba9c..876acd08 100644 --- a/crates/libtortillas/src/torrent/snapshot.rs +++ b/crates/libtortillas/src/torrent/snapshot.rs @@ -1,39 +1,147 @@ -use std::{path::PathBuf, sync::atomic::AtomicU8}; +use std::{collections::BTreeMap, path::PathBuf, sync::atomic::AtomicU8}; use bitvec::vec::BitVec; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; +use tokio::fs; -use super::{BLOCK_SIZE, BlockMap, PieceStorageStrategy, TorrentState}; +use super::{BLOCK_SIZE, PieceStorageStrategy, TorrentState, util}; use crate::{ errors::TorrentError, hashes::InfoHash, metainfo::{Info, MetaInfo}, + pieces::FilePieceManager, }; /// Current persistence schema version for [`TorrentSnapshot`]. -pub const TORRENT_SNAPSHOT_VERSION: u32 = 1; +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. -/// -/// Frontends choose the Serde format and storage location. Live UI rendering -/// should use [`Torrent::listener`](super::Torrent::listener), not this type. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct TorrentSnapshot { pub version: u32, pub info_hash: InfoHash, pub state: TorrentState, pub auto_start: bool, - pub sufficient_peers: usize, + pub sufficient_peers: u64, pub output_path: Option, pub metainfo: MetaInfo, pub piece_storage: PieceStorageStrategy, - pub info_dict: Option, - pub bitfield: BitVec, - pub block_map: BlockMap, + /// 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, +} + +#[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, +} + +#[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 all redundant integrity fields. + /// Validates schema compatibility and structural integrity. pub fn validate(&self) -> Result<(), TorrentError> { if self.version != TORRENT_SNAPSHOT_VERSION { return Err(self.invalid(format!( @@ -48,7 +156,13 @@ impl TorrentSnapshot { if metainfo_hash != self.info_hash { return Err(self.invalid("info hash does not match metainfo")); } - if let Some(info) = &self.info_dict { + 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}")))?; @@ -66,7 +180,8 @@ impl TorrentSnapshot { ))); } for entry in &self.block_map { - let index = *entry.key(); + let index = usize::try_from(entry.piece_index) + .map_err(|_| self.invalid("partial piece index cannot be represented"))?; if index >= piece_count { return Err(self.invalid("partial piece index is outside the metadata piece range")); } @@ -77,27 +192,11 @@ impl TorrentSnapshot { let Some(info) = info else { return Err(self.invalid("partial block state requires resolved metadata")); }; - let piece_length = usize::try_from(info.piece_length) - .map_err(|_| self.invalid("piece length cannot be represented on this platform"))?; - if piece_length == 0 { - return Err(self.invalid("piece length must be greater than zero")); - } - let last_piece = piece_count.saturating_sub(1); - let concrete_length = if index == last_piece { - let remainder = info.total_length() % piece_length; - if remainder == 0 { - piece_length - } else { - remainder - } - } else { - piece_length - }; - let expected_blocks = concrete_length.div_ceil(BLOCK_SIZE); - if entry.value().len() != expected_blocks { + 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.value().len() + entry.blocks.len() ))); } } @@ -105,11 +204,12 @@ impl TorrentSnapshot { Ok(()) } - pub(crate) fn resolved_info(&self) -> Option<&Info> { - self.info_dict.as_ref().or(match &self.metainfo { + #[must_use] + pub fn resolved_info(&self) -> Option<&Info> { + match &self.metainfo { MetaInfo::Torrent(torrent) => Some(&torrent.info), - MetaInfo::MagnetUri(_) => None, - }) + MetaInfo::MagnetUri(_) => self.resolved_magnet_info.as_ref(), + } } fn invalid(&self, reason: impl Into) -> TorrentError { @@ -118,3 +218,287 @@ impl TorrentSnapshot { } } } + +/// 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 => { + fs::metadata(path).await.is_ok_and(|metadata| { + metadata.len() + >= u64::try_from(piece_length(&info, index).unwrap_or(usize::MAX)) + .unwrap_or(u64::MAX) + }) + } + 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 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..df7318c7 100644 --- a/crates/libtortillas/src/torrent/state.rs +++ b/crates/libtortillas/src/torrent/state.rs @@ -6,8 +6,8 @@ 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. +/// `Restarting`, `Stopping`, `Stopped`, or `Failed` without collapsing those +/// states into a generic inactive bucket. #[derive( Debug, Default, @@ -36,6 +36,8 @@ pub enum TorrentState { 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/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index fb2aec98..61d316f0 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -109,7 +109,7 @@ impl TorrentActor { return; } - let peer_frontend = self.frontend.peer( + let peer_frontend = self.frontend.register_peer_scope( PeerScope { torrent: info_hash, peer: id, @@ -132,9 +132,8 @@ impl TorrentActor { }, ); self.peers.insert(id, peer_actor); - self - .frontend - .peer_connected(self.live_view(), &peer_frontend); + self.publish_live_view(|_| crate::frontend::TorrentEventKind::Updated); + self.frontend.emit_peer_connected(&peer_frontend); } #[instrument(skip(self, tell), fields(torrent_id = %self.info_hash(), msg = ?tell))] @@ -175,7 +174,7 @@ impl TorrentActor { } for id in dead_peers { self.peers.remove(&id); - self.frontend.update_torrent(self.live_view()); + self.publish_live_view(|_| crate::frontend::TorrentEventKind::Updated); } } diff --git a/crates/libtortillas/src/tracker/actor.rs b/crates/libtortillas/src/tracker/actor.rs index 078bfb01..1ff1864f 100644 --- a/crates/libtortillas/src/tracker/actor.rs +++ b/crates/libtortillas/src/tracker/actor.rs @@ -142,9 +142,16 @@ impl Actor for TrackerActor { } async fn on_stop( - &mut self, _: WeakActorRef, _: ActorStopReason, + &mut self, _: WeakActorRef, reason: ActorStopReason, ) -> Result<(), Self::Error> { - self.frontend.stopped(); + if reason.is_normal() { + self.frontend.stopped(); + } else { + // Transient supervision may reconstruct this actor with the same + // frontend scope. Keep the listener open until its owning torrent + // performs final tree cleanup. + self.frontend.restarting(); + } if let Some(next_announce) = self.next_announce.take() { next_announce.abort(); } diff --git a/crates/libtortillas/tests/dht_network.rs b/crates/libtortillas/tests/dht_network.rs index 179a1ed0..566c6824 100644 --- a/crates/libtortillas/tests/dht_network.rs +++ b/crates/libtortillas/tests/dht_network.rs @@ -46,7 +46,7 @@ async fn arch_linux_torrent_when_public_dht_is_available_then_downloads_data() { let download = timeout(DOWNLOAD_TIMEOUT, async { loop { let view = listener.view().unwrap(); - if view.progress.downloaded_bytes > 0 { + if view.metrics.progress.verified_bytes.0 > 0 { return view; } timeout(POLL_INTERVAL, listener.recv()).await.ok(); @@ -58,6 +58,6 @@ async fn arch_linux_torrent_when_public_dht_is_available_then_downloads_data() { fs::remove_dir_all(&output_root).await.unwrap(); let view = download.expect("Arch Linux did not download data through DHT in time"); - assert!(view.has_metadata); + assert!(view.has_metadata()); assert!(view.peer_count > 0); } diff --git a/crates/libtortillas/tests/facade.rs b/crates/libtortillas/tests/facade.rs index 6a1b4f71..18c46247 100644 --- a/crates/libtortillas/tests/facade.rs +++ b/crates/libtortillas/tests/facade.rs @@ -1,6 +1,6 @@ use libtortillas::{ facade::{EngineSnapshot, TorrentSnapshot}, - prelude::{EngineHandle, EventSubscription, PeerEventKind, TorrentEventKind, TrackerEventKind}, + prelude::{Engine, EventSubscription, PeerEventKind, TorrentEventKind, TrackerEventKind}, }; #[test] @@ -16,7 +16,7 @@ fn prelude_exposes_frontend_facade_types() { #[test] fn facade_engine_handle_matches_existing_engine_type() { - fn accepts_engine_handle(_: Option) {} + fn accepts_engine_handle(_: Option) {} accepts_engine_handle(None); } 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_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index c3cd6ca5..d9354fa1 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -57,8 +57,8 @@ async fn engine_listener_receives_live_torrent_lifecycle() { unreachable!(); }; assert_eq!(added_torrent.info_hash(), torrent.info_hash()); - assert_eq!(added_torrent.live_view(), torrent.live_view()); - assert_eq!(engine_listener.view().torrent_count, 1); + 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(); @@ -86,6 +86,10 @@ async fn engine_listener_receives_live_torrent_lifecycle() { } )); 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 { @@ -104,7 +108,7 @@ async fn engine_listener_receives_live_torrent_lifecycle() { Err(EventStreamError::Closed) )); assert!(torrent_listener.view().is_none()); - assert_eq!(engine_listener.view().torrent_count, 0); + assert_eq!(engine_listener.view().torrent_count(), 0); engine.shutdown().await.unwrap(); } @@ -172,6 +176,19 @@ async fn concurrent_live_updates_are_delivered_in_sequence_order() { } } +#[tokio::test] +async fn listener_view_is_never_older_than_its_accepted_update() { + let publisher = LivePublisher::new(0_u64, 64); + let mut listener = publisher.listener(); + + for value in 1..=32 { + assert!(publisher.update(value, value)); + let event = listener.recv().await.unwrap(); + assert_eq!(event.kind, value); + assert!(listener.view() >= event.kind); + } +} + #[tokio::test] async fn tracker_handle_exposes_its_own_live_listener() { let engine = deterministic_engine(); @@ -182,7 +199,7 @@ async fn tracker_handle_exposes_its_own_live_listener() { let tracker = torrent.trackers().into_iter().next().unwrap(); let mut listener = tracker.listener(); - assert!(tracker.live_view().status.is_active()); + assert!(tracker.view().status.is_active()); engine.shutdown().await.unwrap(); let stopped = timeout(Duration::from_secs(2), async { @@ -197,6 +214,12 @@ async fn tracker_handle_exposes_its_own_live_listener() { .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] @@ -254,6 +277,27 @@ async fn stopped_engine_reports_typed_actor_communication_errors() { )); } +#[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(); diff --git a/crates/libtortillas/tests/persistence.rs b/crates/libtortillas/tests/persistence.rs index 9fc37272..1b0b889c 100644 --- a/crates/libtortillas/tests/persistence.rs +++ b/crates/libtortillas/tests/persistence.rs @@ -1,12 +1,20 @@ +use async_trait::async_trait; +use bytes::Bytes; use libtortillas::{ engine::Engine, - errors::{EngineError, TorrentError}, + errors::{EngineError, SnapshotUnsupportedReason, TorrentError}, + metainfo::Info, + pieces::PieceManager, prelude::{Settings, TorrentSource, TorrentState}, - torrent::TorrentSnapshot, + 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(); @@ -17,6 +25,27 @@ fn deterministic_engine() -> Engine { .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(); @@ -37,7 +66,7 @@ async fn torrent_snapshot_when_serialized_then_restores_session_state() { .restore_torrent(restored_snapshot) .await .unwrap(); - let view = restored.live_view().unwrap(); + let view = restored.view().unwrap(); assert_eq!(restored.info_hash(), torrent.info_hash()); assert_eq!(view.state, TorrentState::Paused); @@ -53,6 +82,69 @@ async fn torrent_snapshot_when_serialized_then_restores_session_state() { 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(); @@ -110,7 +202,14 @@ async fn engine_snapshot_when_serialized_then_restores_all_torrents() { .await .unwrap(); let expected_hashes = [first.info_hash(), second.info_hash()]; - let snapshot_bytes = serde_json::to_vec(&engine.snapshot().await.unwrap()).unwrap(); + 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(); @@ -127,7 +226,7 @@ async fn engine_snapshot_when_serialized_then_restores_all_torrents() { .iter() .all(|hash| restored_hashes.contains(hash)) ); - assert_eq!(restored_engine.live_view().torrent_count, 2); + assert_eq!(restored_engine.view().torrent_count(), 2); restored_engine.shutdown().await.unwrap(); } @@ -142,7 +241,7 @@ async fn engine_snapshot_when_version_is_unknown_then_restores_nothing() { let error = target_engine.restore(snapshot).await.unwrap_err(); assert!(matches!(error, EngineError::InvalidSnapshot { .. })); - assert_eq!(target_engine.live_view().torrent_count, 0); + assert_eq!(target_engine.view().torrent_count(), 0); target_engine.shutdown().await.unwrap(); } @@ -165,7 +264,7 @@ async fn engine_restore_checks_authoritative_actor_state_before_mutating() { let error = target_engine.restore(snapshot).await.unwrap_err(); assert!(matches!(error, EngineError::InvalidSnapshot { .. })); - assert_eq!(target_engine.live_view().torrent_count, 1); + assert_eq!(target_engine.view().torrent_count(), 1); assert!(target_engine.torrent(existing.info_hash()).await.is_ok()); target_engine.shutdown().await.unwrap(); } @@ -188,6 +287,174 @@ async fn torrent_snapshot_when_piece_state_is_inconsistent_then_is_rejected_clea error, EngineError::Torrent(TorrentError::InvalidSnapshot { .. }) )); - assert_eq!(target_engine.live_view().torrent_count, 0); + 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 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 + ); + + let current: libtortillas::engine::EngineSnapshot = + serde_json::from_str(ENGINE_SNAPSHOT_V2).unwrap(); + let expected: serde_json::Value = serde_json::from_str(ENGINE_SNAPSHOT_V2).unwrap(); + assert_eq!(serde_json::to_value(current).unwrap(), expected); +} From 1b666967219c58dd910ffb638b9148560f6909b2 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 17:30:32 -0700 Subject: [PATCH 61/77] docs: define adapter-neutral architecture invariants --- crates/libtortillas/src/ARCHITECTURE.md | 132 +++++++++++++++++++++--- 1 file changed, 120 insertions(+), 12 deletions(-) diff --git a/crates/libtortillas/src/ARCHITECTURE.md b/crates/libtortillas/src/ARCHITECTURE.md index a8703537..710bae93 100644 --- a/crates/libtortillas/src/ARCHITECTURE.md +++ b/crates/libtortillas/src/ARCHITECTURE.md @@ -22,26 +22,129 @@ 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. +The frontend coordination boundary is split by owned lifecycle: + +```text +frontend/ +├── live.rs generic state/channel primitive +├── registry.rs guard-free keyed scope storage +├── handle/ +│ ├── mod.rs generic identity-bearing handle primitive +│ ├── peer.rs peer identity and public access +│ └── tracker.rs tracker identity and public access +└── hub/ + ├── mod.rs ownership root and scope definitions + ├── engine.rs root status and derived engine view + ├── torrent.rs torrent projection and tree cleanup + ├── peer.rs torrent-local peer registry + └── tracker.rs torrent-local tracker registry +``` + +## Architectural Invariants + +These rules define the source of truth: + +1. Actors own operational domain state. +2. A live scope owns only its frontend projection. +3. Parent views are derived from child scopes; they never keep manually synchronized child-view copies. +4. Every scope has one view-and-event publication entry point. +5. Peer and tracker events do not implicitly rebuild torrent or engine state. +6. A scope closes exactly once, only when it cannot restart. +7. Snapshot schema validation runs once at the authoritative engine restore boundary. +8. Actor and publisher back-references are weak; the ownership graph contains no strong cycle. +9. Synchronous lock order is registry, scope publication/state, then event sender. +10. No actor communication, filesystem operation, arbitrary callback, or `.await` occurs while a synchronous lock is held. + ## Frontend Boundary `Engine` and `Torrent` own the stable application boundary. Their direct methods are the only public command API, while `listener` combines a bounded event subscription with current `EngineView` or `TorrentView` state. A shared -frontend hub coordinates the engine, torrent, peer, and tracker hierarchy. -Public handles hold weak back-references to that hub, and each scope has an -irreversible terminal state so actor updates cannot resurrect removed objects. +frontend hub owns engine lifecycle state and a keyed registry of torrent +scopes. Each torrent scope owns its live torrent publisher plus its peer and +tracker registries. Public handles hold weak back-references to that hub, and +each scope has an irreversible terminal state so actor updates cannot +resurrect removed objects. + +`EngineView` is derived on read from engine lifecycle state and current torrent +scopes, sorted by info hash. The engine does not cache a second +`Vec`. Peer state and metric updates therefore touch only one peer +scope. Peer connection and disconnection are propagated separately as discrete +parent events without cloning unrelated torrent projections. Engine events project the canonical `TorrentEventKind` hierarchy through `CoreEventKind::Torrent`; they do not duplicate every torrent, peer, and tracker event in a second vocabulary. Live views are intentionally distinct from `EngineSnapshot` and -`TorrentSnapshot`. Views are display-oriented and continuously updated by +`TorrentSnapshot`. Views are presentation-oriented and continuously updated by events. Snapshots are versioned, Serde-compatible persistence records that capture metadata, storage configuration, lifecycle intent, and piece progress for later restoration. Frontends must not poll persistence snapshots to render live state. +Event channels are allocated lazily on first subscription. Their capacities are +configured independently through `FrontendSettings`. + +## Metrics + +Bytes are the canonical internal unit. Peer state, peer statistics, live peer +views, and torrent aggregation share `TransferMetrics`; projection code never +converts KiB/s to bytes/s. `TrafficTotals` describe wire traffic and remain +separate from verified `ContentProgress`. `None` rates mean no sample exists, +while a present zero rate means a sample measured no transfer. ETA is derived +from remaining verified content and aggregate sampled download rate. + +Peer actors publish peer-local samples. `TorrentActor` publishes one coalesced +`TorrentMetrics` update after periodic peer-stat collection. + +`PeerEventKind::StateChanged` and `PeerEventKind::MetricsChanged` remain local +to the peer listener. Root propagation is reserved for connection lifecycle, +tracker lifecycle, and coalesced torrent metrics. + +## Persistence Boundary + +Restoration is ordered as: + +```text +schema validation + -> storage reconciliation + -> actor-state installation + -> optional transfer resumption +``` + +`.torrent` sources store `Info` only inside `MetaInfo`; only resolved magnet +metadata uses `resolved_magnet_info`. `TorrentSnapshot::resolved_info` is the +canonical resolver. Custom piece managers return a typed unsupported error +until a durable descriptor/factory contract exists. + +Full storage verification is the default. It hashes completed payload, demotes +missing or corrupt pieces, and clears partial-block bits whose referenced bytes +do not exist. `TrustSnapshot` is explicit and unsafe. + +Snapshot JSON is a durable contract. Version 1 is migrated during +deserialization to version 2. Version 2 uses `u64` for portable numeric fields, +sorted vectors for keyed scheduler state, and `Vec` for bitfields instead +of serializing `DashMap`, `usize`, or `BitVec` implementation details. Every +supported version has a golden JSON fixture. Unsupported future versions remain +typed validation errors. + +Tracker and torrent actors publish `Restarting` after abnormal supervised +termination and keep their scopes open. Only normal, final ownership teardown +publishes `Stopped` and closes the scope tree. + +## Locking and Publication + +The lock hierarchy is registry shard, scope publication/state, then event +sender. `ScopeRegistry` wraps `DashMap`, but never exposes shard guards: +registry methods return cloned `Arc` values or owned vectors. Every shard guard +is therefore released before a scope publication lock is acquired. Scope +construction happens before shard entry acquisition, so callbacks do not run +under a registry lock. A scope publication lock serializes its view transition, +scoped event, and corresponding root event. `LivePublisher` then acquires its +state lock before its optional sender lock. No code acquires a registry guard +while holding a child scope lock, and no synchronous lock crosses an `.await`. + ## Runtime Boundary `libtortillas` is intentionally tied to Tokio. The crate uses Tokio for actor @@ -49,14 +152,19 @@ 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. +Frontend applications should treat Tokio as the runtime boundary. Every +application adapter should create one Tokio runtime at process startup and run +`Engine` plus all torrent handle operations on that runtime. Synchronous or +blocking adapter work should be isolated from async torrent work through +channels or an adapter-owned thread. `tokio::task::spawn_blocking` is suitable +for bounded blocking operations, but not long-lived blocking loops: a blocking +task cannot be aborted after it starts and can delay runtime shutdown. + +`libtortillas` contains no rendering, input-device, transport-server, or +framework-specific policy. Terminal interfaces, HTTP/WebSocket servers, web +backends, and desktop applications are peer adapters of the same facade. They +consume serializable views and typed event streams and translate user intent +into handle operations outside this crate. Runtime independence is not a current API promise. The public facade should not claim support for custom async runtimes, injected HTTP clients, injected clocks, From 125dfa51661cb05252e60003294f8c5951f983cc Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 18:02:49 -0700 Subject: [PATCH 62/77] refactor: simplify frontend module and ownership layout --- crates/libtortillas/examples/live_frontend.rs | 8 +- crates/libtortillas/src/ARCHITECTURE.md | 51 +- crates/libtortillas/src/engine/actor.rs | 8 +- crates/libtortillas/src/engine/mod.rs | 18 +- crates/libtortillas/src/facade.rs | 14 +- crates/libtortillas/src/frontend/event.rs | 19 +- crates/libtortillas/src/frontend/handle.rs | 352 +++++++++++ .../libtortillas/src/frontend/handle/mod.rs | 65 -- .../libtortillas/src/frontend/handle/peer.rs | 121 ---- .../src/frontend/handle/tracker.rs | 180 ------ crates/libtortillas/src/frontend/hub.rs | 567 ++++++++++++++++++ .../libtortillas/src/frontend/hub/engine.rs | 49 -- crates/libtortillas/src/frontend/hub/mod.rs | 88 --- crates/libtortillas/src/frontend/hub/peer.rs | 67 --- .../libtortillas/src/frontend/hub/torrent.rs | 142 ----- .../libtortillas/src/frontend/hub/tracker.rs | 67 --- crates/libtortillas/src/frontend/listener.rs | 73 --- crates/libtortillas/src/frontend/live.rs | 217 ------- crates/libtortillas/src/frontend/mod.rs | 50 +- crates/libtortillas/src/frontend/publisher.rs | 436 -------------- crates/libtortillas/src/frontend/registry.rs | 68 --- crates/libtortillas/src/frontend/stream.rs | 380 ++++++++++++ .../libtortillas/src/frontend/subscription.rs | 100 --- crates/libtortillas/src/frontend/tests.rs | 339 +++++++++++ crates/libtortillas/src/frontend/view.rs | 18 +- crates/libtortillas/src/lib.rs | 3 +- .../src/{frontend => }/metrics.rs | 3 + crates/libtortillas/src/peer/actor.rs | 6 +- .../src/pieces/piece_scheduler.rs | 13 +- crates/libtortillas/src/torrent/actor.rs | 140 ++--- crates/libtortillas/src/torrent/choking.rs | 4 +- crates/libtortillas/src/torrent/handle.rs | 26 +- crates/libtortillas/src/torrent/piece_flow.rs | 6 +- crates/libtortillas/src/torrent/swarm.rs | 4 +- crates/libtortillas/tests/engine_lifecycle.rs | 4 +- crates/libtortillas/tests/live_frontend.rs | 22 +- docs/frontend-integration.md | 37 +- 37 files changed, 1882 insertions(+), 1883 deletions(-) create mode 100644 crates/libtortillas/src/frontend/handle.rs delete mode 100644 crates/libtortillas/src/frontend/handle/mod.rs delete mode 100644 crates/libtortillas/src/frontend/handle/peer.rs delete mode 100644 crates/libtortillas/src/frontend/handle/tracker.rs create mode 100644 crates/libtortillas/src/frontend/hub.rs delete mode 100644 crates/libtortillas/src/frontend/hub/engine.rs delete mode 100644 crates/libtortillas/src/frontend/hub/mod.rs delete mode 100644 crates/libtortillas/src/frontend/hub/peer.rs delete mode 100644 crates/libtortillas/src/frontend/hub/torrent.rs delete mode 100644 crates/libtortillas/src/frontend/hub/tracker.rs delete mode 100644 crates/libtortillas/src/frontend/listener.rs delete mode 100644 crates/libtortillas/src/frontend/live.rs delete mode 100644 crates/libtortillas/src/frontend/publisher.rs delete mode 100644 crates/libtortillas/src/frontend/registry.rs create mode 100644 crates/libtortillas/src/frontend/stream.rs delete mode 100644 crates/libtortillas/src/frontend/subscription.rs create mode 100644 crates/libtortillas/src/frontend/tests.rs rename crates/libtortillas/src/{frontend => }/metrics.rs (98%) diff --git a/crates/libtortillas/examples/live_frontend.rs b/crates/libtortillas/examples/live_frontend.rs index a527d1b9..807308b0 100644 --- a/crates/libtortillas/examples/live_frontend.rs +++ b/crates/libtortillas/examples/live_frontend.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use libtortillas::prelude::{ - CoreEventKind, Engine, EventStreamError, TorrentEventKind, TorrentSource, TorrentState, + Engine, EngineEventKind, EventStreamError, TorrentEventKind, TorrentSource, TorrentState, }; use tracing::{error, info, warn}; @@ -16,7 +16,7 @@ async fn main() -> Result<(), Box> { let engine = Engine::default(); let mut listener = engine.listener(); - let frontend = tokio::spawn(async move { + let event_task = tokio::spawn(async move { loop { match listener.recv().await { Ok(event) => { @@ -27,7 +27,7 @@ async fn main() -> Result<(), Box> { ?event.kind, "frontend received a live engine event" ); - if matches!(event.kind, CoreEventKind::Shutdown(_)) { + if matches!(event.kind, EngineEventKind::Shutdown(_)) { break; } } @@ -75,6 +75,6 @@ async fn main() -> Result<(), Box> { info!(?path, "saved resumable engine state"); } engine.shutdown().await?; - frontend.await?; + event_task.await?; Ok(()) } diff --git a/crates/libtortillas/src/ARCHITECTURE.md b/crates/libtortillas/src/ARCHITECTURE.md index 710bae93..70da2563 100644 --- a/crates/libtortillas/src/ARCHITECTURE.md +++ b/crates/libtortillas/src/ARCHITECTURE.md @@ -22,24 +22,25 @@ 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. -The frontend coordination boundary is split by owned lifecycle: +The frontend boundary uses a small, reader-oriented module layout: ```text +metrics.rs canonical units, transfer metrics, and aggregation frontend/ -├── live.rs generic state/channel primitive -├── registry.rs guard-free keyed scope storage -├── handle/ -│ ├── mod.rs generic identity-bearing handle primitive -│ ├── peer.rs peer identity and public access -│ └── tracker.rs tracker identity and public access -└── hub/ - ├── mod.rs ownership root and scope definitions - ├── engine.rs root status and derived engine view - ├── torrent.rs torrent projection and tree cleanup - ├── peer.rs torrent-local peer registry - └── tracker.rs torrent-local tracker registry +├── mod.rs public map and exports +├── view.rs current presentation models +├── event.rs discrete event contracts +├── stream.rs publisher, subscription, listener, and closure lifecycle +├── handle.rs peer and tracker identity-bearing access +├── hub.rs complete ownership tree and publication coordination +└── tests.rs private invariants and performance proof ``` +The ownership path is deliberately kept in one `hub.rs`. Engine, torrent, peer, +and tracker publication are sections of one coordinator rather than separate +files, so a reader can follow a state change without navigating between small +modules. + ## Architectural Invariants These rules define the source of truth: @@ -51,7 +52,7 @@ These rules define the source of truth: 5. Peer and tracker events do not implicitly rebuild torrent or engine state. 6. A scope closes exactly once, only when it cannot restart. 7. Snapshot schema validation runs once at the authoritative engine restore boundary. -8. Actor and publisher back-references are weak; the ownership graph contains no strong cycle. +8. Actor and hub back-references are weak; the ownership graph contains no strong cycle. 9. Synchronous lock order is registry, scope publication/state, then event sender. 10. No actor communication, filesystem operation, arbitrary callback, or `.await` occurs while a synchronous lock is held. @@ -62,9 +63,13 @@ methods are the only public command API, while `listener` combines a bounded event subscription with current `EngineView` or `TorrentView` state. A shared frontend hub owns engine lifecycle state and a keyed registry of torrent scopes. Each torrent scope owns its live torrent publisher plus its peer and -tracker registries. Public handles hold weak back-references to that hub, and -each scope has an irreversible terminal state so actor updates cannot -resurrect removed objects. +tracker registries. The torrent scope also retains the one backing +`TorrentInner`; there is no parallel keyed handle registry to synchronize. +`TorrentInner` retains only the shared live publisher and a weak hub +back-reference, so this ownership path does not form a cycle. Peer and tracker +handles likewise hold weak hub back-references. Every live publisher has an +irreversible terminal state, so actor updates cannot resurrect removed +objects. `EngineView` is derived on read from engine lifecycle state and current torrent scopes, sorted by info hash. The engine does not cache a second @@ -73,7 +78,7 @@ scope. Peer connection and disconnection are propagated separately as discrete parent events without cloning unrelated torrent projections. Engine events project the canonical `TorrentEventKind` hierarchy through -`CoreEventKind::Torrent`; they do not duplicate every torrent, peer, and +`EngineEventKind::Torrent`; they do not duplicate every torrent, peer, and tracker event in a second vocabulary. Live views are intentionally distinct from `EngineSnapshot` and @@ -86,6 +91,11 @@ live state. Event channels are allocated lazily on first subscription. Their capacities are configured independently through `FrontendSettings`. +`LivePublisher` mutation names state the complete transition: +`replace_view`, `replace_view_and_emit`, `emit_without_view_change`, and +`close_with_terminal_event`. Coordination code does not hide those effects +behind generic `update` or `publish` methods. + ## Metrics Bytes are the canonical internal unit. Peer state, peer statistics, live peer @@ -136,8 +146,9 @@ publishes `Stopped` and closes the scope tree. ## Locking and Publication The lock hierarchy is registry shard, scope publication/state, then event -sender. `ScopeRegistry` wraps `DashMap`, but never exposes shard guards: -registry methods return cloned `Arc` values or owned vectors. Every shard guard +sender. `ScopeRegistry` is not a replacement concurrent map: it is a narrow +policy wrapper around `DashMap` that prevents shard guards from escaping. +Registry methods return cloned `Arc` values or owned vectors. Every shard guard is therefore released before a scope publication lock is acquired. Scope construction happens before shard entry acquisition, so callbacks do not run under a registry lock. A scope publication lock serializes its view transition, diff --git a/crates/libtortillas/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index 449a6fbe..8ff851d9 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -17,7 +17,7 @@ use super::commands; use crate::{ dht::{DhtActor, DhtActorArgs}, errors::EngineError, - frontend::{FrontendHealthLevel, FrontendPublisher}, + frontend::{FrontendHealthLevel, FrontendHub}, hashes::InfoHash, peer::PeerId, protocol::stream::PeerStream, @@ -31,8 +31,8 @@ use crate::{ /// also implements the [Actor] trait, and consequently behaves like an /// actor. pub struct EngineActor { - /// Live frontend event and view publisher shared with managed torrents. - pub(super) frontend: FrontendPublisher, + /// Live projection coordinator shared with managed torrents. + pub(super) frontend: FrontendHub, /// Engine-wide DHT service shared by every torrent. pub(super) dht: Option>, /// Listener to wait for incoming TCP connections from peers @@ -107,7 +107,7 @@ pub struct EngineActorArgs { pub default_base_path: Option, /// Live frontend state shared by the engine handle and actor hierarchy. - pub(crate) frontend: FrontendPublisher, + pub(crate) frontend: FrontendHub, } impl Actor for EngineActor { diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 3384d162..0c1b6643 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -62,7 +62,7 @@ use self::{ }; use crate::{ errors::{EngineError, map_engine_send_error}, - frontend::{EngineListener, EngineView, EventSubscription, FrontendPublisher}, + frontend::{EngineListener, EngineView, EventSubscription, FrontendHub}, hashes::InfoHash, peer::PeerId, settings::Settings, @@ -112,7 +112,7 @@ use crate::{ #[derive(Debug, Clone)] pub struct Engine { actor: ActorRef, - frontend: FrontendPublisher, + frontend: FrontendHub, } #[bon::bon] @@ -210,7 +210,7 @@ impl Engine { None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), }; - let frontend = FrontendPublisher::with_settings(settings.frontend); + let frontend = FrontendHub::with_settings(settings.frontend); let args = EngineActorArgs { tcp_addr, utp_addr, @@ -521,7 +521,7 @@ mod tests { }, engine::{Engine, TorrentSource}, errors::EngineError, - frontend::{CoreEventKind, TorrentEventKind}, + frontend::{EngineEventKind, TorrentEventKind}, settings::{DhtSettings, Settings}, testing::{ BIG_BUCK_BUNNY_INFO_HASH, BIG_BUCK_BUNNY_MAGNET, BIG_BUCK_BUNNY_TORRENT_FILE, LocalPeer, @@ -611,10 +611,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.snapshot().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] @@ -626,10 +626,10 @@ mod tests { let source = TorrentSource::magnet(BIG_BUCK_BUNNY_MAGNET); let torrent = engine.add_torrent(source).await.unwrap(); - let export = engine.snapshot().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] @@ -816,7 +816,7 @@ mod tests { let peer = timeout(Duration::from_secs(2), async { loop { let event = listener.recv().await.unwrap(); - if let CoreEventKind::Torrent { + if let EngineEventKind::Torrent { torrent, event: crate::frontend::TorrentEventKind::PeerConnected(peer), } = event.kind diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index 04713dfd..5dc6f93a 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -19,12 +19,14 @@ pub use crate::{ engine::{Engine, EngineSnapshot, EngineStatus, TorrentSource}, frontend::{ - ByteCount, BytesPerSecond, ContentProgress, CoreEvent, CoreEventKind, DEFAULT_EVENT_CAPACITY, - EngineListener, EngineView, EventListener, EventStreamError, EventSubscription, - FrontendHealth, FrontendHealthLevel, HasTransferMetrics, LivePublisher, PeerEvent, - PeerEventKind, PeerHandle, PeerListener, PeerView, Seconds, Sequenced, TorrentEvent, - TorrentEventKind, TorrentListener, TorrentMetrics, TorrentView, TrackerEvent, - TrackerEventKind, TrackerHandle, TrackerId, TrackerListener, TrackerStatus, TrackerView, + EngineEvent, EngineEventKind, EngineListener, EngineView, EventListener, EventStreamError, + EventSubscription, FrontendHealth, FrontendHealthLevel, LivePublisher, PeerEvent, + PeerEventKind, PeerHandle, PeerListener, PeerView, SequencedEvent, TorrentEvent, + TorrentEventKind, TorrentListener, TorrentView, TrackerEvent, TrackerEventKind, + TrackerHandle, TrackerId, TrackerListener, TrackerStatus, TrackerView, + }, + metrics::{ + ByteCount, BytesPerSecond, ContentProgress, HasTransferMetrics, Seconds, TorrentMetrics, TrafficTotals, TransferMetrics, TransferRates, }, torrent::{RestoreVerification, Torrent, TorrentSnapshot}, diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index 71b2f75e..4ff33db1 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; -use super::{EngineView, PeerHandle, TorrentMetrics, TrackerHandle, TransferMetrics}; +use super::{EngineView, PeerHandle, TrackerHandle}; use crate::{ hashes::InfoHash, + metrics::{TorrentMetrics, TransferMetrics}, torrent::{Torrent, TorrentState}, }; @@ -12,7 +13,7 @@ use crate::{ /// event it emits. A frontend can use them to preserve scoped event order or /// detect a gap after reconnecting a consumer. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Sequenced { +pub struct SequencedEvent { /// Publisher-local sequence number for this event. pub sequence: u64, /// The typed change represented by this event. @@ -20,15 +21,15 @@ pub struct Sequenced { } /// A sequenced event emitted by the engine's frontend publisher. -pub type CoreEvent = Sequenced; +pub type EngineEvent = SequencedEvent; /// A sequenced event emitted by a torrent's live publisher. -pub type TorrentEvent = Sequenced; +pub type TorrentEvent = SequencedEvent; /// A sequenced event emitted by a peer's live publisher. -pub type PeerEvent = Sequenced; +pub type PeerEvent = SequencedEvent; /// A sequenced event emitted by a tracker's live publisher. -pub type TrackerEvent = Sequenced; +pub type TrackerEvent = SequencedEvent; -impl Sequenced { +impl SequencedEvent { /// Returns the torrent associated with this event, when applicable. #[must_use] pub fn torrent(&self) -> Option { @@ -39,7 +40,7 @@ impl Sequenced { /// Typed changes a frontend can react to without actor internals or polling. #[derive(Debug, Clone)] #[non_exhaustive] -pub enum CoreEventKind { +pub enum EngineEventKind { /// The engine finished starting and is ready for operations. EngineStarted(EngineView), /// A change emitted by one managed torrent. @@ -98,7 +99,7 @@ pub enum TrackerEventKind { Stopped, } -impl CoreEventKind { +impl EngineEventKind { /// Returns the torrent associated with this event, when applicable. #[must_use] pub fn torrent(&self) -> Option { diff --git a/crates/libtortillas/src/frontend/handle.rs b/crates/libtortillas/src/frontend/handle.rs new file mode 100644 index 00000000..036cf52d --- /dev/null +++ b/crates/libtortillas/src/frontend/handle.rs @@ -0,0 +1,352 @@ +//! 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, FrontendHub, FrontendHubInner, LivePublisher, PeerEventKind, + PeerView, TrackerEventKind, TrackerStatus, TrackerView, +}; +use crate::{hashes::InfoHash, peer::PeerId}; + +/// Shared live state behind an identity-bearing protocol handle. +pub(crate) struct LiveScope { + pub(crate) identity: I, + hub: Weak, + pub(crate) live: 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, + live: LivePublisher::new(view, event_capacity), + } + } + + fn subscribe(&self) -> EventSubscription { + self.live.subscribe() + } + + fn listener(&self) -> EventListener { + self.live.listener() + } + + fn view(&self) -> V { + self.live.view() + } + + fn frontend(&self) -> Option { + self.hub.upgrade().map(FrontendHub::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 live frontend access 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 + .live + .replace_view_and_emit(view, PeerEventKind::StateChanged); + } + + pub(crate) fn publish_metrics(&self, view: PeerView) { + let metrics = view.transfer; + let _ = self + .inner + .live + .replace_view_and_emit(view, PeerEventKind::MetricsChanged(metrics)); + } + + pub(crate) fn disconnected(&self) { + let mut view = self.view(); + view.connected = false; + if self + .inner + .live + .close_with_terminal_event(view, PeerEventKind::Disconnected) + && let Some(frontend) = self.inner.frontend() + { + frontend.mark_peer_disconnected(self); + } + } + + pub(crate) fn close_without_parent_event(&self) { + let mut view = self.view(); + view.connected = false; + let _ = self + .inner + .live + .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 live frontend access 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 announce_succeeded(&self, peers_returned: u64) { + let mut view = self.view(); + view.status = TrackerStatus::Healthy; + view.peers_returned = Some(peers_returned); + let event = TrackerEventKind::AnnounceSucceeded { peers_returned }; + if self.inner.live.replace_view_and_emit(view, event) + && let Some(frontend) = self.inner.frontend() + { + frontend.emit_tracker_event(self, event); + } + } + + pub(crate) fn announce_failed(&self) { + let mut view = self.view(); + view.status = TrackerStatus::Degraded; + view.peers_returned = None; + if self + .inner + .live + .replace_view_and_emit(view, TrackerEventKind::AnnounceFailed) + && let Some(frontend) = self.inner.frontend() + { + frontend.emit_tracker_event(self, TrackerEventKind::AnnounceFailed); + } + } + + pub(crate) fn restarting(&self) { + let mut view = self.view(); + view.status = TrackerStatus::Restarting; + if self + .inner + .live + .replace_view_and_emit(view, TrackerEventKind::Restarting) + && let Some(frontend) = self.inner.frontend() + { + frontend.emit_tracker_event(self, TrackerEventKind::Restarting); + } + } + + pub(crate) fn stopped(&self) { + let mut view = self.view(); + view.status = TrackerStatus::Stopped; + if self + .inner + .live + .close_with_terminal_event(view, TrackerEventKind::Stopped) + && let Some(frontend) = self.inner.frontend() + { + frontend.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 + .live + .close_with_terminal_event(view, TrackerEventKind::Stopped); + } +} + +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; diff --git a/crates/libtortillas/src/frontend/handle/mod.rs b/crates/libtortillas/src/frontend/handle/mod.rs deleted file mode 100644 index ed046b7d..00000000 --- a/crates/libtortillas/src/frontend/handle/mod.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::{fmt, sync::Weak}; - -use super::{EventListener, EventSubscription, FrontendHub, FrontendPublisher, LivePublisher}; - -mod peer; -mod tracker; - -pub(crate) use peer::PeerScope; -pub use peer::{PeerHandle, PeerListener}; -pub(crate) use tracker::TrackerScope; -pub use tracker::{TrackerHandle, TrackerId, TrackerListener}; - -/// Shared guard-free storage for identity-bearing live protocol handles. -pub(crate) struct LiveHandle { - pub(crate) identity: I, - hub: Weak, - pub(crate) live: LivePublisher, -} - -impl LiveHandle -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, - live: LivePublisher::new(view, event_capacity), - } - } - - fn subscribe(&self) -> EventSubscription { - self.live.subscribe() - } - - fn listener(&self) -> EventListener { - self.live.listener() - } - - fn view(&self) -> V { - self.live.view() - } - - fn replace_view_and_emit(&self, view: V, event: E) -> bool { - self.live.update(view, event) - } - - fn close_with_terminal_event(&self, view: V, event: E) -> bool { - self.live.close(view, event) - } - - fn frontend(&self) -> Option { - self.hub.upgrade().map(FrontendPublisher::from_hub) - } -} - -impl fmt::Debug for LiveHandle { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("LiveHandle") - .field("identity", &self.identity) - .finish_non_exhaustive() - } -} diff --git a/crates/libtortillas/src/frontend/handle/peer.rs b/crates/libtortillas/src/frontend/handle/peer.rs deleted file mode 100644 index 4bdc667b..00000000 --- a/crates/libtortillas/src/frontend/handle/peer.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::{ - fmt, - net::SocketAddr, - sync::{Arc, Weak}, -}; - -use super::LiveHandle; -use crate::{ - frontend::{EventListener, EventSubscription, FrontendHub, PeerEventKind, PeerView}, - hashes::InfoHash, - peer::PeerId, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) struct PeerScope { - pub(crate) torrent: InfoHash, - pub(crate) peer: PeerId, -} - -/// Public identity and live frontend access for one connected peer. -#[derive(Clone)] -pub struct PeerHandle { - pub(crate) inner: Arc>, -} - -impl PeerHandle { - pub(crate) fn new( - scope: PeerScope, view: PeerView, hub: Weak, event_capacity: usize, - ) -> Self { - Self { - inner: Arc::new(LiveHandle::new(scope, 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 scope(&self) -> PeerScope { - self.inner.identity - } - - pub(crate) fn publish_state(&self, view: PeerView) { - let _ = self - .inner - .replace_view_and_emit(view, PeerEventKind::StateChanged); - } - - pub(crate) fn publish_metrics(&self, view: PeerView) { - let metrics = view.transfer; - let _ = self - .inner - .replace_view_and_emit(view, PeerEventKind::MetricsChanged(metrics)); - } - - pub(crate) fn disconnected(&self) { - let mut view = self.view(); - view.connected = false; - if self - .inner - .close_with_terminal_event(view, PeerEventKind::Disconnected) - && let Some(frontend) = self.inner.frontend() - { - frontend.mark_peer_disconnected(self); - } - } - - pub(crate) fn close_without_parent_event(&self) { - let mut view = self.view(); - view.connected = false; - let _ = self - .inner - .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.scope() == other.scope() - } -} - -impl Eq for PeerHandle {} - -pub type PeerListener = EventListener; diff --git a/crates/libtortillas/src/frontend/handle/tracker.rs b/crates/libtortillas/src/frontend/handle/tracker.rs deleted file mode 100644 index 6ef7e1bf..00000000 --- a/crates/libtortillas/src/frontend/handle/tracker.rs +++ /dev/null @@ -1,180 +0,0 @@ -use std::{ - fmt, - sync::{Arc, Weak}, -}; - -use serde::{Deserialize, Serialize}; - -use super::LiveHandle; -use crate::{ - frontend::{ - EventListener, EventSubscription, FrontendHub, TrackerEventKind, TrackerStatus, TrackerView, - }, - hashes::InfoHash, -}; - -#[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 TrackerScope { - pub(crate) torrent: InfoHash, - pub(crate) id: TrackerId, -} - -/// Public identity and live frontend access for one tracker. -#[derive(Clone)] -pub struct TrackerHandle { - pub(crate) inner: Arc>, -} - -impl TrackerHandle { - pub(crate) fn new( - scope: TrackerScope, view: TrackerView, hub: Weak, event_capacity: usize, - ) -> Self { - Self { - inner: Arc::new(LiveHandle::new(scope, 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 scope(&self) -> TrackerScope { - self.inner.identity - } - - pub(crate) fn announce_succeeded(&self, peers_returned: u64) { - let mut view = self.view(); - view.status = TrackerStatus::Healthy; - view.peers_returned = Some(peers_returned); - let event = TrackerEventKind::AnnounceSucceeded { peers_returned }; - if self.inner.replace_view_and_emit(view, event) - && let Some(frontend) = self.inner.frontend() - { - frontend.emit_tracker_event(self, event); - } - } - - pub(crate) fn announce_failed(&self) { - let mut view = self.view(); - view.status = TrackerStatus::Degraded; - view.peers_returned = None; - if self - .inner - .replace_view_and_emit(view, TrackerEventKind::AnnounceFailed) - && let Some(frontend) = self.inner.frontend() - { - frontend.emit_tracker_event(self, TrackerEventKind::AnnounceFailed); - } - } - - pub(crate) fn restarting(&self) { - let mut view = self.view(); - view.status = TrackerStatus::Restarting; - if self - .inner - .replace_view_and_emit(view, TrackerEventKind::Restarting) - && let Some(frontend) = self.inner.frontend() - { - frontend.emit_tracker_event(self, TrackerEventKind::Restarting); - } - } - - pub(crate) fn stopped(&self) { - let mut view = self.view(); - view.status = TrackerStatus::Stopped; - if self - .inner - .close_with_terminal_event(view, TrackerEventKind::Stopped) - && let Some(frontend) = self.inner.frontend() - { - frontend.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 - .close_with_terminal_event(view, TrackerEventKind::Stopped); - } -} - -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.scope() == other.scope() - } -} - -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; diff --git a/crates/libtortillas/src/frontend/hub.rs b/crates/libtortillas/src/frontend/hub.rs new file mode 100644 index 00000000..1a52fb22 --- /dev/null +++ b/crates/libtortillas/src/frontend/hub.rs @@ -0,0 +1,567 @@ +//! 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, FrontendHealth, FrontendHealthLevel, + LivePublisher, PeerEventKind, PeerHandle, PeerView, TorrentEventKind, TorrentView, + TrackerEventKind, TrackerHandle, TrackerView, + handle::{LiveScope, PeerIdentity, TrackerId, TrackerIdentity}, +}; +use crate::{ + engine::EngineStatus, + hashes::InfoHash, + peer::PeerId, + settings::FrontendSettings, + 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 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 { + live: LivePublisher, +} + +/// One self-contained torrent projection tree. +#[derive(Debug)] +pub(crate) struct TorrentScope { + pub(crate) info_hash: InfoHash, + pub(crate) live: 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, + live: 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 FrontendHubInner { + engine: EngineScope, + torrents: ScopeRegistry, + settings: FrontendSettings, + next_tracker_id: AtomicU64, +} + +impl FrontendHubInner { + 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), +} + +/// 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 FrontendHub { + inner: HubReference, +} + +impl FrontendHub { + // Engine projection + + pub(crate) fn new() -> Self { + Self::with_settings(FrontendSettings::default()) + } + + pub(crate) fn with_settings(settings: FrontendSettings) -> Self { + Self { + inner: HubReference::Strong(Arc::new(FrontendHubInner { + engine: EngineScope { + live: 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) -> Arc { + match &self.inner { + HubReference::Strong(inner) => Arc::clone(inner), + HubReference::Weak(inner) => inner + .upgrade() + .expect("frontend hub outlived by its actor hierarchy"), + } + } + + pub(crate) fn subscribe(&self) -> EventSubscription { + self.inner().engine.live.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 inner = self.inner(); + let mut torrents = inner + .torrents + .values() + .into_iter() + .filter(|scope| scope.is_registered()) + .filter_map(|scope| scope.live.view()) + .collect::>(); + torrents.sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); + EngineView { + status: inner.engine.live.view(), + torrents, + } + } + + pub(crate) fn engine_started(&self) { + let inner = self.inner(); + let _ = inner.engine.live.replace_view(EngineStatus::Running); + let _ = inner + .engine + .live + .emit_without_view_change(EngineEventKind::EngineStarted(self.view())); + } + + pub(crate) fn engine_stopping(&self) { + let _ = self + .inner() + .engine + .live + .replace_view(EngineStatus::Stopping); + } + + pub(crate) fn engine_stopped(&self) { + let mut view = self.view(); + view.status = EngineStatus::Stopped; + let _ = self + .inner() + .engine + .live + .close_with_terminal_event(EngineStatus::Stopped, EngineEventKind::Shutdown(view)); + } + + // Torrent scopes + + pub(crate) fn ensure_torrent_scope(&self, info_hash: InfoHash) -> Arc { + let inner = self.inner(); + 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.live.view()) + } + + pub(crate) fn initialize_torrent_projection(&self, torrent: TorrentView) { + let scope = self.ensure_torrent_scope(torrent.info_hash); + let _ = scope.live.replace_view(Some(torrent)); + } + + pub(crate) fn register_torrent_scope(&self, torrent: Torrent) { + let info_hash = torrent.info_hash(); + let scope = self.ensure_torrent_scope(info_hash); + if !scope.register(&torrent) { + return; + } + if let Some(view) = scope.live.view() { + self.replace_torrent_view_and_emit(view, TorrentEventKind::Added); + } + } + + pub(crate) fn replace_torrent_view_and_emit( + &self, torrent: TorrentView, event: TorrentEventKind, + ) { + let info_hash = torrent.info_hash; + let Some(scope) = self.inner().torrents.get(&info_hash) else { + return; + }; + let Some(handle) = self.torrent_handle(info_hash) else { + return; + }; + let _publication = scope.publication_lock(); + if !scope + .live + .replace_view_and_emit(Some(torrent), event.clone()) + { + return; + } + let _ = self + .inner() + .engine + .live + .emit_without_view_change(EngineEventKind::Torrent { + torrent: handle, + event, + }); + } + + pub(crate) fn emit_health( + &self, torrent: Option, level: FrontendHealthLevel, message: impl Into, + ) { + let health = FrontendHealth { + torrent, + level, + message: message.into(), + }; + if let Some(info_hash) = torrent + && let Some(scope) = self.inner().torrents.get(&info_hash) + { + self.emit_without_torrent_view_change(&scope, TorrentEventKind::Health(health)); + } else { + let _ = self + .inner() + .engine + .live + .emit_without_view_change(EngineEventKind::Health(health)); + } + } + + pub(crate) fn remove_torrent_scope(&self, info_hash: InfoHash) { + let Some(scope) = self.inner().torrents.get(&info_hash) else { + return; + }; + let torrent = self.torrent_handle(info_hash); + 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 + .live + .close_with_terminal_event(None, TorrentEventKind::Removed) + { + return; + } + drop(publication); + self.inner().torrents.remove(&info_hash); + if let Some(torrent) = torrent { + let _ = self + .inner() + .engine + .live + .emit_without_view_change(EngineEventKind::Torrent { + torrent, + event: TorrentEventKind::Removed, + }); + } + } + + fn emit_without_torrent_view_change(&self, scope: &TorrentScope, event: TorrentEventKind) { + let Some(torrent) = self.torrent_handle(scope.info_hash) else { + return; + }; + let _publication = scope.publication_lock(); + if !scope.live.emit_without_view_change(event.clone()) { + return; + } + let _ = self + .inner() + .engine + .live + .emit_without_view_change(EngineEventKind::Torrent { torrent, event }); + } + + // Peer scopes + + pub(crate) fn peer_handles(&self, torrent: InfoHash) -> Vec { + self + .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) -> PeerHandle { + let inner = self.inner(); + let scope = self.ensure_torrent_scope(identity.torrent); + let peer = PeerHandle::new( + identity, + view, + self.downgrade(), + inner.settings.peer_event_capacity, + ); + scope.peers.insert(identity.peer, &peer.inner); + peer + } + + pub(crate) fn emit_peer_connected(&self, peer: &PeerHandle) { + let Some(scope) = self.inner().torrents.get(&peer.torrent()) else { + return; + }; + if scope.peers.get(&peer.id()).is_some() { + self.emit_without_torrent_view_change( + &scope, + TorrentEventKind::PeerConnected(peer.clone()), + ); + } + } + + pub(crate) fn mark_peer_disconnected(&self, peer: &PeerHandle) { + let Some(scope) = self.inner().torrents.get(&peer.torrent()) else { + return; + }; + if scope.peers.remove(&peer.id()).is_none() { + return; + } + self.emit_without_torrent_view_change( + &scope, + TorrentEventKind::PeerDisconnected(peer.clone()), + ); + } + + pub(crate) fn close_peer_scopes_for_torrent_restart(&self, torrent: InfoHash) { + let Some(scope) = self.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 { + self + .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, + ) -> TrackerHandle { + let inner = self.inner(); + let torrent_scope = self.ensure_torrent_scope(torrent); + if let Some(inner) = torrent_scope.trackers.get(source) { + return TrackerHandle { inner }; + } + 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); + tracker + } + + pub(crate) fn emit_tracker_event(&self, tracker: &TrackerHandle, event: TrackerEventKind) { + let Some(scope) = self.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(&scope, torrent_event); + } +} + +impl Default for FrontendHub { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/libtortillas/src/frontend/hub/engine.rs b/crates/libtortillas/src/frontend/hub/engine.rs deleted file mode 100644 index 09e0237f..00000000 --- a/crates/libtortillas/src/frontend/hub/engine.rs +++ /dev/null @@ -1,49 +0,0 @@ -use super::super::{CoreEventKind, EngineView, EventSubscription, FrontendPublisher}; -use crate::engine::EngineStatus; - -impl FrontendPublisher { - pub(crate) fn subscribe(&self) -> EventSubscription { - self.hub().engine.live.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 hub = self.hub(); - let mut torrents = hub - .torrents - .values() - .into_iter() - .filter(|scope| scope.is_registered()) - .filter_map(|scope| scope.live.view()) - .collect::>(); - torrents.sort_by(|left, right| left.info_hash.as_bytes().cmp(right.info_hash.as_bytes())); - EngineView { - status: hub.engine.live.view(), - torrents, - } - } - - pub(crate) fn engine_started(&self) { - let hub = self.hub(); - let _ = hub.engine.live.set_view(EngineStatus::Running); - let _ = hub - .engine - .live - .publish(CoreEventKind::EngineStarted(self.view())); - } - - pub(crate) fn engine_stopping(&self) { - let _ = self.hub().engine.live.set_view(EngineStatus::Stopping); - } - - pub(crate) fn engine_stopped(&self) { - let mut view = self.view(); - view.status = EngineStatus::Stopped; - let _ = self - .hub() - .engine - .live - .close(EngineStatus::Stopped, CoreEventKind::Shutdown(view)); - } -} diff --git a/crates/libtortillas/src/frontend/hub/mod.rs b/crates/libtortillas/src/frontend/hub/mod.rs deleted file mode 100644 index 8e226d18..00000000 --- a/crates/libtortillas/src/frontend/hub/mod.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::sync::{ - Mutex, MutexGuard, - atomic::{AtomicBool, AtomicU64, Ordering}, -}; - -use super::{ - CoreEventKind, LivePublisher, PeerEventKind, PeerView, TorrentEventKind, TorrentView, - TrackerEventKind, TrackerView, - handle::{LiveHandle, PeerScope, TrackerId, TrackerScope}, - registry::ScopeRegistry, -}; -use crate::{ - engine::EngineStatus, - hashes::InfoHash, - peer::PeerId, - settings::FrontendSettings, - torrent::{Torrent, TorrentInner}, - tracker::Tracker, -}; - -mod engine; -mod peer; -mod torrent; -mod tracker; - -#[derive(Debug)] -pub(crate) struct EngineScope { - pub(crate) live: LivePublisher, -} - -/// One self-contained torrent projection tree. -#[derive(Debug)] -pub(crate) struct TorrentScope { - pub(crate) info_hash: InfoHash, - pub(crate) live: LivePublisher, TorrentEventKind>, - pub(crate) peers: ScopeRegistry>, - pub(crate) trackers: - ScopeRegistry>, - pub(crate) tracker_sources: - ScopeRegistry>, - registered: AtomicBool, - publication: Mutex<()>, -} - -impl TorrentScope { - pub(crate) fn new(info_hash: InfoHash, event_capacity: usize) -> Self { - Self { - info_hash, - live: LivePublisher::new(None, event_capacity), - peers: ScopeRegistry::new(), - trackers: ScopeRegistry::new(), - tracker_sources: ScopeRegistry::new(), - registered: AtomicBool::new(false), - publication: Mutex::new(()), - } - } - - pub(crate) fn register(&self) { - self.registered.store(true, Ordering::Release); - } - - pub(crate) fn is_registered(&self) -> bool { - self.registered.load(Ordering::Acquire) - } - - pub(crate) 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 FrontendHub { - pub(crate) engine: EngineScope, - pub(crate) torrents: ScopeRegistry, - pub(crate) handles: ScopeRegistry, - pub(crate) settings: FrontendSettings, - pub(crate) next_tracker_id: AtomicU64, -} - -impl FrontendHub { - pub(crate) fn torrent_handle(&self, info_hash: InfoHash) -> Option { - self.handles.get(&info_hash).map(|inner| Torrent { inner }) - } -} diff --git a/crates/libtortillas/src/frontend/hub/peer.rs b/crates/libtortillas/src/frontend/hub/peer.rs deleted file mode 100644 index a9772d38..00000000 --- a/crates/libtortillas/src/frontend/hub/peer.rs +++ /dev/null @@ -1,67 +0,0 @@ -use super::super::{FrontendPublisher, PeerHandle, PeerView, TorrentEventKind, handle::PeerScope}; -use crate::hashes::InfoHash; - -impl FrontendPublisher { - pub(crate) fn peer_handles(&self, torrent: InfoHash) -> Vec { - self - .hub() - .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: PeerScope, view: PeerView) -> PeerHandle { - let hub = self.hub(); - let scope = self.ensure_torrent_scope(identity.torrent); - let peer = PeerHandle::new( - identity, - view, - self.downgrade(), - hub.settings.peer_event_capacity, - ); - scope.peers.insert(identity.peer, &peer.inner); - peer - } - - pub(crate) fn emit_peer_connected(&self, peer: &PeerHandle) { - let Some(scope) = self.hub().torrents.get(&peer.torrent()) else { - return; - }; - if scope.peers.get(&peer.id()).is_some() { - self.emit_without_torrent_view_change( - &scope, - TorrentEventKind::PeerConnected(peer.clone()), - ); - } - } - - pub(crate) fn mark_peer_disconnected(&self, peer: &PeerHandle) { - let Some(scope) = self.hub().torrents.get(&peer.torrent()) else { - return; - }; - if scope.peers.remove(&peer.id()).is_none() { - return; - } - self.emit_without_torrent_view_change( - &scope, - TorrentEventKind::PeerDisconnected(peer.clone()), - ); - } - - pub(crate) fn close_peer_scopes_for_torrent_restart(&self, torrent: InfoHash) { - let Some(scope) = self.hub().torrents.get(&torrent) else { - return; - }; - for inner in scope.peers.remove_all() { - PeerHandle { inner }.close_without_parent_event(); - } - } -} diff --git a/crates/libtortillas/src/frontend/hub/torrent.rs b/crates/libtortillas/src/frontend/hub/torrent.rs deleted file mode 100644 index d190774f..00000000 --- a/crates/libtortillas/src/frontend/hub/torrent.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::sync::Arc; - -use super::super::{ - CoreEventKind, FrontendHealth, FrontendHealthLevel, FrontendPublisher, PeerHandle, - TorrentEventKind, TorrentScope, TorrentView, TrackerHandle, -}; -use crate::{hashes::InfoHash, torrent::Torrent}; - -impl FrontendPublisher { - pub(crate) fn ensure_torrent_scope(&self, info_hash: InfoHash) -> Arc { - let hub = self.hub(); - hub.torrents.get_or_insert_with(info_hash, || { - TorrentScope::new(info_hash, hub.settings.torrent_event_capacity) - }) - } - - pub(crate) fn torrent_handle(&self, torrent: InfoHash) -> Option { - self.hub().torrent_handle(torrent) - } - - #[cfg(test)] - pub(crate) fn torrent_view(&self, torrent: InfoHash) -> Option { - self - .hub() - .torrents - .get(&torrent) - .and_then(|scope| scope.live.view()) - } - - pub(crate) fn initialize_torrent_projection(&self, torrent: TorrentView) { - let scope = self.ensure_torrent_scope(torrent.info_hash); - let _ = scope.live.set_view(Some(torrent)); - } - - pub(crate) fn register_torrent_scope(&self, torrent: Torrent) { - let info_hash = torrent.info_hash(); - let scope = self.ensure_torrent_scope(info_hash); - self.hub().handles.insert(info_hash, &torrent.inner); - scope.register(); - if let Some(view) = scope.live.view() { - self.replace_torrent_view_and_emit(view, TorrentEventKind::Added); - } - } - - pub(crate) fn replace_torrent_view_and_emit( - &self, torrent: TorrentView, event: TorrentEventKind, - ) { - let info_hash = torrent.info_hash; - let Some(scope) = self.hub().torrents.get(&info_hash) else { - return; - }; - let Some(handle) = self.torrent_handle(info_hash) else { - return; - }; - let _publication = scope.publication_lock(); - if !scope.live.update(Some(torrent), event.clone()) { - return; - } - let _ = self.hub().engine.live.publish(CoreEventKind::Torrent { - torrent: handle, - event, - }); - } - - pub(crate) fn emit_health( - &self, torrent: Option, level: FrontendHealthLevel, message: impl Into, - ) { - let health = FrontendHealth { - torrent, - level, - message: message.into(), - }; - if let Some(info_hash) = torrent - && let Some(scope) = self.hub().torrents.get(&info_hash) - { - self.emit_without_torrent_view_change(&scope, TorrentEventKind::Health(health)); - } else { - let _ = self - .hub() - .engine - .live - .publish(CoreEventKind::Health(health)); - } - } - - pub(crate) fn remove_torrent_scope(&self, info_hash: InfoHash) { - let Some(scope) = self.hub().torrents.get(&info_hash) else { - return; - }; - let torrent = self.torrent_handle(info_hash); - 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.live.close(None, TorrentEventKind::Removed) { - return; - } - drop(publication); - self.hub().torrents.remove(&info_hash); - self.hub().handles.remove(&info_hash); - if let Some(torrent) = torrent { - let _ = self.hub().engine.live.publish(CoreEventKind::Torrent { - torrent, - event: TorrentEventKind::Removed, - }); - } - } - - pub(super) fn emit_without_torrent_view_change( - &self, scope: &TorrentScope, event: TorrentEventKind, - ) { - let Some(torrent) = self.torrent_handle(scope.info_hash) else { - return; - }; - let _publication = scope.publication_lock(); - if !scope.live.publish(event.clone()) { - return; - } - let _ = self - .hub() - .engine - .live - .publish(CoreEventKind::Torrent { torrent, event }); - } -} diff --git a/crates/libtortillas/src/frontend/hub/tracker.rs b/crates/libtortillas/src/frontend/hub/tracker.rs deleted file mode 100644 index c0fd73c5..00000000 --- a/crates/libtortillas/src/frontend/hub/tracker.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::sync::atomic::Ordering; - -use super::super::{ - FrontendPublisher, TorrentEventKind, TrackerEventKind, TrackerHandle, TrackerView, - handle::{TrackerId, TrackerScope}, -}; -use crate::{hashes::InfoHash, tracker::Tracker}; - -impl FrontendPublisher { - pub(crate) fn tracker_handles(&self, torrent: InfoHash) -> Vec { - self - .hub() - .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, - ) -> TrackerHandle { - let hub = self.hub(); - let torrent_scope = self.ensure_torrent_scope(torrent); - if let Some(inner) = torrent_scope.tracker_sources.get(source) { - return TrackerHandle { inner }; - } - let id = TrackerId::new(hub.next_tracker_id.fetch_add(1, Ordering::Relaxed)); - let identity = TrackerScope { torrent, id }; - let tracker = TrackerHandle::new( - identity, - view, - self.downgrade(), - hub.settings.tracker_event_capacity, - ); - torrent_scope.trackers.insert(id, &tracker.inner); - torrent_scope - .tracker_sources - .insert(source.clone(), &tracker.inner); - tracker - } - - pub(crate) fn emit_tracker_event(&self, tracker: &TrackerHandle, event: TrackerEventKind) { - let Some(scope) = self.hub().torrents.get(&tracker.torrent()) else { - return; - }; - if scope.trackers.get(&tracker.id()).is_none() { - 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(&scope, torrent_event); - } -} diff --git a/crates/libtortillas/src/frontend/listener.rs b/crates/libtortillas/src/frontend/listener.rs deleted file mode 100644 index a4ffceea..00000000 --- a/crates/libtortillas/src/frontend/listener.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::{ - fmt, - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; - -use futures::Stream; - -use super::{ - CoreEventKind, EngineView, EventStreamError, EventSubscription, Sequenced, TorrentEventKind, - TorrentView, -}; - -/// 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() - } -} - -/// Live engine listener with typed events and current presentation state. -pub type EngineListener = EventListener; - -/// Live listener scoped to one torrent. -pub type TorrentListener = EventListener, TorrentEventKind>; diff --git a/crates/libtortillas/src/frontend/live.rs b/crates/libtortillas/src/frontend/live.rs deleted file mode 100644 index 02e99190..00000000 --- a/crates/libtortillas/src/frontend/live.rs +++ /dev/null @@ -1,217 +0,0 @@ -use std::sync::{Arc, Mutex, MutexGuard}; - -use tokio::sync::broadcast; - -use super::{EventListener, EventSubscription, Sequenced}; - -/// Number of discrete frontend events retained by each live publisher. -pub const DEFAULT_EVENT_CAPACITY: usize = 256; - -fn mutex_lock(lock: &Mutex) -> MutexGuard<'_, T> { - lock - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) -} - -/// Generic current-state and event publisher for live application APIs. -/// -/// 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, -{ - /// Creates a publisher with an initial view and bounded event capacity. - /// - /// A zero capacity is normalized to one so configuration mistakes cannot - /// panic a public operation. - #[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), - }), - } - } - - /// Subscribes to all future events from this publisher. - #[must_use] - pub fn subscribe(&self) -> EventSubscription { - let state = mutex_lock(&self.state); - if state.closed { - return { - let (sender, receiver) = broadcast::channel(1); - let weak = sender.downgrade(); - drop(sender); - EventSubscription::from_receiver(receiver, weak) - }; - } - 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::>()) - } - - /// Creates a stream-compatible listener paired with the current view. - #[must_use] - pub fn listener(&self) -> EventListener { - let state = Arc::clone(&self.state); - EventListener::new(self.subscribe(), move || mutex_lock(&state).view.clone()) - } - - /// Clones the latest coherent view. - #[must_use] - pub fn view(&self) -> V { - mutex_lock(&self.state).view.clone() - } - - /// Replaces the current view without emitting an event. - /// - /// Returns `false` when the publisher has already closed. - pub fn set_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 fn update(&self, view: V, event: E) -> bool { - self.mutate(|current| *current = view, event) - } - - /// Emits an event using this publisher's monotonic sequence. - /// - /// Returns `false` when the publisher has already closed. - pub fn publish(&self, kind: E) -> bool { - self.mutate(|_| {}, kind) - } - - /// Atomically updates the view and permanently closes this publisher after - /// delivering one terminal event. - /// - /// Returns `false` if another caller already closed the publisher. - pub fn close(&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(Sequenced { - sequence: state.sequence, - kind: event, - }); - } - true - } - - fn mutate(&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(&state, event); - true - } - - fn send(&self, state: &LiveState, event: E) { - if let Some(sender) = mutex_lock(&self.channel.sender).as_ref() { - let _ = sender.send(Sequenced { - sequence: state.sequence, - kind: event, - }); - } - } -} - -#[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 live = Arc::new(LivePublisher::new(0_u64, 8)); - let update = Arc::clone(&live); - let close = Arc::clone(&live); - let update_thread = thread::spawn(move || update.update(1, "updated")); - let close_thread = thread::spawn(move || close.close(2, "closed")); - let update_accepted = update_thread.join().unwrap(); - let close_accepted = close_thread.join().unwrap(); - - assert!(close_accepted); - assert!(!live.update(3, "late")); - assert_eq!(live.view(), 2); - if update_accepted { - assert_eq!(live.view(), 2); - } - } - } - - #[test] - fn zero_capacity_is_normalized_without_panicking() { - let publisher = LivePublisher::new(0_u8, 0); - let _subscription = publisher.subscribe(); - assert!(publisher.publish("event")); - } -} diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index d0782c6f..373e6320 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -1,34 +1,42 @@ -//! Live, frontend-facing API contracts. +//! Transport-agnostic live application API. //! -//! This module contains typed events, listeners, publishers, and live views -//! intended for application and UI integrations. Frontends should prefer these -//! types over actor messages, protocol internals, or snapshot polling. +//! The module is intentionally organized by the way a consumer reads it: +//! +//! - [`EngineView`], [`TorrentView`], [`PeerView`], and [`TrackerView`] are +//! current presentation state. +//! - Shared measurements live in [`crate::metrics`] and are re-exported here. +//! - Event enums describe discrete changes. +//! - [`EventSubscription`] is events only; [`EventListener`] pairs events with +//! a current view. +//! - [`PeerHandle`] and [`TrackerHandle`] provide scoped identity and access. +//! - `hub` is the single internal ownership and publication coordinator. +//! +//! Terminal interfaces, HTTP/WebSocket servers, web backends, and desktop +//! applications all consume this same API. Rendering, transport, and input +//! policy remain outside `libtortillas`. mod event; mod handle; mod hub; -mod listener; -mod live; -mod metrics; -mod publisher; -mod registry; -mod subscription; +mod stream; +#[cfg(test)] +mod tests; mod view; pub use event::{ - CoreEvent, CoreEventKind, FrontendHealth, FrontendHealthLevel, PeerEvent, PeerEventKind, - Sequenced, TorrentEvent, TorrentEventKind, TrackerEvent, TrackerEventKind, + EngineEvent, EngineEventKind, FrontendHealth, FrontendHealthLevel, PeerEvent, PeerEventKind, + SequencedEvent, TorrentEvent, TorrentEventKind, TrackerEvent, TrackerEventKind, }; -pub(crate) use handle::PeerScope; +pub(crate) use handle::PeerIdentity; pub use handle::{PeerHandle, PeerListener, TrackerHandle, TrackerId, TrackerListener}; -pub(crate) use hub::{FrontendHub, TorrentScope}; -pub use listener::{EngineListener, EventListener, TorrentListener}; -pub use live::{DEFAULT_EVENT_CAPACITY, LivePublisher}; -pub(crate) use metrics::TransferSample; -pub use metrics::{ +pub(crate) use hub::{FrontendHub, FrontendHubInner}; +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, Seconds, TorrentMetrics, TrafficTotals, TransferMetrics, TransferRates, }; -pub(crate) use publisher::FrontendPublisher; -pub use subscription::{EventStreamError, EventSubscription}; -pub use view::{EngineView, PeerView, TorrentView, TrackerStatus, TrackerView}; diff --git a/crates/libtortillas/src/frontend/publisher.rs b/crates/libtortillas/src/frontend/publisher.rs deleted file mode 100644 index 43f2fe4a..00000000 --- a/crates/libtortillas/src/frontend/publisher.rs +++ /dev/null @@ -1,436 +0,0 @@ -use std::sync::{Arc, Weak, atomic::AtomicU64}; - -use super::{ - LivePublisher, - hub::{EngineScope, FrontendHub}, - registry::ScopeRegistry, -}; -use crate::{engine::EngineStatus, settings::FrontendSettings}; - -#[derive(Debug, Clone)] -enum HubReference { - Strong(Arc), - Weak(Weak), -} - -/// Cloneable access to one frontend hub. -#[derive(Debug, Clone)] -pub(crate) struct FrontendPublisher { - hub: HubReference, -} - -impl FrontendPublisher { - pub(crate) fn new() -> Self { - Self::with_settings(FrontendSettings::default()) - } - - pub(crate) fn with_settings(settings: FrontendSettings) -> Self { - Self { - hub: HubReference::Strong(Arc::new(FrontendHub { - engine: EngineScope { - live: LivePublisher::new(EngineStatus::Starting, settings.engine_event_capacity), - }, - torrents: ScopeRegistry::new(), - handles: ScopeRegistry::new(), - settings, - next_tracker_id: AtomicU64::new(1), - })), - } - } - - pub(crate) fn from_hub(hub: Arc) -> Self { - Self { - hub: HubReference::Strong(hub), - } - } - - pub(crate) fn weak(&self) -> Self { - Self { - hub: HubReference::Weak(self.downgrade()), - } - } - - pub(crate) fn downgrade(&self) -> Weak { - match &self.hub { - HubReference::Strong(hub) => Arc::downgrade(hub), - HubReference::Weak(hub) => hub.clone(), - } - } - - pub(crate) fn hub(&self) -> Arc { - match &self.hub { - HubReference::Strong(hub) => Arc::clone(hub), - HubReference::Weak(hub) => hub - .upgrade() - .expect("frontend hub outlived by its actor hierarchy"), - } - } -} - -impl Default for FrontendPublisher { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use std::{ - net::{Ipv4Addr, SocketAddr}, - time::Duration, - }; - - use super::*; - use crate::{ - frontend::{ - ByteCount, ContentProgress, EventStreamError, PeerEventKind, PeerScope, PeerView, - TorrentMetrics, TorrentView, TrackerEventKind, TrackerView, TrafficTotals, - TransferMetrics, - }, - 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, - peer_choking: true, - peer_interested: false, - client_choking: true, - client_interested: false, - available_pieces: 0, - transfer: TransferMetrics::default(), - } - } - - fn pending_tracker_view() -> TrackerView { - TrackerView { - endpoint: "https://tracker.example".to_string(), - status: super::super::TrackerStatus::Pending, - peers_returned: None, - } - } - - #[tokio::test] - async fn peer_handle_when_updated_then_only_its_listener_receives_event() { - let frontend = FrontendPublisher::new(); - let scope = PeerScope { - torrent: InfoHash::from_bytes([1; 20]), - peer: PeerId::Unknown([2; 20]), - }; - let view = connected_peer_view(); - let peer = frontend.register_peer_scope(scope, view.clone()); - let mut listener = peer.listener(); - let mut updated = view; - updated.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().transfer.totals.downloaded, ByteCount(16)); - } - - #[tokio::test] - async fn peer_metrics_do_not_republish_the_torrent_projection() { - let frontend = FrontendPublisher::new(); - let info_hash = InfoHash::from_bytes([1; 20]); - let torrent = benchmark_torrent_view(info_hash, "isolated"); - frontend.initialize_torrent_projection(torrent.clone()); - let scope = frontend.ensure_torrent_scope(info_hash); - let mut torrent_events = scope.live.subscribe(); - let peer = frontend.register_peer_scope( - PeerScope { - torrent: info_hash, - peer: PeerId::Unknown([2; 20]), - }, - connected_peer_view(), - ); - let mut peer_view = peer.view(); - peer_view.transfer.rates = Some(Default::default()); - - peer.publish_metrics(peer_view); - - assert_eq!(scope.live.view(), Some(torrent)); - assert!( - tokio::time::timeout(Duration::from_millis(20), torrent_events.recv()) - .await - .is_err() - ); - } - - #[tokio::test] - async fn disconnected_peer_rejects_late_actor_updates() { - let frontend = FrontendPublisher::new(); - let scope = PeerScope { - torrent: InfoHash::from_bytes([1; 20]), - peer: PeerId::Unknown([2; 20]), - }; - let view = connected_peer_view(); - let peer = frontend.register_peer_scope(scope, view.clone()); - let mut listener = peer.listener(); - - peer.disconnected(); - let mut late = view; - late.transfer.totals.downloaded = ByteCount(32); - peer.publish_metrics(late); - - assert_eq!( - listener.recv().await.unwrap().kind, - PeerEventKind::Disconnected - ); - assert_eq!( - listener.recv().await, - Err(super::super::EventStreamError::Closed) - ); - assert!(!listener.view().connected); - assert_eq!(listener.view().transfer.totals.downloaded, ByteCount::ZERO); - } - - #[test] - fn live_handles_do_not_keep_their_frontend_hub_alive() { - let frontend = FrontendPublisher::new(); - let hub = frontend.downgrade(); - let scope = PeerScope { - torrent: InfoHash::from_bytes([1; 20]), - peer: PeerId::Unknown([2; 20]), - }; - let peer = frontend.register_peer_scope(scope, connected_peer_view()); - - drop(frontend); - - assert!(hub.upgrade().is_none()); - assert!(peer.view().connected); - } - - #[test] - fn publishers_without_listeners_do_not_allocate_event_channels() { - let frontend = FrontendPublisher::new(); - let peer = frontend.register_peer_scope( - PeerScope { - torrent: InfoHash::from_bytes([1; 20]), - peer: PeerId::Unknown([2; 20]), - }, - connected_peer_view(), - ); - - assert!(!peer.inner.live.has_event_channel()); - let _listener = peer.listener(); - assert!(peer.inner.live.has_event_channel()); - } - - #[tokio::test] - async fn tracker_restart_keeps_listener_open_until_final_stop() { - let frontend = FrontendPublisher::new(); - let source = Tracker::Http("https://tracker.example/announce".to_string()); - let tracker = frontend.register_tracker_scope( - InfoHash::from_bytes([3; 20]), - &source, - pending_tracker_view(), - ); - let mut listener = tracker.listener(); - - tracker.restarting(); - assert_eq!( - listener.recv().await.unwrap().kind, - TrackerEventKind::Restarting - ); - assert_eq!( - listener.view().status, - super::super::TrackerStatus::Restarting - ); - - let restarted = frontend.register_tracker_scope( - InfoHash::from_bytes([3; 20]), - &source, - pending_tracker_view(), - ); - assert_eq!(restarted.id(), tracker.id()); - restarted.announce_succeeded(2); - assert_eq!( - listener.recv().await.unwrap().kind, - TrackerEventKind::AnnounceSucceeded { peers_returned: 2 } - ); - - tracker.stopped(); - tracker.stopped(); - tracker.announce_failed(); - assert_eq!( - listener.recv().await.unwrap().kind, - TrackerEventKind::Stopped - ); - assert_eq!( - listener.recv().await, - Err(super::super::EventStreamError::Closed) - ); - } - - #[tokio::test] - async fn torrent_removal_closes_every_child_scope_exactly_once() { - let frontend = FrontendPublisher::new(); - let info_hash = InfoHash::from_bytes([4; 20]); - frontend.initialize_torrent_projection(benchmark_torrent_view(info_hash, "removed")); - let peer = frontend.register_peer_scope( - PeerScope { - torrent: info_hash, - peer: PeerId::Unknown([5; 20]), - }, - connected_peer_view(), - ); - let source = Tracker::Http("https://tracker.example/announce".to_string()); - let tracker = frontend.register_tracker_scope(info_hash, &source, pending_tracker_view()); - let mut peer_events = peer.subscribe(); - let mut tracker_events = tracker.subscribe(); - - frontend.remove_torrent_scope(info_hash); - frontend.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)); - } - - 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, - }, - ), - } - } - - #[test] - #[ignore = "performance benchmark; run explicitly with --ignored --nocapture"] - fn large_scope_tree_benchmark() { - use std::time::Instant; - - let frontend = FrontendPublisher::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); - frontend.initialize_torrent_projection(benchmark_torrent_view( - InfoHash::from_bytes(hash), - &format!("torrent-{torrent_index}"), - )); - frontend - .ensure_torrent_scope(InfoHash::from_bytes(hash)) - .register(); - for peer_index in 0_u8..10 { - frontend.register_peer_scope( - PeerScope { - torrent: InfoHash::from_bytes(hash), - peer: PeerId::Unknown([peer_index; 20]), - }, - connected_peer_view(), - ); - } - } - 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 frontend.peer_handles(InfoHash::from_bytes(hash)) { - peer.publish_metrics(peer.view()); - } - } - } - let updates = started.elapsed(); - - let started = Instant::now(); - let view = frontend.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]); - frontend.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); - frontend.register_peer_scope( - PeerScope { - torrent: removal_hash, - peer: PeerId::Unknown(id), - }, - connected_peer_view(), - ) - }) - .collect::>(); - let zero_listener_slots = removal_peers - .iter() - .map(|peer| peer.inner.live.allocated_event_slots()) - .sum::(); - let zero_listener_memory_lower_bound = removal_peers - .iter() - .map(|peer| peer.inner.live.allocation_lower_bound_bytes()) - .sum::(); - let started = Instant::now(); - frontend.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.update(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/frontend/registry.rs b/crates/libtortillas/src/frontend/registry.rs deleted file mode 100644 index ed3c2542..00000000 --- a/crates/libtortillas/src/frontend/registry.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::{hash::Hash, sync::Arc}; - -use dashmap::DashMap; - -/// 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)] -pub(crate) struct ScopeRegistry { - values: DashMap>, -} - -impl ScopeRegistry -where - K: Clone + Eq + Hash, -{ - pub(crate) fn new() -> Self { - Self { - values: DashMap::new(), - } - } - - pub(crate) fn insert(&self, key: K, value: &Arc) { - self.values.insert(key, Arc::clone(value)); - } - - pub(crate) 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()) - } - - pub(crate) fn get(&self, key: &K) -> Option> { - self.values.get(key).map(|value| Arc::clone(value.value())) - } - - pub(crate) fn remove(&self, key: &K) -> Option> { - self.values.remove(key).map(|(_, value)| value) - } - - pub(crate) fn values(&self) -> Vec> { - self - .values - .iter() - .map(|value| Arc::clone(value.value())) - .collect() - } - - pub(crate) 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() - } -} diff --git a/crates/libtortillas/src/frontend/stream.rs b/crates/libtortillas/src/frontend/stream.rs new file mode 100644 index 00000000..c6d800e0 --- /dev/null +++ b/crates/libtortillas/src/frontend/stream.rs @@ -0,0 +1,380 @@ +//! 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 current-state and event publisher for live application APIs. +/// +/// 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, +{ + /// Creates a publisher with an initial view and bounded event capacity. + /// + /// A zero capacity is normalized to one so configuration mistakes cannot + /// panic a public operation. + #[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), + }), + } + } + + /// Subscribes to all future events from this publisher. + #[must_use] + pub fn subscribe(&self) -> EventSubscription { + let state = mutex_lock(&self.state); + if state.closed { + return { + let (sender, receiver) = broadcast::channel(1); + let weak = sender.downgrade(); + drop(sender); + EventSubscription::from_receiver(receiver, weak) + }; + } + 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::>()) + } + + /// Creates a stream-compatible listener paired with the current view. + #[must_use] + pub fn listener(&self) -> EventListener { + let state = Arc::clone(&self.state); + EventListener::new(self.subscribe(), move || mutex_lock(&state).view.clone()) + } + + /// Clones the latest coherent view. + #[must_use] + pub fn view(&self) -> V { + mutex_lock(&self.state).view.clone() + } + + /// Replaces the current view without emitting an event. + /// + /// Returns `false` when the publisher has already closed. + pub 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 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 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 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, + }); + } + } +} + +// 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, + } + } + + fn closed() -> Self { + let (sender, receiver) = broadcast::channel(1); + let weak = sender.downgrade(); + drop(sender); + Self::from_receiver(receiver, weak) + } + + /// Waits for the next event in this subscription. + 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 frontend 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("frontend event subscriber lagged by {0} events")] + Lagged(u64), + /// The publisher closed the event stream. + #[error("frontend 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() + } +} + +/// Live engine listener with typed events and current presentation 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 live = Arc::new(LivePublisher::new(0_u64, 8)); + let update = Arc::clone(&live); + let close = Arc::clone(&live); + 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!(!live.replace_view_and_emit(3, "late")); + assert_eq!(live.view(), 2); + if update_accepted { + assert_eq!(live.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")); + } +} diff --git a/crates/libtortillas/src/frontend/subscription.rs b/crates/libtortillas/src/frontend/subscription.rs deleted file mode 100644 index 8b3b0e53..00000000 --- a/crates/libtortillas/src/frontend/subscription.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::{ - fmt, - pin::Pin, - 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::{CoreEventKind, Sequenced}; - -/// 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, - } - } - - fn closed() -> Self { - let (sender, receiver) = broadcast::channel(1); - let weak = sender.downgrade(); - drop(sender); - Self::from_receiver(receiver, weak) - } - - /// Waits for the next event in this subscription. - 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 frontend 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("frontend event subscriber lagged by {0} events")] - Lagged(u64), - /// The publisher closed the event stream. - #[error("frontend event stream closed")] - Closed, -} diff --git a/crates/libtortillas/src/frontend/tests.rs b/crates/libtortillas/src/frontend/tests.rs new file mode 100644 index 00000000..06c65dc8 --- /dev/null +++ b/crates/libtortillas/src/frontend/tests.rs @@ -0,0 +1,339 @@ +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, + peer_choking: true, + peer_interested: false, + client_choking: true, + client_interested: false, + available_pieces: 0, + transfer: TransferMetrics::default(), + } +} + +fn pending_tracker_view() -> TrackerView { + TrackerView { + endpoint: "https://tracker.example".to_string(), + status: TrackerStatus::Pending, + peers_returned: None, + } +} + +#[tokio::test] +async fn peer_handle_when_updated_then_only_its_listener_receives_event() { + let frontend = FrontendHub::new(); + let scope = PeerIdentity { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), + }; + let view = connected_peer_view(); + let peer = frontend.register_peer_scope(scope, view.clone()); + let mut listener = peer.listener(); + let mut updated = view; + updated.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().transfer.totals.downloaded, ByteCount(16)); +} + +#[tokio::test] +async fn peer_metrics_do_not_republish_the_torrent_projection() { + let frontend = FrontendHub::new(); + let info_hash = InfoHash::from_bytes([1; 20]); + let torrent = benchmark_torrent_view(info_hash, "isolated"); + frontend.initialize_torrent_projection(torrent.clone()); + let scope = frontend.ensure_torrent_scope(info_hash); + let mut torrent_events = scope.live.subscribe(); + let peer = frontend.register_peer_scope( + PeerIdentity { + torrent: info_hash, + peer: PeerId::Unknown([2; 20]), + }, + connected_peer_view(), + ); + let mut peer_view = peer.view(); + peer_view.transfer.rates = Some(Default::default()); + + peer.publish_metrics(peer_view); + + assert_eq!(scope.live.view(), Some(torrent)); + assert!( + tokio::time::timeout(Duration::from_millis(20), torrent_events.recv()) + .await + .is_err() + ); +} + +#[tokio::test] +async fn disconnected_peer_rejects_late_actor_updates() { + let frontend = FrontendHub::new(); + let scope = PeerIdentity { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), + }; + let view = connected_peer_view(); + let peer = frontend.register_peer_scope(scope, view.clone()); + let mut listener = peer.listener(); + + peer.disconnected(); + let mut late = view; + late.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().transfer.totals.downloaded, ByteCount::ZERO); +} + +#[test] +fn live_handles_do_not_keep_their_frontend_hub_alive() { + let frontend = FrontendHub::new(); + let hub = frontend.downgrade(); + let scope = PeerIdentity { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), + }; + let peer = frontend.register_peer_scope(scope, connected_peer_view()); + + drop(frontend); + + assert!(hub.upgrade().is_none()); + assert!(peer.view().connected); +} + +#[test] +fn publishers_without_listeners_do_not_allocate_event_channels() { + let frontend = FrontendHub::new(); + let peer = frontend.register_peer_scope( + PeerIdentity { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), + }, + connected_peer_view(), + ); + + assert!(!peer.inner.live.has_event_channel()); + let _listener = peer.listener(); + assert!(peer.inner.live.has_event_channel()); +} + +#[tokio::test] +async fn tracker_restart_keeps_listener_open_until_final_stop() { + let frontend = FrontendHub::new(); + let source = Tracker::Http("https://tracker.example/announce".to_string()); + let tracker = frontend.register_tracker_scope( + InfoHash::from_bytes([3; 20]), + &source, + pending_tracker_view(), + ); + 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 = frontend.register_tracker_scope( + InfoHash::from_bytes([3; 20]), + &source, + pending_tracker_view(), + ); + assert_eq!(restarted.id(), tracker.id()); + restarted.announce_succeeded(2); + assert_eq!( + listener.recv().await.unwrap().kind, + TrackerEventKind::AnnounceSucceeded { peers_returned: 2 } + ); + + tracker.stopped(); + tracker.stopped(); + tracker.announce_failed(); + assert_eq!( + listener.recv().await.unwrap().kind, + TrackerEventKind::Stopped + ); + assert_eq!(listener.recv().await, Err(EventStreamError::Closed)); +} + +#[tokio::test] +async fn torrent_removal_closes_every_child_scope_exactly_once() { + let frontend = FrontendHub::new(); + let info_hash = InfoHash::from_bytes([4; 20]); + frontend.initialize_torrent_projection(benchmark_torrent_view(info_hash, "removed")); + let peer = frontend.register_peer_scope( + PeerIdentity { + torrent: info_hash, + peer: PeerId::Unknown([5; 20]), + }, + connected_peer_view(), + ); + let source = Tracker::Http("https://tracker.example/announce".to_string()); + let tracker = frontend.register_tracker_scope(info_hash, &source, pending_tracker_view()); + let mut peer_events = peer.subscribe(); + let mut tracker_events = tracker.subscribe(); + + frontend.remove_torrent_scope(info_hash); + frontend.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)); +} + +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, + }, + ), + } +} + +#[test] +#[ignore = "performance benchmark; run explicitly with --ignored --nocapture"] +fn large_scope_tree_benchmark() { + use std::time::Instant; + + let frontend = FrontendHub::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); + frontend.initialize_torrent_projection(benchmark_torrent_view( + InfoHash::from_bytes(hash), + &format!("torrent-{torrent_index}"), + )); + frontend + .ensure_torrent_scope(InfoHash::from_bytes(hash)) + .mark_registered_for_benchmark(); + for peer_index in 0_u8..10 { + frontend.register_peer_scope( + PeerIdentity { + torrent: InfoHash::from_bytes(hash), + peer: PeerId::Unknown([peer_index; 20]), + }, + connected_peer_view(), + ); + } + } + 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 frontend.peer_handles(InfoHash::from_bytes(hash)) { + peer.publish_metrics(peer.view()); + } + } + } + let updates = started.elapsed(); + + let started = Instant::now(); + let view = frontend.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]); + frontend.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); + frontend.register_peer_scope( + PeerIdentity { + torrent: removal_hash, + peer: PeerId::Unknown(id), + }, + connected_peer_view(), + ) + }) + .collect::>(); + let zero_listener_slots = removal_peers + .iter() + .map(|peer| peer.inner.live.allocated_event_slots()) + .sum::(); + let zero_listener_memory_lower_bound = removal_peers + .iter() + .map(|peer| peer.inner.live.allocation_lower_bound_bytes()) + .sum::(); + let started = Instant::now(); + frontend.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/frontend/view.rs b/crates/libtortillas/src/frontend/view.rs index 176ac6ab..1c85ff43 100644 --- a/crates/libtortillas/src/frontend/view.rs +++ b/crates/libtortillas/src/frontend/view.rs @@ -2,15 +2,21 @@ use std::{net::SocketAddr, path::PathBuf}; use serde::{Deserialize, Serialize}; -use super::{ - ByteCount, HasTransferMetrics, TorrentMetrics, TrafficTotals, TransferMetrics, TransferRates, +use crate::{ + engine::EngineStatus, + hashes::InfoHash, + metrics::{ + ByteCount, HasTransferMetrics, TorrentMetrics, TrafficTotals, TransferMetrics, TransferRates, + }, + peer::Peer, + torrent::TorrentState, }; -use crate::{engine::EngineStatus, hashes::InfoHash, peer::Peer, torrent::TorrentState}; /// Current live engine state maintained by a frontend listener. /// -/// Unlike persistence snapshots, views are presentation-oriented and updated -/// by applying live [`CoreEvent`](super::CoreEvent) values. +/// Unlike persistence snapshots, views are presentation-oriented projections +/// updated by the actor hierarchy. A listener always reads the current +/// projection directly, including after a lagged event subscription. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EngineView { pub status: EngineStatus, @@ -150,7 +156,7 @@ impl TrackerStatus { #[cfg(test)] mod tests { use super::*; - use crate::frontend::BytesPerSecond; + use crate::metrics::BytesPerSecond; #[test] fn peer_view_uses_canonical_byte_units() { diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index 53dc7dd7..4246bba0 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -20,7 +20,7 @@ //! Frontends should prefer [`facade`] or [`prelude`] imports. The facade names //! the stable concepts any application adapter needs: [`engine::Engine`], //! [`torrent::Torrent`], [`facade::TorrentSource`], -//! [`facade::CoreEvent`], and live engine, torrent, +//! [`facade::EngineEvent`], and live engine, torrent, //! peer, and tracker views. //! //! ```no_run @@ -50,6 +50,7 @@ pub mod facade; pub mod frontend; pub mod hashes; pub mod metainfo; +pub mod metrics; pub mod peer; pub mod pieces; pub mod protocol; diff --git a/crates/libtortillas/src/frontend/metrics.rs b/crates/libtortillas/src/metrics.rs similarity index 98% rename from crates/libtortillas/src/frontend/metrics.rs rename to crates/libtortillas/src/metrics.rs index f701e7cb..514d1d70 100644 --- a/crates/libtortillas/src/frontend/metrics.rs +++ b/crates/libtortillas/src/metrics.rs @@ -1,3 +1,6 @@ +//! Canonical transfer and verified-content measurements shared by actors and +//! presentation views. + use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index 2c5e4537..baa35778 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -22,11 +22,9 @@ use tracing::{Span, debug, info, instrument, trace, warn}; use crate::{ errors::PeerActorError, - frontend::{ - ByteCount, HasTransferMetrics, PeerHandle, PeerView, TrafficTotals, TransferMetrics, - TransferSample, - }, + frontend::{PeerHandle, PeerView}, hashes::InfoHash, + metrics::{ByteCount, HasTransferMetrics, TrafficTotals, TransferMetrics, TransferSample}, peer::{Peer, PeerId}, protocol::{stream::PeerRecv, *}, settings::PeerSettings, diff --git a/crates/libtortillas/src/pieces/piece_scheduler.rs b/crates/libtortillas/src/pieces/piece_scheduler.rs index d1cb02c2..91a389b6 100644 --- a/crates/libtortillas/src/pieces/piece_scheduler.rs +++ b/crates/libtortillas/src/pieces/piece_scheduler.rs @@ -2,10 +2,7 @@ use std::collections::HashMap; 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 { @@ -157,12 +154,8 @@ impl PieceScheduler { self.in_flight.remove(&(piece_index, offset / BLOCK_SIZE)); } - 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()); - } - block_map + pub(crate) fn completed_blocks(&self) -> &HashMap { + &self.completed_blocks } fn block_request( diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 593cb91b..411832a1 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -26,13 +26,13 @@ use tracing::{debug, error, info, instrument, trace, warn}; use super::{choking::ChokingScheduler, util}; use crate::{ errors::{SnapshotUnsupportedReason, TorrentError}, - frontend::{ - ByteCount, ContentProgress, FrontendHealthLevel, FrontendPublisher, HasTransferMetrics, - TorrentMetrics, TorrentView, TrackerStatus, TrackerView, TrafficTotals, TransferMetrics, - TransferRates, - }, + frontend::{FrontendHealthLevel, FrontendHub, TorrentView, TrackerStatus, TrackerView}, hashes::InfoHash, metainfo::{Info, MetaInfo}, + metrics::{ + ByteCount, ContentProgress, HasTransferMetrics, TorrentMetrics, TrafficTotals, + TransferMetrics, TransferRates, + }, peer::{PeerActor, PeerId, commands::SetChoked}, pieces::{FilePieceManager, PieceManager, PieceScheduler, PieceStoreActor}, settings::Settings, @@ -100,7 +100,7 @@ impl PieceManager for PieceManagerProxy { } pub(crate) struct TorrentActor { - pub(super) frontend: FrontendPublisher, + pub(super) frontend: FrontendHub, pub(crate) peers: HashMap>, pub(crate) trackers: HashMap>, @@ -364,10 +364,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 @@ -463,12 +460,13 @@ impl TorrentActor { resolved_magnet_info: self.resolved_magnet_info.clone(), bitfield: self.bitfield.iter().by_vals().collect(), block_map: { - let map = self.piece_scheduler.block_map_export(); - let mut blocks = map + let mut blocks = self + .piece_scheduler + .completed_blocks() .iter() - .map(|entry| PieceBlockSnapshot { - piece_index: Self::snapshot_u64(*entry.key()), - blocks: entry.value().iter().by_vals().collect(), + .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); @@ -497,11 +495,10 @@ impl TorrentActor { 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 @@ -648,7 +645,7 @@ pub struct TorrentActorArgs { pub settings: Settings, /// Live frontend state shared with the owning engine. - pub(crate) frontend: FrontendPublisher, + pub(crate) frontend: FrontendHub, } impl Actor for TorrentActor { @@ -877,9 +874,10 @@ mod tests { use super::*; use crate::{ - frontend::{BytesPerSecond, PeerScope, PeerView}, + frontend::{PeerIdentity, PeerView}, hashes::HashVec, metainfo::{InfoKeys, MetaInfo, TorrentFile}, + metrics::BytesPerSecond, settings::Settings, testing, torrent::{ @@ -1011,7 +1009,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path), settings, - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); actor .tell(SetState { @@ -1031,9 +1029,9 @@ mod tests { assert!(query.contains(&format!("left={}", info.total_length()))); assert!(query.contains("compact=0")); - let export = actor.ask(SnapshotState).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(); } @@ -1062,7 +1060,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(testing::torrent_temp_path()), settings, - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); actor .tell(SetState { @@ -1110,7 +1108,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(base_path.clone()), settings, - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); actor .tell(SetState { @@ -1188,7 +1186,7 @@ mod tests { sufficient_peers: Some(sufficient_peers), base_path: None, settings: Settings::default(), - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); @@ -1222,7 +1220,7 @@ mod tests { sufficient_peers: None, base_path: None, settings: Settings::default(), - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); // Blocking loop that runs until we get an info dict @@ -1262,7 +1260,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); assert_eq!(actor.ask(GetState).await.unwrap(), TorrentState::Ready); @@ -1287,7 +1285,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); assert_eq!( @@ -1316,7 +1314,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); actor @@ -1376,7 +1374,7 @@ mod tests { sufficient_peers: None, base_path: Some(file_path), settings: Settings::default(), - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); @@ -1385,9 +1383,9 @@ mod tests { let wrote_piece_block = timeout(Duration::from_secs(60), async { loop { - let export = actor.ask(SnapshotState).await.unwrap(); - let has_persisted_progress = export.bitfield.iter().any(|complete| *complete) - || 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.blocks.iter().any(|block| *block)); @@ -1421,7 +1419,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 { @@ -1440,7 +1438,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(), @@ -1452,7 +1450,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); // Build the bitfield with fake completed pieces @@ -1474,9 +1472,9 @@ 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 { - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield, @@ -1505,26 +1503,30 @@ mod tests { settings: Settings::default(), }; - let export = test_actor.snapshot().unwrap(); + 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); + // 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!( - export.resolved_magnet_info.is_none(), + snapshot.resolved_magnet_info.is_none(), "torrent metainfo already contains its info dict" ); - assert!(export.resolved_info().is_some()); + assert!(snapshot.resolved_info().is_some()); assert_eq!( - export.bitfield.iter().filter(|complete| **complete).count(), + snapshot + .bitfield + .iter() + .filter(|complete| **complete) + .count(), fake_completed ); - assert_eq!(export.bitfield.len(), piece_count); - assert_eq!(export.block_map.len(), 1); + assert_eq!(snapshot.bitfield.len(), piece_count); + assert_eq!(snapshot.block_map.len(), 1); - let partial_entry = export + let partial_entry = snapshot .block_map .iter() .find(|entry| entry.piece_index == partial_piece_index as u64) @@ -1549,7 +1551,7 @@ mod tests { info_dict.total_length() - expected_downloaded ); - match &export.piece_storage { + match &snapshot.piece_storage { PieceStorageStrategy::Disk(path) => { assert_eq!(path.as_path(), piece_path.as_path()); } @@ -1558,18 +1560,18 @@ mod tests { // Test serialization round-trip use serde_json::{from_str, to_string}; - let export_str = to_string(&export).unwrap(); - let from_export: TorrentSnapshot = 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(); } @@ -1596,7 +1598,7 @@ mod tests { let utp_server = UtpSocket::new_udp(testing::ephemeral_socket_addr()) .await .unwrap(); - let frontend = FrontendPublisher::default(); + let frontend = FrontendHub::default(); let actor_ref = TorrentActor::spawn(TorrentActorArgs { peer_id, metainfo: metainfo.clone(), @@ -1631,7 +1633,7 @@ mod tests { piece_scheduler.set_piece_blocks(partial_piece_index, blocks); let mut test_actor = TorrentActor { - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield, @@ -1662,7 +1664,7 @@ mod tests { let verified_content = test_actor.live_view().metrics.progress.verified_bytes; let sampled_peer = test_actor.frontend.register_peer_scope( - PeerScope { + PeerIdentity { torrent: info_hash, peer: PeerId::Unknown([9; 20]), }, @@ -1804,11 +1806,11 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); let mut actor = TorrentActor { - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield: BitVec::repeat(false, piece_count), diff --git a/crates/libtortillas/src/torrent/choking.rs b/crates/libtortillas/src/torrent/choking.rs index 7f2b37a5..91d5758f 100644 --- a/crates/libtortillas/src/torrent/choking.rs +++ b/crates/libtortillas/src/torrent/choking.rs @@ -1,5 +1,5 @@ use crate::{ - frontend::BytesPerSecond, + metrics::BytesPerSecond, peer::{PeerId, PeerStats}, settings::Settings, torrent::TorrentState, @@ -137,7 +137,7 @@ fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> BytesPerSecond { #[cfg(test)] mod tests { use super::*; - use crate::frontend::{TransferMetrics, TransferRates}; + use crate::metrics::{TransferMetrics, TransferRates}; fn peer_id(value: u8) -> PeerId { PeerId::from([value; 20]) diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 8db9cdf9..330ee44f 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -18,8 +18,8 @@ use super::{ use crate::{ errors::{TorrentError, map_torrent_send_error}, frontend::{ - EventSubscription, FrontendHub, FrontendPublisher, PeerHandle, TorrentEventKind, - TorrentListener, TorrentScope, TorrentView, TrackerHandle, + EventSubscription, FrontendHub, FrontendHubInner, LivePublisher, PeerHandle, + TorrentEventKind, TorrentListener, TorrentView, TrackerHandle, }, hashes::InfoHash, pieces::PieceManager, @@ -29,8 +29,8 @@ use crate::{ pub(crate) struct TorrentInner { pub(crate) info_hash: InfoHash, pub(crate) actor: ActorRef, - pub(crate) hub: Weak, - pub(crate) scope: Arc, + pub(crate) hub: Weak, + pub(crate) live: Arc, TorrentEventKind>>, } /// A handle to a torrent managed by the engine. @@ -57,22 +57,22 @@ impl Torrent { /// to its underlying [`TorrentActor`]. #[cfg(test)] pub(crate) fn new(info_hash: InfoHash, actor_ref: ActorRef) -> Self { - Self::new_with_frontend(info_hash, actor_ref, &FrontendPublisher::default(), None) + Self::new_with_frontend(info_hash, actor_ref, &FrontendHub::default(), None) } pub(crate) fn new_with_frontend( - info_hash: InfoHash, actor: ActorRef, frontend: &FrontendPublisher, + info_hash: InfoHash, actor: ActorRef, frontend: &FrontendHub, initial_view: Option, ) -> Self { let scope = frontend.ensure_torrent_scope(info_hash); if let Some(view) = initial_view { - let _ = scope.live.set_view(Some(view)); + let _ = scope.live.replace_view(Some(view)); } let inner = Arc::new(TorrentInner { info_hash, actor, hub: frontend.downgrade(), - scope: Arc::clone(&scope), + live: Arc::clone(&scope.live), }); Self { inner } } @@ -215,13 +215,13 @@ impl Torrent { /// Subscribes to live events for this torrent only. #[must_use] pub fn subscribe(&self) -> EventSubscription { - self.inner.scope.live.subscribe() + self.inner.live.subscribe() } /// Creates a live listener scoped to this torrent. #[must_use] pub fn listener(&self) -> TorrentListener { - self.inner.scope.live.listener() + self.inner.live.listener() } /// Returns the latest display-oriented state maintained for this torrent. @@ -229,7 +229,7 @@ impl Torrent { /// This returns `None` after the torrent has been removed from its engine. #[must_use] pub fn view(&self) -> Option { - self.inner.scope.live.view() + self.inner.live.view() } /// Returns handles for this torrent's currently connected peers. @@ -248,7 +248,7 @@ impl Torrent { }) } - fn frontend(&self) -> Option { - self.inner.hub.upgrade().map(FrontendPublisher::from_hub) + fn frontend(&self) -> Option { + self.inner.hub.upgrade().map(FrontendHub::from_inner) } } diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index dec6408f..19ce07ae 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -9,7 +9,7 @@ use tracing::{debug, info, trace, warn}; use super::{TorrentActor, util}; #[cfg(test)] -use crate::frontend::FrontendPublisher; +use crate::frontend::FrontendHub; use crate::{ errors::TorrentError, peer::commands::{CancelPiece, Have, NeedPiece}, @@ -477,11 +477,11 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(base_path.clone()), settings: Settings::default(), - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), }); TorrentActor { - frontend: FrontendPublisher::default(), + frontend: FrontendHub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield: BitVec::::repeat(false, info.piece_count()), diff --git a/crates/libtortillas/src/torrent/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index 61d316f0..2a69b329 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -10,7 +10,7 @@ use tracing::{debug, instrument, trace, warn}; use super::TorrentActor; use crate::{ - frontend::{PeerScope, PeerView}, + frontend::{PeerIdentity, PeerView}, peer::{Peer, PeerActor, PeerId}, protocol::{ messages::{Handshake, PeerMessages}, @@ -110,7 +110,7 @@ impl TorrentActor { } let peer_frontend = self.frontend.register_peer_scope( - PeerScope { + PeerIdentity { torrent: info_hash, peer: id, }, diff --git a/crates/libtortillas/tests/engine_lifecycle.rs b/crates/libtortillas/tests/engine_lifecycle.rs index 7b48177f..54a2ef34 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()) @@ -31,9 +31,11 @@ async fn engine_remove_torrent_drops_it_from_exports() { .unwrap(); assert_eq!(torrent.info_hash(), info_hash); 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.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(); diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live_frontend.rs index d9354fa1..ddebbc05 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live_frontend.rs @@ -5,7 +5,7 @@ use libtortillas::{ engine::EngineStatus, errors::EngineError, frontend::{ - CoreEventKind, EventStreamError, LivePublisher, TorrentEventKind, TrackerEventKind, + EngineEventKind, EventStreamError, LivePublisher, TorrentEventKind, TrackerEventKind, TrackerStatus, }, prelude::{Engine, Settings, TorrentSource, TorrentState}, @@ -37,7 +37,7 @@ async fn engine_listener_receives_live_torrent_lifecycle() { let event = engine_listener.next().await.unwrap().unwrap(); if matches!( event.kind, - CoreEventKind::Torrent { + EngineEventKind::Torrent { event: TorrentEventKind::Added, .. } @@ -49,7 +49,7 @@ async fn engine_listener_receives_live_torrent_lifecycle() { .await .unwrap(); assert_eq!(added.torrent(), Some(torrent.info_hash())); - let CoreEventKind::Torrent { + let EngineEventKind::Torrent { torrent: added_torrent, event: TorrentEventKind::Added, } = added.kind @@ -118,7 +118,7 @@ async fn generic_live_publisher_implements_async_stream() { let publisher = LivePublisher::new(0_u8, 4); let mut listener = publisher.listener(); - publisher.update(1, "changed"); + publisher.replace_view_and_emit(1, "changed"); let event = listener.next().await.unwrap().unwrap(); assert_eq!(event.sequence, 1); @@ -145,8 +145,8 @@ async fn closed_live_publisher_rejects_late_updates() { let publisher = LivePublisher::new(0_u8, 4); let mut listener = publisher.listener(); - assert!(publisher.close(1, "closed")); - assert!(!publisher.update(2, "late")); + assert!(publisher.close_with_terminal_event(1, "closed")); + assert!(!publisher.replace_view_and_emit(2, "late")); assert_eq!(listener.recv().await.unwrap().kind, "closed"); assert!(matches!( @@ -164,7 +164,7 @@ async fn concurrent_live_updates_are_delivered_in_sequence_order() { let updates = (1..=UPDATE_COUNT) .map(|view| { let publisher = publisher.clone(); - tokio::spawn(async move { publisher.update(view, view) }) + tokio::spawn(async move { publisher.replace_view_and_emit(view, view) }) }) .collect::>(); @@ -182,7 +182,7 @@ async fn listener_view_is_never_older_than_its_accepted_update() { let mut listener = publisher.listener(); for value in 1..=32 { - assert!(publisher.update(value, value)); + assert!(publisher.replace_view_and_emit(value, value)); let event = listener.recv().await.unwrap(); assert_eq!(event.kind, value); assert!(listener.view() >= event.kind); @@ -231,7 +231,7 @@ async fn engine_listener_receives_graceful_shutdown() { let shutdown = timeout(Duration::from_secs(2), async { loop { let event = listener.recv().await.unwrap(); - if matches!(event.kind, CoreEventKind::Shutdown(_)) { + if matches!(event.kind, EngineEventKind::Shutdown(_)) { break event; } } @@ -239,7 +239,7 @@ async fn engine_listener_receives_graceful_shutdown() { .await .unwrap(); - let CoreEventKind::Shutdown(view) = shutdown.kind else { + let EngineEventKind::Shutdown(view) = shutdown.kind else { unreachable!(); }; assert_eq!(view.status, EngineStatus::Stopped); @@ -344,7 +344,7 @@ async fn live_views_are_serde_compatible() { let event = listener.recv().await.unwrap(); if matches!( event.kind, - CoreEventKind::Torrent { + EngineEventKind::Torrent { event: TorrentEventKind::Added, .. } diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 04a0349e..969c54e4 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -9,12 +9,12 @@ polling loop. Call `Engine::listener()` before invoking operations. The listener combines two related capabilities: -- `recv().await` yields sequenced `CoreEvent` values as changes happen. -- `view()` returns the latest display-oriented `EngineView` held by the live - publisher. +- `recv().await` yields sequenced `EngineEvent` values as changes happen. +- `view()` derives the latest presentation-oriented `EngineView` from the live + scope tree. Every `Torrent` returned by `Engine::add_torrent()` similarly has `listener()` -and `subscribe()` methods. A torrent listener has its own publisher, receives +and `subscribe()` methods. A torrent listener has its own scope, receives typed `TorrentEvent` values for that torrent only, and exposes its latest `TorrentView`. It does not filter the engine's global event stream. @@ -22,15 +22,17 @@ Peers and trackers returned by `Torrent::peers()` and `Torrent::trackers()` follow the same pattern. Each `PeerHandle` and `TrackerHandle` owns an independent typed listener and current view, including a terminal disconnected or stopped view. Engine listeners receive -`CoreEventKind::Torrent { torrent, event }`, where `event` uses the same +`EngineEventKind::Torrent { torrent, event }`, where `event` uses the same `TorrentEventKind` vocabulary as the torrent listener. Peer and tracker changes carry their public handles inside that nested event, so a frontend can descend into more detailed streams only when needed. -Each publisher's shared event channel retains 256 events by default. Slow listeners -receive `EventStreamError::Lagged` instead of causing unbounded memory growth. -After lagging, redraw from `listener.view()` and continue calling `recv()`. -Sequence numbers are monotonic within each publisher. +Event channels are allocated lazily on first subscription. Defaults retain 256 +engine or torrent events and 64 peer or tracker events; all four capacities are +configurable through `FrontendSettings`. Slow listeners receive +`EventStreamError::Lagged` instead of causing unbounded memory growth. After +lagging, rebuild adapter state from `listener.view()` and continue calling +`recv()`. Sequence numbers are monotonic within each scope. Use `subscribe()` when only discrete events are needed. Use `listener()` when the frontend also needs a coherent current view for initial rendering or lag @@ -61,12 +63,17 @@ usual Tokio-style loop. Engine, torrent, peer, and tracker APIs all reuse these types; future protocols can expose the same behavior without another listener implementation. +Publisher mutation names state their complete effect: +`replace_view()` changes only the current projection, +`replace_view_and_emit()` performs a coherent view/event transition, +`emit_without_view_change()` emits a discrete event, and +`close_with_terminal_event()` performs the one irreversible close transition. + ## Views and persistence snapshots `EngineView`, `TorrentView`, `PeerView`, and `TrackerView` are live presentation -contracts. They are updated by their publishers, are suitable for rendering, -and are Serde-compatible where a frontend wants to store or transmit display -state. +contracts. They are suitable for rendering, API responses, or transport +serialization and are Serde-compatible. `Engine::snapshot()` and `Torrent::snapshot()` are not the live frontend path. Snapshots are the persistence boundary for serializing resumable engine and @@ -91,11 +98,11 @@ state. ## Runtime and shutdown The library is Tokio-based. Keep the engine, torrent handles, application tasks, -and listener tasks on the application runtime. Terminal or UI operations that -block should run separately from those async tasks. +and listener tasks on the application runtime. Any blocking adapter work should +run separately from those async tasks. Call `Engine::shutdown()` and keep the engine -listener alive until it receives `CoreEventKind::Shutdown`. This ensures the +listener alive until it receives `EngineEventKind::Shutdown`. This ensures the frontend observes the terminal state after managed torrents stop. See [`live_frontend.rs`](../crates/libtortillas/examples/live_frontend.rs) for a From 204fa45900f9abea6adae44fca1671a2d6493389 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 19:10:32 -0700 Subject: [PATCH 63/77] fix: keep torrent downloads progressing to completion --- crates/libtortillas/src/peer/actor.rs | 10 +- crates/libtortillas/src/peer/mod.rs | 4 +- .../src/pieces/piece_scheduler.rs | 152 ++++++++++++++- crates/libtortillas/src/protocol/messages.rs | 61 ++++-- crates/libtortillas/src/settings.rs | 4 + crates/libtortillas/src/torrent/actor.rs | 175 +++++++++++++++++- .../libtortillas/src/torrent/choking_flow.rs | 12 ++ crates/libtortillas/src/torrent/messages.rs | 19 +- crates/libtortillas/src/torrent/piece_flow.rs | 157 ++++++++-------- crates/libtortillas/src/torrent/swarm.rs | 48 +++++ 10 files changed, 532 insertions(+), 110 deletions(-) diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index baa35778..b3b059da 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -389,6 +389,7 @@ 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()))?; @@ -680,7 +681,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"); @@ -688,9 +692,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, }) 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/pieces/piece_scheduler.rs b/crates/libtortillas/src/pieces/piece_scheduler.rs index 91a389b6..6941492b 100644 --- a/crates/libtortillas/src/pieces/piece_scheduler.rs +++ b/crates/libtortillas/src/pieces/piece_scheduler.rs @@ -1,4 +1,8 @@ -use std::collections::HashMap; +use std::{ + collections::HashMap, + sync::{Arc, atomic::AtomicU8}, + time::{Duration, Instant}, +}; use bitvec::vec::BitVec; @@ -21,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, } } @@ -61,7 +73,7 @@ impl PieceScheduler { 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); @@ -70,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 { @@ -103,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 @@ -111,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; } @@ -135,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; @@ -146,12 +177,47 @@ 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_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 release_request(&mut self, piece_index: usize, offset: usize) { - self.in_flight.remove(&(piece_index, offset / BLOCK_SIZE)); + 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); + } } pub(crate) fn completed_blocks(&self) -> &HashMap { @@ -175,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/settings.rs b/crates/libtortillas/src/settings.rs index 1b2cc237..05126a6b 100644 --- a/crates/libtortillas/src/settings.rs +++ b/crates/libtortillas/src/settings.rs @@ -164,6 +164,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. @@ -195,6 +198,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 411832a1..0c20a52c 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -229,7 +229,7 @@ impl TorrentActor { return; } - self.sync_tracker_announce_progress().await; + self.update_tracker_progress().await; if self.is_full() { self.transition_state(TorrentState::Seeding); @@ -245,9 +245,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); } } @@ -286,6 +284,7 @@ impl TorrentActor { } self.broadcast_to_peers(SetChoked { choked: true }).await; + self.update_tracker_progress().await; self .update_trackers(TrackerUpdate::Event(Event::Stopped)) .await; @@ -418,7 +417,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; }; @@ -431,6 +430,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; @@ -878,16 +886,84 @@ mod tests { hashes::HashVec, metainfo::{InfoKeys, MetaInfo, TorrentFile}, metrics::BytesPerSecond, + protocol::{ + messages::{Handshake, PeerMessages}, + stream::{PeerRecv, PeerSend, PeerStream}, + }, settings::Settings, testing, torrent::{ BLOCK_SIZE, Torrent, TorrentSnapshot, commands::{GetState, HasInfoDict, SetState, SnapshotState}, - events::IncomingPiece, + 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, @@ -1158,6 +1234,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, + frontend: FrontendHub::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() { diff --git a/crates/libtortillas/src/torrent/choking_flow.rs b/crates/libtortillas/src/torrent/choking_flow.rs index 71c8cd76..24ae3933 100644 --- a/crates/libtortillas/src/torrent/choking_flow.rs +++ b/crates/libtortillas/src/torrent/choking_flow.rs @@ -19,11 +19,18 @@ 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_live_view(|view| { crate::frontend::TorrentEventKind::MetricsChanged(view.metrics.clone()) }); + self.try_update_tracker_progress(); let decision = self.choking_scheduler.decide(&peer_stats, self.state); let unchoked: HashSet<_> = decision.unchoked.iter().copied().collect(); @@ -47,6 +54,11 @@ 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 { diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index f75dcc6a..8146b797 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -85,8 +85,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 @@ -134,17 +137,18 @@ 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"); @@ -167,6 +171,7 @@ pub(crate) mod commands { } frontend.disconnected(); self.publish_live_view(|_| TorrentEventKind::Updated); + self.fill_all_peer_request_windows(); } #[message] diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index 19ce07ae..8ce763c4 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -101,53 +101,89 @@ 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"); } + } - self.publish_live_view(|view| { - crate::frontend::TorrentEventKind::MetricsChanged(view.metrics.clone()) - }); + 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) async fn request_blocks_from_peer( - &mut self, peer_id: crate::peer::PeerId, limit: usize, - ) { + 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) { 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(), ); @@ -158,12 +194,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"); } } } @@ -227,16 +265,17 @@ 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_live_view(|view| { + crate::frontend::TorrentEventKind::MetricsChanged(view.metrics.clone()) + }); + self.fill_peer_request_window(peer_id); return; } @@ -249,23 +288,30 @@ 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_live_view(|view| { + crate::frontend::TorrentEventKind::MetricsChanged(view.metrics.clone()) + }); if self.piece_scheduler.next_piece() >= piece_count { + 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"); @@ -276,12 +322,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; }; @@ -296,21 +336,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; } } @@ -329,12 +359,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; } }; @@ -344,10 +368,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; } } @@ -415,7 +435,6 @@ mod tests { use crate::{ hashes::HashVec, metainfo::{Info, InfoKeys, MetaInfo, TorrentFile}, - peer::PeerId, pieces::{FilePieceManager, PieceScheduler, PieceStoreActor}, settings::Settings, testing, @@ -523,11 +542,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!( @@ -562,11 +577,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/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index 2a69b329..0c4775a9 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -178,6 +178,32 @@ impl TorrentActor { } } + /// 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); + } + } + #[instrument(skip(self, message), fields(torrent_id = %self.info_hash()))] pub(super) async fn update_trackers(&mut self, message: TrackerUpdate) { let actor_refs: Vec<(Tracker, ActorRef)> = self @@ -212,6 +238,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, From 85566103c274a611b3fd465d5122465d7a89dd2e Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 19:23:42 -0700 Subject: [PATCH 64/77] docs: move architecture guides into rustdoc --- README.md | 2 +- crates/libtortillas/src/ARCHITECTURE.md | 220 ------------------------ crates/libtortillas/src/engine/mod.rs | 16 ++ crates/libtortillas/src/frontend/mod.rs | 157 ++++++++++++++++- crates/libtortillas/src/lib.rs | 34 ++++ crates/libtortillas/src/metrics.rs | 12 ++ crates/libtortillas/src/settings.rs | 7 + crates/libtortillas/src/torrent/mod.rs | 82 +++++++++ docs/frontend-integration.md | 109 ------------ 9 files changed, 303 insertions(+), 336 deletions(-) delete mode 100644 crates/libtortillas/src/ARCHITECTURE.md delete mode 100644 docs/frontend-integration.md diff --git a/README.md b/README.md index 683a7c0e..35b99a05 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ client, clock, listener, or storage executor. Frontends should use live listeners for updates and direct `Engine` and `Torrent` methods for operations rather than polling persistence snapshots. See the -[frontend integration guide](docs/frontend-integration.md) and the +[`libtortillas::frontend` API documentation](https://docs.rs/libtortillas/latest/libtortillas/frontend/) and the [`live_frontend` example](crates/libtortillas/examples/live_frontend.rs). ## 🤝 Contributing diff --git a/crates/libtortillas/src/ARCHITECTURE.md b/crates/libtortillas/src/ARCHITECTURE.md deleted file mode 100644 index 70da2563..00000000 --- a/crates/libtortillas/src/ARCHITECTURE.md +++ /dev/null @@ -1,220 +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. - -The frontend boundary uses a small, reader-oriented module layout: - -```text -metrics.rs canonical units, transfer metrics, and aggregation -frontend/ -├── mod.rs public map and exports -├── view.rs current presentation models -├── event.rs discrete event contracts -├── stream.rs publisher, subscription, listener, and closure lifecycle -├── handle.rs peer and tracker identity-bearing access -├── hub.rs complete ownership tree and publication coordination -└── tests.rs private invariants and performance proof -``` - -The ownership path is deliberately kept in one `hub.rs`. Engine, torrent, peer, -and tracker publication are sections of one coordinator rather than separate -files, so a reader can follow a state change without navigating between small -modules. - -## Architectural Invariants - -These rules define the source of truth: - -1. Actors own operational domain state. -2. A live scope owns only its frontend projection. -3. Parent views are derived from child scopes; they never keep manually synchronized child-view copies. -4. Every scope has one view-and-event publication entry point. -5. Peer and tracker events do not implicitly rebuild torrent or engine state. -6. A scope closes exactly once, only when it cannot restart. -7. Snapshot schema validation runs once at the authoritative engine restore boundary. -8. Actor and hub back-references are weak; the ownership graph contains no strong cycle. -9. Synchronous lock order is registry, scope publication/state, then event sender. -10. No actor communication, filesystem operation, arbitrary callback, or `.await` occurs while a synchronous lock is held. - -## Frontend Boundary - -`Engine` and `Torrent` own the stable application boundary. Their direct -methods are the only public command API, while `listener` combines a bounded -event subscription with current `EngineView` or `TorrentView` state. A shared -frontend hub owns engine lifecycle state and a keyed registry of torrent -scopes. Each torrent scope owns its live torrent publisher plus its peer and -tracker registries. The torrent scope also retains the one backing -`TorrentInner`; there is no parallel keyed handle registry to synchronize. -`TorrentInner` retains only the shared live publisher and a weak hub -back-reference, so this ownership path does not form a cycle. Peer and tracker -handles likewise hold weak hub back-references. Every live publisher has an -irreversible terminal state, so actor updates cannot resurrect removed -objects. - -`EngineView` is derived on read from engine lifecycle state and current torrent -scopes, sorted by info hash. The engine does not cache a second -`Vec`. Peer state and metric updates therefore touch only one peer -scope. Peer connection and disconnection are propagated separately as discrete -parent events without cloning unrelated torrent projections. - -Engine events project the canonical `TorrentEventKind` hierarchy through -`EngineEventKind::Torrent`; they do not duplicate every torrent, peer, and -tracker event in a second vocabulary. - -Live views are intentionally distinct from `EngineSnapshot` and -`TorrentSnapshot`. Views are presentation-oriented and continuously updated by -events. Snapshots are versioned, Serde-compatible persistence records that -capture metadata, storage configuration, lifecycle intent, and piece progress -for later restoration. Frontends must not poll persistence snapshots to render -live state. - -Event channels are allocated lazily on first subscription. Their capacities are -configured independently through `FrontendSettings`. - -`LivePublisher` mutation names state the complete transition: -`replace_view`, `replace_view_and_emit`, `emit_without_view_change`, and -`close_with_terminal_event`. Coordination code does not hide those effects -behind generic `update` or `publish` methods. - -## Metrics - -Bytes are the canonical internal unit. Peer state, peer statistics, live peer -views, and torrent aggregation share `TransferMetrics`; projection code never -converts KiB/s to bytes/s. `TrafficTotals` describe wire traffic and remain -separate from verified `ContentProgress`. `None` rates mean no sample exists, -while a present zero rate means a sample measured no transfer. ETA is derived -from remaining verified content and aggregate sampled download rate. - -Peer actors publish peer-local samples. `TorrentActor` publishes one coalesced -`TorrentMetrics` update after periodic peer-stat collection. - -`PeerEventKind::StateChanged` and `PeerEventKind::MetricsChanged` remain local -to the peer listener. Root propagation is reserved for connection lifecycle, -tracker lifecycle, and coalesced torrent metrics. - -## Persistence Boundary - -Restoration is ordered as: - -```text -schema validation - -> storage reconciliation - -> actor-state installation - -> optional transfer resumption -``` - -`.torrent` sources store `Info` only inside `MetaInfo`; only resolved magnet -metadata uses `resolved_magnet_info`. `TorrentSnapshot::resolved_info` is the -canonical resolver. Custom piece managers return a typed unsupported error -until a durable descriptor/factory contract exists. - -Full storage verification is the default. It hashes completed payload, demotes -missing or corrupt pieces, and clears partial-block bits whose referenced bytes -do not exist. `TrustSnapshot` is explicit and unsafe. - -Snapshot JSON is a durable contract. Version 1 is migrated during -deserialization to version 2. Version 2 uses `u64` for portable numeric fields, -sorted vectors for keyed scheduler state, and `Vec` for bitfields instead -of serializing `DashMap`, `usize`, or `BitVec` implementation details. Every -supported version has a golden JSON fixture. Unsupported future versions remain -typed validation errors. - -Tracker and torrent actors publish `Restarting` after abnormal supervised -termination and keep their scopes open. Only normal, final ownership teardown -publishes `Stopped` and closes the scope tree. - -## Locking and Publication - -The lock hierarchy is registry shard, scope publication/state, then event -sender. `ScopeRegistry` is not a replacement concurrent map: it is a narrow -policy wrapper around `DashMap` that prevents shard guards from escaping. -Registry methods return cloned `Arc` values or owned vectors. Every shard guard -is therefore released before a scope publication lock is acquired. Scope -construction happens before shard entry acquisition, so callbacks do not run -under a registry lock. A scope publication lock serializes its view transition, -scoped event, and corresponding root event. `LivePublisher` then acquires its -state lock before its optional sender lock. No code acquires a registry guard -while holding a child scope lock, and no synchronous lock crosses an `.await`. - -## 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. Every -application adapter should create one Tokio runtime at process startup and run -`Engine` plus all torrent handle operations on that runtime. Synchronous or -blocking adapter work should be isolated from async torrent work through -channels or an adapter-owned thread. `tokio::task::spawn_blocking` is suitable -for bounded blocking operations, but not long-lived blocking loops: a blocking -task cannot be aborted after it starts and can delay runtime shutdown. - -`libtortillas` contains no rendering, input-device, transport-server, or -framework-specific policy. Terminal interfaces, HTTP/WebSocket servers, web -backends, and desktop applications are peer adapters of the same facade. They -consume serializable views and typed event streams and translate user intent -into handle operations outside this crate. - -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 carried by live views and persistence 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/mod.rs b/crates/libtortillas/src/engine/mod.rs index 0c1b6643..131aaf72 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -14,6 +14,19 @@ //! - 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 @@ -41,6 +54,9 @@ //! 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; diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index 373e6320..d5d117a3 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -1,19 +1,164 @@ //! Transport-agnostic live application API. //! -//! The module is intentionally organized by the way a consumer reads it: +//! Terminal interfaces, HTTP or WebSocket servers, websites, and desktop +//! applications all consume this same API. Rendering, transport, input, and +//! application routing policy remain outside `libtortillas`. +//! +//! # Public model +//! +//! The module is organized by the way an application reads it: //! //! - [`EngineView`], [`TorrentView`], [`PeerView`], and [`TrackerView`] are //! current presentation state. //! - Shared measurements live in [`crate::metrics`] and are re-exported here. //! - Event enums describe discrete changes. //! - [`EventSubscription`] is events only; [`EventListener`] pairs events with -//! a current view. +//! a coherent current view. //! - [`PeerHandle`] and [`TrackerHandle`] provide scoped identity and access. -//! - `hub` is the single internal ownership and publication coordinator. +//! - The private hub owns the complete live projection tree and coordinates +//! publication. +//! +//! [`crate::engine::Engine`] and [`crate::torrent::Torrent`] remain the sole +//! public command API. There is no parallel command enum or generic `send` +//! method for application operations. +//! +//! # Listening to an engine +//! +//! Create a listener before starting operations when the application must not +//! miss their events. Use [`EventListener::view`] for initial rendering and +//! lag recovery, and [`EventListener::recv`] for future changes. +//! +//! ```no_run +//! use libtortillas::prelude::{Engine, EngineEventKind, EventStreamError}; +//! +//! # async fn run() -> Result<(), Box> { +//! let engine = Engine::default(); +//! let mut listener = engine.listener(); +//! let initial_view = listener.view(); +//! +//! loop { +//! match listener.recv().await { +//! Ok(event) => { +//! let current_view = listener.view(); +//! // Render, serialize, or forward `current_view` and `event`. +//! let _ = current_view; +//! if matches!(event.kind, EngineEventKind::Shutdown(_)) { +//! break; +//! } +//! } +//! Err(EventStreamError::Lagged(_)) => { +//! // Discard adapter-local assumptions and redraw from current state. +//! let current_view = listener.view(); +//! let _ = current_view; +//! } +//! Err(EventStreamError::Closed) => break, +//! } +//! } +//! # let _ = initial_view; +//! # Ok(()) +//! # } +//! ``` +//! +//! Every [`crate::torrent::Torrent`] has its own `listener()` and +//! `subscribe()` methods. Peers and trackers returned by `Torrent::peers()` and +//! `Torrent::trackers()` follow the same pattern. A scoped listener receives +//! only that scope's events; it does not filter the engine stream. +//! +//! Engine listeners receive [`EngineEventKind::Torrent`], whose nested event +//! uses the same [`TorrentEventKind`] vocabulary as the torrent listener. +//! Peer and tracker lifecycle events carry public handles, allowing an +//! application to descend into detailed streams only when needed. +//! +//! Use `subscribe()` when only discrete events are needed. Use `listener()` +//! when initial rendering or recovery requires a current view as well. +//! +//! # Ownership and source of truth +//! +//! ```text +//! EngineActor ── owns operational engine state +//! FrontendHub +//! ├── engine lifecycle and event publisher +//! └── keyed torrent scopes +//! └── torrent view and event publisher +//! ├── keyed peer scopes +//! └── keyed tracker scopes +//! +//! EngineView = engine lifecycle + views derived from current torrent scopes +//! ``` +//! +//! The engine never caches a second `Vec`. [`EngineListener`] +//! derives [`EngineView`] on read from current torrent scopes and sorts them by +//! info hash. Peer-only changes therefore touch one peer scope and cannot make +//! a copied engine projection drift from the torrent projection. +//! +//! The private scope registry is a policy wrapper around `DashMap`, not a +//! replacement concurrent map. It prevents shard guards from escaping by +//! returning cloned `Arc` values or owned vectors. Peer and tracker registries +//! are nested under their torrent, making lookup and removal proportional to +//! that torrent's children. +//! +//! # Architectural invariants +//! +//! These rules define the live API's source of truth: +//! +//! 1. Actors own operational domain state. +//! 2. A live scope owns only its frontend projection. +//! 3. Parent views are derived from child scopes; they do not maintain manually +//! synchronized child-view copies. +//! 4. Every scope has one view-and-event publication entry point. +//! 5. Peer and tracker events do not implicitly rebuild torrent or engine +//! state. +//! 6. A scope closes exactly once, only when it cannot restart. +//! 7. Snapshot schema validation runs once at the authoritative engine restore +//! boundary. +//! 8. Actor and hub back-references are weak; the ownership graph contains no +//! strong cycle. +//! 9. Synchronous lock order is registry shard, scope publication/state, then +//! event sender. +//! 10. Actor communication, filesystem work, arbitrary callbacks, and `.await` +//! never occur while a synchronous lock is held. +//! +//! # Event delivery and lifecycle +//! +//! Channels are allocated lazily on first subscription. Defaults retain 256 +//! engine or torrent events and 64 peer or tracker events; all capacities are +//! configurable with [`crate::settings::FrontendSettings`]. A slow consumer +//! receives [`EventStreamError::Lagged`] instead of causing unbounded memory +//! growth. Sequence numbers increase monotonically within each scope. +//! +//! [`LivePublisher`] mutation names describe their full effect: +//! [`LivePublisher::replace_view`] changes only the projection, +//! [`LivePublisher::replace_view_and_emit`] performs a coherent view/event +//! transition, [`LivePublisher::emit_without_view_change`] emits a discrete +//! event, and [`LivePublisher::close_with_terminal_event`] performs the one +//! irreversible close transition. +//! +//! Supervised torrent and tracker actors publish a restarting state after +//! abnormal termination and keep their streams open. Final ownership teardown +//! publishes the terminal state once, closes the scope tree, and rejects late +//! actor updates. +//! +//! # Locking and publication +//! +//! Registry methods release their `DashMap` shard guard before acquiring a +//! scope lock. Scope construction occurs before shard entry acquisition, so +//! callbacks never execute under a registry lock. A scope publication lock +//! serializes its view transition, scoped event, and corresponding root event. +//! [`LivePublisher`] then acquires its state lock before its optional sender +//! lock. No path acquires a registry guard while holding a child scope lock, +//! and no synchronous lock crosses an `.await`. +//! +//! # Views and persistence +//! +//! Views are presentation contracts suitable for rendering, API responses, +//! and transport serialization. [`crate::engine::EngineSnapshot`] and +//! [`crate::torrent::TorrentSnapshot`] are durable persistence contracts. +//! Applications must not poll snapshots to refresh a frontend. See +//! [`crate::torrent`] for restore validation and storage reconciliation rules. //! -//! Terminal interfaces, HTTP/WebSocket servers, web backends, and desktop -//! applications all consume this same API. Rendering, transport, and input -//! policy remain outside `libtortillas`. +//! Application-specific action routing can use an adapter-owned Tokio channel +//! whose consumer invokes methods on `Engine` and `Torrent`. That keeps UI or +//! server commands outside the library without duplicating its public API. mod event; mod handle; diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index 4246bba0..33945f61 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -1,5 +1,35 @@ //! Async BitTorrent engine for building Tortillas frontends. //! +//! # Architecture +//! +//! The runtime is organized as a supervised actor tree: +//! +//! ```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`], [`torrent::Torrent`], and the transport-agnostic +//! [`frontend`] views and event streams. Durable state is represented by +//! [`engine::EngineSnapshot`] and [`torrent::TorrentSnapshot`], never by live +//! presentation views. +//! +//! Stable public types are exported by module facades while actor messages and +//! coordination details remain crate-private. Domain values such as lifecycle +//! state, storage strategy, metrics, and snapshots live outside actor files so +//! actors can focus on orchestration. +//! +//! See [`frontend`] for the source-of-truth, publication, lifecycle, and lock +//! invariants. See [`torrent`] for transfer scheduling and persistence +//! semantics. +//! //! # Runtime boundary //! //! `libtortillas` is intentionally a Tokio-based library. Public handles such @@ -11,6 +41,10 @@ //! 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. +//! Synchronous adapter work should communicate with async engine tasks through +//! channels or a dedicated adapter thread. [`tokio::task::spawn_blocking`] is +//! appropriate for bounded blocking work, but not for a permanent input loop: +//! a blocking task cannot be aborted after it starts and can delay shutdown. //! //! An application can use `#[tokio::main]` on its binary entry point, or create //! an explicit Tokio runtime before initializing `Engine`. diff --git a/crates/libtortillas/src/metrics.rs b/crates/libtortillas/src/metrics.rs index 514d1d70..00adb1f9 100644 --- a/crates/libtortillas/src/metrics.rs +++ b/crates/libtortillas/src/metrics.rs @@ -1,5 +1,17 @@ //! 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. +//! +//! An absent rate sample means no sample has been collected. A present +//! [`TransferRates`] containing zero means an interval was measured and no +//! transfer occurred. ETA is derived from verified remaining bytes and the +//! aggregate sampled download rate rather than stored as independently mutable +//! state. use std::time::{Duration, Instant}; diff --git a/crates/libtortillas/src/settings.rs b/crates/libtortillas/src/settings.rs index 05126a6b..2f2fb662 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] = [ diff --git a/crates/libtortillas/src/torrent/mod.rs b/crates/libtortillas/src/torrent/mod.rs index 7f8f448e..b9b17c2b 100644 --- a/crates/libtortillas/src/torrent/mod.rs +++ b/crates/libtortillas/src/torrent/mod.rs @@ -1,3 +1,85 @@ +//! One torrent's lifecycle, transfer coordination, storage, and persistence. +//! +//! # Operational ownership +//! +//! `TorrentActor` is the authoritative owner of torrent state. It coordinates +//! one peer actor per connection, one tracker actor per endpoint, piece +//! scheduling, verified progress, and storage. The public [`Torrent`] handle +//! exposes commands while [`crate::frontend::TorrentListener`] exposes the +//! presentation projection and typed events. +//! +//! High-frequency peer protocol state remains local to peer scopes. Torrent +//! transfer metrics are aggregated after periodic peer-stat collection rather +//! than republishing the complete hierarchy for every wire message. Tracker +//! progress is likewise sampled; final lifecycle announcements receive a +//! reliable current value. +//! +//! The piece scheduler fills a bounded request window for each peer, considers +//! that peer's advertised bitfield, releases requests when a peer disconnects +//! or rejects them, and makes unanswered requests eligible for reassignment +//! after [`crate::settings::TorrentSettings::peer_request_timeout`]. Piece +//! completion refills the consumed window slot, so a long download cannot +//! drain its pipeline one piece at a time. +//! +//! # 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`]. +//! [`TorrentState::Paused`] is an explicit user state and is not eligible for +//! autostart. Shutdown, supervision, and failure paths remain distinct through +//! `Restarting`, `Stopping`, `Stopped`, and `Failed` states. +//! +//! # Persistence boundary +//! +//! Live views and persistence snapshots are deliberately separate. Restoration +//! follows one ordered transaction: +//! +//! ```text +//! schema validation +//! -> storage reconciliation +//! -> actor-state installation +//! -> optional transfer resumption +//! ``` +//! +//! Snapshot schema validation occurs once in the engine actor. Internal restore +//! APIs accept a validated wrapper so torrent actors cannot repeat or bypass +//! that boundary. +//! +//! A `.torrent` source stores its `Info` dictionary only inside +//! [`crate::metainfo::MetaInfo`]. Only a resolved magnet stores separate +//! resolved metadata, and [`TorrentSnapshot::resolved_info`] is the canonical +//! resolver used by validation and restoration. +//! +//! [`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. +//! +//! Arbitrary custom piece-manager trait objects have no implicit persistence +//! representation. Snapshotting one returns a typed unsupported error instead +//! of silently restoring it as a different storage implementation. +//! +//! Snapshot JSON is a versioned durable contract: portable numeric fields use +//! `u64`, keyed scheduler state is sorted, and bitfields serialize as +//! `Vec` rather than implementation-specific concurrent collections. +//! Migrations are explicit and supported versions have golden fixtures. +//! +//! # Choking +//! +//! Active torrents periodically collect peer transfer samples and recalculate +//! upload slots. Downloads prefer interested peers with the highest recent +//! download rate; seeds prefer recent upload rate. One slot rotates as an +//! optimistic unchoke when the interested set exceeds available slots. +//! `PeerActor` remains responsible for the corresponding wire-level `Choke` +//! and `Unchoke` messages. + mod actor; mod block; mod choking; diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md deleted file mode 100644 index 969c54e4..00000000 --- a/docs/frontend-integration.md +++ /dev/null @@ -1,109 +0,0 @@ -# Frontend integration - -`libtortillas` exposes live frontend behavior directly on `Engine` and -`Torrent`. Applications do not need actor references, protocol messages, or a -polling loop. - -## Live listeners - -Call `Engine::listener()` before invoking operations. The listener combines two -related capabilities: - -- `recv().await` yields sequenced `EngineEvent` values as changes happen. -- `view()` derives the latest presentation-oriented `EngineView` from the live - scope tree. - -Every `Torrent` returned by `Engine::add_torrent()` similarly has `listener()` -and `subscribe()` methods. A torrent listener has its own scope, receives -typed `TorrentEvent` values for that torrent only, and exposes its latest -`TorrentView`. It does not filter the engine's global event stream. - -Peers and trackers returned by `Torrent::peers()` and `Torrent::trackers()` -follow the same pattern. Each `PeerHandle` and `TrackerHandle` owns an -independent typed listener and current view, including a terminal disconnected -or stopped view. Engine listeners receive -`EngineEventKind::Torrent { torrent, event }`, where `event` uses the same -`TorrentEventKind` vocabulary as the torrent listener. Peer and tracker -changes carry their public handles inside that nested event, so a frontend can -descend into more detailed streams only when needed. - -Event channels are allocated lazily on first subscription. Defaults retain 256 -engine or torrent events and 64 peer or tracker events; all four capacities are -configurable through `FrontendSettings`. Slow listeners receive -`EventStreamError::Lagged` instead of causing unbounded memory growth. After -lagging, rebuild adapter state from `listener.view()` and continue calling -`recv()`. Sequence numbers are monotonic within each scope. - -Use `subscribe()` when only discrete events are needed. Use `listener()` when -the frontend also needs a coherent current view for initial rendering or lag -recovery. - -## Operations - -`Engine` and `Torrent` methods are the sole public command API. Call -`engine.add_torrent(...)`, `engine.remove_torrent(...)`, or -`engine.shutdown()` directly; call `torrent.start()`, `torrent.pause()`, and -configuration methods directly on a `Torrent` handle. There is no parallel -command enum or generic `send` method duplicating these operations. - -Applications that need to funnel UI actions through a task can put their own -application command type on a Tokio channel and call these methods in its -consumer. That keeps application-specific routing outside the engine without -making the library maintain two representations of every operation. - -`resume()` aliases `start()` and `stop()` aliases `pause()` because those pairs -currently produce the same engine transition. - -## Reusable live publishers - -`LivePublisher`, `EventListener`, and `EventSubscription` are -generic over the current view and event type. `EventListener` and -`EventSubscription` implement `futures::Stream`, while `recv()` supports the -usual Tokio-style loop. Engine, torrent, peer, and tracker APIs all reuse these -types; future protocols can expose the same behavior without another listener -implementation. - -Publisher mutation names state their complete effect: -`replace_view()` changes only the current projection, -`replace_view_and_emit()` performs a coherent view/event transition, -`emit_without_view_change()` emits a discrete event, and -`close_with_terminal_event()` performs the one irreversible close transition. - -## Views and persistence snapshots - -`EngineView`, `TorrentView`, `PeerView`, and `TrackerView` are live presentation -contracts. They are suitable for rendering, API responses, or transport -serialization and are Serde-compatible. - -`Engine::snapshot()` and `Torrent::snapshot()` are not the live frontend path. -Snapshots are the persistence boundary for serializing resumable engine and -torrent state in an application-selected Serde format. Frontends should never -poll snapshots to refresh the UI. - -For example, an application can serialize `engine.snapshot().await?` with -`serde_json`, `postcard`, `rmp-serde`, or another format, then deserialize it -and call `engine.restore(snapshot).await?` in a later process. Use -`engine.restore_torrent(snapshot).await?` for one torrent. Snapshot schemas are -versioned so incompatible data returns a typed error. - -Engine restore is validated and applied as one engine-actor operation. The -target must be empty when that operation begins, and a failed multi-torrent -restore removes everything created by that operation. - -Snapshots retain metadata, lifecycle intent, storage paths and strategy, -verified pieces, and partial blocks. Downloaded bytes remain in the referenced -storage paths; the snapshot does not duplicate payload data into frontend -state. - -## Runtime and shutdown - -The library is Tokio-based. Keep the engine, torrent handles, application tasks, -and listener tasks on the application runtime. Any blocking adapter work should -run separately from those async tasks. - -Call `Engine::shutdown()` and keep the engine -listener alive until it receives `EngineEventKind::Shutdown`. This ensures the -frontend observes the terminal state after managed torrents stop. - -See [`live_frontend.rs`](../crates/libtortillas/examples/live_frontend.rs) for a -compiling operation/listener loop. From 87dca15be1ee9fdcd6aefad20b15e777b207a604 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 19:33:07 -0700 Subject: [PATCH 65/77] docs: lead crate docs with download example --- crates/libtortillas/src/lib.rs | 85 +++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 23 deletions(-) diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index 33945f61..106ba036 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -1,34 +1,40 @@ //! Async BitTorrent engine for building Tortillas frontends. //! -//! # Architecture +//! # Quick start //! -//! The runtime is organized as a supervised actor tree: +//! The following program downloads the payload described by a local +//! `.torrent` file into `downloads/`. Torrents start automatically once they +//! have metadata and enough peers. //! -//! ```text -//! EngineActor -//! ├── DhtActor (one shared instance) -//! └── TorrentActor (one per torrent) -//! ├── TrackerActor (one per tracker) -//! └── PeerActor (one per connected peer) +//! ```no_run +//! use libtortillas::prelude::{Engine, TorrentSource, TorrentState}; //! -//! DhtActor ───── discovered peers ────> TorrentActor -//! TrackerActor ── discovered peers ────> TorrentActor -//! ``` +//! #[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?; +//! let mut listener = torrent.listener(); //! -//! Actors own operational protocol state. Public applications interact through -//! [`engine::Engine`], [`torrent::Torrent`], and the transport-agnostic -//! [`frontend`] views and event streams. Durable state is represented by -//! [`engine::EngineSnapshot`] and [`torrent::TorrentSnapshot`], never by live -//! presentation views. +//! // The listener's view is always current, even if an event is missed. +//! loop { +//! if matches!(listener.view(), Some(view) if view.state == TorrentState::Seeding) { +//! break; +//! } +//! listener.recv().await?; +//! } //! -//! Stable public types are exported by module facades while actor messages and -//! coordination details remain crate-private. Domain values such as lifecycle -//! state, storage strategy, metrics, and snapshots live outside actor files so -//! actors can focus on orchestration. +//! println!("download complete: {}", torrent.info_hash()); +//! engine.shutdown().await?; +//! Ok(()) +//! } +//! ``` //! -//! See [`frontend`] for the source-of-truth, publication, lifecycle, and lock -//! invariants. See [`torrent`] for transfer scheduling and persistence -//! semantics. +//! [`engine::TorrentSource`] also accepts magnet URIs, in-memory `.torrent` +//! bytes, and remote `.torrent` URLs. See the repository's +//! [examples directory](https://github.com/artrixdotdev/tortillas/tree/main/crates/libtortillas/examples) +//! for complete runnable programs. //! //! # Runtime boundary //! @@ -76,6 +82,39 @@ //! Engine and torrent handles expose listeners for live UI updates. Persistence //! snapshots are intentionally separate and should not be polled for display //! changes. +//! +//! # 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: +//! +//! ```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`], [`torrent::Torrent`], and the transport-agnostic +//! [`frontend`] views and event streams. Durable state is represented by +//! [`engine::EngineSnapshot`] and [`torrent::TorrentSnapshot`], never by live +//! presentation views. +//! +//! Stable public types are exported by module facades while actor messages and +//! coordination details remain crate-private. Domain values such as lifecycle +//! state, storage strategy, metrics, and snapshots live outside actor files so +//! actors can focus on orchestration. +//! +//! See [`frontend`] for the source-of-truth, publication, lifecycle, and lock +//! invariants. See [`torrent`] for transfer scheduling and persistence +//! semantics. pub(crate) mod dht; pub mod engine; From fe279be37bac512878991718c72682f760a0c0e2 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 19:37:13 -0700 Subject: [PATCH 66/77] docs: make crate landing page a practical guide --- crates/libtortillas/src/lib.rs | 178 ++++++++++++++++++++++++++------- 1 file changed, 140 insertions(+), 38 deletions(-) diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index 106ba036..a811ea05 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -1,13 +1,23 @@ -//! Async BitTorrent engine for building Tortillas frontends. +//! Async BitTorrent library for downloading and seeding files. //! -//! # Quick start +//! # Getting started //! -//! The following program downloads the payload described by a local -//! `.torrent` file into `downloads/`. Torrents start automatically once they -//! have metadata and enough peers. +//! A basic downloader only needs an [`engine::Engine`] and a +//! [`engine::TorrentSource`]. The live frontend API is optional. +//! +//! Add the library and its Tokio runtime to a binary crate: +//! +//! ```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, TorrentState}; +//! use libtortillas::prelude::{Engine, TorrentSource}; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { @@ -15,28 +25,137 @@ //! 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`] +//! returned by [`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: +//! +//! - [`engine::TorrentSource::torrent_file_path`] for a local `.torrent` file. +//! - [`engine::TorrentSource::magnet`] for a magnet URI. +//! - [`engine::TorrentSource::torrent_file_bytes`] for bytes already in memory. +//! - [`engine::TorrentSource::remote_torrent_url`] for an HTTP or HTTPS URL. +//! +//! Every source is passed to [`engine::Engine::add_torrent`] in the same way. +//! There is no frontend-specific setup. +//! +//! For example, downloading from a magnet link only changes the source: +//! +//! ```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:0123456789abcdef0123456789abcdef01234567", +//! )) +//! .await?; +//! +//! println!("torrenting {}", torrent.info_hash()); +//! Ok(()) +//! } +//! ``` +//! +//! ## Basic control +//! +//! The returned [`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::Engine::remove_torrent`] to remove one and +//! [`engine::Engine::shutdown`] before exiting cleanly. +//! +//! ```no_run +//! use libtortillas::prelude::Torrent; +//! +//! async fn pause_and_resume(torrent: &Torrent) -> Result<(), Box> { +//! torrent.pause().await?; +//! println!("state after pausing: {:?}", torrent.state().await?); +//! +//! torrent.resume().await?; +//! Ok(()) +//! } +//! ``` +//! +//! ## 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 live frontend integration. +//! +//! # Live updates are optional +//! +//! Applications that only need to download and seed files do not need +//! [`frontend`] listeners, events, views, or metrics. Those APIs exist for +//! applications that want to display live progress or forward state through a +//! terminal, web server, website, or desktop application. +//! +//! When live updates are useful, start with [`frontend::EventListener`] and the +//! current view exposed by [`frontend::EventListener::view`]. The +//! [`frontend`] module documents the complete transport-agnostic model. +//! +//! This helper waits for changes and prints verified payload progress until the +//! torrent finishes downloading: +//! +//! ```no_run +//! use libtortillas::prelude::{Torrent, TorrentState}; +//! +//! async fn show_progress(torrent: &Torrent) -> Result<(), Box> { //! let mut listener = torrent.listener(); //! -//! // The listener's view is always current, even if an event is missed. //! loop { -//! if matches!(listener.view(), Some(view) if view.state == TorrentState::Seeding) { -//! break; +//! 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?; //! } //! -//! println!("download complete: {}", torrent.info_hash()); -//! engine.shutdown().await?; //! Ok(()) //! } //! ``` //! -//! [`engine::TorrentSource`] also accepts magnet URIs, in-memory `.torrent` -//! bytes, and remote `.torrent` URLs. See the repository's -//! [examples directory](https://github.com/artrixdotdev/tortillas/tree/main/crates/libtortillas/examples) -//! for complete runnable programs. -//! -//! # Runtime boundary +//! # Runtime and advanced APIs //! //! `libtortillas` is intentionally a Tokio-based library. Public handles such //! as [`engine::Engine`] and [`torrent::Torrent`] expose async methods that @@ -52,32 +171,15 @@ //! appropriate for bounded blocking work, but not for a permanent input loop: //! a blocking task cannot be aborted after it starts and can delay shutdown. //! -//! An application can use `#[tokio::main]` on its binary entry point, or create +//! An application can use `#[tokio::main]`, as in the example above, or create //! an explicit Tokio runtime before initializing `Engine`. //! -//! # Frontend facade -//! -//! Frontends should prefer [`facade`] or [`prelude`] imports. The facade names -//! the stable concepts any application adapter needs: [`engine::Engine`], -//! [`torrent::Torrent`], [`facade::TorrentSource`], -//! [`facade::EngineEvent`], and live engine, torrent, -//! peer, and tracker views. -//! -//! ```no_run -//! use libtortillas::prelude::{Engine, TorrentSource}; -//! -//! let engine = Engine::default(); -//! let source = TorrentSource::magnet("magnet:?xt=urn:btih:..."); -//! # let _ = (engine, source); -//! ``` -//! -//! # Advanced APIs -//! //! 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. //! //! Engine and torrent handles expose listeners for live UI updates. Persistence //! snapshots are intentionally separate and should not be polled for display From 96c7821bed330bfe3eefa2142c6facfa65f9e81f Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Fri, 24 Jul 2026 19:44:17 -0700 Subject: [PATCH 67/77] docs: verify getting started workflows --- crates/libtortillas/src/engine/mod.rs | 8 ++- crates/libtortillas/src/lib.rs | 63 ++++++++++--------- crates/libtortillas/tests/engine_lifecycle.rs | 63 ++++++++++++++++--- 3 files changed, 96 insertions(+), 38 deletions(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 131aaf72..00ac9bcf 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -540,8 +540,8 @@ mod tests { frontend::{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, }; @@ -639,7 +639,9 @@ 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 snapshot = engine.snapshot().await.unwrap(); diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index a811ea05..00032f08 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -2,8 +2,8 @@ //! //! # Getting started //! -//! A basic downloader only needs an [`engine::Engine`] and a -//! [`engine::TorrentSource`]. The live frontend API is optional. +//! A basic downloader only needs an [`Engine`](engine::Engine) and a +//! [`TorrentSource`](engine::TorrentSource). The live frontend API is optional. //! //! Add the library and its Tokio runtime to a binary crate: //! @@ -35,21 +35,26 @@ //! ``` //! //! That is enough to start torrenting. By default, a newly added torrent starts -//! automatically after it discovers enough peers. The [`torrent::Torrent`] -//! returned by [`engine::Engine::add_torrent`] is a lightweight handle for -//! controlling that download. +//! 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: //! -//! - [`engine::TorrentSource::torrent_file_path`] for a local `.torrent` file. -//! - [`engine::TorrentSource::magnet`] for a magnet URI. -//! - [`engine::TorrentSource::torrent_file_bytes`] for bytes already in memory. -//! - [`engine::TorrentSource::remote_torrent_url`] for an HTTP or HTTPS URL. +//! - [`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::Engine::add_torrent`] in the same way. -//! There is no frontend-specific setup. +//! Every source is passed to +//! [`Engine::add_torrent`](engine::Engine::add_torrent) in the same way. There +//! is no frontend-specific setup. //! //! For example, downloading from a magnet link only changes the source: //! @@ -59,7 +64,7 @@ //! async fn add_magnet(engine: &Engine) -> Result<(), Box> { //! let torrent = engine //! .add_torrent(TorrentSource::magnet( -//! "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", +//! "magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny", //! )) //! .await?; //! @@ -70,12 +75,12 @@ //! //! ## Basic control //! -//! The returned [`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::Engine::remove_torrent`] to remove one and -//! [`engine::Engine::shutdown`] before exiting cleanly. +//! 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::Torrent; @@ -124,9 +129,10 @@ //! applications that want to display live progress or forward state through a //! terminal, web server, website, or desktop application. //! -//! When live updates are useful, start with [`frontend::EventListener`] and the -//! current view exposed by [`frontend::EventListener::view`]. The -//! [`frontend`] module documents the complete transport-agnostic model. +//! When live updates are useful, start with +//! [`EventListener`](frontend::EventListener) and its +//! [`view`](frontend::EventListener::view). The [`frontend`] module documents +//! the complete transport-agnostic model. //! //! This helper waits for changes and prints verified payload progress until the //! torrent finishes downloading: @@ -158,9 +164,9 @@ //! # Runtime and advanced APIs //! //! `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. +//! as [`Engine`](engine::Engine) and [`Torrent`](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. //! //! Frontends should create one application-level Tokio runtime and keep the //! engine plus all torrent handles on work scheduled by that runtime. The crate @@ -204,10 +210,11 @@ //! ``` //! //! Actors own operational protocol state. Public applications interact through -//! [`engine::Engine`], [`torrent::Torrent`], and the transport-agnostic -//! [`frontend`] views and event streams. Durable state is represented by -//! [`engine::EngineSnapshot`] and [`torrent::TorrentSnapshot`], never by live -//! presentation views. +//! [`Engine`](engine::Engine), [`Torrent`](torrent::Torrent), and the +//! transport-agnostic [`frontend`] views and event streams. Durable state is +//! represented by [`EngineSnapshot`](engine::EngineSnapshot) and +//! [`TorrentSnapshot`](torrent::TorrentSnapshot), never by live presentation +//! views. //! //! Stable public types are exported by module facades while actor messages and //! coordination details remain crate-private. Domain values such as lifecycle diff --git a/crates/libtortillas/tests/engine_lifecycle.rs b/crates/libtortillas/tests/engine_lifecycle.rs index 54a2ef34..aebd3e32 100644 --- a/crates/libtortillas/tests/engine_lifecycle.rs +++ b/crates/libtortillas/tests/engine_lifecycle.rs @@ -97,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; @@ -106,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 { @@ -133,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) + )) } From 19c9b02f59c6419ffada31a6b116d7422b3f39fb Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sun, 26 Jul 2026 13:19:13 -0700 Subject: [PATCH 68/77] feat: expose peer and tracker transfer metrics --- crates/libtortillas/src/facade.rs | 4 +- crates/libtortillas/src/frontend/event.rs | 4 +- crates/libtortillas/src/frontend/handle.rs | 109 +++++- crates/libtortillas/src/frontend/mod.rs | 239 +++++++++++- crates/libtortillas/src/frontend/tests.rs | 339 ------------------ crates/libtortillas/src/frontend/view.rs | 70 ++-- crates/libtortillas/src/metrics.rs | 93 ++++- crates/libtortillas/src/peer/actor.rs | 42 +-- crates/libtortillas/src/peer/state.rs | 61 +++- crates/libtortillas/src/protocol/stream.rs | 269 ++++++++------ crates/libtortillas/src/torrent/actor.rs | 94 +++-- crates/libtortillas/src/torrent/choking.rs | 38 +- .../libtortillas/src/torrent/choking_flow.rs | 17 +- crates/libtortillas/src/torrent/messages.rs | 6 + crates/libtortillas/src/tracker/actor.rs | 75 +++- crates/libtortillas/src/tracker/http.rs | 13 +- crates/libtortillas/src/tracker/stats.rs | 57 +++ crates/libtortillas/src/tracker/udp.rs | 22 +- 18 files changed, 934 insertions(+), 618 deletions(-) delete mode 100644 crates/libtortillas/src/frontend/tests.rs diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index 5dc6f93a..f86359b4 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -26,8 +26,8 @@ pub use crate::{ TrackerHandle, TrackerId, TrackerListener, TrackerStatus, TrackerView, }, metrics::{ - ByteCount, BytesPerSecond, ContentProgress, HasTransferMetrics, Seconds, TorrentMetrics, - TrafficTotals, TransferMetrics, TransferRates, + ByteCount, BytesPerSecond, ContentProgress, HasTransferMetrics, PeerMetrics, Seconds, + TorrentMetrics, TrackerMetrics, TrafficTotals, TransferMetrics, TransferRates, }, torrent::{RestoreVerification, Torrent, TorrentSnapshot}, }; diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/frontend/event.rs index 4ff33db1..0300b22f 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/frontend/event.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use super::{EngineView, PeerHandle, TrackerHandle}; use crate::{ hashes::InfoHash, - metrics::{TorrentMetrics, TransferMetrics}, + metrics::{PeerMetrics, TorrentMetrics}, torrent::{Torrent, TorrentState}, }; @@ -85,7 +85,7 @@ pub enum TorrentEventKind { #[non_exhaustive] pub enum PeerEventKind { StateChanged, - MetricsChanged(TransferMetrics), + MetricsChanged(PeerMetrics), Disconnected, } diff --git a/crates/libtortillas/src/frontend/handle.rs b/crates/libtortillas/src/frontend/handle.rs index 036cf52d..f77d862b 100644 --- a/crates/libtortillas/src/frontend/handle.rs +++ b/crates/libtortillas/src/frontend/handle.rs @@ -15,7 +15,7 @@ use super::{ EventListener, EventSubscription, FrontendHub, FrontendHubInner, LivePublisher, PeerEventKind, PeerView, TrackerEventKind, TrackerStatus, TrackerView, }; -use crate::{hashes::InfoHash, peer::PeerId}; +use crate::{hashes::InfoHash, metrics::TrackerMetrics, peer::PeerId}; /// Shared live state behind an identity-bearing protocol handle. pub(crate) struct LiveScope { @@ -128,7 +128,7 @@ impl PeerHandle { } pub(crate) fn publish_metrics(&self, view: PeerView) { - let metrics = view.transfer; + let metrics = view.metrics; let _ = self .inner .live @@ -262,10 +262,17 @@ impl TrackerHandle { self.inner.identity } - pub(crate) fn announce_succeeded(&self, peers_returned: u64) { + pub(crate) fn publish_metrics(&self, metrics: TrackerMetrics) { + let mut view = self.view(); + view.metrics = metrics; + let _ = self.inner.live.replace_view(view); + } + + pub(crate) fn announce_succeeded(&self, metrics: TrackerMetrics) { let mut view = self.view(); view.status = TrackerStatus::Healthy; - view.peers_returned = Some(peers_returned); + view.metrics = metrics; + let peers_returned = metrics.latest_peers_returned.unwrap_or_default(); let event = TrackerEventKind::AnnounceSucceeded { peers_returned }; if self.inner.live.replace_view_and_emit(view, event) && let Some(frontend) = self.inner.frontend() @@ -274,10 +281,10 @@ impl TrackerHandle { } } - pub(crate) fn announce_failed(&self) { + pub(crate) fn announce_failed(&self, metrics: TrackerMetrics) { let mut view = self.view(); view.status = TrackerStatus::Degraded; - view.peers_returned = None; + view.metrics = metrics; if self .inner .live @@ -350,3 +357,93 @@ impl fmt::Display for TrackerHandle { } 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(frontend: &FrontendHub) -> PeerHandle { + frontend.register_peer_scope( + PeerIdentity { + torrent: InfoHash::from_bytes([1; 20]), + peer: PeerId::Unknown([2; 20]), + }, + connected_peer_view(), + ) + } + + #[tokio::test] + async fn peer_handle_when_updated_then_only_its_listener_receives_event() { + let frontend = FrontendHub::new(); + let peer = peer_handle(&frontend); + 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 frontend = FrontendHub::new(); + let peer = peer_handle(&frontend); + 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 live_handles_do_not_keep_their_frontend_hub_alive() { + let frontend = FrontendHub::new(); + let hub = frontend.downgrade(); + let peer = peer_handle(&frontend); + + drop(frontend); + + assert!(hub.upgrade().is_none()); + assert!(peer.view().connected); + } +} diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index d5d117a3..5ff1f258 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -164,8 +164,6 @@ mod event; mod handle; mod hub; mod stream; -#[cfg(test)] -mod tests; mod view; pub use event::{ @@ -182,6 +180,239 @@ pub use stream::{ pub use view::{EngineView, PeerView, TorrentView, TrackerStatus, TrackerView}; pub use crate::metrics::{ - ByteCount, BytesPerSecond, ContentProgress, HasTransferMetrics, Seconds, TorrentMetrics, - TrafficTotals, TransferMetrics, TransferRates, + ByteCount, BytesPerSecond, ContentProgress, HasTransferMetrics, PeerMetrics, Seconds, + TorrentMetrics, TrackerMetrics, TrafficTotals, TransferMetrics, TransferRates, }; + +#[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 frontend = FrontendHub::new(); + let info_hash = InfoHash::from_bytes([1; 20]); + let torrent = benchmark_torrent_view(info_hash, "isolated"); + frontend.initialize_torrent_projection(torrent.clone()); + let scope = frontend.ensure_torrent_scope(info_hash); + let mut torrent_events = scope.live.subscribe(); + let peer = frontend.register_peer_scope( + PeerIdentity { + torrent: info_hash, + peer: PeerId::Unknown([2; 20]), + }, + connected_peer_view(), + ); + let mut peer_view = peer.view(); + peer_view.metrics.transfer.rates = Some(Default::default()); + + peer.publish_metrics(peer_view); + + assert_eq!(scope.live.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 frontend = FrontendHub::new(); + let source = Tracker::Http("https://tracker.example/announce".to_string()); + let tracker = frontend.register_tracker_scope( + InfoHash::from_bytes([3; 20]), + &source, + pending_tracker_view(), + ); + 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 = frontend.register_tracker_scope( + InfoHash::from_bytes([3; 20]), + &source, + pending_tracker_view(), + ); + 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)); + } + + #[test] + #[ignore = "performance benchmark; run explicitly with --ignored --nocapture"] + fn large_scope_tree_benchmark() { + use std::time::Instant; + + let frontend = FrontendHub::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); + frontend.initialize_torrent_projection(benchmark_torrent_view( + InfoHash::from_bytes(hash), + &format!("torrent-{torrent_index}"), + )); + frontend + .ensure_torrent_scope(InfoHash::from_bytes(hash)) + .mark_registered_for_benchmark(); + for peer_index in 0_u8..10 { + frontend.register_peer_scope( + PeerIdentity { + torrent: InfoHash::from_bytes(hash), + peer: PeerId::Unknown([peer_index; 20]), + }, + connected_peer_view(), + ); + } + } + 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 frontend.peer_handles(InfoHash::from_bytes(hash)) { + peer.publish_metrics(peer.view()); + } + } + } + let updates = started.elapsed(); + + let started = Instant::now(); + let view = frontend.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]); + frontend.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); + frontend.register_peer_scope( + PeerIdentity { + torrent: removal_hash, + peer: PeerId::Unknown(id), + }, + connected_peer_view(), + ) + }) + .collect::>(); + let zero_listener_slots = removal_peers + .iter() + .map(|peer| peer.inner.live.allocated_event_slots()) + .sum::(); + let zero_listener_memory_lower_bound = removal_peers + .iter() + .map(|peer| peer.inner.live.allocation_lower_bound_bytes()) + .sum::(); + let started = Instant::now(); + frontend.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/frontend/tests.rs b/crates/libtortillas/src/frontend/tests.rs deleted file mode 100644 index 06c65dc8..00000000 --- a/crates/libtortillas/src/frontend/tests.rs +++ /dev/null @@ -1,339 +0,0 @@ -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, - peer_choking: true, - peer_interested: false, - client_choking: true, - client_interested: false, - available_pieces: 0, - transfer: TransferMetrics::default(), - } -} - -fn pending_tracker_view() -> TrackerView { - TrackerView { - endpoint: "https://tracker.example".to_string(), - status: TrackerStatus::Pending, - peers_returned: None, - } -} - -#[tokio::test] -async fn peer_handle_when_updated_then_only_its_listener_receives_event() { - let frontend = FrontendHub::new(); - let scope = PeerIdentity { - torrent: InfoHash::from_bytes([1; 20]), - peer: PeerId::Unknown([2; 20]), - }; - let view = connected_peer_view(); - let peer = frontend.register_peer_scope(scope, view.clone()); - let mut listener = peer.listener(); - let mut updated = view; - updated.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().transfer.totals.downloaded, ByteCount(16)); -} - -#[tokio::test] -async fn peer_metrics_do_not_republish_the_torrent_projection() { - let frontend = FrontendHub::new(); - let info_hash = InfoHash::from_bytes([1; 20]); - let torrent = benchmark_torrent_view(info_hash, "isolated"); - frontend.initialize_torrent_projection(torrent.clone()); - let scope = frontend.ensure_torrent_scope(info_hash); - let mut torrent_events = scope.live.subscribe(); - let peer = frontend.register_peer_scope( - PeerIdentity { - torrent: info_hash, - peer: PeerId::Unknown([2; 20]), - }, - connected_peer_view(), - ); - let mut peer_view = peer.view(); - peer_view.transfer.rates = Some(Default::default()); - - peer.publish_metrics(peer_view); - - assert_eq!(scope.live.view(), Some(torrent)); - assert!( - tokio::time::timeout(Duration::from_millis(20), torrent_events.recv()) - .await - .is_err() - ); -} - -#[tokio::test] -async fn disconnected_peer_rejects_late_actor_updates() { - let frontend = FrontendHub::new(); - let scope = PeerIdentity { - torrent: InfoHash::from_bytes([1; 20]), - peer: PeerId::Unknown([2; 20]), - }; - let view = connected_peer_view(); - let peer = frontend.register_peer_scope(scope, view.clone()); - let mut listener = peer.listener(); - - peer.disconnected(); - let mut late = view; - late.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().transfer.totals.downloaded, ByteCount::ZERO); -} - -#[test] -fn live_handles_do_not_keep_their_frontend_hub_alive() { - let frontend = FrontendHub::new(); - let hub = frontend.downgrade(); - let scope = PeerIdentity { - torrent: InfoHash::from_bytes([1; 20]), - peer: PeerId::Unknown([2; 20]), - }; - let peer = frontend.register_peer_scope(scope, connected_peer_view()); - - drop(frontend); - - assert!(hub.upgrade().is_none()); - assert!(peer.view().connected); -} - -#[test] -fn publishers_without_listeners_do_not_allocate_event_channels() { - let frontend = FrontendHub::new(); - let peer = frontend.register_peer_scope( - PeerIdentity { - torrent: InfoHash::from_bytes([1; 20]), - peer: PeerId::Unknown([2; 20]), - }, - connected_peer_view(), - ); - - assert!(!peer.inner.live.has_event_channel()); - let _listener = peer.listener(); - assert!(peer.inner.live.has_event_channel()); -} - -#[tokio::test] -async fn tracker_restart_keeps_listener_open_until_final_stop() { - let frontend = FrontendHub::new(); - let source = Tracker::Http("https://tracker.example/announce".to_string()); - let tracker = frontend.register_tracker_scope( - InfoHash::from_bytes([3; 20]), - &source, - pending_tracker_view(), - ); - 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 = frontend.register_tracker_scope( - InfoHash::from_bytes([3; 20]), - &source, - pending_tracker_view(), - ); - assert_eq!(restarted.id(), tracker.id()); - restarted.announce_succeeded(2); - assert_eq!( - listener.recv().await.unwrap().kind, - TrackerEventKind::AnnounceSucceeded { peers_returned: 2 } - ); - - tracker.stopped(); - tracker.stopped(); - tracker.announce_failed(); - assert_eq!( - listener.recv().await.unwrap().kind, - TrackerEventKind::Stopped - ); - assert_eq!(listener.recv().await, Err(EventStreamError::Closed)); -} - -#[tokio::test] -async fn torrent_removal_closes_every_child_scope_exactly_once() { - let frontend = FrontendHub::new(); - let info_hash = InfoHash::from_bytes([4; 20]); - frontend.initialize_torrent_projection(benchmark_torrent_view(info_hash, "removed")); - let peer = frontend.register_peer_scope( - PeerIdentity { - torrent: info_hash, - peer: PeerId::Unknown([5; 20]), - }, - connected_peer_view(), - ); - let source = Tracker::Http("https://tracker.example/announce".to_string()); - let tracker = frontend.register_tracker_scope(info_hash, &source, pending_tracker_view()); - let mut peer_events = peer.subscribe(); - let mut tracker_events = tracker.subscribe(); - - frontend.remove_torrent_scope(info_hash); - frontend.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)); -} - -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, - }, - ), - } -} - -#[test] -#[ignore = "performance benchmark; run explicitly with --ignored --nocapture"] -fn large_scope_tree_benchmark() { - use std::time::Instant; - - let frontend = FrontendHub::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); - frontend.initialize_torrent_projection(benchmark_torrent_view( - InfoHash::from_bytes(hash), - &format!("torrent-{torrent_index}"), - )); - frontend - .ensure_torrent_scope(InfoHash::from_bytes(hash)) - .mark_registered_for_benchmark(); - for peer_index in 0_u8..10 { - frontend.register_peer_scope( - PeerIdentity { - torrent: InfoHash::from_bytes(hash), - peer: PeerId::Unknown([peer_index; 20]), - }, - connected_peer_view(), - ); - } - } - 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 frontend.peer_handles(InfoHash::from_bytes(hash)) { - peer.publish_metrics(peer.view()); - } - } - } - let updates = started.elapsed(); - - let started = Instant::now(); - let view = frontend.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]); - frontend.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); - frontend.register_peer_scope( - PeerIdentity { - torrent: removal_hash, - peer: PeerId::Unknown(id), - }, - connected_peer_view(), - ) - }) - .collect::>(); - let zero_listener_slots = removal_peers - .iter() - .map(|peer| peer.inner.live.allocated_event_slots()) - .sum::(); - let zero_listener_memory_lower_bound = removal_peers - .iter() - .map(|peer| peer.inner.live.allocation_lower_bound_bytes()) - .sum::(); - let started = Instant::now(); - frontend.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/frontend/view.rs b/crates/libtortillas/src/frontend/view.rs index 1c85ff43..a133d287 100644 --- a/crates/libtortillas/src/frontend/view.rs +++ b/crates/libtortillas/src/frontend/view.rs @@ -6,7 +6,8 @@ use crate::{ engine::EngineStatus, hashes::InfoHash, metrics::{ - ByteCount, HasTransferMetrics, TorrentMetrics, TrafficTotals, TransferMetrics, TransferRates, + HasTransferMetrics, PeerMetrics, TorrentMetrics, TrackerMetrics, TransferMetrics, + TransferRates, }, peer::Peer, torrent::TorrentState, @@ -67,12 +68,7 @@ pub struct PeerView { pub client: Option, /// Whether this peer is currently connected. pub connected: bool, - pub peer_choking: bool, - pub peer_interested: bool, - pub client_choking: bool, - pub client_interested: bool, - pub available_pieces: u64, - pub transfer: TransferMetrics, + pub metrics: PeerMetrics, } impl PeerView { @@ -83,39 +79,26 @@ impl PeerView { pub(crate) fn from_peer_with_rates( peer: &Peer, connected: bool, rates: Option, ) -> Self { - Self::from_peer_with_transfer( - peer, - connected, - TransferMetrics { - totals: TrafficTotals { - downloaded: ByteCount(u64::try_from(peer.bytes_downloaded()).unwrap_or(u64::MAX)), - uploaded: ByteCount(u64::try_from(peer.bytes_uploaded()).unwrap_or(u64::MAX)), - }, - rates, - }, - ) + let mut metrics = peer.metrics(); + metrics.transfer.rates = rates; + Self::from_peer_with_metrics(peer, connected, metrics) } - pub(crate) fn from_peer_with_transfer( - peer: &Peer, connected: bool, transfer: TransferMetrics, + 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, - peer_choking: peer.am_choked(), - peer_interested: peer.interested(), - client_choking: peer.choked(), - client_interested: peer.am_interested(), - available_pieces: u64::try_from(peer.pieces.count_ones()).unwrap_or(u64::MAX), - transfer, + metrics, } } } impl HasTransferMetrics for PeerView { fn transfer_metrics(&self) -> &TransferMetrics { - &self.transfer + self.metrics.transfer_metrics() } } @@ -126,8 +109,13 @@ pub struct TrackerView { pub endpoint: String, /// Current actor and announce lifecycle. pub status: TrackerStatus, - /// Number of peers returned by the latest successful announce. - pub peers_returned: Option, + pub metrics: TrackerMetrics, +} + +impl HasTransferMetrics for TrackerView { + fn transfer_metrics(&self) -> &TransferMetrics { + self.metrics.transfer_metrics() + } } /// Lifecycle and latest announce outcome for a tracker. @@ -156,7 +144,7 @@ impl TrackerStatus { #[cfg(test)] mod tests { use super::*; - use crate::metrics::BytesPerSecond; + use crate::metrics::{BytesPerSecond, TrafficTotals}; #[test] fn peer_view_uses_canonical_byte_units() { @@ -164,17 +152,17 @@ mod tests { address: None, client: None, connected: true, - peer_choking: false, - peer_interested: true, - client_choking: false, - client_interested: true, - available_pieces: 1, - transfer: TransferMetrics { - totals: TrafficTotals::default(), - rates: Some(TransferRates { - download: BytesPerSecond(3), - upload: BytesPerSecond(2), - }), + metrics: PeerMetrics { + peer_interested: true, + available_pieces: 1, + transfer: TransferMetrics { + totals: TrafficTotals::default(), + rates: Some(TransferRates { + download: BytesPerSecond(3), + upload: BytesPerSecond(2), + }), + }, + ..Default::default() }, }; diff --git a/crates/libtortillas/src/metrics.rs b/crates/libtortillas/src/metrics.rs index 00adb1f9..a3fbf088 100644 --- a/crates/libtortillas/src/metrics.rs +++ b/crates/libtortillas/src/metrics.rs @@ -117,7 +117,7 @@ impl TransferRates { /// Aggregates every available sample while preserving unknown-versus-zero /// semantics. #[must_use] - pub fn aggregate<'a, T: HasTransferMetrics + 'a>( + pub fn aggregate<'a, T: HasTransferMetrics + ?Sized + 'a>( sources: impl IntoIterator, ) -> Option { let mut aggregate = None::; @@ -165,6 +165,40 @@ pub struct TransferMetrics { pub rates: Option, } +/// Peer-specific metrics layered on top of the shared transfer measurements. +#[derive(Debug, Clone, Copy, 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, Copy, 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)] @@ -207,6 +241,12 @@ impl TorrentMetrics { } } +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; @@ -296,6 +336,57 @@ mod tests { ); } + #[test] + fn aggregate_rates_when_metric_scopes_differ_then_uses_shared_transfer_metrics() { + let peer = PeerMetrics { + transfer: TransferMetrics { + rates: Some(TransferRates { + download: BytesPerSecond(10), + upload: BytesPerSecond(4), + }), + ..Default::default() + }, + ..Default::default() + }; + let tracker = TrackerMetrics { + transfer: TransferMetrics { + rates: Some(TransferRates { + download: BytesPerSecond(2), + upload: BytesPerSecond(1), + }), + ..Default::default() + }, + ..Default::default() + }; + let torrent = TorrentMetrics::new( + TransferMetrics { + rates: Some(TransferRates { + download: BytesPerSecond(3), + upload: BytesPerSecond::ZERO, + }), + ..Default::default() + }, + 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]; + + assert_eq!( + TransferRates::aggregate(scopes), + Some(TransferRates { + download: BytesPerSecond(15), + upload: BytesPerSecond(5), + }) + ); + } + #[test] fn transfer_rates_when_counters_increase_then_use_bytes_per_second() { let rates = TransferRates::between( diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index b3b059da..c82032bf 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -24,7 +24,7 @@ use crate::{ errors::PeerActorError, frontend::{PeerHandle, PeerView}, hashes::InfoHash, - metrics::{ByteCount, HasTransferMetrics, TrafficTotals, TransferMetrics, TransferSample}, + metrics::{HasTransferMetrics, PeerMetrics, TransferMetrics, TransferSample}, peer::{Peer, PeerId}, protocol::{stream::PeerRecv, *}, settings::PeerSettings, @@ -34,21 +34,12 @@ use crate::{ #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct PeerStats { pub(crate) id: PeerId, - pub(crate) interested: bool, - pub(crate) choked: bool, - pub(crate) transfer: TransferMetrics, + pub(crate) metrics: PeerMetrics, } impl HasTransferMetrics for PeerStats { fn transfer_metrics(&self) -> &TransferMetrics { - &self.transfer - } -} - -fn peer_traffic_totals(peer: &Peer) -> TrafficTotals { - TrafficTotals { - downloaded: ByteCount(u64::try_from(peer.bytes_downloaded()).unwrap_or(u64::MAX)), - uploaded: ByteCount(u64::try_from(peer.bytes_uploaded()).unwrap_or(u64::MAX)), + self.metrics.transfer_metrics() } } @@ -332,7 +323,7 @@ impl PeerActor { fn snapshot_stats(&mut self) -> Option { let id = self.peer.id?; let now = Instant::now(); - let totals = peer_traffic_totals(&self.peer); + let totals = self.peer.traffic_totals(); let sample = TransferSample::new(now, totals); let rates = sample.rates_since(self.last_rate_sample); self.last_rate_sample = sample; @@ -340,18 +331,13 @@ impl PeerActor { totals, rates: Some(rates), }; + let mut metrics = self.peer.metrics(); + metrics.transfer = transfer; self .frontend - .publish_metrics(PeerView::from_peer_with_transfer( - &self.peer, true, transfer, - )); - - Some(PeerStats { - id, - interested: self.peer.interested(), - choked: self.peer.choked(), - transfer, - }) + .publish_metrics(PeerView::from_peer_with_metrics(&self.peer, true, metrics)); + + Some(PeerStats { id, metrics }) } } @@ -369,7 +355,8 @@ impl Actor for PeerActor { /// 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, frontend) = args; + let (mut peer, mut stream, supervisor, info_hash, settings, frontend) = 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 { @@ -395,7 +382,7 @@ impl Actor for PeerActor { .map_err(|e| PeerActorError::SupervisorCommunicationFailed(e.to_string()))?; Ok(Self { - last_rate_sample: TransferSample::new(Instant::now(), peer_traffic_totals(&peer)), + last_rate_sample: TransferSample::new(Instant::now(), peer.traffic_totals()), peer, stream, supervisor, @@ -521,7 +508,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, @@ -623,13 +609,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!( @@ -666,7 +650,7 @@ impl Message for PeerActor { warn!("Received unexpected handshake from peer"); } } - let rates = self.frontend.view().transfer.rates; + let rates = self.frontend.view().metrics.transfer.rates; self .frontend .publish_state(PeerView::from_peer_with_rates(&self.peer, true, rates)); diff --git a/crates/libtortillas/src/peer/state.rs b/crates/libtortillas/src/peer/state.rs index 481971f0..3618229d 100644 --- a/crates/libtortillas/src/peer/state.rs +++ b/crates/libtortillas/src/peer/state.rs @@ -9,6 +9,7 @@ use std::{ use atomic_time::AtomicOptionInstant; use super::Peer; +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 @@ -45,9 +46,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, } @@ -71,6 +72,30 @@ 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(); + } + + 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) @@ -119,18 +144,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 { @@ -168,4 +183,22 @@ impl Peer { pub fn bytes_uploaded(&self) -> usize { self.state.bytes_uploaded.load(Ordering::Relaxed) } + + pub(crate) fn traffic_totals(&self) -> TrafficTotals { + self.state.traffic_totals() + } + + pub(crate) fn metrics(&self) -> PeerMetrics { + PeerMetrics { + transfer: TransferMetrics { + totals: self.traffic_totals(), + rates: None, + }, + 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/protocol/stream.rs b/crates/libtortillas/src/protocol/stream.rs index 0c8c1c0b..0ce9d16d 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,9 +219,9 @@ 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()), } } @@ -228,36 +232,34 @@ impl PeerStream { /// `recv_handshake_message()` and other direct reads did not leave data for /// `PeerRecv::recv()` to process. 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" - ); + assert!( + self.read_buffer.is_empty(), + "PeerStream::split would discard buffered read data" + ); + let peer_state = self.peer_state; + let (reader, writer) = match self.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, + 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 +278,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 +325,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 +343,66 @@ 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, + 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), + 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 +410,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 +539,57 @@ mod tests { assert_eq!(incoming_id, client_id); } + #[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_when_local_peer_is_available_then_completes_handshake() { let remote_peer_id = PeerId::new(); diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 0c20a52c..74a8bfb4 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -30,8 +30,8 @@ use crate::{ hashes::InfoHash, metainfo::{Info, MetaInfo}, metrics::{ - ByteCount, ContentProgress, HasTransferMetrics, TorrentMetrics, TrafficTotals, - TransferMetrics, TransferRates, + ByteCount, ContentProgress, HasTransferMetrics, TorrentMetrics, TrackerMetrics, + TrafficTotals, TransferMetrics, TransferRates, }, peer::{PeerActor, PeerId, commands::SetChoked}, pieces::{FilePieceManager, PieceManager, PieceScheduler, PieceStoreActor}, @@ -515,11 +515,26 @@ impl TorrentActor { .into_iter() .map(|peer| peer.view()) .collect::>(); - let rates = TransferRates::aggregate(&peers); - let totals = peers + let trackers = self + .frontend + .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 rates = TransferRates::aggregate(metric_sources.iter().copied()); + let totals = metric_sources + .into_iter() .map(HasTransferMetrics::transfer_metrics) - .map(|transfer| transfer.totals) + .map(|metrics| metrics.totals) .fold(TrafficTotals::default(), TrafficTotals::saturating_add); let metrics = TorrentMetrics::new( TransferMetrics { totals, rates }, @@ -736,7 +751,7 @@ impl Actor for TorrentActor { TrackerView { endpoint, status: TrackerStatus::Pending, - peers_returned: None, + metrics: TrackerMetrics::default(), }, ); let actor = TrackerActor::supervise( @@ -885,7 +900,7 @@ mod tests { frontend::{PeerIdentity, PeerView}, hashes::HashVec, metainfo::{InfoKeys, MetaInfo, TorrentFile}, - metrics::BytesPerSecond, + metrics::{BytesPerSecond, PeerMetrics}, protocol::{ messages::{Handshake, PeerMessages}, stream::{PeerRecv, PeerSend, PeerStream}, @@ -1835,20 +1850,42 @@ mod tests { address: None, client: None, connected: true, - peer_choking: false, - peer_interested: true, - client_choking: false, - client_interested: true, - available_pieces: 1, - transfer: TransferMetrics { - totals: TrafficTotals { - downloaded: ByteCount(50_000), - uploaded: ByteCount(5_000), + metrics: PeerMetrics { + peer_interested: true, + available_pieces: 1, + transfer: TransferMetrics { + totals: TrafficTotals { + downloaded: ByteCount(50_000), + uploaded: ByteCount(5_000), + }, + rates: Some(TransferRates { + download: BytesPerSecond(100), + upload: BytesPerSecond(20), + }), }, - rates: Some(TransferRates { - download: BytesPerSecond(100), - upload: BytesPerSecond(20), - }), + ..Default::default() + }, + }, + ); + let _sampled_tracker = test_actor.frontend.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 { + totals: TrafficTotals { + downloaded: ByteCount(250), + uploaded: ByteCount(50), + }, + rates: Some(TransferRates { + download: BytesPerSecond(2), + upload: BytesPerSecond(1), + }), + }, + ..Default::default() }, }, ); @@ -1884,21 +1921,28 @@ mod tests { assert_eq!( view.metrics.traffic.rates, Some(TransferRates { - download: BytesPerSecond(100), - upload: BytesPerSecond(20), + download: BytesPerSecond(102), + upload: BytesPerSecond(21), }) ); - assert_eq!(view.metrics.traffic.totals.downloaded, ByteCount(50_000)); + 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(20) + BytesPerSecond(21) ); sampled_peer.disconnected(); - assert_eq!(test_actor.live_view().metrics.traffic.rates, None); + 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(); diff --git a/crates/libtortillas/src/torrent/choking.rs b/crates/libtortillas/src/torrent/choking.rs index 91d5758f..4b1e71c5 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( ) -> ChokingDecision { let mut candidates: Vec<_> = peers .iter() - .filter(|peer| peer.interested) + .filter(|peer| peer.metrics.peer_interested) .copied() .collect(); candidates.sort_by(|left, right| { @@ -116,10 +116,12 @@ pub(crate) fn select_unchoked_peers( fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> BytesPerSecond { match torrent_state { TorrentState::Downloading => peer + .metrics .transfer .rates .map_or(BytesPerSecond::ZERO, |rates| rates.download), TorrentState::Seeding => peer + .metrics .transfer .rates .map_or(BytesPerSecond::ZERO, |rates| rates.upload), @@ -137,7 +139,7 @@ fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> BytesPerSecond { #[cfg(test)] mod tests { use super::*; - use crate::metrics::{TransferMetrics, TransferRates}; + use crate::metrics::{PeerMetrics, TransferMetrics, TransferRates}; fn peer_id(value: u8) -> PeerId { PeerId::from([value; 20]) @@ -146,32 +148,38 @@ mod tests { fn stats(id: u8) -> PeerStats { PeerStats { id: peer_id(id), - interested: true, - choked: true, - transfer: TransferMetrics { - totals: Default::default(), - rates: Some(TransferRates::default()), + metrics: PeerMetrics { + peer_interested: true, + client_choking: true, + transfer: TransferMetrics { + totals: Default::default(), + rates: Some(TransferRates::default()), + }, + ..Default::default() }, } } fn with_rates(id: u8, download_rate: u64, upload_rate: u64) -> PeerStats { PeerStats { - transfer: TransferMetrics { - rates: Some(TransferRates { - download: BytesPerSecond(download_rate), - upload: BytesPerSecond(upload_rate), - }), - ..Default::default() + metrics: PeerMetrics { + transfer: TransferMetrics { + rates: Some(TransferRates { + download: BytesPerSecond(download_rate), + upload: BytesPerSecond(upload_rate), + }), + ..Default::default() + }, + ..stats(id).metrics }, - ..stats(id) + id: peer_id(id), } } #[test] fn selector_only_includes_interested_peers() { let mut not_interested = stats(2); - not_interested.interested = false; + not_interested.metrics.peer_interested = false; let peers = [stats(1), not_interested, stats(3)]; let upload_slots = Settings::default().torrent.upload_slots; diff --git a/crates/libtortillas/src/torrent/choking_flow.rs b/crates/libtortillas/src/torrent/choking_flow.rs index 24ae3933..a1514043 100644 --- a/crates/libtortillas/src/torrent/choking_flow.rs +++ b/crates/libtortillas/src/torrent/choking_flow.rs @@ -6,9 +6,12 @@ use tokio::time::timeout; use tracing::{trace, warn}; use super::TorrentActor; -use crate::peer::{ - PeerActor, PeerStats, - commands::{SetChoked, Stats}, +use crate::{ + facade::TorrentEventKind, + peer::{ + PeerActor, PeerId, PeerStats, + commands::{SetChoked, Stats}, + }, }; impl TorrentActor { @@ -27,9 +30,7 @@ impl TorrentActor { } // Peer actors publish their own high-frequency samples. The torrent // publishes one coalesced aggregate after the collection interval. - self.publish_live_view(|view| { - crate::frontend::TorrentEventKind::MetricsChanged(view.metrics.clone()) - }); + self.publish_live_view(|view| TorrentEventKind::MetricsChanged(view.metrics.clone())); self.try_update_tracker_progress(); let decision = self.choking_scheduler.decide(&peer_stats, self.state); let unchoked: HashSet<_> = decision.unchoked.iter().copied().collect(); @@ -42,7 +43,7 @@ impl TorrentActor { for stats in peer_stats { let choked = !unchoked.contains(&stats.id); - if stats.choked == choked { + if stats.metrics.client_choking == choked { continue; } @@ -64,7 +65,7 @@ impl TorrentActor { 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/messages.rs b/crates/libtortillas/src/torrent/messages.rs index 8146b797..221e948f 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -44,6 +44,12 @@ pub(crate) mod events { } } + /// Publishes tracker traffic after an announce attempt. + #[message] + pub(crate) fn tracker_metrics_changed(&self) { + self.publish_live_view(|view| TorrentEventKind::MetricsChanged(view.metrics.clone())); + } + /// Sent after an incoming peer initializes a handshake. /// The handshake will be preverified and routed to this torrent instance. /// diff --git a/crates/libtortillas/src/tracker/actor.rs b/crates/libtortillas/src/tracker/actor.rs index 1ff1864f..9c250ab7 100644 --- a/crates/libtortillas/src/tracker/actor.rs +++ b/crates/libtortillas/src/tracker/actor.rs @@ -1,4 +1,7 @@ -use std::{net::SocketAddr, time::Duration}; +use std::{ + net::SocketAddr, + time::{Duration, Instant}, +}; use anyhow::Result; use kameo::{ @@ -20,6 +23,7 @@ use super::{ use crate::{ errors::TrackerActorError, frontend::TrackerHandle, + metrics::{TrackerMetrics, TransferMetrics, TransferSample}, peer::PeerId, settings::TrackerSettings, torrent::{self, TorrentActor}, @@ -35,6 +39,7 @@ pub(crate) struct TrackerActor { actor_ref: ActorRef, settings: TrackerSettings, frontend: TrackerHandle, + last_rate_sample: TransferSample, } #[derive(Clone)] @@ -119,6 +124,15 @@ impl Actor for TrackerActor { if let Some(left) = initial_left { tracker.update(TrackerUpdate::Left(left)).await?; } + let initial_metrics = tracker.stats().metrics(); + let totals = initial_metrics.transfer.totals; + frontend.publish_metrics(initial_metrics); + if let Err(e) = supervisor + .tell(torrent::events::TrackerMetricsChanged) + .await + { + warn!(error = %e, "Failed to publish initial tracker metrics"); + } let next_announce = scheduler .ask(SetTimeout::new( @@ -138,12 +152,30 @@ impl Actor for TrackerActor { actor_ref, settings, frontend, + last_rate_sample: TransferSample::new(Instant::now(), totals), }) } async fn on_stop( &mut self, _: WeakActorRef, reason: ActorStopReason, ) -> Result<(), Self::Error> { + if let Some(next_announce) = self.next_announce.take() { + next_announce.abort(); + } + + let _ = timeout(self.settings.stop_timeout, self.tracker.stop()) + .await + .inspect_err(|e| warn!(e = %e.to_string(), "Tracker stop timed out")); + let metrics = self.snapshot_metrics(self.frontend.view().metrics.latest_peers_returned); + self.frontend.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.frontend.stopped(); } else { @@ -152,13 +184,6 @@ impl Actor for TrackerActor { // performs final tree cleanup. self.frontend.restarting(); } - if let Some(next_announce) = self.next_announce.take() { - next_announce.abort(); - } - - let _ = timeout(self.settings.stop_timeout, self.tracker.stop()) - .await - .inspect_err(|e| warn!(e = %e.to_string(), "Tracker stop timed out")); Ok(()) } @@ -166,6 +191,19 @@ impl Actor for TrackerActor { #[messages] 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 = TransferSample::new(Instant::now(), totals); + metrics.transfer = TransferMetrics { + totals, + rates: Some(sample.rates_since(self.last_rate_sample)), + }; + self.last_rate_sample = sample; + metrics.latest_peers_returned = latest_peers_returned; + metrics + } + async fn schedule_next_announce(&mut self) { let interval = self.tracker.interval(); let delay = if interval == usize::MAX || interval == u32::MAX as usize { @@ -196,11 +234,15 @@ 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; + let latest_peers_returned = result + .as_ref() + .ok() + .map(|peers| u64::try_from(peers.len()).unwrap_or(u64::MAX)); + let metrics = self.snapshot_metrics(latest_peers_returned); + match result { Ok(peers) => { - self - .frontend - .announce_succeeded(u64::try_from(peers.len()).unwrap_or(u64::MAX)); + self.frontend.announce_succeeded(metrics); if let Err(e) = self .supervisor .tell(torrent::events::Announce { @@ -214,9 +256,16 @@ impl TrackerActor { } Err(e) => { error!(error = %e, "Announce request failed"); - self.frontend.announce_failed(); + self.frontend.announce_failed(metrics); } } + 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..d6410cd7 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 @@ -561,6 +558,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/stats.rs b/crates/libtortillas/src/tracker/stats.rs index 68ed07ef..90f9f5fc 100644 --- a/crates/libtortillas/src/tracker/stats.rs +++ b/crates/libtortillas/src/tracker/stats.rs @@ -9,6 +9,8 @@ use std::{ use atomic_time::{AtomicInstant, AtomicOptionInstant}; use tokio::time::Instant; +use crate::metrics::{ByteCount, TrackerMetrics, TrafficTotals, TransferMetrics}; + /// Tracker statistics. /// /// All usages of [`AtomicOptionInstant`] or [`AtomicInstant`] are a bit hacky, @@ -110,6 +112,31 @@ impl TrackerStats { self.bytes_received.fetch_add(value, Ordering::AcqRel); } + /// Returns all application bytes exchanged with this tracker. + #[must_use] + pub 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. + #[must_use] + pub fn metrics(&self) -> TrackerMetrics { + TrackerMetrics { + transfer: TransferMetrics { + totals: self.traffic_totals(), + rates: None, + }, + 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 +163,33 @@ impl TrackerStats { .store(Instant::now().into_std(), Ordering::Release) } } + +#[cfg(test)] +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, From 7c42476b72d55b846869a3d4c02a9bac7eb564c9 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sun, 26 Jul 2026 13:19:17 -0700 Subject: [PATCH 69/77] test: strengthen frontend scope lifecycle coverage --- crates/libtortillas/src/frontend/hub.rs | 95 ++++++++++++++++++++++ crates/libtortillas/src/frontend/stream.rs | 9 ++ 2 files changed, 104 insertions(+) diff --git a/crates/libtortillas/src/frontend/hub.rs b/crates/libtortillas/src/frontend/hub.rs index 1a52fb22..1560aa0e 100644 --- a/crates/libtortillas/src/frontend/hub.rs +++ b/crates/libtortillas/src/frontend/hub.rs @@ -565,3 +565,98 @@ impl Default for FrontendHub { 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 frontend = FrontendHub::new(); + let info_hash = InfoHash::from_bytes([4; 20]); + frontend.initialize_torrent_projection(torrent_view(info_hash)); + let peer = frontend.register_peer_scope( + PeerIdentity { + torrent: info_hash, + peer: PeerId::Unknown([5; 20]), + }, + connected_peer_view(), + ); + let source = Tracker::Http("https://tracker.example/announce".to_string()); + let tracker = frontend.register_tracker_scope(info_hash, &source, pending_tracker_view()); + let mut peer_events = peer.subscribe(); + let mut tracker_events = tracker.subscribe(); + + frontend.remove_torrent_scope(info_hash); + frontend.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)); + } +} diff --git a/crates/libtortillas/src/frontend/stream.rs b/crates/libtortillas/src/frontend/stream.rs index c6d800e0..2b01abc1 100644 --- a/crates/libtortillas/src/frontend/stream.rs +++ b/crates/libtortillas/src/frontend/stream.rs @@ -377,4 +377,13 @@ mod tests { 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()); + } } From 588fd4b6e441eb5e2606d456a38bd8c6d580e52c Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sun, 26 Jul 2026 13:19:17 -0700 Subject: [PATCH 70/77] chore: configure live frontend example tracing --- crates/libtortillas/examples/live_frontend.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/libtortillas/examples/live_frontend.rs b/crates/libtortillas/examples/live_frontend.rs index 807308b0..a0b91775 100644 --- a/crates/libtortillas/examples/live_frontend.rs +++ b/crates/libtortillas/examples/live_frontend.rs @@ -7,11 +7,16 @@ use tracing::{error, info, warn}; #[tokio::main] async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter("live_frontend=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(); From 2a33eb2701034c9285b42dd2b7f0984b04f8eaee Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sun, 26 Jul 2026 13:21:04 -0700 Subject: [PATCH 71/77] refactor: shorten live hub naming --- crates/libtortillas/src/engine/actor.rs | 6 +-- crates/libtortillas/src/engine/mod.rs | 8 ++-- crates/libtortillas/src/frontend/handle.rs | 27 +++++++------ crates/libtortillas/src/frontend/hub.rs | 26 ++++++------- crates/libtortillas/src/frontend/mod.rs | 10 ++--- crates/libtortillas/src/torrent/actor.rs | 38 +++++++++---------- crates/libtortillas/src/torrent/handle.rs | 14 +++---- crates/libtortillas/src/torrent/piece_flow.rs | 6 +-- 8 files changed, 67 insertions(+), 68 deletions(-) diff --git a/crates/libtortillas/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index 8ff851d9..d25d7bcd 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -17,7 +17,7 @@ use super::commands; use crate::{ dht::{DhtActor, DhtActorArgs}, errors::EngineError, - frontend::{FrontendHealthLevel, FrontendHub}, + frontend::{FrontendHealthLevel, Hub}, hashes::InfoHash, peer::PeerId, protocol::stream::PeerStream, @@ -32,7 +32,7 @@ use crate::{ /// actor. pub struct EngineActor { /// Live projection coordinator shared with managed torrents. - pub(super) frontend: FrontendHub, + pub(super) frontend: Hub, /// Engine-wide DHT service shared by every torrent. pub(super) dht: Option>, /// Listener to wait for incoming TCP connections from peers @@ -107,7 +107,7 @@ pub struct EngineActorArgs { pub default_base_path: Option, /// Live frontend state shared by the engine handle and actor hierarchy. - pub(crate) frontend: FrontendHub, + pub(crate) frontend: Hub, } impl Actor for EngineActor { diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 00ac9bcf..e1ad5693 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -78,7 +78,7 @@ use self::{ }; use crate::{ errors::{EngineError, map_engine_send_error}, - frontend::{EngineListener, EngineView, EventSubscription, FrontendHub}, + frontend::{EngineListener, EngineView, EventSubscription, Hub}, hashes::InfoHash, peer::PeerId, settings::Settings, @@ -128,7 +128,7 @@ use crate::{ #[derive(Debug, Clone)] pub struct Engine { actor: ActorRef, - frontend: FrontendHub, + frontend: Hub, } #[bon::bon] @@ -226,7 +226,7 @@ impl Engine { None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), }; - let frontend = FrontendHub::with_settings(settings.frontend); + let frontend = Hub::with_settings(settings.frontend); let args = EngineActorArgs { tcp_addr, utp_addr, @@ -720,7 +720,7 @@ mod tests { } #[tokio::test] - async fn buffered_torrent_events_do_not_retain_the_frontend_hub() { + async fn buffered_torrent_events_do_not_retain_the_hub() { let engine = Engine::builder() .settings(deterministic_settings()) .autostart(false) diff --git a/crates/libtortillas/src/frontend/handle.rs b/crates/libtortillas/src/frontend/handle.rs index f77d862b..1b608881 100644 --- a/crates/libtortillas/src/frontend/handle.rs +++ b/crates/libtortillas/src/frontend/handle.rs @@ -12,15 +12,15 @@ use std::{ use serde::{Deserialize, Serialize}; use super::{ - EventListener, EventSubscription, FrontendHub, FrontendHubInner, LivePublisher, PeerEventKind, - PeerView, TrackerEventKind, TrackerStatus, TrackerView, + 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, + hub: Weak, pub(crate) live: LivePublisher, } @@ -29,7 +29,7 @@ where V: Clone + Send + Sync + 'static, E: Clone + Send + 'static, { - fn new(identity: I, view: V, hub: Weak, event_capacity: usize) -> Self { + fn new(identity: I, view: V, hub: Weak, event_capacity: usize) -> Self { Self { identity, hub, @@ -49,8 +49,8 @@ where self.live.view() } - fn frontend(&self) -> Option { - self.hub.upgrade().map(FrontendHub::from_inner) + fn frontend(&self) -> Option { + self.hub.upgrade().map(Hub::from_inner) } } @@ -79,7 +79,7 @@ pub struct PeerHandle { impl PeerHandle { pub(crate) fn new( - identity: PeerIdentity, view: PeerView, hub: Weak, event_capacity: usize, + identity: PeerIdentity, view: PeerView, hub: Weak, event_capacity: usize, ) -> Self { Self { inner: Arc::new(LiveScope::new(identity, view, hub, event_capacity)), @@ -220,8 +220,7 @@ pub struct TrackerHandle { impl TrackerHandle { pub(crate) fn new( - identity: TrackerIdentity, view: TrackerView, hub: Weak, - event_capacity: usize, + identity: TrackerIdentity, view: TrackerView, hub: Weak, event_capacity: usize, ) -> Self { Self { inner: Arc::new(LiveScope::new(identity, view, hub, event_capacity)), @@ -381,7 +380,7 @@ mod tests { } } - fn peer_handle(frontend: &FrontendHub) -> PeerHandle { + fn peer_handle(frontend: &Hub) -> PeerHandle { frontend.register_peer_scope( PeerIdentity { torrent: InfoHash::from_bytes([1; 20]), @@ -393,7 +392,7 @@ mod tests { #[tokio::test] async fn peer_handle_when_updated_then_only_its_listener_receives_event() { - let frontend = FrontendHub::new(); + let frontend = Hub::new(); let peer = peer_handle(&frontend); let mut listener = peer.listener(); let mut updated = peer.view(); @@ -414,7 +413,7 @@ mod tests { #[tokio::test] async fn disconnected_peer_rejects_late_actor_updates() { - let frontend = FrontendHub::new(); + let frontend = Hub::new(); let peer = peer_handle(&frontend); let mut listener = peer.listener(); let mut late = peer.view(); @@ -436,8 +435,8 @@ mod tests { } #[test] - fn live_handles_do_not_keep_their_frontend_hub_alive() { - let frontend = FrontendHub::new(); + fn live_handles_do_not_keep_their_hub_alive() { + let frontend = Hub::new(); let hub = frontend.downgrade(); let peer = peer_handle(&frontend); diff --git a/crates/libtortillas/src/frontend/hub.rs b/crates/libtortillas/src/frontend/hub.rs index 1560aa0e..dc5bd989 100644 --- a/crates/libtortillas/src/frontend/hub.rs +++ b/crates/libtortillas/src/frontend/hub.rs @@ -161,14 +161,14 @@ impl TorrentScope { /// Ownership root for transport-agnostic live projections. #[derive(Debug)] -pub(crate) struct FrontendHubInner { +pub(crate) struct HubInner { engine: EngineScope, torrents: ScopeRegistry, settings: FrontendSettings, next_tracker_id: AtomicU64, } -impl FrontendHubInner { +impl HubInner { fn torrent_handle(&self, info_hash: InfoHash) -> Option { self .torrents @@ -181,8 +181,8 @@ impl FrontendHubInner { #[derive(Debug, Clone)] enum HubReference { - Strong(Arc), - Weak(Weak), + Strong(Arc), + Weak(Weak), } /// Cloneable coordinator for the complete live projection tree. @@ -190,11 +190,11 @@ enum HubReference { /// 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 FrontendHub { +pub(crate) struct Hub { inner: HubReference, } -impl FrontendHub { +impl Hub { // Engine projection pub(crate) fn new() -> Self { @@ -203,7 +203,7 @@ impl FrontendHub { pub(crate) fn with_settings(settings: FrontendSettings) -> Self { Self { - inner: HubReference::Strong(Arc::new(FrontendHubInner { + inner: HubReference::Strong(Arc::new(HubInner { engine: EngineScope { live: LivePublisher::new(EngineStatus::Starting, settings.engine_event_capacity), }, @@ -214,7 +214,7 @@ impl FrontendHub { } } - pub(crate) fn from_inner(inner: Arc) -> Self { + pub(crate) fn from_inner(inner: Arc) -> Self { Self { inner: HubReference::Strong(inner), } @@ -226,19 +226,19 @@ impl FrontendHub { } } - pub(crate) fn downgrade(&self) -> Weak { + pub(crate) fn downgrade(&self) -> Weak { match &self.inner { HubReference::Strong(inner) => Arc::downgrade(inner), HubReference::Weak(inner) => inner.clone(), } } - fn inner(&self) -> Arc { + fn inner(&self) -> Arc { match &self.inner { HubReference::Strong(inner) => Arc::clone(inner), HubReference::Weak(inner) => inner .upgrade() - .expect("frontend hub outlived by its actor hierarchy"), + .expect("live hub outlived by its actor hierarchy"), } } @@ -560,7 +560,7 @@ impl FrontendHub { } } -impl Default for FrontendHub { +impl Default for Hub { fn default() -> Self { Self::new() } @@ -630,7 +630,7 @@ mod tests { #[tokio::test] async fn torrent_removal_closes_every_child_scope_exactly_once() { - let frontend = FrontendHub::new(); + let frontend = Hub::new(); let info_hash = InfoHash::from_bytes([4; 20]); frontend.initialize_torrent_projection(torrent_view(info_hash)); let peer = frontend.register_peer_scope( diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/frontend/mod.rs index 5ff1f258..404dd161 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/frontend/mod.rs @@ -76,7 +76,7 @@ //! //! ```text //! EngineActor ── owns operational engine state -//! FrontendHub +//! Hub //! ├── engine lifecycle and event publisher //! └── keyed torrent scopes //! └── torrent view and event publisher @@ -172,7 +172,7 @@ pub use event::{ }; pub(crate) use handle::PeerIdentity; pub use handle::{PeerHandle, PeerListener, TrackerHandle, TrackerId, TrackerListener}; -pub(crate) use hub::{FrontendHub, FrontendHubInner}; +pub(crate) use hub::{Hub, HubInner}; pub use stream::{ EngineListener, EventListener, EventStreamError, EventSubscription, LivePublisher, TorrentListener, @@ -242,7 +242,7 @@ mod tests { #[tokio::test] async fn peer_metrics_do_not_republish_the_torrent_projection() { - let frontend = FrontendHub::new(); + let frontend = Hub::new(); let info_hash = InfoHash::from_bytes([1; 20]); let torrent = benchmark_torrent_view(info_hash, "isolated"); frontend.initialize_torrent_projection(torrent.clone()); @@ -270,7 +270,7 @@ mod tests { #[tokio::test] async fn tracker_restart_keeps_listener_open_until_final_stop() { - let frontend = FrontendHub::new(); + let frontend = Hub::new(); let source = Tracker::Http("https://tracker.example/announce".to_string()); let tracker = frontend.register_tracker_scope( InfoHash::from_bytes([3; 20]), @@ -316,7 +316,7 @@ mod tests { fn large_scope_tree_benchmark() { use std::time::Instant; - let frontend = FrontendHub::new(); + let frontend = Hub::new(); let started = Instant::now(); for torrent_index in 0_u16..100 { let bytes = torrent_index.to_be_bytes(); diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 74a8bfb4..fe197b21 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -26,7 +26,7 @@ use tracing::{debug, error, info, instrument, trace, warn}; use super::{choking::ChokingScheduler, util}; use crate::{ errors::{SnapshotUnsupportedReason, TorrentError}, - frontend::{FrontendHealthLevel, FrontendHub, TorrentView, TrackerStatus, TrackerView}, + frontend::{FrontendHealthLevel, Hub, TorrentView, TrackerStatus, TrackerView}, hashes::InfoHash, metainfo::{Info, MetaInfo}, metrics::{ @@ -100,7 +100,7 @@ impl PieceManager for PieceManagerProxy { } pub(crate) struct TorrentActor { - pub(super) frontend: FrontendHub, + pub(super) frontend: Hub, pub(crate) peers: HashMap>, pub(crate) trackers: HashMap>, @@ -668,7 +668,7 @@ pub struct TorrentActorArgs { pub settings: Settings, /// Live frontend state shared with the owning engine. - pub(crate) frontend: FrontendHub, + pub(crate) frontend: Hub, } impl Actor for TorrentActor { @@ -1100,7 +1100,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path), settings, - frontend: FrontendHub::default(), + frontend: Hub::default(), }); actor .tell(SetState { @@ -1151,7 +1151,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(testing::torrent_temp_path()), settings, - frontend: FrontendHub::default(), + frontend: Hub::default(), }); actor .tell(SetState { @@ -1199,7 +1199,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(base_path.clone()), settings, - frontend: FrontendHub::default(), + frontend: Hub::default(), }); actor .tell(SetState { @@ -1303,7 +1303,7 @@ mod tests { sufficient_peers: Some(1), base_path: Some(fixture.path().to_path_buf()), settings, - frontend: FrontendHub::default(), + frontend: Hub::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); actor.tell(AddPeer { peer: seed.peer() }).await.unwrap(); @@ -1364,7 +1364,7 @@ mod tests { sufficient_peers: Some(sufficient_peers), base_path: None, settings: Settings::default(), - frontend: FrontendHub::default(), + frontend: Hub::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); @@ -1398,7 +1398,7 @@ mod tests { sufficient_peers: None, base_path: None, settings: Settings::default(), - frontend: FrontendHub::default(), + frontend: Hub::default(), }); // Blocking loop that runs until we get an info dict @@ -1438,7 +1438,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), - frontend: FrontendHub::default(), + frontend: Hub::default(), }); assert_eq!(actor.ask(GetState).await.unwrap(), TorrentState::Ready); @@ -1463,7 +1463,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), - frontend: FrontendHub::default(), + frontend: Hub::default(), }); assert_eq!( @@ -1492,7 +1492,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), - frontend: FrontendHub::default(), + frontend: Hub::default(), }); actor @@ -1552,7 +1552,7 @@ mod tests { sufficient_peers: None, base_path: Some(file_path), settings: Settings::default(), - frontend: FrontendHub::default(), + frontend: Hub::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); @@ -1628,7 +1628,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), - frontend: FrontendHub::default(), + frontend: Hub::default(), }); // Build the bitfield with fake completed pieces @@ -1652,7 +1652,7 @@ mod tests { // Construct the actor manually for snapshot testing let test_actor = TorrentActor { - frontend: FrontendHub::default(), + frontend: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield, @@ -1776,7 +1776,7 @@ mod tests { let utp_server = UtpSocket::new_udp(testing::ephemeral_socket_addr()) .await .unwrap(); - let frontend = FrontendHub::default(); + let frontend = Hub::default(); let actor_ref = TorrentActor::spawn(TorrentActorArgs { peer_id, metainfo: metainfo.clone(), @@ -1811,7 +1811,7 @@ mod tests { piece_scheduler.set_piece_blocks(partial_piece_index, blocks); let mut test_actor = TorrentActor { - frontend: FrontendHub::default(), + frontend: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield, @@ -2013,11 +2013,11 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), - frontend: FrontendHub::default(), + frontend: Hub::default(), }); let mut actor = TorrentActor { - frontend: FrontendHub::default(), + frontend: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield: BitVec::repeat(false, piece_count), diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 330ee44f..c2c51378 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -18,8 +18,8 @@ use super::{ use crate::{ errors::{TorrentError, map_torrent_send_error}, frontend::{ - EventSubscription, FrontendHub, FrontendHubInner, LivePublisher, PeerHandle, - TorrentEventKind, TorrentListener, TorrentView, TrackerHandle, + EventSubscription, Hub, HubInner, LivePublisher, PeerHandle, TorrentEventKind, + TorrentListener, TorrentView, TrackerHandle, }, hashes::InfoHash, pieces::PieceManager, @@ -29,7 +29,7 @@ use crate::{ pub(crate) struct TorrentInner { pub(crate) info_hash: InfoHash, pub(crate) actor: ActorRef, - pub(crate) hub: Weak, + pub(crate) hub: Weak, pub(crate) live: Arc, TorrentEventKind>>, } @@ -57,11 +57,11 @@ impl Torrent { /// to its underlying [`TorrentActor`]. #[cfg(test)] pub(crate) fn new(info_hash: InfoHash, actor_ref: ActorRef) -> Self { - Self::new_with_frontend(info_hash, actor_ref, &FrontendHub::default(), None) + Self::new_with_frontend(info_hash, actor_ref, &Hub::default(), None) } pub(crate) fn new_with_frontend( - info_hash: InfoHash, actor: ActorRef, frontend: &FrontendHub, + info_hash: InfoHash, actor: ActorRef, frontend: &Hub, initial_view: Option, ) -> Self { let scope = frontend.ensure_torrent_scope(info_hash); @@ -248,7 +248,7 @@ impl Torrent { }) } - fn frontend(&self) -> Option { - self.inner.hub.upgrade().map(FrontendHub::from_inner) + fn frontend(&self) -> Option { + self.inner.hub.upgrade().map(Hub::from_inner) } } diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index 8ce763c4..f2db790c 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -9,7 +9,7 @@ use tracing::{debug, info, trace, warn}; use super::{TorrentActor, util}; #[cfg(test)] -use crate::frontend::FrontendHub; +use crate::frontend::Hub; use crate::{ errors::TorrentError, peer::commands::{CancelPiece, Have, NeedPiece}, @@ -496,11 +496,11 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(base_path.clone()), settings: Settings::default(), - frontend: FrontendHub::default(), + frontend: Hub::default(), }); TorrentActor { - frontend: FrontendHub::default(), + frontend: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield: BitVec::::repeat(false, info.piece_count()), From f2e4950414c38c6b0d9e37b46f610d9847716ab5 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sun, 26 Jul 2026 13:31:56 -0700 Subject: [PATCH 72/77] refactor: rename frontend API to live --- README.md | 17 ++-- .../examples/{live_frontend.rs => live.rs} | 8 +- crates/libtortillas/src/engine/actor.rs | 32 +++---- crates/libtortillas/src/engine/messages.rs | 22 +++-- crates/libtortillas/src/engine/mod.rs | 69 +++++++-------- crates/libtortillas/src/engine/snapshot.rs | 2 +- crates/libtortillas/src/engine/source.rs | 10 +-- crates/libtortillas/src/errors.rs | 6 +- crates/libtortillas/src/facade.rs | 16 ++-- crates/libtortillas/src/lib.rs | 47 +++++----- .../src/{frontend => live}/event.rs | 26 +++--- .../src/{frontend => live}/handle.rs | 80 ++++++++--------- .../src/{frontend => live}/hub.rs | 75 ++++++++-------- .../src/{frontend => live}/mod.rs | 84 +++++++++--------- .../src/{frontend => live}/stream.rs | 22 ++--- .../src/{frontend => live}/view.rs | 14 +-- crates/libtortillas/src/peer/actor.rs | 18 ++-- crates/libtortillas/src/settings.rs | 10 +-- crates/libtortillas/src/torrent/actor.rs | 88 +++++++++---------- crates/libtortillas/src/torrent/handle.rs | 42 ++++----- crates/libtortillas/src/torrent/messages.rs | 6 +- crates/libtortillas/src/torrent/mod.rs | 4 +- crates/libtortillas/src/torrent/piece_flow.rs | 10 +-- crates/libtortillas/src/torrent/state.rs | 4 +- crates/libtortillas/src/torrent/swarm.rs | 12 +-- crates/libtortillas/src/tracker/actor.rs | 26 +++--- crates/libtortillas/src/tracker/model.rs | 14 +-- crates/libtortillas/tests/facade.rs | 2 +- .../tests/{live_frontend.rs => live.rs} | 2 +- 29 files changed, 383 insertions(+), 385 deletions(-) rename crates/libtortillas/examples/{live_frontend.rs => live.rs} (91%) rename crates/libtortillas/src/{frontend => live}/event.rs (85%) rename crates/libtortillas/src/{frontend => live}/handle.rs (85%) rename crates/libtortillas/src/{frontend => live}/hub.rs (90%) rename crates/libtortillas/src/{frontend => live}/mod.rs (84%) rename crates/libtortillas/src/{frontend => live}/stream.rs (95%) rename crates/libtortillas/src/{frontend => live}/view.rs (90%) rename crates/libtortillas/tests/{live_frontend.rs => live.rs} (99%) diff --git a/README.md b/README.md index 35b99a05..cbe6676a 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,7 +81,8 @@ 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 @@ -101,11 +102,11 @@ 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. -Frontends should use live listeners for updates and direct `Engine` and -`Torrent` methods for operations rather than -polling persistence snapshots. See the -[`libtortillas::frontend` API documentation](https://docs.rs/libtortillas/latest/libtortillas/frontend/) and the -[`live_frontend` example](crates/libtortillas/examples/live_frontend.rs). +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 diff --git a/crates/libtortillas/examples/live_frontend.rs b/crates/libtortillas/examples/live.rs similarity index 91% rename from crates/libtortillas/examples/live_frontend.rs rename to crates/libtortillas/examples/live.rs index a0b91775..675009cb 100644 --- a/crates/libtortillas/examples/live_frontend.rs +++ b/crates/libtortillas/examples/live.rs @@ -8,7 +8,7 @@ use tracing::{error, info, warn}; #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt() - .with_env_filter("live_frontend=trace,off") + .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 { @@ -30,7 +30,7 @@ async fn main() -> Result<(), Box> { sequence = event.sequence, torrent_count = view.torrent_count(), ?event.kind, - "frontend received a live engine event" + "received an engine event" ); if matches!(event.kind, EngineEventKind::Shutdown(_)) { break; @@ -41,11 +41,11 @@ async fn main() -> Result<(), Box> { warn!( events, torrent_count = view.torrent_count(), - "redrawing live state after lag" + "refreshing current state after lag" ); } Err(EventStreamError::Closed) => { - info!("frontend event stream closed"); + info!("engine event stream closed"); break; } } diff --git a/crates/libtortillas/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index d25d7bcd..38f4e81d 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -17,8 +17,8 @@ use super::commands; use crate::{ dht::{DhtActor, DhtActorArgs}, errors::EngineError, - frontend::{FrontendHealthLevel, Hub}, hashes::InfoHash, + live::{Hub, LiveHealthLevel}, peer::PeerId, protocol::stream::PeerStream, settings::Settings, @@ -32,7 +32,7 @@ use crate::{ /// actor. pub struct EngineActor { /// Live projection coordinator shared with managed torrents. - pub(super) frontend: Hub, + 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 @@ -106,8 +106,8 @@ pub struct EngineActorArgs { /// If not provided, torrents will use their own default paths. pub default_base_path: Option, - /// Live frontend state shared by the engine handle and actor hierarchy. - pub(crate) frontend: Hub, + /// Projection hub shared by the engine handle and actor hierarchy. + pub(crate) hub: Hub, } impl Actor for EngineActor { @@ -136,7 +136,7 @@ impl Actor for EngineActor { piece_storage_strategy, settings, default_base_path, - frontend, + hub, } = args; let tcp_addr = tcp_addr.unwrap_or(settings.engine.tcp_addr); @@ -174,10 +174,10 @@ impl Actor for EngineActor { None }; - frontend.engine_started(); + hub.engine_started(); Ok(Self { - frontend, + hub, dht, tcp_socket, utp_socket, @@ -196,9 +196,9 @@ impl Actor for EngineActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); - self.frontend.emit_health( + self.hub.emit_health( None, - FrontendHealthLevel::Error, + LiveHealthLevel::Error, "an engine service stopped unexpectedly", ); @@ -230,9 +230,9 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); - self.frontend.emit_health( + self.hub.emit_health( None, - FrontendHealthLevel::Warning, + LiveHealthLevel::Warning, "the TCP peer listener rejected an incoming connection", ); None @@ -258,9 +258,9 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); - self.frontend.emit_health( + self.hub.emit_health( None, - FrontendHealthLevel::Warning, + LiveHealthLevel::Warning, "the uTP peer listener rejected an incoming connection", ); None @@ -272,7 +272,7 @@ impl Actor for EngineActor { async fn on_stop( &mut self, _: WeakActorRef, _: ActorStopReason, ) -> Result<(), Self::Error> { - self.frontend.engine_stopping(); + self.hub.engine_stopping(); let torrents = self .torrents .iter() @@ -285,7 +285,7 @@ impl Actor for EngineActor { } torrent.wait_for_shutdown().await; self.torrents.remove(&info_hash); - self.frontend.remove_torrent_scope(info_hash); + self.hub.remove_torrent_scope(info_hash); } if let Some(dht) = self.dht.take() { @@ -293,7 +293,7 @@ impl Actor for EngineActor { dht.wait_for_shutdown().await; } - self.frontend.engine_stopped(); + self.hub.engine_stopped(); Ok(()) } diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 37e8a4bf..b9c799c6 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -48,7 +48,7 @@ pub(crate) mod commands { if let Err(error) = torrent.stop_gracefully().await { warn!(error = %error, %info_hash, "Failed to stop rejected restored torrent"); } - self.frontend.remove_torrent_scope(info_hash); + self.hub.remove_torrent_scope(info_hash); } } @@ -215,7 +215,7 @@ pub(crate) mod commands { sufficient_peers: restoring.then_some(usize::MAX), base_path, settings: self.settings.clone(), - frontend: self.frontend.weak(), + hub: self.hub.weak(), }, ) .restart_policy(RestartPolicy::Transient) @@ -297,19 +297,17 @@ pub(crate) mod commands { Err(error) => { self.discard_restored_torrent(info_hash, &torrent_ref).await; return Err(EngineError::ActorCommunicationFailed { - operation: "initialize torrent frontend", + operation: "initialize torrent live state", reason: error.to_string(), }); } }; - self - .frontend - .register_torrent_scope(Torrent::new_with_frontend( - info_hash, - torrent_ref.clone(), - &self.frontend, - Some(initial_view), - )); + self.hub.register_torrent_scope(Torrent::new_with_hub( + info_hash, + torrent_ref.clone(), + &self.hub, + Some(initial_view), + )); Ok(torrent_ref) } @@ -344,7 +342,7 @@ pub(crate) mod commands { match self.remove_torrent(info_hash).await { Ok(torrent) => { torrent.kill(); - self.frontend.remove_torrent_scope(info_hash); + self.hub.remove_torrent_scope(info_hash); } Err(remove_error) => { warn!( diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index e1ad5693..2670195a 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -78,8 +78,8 @@ use self::{ }; use crate::{ errors::{EngineError, map_engine_send_error}, - frontend::{EngineListener, EngineView, EventSubscription, Hub}, hashes::InfoHash, + live::{EngineListener, EngineView, EventSubscription, Hub}, peer::PeerId, settings::Settings, torrent::{PieceStorageStrategy, RestoreVerification, Torrent}, @@ -93,8 +93,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. @@ -128,7 +128,7 @@ use crate::{ #[derive(Debug, Clone)] pub struct Engine { actor: ActorRef, - frontend: Hub, + hub: Hub, } #[bon::bon] @@ -226,7 +226,7 @@ impl Engine { None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), }; - let frontend = Hub::with_settings(settings.frontend); + let hub = Hub::with_settings(settings.live); let args = EngineActorArgs { tcp_addr, utp_addr, @@ -235,12 +235,12 @@ impl Engine { piece_storage_strategy, settings, default_base_path: Some(output_path), - frontend: frontend.clone(), + hub: hub.clone(), }; let actor = EngineActor::spawn(args); - Engine { actor, frontend } + Engine { actor, hub } } /// Just a helper function so we don't have to write `&self.0` all the time. @@ -252,7 +252,7 @@ impl Engine { /// 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. /// /// @@ -303,7 +303,7 @@ impl Engine { .await .map_err(|error| map_engine_send_error("add torrent", error))?; - self.frontend_torrent(info_hash) + self.torrent_handle(info_hash) // We don't need to assign link or insert the ref here because its already // done by the engine actor } @@ -338,7 +338,7 @@ impl Engine { .await .map_err(|error| map_engine_send_error("restore torrent", error))?; - self.frontend_torrent(info_hash) + self.torrent_handle(info_hash) } /// Restores all torrent sessions from an engine persistence snapshot. @@ -367,7 +367,7 @@ impl Engine { .map_err(|error| map_engine_send_error("restore engine", error))?; info_hashes .into_iter() - .map(|info_hash| self.frontend_torrent(info_hash)) + .map(|info_hash| self.torrent_handle(info_hash)) .collect() } /// Starts all torrents managed by the engine. @@ -389,7 +389,7 @@ impl Engine { .await .map_err(|error| map_engine_send_error("get torrent", error))?; - self.frontend_torrent(info_hash) + self.torrent_handle(info_hash) } /// Removes a torrent from the engine and stops its actor gracefully. @@ -402,7 +402,7 @@ impl Engine { let stop_result = torrent.stop_gracefully().await; torrent.wait_for_shutdown().await; - self.frontend.remove_torrent_scope(info_hash); + self.hub.remove_torrent_scope(info_hash); stop_result.map_err(|error| EngineError::ActorCommunicationFailed { operation: "stop torrent", reason: error.to_string(), @@ -425,8 +425,9 @@ impl Engine { /// Captures all managed torrent sessions in a Serde-compatible persistence /// snapshot. /// - /// Use [`Self::listener`] for live frontend state. Snapshot frequency is an - /// application persistence decision, not a UI refresh mechanism. + /// Use [`Self::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() @@ -437,34 +438,32 @@ impl Engine { /// Subscribes to typed engine and torrent events as they happen. /// - /// The returned stream is bounded. A lagging frontend can read - /// [`Self::view`] to rebuild its display state and then continue + /// 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.frontend.subscribe() + self.hub.subscribe() } - /// Creates a live listener with typed events and coherent current display - /// state. + /// Creates a listener with typed events and coherent current state. #[must_use] pub fn listener(&self) -> EngineListener { - let frontend = self.frontend.clone(); - EngineListener::new(self.subscribe(), move || frontend.view()) + let hub = self.hub.clone(); + EngineListener::new(self.subscribe(), move || hub.view()) } - /// Returns the current display-oriented engine state maintained by the live - /// event publisher. + /// Returns the current engine state maintained by the projection tree. #[must_use] pub fn view(&self) -> EngineView { - self.frontend.view() + self.hub.view() } - fn frontend_torrent(&self, info_hash: InfoHash) -> Result { + fn torrent_handle(&self, info_hash: InfoHash) -> Result { self - .frontend + .hub .torrent_handle(info_hash) - .ok_or_else(|| EngineError::FrontendHandleMissing { info_hash }) + .ok_or_else(|| EngineError::TorrentHandleMissing { info_hash }) } } @@ -537,7 +536,7 @@ mod tests { }, engine::{Engine, TorrentSource}, errors::EngineError, - frontend::{EngineEventKind, TorrentEventKind}, + live::{EngineEventKind, TorrentEventKind}, settings::{DhtSettings, Settings}, testing::{ BIG_BUCK_BUNNY_INFO_HASH, BIG_BUCK_BUNNY_TORRENT_FILE, LocalPeer, peer_id, @@ -670,7 +669,7 @@ mod tests { } #[tokio::test] - async fn torrent_removal_reconciles_frontend_after_actor_shutdown_failure() { + async fn torrent_removal_reconciles_projection_after_actor_shutdown_failure() { let engine = Engine::builder() .settings(deterministic_settings()) .autostart(false) @@ -708,10 +707,10 @@ mod tests { let info_hash = torrent.info_hash(); let late_view = torrent.view().unwrap(); - engine.frontend.remove_torrent_scope(info_hash); + engine.hub.remove_torrent_scope(info_hash); engine - .frontend - .replace_torrent_view_and_emit(late_view, crate::frontend::TorrentEventKind::Updated); + .hub + .replace_torrent_view_and_emit(late_view, crate::live::TorrentEventKind::Updated); assert!(torrent.view().is_none()); assert_eq!(engine.view().torrent_count(), 0); @@ -725,7 +724,7 @@ mod tests { .settings(deterministic_settings()) .autostart(false) .build(); - let hub = engine.frontend.downgrade(); + let hub = engine.hub.downgrade(); let torrent = engine .add_torrent(TorrentSource::torrent_file_path(torrent_fixture_path( BIG_BUCK_BUNNY_TORRENT_FILE, @@ -836,7 +835,7 @@ mod tests { let event = listener.recv().await.unwrap(); if let EngineEventKind::Torrent { torrent, - event: crate::frontend::TorrentEventKind::PeerConnected(peer), + event: crate::live::TorrentEventKind::PeerConnected(peer), } = event.kind { break (torrent, peer); diff --git a/crates/libtortillas/src/engine/snapshot.rs b/crates/libtortillas/src/engine/snapshot.rs index 21a8a6cc..8db6dbbf 100644 --- a/crates/libtortillas/src/engine/snapshot.rs +++ b/crates/libtortillas/src/engine/snapshot.rs @@ -58,7 +58,7 @@ impl EngineSnapshot { } } -/// Coarse engine status for frontend displays. +/// Coarse engine status for live-state consumers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum EngineStatus { /// Runtime resources are still being initialized. 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 ae5d12b2..2d09b0e0 100644 --- a/crates/libtortillas/src/errors.rs +++ b/crates/libtortillas/src/errors.rs @@ -80,9 +80,9 @@ pub enum EngineError { reason: String, }, - /// The actor owns a torrent that is missing its live public handle. - #[error("Torrent {info_hash} is missing its frontend handle")] - FrontendHandleMissing { info_hash: InfoHash }, + /// 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)] diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index f86359b4..8c037a2d 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -1,7 +1,7 @@ -//! Frontend-facing facade for `libtortillas`. +//! Application-facing facade for `libtortillas`. //! -//! This module defines the stable surface that application adapters should -//! prefer over actor, protocol, tracker, and storage internals. Lower-level +//! This module defines the stable surface applications should prefer over +//! actor, protocol, tracker, and storage internals. Lower-level //! modules remain public for advanced integrations, but a terminal UI, web //! server, browser backend, desktop app, or other consumer can model user //! intent, observe progress, and hold handles through the same types. @@ -18,12 +18,12 @@ pub use crate::{ engine::{Engine, EngineSnapshot, EngineStatus, TorrentSource}, - frontend::{ + live::{ EngineEvent, EngineEventKind, EngineListener, EngineView, EventListener, EventStreamError, - EventSubscription, FrontendHealth, FrontendHealthLevel, LivePublisher, PeerEvent, - PeerEventKind, PeerHandle, PeerListener, PeerView, SequencedEvent, TorrentEvent, - TorrentEventKind, TorrentListener, TorrentView, TrackerEvent, TrackerEventKind, - TrackerHandle, TrackerId, TrackerListener, TrackerStatus, TrackerView, + 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, diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index 00032f08..b0ecbc20 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -3,7 +3,7 @@ //! # Getting started //! //! A basic downloader only needs an [`Engine`](engine::Engine) and a -//! [`TorrentSource`](engine::TorrentSource). The live frontend API is optional. +//! [`TorrentSource`](engine::TorrentSource). Live updates are optional. //! //! Add the library and its Tokio runtime to a binary crate: //! @@ -54,7 +54,7 @@ //! //! Every source is passed to //! [`Engine::add_torrent`](engine::Engine::add_torrent) in the same way. There -//! is no frontend-specific setup. +//! is no live-specific setup. //! //! For example, downloading from a magnet link only changes the source: //! @@ -120,19 +120,19 @@ //! //! Browse the repository's //! [examples directory](https://github.com/artrixdotdev/tortillas/tree/main/crates/libtortillas/examples) -//! for complete runnable programs, including live frontend integration. +//! for complete runnable programs, including event-driven progress reporting. //! -//! # Live updates are optional +//! # Observing live state //! -//! Applications that only need to download and seed files do not need -//! [`frontend`] listeners, events, views, or metrics. Those APIs exist for -//! applications that want to display live progress or forward state through a -//! terminal, web server, website, or desktop application. +//! Applications that only download and seed files do not need [`live`] +//! listeners, events, views, or metrics. The module is for consumers that need +//! current progress and incremental changes, whether they render a terminal, +//! serve an API, update a website, or drive a desktop application. //! -//! When live updates are useful, start with -//! [`EventListener`](frontend::EventListener) and its -//! [`view`](frontend::EventListener::view). The [`frontend`] module documents -//! the complete transport-agnostic model. +//! Start with [`EventListener`](live::EventListener): read its +//! [`view`](live::EventListener::view) for current state and receive events to +//! learn when that state changes. The [`live`] module documents the complete +//! transport-agnostic model. //! //! This helper waits for changes and prints verified payload progress until the //! torrent finishes downloading: @@ -168,10 +168,10 @@ //! methods that must be driven inside a Tokio runtime, and the crate uses Tokio //! tasks, sockets, timers, channels, and filesystem APIs internally. //! -//! 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. +//! Applications should create one 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. //! Synchronous adapter work should communicate with async engine tasks through //! channels or a dedicated adapter thread. [`tokio::task::spawn_blocking`] is //! appropriate for bounded blocking work, but not for a permanent input loop: @@ -187,9 +187,9 @@ //! internals when an equivalent [`facade`] type exists. [`prelude`] re-exports //! the types most applications need. //! -//! Engine and torrent handles expose listeners for live UI updates. Persistence -//! snapshots are intentionally separate and should not be polled for display -//! changes. +//! Engine and torrent handles expose listeners for current state and +//! incremental updates. Persistence snapshots are intentionally separate and +//! should not be polled for live changes. //! //! # Internal architecture //! @@ -211,17 +211,16 @@ //! //! Actors own operational protocol state. Public applications interact through //! [`Engine`](engine::Engine), [`Torrent`](torrent::Torrent), and the -//! transport-agnostic [`frontend`] views and event streams. Durable state is +//! transport-agnostic [`live`] views and event streams. Durable state is //! represented by [`EngineSnapshot`](engine::EngineSnapshot) and -//! [`TorrentSnapshot`](torrent::TorrentSnapshot), never by live presentation -//! views. +//! [`TorrentSnapshot`](torrent::TorrentSnapshot), never by live views. //! //! Stable public types are exported by module facades while actor messages and //! coordination details remain crate-private. Domain values such as lifecycle //! state, storage strategy, metrics, and snapshots live outside actor files so //! actors can focus on orchestration. //! -//! See [`frontend`] for the source-of-truth, publication, lifecycle, and lock +//! See [`live`] for the source-of-truth, publication, lifecycle, and lock //! invariants. See [`torrent`] for transfer scheduling and persistence //! semantics. @@ -229,8 +228,8 @@ pub(crate) mod dht; pub mod engine; pub mod errors; pub mod facade; -pub mod frontend; pub mod hashes; +pub mod live; pub mod metainfo; pub mod metrics; pub mod peer; diff --git a/crates/libtortillas/src/frontend/event.rs b/crates/libtortillas/src/live/event.rs similarity index 85% rename from crates/libtortillas/src/frontend/event.rs rename to crates/libtortillas/src/live/event.rs index 0300b22f..8f0e0e75 100644 --- a/crates/libtortillas/src/frontend/event.rs +++ b/crates/libtortillas/src/live/event.rs @@ -10,7 +10,7 @@ use crate::{ /// A sequenced event emitted by a live publisher. /// /// Sequence numbers are local to one publisher and strictly increase for every -/// event it emits. A frontend can use them to preserve scoped event order or +/// 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 { @@ -20,7 +20,7 @@ pub struct SequencedEvent { pub kind: E, } -/// A sequenced event emitted by the engine's frontend publisher. +/// A sequenced event emitted by the engine's live publisher. pub type EngineEvent = SequencedEvent; /// A sequenced event emitted by a torrent's live publisher. pub type TorrentEvent = SequencedEvent; @@ -37,7 +37,7 @@ impl SequencedEvent { } } -/// Typed changes a frontend can react to without actor internals or polling. +/// Typed changes a consumer can react to without actor internals or polling. #[derive(Debug, Clone)] #[non_exhaustive] pub enum EngineEventKind { @@ -52,8 +52,8 @@ pub enum EngineEventKind { torrent: Torrent, event: TorrentEventKind, }, - /// An engine-wide frontend health report was emitted. - Health(FrontendHealth), + /// An engine-wide health report was emitted. + Health(LiveHealth), /// The engine and its managed torrents stopped. Shutdown(EngineView), } @@ -76,7 +76,7 @@ pub enum TorrentEventKind { TrackerAnnounceFailed(TrackerHandle), TrackerRestarting(TrackerHandle), TrackerStopped(TrackerHandle), - Health(FrontendHealth), + Health(LiveHealth), Removed, } @@ -111,20 +111,20 @@ impl EngineEventKind { } } -/// A recoverable or terminal health report intended for user interfaces. +/// A recoverable or terminal runtime health report. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct FrontendHealth { +pub struct LiveHealth { /// Torrent associated with the report, or `None` for engine-wide health. pub torrent: Option, - /// Severity suitable for presentation and filtering. - pub level: FrontendHealthLevel, - /// Frontend-safe description without internal actor details. + /// Severity suitable for application filtering. + pub level: LiveHealthLevel, + /// Public description without internal actor details. pub message: String, } -/// Severity of a frontend health report. +/// Severity of a runtime health report. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum FrontendHealthLevel { +pub enum LiveHealthLevel { /// The operation recovered but may merit user attention. Warning, /// The engine or torrent could not recover the operation. diff --git a/crates/libtortillas/src/frontend/handle.rs b/crates/libtortillas/src/live/handle.rs similarity index 85% rename from crates/libtortillas/src/frontend/handle.rs rename to crates/libtortillas/src/live/handle.rs index 1b608881..8f7a0fc6 100644 --- a/crates/libtortillas/src/frontend/handle.rs +++ b/crates/libtortillas/src/live/handle.rs @@ -21,7 +21,7 @@ use crate::{hashes::InfoHash, metrics::TrackerMetrics, peer::PeerId}; pub(crate) struct LiveScope { pub(crate) identity: I, hub: Weak, - pub(crate) live: LivePublisher, + pub(crate) publisher: LivePublisher, } impl LiveScope @@ -33,23 +33,23 @@ where Self { identity, hub, - live: LivePublisher::new(view, event_capacity), + publisher: LivePublisher::new(view, event_capacity), } } fn subscribe(&self) -> EventSubscription { - self.live.subscribe() + self.publisher.subscribe() } fn listener(&self) -> EventListener { - self.live.listener() + self.publisher.listener() } fn view(&self) -> V { - self.live.view() + self.publisher.view() } - fn frontend(&self) -> Option { + fn hub(&self) -> Option { self.hub.upgrade().map(Hub::from_inner) } } @@ -71,7 +71,7 @@ pub(crate) struct PeerIdentity { pub(crate) peer: PeerId, } -/// Public identity and live frontend access for one connected peer. +/// Public identity and current state for one connected peer. #[derive(Clone)] pub struct PeerHandle { pub(crate) inner: Arc>, @@ -123,7 +123,7 @@ impl PeerHandle { pub(crate) fn publish_state(&self, view: PeerView) { let _ = self .inner - .live + .publisher .replace_view_and_emit(view, PeerEventKind::StateChanged); } @@ -131,7 +131,7 @@ impl PeerHandle { let metrics = view.metrics; let _ = self .inner - .live + .publisher .replace_view_and_emit(view, PeerEventKind::MetricsChanged(metrics)); } @@ -140,11 +140,11 @@ impl PeerHandle { view.connected = false; if self .inner - .live + .publisher .close_with_terminal_event(view, PeerEventKind::Disconnected) - && let Some(frontend) = self.inner.frontend() + && let Some(hub) = self.inner.hub() { - frontend.mark_peer_disconnected(self); + hub.mark_peer_disconnected(self); } } @@ -153,7 +153,7 @@ impl PeerHandle { view.connected = false; let _ = self .inner - .live + .publisher .close_with_terminal_event(view, PeerEventKind::Disconnected); } } @@ -212,7 +212,7 @@ pub(crate) struct TrackerIdentity { pub(crate) id: TrackerId, } -/// Public identity and live frontend access for one tracker. +/// Public identity and current state for one tracker. #[derive(Clone)] pub struct TrackerHandle { pub(crate) inner: Arc>, @@ -264,7 +264,7 @@ impl TrackerHandle { pub(crate) fn publish_metrics(&self, metrics: TrackerMetrics) { let mut view = self.view(); view.metrics = metrics; - let _ = self.inner.live.replace_view(view); + let _ = self.inner.publisher.replace_view(view); } pub(crate) fn announce_succeeded(&self, metrics: TrackerMetrics) { @@ -273,10 +273,10 @@ impl TrackerHandle { view.metrics = metrics; let peers_returned = metrics.latest_peers_returned.unwrap_or_default(); let event = TrackerEventKind::AnnounceSucceeded { peers_returned }; - if self.inner.live.replace_view_and_emit(view, event) - && let Some(frontend) = self.inner.frontend() + if self.inner.publisher.replace_view_and_emit(view, event) + && let Some(hub) = self.inner.hub() { - frontend.emit_tracker_event(self, event); + hub.emit_tracker_event(self, event); } } @@ -286,11 +286,11 @@ impl TrackerHandle { view.metrics = metrics; if self .inner - .live + .publisher .replace_view_and_emit(view, TrackerEventKind::AnnounceFailed) - && let Some(frontend) = self.inner.frontend() + && let Some(hub) = self.inner.hub() { - frontend.emit_tracker_event(self, TrackerEventKind::AnnounceFailed); + hub.emit_tracker_event(self, TrackerEventKind::AnnounceFailed); } } @@ -299,11 +299,11 @@ impl TrackerHandle { view.status = TrackerStatus::Restarting; if self .inner - .live + .publisher .replace_view_and_emit(view, TrackerEventKind::Restarting) - && let Some(frontend) = self.inner.frontend() + && let Some(hub) = self.inner.hub() { - frontend.emit_tracker_event(self, TrackerEventKind::Restarting); + hub.emit_tracker_event(self, TrackerEventKind::Restarting); } } @@ -312,11 +312,11 @@ impl TrackerHandle { view.status = TrackerStatus::Stopped; if self .inner - .live + .publisher .close_with_terminal_event(view, TrackerEventKind::Stopped) - && let Some(frontend) = self.inner.frontend() + && let Some(hub) = self.inner.hub() { - frontend.emit_tracker_event(self, TrackerEventKind::Stopped); + hub.emit_tracker_event(self, TrackerEventKind::Stopped); } } @@ -325,7 +325,7 @@ impl TrackerHandle { view.status = TrackerStatus::Stopped; let _ = self .inner - .live + .publisher .close_with_terminal_event(view, TrackerEventKind::Stopped); } } @@ -380,8 +380,8 @@ mod tests { } } - fn peer_handle(frontend: &Hub) -> PeerHandle { - frontend.register_peer_scope( + fn peer_handle(hub: &Hub) -> PeerHandle { + hub.register_peer_scope( PeerIdentity { torrent: InfoHash::from_bytes([1; 20]), peer: PeerId::Unknown([2; 20]), @@ -392,8 +392,8 @@ mod tests { #[tokio::test] async fn peer_handle_when_updated_then_only_its_listener_receives_event() { - let frontend = Hub::new(); - let peer = peer_handle(&frontend); + let hub = Hub::new(); + let peer = peer_handle(&hub); let mut listener = peer.listener(); let mut updated = peer.view(); updated.metrics.transfer.totals = TrafficTotals { @@ -413,8 +413,8 @@ mod tests { #[tokio::test] async fn disconnected_peer_rejects_late_actor_updates() { - let frontend = Hub::new(); - let peer = peer_handle(&frontend); + let hub = Hub::new(); + let peer = peer_handle(&hub); let mut listener = peer.listener(); let mut late = peer.view(); @@ -435,14 +435,14 @@ mod tests { } #[test] - fn live_handles_do_not_keep_their_hub_alive() { - let frontend = Hub::new(); - let hub = frontend.downgrade(); - let peer = peer_handle(&frontend); + fn scoped_handles_do_not_keep_their_hub_alive() { + let hub = Hub::new(); + let weak_hub = hub.downgrade(); + let peer = peer_handle(&hub); - drop(frontend); + drop(hub); - assert!(hub.upgrade().is_none()); + assert!(weak_hub.upgrade().is_none()); assert!(peer.view().connected); } } diff --git a/crates/libtortillas/src/frontend/hub.rs b/crates/libtortillas/src/live/hub.rs similarity index 90% rename from crates/libtortillas/src/frontend/hub.rs rename to crates/libtortillas/src/live/hub.rs index dc5bd989..61dbf3f1 100644 --- a/crates/libtortillas/src/frontend/hub.rs +++ b/crates/libtortillas/src/live/hub.rs @@ -15,16 +15,16 @@ use std::{ use dashmap::DashMap; use super::{ - EngineEventKind, EngineView, EventSubscription, FrontendHealth, FrontendHealthLevel, - LivePublisher, PeerEventKind, PeerHandle, PeerView, TorrentEventKind, TorrentView, - TrackerEventKind, TrackerHandle, TrackerView, + 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, peer::PeerId, - settings::FrontendSettings, + settings::LiveSettings, torrent::{Torrent, TorrentInner}, tracker::Tracker, }; @@ -100,14 +100,14 @@ where #[derive(Debug)] struct EngineScope { - live: LivePublisher, + publisher: LivePublisher, } /// One self-contained torrent projection tree. #[derive(Debug)] pub(crate) struct TorrentScope { pub(crate) info_hash: InfoHash, - pub(crate) live: Arc, TorrentEventKind>>, + pub(crate) publisher: Arc, TorrentEventKind>>, peers: ScopeRegistry>, trackers: ScopeRegistry>, torrent: OnceLock>, @@ -119,7 +119,7 @@ impl TorrentScope { fn new(info_hash: InfoHash, event_capacity: usize) -> Self { Self { info_hash, - live: Arc::new(LivePublisher::new(None, event_capacity)), + publisher: Arc::new(LivePublisher::new(None, event_capacity)), peers: ScopeRegistry::new(), trackers: ScopeRegistry::new(), torrent: OnceLock::new(), @@ -164,7 +164,7 @@ impl TorrentScope { pub(crate) struct HubInner { engine: EngineScope, torrents: ScopeRegistry, - settings: FrontendSettings, + settings: LiveSettings, next_tracker_id: AtomicU64, } @@ -198,14 +198,17 @@ impl Hub { // Engine projection pub(crate) fn new() -> Self { - Self::with_settings(FrontendSettings::default()) + Self::with_settings(LiveSettings::default()) } - pub(crate) fn with_settings(settings: FrontendSettings) -> Self { + pub(crate) fn with_settings(settings: LiveSettings) -> Self { Self { inner: HubReference::Strong(Arc::new(HubInner { engine: EngineScope { - live: LivePublisher::new(EngineStatus::Starting, settings.engine_event_capacity), + publisher: LivePublisher::new( + EngineStatus::Starting, + settings.engine_event_capacity, + ), }, torrents: ScopeRegistry::new(), settings, @@ -243,7 +246,7 @@ impl Hub { } pub(crate) fn subscribe(&self) -> EventSubscription { - self.inner().engine.live.subscribe() + self.inner().engine.publisher.subscribe() } /// Derives the root projection from engine lifecycle and registered child @@ -255,21 +258,21 @@ impl Hub { .values() .into_iter() .filter(|scope| scope.is_registered()) - .filter_map(|scope| scope.live.view()) + .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.live.view(), + status: inner.engine.publisher.view(), torrents, } } pub(crate) fn engine_started(&self) { let inner = self.inner(); - let _ = inner.engine.live.replace_view(EngineStatus::Running); + let _ = inner.engine.publisher.replace_view(EngineStatus::Running); let _ = inner .engine - .live + .publisher .emit_without_view_change(EngineEventKind::EngineStarted(self.view())); } @@ -277,7 +280,7 @@ impl Hub { let _ = self .inner() .engine - .live + .publisher .replace_view(EngineStatus::Stopping); } @@ -287,7 +290,7 @@ impl Hub { let _ = self .inner() .engine - .live + .publisher .close_with_terminal_event(EngineStatus::Stopped, EngineEventKind::Shutdown(view)); } @@ -310,12 +313,12 @@ impl Hub { .inner() .torrents .get(&torrent) - .and_then(|scope| scope.live.view()) + .and_then(|scope| scope.publisher.view()) } pub(crate) fn initialize_torrent_projection(&self, torrent: TorrentView) { let scope = self.ensure_torrent_scope(torrent.info_hash); - let _ = scope.live.replace_view(Some(torrent)); + let _ = scope.publisher.replace_view(Some(torrent)); } pub(crate) fn register_torrent_scope(&self, torrent: Torrent) { @@ -324,7 +327,7 @@ impl Hub { if !scope.register(&torrent) { return; } - if let Some(view) = scope.live.view() { + if let Some(view) = scope.publisher.view() { self.replace_torrent_view_and_emit(view, TorrentEventKind::Added); } } @@ -341,7 +344,7 @@ impl Hub { }; let _publication = scope.publication_lock(); if !scope - .live + .publisher .replace_view_and_emit(Some(torrent), event.clone()) { return; @@ -349,7 +352,7 @@ impl Hub { let _ = self .inner() .engine - .live + .publisher .emit_without_view_change(EngineEventKind::Torrent { torrent: handle, event, @@ -357,9 +360,9 @@ impl Hub { } pub(crate) fn emit_health( - &self, torrent: Option, level: FrontendHealthLevel, message: impl Into, + &self, torrent: Option, level: LiveHealthLevel, message: impl Into, ) { - let health = FrontendHealth { + let health = LiveHealth { torrent, level, message: message.into(), @@ -372,7 +375,7 @@ impl Hub { let _ = self .inner() .engine - .live + .publisher .emit_without_view_change(EngineEventKind::Health(health)); } } @@ -404,7 +407,7 @@ impl Hub { } if !scope - .live + .publisher .close_with_terminal_event(None, TorrentEventKind::Removed) { return; @@ -415,7 +418,7 @@ impl Hub { let _ = self .inner() .engine - .live + .publisher .emit_without_view_change(EngineEventKind::Torrent { torrent, event: TorrentEventKind::Removed, @@ -428,13 +431,13 @@ impl Hub { return; }; let _publication = scope.publication_lock(); - if !scope.live.emit_without_view_change(event.clone()) { + if !scope.publisher.emit_without_view_change(event.clone()) { return; } let _ = self .inner() .engine - .live + .publisher .emit_without_view_change(EngineEventKind::Torrent { torrent, event }); } @@ -630,10 +633,10 @@ mod tests { #[tokio::test] async fn torrent_removal_closes_every_child_scope_exactly_once() { - let frontend = Hub::new(); + let hub = Hub::new(); let info_hash = InfoHash::from_bytes([4; 20]); - frontend.initialize_torrent_projection(torrent_view(info_hash)); - let peer = frontend.register_peer_scope( + hub.initialize_torrent_projection(torrent_view(info_hash)); + let peer = hub.register_peer_scope( PeerIdentity { torrent: info_hash, peer: PeerId::Unknown([5; 20]), @@ -641,12 +644,12 @@ mod tests { connected_peer_view(), ); let source = Tracker::Http("https://tracker.example/announce".to_string()); - let tracker = frontend.register_tracker_scope(info_hash, &source, pending_tracker_view()); + let tracker = hub.register_tracker_scope(info_hash, &source, pending_tracker_view()); let mut peer_events = peer.subscribe(); let mut tracker_events = tracker.subscribe(); - frontend.remove_torrent_scope(info_hash); - frontend.remove_torrent_scope(info_hash); + hub.remove_torrent_scope(info_hash); + hub.remove_torrent_scope(info_hash); assert_eq!( peer_events.recv().await.unwrap().kind, diff --git a/crates/libtortillas/src/frontend/mod.rs b/crates/libtortillas/src/live/mod.rs similarity index 84% rename from crates/libtortillas/src/frontend/mod.rs rename to crates/libtortillas/src/live/mod.rs index 404dd161..db03755c 100644 --- a/crates/libtortillas/src/frontend/mod.rs +++ b/crates/libtortillas/src/live/mod.rs @@ -1,22 +1,22 @@ -//! Transport-agnostic live application API. +//! Current state and incremental events for running engines and torrents. //! -//! Terminal interfaces, HTTP or WebSocket servers, websites, and desktop -//! applications all consume this same API. Rendering, transport, input, and -//! application routing policy remain outside `libtortillas`. +//! A listener combines a coherent current view with a bounded stream of future +//! changes. Terminals, servers, websites, and desktop applications can all +//! consume that contract without coupling the library to rendering, transport, +//! input, or application routing. //! //! # Public model //! -//! The module is organized by the way an application reads it: +//! The module is organized around observation: //! //! - [`EngineView`], [`TorrentView`], [`PeerView`], and [`TrackerView`] are -//! current presentation state. +//! current read models. //! - Shared measurements live in [`crate::metrics`] and are re-exported here. //! - Event enums describe discrete changes. //! - [`EventSubscription`] is events only; [`EventListener`] pairs events with //! a coherent current view. //! - [`PeerHandle`] and [`TrackerHandle`] provide scoped identity and access. -//! - The private hub owns the complete live projection tree and coordinates -//! publication. +//! - The private hub owns the projection tree and coordinates publication. //! //! [`crate::engine::Engine`] and [`crate::torrent::Torrent`] remain the sole //! public command API. There is no parallel command enum or generic `send` @@ -25,8 +25,8 @@ //! # Listening to an engine //! //! Create a listener before starting operations when the application must not -//! miss their events. Use [`EventListener::view`] for initial rendering and -//! lag recovery, and [`EventListener::recv`] for future changes. +//! miss their events. Use [`EventListener::view`] for initial state and lag +//! recovery, and [`EventListener::recv`] for future changes. //! //! ```no_run //! use libtortillas::prelude::{Engine, EngineEventKind, EventStreamError}; @@ -47,7 +47,7 @@ //! } //! } //! Err(EventStreamError::Lagged(_)) => { -//! // Discard adapter-local assumptions and redraw from current state. +//! // Discard consumer-local assumptions and reload current state. //! let current_view = listener.view(); //! let _ = current_view; //! } @@ -70,7 +70,7 @@ //! application to descend into detailed streams only when needed. //! //! Use `subscribe()` when only discrete events are needed. Use `listener()` -//! when initial rendering or recovery requires a current view as well. +//! when initialization or recovery requires a current view as well. //! //! # Ownership and source of truth //! @@ -99,10 +99,10 @@ //! //! # Architectural invariants //! -//! These rules define the live API's source of truth: +//! These rules define the source of truth for views and events: //! //! 1. Actors own operational domain state. -//! 2. A live scope owns only its frontend projection. +//! 2. An observation scope owns only its current projection. //! 3. Parent views are derived from child scopes; they do not maintain manually //! synchronized child-view copies. //! 4. Every scope has one view-and-event publication entry point. @@ -122,7 +122,7 @@ //! //! Channels are allocated lazily on first subscription. Defaults retain 256 //! engine or torrent events and 64 peer or tracker events; all capacities are -//! configurable with [`crate::settings::FrontendSettings`]. A slow consumer +//! configurable with [`crate::settings::LiveSettings`]. A slow consumer //! receives [`EventStreamError::Lagged`] instead of causing unbounded memory //! growth. Sequence numbers increase monotonically within each scope. //! @@ -150,15 +150,16 @@ //! //! # Views and persistence //! -//! Views are presentation contracts suitable for rendering, API responses, +//! Views are current-state contracts suitable for rendering, API responses, //! and transport serialization. [`crate::engine::EngineSnapshot`] and //! [`crate::torrent::TorrentSnapshot`] are durable persistence contracts. -//! Applications must not poll snapshots to refresh a frontend. See +//! Applications must not poll snapshots to refresh current state. See //! [`crate::torrent`] for restore validation and storage reconciliation rules. //! -//! Application-specific action routing can use an adapter-owned Tokio channel -//! whose consumer invokes methods on `Engine` and `Torrent`. That keeps UI or -//! server commands outside the library without duplicating its public API. +//! Application-specific action routing can use an application-owned Tokio +//! channel whose consumer invokes methods on `Engine` and `Torrent`. That keeps +//! caller-specific commands outside the library without duplicating its public +//! API. mod event; mod handle; @@ -167,7 +168,7 @@ mod stream; mod view; pub use event::{ - EngineEvent, EngineEventKind, FrontendHealth, FrontendHealthLevel, PeerEvent, PeerEventKind, + EngineEvent, EngineEventKind, LiveHealth, LiveHealthLevel, PeerEvent, PeerEventKind, SequencedEvent, TorrentEvent, TorrentEventKind, TrackerEvent, TrackerEventKind, }; pub(crate) use handle::PeerIdentity; @@ -242,13 +243,13 @@ mod tests { #[tokio::test] async fn peer_metrics_do_not_republish_the_torrent_projection() { - let frontend = Hub::new(); + let hub = Hub::new(); let info_hash = InfoHash::from_bytes([1; 20]); let torrent = benchmark_torrent_view(info_hash, "isolated"); - frontend.initialize_torrent_projection(torrent.clone()); - let scope = frontend.ensure_torrent_scope(info_hash); - let mut torrent_events = scope.live.subscribe(); - let peer = frontend.register_peer_scope( + hub.initialize_torrent_projection(torrent.clone()); + let scope = hub.ensure_torrent_scope(info_hash); + let mut torrent_events = scope.publisher.subscribe(); + let peer = hub.register_peer_scope( PeerIdentity { torrent: info_hash, peer: PeerId::Unknown([2; 20]), @@ -260,7 +261,7 @@ mod tests { peer.publish_metrics(peer_view); - assert_eq!(scope.live.view(), Some(torrent)); + assert_eq!(scope.publisher.view(), Some(torrent)); assert!( tokio::time::timeout(Duration::from_millis(20), torrent_events.recv()) .await @@ -270,9 +271,9 @@ mod tests { #[tokio::test] async fn tracker_restart_keeps_listener_open_until_final_stop() { - let frontend = Hub::new(); + let hub = Hub::new(); let source = Tracker::Http("https://tracker.example/announce".to_string()); - let tracker = frontend.register_tracker_scope( + let tracker = hub.register_tracker_scope( InfoHash::from_bytes([3; 20]), &source, pending_tracker_view(), @@ -286,7 +287,7 @@ mod tests { ); assert_eq!(listener.view().status, TrackerStatus::Restarting); - let restarted = frontend.register_tracker_scope( + let restarted = hub.register_tracker_scope( InfoHash::from_bytes([3; 20]), &source, pending_tracker_view(), @@ -316,21 +317,20 @@ mod tests { fn large_scope_tree_benchmark() { use std::time::Instant; - let frontend = Hub::new(); + 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); - frontend.initialize_torrent_projection(benchmark_torrent_view( + hub.initialize_torrent_projection(benchmark_torrent_view( InfoHash::from_bytes(hash), &format!("torrent-{torrent_index}"), )); - frontend - .ensure_torrent_scope(InfoHash::from_bytes(hash)) + hub.ensure_torrent_scope(InfoHash::from_bytes(hash)) .mark_registered_for_benchmark(); for peer_index in 0_u8..10 { - frontend.register_peer_scope( + hub.register_peer_scope( PeerIdentity { torrent: InfoHash::from_bytes(hash), peer: PeerId::Unknown([peer_index; 20]), @@ -347,7 +347,7 @@ mod tests { let bytes = torrent_index.to_be_bytes(); let mut hash = [0_u8; 20]; hash[..2].copy_from_slice(&bytes); - for peer in frontend.peer_handles(InfoHash::from_bytes(hash)) { + for peer in hub.peer_handles(InfoHash::from_bytes(hash)) { peer.publish_metrics(peer.view()); } } @@ -355,7 +355,7 @@ mod tests { let updates = started.elapsed(); let started = Instant::now(); - let view = frontend.view(); + let view = hub.view(); let view_construction = started.elapsed(); assert_eq!(view.torrent_count(), 100); assert!( @@ -366,13 +366,13 @@ mod tests { ); let removal_hash = InfoHash::from_bytes([255; 20]); - frontend.initialize_torrent_projection(benchmark_torrent_view(removal_hash, "removal")); + 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); - frontend.register_peer_scope( + hub.register_peer_scope( PeerIdentity { torrent: removal_hash, peer: PeerId::Unknown(id), @@ -383,14 +383,14 @@ mod tests { .collect::>(); let zero_listener_slots = removal_peers .iter() - .map(|peer| peer.inner.live.allocated_event_slots()) + .map(|peer| peer.inner.publisher.allocated_event_slots()) .sum::(); let zero_listener_memory_lower_bound = removal_peers .iter() - .map(|peer| peer.inner.live.allocation_lower_bound_bytes()) + .map(|peer| peer.inner.publisher.allocation_lower_bound_bytes()) .sum::(); let started = Instant::now(); - frontend.remove_torrent_scope(removal_hash); + hub.remove_torrent_scope(removal_hash); let removal = started.elapsed(); let burst = LivePublisher::new(0_u64, 8); diff --git a/crates/libtortillas/src/frontend/stream.rs b/crates/libtortillas/src/live/stream.rs similarity index 95% rename from crates/libtortillas/src/frontend/stream.rs rename to crates/libtortillas/src/live/stream.rs index 2b01abc1..8e5f440e 100644 --- a/crates/libtortillas/src/frontend/stream.rs +++ b/crates/libtortillas/src/live/stream.rs @@ -23,7 +23,7 @@ fn mutex_lock(lock: &Mutex) -> MutexGuard<'_, T> { .unwrap_or_else(std::sync::PoisonError::into_inner) } -/// Generic current-state and event publisher for live application APIs. +/// 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 @@ -271,15 +271,15 @@ impl fmt::Debug for EventSubscription { } } -/// Errors produced while receiving live frontend events. +/// 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("frontend event subscriber lagged by {0} events")] + #[error("live event subscriber lagged by {0} events")] Lagged(u64), /// The publisher closed the event stream. - #[error("frontend event stream closed")] + #[error("live event stream closed")] Closed, } @@ -339,7 +339,7 @@ impl fmt::Debug for EventListener { } } -/// Live engine listener with typed events and current presentation state. +/// Engine listener with typed events and current state. pub type EngineListener = EventListener; /// Live listener scoped to one torrent. @@ -354,19 +354,19 @@ mod tests { #[test] fn concurrent_update_and_close_never_accepts_an_update_after_terminal() { for _ in 0..100 { - let live = Arc::new(LivePublisher::new(0_u64, 8)); - let update = Arc::clone(&live); - let close = Arc::clone(&live); + 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!(!live.replace_view_and_emit(3, "late")); - assert_eq!(live.view(), 2); + assert!(!publisher.replace_view_and_emit(3, "late")); + assert_eq!(publisher.view(), 2); if update_accepted { - assert_eq!(live.view(), 2); + assert_eq!(publisher.view(), 2); } } } diff --git a/crates/libtortillas/src/frontend/view.rs b/crates/libtortillas/src/live/view.rs similarity index 90% rename from crates/libtortillas/src/frontend/view.rs rename to crates/libtortillas/src/live/view.rs index a133d287..3bd7ea0f 100644 --- a/crates/libtortillas/src/frontend/view.rs +++ b/crates/libtortillas/src/live/view.rs @@ -13,11 +13,11 @@ use crate::{ torrent::TorrentState, }; -/// Current live engine state maintained by a frontend listener. +/// Current engine state maintained by a listener. /// -/// Unlike persistence snapshots, views are presentation-oriented projections -/// updated by the actor hierarchy. A listener always reads the current -/// projection directly, including after a lagged event subscription. +/// 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, @@ -31,7 +31,7 @@ impl EngineView { } } -/// Current live state of one torrent. +/// Current state of one torrent. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TorrentView { pub info_hash: InfoHash, @@ -59,7 +59,7 @@ impl TorrentView { } } -/// Live view of a connected or recently disconnected peer. +/// Current state of a connected or recently disconnected peer. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PeerView { /// Network address for the peer, when known. @@ -102,7 +102,7 @@ impl HasTransferMetrics for PeerView { } } -/// Frontend-safe live tracker identity and latest announce outcome. +/// Public tracker identity and latest announce outcome. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TrackerView { /// Credential-free tracker endpoint label. diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index c82032bf..abbe172d 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -22,8 +22,8 @@ use tracing::{Span, debug, info, instrument, trace, warn}; use crate::{ errors::PeerActorError, - frontend::{PeerHandle, PeerView}, hashes::InfoHash, + live::{PeerHandle, PeerView}, metrics::{HasTransferMetrics, PeerMetrics, TransferMetrics, TransferSample}, peer::{Peer, PeerId}, protocol::{stream::PeerRecv, *}, @@ -56,7 +56,7 @@ pub(crate) struct PeerActor { pending_message_requests: VecDeque, last_rate_sample: TransferSample, settings: PeerSettings, - frontend: PeerHandle, + live_handle: PeerHandle, } impl PeerActor { @@ -334,7 +334,7 @@ impl PeerActor { let mut metrics = self.peer.metrics(); metrics.transfer = transfer; self - .frontend + .live_handle .publish_metrics(PeerView::from_peer_with_metrics(&self.peer, true, metrics)); Some(PeerStats { id, metrics }) @@ -355,7 +355,7 @@ impl Actor for PeerActor { /// 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 (mut peer, mut stream, supervisor, info_hash, settings, frontend) = args; + let (mut peer, mut stream, supervisor, info_hash, settings, 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"); @@ -389,7 +389,7 @@ impl Actor for PeerActor { pending_block_requests: HashSet::new(), pending_message_requests: VecDeque::with_capacity(settings.pending_message_capacity), settings, - frontend, + live_handle, }) } @@ -401,7 +401,7 @@ impl Actor for PeerActor { .supervisor .tell(torrent::commands::KillPeer { id: peer_id, - frontend: self.frontend.clone(), + handle: self.live_handle.clone(), }) .await { @@ -431,7 +431,7 @@ impl Actor for PeerActor { .supervisor .tell(torrent::commands::KillPeer { id, - frontend: self.frontend.clone(), + handle: self.live_handle.clone(), }) .await { @@ -650,9 +650,9 @@ impl Message for PeerActor { warn!("Received unexpected handshake from peer"); } } - let rates = self.frontend.view().metrics.transfer.rates; + let rates = self.live_handle.view().metrics.transfer.rates; self - .frontend + .live_handle .publish_state(PeerView::from_peer_with_rates(&self.peer, true, rates)); } } diff --git a/crates/libtortillas/src/settings.rs b/crates/libtortillas/src/settings.rs index 2f2fb662..561614cd 100644 --- a/crates/libtortillas/src/settings.rs +++ b/crates/libtortillas/src/settings.rs @@ -33,8 +33,8 @@ pub struct Settings { pub dht: DhtSettings, /// Engine actor and incoming socket settings. pub engine: EngineSettings, - /// Live frontend event-channel settings. - pub frontend: FrontendSettings, + /// Live view and event-channel settings. + pub live: LiveSettings, /// Per-torrent actor settings. pub torrent: TorrentSettings, /// Per-peer actor settings. @@ -43,19 +43,19 @@ pub struct Settings { pub tracker: TrackerSettings, } -/// Bounded event capacities for each frontend scope. +/// 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 FrontendSettings { +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 FrontendSettings { +impl Default for LiveSettings { fn default() -> Self { Self { engine_event_capacity: 256, diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index fe197b21..2420eedc 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -26,8 +26,8 @@ use tracing::{debug, error, info, instrument, trace, warn}; use super::{choking::ChokingScheduler, util}; use crate::{ errors::{SnapshotUnsupportedReason, TorrentError}, - frontend::{FrontendHealthLevel, Hub, TorrentView, TrackerStatus, TrackerView}, hashes::InfoHash, + live::{Hub, LiveHealthLevel, TorrentView, TrackerStatus, TrackerView}, metainfo::{Info, MetaInfo}, metrics::{ ByteCount, ContentProgress, HasTransferMetrics, TorrentMetrics, TrackerMetrics, @@ -100,7 +100,7 @@ impl PieceManager for PieceManagerProxy { } pub(crate) struct TorrentActor { - pub(super) frontend: Hub, + pub(super) hub: Hub, pub(crate) peers: HashMap>, pub(crate) trackers: HashMap>, @@ -220,9 +220,9 @@ impl TorrentActor { // Pre-start the piece manager before transitioning state if let Err(err) = self.piece_manager.pre_start(info.clone()).await { self.transition_state(TorrentState::Failed); - self.frontend.emit_health( + self.hub.emit_health( Some(self.info_hash()), - FrontendHealthLevel::Error, + LiveHealthLevel::Error, "torrent storage could not be initialized", ); error!(?err, "Failed to pre-start piece manager; aborting start"); @@ -483,7 +483,7 @@ impl TorrentActor { }) } - /// Builds the presentation state used by live application listeners. + /// Builds the current state exposed through listeners. pub fn live_view(&self) -> TorrentView { let info = self.info_dict(); let total_bytes = info @@ -510,13 +510,13 @@ impl TorrentActor { }) .count(); let peers = self - .frontend + .hub .peer_handles(self.info_hash()) .into_iter() .map(|peer| peer.view()) .collect::>(); let trackers = self - .frontend + .hub .tracker_handles(self.info_hash()) .into_iter() .map(|tracker| tracker.view()) @@ -567,11 +567,11 @@ impl TorrentActor { /// The single publication entry point for torrent projection changes. pub(super) fn publish_live_view( - &self, event: impl FnOnce(&TorrentView) -> crate::frontend::TorrentEventKind, + &self, event: impl FnOnce(&TorrentView) -> crate::live::TorrentEventKind, ) { let view = self.live_view(); let event = event(&view); - self.frontend.replace_torrent_view_and_emit(view, event); + self.hub.replace_torrent_view_and_emit(view, event); } pub(super) fn transition_state(&mut self, state: TorrentState) { @@ -581,7 +581,7 @@ impl TorrentActor { } self.state = state; - self.publish_live_view(|_| crate::frontend::TorrentEventKind::StateChanged { + self.publish_live_view(|_| crate::live::TorrentEventKind::StateChanged { previous, current: state, }); @@ -667,8 +667,8 @@ pub struct TorrentActorArgs { /// Runtime behavior settings. pub settings: Settings, - /// Live frontend state shared with the owning engine. - pub(crate) frontend: Hub, + /// Projection hub shared with the owning engine. + pub(crate) hub: Hub, } impl Actor for TorrentActor { @@ -692,7 +692,7 @@ impl Actor for TorrentActor { sufficient_peers, base_path, settings, - frontend, + hub, } = args; let torrent_id = metainfo.info_hash()?; @@ -744,8 +744,8 @@ impl Actor for TorrentActor { let tracker_list = metainfo.announce_list(); let mut trackers = HashMap::new(); for tracker in tracker_list { - let endpoint = tracker.frontend_endpoint(); - let tracker_frontend = frontend.register_tracker_scope( + let endpoint = tracker.redacted_endpoint(); + let tracker_handle = hub.register_tracker_scope( torrent_id, &tracker, TrackerView { @@ -765,7 +765,7 @@ impl Actor for TorrentActor { supervisor: us.clone(), scheduler: scheduler.clone(), settings: settings.tracker.clone(), - frontend: tracker_frontend, + live_handle: tracker_handle, }, ) .restart_policy(RestartPolicy::Transient) @@ -789,7 +789,7 @@ impl Actor for TorrentActor { .await; let actor = Self { - frontend, + hub, peers: HashMap::new(), bitfield, tracker_server, @@ -817,9 +817,7 @@ impl Actor for TorrentActor { piece_manager: PieceManagerProxy::Default(default_manager), settings, }; - actor - .frontend - .initialize_torrent_projection(actor.live_view()); + actor.hub.initialize_torrent_projection(actor.live_view()); Ok(actor) } @@ -841,10 +839,10 @@ impl Actor for TorrentActor { self.transition_state(TorrentState::Stopping); } else { // The engine supervises torrent actors transiently. Preserve the - // frontend scope and make the temporary state explicit. + // live scope and make the temporary state explicit. self.transition_state(TorrentState::Restarting); self - .frontend + .hub .close_peer_scopes_for_torrent_restart(self.info_hash()); } info!(reason = %reason, "Torrent stopped"); @@ -871,9 +869,9 @@ impl Actor for TorrentActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); - self.frontend.emit_health( + self.hub.emit_health( Some(self.info_hash()), - FrontendHealthLevel::Error, + LiveHealthLevel::Error, "a torrent service stopped unexpectedly", ); @@ -897,8 +895,8 @@ mod tests { use super::*; use crate::{ - frontend::{PeerIdentity, PeerView}, hashes::HashVec, + live::{PeerIdentity, PeerView}, metainfo::{InfoKeys, MetaInfo, TorrentFile}, metrics::{BytesPerSecond, PeerMetrics}, protocol::{ @@ -1100,7 +1098,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path), settings, - frontend: Hub::default(), + hub: Hub::default(), }); actor .tell(SetState { @@ -1151,7 +1149,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(testing::torrent_temp_path()), settings, - frontend: Hub::default(), + hub: Hub::default(), }); actor .tell(SetState { @@ -1199,7 +1197,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(base_path.clone()), settings, - frontend: Hub::default(), + hub: Hub::default(), }); actor .tell(SetState { @@ -1303,7 +1301,7 @@ mod tests { sufficient_peers: Some(1), base_path: Some(fixture.path().to_path_buf()), settings, - frontend: Hub::default(), + hub: Hub::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); actor.tell(AddPeer { peer: seed.peer() }).await.unwrap(); @@ -1364,7 +1362,7 @@ mod tests { sufficient_peers: Some(sufficient_peers), base_path: None, settings: Settings::default(), - frontend: Hub::default(), + hub: Hub::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); @@ -1398,7 +1396,7 @@ mod tests { sufficient_peers: None, base_path: None, settings: Settings::default(), - frontend: Hub::default(), + hub: Hub::default(), }); // Blocking loop that runs until we get an info dict @@ -1438,7 +1436,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), - frontend: Hub::default(), + hub: Hub::default(), }); assert_eq!(actor.ask(GetState).await.unwrap(), TorrentState::Ready); @@ -1463,7 +1461,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), - frontend: Hub::default(), + hub: Hub::default(), }); assert_eq!( @@ -1492,7 +1490,7 @@ mod tests { sufficient_peers: Some(0), base_path: None, settings: Settings::default(), - frontend: Hub::default(), + hub: Hub::default(), }); actor @@ -1552,7 +1550,7 @@ mod tests { sufficient_peers: None, base_path: Some(file_path), settings: Settings::default(), - frontend: Hub::default(), + hub: Hub::default(), }); let torrent = Torrent::new(info_hash, actor.clone()); @@ -1628,7 +1626,7 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), - frontend: Hub::default(), + hub: Hub::default(), }); // Build the bitfield with fake completed pieces @@ -1652,7 +1650,7 @@ mod tests { // Construct the actor manually for snapshot testing let test_actor = TorrentActor { - frontend: Hub::default(), + hub: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield, @@ -1776,7 +1774,7 @@ mod tests { let utp_server = UtpSocket::new_udp(testing::ephemeral_socket_addr()) .await .unwrap(); - let frontend = Hub::default(); + let hub = Hub::default(); let actor_ref = TorrentActor::spawn(TorrentActorArgs { peer_id, metainfo: metainfo.clone(), @@ -1788,10 +1786,10 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), - frontend: frontend.clone(), + hub: hub.clone(), }); actor_ref.ask(GetState).await.unwrap(); - assert_eq!(frontend.torrent_view(info_hash).unwrap().tracker_count, 1); + 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); @@ -1811,7 +1809,7 @@ mod tests { piece_scheduler.set_piece_blocks(partial_piece_index, blocks); let mut test_actor = TorrentActor { - frontend: Hub::default(), + hub: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield, @@ -1841,7 +1839,7 @@ mod tests { }; let verified_content = test_actor.live_view().metrics.progress.verified_bytes; - let sampled_peer = test_actor.frontend.register_peer_scope( + let sampled_peer = test_actor.hub.register_peer_scope( PeerIdentity { torrent: info_hash, peer: PeerId::Unknown([9; 20]), @@ -1867,7 +1865,7 @@ mod tests { }, }, ); - let _sampled_tracker = test_actor.frontend.register_tracker_scope( + let _sampled_tracker = test_actor.hub.register_tracker_scope( info_hash, &Tracker::Http("http://tracker.example/announce".to_string()), TrackerView { @@ -2013,11 +2011,11 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(file_path.clone()), settings: Settings::default(), - frontend: Hub::default(), + hub: Hub::default(), }); let mut actor = TorrentActor { - frontend: Hub::default(), + hub: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield: BitVec::repeat(false, piece_count), diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index c2c51378..2660b97a 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -17,11 +17,11 @@ use super::{ }; use crate::{ errors::{TorrentError, map_torrent_send_error}, - frontend::{ + hashes::InfoHash, + live::{ EventSubscription, Hub, HubInner, LivePublisher, PeerHandle, TorrentEventKind, TorrentListener, TorrentView, TrackerHandle, }, - hashes::InfoHash, pieces::PieceManager, }; @@ -30,7 +30,7 @@ pub(crate) struct TorrentInner { pub(crate) info_hash: InfoHash, pub(crate) actor: ActorRef, pub(crate) hub: Weak, - pub(crate) live: Arc, TorrentEventKind>>, + pub(crate) publisher: Arc, TorrentEventKind>>, } /// A handle to a torrent managed by the engine. @@ -57,22 +57,22 @@ impl Torrent { /// to its underlying [`TorrentActor`]. #[cfg(test)] pub(crate) fn new(info_hash: InfoHash, actor_ref: ActorRef) -> Self { - Self::new_with_frontend(info_hash, actor_ref, &Hub::default(), None) + Self::new_with_hub(info_hash, actor_ref, &Hub::default(), None) } - pub(crate) fn new_with_frontend( - info_hash: InfoHash, actor: ActorRef, frontend: &Hub, + pub(crate) fn new_with_hub( + info_hash: InfoHash, actor: ActorRef, hub: &Hub, initial_view: Option, ) -> Self { - let scope = frontend.ensure_torrent_scope(info_hash); + let scope = hub.ensure_torrent_scope(info_hash); if let Some(view) = initial_view { - let _ = scope.live.replace_view(Some(view)); + let _ = scope.publisher.replace_view(Some(view)); } let inner = Arc::new(TorrentInner { info_hash, actor, - hub: frontend.downgrade(), - live: Arc::clone(&scope.live), + hub: hub.downgrade(), + publisher: Arc::clone(&scope.publisher), }); Self { inner } } @@ -168,7 +168,7 @@ impl Torrent { /// Captures this torrent's metadata, storage configuration, and verified or /// partial piece state in a Serde-compatible persistence snapshot. /// - /// Use [`Self::listener`] for live frontend state. + /// Use [`Self::listener`] for current state and incremental updates. pub async fn snapshot(&self) -> Result { self .actor() @@ -215,40 +215,40 @@ impl Torrent { /// Subscribes to live events for this torrent only. #[must_use] pub fn subscribe(&self) -> EventSubscription { - self.inner.live.subscribe() + self.inner.publisher.subscribe() } /// Creates a live listener scoped to this torrent. #[must_use] pub fn listener(&self) -> TorrentListener { - self.inner.live.listener() + self.inner.publisher.listener() } - /// Returns the latest display-oriented state maintained for this torrent. + /// 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.live.view() + self.inner.publisher.view() } /// Returns handles for this torrent's currently connected peers. #[must_use] pub fn peers(&self) -> Vec { self - .frontend() - .map_or_else(Vec::new, |frontend| frontend.peer_handles(self.info_hash())) + .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.frontend().map_or_else(Vec::new, |frontend| { - frontend.tracker_handles(self.info_hash()) - }) + self + .hub() + .map_or_else(Vec::new, |live| live.tracker_handles(self.info_hash())) } - fn frontend(&self) -> Option { + 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 221e948f..24258c0d 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -17,8 +17,8 @@ use super::{ }; use crate::{ errors::TorrentError, - frontend::{TorrentEventKind, TorrentView}, hashes::InfoHash, + live::{TorrentEventKind, TorrentView}, metainfo::Info, peer::{Peer, PeerId, commands::HaveInfoDict}, pieces::{PieceManager, PieceScheduler}, @@ -169,13 +169,13 @@ pub(crate) mod commands { #[messages] impl TorrentActor { #[message] - pub(crate) fn kill_peer(&mut self, id: PeerId, frontend: crate::frontend::PeerHandle) { + pub(crate) fn kill_peer(&mut self, id: PeerId, handle: crate::live::PeerHandle) { self.piece_scheduler.peer_disconnected(id); // Kill the actor quietly. if let Some(actor) = self.peers.remove(&id) { actor.kill(); } - frontend.disconnected(); + handle.disconnected(); self.publish_live_view(|_| TorrentEventKind::Updated); self.fill_all_peer_request_windows(); } diff --git a/crates/libtortillas/src/torrent/mod.rs b/crates/libtortillas/src/torrent/mod.rs index b9b17c2b..4a1a2a7a 100644 --- a/crates/libtortillas/src/torrent/mod.rs +++ b/crates/libtortillas/src/torrent/mod.rs @@ -5,8 +5,8 @@ //! `TorrentActor` is the authoritative owner of torrent state. It coordinates //! one peer actor per connection, one tracker actor per endpoint, piece //! scheduling, verified progress, and storage. The public [`Torrent`] handle -//! exposes commands while [`crate::frontend::TorrentListener`] exposes the -//! presentation projection and typed events. +//! exposes commands while [`crate::live::TorrentListener`] exposes the +//! current projection and typed events. //! //! High-frequency peer protocol state remains local to peer scopes. Torrent //! transfer metrics are aggregated after periodic peer-stat collection rather diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index f2db790c..710d7eb4 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -9,7 +9,7 @@ use tracing::{debug, info, trace, warn}; use super::{TorrentActor, util}; #[cfg(test)] -use crate::frontend::Hub; +use crate::live::Hub; use crate::{ errors::TorrentError, peer::commands::{CancelPiece, Have, NeedPiece}, @@ -273,7 +273,7 @@ impl TorrentActor { if !self.validate_and_commit_piece(index).await { self.publish_live_view(|view| { - crate::frontend::TorrentEventKind::MetricsChanged(view.metrics.clone()) + crate::live::TorrentEventKind::MetricsChanged(view.metrics.clone()) }); self.fill_peer_request_window(peer_id); return; @@ -293,7 +293,7 @@ impl TorrentActor { // Piece completion is the meaningful progress boundary. Publishing for // every 16 KiB block creates an event storm without improving the view. self.publish_live_view(|view| { - crate::frontend::TorrentEventKind::MetricsChanged(view.metrics.clone()) + crate::live::TorrentEventKind::MetricsChanged(view.metrics.clone()) }); if self.piece_scheduler.next_piece() >= piece_count { @@ -496,11 +496,11 @@ mod tests { sufficient_peers: Some(usize::MAX), base_path: Some(base_path.clone()), settings: Settings::default(), - frontend: Hub::default(), + hub: Hub::default(), }); TorrentActor { - frontend: Hub::default(), + hub: Hub::default(), peers: HashMap::new(), trackers: HashMap::new(), bitfield: BitVec::::repeat(false, info.piece_count()), diff --git a/crates/libtortillas/src/torrent/state.rs b/crates/libtortillas/src/torrent/state.rs index df7318c7..7ea2a739 100644 --- a/crates/libtortillas/src/torrent/state.rs +++ b/crates/libtortillas/src/torrent/state.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// /// Expected transition shape: /// `Added` or `ResolvingMetadata` -> `Ready` -> `Downloading` -> `Seeding`. -/// Future frontend commands may also move a torrent through `Paused`, +/// Future commands may also move a torrent through `Paused`, /// `Restarting`, `Stopping`, `Stopped`, or `Failed` without collapsing those /// states into a generic inactive bucket. #[derive( @@ -32,7 +32,7 @@ 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, diff --git a/crates/libtortillas/src/torrent/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index 0c4775a9..afb4c93f 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -10,7 +10,7 @@ use tracing::{debug, instrument, trace, warn}; use super::TorrentActor; use crate::{ - frontend::{PeerIdentity, PeerView}, + live::{PeerIdentity, PeerView}, peer::{Peer, PeerActor, PeerId}, protocol::{ messages::{Handshake, PeerMessages}, @@ -109,7 +109,7 @@ impl TorrentActor { return; } - let peer_frontend = self.frontend.register_peer_scope( + let peer_handle = self.hub.register_peer_scope( PeerIdentity { torrent: info_hash, peer: id, @@ -124,7 +124,7 @@ impl TorrentActor { actor_ref, info_hash, peer_settings, - peer_frontend.clone(), + peer_handle.clone(), ), match peer_mailbox_size { 0 => mailbox::unbounded(), @@ -132,8 +132,8 @@ impl TorrentActor { }, ); self.peers.insert(id, peer_actor); - self.publish_live_view(|_| crate::frontend::TorrentEventKind::Updated); - self.frontend.emit_peer_connected(&peer_frontend); + self.publish_live_view(|_| crate::live::TorrentEventKind::Updated); + self.hub.emit_peer_connected(&peer_handle); } #[instrument(skip(self, tell), fields(torrent_id = %self.info_hash(), msg = ?tell))] @@ -174,7 +174,7 @@ impl TorrentActor { } for id in dead_peers { self.peers.remove(&id); - self.publish_live_view(|_| crate::frontend::TorrentEventKind::Updated); + self.publish_live_view(|_| crate::live::TorrentEventKind::Updated); } } diff --git a/crates/libtortillas/src/tracker/actor.rs b/crates/libtortillas/src/tracker/actor.rs index 9c250ab7..8883b90e 100644 --- a/crates/libtortillas/src/tracker/actor.rs +++ b/crates/libtortillas/src/tracker/actor.rs @@ -22,7 +22,7 @@ use super::{ }; use crate::{ errors::TrackerActorError, - frontend::TrackerHandle, + live::TrackerHandle, metrics::{TrackerMetrics, TransferMetrics, TransferSample}, peer::PeerId, settings::TrackerSettings, @@ -38,7 +38,7 @@ pub(crate) struct TrackerActor { next_announce: Option, actor_ref: ActorRef, settings: TrackerSettings, - frontend: TrackerHandle, + live_handle: TrackerHandle, last_rate_sample: TransferSample, } @@ -52,7 +52,7 @@ pub(crate) struct TrackerActorArgs { pub(crate) supervisor: ActorRef, pub(crate) scheduler: ActorRef, pub(crate) settings: TrackerSettings, - pub(crate) frontend: TrackerHandle, + pub(crate) live_handle: TrackerHandle, } impl Actor for TrackerActor { @@ -69,7 +69,7 @@ impl Actor for TrackerActor { supervisor, scheduler, settings, - frontend, + live_handle, } = state; let info_hash = supervisor @@ -126,7 +126,7 @@ impl Actor for TrackerActor { } let initial_metrics = tracker.stats().metrics(); let totals = initial_metrics.transfer.totals; - frontend.publish_metrics(initial_metrics); + live_handle.publish_metrics(initial_metrics); if let Err(e) = supervisor .tell(torrent::events::TrackerMetricsChanged) .await @@ -151,7 +151,7 @@ impl Actor for TrackerActor { next_announce: Some(next_announce), actor_ref, settings, - frontend, + live_handle, last_rate_sample: TransferSample::new(Instant::now(), totals), }) } @@ -166,8 +166,8 @@ 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")); - let metrics = self.snapshot_metrics(self.frontend.view().metrics.latest_peers_returned); - self.frontend.publish_metrics(metrics); + 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) @@ -177,12 +177,12 @@ impl Actor for TrackerActor { } if reason.is_normal() { - self.frontend.stopped(); + self.live_handle.stopped(); } else { // Transient supervision may reconstruct this actor with the same - // frontend scope. Keep the listener open until its owning torrent + // live scope. Keep the listener open until its owning torrent // performs final tree cleanup. - self.frontend.restarting(); + self.live_handle.restarting(); } Ok(()) @@ -242,7 +242,7 @@ impl TrackerActor { let metrics = self.snapshot_metrics(latest_peers_returned); match result { Ok(peers) => { - self.frontend.announce_succeeded(metrics); + self.live_handle.announce_succeeded(metrics); if let Err(e) = self .supervisor .tell(torrent::events::Announce { @@ -256,7 +256,7 @@ impl TrackerActor { } Err(e) => { error!(error = %e, "Announce request failed"); - self.frontend.announce_failed(metrics); + self.live_handle.announce_failed(metrics); } } if let Err(e) = self diff --git a/crates/libtortillas/src/tracker/model.rs b/crates/libtortillas/src/tracker/model.rs index 743203cb..cc43f13b 100644 --- a/crates/libtortillas/src/tracker/model.rs +++ b/crates/libtortillas/src/tracker/model.rs @@ -118,8 +118,8 @@ impl Tracker { } } - /// Returns a credential-free endpoint label for frontend events. - pub(crate) fn frontend_endpoint(&self) -> String { + /// 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(); @@ -291,12 +291,12 @@ mod tests { use super::*; #[test] - fn frontend_endpoint_removes_tracker_credentials_and_paths() { + 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.frontend_endpoint(); + let endpoint = tracker.redacted_endpoint(); assert_eq!(endpoint, "https://tracker.example/"); assert!(!endpoint.contains("alice")); @@ -306,12 +306,12 @@ mod tests { } #[test] - fn udp_frontend_endpoint_removes_tracker_credentials() { + 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.frontend_endpoint(); + let endpoint = tracker.redacted_endpoint(); assert_eq!(endpoint, "udp://tracker.example:6969/"); assert!(!endpoint.contains("alice")); @@ -323,6 +323,6 @@ mod tests { fn invalid_tracker_endpoint_falls_back_to_protocol_only() { let tracker = Tracker::Udp("udp://[invalid".to_string()); - assert_eq!(tracker.frontend_endpoint(), "udp"); + assert_eq!(tracker.redacted_endpoint(), "udp"); } } diff --git a/crates/libtortillas/tests/facade.rs b/crates/libtortillas/tests/facade.rs index 18c46247..d7e11ca7 100644 --- a/crates/libtortillas/tests/facade.rs +++ b/crates/libtortillas/tests/facade.rs @@ -4,7 +4,7 @@ use libtortillas::{ }; #[test] -fn prelude_exposes_frontend_facade_types() { +fn prelude_exposes_live_facade_types() { fn accepts_torrent_events(_: Option>) {} fn accepts_peer_events(_: Option>) {} fn accepts_tracker_events(_: Option>) {} diff --git a/crates/libtortillas/tests/live_frontend.rs b/crates/libtortillas/tests/live.rs similarity index 99% rename from crates/libtortillas/tests/live_frontend.rs rename to crates/libtortillas/tests/live.rs index ddebbc05..142221bf 100644 --- a/crates/libtortillas/tests/live_frontend.rs +++ b/crates/libtortillas/tests/live.rs @@ -4,7 +4,7 @@ use futures::StreamExt; use libtortillas::{ engine::EngineStatus, errors::EngineError, - frontend::{ + live::{ EngineEventKind, EventStreamError, LivePublisher, TorrentEventKind, TrackerEventKind, TrackerStatus, }, From 0459651476f570278c2a42c6ad41f6ee327d66d4 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sun, 26 Jul 2026 20:45:35 -0700 Subject: [PATCH 73/77] fix: harden live lifecycle handling --- crates/libtortillas/src/engine/actor.rs | 22 +- crates/libtortillas/src/engine/messages.rs | 10 +- crates/libtortillas/src/engine/snapshot.rs | 2 + crates/libtortillas/src/facade.rs | 1 + crates/libtortillas/src/live/event.rs | 2 +- crates/libtortillas/src/live/handle.rs | 20 +- crates/libtortillas/src/live/hub.rs | 323 +++++++++++++----- crates/libtortillas/src/live/mod.rs | 74 ++-- crates/libtortillas/src/live/stream.rs | 45 ++- crates/libtortillas/src/live/view.rs | 32 +- crates/libtortillas/src/metrics.rs | 205 +++++++---- crates/libtortillas/src/peer/actor.rs | 27 +- crates/libtortillas/src/peer/state.rs | 2 +- .../libtortillas/src/pieces/piece_manager.rs | 6 +- crates/libtortillas/src/protocol/stream.rs | 42 ++- crates/libtortillas/src/torrent/actor.rs | 137 ++++---- crates/libtortillas/src/torrent/choking.rs | 34 +- crates/libtortillas/src/torrent/handle.rs | 6 +- crates/libtortillas/src/torrent/messages.rs | 14 + crates/libtortillas/src/torrent/piece_flow.rs | 3 + crates/libtortillas/src/torrent/snapshot.rs | 46 ++- crates/libtortillas/src/torrent/swarm.rs | 9 +- crates/libtortillas/src/tracker/actor.rs | 13 +- crates/libtortillas/src/tracker/stats.rs | 2 +- crates/libtortillas/tests/dht_network.rs | 8 +- crates/libtortillas/tests/live.rs | 83 ++--- crates/libtortillas/tests/persistence.rs | 16 +- 27 files changed, 767 insertions(+), 417 deletions(-) diff --git a/crates/libtortillas/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index 38f4e81d..9c9804a0 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -142,18 +142,26 @@ impl Actor for EngineActor { 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}")); + 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}")); + 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}")); + hub.engine_start_failed(error.to_string()); + error + })?; let peer_id = peer_id.unwrap_or_default(); let dht = if settings.dht.enabled { diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index b9c799c6..680723d7 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -36,7 +36,7 @@ pub(crate) mod commands { use super::*; impl EngineActor { - async fn discard_restored_torrent( + async fn discard_failed_torrent( &mut self, info_hash: InfoHash, torrent: &ActorRef, ) { if self.torrents.remove(&info_hash).is_some() @@ -243,12 +243,12 @@ pub(crate) mod commands { Ok(result) => match result.0 { Ok(_) => {} Err(error) => { - self.discard_restored_torrent(info_hash, &torrent_ref).await; + self.discard_failed_torrent(info_hash, &torrent_ref).await; return Err(error.into()); } }, Err(error) => { - self.discard_restored_torrent(info_hash, &torrent_ref).await; + self.discard_failed_torrent(info_hash, &torrent_ref).await; return Err(EngineError::ActorCommunicationFailed { operation: "restore torrent snapshot", reason: error.to_string(), @@ -286,7 +286,7 @@ pub(crate) mod commands { }) .await { - self.discard_restored_torrent(info_hash, &torrent_ref).await; + self.discard_failed_torrent(info_hash, &torrent_ref).await; return Err(EngineError::Torrent(map_torrent_send_error( "resume restored torrent", error, @@ -295,7 +295,7 @@ pub(crate) mod commands { let initial_view = match torrent_ref.ask(torrent::commands::GetLiveView).await { Ok(view) => *view, Err(error) => { - self.discard_restored_torrent(info_hash, &torrent_ref).await; + self.discard_failed_torrent(info_hash, &torrent_ref).await; return Err(EngineError::ActorCommunicationFailed { operation: "initialize torrent live state", reason: error.to_string(), diff --git a/crates/libtortillas/src/engine/snapshot.rs b/crates/libtortillas/src/engine/snapshot.rs index 8db6dbbf..8b6e0ff2 100644 --- a/crates/libtortillas/src/engine/snapshot.rs +++ b/crates/libtortillas/src/engine/snapshot.rs @@ -63,6 +63,8 @@ impl EngineSnapshot { 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. diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index 8c037a2d..4648dea1 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -28,6 +28,7 @@ pub use crate::{ metrics::{ ByteCount, BytesPerSecond, ContentProgress, HasTransferMetrics, PeerMetrics, Seconds, TorrentMetrics, TrackerMetrics, TrafficTotals, TransferMetrics, TransferRates, + TransferSample, }, torrent::{RestoreVerification, Torrent, TorrentSnapshot}, }; diff --git a/crates/libtortillas/src/live/event.rs b/crates/libtortillas/src/live/event.rs index 8f0e0e75..481afc33 100644 --- a/crates/libtortillas/src/live/event.rs +++ b/crates/libtortillas/src/live/event.rs @@ -81,7 +81,7 @@ pub enum TorrentEventKind { } /// Events emitted by one peer's independent live publisher. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum PeerEventKind { StateChanged, diff --git a/crates/libtortillas/src/live/handle.rs b/crates/libtortillas/src/live/handle.rs index 8f7a0fc6..b4b664fa 100644 --- a/crates/libtortillas/src/live/handle.rs +++ b/crates/libtortillas/src/live/handle.rs @@ -128,7 +128,7 @@ impl PeerHandle { } pub(crate) fn publish_metrics(&self, view: PeerView) { - let metrics = view.metrics; + let metrics = view.metrics.clone(); let _ = self .inner .publisher @@ -270,8 +270,8 @@ impl TrackerHandle { pub(crate) fn announce_succeeded(&self, metrics: TrackerMetrics) { let mut view = self.view(); view.status = TrackerStatus::Healthy; - view.metrics = metrics; 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() @@ -310,13 +310,15 @@ impl TrackerHandle { pub(crate) fn stopped(&self) { let mut view = self.view(); view.status = TrackerStatus::Stopped; - if self + let closed = self .inner .publisher - .close_with_terminal_event(view, TrackerEventKind::Stopped) - && let Some(hub) = self.inner.hub() - { - hub.emit_tracker_event(self, TrackerEventKind::Stopped); + .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); + } } } @@ -327,6 +329,9 @@ impl TrackerHandle { .inner .publisher .close_with_terminal_event(view, TrackerEventKind::Stopped); + if let Some(hub) = self.inner.hub() { + hub.remove_tracker_scope(self); + } } } @@ -388,6 +393,7 @@ mod tests { }, connected_peer_view(), ) + .unwrap() } #[tokio::test] diff --git a/crates/libtortillas/src/live/hub.rs b/crates/libtortillas/src/live/hub.rs index 61dbf3f1..c824eab8 100644 --- a/crates/libtortillas/src/live/hub.rs +++ b/crates/libtortillas/src/live/hub.rs @@ -75,6 +75,20 @@ where 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 @@ -185,6 +199,15 @@ enum HubReference { 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 @@ -236,23 +259,27 @@ impl Hub { } } - fn inner(&self) -> Arc { - match &self.inner { - HubReference::Strong(inner) => Arc::clone(inner), - HubReference::Weak(inner) => inner - .upgrade() - .expect("live hub outlived by its actor hierarchy"), - } + fn inner(&self) -> Option> { + self.inner.inner() } pub(crate) fn subscribe(&self) -> EventSubscription { - self.inner().engine.publisher.subscribe() + 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 inner = self.inner(); + let Some(inner) = self.inner() else { + return EngineView { + status: EngineStatus::Stopped, + torrents: Vec::new(), + }; + }; let mut torrents = inner .torrents .values() @@ -268,7 +295,9 @@ impl Hub { } pub(crate) fn engine_started(&self) { - let inner = self.inner(); + let Some(inner) = self.inner() else { + return; + }; let _ = inner.engine.publisher.replace_view(EngineStatus::Running); let _ = inner .engine @@ -276,19 +305,35 @@ impl Hub { .emit_without_view_change(EngineEventKind::EngineStarted(self.view())); } - pub(crate) fn engine_stopping(&self) { - let _ = self - .inner() + 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 - .replace_view(EngineStatus::Stopping); + .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 _ = self - .inner() + let _ = inner .engine .publisher .close_with_terminal_event(EngineStatus::Stopped, EngineEventKind::Shutdown(view)); @@ -296,52 +341,71 @@ impl Hub { // Torrent scopes - pub(crate) fn ensure_torrent_scope(&self, info_hash: InfoHash) -> Arc { - let inner = self.inner(); - inner.torrents.get_or_insert_with(info_hash, || { + 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) + self.inner()?.torrent_handle(torrent) } #[cfg(test)] pub(crate) fn torrent_view(&self, torrent: InfoHash) -> Option { self - .inner() + .inner()? .torrents .get(&torrent) .and_then(|scope| scope.publisher.view()) } pub(crate) fn initialize_torrent_projection(&self, torrent: TorrentView) { - let scope = self.ensure_torrent_scope(torrent.info_hash); + 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 scope = self.ensure_torrent_scope(info_hash); - if !scope.register(&torrent) { + 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; } - if let Some(view) = scope.publisher.view() { - self.replace_torrent_view_and_emit(view, TorrentEventKind::Added); + 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(scope) = self.inner().torrents.get(&info_hash) else { + let Some(inner) = self.inner() else { return; }; - let Some(handle) = self.torrent_handle(info_hash) else { + 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 @@ -349,8 +413,10 @@ impl Hub { { return; } - let _ = self - .inner() + let Some(handle) = scope.handle() else { + return; + }; + let _ = inner .engine .publisher .emit_without_view_change(EngineEventKind::Torrent { @@ -362,18 +428,20 @@ impl Hub { 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) = self.inner().torrents.get(&info_hash) + && let Some(scope) = inner.torrents.get(&info_hash) { - self.emit_without_torrent_view_change(&scope, TorrentEventKind::Health(health)); + Self::emit_without_torrent_view_change(&inner, &scope, TorrentEventKind::Health(health)); } else { - let _ = self - .inner() + let _ = inner .engine .publisher .emit_without_view_change(EngineEventKind::Health(health)); @@ -381,10 +449,13 @@ impl Hub { } pub(crate) fn remove_torrent_scope(&self, info_hash: InfoHash) { - let Some(scope) = self.inner().torrents.get(&info_hash) else { + let Some(inner) = self.inner() else { return; }; - let torrent = self.torrent_handle(info_hash); + let Some(scope) = inner.torrents.get(&info_hash) else { + return; + }; + let torrent = scope.handle(); let peers = scope .peers .values() @@ -413,10 +484,9 @@ impl Hub { return; } drop(publication); - self.inner().torrents.remove(&info_hash); + inner.torrents.remove(&info_hash); if let Some(torrent) = torrent { - let _ = self - .inner() + let _ = inner .engine .publisher .emit_without_view_change(EngineEventKind::Torrent { @@ -426,16 +496,17 @@ impl Hub { } } - fn emit_without_torrent_view_change(&self, scope: &TorrentScope, event: TorrentEventKind) { - let Some(torrent) = self.torrent_handle(scope.info_hash) else { + 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 _ = self - .inner() + let _ = inner .engine .publisher .emit_without_view_change(EngineEventKind::Torrent { torrent, event }); @@ -444,24 +515,27 @@ impl Hub { // Peer scopes pub(crate) fn peer_handles(&self, torrent: InfoHash) -> Vec { - self - .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() - }) + 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) -> PeerHandle { - let inner = self.inner(); - let scope = self.ensure_torrent_scope(identity.torrent); + 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, @@ -469,15 +543,19 @@ impl Hub { inner.settings.peer_event_capacity, ); scope.peers.insert(identity.peer, &peer.inner); - peer + Some(peer) } pub(crate) fn emit_peer_connected(&self, peer: &PeerHandle) { - let Some(scope) = self.inner().torrents.get(&peer.torrent()) else { + 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( + Self::emit_without_torrent_view_change( + &inner, &scope, TorrentEventKind::PeerConnected(peer.clone()), ); @@ -485,20 +563,27 @@ impl Hub { } pub(crate) fn mark_peer_disconnected(&self, peer: &PeerHandle) { - let Some(scope) = self.inner().torrents.get(&peer.torrent()) else { + 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( + 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(scope) = self.inner().torrents.get(&torrent) else { + let Some(inner) = self.inner() else { + return; + }; + let Some(scope) = inner.torrents.get(&torrent) else { return; }; for inner in scope.peers.remove_all() { @@ -509,27 +594,31 @@ impl Hub { // Tracker scopes pub(crate) fn tracker_handles(&self, torrent: InfoHash) -> Vec { - self - .inner() - .torrents - .get(&torrent) - .map_or_else(Vec::new, |scope| { - scope - .trackers - .values() - .into_iter() - .map(|inner| TrackerHandle { inner }) - .collect() - }) + 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, - ) -> TrackerHandle { - let inner = self.inner(); - let torrent_scope = self.ensure_torrent_scope(torrent); - if let Some(inner) = torrent_scope.trackers.get(source) { - return TrackerHandle { inner }; + ) -> 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 }; @@ -542,11 +631,24 @@ impl Hub { torrent_scope .trackers .insert(source.clone(), &tracker.inner); - tracker + 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(scope) = self.inner().torrents.get(&tracker.torrent()) else { + let Some(inner) = self.inner() else { + return; + }; + let Some(scope) = inner.torrents.get(&tracker.torrent()) else { return; }; let torrent_event = match event { @@ -559,7 +661,7 @@ impl Hub { TrackerEventKind::Restarting => TorrentEventKind::TrackerRestarting(tracker.clone()), TrackerEventKind::Stopped => TorrentEventKind::TrackerStopped(tracker.clone()), }; - self.emit_without_torrent_view_change(&scope, torrent_event); + Self::emit_without_torrent_view_change(&inner, &scope, torrent_event); } } @@ -636,15 +738,19 @@ mod tests { 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(), - ); + 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()); + 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(); @@ -662,4 +768,33 @@ mod tests { ); 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 index db03755c..a307b44b 100644 --- a/crates/libtortillas/src/live/mod.rs +++ b/crates/libtortillas/src/live/mod.rs @@ -126,12 +126,9 @@ //! receives [`EventStreamError::Lagged`] instead of causing unbounded memory //! growth. Sequence numbers increase monotonically within each scope. //! -//! [`LivePublisher`] mutation names describe their full effect: -//! [`LivePublisher::replace_view`] changes only the projection, -//! [`LivePublisher::replace_view_and_emit`] performs a coherent view/event -//! transition, [`LivePublisher::emit_without_view_change`] emits a discrete -//! event, and [`LivePublisher::close_with_terminal_event`] performs the one -//! irreversible close transition. +//! Projection mutation and terminal closure are crate-internal. Applications +//! can observe publishers through their view, listener, and subscription APIs +//! without being able to alter actor-owned state. //! //! Supervised torrent and tracker actors publish a restarting state after //! abnormal termination and keep their streams open. Final ownership teardown @@ -182,7 +179,7 @@ pub use view::{EngineView, PeerView, TorrentView, TrackerStatus, TrackerView}; pub use crate::metrics::{ ByteCount, BytesPerSecond, ContentProgress, HasTransferMetrics, PeerMetrics, Seconds, - TorrentMetrics, TrackerMetrics, TrafficTotals, TransferMetrics, TransferRates, + TorrentMetrics, TrackerMetrics, TrafficTotals, TransferMetrics, TransferRates, TransferSample, }; #[cfg(test)] @@ -247,17 +244,23 @@ mod tests { 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); + 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(), - ); + 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.rates = Some(Default::default()); + 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); @@ -273,11 +276,13 @@ mod tests { 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(), - ); + let tracker = hub + .register_tracker_scope( + InfoHash::from_bytes([3; 20]), + &source, + pending_tracker_view(), + ) + .unwrap(); let mut listener = tracker.listener(); tracker.restarting(); @@ -287,11 +292,13 @@ mod tests { ); assert_eq!(listener.view().status, TrackerStatus::Restarting); - let restarted = hub.register_tracker_scope( - InfoHash::from_bytes([3; 20]), - &source, - pending_tracker_view(), - ); + 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), @@ -310,6 +317,16 @@ mod tests { 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] @@ -328,6 +345,7 @@ mod tests { &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( @@ -336,7 +354,8 @@ mod tests { peer: PeerId::Unknown([peer_index; 20]), }, connected_peer_view(), - ); + ) + .unwrap(); } } let construction = started.elapsed(); @@ -379,6 +398,7 @@ mod tests { }, connected_peer_view(), ) + .unwrap() }) .collect::>(); let zero_listener_slots = removal_peers diff --git a/crates/libtortillas/src/live/stream.rs b/crates/libtortillas/src/live/stream.rs index 8e5f440e..4ea899e1 100644 --- a/crates/libtortillas/src/live/stream.rs +++ b/crates/libtortillas/src/live/stream.rs @@ -76,12 +76,7 @@ where pub fn subscribe(&self) -> EventSubscription { let state = mutex_lock(&self.state); if state.closed { - return { - let (sender, receiver) = broadcast::channel(1); - let weak = sender.downgrade(); - drop(sender); - EventSubscription::from_receiver(receiver, weak) - }; + return EventSubscription::closed(); } let mut slot = mutex_lock(&self.channel.sender); let sender = slot.get_or_insert_with(|| { @@ -123,10 +118,14 @@ where 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 fn replace_view(&self, view: V) -> bool { + pub(crate) fn replace_view(&self, view: V) -> bool { let mut state = mutex_lock(&self.state); if state.closed { return false; @@ -138,14 +137,14 @@ where /// Replaces the current view and emits the corresponding event. /// /// Returns `false` when the publisher has already closed. - pub fn replace_view_and_emit(&self, view: V, event: E) -> bool { + 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 fn emit_without_view_change(&self, event: E) -> bool { + pub(crate) fn emit_without_view_change(&self, event: E) -> bool { self.apply_and_emit(|_| {}, event) } @@ -153,7 +152,7 @@ where /// delivering one terminal event. /// /// Returns `false` if another caller already closed the publisher. - pub fn close_with_terminal_event(&self, view: V, event: E) -> bool { + 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; @@ -192,6 +191,21 @@ where } } +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. @@ -219,7 +233,7 @@ impl EventSubscription { } } - fn closed() -> Self { + pub(crate) fn closed() -> Self { let (sender, receiver) = broadcast::channel(1); let weak = sender.downgrade(); drop(sender); @@ -386,4 +400,13 @@ mod tests { 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 index 3bd7ea0f..8949eb4c 100644 --- a/crates/libtortillas/src/live/view.rs +++ b/crates/libtortillas/src/live/view.rs @@ -5,10 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::{ engine::EngineStatus, hashes::InfoHash, - metrics::{ - HasTransferMetrics, PeerMetrics, TorrentMetrics, TrackerMetrics, TransferMetrics, - TransferRates, - }, + metrics::{HasTransferMetrics, PeerMetrics, TorrentMetrics, TrackerMetrics, TransferMetrics}, peer::Peer, torrent::TorrentState, }; @@ -73,14 +70,14 @@ pub struct PeerView { impl PeerView { pub(crate) fn from_peer(peer: &Peer, connected: bool) -> Self { - Self::from_peer_with_rates(peer, connected, None) + Self::from_peer_with_samples(peer, connected, Vec::new()) } - pub(crate) fn from_peer_with_rates( - peer: &Peer, connected: bool, rates: Option, + pub(crate) fn from_peer_with_samples( + peer: &Peer, connected: bool, samples: Vec, ) -> Self { let mut metrics = peer.metrics(); - metrics.transfer.rates = rates; + metrics.transfer.samples = samples; Self::from_peer_with_metrics(peer, connected, metrics) } @@ -143,8 +140,10 @@ impl TrackerStatus { #[cfg(test)] mod tests { + use std::time::Duration; + use super::*; - use crate::metrics::{BytesPerSecond, TrafficTotals}; + use crate::metrics::{ByteCount, BytesPerSecond, TrafficTotals, TransferRates, TransferSample}; #[test] fn peer_view_uses_canonical_byte_units() { @@ -155,13 +154,14 @@ mod tests { metrics: PeerMetrics { peer_interested: true, available_pieces: 1, - transfer: TransferMetrics { - totals: TrafficTotals::default(), - rates: Some(TransferRates { - download: BytesPerSecond(3), - upload: BytesPerSecond(2), - }), - }, + 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() }, }; diff --git a/crates/libtortillas/src/metrics.rs b/crates/libtortillas/src/metrics.rs index a3fbf088..f5739975 100644 --- a/crates/libtortillas/src/metrics.rs +++ b/crates/libtortillas/src/metrics.rs @@ -7,11 +7,12 @@ //! and can include duplicate or rejected data; [`ContentProgress`] measures //! verified torrent payload and must remain separate. //! -//! An absent rate sample means no sample has been collected. A present -//! [`TransferRates`] containing zero means an interval was measured and no -//! transfer occurred. ETA is derived from verified remaining bytes and the -//! aggregate sampled download rate rather than stored as independently mutable -//! state. +//! 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}; @@ -122,7 +123,7 @@ impl TransferRates { ) -> Option { let mut aggregate = None::; for source in sources { - let Some(rates) = source.transfer_metrics().rates else { + let Some(rates) = source.transfer_rates() else { continue; }; let current = aggregate.get_or_insert_default(); @@ -133,9 +134,7 @@ impl TransferRates { } #[must_use] - pub(crate) fn between( - previous: TrafficTotals, current: TrafficTotals, elapsed: Duration, - ) -> Self { + 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 { @@ -156,17 +155,79 @@ impl TransferRates { } } -/// Traffic totals and the latest interval rate sample. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +/// 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, - /// `None` means no sample has been collected. `Some(default())` is a known - /// zero-rate sample. - pub rates: Option, + #[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, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct PeerMetrics { pub transfer: TransferMetrics, pub peer_choking: bool, @@ -184,7 +245,7 @@ impl HasTransferMetrics for PeerMetrics { /// Tracker-specific metrics layered on top of the shared transfer /// measurements. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TrackerMetrics { pub transfer: TransferMetrics, pub announce_attempts: u64, @@ -223,7 +284,7 @@ pub struct TorrentMetrics { impl TorrentMetrics { #[must_use] pub fn new(traffic: TransferMetrics, progress: ContentProgress) -> Self { - let eta = Self::calculate_eta(&progress, traffic.rates); + let eta = Self::calculate_eta(&progress, traffic.rates()); Self { traffic, progress, @@ -250,27 +311,33 @@ impl HasTransferMetrics for TorrentMetrics { /// 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 TransferSample { +pub(crate) struct TimedTransferSample { at: Instant, totals: TrafficTotals, } -impl TransferSample { +impl TimedTransferSample { #[must_use] pub(crate) fn new(at: Instant, totals: TrafficTotals) -> Self { Self { at, totals } } #[must_use] - pub(crate) fn rates_since(self, previous: Self) -> TransferRates { - TransferRates::between( - previous.totals, - self.totals, - self.at.saturating_duration_since(previous.at), - ) + 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), + } } } @@ -287,6 +354,17 @@ mod tests { } } + 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())]; @@ -295,21 +373,18 @@ mod tests { #[test] fn transfer_rates_when_sample_is_zero_then_are_known_zero() { - let rates = TransferSample::new( + let sample = TimedTransferSample::new( Instant::now() + Duration::from_secs(1), TrafficTotals::default(), ) - .rates_since(TransferSample::new( + .sample_since(TimedTransferSample::new( Instant::now(), TrafficTotals::default(), )); - assert_eq!(rates, TransferRates::default()); + assert_eq!(sample.rates(), TransferRates::default()); assert_eq!( - TransferRates::aggregate(&[Source(TransferMetrics { - rates: Some(rates), - ..TransferMetrics::default() - })]), + TransferRates::aggregate(&[Source(TransferMetrics::from_sample(sample))]), Some(TransferRates::default()) ); } @@ -318,13 +393,7 @@ mod tests { fn aggregate_rates_when_some_peers_are_unsampled_then_ignores_them() { let peers = [ Source(TransferMetrics::default()), - Source(TransferMetrics { - rates: Some(TransferRates { - download: BytesPerSecond(10), - upload: BytesPerSecond(4), - }), - ..TransferMetrics::default() - }), + Source(TransferMetrics::from_sample(one_second_sample(10, 4))), ]; assert_eq!( @@ -339,33 +408,15 @@ mod tests { #[test] fn aggregate_rates_when_metric_scopes_differ_then_uses_shared_transfer_metrics() { let peer = PeerMetrics { - transfer: TransferMetrics { - rates: Some(TransferRates { - download: BytesPerSecond(10), - upload: BytesPerSecond(4), - }), - ..Default::default() - }, + transfer: TransferMetrics::from_sample(one_second_sample(10, 4)), ..Default::default() }; let tracker = TrackerMetrics { - transfer: TransferMetrics { - rates: Some(TransferRates { - download: BytesPerSecond(2), - upload: BytesPerSecond(1), - }), - ..Default::default() - }, + transfer: TransferMetrics::from_sample(one_second_sample(2, 1)), ..Default::default() }; let torrent = TorrentMetrics::new( - TransferMetrics { - rates: Some(TransferRates { - download: BytesPerSecond(3), - upload: BytesPerSecond::ZERO, - }), - ..Default::default() - }, + TransferMetrics::from_sample(one_second_sample(3, 0)), ContentProgress { total_bytes: None, verified_bytes: ByteCount::ZERO, @@ -377,9 +428,17 @@ mod tests { }, ); let scopes: [&dyn HasTransferMetrics; 3] = [&peer, &tracker, &torrent]; + let aggregate = TransferMetrics::aggregate(scopes.iter().copied()); assert_eq!( - TransferRates::aggregate(scopes), + aggregate.totals, + TrafficTotals { + downloaded: ByteCount(15), + uploaded: ByteCount(5), + } + ); + assert_eq!( + aggregate.rates(), Some(TransferRates { download: BytesPerSecond(15), upload: BytesPerSecond(5), @@ -446,21 +505,31 @@ mod tests { #[test] fn serialized_metrics_round_trip_without_unit_conversion() { - let metrics = TransferMetrics { - totals: TrafficTotals { + 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), }, - rates: Some(TransferRates { - download: BytesPerSecond(300), - upload: BytesPerSecond(100), - }), - }; + 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 abbe172d..92c16581 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -24,14 +24,14 @@ use crate::{ errors::PeerActorError, hashes::InfoHash, live::{PeerHandle, PeerView}, - metrics::{HasTransferMetrics, PeerMetrics, TransferMetrics, TransferSample}, + metrics::{HasTransferMetrics, PeerMetrics, TimedTransferSample, TransferMetrics}, peer::{Peer, PeerId}, protocol::{stream::PeerRecv, *}, settings::PeerSettings, torrent::{self, BLOCK_SIZE, TorrentActor}, }; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PeerStats { pub(crate) id: PeerId, pub(crate) metrics: PeerMetrics, @@ -54,7 +54,7 @@ pub(crate) struct PeerActor { pending_block_requests: HashSet<(usize, usize, usize)>, pending_message_requests: VecDeque, - last_rate_sample: TransferSample, + last_rate_sample: TimedTransferSample, settings: PeerSettings, live_handle: PeerHandle, } @@ -324,18 +324,19 @@ impl PeerActor { let id = self.peer.id?; let now = Instant::now(); let totals = self.peer.traffic_totals(); - let sample = TransferSample::new(now, totals); - let rates = sample.rates_since(self.last_rate_sample); + let sample = TimedTransferSample::new(now, totals); + let transfer_sample = sample.sample_since(self.last_rate_sample); self.last_rate_sample = sample; - let transfer = TransferMetrics { - totals, - rates: Some(rates), - }; + 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)); + .publish_metrics(PeerView::from_peer_with_metrics( + &self.peer, + true, + metrics.clone(), + )); Some(PeerStats { id, metrics }) } @@ -382,7 +383,7 @@ impl Actor for PeerActor { .map_err(|e| PeerActorError::SupervisorCommunicationFailed(e.to_string()))?; Ok(Self { - last_rate_sample: TransferSample::new(Instant::now(), peer.traffic_totals()), + last_rate_sample: TimedTransferSample::new(Instant::now(), peer.traffic_totals()), peer, stream, supervisor, @@ -650,10 +651,10 @@ impl Message for PeerActor { warn!("Received unexpected handshake from peer"); } } - let rates = self.live_handle.view().metrics.transfer.rates; + let samples = self.live_handle.view().metrics.transfer.samples; self .live_handle - .publish_state(PeerView::from_peer_with_rates(&self.peer, true, rates)); + .publish_state(PeerView::from_peer_with_samples(&self.peer, true, samples)); } } diff --git a/crates/libtortillas/src/peer/state.rs b/crates/libtortillas/src/peer/state.rs index 3618229d..8e1aac4c 100644 --- a/crates/libtortillas/src/peer/state.rs +++ b/crates/libtortillas/src/peer/state.rs @@ -192,7 +192,7 @@ impl Peer { PeerMetrics { transfer: TransferMetrics { totals: self.traffic_totals(), - rates: None, + samples: Vec::new(), }, peer_choking: self.am_choked(), peer_interested: self.interested(), diff --git a/crates/libtortillas/src/pieces/piece_manager.rs b/crates/libtortillas/src/pieces/piece_manager.rs index 1d757330..78f12e36 100644 --- a/crates/libtortillas/src/pieces/piece_manager.rs +++ b/crates/libtortillas/src/pieces/piece_manager.rs @@ -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; diff --git a/crates/libtortillas/src/protocol/stream.rs b/crates/libtortillas/src/protocol/stream.rs index 0ce9d16d..1045bc46 100644 --- a/crates/libtortillas/src/protocol/stream.rs +++ b/crates/libtortillas/src/protocol/stream.rs @@ -227,17 +227,15 @@ impl PeerStream { /// 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) { - assert!( - self.read_buffer.is_empty(), - "PeerStream::split would discard buffered read data" - ); - let peer_state = self.peer_state; - let (reader, writer) = match self.transport { + let Self { + transport, + read_buffer, + peer_state, + } = self; + let (reader, writer) = match transport { PeerTransport::Tcp(stream) => { let (reader, writer) = stream.into_split(); (PeerReadHalf::Tcp(reader), PeerWriteHalf::Tcp(writer)) @@ -250,6 +248,7 @@ impl PeerStream { ( PeerReader { reader, + read_buffer, peer_state: peer_state.clone(), }, PeerWriter { writer, peer_state }, @@ -380,6 +379,7 @@ enum PeerWriteHalf { pub struct PeerReader { reader: PeerReadHalf, + read_buffer: BytesMut, peer_state: PeerState, } @@ -392,6 +392,11 @@ impl AsyncRead for PeerReader { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { + 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), @@ -590,6 +595,23 @@ mod tests { 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/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 2420eedc..66887b3f 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -31,7 +31,7 @@ use crate::{ metainfo::{Info, MetaInfo}, metrics::{ ByteCount, ContentProgress, HasTransferMetrics, TorrentMetrics, TrackerMetrics, - TrafficTotals, TransferMetrics, TransferRates, + TransferMetrics, }, peer::{PeerActor, PeerId, commands::SetChoked}, pieces::{FilePieceManager, PieceManager, PieceScheduler, PieceStoreActor}, @@ -530,14 +530,8 @@ impl TorrentActor { .map(|tracker| tracker as &dyn HasTransferMetrics), ) .collect::>(); - let rates = TransferRates::aggregate(metric_sources.iter().copied()); - let totals = metric_sources - .into_iter() - .map(HasTransferMetrics::transfer_metrics) - .map(|metrics| metrics.totals) - .fold(TrafficTotals::default(), TrafficTotals::saturating_add); let metrics = TorrentMetrics::new( - TransferMetrics { totals, rates }, + TransferMetrics::aggregate(metric_sources), ContentProgress { total_bytes, verified_bytes, @@ -745,7 +739,7 @@ impl Actor for TorrentActor { let mut trackers = HashMap::new(); for tracker in tracker_list { let endpoint = tracker.redacted_endpoint(); - let tracker_handle = hub.register_tracker_scope( + let Some(tracker_handle) = hub.register_tracker_scope( torrent_id, &tracker, TrackerView { @@ -753,7 +747,12 @@ impl Actor for TorrentActor { 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 { @@ -869,11 +868,13 @@ impl Actor for TorrentActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); - self.hub.emit_health( - Some(self.info_hash()), - LiveHealthLevel::Error, - "a torrent service stopped unexpectedly", - ); + if !reason.is_normal() { + self.hub.emit_health( + Some(self.info_hash()), + LiveHealthLevel::Error, + "a torrent service stopped unexpectedly", + ); + } Ok(ControlFlow::Continue(())) } @@ -898,7 +899,7 @@ mod tests { hashes::HashVec, live::{PeerIdentity, PeerView}, metainfo::{InfoKeys, MetaInfo, TorrentFile}, - metrics::{BytesPerSecond, PeerMetrics}, + metrics::{BytesPerSecond, PeerMetrics, TrafficTotals, TransferRates, TransferSample}, protocol::{ messages::{Handshake, PeerMessages}, stream::{PeerRecv, PeerSend, PeerStream}, @@ -1839,54 +1840,62 @@ mod tests { }; 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 { - totals: TrafficTotals { - downloaded: ByteCount(50_000), - uploaded: ByteCount(5_000), - }, - rates: Some(TransferRates { - download: BytesPerSecond(100), - upload: BytesPerSecond(20), + 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() }, - ..Default::default() }, - }, - ); - 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 { - totals: TrafficTotals { - downloaded: ByteCount(250), - uploaded: ByteCount(50), - }, - rates: Some(TransferRates { - download: BytesPerSecond(2), - upload: BytesPerSecond(1), + ) + .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() }, - ..Default::default() }, - }, - ); + ) + .unwrap(); let view = test_actor.live_view(); assert_eq!(view.info_hash, info_hash); @@ -1917,7 +1926,7 @@ mod tests { ); assert!(view.metrics.progress.progress_fraction.unwrap() > 0.0); assert_eq!( - view.metrics.traffic.rates, + view.metrics.traffic.rates(), Some(TransferRates { download: BytesPerSecond(102), upload: BytesPerSecond(21), @@ -1930,12 +1939,18 @@ mod tests { test_actor.state = TorrentState::Seeding; assert_eq!( - test_actor.live_view().metrics.traffic.rates.unwrap().upload, + test_actor + .live_view() + .metrics + .traffic + .rates() + .unwrap() + .upload, BytesPerSecond(21) ); sampled_peer.disconnected(); assert_eq!( - test_actor.live_view().metrics.traffic.rates, + test_actor.live_view().metrics.traffic.rates(), Some(TransferRates { download: BytesPerSecond(2), upload: BytesPerSecond(1), diff --git a/crates/libtortillas/src/torrent/choking.rs b/crates/libtortillas/src/torrent/choking.rs index 4b1e71c5..a7ab9634 100644 --- a/crates/libtortillas/src/torrent/choking.rs +++ b/crates/libtortillas/src/torrent/choking.rs @@ -70,7 +70,7 @@ pub(crate) fn select_unchoked_peers( let mut candidates: Vec<_> = peers .iter() .filter(|peer| peer.metrics.peer_interested) - .copied() + .cloned() .collect(); candidates.sort_by(|left, right| { rate_for(right, torrent_state) @@ -118,12 +118,12 @@ fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> BytesPerSecond { TorrentState::Downloading => peer .metrics .transfer - .rates + .rates() .map_or(BytesPerSecond::ZERO, |rates| rates.download), TorrentState::Seeding => peer .metrics .transfer - .rates + .rates() .map_or(BytesPerSecond::ZERO, |rates| rates.upload), TorrentState::Added | TorrentState::ResolvingMetadata @@ -138,8 +138,10 @@ fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> BytesPerSecond { #[cfg(test)] mod tests { + use std::time::Duration; + use super::*; - use crate::metrics::{PeerMetrics, TransferMetrics, TransferRates}; + use crate::metrics::{ByteCount, PeerMetrics, TrafficTotals, TransferMetrics, TransferSample}; fn peer_id(value: u8) -> PeerId { PeerId::from([value; 20]) @@ -151,10 +153,11 @@ mod tests { metrics: PeerMetrics { peer_interested: true, client_choking: true, - transfer: TransferMetrics { - totals: Default::default(), - rates: Some(TransferRates::default()), - }, + transfer: TransferMetrics::from_sample(TransferSample { + previous_totals: TrafficTotals::default(), + current_totals: TrafficTotals::default(), + elapsed: Duration::from_secs(1), + }), ..Default::default() }, } @@ -163,13 +166,14 @@ mod tests { fn with_rates(id: u8, download_rate: u64, upload_rate: u64) -> PeerStats { PeerStats { metrics: PeerMetrics { - transfer: TransferMetrics { - rates: Some(TransferRates { - download: BytesPerSecond(download_rate), - upload: BytesPerSecond(upload_rate), - }), - ..Default::default() - }, + 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(id).metrics }, id: peer_id(id), diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 2660b97a..b8bb0cfc 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -64,9 +64,11 @@ impl Torrent { info_hash: InfoHash, actor: ActorRef, hub: &Hub, initial_view: Option, ) -> Self { - let scope = hub.ensure_torrent_scope(info_hash); + 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.replace_view(Some(view)); + let _ = scope.publisher.install_initial_view(view); } let inner = Arc::new(TorrentInner { info_hash, diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index 24258c0d..af3d9826 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -202,6 +202,14 @@ pub(crate) mod commands { 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 @@ -211,6 +219,9 @@ pub(crate) mod commands { })?; } self.piece_storage = strategy; + if self.state == TorrentState::Failed { + self.transition_state(TorrentState::Paused); + } self.publish_live_view(|_| TorrentEventKind::Updated); Ok(()) } @@ -271,6 +282,9 @@ pub(crate) mod commands { 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_live_view(|_| TorrentEventKind::Updated); Ok(()) } diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index 710d7eb4..a01a30a9 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -168,6 +168,9 @@ impl TorrentActor { } 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; }; diff --git a/crates/libtortillas/src/torrent/snapshot.rs b/crates/libtortillas/src/torrent/snapshot.rs index 876acd08..a7de2e95 100644 --- a/crates/libtortillas/src/torrent/snapshot.rs +++ b/crates/libtortillas/src/torrent/snapshot.rs @@ -1,4 +1,8 @@ -use std::{collections::BTreeMap, path::PathBuf, sync::atomic::AtomicU8}; +use std::{ + collections::{BTreeMap, HashSet}, + path::PathBuf, + sync::atomic::AtomicU8, +}; use bitvec::vec::BitVec; use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; @@ -179,9 +183,15 @@ impl TorrentSnapshot { 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")); } @@ -323,11 +333,15 @@ impl ValidatedTorrentSnapshot { .await .is_ok(), RestoreVerification::FileMetadata => { - fs::metadata(path).await.is_ok_and(|metadata| { - metadata.len() - >= u64::try_from(piece_length(&info, index).unwrap_or(usize::MAX)) - .unwrap_or(u64::MAX) - }) + 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, } @@ -478,6 +492,26 @@ mod tests { 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") diff --git a/crates/libtortillas/src/torrent/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index afb4c93f..7d001f11 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -109,13 +109,15 @@ impl TorrentActor { return; } - let peer_handle = self.hub.register_peer_scope( + let Some(peer_handle) = self.hub.register_peer_scope( PeerIdentity { torrent: info_hash, peer: id, }, PeerView::from_peer(&peer, true), - ); + ) else { + return; + }; let peer_actor = PeerActor::spawn_with_mailbox( ( @@ -172,8 +174,11 @@ 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_live_view(|_| crate::live::TorrentEventKind::Updated); } } diff --git a/crates/libtortillas/src/tracker/actor.rs b/crates/libtortillas/src/tracker/actor.rs index 8883b90e..aea3f444 100644 --- a/crates/libtortillas/src/tracker/actor.rs +++ b/crates/libtortillas/src/tracker/actor.rs @@ -23,7 +23,7 @@ use super::{ use crate::{ errors::TrackerActorError, live::TrackerHandle, - metrics::{TrackerMetrics, TransferMetrics, TransferSample}, + metrics::{TimedTransferSample, TrackerMetrics, TransferMetrics}, peer::PeerId, settings::TrackerSettings, torrent::{self, TorrentActor}, @@ -39,7 +39,7 @@ pub(crate) struct TrackerActor { actor_ref: ActorRef, settings: TrackerSettings, live_handle: TrackerHandle, - last_rate_sample: TransferSample, + last_rate_sample: TimedTransferSample, } #[derive(Clone)] @@ -152,7 +152,7 @@ impl Actor for TrackerActor { actor_ref, settings, live_handle, - last_rate_sample: TransferSample::new(Instant::now(), totals), + last_rate_sample: TimedTransferSample::new(Instant::now(), totals), }) } @@ -194,11 +194,8 @@ 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 = TransferSample::new(Instant::now(), totals); - metrics.transfer = TransferMetrics { - totals, - rates: Some(sample.rates_since(self.last_rate_sample)), - }; + 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 diff --git a/crates/libtortillas/src/tracker/stats.rs b/crates/libtortillas/src/tracker/stats.rs index 90f9f5fc..17f56b62 100644 --- a/crates/libtortillas/src/tracker/stats.rs +++ b/crates/libtortillas/src/tracker/stats.rs @@ -128,7 +128,7 @@ impl TrackerStats { TrackerMetrics { transfer: TransferMetrics { totals: self.traffic_totals(), - rates: None, + 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), diff --git a/crates/libtortillas/tests/dht_network.rs b/crates/libtortillas/tests/dht_network.rs index 566c6824..15baba52 100644 --- a/crates/libtortillas/tests/dht_network.rs +++ b/crates/libtortillas/tests/dht_network.rs @@ -2,6 +2,7 @@ use std::{env, path::PathBuf, process, time::Duration}; use libtortillas::{ engine::{Engine, TorrentSource}, + live::EventStreamError, metainfo::{MetaInfo, TorrentFile}, settings::Settings, }; @@ -49,7 +50,12 @@ async fn arch_linux_torrent_when_public_dht_is_available_then_downloads_data() { if view.metrics.progress.verified_bytes.0 > 0 { return view; } - timeout(POLL_INTERVAL, listener.recv()).await.ok(); + 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(_) => {} + } } }) .await; diff --git a/crates/libtortillas/tests/live.rs b/crates/libtortillas/tests/live.rs index 142221bf..5ec63972 100644 --- a/crates/libtortillas/tests/live.rs +++ b/crates/libtortillas/tests/live.rs @@ -5,12 +5,15 @@ use libtortillas::{ engine::EngineStatus, errors::EngineError, live::{ - EngineEventKind, EventStreamError, LivePublisher, TorrentEventKind, TrackerEventKind, - TrackerStatus, + EngineEventKind, EventStreamError, LiveHealthLevel, LivePublisher, TorrentEventKind, + TrackerEventKind, TrackerStatus, }, prelude::{Engine, Settings, TorrentSource, TorrentState}, }; -use tokio::time::{sleep, timeout}; +use tokio::{ + net::TcpListener, + time::{sleep, timeout}, +}; const BIG_BUCK_BUNNY: &[u8] = include_bytes!("torrents/big-buck-bunny.torrent"); @@ -113,19 +116,6 @@ async fn engine_listener_receives_live_torrent_lifecycle() { engine.shutdown().await.unwrap(); } -#[tokio::test] -async fn generic_live_publisher_implements_async_stream() { - let publisher = LivePublisher::new(0_u8, 4); - let mut listener = publisher.listener(); - - publisher.replace_view_and_emit(1, "changed"); - - let event = listener.next().await.unwrap().unwrap(); - assert_eq!(event.sequence, 1); - assert_eq!(event.kind, "changed"); - assert_eq!(listener.view(), 1); -} - #[tokio::test] async fn live_listener_closes_when_its_publisher_is_dropped() { let publisher = LivePublisher::<_, &'static str>::new(0_u8, 4); @@ -141,52 +131,33 @@ async fn live_listener_closes_when_its_publisher_is_dropped() { } #[tokio::test] -async fn closed_live_publisher_rejects_late_updates() { - let publisher = LivePublisher::new(0_u8, 4); - let mut listener = publisher.listener(); +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(); - assert!(publisher.close_with_terminal_event(1, "closed")); - assert!(!publisher.replace_view_and_emit(2, "late")); + let event = timeout(Duration::from_secs(2), listener.recv()) + .await + .expect("engine startup failure was not published") + .unwrap(); - assert_eq!(listener.recv().await.unwrap().kind, "closed"); + 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) )); - assert_eq!(listener.view(), 1); -} - -#[tokio::test(flavor = "multi_thread")] -async fn concurrent_live_updates_are_delivered_in_sequence_order() { - const UPDATE_COUNT: u64 = 64; - let publisher = LivePublisher::new(0_u64, UPDATE_COUNT as usize); - let mut events = publisher.subscribe(); - let updates = (1..=UPDATE_COUNT) - .map(|view| { - let publisher = publisher.clone(); - tokio::spawn(async move { publisher.replace_view_and_emit(view, view) }) - }) - .collect::>(); - - for update in updates { - update.await.unwrap(); - } - for sequence in 1..=UPDATE_COUNT { - assert_eq!(events.recv().await.unwrap().sequence, sequence); - } -} - -#[tokio::test] -async fn listener_view_is_never_older_than_its_accepted_update() { - let publisher = LivePublisher::new(0_u64, 64); - let mut listener = publisher.listener(); - - for value in 1..=32 { - assert!(publisher.replace_view_and_emit(value, value)); - let event = listener.recv().await.unwrap(); - assert_eq!(event.kind, value); - assert!(listener.view() >= event.kind); - } } #[tokio::test] diff --git a/crates/libtortillas/tests/persistence.rs b/crates/libtortillas/tests/persistence.rs index 1b0b889c..e67a1d39 100644 --- a/crates/libtortillas/tests/persistence.rs +++ b/crates/libtortillas/tests/persistence.rs @@ -351,6 +351,17 @@ async fn custom_piece_manager_snapshot_returns_typed_unsupported_error() { .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(); @@ -452,9 +463,8 @@ fn engine_snapshot_golden_fixtures_migrate_and_round_trip() { migrated.version, libtortillas::engine::ENGINE_SNAPSHOT_VERSION ); + migrated.validate().unwrap(); - let current: libtortillas::engine::EngineSnapshot = - serde_json::from_str(ENGINE_SNAPSHOT_V2).unwrap(); let expected: serde_json::Value = serde_json::from_str(ENGINE_SNAPSHOT_V2).unwrap(); - assert_eq!(serde_json::to_value(current).unwrap(), expected); + assert_eq!(serde_json::to_value(migrated).unwrap(), expected); } From 99d25385aeee40b97c78617e488dd84baa8827c4 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sun, 26 Jul 2026 20:59:50 -0700 Subject: [PATCH 74/77] feat: gate live runtime behind a default feature --- .github/workflows/checks.yml | 3 + README.md | 16 +- crates/libtortillas/Cargo.toml | 25 ++- crates/libtortillas/src/engine/actor.rs | 17 +- crates/libtortillas/src/engine/messages.rs | 44 +++-- crates/libtortillas/src/engine/mod.rs | 83 ++++++++-- crates/libtortillas/src/facade.rs | 5 +- crates/libtortillas/src/lib.rs | 25 ++- crates/libtortillas/src/live/hub.rs | 2 +- crates/libtortillas/src/live/mod.rs | 23 +++ crates/libtortillas/src/peer/actor.rs | 156 +++++++++++++++++- crates/libtortillas/src/peer/state.rs | 4 + crates/libtortillas/src/settings.rs | 21 +-- crates/libtortillas/src/torrent/actor.rs | 29 +++- crates/libtortillas/src/torrent/choking.rs | 19 +-- .../libtortillas/src/torrent/choking_flow.rs | 14 +- crates/libtortillas/src/torrent/handle.rs | 38 +++-- crates/libtortillas/src/torrent/messages.rs | 71 +++++--- crates/libtortillas/src/torrent/mod.rs | 5 +- crates/libtortillas/src/torrent/piece_flow.rs | 4 +- crates/libtortillas/src/torrent/swarm.rs | 27 ++- crates/libtortillas/src/tracker/actor.rs | 83 ++++++---- crates/libtortillas/src/tracker/model.rs | 2 + crates/libtortillas/src/tracker/stats.rs | 7 +- crates/libtortillas/tests/facade.rs | 20 ++- 25 files changed, 569 insertions(+), 174 deletions(-) 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/README.md b/README.md index cbe6676a..f841d6f7 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,16 @@ applications that need BitTorrent downloads, seeding, and observable progress. 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 @@ -102,9 +112,9 @@ 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. -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 +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). diff --git a/crates/libtortillas/Cargo.toml b/crates/libtortillas/Cargo.toml index 312f9b8c..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,9 +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"] } +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/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index 9c9804a0..ff6ca475 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -14,11 +14,12 @@ 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, hashes::InfoHash, - live::{Hub, LiveHealthLevel}, peer::PeerId, protocol::stream::PeerStream, settings::Settings, @@ -32,6 +33,7 @@ use crate::{ /// 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>, @@ -107,6 +109,7 @@ pub struct EngineActorArgs { pub default_base_path: Option, /// Projection hub shared by the engine handle and actor hierarchy. + #[cfg(feature = "live")] pub(crate) hub: Hub, } @@ -136,6 +139,7 @@ impl Actor for EngineActor { piece_storage_strategy, settings, default_base_path, + #[cfg(feature = "live")] hub, } = args; @@ -144,11 +148,13 @@ impl Actor for EngineActor { let udp_addr = udp_addr.unwrap_or(settings.engine.udp_addr); let tcp_socket = TcpListener::bind(tcp_addr).await.map_err(|error| { let error = EngineError::NetworkSetupFailed(format!("tcp bind {tcp_addr}: {error}")); + #[cfg(feature = "live")] 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}")); + #[cfg(feature = "live")] hub.engine_start_failed(error.to_string()); error })?; @@ -159,6 +165,7 @@ impl Actor for EngineActor { .await .map_err(|error| { let error = EngineError::NetworkSetupFailed(format!("udp bind {udp_addr}: {error}")); + #[cfg(feature = "live")] hub.engine_start_failed(error.to_string()); error })?; @@ -182,9 +189,11 @@ impl Actor for EngineActor { None }; + #[cfg(feature = "live")] hub.engine_started(); Ok(Self { + #[cfg(feature = "live")] hub, dht, tcp_socket, @@ -204,6 +213,7 @@ impl Actor for EngineActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); + #[cfg(feature = "live")] self.hub.emit_health( None, LiveHealthLevel::Error, @@ -238,6 +248,7 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); + #[cfg(feature = "live")] self.hub.emit_health( None, LiveHealthLevel::Warning, @@ -266,6 +277,7 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); + #[cfg(feature = "live")] self.hub.emit_health( None, LiveHealthLevel::Warning, @@ -280,6 +292,7 @@ impl Actor for EngineActor { async fn on_stop( &mut self, _: WeakActorRef, _: ActorStopReason, ) -> Result<(), Self::Error> { + #[cfg(feature = "live")] self.hub.engine_stopping(); let torrents = self .torrents @@ -293,6 +306,7 @@ impl Actor for EngineActor { } torrent.wait_for_shutdown().await; self.torrents.remove(&info_hash); + #[cfg(feature = "live")] self.hub.remove_torrent_scope(info_hash); } @@ -301,6 +315,7 @@ impl Actor for EngineActor { dht.wait_for_shutdown().await; } + #[cfg(feature = "live")] self.hub.engine_stopped(); Ok(()) diff --git a/crates/libtortillas/src/engine/messages.rs b/crates/libtortillas/src/engine/messages.rs index 680723d7..302aa8bc 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -4,6 +4,8 @@ use tokio::time::timeout; use tracing::{error, warn}; use super::{ENGINE_SNAPSHOT_VERSION, EngineActor, EngineSnapshot}; +#[cfg(feature = "live")] +use crate::torrent::Torrent; use crate::{ dht::messages::commands::{RegisterTorrent, UnregisterTorrent}, errors::{EngineError, map_torrent_send_error}, @@ -12,8 +14,8 @@ use crate::{ peer::Peer, protocol::stream::{PeerStream, validate_handshake_protocol}, torrent::{ - self, RestoreVerification, Torrent, TorrentActor, TorrentActorArgs, TorrentSnapshot, - TorrentState, ValidatedTorrentSnapshot, + self, RestoreVerification, TorrentActor, TorrentActorArgs, TorrentSnapshot, TorrentState, + ValidatedTorrentSnapshot, }, }; @@ -48,6 +50,7 @@ pub(crate) mod commands { if let Err(error) = torrent.stop_gracefully().await { warn!(error = %error, %info_hash, "Failed to stop rejected restored torrent"); } + #[cfg(feature = "live")] self.hub.remove_torrent_scope(info_hash); } } @@ -215,6 +218,7 @@ pub(crate) mod commands { sufficient_peers: restoring.then_some(usize::MAX), base_path, settings: self.settings.clone(), + #[cfg(feature = "live")] hub: self.hub.weak(), }, ) @@ -292,22 +296,25 @@ pub(crate) mod commands { error, ))); } - 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), - )); + #[cfg(feature = "live")] + { + 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) } @@ -342,6 +349,7 @@ pub(crate) mod commands { match self.remove_torrent(info_hash).await { Ok(torrent) => { torrent.kill(); + #[cfg(feature = "live")] self.hub.remove_torrent_scope(info_hash); } Err(remove_error) => { diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 2670195a..c1c5c769 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -76,10 +76,11 @@ 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, map_engine_send_error}, hashes::InfoHash, - live::{EngineListener, EngineView, EventSubscription, Hub}, peer::PeerId, settings::Settings, torrent::{PieceStorageStrategy, RestoreVerification, Torrent}, @@ -128,6 +129,7 @@ use crate::{ #[derive(Debug, Clone)] pub struct Engine { actor: ActorRef, + #[cfg(feature = "live")] hub: Hub, } @@ -226,6 +228,7 @@ impl Engine { 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, @@ -235,12 +238,17 @@ 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, hub } + Engine { + actor, + #[cfg(feature = "live")] + hub, + } } /// Just a helper function so we don't have to write `&self.0` all the time. @@ -295,7 +303,7 @@ impl Engine { let metainfo = source.into_metainfo().await?; let info_hash = metainfo.info_hash()?; - self + let torrent_ref = self .actor() .ask(CreateTorrent { request: CreateTorrentRequest::New(Box::new(metainfo)), @@ -303,7 +311,16 @@ impl Engine { .await .map_err(|error| map_engine_send_error("add torrent", error))?; - self.torrent_handle(info_hash) + #[cfg(feature = "live")] + let _ = &torrent_ref; + #[cfg(feature = "live")] + { + self.torrent_handle(info_hash) + } + #[cfg(not(feature = "live"))] + { + Ok(Torrent::new(info_hash, torrent_ref)) + } // We don't need to assign link or insert the ref here because its already // done by the engine actor } @@ -327,7 +344,7 @@ impl Engine { ) -> Result { let info_hash = snapshot.info_hash; - self + let torrent_ref = self .actor() .ask(CreateTorrent { request: CreateTorrentRequest::Restore { @@ -338,7 +355,16 @@ impl Engine { .await .map_err(|error| map_engine_send_error("restore torrent", error))?; - self.torrent_handle(info_hash) + #[cfg(feature = "live")] + let _ = &torrent_ref; + #[cfg(feature = "live")] + { + self.torrent_handle(info_hash) + } + #[cfg(not(feature = "live"))] + { + Ok(Torrent::new(info_hash, torrent_ref)) + } } /// Restores all torrent sessions from an engine persistence snapshot. @@ -365,10 +391,26 @@ impl Engine { }) .await .map_err(|error| map_engine_send_error("restore engine", error))?; - info_hashes - .into_iter() - .map(|info_hash| self.torrent_handle(info_hash)) - .collect() + #[cfg(feature = "live")] + { + info_hashes + .into_iter() + .map(|info_hash| self.torrent_handle(info_hash)) + .collect() + } + #[cfg(not(feature = "live"))] + { + let mut torrents = Vec::with_capacity(info_hashes.len()); + for info_hash in info_hashes { + let torrent_ref = self + .actor() + .ask(GetTorrent { info_hash }) + .await + .map_err(|error| map_engine_send_error("get restored torrent", error))?; + torrents.push(Torrent::new(info_hash, torrent_ref)); + } + Ok(torrents) + } } /// Starts all torrents managed by the engine. /// See [`Torrent::start`] for more information. @@ -383,13 +425,22 @@ impl Engine { /// Returns a public handle for a torrent managed by this engine. pub async fn torrent(&self, info_hash: InfoHash) -> Result { - self + let torrent_ref = self .actor() .ask(GetTorrent { info_hash }) .await .map_err(|error| map_engine_send_error("get torrent", error))?; - self.torrent_handle(info_hash) + #[cfg(feature = "live")] + let _ = &torrent_ref; + #[cfg(feature = "live")] + { + self.torrent_handle(info_hash) + } + #[cfg(not(feature = "live"))] + { + Ok(Torrent::new(info_hash, torrent_ref)) + } } /// Removes a torrent from the engine and stops its actor gracefully. @@ -402,6 +453,7 @@ impl Engine { let stop_result = torrent.stop_gracefully().await; torrent.wait_for_shutdown().await; + #[cfg(feature = "live")] self.hub.remove_torrent_scope(info_hash); stop_result.map_err(|error| EngineError::ActorCommunicationFailed { operation: "stop torrent", @@ -425,7 +477,8 @@ impl Engine { /// Captures all managed torrent sessions in a Serde-compatible persistence /// snapshot. /// - /// Use [`Self::listener`] for current state and incremental updates. + /// 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 { @@ -441,12 +494,14 @@ impl Engine { /// The returned stream is bounded. A lagging consumer can read /// [`Self::view`] to rebuild its current state and then continue /// receiving events. + #[cfg(feature = "live")] #[must_use] pub fn subscribe(&self) -> EventSubscription { self.hub.subscribe() } /// Creates a listener with typed events and coherent current state. + #[cfg(feature = "live")] #[must_use] pub fn listener(&self) -> EngineListener { let hub = self.hub.clone(); @@ -454,11 +509,13 @@ impl Engine { } /// Returns the current engine state maintained by the projection tree. + #[cfg(feature = "live")] #[must_use] pub fn view(&self) -> EngineView { self.hub.view() } + #[cfg(feature = "live")] fn torrent_handle(&self, info_hash: InfoHash) -> Result { self .hub diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index 4648dea1..32120821 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -18,6 +18,10 @@ pub use crate::{ 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, @@ -30,5 +34,4 @@ pub use crate::{ TorrentMetrics, TrackerMetrics, TrafficTotals, TransferMetrics, TransferRates, TransferSample, }, - torrent::{RestoreVerification, Torrent, TorrentSnapshot}, }; diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index b0ecbc20..16c786b5 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -124,15 +124,19 @@ //! //! # Observing live state //! -//! Applications that only download and seed files do not need [`live`] +//! 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. +//! +//! Applications that only download and seed files do not need live //! listeners, events, views, or metrics. The module is for consumers that need //! current progress and incremental changes, whether they render a terminal, //! serve an API, update a website, or drive a desktop application. //! -//! Start with [`EventListener`](live::EventListener): read its -//! [`view`](live::EventListener::view) for current state and receive events to -//! learn when that state changes. The [`live`] module documents the complete -//! transport-agnostic model. +//! Start with `live::EventListener`: read its `view` for current state and +//! receive events to learn when that state changes. The `live` module +//! documents the complete transport-agnostic model. //! //! This helper waits for changes and prints verified payload progress until the //! torrent finishes downloading: @@ -211,7 +215,7 @@ //! //! 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 +//! transport-agnostic live views and event streams. Durable state is //! represented by [`EngineSnapshot`](engine::EngineSnapshot) and //! [`TorrentSnapshot`](torrent::TorrentSnapshot), never by live views. //! @@ -220,8 +224,8 @@ //! state, storage strategy, metrics, and snapshots live outside actor files so //! actors can focus on orchestration. //! -//! See [`live`] for the source-of-truth, publication, lifecycle, and lock -//! invariants. See [`torrent`] for transfer scheduling and persistence +//! See the `live` module for the source-of-truth, publication, lifecycle, and +//! lock invariants. See [`torrent`] for transfer scheduling and persistence //! semantics. pub(crate) mod dht; @@ -229,8 +233,10 @@ 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; @@ -704,10 +710,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/hub.rs b/crates/libtortillas/src/live/hub.rs index c824eab8..64b1f308 100644 --- a/crates/libtortillas/src/live/hub.rs +++ b/crates/libtortillas/src/live/hub.rs @@ -23,8 +23,8 @@ use super::{ use crate::{ engine::EngineStatus, hashes::InfoHash, + live::LiveSettings, peer::PeerId, - settings::LiveSettings, torrent::{Torrent, TorrentInner}, tracker::Tracker, }; diff --git a/crates/libtortillas/src/live/mod.rs b/crates/libtortillas/src/live/mod.rs index a307b44b..a67f70f0 100644 --- a/crates/libtortillas/src/live/mod.rs +++ b/crates/libtortillas/src/live/mod.rs @@ -164,6 +164,29 @@ 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, diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index 92c16581..7df77825 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -23,26 +23,114 @@ use tracing::{Span, debug, info, instrument, trace, warn}; use crate::{ errors::PeerActorError, hashes::InfoHash, - live::{PeerHandle, PeerView}, - metrics::{HasTransferMetrics, PeerMetrics, TimedTransferSample, TransferMetrics}, peer::{Peer, PeerId}, protocol::{stream::PeerRecv, *}, settings::PeerSettings, torrent::{self, BLOCK_SIZE, TorrentActor}, }; +#[cfg(feature = "live")] +use crate::{ + live::{PeerHandle, PeerView}, + metrics::{HasTransferMetrics, PeerMetrics, TimedTransferSample, TransferMetrics}, +}; +#[cfg(feature = "live")] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PeerStats { pub(crate) id: PeerId, 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, 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, +} + +impl PeerStats { + pub(crate) fn interested(&self) -> bool { + #[cfg(feature = "live")] + { + self.metrics.peer_interested + } + #[cfg(not(feature = "live"))] + { + self.interested + } + } + + pub(crate) fn client_choking(&self) -> bool { + #[cfg(feature = "live")] + { + self.metrics.client_choking + } + #[cfg(not(feature = "live"))] + { + self.choked + } + } + + pub(crate) fn download_rate(&self) -> u64 { + #[cfg(feature = "live")] + { + self + .metrics + .transfer + .rates() + .map_or(0, |rates| rates.download.0) + } + #[cfg(not(feature = "live"))] + { + u64::try_from(self.download_rate).unwrap_or(u64::MAX) + } + } + + pub(crate) fn upload_rate(&self) -> u64 { + #[cfg(feature = "live")] + { + self + .metrics + .transfer + .rates() + .map_or(0, |rates| rates.upload.0) + } + #[cfg(not(feature = "live"))] + { + u64::try_from(self.upload_rate).unwrap_or(u64::MAX) + } + } +} + +#[cfg(not(feature = "live"))] +#[derive(Clone, Copy, Debug)] +struct RateSample { + at: Instant, + bytes_downloaded: usize, + bytes_uploaded: usize, +} + +#[cfg(not(feature = "live"))] +impl RateSample { + fn new(peer: &Peer) -> Self { + Self { + at: Instant::now(), + bytes_downloaded: peer.bytes_downloaded(), + bytes_uploaded: peer.bytes_uploaded(), + } + } +} + /// The actor that handles all communications with a given peer. pub(crate) struct PeerActor { /// The peers state and statistics @@ -54,8 +142,12 @@ 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, } @@ -320,6 +412,7 @@ impl PeerActor { self.stream.send(msg).await } + #[cfg(feature = "live")] fn snapshot_stats(&mut self) -> Option { let id = self.peer.id?; let now = Instant::now(); @@ -330,6 +423,7 @@ impl PeerActor { let transfer = TransferMetrics::from_sample(transfer_sample); let mut metrics = self.peer.metrics(); metrics.transfer = transfer; + #[cfg(feature = "live")] self .live_handle .publish_metrics(PeerView::from_peer_with_metrics( @@ -340,9 +434,39 @@ impl PeerActor { Some(PeerStats { id, metrics }) } + + #[cfg(not(feature = "live"))] + fn snapshot_stats(&mut self) -> Option { + let id = self.peer.id?; + let now = Instant::now(); + let bytes_downloaded = self.peer.bytes_downloaded(); + let bytes_uploaded = self.peer.bytes_uploaded(); + let elapsed_secs = now + .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) / elapsed_secs; + let upload_rate = + bytes_uploaded.saturating_sub(self.last_rate_sample.bytes_uploaded) / elapsed_secs; + self.last_rate_sample = RateSample { + at: now, + bytes_downloaded, + bytes_uploaded, + }; + + Some(PeerStats { + id, + interested: self.peer.interested(), + choked: self.peer.choked(), + download_rate, + upload_rate, + }) + } } impl Actor for PeerActor { + #[cfg(feature = "live")] type Args = ( Peer, PeerStream, @@ -351,12 +475,23 @@ impl Actor for PeerActor { PeerSettings, PeerHandle, ); + #[cfg(not(feature = "live"))] + type Args = ( + Peer, + PeerStream, + ActorRef, + InfoHash, + PeerSettings, + ); 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 { + #[cfg(feature = "live")] let (mut peer, mut stream, supervisor, info_hash, settings, live_handle) = args; + #[cfg(not(feature = "live"))] + let (mut peer, mut stream, supervisor, info_hash, settings) = args; peer.share_traffic_with(&stream.peer_state()); info!(peer_id = %peer.id.unwrap(), peer_addr = %stream, torrent_id = %info_hash, "Peer connected"); @@ -383,13 +518,17 @@ impl Actor for PeerActor { .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, supervisor, pending_block_requests: HashSet::new(), pending_message_requests: VecDeque::with_capacity(settings.pending_message_capacity), settings, + #[cfg(feature = "live")] live_handle, }) } @@ -402,6 +541,7 @@ impl Actor for PeerActor { .supervisor .tell(torrent::commands::KillPeer { id: peer_id, + #[cfg(feature = "live")] handle: self.live_handle.clone(), }) .await @@ -432,6 +572,7 @@ impl Actor for PeerActor { .supervisor .tell(torrent::commands::KillPeer { id, + #[cfg(feature = "live")] handle: self.live_handle.clone(), }) .await @@ -651,10 +792,13 @@ impl Message for PeerActor { warn!("Received unexpected handshake from peer"); } } - let samples = self.live_handle.view().metrics.transfer.samples; - self - .live_handle - .publish_state(PeerView::from_peer_with_samples(&self.peer, true, samples)); + #[cfg(feature = "live")] + { + let samples = self.live_handle.view().metrics.transfer.samples; + self + .live_handle + .publish_state(PeerView::from_peer_with_samples(&self.peer, true, samples)); + } } } diff --git a/crates/libtortillas/src/peer/state.rs b/crates/libtortillas/src/peer/state.rs index 8e1aac4c..5e515426 100644 --- a/crates/libtortillas/src/peer/state.rs +++ b/crates/libtortillas/src/peer/state.rs @@ -9,6 +9,7 @@ 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 @@ -86,6 +87,7 @@ impl PeerState { self.bytes_uploaded = state.bytes_uploaded.clone(); } + #[cfg(feature = "live")] pub(crate) fn traffic_totals(&self) -> TrafficTotals { TrafficTotals { downloaded: ByteCount( @@ -184,10 +186,12 @@ impl Peer { self.state.bytes_uploaded.load(Ordering::Relaxed) } + #[cfg(feature = "live")] pub(crate) fn traffic_totals(&self) -> TrafficTotals { self.state.traffic_totals() } + #[cfg(feature = "live")] pub(crate) fn metrics(&self) -> PeerMetrics { PeerMetrics { transfer: TransferMetrics { diff --git a/crates/libtortillas/src/settings.rs b/crates/libtortillas/src/settings.rs index 561614cd..90238400 100644 --- a/crates/libtortillas/src/settings.rs +++ b/crates/libtortillas/src/settings.rs @@ -34,6 +34,7 @@ pub struct Settings { /// 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, @@ -47,24 +48,8 @@ pub struct Settings { /// /// 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, - } - } -} +#[cfg(feature = "live")] +pub use crate::live::LiveSettings; /// Mainline [BEP 5] DHT networking and lookup settings. /// diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 66887b3f..6fb434a8 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -27,12 +27,7 @@ use super::{choking::ChokingScheduler, util}; use crate::{ errors::{SnapshotUnsupportedReason, TorrentError}, hashes::InfoHash, - live::{Hub, LiveHealthLevel, TorrentView, TrackerStatus, TrackerView}, metainfo::{Info, MetaInfo}, - metrics::{ - ByteCount, ContentProgress, HasTransferMetrics, TorrentMetrics, TrackerMetrics, - TransferMetrics, - }, peer::{PeerActor, PeerId, commands::SetChoked}, pieces::{FilePieceManager, PieceManager, PieceScheduler, PieceStoreActor}, settings::Settings, @@ -44,6 +39,14 @@ use crate::{ Announce, Event, Tracker, TrackerActor, TrackerActorArgs, TrackerUpdate, udp::UdpServer, }, }; +#[cfg(feature = "live")] +use crate::{ + live::{Hub, LiveHealthLevel, 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 @@ -100,6 +103,7 @@ impl PieceManager for PieceManagerProxy { } pub(crate) struct TorrentActor { + #[cfg(feature = "live")] pub(super) hub: Hub, pub(crate) peers: HashMap>, pub(crate) trackers: HashMap>, @@ -220,6 +224,7 @@ impl TorrentActor { // Pre-start the piece manager before transitioning state if let Err(err) = self.piece_manager.pre_start(info.clone()).await { self.transition_state(TorrentState::Failed); + #[cfg(feature = "live")] self.hub.emit_health( Some(self.info_hash()), LiveHealthLevel::Error, @@ -386,6 +391,7 @@ impl TorrentActor { Some(total_bytes) } + #[cfg(feature = "live")] fn total_verified_bytes(&self) -> Option { let info = self.info_dict()?; let total_length = info.total_length(); @@ -484,6 +490,7 @@ impl TorrentActor { } /// Builds the current state exposed through listeners. + #[cfg(feature = "live")] pub fn live_view(&self) -> TorrentView { let info = self.info_dict(); let total_bytes = info @@ -560,6 +567,7 @@ impl TorrentActor { } /// The single publication entry point for torrent projection changes. + #[cfg(feature = "live")] pub(super) fn publish_live_view( &self, event: impl FnOnce(&TorrentView) -> crate::live::TorrentEventKind, ) { @@ -575,6 +583,7 @@ impl TorrentActor { } self.state = state; + #[cfg(feature = "live")] self.publish_live_view(|_| crate::live::TorrentEventKind::StateChanged { previous, current: state, @@ -585,6 +594,7 @@ impl TorrentActor { u64::try_from(value).unwrap_or(u64::MAX) } + #[cfg(feature = "live")] fn display_name(&self) -> &str { match &self.metainfo { MetaInfo::Torrent(torrent) => &torrent.info.name, @@ -662,6 +672,7 @@ pub struct TorrentActorArgs { pub settings: Settings, /// Projection hub shared with the owning engine. + #[cfg(feature = "live")] pub(crate) hub: Hub, } @@ -686,6 +697,7 @@ impl Actor for TorrentActor { sufficient_peers, base_path, settings, + #[cfg(feature = "live")] hub, } = args; @@ -738,7 +750,9 @@ impl Actor for TorrentActor { 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, @@ -764,6 +778,7 @@ impl Actor for TorrentActor { supervisor: us.clone(), scheduler: scheduler.clone(), settings: settings.tracker.clone(), + #[cfg(feature = "live")] live_handle: tracker_handle, }, ) @@ -788,6 +803,7 @@ impl Actor for TorrentActor { .await; let actor = Self { + #[cfg(feature = "live")] hub, peers: HashMap::new(), bitfield, @@ -816,6 +832,7 @@ impl Actor for TorrentActor { piece_manager: PieceManagerProxy::Default(default_manager), settings, }; + #[cfg(feature = "live")] actor.hub.initialize_torrent_projection(actor.live_view()); Ok(actor) @@ -840,6 +857,7 @@ impl Actor for TorrentActor { // The engine supervises torrent actors transiently. Preserve the // live scope and make the temporary state explicit. self.transition_state(TorrentState::Restarting); + #[cfg(feature = "live")] self .hub .close_peer_scopes_for_torrent_restart(self.info_hash()); @@ -868,6 +886,7 @@ impl Actor for TorrentActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); + #[cfg(feature = "live")] if !reason.is_normal() { self.hub.emit_health( Some(self.info_hash()), diff --git a/crates/libtortillas/src/torrent/choking.rs b/crates/libtortillas/src/torrent/choking.rs index a7ab9634..ac34c5e7 100644 --- a/crates/libtortillas/src/torrent/choking.rs +++ b/crates/libtortillas/src/torrent/choking.rs @@ -1,5 +1,4 @@ use crate::{ - metrics::BytesPerSecond, peer::{PeerId, PeerStats}, settings::Settings, torrent::TorrentState, @@ -69,7 +68,7 @@ pub(crate) fn select_unchoked_peers( ) -> ChokingDecision { let mut candidates: Vec<_> = peers .iter() - .filter(|peer| peer.metrics.peer_interested) + .filter(|peer| peer.interested()) .cloned() .collect(); candidates.sort_by(|left, right| { @@ -113,18 +112,10 @@ pub(crate) fn select_unchoked_peers( } } -fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> BytesPerSecond { +fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> u64 { match torrent_state { - TorrentState::Downloading => peer - .metrics - .transfer - .rates() - .map_or(BytesPerSecond::ZERO, |rates| rates.download), - TorrentState::Seeding => peer - .metrics - .transfer - .rates() - .map_or(BytesPerSecond::ZERO, |rates| rates.upload), + TorrentState::Downloading => peer.download_rate(), + TorrentState::Seeding => peer.upload_rate(), TorrentState::Added | TorrentState::ResolvingMetadata | TorrentState::Ready @@ -132,7 +123,7 @@ fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> BytesPerSecond { | TorrentState::Restarting | TorrentState::Stopping | TorrentState::Stopped - | TorrentState::Failed => BytesPerSecond::ZERO, + | TorrentState::Failed => 0, } } diff --git a/crates/libtortillas/src/torrent/choking_flow.rs b/crates/libtortillas/src/torrent/choking_flow.rs index a1514043..d58b56ef 100644 --- a/crates/libtortillas/src/torrent/choking_flow.rs +++ b/crates/libtortillas/src/torrent/choking_flow.rs @@ -6,12 +6,11 @@ use tokio::time::timeout; use tracing::{trace, warn}; use super::TorrentActor; -use crate::{ - facade::TorrentEventKind, - peer::{ - PeerActor, PeerId, PeerStats, - commands::{SetChoked, Stats}, - }, +#[cfg(feature = "live")] +use crate::live::TorrentEventKind; +use crate::peer::{ + PeerActor, PeerId, PeerStats, + commands::{SetChoked, Stats}, }; impl TorrentActor { @@ -30,6 +29,7 @@ impl TorrentActor { } // Peer actors publish their own high-frequency samples. The torrent // publishes one coalesced aggregate after the collection interval. + #[cfg(feature = "live")] self.publish_live_view(|view| TorrentEventKind::MetricsChanged(view.metrics.clone())); self.try_update_tracker_progress(); let decision = self.choking_scheduler.decide(&peer_stats, self.state); @@ -43,7 +43,7 @@ impl TorrentActor { for stats in peer_stats { let choked = !unchoked.contains(&stats.id); - if stats.metrics.client_choking == choked { + if stats.client_choking() == choked { continue; } diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index b8bb0cfc..3035019c 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -1,8 +1,6 @@ -use std::{ - fmt, - path::PathBuf, - sync::{Arc, Weak}, -}; +#[cfg(feature = "live")] +use std::sync::Weak; +use std::{fmt, path::PathBuf, sync::Arc}; use kameo::actor::ActorRef; use tokio::sync::oneshot; @@ -15,13 +13,14 @@ use super::{ SetSufficientPeers, SnapshotState, }, }; +#[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, - live::{ - EventSubscription, Hub, HubInner, LivePublisher, PeerHandle, TorrentEventKind, - TorrentListener, TorrentView, TrackerHandle, - }, pieces::PieceManager, }; @@ -29,7 +28,9 @@ use crate::{ 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>>, } @@ -55,11 +56,19 @@ impl fmt::Debug for Torrent { impl Torrent { /// Creates a new [`Torrent`] handle from an [`InfoHash`] and a reference /// to its underlying [`TorrentActor`]. - #[cfg(test)] + #[cfg(all(test, feature = "live"))] pub(crate) fn new(info_hash: InfoHash, actor_ref: ActorRef) -> Self { Self::new_with_hub(info_hash, actor_ref, &Hub::default(), None) } + #[cfg(not(feature = "live"))] + pub(crate) fn new(info_hash: InfoHash, actor: ActorRef) -> Self { + Self { + inner: Arc::new(TorrentInner { info_hash, actor }), + } + } + + #[cfg(feature = "live")] pub(crate) fn new_with_hub( info_hash: InfoHash, actor: ActorRef, hub: &Hub, initial_view: Option, @@ -170,7 +179,8 @@ impl Torrent { /// Captures this torrent's metadata, storage configuration, and verified or /// partial piece state in a Serde-compatible persistence snapshot. /// - /// Use [`Self::listener`] for current state and incremental updates. + /// With the `live` feature, use the torrent listener for current state and + /// incremental updates. pub async fn snapshot(&self) -> Result { self .actor() @@ -215,12 +225,14 @@ impl Torrent { } /// Subscribes to live events for this torrent only. + #[cfg(feature = "live")] #[must_use] pub fn subscribe(&self) -> EventSubscription { self.inner.publisher.subscribe() } /// Creates a live listener scoped to this torrent. + #[cfg(feature = "live")] #[must_use] pub fn listener(&self) -> TorrentListener { self.inner.publisher.listener() @@ -229,12 +241,14 @@ impl Torrent { /// Returns the latest state maintained for this torrent. /// /// This returns `None` after the torrent has been removed from its engine. + #[cfg(feature = "live")] #[must_use] pub fn view(&self) -> Option { self.inner.publisher.view() } /// Returns handles for this torrent's currently connected peers. + #[cfg(feature = "live")] #[must_use] pub fn peers(&self) -> Vec { self @@ -243,6 +257,7 @@ impl Torrent { } /// Returns handles for this torrent's configured trackers. + #[cfg(feature = "live")] #[must_use] pub fn trackers(&self) -> Vec { self @@ -250,6 +265,7 @@ impl Torrent { .map_or_else(Vec::new, |live| live.tracker_handles(self.info_hash())) } + #[cfg(feature = "live")] 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 af3d9826..76ebb423 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -15,10 +15,11 @@ use super::{ actor::{PieceManagerProxy, ReadyHookSender}, util, }; +#[cfg(feature = "live")] +use crate::live::{TorrentEventKind, TorrentView}; use crate::{ errors::TorrentError, hashes::InfoHash, - live::{TorrentEventKind, TorrentView}, metainfo::Info, peer::{Peer, PeerId, commands::HaveInfoDict}, pieces::{PieceManager, PieceScheduler}, @@ -44,12 +45,6 @@ pub(crate) mod events { } } - /// Publishes tracker traffic after an announce attempt. - #[message] - pub(crate) fn tracker_metrics_changed(&self) { - self.publish_live_view(|view| TorrentEventKind::MetricsChanged(view.metrics.clone())); - } - /// Sent after an incoming peer initializes a handshake. /// The handshake will be preverified and routed to this torrent instance. /// @@ -128,6 +123,7 @@ pub(crate) mod events { if self.state == TorrentState::ResolvingMetadata { self.transition_state(TorrentState::Added); } + #[cfg(feature = "live")] self.publish_live_view(|_| TorrentEventKind::MetadataResolved); self .broadcast_to_peers(HaveInfoDict { @@ -161,6 +157,16 @@ pub(crate) mod events { } } } + + #[cfg(feature = "live")] + #[messages] + impl TorrentActor { + /// Publishes tracker traffic after an announce attempt. + #[message] + pub(crate) fn tracker_metrics_changed(&self) { + self.publish_live_view(|view| TorrentEventKind::MetricsChanged(view.metrics.clone())); + } + } } pub(crate) mod commands { @@ -168,24 +174,13 @@ pub(crate) mod commands { #[messages] impl TorrentActor { - #[message] - pub(crate) fn kill_peer(&mut self, id: PeerId, handle: crate::live::PeerHandle) { - self.piece_scheduler.peer_disconnected(id); - // Kill the actor quietly. - if let Some(actor) = self.peers.remove(&id) { - actor.kill(); - } - handle.disconnected(); - self.publish_live_view(|_| TorrentEventKind::Updated); - self.fill_all_peer_request_windows(); - } - #[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); + #[cfg(feature = "live")] self.publish_live_view(|_| TorrentEventKind::Updated); } else { warn!("Received kill tracker message for unknown tracker"); @@ -222,6 +217,7 @@ pub(crate) mod commands { if self.state == TorrentState::Failed { self.transition_state(TorrentState::Paused); } + #[cfg(feature = "live")] self.publish_live_view(|_| TorrentEventKind::Updated); Ok(()) } @@ -253,6 +249,7 @@ pub(crate) mod commands { }); } self.piece_manager = PieceManagerProxy::Custom(manager); + #[cfg(feature = "live")] self.publish_live_view(|_| TorrentEventKind::Updated); Ok(()) } @@ -285,6 +282,7 @@ pub(crate) mod commands { if self.state == TorrentState::Failed { self.transition_state(TorrentState::Paused); } + #[cfg(feature = "live")] self.publish_live_view(|_| TorrentEventKind::Updated); Ok(()) } @@ -307,6 +305,7 @@ pub(crate) mod commands { if !self.pending_start { self.autostart().await; } + #[cfg(feature = "live")] self.publish_live_view(|_| TorrentEventKind::Updated); Ok(()) } @@ -319,6 +318,7 @@ pub(crate) mod commands { if !self.pending_start { self.autostart().await; } + #[cfg(feature = "live")] self.publish_live_view(|_| TorrentEventKind::Updated); Ok(()) } @@ -370,6 +370,7 @@ pub(crate) mod commands { } })?; self.transition_state(restored_state); + #[cfg(feature = "live")] self.publish_live_view(|_| TorrentEventKind::Updated); Ok(()) @@ -544,14 +545,42 @@ pub(crate) mod commands { 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.piece_scheduler.peer_disconnected(id); + if let Some(actor) = self.peers.remove(&id) { + actor.kill(); + } + handle.disconnected(); + self.publish_live_view(|_| TorrentEventKind::Updated); + self.fill_all_peer_request_windows(); + } + #[message] 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) -> Result, TorrentError> { - self.snapshot().map(Box::new) + pub(crate) fn kill_peer(&mut self, id: PeerId) { + self.piece_scheduler.peer_disconnected(id); + if let Some(actor) = self.peers.remove(&id) { + actor.kill(); + } + self.fill_all_peer_request_windows(); } } } diff --git a/crates/libtortillas/src/torrent/mod.rs b/crates/libtortillas/src/torrent/mod.rs index 4a1a2a7a..06673225 100644 --- a/crates/libtortillas/src/torrent/mod.rs +++ b/crates/libtortillas/src/torrent/mod.rs @@ -5,8 +5,8 @@ //! `TorrentActor` is the authoritative owner of torrent state. It coordinates //! one peer actor per connection, one tracker actor per endpoint, piece //! scheduling, verified progress, and storage. The public [`Torrent`] handle -//! exposes commands while [`crate::live::TorrentListener`] exposes the -//! current projection and typed events. +//! exposes commands while the live torrent listener exposes the current +//! projection and typed events. //! //! High-frequency peer protocol state remains local to peer scopes. Torrent //! transfer metrics are aggregated after periodic peer-stat collection rather @@ -97,6 +97,7 @@ pub(crate) use actor::{TorrentActor, TorrentActorArgs}; pub use block::{BLOCK_SIZE, BlockMap}; pub use discovery::AnnounceFrom; pub use handle::Torrent; +#[cfg(feature = "live")] pub(crate) use handle::TorrentInner; pub(crate) use messages::*; pub use snapshot::{ diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index a01a30a9..8bb4fd2c 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -8,7 +8,7 @@ use tokio::{ use tracing::{debug, info, trace, warn}; use super::{TorrentActor, util}; -#[cfg(test)] +#[cfg(all(test, feature = "live"))] use crate::live::Hub; use crate::{ errors::TorrentError, @@ -275,6 +275,7 @@ impl TorrentActor { let piece_count = info_dict.piece_count(); if !self.validate_and_commit_piece(index).await { + #[cfg(feature = "live")] self.publish_live_view(|view| { crate::live::TorrentEventKind::MetricsChanged(view.metrics.clone()) }); @@ -295,6 +296,7 @@ impl TorrentActor { // Piece completion is the meaningful progress boundary. Publishing for // every 16 KiB block creates an event storm without improving the view. + #[cfg(feature = "live")] self.publish_live_view(|view| { crate::live::TorrentEventKind::MetricsChanged(view.metrics.clone()) }); diff --git a/crates/libtortillas/src/torrent/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index 7d001f11..1207fd18 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -9,8 +9,9 @@ use kameo::{ use tracing::{debug, instrument, trace, warn}; use super::TorrentActor; +#[cfg(feature = "live")] +use crate::live::{PeerIdentity, PeerView}; use crate::{ - live::{PeerIdentity, PeerView}, peer::{Peer, PeerActor, PeerId}, protocol::{ messages::{Handshake, PeerMessages}, @@ -109,6 +110,7 @@ impl TorrentActor { return; } + #[cfg(feature = "live")] let Some(peer_handle) = self.hub.register_peer_scope( PeerIdentity { torrent: info_hash, @@ -119,22 +121,28 @@ impl TorrentActor { return; }; + #[cfg(feature = "live")] + let peer_args = ( + peer, + stream, + actor_ref, + info_hash, + peer_settings, + peer_handle.clone(), + ); + #[cfg(not(feature = "live"))] + let peer_args = (peer, stream, actor_ref, info_hash, peer_settings); let peer_actor = PeerActor::spawn_with_mailbox( - ( - peer, - stream, - actor_ref, - info_hash, - peer_settings, - peer_handle.clone(), - ), + peer_args, match peer_mailbox_size { 0 => mailbox::unbounded(), size => mailbox::bounded(size), }, ); self.peers.insert(id, peer_actor); + #[cfg(feature = "live")] self.publish_live_view(|_| crate::live::TorrentEventKind::Updated); + #[cfg(feature = "live")] self.hub.emit_peer_connected(&peer_handle); } @@ -179,6 +187,7 @@ impl TorrentActor { self.peers.remove(&id); } if removed_dead_peers { + #[cfg(feature = "live")] self.publish_live_view(|_| crate::live::TorrentEventKind::Updated); } } diff --git a/crates/libtortillas/src/tracker/actor.rs b/crates/libtortillas/src/tracker/actor.rs index aea3f444..754ae4ed 100644 --- a/crates/libtortillas/src/tracker/actor.rs +++ b/crates/libtortillas/src/tracker/actor.rs @@ -1,7 +1,6 @@ -use std::{ - net::SocketAddr, - time::{Duration, Instant}, -}; +#[cfg(feature = "live")] +use std::time::Instant; +use std::{net::SocketAddr, time::Duration}; use anyhow::Result; use kameo::{ @@ -22,12 +21,15 @@ use super::{ }; use crate::{ errors::TrackerActorError, - live::TrackerHandle, - metrics::{TimedTransferSample, TrackerMetrics, TransferMetrics}, peer::PeerId, 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 { @@ -38,7 +40,9 @@ 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, } @@ -52,6 +56,7 @@ pub(crate) struct TrackerActorArgs { pub(crate) supervisor: ActorRef, pub(crate) scheduler: ActorRef, pub(crate) settings: TrackerSettings, + #[cfg(feature = "live")] pub(crate) live_handle: TrackerHandle, } @@ -69,6 +74,7 @@ impl Actor for TrackerActor { supervisor, scheduler, settings, + #[cfg(feature = "live")] live_handle, } = state; @@ -124,15 +130,19 @@ impl Actor for TrackerActor { if let Some(left) = initial_left { tracker.update(TrackerUpdate::Left(left)).await?; } - 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"); - } + #[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( @@ -151,7 +161,9 @@ 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), }) } @@ -166,24 +178,29 @@ 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")); - 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 + #[cfg(feature = "live")] { - warn!(error = %e, "Failed to publish final tracker metrics"); - } + 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(); + 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(); + } } + #[cfg(not(feature = "live"))] + let _ = reason; Ok(()) } @@ -191,6 +208,7 @@ impl Actor for TrackerActor { #[messages] impl TrackerActor { + #[cfg(feature = "live")] fn snapshot_metrics(&mut self, latest_peers_returned: Option) -> TrackerMetrics { let mut metrics = self.tracker.stats().metrics(); let totals = metrics.transfer.totals; @@ -232,13 +250,16 @@ impl TrackerActor { #[message(derive(Debug, Clone, Copy))] pub(crate) async fn announce(&mut self) -> Option { 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) => { + #[cfg(feature = "live")] self.live_handle.announce_succeeded(metrics); if let Err(e) = self .supervisor @@ -253,9 +274,11 @@ impl TrackerActor { } Err(e) => { error!(error = %e, "Announce request failed"); + #[cfg(feature = "live")] self.live_handle.announce_failed(metrics); } } + #[cfg(feature = "live")] if let Err(e) = self .supervisor .tell(torrent::events::TrackerMetricsChanged) diff --git a/crates/libtortillas/src/tracker/model.rs b/crates/libtortillas/src/tracker/model.rs index cc43f13b..bfcdede3 100644 --- a/crates/libtortillas/src/tracker/model.rs +++ b/crates/libtortillas/src/tracker/model.rs @@ -119,6 +119,7 @@ impl Tracker { } /// Returns a credential-free endpoint label for public views. + #[cfg(feature = "live")] pub(crate) fn redacted_endpoint(&self) -> String { let uri = self.uri(); let Ok(url) = reqwest::Url::parse(&uri) else { @@ -138,6 +139,7 @@ impl Tracker { format!("{}://{host}{port}/", url.scheme()) } + #[cfg(feature = "live")] fn scheme(&self) -> &'static str { match self { Self::Http(_) => "http", diff --git a/crates/libtortillas/src/tracker/stats.rs b/crates/libtortillas/src/tracker/stats.rs index 17f56b62..990bd014 100644 --- a/crates/libtortillas/src/tracker/stats.rs +++ b/crates/libtortillas/src/tracker/stats.rs @@ -9,6 +9,7 @@ use std::{ use atomic_time::{AtomicInstant, AtomicOptionInstant}; use tokio::time::Instant; +#[cfg(feature = "live")] use crate::metrics::{ByteCount, TrackerMetrics, TrafficTotals, TransferMetrics}; /// Tracker statistics. @@ -113,8 +114,9 @@ impl TrackerStats { } /// Returns all application bytes exchanged with this tracker. + #[cfg(feature = "live")] #[must_use] - pub fn traffic_totals(&self) -> TrafficTotals { + 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)), @@ -123,8 +125,9 @@ impl TrackerStats { /// Creates a typed snapshot with shared transfer metrics and tracker-only /// counters. + #[cfg(feature = "live")] #[must_use] - pub fn metrics(&self) -> TrackerMetrics { + pub(crate) fn metrics(&self) -> TrackerMetrics { TrackerMetrics { transfer: TransferMetrics { totals: self.traffic_totals(), diff --git a/crates/libtortillas/tests/facade.rs b/crates/libtortillas/tests/facade.rs index d7e11ca7..f57896ee 100644 --- a/crates/libtortillas/tests/facade.rs +++ b/crates/libtortillas/tests/facade.rs @@ -1,10 +1,15 @@ use libtortillas::{ facade::{EngineSnapshot, TorrentSnapshot}, - prelude::{Engine, EventSubscription, PeerEventKind, TorrentEventKind, TrackerEventKind}, + prelude::Engine, }; +#[cfg(feature = "live")] #[test] fn prelude_exposes_live_facade_types() { + use libtortillas::prelude::{ + EventSubscription, PeerEventKind, TorrentEventKind, TrackerEventKind, + }; + fn accepts_torrent_events(_: Option>) {} fn accepts_peer_events(_: Option>) {} fn accepts_tracker_events(_: Option>) {} @@ -32,3 +37,16 @@ fn facade_reexports_canonical_snapshot_types() { accepts_engine_snapshot(engine_snapshot); accepts_torrent_snapshot(torrent_snapshot); } + +#[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; +} From c7844ce015e75a8ba140a14013aec57a165d1315 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Mon, 27 Jul 2026 09:43:21 -0700 Subject: [PATCH 75/77] refactor: reduce live feature branching --- crates/libtortillas/src/engine/mod.rs | 87 +++++------- crates/libtortillas/src/peer/actor.rs | 128 +++++------------- crates/libtortillas/src/torrent/actor.rs | 27 ++++ crates/libtortillas/src/torrent/choking.rs | 36 ++--- .../libtortillas/src/torrent/choking_flow.rs | 7 +- crates/libtortillas/src/torrent/messages.rs | 40 ++---- crates/libtortillas/src/torrent/piece_flow.rs | 10 +- crates/libtortillas/src/torrent/swarm.rs | 22 ++- 8 files changed, 146 insertions(+), 211 deletions(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index c1c5c769..1eba97e0 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -83,7 +83,7 @@ use crate::{ hashes::InfoHash, peer::PeerId, settings::Settings, - torrent::{PieceStorageStrategy, RestoreVerification, Torrent}, + torrent::{PieceStorageStrategy, RestoreVerification, Torrent, TorrentActor}, }; /// The main entry point for managing torrents. @@ -311,16 +311,7 @@ impl Engine { .await .map_err(|error| map_engine_send_error("add torrent", error))?; - #[cfg(feature = "live")] - let _ = &torrent_ref; - #[cfg(feature = "live")] - { - self.torrent_handle(info_hash) - } - #[cfg(not(feature = "live"))] - { - 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 } @@ -355,16 +346,7 @@ impl Engine { .await .map_err(|error| map_engine_send_error("restore torrent", error))?; - #[cfg(feature = "live")] - let _ = &torrent_ref; - #[cfg(feature = "live")] - { - self.torrent_handle(info_hash) - } - #[cfg(not(feature = "live"))] - { - Ok(Torrent::new(info_hash, torrent_ref)) - } + self.torrent_from_actor(info_hash, torrent_ref) } /// Restores all torrent sessions from an engine persistence snapshot. @@ -391,26 +373,11 @@ impl Engine { }) .await .map_err(|error| map_engine_send_error("restore engine", error))?; - #[cfg(feature = "live")] - { - info_hashes - .into_iter() - .map(|info_hash| self.torrent_handle(info_hash)) - .collect() - } - #[cfg(not(feature = "live"))] - { - let mut torrents = Vec::with_capacity(info_hashes.len()); - for info_hash in info_hashes { - let torrent_ref = self - .actor() - .ask(GetTorrent { info_hash }) - .await - .map_err(|error| map_engine_send_error("get restored torrent", error))?; - torrents.push(Torrent::new(info_hash, torrent_ref)); - } - Ok(torrents) + 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. @@ -431,16 +398,7 @@ impl Engine { .await .map_err(|error| map_engine_send_error("get torrent", error))?; - #[cfg(feature = "live")] - let _ = &torrent_ref; - #[cfg(feature = "live")] - { - self.torrent_handle(info_hash) - } - #[cfg(not(feature = "live"))] - { - Ok(Torrent::new(info_hash, torrent_ref)) - } + self.torrent_from_actor(info_hash, torrent_ref) } /// Removes a torrent from the engine and stops its actor gracefully. @@ -522,6 +480,35 @@ impl Engine { .torrent_handle(info_hash) .ok_or_else(|| EngineError::TorrentHandleMissing { info_hash }) } + + #[cfg(feature = "live")] + fn torrent_from_actor( + &self, info_hash: InfoHash, _actor: ActorRef, + ) -> Result { + self.torrent_handle(info_hash) + } + + #[cfg(not(feature = "live"))] + fn torrent_from_actor( + &self, info_hash: InfoHash, actor: ActorRef, + ) -> Result { + Ok(Torrent::new(info_hash, actor)) + } + + #[cfg(feature = "live")] + async fn restored_torrent(&self, info_hash: InfoHash) -> Result { + self.torrent_handle(info_hash) + } + + #[cfg(not(feature = "live"))] + 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) + } } impl Default for Engine { diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index 7df77825..b14e54e2 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -34,10 +34,14 @@ use crate::{ metrics::{HasTransferMetrics, PeerMetrics, TimedTransferSample, TransferMetrics}, }; -#[cfg(feature = "live")] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PeerStats { pub(crate) id: PeerId, + pub(crate) interested: bool, + pub(crate) client_choking: bool, + pub(crate) download_rate: u64, + pub(crate) upload_rate: u64, + #[cfg(feature = "live")] pub(crate) metrics: PeerMetrics, } @@ -48,70 +52,6 @@ impl HasTransferMetrics for PeerStats { } } -#[cfg(not(feature = "live"))] -#[derive(Clone, Copy, 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, -} - -impl PeerStats { - pub(crate) fn interested(&self) -> bool { - #[cfg(feature = "live")] - { - self.metrics.peer_interested - } - #[cfg(not(feature = "live"))] - { - self.interested - } - } - - pub(crate) fn client_choking(&self) -> bool { - #[cfg(feature = "live")] - { - self.metrics.client_choking - } - #[cfg(not(feature = "live"))] - { - self.choked - } - } - - pub(crate) fn download_rate(&self) -> u64 { - #[cfg(feature = "live")] - { - self - .metrics - .transfer - .rates() - .map_or(0, |rates| rates.download.0) - } - #[cfg(not(feature = "live"))] - { - u64::try_from(self.download_rate).unwrap_or(u64::MAX) - } - } - - pub(crate) fn upload_rate(&self) -> u64 { - #[cfg(feature = "live")] - { - self - .metrics - .transfer - .rates() - .map_or(0, |rates| rates.upload.0) - } - #[cfg(not(feature = "live"))] - { - u64::try_from(self.upload_rate).unwrap_or(u64::MAX) - } - } -} - #[cfg(not(feature = "live"))] #[derive(Clone, Copy, Debug)] struct RateSample { @@ -151,6 +91,16 @@ pub(crate) struct PeerActor { 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 { /// Sends an extended handshake in return if the received extended message /// was a handshake. @@ -423,7 +373,6 @@ impl PeerActor { let transfer = TransferMetrics::from_sample(transfer_sample); let mut metrics = self.peer.metrics(); metrics.transfer = transfer; - #[cfg(feature = "live")] self .live_handle .publish_metrics(PeerView::from_peer_with_metrics( @@ -432,7 +381,15 @@ impl PeerActor { metrics.clone(), )); - Some(PeerStats { id, metrics }) + 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"))] @@ -458,40 +415,29 @@ impl PeerActor { Some(PeerStats { id, interested: self.peer.interested(), - choked: self.peer.choked(), - download_rate, - upload_rate, + 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 { - #[cfg(feature = "live")] - type Args = ( - Peer, - PeerStream, - ActorRef, - InfoHash, - PeerSettings, - PeerHandle, - ); - #[cfg(not(feature = "live"))] - 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 { - #[cfg(feature = "live")] - let (mut peer, mut stream, supervisor, info_hash, settings, live_handle) = args; - #[cfg(not(feature = "live"))] - let (mut 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"); diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index 6fb434a8..a954f47c 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -576,6 +576,33 @@ impl TorrentActor { self.hub.replace_torrent_view_and_emit(view, event); } + #[inline] + pub(super) fn publish_updated(&self) { + #[cfg(feature = "live")] + self.publish_live_view(|_| crate::live::TorrentEventKind::Updated); + } + + #[inline] + pub(super) fn publish_metrics_changed(&self) { + #[cfg(feature = "live")] + self.publish_live_view(|view| { + crate::live::TorrentEventKind::MetricsChanged(view.metrics.clone()) + }); + } + + #[inline] + pub(super) fn publish_metadata_resolved(&self) { + #[cfg(feature = "live")] + 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(super) fn transition_state(&mut self, state: TorrentState) { let previous = self.state; if previous == state { diff --git a/crates/libtortillas/src/torrent/choking.rs b/crates/libtortillas/src/torrent/choking.rs index ac34c5e7..f5814e15 100644 --- a/crates/libtortillas/src/torrent/choking.rs +++ b/crates/libtortillas/src/torrent/choking.rs @@ -68,7 +68,7 @@ pub(crate) fn select_unchoked_peers( ) -> ChokingDecision { let mut candidates: Vec<_> = peers .iter() - .filter(|peer| peer.interested()) + .filter(|peer| peer.interested) .cloned() .collect(); candidates.sort_by(|left, right| { @@ -114,8 +114,8 @@ pub(crate) fn select_unchoked_peers( fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> u64 { match torrent_state { - TorrentState::Downloading => peer.download_rate(), - TorrentState::Seeding => peer.upload_rate(), + TorrentState::Downloading => peer.download_rate, + TorrentState::Seeding => peer.upload_rate, TorrentState::Added | TorrentState::ResolvingMetadata | TorrentState::Ready @@ -141,6 +141,10 @@ mod tests { fn stats(id: u8) -> PeerStats { PeerStats { id: peer_id(id), + interested: true, + client_choking: true, + download_rate: 0, + upload_rate: 0, metrics: PeerMetrics { peer_interested: true, client_choking: true, @@ -155,26 +159,24 @@ mod tests { } fn with_rates(id: u8, download_rate: u64, upload_rate: u64) -> PeerStats { - PeerStats { - metrics: PeerMetrics { - 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(id).metrics + let mut stats = stats(id); + stats.download_rate = download_rate; + stats.upload_rate = upload_rate; + stats.metrics.transfer = TransferMetrics::from_sample(TransferSample { + previous_totals: TrafficTotals::default(), + current_totals: TrafficTotals { + downloaded: ByteCount(download_rate), + uploaded: ByteCount(upload_rate), }, - id: peer_id(id), - } + elapsed: Duration::from_secs(1), + }); + stats } #[test] fn selector_only_includes_interested_peers() { let mut not_interested = stats(2); - not_interested.metrics.peer_interested = false; + not_interested.interested = false; let peers = [stats(1), not_interested, stats(3)]; let upload_slots = Settings::default().torrent.upload_slots; diff --git a/crates/libtortillas/src/torrent/choking_flow.rs b/crates/libtortillas/src/torrent/choking_flow.rs index d58b56ef..3203b707 100644 --- a/crates/libtortillas/src/torrent/choking_flow.rs +++ b/crates/libtortillas/src/torrent/choking_flow.rs @@ -6,8 +6,6 @@ use tokio::time::timeout; use tracing::{trace, warn}; use super::TorrentActor; -#[cfg(feature = "live")] -use crate::live::TorrentEventKind; use crate::peer::{ PeerActor, PeerId, PeerStats, commands::{SetChoked, Stats}, @@ -29,8 +27,7 @@ impl TorrentActor { } // Peer actors publish their own high-frequency samples. The torrent // publishes one coalesced aggregate after the collection interval. - #[cfg(feature = "live")] - self.publish_live_view(|view| TorrentEventKind::MetricsChanged(view.metrics.clone())); + 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(); @@ -43,7 +40,7 @@ impl TorrentActor { for stats in peer_stats { let choked = !unchoked.contains(&stats.id); - if stats.client_choking() == choked { + if stats.client_choking == choked { continue; } diff --git a/crates/libtortillas/src/torrent/messages.rs b/crates/libtortillas/src/torrent/messages.rs index 76ebb423..53b0c87c 100644 --- a/crates/libtortillas/src/torrent/messages.rs +++ b/crates/libtortillas/src/torrent/messages.rs @@ -16,7 +16,7 @@ use super::{ util, }; #[cfg(feature = "live")] -use crate::live::{TorrentEventKind, TorrentView}; +use crate::live::TorrentView; use crate::{ errors::TorrentError, hashes::InfoHash, @@ -123,8 +123,7 @@ pub(crate) mod events { if self.state == TorrentState::ResolvingMetadata { self.transition_state(TorrentState::Added); } - #[cfg(feature = "live")] - self.publish_live_view(|_| TorrentEventKind::MetadataResolved); + self.publish_metadata_resolved(); self .broadcast_to_peers(HaveInfoDict { bitfield: Arc::new(self.bitfield.clone()), @@ -164,7 +163,7 @@ pub(crate) mod events { /// Publishes tracker traffic after an announce attempt. #[message] pub(crate) fn tracker_metrics_changed(&self) { - self.publish_live_view(|view| TorrentEventKind::MetricsChanged(view.metrics.clone())); + self.publish_metrics_changed(); } } } @@ -180,8 +179,7 @@ pub(crate) mod commands { if let Some(actor) = self.trackers.get(&tracker) { actor.kill(); self.trackers.remove(&tracker); - #[cfg(feature = "live")] - self.publish_live_view(|_| TorrentEventKind::Updated); + self.publish_updated(); } else { warn!("Received kill tracker message for unknown tracker"); } @@ -217,8 +215,7 @@ pub(crate) mod commands { if self.state == TorrentState::Failed { self.transition_state(TorrentState::Paused); } - #[cfg(feature = "live")] - self.publish_live_view(|_| TorrentEventKind::Updated); + self.publish_updated(); Ok(()) } @@ -249,8 +246,7 @@ pub(crate) mod commands { }); } self.piece_manager = PieceManagerProxy::Custom(manager); - #[cfg(feature = "live")] - self.publish_live_view(|_| TorrentEventKind::Updated); + self.publish_updated(); Ok(()) } @@ -282,8 +278,7 @@ pub(crate) mod commands { if self.state == TorrentState::Failed { self.transition_state(TorrentState::Paused); } - #[cfg(feature = "live")] - self.publish_live_view(|_| TorrentEventKind::Updated); + self.publish_updated(); Ok(()) } @@ -305,8 +300,7 @@ pub(crate) mod commands { if !self.pending_start { self.autostart().await; } - #[cfg(feature = "live")] - self.publish_live_view(|_| TorrentEventKind::Updated); + self.publish_updated(); Ok(()) } @@ -318,8 +312,7 @@ pub(crate) mod commands { if !self.pending_start { self.autostart().await; } - #[cfg(feature = "live")] - self.publish_live_view(|_| TorrentEventKind::Updated); + self.publish_updated(); Ok(()) } @@ -370,8 +363,7 @@ pub(crate) mod commands { } })?; self.transition_state(restored_state); - #[cfg(feature = "live")] - self.publish_live_view(|_| TorrentEventKind::Updated); + self.publish_updated(); Ok(()) })(); @@ -556,12 +548,9 @@ pub(crate) mod commands { impl TorrentActor { #[message] pub(crate) fn kill_peer(&mut self, id: PeerId, handle: crate::live::PeerHandle) { - self.piece_scheduler.peer_disconnected(id); - if let Some(actor) = self.peers.remove(&id) { - actor.kill(); - } + self.remove_peer(id); handle.disconnected(); - self.publish_live_view(|_| TorrentEventKind::Updated); + self.publish_updated(); self.fill_all_peer_request_windows(); } @@ -576,10 +565,7 @@ pub(crate) mod commands { impl TorrentActor { #[message] pub(crate) fn kill_peer(&mut self, id: PeerId) { - self.piece_scheduler.peer_disconnected(id); - if let Some(actor) = self.peers.remove(&id) { - actor.kill(); - } + self.remove_peer(id); self.fill_all_peer_request_windows(); } } diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index 8bb4fd2c..ae2757f5 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -275,10 +275,7 @@ impl TorrentActor { let piece_count = info_dict.piece_count(); if !self.validate_and_commit_piece(index).await { - #[cfg(feature = "live")] - self.publish_live_view(|view| { - crate::live::TorrentEventKind::MetricsChanged(view.metrics.clone()) - }); + self.publish_metrics_changed(); self.fill_peer_request_window(peer_id); return; } @@ -296,10 +293,7 @@ impl TorrentActor { // Piece completion is the meaningful progress boundary. Publishing for // every 16 KiB block creates an event storm without improving the view. - #[cfg(feature = "live")] - self.publish_live_view(|view| { - crate::live::TorrentEventKind::MetricsChanged(view.metrics.clone()) - }); + self.publish_metrics_changed(); if self.piece_scheduler.next_piece() >= piece_count { self.update_tracker_progress().await; diff --git a/crates/libtortillas/src/torrent/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index 1207fd18..f9fe7eea 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -12,7 +12,7 @@ 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}, @@ -121,17 +121,15 @@ impl TorrentActor { return; }; - #[cfg(feature = "live")] - let peer_args = ( + let peer_args = PeerActorArgs { peer, stream, - actor_ref, + supervisor: actor_ref, info_hash, - peer_settings, - peer_handle.clone(), - ); - #[cfg(not(feature = "live"))] - let peer_args = (peer, stream, actor_ref, info_hash, peer_settings); + settings: peer_settings, + #[cfg(feature = "live")] + live_handle: peer_handle.clone(), + }; let peer_actor = PeerActor::spawn_with_mailbox( peer_args, match peer_mailbox_size { @@ -140,8 +138,7 @@ impl TorrentActor { }, ); self.peers.insert(id, peer_actor); - #[cfg(feature = "live")] - self.publish_live_view(|_| crate::live::TorrentEventKind::Updated); + self.publish_updated(); #[cfg(feature = "live")] self.hub.emit_peer_connected(&peer_handle); } @@ -187,8 +184,7 @@ impl TorrentActor { self.peers.remove(&id); } if removed_dead_peers { - #[cfg(feature = "live")] - self.publish_live_view(|_| crate::live::TorrentEventKind::Updated); + self.publish_updated(); } } From d0d5f53e371a700efa49f1abc1efacbf27db22f2 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Mon, 27 Jul 2026 10:29:53 -0700 Subject: [PATCH 76/77] refactor: streamline live feature maintenance --- crates/libtortillas/src/engine/actor.rs | 36 ++--- crates/libtortillas/src/engine/messages.rs | 9 +- crates/libtortillas/src/engine/mod.rs | 5 +- crates/libtortillas/src/facade.rs | 16 +- crates/libtortillas/src/lib.rs | 53 ++---- crates/libtortillas/src/live/event.rs | 6 - crates/libtortillas/src/live/mod.rs | 151 ++---------------- crates/libtortillas/src/live/stream.rs | 9 +- crates/libtortillas/src/live/view.rs | 8 +- crates/libtortillas/src/peer/actor.rs | 3 +- .../src/pieces/piece_scheduler.rs | 2 +- crates/libtortillas/src/protocol/stream.rs | 1 + crates/libtortillas/src/torrent/actor.rs | 58 +++---- crates/libtortillas/src/torrent/choking.rs | 22 ++- crates/libtortillas/src/torrent/mod.rs | 61 +------ crates/libtortillas/src/torrent/piece_flow.rs | 2 + crates/libtortillas/src/torrent/swarm.rs | 3 +- crates/libtortillas/src/tracker/actor.rs | 30 ++-- crates/libtortillas/src/tracker/http.rs | 1 + crates/libtortillas/src/tracker/model.rs | 2 +- crates/libtortillas/src/tracker/stats.rs | 2 +- 21 files changed, 126 insertions(+), 354 deletions(-) diff --git a/crates/libtortillas/src/engine/actor.rs b/crates/libtortillas/src/engine/actor.rs index ff6ca475..af6772dc 100644 --- a/crates/libtortillas/src/engine/actor.rs +++ b/crates/libtortillas/src/engine/actor.rs @@ -148,14 +148,12 @@ impl Actor for EngineActor { let udp_addr = udp_addr.unwrap_or(settings.engine.udp_addr); let tcp_socket = TcpListener::bind(tcp_addr).await.map_err(|error| { let error = EngineError::NetworkSetupFailed(format!("tcp bind {tcp_addr}: {error}")); - #[cfg(feature = "live")] - hub.engine_start_failed(error.to_string()); + 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}")); - #[cfg(feature = "live")] - hub.engine_start_failed(error.to_string()); + crate::live_only!(hub.engine_start_failed(error.to_string())); error })?; let udp_server = UdpServer::new_with_receive_buffer_size( @@ -165,8 +163,7 @@ impl Actor for EngineActor { .await .map_err(|error| { let error = EngineError::NetworkSetupFailed(format!("udp bind {udp_addr}: {error}")); - #[cfg(feature = "live")] - hub.engine_start_failed(error.to_string()); + crate::live_only!(hub.engine_start_failed(error.to_string())); error })?; @@ -189,8 +186,7 @@ impl Actor for EngineActor { None }; - #[cfg(feature = "live")] - hub.engine_started(); + crate::live_only!(hub.engine_started()); Ok(Self { #[cfg(feature = "live")] @@ -213,12 +209,11 @@ impl Actor for EngineActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); - #[cfg(feature = "live")] - self.hub.emit_health( + crate::live_only!(self.hub.emit_health( None, LiveHealthLevel::Error, "an engine service stopped unexpectedly", - ); + )); Ok(ControlFlow::Continue(())) } @@ -248,12 +243,11 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); - #[cfg(feature = "live")] - self.hub.emit_health( + crate::live_only!(self.hub.emit_health( None, LiveHealthLevel::Warning, "the TCP peer listener rejected an incoming connection", - ); + )); None } }, @@ -277,12 +271,11 @@ impl Actor for EngineActor { } Err(err) => { error!("Failed to accept incoming peer: {}", err); - #[cfg(feature = "live")] - self.hub.emit_health( + crate::live_only!(self.hub.emit_health( None, LiveHealthLevel::Warning, "the uTP peer listener rejected an incoming connection", - ); + )); None } }, @@ -292,8 +285,7 @@ impl Actor for EngineActor { async fn on_stop( &mut self, _: WeakActorRef, _: ActorStopReason, ) -> Result<(), Self::Error> { - #[cfg(feature = "live")] - self.hub.engine_stopping(); + crate::live_only!(self.hub.engine_stopping()); let torrents = self .torrents .iter() @@ -306,8 +298,7 @@ impl Actor for EngineActor { } torrent.wait_for_shutdown().await; self.torrents.remove(&info_hash); - #[cfg(feature = "live")] - self.hub.remove_torrent_scope(info_hash); + crate::live_only!(self.hub.remove_torrent_scope(info_hash)); } if let Some(dht) = self.dht.take() { @@ -315,8 +306,7 @@ impl Actor for EngineActor { dht.wait_for_shutdown().await; } - #[cfg(feature = "live")] - self.hub.engine_stopped(); + 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 302aa8bc..214aa976 100644 --- a/crates/libtortillas/src/engine/messages.rs +++ b/crates/libtortillas/src/engine/messages.rs @@ -50,8 +50,7 @@ pub(crate) mod commands { if let Err(error) = torrent.stop_gracefully().await { warn!(error = %error, %info_hash, "Failed to stop rejected restored torrent"); } - #[cfg(feature = "live")] - self.hub.remove_torrent_scope(info_hash); + crate::live_only!(self.hub.remove_torrent_scope(info_hash)); } } @@ -296,8 +295,7 @@ pub(crate) mod commands { error, ))); } - #[cfg(feature = "live")] - { + crate::live_only! { let initial_view = match torrent_ref.ask(torrent::commands::GetLiveView).await { Ok(view) => *view, Err(error) => { @@ -349,8 +347,7 @@ pub(crate) mod commands { match self.remove_torrent(info_hash).await { Ok(torrent) => { torrent.kill(); - #[cfg(feature = "live")] - self.hub.remove_torrent_scope(info_hash); + crate::live_only!(self.hub.remove_torrent_scope(info_hash)); } Err(remove_error) => { warn!( diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 1eba97e0..09122709 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -411,8 +411,7 @@ impl Engine { let stop_result = torrent.stop_gracefully().await; torrent.wait_for_shutdown().await; - #[cfg(feature = "live")] - self.hub.remove_torrent_scope(info_hash); + crate::live_only!(self.hub.remove_torrent_scope(info_hash)); stop_result.map_err(|error| EngineError::ActorCommunicationFailed { operation: "stop torrent", reason: error.to_string(), @@ -566,7 +565,7 @@ mod snapshot_tests { } } -#[cfg(test)] +#[cfg(all(test, feature = "live"))] mod tests { use std::time::Duration; diff --git a/crates/libtortillas/src/facade.rs b/crates/libtortillas/src/facade.rs index 32120821..1c104d8d 100644 --- a/crates/libtortillas/src/facade.rs +++ b/crates/libtortillas/src/facade.rs @@ -1,20 +1,6 @@ //! Application-facing facade for `libtortillas`. //! -//! This module defines the stable surface applications should prefer over -//! actor, protocol, tracker, and storage internals. Lower-level -//! modules remain public for advanced integrations, but a terminal UI, web -//! server, browser backend, desktop app, or other consumer can model user -//! intent, observe progress, and hold handles through the same types. -//! -//! # Example -//! -//! ```no_run -//! use libtortillas::facade::{Engine, TorrentSource}; -//! -//! let engine = Engine::default(); -//! let source = TorrentSource::magnet("magnet:?xt=urn:btih:..."); -//! # let _ = (engine, source); -//! ``` +//! Re-exports the handles, snapshots, and live types most applications need. pub use crate::{ engine::{Engine, EngineSnapshot, EngineStatus, TorrentSource}, diff --git a/crates/libtortillas/src/lib.rs b/crates/libtortillas/src/lib.rs index 16c786b5..869027c3 100644 --- a/crates/libtortillas/src/lib.rs +++ b/crates/libtortillas/src/lib.rs @@ -129,14 +129,7 @@ //! features to omit the projection tree, event publishers, listener handles, //! and live metrics. //! -//! Applications that only download and seed files do not need live -//! listeners, events, views, or metrics. The module is for consumers that need -//! current progress and incremental changes, whether they render a terminal, -//! serve an API, update a website, or drive a desktop application. -//! -//! Start with `live::EventListener`: read its `view` for current state and -//! receive events to learn when that state changes. The `live` module -//! documents the complete transport-agnostic model. +//! 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: @@ -144,6 +137,7 @@ //! ```no_run //! use libtortillas::prelude::{Torrent, TorrentState}; //! +//! # #[cfg(feature = "live")] //! async fn show_progress(torrent: &Torrent) -> Result<(), Box> { //! let mut listener = torrent.listener(); //! @@ -167,22 +161,8 @@ //! //! # Runtime and advanced APIs //! -//! `libtortillas` is intentionally a Tokio-based library. Public handles such -//! as [`Engine`](engine::Engine) and [`Torrent`](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. -//! -//! Applications should create one 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. -//! Synchronous adapter work should communicate with async engine tasks through -//! channels or a dedicated adapter thread. [`tokio::task::spawn_blocking`] is -//! appropriate for bounded blocking work, but not for a permanent input loop: -//! a blocking task cannot be aborted after it starts and can delay shutdown. -//! -//! An application can use `#[tokio::main]`, as in the example above, or create -//! an explicit Tokio runtime before initializing `Engine`. +//! `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 @@ -191,10 +171,6 @@ //! internals when an equivalent [`facade`] type exists. [`prelude`] re-exports //! the types most applications need. //! -//! Engine and torrent handles expose listeners for current state and -//! incremental updates. Persistence snapshots are intentionally separate and -//! should not be polled for live changes. -//! //! # Internal architecture //! //! Most applications do not need these implementation details. They are @@ -218,15 +194,18 @@ //! transport-agnostic live views and event streams. Durable state is //! represented by [`EngineSnapshot`](engine::EngineSnapshot) and //! [`TorrentSnapshot`](torrent::TorrentSnapshot), never by live views. -//! -//! Stable public types are exported by module facades while actor messages and -//! coordination details remain crate-private. Domain values such as lifecycle -//! state, storage strategy, metrics, and snapshots live outside actor files so -//! actors can focus on orchestration. -//! -//! See the `live` module for the source-of-truth, publication, lifecycle, and -//! lock invariants. See [`torrent`] for transfer scheduling and persistence -//! semantics. +// `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; diff --git a/crates/libtortillas/src/live/event.rs b/crates/libtortillas/src/live/event.rs index 481afc33..c27ec7b2 100644 --- a/crates/libtortillas/src/live/event.rs +++ b/crates/libtortillas/src/live/event.rs @@ -14,19 +14,13 @@ use crate::{ /// detect a gap after reconnecting a consumer. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SequencedEvent { - /// Publisher-local sequence number for this event. pub sequence: u64, - /// The typed change represented by this event. pub kind: E, } -/// A sequenced event emitted by the engine's live publisher. pub type EngineEvent = SequencedEvent; -/// A sequenced event emitted by a torrent's live publisher. pub type TorrentEvent = SequencedEvent; -/// A sequenced event emitted by a peer's live publisher. pub type PeerEvent = SequencedEvent; -/// A sequenced event emitted by a tracker's live publisher. pub type TrackerEvent = SequencedEvent; impl SequencedEvent { diff --git a/crates/libtortillas/src/live/mod.rs b/crates/libtortillas/src/live/mod.rs index a67f70f0..d9b7dadb 100644 --- a/crates/libtortillas/src/live/mod.rs +++ b/crates/libtortillas/src/live/mod.rs @@ -1,162 +1,45 @@ -//! Current state and incremental events for running engines and torrents. +//! Current state and event streams for running engines and torrents. //! -//! A listener combines a coherent current view with a bounded stream of future -//! changes. Terminals, servers, websites, and desktop applications can all -//! consume that contract without coupling the library to rendering, transport, -//! input, or application routing. -//! -//! # Public model -//! -//! The module is organized around observation: -//! -//! - [`EngineView`], [`TorrentView`], [`PeerView`], and [`TrackerView`] are -//! current read models. -//! - Shared measurements live in [`crate::metrics`] and are re-exported here. -//! - Event enums describe discrete changes. -//! - [`EventSubscription`] is events only; [`EventListener`] pairs events with -//! a coherent current view. -//! - [`PeerHandle`] and [`TrackerHandle`] provide scoped identity and access. -//! - The private hub owns the projection tree and coordinates publication. -//! -//! [`crate::engine::Engine`] and [`crate::torrent::Torrent`] remain the sole -//! public command API. There is no parallel command enum or generic `send` -//! method for application operations. +//! 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 //! -//! Create a listener before starting operations when the application must not -//! miss their events. Use [`EventListener::view`] for initial state and lag -//! recovery, and [`EventListener::recv`] for future changes. -//! //! ```no_run //! use libtortillas::prelude::{Engine, EngineEventKind, EventStreamError}; //! //! # async fn run() -> Result<(), Box> { //! let engine = Engine::default(); //! let mut listener = engine.listener(); -//! let initial_view = listener.view(); //! //! loop { //! match listener.recv().await { -//! Ok(event) => { -//! let current_view = listener.view(); -//! // Render, serialize, or forward `current_view` and `event`. -//! let _ = current_view; -//! if matches!(event.kind, EngineEventKind::Shutdown(_)) { -//! break; -//! } -//! } +//! Ok(event) if matches!(event.kind, EngineEventKind::Shutdown(_)) => break, +//! Ok(_) => {} //! Err(EventStreamError::Lagged(_)) => { -//! // Discard consumer-local assumptions and reload current state. -//! let current_view = listener.view(); -//! let _ = current_view; +//! let _current = listener.view(); //! } //! Err(EventStreamError::Closed) => break, //! } //! } -//! # let _ = initial_view; //! # Ok(()) //! # } //! ``` //! -//! Every [`crate::torrent::Torrent`] has its own `listener()` and -//! `subscribe()` methods. Peers and trackers returned by `Torrent::peers()` and -//! `Torrent::trackers()` follow the same pattern. A scoped listener receives -//! only that scope's events; it does not filter the engine stream. -//! -//! Engine listeners receive [`EngineEventKind::Torrent`], whose nested event -//! uses the same [`TorrentEventKind`] vocabulary as the torrent listener. -//! Peer and tracker lifecycle events carry public handles, allowing an -//! application to descend into detailed streams only when needed. -//! -//! Use `subscribe()` when only discrete events are needed. Use `listener()` -//! when initialization or recovery requires a current view as well. -//! -//! # Ownership and source of truth -//! -//! ```text -//! EngineActor ── owns operational engine state -//! Hub -//! ├── engine lifecycle and event publisher -//! └── keyed torrent scopes -//! └── torrent view and event publisher -//! ├── keyed peer scopes -//! └── keyed tracker scopes -//! -//! EngineView = engine lifecycle + views derived from current torrent scopes -//! ``` -//! -//! The engine never caches a second `Vec`. [`EngineListener`] -//! derives [`EngineView`] on read from current torrent scopes and sorts them by -//! info hash. Peer-only changes therefore touch one peer scope and cannot make -//! a copied engine projection drift from the torrent projection. -//! -//! The private scope registry is a policy wrapper around `DashMap`, not a -//! replacement concurrent map. It prevents shard guards from escaping by -//! returning cloned `Arc` values or owned vectors. Peer and tracker registries -//! are nested under their torrent, making lookup and removal proportional to -//! that torrent's children. -//! -//! # Architectural invariants -//! -//! These rules define the source of truth for views and events: -//! -//! 1. Actors own operational domain state. -//! 2. An observation scope owns only its current projection. -//! 3. Parent views are derived from child scopes; they do not maintain manually -//! synchronized child-view copies. -//! 4. Every scope has one view-and-event publication entry point. -//! 5. Peer and tracker events do not implicitly rebuild torrent or engine -//! state. -//! 6. A scope closes exactly once, only when it cannot restart. -//! 7. Snapshot schema validation runs once at the authoritative engine restore -//! boundary. -//! 8. Actor and hub back-references are weak; the ownership graph contains no -//! strong cycle. -//! 9. Synchronous lock order is registry shard, scope publication/state, then -//! event sender. -//! 10. Actor communication, filesystem work, arbitrary callbacks, and `.await` -//! never occur while a synchronous lock is held. -//! -//! # Event delivery and lifecycle -//! -//! Channels are allocated lazily on first subscription. Defaults retain 256 -//! engine or torrent events and 64 peer or tracker events; all capacities are -//! configurable with [`crate::settings::LiveSettings`]. A slow consumer -//! receives [`EventStreamError::Lagged`] instead of causing unbounded memory -//! growth. Sequence numbers increase monotonically within each scope. -//! -//! Projection mutation and terminal closure are crate-internal. Applications -//! can observe publishers through their view, listener, and subscription APIs -//! without being able to alter actor-owned state. -//! -//! Supervised torrent and tracker actors publish a restarting state after -//! abnormal termination and keep their streams open. Final ownership teardown -//! publishes the terminal state once, closes the scope tree, and rejects late -//! actor updates. -//! -//! # Locking and publication -//! -//! Registry methods release their `DashMap` shard guard before acquiring a -//! scope lock. Scope construction occurs before shard entry acquisition, so -//! callbacks never execute under a registry lock. A scope publication lock -//! serializes its view transition, scoped event, and corresponding root event. -//! [`LivePublisher`] then acquires its state lock before its optional sender -//! lock. No path acquires a registry guard while holding a child scope lock, -//! and no synchronous lock crosses an `.await`. +//! 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. //! -//! # Views and persistence +//! # Internal invariants //! -//! Views are current-state contracts suitable for rendering, API responses, -//! and transport serialization. [`crate::engine::EngineSnapshot`] and -//! [`crate::torrent::TorrentSnapshot`] are durable persistence contracts. -//! Applications must not poll snapshots to refresh current state. See -//! [`crate::torrent`] for restore validation and storage reconciliation rules. +//! 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. //! -//! Application-specific action routing can use an application-owned Tokio -//! channel whose consumer invokes methods on `Engine` and `Torrent`. That keeps -//! caller-specific commands outside the library without duplicating its public -//! API. +//! 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; diff --git a/crates/libtortillas/src/live/stream.rs b/crates/libtortillas/src/live/stream.rs index 4ea899e1..8a8d30a7 100644 --- a/crates/libtortillas/src/live/stream.rs +++ b/crates/libtortillas/src/live/stream.rs @@ -52,10 +52,7 @@ where V: Clone + Send + Sync + 'static, E: Clone + Send + 'static, { - /// Creates a publisher with an initial view and bounded event capacity. - /// - /// A zero capacity is normalized to one so configuration mistakes cannot - /// panic a public operation. + /// A zero capacity is normalized to one. #[must_use] pub fn new(initial_view: V, event_capacity: usize) -> Self { Self { @@ -71,7 +68,6 @@ where } } - /// Subscribes to all future events from this publisher. #[must_use] pub fn subscribe(&self) -> EventSubscription { let state = mutex_lock(&self.state); @@ -105,14 +101,12 @@ where .saturating_mul(std::mem::size_of::>()) } - /// Creates a stream-compatible listener paired with the current view. #[must_use] pub fn listener(&self) -> EventListener { let state = Arc::clone(&self.state); EventListener::new(self.subscribe(), move || mutex_lock(&state).view.clone()) } - /// Clones the latest coherent view. #[must_use] pub fn view(&self) -> V { mutex_lock(&self.state).view.clone() @@ -240,7 +234,6 @@ impl EventSubscription { Self::from_receiver(receiver, weak) } - /// Waits for the next event in this subscription. pub async fn recv(&mut self) -> Result, EventStreamError> { poll_fn(|context| Pin::new(&mut *self).poll_next(context)) .await diff --git a/crates/libtortillas/src/live/view.rs b/crates/libtortillas/src/live/view.rs index 8949eb4c..58ba4f58 100644 --- a/crates/libtortillas/src/live/view.rs +++ b/crates/libtortillas/src/live/view.rs @@ -43,13 +43,11 @@ pub struct TorrentView { } impl TorrentView { - /// Whether the torrent has resolved payload metadata. #[must_use] pub const fn has_metadata(&self) -> bool { self.metrics.progress.total_bytes.is_some() } - /// Whether the torrent has reached its ready lifecycle state. #[must_use] pub const fn is_ready(&self) -> bool { matches!(self.state, TorrentState::Ready) @@ -59,11 +57,8 @@ impl TorrentView { /// Current state of a connected or recently disconnected peer. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PeerView { - /// Network address for the peer, when known. pub address: Option, - /// Parsed peer-client family, when known. pub client: Option, - /// Whether this peer is currently connected. pub connected: bool, pub metrics: PeerMetrics, } @@ -102,9 +97,8 @@ impl HasTransferMetrics for PeerView { /// Public tracker identity and latest announce outcome. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TrackerView { - /// Credential-free tracker endpoint label. + /// Tracker URL with credentials removed. pub endpoint: String, - /// Current actor and announce lifecycle. pub status: TrackerStatus, pub metrics: TrackerMetrics, } diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index b14e54e2..1a296b66 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -738,8 +738,7 @@ impl Message for PeerActor { warn!("Received unexpected handshake from peer"); } } - #[cfg(feature = "live")] - { + crate::live_only! { let samples = self.live_handle.view().metrics.transfer.samples; self .live_handle diff --git a/crates/libtortillas/src/pieces/piece_scheduler.rs b/crates/libtortillas/src/pieces/piece_scheduler.rs index 6941492b..187b1303 100644 --- a/crates/libtortillas/src/pieces/piece_scheduler.rs +++ b/crates/libtortillas/src/pieces/piece_scheduler.rs @@ -66,7 +66,7 @@ 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); } diff --git a/crates/libtortillas/src/protocol/stream.rs b/crates/libtortillas/src/protocol/stream.rs index 1045bc46..c4010371 100644 --- a/crates/libtortillas/src/protocol/stream.rs +++ b/crates/libtortillas/src/protocol/stream.rs @@ -544,6 +544,7 @@ 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(); diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index a954f47c..cfdc2a5e 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -224,12 +224,11 @@ impl TorrentActor { // Pre-start the piece manager before transitioning state if let Err(err) = self.piece_manager.pre_start(info.clone()).await { self.transition_state(TorrentState::Failed); - #[cfg(feature = "live")] - self.hub.emit_health( + 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; } @@ -578,22 +577,21 @@ impl TorrentActor { #[inline] pub(super) fn publish_updated(&self) { - #[cfg(feature = "live")] - self.publish_live_view(|_| crate::live::TorrentEventKind::Updated); + crate::live_only!(self.publish_live_view(|_| crate::live::TorrentEventKind::Updated)); } #[inline] pub(super) fn publish_metrics_changed(&self) { - #[cfg(feature = "live")] - self.publish_live_view(|view| { + crate::live_only!(self.publish_live_view(|view| { crate::live::TorrentEventKind::MetricsChanged(view.metrics.clone()) - }); + })); } #[inline] pub(super) fn publish_metadata_resolved(&self) { - #[cfg(feature = "live")] - self.publish_live_view(|_| crate::live::TorrentEventKind::MetadataResolved); + crate::live_only!( + self.publish_live_view(|_| crate::live::TorrentEventKind::MetadataResolved) + ); } pub(super) fn remove_peer(&mut self, id: PeerId) { @@ -610,11 +608,12 @@ impl TorrentActor { } self.state = state; - #[cfg(feature = "live")] - self.publish_live_view(|_| crate::live::TorrentEventKind::StateChanged { - previous, - current: state, - }); + crate::live_only!( + self.publish_live_view(|_| crate::live::TorrentEventKind::StateChanged { + previous, + current: state, + }) + ); } fn snapshot_u64(value: usize) -> u64 { @@ -859,8 +858,7 @@ impl Actor for TorrentActor { piece_manager: PieceManagerProxy::Default(default_manager), settings, }; - #[cfg(feature = "live")] - actor.hub.initialize_torrent_projection(actor.live_view()); + crate::live_only!(actor.hub.initialize_torrent_projection(actor.live_view())); Ok(actor) } @@ -884,10 +882,11 @@ impl Actor for TorrentActor { // The engine supervises torrent actors transiently. Preserve the // live scope and make the temporary state explicit. self.transition_state(TorrentState::Restarting); - #[cfg(feature = "live")] - self - .hub - .close_peer_scopes_for_torrent_restart(self.info_hash()); + 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() { @@ -913,20 +912,21 @@ impl Actor for TorrentActor { &mut self, _: WeakActorRef, id: ActorId, reason: ActorStopReason, ) -> Result, Self::Error> { error!(?id, ?reason, "Linked child died"); - #[cfg(feature = "live")] - if !reason.is_normal() { - self.hub.emit_health( - Some(self.info_hash()), - LiveHealthLevel::Error, - "a torrent service stopped unexpectedly", - ); + 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}; diff --git a/crates/libtortillas/src/torrent/choking.rs b/crates/libtortillas/src/torrent/choking.rs index f5814e15..39e7c2bb 100644 --- a/crates/libtortillas/src/torrent/choking.rs +++ b/crates/libtortillas/src/torrent/choking.rs @@ -129,9 +129,11 @@ fn rate_for(peer: &PeerStats, torrent_state: TorrentState) -> u64 { #[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 { @@ -145,6 +147,7 @@ mod tests { client_choking: true, download_rate: 0, upload_rate: 0, + #[cfg(feature = "live")] metrics: PeerMetrics { peer_interested: true, client_choking: true, @@ -162,14 +165,17 @@ mod tests { let mut stats = stats(id); stats.download_rate = download_rate; stats.upload_rate = upload_rate; - 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), - }); + #[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 } diff --git a/crates/libtortillas/src/torrent/mod.rs b/crates/libtortillas/src/torrent/mod.rs index 06673225..14d49aac 100644 --- a/crates/libtortillas/src/torrent/mod.rs +++ b/crates/libtortillas/src/torrent/mod.rs @@ -1,25 +1,4 @@ -//! One torrent's lifecycle, transfer coordination, storage, and persistence. -//! -//! # Operational ownership -//! -//! `TorrentActor` is the authoritative owner of torrent state. It coordinates -//! one peer actor per connection, one tracker actor per endpoint, piece -//! scheduling, verified progress, and storage. The public [`Torrent`] handle -//! exposes commands while the live torrent listener exposes the current -//! projection and typed events. -//! -//! High-frequency peer protocol state remains local to peer scopes. Torrent -//! transfer metrics are aggregated after periodic peer-stat collection rather -//! than republishing the complete hierarchy for every wire message. Tracker -//! progress is likewise sampled; final lifecycle announcements receive a -//! reliable current value. -//! -//! The piece scheduler fills a bounded request window for each peer, considers -//! that peer's advertised bitfield, releases requests when a peer disconnects -//! or rejects them, and makes unanswered requests eligible for reassignment -//! after [`crate::settings::TorrentSettings::peer_request_timeout`]. Piece -//! completion refills the consumed window slot, so a long download cannot -//! drain its pipeline one piece at a time. +//! Torrent lifecycle, transfer coordination, storage, and persistence. //! //! # Lifecycle //! @@ -29,15 +8,13 @@ //! [`TorrentState::Ready`] when autostart is disabled or moves into //! [`TorrentState::Downloading`] when transfer begins. //! -//! Completed downloads transition to [`TorrentState::Seeding`]. -//! [`TorrentState::Paused`] is an explicit user state and is not eligible for -//! autostart. Shutdown, supervision, and failure paths remain distinct through -//! `Restarting`, `Stopping`, `Stopped`, and `Failed` states. +//! Completed downloads transition to [`TorrentState::Seeding`], while paused +//! torrents remain paused until explicitly resumed. //! //! # Persistence boundary //! -//! Live views and persistence snapshots are deliberately separate. Restoration -//! follows one ordered transaction: +//! Live views and persistence snapshots are separate. Restoration runs in this +//! order: //! //! ```text //! schema validation @@ -46,15 +23,6 @@ //! -> optional transfer resumption //! ``` //! -//! Snapshot schema validation occurs once in the engine actor. Internal restore -//! APIs accept a validated wrapper so torrent actors cannot repeat or bypass -//! that boundary. -//! -//! A `.torrent` source stores its `Info` dictionary only inside -//! [`crate::metainfo::MetaInfo`]. Only a resolved magnet stores separate -//! resolved metadata, and [`TorrentSnapshot::resolved_info`] is the canonical -//! resolver used by validation and restoration. -//! //! [`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. @@ -62,23 +30,8 @@ //! only be used when the application can independently guarantee storage //! integrity. //! -//! Arbitrary custom piece-manager trait objects have no implicit persistence -//! representation. Snapshotting one returns a typed unsupported error instead -//! of silently restoring it as a different storage implementation. -//! -//! Snapshot JSON is a versioned durable contract: portable numeric fields use -//! `u64`, keyed scheduler state is sorted, and bitfields serialize as -//! `Vec` rather than implementation-specific concurrent collections. -//! Migrations are explicit and supported versions have golden fixtures. -//! -//! # Choking -//! -//! Active torrents periodically collect peer transfer samples and recalculate -//! upload slots. Downloads prefer interested peers with the highest recent -//! download rate; seeds prefer recent upload rate. One slot rotates as an -//! optimistic unchoke when the interested set exceeds available slots. -//! `PeerActor` remains responsible for the corresponding wire-level `Choke` -//! and `Unchoke` messages. +//! Custom piece managers cannot be snapshotted unless they have a durable +//! representation. mod actor; mod block; diff --git a/crates/libtortillas/src/torrent/piece_flow.rs b/crates/libtortillas/src/torrent/piece_flow.rs index ae2757f5..72ebcf64 100644 --- a/crates/libtortillas/src/torrent/piece_flow.rs +++ b/crates/libtortillas/src/torrent/piece_flow.rs @@ -495,10 +495,12 @@ 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(), diff --git a/crates/libtortillas/src/torrent/swarm.rs b/crates/libtortillas/src/torrent/swarm.rs index f9fe7eea..ea65daf5 100644 --- a/crates/libtortillas/src/torrent/swarm.rs +++ b/crates/libtortillas/src/torrent/swarm.rs @@ -139,8 +139,7 @@ impl TorrentActor { ); self.peers.insert(id, peer_actor); self.publish_updated(); - #[cfg(feature = "live")] - self.hub.emit_peer_connected(&peer_handle); + crate::live_only!(self.hub.emit_peer_connected(&peer_handle)); } #[instrument(skip(self, tell), fields(torrent_id = %self.info_hash(), msg = ?tell))] diff --git a/crates/libtortillas/src/tracker/actor.rs b/crates/libtortillas/src/tracker/actor.rs index 754ae4ed..60e03f8d 100644 --- a/crates/libtortillas/src/tracker/actor.rs +++ b/crates/libtortillas/src/tracker/actor.rs @@ -169,7 +169,7 @@ impl Actor for TrackerActor { } async fn on_stop( - &mut self, _: WeakActorRef, reason: ActorStopReason, + &mut self, _: WeakActorRef, _reason: ActorStopReason, ) -> Result<(), Self::Error> { if let Some(next_announce) = self.next_announce.take() { next_announce.abort(); @@ -178,8 +178,7 @@ 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")); - #[cfg(feature = "live")] - { + 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 @@ -190,7 +189,7 @@ impl Actor for TrackerActor { warn!(error = %e, "Failed to publish final tracker metrics"); } - if reason.is_normal() { + if _reason.is_normal() { self.live_handle.stopped(); } else { // Transient supervision may reconstruct this actor with the same @@ -199,8 +198,6 @@ impl Actor for TrackerActor { self.live_handle.restarting(); } } - #[cfg(not(feature = "live"))] - let _ = reason; Ok(()) } @@ -259,8 +256,7 @@ impl TrackerActor { let metrics = self.snapshot_metrics(latest_peers_returned); match result { Ok(peers) => { - #[cfg(feature = "live")] - self.live_handle.announce_succeeded(metrics); + crate::live_only!(self.live_handle.announce_succeeded(metrics)); if let Err(e) = self .supervisor .tell(torrent::events::Announce { @@ -274,17 +270,17 @@ impl TrackerActor { } Err(e) => { error!(error = %e, "Announce request failed"); - #[cfg(feature = "live")] - self.live_handle.announce_failed(metrics); + crate::live_only!(self.live_handle.announce_failed(metrics)); } } - #[cfg(feature = "live")] - if let Err(e) = self - .supervisor - .tell(torrent::events::TrackerMetricsChanged) - .await - { - error!(error = %e, "Failed to publish tracker 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 d6410cd7..b7ae854a 100644 --- a/crates/libtortillas/src/tracker/http.rs +++ b/crates/libtortillas/src/tracker/http.rs @@ -546,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); diff --git a/crates/libtortillas/src/tracker/model.rs b/crates/libtortillas/src/tracker/model.rs index bfcdede3..88c94de0 100644 --- a/crates/libtortillas/src/tracker/model.rs +++ b/crates/libtortillas/src/tracker/model.rs @@ -288,7 +288,7 @@ fn tracker_from_uri(uri: String) -> Result { } } -#[cfg(test)] +#[cfg(all(test, feature = "live"))] mod tests { use super::*; diff --git a/crates/libtortillas/src/tracker/stats.rs b/crates/libtortillas/src/tracker/stats.rs index 990bd014..551cb8ca 100644 --- a/crates/libtortillas/src/tracker/stats.rs +++ b/crates/libtortillas/src/tracker/stats.rs @@ -167,7 +167,7 @@ impl TrackerStats { } } -#[cfg(test)] +#[cfg(all(test, feature = "live"))] mod tests { use super::*; From ee773aa06c4532b1f0f87679aa2d27d0040cb987 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Mon, 27 Jul 2026 10:51:49 -0700 Subject: [PATCH 77/77] refactor: group live-only methods --- crates/libtortillas/src/engine/mod.rs | 54 +++---- crates/libtortillas/src/live/view.rs | 7 +- crates/libtortillas/src/peer/actor.rs | 8 +- crates/libtortillas/src/peer/state.rs | 9 +- crates/libtortillas/src/torrent/actor.rs | 181 +++++++++++----------- crates/libtortillas/src/torrent/handle.rs | 76 +++++---- crates/libtortillas/src/tracker/actor.rs | 6 +- crates/libtortillas/src/tracker/model.rs | 5 +- 8 files changed, 173 insertions(+), 173 deletions(-) diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 09122709..c9946d4c 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -83,7 +83,7 @@ use crate::{ hashes::InfoHash, peer::PeerId, settings::Settings, - torrent::{PieceStorageStrategy, RestoreVerification, Torrent, TorrentActor}, + torrent::{PieceStorageStrategy, RestoreVerification, Torrent, TorrentActor, TorrentSnapshot}, }; /// The main entry point for managing torrents. @@ -320,9 +320,7 @@ impl Engine { /// /// 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: crate::torrent::TorrentSnapshot, - ) -> Result { + pub async fn restore_torrent(&self, snapshot: TorrentSnapshot) -> Result { self .restore_torrent_with_verification(snapshot, RestoreVerification::Full) .await @@ -331,7 +329,7 @@ impl Engine { /// Restores one torrent using an explicit durable-storage verification /// policy. pub async fn restore_torrent_with_verification( - &self, snapshot: crate::torrent::TorrentSnapshot, verification: RestoreVerification, + &self, snapshot: TorrentSnapshot, verification: RestoreVerification, ) -> Result { let info_hash = snapshot.info_hash; @@ -445,20 +443,39 @@ impl Engine { .await .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. - #[cfg(feature = "live")] #[must_use] pub fn subscribe(&self) -> EventSubscription { self.hub.subscribe() } /// Creates a listener with typed events and coherent current state. - #[cfg(feature = "live")] #[must_use] pub fn listener(&self) -> EngineListener { let hub = self.hub.clone(); @@ -466,13 +483,11 @@ impl Engine { } /// Returns the current engine state maintained by the projection tree. - #[cfg(feature = "live")] #[must_use] pub fn view(&self) -> EngineView { self.hub.view() } - #[cfg(feature = "live")] fn torrent_handle(&self, info_hash: InfoHash) -> Result { self .hub @@ -480,34 +495,15 @@ impl Engine { .ok_or_else(|| EngineError::TorrentHandleMissing { info_hash }) } - #[cfg(feature = "live")] fn torrent_from_actor( &self, info_hash: InfoHash, _actor: ActorRef, ) -> Result { self.torrent_handle(info_hash) } - #[cfg(not(feature = "live"))] - fn torrent_from_actor( - &self, info_hash: InfoHash, actor: ActorRef, - ) -> Result { - Ok(Torrent::new(info_hash, actor)) - } - - #[cfg(feature = "live")] async fn restored_torrent(&self, info_hash: InfoHash) -> Result { self.torrent_handle(info_hash) } - - #[cfg(not(feature = "live"))] - 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) - } } impl Default for Engine { @@ -878,7 +874,7 @@ mod tests { let event = listener.recv().await.unwrap(); if let EngineEventKind::Torrent { torrent, - event: crate::live::TorrentEventKind::PeerConnected(peer), + event: TorrentEventKind::PeerConnected(peer), } = event.kind { break (torrent, peer); diff --git a/crates/libtortillas/src/live/view.rs b/crates/libtortillas/src/live/view.rs index 58ba4f58..e1b43f3b 100644 --- a/crates/libtortillas/src/live/view.rs +++ b/crates/libtortillas/src/live/view.rs @@ -5,7 +5,10 @@ use serde::{Deserialize, Serialize}; use crate::{ engine::EngineStatus, hashes::InfoHash, - metrics::{HasTransferMetrics, PeerMetrics, TorrentMetrics, TrackerMetrics, TransferMetrics}, + metrics::{ + HasTransferMetrics, PeerMetrics, TorrentMetrics, TrackerMetrics, TransferMetrics, + TransferSample, + }, peer::Peer, torrent::TorrentState, }; @@ -69,7 +72,7 @@ impl PeerView { } pub(crate) fn from_peer_with_samples( - peer: &Peer, connected: bool, samples: Vec, + peer: &Peer, connected: bool, samples: Vec, ) -> Self { let mut metrics = peer.metrics(); metrics.transfer.samples = samples; diff --git a/crates/libtortillas/src/peer/actor.rs b/crates/libtortillas/src/peer/actor.rs index 1a296b66..4e6a1381 100644 --- a/crates/libtortillas/src/peer/actor.rs +++ b/crates/libtortillas/src/peer/actor.rs @@ -361,8 +361,10 @@ impl PeerActor { self.stream.send(msg).await } +} - #[cfg(feature = "live")] +#[cfg(feature = "live")] +impl PeerActor { fn snapshot_stats(&mut self) -> Option { let id = self.peer.id?; let now = Instant::now(); @@ -391,8 +393,10 @@ impl PeerActor { metrics, }) } +} - #[cfg(not(feature = "live"))] +#[cfg(not(feature = "live"))] +impl PeerActor { fn snapshot_stats(&mut self) -> Option { let id = self.peer.id?; let now = Instant::now(); diff --git a/crates/libtortillas/src/peer/state.rs b/crates/libtortillas/src/peer/state.rs index 5e515426..ab2bdedf 100644 --- a/crates/libtortillas/src/peer/state.rs +++ b/crates/libtortillas/src/peer/state.rs @@ -86,8 +86,10 @@ impl PeerState { self.bytes_downloaded = state.bytes_downloaded.clone(); self.bytes_uploaded = state.bytes_uploaded.clone(); } +} - #[cfg(feature = "live")] +#[cfg(feature = "live")] +impl PeerState { pub(crate) fn traffic_totals(&self) -> TrafficTotals { TrafficTotals { downloaded: ByteCount( @@ -185,13 +187,14 @@ impl Peer { pub fn bytes_uploaded(&self) -> usize { self.state.bytes_uploaded.load(Ordering::Relaxed) } +} - #[cfg(feature = "live")] +#[cfg(feature = "live")] +impl Peer { pub(crate) fn traffic_totals(&self) -> TrafficTotals { self.state.traffic_totals() } - #[cfg(feature = "live")] pub(crate) fn metrics(&self) -> PeerMetrics { PeerMetrics { transfer: TransferMetrics { diff --git a/crates/libtortillas/src/torrent/actor.rs b/crates/libtortillas/src/torrent/actor.rs index cfdc2a5e..1fab81cb 100644 --- a/crates/libtortillas/src/torrent/actor.rs +++ b/crates/libtortillas/src/torrent/actor.rs @@ -41,7 +41,7 @@ use crate::{ }; #[cfg(feature = "live")] use crate::{ - live::{Hub, LiveHealthLevel, TorrentView, TrackerStatus, TrackerView}, + live::{Hub, LiveHealthLevel, TorrentEventKind, TorrentView, TrackerStatus, TrackerView}, metrics::{ ByteCount, ContentProgress, HasTransferMetrics, TorrentMetrics, TrackerMetrics, TransferMetrics, @@ -390,28 +390,6 @@ impl TorrentActor { Some(total_bytes) } - #[cfg(feature = "live")] - 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), - ) - } - pub(super) fn tracker_announce_progress(&self) -> Option { let info = self.info_dict()?; let total_length = info.total_length(); @@ -488,8 +466,93 @@ impl TorrentActor { }) } + #[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(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. - #[cfg(feature = "live")] pub fn live_view(&self) -> TorrentView { let info = self.info_dict(); let total_bytes = info @@ -566,79 +629,11 @@ impl TorrentActor { } /// The single publication entry point for torrent projection changes. - #[cfg(feature = "live")] - pub(super) fn publish_live_view( - &self, event: impl FnOnce(&TorrentView) -> crate::live::TorrentEventKind, - ) { + 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); } - - #[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(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(|_| crate::live::TorrentEventKind::StateChanged { - previous, - current: state, - }) - ); - } - - fn snapshot_u64(value: usize) -> u64 { - u64::try_from(value).unwrap_or(u64::MAX) - } - - #[cfg(feature = "live")] - 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_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() - } } /// Configuration arguments for creating a [`TorrentActor`]. diff --git a/crates/libtortillas/src/torrent/handle.rs b/crates/libtortillas/src/torrent/handle.rs index 3035019c..34d442ee 100644 --- a/crates/libtortillas/src/torrent/handle.rs +++ b/crates/libtortillas/src/torrent/handle.rs @@ -54,40 +54,6 @@ impl fmt::Debug for Torrent { } impl Torrent { - /// Creates a new [`Torrent`] handle from an [`InfoHash`] and a reference - /// to its underlying [`TorrentActor`]. - #[cfg(all(test, feature = "live"))] - pub(crate) fn new(info_hash: InfoHash, actor_ref: ActorRef) -> Self { - Self::new_with_hub(info_hash, actor_ref, &Hub::default(), None) - } - - #[cfg(not(feature = "live"))] - pub(crate) fn new(info_hash: InfoHash, actor: ActorRef) -> Self { - Self { - inner: Arc::new(TorrentInner { info_hash, actor }), - } - } - - #[cfg(feature = "live")] - 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 } - } - pub(crate) fn actor(&self) -> &ActorRef { &self.inner.actor } @@ -223,16 +189,50 @@ impl Torrent { })?; Ok(()) } +} + +#[cfg(not(feature = "live"))] +impl Torrent { + pub(crate) fn new(info_hash: InfoHash, actor: ActorRef) -> Self { + Self { + inner: Arc::new(TorrentInner { info_hash, actor }), + } + } +} + +#[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. - #[cfg(feature = "live")] #[must_use] pub fn subscribe(&self) -> EventSubscription { self.inner.publisher.subscribe() } /// Creates a live listener scoped to this torrent. - #[cfg(feature = "live")] #[must_use] pub fn listener(&self) -> TorrentListener { self.inner.publisher.listener() @@ -241,14 +241,12 @@ impl Torrent { /// Returns the latest state maintained for this torrent. /// /// This returns `None` after the torrent has been removed from its engine. - #[cfg(feature = "live")] #[must_use] pub fn view(&self) -> Option { self.inner.publisher.view() } /// Returns handles for this torrent's currently connected peers. - #[cfg(feature = "live")] #[must_use] pub fn peers(&self) -> Vec { self @@ -257,7 +255,6 @@ impl Torrent { } /// Returns handles for this torrent's configured trackers. - #[cfg(feature = "live")] #[must_use] pub fn trackers(&self) -> Vec { self @@ -265,7 +262,6 @@ impl Torrent { .map_or_else(Vec::new, |live| live.tracker_handles(self.info_hash())) } - #[cfg(feature = "live")] fn hub(&self) -> Option { self.inner.hub.upgrade().map(Hub::from_inner) } diff --git a/crates/libtortillas/src/tracker/actor.rs b/crates/libtortillas/src/tracker/actor.rs index 60e03f8d..8627a35c 100644 --- a/crates/libtortillas/src/tracker/actor.rs +++ b/crates/libtortillas/src/tracker/actor.rs @@ -203,9 +203,8 @@ impl Actor for TrackerActor { } } -#[messages] +#[cfg(feature = "live")] impl TrackerActor { - #[cfg(feature = "live")] fn snapshot_metrics(&mut self, latest_peers_returned: Option) -> TrackerMetrics { let mut metrics = self.tracker.stats().metrics(); let totals = metrics.transfer.totals; @@ -215,7 +214,10 @@ impl TrackerActor { metrics.latest_peers_returned = latest_peers_returned; metrics } +} +#[messages] +impl TrackerActor { async fn schedule_next_announce(&mut self) { let interval = self.tracker.interval(); let delay = if interval == usize::MAX || interval == u32::MAX as usize { diff --git a/crates/libtortillas/src/tracker/model.rs b/crates/libtortillas/src/tracker/model.rs index 88c94de0..50fbaa33 100644 --- a/crates/libtortillas/src/tracker/model.rs +++ b/crates/libtortillas/src/tracker/model.rs @@ -117,9 +117,11 @@ impl Tracker { Tracker::Http(uri) | Tracker::Udp(uri) | Tracker::Websocket(uri) => uri.clone(), } } +} +#[cfg(feature = "live")] +impl Tracker { /// Returns a credential-free endpoint label for public views. - #[cfg(feature = "live")] pub(crate) fn redacted_endpoint(&self) -> String { let uri = self.uri(); let Ok(url) = reqwest::Url::parse(&uri) else { @@ -139,7 +141,6 @@ impl Tracker { format!("{}://{host}{port}/", url.scheme()) } - #[cfg(feature = "live")] fn scheme(&self) -> &'static str { match self { Self::Http(_) => "http",