1#![allow(
2 clippy::cast_possible_truncation,
3 clippy::cast_precision_loss,
4 clippy::cast_possible_wrap,
5 clippy::cast_sign_loss,
6 clippy::unchecked_time_subtraction,
7 reason = "M175: session-level counters/rates bounded by torrent count; time deltas use post-init Instants"
8)]
9
10use std::collections::HashMap;
16use std::net::{IpAddr, SocketAddr};
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicU32, Ordering};
20
21use dashmap::DashMap;
22use tokio::sync::{broadcast, mpsc, oneshot};
23
24use tracing::{debug, info, warn};
25
26use irontide_core::{DEFAULT_CHUNK_SIZE, Id20, Lengths, Magnet};
27use irontide_dht::DhtHandle;
28use irontide_storage::TorrentStorage;
29
30use crate::alert::{Alert, AlertCategory, AlertKind, AlertStream, post_alert};
31use crate::settings_convert::SettingsConvertExt;
32use crate::torrent::TorrentHandle;
33use crate::types::{
34 FileInfo, SessionStats, TorrentConfig, TorrentInfo, TorrentState, TorrentStats, TorrentSummary,
35};
36use irontide_settings::Settings;
37
38type SharedBucket = Arc<parking_lot::Mutex<crate::rate_limiter::TokenBucket>>;
40
41type QueueMoveFn = fn(&mut [crate::queue::QueueEntry], Id20) -> Vec<(Id20, i32, i32)>;
43
44use irontide_session_types::{SharedBanManager, SharedIpFilter};
47
48#[derive(Debug, Clone)]
50pub struct ResumeLoadResult {
51 pub restored: usize,
53 pub skipped: usize,
55 pub failed: usize,
57}
58
59#[derive(Debug, Clone)]
64pub enum AddSource {
65 Magnet(String),
67 Bytes(Vec<u8>),
69}
70
71#[derive(Debug, Clone)]
92pub struct AddTorrentParams {
93 pub source: AddSource,
95 pub category: Option<String>,
98 pub tags: Vec<String>,
101 pub download_dir: Option<PathBuf>,
104 pub paused: Option<bool>,
110 pub skip_checking: bool,
112}
113
114impl AddTorrentParams {
115 #[must_use]
117 pub fn magnet(uri: impl Into<String>) -> Self {
118 Self {
119 source: AddSource::Magnet(uri.into()),
120 category: None,
121 tags: Vec::new(),
122 download_dir: None,
123 paused: None,
124 skip_checking: false,
125 }
126 }
127
128 #[must_use]
130 pub fn bytes(data: impl Into<Vec<u8>>) -> Self {
131 Self {
132 source: AddSource::Bytes(data.into()),
133 category: None,
134 tags: Vec::new(),
135 download_dir: None,
136 paused: None,
137 skip_checking: false,
138 }
139 }
140
141 #[must_use]
144 pub fn with_category(mut self, name: impl Into<String>) -> Self {
145 self.category = Some(name.into());
146 self
147 }
148
149 #[must_use]
153 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
154 self.tags = tags;
155 self
156 }
157
158 #[must_use]
160 pub fn with_download_dir(mut self, dir: impl Into<PathBuf>) -> Self {
161 self.download_dir = Some(dir.into());
162 self
163 }
164
165 #[must_use]
170 pub fn paused(mut self, paused: bool) -> Self {
171 self.paused = Some(paused);
172 self
173 }
174
175 #[must_use]
177 pub fn skip_checking(mut self, skip: bool) -> Self {
178 self.skip_checking = skip;
179 self
180 }
181}
182
183struct TorrentEntry {
193 handle: TorrentHandle,
194 queue_position: i32,
196 auto_managed: bool,
198 started_at: Option<tokio::time::Instant>,
200 smoothed_download_rate: f64,
202 smoothed_upload_rate: f64,
204}
205
206struct PreparedAddTorrent {
223 handle: TorrentHandle,
224 info_hash: Id20,
225 is_private: bool,
226 m170_post: Option<M170PostAdd>,
230}
231
232struct M170PostAdd {
236 category: Option<String>,
237 paused: bool,
238}
239
240struct AddTorrentPrepBundle {
247 torrent_meta: irontide_core::TorrentMeta,
248 storage_override: Option<Arc<dyn TorrentStorage>>,
249 torrent_config: TorrentConfig,
250 disk_manager: crate::disk::DiskManagerHandle,
251 dht_v4_broadcast: irontide_dht::DhtBroadcast,
252 dht_v6_broadcast: irontide_dht::DhtBroadcast,
253 global_up: Option<SharedBucket>,
254 global_down: Option<SharedBucket>,
255 slot_tuner: crate::slot_tuner::SlotTuner,
256 alert_tx: broadcast::Sender<Alert>,
257 alert_mask: Arc<AtomicU32>,
258 utp_socket: Option<irontide_utp::UtpSocket>,
259 utp_socket_v6: Option<irontide_utp::UtpSocket>,
260 ban_manager: SharedBanManager,
261 ip_filter: SharedIpFilter,
262 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
263 sam_session: Option<Arc<crate::i2p::SamSession>>,
264 ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
265 factory: Arc<crate::transport::NetworkFactory>,
266 hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
267 counters: Arc<crate::stats::SessionCounters>,
268 m170_post: Option<M170PostAdd>,
269}
270
271impl TorrentEntry {
272 async fn is_private(&self) -> bool {
283 match self.handle.get_meta().await {
284 Ok(Some(meta)) => meta.info.private == Some(1),
285 _ => false,
289 }
290 }
291}
292
293enum SessionCommand {
295 AddTorrent {
296 meta: Box<irontide_core::TorrentMeta>,
297 storage: Option<Arc<dyn TorrentStorage>>,
298 download_dir: Option<PathBuf>,
299 reply: oneshot::Sender<crate::Result<Id20>>,
300 },
301 CommitAddTorrent {
310 result: crate::Result<PreparedAddTorrent>,
311 reply: oneshot::Sender<crate::Result<Id20>>,
312 },
313 AddMagnet {
314 magnet: Magnet,
315 download_dir: Option<PathBuf>,
316 reply: oneshot::Sender<crate::Result<Id20>>,
317 },
318 RemoveTorrent {
319 info_hash: Id20,
320 reply: oneshot::Sender<crate::Result<()>>,
321 },
322 PauseTorrent {
323 info_hash: Id20,
324 reply: oneshot::Sender<crate::Result<()>>,
325 },
326 ResumeTorrent {
327 info_hash: Id20,
328 reply: oneshot::Sender<crate::Result<()>>,
329 },
330 ForceResumeTorrent {
331 info_hash: Id20,
332 reply: oneshot::Sender<crate::Result<()>>,
333 },
334 SetTorrentSeedRatio {
335 info_hash: Id20,
336 limit: Option<f64>,
337 reply: oneshot::Sender<crate::Result<()>>,
338 },
339 TorrentStats {
340 info_hash: Id20,
341 reply: oneshot::Sender<crate::Result<TorrentStats>>,
342 },
343 TorrentInfo {
344 info_hash: Id20,
345 reply: oneshot::Sender<crate::Result<TorrentInfo>>,
346 },
347 ListTorrents {
348 reply: oneshot::Sender<Vec<Id20>>,
349 },
350 SessionStats {
351 reply: oneshot::Sender<SessionStats>,
352 },
353 SaveTorrentResumeData {
354 info_hash: Id20,
355 reply: oneshot::Sender<crate::Result<irontide_core::FastResumeData>>,
356 },
357 SaveSessionState {
358 reply: oneshot::Sender<crate::Result<crate::persistence::SessionState>>,
359 },
360 LoadResumeState {
362 reply: oneshot::Sender<crate::Result<ResumeLoadResult>>,
363 },
364 QueuePosition {
365 info_hash: Id20,
366 reply: oneshot::Sender<crate::Result<i32>>,
367 },
368 SetQueuePosition {
369 info_hash: Id20,
370 pos: i32,
371 reply: oneshot::Sender<crate::Result<()>>,
372 },
373 QueuePositionUp {
374 info_hash: Id20,
375 reply: oneshot::Sender<crate::Result<()>>,
376 },
377 QueuePositionDown {
378 info_hash: Id20,
379 reply: oneshot::Sender<crate::Result<()>>,
380 },
381 QueuePositionTop {
382 info_hash: Id20,
383 reply: oneshot::Sender<crate::Result<()>>,
384 },
385 QueuePositionBottom {
386 info_hash: Id20,
387 reply: oneshot::Sender<crate::Result<()>>,
388 },
389 BanPeer {
390 ip: IpAddr,
391 reply: oneshot::Sender<()>,
392 },
393 UnbanPeer {
394 ip: IpAddr,
395 reply: oneshot::Sender<bool>,
396 },
397 BannedPeers {
398 reply: oneshot::Sender<Vec<IpAddr>>,
399 },
400 SetIpFilter {
401 filter: crate::ip_filter::IpFilter,
402 reply: oneshot::Sender<()>,
403 },
404 GetIpFilter {
405 reply: oneshot::Sender<crate::ip_filter::IpFilter>,
406 },
407 GetSettings {
408 reply: oneshot::Sender<Settings>,
409 },
410 ApplySettings {
411 settings: Box<Settings>,
412 reply: oneshot::Sender<crate::Result<()>>,
413 },
414 MoveTorrentStorage {
415 info_hash: Id20,
416 new_path: std::path::PathBuf,
417 reply: oneshot::Sender<crate::Result<()>>,
418 },
419 AddPeers {
420 info_hash: Id20,
421 peers: Vec<SocketAddr>,
422 source: crate::peer_state::PeerSource,
423 reply: oneshot::Sender<crate::Result<()>>,
424 },
425 OpenFile {
426 info_hash: Id20,
427 file_index: usize,
428 reply: oneshot::Sender<crate::Result<crate::streaming::FileStream>>,
429 },
430 ForceReannounce {
431 info_hash: Id20,
432 reply: oneshot::Sender<crate::Result<()>>,
433 },
434 TrackerList {
435 info_hash: Id20,
436 reply: oneshot::Sender<crate::Result<Vec<crate::tracker_manager::TrackerInfo>>>,
437 },
438 GetPeerSourceCounts {
442 info_hash: Id20,
443 reply: oneshot::Sender<crate::Result<(usize, usize)>>,
444 },
445 QueryUnchokeDurations {
449 info_hash: Id20,
450 reply: oneshot::Sender<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>>,
451 },
452 GetWebSeedStats {
455 info_hash: Id20,
456 reply: oneshot::Sender<crate::Result<Vec<irontide_core::WebSeedStats>>>,
457 },
458 Scrape {
459 info_hash: Id20,
460 reply: oneshot::Sender<crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>>>,
461 },
462 SetFilePriority {
463 info_hash: Id20,
464 index: usize,
465 priority: irontide_core::FilePriority,
466 reply: oneshot::Sender<crate::Result<()>>,
467 },
468 FilePriorities {
469 info_hash: Id20,
470 reply: oneshot::Sender<crate::Result<Vec<irontide_core::FilePriority>>>,
471 },
472 SetDownloadLimit {
473 info_hash: Id20,
474 bytes_per_sec: u64,
475 reply: oneshot::Sender<crate::Result<()>>,
476 },
477 SetUploadLimit {
478 info_hash: Id20,
479 bytes_per_sec: u64,
480 reply: oneshot::Sender<crate::Result<()>>,
481 },
482 DownloadLimit {
483 info_hash: Id20,
484 reply: oneshot::Sender<crate::Result<u64>>,
485 },
486 UploadLimit {
487 info_hash: Id20,
488 reply: oneshot::Sender<crate::Result<u64>>,
489 },
490 SetSequentialDownload {
491 info_hash: Id20,
492 enabled: bool,
493 reply: oneshot::Sender<crate::Result<()>>,
494 },
495 IsSequentialDownload {
496 info_hash: Id20,
497 reply: oneshot::Sender<crate::Result<bool>>,
498 },
499 SetSuperSeeding {
500 info_hash: Id20,
501 enabled: bool,
502 reply: oneshot::Sender<crate::Result<()>>,
503 },
504 IsSuperSeeding {
505 info_hash: Id20,
506 reply: oneshot::Sender<crate::Result<bool>>,
507 },
508 SetSeedMode {
510 info_hash: Id20,
511 enabled: bool,
512 reply: oneshot::Sender<crate::Result<()>>,
513 },
514 AddTracker {
515 info_hash: Id20,
516 url: String,
517 reply: oneshot::Sender<crate::Result<()>>,
518 },
519 ReplaceTrackers {
520 info_hash: Id20,
521 urls: Vec<String>,
522 reply: oneshot::Sender<crate::Result<()>>,
523 },
524 ForceRecheck {
526 info_hash: Id20,
527 reply: oneshot::Sender<crate::Result<()>>,
528 },
529 RenameFile {
531 info_hash: Id20,
532 file_index: usize,
533 new_name: String,
534 reply: oneshot::Sender<crate::Result<()>>,
535 },
536 SetMaxConnections {
538 info_hash: Id20,
539 limit: usize,
540 reply: oneshot::Sender<crate::Result<()>>,
541 },
542 MaxConnections {
544 info_hash: Id20,
545 reply: oneshot::Sender<crate::Result<usize>>,
546 },
547 SetMaxUploads {
549 info_hash: Id20,
550 limit: usize,
551 reply: oneshot::Sender<crate::Result<()>>,
552 },
553 MaxUploads {
555 info_hash: Id20,
556 reply: oneshot::Sender<crate::Result<usize>>,
557 },
558 GetPeerInfo {
560 info_hash: Id20,
561 reply: oneshot::Sender<crate::Result<Vec<crate::types::PeerInfo>>>,
562 },
563 GetDownloadQueue {
565 info_hash: Id20,
566 reply: oneshot::Sender<crate::Result<Vec<crate::types::PartialPieceInfo>>>,
567 },
568 HavePiece {
570 info_hash: Id20,
571 index: u32,
572 reply: oneshot::Sender<crate::Result<bool>>,
573 },
574 PieceAvailability {
576 info_hash: Id20,
577 reply: oneshot::Sender<crate::Result<Vec<u32>>>,
578 },
579 FileProgress {
581 info_hash: Id20,
582 reply: oneshot::Sender<crate::Result<Vec<u64>>>,
583 },
584 InfoHashesQuery {
586 info_hash: Id20,
587 reply: oneshot::Sender<crate::Result<irontide_core::InfoHashes>>,
588 },
589 TorrentFile {
591 info_hash: Id20,
592 reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV1>>>,
593 },
594 TorrentFileV2 {
596 info_hash: Id20,
597 reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV2>>>,
598 },
599 ForceDhtAnnounce {
601 info_hash: Id20,
602 reply: oneshot::Sender<crate::Result<()>>,
603 },
604 ForceLsdAnnounce {
606 info_hash: Id20,
607 reply: oneshot::Sender<crate::Result<()>>,
608 },
609 ReadPiece {
611 info_hash: Id20,
612 index: u32,
613 reply: oneshot::Sender<crate::Result<bytes::Bytes>>,
614 },
615 FlushCache {
617 info_hash: Id20,
618 reply: oneshot::Sender<crate::Result<()>>,
619 },
620 IsValid {
622 info_hash: Id20,
623 reply: oneshot::Sender<bool>,
624 },
625 ClearError {
627 info_hash: Id20,
628 reply: oneshot::Sender<crate::Result<()>>,
629 },
630 FileStatus {
632 info_hash: Id20,
633 reply: oneshot::Sender<crate::Result<Vec<crate::types::FileStatus>>>,
634 },
635 Flags {
637 info_hash: Id20,
638 reply: oneshot::Sender<crate::Result<crate::types::TorrentFlags>>,
639 },
640 SetFlags {
642 info_hash: Id20,
643 flags: crate::types::TorrentFlags,
644 reply: oneshot::Sender<crate::Result<()>>,
645 },
646 UnsetFlags {
648 info_hash: Id20,
649 flags: crate::types::TorrentFlags,
650 reply: oneshot::Sender<crate::Result<()>>,
651 },
652 ConnectPeer {
654 info_hash: Id20,
655 addr: SocketAddr,
656 reply: oneshot::Sender<crate::Result<()>>,
657 },
658 DhtPutImmutable {
659 value: Vec<u8>,
660 reply: oneshot::Sender<crate::Result<Id20>>,
661 },
662 DhtGetImmutable {
663 target: Id20,
664 reply: oneshot::Sender<crate::Result<Option<Vec<u8>>>>,
665 },
666 DhtPutMutable {
667 keypair_bytes: [u8; 32],
668 value: Vec<u8>,
669 seq: i64,
670 salt: Vec<u8>,
671 reply: oneshot::Sender<crate::Result<Id20>>,
672 },
673 #[allow(clippy::type_complexity)]
674 DhtGetMutable {
675 public_key: [u8; 32],
676 salt: Vec<u8>,
677 reply: oneshot::Sender<crate::Result<Option<(Vec<u8>, i64)>>>,
678 },
679 SaveResumeState {
681 reply: oneshot::Sender<crate::Result<usize>>,
682 },
683 PostSessionStats,
685 AddTorrentM170 {
688 params: Box<AddTorrentParams>,
689 reply: oneshot::Sender<crate::Result<Id20>>,
690 },
691 CreateCategory {
693 name: String,
694 save_path: PathBuf,
695 reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
696 },
697 EditCategory {
699 name: String,
700 save_path: PathBuf,
701 reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
702 },
703 RemoveCategories {
705 names: Vec<String>,
706 reply: oneshot::Sender<Vec<String>>,
707 },
708 ListCategories {
710 reply: oneshot::Sender<Vec<crate::category_manager::CategoryMetadata>>,
711 },
712 CreateTags {
715 names: Vec<String>,
716 reply: oneshot::Sender<Vec<Result<(), crate::tag_manager::TagError>>>,
717 },
718 DeleteTags {
721 names: Vec<String>,
722 reply: oneshot::Sender<Vec<String>>,
723 },
724 ListTags {
726 reply: oneshot::Sender<Vec<String>>,
727 },
728 AddTagsToTorrents {
734 info_hashes: Vec<Id20>,
735 tags: Vec<String>,
736 reply: oneshot::Sender<crate::Result<()>>,
737 },
738 RemoveTagsFromTorrents {
741 info_hashes: Vec<Id20>,
742 tags: Vec<String>,
743 reply: oneshot::Sender<crate::Result<()>>,
744 },
745 RemoveTorrentWithFiles {
748 info_hash: Id20,
749 reply: oneshot::Sender<crate::Result<()>>,
750 },
751 GetWebSeeds {
754 info_hash: Id20,
755 reply: oneshot::Sender<crate::Result<Vec<String>>>,
756 },
757 GetPieceStates {
760 info_hash: Id20,
761 reply: oneshot::Sender<crate::Result<Vec<u8>>>,
762 },
763 GetPieceHashes {
765 info_hash: Id20,
766 offset: u32,
767 limit: u32,
768 reply: oneshot::Sender<crate::Result<Vec<String>>>,
769 },
770 DhtNodeCount {
773 reply: oneshot::Sender<usize>,
774 },
775 DebugState {
781 reply: oneshot::Sender<crate::types::DebugState>,
782 },
783 #[cfg(feature = "test-util")]
784 TestInjectMetadata {
785 info_hash: Id20,
786 info_bytes: Vec<u8>,
787 reply: oneshot::Sender<crate::Result<()>>,
788 },
789 Shutdown,
790}
791
792impl SessionCommand {
793 fn name(&self) -> &'static str {
798 match self {
799 Self::AddTorrent { .. } => "AddTorrent",
800 Self::CommitAddTorrent { .. } => "CommitAddTorrent",
801 Self::AddMagnet { .. } => "AddMagnet",
802 Self::RemoveTorrent { .. } => "RemoveTorrent",
803 Self::PauseTorrent { .. } => "PauseTorrent",
804 Self::ResumeTorrent { .. } => "ResumeTorrent",
805 Self::ForceResumeTorrent { .. } => "ForceResumeTorrent",
806 Self::SetTorrentSeedRatio { .. } => "SetTorrentSeedRatio",
807 Self::TorrentStats { .. } => "TorrentStats",
808 Self::TorrentInfo { .. } => "TorrentInfo",
809 Self::ListTorrents { .. } => "ListTorrents",
810 Self::SessionStats { .. } => "SessionStats",
811 Self::SaveTorrentResumeData { .. } => "SaveTorrentResumeData",
812 Self::SaveSessionState { .. } => "SaveSessionState",
813 Self::LoadResumeState { .. } => "LoadResumeState",
814 Self::QueuePosition { .. } => "QueuePosition",
815 Self::SetQueuePosition { .. } => "SetQueuePosition",
816 Self::QueuePositionUp { .. } => "QueuePositionUp",
817 Self::QueuePositionDown { .. } => "QueuePositionDown",
818 Self::QueuePositionTop { .. } => "QueuePositionTop",
819 Self::QueuePositionBottom { .. } => "QueuePositionBottom",
820 Self::BanPeer { .. } => "BanPeer",
821 Self::UnbanPeer { .. } => "UnbanPeer",
822 Self::BannedPeers { .. } => "BannedPeers",
823 Self::SetIpFilter { .. } => "SetIpFilter",
824 Self::GetIpFilter { .. } => "GetIpFilter",
825 Self::GetSettings { .. } => "GetSettings",
826 Self::ApplySettings { .. } => "ApplySettings",
827 Self::MoveTorrentStorage { .. } => "MoveTorrentStorage",
828 Self::AddPeers { .. } => "AddPeers",
829 Self::OpenFile { .. } => "OpenFile",
830 Self::ForceReannounce { .. } => "ForceReannounce",
831 Self::TrackerList { .. } => "TrackerList",
832 Self::GetPeerSourceCounts { .. } => "GetPeerSourceCounts",
833 Self::QueryUnchokeDurations { .. } => "QueryUnchokeDurations",
834 Self::GetWebSeedStats { .. } => "GetWebSeedStats",
835 Self::Scrape { .. } => "Scrape",
836 Self::SetFilePriority { .. } => "SetFilePriority",
837 Self::FilePriorities { .. } => "FilePriorities",
838 Self::SetDownloadLimit { .. } => "SetDownloadLimit",
839 Self::SetUploadLimit { .. } => "SetUploadLimit",
840 Self::DownloadLimit { .. } => "DownloadLimit",
841 Self::UploadLimit { .. } => "UploadLimit",
842 Self::SetSequentialDownload { .. } => "SetSequentialDownload",
843 Self::IsSequentialDownload { .. } => "IsSequentialDownload",
844 Self::SetSuperSeeding { .. } => "SetSuperSeeding",
845 Self::IsSuperSeeding { .. } => "IsSuperSeeding",
846 Self::SetSeedMode { .. } => "SetSeedMode",
847 Self::AddTracker { .. } => "AddTracker",
848 Self::ReplaceTrackers { .. } => "ReplaceTrackers",
849 Self::ForceRecheck { .. } => "ForceRecheck",
850 Self::RenameFile { .. } => "RenameFile",
851 Self::SetMaxConnections { .. } => "SetMaxConnections",
852 Self::MaxConnections { .. } => "MaxConnections",
853 Self::SetMaxUploads { .. } => "SetMaxUploads",
854 Self::MaxUploads { .. } => "MaxUploads",
855 Self::GetPeerInfo { .. } => "GetPeerInfo",
856 Self::GetDownloadQueue { .. } => "GetDownloadQueue",
857 Self::HavePiece { .. } => "HavePiece",
858 Self::PieceAvailability { .. } => "PieceAvailability",
859 Self::FileProgress { .. } => "FileProgress",
860 Self::InfoHashesQuery { .. } => "InfoHashesQuery",
861 Self::TorrentFile { .. } => "TorrentFile",
862 Self::TorrentFileV2 { .. } => "TorrentFileV2",
863 Self::ForceDhtAnnounce { .. } => "ForceDhtAnnounce",
864 Self::ForceLsdAnnounce { .. } => "ForceLsdAnnounce",
865 Self::ReadPiece { .. } => "ReadPiece",
866 Self::FlushCache { .. } => "FlushCache",
867 Self::IsValid { .. } => "IsValid",
868 Self::ClearError { .. } => "ClearError",
869 Self::FileStatus { .. } => "FileStatus",
870 Self::Flags { .. } => "Flags",
871 Self::SetFlags { .. } => "SetFlags",
872 Self::UnsetFlags { .. } => "UnsetFlags",
873 Self::ConnectPeer { .. } => "ConnectPeer",
874 Self::DhtPutImmutable { .. } => "DhtPutImmutable",
875 Self::DhtGetImmutable { .. } => "DhtGetImmutable",
876 Self::DhtPutMutable { .. } => "DhtPutMutable",
877 Self::DhtGetMutable { .. } => "DhtGetMutable",
878 Self::SaveResumeState { .. } => "SaveResumeState",
879 Self::PostSessionStats => "PostSessionStats",
880 Self::AddTorrentM170 { .. } => "AddTorrentM170",
881 Self::CreateCategory { .. } => "CreateCategory",
882 Self::EditCategory { .. } => "EditCategory",
883 Self::RemoveCategories { .. } => "RemoveCategories",
884 Self::ListCategories { .. } => "ListCategories",
885 Self::CreateTags { .. } => "CreateTags",
886 Self::DeleteTags { .. } => "DeleteTags",
887 Self::ListTags { .. } => "ListTags",
888 Self::AddTagsToTorrents { .. } => "AddTagsToTorrents",
889 Self::RemoveTagsFromTorrents { .. } => "RemoveTagsFromTorrents",
890 Self::RemoveTorrentWithFiles { .. } => "RemoveTorrentWithFiles",
891 Self::GetWebSeeds { .. } => "GetWebSeeds",
892 Self::GetPieceStates { .. } => "GetPieceStates",
893 Self::GetPieceHashes { .. } => "GetPieceHashes",
894 Self::DhtNodeCount { .. } => "DhtNodeCount",
895 Self::DebugState { .. } => "DebugState",
896 #[cfg(feature = "test-util")]
897 Self::TestInjectMetadata { .. } => "TestInjectMetadata",
898 Self::Shutdown => "Shutdown",
899 }
900 }
901}
902
903#[derive(Clone)]
908struct SessionCmdSender(mpsc::Sender<(tokio::time::Instant, SessionCommand)>);
909
910impl SessionCmdSender {
911 async fn send(
912 &self,
913 cmd: SessionCommand,
914 ) -> Result<(), mpsc::error::SendError<SessionCommand>> {
915 let sent_at = tokio::time::Instant::now();
916 self.0
917 .send((sent_at, cmd))
918 .await
919 .map_err(|e| mpsc::error::SendError(e.0.1))
920 }
921}
922
923#[derive(Debug, Clone, Default)]
931pub struct AppliedSettings {
932 pub immediate: Vec<&'static str>,
934 pub restart_required: Vec<&'static str>,
938}
939
940macro_rules! push_if {
970 (immediate, immediate, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {
971 if $o.$($acc)* != $n.$($acc)* { $v.push($wire); }
972 };
973 (restart, restart, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {
974 if $o.$($acc)* != $n.$($acc)* { $v.push($wire); }
975 };
976 ($t:tt, $c:tt, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {};
977}
978
979macro_rules! emit_immediate_top {
982 ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
983 fn append_immediate_top(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
984 $( push_if!(immediate, $class, o, n, ($name), $wire, v); )*
985 }
986 };
987}
988irontide_settings::for_each_setting!(emit_immediate_top);
989
990macro_rules! emit_immediate_qbt {
991 ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
992 fn append_immediate_qbt(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
993 $( push_if!(immediate, $class, o, n, (qbt_compat . $name), $wire, v); )*
994 }
995 };
996}
997irontide_settings::for_each_qbt_compat_setting!(emit_immediate_qbt);
998
999macro_rules! emit_restart_top {
1000 ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1001 fn append_restart_top(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1002 $( push_if!(restart, $class, o, n, ($name), $wire, v); )*
1003 }
1004 };
1005}
1006irontide_settings::for_each_setting!(emit_restart_top);
1007
1008macro_rules! emit_restart_qbt {
1009 ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1010 fn append_restart_qbt(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1011 $( push_if!(restart, $class, o, n, (qbt_compat . $name), $wire, v); )*
1012 }
1013 };
1014}
1015irontide_settings::for_each_qbt_compat_setting!(emit_restart_qbt);
1016
1017macro_rules! emit_restart_proxy {
1018 ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1019 fn append_restart_proxy(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1020 $( push_if!(restart, $class, o, n, (proxy . $name), $wire, v); )*
1021 }
1022 };
1023}
1024irontide_settings::for_each_proxy_setting!(emit_restart_proxy);
1025
1026fn classify_immediate(old: &Settings, new: &Settings) -> Vec<&'static str> {
1050 let mut v = Vec::new();
1051 append_immediate_top(old, new, &mut v);
1052 append_immediate_qbt(old, new, &mut v);
1053 v
1054}
1055
1056fn classify_restart_required(old: &Settings, new: &Settings) -> Vec<&'static str> {
1073 let mut v = Vec::new();
1074 append_restart_top(old, new, &mut v);
1075 append_restart_qbt(old, new, &mut v);
1076 append_restart_proxy(old, new, &mut v);
1077 v
1078}
1079
1080#[derive(Clone)]
1082pub struct SessionHandle {
1083 cmd_tx: SessionCmdSender,
1084 alert_tx: broadcast::Sender<Alert>,
1085 alert_mask: Arc<AtomicU32>,
1086 counters: Arc<crate::stats::SessionCounters>,
1087 #[allow(dead_code)]
1089 factory: Arc<crate::transport::NetworkFactory>,
1090 reconfig_in_flight: crate::apply::ReconfigInFlight,
1098 snapshot: Arc<arc_swap::ArcSwap<SessionSnapshot>>,
1105}
1106
1107impl SessionHandle {
1108 pub async fn start(settings: Settings) -> crate::Result<Self> {
1114 Self::start_with_plugins(settings, Arc::new(Vec::new())).await
1115 }
1116
1117 pub async fn start_with_backend(
1123 settings: Settings,
1124 backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1125 ) -> crate::Result<Self> {
1126 Self::start_with_plugins_and_backend(settings, Arc::new(Vec::new()), backend).await
1127 }
1128
1129 pub async fn start_with_plugins(
1135 settings: Settings,
1136 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1137 ) -> crate::Result<Self> {
1138 let disk_config = crate::disk::DiskConfig::from(&settings);
1139 let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1140 Self::start_with_plugins_and_backend(settings, plugins, backend).await
1141 }
1142
1143 pub async fn start_with_plugins_and_backend(
1150 settings: Settings,
1151 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1152 backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1153 ) -> crate::Result<Self> {
1154 Self::start_full(
1155 settings,
1156 plugins,
1157 backend,
1158 Arc::new(crate::transport::NetworkFactory::tokio()),
1159 )
1160 .await
1161 }
1162
1163 pub async fn start_with_transport(
1171 settings: Settings,
1172 factory: Arc<crate::transport::NetworkFactory>,
1173 ) -> crate::Result<Self> {
1174 let disk_config = crate::disk::DiskConfig::from(&settings);
1175 let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1176 Self::start_full(settings, Arc::new(Vec::new()), backend, factory).await
1177 }
1178
1179 pub async fn start_full(
1190 settings: Settings,
1191 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1192 backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1193 factory: Arc<crate::transport::NetworkFactory>,
1194 ) -> crate::Result<Self> {
1195 let mut settings = settings;
1196
1197 if settings.force_proxy {
1199 if settings.proxy.proxy_type == crate::proxy::ProxyType::None {
1200 return Err(crate::Error::Config(
1201 "force_proxy requires a proxy to be configured".into(),
1202 ));
1203 }
1204 settings.enable_upnp = false;
1205 settings.enable_natpmp = false;
1206 settings.enable_dht = false;
1207 settings.enable_lsd = false;
1208 }
1209
1210 if settings.anonymous_mode {
1212 settings.enable_dht = false;
1213 settings.enable_lsd = false;
1214 settings.enable_upnp = false;
1215 settings.enable_natpmp = false;
1216 }
1217
1218 match irontide_settings::migrate_qbt_credentials(&mut settings.qbt_compat) {
1228 Ok(irontide_settings::QbtCredentialMigration::Upgraded) => {
1229 warn!(
1230 "qbt_compat: legacy plaintext password migrated to argon2id in memory — \
1231 persist via `irontide_config::migrate_qbt_credentials_in_file` or the \
1232 next config-touching CLI command to remove the plaintext from disk"
1233 );
1234 }
1235 Ok(irontide_settings::QbtCredentialMigration::NoOp) => {}
1236 Err(e) => {
1237 warn!(
1238 error = %e,
1239 "qbt_compat: in-memory password migration failed — continuing with \
1240 legacy plaintext; retry on next daemon start"
1241 );
1242 }
1243 }
1244
1245 let (raw_cmd_tx, cmd_rx) = mpsc::channel::<(tokio::time::Instant, SessionCommand)>(256);
1246 let cmd_tx = SessionCmdSender(raw_cmd_tx);
1247
1248 let (alert_tx, _) = broadcast::channel(settings.alert_channel_size);
1250 let alert_mask = Arc::new(AtomicU32::new(settings.alert_mask.bits()));
1251
1252 let (notification_settings_tx, notification_settings_rx) =
1262 tokio::sync::watch::channel(settings.clone());
1263 let (notification_shutdown_tx, notification_shutdown_rx) = oneshot::channel::<()>();
1264 let _notification_dispatcher_handle = crate::notification::spawn_notification_dispatcher(
1265 crate::notification::DispatcherOptions {
1266 sink: Box::new(crate::notification::LibNotifySink::new()),
1267 settings_rx: notification_settings_rx,
1268 alerts_rx: alert_tx.subscribe(),
1269 shutdown_rx: notification_shutdown_rx,
1270 },
1271 );
1272
1273 let watched_folder_changed = Arc::new(tokio::sync::Notify::new());
1279 let (watched_folder_shutdown_tx, watched_folder_shutdown_rx) = oneshot::channel::<()>();
1280 let watched_folder_settings_rx = notification_settings_tx.subscribe();
1284
1285 let (lsd, lsd_peers_rx) = if settings.enable_lsd {
1286 match crate::lsd::LsdHandle::start(settings.listen_port, settings.enable_ipv6).await {
1287 Ok((handle, rx)) => (Some(handle), Some(rx)),
1288 Err(e) => {
1289 warn!("LSD unavailable (port 6771): {e}");
1290 (None, None)
1291 }
1292 }
1293 } else {
1294 (None, None)
1295 };
1296
1297 let global_upload_bucket = Arc::new(parking_lot::Mutex::new(
1298 crate::rate_limiter::TokenBucket::new(settings.upload_rate_limit),
1299 ));
1300 let global_download_bucket = Arc::new(parking_lot::Mutex::new(
1301 crate::rate_limiter::TokenBucket::new(settings.download_rate_limit),
1302 ));
1303
1304 let ip_filter: SharedIpFilter =
1312 Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
1313 let max_connections_global = Arc::new(std::sync::atomic::AtomicI32::new(
1314 settings.max_connections_global,
1315 ));
1316 let live_connections = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1317
1318 let utp_admit = {
1319 let ip_filter_for_utp = Arc::clone(&ip_filter);
1320 irontide_utp::AdmitGate::new(
1321 Arc::clone(&max_connections_global),
1322 Arc::clone(&live_connections),
1323 Arc::new(move |addr| ip_filter_for_utp.read().is_blocked(addr)),
1324 )
1325 };
1326
1327 let (utp_socket, utp_listener) = if settings.enable_utp {
1336 let utp_config = settings.to_utp_config(settings.listen_port);
1337 let bind_addr = utp_config.bind_addr;
1338 let result = if factory.has_bind_udp() {
1339 match factory.bind_udp(bind_addr).await {
1340 Ok(transport) => irontide_utp::UtpSocket::bind_with_transport_and_admit_gate(
1341 transport,
1342 utp_config,
1343 utp_admit.clone(),
1344 ),
1345 Err(e) => Err(irontide_utp::Error::Io(e)),
1346 }
1347 } else {
1348 irontide_utp::UtpSocket::bind_with_admit_gate(utp_config, utp_admit.clone()).await
1349 };
1350 match result {
1351 Ok((socket, listener)) => (Some(socket), Some(listener)),
1352 Err(e) => {
1353 warn!("uTP bind failed: {e}");
1354 (None, None)
1355 }
1356 }
1357 } else {
1358 (None, None)
1359 };
1360
1361 let (utp_socket_v6, utp_listener_v6) =
1364 if settings.enable_utp && settings.enable_ipv6 && !factory.has_bind_udp() {
1365 match irontide_utp::UtpSocket::bind_with_admit_gate(
1366 settings.to_utp_config_v6(settings.listen_port),
1367 utp_admit.clone(),
1368 )
1369 .await
1370 {
1371 Ok((socket, listener)) => (Some(socket), Some(listener)),
1372 Err(e) => {
1373 debug!("uTP IPv6 bind failed (non-fatal): {e}");
1374 (None, None)
1375 }
1376 }
1377 } else {
1378 (None, None)
1379 };
1380
1381 let (nat, nat_events_rx) = if settings.enable_upnp || settings.enable_natpmp {
1383 let nat_config = settings.to_nat_config();
1384 let (handle, events_rx) = irontide_nat::NatHandle::start(nat_config);
1385 let udp_port = if settings.enable_utp {
1386 Some(settings.listen_port)
1387 } else {
1388 None
1389 };
1390 handle.map_ports(settings.listen_port, udp_port).await;
1391 (Some(handle), Some(events_rx))
1392 } else {
1393 (None, None)
1394 };
1395
1396 let sam_session = if settings.enable_i2p {
1398 let tunnel_config = settings.to_sam_tunnel_config();
1399 match crate::i2p::SamSession::create(
1400 &settings.i2p_hostname,
1401 settings.i2p_port,
1402 "torrent",
1403 tunnel_config,
1404 )
1405 .await
1406 {
1407 Ok(session) => {
1408 let b32 = session.destination().to_b32_address();
1409 info!("I2P SAM session created: {}", b32);
1410 post_alert(
1411 &alert_tx,
1412 &alert_mask,
1413 AlertKind::I2pSessionCreated { b32_address: b32 },
1414 );
1415 Some(Arc::new(session))
1416 }
1417 Err(e) => {
1418 warn!("I2P SAM session failed: {e}");
1419 post_alert(
1420 &alert_tx,
1421 &alert_mask,
1422 AlertKind::I2pError {
1423 message: format!("SAM session creation failed: {e}"),
1424 },
1425 );
1426 None
1427 }
1428 }
1429 } else {
1430 None
1431 };
1432
1433 let ssl_manager = if settings.ssl_listen_port != 0 || settings.ssl_cert_path.is_some() {
1435 match crate::ssl_manager::SslManager::new(&settings) {
1436 Ok(mgr) => {
1437 info!("SSL manager initialized");
1438 Some(Arc::new(mgr))
1439 }
1440 Err(e) => {
1441 warn!(error = %e, "SSL manager initialization failed");
1442 None
1443 }
1444 }
1445 } else {
1446 None
1447 };
1448
1449 let tcp_listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
1451 .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.listen_port)))
1452 .await
1453 {
1454 Ok(l) => {
1455 info!(port = settings.listen_port, "TCP listener started");
1456 Some(l)
1457 }
1458 Err(e) => {
1459 warn!(port = settings.listen_port, error = %e, "TCP listener bind failed");
1460 None
1461 }
1462 };
1463
1464 let ssl_listener: Option<Box<dyn crate::transport::TransportListener>> = if settings
1466 .ssl_listen_port
1467 != 0
1468 {
1469 match factory
1470 .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.ssl_listen_port)))
1471 .await
1472 {
1473 Ok(l) => {
1474 info!(port = settings.ssl_listen_port, "SSL listener started");
1475 Some(l)
1476 }
1477 Err(e) => {
1478 warn!(port = settings.ssl_listen_port, error = %e, "SSL listener bind failed");
1479 None
1480 }
1481 }
1482 } else {
1483 None
1484 };
1485
1486 let (dht_v4, dht_v4_ip_rx) = if settings.enable_dht {
1488 match DhtHandle::start(settings.to_dht_config()).await {
1489 Ok((handle, ip_rx)) => {
1490 info!("DHT v4 started");
1491 (Some(handle), Some(ip_rx))
1492 }
1493 Err(e) => {
1494 warn!("DHT v4 start failed: {e}");
1495 (None, None)
1496 }
1497 }
1498 } else {
1499 (None, None)
1500 };
1501
1502 let (dht_v6, dht_v6_ip_rx) = if settings.enable_dht && settings.enable_ipv6 {
1503 match DhtHandle::start(settings.to_dht_config_v6()).await {
1504 Ok((handle, ip_rx)) => {
1505 info!("DHT v6 started");
1506 (Some(handle), Some(ip_rx))
1507 }
1508 Err(e) => {
1509 debug!("DHT v6 start failed (non-fatal): {e}");
1510 (None, None)
1511 }
1512 }
1513 } else {
1514 (None, None)
1515 };
1516
1517 let dht_v4_broadcast = irontide_dht::DhtBroadcast::new(dht_v4.clone());
1523 let dht_v6_broadcast = irontide_dht::DhtBroadcast::new(dht_v6.clone());
1524
1525 let ban_config = crate::ban::BanConfig::from(&settings);
1526 let ban_manager: SharedBanManager = Arc::new(parking_lot::RwLock::new(
1527 crate::ban::BanManager::new(ban_config),
1528 ));
1529
1530 let disk_config = crate::disk::DiskConfig::from(&settings);
1534 let spawner = crate::blocking_spawner::BlockingSpawner::new(settings.max_blocking_threads);
1535 let (disk_manager, disk_actor_handle) =
1536 crate::disk::DiskManagerHandle::new_with_backend(disk_config, backend, spawner);
1537
1538 let counters = Arc::new(crate::stats::SessionCounters::new_with_diagnostics(
1539 settings.enable_diagnostic_counters,
1540 ));
1541
1542 let hash_pool = std::sync::Arc::new(crate::hash_pool::HashPool::new(
1544 settings.hashing_threads,
1545 64,
1546 ));
1547
1548 let info_hash_registry = Arc::new(DashMap::new());
1550 let (validated_tx, validated_conn_rx) = mpsc::channel(64);
1551 let listener_task = crate::listener::ListenerTask::new(
1556 tcp_listener,
1557 utp_listener,
1558 utp_listener_v6,
1559 Arc::clone(&info_hash_registry),
1560 validated_tx,
1561 Arc::clone(&max_connections_global),
1562 Arc::clone(&live_connections),
1563 );
1564 let listener_handle = crate::listener::ListenerHandle::spawn(listener_task);
1573
1574 let external_ip = settings.external_ip;
1575
1576 let category_registry_path = crate::category_manager::resolve_category_registry_path(
1580 settings.category_registry_path.as_deref(),
1581 );
1582 let category_registry = Arc::new(parking_lot::RwLock::new(
1583 crate::category_manager::CategoryRegistry::load(category_registry_path),
1584 ));
1585 let tag_registry_path =
1588 crate::tag_manager::resolve_tag_registry_path(settings.tag_registry_path.as_deref());
1589 let tag_registry = Arc::new(parking_lot::RwLock::new(
1590 crate::tag_manager::TagRegistry::load(tag_registry_path),
1591 ));
1592 let deletion_grace = Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new()));
1593 let reconfig_in_flight = crate::apply::ReconfigInFlight::new();
1594 let snapshot = Arc::new(arc_swap::ArcSwap::from_pointee(SessionSnapshot::default()));
1599
1600 let actor = SessionActor {
1601 settings,
1602 commit_tx: cmd_tx.clone(),
1606 torrents: HashMap::new(),
1607 snapshot: Arc::clone(&snapshot),
1608 dht_v4,
1609 dht_v6,
1610 dht_v4_broadcast,
1611 dht_v6_broadcast,
1612 lsd,
1613 lsd_peers_rx,
1614 cmd_rx,
1615 alert_tx: alert_tx.clone(),
1616 alert_mask: Arc::clone(&alert_mask),
1617 global_upload_bucket,
1618 global_download_bucket,
1619 utp_socket,
1620 utp_socket_v6,
1621 nat,
1622 nat_events_rx,
1623 ban_manager,
1624 ip_filter,
1625 disk_manager,
1626 disk_actor_handle,
1627 external_ip,
1628 dht_v4_ip_rx,
1629 dht_v6_ip_rx,
1630 plugins,
1631 sam_session,
1632 ssl_manager,
1633 ssl_listener,
1634 validated_conn_rx,
1635 info_hash_registry,
1636 _listener_task: listener_handle,
1637 max_connections_global,
1638 live_connections,
1639 counters: Arc::clone(&counters),
1640 factory: Arc::clone(&factory),
1641 hash_pool,
1642 category_registry,
1643 tag_registry,
1644 deletion_grace,
1645 reconfig_in_flight: reconfig_in_flight.clone(),
1647 self_alert_rx: alert_tx.subscribe(),
1648 resume_save_notify: Arc::new(tokio::sync::Notify::new()),
1649 resume_save_lock: Arc::new(tokio::sync::Mutex::new(())),
1650 notification_settings_tx,
1651 notification_shutdown_tx,
1652 watched_folder_changed: Arc::clone(&watched_folder_changed),
1653 watched_folder_shutdown_tx,
1654 };
1655
1656 let join_handle = tokio::spawn(actor.run());
1657 tokio::spawn(async move {
1658 match join_handle.await {
1659 Ok(()) => {
1660 tracing::warn!("session actor exited cleanly");
1661 }
1662 Err(e) if e.is_panic() => {
1663 let panic_payload = e.into_panic();
1664 let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
1665 (*s).to_string()
1666 } else if let Some(s) = panic_payload.downcast_ref::<String>() {
1667 s.clone()
1668 } else {
1669 "unknown panic payload".to_string()
1670 };
1671 tracing::error!("session actor PANICKED: {msg}");
1672 }
1673 Err(e) => {
1674 tracing::error!("session actor task error: {e}");
1675 }
1676 }
1677 });
1678 let handle = Self {
1679 cmd_tx,
1680 alert_tx,
1681 alert_mask,
1682 counters,
1683 factory,
1684 reconfig_in_flight,
1690 snapshot,
1692 };
1693
1694 let _watched_folder_join = crate::watched_folder::spawn_watched_folder_dispatcher(
1701 handle.clone(),
1702 watched_folder_settings_rx,
1703 watched_folder_changed,
1704 watched_folder_shutdown_rx,
1705 );
1706
1707 Ok(handle)
1708 }
1709
1710 pub async fn add_torrent_with_meta(
1721 &self,
1722 meta: irontide_core::TorrentMeta,
1723 storage: Option<Arc<dyn TorrentStorage>>,
1724 ) -> crate::Result<Id20> {
1725 self.add_torrent_with_dir(meta, storage, None).await
1726 }
1727
1728 pub async fn add_torrent(&self, params: AddTorrentParams) -> crate::Result<Id20> {
1746 let (tx, rx) = oneshot::channel();
1747 self.cmd_tx
1748 .send(SessionCommand::AddTorrentM170 {
1749 params: Box::new(params),
1750 reply: tx,
1751 })
1752 .await
1753 .map_err(|_| crate::Error::Shutdown)?;
1754 rx.await.map_err(|_| crate::Error::Shutdown)?
1755 }
1756
1757 pub async fn create_category(
1764 &self,
1765 name: String,
1766 save_path: PathBuf,
1767 ) -> Result<(), crate::category_manager::CategoryError> {
1768 let (tx, rx) = oneshot::channel();
1769 if self
1770 .cmd_tx
1771 .send(SessionCommand::CreateCategory {
1772 name,
1773 save_path,
1774 reply: tx,
1775 })
1776 .await
1777 .is_err()
1778 {
1779 return Err(crate::category_manager::CategoryError::Persistence(
1780 std::io::Error::other("session shutting down"),
1781 ));
1782 }
1783 rx.await.unwrap_or_else(|_| {
1784 Err(crate::category_manager::CategoryError::Persistence(
1785 std::io::Error::other("session shutting down"),
1786 ))
1787 })
1788 }
1789
1790 pub async fn edit_category(
1798 &self,
1799 name: String,
1800 save_path: PathBuf,
1801 ) -> Result<(), crate::category_manager::CategoryError> {
1802 let (tx, rx) = oneshot::channel();
1803 if self
1804 .cmd_tx
1805 .send(SessionCommand::EditCategory {
1806 name,
1807 save_path,
1808 reply: tx,
1809 })
1810 .await
1811 .is_err()
1812 {
1813 return Err(crate::category_manager::CategoryError::Persistence(
1814 std::io::Error::other("session shutting down"),
1815 ));
1816 }
1817 rx.await.unwrap_or_else(|_| {
1818 Err(crate::category_manager::CategoryError::Persistence(
1819 std::io::Error::other("session shutting down"),
1820 ))
1821 })
1822 }
1823
1824 pub async fn remove_categories(&self, names: Vec<String>) -> Vec<String> {
1829 let (tx, rx) = oneshot::channel();
1830 if self
1831 .cmd_tx
1832 .send(SessionCommand::RemoveCategories { names, reply: tx })
1833 .await
1834 .is_err()
1835 {
1836 return Vec::new();
1837 }
1838 rx.await.unwrap_or_default()
1839 }
1840
1841 pub async fn list_categories(&self) -> Vec<crate::category_manager::CategoryMetadata> {
1843 let (tx, rx) = oneshot::channel();
1844 if self
1845 .cmd_tx
1846 .send(SessionCommand::ListCategories { reply: tx })
1847 .await
1848 .is_err()
1849 {
1850 return Vec::new();
1851 }
1852 rx.await.unwrap_or_default()
1853 }
1854
1855 pub async fn list_tags(&self) -> Vec<String> {
1857 let (tx, rx) = oneshot::channel();
1858 if self
1859 .cmd_tx
1860 .send(SessionCommand::ListTags { reply: tx })
1861 .await
1862 .is_err()
1863 {
1864 return Vec::new();
1865 }
1866 rx.await.unwrap_or_default()
1867 }
1868
1869 pub async fn create_tags(
1875 &self,
1876 names: Vec<String>,
1877 ) -> Vec<Result<(), crate::tag_manager::TagError>> {
1878 let (tx, rx) = oneshot::channel();
1879 if self
1880 .cmd_tx
1881 .send(SessionCommand::CreateTags { names, reply: tx })
1882 .await
1883 .is_err()
1884 {
1885 return Vec::new();
1886 }
1887 rx.await.unwrap_or_default()
1888 }
1889
1890 pub async fn delete_tags(&self, names: Vec<String>) -> Vec<String> {
1894 let (tx, rx) = oneshot::channel();
1895 if self
1896 .cmd_tx
1897 .send(SessionCommand::DeleteTags { names, reply: tx })
1898 .await
1899 .is_err()
1900 {
1901 return Vec::new();
1902 }
1903 rx.await.unwrap_or_default()
1904 }
1905
1906 pub async fn add_tags_to_torrents(
1915 &self,
1916 hashes: Vec<Id20>,
1917 tags: Vec<String>,
1918 ) -> crate::Result<()> {
1919 let (tx, rx) = oneshot::channel();
1920 self.cmd_tx
1921 .send(SessionCommand::AddTagsToTorrents {
1922 info_hashes: hashes,
1923 tags,
1924 reply: tx,
1925 })
1926 .await
1927 .map_err(|_| crate::Error::Shutdown)?;
1928 rx.await.map_err(|_| crate::Error::Shutdown)?
1929 }
1930
1931 pub async fn remove_tags_from_torrents(
1939 &self,
1940 hashes: Vec<Id20>,
1941 tags: Vec<String>,
1942 ) -> crate::Result<()> {
1943 let (tx, rx) = oneshot::channel();
1944 self.cmd_tx
1945 .send(SessionCommand::RemoveTagsFromTorrents {
1946 info_hashes: hashes,
1947 tags,
1948 reply: tx,
1949 })
1950 .await
1951 .map_err(|_| crate::Error::Shutdown)?;
1952 rx.await.map_err(|_| crate::Error::Shutdown)?
1953 }
1954
1955 pub async fn remove_torrent_with_files(&self, info_hash: Id20) -> crate::Result<()> {
1972 let (tx, rx) = oneshot::channel();
1973 self.cmd_tx
1974 .send(SessionCommand::RemoveTorrentWithFiles {
1975 info_hash,
1976 reply: tx,
1977 })
1978 .await
1979 .map_err(|_| crate::Error::Shutdown)?;
1980 rx.await.map_err(|_| crate::Error::Shutdown)?
1981 }
1982
1983 pub async fn add_torrent_with_dir(
1989 &self,
1990 meta: irontide_core::TorrentMeta,
1991 storage: Option<Arc<dyn TorrentStorage>>,
1992 download_dir: Option<PathBuf>,
1993 ) -> crate::Result<Id20> {
1994 let (tx, rx) = oneshot::channel();
1995 self.cmd_tx
1996 .send(SessionCommand::AddTorrent {
1997 meta: Box::new(meta),
1998 storage,
1999 download_dir,
2000 reply: tx,
2001 })
2002 .await
2003 .map_err(|_| crate::Error::Shutdown)?;
2004 rx.await.map_err(|_| crate::Error::Shutdown)?
2005 }
2006
2007 pub async fn add_magnet(&self, magnet: Magnet) -> crate::Result<Id20> {
2013 self.add_magnet_with_dir(magnet, None).await
2014 }
2015
2016 pub async fn add_magnet_with_dir(
2022 &self,
2023 magnet: Magnet,
2024 download_dir: Option<PathBuf>,
2025 ) -> crate::Result<Id20> {
2026 let (tx, rx) = oneshot::channel();
2027 self.cmd_tx
2028 .send(SessionCommand::AddMagnet {
2029 magnet,
2030 download_dir,
2031 reply: tx,
2032 })
2033 .await
2034 .map_err(|_| crate::Error::Shutdown)?;
2035 rx.await.map_err(|_| crate::Error::Shutdown)?
2036 }
2037
2038 pub async fn remove_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2044 let (tx, rx) = oneshot::channel();
2045 self.cmd_tx
2046 .send(SessionCommand::RemoveTorrent {
2047 info_hash,
2048 reply: tx,
2049 })
2050 .await
2051 .map_err(|_| crate::Error::Shutdown)?;
2052 rx.await.map_err(|_| crate::Error::Shutdown)?
2053 }
2054
2055 pub async fn pause_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2061 let (tx, rx) = oneshot::channel();
2062 self.cmd_tx
2063 .send(SessionCommand::PauseTorrent {
2064 info_hash,
2065 reply: tx,
2066 })
2067 .await
2068 .map_err(|_| crate::Error::Shutdown)?;
2069 rx.await.map_err(|_| crate::Error::Shutdown)?
2070 }
2071
2072 pub async fn resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2078 let (tx, rx) = oneshot::channel();
2079 self.cmd_tx
2080 .send(SessionCommand::ResumeTorrent {
2081 info_hash,
2082 reply: tx,
2083 })
2084 .await
2085 .map_err(|_| crate::Error::Shutdown)?;
2086 rx.await.map_err(|_| crate::Error::Shutdown)?
2087 }
2088
2089 pub async fn force_resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2095 let (tx, rx) = oneshot::channel();
2096 self.cmd_tx
2097 .send(SessionCommand::ForceResumeTorrent {
2098 info_hash,
2099 reply: tx,
2100 })
2101 .await
2102 .map_err(|_| crate::Error::Shutdown)?;
2103 rx.await.map_err(|_| crate::Error::Shutdown)?
2104 }
2105
2106 pub async fn set_torrent_seed_ratio(
2112 &self,
2113 info_hash: Id20,
2114 limit: Option<f64>,
2115 ) -> crate::Result<()> {
2116 let (tx, rx) = oneshot::channel();
2117 self.cmd_tx
2118 .send(SessionCommand::SetTorrentSeedRatio {
2119 info_hash,
2120 limit,
2121 reply: tx,
2122 })
2123 .await
2124 .map_err(|_| crate::Error::Shutdown)?;
2125 rx.await.map_err(|_| crate::Error::Shutdown)?
2126 }
2127
2128 pub async fn torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
2134 let (tx, rx) = oneshot::channel();
2135 self.cmd_tx
2136 .send(SessionCommand::TorrentStats {
2137 info_hash,
2138 reply: tx,
2139 })
2140 .await
2141 .map_err(|_| crate::Error::Shutdown)?;
2142 rx.await.map_err(|_| crate::Error::Shutdown)?
2143 }
2144
2145 pub async fn peer_unchoke_durations(
2161 &self,
2162 info_hash: Id20,
2163 ) -> crate::Result<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>> {
2164 let (tx, rx) = oneshot::channel();
2165 self.cmd_tx
2166 .send(SessionCommand::QueryUnchokeDurations {
2167 info_hash,
2168 reply: tx,
2169 })
2170 .await
2171 .map_err(|_| crate::Error::Shutdown)?;
2172 rx.await.map_err(|_| crate::Error::Shutdown)
2173 }
2174
2175 pub async fn torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
2181 let (tx, rx) = oneshot::channel();
2182 self.cmd_tx
2183 .send(SessionCommand::TorrentInfo {
2184 info_hash,
2185 reply: tx,
2186 })
2187 .await
2188 .map_err(|_| crate::Error::Shutdown)?;
2189 rx.await.map_err(|_| crate::Error::Shutdown)?
2190 }
2191
2192 pub async fn list_torrents(&self) -> crate::Result<Vec<Id20>> {
2198 let (tx, rx) = oneshot::channel();
2199 self.cmd_tx
2200 .send(SessionCommand::ListTorrents { reply: tx })
2201 .await
2202 .map_err(|_| crate::Error::Shutdown)?;
2203 rx.await.map_err(|_| crate::Error::Shutdown)
2204 }
2205
2206 pub async fn session_stats(&self) -> crate::Result<SessionStats> {
2212 let (tx, rx) = oneshot::channel();
2213 self.cmd_tx
2214 .send(SessionCommand::SessionStats { reply: tx })
2215 .await
2216 .map_err(|_| crate::Error::Shutdown)?;
2217 rx.await.map_err(|_| crate::Error::Shutdown)
2218 }
2219
2220 pub async fn debug_state(&self) -> crate::Result<crate::types::DebugState> {
2230 let (tx, rx) = oneshot::channel();
2231 self.cmd_tx
2232 .send(SessionCommand::DebugState { reply: tx })
2233 .await
2234 .map_err(|_| crate::Error::Shutdown)?;
2235 tokio::time::timeout(std::time::Duration::from_secs(5), rx)
2238 .await
2239 .map_err(|_| crate::Error::Shutdown)?
2240 .map_err(|_| crate::Error::Shutdown)
2241 }
2242
2243 #[must_use]
2245 pub fn subscribe(&self) -> broadcast::Receiver<Alert> {
2246 self.alert_tx.subscribe()
2247 }
2248
2249 #[must_use]
2251 pub fn subscribe_filtered(&self, filter: AlertCategory) -> AlertStream {
2252 AlertStream::new(self.alert_tx.subscribe(), filter)
2253 }
2254
2255 pub async fn post_session_stats(&self) -> crate::Result<()> {
2261 self.cmd_tx
2262 .send(SessionCommand::PostSessionStats)
2263 .await
2264 .map_err(|_| crate::Error::Shutdown)
2265 }
2266
2267 #[must_use]
2269 pub fn counters(&self) -> &Arc<crate::stats::SessionCounters> {
2270 &self.counters
2271 }
2272
2273 pub fn set_alert_mask(&self, mask: AlertCategory) {
2275 self.alert_mask.store(mask.bits(), Ordering::Relaxed);
2276 }
2277
2278 #[must_use]
2280 pub fn alert_mask(&self) -> AlertCategory {
2281 AlertCategory::from_bits_truncate(self.alert_mask.load(Ordering::Relaxed))
2282 }
2283
2284 pub async fn add_peers(
2290 &self,
2291 info_hash: Id20,
2292 peers: Vec<SocketAddr>,
2293 source: crate::peer_state::PeerSource,
2294 ) -> crate::Result<()> {
2295 let (tx, rx) = oneshot::channel();
2296 self.cmd_tx
2297 .send(SessionCommand::AddPeers {
2298 info_hash,
2299 peers,
2300 source,
2301 reply: tx,
2302 })
2303 .await
2304 .map_err(|_| crate::Error::Shutdown)?;
2305 rx.await.map_err(|_| crate::Error::Shutdown)?
2306 }
2307
2308 pub async fn shutdown(&self) -> crate::Result<()> {
2314 let _ = tokio::time::timeout(
2316 std::time::Duration::from_secs(10),
2317 self.cmd_tx.send(SessionCommand::Shutdown),
2318 )
2319 .await;
2320 Ok(())
2321 }
2322
2323 pub async fn save_torrent_resume_data(
2329 &self,
2330 info_hash: Id20,
2331 ) -> crate::Result<irontide_core::FastResumeData> {
2332 let (tx, rx) = oneshot::channel();
2333 self.cmd_tx
2334 .send(SessionCommand::SaveTorrentResumeData {
2335 info_hash,
2336 reply: tx,
2337 })
2338 .await
2339 .map_err(|_| crate::Error::Shutdown)?;
2340 rx.await.map_err(|_| crate::Error::Shutdown)?
2341 }
2342
2343 pub async fn save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
2349 let (tx, rx) = oneshot::channel();
2350 self.cmd_tx
2351 .send(SessionCommand::SaveSessionState { reply: tx })
2352 .await
2353 .map_err(|_| crate::Error::Shutdown)?;
2354 rx.await.map_err(|_| crate::Error::Shutdown)?
2355 }
2356
2357 pub async fn load_resume_state(&self) -> crate::Result<ResumeLoadResult> {
2368 let (tx, rx) = oneshot::channel();
2369 self.cmd_tx
2370 .send(SessionCommand::LoadResumeState { reply: tx })
2371 .await
2372 .map_err(|_| crate::Error::Shutdown)?;
2373 rx.await.map_err(|_| crate::Error::Shutdown)?
2374 }
2375
2376 pub async fn save_resume_state(&self) -> crate::Result<usize> {
2386 let (tx, rx) = oneshot::channel();
2387 self.cmd_tx
2388 .send(SessionCommand::SaveResumeState { reply: tx })
2389 .await
2390 .map_err(|_| crate::Error::Shutdown)?;
2391 rx.await.map_err(|_| crate::Error::Shutdown)?
2392 }
2393
2394 pub async fn queue_position(&self, info_hash: Id20) -> crate::Result<i32> {
2400 let (tx, rx) = oneshot::channel();
2401 self.cmd_tx
2402 .send(SessionCommand::QueuePosition {
2403 info_hash,
2404 reply: tx,
2405 })
2406 .await
2407 .map_err(|_| crate::Error::Shutdown)?;
2408 rx.await.map_err(|_| crate::Error::Shutdown)?
2409 }
2410
2411 pub async fn set_queue_position(&self, info_hash: Id20, pos: i32) -> crate::Result<()> {
2417 let (tx, rx) = oneshot::channel();
2418 self.cmd_tx
2419 .send(SessionCommand::SetQueuePosition {
2420 info_hash,
2421 pos,
2422 reply: tx,
2423 })
2424 .await
2425 .map_err(|_| crate::Error::Shutdown)?;
2426 rx.await.map_err(|_| crate::Error::Shutdown)?
2427 }
2428
2429 pub async fn queue_position_up(&self, info_hash: Id20) -> crate::Result<()> {
2435 let (tx, rx) = oneshot::channel();
2436 self.cmd_tx
2437 .send(SessionCommand::QueuePositionUp {
2438 info_hash,
2439 reply: tx,
2440 })
2441 .await
2442 .map_err(|_| crate::Error::Shutdown)?;
2443 rx.await.map_err(|_| crate::Error::Shutdown)?
2444 }
2445
2446 pub async fn queue_position_down(&self, info_hash: Id20) -> crate::Result<()> {
2452 let (tx, rx) = oneshot::channel();
2453 self.cmd_tx
2454 .send(SessionCommand::QueuePositionDown {
2455 info_hash,
2456 reply: tx,
2457 })
2458 .await
2459 .map_err(|_| crate::Error::Shutdown)?;
2460 rx.await.map_err(|_| crate::Error::Shutdown)?
2461 }
2462
2463 pub async fn queue_position_top(&self, info_hash: Id20) -> crate::Result<()> {
2469 let (tx, rx) = oneshot::channel();
2470 self.cmd_tx
2471 .send(SessionCommand::QueuePositionTop {
2472 info_hash,
2473 reply: tx,
2474 })
2475 .await
2476 .map_err(|_| crate::Error::Shutdown)?;
2477 rx.await.map_err(|_| crate::Error::Shutdown)?
2478 }
2479
2480 pub async fn queue_position_bottom(&self, info_hash: Id20) -> crate::Result<()> {
2486 let (tx, rx) = oneshot::channel();
2487 self.cmd_tx
2488 .send(SessionCommand::QueuePositionBottom {
2489 info_hash,
2490 reply: tx,
2491 })
2492 .await
2493 .map_err(|_| crate::Error::Shutdown)?;
2494 rx.await.map_err(|_| crate::Error::Shutdown)?
2495 }
2496
2497 pub async fn ban_peer(&self, ip: IpAddr) -> crate::Result<()> {
2503 let (tx, rx) = oneshot::channel();
2504 self.cmd_tx
2505 .send(SessionCommand::BanPeer { ip, reply: tx })
2506 .await
2507 .map_err(|_| crate::Error::Shutdown)?;
2508 rx.await.map_err(|_| crate::Error::Shutdown)
2509 }
2510
2511 pub async fn unban_peer(&self, ip: IpAddr) -> crate::Result<bool> {
2517 let (tx, rx) = oneshot::channel();
2518 self.cmd_tx
2519 .send(SessionCommand::UnbanPeer { ip, reply: tx })
2520 .await
2521 .map_err(|_| crate::Error::Shutdown)?;
2522 rx.await.map_err(|_| crate::Error::Shutdown)
2523 }
2524
2525 pub async fn set_ip_filter(&self, filter: crate::ip_filter::IpFilter) -> crate::Result<()> {
2532 let (tx, rx) = oneshot::channel();
2533 self.cmd_tx
2534 .send(SessionCommand::SetIpFilter { filter, reply: tx })
2535 .await
2536 .map_err(|_| crate::Error::Shutdown)?;
2537 rx.await.map_err(|_| crate::Error::Shutdown)
2538 }
2539
2540 pub async fn ip_filter(&self) -> crate::Result<crate::ip_filter::IpFilter> {
2546 let (tx, rx) = oneshot::channel();
2547 self.cmd_tx
2548 .send(SessionCommand::GetIpFilter { reply: tx })
2549 .await
2550 .map_err(|_| crate::Error::Shutdown)?;
2551 rx.await.map_err(|_| crate::Error::Shutdown)
2552 }
2553
2554 pub async fn settings(&self) -> crate::Result<Settings> {
2560 let (tx, rx) = oneshot::channel();
2561 self.cmd_tx
2562 .send(SessionCommand::GetSettings { reply: tx })
2563 .await
2564 .map_err(|_| crate::Error::Shutdown)?;
2565 rx.await.map_err(|_| crate::Error::Shutdown)
2566 }
2567
2568 pub async fn apply_settings(&self, settings: Settings) -> crate::Result<()> {
2582 let _guard = self
2583 .reconfig_in_flight
2584 .try_lock()
2585 .ok_or(crate::Error::ConcurrentReconfig)?;
2586 let (tx, rx) = oneshot::channel();
2587 self.cmd_tx
2588 .send(SessionCommand::ApplySettings {
2589 settings: Box::new(settings),
2590 reply: tx,
2591 })
2592 .await
2593 .map_err(|_| crate::Error::Shutdown)?;
2594 rx.await.map_err(|_| crate::Error::Shutdown)?
2595 }
2596
2597 pub async fn apply_settings_classified(
2617 &self,
2618 settings: Settings,
2619 ) -> crate::Result<AppliedSettings> {
2620 let _guard = self
2621 .reconfig_in_flight
2622 .try_lock()
2623 .ok_or(crate::Error::ConcurrentReconfig)?;
2624 let snapshot = self.settings().await?;
2627 let immediate = classify_immediate(&snapshot, &settings);
2628 let restart_required = classify_restart_required(&snapshot, &settings);
2629 let (tx, rx) = oneshot::channel();
2634 self.cmd_tx
2635 .send(SessionCommand::ApplySettings {
2636 settings: Box::new(settings),
2637 reply: tx,
2638 })
2639 .await
2640 .map_err(|_| crate::Error::Shutdown)?;
2641 rx.await.map_err(|_| crate::Error::Shutdown)??;
2642 Ok(AppliedSettings {
2643 immediate,
2644 restart_required,
2645 })
2646 }
2647
2648 pub async fn dht_node_count(&self) -> crate::Result<usize> {
2660 let (tx, rx) = oneshot::channel();
2661 self.cmd_tx
2662 .send(SessionCommand::DhtNodeCount { reply: tx })
2663 .await
2664 .map_err(|_| crate::Error::Shutdown)?;
2665 rx.await.map_err(|_| crate::Error::Shutdown)
2666 }
2667
2668 pub async fn banned_peers(&self) -> crate::Result<Vec<IpAddr>> {
2674 let (tx, rx) = oneshot::channel();
2675 self.cmd_tx
2676 .send(SessionCommand::BannedPeers { reply: tx })
2677 .await
2678 .map_err(|_| crate::Error::Shutdown)?;
2679 rx.await.map_err(|_| crate::Error::Shutdown)
2680 }
2681
2682 pub async fn move_torrent_storage(
2688 &self,
2689 info_hash: Id20,
2690 new_path: std::path::PathBuf,
2691 ) -> crate::Result<()> {
2692 let (tx, rx) = oneshot::channel();
2693 self.cmd_tx
2694 .send(SessionCommand::MoveTorrentStorage {
2695 info_hash,
2696 new_path,
2697 reply: tx,
2698 })
2699 .await
2700 .map_err(|_| crate::Error::Shutdown)?;
2701 rx.await.map_err(|_| crate::Error::Shutdown)?
2702 }
2703
2704 pub async fn open_file(
2714 &self,
2715 info_hash: Id20,
2716 file_index: usize,
2717 ) -> crate::Result<crate::streaming::FileStream> {
2718 let (tx, rx) = oneshot::channel();
2719 self.cmd_tx
2720 .send(SessionCommand::OpenFile {
2721 info_hash,
2722 file_index,
2723 reply: tx,
2724 })
2725 .await
2726 .map_err(|_| crate::Error::Shutdown)?;
2727 rx.await.map_err(|_| crate::Error::Shutdown)?
2728 }
2729
2730 pub async fn force_reannounce(&self, info_hash: Id20) -> crate::Result<()> {
2736 let (tx, rx) = oneshot::channel();
2737 self.cmd_tx
2738 .send(SessionCommand::ForceReannounce {
2739 info_hash,
2740 reply: tx,
2741 })
2742 .await
2743 .map_err(|_| crate::Error::Shutdown)?;
2744 rx.await.map_err(|_| crate::Error::Shutdown)?
2745 }
2746
2747 pub async fn tracker_list(
2753 &self,
2754 info_hash: Id20,
2755 ) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
2756 let (tx, rx) = oneshot::channel();
2757 self.cmd_tx
2758 .send(SessionCommand::TrackerList {
2759 info_hash,
2760 reply: tx,
2761 })
2762 .await
2763 .map_err(|_| crate::Error::Shutdown)?;
2764 rx.await.map_err(|_| crate::Error::Shutdown)?
2765 }
2766
2767 pub async fn pex_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2775 let counts = self.peer_source_counts(info_hash).await?;
2776 Ok(counts.0)
2777 }
2778
2779 pub async fn lsd_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2787 let counts = self.peer_source_counts(info_hash).await?;
2788 Ok(counts.1)
2789 }
2790
2791 async fn peer_source_counts(&self, info_hash: Id20) -> crate::Result<(usize, usize)> {
2792 let (tx, rx) = oneshot::channel();
2793 self.cmd_tx
2794 .send(SessionCommand::GetPeerSourceCounts {
2795 info_hash,
2796 reply: tx,
2797 })
2798 .await
2799 .map_err(|_| crate::Error::Shutdown)?;
2800 rx.await.map_err(|_| crate::Error::Shutdown)?
2801 }
2802
2803 pub async fn web_seed_stats(
2811 &self,
2812 info_hash: Id20,
2813 ) -> crate::Result<Vec<irontide_core::WebSeedStats>> {
2814 let (tx, rx) = oneshot::channel();
2815 self.cmd_tx
2816 .send(SessionCommand::GetWebSeedStats {
2817 info_hash,
2818 reply: tx,
2819 })
2820 .await
2821 .map_err(|_| crate::Error::Shutdown)?;
2822 rx.await.map_err(|_| crate::Error::Shutdown)?
2823 }
2824
2825 pub async fn get_web_seeds(&self, info_hash: Id20) -> crate::Result<Vec<String>> {
2838 let (tx, rx) = oneshot::channel();
2839 self.cmd_tx
2840 .send(SessionCommand::GetWebSeeds {
2841 info_hash,
2842 reply: tx,
2843 })
2844 .await
2845 .map_err(|_| crate::Error::Shutdown)?;
2846 rx.await.map_err(|_| crate::Error::Shutdown)?
2847 }
2848
2849 pub async fn get_piece_states(&self, info_hash: Id20) -> crate::Result<Vec<u8>> {
2862 let (tx, rx) = oneshot::channel();
2863 self.cmd_tx
2864 .send(SessionCommand::GetPieceStates {
2865 info_hash,
2866 reply: tx,
2867 })
2868 .await
2869 .map_err(|_| crate::Error::Shutdown)?;
2870 rx.await.map_err(|_| crate::Error::Shutdown)?
2871 }
2872
2873 pub async fn get_piece_hashes(
2888 &self,
2889 info_hash: Id20,
2890 offset: u32,
2891 limit: u32,
2892 ) -> crate::Result<Vec<String>> {
2893 let (tx, rx) = oneshot::channel();
2894 self.cmd_tx
2895 .send(SessionCommand::GetPieceHashes {
2896 info_hash,
2897 offset,
2898 limit,
2899 reply: tx,
2900 })
2901 .await
2902 .map_err(|_| crate::Error::Shutdown)?;
2903 rx.await.map_err(|_| crate::Error::Shutdown)?
2904 }
2905
2906 pub async fn scrape(
2912 &self,
2913 info_hash: Id20,
2914 ) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
2915 let (tx, rx) = oneshot::channel();
2916 self.cmd_tx
2917 .send(SessionCommand::Scrape {
2918 info_hash,
2919 reply: tx,
2920 })
2921 .await
2922 .map_err(|_| crate::Error::Shutdown)?;
2923 rx.await.map_err(|_| crate::Error::Shutdown)?
2924 }
2925
2926 pub async fn set_file_priority(
2932 &self,
2933 info_hash: Id20,
2934 index: usize,
2935 priority: irontide_core::FilePriority,
2936 ) -> crate::Result<()> {
2937 let (tx, rx) = oneshot::channel();
2938 self.cmd_tx
2939 .send(SessionCommand::SetFilePriority {
2940 info_hash,
2941 index,
2942 priority,
2943 reply: tx,
2944 })
2945 .await
2946 .map_err(|_| crate::Error::Shutdown)?;
2947 rx.await.map_err(|_| crate::Error::Shutdown)?
2948 }
2949
2950 pub async fn file_priorities(
2956 &self,
2957 info_hash: Id20,
2958 ) -> crate::Result<Vec<irontide_core::FilePriority>> {
2959 let (tx, rx) = oneshot::channel();
2960 self.cmd_tx
2961 .send(SessionCommand::FilePriorities {
2962 info_hash,
2963 reply: tx,
2964 })
2965 .await
2966 .map_err(|_| crate::Error::Shutdown)?;
2967 rx.await.map_err(|_| crate::Error::Shutdown)?
2968 }
2969
2970 pub async fn set_download_limit(
2976 &self,
2977 info_hash: Id20,
2978 bytes_per_sec: u64,
2979 ) -> crate::Result<()> {
2980 let (tx, rx) = oneshot::channel();
2981 self.cmd_tx
2982 .send(SessionCommand::SetDownloadLimit {
2983 info_hash,
2984 bytes_per_sec,
2985 reply: tx,
2986 })
2987 .await
2988 .map_err(|_| crate::Error::Shutdown)?;
2989 rx.await.map_err(|_| crate::Error::Shutdown)?
2990 }
2991
2992 pub async fn set_upload_limit(&self, info_hash: Id20, bytes_per_sec: u64) -> crate::Result<()> {
2998 let (tx, rx) = oneshot::channel();
2999 self.cmd_tx
3000 .send(SessionCommand::SetUploadLimit {
3001 info_hash,
3002 bytes_per_sec,
3003 reply: tx,
3004 })
3005 .await
3006 .map_err(|_| crate::Error::Shutdown)?;
3007 rx.await.map_err(|_| crate::Error::Shutdown)?
3008 }
3009
3010 pub async fn download_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3016 let (tx, rx) = oneshot::channel();
3017 self.cmd_tx
3018 .send(SessionCommand::DownloadLimit {
3019 info_hash,
3020 reply: tx,
3021 })
3022 .await
3023 .map_err(|_| crate::Error::Shutdown)?;
3024 rx.await.map_err(|_| crate::Error::Shutdown)?
3025 }
3026
3027 pub async fn upload_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3033 let (tx, rx) = oneshot::channel();
3034 self.cmd_tx
3035 .send(SessionCommand::UploadLimit {
3036 info_hash,
3037 reply: tx,
3038 })
3039 .await
3040 .map_err(|_| crate::Error::Shutdown)?;
3041 rx.await.map_err(|_| crate::Error::Shutdown)?
3042 }
3043
3044 pub async fn set_sequential_download(
3050 &self,
3051 info_hash: Id20,
3052 enabled: bool,
3053 ) -> crate::Result<()> {
3054 let (tx, rx) = oneshot::channel();
3055 self.cmd_tx
3056 .send(SessionCommand::SetSequentialDownload {
3057 info_hash,
3058 enabled,
3059 reply: tx,
3060 })
3061 .await
3062 .map_err(|_| crate::Error::Shutdown)?;
3063 rx.await.map_err(|_| crate::Error::Shutdown)?
3064 }
3065
3066 pub async fn is_sequential_download(&self, info_hash: Id20) -> crate::Result<bool> {
3072 let (tx, rx) = oneshot::channel();
3073 self.cmd_tx
3074 .send(SessionCommand::IsSequentialDownload {
3075 info_hash,
3076 reply: tx,
3077 })
3078 .await
3079 .map_err(|_| crate::Error::Shutdown)?;
3080 rx.await.map_err(|_| crate::Error::Shutdown)?
3081 }
3082
3083 pub async fn set_super_seeding(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3089 let (tx, rx) = oneshot::channel();
3090 self.cmd_tx
3091 .send(SessionCommand::SetSuperSeeding {
3092 info_hash,
3093 enabled,
3094 reply: tx,
3095 })
3096 .await
3097 .map_err(|_| crate::Error::Shutdown)?;
3098 rx.await.map_err(|_| crate::Error::Shutdown)?
3099 }
3100
3101 pub async fn is_super_seeding(&self, info_hash: Id20) -> crate::Result<bool> {
3107 let (tx, rx) = oneshot::channel();
3108 self.cmd_tx
3109 .send(SessionCommand::IsSuperSeeding {
3110 info_hash,
3111 reply: tx,
3112 })
3113 .await
3114 .map_err(|_| crate::Error::Shutdown)?;
3115 rx.await.map_err(|_| crate::Error::Shutdown)?
3116 }
3117
3118 pub async fn set_seed_mode(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3134 let (tx, rx) = oneshot::channel();
3135 self.cmd_tx
3136 .send(SessionCommand::SetSeedMode {
3137 info_hash,
3138 enabled,
3139 reply: tx,
3140 })
3141 .await
3142 .map_err(|_| crate::Error::Shutdown)?;
3143 rx.await.map_err(|_| crate::Error::Shutdown)?
3144 }
3145
3146 pub async fn add_tracker(&self, info_hash: Id20, url: String) -> crate::Result<()> {
3154 let (tx, rx) = oneshot::channel();
3155 self.cmd_tx
3156 .send(SessionCommand::AddTracker {
3157 info_hash,
3158 url,
3159 reply: tx,
3160 })
3161 .await
3162 .map_err(|_| crate::Error::Shutdown)?;
3163 rx.await.map_err(|_| crate::Error::Shutdown)?
3164 }
3165
3166 pub async fn replace_trackers(&self, info_hash: Id20, urls: Vec<String>) -> crate::Result<()> {
3172 let (tx, rx) = oneshot::channel();
3173 self.cmd_tx
3174 .send(SessionCommand::ReplaceTrackers {
3175 info_hash,
3176 urls,
3177 reply: tx,
3178 })
3179 .await
3180 .map_err(|_| crate::Error::Shutdown)?;
3181 rx.await.map_err(|_| crate::Error::Shutdown)?
3182 }
3183
3184 pub async fn force_recheck(&self, info_hash: Id20) -> crate::Result<()> {
3194 let (tx, rx) = oneshot::channel();
3195 self.cmd_tx
3196 .send(SessionCommand::ForceRecheck {
3197 info_hash,
3198 reply: tx,
3199 })
3200 .await
3201 .map_err(|_| crate::Error::Shutdown)?;
3202 rx.await.map_err(|_| crate::Error::Shutdown)?
3203 }
3204
3205 pub async fn rename_file(
3215 &self,
3216 info_hash: Id20,
3217 file_index: usize,
3218 new_name: String,
3219 ) -> crate::Result<()> {
3220 let (tx, rx) = oneshot::channel();
3221 self.cmd_tx
3222 .send(SessionCommand::RenameFile {
3223 info_hash,
3224 file_index,
3225 new_name,
3226 reply: tx,
3227 })
3228 .await
3229 .map_err(|_| crate::Error::Shutdown)?;
3230 rx.await.map_err(|_| crate::Error::Shutdown)?
3231 }
3232
3233 pub async fn set_max_connections(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3239 let (tx, rx) = oneshot::channel();
3240 self.cmd_tx
3241 .send(SessionCommand::SetMaxConnections {
3242 info_hash,
3243 limit,
3244 reply: tx,
3245 })
3246 .await
3247 .map_err(|_| crate::Error::Shutdown)?;
3248 rx.await.map_err(|_| crate::Error::Shutdown)?
3249 }
3250
3251 pub async fn max_connections(&self, info_hash: Id20) -> crate::Result<usize> {
3257 let (tx, rx) = oneshot::channel();
3258 self.cmd_tx
3259 .send(SessionCommand::MaxConnections {
3260 info_hash,
3261 reply: tx,
3262 })
3263 .await
3264 .map_err(|_| crate::Error::Shutdown)?;
3265 rx.await.map_err(|_| crate::Error::Shutdown)?
3266 }
3267
3268 pub async fn set_max_uploads(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3274 let (tx, rx) = oneshot::channel();
3275 self.cmd_tx
3276 .send(SessionCommand::SetMaxUploads {
3277 info_hash,
3278 limit,
3279 reply: tx,
3280 })
3281 .await
3282 .map_err(|_| crate::Error::Shutdown)?;
3283 rx.await.map_err(|_| crate::Error::Shutdown)?
3284 }
3285
3286 pub async fn max_uploads(&self, info_hash: Id20) -> crate::Result<usize> {
3292 let (tx, rx) = oneshot::channel();
3293 self.cmd_tx
3294 .send(SessionCommand::MaxUploads {
3295 info_hash,
3296 reply: tx,
3297 })
3298 .await
3299 .map_err(|_| crate::Error::Shutdown)?;
3300 rx.await.map_err(|_| crate::Error::Shutdown)?
3301 }
3302
3303 pub async fn get_peer_info(
3309 &self,
3310 info_hash: Id20,
3311 ) -> crate::Result<Vec<crate::types::PeerInfo>> {
3312 let (tx, rx) = oneshot::channel();
3313 self.cmd_tx
3314 .send(SessionCommand::GetPeerInfo {
3315 info_hash,
3316 reply: tx,
3317 })
3318 .await
3319 .map_err(|_| crate::Error::Shutdown)?;
3320 rx.await.map_err(|_| crate::Error::Shutdown)?
3321 }
3322
3323 pub async fn get_download_queue(
3329 &self,
3330 info_hash: Id20,
3331 ) -> crate::Result<Vec<crate::types::PartialPieceInfo>> {
3332 let (tx, rx) = oneshot::channel();
3333 self.cmd_tx
3334 .send(SessionCommand::GetDownloadQueue {
3335 info_hash,
3336 reply: tx,
3337 })
3338 .await
3339 .map_err(|_| crate::Error::Shutdown)?;
3340 rx.await.map_err(|_| crate::Error::Shutdown)?
3341 }
3342
3343 pub async fn have_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bool> {
3349 let (tx, rx) = oneshot::channel();
3350 self.cmd_tx
3351 .send(SessionCommand::HavePiece {
3352 info_hash,
3353 index,
3354 reply: tx,
3355 })
3356 .await
3357 .map_err(|_| crate::Error::Shutdown)?;
3358 rx.await.map_err(|_| crate::Error::Shutdown)?
3359 }
3360
3361 pub async fn piece_availability(&self, info_hash: Id20) -> crate::Result<Vec<u32>> {
3367 let (tx, rx) = oneshot::channel();
3368 self.cmd_tx
3369 .send(SessionCommand::PieceAvailability {
3370 info_hash,
3371 reply: tx,
3372 })
3373 .await
3374 .map_err(|_| crate::Error::Shutdown)?;
3375 rx.await.map_err(|_| crate::Error::Shutdown)?
3376 }
3377
3378 pub async fn file_progress(&self, info_hash: Id20) -> crate::Result<Vec<u64>> {
3384 let (tx, rx) = oneshot::channel();
3385 self.cmd_tx
3386 .send(SessionCommand::FileProgress {
3387 info_hash,
3388 reply: tx,
3389 })
3390 .await
3391 .map_err(|_| crate::Error::Shutdown)?;
3392 rx.await.map_err(|_| crate::Error::Shutdown)?
3393 }
3394
3395 pub async fn info_hashes(&self, info_hash: Id20) -> crate::Result<irontide_core::InfoHashes> {
3401 let (tx, rx) = oneshot::channel();
3402 self.cmd_tx
3403 .send(SessionCommand::InfoHashesQuery {
3404 info_hash,
3405 reply: tx,
3406 })
3407 .await
3408 .map_err(|_| crate::Error::Shutdown)?;
3409 rx.await.map_err(|_| crate::Error::Shutdown)?
3410 }
3411
3412 pub async fn torrent_file(
3420 &self,
3421 info_hash: Id20,
3422 ) -> crate::Result<Option<irontide_core::TorrentMetaV1>> {
3423 let (tx, rx) = oneshot::channel();
3424 self.cmd_tx
3425 .send(SessionCommand::TorrentFile {
3426 info_hash,
3427 reply: tx,
3428 })
3429 .await
3430 .map_err(|_| crate::Error::Shutdown)?;
3431 rx.await.map_err(|_| crate::Error::Shutdown)?
3432 }
3433
3434 pub async fn torrent_file_v2(
3443 &self,
3444 info_hash: Id20,
3445 ) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
3446 let (tx, rx) = oneshot::channel();
3447 self.cmd_tx
3448 .send(SessionCommand::TorrentFileV2 {
3449 info_hash,
3450 reply: tx,
3451 })
3452 .await
3453 .map_err(|_| crate::Error::Shutdown)?;
3454 rx.await.map_err(|_| crate::Error::Shutdown)?
3455 }
3456
3457 #[cfg(feature = "test-util")]
3473 pub async fn debug_inject_metadata(
3474 &self,
3475 info_hash: Id20,
3476 info_bytes: Vec<u8>,
3477 ) -> crate::Result<()> {
3478 let (tx, rx) = oneshot::channel();
3479 self.cmd_tx
3480 .send(SessionCommand::TestInjectMetadata {
3481 info_hash,
3482 info_bytes,
3483 reply: tx,
3484 })
3485 .await
3486 .map_err(|_| crate::Error::Shutdown)?;
3487 rx.await.map_err(|_| crate::Error::Shutdown)?
3488 }
3489
3490 pub async fn force_dht_announce(&self, info_hash: Id20) -> crate::Result<()> {
3496 let (tx, rx) = oneshot::channel();
3497 self.cmd_tx
3498 .send(SessionCommand::ForceDhtAnnounce {
3499 info_hash,
3500 reply: tx,
3501 })
3502 .await
3503 .map_err(|_| crate::Error::Shutdown)?;
3504 rx.await.map_err(|_| crate::Error::Shutdown)?
3505 }
3506
3507 pub async fn force_lsd_announce(&self, info_hash: Id20) -> crate::Result<()> {
3515 let (tx, rx) = oneshot::channel();
3516 self.cmd_tx
3517 .send(SessionCommand::ForceLsdAnnounce {
3518 info_hash,
3519 reply: tx,
3520 })
3521 .await
3522 .map_err(|_| crate::Error::Shutdown)?;
3523 rx.await.map_err(|_| crate::Error::Shutdown)?
3524 }
3525
3526 pub async fn read_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bytes::Bytes> {
3532 let (tx, rx) = oneshot::channel();
3533 self.cmd_tx
3534 .send(SessionCommand::ReadPiece {
3535 info_hash,
3536 index,
3537 reply: tx,
3538 })
3539 .await
3540 .map_err(|_| crate::Error::Shutdown)?;
3541 rx.await.map_err(|_| crate::Error::Shutdown)?
3542 }
3543
3544 pub async fn flush_cache(&self, info_hash: Id20) -> crate::Result<()> {
3550 let (tx, rx) = oneshot::channel();
3551 self.cmd_tx
3552 .send(SessionCommand::FlushCache {
3553 info_hash,
3554 reply: tx,
3555 })
3556 .await
3557 .map_err(|_| crate::Error::Shutdown)?;
3558 rx.await.map_err(|_| crate::Error::Shutdown)?
3559 }
3560
3561 pub async fn is_valid(&self, info_hash: Id20) -> bool {
3563 let (tx, rx) = oneshot::channel();
3564 if self
3565 .cmd_tx
3566 .send(SessionCommand::IsValid {
3567 info_hash,
3568 reply: tx,
3569 })
3570 .await
3571 .is_err()
3572 {
3573 return false;
3574 }
3575 rx.await.unwrap_or(false)
3576 }
3577
3578 pub async fn clear_error(&self, info_hash: Id20) -> crate::Result<()> {
3584 let (tx, rx) = oneshot::channel();
3585 self.cmd_tx
3586 .send(SessionCommand::ClearError {
3587 info_hash,
3588 reply: tx,
3589 })
3590 .await
3591 .map_err(|_| crate::Error::Shutdown)?;
3592 rx.await.map_err(|_| crate::Error::Shutdown)?
3593 }
3594
3595 pub async fn file_status(
3601 &self,
3602 info_hash: Id20,
3603 ) -> crate::Result<Vec<crate::types::FileStatus>> {
3604 let (tx, rx) = oneshot::channel();
3605 self.cmd_tx
3606 .send(SessionCommand::FileStatus {
3607 info_hash,
3608 reply: tx,
3609 })
3610 .await
3611 .map_err(|_| crate::Error::Shutdown)?;
3612 rx.await.map_err(|_| crate::Error::Shutdown)?
3613 }
3614
3615 pub async fn flags(&self, info_hash: Id20) -> crate::Result<crate::types::TorrentFlags> {
3621 let (tx, rx) = oneshot::channel();
3622 self.cmd_tx
3623 .send(SessionCommand::Flags {
3624 info_hash,
3625 reply: tx,
3626 })
3627 .await
3628 .map_err(|_| crate::Error::Shutdown)?;
3629 rx.await.map_err(|_| crate::Error::Shutdown)?
3630 }
3631
3632 pub async fn set_flags(
3638 &self,
3639 info_hash: Id20,
3640 flags: crate::types::TorrentFlags,
3641 ) -> crate::Result<()> {
3642 let (tx, rx) = oneshot::channel();
3643 self.cmd_tx
3644 .send(SessionCommand::SetFlags {
3645 info_hash,
3646 flags,
3647 reply: tx,
3648 })
3649 .await
3650 .map_err(|_| crate::Error::Shutdown)?;
3651 rx.await.map_err(|_| crate::Error::Shutdown)?
3652 }
3653
3654 pub async fn unset_flags(
3660 &self,
3661 info_hash: Id20,
3662 flags: crate::types::TorrentFlags,
3663 ) -> crate::Result<()> {
3664 let (tx, rx) = oneshot::channel();
3665 self.cmd_tx
3666 .send(SessionCommand::UnsetFlags {
3667 info_hash,
3668 flags,
3669 reply: tx,
3670 })
3671 .await
3672 .map_err(|_| crate::Error::Shutdown)?;
3673 rx.await.map_err(|_| crate::Error::Shutdown)?
3674 }
3675
3676 pub async fn connect_peer(&self, info_hash: Id20, addr: SocketAddr) -> crate::Result<()> {
3682 let (tx, rx) = oneshot::channel();
3683 self.cmd_tx
3684 .send(SessionCommand::ConnectPeer {
3685 info_hash,
3686 addr,
3687 reply: tx,
3688 })
3689 .await
3690 .map_err(|_| crate::Error::Shutdown)?;
3691 rx.await.map_err(|_| crate::Error::Shutdown)?
3692 }
3693
3694 pub async fn dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
3702 let (tx, rx) = oneshot::channel();
3703 self.cmd_tx
3704 .send(SessionCommand::DhtPutImmutable { value, reply: tx })
3705 .await
3706 .map_err(|_| crate::Error::Shutdown)?;
3707 rx.await.map_err(|_| crate::Error::Shutdown)?
3708 }
3709
3710 pub async fn dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
3718 let (tx, rx) = oneshot::channel();
3719 self.cmd_tx
3720 .send(SessionCommand::DhtGetImmutable { target, reply: tx })
3721 .await
3722 .map_err(|_| crate::Error::Shutdown)?;
3723 rx.await.map_err(|_| crate::Error::Shutdown)?
3724 }
3725
3726 pub async fn dht_put_mutable(
3734 &self,
3735 keypair_bytes: [u8; 32],
3736 value: Vec<u8>,
3737 seq: i64,
3738 salt: Vec<u8>,
3739 ) -> crate::Result<Id20> {
3740 let (tx, rx) = oneshot::channel();
3741 self.cmd_tx
3742 .send(SessionCommand::DhtPutMutable {
3743 keypair_bytes,
3744 value,
3745 seq,
3746 salt,
3747 reply: tx,
3748 })
3749 .await
3750 .map_err(|_| crate::Error::Shutdown)?;
3751 rx.await.map_err(|_| crate::Error::Shutdown)?
3752 }
3753
3754 pub async fn dht_get_mutable(
3762 &self,
3763 public_key: [u8; 32],
3764 salt: Vec<u8>,
3765 ) -> crate::Result<Option<(Vec<u8>, i64)>> {
3766 let (tx, rx) = oneshot::channel();
3767 self.cmd_tx
3768 .send(SessionCommand::DhtGetMutable {
3769 public_key,
3770 salt,
3771 reply: tx,
3772 })
3773 .await
3774 .map_err(|_| crate::Error::Shutdown)?;
3775 rx.await.map_err(|_| crate::Error::Shutdown)?
3776 }
3777
3778 #[allow(
3794 clippy::unused_async,
3795 reason = "async + Result signature kept for facade API stability (M245 D5); the snapshot read is synchronous and infallible"
3796 )]
3797 pub async fn list_torrent_summaries(&self) -> crate::Result<Vec<TorrentSummary>> {
3798 Ok(self.snapshot.load().summaries())
3799 }
3800
3801 pub async fn add_magnet_uri(&self, uri: &str) -> crate::Result<irontide_core::InfoHashes> {
3810 let magnet = irontide_core::Magnet::parse(uri)?;
3811 let info_hashes = magnet.info_hashes.clone();
3812 self.add_magnet(magnet).await?;
3813 Ok(info_hashes)
3814 }
3815
3816 pub async fn add_torrent_bytes(
3825 &self,
3826 bytes: &[u8],
3827 ) -> crate::Result<irontide_core::InfoHashes> {
3828 let meta = irontide_core::torrent_from_bytes_any(bytes)?;
3829 let info_hashes = meta.info_hashes();
3830 self.add_torrent_with_meta(meta, None).await?;
3831 Ok(info_hashes)
3832 }
3833}
3834
3835#[derive(Debug, Default, Clone)]
3851pub struct SessionSnapshot {
3852 by_id: std::collections::BTreeMap<Id20, TorrentSummary>,
3853}
3854
3855impl SessionSnapshot {
3856 #[must_use]
3859 pub fn summaries(&self) -> Vec<TorrentSummary> {
3860 self.by_id.values().cloned().collect()
3861 }
3862
3863 pub(crate) fn as_map(&self) -> &std::collections::BTreeMap<Id20, TorrentSummary> {
3866 &self.by_id
3867 }
3868
3869 pub(crate) fn from_map(by_id: std::collections::BTreeMap<Id20, TorrentSummary>) -> Self {
3871 Self { by_id }
3872 }
3873}
3874
3875struct SessionActor {
3880 settings: Settings,
3881 commit_tx: SessionCmdSender,
3888 torrents: HashMap<Id20, TorrentEntry>,
3889 snapshot: Arc<arc_swap::ArcSwap<SessionSnapshot>>,
3895 dht_v4: Option<DhtHandle>,
3896 dht_v6: Option<DhtHandle>,
3897 dht_v4_broadcast: irontide_dht::DhtBroadcast,
3904 dht_v6_broadcast: irontide_dht::DhtBroadcast,
3905 lsd: Option<crate::lsd::LsdHandle>,
3906 lsd_peers_rx: Option<mpsc::Receiver<(Id20, SocketAddr)>>,
3907 cmd_rx: mpsc::Receiver<(tokio::time::Instant, SessionCommand)>,
3908 alert_tx: broadcast::Sender<Alert>,
3909 alert_mask: Arc<AtomicU32>,
3910 global_upload_bucket: SharedBucket,
3911 global_download_bucket: SharedBucket,
3912 utp_socket: Option<irontide_utp::UtpSocket>,
3913 utp_socket_v6: Option<irontide_utp::UtpSocket>,
3914 nat: Option<irontide_nat::NatHandle>,
3915 nat_events_rx: Option<mpsc::Receiver<irontide_nat::NatEvent>>,
3916 ban_manager: SharedBanManager,
3917 ip_filter: SharedIpFilter,
3918 disk_manager: crate::disk::DiskManagerHandle,
3919 #[allow(dead_code)]
3920 disk_actor_handle: tokio::task::JoinHandle<()>,
3921 external_ip: Option<std::net::IpAddr>,
3923 dht_v4_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
3925 dht_v6_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
3927 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
3929 sam_session: Option<Arc<crate::i2p::SamSession>>,
3931 ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
3933 ssl_listener: Option<Box<dyn crate::transport::TransportListener>>,
3935 validated_conn_rx: mpsc::Receiver<crate::listener::IdentifiedConnection>,
3937 info_hash_registry: Arc<DashMap<Id20, ()>>,
3942 #[allow(dead_code)] _listener_task: crate::listener::ListenerHandle,
3948 max_connections_global: Arc<std::sync::atomic::AtomicI32>,
3953 #[allow(dead_code)] live_connections: Arc<std::sync::atomic::AtomicUsize>,
3960 counters: Arc<crate::stats::SessionCounters>,
3962 factory: Arc<crate::transport::NetworkFactory>,
3964 hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
3966 category_registry: Arc<parking_lot::RwLock<crate::category_manager::CategoryRegistry>>,
3968 tag_registry: Arc<parking_lot::RwLock<crate::tag_manager::TagRegistry>>,
3970 deletion_grace: Arc<parking_lot::Mutex<std::collections::HashSet<Id20>>>,
3974 #[allow(dead_code)] reconfig_in_flight: crate::apply::ReconfigInFlight,
3980 self_alert_rx: broadcast::Receiver<Alert>,
3981 resume_save_notify: Arc<tokio::sync::Notify>,
3982 resume_save_lock: Arc<tokio::sync::Mutex<()>>,
3988 notification_settings_tx: tokio::sync::watch::Sender<Settings>,
3994 #[allow(dead_code)]
4003 notification_shutdown_tx: oneshot::Sender<()>,
4004 watched_folder_changed: Arc<tokio::sync::Notify>,
4012 #[allow(dead_code)]
4015 watched_folder_shutdown_tx: oneshot::Sender<()>,
4016}
4017
4018impl SessionActor {
4019 async fn get_entry_meta(&self, info_hash: Id20) -> crate::Result<irontide_core::TorrentMetaV1> {
4029 let entry = self
4030 .torrents
4031 .get(&info_hash)
4032 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
4033 entry
4034 .handle
4035 .get_meta()
4036 .await?
4037 .ok_or(crate::Error::MetadataNotReady(info_hash))
4038 }
4039
4040 async fn run(mut self) {
4041 let mut refill_interval = tokio::time::interval(std::time::Duration::from_millis(100));
4042 refill_interval.tick().await; let auto_manage_secs = self.settings.auto_manage_interval.max(1);
4045 let mut auto_manage_interval =
4046 tokio::time::interval(std::time::Duration::from_secs(auto_manage_secs));
4047 auto_manage_interval.tick().await; let stats_interval_ms = self.settings.stats_report_interval;
4051 let mut stats_timer = if stats_interval_ms > 0 {
4052 Some(tokio::time::interval(std::time::Duration::from_millis(
4053 stats_interval_ms,
4054 )))
4055 } else {
4056 None
4057 };
4058 if let Some(ref mut t) = stats_timer {
4059 t.tick().await; }
4061
4062 let sample_interval_secs = self.settings.dht_sample_infohashes_interval;
4064 let mut sample_timer = if sample_interval_secs > 0 {
4065 Some(tokio::time::interval(std::time::Duration::from_secs(
4066 sample_interval_secs,
4067 )))
4068 } else {
4069 None
4070 };
4071 if let Some(ref mut t) = sample_timer {
4072 t.tick().await; }
4074
4075 let mut resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
4077 Some(tokio::time::interval(std::time::Duration::from_secs(
4078 self.settings.save_resume_interval_secs,
4079 )))
4080 } else {
4081 None
4082 };
4083 if let Some(ref mut t) = resume_save_interval {
4084 t.tick().await; }
4086
4087 {
4089 let resume_dir = self.effective_resume_dir();
4090 let resume_files = crate::resume_file::scan_resume_dir(&resume_dir);
4091 if !resume_files.is_empty() {
4092 match self.handle_load_resume_state().await {
4094 Ok(result) => {
4095 info!(
4096 restored = result.restored,
4097 skipped = result.skipped,
4098 failed = result.failed,
4099 "auto-restored torrents on startup"
4100 );
4101 }
4102 Err(e) => {
4103 warn!("auto-restore on startup failed: {e}");
4104 }
4105 }
4106
4107 if self.settings.queueing_enabled {
4112 self.evaluate_queue().await;
4113 }
4114
4115 let active_hashes: std::collections::HashSet<String> = self
4118 .torrents
4119 .keys()
4120 .map(|h| hex::encode(h.as_bytes()))
4121 .collect();
4122
4123 let current_files = crate::resume_file::scan_resume_dir(&resume_dir);
4125 for path in ¤t_files {
4126 if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
4127 && !active_hashes.contains(stem)
4128 {
4129 if let Err(e) = std::fs::remove_file(path) {
4130 warn!(path = %path.display(), "failed to remove orphan resume file: {e}");
4131 } else {
4132 debug!(path = %path.display(), "removed orphan resume file");
4133 }
4134 }
4135 }
4136 }
4137 }
4138
4139 loop {
4140 tokio::select! {
4141 cmd = self.cmd_rx.recv() => {
4142 let recv_at = tokio::time::Instant::now();
4151 let queue_wait_ms = cmd.as_ref().map_or(0.0, |(sent_at, _)| {
4152 recv_at.saturating_duration_since(*sent_at).as_secs_f64() * 1000.0
4153 });
4154 let cmd_name = cmd.as_ref().map_or("<closed>", |(_, c)| c.name());
4155 let handler_start = tokio::time::Instant::now();
4156 let cmd = cmd.map(|(_sent_at, c)| c);
4157 match cmd {
4158 Some(SessionCommand::AddTorrent {
4159 meta,
4160 storage,
4161 download_dir,
4162 reply,
4163 }) => {
4164 let setup: crate::Result<AddTorrentPrepBundle> = (|| {
4172 let info_hash = meta.as_v1().map_or_else(
4173 || meta.info_hashes().best_v1(),
4174 |v| v.info_hash,
4175 );
4176 if self.torrents.contains_key(&info_hash) {
4177 return Err(crate::Error::DuplicateTorrent(info_hash));
4178 }
4179 if self.torrents.len() >= self.settings.max_torrents {
4180 return Err(crate::Error::SessionAtCapacity(
4181 self.settings.max_torrents,
4182 ));
4183 }
4184 Ok(self.build_add_torrent_prep_bundle(
4185 *meta,
4186 storage,
4187 download_dir,
4188 Vec::new(),
4189 None,
4190 ))
4191 })();
4192 match setup {
4193 Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
4194 Err(e) => {
4195 let _ = reply.send(Err(e));
4196 }
4197 }
4198 }
4199 Some(SessionCommand::CommitAddTorrent { result, reply }) => {
4200 let id = self.commit_add_torrent(result).await;
4207 let _ = reply.send(id);
4208 }
4209 Some(SessionCommand::AddMagnet { magnet, download_dir, reply }) => {
4210 let result = self
4213 .handle_add_magnet(magnet, download_dir, Vec::new())
4214 .await;
4215 let _ = reply.send(result);
4216 }
4217 Some(SessionCommand::RemoveTorrent { info_hash, reply }) => {
4218 let result = self.handle_remove_torrent(info_hash).await;
4219 let _ = reply.send(result);
4220 }
4221 Some(SessionCommand::PauseTorrent { info_hash, reply }) => {
4222 let result = self.handle_pause_torrent(info_hash).await;
4223 let _ = reply.send(result);
4224 }
4225 Some(SessionCommand::ResumeTorrent { info_hash, reply }) => {
4226 let result = self.handle_resume_torrent(info_hash).await;
4227 let _ = reply.send(result);
4228 }
4229 Some(SessionCommand::ForceResumeTorrent { info_hash, reply }) => {
4230 let result = self.handle_force_resume_torrent(info_hash).await;
4231 let _ = reply.send(result);
4232 }
4233 Some(SessionCommand::SetTorrentSeedRatio { info_hash, limit, reply }) => {
4234 let result = self.handle_set_torrent_seed_ratio(info_hash, limit).await;
4235 let _ = reply.send(result);
4236 }
4237 Some(SessionCommand::TorrentStats { info_hash, reply }) => {
4238 let result = self.handle_torrent_stats(info_hash).await;
4239 let _ = reply.send(result);
4240 }
4241 Some(SessionCommand::TorrentInfo { info_hash, reply }) => {
4242 let result = self.handle_torrent_info(info_hash).await;
4246 let _ = reply.send(result);
4247 }
4248 Some(SessionCommand::ListTorrents { reply }) => {
4249 let list: Vec<Id20> = self.torrents.keys().copied().collect();
4250 let _ = reply.send(list);
4251 }
4252 Some(SessionCommand::SessionStats { reply }) => {
4253 let stats = self.make_session_stats().await;
4254 let _ = reply.send(stats);
4255 }
4256 Some(SessionCommand::SaveTorrentResumeData { info_hash, reply }) => {
4257 let result = self.handle_save_torrent_resume(info_hash).await;
4258 let _ = reply.send(result);
4259 }
4260 Some(SessionCommand::SaveSessionState { reply }) => {
4261 let result = self.handle_save_session_state().await;
4262 let _ = reply.send(result);
4263 }
4264 Some(SessionCommand::LoadResumeState { reply }) => {
4265 let result = self.handle_load_resume_state().await;
4266 let _ = reply.send(result);
4267 }
4268 Some(SessionCommand::QueuePosition { info_hash, reply }) => {
4269 let result = match self.torrents.get(&info_hash) {
4270 Some(entry) => Ok(entry.queue_position),
4271 None => Err(crate::Error::TorrentNotFound(info_hash)),
4272 };
4273 let _ = reply.send(result);
4274 }
4275 Some(SessionCommand::SetQueuePosition { info_hash, pos, reply }) => {
4276 let result = self.handle_set_queue_position(info_hash, pos);
4277 let _ = reply.send(result);
4278 }
4279 Some(SessionCommand::QueuePositionUp { info_hash, reply }) => {
4280 let result = self.handle_queue_move(info_hash, crate::queue::move_up);
4281 let _ = reply.send(result);
4282 }
4283 Some(SessionCommand::QueuePositionDown { info_hash, reply }) => {
4284 let result = self.handle_queue_move(info_hash, crate::queue::move_down);
4285 let _ = reply.send(result);
4286 }
4287 Some(SessionCommand::QueuePositionTop { info_hash, reply }) => {
4288 let result = self.handle_queue_move(info_hash, crate::queue::move_top);
4289 let _ = reply.send(result);
4290 }
4291 Some(SessionCommand::QueuePositionBottom { info_hash, reply }) => {
4292 let result = self.handle_queue_move(info_hash, crate::queue::move_bottom);
4293 let _ = reply.send(result);
4294 }
4295 Some(SessionCommand::BanPeer { ip, reply }) => {
4296 self.ban_manager.write().ban(ip);
4297 let _ = reply.send(());
4298 }
4299 Some(SessionCommand::UnbanPeer { ip, reply }) => {
4300 let was_banned = self.ban_manager.write().unban(&ip);
4301 let _ = reply.send(was_banned);
4302 }
4303 Some(SessionCommand::BannedPeers { reply }) => {
4304 let list: Vec<IpAddr> = self.ban_manager.read()
4305 .banned_list().iter().copied().collect();
4306 let _ = reply.send(list);
4307 }
4308 Some(SessionCommand::SetIpFilter { filter, reply }) => {
4309 *self.ip_filter.write() = filter;
4310 let _ = reply.send(());
4311 }
4312 Some(SessionCommand::GetIpFilter { reply }) => {
4313 let filter = self.ip_filter.read().clone();
4314 let _ = reply.send(filter);
4315 }
4316 Some(SessionCommand::GetSettings { reply }) => {
4317 let _ = reply.send(self.settings.clone());
4318 }
4319 Some(SessionCommand::ApplySettings { settings, reply }) => {
4320 let result = self.handle_apply_settings(*settings);
4321 let _ = reply.send(result);
4322 }
4323 Some(SessionCommand::DhtNodeCount { reply }) => {
4324 let mut total: usize = 0;
4329 if let Some(dht) = &self.dht_v4
4330 && let Ok(c) = dht.node_count().await
4331 {
4332 total += c;
4333 }
4334 if let Some(dht) = &self.dht_v6
4335 && let Ok(c) = dht.node_count().await
4336 {
4337 total += c;
4338 }
4339 let _ = reply.send(total);
4340 }
4341 Some(SessionCommand::MoveTorrentStorage { info_hash, new_path, reply }) => {
4342 let result = self.handle_move_torrent_storage(info_hash, new_path).await;
4343 let _ = reply.send(result);
4344 }
4345 Some(SessionCommand::AddPeers { info_hash, peers, source, reply }) => {
4346 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4347 entry.handle.add_peers(peers, source).await
4348 } else {
4349 Err(crate::Error::TorrentNotFound(info_hash))
4350 };
4351 let _ = reply.send(result);
4352 }
4353 Some(SessionCommand::OpenFile { info_hash, file_index, reply }) => {
4354 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4355 entry.handle.open_file(file_index).await
4356 } else {
4357 Err(crate::Error::TorrentNotFound(info_hash))
4358 };
4359 let _ = reply.send(result);
4360 }
4361 Some(SessionCommand::ForceReannounce { info_hash, reply }) => {
4362 let result = match self.torrents.get(&info_hash) {
4363 Some(entry) => {
4364 entry.handle.force_reannounce().await
4365 }
4366 None => Err(crate::Error::TorrentNotFound(info_hash)),
4367 };
4368 let _ = reply.send(result);
4369 }
4370 Some(SessionCommand::TrackerList { info_hash, reply }) => {
4371 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4372 entry.handle.tracker_list().await
4373 } else {
4374 Err(crate::Error::TorrentNotFound(info_hash))
4375 };
4376 let _ = reply.send(result);
4377 }
4378 Some(SessionCommand::GetPeerSourceCounts { info_hash, reply }) => {
4379 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4380 entry.handle.peer_source_counts().await
4381 } else {
4382 Err(crate::Error::TorrentNotFound(info_hash))
4383 };
4384 let _ = reply.send(result);
4385 }
4386 Some(SessionCommand::QueryUnchokeDurations { info_hash, reply }) => {
4387 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4388 entry.handle.query_unchoke_durations().await.ok()
4389 } else {
4390 None
4391 };
4392 let _ = reply.send(result);
4393 }
4394 Some(SessionCommand::GetWebSeedStats { info_hash, reply }) => {
4395 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4396 entry.handle.get_web_seed_stats().await
4397 } else {
4398 Err(crate::Error::TorrentNotFound(info_hash))
4399 };
4400 let _ = reply.send(result);
4401 }
4402 Some(SessionCommand::GetWebSeeds { info_hash, reply }) => {
4403 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4404 entry.handle.get_web_seeds().await
4405 } else {
4406 Err(crate::Error::TorrentNotFound(info_hash))
4407 };
4408 let _ = reply.send(result);
4409 }
4410 Some(SessionCommand::GetPieceStates { info_hash, reply }) => {
4411 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4412 entry.handle.get_piece_states().await
4413 } else {
4414 Err(crate::Error::TorrentNotFound(info_hash))
4415 };
4416 let _ = reply.send(result);
4417 }
4418 Some(SessionCommand::GetPieceHashes { info_hash, offset, limit, reply }) => {
4419 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4420 entry.handle.get_piece_hashes(offset, limit).await
4421 } else {
4422 Err(crate::Error::TorrentNotFound(info_hash))
4423 };
4424 let _ = reply.send(result);
4425 }
4426 Some(SessionCommand::Scrape { info_hash, reply }) => {
4427 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4428 entry.handle.scrape().await
4429 } else {
4430 Err(crate::Error::TorrentNotFound(info_hash))
4431 };
4432 let _ = reply.send(result);
4433 }
4434 Some(SessionCommand::SetFilePriority { info_hash, index, priority, reply }) => {
4435 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4436 entry.handle.set_file_priority(index, priority).await
4437 } else {
4438 Err(crate::Error::TorrentNotFound(info_hash))
4439 };
4440 let _ = reply.send(result);
4441 }
4442 Some(SessionCommand::FilePriorities { info_hash, reply }) => {
4443 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4444 entry.handle.file_priorities().await
4445 } else {
4446 Err(crate::Error::TorrentNotFound(info_hash))
4447 };
4448 let _ = reply.send(result);
4449 }
4450 Some(SessionCommand::SetDownloadLimit { info_hash, bytes_per_sec, reply }) => {
4451 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4452 entry.handle.set_download_limit(bytes_per_sec).await
4453 } else {
4454 Err(crate::Error::TorrentNotFound(info_hash))
4455 };
4456 let _ = reply.send(result);
4457 }
4458 Some(SessionCommand::SetUploadLimit { info_hash, bytes_per_sec, reply }) => {
4459 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4460 entry.handle.set_upload_limit(bytes_per_sec).await
4461 } else {
4462 Err(crate::Error::TorrentNotFound(info_hash))
4463 };
4464 let _ = reply.send(result);
4465 }
4466 Some(SessionCommand::DownloadLimit { info_hash, reply }) => {
4467 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4468 entry.handle.download_limit().await
4469 } else {
4470 Err(crate::Error::TorrentNotFound(info_hash))
4471 };
4472 let _ = reply.send(result);
4473 }
4474 Some(SessionCommand::UploadLimit { info_hash, reply }) => {
4475 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4476 entry.handle.upload_limit().await
4477 } else {
4478 Err(crate::Error::TorrentNotFound(info_hash))
4479 };
4480 let _ = reply.send(result);
4481 }
4482 Some(SessionCommand::SetSequentialDownload { info_hash, enabled, reply }) => {
4483 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4484 entry.handle.set_sequential_download(enabled).await
4485 } else {
4486 Err(crate::Error::TorrentNotFound(info_hash))
4487 };
4488 let _ = reply.send(result);
4489 }
4490 Some(SessionCommand::IsSequentialDownload { info_hash, reply }) => {
4491 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4492 entry.handle.is_sequential_download().await
4493 } else {
4494 Err(crate::Error::TorrentNotFound(info_hash))
4495 };
4496 let _ = reply.send(result);
4497 }
4498 Some(SessionCommand::SetSuperSeeding { info_hash, enabled, reply }) => {
4499 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4500 entry.handle.set_super_seeding(enabled).await
4501 } else {
4502 Err(crate::Error::TorrentNotFound(info_hash))
4503 };
4504 let _ = reply.send(result);
4505 }
4506 Some(SessionCommand::IsSuperSeeding { info_hash, reply }) => {
4507 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4508 entry.handle.is_super_seeding().await
4509 } else {
4510 Err(crate::Error::TorrentNotFound(info_hash))
4511 };
4512 let _ = reply.send(result);
4513 }
4514 Some(SessionCommand::SetSeedMode { info_hash, enabled, reply }) => {
4515 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4516 entry.handle.set_seed_mode(enabled).await
4517 } else {
4518 Err(crate::Error::TorrentNotFound(info_hash))
4519 };
4520 let _ = reply.send(result);
4521 }
4522 Some(SessionCommand::AddTracker { info_hash, url, reply }) => {
4523 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4524 entry.handle.add_tracker(url).await
4525 } else {
4526 Err(crate::Error::TorrentNotFound(info_hash))
4527 };
4528 let _ = reply.send(result);
4529 }
4530 Some(SessionCommand::ReplaceTrackers { info_hash, urls, reply }) => {
4531 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4532 entry.handle.replace_trackers(urls).await
4533 } else {
4534 Err(crate::Error::TorrentNotFound(info_hash))
4535 };
4536 let _ = reply.send(result);
4537 }
4538 Some(SessionCommand::ForceRecheck { info_hash, reply }) => {
4539 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4540 entry.handle.force_recheck().await
4541 } else {
4542 Err(crate::Error::TorrentNotFound(info_hash))
4543 };
4544 let _ = reply.send(result);
4545 }
4546 Some(SessionCommand::RenameFile { info_hash, file_index, new_name, reply }) => {
4547 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4548 entry.handle.rename_file(file_index, new_name).await
4549 } else {
4550 Err(crate::Error::TorrentNotFound(info_hash))
4551 };
4552 let _ = reply.send(result);
4553 }
4554 Some(SessionCommand::SetMaxConnections { info_hash, limit, reply }) => {
4555 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4556 entry.handle.set_max_connections(limit).await
4557 } else {
4558 Err(crate::Error::TorrentNotFound(info_hash))
4559 };
4560 let _ = reply.send(result);
4561 }
4562 Some(SessionCommand::MaxConnections { info_hash, reply }) => {
4563 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4564 entry.handle.max_connections().await
4565 } else {
4566 Err(crate::Error::TorrentNotFound(info_hash))
4567 };
4568 let _ = reply.send(result);
4569 }
4570 Some(SessionCommand::SetMaxUploads { info_hash, limit, reply }) => {
4571 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4572 entry.handle.set_max_uploads(limit).await
4573 } else {
4574 Err(crate::Error::TorrentNotFound(info_hash))
4575 };
4576 let _ = reply.send(result);
4577 }
4578 Some(SessionCommand::MaxUploads { info_hash, reply }) => {
4579 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4580 entry.handle.max_uploads().await
4581 } else {
4582 Err(crate::Error::TorrentNotFound(info_hash))
4583 };
4584 let _ = reply.send(result);
4585 }
4586 Some(SessionCommand::GetPeerInfo { info_hash, reply }) => {
4587 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4588 entry.handle.get_peer_info().await
4589 } else {
4590 Err(crate::Error::TorrentNotFound(info_hash))
4591 };
4592 let _ = reply.send(result);
4593 }
4594 Some(SessionCommand::GetDownloadQueue { info_hash, reply }) => {
4595 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4596 entry.handle.get_download_queue().await
4597 } else {
4598 Err(crate::Error::TorrentNotFound(info_hash))
4599 };
4600 let _ = reply.send(result);
4601 }
4602 Some(SessionCommand::HavePiece { info_hash, index, reply }) => {
4603 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4604 entry.handle.have_piece(index).await
4605 } else {
4606 Err(crate::Error::TorrentNotFound(info_hash))
4607 };
4608 let _ = reply.send(result);
4609 }
4610 Some(SessionCommand::PieceAvailability { info_hash, reply }) => {
4611 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4612 entry.handle.piece_availability().await
4613 } else {
4614 Err(crate::Error::TorrentNotFound(info_hash))
4615 };
4616 let _ = reply.send(result);
4617 }
4618 Some(SessionCommand::FileProgress { info_hash, reply }) => {
4619 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4620 entry.handle.file_progress().await
4621 } else {
4622 Err(crate::Error::TorrentNotFound(info_hash))
4623 };
4624 let _ = reply.send(result);
4625 }
4626 Some(SessionCommand::InfoHashesQuery { info_hash, reply }) => {
4627 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4628 entry.handle.info_hashes().await
4629 } else {
4630 Err(crate::Error::TorrentNotFound(info_hash))
4631 };
4632 let _ = reply.send(result);
4633 }
4634 Some(SessionCommand::TorrentFile { info_hash, reply }) => {
4635 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4636 entry.handle.torrent_file().await
4637 } else {
4638 Err(crate::Error::TorrentNotFound(info_hash))
4639 };
4640 let _ = reply.send(result);
4641 }
4642 Some(SessionCommand::TorrentFileV2 { info_hash, reply }) => {
4643 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4644 entry.handle.torrent_file_v2().await
4645 } else {
4646 Err(crate::Error::TorrentNotFound(info_hash))
4647 };
4648 let _ = reply.send(result);
4649 }
4650 #[cfg(feature = "test-util")]
4651 Some(SessionCommand::TestInjectMetadata {
4652 info_hash,
4653 info_bytes,
4654 reply,
4655 }) => {
4656 let result = match self.torrents.get(&info_hash) {
4657 Some(entry) => {
4658 entry.handle.test_inject_metadata(info_bytes).await
4659 }
4660 None => Err(crate::Error::TorrentNotFound(info_hash)),
4661 };
4662 let _ = reply.send(result);
4663 }
4664 Some(SessionCommand::ForceDhtAnnounce { info_hash, reply }) => {
4665 let result = match self.torrents.get(&info_hash) {
4670 Some(entry) => {
4671 if entry.is_private().await {
4672 Err(crate::Error::InvalidSettings(
4673 "DHT disabled for private torrent".into(),
4674 ))
4675 } else {
4676 entry.handle.force_dht_announce().await
4677 }
4678 }
4679 None => Err(crate::Error::TorrentNotFound(info_hash)),
4680 };
4681 let _ = reply.send(result);
4682 }
4683 Some(SessionCommand::ForceLsdAnnounce { info_hash, reply }) => {
4684 let result = match self.torrents.get(&info_hash) {
4689 Some(entry) => {
4690 if entry.is_private().await {
4691 Err(crate::Error::InvalidSettings(
4693 "LSD disabled for private torrent".into(),
4694 ))
4695 } else {
4696 if let Some(ref lsd) = self.lsd {
4697 lsd.announce(vec![info_hash]).await;
4698 }
4699 Ok(())
4700 }
4701 }
4702 None => Err(crate::Error::TorrentNotFound(info_hash)),
4703 };
4704 let _ = reply.send(result);
4705 }
4706 Some(SessionCommand::ReadPiece { info_hash, index, reply }) => {
4707 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4708 entry.handle.read_piece(index).await
4709 } else {
4710 Err(crate::Error::TorrentNotFound(info_hash))
4711 };
4712 let _ = reply.send(result);
4713 }
4714 Some(SessionCommand::FlushCache { info_hash, reply }) => {
4715 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4716 entry.handle.flush_cache().await
4717 } else {
4718 Err(crate::Error::TorrentNotFound(info_hash))
4719 };
4720 let _ = reply.send(result);
4721 }
4722 Some(SessionCommand::IsValid { info_hash, reply }) => {
4723 let valid = self.torrents.get(&info_hash)
4724 .is_some_and(|e| e.handle.is_valid());
4725 let _ = reply.send(valid);
4726 }
4727 Some(SessionCommand::ClearError { info_hash, reply }) => {
4728 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4729 entry.handle.clear_error().await
4730 } else {
4731 Err(crate::Error::TorrentNotFound(info_hash))
4732 };
4733 let _ = reply.send(result);
4734 }
4735 Some(SessionCommand::FileStatus { info_hash, reply }) => {
4736 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4737 entry.handle.file_status().await
4738 } else {
4739 Err(crate::Error::TorrentNotFound(info_hash))
4740 };
4741 let _ = reply.send(result);
4742 }
4743 Some(SessionCommand::Flags { info_hash, reply }) => {
4744 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4745 entry.handle.flags().await
4746 } else {
4747 Err(crate::Error::TorrentNotFound(info_hash))
4748 };
4749 let _ = reply.send(result);
4750 }
4751 Some(SessionCommand::SetFlags { info_hash, flags, reply }) => {
4752 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4753 entry.handle.set_flags(flags).await
4754 } else {
4755 Err(crate::Error::TorrentNotFound(info_hash))
4756 };
4757 let _ = reply.send(result);
4758 }
4759 Some(SessionCommand::UnsetFlags { info_hash, flags, reply }) => {
4760 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4761 entry.handle.unset_flags(flags).await
4762 } else {
4763 Err(crate::Error::TorrentNotFound(info_hash))
4764 };
4765 let _ = reply.send(result);
4766 }
4767 Some(SessionCommand::ConnectPeer { info_hash, addr, reply }) => {
4768 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4769 entry.handle.connect_peer(addr).await
4770 } else {
4771 Err(crate::Error::TorrentNotFound(info_hash))
4772 };
4773 let _ = reply.send(result);
4774 }
4775 Some(SessionCommand::DhtPutImmutable { value, reply }) => {
4776 let result = self.handle_dht_put_immutable(value).await;
4777 let _ = reply.send(result);
4778 }
4779 Some(SessionCommand::DhtGetImmutable { target, reply }) => {
4780 let result = self.handle_dht_get_immutable(target).await;
4781 let _ = reply.send(result);
4782 }
4783 Some(SessionCommand::DhtPutMutable { keypair_bytes, value, seq, salt, reply }) => {
4784 let result = self.handle_dht_put_mutable(keypair_bytes, value, seq, salt).await;
4785 let _ = reply.send(result);
4786 }
4787 Some(SessionCommand::DhtGetMutable { public_key, salt, reply }) => {
4788 let result = self.handle_dht_get_mutable(public_key, salt).await;
4789 let _ = reply.send(result);
4790 }
4791 Some(SessionCommand::PostSessionStats) => {
4792 self.fire_stats_alert();
4793 }
4794 Some(SessionCommand::SaveResumeState { reply }) => {
4795 let lock = Arc::clone(&self.resume_save_lock);
4804 let (resume_dir, jobs) = self.snapshot_resume_jobs();
4805 tokio::spawn(async move {
4806 let _guard = lock.lock_owned().await;
4807 let count = run_resume_save_jobs(resume_dir, jobs).await;
4808 let _ = reply.send(Ok(count));
4809 });
4810 }
4811 Some(SessionCommand::AddTorrentM170 { params, reply }) => {
4812 self.dispatch_add_torrent_m170(*params, reply).await;
4816 }
4817 Some(SessionCommand::CreateCategory { name, save_path, reply }) => {
4818 let result = self.handle_create_category(name, save_path).await;
4819 let _ = reply.send(result);
4820 }
4821 Some(SessionCommand::EditCategory { name, save_path, reply }) => {
4822 let result = self.handle_edit_category(name, save_path).await;
4823 let _ = reply.send(result);
4824 }
4825 Some(SessionCommand::RemoveCategories { names, reply }) => {
4826 let result = self.handle_remove_categories(names).await;
4827 let _ = reply.send(result);
4828 }
4829 Some(SessionCommand::ListCategories { reply }) => {
4830 let snapshot = self.category_registry.read().list();
4831 let _ = reply.send(snapshot);
4832 }
4833 Some(SessionCommand::CreateTags { names, reply }) => {
4834 let results: Vec<_> = {
4835 let mut reg = self.tag_registry.write();
4836 names.into_iter().map(|n| reg.create(n)).collect()
4837 };
4838 if let Err(e) = self.persist_tag_registry().await {
4842 tracing::warn!(
4843 error = %e,
4844 "failed to persist tag registry after CreateTags"
4845 );
4846 }
4847 let _ = reply.send(results);
4848 }
4849 Some(SessionCommand::DeleteTags { names, reply }) => {
4850 let removed = self.handle_delete_tags(names).await;
4851 let _ = reply.send(removed);
4852 }
4853 Some(SessionCommand::ListTags { reply }) => {
4854 let names = self.tag_registry.read().list();
4855 let _ = reply.send(names);
4856 }
4857 Some(SessionCommand::AddTagsToTorrents { info_hashes, tags, reply }) => {
4858 let res = self.handle_add_tags_to_torrents(info_hashes, tags).await;
4859 let _ = reply.send(res);
4860 }
4861 Some(SessionCommand::RemoveTagsFromTorrents { info_hashes, tags, reply }) => {
4862 let res = self
4863 .handle_remove_tags_from_torrents(info_hashes, tags)
4864 .await;
4865 let _ = reply.send(res);
4866 }
4867 Some(SessionCommand::RemoveTorrentWithFiles { info_hash, reply }) => {
4868 let result = self.handle_remove_torrent_with_files(info_hash).await;
4869 let _ = reply.send(result);
4870 }
4871 Some(SessionCommand::DebugState { reply }) => {
4872 let state = self.make_debug_state().await;
4873 let _ = reply.send(state);
4874 }
4875 Some(SessionCommand::Shutdown) | None => {
4876 self.shutdown_all().await;
4877 return;
4878 }
4879 }
4880 let handler_ms = handler_start.elapsed().as_secs_f64() * 1000.0;
4887 info!(
4888 target: "irontide_session::cmd_timing",
4889 cmd = cmd_name,
4890 queue_wait_ms = queue_wait_ms,
4891 handler_ms = handler_ms,
4892 "session_cmd"
4893 );
4894 }
4895 result = async {
4896 match &mut self.lsd_peers_rx {
4897 Some(rx) => rx.recv().await,
4898 None => std::future::pending().await,
4899 }
4900 } => {
4901 if let Some((info_hash, peer_addr)) = result
4902 && let Some(entry) = self.torrents.get(&info_hash)
4903 {
4904 let is_priv = entry.is_private().await;
4907 if !is_priv {
4908 let _ = entry.handle.add_peers(vec![peer_addr], crate::peer_state::PeerSource::Lsd).await;
4910 }
4911 }
4912 }
4913 Some(conn) = self.validated_conn_rx.recv() => {
4915 self.handle_identified_inbound(conn);
4916 }
4917 result = async {
4919 if let Some(ref mut listener) = self.ssl_listener {
4920 listener.accept().await
4921 } else {
4922 std::future::pending().await
4923 }
4924 } => {
4925 if let Ok((stream, addr)) = result {
4926 self.handle_ssl_incoming(stream, addr).await;
4927 }
4928 }
4929 _ = refill_interval.tick() => {
4931 let elapsed = std::time::Duration::from_millis(100);
4932 self.global_upload_bucket.lock().refill(elapsed);
4933 self.global_download_bucket.lock().refill(elapsed);
4934 }
4935 _ = auto_manage_interval.tick() => {
4937 self.evaluate_queue().await;
4938 }
4939 alert = self.self_alert_rx.recv() => {
4943 if let Ok(alert) = alert
4944 && matches!(
4945 alert.kind,
4946 AlertKind::StateChanged {
4947 prev_state: TorrentState::Checking,
4948 new_state,
4949 ..
4950 } if new_state != TorrentState::Checking
4951 )
4952 {
4953 self.evaluate_queue().await;
4954 }
4955 }
4956 event = recv_nat_event(&mut self.nat_events_rx) => {
4958 match event {
4959 irontide_nat::NatEvent::MappingSucceeded { port, protocol } => {
4960 info!(port, %protocol, "port mapping succeeded");
4961 post_alert(
4962 &self.alert_tx,
4963 &self.alert_mask,
4964 AlertKind::PortMappingSucceeded { port, protocol },
4965 );
4966 }
4967 irontide_nat::NatEvent::MappingFailed { port, message } => {
4968 warn!(port, %message, "port mapping failed");
4969 post_alert(
4970 &self.alert_tx,
4971 &self.alert_mask,
4972 AlertKind::PortMappingFailed { port, message },
4973 );
4974 }
4975 irontide_nat::NatEvent::ExternalIpDiscovered { ip } => {
4976 info!(%ip, "external IP discovered via NAT traversal");
4977 self.external_ip = Some(ip);
4978 for entry in self.torrents.values() {
4980 let _ = entry.handle.update_external_ip(ip).await;
4981 }
4982 if let Some(dht) = &self.dht_v4 {
4984 let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
4985 }
4986 if let Some(dht) = &self.dht_v6 {
4987 let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
4988 }
4989 }
4990 }
4991 }
4992 Some(ip) = recv_dht_ip(&mut self.dht_v4_ip_rx) => {
4994 info!(%ip, "external IP discovered via DHT v4 (BEP 42)");
4995 self.external_ip = Some(ip);
4996 for entry in self.torrents.values() {
4997 let _ = entry.handle.update_external_ip(ip).await;
4998 }
4999 }
5000 Some(ip) = recv_dht_ip(&mut self.dht_v6_ip_rx) => {
5002 info!(%ip, "external IP discovered via DHT v6 (BEP 42)");
5003 self.external_ip = Some(ip);
5004 for entry in self.torrents.values() {
5005 let _ = entry.handle.update_external_ip(ip).await;
5006 }
5007 }
5008 _ = async {
5010 match &mut stats_timer {
5011 Some(t) => t.tick().await,
5012 None => std::future::pending().await,
5013 }
5014 } => {
5015 let _ = self.make_session_stats().await;
5028 self.fire_stats_alert();
5029 }
5030 _ = async {
5032 match &mut sample_timer {
5033 Some(t) => t.tick().await,
5034 None => std::future::pending().await,
5035 }
5036 } => {
5037 self.fire_sample_infohashes().await;
5038 }
5039 _ = async {
5041 match &mut resume_save_interval {
5042 Some(t) => t.tick().await,
5043 None => std::future::pending().await,
5044 }
5045 } => {
5046 match Arc::clone(&self.resume_save_lock).try_lock_owned() {
5050 Ok(guard) => {
5051 let (resume_dir, jobs) = self.snapshot_resume_jobs();
5052 tokio::spawn(async move {
5053 let _guard = guard;
5054 let count = run_resume_save_jobs(resume_dir, jobs).await;
5055 if count > 0 {
5056 info!(count, "periodic resume save completed");
5057 }
5058 });
5059 }
5060 Err(_) => {
5061 debug!("resume save already in flight — skipping this periodic tick");
5062 }
5063 }
5064 }
5065 () = self.resume_save_notify.notified() => {
5067 resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
5068 Some(tokio::time::interval(std::time::Duration::from_secs(
5069 self.settings.save_resume_interval_secs,
5070 )))
5071 } else {
5072 None
5073 };
5074 if let Some(ref mut t) = resume_save_interval {
5075 t.tick().await; }
5077 }
5078 }
5079 }
5080 }
5081
5082 fn global_buckets_if_limited(&self) -> (Option<SharedBucket>, Option<SharedBucket>) {
5084 let up = if self.settings.upload_rate_limit > 0 {
5085 Some(Arc::clone(&self.global_upload_bucket))
5086 } else {
5087 None
5088 };
5089 let down = if self.settings.download_rate_limit > 0 {
5090 Some(Arc::clone(&self.global_download_bucket))
5091 } else {
5092 None
5093 };
5094 (up, down)
5095 }
5096
5097 fn make_slot_tuner(&self) -> crate::slot_tuner::SlotTuner {
5098 if self.settings.auto_upload_slots {
5099 crate::slot_tuner::SlotTuner::new(
5100 4, self.settings.auto_upload_slots_min,
5102 self.settings.auto_upload_slots_max,
5103 )
5104 } else {
5105 crate::slot_tuner::SlotTuner::disabled(4)
5106 }
5107 }
5108
5109 fn make_torrent_config(&self) -> TorrentConfig {
5110 TorrentConfig::from(&self.settings)
5111 }
5112
5113 fn next_queue_position(&self) -> i32 {
5115 self.torrents
5116 .values()
5117 .filter(|e| e.auto_managed)
5118 .map(|e| e.queue_position)
5119 .max()
5120 .map_or(0, |m| m + 1)
5121 }
5122
5123 async fn handle_add_torrent(
5132 &mut self,
5133 torrent_meta: irontide_core::TorrentMeta,
5134 storage: Option<Arc<dyn TorrentStorage>>,
5135 download_dir: Option<PathBuf>,
5136 tags: Vec<String>,
5137 ) -> crate::Result<Id20> {
5138 let info_hash = torrent_meta
5139 .as_v1()
5140 .map_or_else(|| torrent_meta.info_hashes().best_v1(), |v| v.info_hash);
5141 if self.torrents.contains_key(&info_hash) {
5142 return Err(crate::Error::DuplicateTorrent(info_hash));
5143 }
5144 if self.torrents.len() >= self.settings.max_torrents {
5145 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5146 }
5147 let bundle =
5148 self.build_add_torrent_prep_bundle(torrent_meta, storage, download_dir, tags, None);
5149 let prep = prepare_add_torrent_off_actor(bundle).await;
5150 self.commit_add_torrent(prep).await
5151 }
5152
5153 fn build_add_torrent_prep_bundle(
5158 &self,
5159 torrent_meta: irontide_core::TorrentMeta,
5160 storage: Option<Arc<dyn TorrentStorage>>,
5161 download_dir: Option<PathBuf>,
5162 tags: Vec<String>,
5163 m170_post: Option<M170PostAdd>,
5164 ) -> AddTorrentPrepBundle {
5165 let mut torrent_config = self.make_torrent_config();
5166 if let Some(dir) = download_dir {
5167 torrent_config.download_dir = dir;
5168 }
5169 torrent_config.tags = tags;
5174
5175 let (global_up, global_down) = self.global_buckets_if_limited();
5176 let slot_tuner = self.make_slot_tuner();
5177
5178 AddTorrentPrepBundle {
5179 torrent_meta,
5180 storage_override: storage,
5181 torrent_config,
5182 disk_manager: self.disk_manager.clone(),
5183 dht_v4_broadcast: self.dht_v4_broadcast.clone(),
5184 dht_v6_broadcast: self.dht_v6_broadcast.clone(),
5185 global_up,
5186 global_down,
5187 slot_tuner,
5188 alert_tx: self.alert_tx.clone(),
5189 alert_mask: Arc::clone(&self.alert_mask),
5190 utp_socket: self.utp_socket.clone(),
5191 utp_socket_v6: self.utp_socket_v6.clone(),
5192 ban_manager: Arc::clone(&self.ban_manager),
5193 ip_filter: Arc::clone(&self.ip_filter),
5194 plugins: Arc::clone(&self.plugins),
5195 sam_session: self.sam_session.clone(),
5196 ssl_manager: self.ssl_manager.clone(),
5197 factory: Arc::clone(&self.factory),
5198 hash_pool: Arc::clone(&self.hash_pool),
5199 counters: Arc::clone(&self.counters),
5200 m170_post,
5201 }
5202 }
5203
5204 async fn commit_add_torrent(
5210 &mut self,
5211 prep: crate::Result<PreparedAddTorrent>,
5212 ) -> crate::Result<Id20> {
5213 let PreparedAddTorrent {
5214 handle,
5215 info_hash,
5216 is_private,
5217 m170_post,
5218 } = prep?;
5219 if self.torrents.contains_key(&info_hash) {
5225 drop(handle);
5226 return Err(crate::Error::DuplicateTorrent(info_hash));
5227 }
5228 if self.torrents.len() >= self.settings.max_torrents {
5229 drop(handle);
5230 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5231 }
5232 self.torrents.insert(
5233 info_hash,
5234 TorrentEntry {
5235 handle,
5236 queue_position: -1,
5237 auto_managed: true,
5238 started_at: Some(tokio::time::Instant::now()),
5239 smoothed_download_rate: f64::MAX,
5240 smoothed_upload_rate: f64::MAX,
5241 },
5242 );
5243 self.info_hash_registry.insert(info_hash, ());
5244
5245 let pos = self.next_queue_position();
5247 if let Some(entry) = self.torrents.get_mut(&info_hash)
5248 && entry.auto_managed
5249 {
5250 entry.queue_position = pos;
5251 }
5252
5253 info!(%info_hash, "torrent added to session");
5254 if let Some(ref lsd) = self.lsd
5261 && !is_private
5262 {
5263 lsd.announce(vec![info_hash]).await;
5264 }
5265 if let Some(M170PostAdd { category, paused }) = m170_post {
5269 self.apply_post_add_m170(info_hash, category, paused);
5270 }
5271 self.snapshot_publish_one(info_hash).await;
5276 Ok(info_hash)
5277 }
5278
5279 fn try_spawn_add_torrent(
5283 &self,
5284 bundle: AddTorrentPrepBundle,
5285 reply: oneshot::Sender<crate::Result<Id20>>,
5286 ) {
5287 let commit_tx = self.commit_tx.clone();
5288 tokio::spawn(async move {
5289 let result = prepare_add_torrent_off_actor(bundle).await;
5290 if commit_tx
5291 .send(SessionCommand::CommitAddTorrent { result, reply })
5292 .await
5293 .is_err()
5294 {
5295 warn!("M223 prep task: commit_tx send failed (session shutting down)");
5300 }
5301 });
5302 }
5303
5304 async fn handle_add_magnet(
5305 &mut self,
5306 magnet: Magnet,
5307 download_dir: Option<PathBuf>,
5308 tags: Vec<String>,
5309 ) -> crate::Result<Id20> {
5310 let info_hash = magnet.info_hash();
5311 let display_name = magnet.display_name.clone().unwrap_or_default();
5312 if self.torrents.contains_key(&info_hash) {
5313 return Err(crate::Error::DuplicateTorrent(info_hash));
5314 }
5315 if self.torrents.len() >= self.settings.max_torrents {
5316 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5317 }
5318 let mut config = self.make_torrent_config();
5319 if let Some(dir) = download_dir {
5320 config.download_dir = dir;
5321 }
5322 config.tags = tags;
5327 let (global_up, global_down) = self.global_buckets_if_limited();
5328 let slot_tuner = self.make_slot_tuner();
5329 let handle = TorrentHandle::from_magnet(
5330 magnet,
5331 self.disk_manager.clone(),
5332 config,
5333 self.dht_v4_broadcast.subscribe(),
5334 self.dht_v6_broadcast.subscribe(),
5335 global_up,
5336 global_down,
5337 slot_tuner,
5338 self.alert_tx.clone(),
5339 Arc::clone(&self.alert_mask),
5340 self.utp_socket.clone(),
5341 self.utp_socket_v6.clone(),
5342 Arc::clone(&self.ban_manager),
5343 Arc::clone(&self.ip_filter),
5344 Arc::clone(&self.plugins),
5345 self.sam_session.clone(),
5346 self.ssl_manager.clone(),
5347 Arc::clone(&self.factory),
5348 Some(Arc::clone(&self.hash_pool)),
5349 Arc::clone(&self.counters),
5350 )
5351 .await?;
5352 self.spawn_metadata_resolver(info_hash, &handle);
5356
5357 self.torrents.insert(
5358 info_hash,
5359 TorrentEntry {
5360 handle,
5361 queue_position: -1,
5362 auto_managed: true,
5363 started_at: Some(tokio::time::Instant::now()),
5364 smoothed_download_rate: f64::MAX,
5365 smoothed_upload_rate: f64::MAX,
5366 },
5367 );
5368 self.info_hash_registry.insert(info_hash, ());
5369
5370 let pos = self.next_queue_position();
5372 if let Some(entry) = self.torrents.get_mut(&info_hash)
5373 && entry.auto_managed
5374 {
5375 entry.queue_position = pos;
5376 }
5377
5378 info!(%info_hash, "magnet torrent added to session");
5379 post_alert(
5380 &self.alert_tx,
5381 &self.alert_mask,
5382 AlertKind::TorrentAdded {
5383 info_hash,
5384 name: display_name,
5385 },
5386 );
5387 if let Some(ref lsd) = self.lsd {
5391 lsd.announce(vec![info_hash]).await;
5392 }
5393 self.snapshot_publish_one(info_hash).await;
5398 Ok(info_hash)
5399 }
5400
5401 fn spawn_metadata_resolver(&self, info_hash: Id20, torrent_handle: &TorrentHandle) {
5408 let dht = match self.dht_v4 {
5409 Some(ref dht) => dht.clone(),
5410 None => return, };
5412 let factory = Arc::clone(&self.factory);
5413 let connect_timeout = std::time::Duration::from_secs(self.settings.peer_connect_timeout);
5414 let handle = torrent_handle.clone();
5415
5416 tokio::spawn(async move {
5417 let peer_rx = match dht.get_peers(info_hash).await {
5418 Ok(rx) => rx,
5419 Err(e) => {
5420 debug!(
5421 %info_hash,
5422 "metadata resolver: failed to start DHT get_peers: {e}"
5423 );
5424 return;
5425 }
5426 };
5427
5428 let peer_id = irontide_core::PeerId::generate().0;
5429 match crate::metadata_resolver::resolve_metadata(
5430 info_hash,
5431 peer_id,
5432 peer_rx,
5433 factory,
5434 connect_timeout,
5435 crate::metadata_resolver::DEFAULT_MAX_CONCURRENT,
5436 )
5437 .await
5438 {
5439 Ok((meta, peers)) => {
5440 let info_bytes = if let Some(b) = meta.info_bytes {
5441 b.to_vec()
5442 } else {
5443 match irontide_bencode::to_bytes(&meta.info) {
5444 Ok(bytes) => bytes,
5445 Err(e) => {
5446 debug!(
5447 %info_hash,
5448 "metadata resolver: failed to re-encode info dict: {e}"
5449 );
5450 return;
5451 }
5452 }
5453 };
5454 debug!(
5455 %info_hash,
5456 num_peers = peers.len(),
5457 "metadata resolver: pre-resolved metadata, sending to torrent actor"
5458 );
5459 handle.send_pre_resolved_metadata(info_bytes, peers);
5460 }
5461 Err(e) => {
5462 debug!(
5463 %info_hash,
5464 "metadata resolver: failed to resolve metadata: {e}"
5465 );
5466 }
5467 }
5468 });
5469 }
5470
5471 async fn handle_remove_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5472 let entry = self
5473 .torrents
5474 .remove(&info_hash)
5475 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5476 self.info_hash_registry.remove(&info_hash);
5477 self.snapshot_drop_one(info_hash);
5481 let was_auto_managed = entry.auto_managed;
5482 let removed_position = entry.queue_position;
5483 entry.handle.shutdown().await?;
5484 self.disk_manager.unregister_torrent(info_hash).await;
5485
5486 if was_auto_managed && removed_position >= 0 {
5488 let mut entries = self.queue_entries();
5489 let changed = crate::queue::remove_position(&mut entries, removed_position);
5490 self.apply_queue_changes(&changed);
5491 }
5492
5493 let resume_dir = self.effective_resume_dir();
5497 if let Err(e) = crate::resume_file::delete_resume_file(&resume_dir, &info_hash) {
5498 if e.kind() != std::io::ErrorKind::NotFound {
5500 warn!(%info_hash, "failed to delete resume file on removal: {e}");
5501 }
5502 }
5503
5504 info!(%info_hash, "torrent removed from session");
5505 post_alert(
5506 &self.alert_tx,
5507 &self.alert_mask,
5508 AlertKind::TorrentRemoved { info_hash },
5509 );
5510 Ok(())
5511 }
5512
5513 async fn dispatch_add_torrent_m170(
5527 &mut self,
5528 params: AddTorrentParams,
5529 reply: oneshot::Sender<crate::Result<Id20>>,
5530 ) {
5531 let (resolved_dir, resolved_category) =
5534 match self.resolve_download_dir_and_category(¶ms) {
5535 Ok(x) => x,
5536 Err(e) => {
5537 let _ = reply.send(Err(e));
5538 return;
5539 }
5540 };
5541
5542 let AddTorrentParams {
5543 source,
5544 tags,
5545 paused,
5546 skip_checking: _, ..
5548 } = params;
5549
5550 let paused = paused.unwrap_or(self.settings.default_add_paused);
5553
5554 match source {
5555 AddSource::Magnet(uri) => {
5556 let result: crate::Result<Id20> = async {
5558 let magnet = irontide_core::Magnet::parse(&uri)?;
5559 let info_hash = magnet.info_hash();
5560 self.reject_if_in_deletion_grace(info_hash)?;
5561 let id = self.handle_add_magnet(magnet, resolved_dir, tags).await?;
5562 self.apply_post_add_m170(id, resolved_category, paused);
5563 Ok(id)
5564 }
5565 .await;
5566 let _ = reply.send(result);
5567 }
5568 AddSource::Bytes(bytes) => {
5569 let setup: crate::Result<AddTorrentPrepBundle> = (|| {
5571 let meta = irontide_core::torrent_from_bytes_any(&bytes)?;
5572 let info_hash = meta
5573 .as_v1()
5574 .map_or_else(|| meta.info_hashes().best_v1(), |v| v.info_hash);
5575 self.reject_if_in_deletion_grace(info_hash)?;
5576 if self.torrents.contains_key(&info_hash) {
5577 return Err(crate::Error::DuplicateTorrent(info_hash));
5578 }
5579 if self.torrents.len() >= self.settings.max_torrents {
5580 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5581 }
5582 Ok(self.build_add_torrent_prep_bundle(
5583 meta,
5584 None,
5585 resolved_dir,
5586 tags,
5587 Some(M170PostAdd {
5588 category: resolved_category,
5589 paused,
5590 }),
5591 ))
5592 })();
5593 match setup {
5594 Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
5595 Err(e) => {
5596 let _ = reply.send(Err(e));
5597 }
5598 }
5599 }
5600 }
5601 }
5602
5603 fn resolve_download_dir_and_category(
5606 &self,
5607 params: &AddTorrentParams,
5608 ) -> crate::Result<(Option<PathBuf>, Option<String>)> {
5609 match (¶ms.download_dir, ¶ms.category) {
5610 (Some(explicit), cat) => {
5611 Ok((Some(explicit.clone()), cat.clone()))
5614 }
5615 (None, Some(name)) => {
5616 let registry = self.category_registry.read();
5617 match registry.get(name) {
5618 Some(meta) => Ok((Some(meta.save_path.clone()), Some(name.clone()))),
5619 None => Err(crate::Error::CategoryNotFound(name.clone())),
5620 }
5621 }
5622 (None, None) => Ok((None, None)),
5623 }
5624 }
5625
5626 fn reject_if_in_deletion_grace(&self, info_hash: Id20) -> crate::Result<()> {
5629 if self.deletion_grace.lock().contains(&info_hash) {
5630 return Err(crate::Error::TorrentBeingRemoved(info_hash));
5631 }
5632 Ok(())
5633 }
5634
5635 fn apply_post_add_m170(&self, info_hash: Id20, category: Option<String>, paused: bool) {
5639 if let Some(entry) = self.torrents.get(&info_hash) {
5640 if let Some(name) = category {
5644 let handle = entry.handle.clone();
5645 tokio::spawn(async move {
5646 if let Err(e) = handle.set_category(Some(name)).await {
5647 warn!(%info_hash, "failed to propagate category: {e}");
5648 }
5649 });
5650 }
5651 if paused {
5652 let handle = entry.handle.clone();
5653 tokio::spawn(async move {
5654 if let Err(e) = handle.pause().await {
5655 warn!(%info_hash, "failed to pause on add: {e}");
5656 }
5657 });
5658 }
5659 }
5660 }
5661
5662 async fn handle_create_category(
5664 &self,
5665 name: String,
5666 save_path: PathBuf,
5667 ) -> Result<(), crate::category_manager::CategoryError> {
5668 {
5669 let mut registry = self.category_registry.write();
5670 registry.create(name, save_path)?;
5671 }
5672 self.persist_category_registry().await
5673 }
5674
5675 async fn handle_edit_category(
5677 &self,
5678 name: String,
5679 save_path: PathBuf,
5680 ) -> Result<(), crate::category_manager::CategoryError> {
5681 {
5682 let mut registry = self.category_registry.write();
5683 registry.edit(&name, save_path)?;
5684 }
5685 self.persist_category_registry().await
5686 }
5687
5688 async fn handle_remove_categories(&self, names: Vec<String>) -> Vec<String> {
5692 let removed: Vec<String> = {
5693 let mut registry = self.category_registry.write();
5694 registry.remove(&names)
5695 };
5696 if removed.is_empty() {
5697 return removed;
5698 }
5699
5700 for entry in self.torrents.values() {
5704 let handle = entry.handle.clone();
5705 let to_check: Vec<String> = removed.clone();
5706 tokio::spawn(async move {
5707 if let Ok(stats) = handle.stats().await
5708 && let Some(current) = stats.category
5709 && to_check.iter().any(|n| n.as_str() == current.as_str())
5710 && let Err(e) = handle.set_category(None).await
5711 {
5712 warn!(
5713 cat = %current,
5714 "failed to clear category label after removeCategories: {e}"
5715 );
5716 }
5717 });
5718 }
5719
5720 if let Err(e) = self.persist_category_registry().await {
5721 warn!("failed to persist category registry after remove: {e}");
5722 }
5723 removed
5724 }
5725
5726 async fn persist_category_registry(
5728 &self,
5729 ) -> Result<(), crate::category_manager::CategoryError> {
5730 let registry = Arc::clone(&self.category_registry);
5731 let snapshot = registry.read().clone();
5734 tokio::task::spawn_blocking(move || snapshot.save())
5735 .await
5736 .map_err(|join_err| {
5737 crate::category_manager::CategoryError::Persistence(std::io::Error::other(format!(
5738 "category registry save join error: {join_err}"
5739 )))
5740 })?
5741 }
5742
5743 async fn handle_delete_tags(&self, names: Vec<String>) -> Vec<String> {
5750 let removed = {
5751 let mut reg = self.tag_registry.write();
5752 reg.delete(&names)
5753 };
5754 if !removed.is_empty() {
5755 let to_remove: std::collections::HashSet<String> = removed.iter().cloned().collect();
5756 for entry in self.torrents.values() {
5757 let handle = entry.handle.clone();
5758 let to_remove = to_remove.clone();
5759 tokio::spawn(async move {
5760 if let Ok(stats) = handle.stats().await {
5761 let new_tags: Vec<String> = stats
5762 .tags
5763 .into_iter()
5764 .filter(|t| !to_remove.contains(t))
5765 .collect();
5766 if let Err(e) = handle.set_tags(new_tags).await {
5767 tracing::warn!(error = %e, "failed to apply tag deletion to torrent");
5768 }
5769 }
5770 });
5771 }
5772 if let Err(e) = self.persist_tag_registry().await {
5773 tracing::warn!(error = %e, "persist tag registry after DeleteTags");
5774 }
5775 }
5776 removed
5777 }
5778
5779 async fn handle_add_tags_to_torrents(
5791 &self,
5792 info_hashes: Vec<Id20>,
5793 tags_to_add: Vec<String>,
5794 ) -> crate::Result<()> {
5795 for hash in info_hashes {
5796 let Some(entry) = self.torrents.get(&hash) else {
5797 continue;
5798 };
5799 let current = entry.handle.stats().await?;
5800 let mut new_tags = current.tags;
5801 for t in &tags_to_add {
5802 if !new_tags.contains(t) {
5803 new_tags.push(t.clone());
5804 }
5805 }
5806 new_tags.sort();
5807 new_tags.dedup();
5808 entry.handle.set_tags(new_tags).await?;
5809 }
5810 Ok(())
5811 }
5812
5813 async fn handle_remove_tags_from_torrents(
5822 &self,
5823 info_hashes: Vec<Id20>,
5824 tags_to_remove: Vec<String>,
5825 ) -> crate::Result<()> {
5826 for hash in info_hashes {
5827 let Some(entry) = self.torrents.get(&hash) else {
5828 continue;
5829 };
5830 let current = entry.handle.stats().await?;
5831 let new_tags: Vec<String> = current
5832 .tags
5833 .into_iter()
5834 .filter(|t| !tags_to_remove.contains(t))
5835 .collect();
5836 entry.handle.set_tags(new_tags).await?;
5837 }
5838 Ok(())
5839 }
5840
5841 async fn persist_tag_registry(&self) -> Result<(), crate::tag_manager::TagError> {
5844 let to_save: crate::tag_manager::TagRegistry = { self.tag_registry.read().clone() };
5845 tokio::task::spawn_blocking(move || to_save.save())
5846 .await
5847 .unwrap_or_else(|_| {
5848 Err(crate::tag_manager::TagError::Persistence(
5849 std::io::Error::other("spawn_blocking failed"),
5850 ))
5851 })
5852 }
5853
5854 async fn handle_remove_torrent_with_files(&mut self, info_hash: Id20) -> crate::Result<()> {
5856 let handle = {
5866 let entry = self
5867 .torrents
5868 .get(&info_hash)
5869 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5870 entry.handle.clone()
5871 };
5872 let file_paths: Vec<PathBuf> = match handle.get_meta().await {
5873 Ok(Some(meta)) => meta
5874 .info
5875 .files()
5876 .iter()
5877 .map(|f| f.path.iter().collect::<PathBuf>())
5878 .collect(),
5879 Ok(None) | Err(_) => Vec::new(),
5882 };
5883 let download_dir = self.settings.download_dir.clone();
5884 let _ = handle.pause().await;
5885
5886 self.deletion_grace.lock().insert(info_hash);
5890
5891 let remove_result = self.handle_remove_torrent(info_hash).await;
5894 if let Err(e) = &remove_result {
5895 warn!(
5896 %info_hash,
5897 error = %e,
5898 "remove_torrent_with_files: in-memory removal failed; continuing with file delete"
5899 );
5900 }
5901
5902 let grace = Arc::clone(&self.deletion_grace);
5907 tokio::task::spawn_blocking(move || {
5908 irontide_storage::delete_torrent_files_sync(download_dir, file_paths);
5909 grace.lock().remove(&info_hash);
5910 });
5911
5912 Ok(())
5913 }
5914
5915 async fn handle_pause_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5916 let entry = self
5917 .torrents
5918 .get(&info_hash)
5919 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5920 entry.handle.pause().await
5921 }
5922
5923 async fn handle_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5924 let entry = self
5925 .torrents
5926 .get(&info_hash)
5927 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5928 entry.handle.resume().await
5929 }
5930
5931 async fn handle_force_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5932 let entry = self
5933 .torrents
5934 .get(&info_hash)
5935 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5936 entry
5937 .handle
5938 .cmd_tx
5939 .send(crate::types::TorrentCommand::ForceResume)
5940 .await
5941 .map_err(|_| crate::Error::Shutdown)
5942 }
5943
5944 async fn handle_set_torrent_seed_ratio(
5945 &self,
5946 info_hash: Id20,
5947 limit: Option<f64>,
5948 ) -> crate::Result<()> {
5949 let entry = self
5950 .torrents
5951 .get(&info_hash)
5952 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5953 let (tx, rx) = oneshot::channel();
5954 entry
5955 .handle
5956 .cmd_tx
5957 .send(crate::types::TorrentCommand::SetSeedRatioLimit { limit, reply: tx })
5958 .await
5959 .map_err(|_| crate::Error::Shutdown)?;
5960 rx.await.map_err(|_| crate::Error::Shutdown)
5961 }
5962
5963 async fn handle_move_torrent_storage(
5964 &self,
5965 info_hash: Id20,
5966 new_path: std::path::PathBuf,
5967 ) -> crate::Result<()> {
5968 let entry = self
5969 .torrents
5970 .get(&info_hash)
5971 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5972 entry.handle.move_storage(new_path).await
5973 }
5974
5975 async fn handle_torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
5976 let entry = self
5977 .torrents
5978 .get(&info_hash)
5979 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5980 let mut stats = entry.handle.stats().await?;
5981 stats.queue_position = entry.queue_position;
5983 stats.auto_managed = entry.auto_managed;
5984 Ok(stats)
5985 }
5986
5987 async fn handle_torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
5988 let meta = self.get_entry_meta(info_hash).await?;
5993 let files: Vec<FileInfo> = if let Some(ref file_list) = meta.info.files {
5994 file_list
5995 .iter()
5996 .map(|f| FileInfo {
5997 path: f.path.iter().collect::<PathBuf>(),
5998 length: f.length,
5999 })
6000 .collect()
6001 } else {
6002 vec![FileInfo {
6003 path: PathBuf::from(&meta.info.name),
6004 length: meta.info.total_length(),
6005 }]
6006 };
6007
6008 Ok(TorrentInfo {
6009 info_hash,
6010 name: meta.info.name.clone(),
6011 total_length: meta.info.total_length(),
6012 piece_length: meta.info.piece_length,
6013 num_pieces: meta.info.num_pieces() as u32,
6014 files,
6015 private: meta.info.private == Some(1),
6016 })
6017 }
6018
6019 fn update_session_gauges(&self) {
6021 use crate::stats::{
6022 DHT_NODES, DHT_NODES_V4, DHT_NODES_V6, PEER_NUM_BANNED, SES_ACTIVE_TORRENTS,
6023 SES_NUM_TORRENTS,
6024 };
6025 let c = &self.counters;
6026 c.set(SES_NUM_TORRENTS, self.torrents.len() as i64);
6027 c.set(SES_ACTIVE_TORRENTS, self.torrents.len() as i64);
6028
6029 let dht_nodes = i64::from(self.dht_v4.is_some()) + i64::from(self.dht_v6.is_some());
6031 c.set(DHT_NODES, dht_nodes);
6032 c.set(DHT_NODES_V4, i64::from(self.dht_v4.is_some()));
6033 c.set(DHT_NODES_V6, i64::from(self.dht_v6.is_some()));
6034
6035 let ban_count = self.ban_manager.read().banned_list().len() as i64;
6037 c.set(PEER_NUM_BANNED, ban_count);
6038 }
6039
6040 fn fire_stats_alert(&self) {
6042 self.update_session_gauges();
6043 let values = self.counters.snapshot();
6044 crate::alert::post_alert(
6045 &self.alert_tx,
6046 &self.alert_mask,
6047 crate::alert::AlertKind::SessionStatsAlert { values },
6048 );
6049 }
6050
6051 async fn fire_sample_infohashes(&self) {
6053 let ((Some(dht), _) | (_, Some(dht))) = (&self.dht_v4, &self.dht_v6) else {
6054 return;
6055 };
6056 let mut buf = [0u8; 20];
6057 irontide_core::random_bytes(&mut buf);
6058 let target = Id20::from(buf);
6059 match dht.sample_infohashes(target).await {
6060 Ok(result) => {
6061 post_alert(
6062 &self.alert_tx,
6063 &self.alert_mask,
6064 AlertKind::DhtSampleInfohashes {
6065 num_samples: result.samples.len(),
6066 total_estimate: result.num,
6067 },
6068 );
6069 }
6070 Err(e) => {
6071 debug!("sample_infohashes failed: {e}");
6072 }
6073 }
6074 }
6075
6076 async fn collect_torrent_stats(&self) -> Vec<(Id20, TorrentStats)> {
6093 use futures::stream::{FuturesUnordered, StreamExt};
6094
6095 let mut futs: FuturesUnordered<_> = self
6096 .torrents
6097 .iter()
6098 .map(|(&info_hash, entry)| {
6099 let handle = entry.handle.clone();
6100 async move {
6101 tokio::time::timeout(std::time::Duration::from_millis(500), handle.stats())
6102 .await
6103 .ok()
6104 .and_then(Result::ok)
6105 .map(|stats| (info_hash, stats))
6106 }
6107 })
6108 .collect();
6109
6110 let mut out = Vec::with_capacity(self.torrents.len());
6111 while let Some(maybe) = futs.next().await {
6112 if let Some(pair) = maybe {
6113 out.push(pair);
6114 }
6115 }
6116 out
6117 }
6118
6119 async fn snapshot_publish_one(&self, info_hash: Id20) {
6127 let handle = match self.torrents.get(&info_hash) {
6128 Some(entry) => entry.handle.clone(),
6129 None => return,
6130 };
6131 if let Ok(Ok(stats)) =
6132 tokio::time::timeout(std::time::Duration::from_millis(500), handle.stats()).await
6133 {
6134 let mut map = self.snapshot.load().as_map().clone();
6135 map.insert(info_hash, TorrentSummary::from(&stats));
6136 self.snapshot
6137 .store(Arc::new(SessionSnapshot::from_map(map)));
6138 }
6139 }
6140
6141 fn snapshot_drop_one(&self, info_hash: Id20) {
6145 let mut map = self.snapshot.load().as_map().clone();
6146 if map.remove(&info_hash).is_some() {
6147 self.snapshot
6148 .store(Arc::new(SessionSnapshot::from_map(map)));
6149 }
6150 }
6151
6152 async fn make_session_stats(&self) -> SessionStats {
6153 self.update_session_gauges();
6154
6155 let active_torrents = self.torrents.len();
6156 let dht_nodes = usize::from(self.dht_v4.is_some()) + usize::from(self.dht_v6.is_some());
6157
6158 let collected = self.collect_torrent_stats().await;
6162
6163 let mut total_downloaded = 0u64;
6164 let mut total_uploaded = 0u64;
6165 let mut responded = std::collections::HashMap::with_capacity(collected.len());
6166 for (info_hash, stats) in collected {
6167 total_downloaded = total_downloaded.saturating_add(stats.downloaded);
6168 total_uploaded = total_uploaded.saturating_add(stats.uploaded);
6169 responded.insert(info_hash, stats);
6170 }
6171
6172 let prev = self.snapshot.load();
6178 let mut map = std::collections::BTreeMap::new();
6179 for &info_hash in self.torrents.keys() {
6180 if let Some(stats) = responded.get(&info_hash) {
6181 map.insert(info_hash, TorrentSummary::from(stats));
6182 } else if let Some(prev_summary) = prev.as_map().get(&info_hash) {
6183 map.insert(info_hash, prev_summary.clone());
6184 }
6185 }
6186 self.snapshot
6187 .store(Arc::new(SessionSnapshot::from_map(map)));
6188
6189 SessionStats {
6190 active_torrents,
6191 total_downloaded,
6192 total_uploaded,
6193 dht_nodes,
6194 }
6195 }
6196
6197 async fn make_debug_state(&self) -> crate::types::DebugState {
6201 use crate::stats::{
6202 DISPATCH_ACQUIRE_NONE_TOTAL, DISPATCH_ACQUIRE_TOTAL, DISPATCH_ACQUIRE_US,
6203 DISPATCH_NOTIFY_WAKEUP_TOTAL,
6204 };
6205
6206 let snap = self.counters.snapshot();
6208 let dispatch = crate::types::DebugDispatchState {
6209 acquire_total: snap[DISPATCH_ACQUIRE_TOTAL],
6210 acquire_none_total: snap[DISPATCH_ACQUIRE_NONE_TOTAL],
6211 acquire_us: snap[DISPATCH_ACQUIRE_US],
6212 notify_wakeup_total: snap[DISPATCH_NOTIFY_WAKEUP_TOTAL],
6213 pieces_queued: 0,
6214 pieces_inflight: 0,
6215 };
6216
6217 let mut torrents = Vec::with_capacity(self.torrents.len());
6218 for (&info_hash, entry) in &self.torrents {
6219 let Ok(Ok(stats)) =
6221 tokio::time::timeout(std::time::Duration::from_millis(500), entry.handle.stats())
6222 .await
6223 else {
6224 continue;
6225 };
6226
6227 let peers_raw = match tokio::time::timeout(
6229 std::time::Duration::from_millis(500),
6230 entry.handle.get_peer_info(),
6231 )
6232 .await
6233 {
6234 Ok(Ok(p)) => p,
6235 _ => Vec::new(),
6236 };
6237
6238 let peers: Vec<crate::types::DebugPeerState> = peers_raw
6239 .iter()
6240 .map(|p| crate::types::DebugPeerState {
6241 addr: p.addr,
6242 in_flight: p.in_flight_requests,
6243 target_depth: p.target_pipeline_depth,
6244 choking: p.peer_choking,
6245 download_rate: p.download_rate,
6246 })
6247 .collect();
6248
6249 let mut per_torrent_dispatch = dispatch.clone();
6250 per_torrent_dispatch.pieces_queued = stats.dispatch_pieces_queued;
6251 per_torrent_dispatch.pieces_inflight = stats.dispatch_pieces_inflight;
6252
6253 torrents.push(crate::types::DebugTorrentState {
6254 info_hash: info_hash.to_hex(),
6255 state: format!("{:?}", stats.state),
6256 num_peers: stats.peers_connected,
6257 dispatch: per_torrent_dispatch,
6258 peers,
6259 });
6260 }
6261
6262 crate::types::DebugState { torrents }
6263 }
6264
6265 async fn handle_save_torrent_resume(
6266 &self,
6267 info_hash: Id20,
6268 ) -> crate::Result<irontide_core::FastResumeData> {
6269 let entry = self
6270 .torrents
6271 .get(&info_hash)
6272 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6273 let mut resume = entry.handle.save_resume_data().await?;
6274 resume.queue_position = i64::from(entry.queue_position);
6277 resume.auto_managed = i64::from(entry.auto_managed);
6278 Ok(resume)
6279 }
6280
6281 async fn handle_save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
6282 use crate::persistence::SessionState;
6283
6284 let mut torrents = Vec::new();
6285 for (info_hash, entry) in &self.torrents {
6286 match entry.handle.save_resume_data().await {
6287 Ok(rd) => torrents.push(rd),
6288 Err(e) => {
6289 warn!(%info_hash, "failed to save resume data: {e}");
6290 }
6291 }
6292 }
6293
6294 let (banned_peers, peer_strikes) = {
6296 let ban_mgr = self.ban_manager.read();
6297 let banned_peers: Vec<String> = ban_mgr
6298 .banned_list()
6299 .iter()
6300 .map(std::string::ToString::to_string)
6301 .collect();
6302 let peer_strikes: Vec<crate::persistence::PeerStrikeEntry> = ban_mgr
6303 .strikes_map()
6304 .iter()
6305 .map(|(ip, &count)| crate::persistence::PeerStrikeEntry {
6306 ip: ip.to_string(),
6307 count: i64::from(count),
6308 })
6309 .collect();
6310 (banned_peers, peer_strikes)
6311 };
6312
6313 let mut dht_entries = Vec::new();
6314 let mut dht_node_id = None;
6315 if let Some(ref dht) = self.dht_v4 {
6316 if let Ok(stats) = dht.stats().await {
6318 dht_node_id = Some(stats.node_id.to_hex());
6319 }
6320 for (_id, addr) in dht.get_routing_nodes().await {
6321 dht_entries.push(crate::persistence::DhtNodeEntry {
6322 host: addr.ip().to_string(),
6323 port: i64::from(addr.port()),
6324 });
6325 }
6326 }
6327 if let Some(ref dht) = self.dht_v6 {
6328 for (_id, addr) in dht.get_routing_nodes().await {
6329 dht_entries.push(crate::persistence::DhtNodeEntry {
6330 host: addr.ip().to_string(),
6331 port: i64::from(addr.port()),
6332 });
6333 }
6334 }
6335
6336 Ok(SessionState {
6337 dht_nodes: dht_entries,
6338 dht_node_id,
6339 torrents,
6340 banned_peers,
6341 peer_strikes,
6342 })
6343 }
6344
6345 fn effective_resume_dir(&self) -> PathBuf {
6347 self.settings
6348 .resume_data_dir
6349 .clone()
6350 .unwrap_or_else(crate::resume_file::default_resume_dir)
6351 }
6352
6353 async fn handle_load_resume_state(&mut self) -> crate::Result<ResumeLoadResult> {
6359 let resume_dir = self.effective_resume_dir();
6360 let paths = crate::resume_file::scan_resume_dir(&resume_dir);
6361
6362 let mut restored = 0usize;
6363 let mut skipped = 0usize;
6364 let mut failed = 0usize;
6365
6366 for path in &paths {
6367 let file_name = path
6368 .file_name()
6369 .and_then(|n| n.to_str())
6370 .unwrap_or("<unknown>");
6371
6372 let bytes = match std::fs::read(path) {
6374 Ok(b) => b,
6375 Err(e) => {
6376 warn!(file = %file_name, "failed to read resume file: {e}");
6377 failed = failed.saturating_add(1);
6378 continue;
6379 }
6380 };
6381
6382 let rd = match crate::resume_file::deserialize_resume(&bytes) {
6383 Ok(rd) => rd,
6384 Err(e) => {
6385 warn!(file = %file_name, "failed to deserialize resume file: {e}");
6386 failed = failed.saturating_add(1);
6387 continue;
6388 }
6389 };
6390
6391 if let Some(meta) = crate::resume_file::reconstruct_torrent_meta(&rd) {
6393 let info_hash = meta.info_hash;
6394 let pieces = rd.pieces.clone();
6395 let torrent_meta = irontide_core::TorrentMeta::V1(meta);
6396
6397 let restore_dir = if rd.save_path.is_empty() {
6399 None
6400 } else {
6401 Some(PathBuf::from(&rd.save_path))
6402 };
6403 let restore_tags = rd.tags.clone();
6408 match self
6409 .handle_add_torrent(torrent_meta, None, restore_dir, restore_tags)
6410 .await
6411 {
6412 Ok(added_hash) => {
6413 if !pieces.is_empty()
6415 && let Some(entry) = self.torrents.get(&added_hash)
6416 && let Err(e) = entry.handle.restore_resume_bitmap(pieces).await
6417 {
6418 warn!(
6419 %info_hash,
6420 "failed to restore piece bitmap, torrent will recheck: {e}"
6421 );
6422 }
6423 if let Some(ref cat) = rd.category
6425 && let Some(entry) = self.torrents.get(&added_hash)
6426 {
6427 let handle = entry.handle.clone();
6428 let cat_owned = cat.clone();
6429 tokio::spawn(async move {
6430 let _ = handle.set_category(Some(cat_owned)).await;
6431 });
6432 }
6433 if !rd.web_seed_stats.is_empty()
6436 && let Some(entry) = self.torrents.get(&added_hash)
6437 {
6438 let handle = entry.handle.clone();
6439 let stats_owned = rd.web_seed_stats.clone();
6440 tokio::spawn(async move {
6441 let _ = handle.restore_web_seed_stats(stats_owned).await;
6442 });
6443 }
6444 if self.settings.queueing_enabled
6445 && let Some(entry) = self.torrents.get(&added_hash)
6446 {
6447 let _ = entry.handle.queue().await;
6448 }
6449 if let Some(entry) = self.torrents.get_mut(&added_hash) {
6450 entry.queue_position = rd.queue_position as i32;
6451 entry.auto_managed = rd.auto_managed != 0;
6452 }
6453 info!(%info_hash, "restored torrent from resume file");
6454 restored = restored.saturating_add(1);
6455 }
6456 Err(crate::Error::DuplicateTorrent(_)) => {
6457 debug!(%info_hash, "skipped duplicate torrent from resume");
6458 skipped = skipped.saturating_add(1);
6459 }
6460 Err(e) => {
6461 warn!(%info_hash, "failed to add restored torrent: {e}");
6462 failed = failed.saturating_add(1);
6463 }
6464 }
6465 } else if let Some(magnet) = crate::resume_file::reconstruct_magnet(&rd) {
6466 let info_hash = magnet.info_hash();
6468 let restore_dir = if rd.save_path.is_empty() {
6469 None
6470 } else {
6471 Some(PathBuf::from(&rd.save_path))
6472 };
6473 let restore_tags = rd.tags.clone();
6475 match self
6476 .handle_add_magnet(magnet, restore_dir, restore_tags)
6477 .await
6478 {
6479 Ok(added_hash) => {
6480 if let Some(ref cat) = rd.category
6482 && let Some(entry) = self.torrents.get(&added_hash)
6483 {
6484 let handle = entry.handle.clone();
6485 let cat_owned = cat.clone();
6486 tokio::spawn(async move {
6487 let _ = handle.set_category(Some(cat_owned)).await;
6488 });
6489 }
6490 if !rd.web_seed_stats.is_empty()
6494 && let Some(entry) = self.torrents.get(&added_hash)
6495 {
6496 let handle = entry.handle.clone();
6497 let stats_owned = rd.web_seed_stats.clone();
6498 tokio::spawn(async move {
6499 let _ = handle.restore_web_seed_stats(stats_owned).await;
6500 });
6501 }
6502 if self.settings.queueing_enabled
6503 && let Some(entry) = self.torrents.get(&added_hash)
6504 {
6505 let _ = entry.handle.queue().await;
6506 }
6507 if let Some(entry) = self.torrents.get_mut(&added_hash) {
6508 entry.queue_position = rd.queue_position as i32;
6509 entry.auto_managed = rd.auto_managed != 0;
6510 }
6511 info!(%info_hash, "restored magnet from resume file");
6512 restored = restored.saturating_add(1);
6513 }
6514 Err(crate::Error::DuplicateTorrent(_)) => {
6515 debug!(%info_hash, "skipped duplicate magnet from resume");
6516 skipped = skipped.saturating_add(1);
6517 }
6518 Err(e) => {
6519 warn!(%info_hash, "failed to add restored magnet: {e}");
6520 failed = failed.saturating_add(1);
6521 }
6522 }
6523 } else {
6524 warn!(file = %file_name, "resume file has no valid info dict and no valid info hash");
6525 failed = failed.saturating_add(1);
6526 }
6527 }
6528
6529 {
6533 let mut entries: Vec<(Id20, i32)> = self
6534 .torrents
6535 .iter()
6536 .filter(|(_, e)| e.auto_managed)
6537 .map(|(h, e)| (*h, e.queue_position))
6538 .collect();
6539 entries.sort_by_key(|&(_, pos)| pos);
6540 for (new_pos, (hash, _)) in entries.into_iter().enumerate() {
6541 if let Some(entry) = self.torrents.get_mut(&hash) {
6542 entry.queue_position = new_pos as i32;
6543 }
6544 }
6545 }
6546
6547 info!(restored, skipped, failed, "resume state loaded");
6548 Ok(ResumeLoadResult {
6549 restored,
6550 skipped,
6551 failed,
6552 })
6553 }
6554
6555 fn snapshot_resume_jobs(&self) -> (std::path::PathBuf, Vec<ResumeSaveJob>) {
6561 let resume_dir = self.effective_resume_dir();
6562 let jobs = self
6563 .torrents
6564 .iter()
6565 .map(|(info_hash, entry)| ResumeSaveJob {
6566 info_hash: *info_hash,
6567 handle: entry.handle.clone(),
6568 queue_position: i64::from(entry.queue_position),
6569 auto_managed: i64::from(entry.auto_managed),
6570 })
6571 .collect();
6572 (resume_dir, jobs)
6573 }
6574
6575 async fn save_dirty_resume_files(&self) -> usize {
6584 let _guard = self.resume_save_lock.lock().await;
6585 let (resume_dir, jobs) = self.snapshot_resume_jobs();
6586 run_resume_save_jobs(resume_dir, jobs).await
6587 }
6588
6589 fn handle_apply_settings(&mut self, new: Settings) -> crate::Result<()> {
6612 new.validate()?;
6615
6616 let old_upload_rate = self.settings.upload_rate_limit;
6620 let old_download_rate = self.settings.download_rate_limit;
6621 let old_alert_mask = self.settings.alert_mask;
6622 let old_settings = self.settings.clone();
6623 let old_settings_for_delta = self.settings.clone();
6624
6625 let new_upload_rate = new.upload_rate_limit;
6626 let new_download_rate = new.download_rate_limit;
6627 let new_alert_mask = new.alert_mask;
6628
6629 let upload_bucket = Arc::clone(&self.global_upload_bucket);
6634 let download_bucket = Arc::clone(&self.global_download_bucket);
6635 let alert_mask = Arc::clone(&self.alert_mask);
6636
6637 let phase1: crate::apply::Phase<Self> = crate::apply::Phase {
6638 name: "rate_limits_and_mask",
6639 forward: Box::new(move |this: &mut Self| {
6640 if new_upload_rate != old_upload_rate {
6641 upload_bucket.lock().set_rate(new_upload_rate);
6642 }
6643 if new_download_rate != old_download_rate {
6644 download_bucket.lock().set_rate(new_download_rate);
6645 }
6646 if new_alert_mask != old_alert_mask {
6647 alert_mask.store(new_alert_mask.bits(), Ordering::Relaxed);
6648 }
6649 this.settings = new;
6650 Ok(())
6651 }),
6652 rollback: Box::new(move |this: &mut Self| {
6653 this.settings = old_settings;
6655 if new_alert_mask != old_alert_mask {
6656 this.alert_mask
6657 .store(old_alert_mask.bits(), Ordering::Relaxed);
6658 }
6659 if new_download_rate != old_download_rate {
6660 this.global_download_bucket
6661 .lock()
6662 .set_rate(old_download_rate);
6663 }
6664 if new_upload_rate != old_upload_rate {
6665 this.global_upload_bucket.lock().set_rate(old_upload_rate);
6666 }
6667 }),
6668 };
6669
6670 let phases = vec![phase1];
6672
6673 match crate::apply::apply_phases_with_rollback(self, phases) {
6674 Ok(()) => {
6675 let _ = self.notification_settings_tx.send(self.settings.clone());
6683
6684 if (old_settings_for_delta.enable_dht != self.settings.enable_dht
6686 || old_settings_for_delta.anonymous_mode != self.settings.anonymous_mode)
6687 && (!self.settings.enable_dht || self.settings.anonymous_mode)
6688 {
6689 tracing::info!("DHT disabled via settings");
6690 self.dht_v4 = None;
6691 self.dht_v6 = None;
6692 self.dht_v4_broadcast.replace(None);
6693 self.dht_v6_broadcast.replace(None);
6694 }
6695
6696 self.max_connections_global.store(
6702 self.settings.max_connections_global,
6703 std::sync::atomic::Ordering::SeqCst,
6704 );
6705
6706 let delta =
6707 crate::types::SettingsDelta::from_diff(&old_settings_for_delta, &self.settings);
6708 if delta.save_resume_interval_secs.is_some() {
6709 self.resume_save_notify.notify_one();
6710 }
6711 if let Some(enabled) = delta.ip_filter_enabled {
6712 self.ip_filter.write().enabled = enabled;
6713 }
6714 if delta.watched_folder.is_some() || delta.delete_torrent_after_add.is_some() {
6720 self.watched_folder_changed.notify_one();
6721 }
6722 if !delta.is_empty() {
6723 let mut failed: Vec<irontide_core::Id20> = Vec::new();
6730 for (hash, entry) in &self.torrents {
6731 if entry
6732 .handle
6733 .cmd_tx
6734 .try_send(crate::types::TorrentCommand::UpdateSettings(delta.clone()))
6735 .is_err()
6736 {
6737 failed.push(*hash);
6738 }
6739 }
6740 if !failed.is_empty() {
6741 tracing::warn!(
6742 count = failed.len(),
6743 "SettingsDelta fan-out: per-torrent channel saturated; \
6744 affected torrents will pick up the change on the next apply"
6745 );
6746 }
6747 }
6748 post_alert(&self.alert_tx, &self.alert_mask, AlertKind::SettingsChanged);
6749 Ok(())
6750 }
6751 Err(crate::apply::ApplyError::ValidationFailed(msg)) => {
6752 Err(crate::Error::InvalidSettings(msg))
6753 }
6754 Err(e) => Err(crate::Error::Config(format!("apply settings: {e}"))),
6755 }
6756 }
6757
6758 fn queue_entries(&self) -> Vec<crate::queue::QueueEntry> {
6760 self.torrents
6761 .iter()
6762 .filter(|(_, e)| e.auto_managed)
6763 .map(|(&hash, e)| crate::queue::QueueEntry {
6764 info_hash: hash,
6765 position: e.queue_position,
6766 })
6767 .collect()
6768 }
6769
6770 fn handle_set_queue_position(&mut self, info_hash: Id20, pos: i32) -> crate::Result<()> {
6771 if !self.torrents.contains_key(&info_hash) {
6772 return Err(crate::Error::TorrentNotFound(info_hash));
6773 }
6774 let mut entries = self.queue_entries();
6775 let changed = crate::queue::set_position(&mut entries, info_hash, pos);
6776 self.apply_queue_changes(&changed);
6777 Ok(())
6778 }
6779
6780 fn handle_queue_move(&mut self, info_hash: Id20, op: QueueMoveFn) -> crate::Result<()> {
6781 if !self.torrents.contains_key(&info_hash) {
6782 return Err(crate::Error::TorrentNotFound(info_hash));
6783 }
6784 let mut entries = self.queue_entries();
6785 let changed = op(&mut entries, info_hash);
6786 self.apply_queue_changes(&changed);
6787 Ok(())
6788 }
6789
6790 fn apply_queue_changes(&mut self, changed: &[(Id20, i32, i32)]) {
6792 for &(hash, old_pos, new_pos) in changed {
6793 if let Some(entry) = self.torrents.get_mut(&hash) {
6794 entry.queue_position = new_pos;
6795 }
6796 crate::alert::post_alert(
6797 &self.alert_tx,
6798 &self.alert_mask,
6799 crate::alert::AlertKind::TorrentQueuePositionChanged {
6800 info_hash: hash,
6801 old_pos,
6802 new_pos,
6803 },
6804 );
6805 }
6806 }
6807
6808 async fn evaluate_queue(&mut self) {
6809 if !self.settings.queueing_enabled {
6810 return;
6811 }
6812 let now = tokio::time::Instant::now();
6813 let startup_duration = std::time::Duration::from_secs(self.settings.auto_manage_startup);
6814 let mut candidates = Vec::new();
6815
6816 let hashes: Vec<Id20> = self.torrents.keys().copied().collect();
6818
6819 for &info_hash in &hashes {
6820 let (queue_position, started_at) = {
6821 let Some(entry) = self.torrents.get(&info_hash) else {
6822 continue;
6823 };
6824 if !entry.auto_managed {
6825 continue;
6826 }
6827 (entry.queue_position, entry.started_at)
6828 };
6829
6830 let stats = match self.torrents.get(&info_hash) {
6832 Some(entry) => match entry.handle.stats().await {
6833 Ok(s) => s,
6834 Err(_) => continue,
6835 },
6836 None => continue,
6837 };
6838
6839 let category = match stats.state {
6840 TorrentState::Checking | TorrentState::FetchingMetadata => {
6841 crate::queue::QueueCategory::Checking
6842 }
6843 TorrentState::Downloading => crate::queue::QueueCategory::Downloading,
6844 TorrentState::Seeding | TorrentState::Complete => {
6845 crate::queue::QueueCategory::Seeding
6846 }
6847 TorrentState::Queued => {
6848 if stats.progress >= 1.0 {
6849 crate::queue::QueueCategory::Seeding
6850 } else {
6851 crate::queue::QueueCategory::Downloading
6852 }
6853 }
6854 TorrentState::Paused | TorrentState::Stopped | TorrentState::Sharing => continue,
6855 };
6856
6857 let is_active = !matches!(stats.state, TorrentState::Paused | TorrentState::Queued);
6858
6859 let alpha = self.settings.queue_rate_ewma_alpha.clamp(0.0, 1.0);
6861 let (smoothed_dl, smoothed_ul) = if let Some(entry) = self.torrents.get_mut(&info_hash)
6862 {
6863 let raw_dl = stats.download_rate as f64;
6864 let raw_ul = stats.upload_rate as f64;
6865 entry.smoothed_download_rate =
6866 alpha.mul_add(raw_dl, (1.0 - alpha) * entry.smoothed_download_rate);
6867 entry.smoothed_upload_rate =
6868 alpha.mul_add(raw_ul, (1.0 - alpha) * entry.smoothed_upload_rate);
6869 (entry.smoothed_download_rate, entry.smoothed_upload_rate)
6870 } else {
6871 continue;
6872 };
6873
6874 let past_startup = started_at.is_none_or(|t| now.duration_since(t) > startup_duration);
6875
6876 let is_inactive = past_startup
6877 && match category {
6878 crate::queue::QueueCategory::Downloading => {
6879 (smoothed_dl as u64) < self.settings.inactive_down_rate
6880 }
6881 crate::queue::QueueCategory::Seeding => {
6882 (smoothed_ul as u64) < self.settings.inactive_up_rate
6883 }
6884 crate::queue::QueueCategory::Checking => false,
6885 };
6886
6887 let anti_flap_duration = if category == crate::queue::QueueCategory::Seeding {
6888 std::time::Duration::from_secs(self.settings.seed_queue_min_active_secs)
6889 } else {
6890 startup_duration
6891 };
6892 let recently_started =
6893 started_at.is_some_and(|t| now.duration_since(t) < anti_flap_duration);
6894
6895 let seed_rank = if category == crate::queue::QueueCategory::Seeding {
6896 Some(crate::queue::compute_seed_rank(
6897 stats.num_complete,
6898 stats.num_incomplete,
6899 ))
6900 } else {
6901 None
6902 };
6903
6904 candidates.push(crate::queue::QueueCandidate {
6905 info_hash,
6906 position: queue_position,
6907 category,
6908 is_active,
6909 is_inactive,
6910 recently_started,
6911 seed_rank,
6912 });
6913 }
6914
6915 let config = crate::queue::QueueConfig {
6916 active_downloads: self.settings.active_downloads,
6917 active_seeds: self.settings.active_seeds,
6918 active_checking: self.settings.active_checking,
6919 active_limit: self.settings.active_limit,
6920 dont_count_slow: self.settings.dont_count_slow_torrents,
6921 prefer_seeds: self.settings.auto_manage_prefer_seeds,
6922 };
6923 let mut decision = crate::queue::evaluate(&candidates, &config);
6924 crate::queue::apply_preemption(&mut decision, &candidates);
6925
6926 for hash in &decision.to_pause {
6928 if let Some(entry) = self.torrents.get(hash) {
6929 let _ = entry.handle.queue().await;
6930 }
6931 post_alert(
6932 &self.alert_tx,
6933 &self.alert_mask,
6934 AlertKind::TorrentAutoManaged {
6935 info_hash: *hash,
6936 paused: true,
6937 },
6938 );
6939 }
6940
6941 for hash in &decision.to_resume {
6942 if let Some(entry) = self.torrents.get_mut(hash) {
6943 let _ = entry.handle.resume().await;
6944 entry.started_at = Some(tokio::time::Instant::now());
6945 }
6946 post_alert(
6947 &self.alert_tx,
6948 &self.alert_mask,
6949 AlertKind::TorrentAutoManaged {
6950 info_hash: *hash,
6951 paused: false,
6952 },
6953 );
6954 }
6955 }
6956
6957 fn handle_identified_inbound(&self, conn: crate::listener::IdentifiedConnection) {
6959 if let Some(entry) = self.torrents.get(&conn.info_hash) {
6960 debug!(%conn.addr, %conn.info_hash, "routing validated inbound peer");
6961 let handle = entry.handle.clone();
6962 tokio::spawn(async move {
6963 let _ = handle.send_incoming_peer(conn.stream, conn.addr).await;
6964 });
6965 } else {
6966 debug!(%conn.addr, %conn.info_hash, "validated peer for removed torrent, dropping");
6968 }
6969 }
6970
6971 async fn handle_ssl_incoming(
6978 &mut self,
6979 stream: crate::transport::BoxedStream,
6980 addr: std::net::SocketAddr,
6981 ) {
6982 use tokio_rustls::LazyConfigAcceptor;
6983
6984 let acceptor = LazyConfigAcceptor::new(rustls::server::Acceptor::default(), stream);
6985
6986 let start_handshake = match acceptor.await {
6987 Ok(sh) => sh,
6988 Err(e) => {
6989 debug!(%addr, error = %e, "SSL ClientHello read failed");
6990 return;
6991 }
6992 };
6993
6994 let client_hello = start_handshake.client_hello();
6996 let sni = if let Some(name) = client_hello.server_name() {
6997 name.to_string()
6998 } else {
6999 debug!(%addr, "SSL connection missing SNI");
7000 return;
7001 };
7002
7003 let Ok(info_hash) = Id20::from_hex(&sni) else {
7005 debug!(%addr, sni = %sni, "SSL SNI is not a valid info hash");
7006 return;
7007 };
7008
7009 let Some(torrent) = self.torrents.get(&info_hash) else {
7011 debug!(%addr, %info_hash, "SSL connection for unknown torrent");
7012 return;
7013 };
7014
7015 let meta = match torrent.handle.get_meta().await {
7022 Ok(Some(m)) => m,
7023 Ok(None) => {
7024 debug!(%addr, %info_hash, "SSL connection for torrent still resolving metadata");
7025 return;
7026 }
7027 Err(_) => {
7028 debug!(%addr, %info_hash, "SSL connection but TorrentActor shut down");
7029 return;
7030 }
7031 };
7032 let ssl_cert = if let Some(cert) = meta.ssl_cert.as_ref() {
7033 cert.clone()
7034 } else {
7035 debug!(%addr, %info_hash, "SSL connection for non-SSL torrent (no ssl_cert in info dict)");
7036 return;
7037 };
7038
7039 let server_config = if let Some(mgr) = self.ssl_manager.as_ref() {
7041 match mgr.server_config(&ssl_cert) {
7042 Ok(cfg) => cfg,
7043 Err(e) => {
7044 warn!(%addr, %info_hash, error = %e, "failed to build SSL server config");
7045 return;
7046 }
7047 }
7048 } else {
7049 debug!(%addr, "SSL manager not initialized");
7050 return;
7051 };
7052
7053 let tls_stream = match start_handshake.into_stream(server_config).await {
7055 Ok(s) => s,
7056 Err(e) => {
7057 warn!(%addr, %info_hash, error = %e, "SSL handshake failed");
7058 post_alert(
7059 &self.alert_tx,
7060 &self.alert_mask,
7061 AlertKind::SslTorrentError {
7062 info_hash,
7063 message: format!("inbound TLS handshake from {addr}: {e}"),
7064 },
7065 );
7066 return;
7067 }
7068 };
7069
7070 let _ = torrent.handle.spawn_ssl_peer(addr, tls_stream).await;
7072 }
7073
7074 async fn handle_dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
7075 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7076 match dht.put_immutable(value.clone()).await {
7077 Ok(target) => {
7078 post_alert(
7079 &self.alert_tx,
7080 &self.alert_mask,
7081 AlertKind::DhtPutComplete { target },
7082 );
7083 Ok(target)
7084 }
7085 Err(e) => {
7086 let target = irontide_core::sha1(&value);
7087 post_alert(
7088 &self.alert_tx,
7089 &self.alert_mask,
7090 AlertKind::DhtItemError {
7091 target,
7092 message: e.to_string(),
7093 },
7094 );
7095 Err(crate::Error::Dht(e))
7096 }
7097 }
7098 }
7099
7100 async fn handle_dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
7101 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7102 match dht.get_immutable(target).await {
7103 Ok(value) => {
7104 post_alert(
7105 &self.alert_tx,
7106 &self.alert_mask,
7107 AlertKind::DhtGetResult {
7108 target,
7109 value: value.clone(),
7110 },
7111 );
7112 Ok(value)
7113 }
7114 Err(e) => {
7115 post_alert(
7116 &self.alert_tx,
7117 &self.alert_mask,
7118 AlertKind::DhtItemError {
7119 target,
7120 message: e.to_string(),
7121 },
7122 );
7123 Err(crate::Error::Dht(e))
7124 }
7125 }
7126 }
7127
7128 async fn handle_dht_put_mutable(
7129 &self,
7130 keypair_bytes: [u8; 32],
7131 value: Vec<u8>,
7132 seq: i64,
7133 salt: Vec<u8>,
7134 ) -> crate::Result<Id20> {
7135 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7136 match dht.put_mutable(keypair_bytes, value, seq, salt).await {
7137 Ok(target) => {
7138 post_alert(
7139 &self.alert_tx,
7140 &self.alert_mask,
7141 AlertKind::DhtMutablePutComplete { target, seq },
7142 );
7143 Ok(target)
7144 }
7145 Err(e) => {
7146 post_alert(
7147 &self.alert_tx,
7148 &self.alert_mask,
7149 AlertKind::DhtItemError {
7150 target: Id20::from([0u8; 20]),
7151 message: e.to_string(),
7152 },
7153 );
7154 Err(crate::Error::Dht(e))
7155 }
7156 }
7157 }
7158
7159 async fn handle_dht_get_mutable(
7160 &self,
7161 public_key: [u8; 32],
7162 salt: Vec<u8>,
7163 ) -> crate::Result<Option<(Vec<u8>, i64)>> {
7164 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7165 let target = irontide_dht::compute_mutable_target(&public_key, &salt);
7166 match dht.get_mutable(public_key, salt).await {
7167 Ok(result) => {
7168 let (value, seq) = match &result {
7169 Some((v, s)) => (Some(v.clone()), Some(*s)),
7170 None => (None, None),
7171 };
7172 post_alert(
7173 &self.alert_tx,
7174 &self.alert_mask,
7175 AlertKind::DhtMutableGetResult {
7176 target,
7177 value,
7178 seq,
7179 public_key,
7180 },
7181 );
7182 Ok(result)
7183 }
7184 Err(e) => {
7185 post_alert(
7186 &self.alert_tx,
7187 &self.alert_mask,
7188 AlertKind::DhtItemError {
7189 target,
7190 message: e.to_string(),
7191 },
7192 );
7193 Err(crate::Error::Dht(e))
7194 }
7195 }
7196 }
7197
7198 async fn shutdown_all(&mut self) {
7199 let save_count = self.save_dirty_resume_files().await;
7201 if save_count > 0 {
7202 info!(save_count, "saved resume files on shutdown");
7203 }
7204
7205 for (info_hash, entry) in self.torrents.drain() {
7206 debug!(%info_hash, "shutting down torrent");
7207 let _ = entry.handle.shutdown().await;
7208 }
7209 if let Some(ref dht) = self.dht_v4 {
7210 let _ = dht.shutdown().await;
7211 }
7212 if let Some(ref dht) = self.dht_v6 {
7213 let _ = dht.shutdown().await;
7214 }
7215 if let Some(ref nat) = self.nat {
7216 nat.shutdown().await;
7217 }
7218 if let Some(ref lsd) = self.lsd {
7219 lsd.shutdown().await;
7220 }
7221 if let Some(ref socket) = self.utp_socket
7222 && let Err(e) = socket.shutdown().await
7223 {
7224 debug!(error = %e, "uTP socket shutdown error");
7225 }
7226 if let Some(ref socket) = self.utp_socket_v6
7227 && let Err(e) = socket.shutdown().await
7228 {
7229 debug!(error = %e, "uTP v6 socket shutdown error");
7230 }
7231 self.disk_manager.shutdown().await;
7232 }
7233}
7234
7235async fn recv_nat_event(
7238 rx: &mut Option<mpsc::Receiver<irontide_nat::NatEvent>>,
7239) -> irontide_nat::NatEvent {
7240 match rx {
7241 Some(r) => match r.recv().await {
7242 Some(event) => event,
7243 None => std::future::pending().await,
7244 },
7245 None => std::future::pending().await,
7246 }
7247}
7248
7249async fn recv_dht_ip(
7251 rx: &mut Option<mpsc::Receiver<std::net::IpAddr>>,
7252) -> Option<std::net::IpAddr> {
7253 match rx {
7254 Some(r) => r.recv().await,
7255 None => std::future::pending().await,
7256 }
7257}
7258
7259async fn prepare_add_torrent_off_actor(
7279 bundle: AddTorrentPrepBundle,
7280) -> crate::Result<PreparedAddTorrent> {
7281 let AddTorrentPrepBundle {
7282 torrent_meta,
7283 storage_override,
7284 torrent_config,
7285 disk_manager,
7286 dht_v4_broadcast,
7287 dht_v6_broadcast,
7288 global_up,
7289 global_down,
7290 slot_tuner,
7291 alert_tx,
7292 alert_mask,
7293 utp_socket,
7294 utp_socket_v6,
7295 ban_manager,
7296 ip_filter,
7297 plugins,
7298 sam_session,
7299 ssl_manager,
7300 factory,
7301 hash_pool,
7302 counters,
7303 m170_post,
7304 } = bundle;
7305
7306 let version = torrent_meta.version();
7307 let meta_v2 = torrent_meta.as_v2().cloned();
7308
7309 let meta = if let Some(v1) = torrent_meta.as_v1() {
7313 v1.clone()
7314 } else {
7315 let v2 = torrent_meta.as_v2().unwrap();
7316 synthesize_v1_from_v2(v2)
7317 };
7318 let info_hash = meta.info_hash;
7319 let is_private = meta.info.private == Some(1);
7320
7321 let storage: Arc<dyn TorrentStorage> = if let Some(s) = storage_override {
7323 s
7324 } else {
7325 let lengths = Lengths::new(
7326 meta.info.total_length(),
7327 meta.info.piece_length,
7328 DEFAULT_CHUNK_SIZE,
7329 );
7330 let files = meta.info.files();
7331 let file_paths: Vec<PathBuf> = files
7332 .iter()
7333 .map(|f| f.path.iter().collect::<PathBuf>())
7334 .collect();
7335 let file_lengths: Vec<u64> = files.iter().map(|f| f.length).collect();
7336 let prealloc_mode = torrent_config.preallocate_mode.unwrap_or_else(|| {
7337 irontide_storage::PreallocateMode::from(
7338 torrent_config.storage_mode == irontide_core::StorageMode::Full,
7339 )
7340 });
7341 match irontide_storage::FilesystemStorage::new(
7342 &torrent_config.download_dir,
7343 file_paths,
7344 file_lengths,
7345 lengths.clone(),
7346 None,
7347 prealloc_mode,
7348 torrent_config.filesystem_direct_io,
7349 ) {
7350 Ok(s) => Arc::new(s),
7351 Err(e) => {
7352 warn!("failed to create filesystem storage: {e}, falling back to memory");
7353 Arc::new(irontide_storage::MemoryStorage::new(lengths))
7354 }
7355 }
7356 };
7357 let disk_handle = disk_manager.register_torrent(info_hash, storage).await;
7358
7359 let handle = TorrentHandle::from_torrent(
7360 meta.clone(),
7361 version,
7362 meta_v2,
7363 disk_handle,
7364 disk_manager,
7365 torrent_config,
7366 dht_v4_broadcast.subscribe(),
7367 dht_v6_broadcast.subscribe(),
7368 global_up,
7369 global_down,
7370 slot_tuner,
7371 alert_tx.clone(),
7372 Arc::clone(&alert_mask),
7373 utp_socket,
7374 utp_socket_v6,
7375 ban_manager,
7376 ip_filter,
7377 plugins,
7378 sam_session,
7379 ssl_manager,
7380 factory,
7381 Some(hash_pool),
7382 counters,
7383 )
7384 .await?;
7385
7386 post_alert(
7403 &alert_tx,
7404 &alert_mask,
7405 AlertKind::TorrentAdded {
7406 info_hash,
7407 name: meta.info.name.clone(),
7408 },
7409 );
7410 Ok(PreparedAddTorrent {
7411 handle,
7412 info_hash,
7413 is_private,
7414 m170_post,
7415 })
7416}
7417
7418struct ResumeSaveJob {
7424 info_hash: Id20,
7425 handle: TorrentHandle,
7426 queue_position: i64,
7427 auto_managed: i64,
7428}
7429
7430async fn run_resume_save_jobs(resume_dir: std::path::PathBuf, jobs: Vec<ResumeSaveJob>) -> usize {
7437 let torrents_dir = resume_dir.join("torrents");
7440 match tokio::task::spawn_blocking(move || std::fs::create_dir_all(&torrents_dir)).await {
7441 Ok(Ok(())) => {}
7442 Ok(Err(e)) => {
7443 warn!("failed to create resume dir: {e}");
7444 return 0;
7445 }
7446 Err(e) => {
7447 warn!("resume dir create task panicked: {e}");
7448 return 0;
7449 }
7450 }
7451
7452 let mut saved = 0usize;
7453 for job in &jobs {
7454 let mut rd = match job.handle.take_resume_if_dirty().await {
7461 Ok(Some(rd)) => rd,
7462 Ok(None) => continue,
7463 Err(e) => {
7464 warn!(info_hash = %job.info_hash, "failed to take resume data: {e}");
7465 continue;
7466 }
7467 };
7468 rd.queue_position = job.queue_position;
7469 rd.auto_managed = job.auto_managed;
7470
7471 let bytes = match crate::resume_file::serialize_resume(&rd) {
7476 Ok(b) => b,
7477 Err(e) => {
7478 warn!(info_hash = %job.info_hash, "failed to serialize resume data: {e}");
7479 redirty_after_failed_save(&job.handle, &job.info_hash).await;
7480 continue;
7481 }
7482 };
7483
7484 let path = crate::resume_file::resume_file_path(&resume_dir, &job.info_hash);
7486 let write_res =
7487 tokio::task::spawn_blocking(move || crate::resume_file::atomic_write(&path, &bytes))
7488 .await;
7489 match write_res {
7490 Ok(Ok(())) => {}
7491 Ok(Err(e)) => {
7492 warn!(info_hash = %job.info_hash, "failed to write resume file: {e}");
7493 redirty_after_failed_save(&job.handle, &job.info_hash).await;
7494 continue;
7495 }
7496 Err(e) => {
7497 warn!(info_hash = %job.info_hash, "resume write task panicked: {e}");
7498 redirty_after_failed_save(&job.handle, &job.info_hash).await;
7499 continue;
7500 }
7501 }
7502
7503 saved = saved.saturating_add(1);
7504 }
7505 saved
7506}
7507
7508async fn redirty_after_failed_save(handle: &TorrentHandle, info_hash: &Id20) {
7513 if let Err(e) = handle.mark_resume_dirty().await {
7514 warn!(info_hash = %info_hash, "failed to re-mark resume dirty after save failure: {e}");
7515 }
7516}
7517
7518fn synthesize_v1_from_v2(v2: &irontide_core::TorrentMetaV2) -> irontide_core::TorrentMetaV1 {
7519 use irontide_core::{FileEntry, InfoDict};
7520
7521 let info_hash = v2.info_hashes.best_v1();
7522
7523 let v2_files = v2.info.files();
7525 let file_entries: Vec<FileEntry> = v2_files
7526 .iter()
7527 .map(|f| FileEntry {
7528 length: f.attr.length,
7529 path: f.path.clone(),
7530 attr: None,
7531 mtime: None,
7532 symlink_path: None,
7533 })
7534 .collect();
7535
7536 let num_pieces = v2.info.num_pieces() as usize;
7539 let pieces = vec![0u8; num_pieces * 20];
7540
7541 let info = InfoDict {
7542 name: v2.info.name.clone(),
7543 piece_length: v2.info.piece_length,
7544 pieces,
7545 length: if file_entries.len() == 1 {
7546 Some(file_entries[0].length)
7547 } else {
7548 None
7549 },
7550 files: if file_entries.len() > 1 {
7551 Some(file_entries)
7552 } else {
7553 None
7554 },
7555 private: None,
7556 source: None,
7557 ssl_cert: v2.ssl_cert.clone(),
7558 similar: Vec::new(),
7559 collections: Vec::new(),
7560 };
7561
7562 irontide_core::TorrentMetaV1 {
7563 info_hash,
7564 announce: v2.announce.clone(),
7565 announce_list: v2.announce_list.clone(),
7566 comment: v2.comment.clone(),
7567 created_by: v2.created_by.clone(),
7568 creation_date: v2.creation_date,
7569 info,
7570 info_bytes: None,
7571 url_list: Vec::new(),
7572 httpseeds: Vec::new(),
7573 ssl_cert: v2.ssl_cert.clone(),
7574 }
7575}
7576
7577#[cfg(test)]
7578mod tests {
7579 use super::*;
7580 use crate::types::TorrentState;
7581 use irontide_core::{DEFAULT_CHUNK_SIZE, Lengths, TorrentMetaV1, torrent_from_bytes};
7582 use irontide_storage::MemoryStorage;
7583 use std::time::Duration;
7584
7585 fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
7586 use serde::Serialize;
7587
7588 #[derive(Serialize)]
7589 struct Info<'a> {
7590 length: u64,
7591 name: &'a str,
7592 #[serde(rename = "piece length")]
7593 piece_length: u64,
7594 #[serde(with = "serde_bytes")]
7595 pieces: &'a [u8],
7596 }
7597
7598 #[derive(Serialize)]
7599 struct Torrent<'a> {
7600 info: Info<'a>,
7601 }
7602
7603 let mut pieces = Vec::new();
7604 let mut offset = 0;
7605 while offset < data.len() {
7606 let end = (offset + piece_length as usize).min(data.len());
7607 let hash = irontide_core::sha1(&data[offset..end]);
7608 pieces.extend_from_slice(hash.as_bytes());
7609 offset = end;
7610 }
7611
7612 let t = Torrent {
7613 info: Info {
7614 length: data.len() as u64,
7615 name: "test",
7616 piece_length,
7617 pieces: &pieces,
7618 },
7619 };
7620
7621 let bytes = irontide_bencode::to_bytes(&t).unwrap();
7622 torrent_from_bytes(&bytes).unwrap()
7623 }
7624
7625 fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
7626 let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
7627 Arc::new(MemoryStorage::new(lengths))
7628 }
7629
7630 static TEST_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7631
7632 fn test_settings() -> Settings {
7633 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7634 let pid = std::process::id();
7635 let dl_dir = std::env::temp_dir().join(format!("irontide-session-lib-dl-{pid}-{n}"));
7636 let resume_dir =
7637 std::env::temp_dir().join(format!("irontide-session-lib-resume-{pid}-{n}"));
7638 let _ = std::fs::remove_dir_all(&dl_dir);
7639 let _ = std::fs::remove_dir_all(&resume_dir);
7640 let _ = std::fs::create_dir_all(&dl_dir);
7641
7642 Settings {
7643 listen_port: 0,
7644 download_dir: dl_dir,
7645 resume_data_dir: Some(resume_dir),
7646 max_torrents: 10,
7647 enable_dht: false,
7648 enable_pex: false,
7649 enable_lsd: false,
7650 enable_fast_extension: false,
7651 enable_utp: false,
7652 enable_upnp: false,
7653 enable_natpmp: false,
7654 enable_ipv6: false,
7655 alert_channel_size: 64,
7656 disk_io_threads: 2,
7657 storage_mode: irontide_core::StorageMode::Sparse,
7658 disk_cache_size: 1024 * 1024,
7659 ..Settings::default()
7660 }
7661 }
7662
7663 #[tokio::test]
7666 async fn session_start_and_shutdown() {
7667 let session = SessionHandle::start(test_settings()).await.unwrap();
7668 let stats = session.session_stats().await.unwrap();
7669 assert_eq!(stats.active_torrents, 0);
7670 session.shutdown().await.unwrap();
7671 }
7672
7673 #[tokio::test]
7674 async fn peer_unchoke_durations_returns_none_for_missing_torrent() {
7675 let session = SessionHandle::start(test_settings()).await.unwrap();
7676 let bogus = Id20([0u8; 20]);
7677 let result = session.peer_unchoke_durations(bogus).await.unwrap();
7678 assert!(
7679 result.is_none(),
7680 "missing torrent must yield None, not an empty map"
7681 );
7682 session.shutdown().await.unwrap();
7683 }
7684
7685 #[tokio::test]
7686 async fn peer_unchoke_durations_returns_empty_map_for_known_torrent_with_no_peers() {
7687 let session = SessionHandle::start(test_settings()).await.unwrap();
7688 let data = vec![0xAB; 16384];
7689 let meta = make_test_torrent(&data, 16384);
7690 let storage = make_storage(&data, 16384);
7691 let info_hash = session
7692 .add_torrent_with_meta(meta.into(), Some(storage))
7693 .await
7694 .unwrap();
7695 let result = session
7696 .peer_unchoke_durations(info_hash)
7697 .await
7698 .unwrap()
7699 .expect("known torrent must yield Some, even with no peers");
7700 assert!(
7701 result.is_empty(),
7702 "fresh torrent with no peers has no unchoke history"
7703 );
7704 session.shutdown().await.unwrap();
7705 }
7706
7707 #[tokio::test]
7710 async fn add_and_list_torrent() {
7711 let session = SessionHandle::start(test_settings()).await.unwrap();
7712 let data = vec![0xAB; 16384];
7713 let meta = make_test_torrent(&data, 16384);
7714 let expected_hash = meta.info_hash;
7715
7716 let storage = make_storage(&data, 16384);
7717 let info_hash = session
7718 .add_torrent_with_meta(meta.into(), Some(storage))
7719 .await
7720 .unwrap();
7721 assert_eq!(info_hash, expected_hash);
7722
7723 let list = session.list_torrents().await.unwrap();
7724 assert_eq!(list.len(), 1);
7725 assert!(list.contains(&info_hash));
7726
7727 session.shutdown().await.unwrap();
7728 }
7729
7730 #[tokio::test]
7733 async fn remove_torrent() {
7734 let session = SessionHandle::start(test_settings()).await.unwrap();
7735 let data = vec![0xAB; 16384];
7736 let meta = make_test_torrent(&data, 16384);
7737 let storage = make_storage(&data, 16384);
7738
7739 let info_hash = session
7740 .add_torrent_with_meta(meta.into(), Some(storage))
7741 .await
7742 .unwrap();
7743 session.remove_torrent(info_hash).await.unwrap();
7744
7745 tokio::time::sleep(Duration::from_millis(50)).await;
7746
7747 let list = session.list_torrents().await.unwrap();
7748 assert!(list.is_empty());
7749
7750 session.shutdown().await.unwrap();
7751 }
7752
7753 #[tokio::test]
7756 async fn duplicate_torrent_rejected() {
7757 let session = SessionHandle::start(test_settings()).await.unwrap();
7758 let data = vec![0xAB; 16384];
7759 let meta = make_test_torrent(&data, 16384);
7760 let storage1 = make_storage(&data, 16384);
7761 let storage2 = make_storage(&data, 16384);
7762
7763 session
7764 .add_torrent_with_meta(meta.clone().into(), Some(storage1))
7765 .await
7766 .unwrap();
7767 let result = session
7768 .add_torrent_with_meta(meta.into(), Some(storage2))
7769 .await;
7770 assert!(result.is_err());
7771 assert!(result.unwrap_err().to_string().contains("duplicate"));
7772
7773 session.shutdown().await.unwrap();
7774 }
7775
7776 #[tokio::test]
7779 async fn session_at_capacity() {
7780 let mut config = test_settings();
7781 config.max_torrents = 1;
7782 let session = SessionHandle::start(config).await.unwrap();
7783
7784 let data1 = vec![0xAA; 16384];
7785 let meta1 = make_test_torrent(&data1, 16384);
7786 let storage1 = make_storage(&data1, 16384);
7787 session
7788 .add_torrent_with_meta(meta1.into(), Some(storage1))
7789 .await
7790 .unwrap();
7791
7792 let data2 = vec![0xBB; 16384];
7793 let meta2 = make_test_torrent(&data2, 16384);
7794 let storage2 = make_storage(&data2, 16384);
7795 let result = session
7796 .add_torrent_with_meta(meta2.into(), Some(storage2))
7797 .await;
7798 assert!(result.is_err());
7799 assert!(result.unwrap_err().to_string().contains("capacity"));
7800
7801 session.shutdown().await.unwrap();
7802 }
7803
7804 #[tokio::test]
7807 async fn torrent_stats_via_session() {
7808 let session = SessionHandle::start(test_settings()).await.unwrap();
7809 let data = vec![0xAB; 32768];
7810 let meta = make_test_torrent(&data, 16384);
7811 let storage = make_storage(&data, 16384);
7812
7813 let info_hash = session
7814 .add_torrent_with_meta(meta.into(), Some(storage))
7815 .await
7816 .unwrap();
7817 let stats = session.torrent_stats(info_hash).await.unwrap();
7818 assert_eq!(stats.state, TorrentState::Downloading);
7819 assert_eq!(stats.pieces_total, 2);
7820
7821 session.shutdown().await.unwrap();
7822 }
7823
7824 #[tokio::test]
7827 async fn torrent_info_via_session() {
7828 let session = SessionHandle::start(test_settings()).await.unwrap();
7829 let data = vec![0xAB; 32768];
7830 let meta = make_test_torrent(&data, 16384);
7831 let storage = make_storage(&data, 16384);
7832
7833 let info_hash = session
7834 .add_torrent_with_meta(meta.into(), Some(storage))
7835 .await
7836 .unwrap();
7837 let info = session.torrent_info(info_hash).await.unwrap();
7838 assert_eq!(info.info_hash, info_hash);
7839 assert_eq!(info.name, "test");
7840 assert_eq!(info.total_length, 32768);
7841 assert_eq!(info.num_pieces, 2);
7842 assert!(!info.private);
7843 assert_eq!(info.files.len(), 1);
7844 assert_eq!(info.files[0].length, 32768);
7845
7846 session.shutdown().await.unwrap();
7847 }
7848
7849 #[tokio::test]
7852 async fn pause_resume_via_session() {
7853 let session = SessionHandle::start(test_settings()).await.unwrap();
7854 let data = vec![0xAB; 16384];
7855 let meta = make_test_torrent(&data, 16384);
7856 let storage = make_storage(&data, 16384);
7857
7858 let info_hash = session
7859 .add_torrent_with_meta(meta.into(), Some(storage))
7860 .await
7861 .unwrap();
7862
7863 session.pause_torrent(info_hash).await.unwrap();
7864 tokio::time::sleep(Duration::from_millis(50)).await;
7865 let stats = session.torrent_stats(info_hash).await.unwrap();
7866 assert_eq!(stats.state, TorrentState::Paused);
7867
7868 session.resume_torrent(info_hash).await.unwrap();
7869 tokio::time::sleep(Duration::from_millis(50)).await;
7870 let stats = session.torrent_stats(info_hash).await.unwrap();
7871 assert_eq!(stats.state, TorrentState::Downloading);
7872
7873 session.shutdown().await.unwrap();
7874 }
7875
7876 #[tokio::test]
7879 async fn not_found_errors() {
7880 let session = SessionHandle::start(test_settings()).await.unwrap();
7881 let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
7882
7883 assert!(session.torrent_stats(fake_hash).await.is_err());
7884 assert!(session.torrent_info(fake_hash).await.is_err());
7885 assert!(session.pause_torrent(fake_hash).await.is_err());
7886 assert!(session.resume_torrent(fake_hash).await.is_err());
7887 assert!(session.remove_torrent(fake_hash).await.is_err());
7888
7889 session.shutdown().await.unwrap();
7890 }
7891
7892 #[tokio::test]
7895 async fn session_stats_aggregate() {
7896 let session = SessionHandle::start(test_settings()).await.unwrap();
7897
7898 let data1 = vec![0xAA; 16384];
7899 let meta1 = make_test_torrent(&data1, 16384);
7900 let storage1 = make_storage(&data1, 16384);
7901 session
7902 .add_torrent_with_meta(meta1.into(), Some(storage1))
7903 .await
7904 .unwrap();
7905
7906 let data2 = vec![0xBB; 16384];
7907 let meta2 = make_test_torrent(&data2, 16384);
7908 let storage2 = make_storage(&data2, 16384);
7909 session
7910 .add_torrent_with_meta(meta2.into(), Some(storage2))
7911 .await
7912 .unwrap();
7913
7914 let stats = session.session_stats().await.unwrap();
7915 assert_eq!(stats.active_torrents, 2);
7916
7917 session.shutdown().await.unwrap();
7918 }
7919
7920 #[tokio::test]
7923 async fn add_magnet_and_list() {
7924 use irontide_core::Magnet;
7925
7926 let session = SessionHandle::start(test_settings()).await.unwrap();
7927 let magnet = Magnet {
7928 info_hashes: irontide_core::InfoHashes::v1_only(
7929 Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
7930 ),
7931 display_name: Some("test-magnet".into()),
7932 trackers: vec![],
7933 peers: vec![],
7934 selected_files: None,
7935 };
7936 let expected_hash = magnet.info_hash();
7937
7938 let info_hash = session.add_magnet(magnet).await.unwrap();
7939 assert_eq!(info_hash, expected_hash);
7940
7941 let list = session.list_torrents().await.unwrap();
7942 assert_eq!(list.len(), 1);
7943 assert!(list.contains(&info_hash));
7944
7945 let err = session.torrent_info(info_hash).await.unwrap_err();
7947 assert!(err.to_string().contains("metadata not yet available"));
7948
7949 session.shutdown().await.unwrap();
7950 }
7951
7952 #[tokio::test]
7955 async fn add_magnet_duplicate_rejected() {
7956 use irontide_core::Magnet;
7957
7958 let session = SessionHandle::start(test_settings()).await.unwrap();
7959 let magnet = Magnet {
7960 info_hashes: irontide_core::InfoHashes::v1_only(
7961 Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
7962 ),
7963 display_name: Some("test-magnet".into()),
7964 trackers: vec![],
7965 peers: vec![],
7966 selected_files: None,
7967 };
7968
7969 session.add_magnet(magnet.clone()).await.unwrap();
7970 let result = session.add_magnet(magnet).await;
7971 assert!(result.is_err());
7972 assert!(result.unwrap_err().to_string().contains("duplicate"));
7973
7974 session.shutdown().await.unwrap();
7975 }
7976
7977 #[tokio::test]
7980 async fn session_with_lsd_enabled() {
7981 use irontide_core::Magnet;
7982
7983 let mut config = test_settings();
7985 config.enable_lsd = true;
7986
7987 let session = SessionHandle::start(config).await.unwrap();
7988
7989 let data = vec![0xAB; 16384];
7991 let meta = make_test_torrent(&data, 16384);
7992 let storage = make_storage(&data, 16384);
7993 session
7994 .add_torrent_with_meta(meta.into(), Some(storage))
7995 .await
7996 .unwrap();
7997
7998 let magnet = Magnet {
8000 info_hashes: irontide_core::InfoHashes::v1_only(
8001 Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
8002 ),
8003 display_name: Some("lsd-test".into()),
8004 trackers: vec![],
8005 peers: vec![],
8006 selected_files: None,
8007 };
8008 session.add_magnet(magnet).await.unwrap();
8009
8010 let list = session.list_torrents().await.unwrap();
8011 assert_eq!(list.len(), 2);
8012
8013 session.shutdown().await.unwrap();
8014 }
8015
8016 #[tokio::test]
8019 async fn add_v2_only_torrent() {
8020 use irontide_bencode::BencodeValue;
8021 use std::collections::BTreeMap;
8022
8023 let session = SessionHandle::start(test_settings()).await.unwrap();
8024
8025 let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8027 attr_map.insert(b"length".to_vec(), BencodeValue::Integer(16384));
8028 let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8029 file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
8030 let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8031 ft_map.insert(b"test.dat".to_vec(), BencodeValue::Dict(file_node));
8032
8033 let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8034 info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
8035 info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
8036 info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"v2test".to_vec()));
8037 info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(16384));
8038
8039 let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8040 root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
8041
8042 let bytes = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
8043 let meta = irontide_core::torrent_from_bytes_any(&bytes).unwrap();
8044 assert!(meta.is_v2());
8045
8046 let info_hash = session.add_torrent_with_meta(meta, None).await.unwrap();
8048 let list = session.list_torrents().await.unwrap();
8049 assert!(list.contains(&info_hash));
8050
8051 session.shutdown().await.unwrap();
8052 }
8053
8054 #[tokio::test]
8057 async fn save_torrent_resume_data_via_session() {
8058 let session = SessionHandle::start(test_settings()).await.unwrap();
8059 let data = vec![0xAB; 32768];
8060 let meta = make_test_torrent(&data, 16384);
8061 let info_hash = meta.info_hash;
8062 let storage = make_storage(&data, 16384);
8063 session
8064 .add_torrent_with_meta(meta.into(), Some(storage))
8065 .await
8066 .unwrap();
8067
8068 let rd = session.save_torrent_resume_data(info_hash).await.unwrap();
8069 assert_eq!(rd.info_hash, info_hash.as_bytes().as_slice());
8070 assert_eq!(rd.name, "test");
8071 assert_eq!(rd.file_format, "libtorrent resume file");
8072 assert_eq!(rd.file_version, 1);
8073 assert!(!rd.pieces.is_empty());
8074 assert_eq!(rd.paused, 0);
8075
8076 session.shutdown().await.unwrap();
8077 }
8078
8079 #[tokio::test]
8082 async fn save_session_state_captures_all_torrents() {
8083 let session = SessionHandle::start(test_settings()).await.unwrap();
8084
8085 let data1 = vec![0xAA; 16384];
8086 let meta1 = make_test_torrent(&data1, 16384);
8087 let storage1 = make_storage(&data1, 16384);
8088 session
8089 .add_torrent_with_meta(meta1.into(), Some(storage1))
8090 .await
8091 .unwrap();
8092
8093 let data2 = vec![0xBB; 16384];
8094 let meta2 = make_test_torrent(&data2, 16384);
8095 let storage2 = make_storage(&data2, 16384);
8096 session
8097 .add_torrent_with_meta(meta2.into(), Some(storage2))
8098 .await
8099 .unwrap();
8100
8101 let state = session.save_session_state().await.unwrap();
8102 assert_eq!(state.torrents.len(), 2);
8103
8104 for rd in &state.torrents {
8105 assert_eq!(rd.file_format, "libtorrent resume file");
8106 assert_eq!(rd.info_hash.len(), 20);
8107 }
8108
8109 session.shutdown().await.unwrap();
8110 }
8111
8112 #[tokio::test]
8115 async fn save_resume_data_not_found() {
8116 let session = SessionHandle::start(test_settings()).await.unwrap();
8117 let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8118 let result = session.save_torrent_resume_data(fake_hash).await;
8119 assert!(result.is_err());
8120 assert!(result.unwrap_err().to_string().contains("not found"));
8121 session.shutdown().await.unwrap();
8122 }
8123
8124 #[tokio::test]
8127 async fn subscribe_receives_torrent_added_alert() {
8128 use crate::alert::AlertKind;
8129
8130 let session = SessionHandle::start(test_settings()).await.unwrap();
8131 let mut alerts = session.subscribe();
8132
8133 let data = vec![0xAB; 16384];
8134 let meta = make_test_torrent(&data, 16384);
8135 let storage = make_storage(&data, 16384);
8136 let _info_hash = session
8137 .add_torrent_with_meta(meta.into(), Some(storage))
8138 .await
8139 .unwrap();
8140
8141 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8142 .await
8143 .unwrap()
8144 .unwrap();
8145 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8146 session.shutdown().await.unwrap();
8147 }
8148
8149 #[tokio::test]
8152 async fn subscribe_receives_torrent_removed_alert() {
8153 use crate::alert::AlertKind;
8154 use crate::types::TorrentState;
8155
8156 let session = SessionHandle::start(test_settings()).await.unwrap();
8157 let mut alerts = session.subscribe();
8158
8159 let data = vec![0xAB; 16384];
8160 let meta = make_test_torrent(&data, 16384);
8161 let storage = make_storage(&data, 16384);
8162 let info_hash = session
8163 .add_torrent_with_meta(meta.into(), Some(storage))
8164 .await
8165 .unwrap();
8166
8167 while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_secs(1), alerts.recv()).await {
8169 if matches!(
8170 a.kind,
8171 AlertKind::StateChanged {
8172 new_state: TorrentState::Downloading,
8173 ..
8174 }
8175 ) {
8176 break;
8177 }
8178 }
8179
8180 session.remove_torrent(info_hash).await.unwrap();
8181
8182 loop {
8184 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8185 .await
8186 .unwrap()
8187 .unwrap();
8188 if matches!(alert.kind, AlertKind::TorrentRemoved { .. }) {
8189 break;
8190 }
8191 }
8192 session.shutdown().await.unwrap();
8193 }
8194
8195 #[tokio::test]
8198 async fn multiple_subscribers_each_receive_alerts() {
8199 use crate::alert::AlertKind;
8200
8201 let session = SessionHandle::start(test_settings()).await.unwrap();
8202 let mut sub1 = session.subscribe();
8203 let mut sub2 = session.subscribe();
8204
8205 let data = vec![0xAB; 16384];
8206 let meta = make_test_torrent(&data, 16384);
8207 let storage = make_storage(&data, 16384);
8208 session
8209 .add_torrent_with_meta(meta.into(), Some(storage))
8210 .await
8211 .unwrap();
8212
8213 let a1 = tokio::time::timeout(Duration::from_secs(2), sub1.recv())
8214 .await
8215 .unwrap()
8216 .unwrap();
8217 let a2 = tokio::time::timeout(Duration::from_secs(2), sub2.recv())
8218 .await
8219 .unwrap()
8220 .unwrap();
8221
8222 assert!(matches!(a1.kind, AlertKind::TorrentAdded { .. }));
8223 assert!(matches!(a2.kind, AlertKind::TorrentAdded { .. }));
8224 session.shutdown().await.unwrap();
8225 }
8226
8227 #[tokio::test]
8230 async fn set_alert_mask_filters_at_runtime() {
8231 use crate::alert::{AlertCategory, AlertKind};
8232
8233 let session = SessionHandle::start(test_settings()).await.unwrap();
8234 let mut alerts = session.subscribe();
8235
8236 let data = vec![0xAB; 16384];
8238 let meta = make_test_torrent(&data, 16384);
8239 let storage = make_storage(&data, 16384);
8240 session
8241 .add_torrent_with_meta(meta.into(), Some(storage))
8242 .await
8243 .unwrap();
8244
8245 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8246 .await
8247 .unwrap()
8248 .unwrap();
8249 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8250
8251 while tokio::time::timeout(Duration::from_millis(200), alerts.recv())
8253 .await
8254 .is_ok()
8255 {}
8256
8257 session.set_alert_mask(AlertCategory::empty());
8259
8260 let data2 = vec![0xBB; 16384];
8261 let meta2 = make_test_torrent(&data2, 16384);
8262 let storage2 = make_storage(&data2, 16384);
8263 session
8264 .add_torrent_with_meta(meta2.into(), Some(storage2))
8265 .await
8266 .unwrap();
8267
8268 let result = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await;
8270 assert!(result.is_err(), "should have timed out with empty mask");
8271
8272 session.set_alert_mask(AlertCategory::STATUS);
8274
8275 let data3 = vec![0xCC; 16384];
8276 let meta3 = make_test_torrent(&data3, 16384);
8277 let storage3 = make_storage(&data3, 16384);
8278 session
8279 .add_torrent_with_meta(meta3.into(), Some(storage3))
8280 .await
8281 .unwrap();
8282
8283 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8284 .await
8285 .unwrap()
8286 .unwrap();
8287 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8288
8289 session.shutdown().await.unwrap();
8290 }
8291
8292 #[tokio::test]
8295 async fn alert_stream_filters_per_subscriber() {
8296 use crate::alert::{AlertCategory, AlertKind};
8297
8298 let session = SessionHandle::start(test_settings()).await.unwrap();
8299
8300 let mut status_sub = session.subscribe_filtered(AlertCategory::STATUS);
8302 let mut peer_sub = session.subscribe_filtered(AlertCategory::PEER);
8304
8305 let data = vec![0xAB; 16384];
8306 let meta = make_test_torrent(&data, 16384);
8307 let storage = make_storage(&data, 16384);
8308 session
8309 .add_torrent_with_meta(meta.into(), Some(storage))
8310 .await
8311 .unwrap();
8312
8313 let alert = tokio::time::timeout(Duration::from_secs(2), status_sub.recv())
8315 .await
8316 .unwrap()
8317 .unwrap();
8318 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8319
8320 let result = tokio::time::timeout(Duration::from_millis(200), peer_sub.recv()).await;
8322 assert!(
8323 result.is_err(),
8324 "PEER subscriber should not get STATUS alerts"
8325 );
8326
8327 session.shutdown().await.unwrap();
8328 }
8329
8330 #[tokio::test]
8333 async fn state_changed_tracks_transitions() {
8334 use crate::alert::AlertKind;
8335
8336 let session = SessionHandle::start(test_settings()).await.unwrap();
8337 let mut alerts = session.subscribe();
8338
8339 let data = vec![0xAB; 16384];
8340 let meta = make_test_torrent(&data, 16384);
8341 let storage = make_storage(&data, 16384);
8342 let info_hash = session
8343 .add_torrent_with_meta(meta.into(), Some(storage))
8344 .await
8345 .unwrap();
8346
8347 let _ = tokio::time::timeout(Duration::from_secs(1), alerts.recv())
8349 .await
8350 .unwrap();
8351
8352 session.pause_torrent(info_hash).await.unwrap();
8354 tokio::time::sleep(Duration::from_millis(100)).await;
8355
8356 let mut state_changes = Vec::new();
8358 let mut paused_alerts = Vec::new();
8359 while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8360 {
8361 match &a.kind {
8362 AlertKind::StateChanged {
8363 prev_state,
8364 new_state,
8365 ..
8366 } => {
8367 state_changes.push((*prev_state, *new_state));
8368 }
8369 AlertKind::TorrentPaused { .. } => {
8370 paused_alerts.push(a);
8371 }
8372 _ => {} }
8374 }
8375
8376 assert!(
8377 state_changes.contains(&(TorrentState::Downloading, TorrentState::Paused)),
8378 "expected Downloading→Paused, got: {state_changes:?}"
8379 );
8380 assert!(!paused_alerts.is_empty(), "expected TorrentPaused alert");
8381
8382 session.resume_torrent(info_hash).await.unwrap();
8384 tokio::time::sleep(Duration::from_millis(100)).await;
8385
8386 let mut resume_state_changes = Vec::new();
8387 let mut resumed_alerts = Vec::new();
8388 while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8389 {
8390 match &a.kind {
8391 AlertKind::StateChanged {
8392 prev_state,
8393 new_state,
8394 ..
8395 } => {
8396 resume_state_changes.push((*prev_state, *new_state));
8397 }
8398 AlertKind::TorrentResumed { .. } => {
8399 resumed_alerts.push(a);
8400 }
8401 _ => {}
8402 }
8403 }
8404
8405 assert!(
8406 resume_state_changes.contains(&(TorrentState::Paused, TorrentState::Downloading)),
8407 "expected Paused→Downloading, got: {resume_state_changes:?}"
8408 );
8409 assert!(!resumed_alerts.is_empty(), "expected TorrentResumed alert");
8410
8411 session.shutdown().await.unwrap();
8412 }
8413
8414 #[tokio::test]
8415 async fn session_config_creates_utp_socket() {
8416 let mut config = test_settings();
8418 config.enable_utp = true;
8419 let session = SessionHandle::start(config).await.unwrap();
8420 let stats = session.session_stats().await.unwrap();
8421 assert_eq!(stats.active_torrents, 0);
8422 session.shutdown().await.unwrap();
8423 }
8424
8425 #[test]
8426 fn settings_nat_defaults() {
8427 let s = Settings::default();
8428 assert!(s.enable_upnp, "enable_upnp should default to true");
8429 assert!(s.enable_natpmp, "enable_natpmp should default to true");
8430 }
8431
8432 #[tokio::test]
8433 async fn session_with_nat_disabled() {
8434 let config = test_settings();
8435 assert!(!config.enable_upnp);
8437 assert!(!config.enable_natpmp);
8438 let session = SessionHandle::start(config).await.unwrap();
8439 let stats = session.session_stats().await.unwrap();
8440 assert_eq!(stats.active_torrents, 0);
8441 session.shutdown().await.unwrap();
8442 }
8443
8444 #[test]
8447 fn anonymous_mode_disables_discovery() {
8448 let mut config = test_settings();
8449 config.anonymous_mode = true;
8450 config.enable_dht = true;
8451 config.enable_lsd = true;
8452 config.enable_upnp = true;
8453 config.enable_natpmp = true;
8454
8455 if config.anonymous_mode {
8458 config.enable_dht = false;
8459 config.enable_lsd = false;
8460 config.enable_upnp = false;
8461 config.enable_natpmp = false;
8462 }
8463
8464 assert!(!config.enable_dht);
8465 assert!(!config.enable_lsd);
8466 assert!(!config.enable_upnp);
8467 assert!(!config.enable_natpmp);
8468 }
8469
8470 #[tokio::test]
8471 async fn anonymous_mode_session_starts_with_discovery_disabled() {
8472 let mut config = test_settings();
8473 config.anonymous_mode = true;
8474 config.enable_dht = true;
8476 config.enable_lsd = true;
8477
8478 let session = SessionHandle::start(config).await.unwrap();
8479 let stats = session.session_stats().await.unwrap();
8480 assert_eq!(stats.active_torrents, 0);
8481 session.shutdown().await.unwrap();
8482 }
8483
8484 #[test]
8485 fn force_proxy_requires_proxy_configured() {
8486 let mut config = test_settings();
8487 config.force_proxy = true;
8488 config.proxy = crate::proxy::ProxyConfig::default(); assert_eq!(config.proxy.proxy_type, crate::proxy::ProxyType::None);
8492 assert!(config.force_proxy);
8493 }
8495
8496 #[tokio::test]
8497 async fn force_proxy_errors_without_proxy() {
8498 let mut config = test_settings();
8499 config.force_proxy = true;
8500 let result = SessionHandle::start(config).await;
8503 assert!(result.is_err());
8504 match result {
8505 Err(e) => assert!(
8506 e.to_string().contains("force_proxy"),
8507 "error should mention force_proxy: {e}"
8508 ),
8509 Ok(_) => panic!("expected error"),
8510 }
8511 }
8512
8513 #[test]
8514 fn force_proxy_disables_features() {
8515 let mut config = test_settings();
8516 config.force_proxy = true;
8517 config.proxy = crate::proxy::ProxyConfig {
8518 proxy_type: crate::proxy::ProxyType::Socks5,
8519 hostname: "proxy.example.com".into(),
8520 port: 1080,
8521 ..Default::default()
8522 };
8523 config.enable_dht = true;
8524 config.enable_lsd = true;
8525 config.enable_upnp = true;
8526 config.enable_natpmp = true;
8527
8528 if config.force_proxy {
8530 config.enable_upnp = false;
8531 config.enable_natpmp = false;
8532 config.enable_dht = false;
8533 config.enable_lsd = false;
8534 }
8535
8536 assert!(!config.enable_dht);
8537 assert!(!config.enable_lsd);
8538 assert!(!config.enable_upnp);
8539 assert!(!config.enable_natpmp);
8540 }
8541
8542 #[test]
8543 fn proxy_config_round_trip() {
8544 let s = Settings {
8545 proxy: crate::proxy::ProxyConfig {
8546 proxy_type: crate::proxy::ProxyType::Socks5Password,
8547 hostname: "localhost".into(),
8548 port: 9050,
8549 username: Some("user".into()),
8550 password: Some("pass".into()),
8551 ..Default::default()
8552 },
8553 force_proxy: true,
8554 anonymous_mode: true,
8555 ..test_settings()
8556 };
8557
8558 assert_eq!(s.proxy.proxy_type, crate::proxy::ProxyType::Socks5Password);
8559 assert_eq!(s.proxy.hostname, "localhost");
8560 assert_eq!(s.proxy.port, 9050);
8561 assert!(s.force_proxy);
8562 assert!(s.anonymous_mode);
8563 assert_eq!(s.proxy.to_url(), "socks5://user:pass@localhost:9050");
8564 }
8565
8566 #[tokio::test]
8567 async fn apply_settings_runtime() {
8568 let session = SessionHandle::start(test_settings()).await.unwrap();
8569 let original = session.settings().await.unwrap();
8570 assert_eq!(original.max_torrents, 10);
8571
8572 let mut new = original.clone();
8573 new.max_torrents = 200;
8574 new.upload_rate_limit = 1_000_000;
8575 session.apply_settings(new).await.unwrap();
8576
8577 let updated = session.settings().await.unwrap();
8578 assert_eq!(updated.max_torrents, 200);
8579 assert_eq!(updated.upload_rate_limit, 1_000_000);
8580
8581 session.shutdown().await.unwrap();
8582 }
8583
8584 #[tokio::test]
8585 async fn apply_settings_validation_error() {
8586 let session = SessionHandle::start(test_settings()).await.unwrap();
8587
8588 let bad = Settings {
8590 force_proxy: true,
8591 ..Settings::default()
8592 };
8593 let result = session.apply_settings(bad).await;
8594 assert!(result.is_err());
8595
8596 let current = session.settings().await.unwrap();
8598 assert!(!current.force_proxy);
8599
8600 session.shutdown().await.unwrap();
8601 }
8602
8603 #[tokio::test]
8606 async fn session_stats_counters_accessible() {
8607 let session = SessionHandle::start(test_settings()).await.unwrap();
8608 let counters = session.counters();
8609 let _ = counters.uptime_secs();
8613 assert_eq!(counters.len(), crate::stats::NUM_METRICS);
8614 session.shutdown().await.unwrap();
8615 }
8616
8617 #[tokio::test]
8618 async fn post_session_stats_fires_alert() {
8619 use crate::alert::{AlertCategory, AlertKind};
8620
8621 let session = SessionHandle::start(test_settings()).await.unwrap();
8622 let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8623
8624 session.post_session_stats().await.unwrap();
8625
8626 let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
8627 .await
8628 .expect("timed out waiting for SessionStatsAlert")
8629 .expect("recv error");
8630 assert!(
8631 matches!(alert.kind, AlertKind::SessionStatsAlert { ref values } if values.len() == crate::stats::NUM_METRICS),
8632 "expected SessionStatsAlert with {} values, got {:?}",
8633 crate::stats::NUM_METRICS,
8634 alert.kind,
8635 );
8636 session.shutdown().await.unwrap();
8637 }
8638
8639 #[tokio::test]
8640 async fn session_stats_include_torrent_count() {
8641 use crate::alert::{AlertCategory, AlertKind};
8642
8643 let session = SessionHandle::start(test_settings()).await.unwrap();
8644 let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8645
8646 let data = vec![0xAB; 16384];
8648 let meta = make_test_torrent(&data, 16384);
8649 let storage = make_storage(&data, 16384);
8650 session
8651 .add_torrent_with_meta(meta.into(), Some(storage))
8652 .await
8653 .unwrap();
8654
8655 session.post_session_stats().await.unwrap();
8656
8657 let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
8658 .await
8659 .expect("timed out waiting for SessionStatsAlert")
8660 .expect("recv error");
8661 match alert.kind {
8662 AlertKind::SessionStatsAlert { values } => {
8663 assert!(
8664 values[crate::stats::SES_NUM_TORRENTS] > 0,
8665 "SES_NUM_TORRENTS should be > 0 after adding a torrent, got {}",
8666 values[crate::stats::SES_NUM_TORRENTS],
8667 );
8668 }
8669 other => panic!("expected SessionStatsAlert, got {other:?}"),
8670 }
8671 session.shutdown().await.unwrap();
8672 }
8673
8674 #[tokio::test]
8675 async fn stats_timer_disabled_when_zero() {
8676 use crate::alert::AlertCategory;
8677
8678 let mut config = test_settings();
8679 config.stats_report_interval = 0;
8680 let session = SessionHandle::start(config).await.unwrap();
8681 let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8682
8683 let result = tokio::time::timeout(Duration::from_millis(200), stats_sub.recv()).await;
8685 assert!(
8686 result.is_err(),
8687 "no SessionStatsAlert should fire when stats_report_interval is 0"
8688 );
8689 session.shutdown().await.unwrap();
8690 }
8691
8692 #[tokio::test]
8693 async fn sample_infohashes_timer_disabled_when_zero() {
8694 use crate::alert::AlertCategory;
8695
8696 let mut config = test_settings();
8697 config.dht_sample_infohashes_interval = 0;
8698 let session = SessionHandle::start(config).await.unwrap();
8699 let mut dht_sub = session.subscribe_filtered(AlertCategory::DHT);
8700
8701 let result = tokio::time::timeout(Duration::from_millis(200), dht_sub.recv()).await;
8703 assert!(
8704 result.is_err(),
8705 "no DhtSampleInfohashes alert should fire when interval is 0"
8706 );
8707 session.shutdown().await.unwrap();
8708 }
8709
8710 #[tokio::test]
8713 async fn open_file_not_found() {
8714 let session = SessionHandle::start(test_settings()).await.unwrap();
8715 let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8716 let result = session.open_file(fake_hash, 0).await;
8717 assert!(result.is_err());
8718 let err = result.err().unwrap();
8719 assert!(err.to_string().contains("not found"));
8720 session.shutdown().await.unwrap();
8721 }
8722
8723 #[tokio::test]
8726 async fn open_file_routes_to_torrent() {
8727 let session = SessionHandle::start(test_settings()).await.unwrap();
8728 let data = vec![0xAB; 32768];
8729 let meta = make_test_torrent(&data, 16384);
8730 let storage = make_storage(&data, 16384);
8731
8732 let info_hash = session
8733 .add_torrent_with_meta(meta.into(), Some(storage))
8734 .await
8735 .unwrap();
8736
8737 let stream = session.open_file(info_hash, 0).await;
8739 assert!(stream.is_ok(), "open_file should succeed for file_index 0");
8740
8741 let result = session.open_file(info_hash, 999).await;
8743 assert!(
8744 result.is_err(),
8745 "open_file should fail for invalid file_index"
8746 );
8747
8748 session.shutdown().await.unwrap();
8749 }
8750
8751 #[tokio::test]
8754 async fn session_force_reannounce() {
8755 let session = SessionHandle::start(test_settings()).await.unwrap();
8756 let data = vec![0xAB; 16384];
8757 let meta = make_test_torrent(&data, 16384);
8758 let storage = make_storage(&data, 16384);
8759 let info_hash = session
8760 .add_torrent_with_meta(meta.into(), Some(storage))
8761 .await
8762 .unwrap();
8763
8764 let result = session.force_reannounce(info_hash).await;
8766 assert!(
8767 result.is_ok(),
8768 "force_reannounce should succeed: {result:?}"
8769 );
8770
8771 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8773 assert!(session.force_reannounce(fake).await.is_err());
8774
8775 session.shutdown().await.unwrap();
8776 }
8777
8778 #[tokio::test]
8781 async fn session_tracker_list() {
8782 let session = SessionHandle::start(test_settings()).await.unwrap();
8783 let data = vec![0xAB; 16384];
8784 let meta = make_test_torrent(&data, 16384);
8785 let storage = make_storage(&data, 16384);
8786 let info_hash = session
8787 .add_torrent_with_meta(meta.into(), Some(storage))
8788 .await
8789 .unwrap();
8790
8791 let trackers = session.tracker_list(info_hash).await.unwrap();
8793 assert!(trackers.is_empty(), "test torrent has no trackers");
8794
8795 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8797 assert!(session.tracker_list(fake).await.is_err());
8798
8799 session.shutdown().await.unwrap();
8800 }
8801
8802 #[tokio::test]
8805 async fn session_scrape() {
8806 let session = SessionHandle::start(test_settings()).await.unwrap();
8807 let data = vec![0xAB; 16384];
8808 let meta = make_test_torrent(&data, 16384);
8809 let storage = make_storage(&data, 16384);
8810 let info_hash = session
8811 .add_torrent_with_meta(meta.into(), Some(storage))
8812 .await
8813 .unwrap();
8814
8815 let scrape = session.scrape(info_hash).await.unwrap();
8817 assert!(scrape.is_none(), "test torrent has no trackers to scrape");
8818
8819 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8821 assert!(session.scrape(fake).await.is_err());
8822
8823 session.shutdown().await.unwrap();
8824 }
8825
8826 #[tokio::test]
8829 async fn session_set_file_priority() {
8830 let session = SessionHandle::start(test_settings()).await.unwrap();
8831 let data = vec![0xAB; 16384];
8832 let meta = make_test_torrent(&data, 16384);
8833 let storage = make_storage(&data, 16384);
8834 let info_hash = session
8835 .add_torrent_with_meta(meta.into(), Some(storage))
8836 .await
8837 .unwrap();
8838
8839 let result = session
8841 .set_file_priority(info_hash, 0, irontide_core::FilePriority::Normal)
8842 .await;
8843 assert!(
8844 result.is_ok(),
8845 "set_file_priority should succeed: {result:?}"
8846 );
8847
8848 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8850 assert!(
8851 session
8852 .set_file_priority(fake, 0, irontide_core::FilePriority::Normal)
8853 .await
8854 .is_err()
8855 );
8856
8857 session.shutdown().await.unwrap();
8858 }
8859
8860 #[tokio::test]
8863 async fn session_file_priorities() {
8864 let session = SessionHandle::start(test_settings()).await.unwrap();
8865 let data = vec![0xAB; 16384];
8866 let meta = make_test_torrent(&data, 16384);
8867 let storage = make_storage(&data, 16384);
8868 let info_hash = session
8869 .add_torrent_with_meta(meta.into(), Some(storage))
8870 .await
8871 .unwrap();
8872
8873 let priorities = session.file_priorities(info_hash).await.unwrap();
8875 assert_eq!(
8876 priorities.len(),
8877 1,
8878 "single-file torrent should have 1 file priority"
8879 );
8880 assert_eq!(priorities[0], irontide_core::FilePriority::Normal);
8881
8882 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8884 assert!(session.file_priorities(fake).await.is_err());
8885
8886 session.shutdown().await.unwrap();
8887 }
8888
8889 #[tokio::test]
8892 async fn set_download_limit_zero_means_unlimited() {
8893 let session = SessionHandle::start(test_settings()).await.unwrap();
8894 let data = vec![0xAB; 16384];
8895 let meta = make_test_torrent(&data, 16384);
8896 let storage = make_storage(&data, 16384);
8897 let info_hash = session
8898 .add_torrent_with_meta(meta.into(), Some(storage))
8899 .await
8900 .unwrap();
8901
8902 session.set_download_limit(info_hash, 50_000).await.unwrap();
8904 session.set_download_limit(info_hash, 0).await.unwrap();
8905 let limit = session.download_limit(info_hash).await.unwrap();
8906 assert_eq!(limit, 0, "0 means unlimited");
8907
8908 session.shutdown().await.unwrap();
8909 }
8910
8911 #[tokio::test]
8914 async fn set_upload_limit_persists() {
8915 let session = SessionHandle::start(test_settings()).await.unwrap();
8916 let data = vec![0xAB; 16384];
8917 let meta = make_test_torrent(&data, 16384);
8918 let storage = make_storage(&data, 16384);
8919 let info_hash = session
8920 .add_torrent_with_meta(meta.into(), Some(storage))
8921 .await
8922 .unwrap();
8923
8924 session.set_upload_limit(info_hash, 100_000).await.unwrap();
8925 let limit = session.upload_limit(info_hash).await.unwrap();
8926 assert_eq!(limit, 100_000);
8927
8928 session.shutdown().await.unwrap();
8929 }
8930
8931 #[tokio::test]
8934 async fn download_limit_default_is_zero() {
8935 let session = SessionHandle::start(test_settings()).await.unwrap();
8936 let data = vec![0xAB; 16384];
8937 let meta = make_test_torrent(&data, 16384);
8938 let storage = make_storage(&data, 16384);
8939 let info_hash = session
8940 .add_torrent_with_meta(meta.into(), Some(storage))
8941 .await
8942 .unwrap();
8943
8944 let limit = session.download_limit(info_hash).await.unwrap();
8946 assert_eq!(limit, 0, "default download limit should be 0 (unlimited)");
8947
8948 session.shutdown().await.unwrap();
8949 }
8950
8951 #[tokio::test]
8954 async fn rate_limit_round_trip() {
8955 let session = SessionHandle::start(test_settings()).await.unwrap();
8956 let data = vec![0xAB; 16384];
8957 let meta = make_test_torrent(&data, 16384);
8958 let storage = make_storage(&data, 16384);
8959 let info_hash = session
8960 .add_torrent_with_meta(meta.into(), Some(storage))
8961 .await
8962 .unwrap();
8963
8964 session
8966 .set_download_limit(info_hash, 1_000_000)
8967 .await
8968 .unwrap();
8969 session.set_upload_limit(info_hash, 500_000).await.unwrap();
8970
8971 let dl = session.download_limit(info_hash).await.unwrap();
8973 let ul = session.upload_limit(info_hash).await.unwrap();
8974 assert_eq!(dl, 1_000_000);
8975 assert_eq!(ul, 500_000);
8976
8977 session
8979 .set_download_limit(info_hash, 2_000_000)
8980 .await
8981 .unwrap();
8982 let dl = session.download_limit(info_hash).await.unwrap();
8983 assert_eq!(dl, 2_000_000);
8984
8985 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8987 assert!(session.download_limit(fake).await.is_err());
8988 assert!(session.upload_limit(fake).await.is_err());
8989 assert!(session.set_download_limit(fake, 100).await.is_err());
8990 assert!(session.set_upload_limit(fake, 100).await.is_err());
8991
8992 session.shutdown().await.unwrap();
8993 }
8994
8995 #[tokio::test]
8998 async fn sequential_download_toggle() {
8999 let session = SessionHandle::start(test_settings()).await.unwrap();
9000 let data = vec![0xAB; 16384];
9001 let meta = make_test_torrent(&data, 16384);
9002 let storage = make_storage(&data, 16384);
9003 let info_hash = session
9004 .add_torrent_with_meta(meta.into(), Some(storage))
9005 .await
9006 .unwrap();
9007
9008 session
9010 .set_sequential_download(info_hash, true)
9011 .await
9012 .unwrap();
9013 assert!(session.is_sequential_download(info_hash).await.unwrap());
9014
9015 session
9017 .set_sequential_download(info_hash, false)
9018 .await
9019 .unwrap();
9020 assert!(!session.is_sequential_download(info_hash).await.unwrap());
9021
9022 session.shutdown().await.unwrap();
9023 }
9024
9025 #[tokio::test]
9028 async fn super_seeding_toggle() {
9029 let session = SessionHandle::start(test_settings()).await.unwrap();
9030 let data = vec![0xAB; 16384];
9031 let meta = make_test_torrent(&data, 16384);
9032 let storage = make_storage(&data, 16384);
9033 let info_hash = session
9034 .add_torrent_with_meta(meta.into(), Some(storage))
9035 .await
9036 .unwrap();
9037
9038 session.set_super_seeding(info_hash, true).await.unwrap();
9040 assert!(session.is_super_seeding(info_hash).await.unwrap());
9041
9042 session.set_super_seeding(info_hash, false).await.unwrap();
9044 assert!(!session.is_super_seeding(info_hash).await.unwrap());
9045
9046 session.shutdown().await.unwrap();
9047 }
9048
9049 #[tokio::test]
9052 async fn sequential_download_default_false() {
9053 let session = SessionHandle::start(test_settings()).await.unwrap();
9054 let data = vec![0xAB; 16384];
9055 let meta = make_test_torrent(&data, 16384);
9056 let storage = make_storage(&data, 16384);
9057 let info_hash = session
9058 .add_torrent_with_meta(meta.into(), Some(storage))
9059 .await
9060 .unwrap();
9061
9062 assert!(!session.is_sequential_download(info_hash).await.unwrap());
9064
9065 session.shutdown().await.unwrap();
9066 }
9067
9068 #[tokio::test]
9071 async fn super_seeding_default_false() {
9072 let session = SessionHandle::start(test_settings()).await.unwrap();
9073 let data = vec![0xAB; 16384];
9074 let meta = make_test_torrent(&data, 16384);
9075 let storage = make_storage(&data, 16384);
9076 let info_hash = session
9077 .add_torrent_with_meta(meta.into(), Some(storage))
9078 .await
9079 .unwrap();
9080
9081 assert!(!session.is_super_seeding(info_hash).await.unwrap());
9083
9084 session.shutdown().await.unwrap();
9085 }
9086
9087 #[tokio::test]
9090 async fn seed_mode_flips_user_flag() {
9091 let session = SessionHandle::start(test_settings()).await.unwrap();
9092 let data = vec![0xAB; 16384];
9093 let meta = make_test_torrent(&data, 16384);
9094 let storage = make_storage(&data, 16384);
9095 let info_hash = session
9096 .add_torrent_with_meta(meta.into(), Some(storage))
9097 .await
9098 .unwrap();
9099
9100 let stats_before = session.torrent_stats(info_hash).await.unwrap();
9102 assert!(
9103 !stats_before.user_seed_mode,
9104 "new torrent should not start in user seed mode"
9105 );
9106
9107 session.set_seed_mode(info_hash, true).await.unwrap();
9109 let stats_on = session.torrent_stats(info_hash).await.unwrap();
9110 assert!(
9111 stats_on.user_seed_mode,
9112 "stats should reflect user_seed_mode=true after enabling"
9113 );
9114
9115 session.set_seed_mode(info_hash, false).await.unwrap();
9117 let stats_off = session.torrent_stats(info_hash).await.unwrap();
9118 assert!(
9119 !stats_off.user_seed_mode,
9120 "stats should reflect user_seed_mode=false after disabling"
9121 );
9122
9123 session.shutdown().await.unwrap();
9124 }
9125
9126 #[tokio::test]
9129 async fn seed_mode_round_trip() {
9130 let session = SessionHandle::start(test_settings()).await.unwrap();
9134 let data = vec![0xAB; 16384];
9135 let meta = make_test_torrent(&data, 16384);
9136 let storage = make_storage(&data, 16384);
9137 let info_hash = session
9138 .add_torrent_with_meta(meta.into(), Some(storage))
9139 .await
9140 .unwrap();
9141
9142 for (i, enabled) in [true, false, true, true, false].iter().enumerate() {
9143 session.set_seed_mode(info_hash, *enabled).await.unwrap();
9144 let stats = session.torrent_stats(info_hash).await.unwrap();
9145 assert_eq!(
9146 stats.user_seed_mode, *enabled,
9147 "iteration {i}: stats.user_seed_mode should track the toggle"
9148 );
9149 }
9150
9151 session.shutdown().await.unwrap();
9152 }
9153
9154 #[tokio::test]
9157 async fn seed_mode_missing_info_hash_errors() {
9158 let session = SessionHandle::start(test_settings()).await.unwrap();
9159 let fake =
9160 irontide_core::Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
9161 let err = session
9162 .set_seed_mode(fake, true)
9163 .await
9164 .expect_err("set_seed_mode on unknown info hash must return an error");
9165 match err {
9166 crate::Error::TorrentNotFound(h) => assert_eq!(h, fake),
9167 other => panic!("expected TorrentNotFound, got {other:?}"),
9168 }
9169 session.shutdown().await.unwrap();
9170 }
9171
9172 #[tokio::test]
9175 async fn seed_mode_idempotent() {
9176 let session = SessionHandle::start(test_settings()).await.unwrap();
9178 let data = vec![0xAB; 16384];
9179 let meta = make_test_torrent(&data, 16384);
9180 let storage = make_storage(&data, 16384);
9181 let info_hash = session
9182 .add_torrent_with_meta(meta.into(), Some(storage))
9183 .await
9184 .unwrap();
9185
9186 session.set_seed_mode(info_hash, true).await.unwrap();
9188 session.set_seed_mode(info_hash, true).await.unwrap();
9189 assert!(
9190 session
9191 .torrent_stats(info_hash)
9192 .await
9193 .unwrap()
9194 .user_seed_mode
9195 );
9196
9197 session.set_seed_mode(info_hash, false).await.unwrap();
9199 session.set_seed_mode(info_hash, false).await.unwrap();
9200 assert!(
9201 !session
9202 .torrent_stats(info_hash)
9203 .await
9204 .unwrap()
9205 .user_seed_mode
9206 );
9207
9208 session.shutdown().await.unwrap();
9209 }
9210
9211 #[tokio::test]
9214 async fn add_tracker_increases_count() {
9215 let session = SessionHandle::start(test_settings()).await.unwrap();
9216 let data = vec![0xAB; 16384];
9217 let meta = make_test_torrent(&data, 16384);
9218 let storage = make_storage(&data, 16384);
9219 let info_hash = session
9220 .add_torrent_with_meta(meta.into(), Some(storage))
9221 .await
9222 .unwrap();
9223
9224 let before = session.tracker_list(info_hash).await.unwrap();
9226 assert!(before.is_empty());
9227
9228 session
9230 .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9231 .await
9232 .unwrap();
9233
9234 let after = session.tracker_list(info_hash).await.unwrap();
9235 assert_eq!(after.len(), 1);
9236 assert_eq!(after[0].url, "udp://tracker.example.com:6969/announce");
9237
9238 session.shutdown().await.unwrap();
9239 }
9240
9241 #[tokio::test]
9244 async fn replace_trackers_replaces_all() {
9245 let session = SessionHandle::start(test_settings()).await.unwrap();
9246 let data = vec![0xAB; 16384];
9247 let meta = make_test_torrent(&data, 16384);
9248 let storage = make_storage(&data, 16384);
9249 let info_hash = session
9250 .add_torrent_with_meta(meta.into(), Some(storage))
9251 .await
9252 .unwrap();
9253
9254 session
9256 .add_tracker(info_hash, "udp://tracker1.example.com:6969/announce".into())
9257 .await
9258 .unwrap();
9259 session
9260 .add_tracker(info_hash, "http://tracker2.example.com/announce".into())
9261 .await
9262 .unwrap();
9263 assert_eq!(session.tracker_list(info_hash).await.unwrap().len(), 2);
9264
9265 session
9267 .replace_trackers(
9268 info_hash,
9269 vec!["http://replacement.example.com/announce".into()],
9270 )
9271 .await
9272 .unwrap();
9273
9274 let after = session.tracker_list(info_hash).await.unwrap();
9275 assert_eq!(after.len(), 1);
9276 assert_eq!(after[0].url, "http://replacement.example.com/announce");
9277
9278 session.shutdown().await.unwrap();
9279 }
9280
9281 #[tokio::test]
9284 async fn add_tracker_deduplicates() {
9285 let session = SessionHandle::start(test_settings()).await.unwrap();
9286 let data = vec![0xAB; 16384];
9287 let meta = make_test_torrent(&data, 16384);
9288 let storage = make_storage(&data, 16384);
9289 let info_hash = session
9290 .add_torrent_with_meta(meta.into(), Some(storage))
9291 .await
9292 .unwrap();
9293
9294 session
9296 .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9297 .await
9298 .unwrap();
9299 session
9300 .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9301 .await
9302 .unwrap();
9303
9304 let trackers = session.tracker_list(info_hash).await.unwrap();
9306 assert_eq!(trackers.len(), 1);
9307
9308 session.shutdown().await.unwrap();
9309 }
9310
9311 #[tokio::test]
9314 async fn info_hashes_matches_added_torrent() {
9315 let session = SessionHandle::start(test_settings()).await.unwrap();
9316 let data = vec![0xAB; 16384];
9317 let meta = make_test_torrent(&data, 16384);
9318 let expected_v1 = meta.info_hash;
9319 let storage = make_storage(&data, 16384);
9320
9321 let info_hash = session
9322 .add_torrent_with_meta(meta.into(), Some(storage))
9323 .await
9324 .unwrap();
9325 let hashes = session.info_hashes(info_hash).await.unwrap();
9326 assert_eq!(hashes.v1, Some(expected_v1));
9327 assert!(hashes.v2.is_none());
9329
9330 session.shutdown().await.unwrap();
9331 }
9332
9333 #[tokio::test]
9336 async fn torrent_file_returns_meta() {
9337 let session = SessionHandle::start(test_settings()).await.unwrap();
9338 let data = vec![0xAB; 32768];
9339 let meta = make_test_torrent(&data, 16384);
9340 let storage = make_storage(&data, 16384);
9341
9342 let info_hash = session
9343 .add_torrent_with_meta(meta.into(), Some(storage))
9344 .await
9345 .unwrap();
9346 let torrent = session.torrent_file(info_hash).await.unwrap();
9347 assert!(torrent.is_some());
9348 let torrent = torrent.unwrap();
9349 assert_eq!(torrent.info_hash, info_hash);
9350 assert_eq!(torrent.info.name, "test");
9351 assert_eq!(torrent.info.total_length(), 32768);
9352
9353 session.shutdown().await.unwrap();
9354 }
9355
9356 #[tokio::test]
9359 async fn torrent_file_none_before_metadata() {
9360 let session = SessionHandle::start(test_settings()).await.unwrap();
9361 let magnet = irontide_core::Magnet::parse(
9362 "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&dn=test",
9363 )
9364 .unwrap();
9365
9366 let info_hash = session.add_magnet(magnet).await.unwrap();
9367 let torrent = session.torrent_file(info_hash).await.unwrap();
9368 assert!(torrent.is_none());
9370
9371 session.shutdown().await.unwrap();
9372 }
9373
9374 #[tokio::test]
9377 async fn force_dht_announce_no_error() {
9378 let session = SessionHandle::start(test_settings()).await.unwrap();
9379 let data = vec![0xAB; 16384];
9380 let meta = make_test_torrent(&data, 16384);
9381 let storage = make_storage(&data, 16384);
9382 let info_hash = session
9383 .add_torrent_with_meta(meta.into(), Some(storage))
9384 .await
9385 .unwrap();
9386
9387 let result = session.force_dht_announce(info_hash).await;
9389 assert!(
9390 result.is_ok(),
9391 "force_dht_announce should succeed: {result:?}"
9392 );
9393
9394 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9396 assert!(session.force_dht_announce(fake).await.is_err());
9397
9398 session.shutdown().await.unwrap();
9399 }
9400
9401 #[tokio::test]
9404 async fn force_lsd_announce_no_error() {
9405 let session = SessionHandle::start(test_settings()).await.unwrap();
9406 let data = vec![0xAB; 16384];
9407 let meta = make_test_torrent(&data, 16384);
9408 let storage = make_storage(&data, 16384);
9409 let info_hash = session
9410 .add_torrent_with_meta(meta.into(), Some(storage))
9411 .await
9412 .unwrap();
9413
9414 let result = session.force_lsd_announce(info_hash).await;
9416 assert!(
9417 result.is_ok(),
9418 "force_lsd_announce should succeed: {result:?}"
9419 );
9420
9421 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9423 assert!(session.force_lsd_announce(fake).await.is_err());
9424
9425 session.shutdown().await.unwrap();
9426 }
9427
9428 #[tokio::test]
9431 async fn read_piece_after_download() {
9432 let data = vec![0xCD; 32768]; let meta = make_test_torrent(&data, 16384);
9434 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
9435 let storage = Arc::new(MemoryStorage::new(lengths));
9436 storage.write_chunk(0, 0, &data[..16384]).unwrap();
9438 storage.write_chunk(1, 0, &data[16384..]).unwrap();
9439
9440 let session = SessionHandle::start(test_settings()).await.unwrap();
9441 let info_hash = session
9442 .add_torrent_with_meta(meta.into(), Some(storage))
9443 .await
9444 .unwrap();
9445
9446 let piece_data = session.read_piece(info_hash, 0).await.unwrap();
9448 assert_eq!(piece_data.len(), 16384);
9449 assert!(piece_data.iter().all(|&b| b == 0xCD));
9450
9451 let piece_data = session.read_piece(info_hash, 1).await.unwrap();
9453 assert_eq!(piece_data.len(), 16384);
9454 assert!(piece_data.iter().all(|&b| b == 0xCD));
9455
9456 let result = session.read_piece(info_hash, 999).await;
9458 assert!(result.is_err(), "read_piece out of range should fail");
9459
9460 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9462 assert!(session.read_piece(fake, 0).await.is_err());
9463
9464 session.shutdown().await.unwrap();
9465 }
9466
9467 #[tokio::test]
9470 async fn flush_cache_completes() {
9471 let session = SessionHandle::start(test_settings()).await.unwrap();
9472 let data = vec![0xAB; 16384];
9473 let meta = make_test_torrent(&data, 16384);
9474 let storage = make_storage(&data, 16384);
9475 let info_hash = session
9476 .add_torrent_with_meta(meta.into(), Some(storage))
9477 .await
9478 .unwrap();
9479
9480 let result = session.flush_cache(info_hash).await;
9482 assert!(result.is_ok(), "flush_cache should succeed: {result:?}");
9483
9484 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9486 assert!(session.flush_cache(fake).await.is_err());
9487
9488 session.shutdown().await.unwrap();
9489 }
9490
9491 fn test_settings_with_dht() -> Settings {
9494 let mut s = test_settings();
9495 s.enable_dht = true;
9496 s
9497 }
9498
9499 fn test_settings_with_lsd() -> Settings {
9500 let mut s = test_settings();
9501 s.enable_lsd = true;
9502 s
9503 }
9504
9505 #[tokio::test]
9506 async fn test_dht_disabled_returns_error() {
9507 let session = SessionHandle::start(test_settings()).await.unwrap();
9508
9509 let err = session
9511 .dht_put_immutable(b"test".to_vec())
9512 .await
9513 .unwrap_err();
9514 assert!(
9515 format!("{err:?}").contains("DhtDisabled"),
9516 "expected DhtDisabled, got {err:?}"
9517 );
9518
9519 let target = Id20::from([0u8; 20]);
9520 let err = session.dht_get_immutable(target).await.unwrap_err();
9521 assert!(
9522 format!("{err:?}").contains("DhtDisabled"),
9523 "expected DhtDisabled, got {err:?}"
9524 );
9525
9526 let err = session
9527 .dht_put_mutable([42u8; 32], b"val".to_vec(), 1, Vec::new())
9528 .await
9529 .unwrap_err();
9530 assert!(
9531 format!("{err:?}").contains("DhtDisabled"),
9532 "expected DhtDisabled, got {err:?}"
9533 );
9534
9535 let err = session
9536 .dht_get_mutable([42u8; 32], Vec::new())
9537 .await
9538 .unwrap_err();
9539 assert!(
9540 format!("{err:?}").contains("DhtDisabled"),
9541 "expected DhtDisabled, got {err:?}"
9542 );
9543
9544 session.shutdown().await.unwrap();
9545 }
9546
9547 #[tokio::test]
9548 async fn test_dht_put_get_immutable_round_trip() {
9549 let session = SessionHandle::start(test_settings_with_dht())
9550 .await
9551 .unwrap();
9552
9553 let value = b"hello BEP 44".to_vec();
9555 let target = session.dht_put_immutable(value.clone()).await.unwrap();
9556
9557 let got = session.dht_get_immutable(target).await.unwrap();
9560 assert_eq!(got, Some(value));
9561
9562 session.shutdown().await.unwrap();
9563 }
9564
9565 #[tokio::test]
9566 async fn test_dht_put_immutable_fires_alert() {
9567 use crate::alert::{AlertCategory, AlertKind};
9568
9569 let session = SessionHandle::start(test_settings_with_dht())
9570 .await
9571 .unwrap();
9572 let mut alerts = session.subscribe_filtered(AlertCategory::DHT);
9573
9574 let value = b"alert test".to_vec();
9575 let target = session.dht_put_immutable(value).await.unwrap();
9576
9577 let alert = tokio::time::timeout(Duration::from_secs(5), alerts.recv())
9579 .await
9580 .expect("timeout waiting for alert")
9581 .expect("alert channel closed");
9582
9583 match alert.kind {
9584 AlertKind::DhtPutComplete { target: t } => {
9585 assert_eq!(t, target);
9586 }
9587 other => panic!("expected DhtPutComplete, got {other:?}"),
9588 }
9589
9590 session.shutdown().await.unwrap();
9591 }
9592
9593 fn make_private_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
9597 use serde::Serialize;
9598
9599 #[derive(Serialize)]
9600 struct Info<'a> {
9601 length: u64,
9602 name: &'a str,
9603 #[serde(rename = "piece length")]
9604 piece_length: u64,
9605 #[serde(with = "serde_bytes")]
9606 pieces: &'a [u8],
9607 private: i64,
9608 }
9609
9610 #[derive(Serialize)]
9611 struct Torrent<'a> {
9612 info: Info<'a>,
9613 }
9614
9615 let mut pieces = Vec::new();
9616 let mut offset = 0;
9617 while offset < data.len() {
9618 let end = (offset + piece_length as usize).min(data.len());
9619 let hash = irontide_core::sha1(&data[offset..end]);
9620 pieces.extend_from_slice(hash.as_bytes());
9621 offset = end;
9622 }
9623
9624 let t = Torrent {
9625 info: Info {
9626 length: data.len() as u64,
9627 name: "private-test",
9628 piece_length,
9629 pieces: &pieces,
9630 private: 1,
9631 },
9632 };
9633
9634 let bytes = irontide_bencode::to_bytes(&t).unwrap();
9635 torrent_from_bytes(&bytes).unwrap()
9636 }
9637
9638 #[test]
9639 fn is_private_true_via_parsed_meta() {
9640 let data = vec![0xAB; 16384];
9642 let meta = make_private_torrent(&data, 16384);
9643 assert_eq!(
9644 meta.info.private,
9645 Some(1),
9646 "private field should be Some(1)"
9647 );
9648 }
9649
9650 #[test]
9651 fn is_private_false_for_public_torrent() {
9652 let data = vec![0xAB; 16384];
9654 let meta = make_test_torrent(&data, 16384);
9655 assert_eq!(
9656 meta.info.private, None,
9657 "public torrent should have no private flag"
9658 );
9659 }
9660
9661 #[test]
9662 fn private_torrent_config_disables_lsd() {
9663 let config = TorrentConfig::default();
9665 assert!(
9666 config.enable_lsd,
9667 "default TorrentConfig should have LSD enabled"
9668 );
9669 }
9670
9671 #[tokio::test]
9672 async fn force_lsd_announce_private_torrent_returns_error() {
9673 let session = SessionHandle::start(test_settings()).await.unwrap();
9674 let data = vec![0xAB; 16384];
9675 let meta = make_private_torrent(&data, 16384);
9676 let storage = make_storage(&data, 16384);
9677 let info_hash = session
9678 .add_torrent_with_meta(meta.into(), Some(storage))
9679 .await
9680 .unwrap();
9681
9682 let result = session.force_lsd_announce(info_hash).await;
9684 assert!(
9685 result.is_err(),
9686 "force_lsd_announce on private torrent should return error, got: {result:?}"
9687 );
9688 let err_str = format!("{:?}", result.unwrap_err());
9689 assert!(
9690 err_str.contains("InvalidSettings") || err_str.contains("LSD disabled"),
9691 "expected InvalidSettings error, got: {err_str}"
9692 );
9693
9694 session.shutdown().await.unwrap();
9695 }
9696
9697 #[tokio::test]
9698 async fn force_lsd_announce_public_torrent_does_not_trigger_bep27_error() {
9699 let session = SessionHandle::start(test_settings_with_lsd())
9709 .await
9710 .unwrap();
9711 let data = vec![0xAB; 16384];
9712 let meta = make_test_torrent(&data, 16384);
9713 let storage = make_storage(&data, 16384);
9714 let info_hash = session
9715 .add_torrent_with_meta(meta.into(), Some(storage))
9716 .await
9717 .unwrap();
9718
9719 let result = session.force_lsd_announce(info_hash).await;
9720 if let Err(e) = &result {
9721 assert!(
9722 !format!("{e:?}").contains("LSD disabled for private torrent"),
9723 "public torrent must NOT trigger BEP 27 error; got {e:?}"
9724 );
9725 }
9726
9727 session.shutdown().await.unwrap();
9728 }
9729
9730 #[tokio::test]
9731 async fn force_dht_announce_private_torrent_returns_error() {
9732 let session = SessionHandle::start(test_settings_with_dht())
9733 .await
9734 .unwrap();
9735 let data = vec![0xAB; 16384];
9736 let meta = make_private_torrent(&data, 16384);
9737 let storage = make_storage(&data, 16384);
9738 let info_hash = session
9739 .add_torrent_with_meta(meta.into(), Some(storage))
9740 .await
9741 .unwrap();
9742
9743 let result = session.force_dht_announce(info_hash).await;
9745 assert!(
9746 result.is_err(),
9747 "force_dht_announce on private torrent should return error, got: {result:?}"
9748 );
9749 let err_str = format!("{:?}", result.unwrap_err());
9750 assert!(
9751 err_str.contains("InvalidSettings")
9752 || err_str.contains("DHT disabled for private torrent"),
9753 "expected InvalidSettings / DHT-disabled error, got: {err_str}"
9754 );
9755
9756 session.shutdown().await.unwrap();
9757 }
9758
9759 #[tokio::test]
9760 async fn force_dht_announce_public_torrent_does_not_trigger_bep27_error() {
9761 let session = SessionHandle::start(test_settings_with_dht())
9762 .await
9763 .unwrap();
9764 let data = vec![0xAB; 16384];
9765 let meta = make_test_torrent(&data, 16384);
9766 let storage = make_storage(&data, 16384);
9767 let info_hash = session
9768 .add_torrent_with_meta(meta.into(), Some(storage))
9769 .await
9770 .unwrap();
9771
9772 let result = session.force_dht_announce(info_hash).await;
9773 if let Err(e) = &result {
9779 assert!(
9780 !format!("{e:?}").contains("DHT disabled for private torrent"),
9781 "public torrent must NOT trigger BEP 27 error; got {e:?}"
9782 );
9783 }
9784
9785 session.shutdown().await.unwrap();
9786 }
9787
9788 fn resume_test_settings(dir: &std::path::Path) -> Settings {
9791 Settings {
9792 resume_data_dir: Some(dir.to_path_buf()),
9793 save_resume_interval_secs: 0, ..test_settings()
9795 }
9796 }
9797
9798 #[tokio::test]
9799 async fn save_resume_state_empty_session_returns_zero() {
9800 let tmp = tempfile::TempDir::new().unwrap();
9801 let session = SessionHandle::start(resume_test_settings(tmp.path()))
9802 .await
9803 .unwrap();
9804
9805 let count = session.save_resume_state().await.unwrap();
9806 assert_eq!(count, 0, "empty session should save 0 resume files");
9807
9808 session.shutdown().await.unwrap();
9809 }
9810
9811 #[tokio::test]
9812 async fn save_resume_state_saves_dirty_torrents() {
9813 let tmp = tempfile::TempDir::new().unwrap();
9814 let session = SessionHandle::start(resume_test_settings(tmp.path()))
9815 .await
9816 .unwrap();
9817
9818 let data1 = vec![0xAA; 16384];
9820 let meta1 = make_test_torrent(&data1, 16384);
9821 let hash1 = meta1.info_hash;
9822 let storage1 = make_storage(&data1, 16384);
9823 session
9824 .add_torrent_with_meta(meta1.into(), Some(storage1))
9825 .await
9826 .unwrap();
9827
9828 let data2 = vec![0xBB; 16384];
9829 let meta2 = make_test_torrent(&data2, 16384);
9830 let hash2 = meta2.info_hash;
9831 let storage2 = make_storage(&data2, 16384);
9832 session
9833 .add_torrent_with_meta(meta2.into(), Some(storage2))
9834 .await
9835 .unwrap();
9836
9837 tokio::time::sleep(Duration::from_millis(50)).await;
9840
9841 let count = session.save_resume_state().await.unwrap();
9842 assert!(count <= 2, "should save at most 2 resume files");
9846
9847 let torrents_dir = tmp.path().join("torrents");
9849 if count > 0 {
9850 assert!(torrents_dir.exists(), "torrents/ directory should exist");
9851 }
9852
9853 let path1 = crate::resume_file::resume_file_path(tmp.path(), &hash1);
9855 let path2 = crate::resume_file::resume_file_path(tmp.path(), &hash2);
9856 let files_exist = usize::from(path1.exists()) + usize::from(path2.exists());
9857 assert_eq!(
9858 files_exist, count,
9859 "number of files on disk should match returned count"
9860 );
9861
9862 session.shutdown().await.unwrap();
9863 }
9864
9865 #[tokio::test]
9866 async fn save_resume_state_round_trip() {
9867 let tmp = tempfile::TempDir::new().unwrap();
9868 let session = SessionHandle::start(resume_test_settings(tmp.path()))
9869 .await
9870 .unwrap();
9871
9872 let data = vec![0xCD; 32768];
9873 let meta = make_test_torrent(&data, 16384);
9874 let info_hash = meta.info_hash;
9875 let storage = make_storage(&data, 16384);
9876 session
9877 .add_torrent_with_meta(meta.into(), Some(storage))
9878 .await
9879 .unwrap();
9880
9881 tokio::time::sleep(Duration::from_millis(50)).await;
9883
9884 let count = session.save_resume_state().await.unwrap();
9885
9886 if count > 0 {
9888 let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
9889 assert!(path.exists(), "resume file should exist after save");
9890
9891 let bytes = std::fs::read(&path).unwrap();
9892 let rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
9893 assert_eq!(
9894 rd.info_hash,
9895 info_hash.as_bytes().to_vec(),
9896 "deserialized info_hash should match"
9897 );
9898 assert_eq!(rd.name, "test", "deserialized name should match");
9899 }
9900
9901 session.shutdown().await.unwrap();
9902 }
9903
9904 #[tokio::test]
9905 async fn save_resume_state_clears_dirty_flag() {
9906 let tmp = tempfile::TempDir::new().unwrap();
9907 let session = SessionHandle::start(resume_test_settings(tmp.path()))
9908 .await
9909 .unwrap();
9910
9911 let data = vec![0xEE; 16384];
9912 let meta = make_test_torrent(&data, 16384);
9913 let storage = make_storage(&data, 16384);
9914 session
9915 .add_torrent_with_meta(meta.into(), Some(storage))
9916 .await
9917 .unwrap();
9918
9919 tokio::time::sleep(Duration::from_millis(50)).await;
9920
9921 let first_count = session.save_resume_state().await.unwrap();
9922
9923 let second_count = session.save_resume_state().await.unwrap();
9925 assert_eq!(
9926 second_count, 0,
9927 "second save should return 0 after dirty flag cleared (first saved {first_count})"
9928 );
9929
9930 session.shutdown().await.unwrap();
9931 }
9932
9933 #[tokio::test]
9934 async fn save_resume_state_second_save_skips_clean() {
9935 let tmp = tempfile::TempDir::new().unwrap();
9936 let session = SessionHandle::start(resume_test_settings(tmp.path()))
9937 .await
9938 .unwrap();
9939
9940 let data1 = vec![0xAA; 16384];
9941 let meta1 = make_test_torrent(&data1, 16384);
9942 let storage1 = make_storage(&data1, 16384);
9943 session
9944 .add_torrent_with_meta(meta1.into(), Some(storage1))
9945 .await
9946 .unwrap();
9947
9948 let data2 = vec![0xBB; 16384];
9949 let meta2 = make_test_torrent(&data2, 16384);
9950 let storage2 = make_storage(&data2, 16384);
9951 session
9952 .add_torrent_with_meta(meta2.into(), Some(storage2))
9953 .await
9954 .unwrap();
9955
9956 tokio::time::sleep(Duration::from_millis(50)).await;
9957
9958 let first = session.save_resume_state().await.unwrap();
9960
9961 let second = session.save_resume_state().await.unwrap();
9963 assert_eq!(
9964 second, 0,
9965 "second save should skip all clean torrents (first saved {first})"
9966 );
9967
9968 session.shutdown().await.unwrap();
9969 }
9970
9971 #[tokio::test]
9976 async fn load_resume_empty_dir_returns_zeros() {
9977 let tmp = tempfile::TempDir::new().unwrap();
9978 let mut settings = test_settings();
9979 settings.resume_data_dir = Some(tmp.path().to_path_buf());
9980
9981 let session = SessionHandle::start(settings).await.unwrap();
9982 let result = session.load_resume_state().await.unwrap();
9983 assert_eq!(result.restored, 0);
9984 assert_eq!(result.skipped, 0);
9985 assert_eq!(result.failed, 0);
9986
9987 session.shutdown().await.unwrap();
9988 }
9989
9990 #[tokio::test]
9993 async fn load_resume_corrupt_file_counted_as_failed() {
9994 let tmp = tempfile::TempDir::new().unwrap();
9995 let torrents_dir = tmp.path().join("torrents");
9996 std::fs::create_dir_all(&torrents_dir).unwrap();
9997
9998 let mut settings = test_settings();
9999 settings.resume_data_dir = Some(tmp.path().to_path_buf());
10000
10001 let session = SessionHandle::start(settings).await.unwrap();
10003
10004 tokio::time::sleep(Duration::from_millis(50)).await;
10007
10008 std::fs::write(
10011 torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume"),
10012 b"this is not valid bencode",
10013 )
10014 .unwrap();
10015
10016 let result = session.load_resume_state().await.unwrap();
10017 assert_eq!(result.restored, 0);
10018 assert_eq!(result.skipped, 0);
10019 assert_eq!(result.failed, 1);
10020
10021 session.shutdown().await.unwrap();
10022 }
10023
10024 #[tokio::test]
10027 async fn load_resume_duplicate_skipped() {
10028 let tmp = tempfile::TempDir::new().unwrap();
10029 let mut settings = test_settings();
10030 settings.resume_data_dir = Some(tmp.path().to_path_buf());
10031
10032 let session = SessionHandle::start(settings).await.unwrap();
10033
10034 let data = vec![0xAB; 16384];
10036 let meta = make_test_torrent(&data, 16384);
10037 let info_hash = meta.info_hash;
10038 let storage = make_storage(&data, 16384);
10039 session
10040 .add_torrent_with_meta(meta.into(), Some(storage))
10041 .await
10042 .unwrap();
10043
10044 tokio::time::sleep(Duration::from_millis(50)).await;
10046
10047 let _ = session.save_resume_state().await;
10049
10050 let result = session.load_resume_state().await.unwrap();
10052 assert!(
10053 session.list_torrents().await.unwrap().contains(&info_hash),
10054 "original torrent should still exist"
10055 );
10056 assert_eq!(result.skipped, 1, "duplicate should be skipped");
10057 assert_eq!(result.failed, 0);
10058
10059 session.shutdown().await.unwrap();
10060 }
10061
10062 #[test]
10065 fn reconstruct_torrent_meta_returns_some_with_correct_fields() {
10066 use crate::resume_file::reconstruct_torrent_meta;
10067 use irontide_core::FastResumeData;
10068
10069 let data = vec![0xAB; 16384];
10070 let meta = make_test_torrent(&data, 16384);
10071 let info_hash = meta.info_hash;
10072
10073 let info_bytes = irontide_bencode::to_bytes(&meta.info).unwrap();
10075 let mut rd = FastResumeData::new(
10076 info_hash.as_bytes().to_vec(),
10077 "test-torrent".into(),
10078 "/downloads".into(),
10079 );
10080 rd.info = Some(info_bytes);
10081 rd.trackers = vec![
10082 vec!["http://tracker1.example.com/announce".into()],
10083 vec!["http://tracker2.example.com/announce".into()],
10084 ];
10085 rd.url_seeds = vec!["http://seed.example.com/".into()];
10086 rd.http_seeds = vec!["http://httpseed.example.com/".into()];
10087
10088 let reconstructed = reconstruct_torrent_meta(&rd).expect("should reconstruct");
10089
10090 assert_eq!(reconstructed.info_hash, info_hash);
10091 assert_eq!(
10092 reconstructed.announce.as_deref(),
10093 Some("http://tracker1.example.com/announce")
10094 );
10095 assert!(reconstructed.announce_list.is_some());
10096 assert_eq!(reconstructed.announce_list.as_ref().unwrap().len(), 2);
10097 assert_eq!(
10098 reconstructed.url_list,
10099 vec!["http://seed.example.com/".to_string()]
10100 );
10101 assert_eq!(
10102 reconstructed.httpseeds,
10103 vec!["http://httpseed.example.com/".to_string()]
10104 );
10105 assert!(reconstructed.info_bytes.is_some());
10106 assert!(reconstructed.comment.is_none());
10107 assert!(reconstructed.created_by.is_none());
10108 assert!(reconstructed.creation_date.is_none());
10109 }
10110
10111 #[test]
10114 fn reconstruct_torrent_meta_returns_none_without_info() {
10115 use crate::resume_file::reconstruct_torrent_meta;
10116 use irontide_core::FastResumeData;
10117
10118 let rd = FastResumeData::new(vec![0xAB; 20], "magnet".into(), "/tmp".into());
10119 assert!(rd.info.is_none());
10121 assert!(reconstruct_torrent_meta(&rd).is_none());
10122 }
10123
10124 #[test]
10127 fn reconstruct_magnet_returns_some_with_correct_fields() {
10128 use crate::resume_file::reconstruct_magnet;
10129 use irontide_core::FastResumeData;
10130
10131 let mut rd = FastResumeData::new(vec![0xCC; 20], "my-torrent".into(), "/downloads".into());
10132 rd.trackers = vec![
10133 vec!["http://tracker1.com/announce".into()],
10134 vec![
10135 "http://tracker2.com/announce".into(),
10136 "http://tracker3.com/announce".into(),
10137 ],
10138 ];
10139
10140 let magnet = reconstruct_magnet(&rd).expect("should reconstruct magnet");
10141
10142 assert!(magnet.info_hashes.v1.is_some());
10143 assert!(magnet.info_hashes.v2.is_none());
10144 assert_eq!(magnet.display_name.as_deref(), Some("my-torrent"));
10145 assert_eq!(magnet.trackers.len(), 3);
10147 assert!(magnet.peers.is_empty());
10148 assert!(magnet.selected_files.is_none());
10149 }
10150
10151 #[test]
10154 fn reconstruct_magnet_preserves_info_hash2() {
10155 use crate::resume_file::reconstruct_magnet;
10156 use irontide_core::FastResumeData;
10157
10158 let mut rd = FastResumeData::new(vec![0xDD; 20], "v2-magnet".into(), "/tmp".into());
10159 rd.info_hash2 = Some(vec![0xEE; 32]);
10160
10161 let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
10162 assert!(magnet.info_hashes.v1.is_some());
10163 assert!(magnet.info_hashes.v2.is_some());
10164
10165 let v2 = magnet.info_hashes.v2.unwrap();
10166 assert_eq!(v2.as_bytes(), &[0xEE; 32]);
10167 }
10168
10169 #[test]
10172 fn reconstruct_magnet_empty_name_is_none() {
10173 use crate::resume_file::reconstruct_magnet;
10174 use irontide_core::FastResumeData;
10175
10176 let rd = FastResumeData::new(vec![0xFF; 20], String::new(), "/tmp".into());
10177 let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
10178 assert!(
10179 magnet.display_name.is_none(),
10180 "empty name should map to None"
10181 );
10182 }
10183
10184 #[tokio::test]
10189 async fn shutdown_saves_resume_files() {
10190 let tmp = tempfile::TempDir::new().unwrap();
10191 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10192 .await
10193 .unwrap();
10194
10195 let data = vec![0xAB; 16384];
10196 let meta = make_test_torrent(&data, 16384);
10197 let info_hash = meta.info_hash;
10198 let storage = make_storage(&data, 16384);
10199 session
10200 .add_torrent_with_meta(meta.into(), Some(storage))
10201 .await
10202 .unwrap();
10203
10204 session.pause_torrent(info_hash).await.unwrap();
10206 tokio::time::sleep(Duration::from_millis(50)).await;
10207 session.resume_torrent(info_hash).await.unwrap();
10208 tokio::time::sleep(Duration::from_millis(50)).await;
10209
10210 let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10211
10212 session.shutdown().await.unwrap();
10216 tokio::time::sleep(Duration::from_millis(200)).await;
10217
10218 assert!(path.exists(), "resume file should exist after shutdown");
10219 }
10220
10221 #[tokio::test]
10224 async fn auto_restore_on_startup() {
10225 let tmp = tempfile::TempDir::new().unwrap();
10226
10227 let info_hash;
10228 {
10229 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10231 .await
10232 .unwrap();
10233
10234 let data = vec![0xAB; 16384];
10235 let meta = make_test_torrent(&data, 16384);
10236 info_hash = meta.info_hash;
10237 let storage = make_storage(&data, 16384);
10238 session
10239 .add_torrent_with_meta(meta.into(), Some(storage))
10240 .await
10241 .unwrap();
10242
10243 tokio::time::sleep(Duration::from_millis(50)).await;
10244 let _ = session.save_resume_state().await;
10245 session.shutdown().await.unwrap();
10246 }
10247
10248 let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10250 assert!(path.exists(), "resume file should exist before restart");
10251
10252 {
10253 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10255 .await
10256 .unwrap();
10257
10258 tokio::time::sleep(Duration::from_millis(100)).await;
10260
10261 let list = session.list_torrents().await.unwrap();
10262 assert!(
10263 list.contains(&info_hash),
10264 "torrent should be auto-restored on startup"
10265 );
10266
10267 session.shutdown().await.unwrap();
10268 }
10269 }
10270
10271 #[tokio::test]
10274 async fn shutdown_with_readonly_resume_dir_completes() {
10275 let tmp = tempfile::TempDir::new().unwrap();
10276 let readonly_dir = PathBuf::from("/proc/irontide-test-nonexistent");
10279 let mut settings = test_settings();
10280 settings.resume_data_dir = Some(readonly_dir);
10281
10282 let session = SessionHandle::start(settings).await.unwrap();
10283
10284 let data = vec![0xAB; 16384];
10285 let meta = make_test_torrent(&data, 16384);
10286 let storage = make_storage(&data, 16384);
10287 session
10288 .add_torrent_with_meta(meta.into(), Some(storage))
10289 .await
10290 .unwrap();
10291
10292 tokio::time::sleep(Duration::from_millis(50)).await;
10293
10294 session.shutdown().await.unwrap();
10297
10298 drop(tmp);
10300 }
10301
10302 #[tokio::test]
10305 async fn orphan_resume_file_deleted_on_startup() {
10306 let tmp = tempfile::TempDir::new().unwrap();
10307 let torrents_dir = tmp.path().join("torrents");
10308 std::fs::create_dir_all(&torrents_dir).unwrap();
10309
10310 let orphan_path = torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume");
10318 std::fs::write(&orphan_path, b"not valid bencode").unwrap();
10319 assert!(orphan_path.exists(), "orphan file should exist before test");
10320
10321 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10322 .await
10323 .unwrap();
10324
10325 tokio::time::sleep(Duration::from_millis(100)).await;
10327
10328 assert!(
10329 !orphan_path.exists(),
10330 "orphan resume file should be deleted on startup"
10331 );
10332
10333 session.shutdown().await.unwrap();
10334 }
10335
10336 #[tokio::test]
10345 async fn multi_torrent_save_load_round_trip() {
10346 let tmp = tempfile::TempDir::new().unwrap();
10347
10348 let datasets: [u8; 3] = [0xAA, 0xBB, 0xCC];
10350 let mut hashes = Vec::with_capacity(3);
10351
10352 {
10353 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10355 .await
10356 .unwrap();
10357
10358 for &byte in &datasets {
10359 let data = vec![byte; 16384];
10360 let meta = make_test_torrent(&data, 16384);
10361 let info_hash = meta.info_hash;
10362 let storage = make_storage(&data, 16384);
10363 session
10364 .add_torrent_with_meta(meta.into(), Some(storage))
10365 .await
10366 .unwrap();
10367 hashes.push(info_hash);
10368 }
10369
10370 tokio::time::sleep(Duration::from_millis(100)).await;
10372
10373 let saved = session.save_resume_state().await.unwrap();
10374 assert_eq!(saved, 3, "all 3 torrents should be saved");
10375
10376 let files = crate::resume_file::scan_resume_dir(tmp.path());
10378 assert_eq!(files.len(), 3, "3 .resume files should be on disk");
10379
10380 for hash in &hashes {
10381 let path = crate::resume_file::resume_file_path(tmp.path(), hash);
10382 assert!(
10383 path.exists(),
10384 "resume file for {} should exist",
10385 hex::encode(hash.as_bytes())
10386 );
10387 }
10388
10389 session.shutdown().await.unwrap();
10390 }
10391
10392 {
10393 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10401 .await
10402 .unwrap();
10403
10404 tokio::time::sleep(Duration::from_millis(200)).await;
10406
10407 let list = session.list_torrents().await.unwrap();
10408 assert_eq!(list.len(), 3, "all 3 torrents should be auto-restored");
10409
10410 for hash in &hashes {
10411 assert!(
10412 list.contains(hash),
10413 "torrent {} should be present after restore",
10414 hex::encode(hash.as_bytes())
10415 );
10416 }
10417
10418 session.shutdown().await.unwrap();
10419 }
10420 }
10421
10422 #[tokio::test]
10428 async fn corrupt_one_of_three_resume_files() {
10429 let tmp = tempfile::TempDir::new().unwrap();
10430
10431 let datasets: [u8; 3] = [0xDD, 0xEE, 0xFF];
10432 let mut hashes = Vec::with_capacity(3);
10433
10434 {
10435 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10437 .await
10438 .unwrap();
10439
10440 for &byte in &datasets {
10441 let data = vec![byte; 16384];
10442 let meta = make_test_torrent(&data, 16384);
10443 let info_hash = meta.info_hash;
10444 let storage = make_storage(&data, 16384);
10445 session
10446 .add_torrent_with_meta(meta.into(), Some(storage))
10447 .await
10448 .unwrap();
10449 hashes.push(info_hash);
10450 }
10451
10452 tokio::time::sleep(Duration::from_millis(100)).await;
10453
10454 let saved = session.save_resume_state().await.unwrap();
10455 assert_eq!(saved, 3, "all 3 torrents should be saved");
10456
10457 session.shutdown().await.unwrap();
10458 }
10459
10460 let corrupt_path = crate::resume_file::resume_file_path(tmp.path(), &hashes[1]);
10462 assert!(
10463 corrupt_path.exists(),
10464 "file to corrupt must exist before overwrite"
10465 );
10466 std::fs::write(&corrupt_path, b"CORRUPTED GARBAGE DATA 0xDEAD").unwrap();
10467
10468 {
10469 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10472 .await
10473 .unwrap();
10474
10475 tokio::time::sleep(Duration::from_millis(200)).await;
10477
10478 let list = session.list_torrents().await.unwrap();
10479 assert_eq!(
10480 list.len(),
10481 2,
10482 "2 torrents should be restored (1 corrupt skipped)"
10483 );
10484
10485 assert!(
10487 list.contains(&hashes[0]),
10488 "first torrent should be restored"
10489 );
10490 assert!(
10491 list.contains(&hashes[2]),
10492 "third torrent should be restored"
10493 );
10494
10495 assert!(
10497 !list.contains(&hashes[1]),
10498 "corrupted torrent should not be restored"
10499 );
10500
10501 assert!(
10503 !corrupt_path.exists(),
10504 "corrupt resume file should be deleted by orphan cleanup"
10505 );
10506
10507 session.shutdown().await.unwrap();
10508 }
10509 }
10510
10511 #[tokio::test]
10518 async fn remove_torrent_deletes_resume_file() {
10519 let tmp = tempfile::TempDir::new().unwrap();
10520
10521 let data = vec![0x42; 16384];
10522 let meta = make_test_torrent(&data, 16384);
10523 let info_hash = meta.info_hash;
10524 let storage = make_storage(&data, 16384);
10525
10526 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10527 .await
10528 .unwrap();
10529
10530 session
10531 .add_torrent_with_meta(meta.into(), Some(storage))
10532 .await
10533 .unwrap();
10534
10535 tokio::time::sleep(Duration::from_millis(100)).await;
10537
10538 let saved = session.save_resume_state().await.unwrap();
10539 assert!(saved > 0, "torrent should be saved to a resume file");
10540
10541 let resume_path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10542 assert!(resume_path.exists(), "resume file should exist after save");
10543
10544 session.remove_torrent(info_hash).await.unwrap();
10546 tokio::time::sleep(Duration::from_millis(50)).await;
10547
10548 let list = session.list_torrents().await.unwrap();
10549 assert!(
10550 !list.contains(&info_hash),
10551 "torrent should be gone from session after removal"
10552 );
10553
10554 assert!(
10555 !resume_path.exists(),
10556 "resume file should be deleted when torrent is removed"
10557 );
10558
10559 let remaining = crate::resume_file::scan_resume_dir(tmp.path());
10561 assert!(
10562 remaining.is_empty(),
10563 "no resume files should remain after removing the only torrent"
10564 );
10565
10566 session.shutdown().await.unwrap();
10567 }
10568
10569 fn test_settings_isolated_resume(resume_dir: &std::path::Path) -> Settings {
10575 Settings {
10576 resume_data_dir: Some(resume_dir.to_path_buf()),
10577 ..test_settings()
10578 }
10579 }
10580
10581 #[tokio::test]
10582 async fn remove_torrent_with_files_deletes_disk_files() {
10583 let download_dir = tempfile::tempdir().unwrap();
10587 let resume_dir = tempfile::tempdir().unwrap();
10588 let mut settings = test_settings_isolated_resume(resume_dir.path());
10589 settings.download_dir = download_dir.path().to_path_buf();
10590 let session = SessionHandle::start(settings).await.unwrap();
10591
10592 let data = vec![0xAB_u8; 16384];
10593 let meta = make_test_torrent(&data, 16384);
10594 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10595 let storage: Arc<dyn TorrentStorage> = Arc::new(
10596 irontide_storage::FilesystemStorage::new(
10597 download_dir.path(),
10598 vec![PathBuf::from("test")],
10599 vec![data.len() as u64],
10600 lengths,
10601 None,
10602 irontide_storage::PreallocateMode::None,
10603 false,
10604 )
10605 .unwrap(),
10606 );
10607
10608 storage.write_chunk(0, 0, &data).unwrap();
10611
10612 let info_hash = session
10613 .add_torrent_with_meta(meta.into(), Some(storage))
10614 .await
10615 .unwrap();
10616
10617 let file_on_disk = download_dir.path().join("test");
10618 assert!(file_on_disk.exists(), "file should exist before delete");
10619
10620 session.remove_torrent_with_files(info_hash).await.unwrap();
10621
10622 for _ in 0..20 {
10624 if !file_on_disk.exists() {
10625 break;
10626 }
10627 tokio::time::sleep(Duration::from_millis(50)).await;
10628 }
10629 assert!(
10630 !file_on_disk.exists(),
10631 "file should have been removed from disk"
10632 );
10633 assert!(
10634 download_dir.path().exists(),
10635 "download_dir root must never be removed"
10636 );
10637
10638 session.shutdown().await.unwrap();
10639 }
10640
10641 #[tokio::test]
10642 async fn remove_torrent_with_files_tolerates_already_deleted_files() {
10643 let download_dir = tempfile::tempdir().unwrap();
10647 let resume_dir = tempfile::tempdir().unwrap();
10648 let mut settings = test_settings_isolated_resume(resume_dir.path());
10649 settings.download_dir = download_dir.path().to_path_buf();
10650 let session = SessionHandle::start(settings).await.unwrap();
10651
10652 let data = vec![0xCD_u8; 16384];
10653 let meta = make_test_torrent(&data, 16384);
10654 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10655 let storage: Arc<dyn TorrentStorage> = Arc::new(
10656 irontide_storage::FilesystemStorage::new(
10657 download_dir.path(),
10658 vec![PathBuf::from("test")],
10659 vec![data.len() as u64],
10660 lengths,
10661 None,
10662 irontide_storage::PreallocateMode::None,
10663 false,
10664 )
10665 .unwrap(),
10666 );
10667 let info_hash = session
10668 .add_torrent_with_meta(meta.into(), Some(storage))
10669 .await
10670 .unwrap();
10671
10672 std::fs::remove_file(download_dir.path().join("test")).unwrap();
10674
10675 let result = session.remove_torrent_with_files(info_hash).await;
10677 assert!(
10678 result.is_ok(),
10679 "remove_torrent_with_files must return Ok on missing files"
10680 );
10681
10682 session.shutdown().await.unwrap();
10683 }
10684
10685 #[tokio::test]
10686 async fn remove_torrent_with_files_grace_guards_fast_re_add() {
10687 use serde::Serialize;
10694
10695 #[derive(Serialize)]
10696 struct Info<'a> {
10697 length: u64,
10698 name: &'a str,
10699 #[serde(rename = "piece length")]
10700 piece_length: u64,
10701 #[serde(with = "serde_bytes")]
10702 pieces: &'a [u8],
10703 }
10704 #[derive(Serialize)]
10705 struct Torrent<'a> {
10706 info: Info<'a>,
10707 }
10708
10709 let download_dir = tempfile::tempdir().unwrap();
10710 let resume_dir = tempfile::tempdir().unwrap();
10711 let mut settings = test_settings_isolated_resume(resume_dir.path());
10712 settings.download_dir = download_dir.path().to_path_buf();
10713 let session = SessionHandle::start(settings).await.unwrap();
10714
10715 let data = vec![0xEE_u8; 16384];
10718 let meta = make_test_torrent(&data, 16384);
10719 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10720 let storage: Arc<dyn TorrentStorage> = Arc::new(
10721 irontide_storage::FilesystemStorage::new(
10722 download_dir.path(),
10723 vec![PathBuf::from("test")],
10724 vec![data.len() as u64],
10725 lengths,
10726 None,
10727 irontide_storage::PreallocateMode::None,
10728 false,
10729 )
10730 .unwrap(),
10731 );
10732 let mut pieces = Vec::new();
10735 let hash = irontide_core::sha1(&data);
10736 pieces.extend_from_slice(hash.as_bytes());
10737 let bytes = irontide_bencode::to_bytes(&Torrent {
10738 info: Info {
10739 length: data.len() as u64,
10740 name: "test",
10741 piece_length: 16384,
10742 pieces: &pieces,
10743 },
10744 })
10745 .unwrap();
10746
10747 let info_hash = session
10748 .add_torrent_with_meta(meta.into(), Some(storage))
10749 .await
10750 .unwrap();
10751
10752 session.remove_torrent_with_files(info_hash).await.unwrap();
10755
10756 let params = AddTorrentParams::bytes(bytes);
10762 let result = session.add_torrent(params).await;
10763 match result {
10764 Ok(_) => {
10765 }
10767 Err(crate::Error::TorrentBeingRemoved(h)) => {
10768 assert_eq!(h, info_hash, "grace error must name the same hash");
10769 }
10770 Err(e) => panic!("unexpected error on re-add: {e}"),
10771 }
10772
10773 session.shutdown().await.unwrap();
10774 }
10775
10776 #[cfg(feature = "test-util")]
10787 fn make_debug_inject_info() -> (Vec<u8>, Id20) {
10788 use serde::Serialize;
10789
10790 #[derive(Serialize)]
10791 struct Info<'a> {
10792 length: u64,
10793 name: &'a str,
10794 #[serde(rename = "piece length")]
10795 piece_length: u64,
10796 #[serde(with = "serde_bytes")]
10797 pieces: &'a [u8],
10798 }
10799
10800 let data = vec![0xAB_u8; 1024];
10801 let piece_hash = irontide_core::sha1(&data);
10802 let mut pieces = Vec::new();
10803 pieces.extend_from_slice(piece_hash.as_bytes());
10804
10805 let info = Info {
10806 length: data.len() as u64,
10807 name: "sync-inject-test",
10808 piece_length: 1024,
10809 pieces: &pieces,
10810 };
10811
10812 let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
10813 let info_hash = irontide_core::sha1(&info_bytes);
10814 (info_bytes, info_hash)
10815 }
10816
10817 #[cfg(feature = "test-util")]
10818 #[tokio::test]
10819 async fn debug_inject_metadata_resolves_magnet_meta_synchronously() {
10820 use crate::session::AddTorrentParams;
10821
10822 let (info_bytes, info_hash) = make_debug_inject_info();
10823
10824 let resume_dir = tempfile::tempdir().unwrap();
10828 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10829 .await
10830 .unwrap();
10831
10832 let magnet_uri = format!(
10833 "magnet:?xt=urn:btih:{}&dn=sync-inject-test",
10834 info_hash.to_hex()
10835 );
10836 let added = session
10837 .add_torrent(AddTorrentParams::magnet(magnet_uri))
10838 .await
10839 .unwrap();
10840 assert_eq!(
10841 added, info_hash,
10842 "magnet info hash must equal synth info hash"
10843 );
10844
10845 session
10850 .debug_inject_metadata(info_hash, info_bytes)
10851 .await
10852 .expect("debug_inject_metadata must succeed");
10853
10854 let meta = session
10855 .torrent_file(info_hash)
10856 .await
10857 .expect("torrent_file call")
10858 .expect("metadata must be present immediately after sync inject");
10859 assert_eq!(meta.info_hash, info_hash);
10860
10861 session.shutdown().await.unwrap();
10862 }
10863
10864 #[cfg(feature = "test-util")]
10865 #[tokio::test]
10866 async fn debug_inject_metadata_returns_torrent_not_found_for_unknown_hash() {
10867 let resume_dir = tempfile::tempdir().unwrap();
10868 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10869 .await
10870 .unwrap();
10871
10872 let bogus = Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
10873 let result = session.debug_inject_metadata(bogus, vec![]).await;
10874 assert!(
10875 matches!(result, Err(crate::Error::TorrentNotFound(_))),
10876 "expected TorrentNotFound for unknown hash; got {result:?}"
10877 );
10878
10879 session.shutdown().await.unwrap();
10880 }
10881
10882 #[cfg(feature = "test-util")]
10897 fn build_synth_info_bytes_with_options(
10898 name: &str,
10899 length_bytes: u64,
10900 piece_length: u64,
10901 private: Option<i64>,
10902 ssl_cert: Option<Vec<u8>>,
10903 ) -> Vec<u8> {
10904 use serde::Serialize;
10905
10906 #[derive(Serialize)]
10907 struct Info {
10908 length: u64,
10909 name: String,
10910 #[serde(rename = "piece length")]
10911 piece_length: u64,
10912 pieces: serde_bytes::ByteBuf,
10913 #[serde(skip_serializing_if = "Option::is_none")]
10914 private: Option<i64>,
10915 #[serde(rename = "ssl-cert", skip_serializing_if = "Option::is_none")]
10916 ssl_cert: Option<serde_bytes::ByteBuf>,
10917 }
10918
10919 let num_pieces = length_bytes.div_ceil(piece_length);
10924 let zero_piece_hash = irontide_core::sha1(&vec![0_u8; piece_length as usize]);
10925 let mut pieces = Vec::with_capacity(20 * num_pieces as usize);
10926 for _ in 0..num_pieces {
10927 pieces.extend_from_slice(zero_piece_hash.as_bytes());
10928 }
10929
10930 let info = Info {
10931 length: length_bytes,
10932 name: name.to_owned(),
10933 piece_length,
10934 pieces: serde_bytes::ByteBuf::from(pieces),
10935 private,
10936 ssl_cert: ssl_cert.map(serde_bytes::ByteBuf::from),
10937 };
10938 irontide_bencode::to_bytes(&info).expect("bencode synth info dict")
10939 }
10940
10941 #[cfg(feature = "test-util")]
10942 #[tokio::test]
10943 async fn ssl_cert_propagates_to_meta_after_inject() {
10944 use crate::session::AddTorrentParams;
10945
10946 let resume_dir = tempfile::tempdir().unwrap();
10947 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10948 .await
10949 .unwrap();
10950
10951 let cert_pem = b"-----BEGIN CERT-----\nfake\n-----END CERT-----\n".to_vec();
10952 let info_bytes = build_synth_info_bytes_with_options(
10953 "ssl-fixture",
10954 16_384,
10955 16_384,
10956 None,
10957 Some(cert_pem.clone()),
10958 );
10959 let info_hash = irontide_core::sha1(&info_bytes);
10960
10961 let magnet = format!("magnet:?xt=urn:btih:{}&dn=ssl-fixture", info_hash.to_hex());
10962 let added = session
10963 .add_torrent(AddTorrentParams::magnet(magnet))
10964 .await
10965 .unwrap();
10966 assert_eq!(
10967 added, info_hash,
10968 "magnet info hash must equal synth info hash"
10969 );
10970
10971 session
10972 .debug_inject_metadata(info_hash, info_bytes)
10973 .await
10974 .expect("debug_inject_metadata must succeed");
10975
10976 let meta = session
10977 .torrent_file(info_hash)
10978 .await
10979 .expect("torrent_file Ok")
10980 .expect("metadata must be present immediately after sync inject");
10981 assert_eq!(
10982 meta.info.ssl_cert.as_ref(),
10983 Some(&cert_pem),
10984 "ssl_cert from synth info dict must propagate to meta.info.ssl_cert"
10985 );
10986
10987 session.shutdown().await.unwrap();
10988 }
10989
10990 #[cfg(feature = "test-util")]
10991 #[tokio::test]
10992 async fn ssl_cert_absent_remains_none_in_meta_after_inject() {
10993 use crate::session::AddTorrentParams;
10994
10995 let resume_dir = tempfile::tempdir().unwrap();
10996 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10997 .await
10998 .unwrap();
10999
11000 let info_bytes =
11001 build_synth_info_bytes_with_options("no-ssl-fixture", 16_384, 16_384, None, None);
11002 let info_hash = irontide_core::sha1(&info_bytes);
11003
11004 let magnet = format!(
11005 "magnet:?xt=urn:btih:{}&dn=no-ssl-fixture",
11006 info_hash.to_hex()
11007 );
11008 let added = session
11009 .add_torrent(AddTorrentParams::magnet(magnet))
11010 .await
11011 .unwrap();
11012 assert_eq!(
11013 added, info_hash,
11014 "magnet info hash must equal synth info hash"
11015 );
11016
11017 session
11018 .debug_inject_metadata(info_hash, info_bytes)
11019 .await
11020 .expect("debug_inject_metadata must succeed");
11021
11022 let meta = session
11023 .torrent_file(info_hash)
11024 .await
11025 .expect("torrent_file Ok")
11026 .expect("metadata must be present immediately after sync inject");
11027 assert!(
11028 meta.info.ssl_cert.is_none(),
11029 "absent ssl-cert in info dict must remain None in meta; got {:?}",
11030 meta.info.ssl_cert
11031 );
11032
11033 session.shutdown().await.unwrap();
11034 }
11035
11036 #[tokio::test]
11039 async fn init_throttle_queues_restored_torrents() {
11040 let tmp = tempfile::TempDir::new().unwrap();
11041 let resume_dir = tmp.path().to_path_buf();
11042
11043 {
11045 let mut settings = resume_test_settings(&resume_dir);
11046 settings.queueing_enabled = false;
11047 let session = SessionHandle::start(settings).await.unwrap();
11048 for i in 0u8..5 {
11049 let data = vec![i.wrapping_add(0xA0); 16384];
11050 let meta = make_test_torrent(&data, 16384);
11051 let storage = make_storage(&data, 16384);
11052 session
11053 .add_torrent_with_meta(meta.into(), Some(storage))
11054 .await
11055 .unwrap();
11056 }
11057 tokio::time::sleep(Duration::from_millis(100)).await;
11058 let saved = session.save_resume_state().await.unwrap();
11059 assert!(saved >= 3, "should save most resume files, got {saved}");
11060 session.shutdown().await.unwrap();
11061 }
11062
11063 {
11065 let mut settings = resume_test_settings(&resume_dir);
11066 settings.queueing_enabled = true;
11067 settings.active_checking = 2;
11068 settings.active_downloads = 2;
11069 settings.active_seeds = 2;
11070 settings.active_limit = 4;
11071 let session = SessionHandle::start(settings).await.unwrap();
11072 let mut queued = 0;
11083 let mut active = 0;
11084 for _ in 0..60 {
11085 let list = session.list_torrent_summaries().await.unwrap();
11086 queued = list
11087 .iter()
11088 .filter(|t| t.state == TorrentState::Queued)
11089 .count();
11090 active = list
11091 .iter()
11092 .filter(|t| t.state != TorrentState::Queued)
11093 .count();
11094 if queued > 0 {
11095 break;
11096 }
11097 tokio::time::sleep(Duration::from_millis(50)).await;
11098 }
11099
11100 assert!(
11101 queued > 0,
11102 "at least one torrent should be Queued after a stats tick, but all {active} are active"
11103 );
11104 assert!(
11105 active <= 4,
11106 "active torrents ({active}) should not exceed active_limit (4)"
11107 );
11108 session.shutdown().await.unwrap();
11109 }
11110 }
11111
11112 #[tokio::test]
11113 async fn init_throttle_disabled_restores_all_immediately() {
11114 let tmp = tempfile::TempDir::new().unwrap();
11115 let resume_dir = tmp.path().to_path_buf();
11116
11117 {
11119 let settings = resume_test_settings(&resume_dir);
11120 let session = SessionHandle::start(settings).await.unwrap();
11121 for i in 0u8..3 {
11122 let data = vec![i.wrapping_add(0xC0); 16384];
11123 let meta = make_test_torrent(&data, 16384);
11124 let storage = make_storage(&data, 16384);
11125 session
11126 .add_torrent_with_meta(meta.into(), Some(storage))
11127 .await
11128 .unwrap();
11129 }
11130 tokio::time::sleep(Duration::from_millis(100)).await;
11131 session.save_resume_state().await.unwrap();
11132 session.shutdown().await.unwrap();
11133 }
11134
11135 {
11137 let mut settings = resume_test_settings(&resume_dir);
11138 settings.queueing_enabled = false;
11139 let session = SessionHandle::start(settings).await.unwrap();
11140 tokio::time::sleep(Duration::from_millis(200)).await;
11141
11142 let list = session.list_torrent_summaries().await.unwrap();
11143 let queued = list
11144 .iter()
11145 .filter(|t| t.state == TorrentState::Queued)
11146 .count();
11147 assert_eq!(
11148 queued, 0,
11149 "with queueing disabled, no torrents should be Queued"
11150 );
11151 session.shutdown().await.unwrap();
11152 }
11153 }
11154
11155 #[tokio::test]
11156 async fn checking_complete_triggers_immediate_eval() {
11157 use crate::alert::AlertKind;
11158
11159 let mut settings = test_settings();
11160 settings.queueing_enabled = true;
11161 settings.active_checking = 1;
11162 settings.active_downloads = 5;
11163 settings.active_seeds = 5;
11164 settings.active_limit = 10;
11165 settings.auto_manage_interval = 300;
11166 let session = SessionHandle::start(settings).await.unwrap();
11167 let mut alerts = session.subscribe();
11168
11169 let mut hashes = Vec::new();
11171 for i in 0u8..3 {
11172 let data = vec![i.wrapping_add(0xD0); 16384];
11173 let meta = make_test_torrent(&data, 16384);
11174 let storage = make_storage(&data, 16384);
11175 let h = session
11176 .add_torrent_with_meta(meta.into(), Some(storage))
11177 .await
11178 .unwrap();
11179 hashes.push(h);
11180 }
11181
11182 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
11186 let mut saw_checking_transition = false;
11187 while tokio::time::Instant::now() < deadline {
11188 if let Ok(Ok(alert)) =
11189 tokio::time::timeout(Duration::from_millis(500), alerts.recv()).await
11190 && matches!(
11191 alert.kind,
11192 AlertKind::StateChanged {
11193 prev_state: TorrentState::Checking,
11194 ..
11195 }
11196 )
11197 {
11198 saw_checking_transition = true;
11199 break;
11200 }
11201 }
11202
11203 assert!(
11204 saw_checking_transition,
11205 "should have seen a Checking→* state transition"
11206 );
11207
11208 tokio::time::sleep(Duration::from_millis(200)).await;
11212
11213 let list = session.list_torrent_summaries().await.unwrap();
11214 let active = list
11215 .iter()
11216 .filter(|t| t.state != TorrentState::Queued)
11217 .count();
11218 assert!(
11219 active >= 1,
11220 "at least one torrent should be active after checking-complete trigger"
11221 );
11222
11223 session.shutdown().await.unwrap();
11224 }
11225
11226 #[tokio::test]
11229 async fn resume_restores_queue_position() {
11230 let tmp = tempfile::TempDir::new().unwrap();
11231 let resume_dir = tmp.path().to_path_buf();
11232
11233 let data = vec![0xF0; 16384];
11234 let meta = make_test_torrent(&data, 16384);
11235 let info_hash = meta.info_hash;
11236
11237 {
11239 let settings = resume_test_settings(&resume_dir);
11240 let session = SessionHandle::start(settings).await.unwrap();
11241 let storage = make_storage(&data, 16384);
11242 session
11243 .add_torrent_with_meta(meta.clone().into(), Some(storage))
11244 .await
11245 .unwrap();
11246 session.set_queue_position(info_hash, 3).await.unwrap();
11247 tokio::time::sleep(Duration::from_millis(100)).await;
11248 session.save_resume_state().await.unwrap();
11249 session.shutdown().await.unwrap();
11250 }
11251
11252 {
11254 let settings = resume_test_settings(&resume_dir);
11255 let session = SessionHandle::start(settings).await.unwrap();
11256 tokio::time::sleep(Duration::from_millis(200)).await;
11257
11258 let pos = session.queue_position(info_hash).await.unwrap();
11259 assert_eq!(pos, 0, "single torrent renormalizes to position 0");
11262 session.shutdown().await.unwrap();
11263 }
11264 }
11265
11266 #[tokio::test]
11267 async fn resume_restores_auto_managed_false() {
11268 let tmp = tempfile::TempDir::new().unwrap();
11269 let resume_dir = tmp.path().to_path_buf();
11270
11271 let data = vec![0xF1; 16384];
11272 let meta = make_test_torrent(&data, 16384);
11273 let info_hash = meta.info_hash;
11274
11275 {
11277 let settings = resume_test_settings(&resume_dir);
11278 let session = SessionHandle::start(settings).await.unwrap();
11279 let storage = make_storage(&data, 16384);
11280 session
11281 .add_torrent_with_meta(meta.clone().into(), Some(storage))
11282 .await
11283 .unwrap();
11284 tokio::time::sleep(Duration::from_millis(100)).await;
11288 session.save_resume_state().await.unwrap();
11289 session.shutdown().await.unwrap();
11290 }
11291
11292 {
11294 let path = crate::resume_file::resume_file_path(&resume_dir, &info_hash);
11295 if path.exists() {
11296 let bytes = std::fs::read(&path).unwrap();
11297 let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11298 rd.auto_managed = 0;
11299 let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11300 std::fs::write(&path, patched).unwrap();
11301 }
11302 }
11303
11304 {
11306 let settings = resume_test_settings(&resume_dir);
11307 let session = SessionHandle::start(settings).await.unwrap();
11308 tokio::time::sleep(Duration::from_millis(200)).await;
11309
11310 let stats = session.torrent_stats(info_hash).await.unwrap();
11311 assert!(
11312 !stats.auto_managed,
11313 "auto_managed should be false after restore"
11314 );
11315 session.shutdown().await.unwrap();
11316 }
11317 }
11318
11319 #[tokio::test]
11320 async fn resume_renormalizes_duplicate_positions() {
11321 let tmp = tempfile::TempDir::new().unwrap();
11322 let resume_dir = tmp.path().to_path_buf();
11323
11324 let mut hashes = Vec::new();
11326 {
11327 let settings = resume_test_settings(&resume_dir);
11328 let session = SessionHandle::start(settings).await.unwrap();
11329 for i in 0u8..3 {
11330 let data = vec![i.wrapping_add(0xE0); 16384];
11331 let meta = make_test_torrent(&data, 16384);
11332 let storage = make_storage(&data, 16384);
11333 let h = session
11334 .add_torrent_with_meta(meta.into(), Some(storage))
11335 .await
11336 .unwrap();
11337 hashes.push(h);
11338 }
11339 tokio::time::sleep(Duration::from_millis(100)).await;
11340 session.save_resume_state().await.unwrap();
11341 session.shutdown().await.unwrap();
11342 }
11343
11344 for hash in &hashes {
11346 let path = crate::resume_file::resume_file_path(&resume_dir, hash);
11347 if path.exists() {
11348 let bytes = std::fs::read(&path).unwrap();
11349 let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11350 rd.queue_position = 0;
11351 let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11352 std::fs::write(&path, patched).unwrap();
11353 }
11354 }
11355
11356 {
11358 let settings = resume_test_settings(&resume_dir);
11359 let session = SessionHandle::start(settings).await.unwrap();
11360 tokio::time::sleep(Duration::from_millis(200)).await;
11361
11362 let mut positions = Vec::new();
11363 for hash in &hashes {
11364 if let Ok(pos) = session.queue_position(*hash).await {
11365 positions.push(pos);
11366 }
11367 }
11368 positions.sort_unstable();
11369 let expected: Vec<i32> = (0..positions.len() as i32).collect();
11370 assert_eq!(
11371 positions, expected,
11372 "positions should be contiguous 0..N-1 after renormalization"
11373 );
11374 session.shutdown().await.unwrap();
11375 }
11376 }
11377
11378 #[test]
11381 fn ewma_smooths_transient_drop() {
11382 let alpha = 0.3_f64;
11383 let prev = 100_000.0_f64;
11384 let sample = 0.0_f64;
11385 let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11386 assert!(
11387 (smoothed - 70_000.0).abs() < 1.0,
11388 "smoothed rate should be ~70000, got {smoothed}"
11389 );
11390 }
11391
11392 #[test]
11393 fn ewma_alpha_one_equals_raw() {
11394 let alpha = 1.0_f64;
11395 let prev = 100_000.0_f64;
11396 let sample = 42_000.0_f64;
11397 let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11398 assert!(
11399 (smoothed - sample).abs() < 0.001,
11400 "alpha=1.0 should produce raw rate, got {smoothed}"
11401 );
11402 }
11403
11404 #[test]
11407 fn seed_anti_flap_uses_longer_duration() {
11408 let seed_queue_min_active_secs = 1800_u64;
11409 let auto_manage_startup = 60_u64;
11410 let started_5_min_ago = std::time::Duration::from_mins(5);
11411 let seed_duration = std::time::Duration::from_secs(seed_queue_min_active_secs);
11412
11413 assert!(
11416 started_5_min_ago < seed_duration,
11417 "5 min < 30 min, seeding torrent should be recently_started"
11418 );
11419
11420 let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11423 assert!(
11424 started_5_min_ago > dl_duration,
11425 "5 min > 60s, downloading torrent should NOT be recently_started"
11426 );
11427 }
11428
11429 #[test]
11430 fn download_anti_flap_uses_startup_duration() {
11431 let auto_manage_startup = 60_u64;
11432 let started_5_min_ago = std::time::Duration::from_mins(5);
11433 let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11434 assert!(
11435 started_5_min_ago > dl_duration,
11436 "downloading torrent started 5 min ago should NOT be recently_started"
11437 );
11438 }
11439
11440 #[test]
11443 fn classify_restart_required_upnp_change() {
11444 let old = Settings::default();
11445 let mut new = old.clone();
11446 new.enable_upnp = !old.enable_upnp;
11447 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11448 assert_eq!(classify_restart_required(&old, &new), vec!["upnp"]);
11449 }
11450
11451 #[test]
11452 fn classify_restart_required_natpmp_change() {
11453 let old = Settings::default();
11454 let mut new = old.clone();
11455 new.enable_natpmp = !old.enable_natpmp;
11456 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11457 assert_eq!(classify_restart_required(&old, &new), vec!["natpmp"]);
11458 }
11459
11460 #[test]
11461 fn classify_immediate_max_connec_global_change() {
11462 let old = Settings::default();
11463 let mut new = old.clone();
11464 new.max_connections_global = if old.max_connections_global == 500 {
11465 501
11466 } else {
11467 500
11468 };
11469 assert_eq!(classify_immediate(&old, &new), vec!["max_connec_global"]);
11470 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11471 }
11472
11473 #[test]
11474 fn classify_immediate_max_uploads_per_torrent_change() {
11475 let old = Settings::default();
11479 let mut new = old.clone();
11480 new.max_uploads_per_torrent = 4;
11481 assert_eq!(
11482 classify_immediate(&old, &new),
11483 vec!["max_uploads_per_torrent"]
11484 );
11485 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11486 }
11487
11488 #[test]
11489 fn classify_restart_required_proxy_type_change() {
11490 let old = Settings::default();
11491 let mut new = old.clone();
11492 new.proxy.proxy_type = crate::proxy::ProxyType::Socks5;
11493 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11494 assert_eq!(classify_restart_required(&old, &new), vec!["proxy_type"]);
11495 }
11496
11497 #[test]
11498 fn classify_restart_required_proxy_credentials_change() {
11499 let old = Settings::default();
11500 let mut new = old.clone();
11501 new.proxy.username = Some("alice".into());
11502 new.proxy.password = Some("secret".into());
11503 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11504 let restart = classify_restart_required(&old, &new);
11505 let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
11508 assert_eq!(
11509 set,
11510 ["proxy_username", "proxy_password"]
11511 .into_iter()
11512 .collect::<std::collections::HashSet<_>>()
11513 );
11514 }
11515
11516 #[test]
11517 fn classify_combined_immediate_and_restart() {
11518 let old = Settings::default();
11522 let mut new = old.clone();
11523 new.max_connections_global = old.max_connections_global + 1;
11524 new.max_uploads_per_torrent = 4;
11525 new.enable_upnp = !old.enable_upnp;
11526 new.proxy.proxy_type = crate::proxy::ProxyType::Http;
11527
11528 let immediate = classify_immediate(&old, &new);
11529 let imm_set: std::collections::HashSet<&str> = immediate.iter().copied().collect();
11530 assert_eq!(
11531 imm_set,
11532 ["max_connec_global", "max_uploads_per_torrent"]
11533 .into_iter()
11534 .collect::<std::collections::HashSet<_>>()
11535 );
11536 let restart = classify_restart_required(&old, &new);
11537 let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
11538 assert_eq!(
11539 set,
11540 ["upnp", "proxy_type"]
11541 .into_iter()
11542 .collect::<std::collections::HashSet<_>>()
11543 );
11544 }
11545
11546 #[test]
11549 fn classify_immediate_seed_time_limit_change() {
11550 let old = Settings::default();
11551 let mut new = old.clone();
11552 new.seed_time_limit_secs = Some(3600);
11553 assert_eq!(classify_immediate(&old, &new), vec!["max_seeding_time"]);
11554 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11555 }
11556
11557 #[test]
11558 fn classify_immediate_inactive_seed_time_limit_change() {
11559 let old = Settings::default();
11560 let mut new = old.clone();
11561 new.inactive_seed_time_limit_secs = Some(1800);
11562 assert_eq!(
11563 classify_immediate(&old, &new),
11564 vec!["max_inactive_seeding_time"]
11565 );
11566 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11567 }
11568
11569 #[test]
11575 fn classify_immediate_save_resume_interval_change() {
11576 let old = Settings::default();
11580 let mut new = old.clone();
11581 new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(60);
11582 assert_eq!(classify_immediate(&old, &new), vec!["save_resume_interval"]);
11583 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11584 }
11585
11586 #[test]
11587 fn classify_immediate_hashing_threads_change() {
11588 let old = Settings::default();
11594 let mut new = old.clone();
11595 new.hashing_threads = old.hashing_threads.saturating_add(2);
11596 assert_eq!(classify_immediate(&old, &new), vec!["hashing_threads"]);
11597 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11598 }
11599
11600 #[test]
11601 fn classify_immediate_ip_filter_enabled_change() {
11602 let old = Settings::default();
11607 let mut new = old.clone();
11608 new.ip_filter_enabled = !old.ip_filter_enabled;
11609 assert_eq!(classify_immediate(&old, &new), vec!["ip_filter_enabled"]);
11610 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11611 }
11612
11613 #[test]
11614 fn settings_delta_from_diff_includes_save_resume_interval() {
11615 use crate::types::SettingsDelta;
11618 let old = Settings::default();
11619 let mut new = old.clone();
11620 new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(30);
11621 let d = SettingsDelta::from_diff(&old, &new);
11622 assert_eq!(
11623 d.save_resume_interval_secs,
11624 Some(new.save_resume_interval_secs)
11625 );
11626 assert!(d.hashing_threads.is_none());
11627 assert!(d.ip_filter_enabled.is_none());
11628 assert!(!d.is_empty());
11629 }
11630
11631 #[test]
11632 fn settings_delta_from_diff_includes_hashing_threads() {
11633 use crate::types::SettingsDelta;
11636 let old = Settings::default();
11637 let mut new = old.clone();
11638 new.hashing_threads = old.hashing_threads.saturating_add(1);
11639 let d = SettingsDelta::from_diff(&old, &new);
11640 assert_eq!(d.hashing_threads, Some(new.hashing_threads));
11641 assert!(d.save_resume_interval_secs.is_none());
11642 assert!(d.ip_filter_enabled.is_none());
11643 assert!(!d.is_empty());
11644 }
11645
11646 #[test]
11647 fn settings_delta_from_diff_includes_ip_filter_enabled() {
11648 use crate::types::SettingsDelta;
11651 let old = Settings::default();
11652 let mut new = old.clone();
11653 new.ip_filter_enabled = !old.ip_filter_enabled;
11654 let d = SettingsDelta::from_diff(&old, &new);
11655 assert_eq!(d.ip_filter_enabled, Some(new.ip_filter_enabled));
11656 assert!(d.save_resume_interval_secs.is_none());
11657 assert!(d.hashing_threads.is_none());
11658 assert!(!d.is_empty());
11659 }
11660
11661 #[test]
11662 fn settings_delta_is_empty_honours_m225_fields() {
11663 use crate::types::SettingsDelta;
11666 let mut d = SettingsDelta::default();
11667 assert!(d.is_empty());
11668 d.save_resume_interval_secs = Some(120);
11669 assert!(!d.is_empty());
11670 d = SettingsDelta::default();
11671 d.hashing_threads = Some(8);
11672 assert!(!d.is_empty());
11673 d = SettingsDelta::default();
11674 d.ip_filter_enabled = Some(false);
11675 assert!(!d.is_empty());
11676 }
11677
11678 fn m226_delta_and_classify_check<F>(mutate: F, alias: &'static str)
11684 where
11685 F: FnOnce(&mut Settings),
11686 {
11687 use crate::types::SettingsDelta;
11688 let old = Settings::default();
11689 let mut new = old.clone();
11690 mutate(&mut new);
11691 let d = SettingsDelta::from_diff(&old, &new);
11692 assert!(
11693 !d.is_empty(),
11694 "{alias}: delta must not be empty after toggle"
11695 );
11696 let imm = classify_immediate(&old, &new);
11697 assert!(
11698 imm.contains(&alias),
11699 "{alias}: classify_immediate must contain alias, got {imm:?}"
11700 );
11701 let rr = classify_restart_required(&old, &new);
11702 assert!(
11703 !rr.contains(&alias),
11704 "{alias}: must NOT appear in classify_restart_required"
11705 );
11706 }
11707
11708 #[test]
11709 fn m226_notify_on_complete_immediate() {
11710 m226_delta_and_classify_check(|s| s.notify_on_complete = true, "notify_on_complete");
11711 }
11712
11713 #[test]
11714 fn m226_notify_on_error_immediate() {
11715 m226_delta_and_classify_check(|s| s.notify_on_error = true, "notify_on_error");
11716 }
11717
11718 #[test]
11719 fn m226_on_complete_program_immediate() {
11720 m226_delta_and_classify_check(
11721 |s| s.on_complete_program = Some(std::path::PathBuf::from("/usr/local/bin/finish")),
11722 "on_complete_program",
11723 );
11724 }
11725
11726 #[test]
11727 fn m226_use_incomplete_dir_immediate() {
11728 m226_delta_and_classify_check(|s| s.use_incomplete_dir = true, "use_incomplete_dir");
11729 }
11730
11731 #[test]
11732 fn m226_incomplete_dir_immediate() {
11733 m226_delta_and_classify_check(
11734 |s| s.incomplete_dir = Some(std::path::PathBuf::from("/tmp/inc")),
11735 "incomplete_dir",
11736 );
11737 }
11738
11739 #[test]
11740 fn m226_default_skip_hash_check_immediate() {
11741 m226_delta_and_classify_check(
11742 |s| s.default_skip_hash_check = true,
11743 "default_skip_hash_check",
11744 );
11745 }
11746
11747 #[test]
11748 fn m226_incomplete_extension_enabled_immediate() {
11749 m226_delta_and_classify_check(
11751 |s| s.incomplete_extension_enabled = false,
11752 "incomplete_extension_enabled",
11753 );
11754 }
11755
11756 #[test]
11757 fn m226_watched_folder_immediate() {
11758 m226_delta_and_classify_check(
11759 |s| s.watched_folder = Some(std::path::PathBuf::from("/tmp/watched")),
11760 "watched_folder",
11761 );
11762 }
11763
11764 #[test]
11765 fn m226_delete_torrent_after_add_immediate() {
11766 m226_delta_and_classify_check(
11767 |s| s.delete_torrent_after_add = true,
11768 "delete_torrent_after_add",
11769 );
11770 }
11771
11772 #[test]
11773 fn m226_move_completed_enabled_immediate() {
11774 m226_delta_and_classify_check(
11775 |s| s.move_completed_enabled = true,
11776 "move_completed_enabled",
11777 );
11778 }
11779
11780 #[test]
11781 fn m226_move_completed_to_immediate() {
11782 m226_delta_and_classify_check(
11783 |s| s.move_completed_to = Some(std::path::PathBuf::from("/tmp/done")),
11784 "move_completed_to",
11785 );
11786 }
11787
11788 #[test]
11789 fn m226_ip_filter_auto_refresh_immediate() {
11790 m226_delta_and_classify_check(
11791 |s| s.ip_filter_auto_refresh = true,
11792 "ip_filter_auto_refresh",
11793 );
11794 }
11795
11796 #[test]
11797 fn m226_web_ui_https_enabled_immediate() {
11798 m226_delta_and_classify_check(|s| s.web_ui_https_enabled = true, "web_ui_https_enabled");
11799 }
11800
11801 #[test]
11802 fn m226_network_interface_immediate() {
11803 m226_delta_and_classify_check(
11804 |s| s.network_interface = Some("eth0".into()),
11805 "network_interface",
11806 );
11807 }
11808
11809 #[test]
11810 fn m226_default_add_paused_immediate() {
11811 m226_delta_and_classify_check(|s| s.default_add_paused = true, "default_add_paused");
11812 }
11813
11814 #[test]
11815 fn m226_delta_clears_optional_path_incomplete_dir() {
11816 use crate::types::SettingsDelta;
11819 let old = Settings {
11820 incomplete_dir: Some(std::path::PathBuf::from("/foo")),
11821 ..Settings::default()
11822 };
11823 let new = Settings {
11824 incomplete_dir: None,
11825 ..old.clone()
11826 };
11827 let d = SettingsDelta::from_diff(&old, &new);
11828 assert_eq!(d.incomplete_dir, Some(None), "must signal clear to None");
11829 assert!(!d.is_empty());
11830 }
11831
11832 #[test]
11833 fn m226_delta_clears_optional_path_watched_folder() {
11834 use crate::types::SettingsDelta;
11836 let old = Settings {
11837 watched_folder: Some(std::path::PathBuf::from("/tmp/watch")),
11838 ..Settings::default()
11839 };
11840 let new = Settings {
11841 watched_folder: None,
11842 ..old.clone()
11843 };
11844 let d = SettingsDelta::from_diff(&old, &new);
11845 assert_eq!(d.watched_folder, Some(None));
11846 assert!(!d.is_empty());
11847 }
11848
11849 #[test]
11850 fn m226_delta_is_empty_honours_new_fields() {
11851 use crate::types::SettingsDelta;
11853 let mut d = SettingsDelta::default();
11854 assert!(d.is_empty());
11855 d.notify_on_complete = Some(true);
11856 assert!(!d.is_empty());
11857 d = SettingsDelta::default();
11858 d.watched_folder = Some(None); assert!(!d.is_empty());
11860 d = SettingsDelta::default();
11861 d.default_add_paused = Some(true);
11862 assert!(!d.is_empty());
11863 }
11864
11865 #[test]
11866 fn m226_no_fields_appear_in_restart_required() {
11867 type Mutation = fn(&mut Settings);
11870 let mutations: [Mutation; 15] = [
11871 |s| s.notify_on_complete = true,
11872 |s| s.notify_on_error = true,
11873 |s| s.on_complete_program = Some(std::path::PathBuf::from("/p")),
11874 |s| s.use_incomplete_dir = true,
11875 |s| s.incomplete_dir = Some(std::path::PathBuf::from("/i")),
11876 |s| s.default_skip_hash_check = true,
11877 |s| s.incomplete_extension_enabled = false,
11878 |s| s.watched_folder = Some(std::path::PathBuf::from("/w")),
11879 |s| s.delete_torrent_after_add = true,
11880 |s| s.move_completed_enabled = true,
11881 |s| s.move_completed_to = Some(std::path::PathBuf::from("/m")),
11882 |s| s.ip_filter_auto_refresh = true,
11883 |s| s.web_ui_https_enabled = true,
11884 |s| s.network_interface = Some("eth0".into()),
11885 |s| s.default_add_paused = true,
11886 ];
11887 let old = Settings::default();
11888 for (idx, m) in mutations.iter().enumerate() {
11889 let mut new = old.clone();
11890 m(&mut new);
11891 let rr = classify_restart_required(&old, &new);
11892 assert!(
11893 rr.is_empty(),
11894 "mutation #{idx}: M226 fields must not surface restart_required, got {rr:?}"
11895 );
11896 }
11897 }
11898
11899 #[test]
11900 fn classify_immediate_seed_time_and_inactive_combined() {
11901 let old = Settings::default();
11904 let mut new = old.clone();
11905 new.seed_time_limit_secs = Some(7200);
11906 new.inactive_seed_time_limit_secs = Some(900);
11907 let imm = classify_immediate(&old, &new);
11908 let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11909 assert_eq!(
11910 set,
11911 ["max_seeding_time", "max_inactive_seeding_time"]
11912 .into_iter()
11913 .collect::<std::collections::HashSet<_>>()
11914 );
11915 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11916 }
11917
11918 #[test]
11919 fn classify_combined_seed_time_and_hashing_both_immediate() {
11920 let old = Settings::default();
11924 let mut new = old.clone();
11925 new.seed_time_limit_secs = Some(1200);
11926 new.hashing_threads = old.hashing_threads.saturating_add(2);
11927 let imm = classify_immediate(&old, &new);
11928 let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11929 assert_eq!(
11930 set,
11931 ["max_seeding_time", "hashing_threads"]
11932 .into_iter()
11933 .collect::<std::collections::HashSet<_>>()
11934 );
11935 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11936 }
11937
11938 #[test]
11939 fn classify_combined_hashing_and_save_resume_both_immediate() {
11940 let old = Settings::default();
11944 let mut new = old.clone();
11945 new.hashing_threads = old.hashing_threads.saturating_add(3);
11946 new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(120);
11947 let imm = classify_immediate(&old, &new);
11948 let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11949 assert_eq!(
11950 set,
11951 ["hashing_threads", "save_resume_interval"]
11952 .into_iter()
11953 .collect::<std::collections::HashSet<_>>()
11954 );
11955 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11956 }
11957
11958 fn m226_make_torrent_bytes(data: &[u8], piece_length: u64) -> Vec<u8> {
11971 use serde::Serialize;
11972
11973 #[derive(Serialize)]
11974 struct Info<'a> {
11975 length: u64,
11976 name: &'a str,
11977 #[serde(rename = "piece length")]
11978 piece_length: u64,
11979 #[serde(with = "serde_bytes")]
11980 pieces: &'a [u8],
11981 }
11982 #[derive(Serialize)]
11983 struct Torrent<'a> {
11984 info: Info<'a>,
11985 }
11986
11987 let mut pieces = Vec::new();
11988 let mut offset = 0;
11989 while offset < data.len() {
11990 let end = (offset + piece_length as usize).min(data.len());
11991 let hash = irontide_core::sha1(&data[offset..end]);
11992 pieces.extend_from_slice(hash.as_bytes());
11993 offset = end;
11994 }
11995
11996 irontide_bencode::to_bytes(&Torrent {
11997 info: Info {
11998 length: data.len() as u64,
11999 name: "m226-test",
12000 piece_length,
12001 pieces: &pieces,
12002 },
12003 })
12004 .unwrap()
12005 }
12006
12007 #[tokio::test]
12010 async fn add_torrent_with_default_add_paused_true_pauses_torrent() {
12011 let mut settings = test_settings();
12012 settings.default_add_paused = true;
12013 let session = SessionHandle::start(settings).await.unwrap();
12014
12015 let data = vec![0xAB; 16384];
12016 let bytes = m226_make_torrent_bytes(&data, 16384);
12017 let info_hash = session
12018 .add_torrent(AddTorrentParams::bytes(bytes))
12019 .await
12020 .unwrap();
12021
12022 tokio::time::sleep(Duration::from_millis(100)).await;
12025 let stats = session.torrent_stats(info_hash).await.unwrap();
12026 assert_eq!(
12027 stats.state,
12028 TorrentState::Paused,
12029 "engine default_add_paused=true must pause the torrent when caller \
12030 passes AddTorrentParams::bytes() without an explicit .paused(...)"
12031 );
12032
12033 session.shutdown().await.unwrap();
12034 }
12035
12036 #[tokio::test]
12040 async fn add_torrent_with_explicit_paused_false_resumes_despite_default() {
12041 let mut settings = test_settings();
12042 settings.default_add_paused = true;
12043 let session = SessionHandle::start(settings).await.unwrap();
12044
12045 let data = vec![0xCD; 16384];
12046 let bytes = m226_make_torrent_bytes(&data, 16384);
12047 let info_hash = session
12048 .add_torrent(AddTorrentParams::bytes(bytes).paused(false))
12049 .await
12050 .unwrap();
12051
12052 tokio::time::sleep(Duration::from_millis(100)).await;
12055 let stats = session.torrent_stats(info_hash).await.unwrap();
12056 assert_ne!(
12057 stats.state,
12058 TorrentState::Paused,
12059 "explicit .paused(false) must override default_add_paused=true; \
12060 got state={:?}",
12061 stats.state
12062 );
12063
12064 session.shutdown().await.unwrap();
12065 }
12066
12067 #[tokio::test]
12071 async fn add_torrent_with_explicit_paused_true_pauses_despite_default_false() {
12072 let mut settings = test_settings();
12073 settings.default_add_paused = false;
12074 let session = SessionHandle::start(settings).await.unwrap();
12075
12076 let data = vec![0xEF; 16384];
12077 let bytes = m226_make_torrent_bytes(&data, 16384);
12078 let info_hash = session
12079 .add_torrent(AddTorrentParams::bytes(bytes).paused(true))
12080 .await
12081 .unwrap();
12082
12083 tokio::time::sleep(Duration::from_millis(100)).await;
12084 let stats = session.torrent_stats(info_hash).await.unwrap();
12085 assert_eq!(
12086 stats.state,
12087 TorrentState::Paused,
12088 "explicit .paused(true) must pause even when \
12089 default_add_paused=false"
12090 );
12091
12092 session.shutdown().await.unwrap();
12093 }
12094}