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 pub content_layout: Option<irontide_core::ContentLayout>,
117 pub preallocate_mode: Option<irontide_storage::PreallocateMode>,
120 pub auto_managed: Option<bool>,
123 pub sequential_download: Option<bool>,
125 pub prioritize_first_last_pieces: Option<bool>,
127 pub file_priorities: Vec<irontide_core::FilePriority>,
131}
132
133impl AddTorrentParams {
134 #[must_use]
136 pub fn magnet(uri: impl Into<String>) -> Self {
137 Self {
138 source: AddSource::Magnet(uri.into()),
139 category: None,
140 tags: Vec::new(),
141 download_dir: None,
142 paused: None,
143 skip_checking: false,
144 content_layout: None,
145 preallocate_mode: None,
146 auto_managed: None,
147 sequential_download: None,
148 prioritize_first_last_pieces: None,
149 file_priorities: Vec::new(),
150 }
151 }
152
153 #[must_use]
155 pub fn bytes(data: impl Into<Vec<u8>>) -> Self {
156 Self {
157 source: AddSource::Bytes(data.into()),
158 category: None,
159 tags: Vec::new(),
160 download_dir: None,
161 paused: None,
162 skip_checking: false,
163 content_layout: None,
164 preallocate_mode: None,
165 auto_managed: None,
166 sequential_download: None,
167 prioritize_first_last_pieces: None,
168 file_priorities: Vec::new(),
169 }
170 }
171
172 #[must_use]
175 pub fn with_category(mut self, name: impl Into<String>) -> Self {
176 self.category = Some(name.into());
177 self
178 }
179
180 #[must_use]
184 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
185 self.tags = tags;
186 self
187 }
188
189 #[must_use]
194 pub fn with_content_layout(mut self, layout: irontide_core::ContentLayout) -> Self {
195 self.content_layout = Some(layout);
196 self
197 }
198
199 #[must_use]
201 pub fn with_download_dir(mut self, dir: impl Into<PathBuf>) -> Self {
202 self.download_dir = Some(dir.into());
203 self
204 }
205
206 #[must_use]
208 pub fn with_preallocate_mode(mut self, mode: irontide_storage::PreallocateMode) -> Self {
209 self.preallocate_mode = Some(mode);
210 self
211 }
212
213 #[must_use]
216 pub fn auto_managed(mut self, enabled: bool) -> Self {
217 self.auto_managed = Some(enabled);
218 self
219 }
220
221 #[must_use]
224 pub fn sequential_download(mut self, enabled: bool) -> Self {
225 self.sequential_download = Some(enabled);
226 self
227 }
228
229 #[must_use]
231 pub fn prioritize_first_last_pieces(mut self, enabled: bool) -> Self {
232 self.prioritize_first_last_pieces = Some(enabled);
233 self
234 }
235
236 #[must_use]
238 pub fn with_file_priorities(mut self, priorities: Vec<irontide_core::FilePriority>) -> Self {
239 self.file_priorities = priorities;
240 self
241 }
242
243 #[must_use]
248 pub fn paused(mut self, paused: bool) -> Self {
249 self.paused = Some(paused);
250 self
251 }
252
253 #[must_use]
255 pub fn skip_checking(mut self, skip: bool) -> Self {
256 self.skip_checking = skip;
257 self
258 }
259}
260
261struct TorrentEntry {
271 handle: TorrentHandle,
272 queue_position: i32,
274 auto_managed: bool,
276 started_at: Option<tokio::time::Instant>,
278 smoothed_download_rate: f64,
280 smoothed_upload_rate: f64,
282}
283
284struct PreparedAddTorrent {
301 handle: TorrentHandle,
302 info_hash: Id20,
303 is_private: bool,
304 m170_post: Option<M170PostAdd>,
308 auto_managed: Option<bool>,
311}
312
313struct M170PostAdd {
317 category: Option<String>,
318 paused: bool,
319}
320
321#[derive(Debug, Clone, Default)]
328struct AddConfigOverrides {
329 content_layout: Option<irontide_core::ContentLayout>,
330 sequential_download: Option<bool>,
331 prioritize_first_last_pieces: Option<bool>,
332 preallocate_mode: Option<irontide_storage::PreallocateMode>,
334 file_priorities: Vec<irontide_core::FilePriority>,
336 auto_managed: Option<bool>,
340}
341
342impl AddConfigOverrides {
343 fn bake_into(&self, config: &mut TorrentConfig) {
345 if let Some(layout) = self.content_layout {
346 config.content_layout = layout;
347 }
348 if let Some(seq) = self.sequential_download {
349 config.sequential_download = seq;
350 }
351 if let Some(fl) = self.prioritize_first_last_pieces {
352 config.prioritize_first_last_pieces = fl;
353 }
354 if let Some(mode) = self.preallocate_mode {
355 config.preallocate_mode = mode;
356 }
357 if !self.file_priorities.is_empty() {
358 config.file_priorities.clone_from(&self.file_priorities);
359 }
360 }
361}
362
363struct AddTorrentPrepBundle {
370 torrent_meta: irontide_core::TorrentMeta,
371 storage_override: Option<Arc<dyn TorrentStorage>>,
372 torrent_config: TorrentConfig,
373 disk_manager: crate::disk::DiskManagerHandle,
374 dht_v4_broadcast: irontide_dht::DhtBroadcast,
375 dht_v6_broadcast: irontide_dht::DhtBroadcast,
376 global_up: Option<SharedBucket>,
377 global_down: Option<SharedBucket>,
378 slot_tuner: crate::slot_tuner::SlotTuner,
379 alert_tx: broadcast::Sender<Alert>,
380 alert_mask: Arc<AtomicU32>,
381 utp_socket: Option<irontide_utp::UtpSocket>,
382 utp_socket_v6: Option<irontide_utp::UtpSocket>,
383 ban_manager: SharedBanManager,
384 ip_filter: SharedIpFilter,
385 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
386 sam_session: Option<Arc<crate::i2p::SamSession>>,
387 ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
388 factory: Arc<crate::transport::NetworkFactory>,
389 hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
390 counters: Arc<crate::stats::SessionCounters>,
391 m170_post: Option<M170PostAdd>,
392 auto_managed: Option<bool>,
395}
396
397impl TorrentEntry {
398 async fn is_private(&self) -> bool {
409 match self.handle.get_meta().await {
410 Ok(Some(meta)) => meta.info.private == Some(1),
411 _ => false,
415 }
416 }
417}
418
419enum SessionCommand {
421 AddTorrent {
422 meta: Box<irontide_core::TorrentMeta>,
423 storage: Option<Arc<dyn TorrentStorage>>,
424 download_dir: Option<PathBuf>,
425 reply: oneshot::Sender<crate::Result<Id20>>,
426 },
427 CommitAddTorrent {
436 result: crate::Result<PreparedAddTorrent>,
437 reply: oneshot::Sender<crate::Result<Id20>>,
438 },
439 AddMagnet {
440 magnet: Magnet,
441 download_dir: Option<PathBuf>,
442 reply: oneshot::Sender<crate::Result<Id20>>,
443 },
444 RemoveTorrent {
445 info_hash: Id20,
446 reply: oneshot::Sender<crate::Result<()>>,
447 },
448 PauseTorrent {
449 info_hash: Id20,
450 reply: oneshot::Sender<crate::Result<()>>,
451 },
452 ResumeTorrent {
453 info_hash: Id20,
454 reply: oneshot::Sender<crate::Result<()>>,
455 },
456 ForceResumeTorrent {
457 info_hash: Id20,
458 reply: oneshot::Sender<crate::Result<()>>,
459 },
460 SetTorrentSeedRatio {
461 info_hash: Id20,
462 limit: Option<f64>,
463 reply: oneshot::Sender<crate::Result<()>>,
464 },
465 TorrentStats {
466 info_hash: Id20,
467 reply: oneshot::Sender<crate::Result<TorrentStats>>,
468 },
469 TorrentInfo {
470 info_hash: Id20,
471 reply: oneshot::Sender<crate::Result<TorrentInfo>>,
472 },
473 ListTorrents {
474 reply: oneshot::Sender<Vec<Id20>>,
475 },
476 SessionStats {
477 reply: oneshot::Sender<SessionStats>,
478 },
479 SaveTorrentResumeData {
480 info_hash: Id20,
481 reply: oneshot::Sender<crate::Result<irontide_core::FastResumeData>>,
482 },
483 SaveSessionState {
484 reply: oneshot::Sender<crate::Result<crate::persistence::SessionState>>,
485 },
486 LoadResumeState {
488 reply: oneshot::Sender<crate::Result<ResumeLoadResult>>,
489 },
490 QueuePosition {
491 info_hash: Id20,
492 reply: oneshot::Sender<crate::Result<i32>>,
493 },
494 SetQueuePosition {
495 info_hash: Id20,
496 pos: i32,
497 reply: oneshot::Sender<crate::Result<()>>,
498 },
499 SetAutoManaged {
501 info_hash: Id20,
502 enabled: bool,
503 reply: oneshot::Sender<crate::Result<()>>,
504 },
505 QueuePositionUp {
506 info_hash: Id20,
507 reply: oneshot::Sender<crate::Result<()>>,
508 },
509 QueuePositionDown {
510 info_hash: Id20,
511 reply: oneshot::Sender<crate::Result<()>>,
512 },
513 QueuePositionTop {
514 info_hash: Id20,
515 reply: oneshot::Sender<crate::Result<()>>,
516 },
517 QueuePositionBottom {
518 info_hash: Id20,
519 reply: oneshot::Sender<crate::Result<()>>,
520 },
521 BanPeer {
522 ip: IpAddr,
523 reply: oneshot::Sender<()>,
524 },
525 UnbanPeer {
526 ip: IpAddr,
527 reply: oneshot::Sender<bool>,
528 },
529 BannedPeers {
530 reply: oneshot::Sender<Vec<IpAddr>>,
531 },
532 SetIpFilter {
533 filter: crate::ip_filter::IpFilter,
534 reply: oneshot::Sender<()>,
535 },
536 GetIpFilter {
537 reply: oneshot::Sender<crate::ip_filter::IpFilter>,
538 },
539 GetSettings {
540 reply: oneshot::Sender<Settings>,
541 },
542 ApplySettings {
543 settings: Box<Settings>,
544 reply: oneshot::Sender<crate::Result<()>>,
545 },
546 MoveTorrentStorage {
547 info_hash: Id20,
548 new_path: std::path::PathBuf,
549 reply: oneshot::Sender<crate::Result<()>>,
550 },
551 AddPeers {
552 info_hash: Id20,
553 peers: Vec<SocketAddr>,
554 source: crate::peer_state::PeerSource,
555 reply: oneshot::Sender<crate::Result<()>>,
556 },
557 OpenFile {
558 info_hash: Id20,
559 file_index: usize,
560 reply: oneshot::Sender<crate::Result<crate::streaming::FileStream>>,
561 },
562 ForceReannounce {
563 info_hash: Id20,
564 reply: oneshot::Sender<crate::Result<()>>,
565 },
566 TrackerList {
567 info_hash: Id20,
568 reply: oneshot::Sender<crate::Result<Vec<crate::tracker_manager::TrackerInfo>>>,
569 },
570 GetPeerSourceCounts {
574 info_hash: Id20,
575 reply: oneshot::Sender<crate::Result<(usize, usize)>>,
576 },
577 QueryUnchokeDurations {
581 info_hash: Id20,
582 reply: oneshot::Sender<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>>,
583 },
584 GetWebSeedStats {
587 info_hash: Id20,
588 reply: oneshot::Sender<crate::Result<Vec<irontide_core::WebSeedStats>>>,
589 },
590 Scrape {
591 info_hash: Id20,
592 reply: oneshot::Sender<crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>>>,
593 },
594 SetFilePriority {
595 info_hash: Id20,
596 index: usize,
597 priority: irontide_core::FilePriority,
598 reply: oneshot::Sender<crate::Result<()>>,
599 },
600 FilePriorities {
601 info_hash: Id20,
602 reply: oneshot::Sender<crate::Result<Vec<irontide_core::FilePriority>>>,
603 },
604 SetDownloadLimit {
605 info_hash: Id20,
606 bytes_per_sec: u64,
607 reply: oneshot::Sender<crate::Result<()>>,
608 },
609 SetUploadLimit {
610 info_hash: Id20,
611 bytes_per_sec: u64,
612 reply: oneshot::Sender<crate::Result<()>>,
613 },
614 DownloadLimit {
615 info_hash: Id20,
616 reply: oneshot::Sender<crate::Result<u64>>,
617 },
618 UploadLimit {
619 info_hash: Id20,
620 reply: oneshot::Sender<crate::Result<u64>>,
621 },
622 SetSequentialDownload {
623 info_hash: Id20,
624 enabled: bool,
625 reply: oneshot::Sender<crate::Result<()>>,
626 },
627 IsSequentialDownload {
628 info_hash: Id20,
629 reply: oneshot::Sender<crate::Result<bool>>,
630 },
631 SetPrioritizeFirstLastPieces {
633 info_hash: Id20,
634 enabled: bool,
635 reply: oneshot::Sender<crate::Result<()>>,
636 },
637 IsPrioritizeFirstLastPieces {
639 info_hash: Id20,
640 reply: oneshot::Sender<crate::Result<bool>>,
641 },
642 SetSuperSeeding {
643 info_hash: Id20,
644 enabled: bool,
645 reply: oneshot::Sender<crate::Result<()>>,
646 },
647 IsSuperSeeding {
648 info_hash: Id20,
649 reply: oneshot::Sender<crate::Result<bool>>,
650 },
651 SetSeedMode {
653 info_hash: Id20,
654 enabled: bool,
655 reply: oneshot::Sender<crate::Result<()>>,
656 },
657 AddTracker {
658 info_hash: Id20,
659 url: String,
660 reply: oneshot::Sender<crate::Result<()>>,
661 },
662 ReplaceTrackers {
663 info_hash: Id20,
664 urls: Vec<String>,
665 reply: oneshot::Sender<crate::Result<()>>,
666 },
667 ForceRecheck {
669 info_hash: Id20,
670 reply: oneshot::Sender<crate::Result<()>>,
671 },
672 RenameFile {
674 info_hash: Id20,
675 file_index: usize,
676 new_name: String,
677 reply: oneshot::Sender<crate::Result<()>>,
678 },
679 SetMaxConnections {
681 info_hash: Id20,
682 limit: usize,
683 reply: oneshot::Sender<crate::Result<()>>,
684 },
685 MaxConnections {
687 info_hash: Id20,
688 reply: oneshot::Sender<crate::Result<usize>>,
689 },
690 SetMaxUploads {
692 info_hash: Id20,
693 limit: usize,
694 reply: oneshot::Sender<crate::Result<()>>,
695 },
696 MaxUploads {
698 info_hash: Id20,
699 reply: oneshot::Sender<crate::Result<usize>>,
700 },
701 GetPeerInfo {
703 info_hash: Id20,
704 reply: oneshot::Sender<crate::Result<Vec<crate::types::PeerInfo>>>,
705 },
706 GetDownloadQueue {
708 info_hash: Id20,
709 reply: oneshot::Sender<crate::Result<Vec<crate::types::PartialPieceInfo>>>,
710 },
711 HavePiece {
713 info_hash: Id20,
714 index: u32,
715 reply: oneshot::Sender<crate::Result<bool>>,
716 },
717 PieceAvailability {
719 info_hash: Id20,
720 reply: oneshot::Sender<crate::Result<Vec<u32>>>,
721 },
722 FileProgress {
724 info_hash: Id20,
725 reply: oneshot::Sender<crate::Result<Vec<u64>>>,
726 },
727 InfoHashesQuery {
729 info_hash: Id20,
730 reply: oneshot::Sender<crate::Result<irontide_core::InfoHashes>>,
731 },
732 TorrentFile {
734 info_hash: Id20,
735 reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV1>>>,
736 },
737 TorrentFileV2 {
739 info_hash: Id20,
740 reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV2>>>,
741 },
742 ForceDhtAnnounce {
744 info_hash: Id20,
745 reply: oneshot::Sender<crate::Result<()>>,
746 },
747 ForceLsdAnnounce {
749 info_hash: Id20,
750 reply: oneshot::Sender<crate::Result<()>>,
751 },
752 ReadPiece {
754 info_hash: Id20,
755 index: u32,
756 reply: oneshot::Sender<crate::Result<bytes::Bytes>>,
757 },
758 FlushCache {
760 info_hash: Id20,
761 reply: oneshot::Sender<crate::Result<()>>,
762 },
763 IsValid {
765 info_hash: Id20,
766 reply: oneshot::Sender<bool>,
767 },
768 ClearError {
770 info_hash: Id20,
771 reply: oneshot::Sender<crate::Result<()>>,
772 },
773 FileStatus {
775 info_hash: Id20,
776 reply: oneshot::Sender<crate::Result<Vec<crate::types::FileStatus>>>,
777 },
778 Flags {
780 info_hash: Id20,
781 reply: oneshot::Sender<crate::Result<crate::types::TorrentFlags>>,
782 },
783 SetFlags {
785 info_hash: Id20,
786 flags: crate::types::TorrentFlags,
787 reply: oneshot::Sender<crate::Result<()>>,
788 },
789 UnsetFlags {
791 info_hash: Id20,
792 flags: crate::types::TorrentFlags,
793 reply: oneshot::Sender<crate::Result<()>>,
794 },
795 ConnectPeer {
797 info_hash: Id20,
798 addr: SocketAddr,
799 reply: oneshot::Sender<crate::Result<()>>,
800 },
801 DhtPutImmutable {
802 value: Vec<u8>,
803 reply: oneshot::Sender<crate::Result<Id20>>,
804 },
805 DhtGetImmutable {
806 target: Id20,
807 reply: oneshot::Sender<crate::Result<Option<Vec<u8>>>>,
808 },
809 DhtPutMutable {
810 keypair_bytes: [u8; 32],
811 value: Vec<u8>,
812 seq: i64,
813 salt: Vec<u8>,
814 reply: oneshot::Sender<crate::Result<Id20>>,
815 },
816 #[allow(clippy::type_complexity)]
817 DhtGetMutable {
818 public_key: [u8; 32],
819 salt: Vec<u8>,
820 reply: oneshot::Sender<crate::Result<Option<(Vec<u8>, i64)>>>,
821 },
822 SaveResumeState {
824 reply: oneshot::Sender<crate::Result<usize>>,
825 },
826 PostSessionStats,
828 AddTorrentM170 {
831 params: Box<AddTorrentParams>,
832 reply: oneshot::Sender<crate::Result<Id20>>,
833 },
834 CreateCategory {
836 name: String,
837 save_path: PathBuf,
838 reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
839 },
840 EditCategory {
842 name: String,
843 save_path: PathBuf,
844 reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
845 },
846 RemoveCategories {
848 names: Vec<String>,
849 reply: oneshot::Sender<Vec<String>>,
850 },
851 ListCategories {
853 reply: oneshot::Sender<Vec<crate::category_manager::CategoryMetadata>>,
854 },
855 CreateTags {
858 names: Vec<String>,
859 reply: oneshot::Sender<Vec<Result<(), crate::tag_manager::TagError>>>,
860 },
861 DeleteTags {
864 names: Vec<String>,
865 reply: oneshot::Sender<Vec<String>>,
866 },
867 ListTags {
869 reply: oneshot::Sender<Vec<String>>,
870 },
871 AddTagsToTorrents {
877 info_hashes: Vec<Id20>,
878 tags: Vec<String>,
879 reply: oneshot::Sender<crate::Result<()>>,
880 },
881 RemoveTagsFromTorrents {
884 info_hashes: Vec<Id20>,
885 tags: Vec<String>,
886 reply: oneshot::Sender<crate::Result<()>>,
887 },
888 RemoveTorrentWithFiles {
891 info_hash: Id20,
892 reply: oneshot::Sender<crate::Result<()>>,
893 },
894 GetWebSeeds {
897 info_hash: Id20,
898 reply: oneshot::Sender<crate::Result<Vec<String>>>,
899 },
900 GetPieceStates {
903 info_hash: Id20,
904 reply: oneshot::Sender<crate::Result<Vec<u8>>>,
905 },
906 GetPieceHashes {
908 info_hash: Id20,
909 offset: u32,
910 limit: u32,
911 reply: oneshot::Sender<crate::Result<Vec<String>>>,
912 },
913 DhtNodeCount {
916 reply: oneshot::Sender<usize>,
917 },
918 DebugState {
924 reply: oneshot::Sender<crate::types::DebugState>,
925 },
926 #[cfg(feature = "test-util")]
927 TestInjectMetadata {
928 info_hash: Id20,
929 info_bytes: Vec<u8>,
930 reply: oneshot::Sender<crate::Result<()>>,
931 },
932 Shutdown,
933}
934
935impl SessionCommand {
936 fn name(&self) -> &'static str {
941 match self {
942 Self::AddTorrent { .. } => "AddTorrent",
943 Self::CommitAddTorrent { .. } => "CommitAddTorrent",
944 Self::AddMagnet { .. } => "AddMagnet",
945 Self::RemoveTorrent { .. } => "RemoveTorrent",
946 Self::PauseTorrent { .. } => "PauseTorrent",
947 Self::ResumeTorrent { .. } => "ResumeTorrent",
948 Self::ForceResumeTorrent { .. } => "ForceResumeTorrent",
949 Self::SetTorrentSeedRatio { .. } => "SetTorrentSeedRatio",
950 Self::TorrentStats { .. } => "TorrentStats",
951 Self::TorrentInfo { .. } => "TorrentInfo",
952 Self::ListTorrents { .. } => "ListTorrents",
953 Self::SessionStats { .. } => "SessionStats",
954 Self::SaveTorrentResumeData { .. } => "SaveTorrentResumeData",
955 Self::SaveSessionState { .. } => "SaveSessionState",
956 Self::LoadResumeState { .. } => "LoadResumeState",
957 Self::QueuePosition { .. } => "QueuePosition",
958 Self::SetQueuePosition { .. } => "SetQueuePosition",
959 Self::SetAutoManaged { .. } => "SetAutoManaged",
960 Self::QueuePositionUp { .. } => "QueuePositionUp",
961 Self::QueuePositionDown { .. } => "QueuePositionDown",
962 Self::QueuePositionTop { .. } => "QueuePositionTop",
963 Self::QueuePositionBottom { .. } => "QueuePositionBottom",
964 Self::BanPeer { .. } => "BanPeer",
965 Self::UnbanPeer { .. } => "UnbanPeer",
966 Self::BannedPeers { .. } => "BannedPeers",
967 Self::SetIpFilter { .. } => "SetIpFilter",
968 Self::GetIpFilter { .. } => "GetIpFilter",
969 Self::GetSettings { .. } => "GetSettings",
970 Self::ApplySettings { .. } => "ApplySettings",
971 Self::MoveTorrentStorage { .. } => "MoveTorrentStorage",
972 Self::AddPeers { .. } => "AddPeers",
973 Self::OpenFile { .. } => "OpenFile",
974 Self::ForceReannounce { .. } => "ForceReannounce",
975 Self::TrackerList { .. } => "TrackerList",
976 Self::GetPeerSourceCounts { .. } => "GetPeerSourceCounts",
977 Self::QueryUnchokeDurations { .. } => "QueryUnchokeDurations",
978 Self::GetWebSeedStats { .. } => "GetWebSeedStats",
979 Self::Scrape { .. } => "Scrape",
980 Self::SetFilePriority { .. } => "SetFilePriority",
981 Self::FilePriorities { .. } => "FilePriorities",
982 Self::SetDownloadLimit { .. } => "SetDownloadLimit",
983 Self::SetUploadLimit { .. } => "SetUploadLimit",
984 Self::DownloadLimit { .. } => "DownloadLimit",
985 Self::UploadLimit { .. } => "UploadLimit",
986 Self::SetSequentialDownload { .. } => "SetSequentialDownload",
987 Self::IsSequentialDownload { .. } => "IsSequentialDownload",
988 Self::SetPrioritizeFirstLastPieces { .. } => "SetPrioritizeFirstLastPieces",
989 Self::IsPrioritizeFirstLastPieces { .. } => "IsPrioritizeFirstLastPieces",
990 Self::SetSuperSeeding { .. } => "SetSuperSeeding",
991 Self::IsSuperSeeding { .. } => "IsSuperSeeding",
992 Self::SetSeedMode { .. } => "SetSeedMode",
993 Self::AddTracker { .. } => "AddTracker",
994 Self::ReplaceTrackers { .. } => "ReplaceTrackers",
995 Self::ForceRecheck { .. } => "ForceRecheck",
996 Self::RenameFile { .. } => "RenameFile",
997 Self::SetMaxConnections { .. } => "SetMaxConnections",
998 Self::MaxConnections { .. } => "MaxConnections",
999 Self::SetMaxUploads { .. } => "SetMaxUploads",
1000 Self::MaxUploads { .. } => "MaxUploads",
1001 Self::GetPeerInfo { .. } => "GetPeerInfo",
1002 Self::GetDownloadQueue { .. } => "GetDownloadQueue",
1003 Self::HavePiece { .. } => "HavePiece",
1004 Self::PieceAvailability { .. } => "PieceAvailability",
1005 Self::FileProgress { .. } => "FileProgress",
1006 Self::InfoHashesQuery { .. } => "InfoHashesQuery",
1007 Self::TorrentFile { .. } => "TorrentFile",
1008 Self::TorrentFileV2 { .. } => "TorrentFileV2",
1009 Self::ForceDhtAnnounce { .. } => "ForceDhtAnnounce",
1010 Self::ForceLsdAnnounce { .. } => "ForceLsdAnnounce",
1011 Self::ReadPiece { .. } => "ReadPiece",
1012 Self::FlushCache { .. } => "FlushCache",
1013 Self::IsValid { .. } => "IsValid",
1014 Self::ClearError { .. } => "ClearError",
1015 Self::FileStatus { .. } => "FileStatus",
1016 Self::Flags { .. } => "Flags",
1017 Self::SetFlags { .. } => "SetFlags",
1018 Self::UnsetFlags { .. } => "UnsetFlags",
1019 Self::ConnectPeer { .. } => "ConnectPeer",
1020 Self::DhtPutImmutable { .. } => "DhtPutImmutable",
1021 Self::DhtGetImmutable { .. } => "DhtGetImmutable",
1022 Self::DhtPutMutable { .. } => "DhtPutMutable",
1023 Self::DhtGetMutable { .. } => "DhtGetMutable",
1024 Self::SaveResumeState { .. } => "SaveResumeState",
1025 Self::PostSessionStats => "PostSessionStats",
1026 Self::AddTorrentM170 { .. } => "AddTorrentM170",
1027 Self::CreateCategory { .. } => "CreateCategory",
1028 Self::EditCategory { .. } => "EditCategory",
1029 Self::RemoveCategories { .. } => "RemoveCategories",
1030 Self::ListCategories { .. } => "ListCategories",
1031 Self::CreateTags { .. } => "CreateTags",
1032 Self::DeleteTags { .. } => "DeleteTags",
1033 Self::ListTags { .. } => "ListTags",
1034 Self::AddTagsToTorrents { .. } => "AddTagsToTorrents",
1035 Self::RemoveTagsFromTorrents { .. } => "RemoveTagsFromTorrents",
1036 Self::RemoveTorrentWithFiles { .. } => "RemoveTorrentWithFiles",
1037 Self::GetWebSeeds { .. } => "GetWebSeeds",
1038 Self::GetPieceStates { .. } => "GetPieceStates",
1039 Self::GetPieceHashes { .. } => "GetPieceHashes",
1040 Self::DhtNodeCount { .. } => "DhtNodeCount",
1041 Self::DebugState { .. } => "DebugState",
1042 #[cfg(feature = "test-util")]
1043 Self::TestInjectMetadata { .. } => "TestInjectMetadata",
1044 Self::Shutdown => "Shutdown",
1045 }
1046 }
1047}
1048
1049#[derive(Clone)]
1054struct SessionCmdSender(mpsc::Sender<(tokio::time::Instant, SessionCommand)>);
1055
1056impl SessionCmdSender {
1057 async fn send(
1058 &self,
1059 cmd: SessionCommand,
1060 ) -> Result<(), mpsc::error::SendError<SessionCommand>> {
1061 let sent_at = tokio::time::Instant::now();
1062 self.0
1063 .send((sent_at, cmd))
1064 .await
1065 .map_err(|e| mpsc::error::SendError(e.0.1))
1066 }
1067}
1068
1069#[derive(Debug, Clone, Default)]
1077pub struct AppliedSettings {
1078 pub immediate: Vec<&'static str>,
1080 pub restart_required: Vec<&'static str>,
1084}
1085
1086macro_rules! push_if {
1116 (immediate, immediate, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {
1117 if $o.$($acc)* != $n.$($acc)* { $v.push($wire); }
1118 };
1119 (restart, restart, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {
1120 if $o.$($acc)* != $n.$($acc)* { $v.push($wire); }
1121 };
1122 ($t:tt, $c:tt, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {};
1123}
1124
1125macro_rules! emit_immediate_top {
1128 ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1129 fn append_immediate_top(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1130 $( push_if!(immediate, $class, o, n, ($name), $wire, v); )*
1131 }
1132 };
1133}
1134irontide_settings::for_each_setting!(emit_immediate_top);
1135
1136macro_rules! emit_immediate_qbt {
1137 ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1138 fn append_immediate_qbt(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1139 $( push_if!(immediate, $class, o, n, (qbt_compat . $name), $wire, v); )*
1140 }
1141 };
1142}
1143irontide_settings::for_each_qbt_compat_setting!(emit_immediate_qbt);
1144
1145macro_rules! emit_restart_top {
1146 ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1147 fn append_restart_top(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1148 $( push_if!(restart, $class, o, n, ($name), $wire, v); )*
1149 }
1150 };
1151}
1152irontide_settings::for_each_setting!(emit_restart_top);
1153
1154macro_rules! emit_restart_qbt {
1155 ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1156 fn append_restart_qbt(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1157 $( push_if!(restart, $class, o, n, (qbt_compat . $name), $wire, v); )*
1158 }
1159 };
1160}
1161irontide_settings::for_each_qbt_compat_setting!(emit_restart_qbt);
1162
1163macro_rules! emit_restart_proxy {
1164 ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1165 fn append_restart_proxy(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1166 $( push_if!(restart, $class, o, n, (proxy . $name), $wire, v); )*
1167 }
1168 };
1169}
1170irontide_settings::for_each_proxy_setting!(emit_restart_proxy);
1171
1172fn classify_immediate(old: &Settings, new: &Settings) -> Vec<&'static str> {
1196 let mut v = Vec::new();
1197 append_immediate_top(old, new, &mut v);
1198 append_immediate_qbt(old, new, &mut v);
1199 v
1200}
1201
1202fn classify_restart_required(old: &Settings, new: &Settings) -> Vec<&'static str> {
1219 let mut v = Vec::new();
1220 append_restart_top(old, new, &mut v);
1221 append_restart_qbt(old, new, &mut v);
1222 append_restart_proxy(old, new, &mut v);
1223 v
1224}
1225
1226#[derive(Clone)]
1228pub struct SessionHandle {
1229 cmd_tx: SessionCmdSender,
1230 alert_tx: broadcast::Sender<Alert>,
1231 alert_mask: Arc<AtomicU32>,
1232 counters: Arc<crate::stats::SessionCounters>,
1233 #[allow(dead_code)]
1235 factory: Arc<crate::transport::NetworkFactory>,
1236 reconfig_in_flight: crate::apply::ReconfigInFlight,
1244 snapshot: Arc<arc_swap::ArcSwap<SessionSnapshot>>,
1251}
1252
1253impl SessionHandle {
1254 pub async fn start(settings: Settings) -> crate::Result<Self> {
1260 Self::start_with_plugins(settings, Arc::new(Vec::new())).await
1261 }
1262
1263 pub async fn start_with_backend(
1269 settings: Settings,
1270 backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1271 ) -> crate::Result<Self> {
1272 Self::start_with_plugins_and_backend(settings, Arc::new(Vec::new()), backend).await
1273 }
1274
1275 pub async fn start_with_plugins(
1281 settings: Settings,
1282 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1283 ) -> crate::Result<Self> {
1284 let disk_config = crate::disk::DiskConfig::from(&settings);
1285 let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1286 Self::start_with_plugins_and_backend(settings, plugins, backend).await
1287 }
1288
1289 pub async fn start_with_plugins_and_backend(
1296 settings: Settings,
1297 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1298 backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1299 ) -> crate::Result<Self> {
1300 Self::start_full(
1301 settings,
1302 plugins,
1303 backend,
1304 Arc::new(crate::transport::NetworkFactory::tokio()),
1305 )
1306 .await
1307 }
1308
1309 pub async fn start_with_transport(
1317 settings: Settings,
1318 factory: Arc<crate::transport::NetworkFactory>,
1319 ) -> crate::Result<Self> {
1320 let disk_config = crate::disk::DiskConfig::from(&settings);
1321 let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1322 Self::start_full(settings, Arc::new(Vec::new()), backend, factory).await
1323 }
1324
1325 pub async fn start_full(
1336 settings: Settings,
1337 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1338 backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1339 factory: Arc<crate::transport::NetworkFactory>,
1340 ) -> crate::Result<Self> {
1341 let mut settings = settings;
1342
1343 if settings.force_proxy {
1345 if settings.proxy.proxy_type == crate::proxy::ProxyType::None {
1346 return Err(crate::Error::Config(
1347 "force_proxy requires a proxy to be configured".into(),
1348 ));
1349 }
1350 settings.enable_upnp = false;
1351 settings.enable_natpmp = false;
1352 settings.enable_dht = false;
1353 settings.enable_lsd = false;
1354 }
1355
1356 if settings.anonymous_mode {
1358 settings.enable_dht = false;
1359 settings.enable_lsd = false;
1360 settings.enable_upnp = false;
1361 settings.enable_natpmp = false;
1362 }
1363
1364 match irontide_settings::migrate_qbt_credentials(&mut settings.qbt_compat) {
1374 Ok(irontide_settings::QbtCredentialMigration::Upgraded) => {
1375 warn!(
1376 "qbt_compat: legacy plaintext password migrated to argon2id in memory — \
1377 persist via `irontide_config::migrate_qbt_credentials_in_file` or the \
1378 next config-touching CLI command to remove the plaintext from disk"
1379 );
1380 }
1381 Ok(irontide_settings::QbtCredentialMigration::NoOp) => {}
1382 Err(e) => {
1383 warn!(
1384 error = %e,
1385 "qbt_compat: in-memory password migration failed — continuing with \
1386 legacy plaintext; retry on next daemon start"
1387 );
1388 }
1389 }
1390
1391 let (raw_cmd_tx, cmd_rx) = mpsc::channel::<(tokio::time::Instant, SessionCommand)>(256);
1392 let cmd_tx = SessionCmdSender(raw_cmd_tx);
1393
1394 let (alert_tx, _) = broadcast::channel(settings.alert_channel_size);
1396 let alert_mask = Arc::new(AtomicU32::new(settings.alert_mask.bits()));
1397
1398 let (notification_settings_tx, notification_settings_rx) =
1408 tokio::sync::watch::channel(settings.clone());
1409 let (notification_shutdown_tx, notification_shutdown_rx) = oneshot::channel::<()>();
1410 let _notification_dispatcher_handle = crate::notification::spawn_notification_dispatcher(
1411 crate::notification::DispatcherOptions {
1412 sink: Box::new(crate::notification::LibNotifySink::new()),
1413 settings_rx: notification_settings_rx,
1414 alerts_rx: alert_tx.subscribe(),
1415 shutdown_rx: notification_shutdown_rx,
1416 },
1417 );
1418
1419 let watched_folder_changed = Arc::new(tokio::sync::Notify::new());
1425 let (watched_folder_shutdown_tx, watched_folder_shutdown_rx) = oneshot::channel::<()>();
1426 let watched_folder_settings_rx = notification_settings_tx.subscribe();
1430
1431 let (lsd, lsd_peers_rx) = if settings.enable_lsd {
1432 match crate::lsd::LsdHandle::start(settings.listen_port, settings.enable_ipv6).await {
1433 Ok((handle, rx)) => (Some(handle), Some(rx)),
1434 Err(e) => {
1435 warn!("LSD unavailable (port 6771): {e}");
1436 (None, None)
1437 }
1438 }
1439 } else {
1440 (None, None)
1441 };
1442
1443 let global_upload_bucket = Arc::new(parking_lot::Mutex::new(
1444 crate::rate_limiter::TokenBucket::new(settings.upload_rate_limit),
1445 ));
1446 let global_download_bucket = Arc::new(parking_lot::Mutex::new(
1447 crate::rate_limiter::TokenBucket::new(settings.download_rate_limit),
1448 ));
1449
1450 let ip_filter: SharedIpFilter =
1458 Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
1459 let max_connections_global = Arc::new(std::sync::atomic::AtomicI32::new(
1460 settings.max_connections_global,
1461 ));
1462 let live_connections = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1463
1464 let utp_admit = {
1465 let ip_filter_for_utp = Arc::clone(&ip_filter);
1466 irontide_utp::AdmitGate::new(
1467 Arc::clone(&max_connections_global),
1468 Arc::clone(&live_connections),
1469 Arc::new(move |addr| ip_filter_for_utp.read().is_blocked(addr)),
1470 )
1471 };
1472
1473 let (utp_socket, utp_listener) = if settings.enable_utp {
1482 let utp_config = settings.to_utp_config(settings.listen_port);
1483 let bind_addr = utp_config.bind_addr;
1484 let result = if factory.has_bind_udp() {
1485 match factory.bind_udp(bind_addr).await {
1486 Ok(transport) => irontide_utp::UtpSocket::bind_with_transport_and_admit_gate(
1487 transport,
1488 utp_config,
1489 utp_admit.clone(),
1490 ),
1491 Err(e) => Err(irontide_utp::Error::Io(e)),
1492 }
1493 } else {
1494 irontide_utp::UtpSocket::bind_with_admit_gate(utp_config, utp_admit.clone()).await
1495 };
1496 match result {
1497 Ok((socket, listener)) => (Some(socket), Some(listener)),
1498 Err(e) => {
1499 warn!("uTP bind failed: {e}");
1500 (None, None)
1501 }
1502 }
1503 } else {
1504 (None, None)
1505 };
1506
1507 let (utp_socket_v6, utp_listener_v6) =
1510 if settings.enable_utp && settings.enable_ipv6 && !factory.has_bind_udp() {
1511 match irontide_utp::UtpSocket::bind_with_admit_gate(
1512 settings.to_utp_config_v6(settings.listen_port),
1513 utp_admit.clone(),
1514 )
1515 .await
1516 {
1517 Ok((socket, listener)) => (Some(socket), Some(listener)),
1518 Err(e) => {
1519 debug!("uTP IPv6 bind failed (non-fatal): {e}");
1520 (None, None)
1521 }
1522 }
1523 } else {
1524 (None, None)
1525 };
1526
1527 let (nat, nat_events_rx) = if settings.enable_upnp || settings.enable_natpmp {
1529 let nat_config = settings.to_nat_config();
1530 let (handle, events_rx) = irontide_nat::NatHandle::start(nat_config);
1531 let udp_port = if settings.enable_utp {
1532 Some(settings.listen_port)
1533 } else {
1534 None
1535 };
1536 handle.map_ports(settings.listen_port, udp_port).await;
1537 (Some(handle), Some(events_rx))
1538 } else {
1539 (None, None)
1540 };
1541
1542 let sam_session = if settings.enable_i2p {
1544 let tunnel_config = settings.to_sam_tunnel_config();
1545 match crate::i2p::SamSession::create(
1546 &settings.i2p_hostname,
1547 settings.i2p_port,
1548 "torrent",
1549 tunnel_config,
1550 )
1551 .await
1552 {
1553 Ok(session) => {
1554 let b32 = session.destination().to_b32_address();
1555 info!("I2P SAM session created: {}", b32);
1556 post_alert(
1557 &alert_tx,
1558 &alert_mask,
1559 AlertKind::I2pSessionCreated { b32_address: b32 },
1560 );
1561 Some(Arc::new(session))
1562 }
1563 Err(e) => {
1564 warn!("I2P SAM session failed: {e}");
1565 post_alert(
1566 &alert_tx,
1567 &alert_mask,
1568 AlertKind::I2pError {
1569 message: format!("SAM session creation failed: {e}"),
1570 },
1571 );
1572 None
1573 }
1574 }
1575 } else {
1576 None
1577 };
1578
1579 let ssl_manager = if settings.ssl_listen_port != 0 || settings.ssl_cert_path.is_some() {
1581 match crate::ssl_manager::SslManager::new(&settings) {
1582 Ok(mgr) => {
1583 info!("SSL manager initialized");
1584 Some(Arc::new(mgr))
1585 }
1586 Err(e) => {
1587 warn!(error = %e, "SSL manager initialization failed");
1588 None
1589 }
1590 }
1591 } else {
1592 None
1593 };
1594
1595 let tcp_listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
1597 .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.listen_port)))
1598 .await
1599 {
1600 Ok(l) => {
1601 info!(port = settings.listen_port, "TCP listener started");
1602 Some(l)
1603 }
1604 Err(e) => {
1605 warn!(port = settings.listen_port, error = %e, "TCP listener bind failed");
1606 None
1607 }
1608 };
1609
1610 let ssl_listener: Option<Box<dyn crate::transport::TransportListener>> = if settings
1612 .ssl_listen_port
1613 != 0
1614 {
1615 match factory
1616 .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.ssl_listen_port)))
1617 .await
1618 {
1619 Ok(l) => {
1620 info!(port = settings.ssl_listen_port, "SSL listener started");
1621 Some(l)
1622 }
1623 Err(e) => {
1624 warn!(port = settings.ssl_listen_port, error = %e, "SSL listener bind failed");
1625 None
1626 }
1627 }
1628 } else {
1629 None
1630 };
1631
1632 let (dht_v4, dht_v4_ip_rx) = if settings.enable_dht {
1634 match DhtHandle::start(settings.to_dht_config()).await {
1635 Ok((handle, ip_rx)) => {
1636 info!("DHT v4 started");
1637 (Some(handle), Some(ip_rx))
1638 }
1639 Err(e) => {
1640 warn!("DHT v4 start failed: {e}");
1641 (None, None)
1642 }
1643 }
1644 } else {
1645 (None, None)
1646 };
1647
1648 let (dht_v6, dht_v6_ip_rx) = if settings.enable_dht && settings.enable_ipv6 {
1649 match DhtHandle::start(settings.to_dht_config_v6()).await {
1650 Ok((handle, ip_rx)) => {
1651 info!("DHT v6 started");
1652 (Some(handle), Some(ip_rx))
1653 }
1654 Err(e) => {
1655 debug!("DHT v6 start failed (non-fatal): {e}");
1656 (None, None)
1657 }
1658 }
1659 } else {
1660 (None, None)
1661 };
1662
1663 let dht_v4_broadcast = irontide_dht::DhtBroadcast::new(dht_v4.clone());
1669 let dht_v6_broadcast = irontide_dht::DhtBroadcast::new(dht_v6.clone());
1670
1671 let ban_config = crate::ban::BanConfig::from(&settings);
1672 let ban_manager: SharedBanManager = Arc::new(parking_lot::RwLock::new(
1673 crate::ban::BanManager::new(ban_config),
1674 ));
1675
1676 let disk_config = crate::disk::DiskConfig::from(&settings);
1680 let spawner = crate::blocking_spawner::BlockingSpawner::new(settings.max_blocking_threads);
1681 let (disk_manager, disk_actor_handle) =
1682 crate::disk::DiskManagerHandle::new_with_backend(disk_config, backend, spawner);
1683
1684 let counters = Arc::new(crate::stats::SessionCounters::new_with_diagnostics(
1685 settings.enable_diagnostic_counters,
1686 ));
1687
1688 let hash_pool = std::sync::Arc::new(crate::hash_pool::HashPool::new(
1690 settings.hashing_threads,
1691 64,
1692 ));
1693
1694 let info_hash_registry = Arc::new(DashMap::new());
1696 let (validated_tx, validated_conn_rx) = mpsc::channel(64);
1697 let listener_task = crate::listener::ListenerTask::new(
1702 tcp_listener,
1703 utp_listener,
1704 utp_listener_v6,
1705 Arc::clone(&info_hash_registry),
1706 validated_tx,
1707 Arc::clone(&max_connections_global),
1708 Arc::clone(&live_connections),
1709 );
1710 let listener_handle = crate::listener::ListenerHandle::spawn(listener_task);
1719
1720 let external_ip = settings.external_ip;
1721
1722 let category_registry_path = crate::category_manager::resolve_category_registry_path(
1726 settings.category_registry_path.as_deref(),
1727 );
1728 let category_registry = Arc::new(parking_lot::RwLock::new(
1729 crate::category_manager::CategoryRegistry::load(category_registry_path),
1730 ));
1731 let tag_registry_path =
1734 crate::tag_manager::resolve_tag_registry_path(settings.tag_registry_path.as_deref());
1735 let tag_registry = Arc::new(parking_lot::RwLock::new(
1736 crate::tag_manager::TagRegistry::load(tag_registry_path),
1737 ));
1738 let deletion_grace = Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new()));
1739 let reconfig_in_flight = crate::apply::ReconfigInFlight::new();
1740 let snapshot = Arc::new(arc_swap::ArcSwap::from_pointee(SessionSnapshot::default()));
1745
1746 let actor = SessionActor {
1747 geoip: crate::geoip::build_geoip_resolver(&settings),
1750 settings,
1751 commit_tx: cmd_tx.clone(),
1755 torrents: HashMap::new(),
1756 snapshot: Arc::clone(&snapshot),
1757 dht_v4,
1758 dht_v6,
1759 dht_v4_broadcast,
1760 dht_v6_broadcast,
1761 lsd,
1762 lsd_peers_rx,
1763 cmd_rx,
1764 alert_tx: alert_tx.clone(),
1765 alert_mask: Arc::clone(&alert_mask),
1766 global_upload_bucket,
1767 global_download_bucket,
1768 utp_socket,
1769 utp_socket_v6,
1770 nat,
1771 nat_events_rx,
1772 ban_manager,
1773 ip_filter,
1774 disk_manager,
1775 disk_actor_handle,
1776 external_ip,
1777 external_tcp_port: None,
1778 incoming_peer_connections: 0,
1779 dht_v4_ip_rx,
1780 dht_v6_ip_rx,
1781 plugins,
1782 sam_session,
1783 ssl_manager,
1784 ssl_listener,
1785 validated_conn_rx,
1786 info_hash_registry,
1787 _listener_task: listener_handle,
1788 max_connections_global,
1789 live_connections,
1790 counters: Arc::clone(&counters),
1791 factory: Arc::clone(&factory),
1792 hash_pool,
1793 category_registry,
1794 tag_registry,
1795 deletion_grace,
1796 reconfig_in_flight: reconfig_in_flight.clone(),
1798 self_alert_rx: alert_tx.subscribe(),
1799 resume_save_notify: Arc::new(tokio::sync::Notify::new()),
1800 resume_save_lock: Arc::new(tokio::sync::Mutex::new(())),
1801 notification_settings_tx,
1802 notification_shutdown_tx,
1803 watched_folder_changed: Arc::clone(&watched_folder_changed),
1804 watched_folder_shutdown_tx,
1805 };
1806
1807 let join_handle = tokio::spawn(actor.run());
1808 tokio::spawn(async move {
1809 match join_handle.await {
1810 Ok(()) => {
1811 tracing::warn!("session actor exited cleanly");
1812 }
1813 Err(e) if e.is_panic() => {
1814 let panic_payload = e.into_panic();
1815 let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
1816 (*s).to_string()
1817 } else if let Some(s) = panic_payload.downcast_ref::<String>() {
1818 s.clone()
1819 } else {
1820 "unknown panic payload".to_string()
1821 };
1822 tracing::error!("session actor PANICKED: {msg}");
1823 }
1824 Err(e) => {
1825 tracing::error!("session actor task error: {e}");
1826 }
1827 }
1828 });
1829 let handle = Self {
1830 cmd_tx,
1831 alert_tx,
1832 alert_mask,
1833 counters,
1834 factory,
1835 reconfig_in_flight,
1841 snapshot,
1843 };
1844
1845 let _watched_folder_join = crate::watched_folder::spawn_watched_folder_dispatcher(
1852 handle.clone(),
1853 watched_folder_settings_rx,
1854 watched_folder_changed,
1855 watched_folder_shutdown_rx,
1856 );
1857
1858 Ok(handle)
1859 }
1860
1861 pub async fn add_torrent_with_meta(
1872 &self,
1873 meta: irontide_core::TorrentMeta,
1874 storage: Option<Arc<dyn TorrentStorage>>,
1875 ) -> crate::Result<Id20> {
1876 self.add_torrent_with_dir(meta, storage, None).await
1877 }
1878
1879 pub async fn add_torrent(&self, params: AddTorrentParams) -> crate::Result<Id20> {
1897 let (tx, rx) = oneshot::channel();
1898 self.cmd_tx
1899 .send(SessionCommand::AddTorrentM170 {
1900 params: Box::new(params),
1901 reply: tx,
1902 })
1903 .await
1904 .map_err(|_| crate::Error::Shutdown)?;
1905 rx.await.map_err(|_| crate::Error::Shutdown)?
1906 }
1907
1908 pub async fn create_category(
1915 &self,
1916 name: String,
1917 save_path: PathBuf,
1918 ) -> Result<(), crate::category_manager::CategoryError> {
1919 let (tx, rx) = oneshot::channel();
1920 if self
1921 .cmd_tx
1922 .send(SessionCommand::CreateCategory {
1923 name,
1924 save_path,
1925 reply: tx,
1926 })
1927 .await
1928 .is_err()
1929 {
1930 return Err(crate::category_manager::CategoryError::Persistence(
1931 std::io::Error::other("session shutting down"),
1932 ));
1933 }
1934 rx.await.unwrap_or_else(|_| {
1935 Err(crate::category_manager::CategoryError::Persistence(
1936 std::io::Error::other("session shutting down"),
1937 ))
1938 })
1939 }
1940
1941 pub async fn edit_category(
1949 &self,
1950 name: String,
1951 save_path: PathBuf,
1952 ) -> Result<(), crate::category_manager::CategoryError> {
1953 let (tx, rx) = oneshot::channel();
1954 if self
1955 .cmd_tx
1956 .send(SessionCommand::EditCategory {
1957 name,
1958 save_path,
1959 reply: tx,
1960 })
1961 .await
1962 .is_err()
1963 {
1964 return Err(crate::category_manager::CategoryError::Persistence(
1965 std::io::Error::other("session shutting down"),
1966 ));
1967 }
1968 rx.await.unwrap_or_else(|_| {
1969 Err(crate::category_manager::CategoryError::Persistence(
1970 std::io::Error::other("session shutting down"),
1971 ))
1972 })
1973 }
1974
1975 pub async fn remove_categories(&self, names: Vec<String>) -> Vec<String> {
1980 let (tx, rx) = oneshot::channel();
1981 if self
1982 .cmd_tx
1983 .send(SessionCommand::RemoveCategories { names, reply: tx })
1984 .await
1985 .is_err()
1986 {
1987 return Vec::new();
1988 }
1989 rx.await.unwrap_or_default()
1990 }
1991
1992 pub async fn list_categories(&self) -> Vec<crate::category_manager::CategoryMetadata> {
1994 let (tx, rx) = oneshot::channel();
1995 if self
1996 .cmd_tx
1997 .send(SessionCommand::ListCategories { reply: tx })
1998 .await
1999 .is_err()
2000 {
2001 return Vec::new();
2002 }
2003 rx.await.unwrap_or_default()
2004 }
2005
2006 pub async fn list_tags(&self) -> Vec<String> {
2008 let (tx, rx) = oneshot::channel();
2009 if self
2010 .cmd_tx
2011 .send(SessionCommand::ListTags { reply: tx })
2012 .await
2013 .is_err()
2014 {
2015 return Vec::new();
2016 }
2017 rx.await.unwrap_or_default()
2018 }
2019
2020 pub async fn create_tags(
2026 &self,
2027 names: Vec<String>,
2028 ) -> Vec<Result<(), crate::tag_manager::TagError>> {
2029 let (tx, rx) = oneshot::channel();
2030 if self
2031 .cmd_tx
2032 .send(SessionCommand::CreateTags { names, reply: tx })
2033 .await
2034 .is_err()
2035 {
2036 return Vec::new();
2037 }
2038 rx.await.unwrap_or_default()
2039 }
2040
2041 pub async fn delete_tags(&self, names: Vec<String>) -> Vec<String> {
2045 let (tx, rx) = oneshot::channel();
2046 if self
2047 .cmd_tx
2048 .send(SessionCommand::DeleteTags { names, reply: tx })
2049 .await
2050 .is_err()
2051 {
2052 return Vec::new();
2053 }
2054 rx.await.unwrap_or_default()
2055 }
2056
2057 pub async fn add_tags_to_torrents(
2066 &self,
2067 hashes: Vec<Id20>,
2068 tags: Vec<String>,
2069 ) -> crate::Result<()> {
2070 let (tx, rx) = oneshot::channel();
2071 self.cmd_tx
2072 .send(SessionCommand::AddTagsToTorrents {
2073 info_hashes: hashes,
2074 tags,
2075 reply: tx,
2076 })
2077 .await
2078 .map_err(|_| crate::Error::Shutdown)?;
2079 rx.await.map_err(|_| crate::Error::Shutdown)?
2080 }
2081
2082 pub async fn remove_tags_from_torrents(
2090 &self,
2091 hashes: Vec<Id20>,
2092 tags: Vec<String>,
2093 ) -> crate::Result<()> {
2094 let (tx, rx) = oneshot::channel();
2095 self.cmd_tx
2096 .send(SessionCommand::RemoveTagsFromTorrents {
2097 info_hashes: hashes,
2098 tags,
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 remove_torrent_with_files(&self, info_hash: Id20) -> crate::Result<()> {
2123 let (tx, rx) = oneshot::channel();
2124 self.cmd_tx
2125 .send(SessionCommand::RemoveTorrentWithFiles {
2126 info_hash,
2127 reply: tx,
2128 })
2129 .await
2130 .map_err(|_| crate::Error::Shutdown)?;
2131 rx.await.map_err(|_| crate::Error::Shutdown)?
2132 }
2133
2134 pub async fn add_torrent_with_dir(
2140 &self,
2141 meta: irontide_core::TorrentMeta,
2142 storage: Option<Arc<dyn TorrentStorage>>,
2143 download_dir: Option<PathBuf>,
2144 ) -> crate::Result<Id20> {
2145 let (tx, rx) = oneshot::channel();
2146 self.cmd_tx
2147 .send(SessionCommand::AddTorrent {
2148 meta: Box::new(meta),
2149 storage,
2150 download_dir,
2151 reply: tx,
2152 })
2153 .await
2154 .map_err(|_| crate::Error::Shutdown)?;
2155 rx.await.map_err(|_| crate::Error::Shutdown)?
2156 }
2157
2158 pub async fn add_magnet(&self, magnet: Magnet) -> crate::Result<Id20> {
2164 self.add_magnet_with_dir(magnet, None).await
2165 }
2166
2167 pub async fn add_magnet_with_dir(
2173 &self,
2174 magnet: Magnet,
2175 download_dir: Option<PathBuf>,
2176 ) -> crate::Result<Id20> {
2177 let (tx, rx) = oneshot::channel();
2178 self.cmd_tx
2179 .send(SessionCommand::AddMagnet {
2180 magnet,
2181 download_dir,
2182 reply: tx,
2183 })
2184 .await
2185 .map_err(|_| crate::Error::Shutdown)?;
2186 rx.await.map_err(|_| crate::Error::Shutdown)?
2187 }
2188
2189 pub async fn remove_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2195 let (tx, rx) = oneshot::channel();
2196 self.cmd_tx
2197 .send(SessionCommand::RemoveTorrent {
2198 info_hash,
2199 reply: tx,
2200 })
2201 .await
2202 .map_err(|_| crate::Error::Shutdown)?;
2203 rx.await.map_err(|_| crate::Error::Shutdown)?
2204 }
2205
2206 pub async fn pause_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2212 let (tx, rx) = oneshot::channel();
2213 self.cmd_tx
2214 .send(SessionCommand::PauseTorrent {
2215 info_hash,
2216 reply: tx,
2217 })
2218 .await
2219 .map_err(|_| crate::Error::Shutdown)?;
2220 rx.await.map_err(|_| crate::Error::Shutdown)?
2221 }
2222
2223 pub async fn resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2229 let (tx, rx) = oneshot::channel();
2230 self.cmd_tx
2231 .send(SessionCommand::ResumeTorrent {
2232 info_hash,
2233 reply: tx,
2234 })
2235 .await
2236 .map_err(|_| crate::Error::Shutdown)?;
2237 rx.await.map_err(|_| crate::Error::Shutdown)?
2238 }
2239
2240 pub async fn force_resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2246 let (tx, rx) = oneshot::channel();
2247 self.cmd_tx
2248 .send(SessionCommand::ForceResumeTorrent {
2249 info_hash,
2250 reply: tx,
2251 })
2252 .await
2253 .map_err(|_| crate::Error::Shutdown)?;
2254 rx.await.map_err(|_| crate::Error::Shutdown)?
2255 }
2256
2257 pub async fn set_torrent_seed_ratio(
2263 &self,
2264 info_hash: Id20,
2265 limit: Option<f64>,
2266 ) -> crate::Result<()> {
2267 let (tx, rx) = oneshot::channel();
2268 self.cmd_tx
2269 .send(SessionCommand::SetTorrentSeedRatio {
2270 info_hash,
2271 limit,
2272 reply: tx,
2273 })
2274 .await
2275 .map_err(|_| crate::Error::Shutdown)?;
2276 rx.await.map_err(|_| crate::Error::Shutdown)?
2277 }
2278
2279 pub async fn torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
2285 let (tx, rx) = oneshot::channel();
2286 self.cmd_tx
2287 .send(SessionCommand::TorrentStats {
2288 info_hash,
2289 reply: tx,
2290 })
2291 .await
2292 .map_err(|_| crate::Error::Shutdown)?;
2293 rx.await.map_err(|_| crate::Error::Shutdown)?
2294 }
2295
2296 pub async fn peer_unchoke_durations(
2312 &self,
2313 info_hash: Id20,
2314 ) -> crate::Result<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>> {
2315 let (tx, rx) = oneshot::channel();
2316 self.cmd_tx
2317 .send(SessionCommand::QueryUnchokeDurations {
2318 info_hash,
2319 reply: tx,
2320 })
2321 .await
2322 .map_err(|_| crate::Error::Shutdown)?;
2323 rx.await.map_err(|_| crate::Error::Shutdown)
2324 }
2325
2326 pub async fn torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
2332 let (tx, rx) = oneshot::channel();
2333 self.cmd_tx
2334 .send(SessionCommand::TorrentInfo {
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 list_torrents(&self) -> crate::Result<Vec<Id20>> {
2349 let (tx, rx) = oneshot::channel();
2350 self.cmd_tx
2351 .send(SessionCommand::ListTorrents { reply: tx })
2352 .await
2353 .map_err(|_| crate::Error::Shutdown)?;
2354 rx.await.map_err(|_| crate::Error::Shutdown)
2355 }
2356
2357 pub async fn session_stats(&self) -> crate::Result<SessionStats> {
2363 let (tx, rx) = oneshot::channel();
2364 self.cmd_tx
2365 .send(SessionCommand::SessionStats { reply: tx })
2366 .await
2367 .map_err(|_| crate::Error::Shutdown)?;
2368 rx.await.map_err(|_| crate::Error::Shutdown)
2369 }
2370
2371 pub async fn debug_state(&self) -> crate::Result<crate::types::DebugState> {
2381 let (tx, rx) = oneshot::channel();
2382 self.cmd_tx
2383 .send(SessionCommand::DebugState { reply: tx })
2384 .await
2385 .map_err(|_| crate::Error::Shutdown)?;
2386 tokio::time::timeout(std::time::Duration::from_secs(5), rx)
2389 .await
2390 .map_err(|_| crate::Error::Shutdown)?
2391 .map_err(|_| crate::Error::Shutdown)
2392 }
2393
2394 #[must_use]
2396 pub fn subscribe(&self) -> broadcast::Receiver<Alert> {
2397 self.alert_tx.subscribe()
2398 }
2399
2400 #[must_use]
2402 pub fn subscribe_filtered(&self, filter: AlertCategory) -> AlertStream {
2403 AlertStream::new(self.alert_tx.subscribe(), filter)
2404 }
2405
2406 pub async fn post_session_stats(&self) -> crate::Result<()> {
2412 self.cmd_tx
2413 .send(SessionCommand::PostSessionStats)
2414 .await
2415 .map_err(|_| crate::Error::Shutdown)
2416 }
2417
2418 #[must_use]
2420 pub fn counters(&self) -> &Arc<crate::stats::SessionCounters> {
2421 &self.counters
2422 }
2423
2424 pub fn set_alert_mask(&self, mask: AlertCategory) {
2426 self.alert_mask.store(mask.bits(), Ordering::Relaxed);
2427 }
2428
2429 #[must_use]
2431 pub fn alert_mask(&self) -> AlertCategory {
2432 AlertCategory::from_bits_truncate(self.alert_mask.load(Ordering::Relaxed))
2433 }
2434
2435 pub async fn add_peers(
2441 &self,
2442 info_hash: Id20,
2443 peers: Vec<SocketAddr>,
2444 source: crate::peer_state::PeerSource,
2445 ) -> crate::Result<()> {
2446 let (tx, rx) = oneshot::channel();
2447 self.cmd_tx
2448 .send(SessionCommand::AddPeers {
2449 info_hash,
2450 peers,
2451 source,
2452 reply: tx,
2453 })
2454 .await
2455 .map_err(|_| crate::Error::Shutdown)?;
2456 rx.await.map_err(|_| crate::Error::Shutdown)?
2457 }
2458
2459 pub async fn shutdown(&self) -> crate::Result<()> {
2465 let _ = tokio::time::timeout(
2467 std::time::Duration::from_secs(10),
2468 self.cmd_tx.send(SessionCommand::Shutdown),
2469 )
2470 .await;
2471 Ok(())
2472 }
2473
2474 pub async fn save_torrent_resume_data(
2480 &self,
2481 info_hash: Id20,
2482 ) -> crate::Result<irontide_core::FastResumeData> {
2483 let (tx, rx) = oneshot::channel();
2484 self.cmd_tx
2485 .send(SessionCommand::SaveTorrentResumeData {
2486 info_hash,
2487 reply: tx,
2488 })
2489 .await
2490 .map_err(|_| crate::Error::Shutdown)?;
2491 rx.await.map_err(|_| crate::Error::Shutdown)?
2492 }
2493
2494 pub async fn save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
2500 let (tx, rx) = oneshot::channel();
2501 self.cmd_tx
2502 .send(SessionCommand::SaveSessionState { reply: tx })
2503 .await
2504 .map_err(|_| crate::Error::Shutdown)?;
2505 rx.await.map_err(|_| crate::Error::Shutdown)?
2506 }
2507
2508 pub async fn load_resume_state(&self) -> crate::Result<ResumeLoadResult> {
2519 let (tx, rx) = oneshot::channel();
2520 self.cmd_tx
2521 .send(SessionCommand::LoadResumeState { reply: tx })
2522 .await
2523 .map_err(|_| crate::Error::Shutdown)?;
2524 rx.await.map_err(|_| crate::Error::Shutdown)?
2525 }
2526
2527 pub async fn save_resume_state(&self) -> crate::Result<usize> {
2537 let (tx, rx) = oneshot::channel();
2538 self.cmd_tx
2539 .send(SessionCommand::SaveResumeState { reply: tx })
2540 .await
2541 .map_err(|_| crate::Error::Shutdown)?;
2542 rx.await.map_err(|_| crate::Error::Shutdown)?
2543 }
2544
2545 pub async fn queue_position(&self, info_hash: Id20) -> crate::Result<i32> {
2551 let (tx, rx) = oneshot::channel();
2552 self.cmd_tx
2553 .send(SessionCommand::QueuePosition {
2554 info_hash,
2555 reply: tx,
2556 })
2557 .await
2558 .map_err(|_| crate::Error::Shutdown)?;
2559 rx.await.map_err(|_| crate::Error::Shutdown)?
2560 }
2561
2562 pub async fn set_queue_position(&self, info_hash: Id20, pos: i32) -> crate::Result<()> {
2568 let (tx, rx) = oneshot::channel();
2569 self.cmd_tx
2570 .send(SessionCommand::SetQueuePosition {
2571 info_hash,
2572 pos,
2573 reply: tx,
2574 })
2575 .await
2576 .map_err(|_| crate::Error::Shutdown)?;
2577 rx.await.map_err(|_| crate::Error::Shutdown)?
2578 }
2579
2580 pub async fn set_auto_managed(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
2588 let (tx, rx) = oneshot::channel();
2589 self.cmd_tx
2590 .send(SessionCommand::SetAutoManaged {
2591 info_hash,
2592 enabled,
2593 reply: tx,
2594 })
2595 .await
2596 .map_err(|_| crate::Error::Shutdown)?;
2597 rx.await.map_err(|_| crate::Error::Shutdown)?
2598 }
2599
2600 pub async fn queue_position_up(&self, info_hash: Id20) -> crate::Result<()> {
2606 let (tx, rx) = oneshot::channel();
2607 self.cmd_tx
2608 .send(SessionCommand::QueuePositionUp {
2609 info_hash,
2610 reply: tx,
2611 })
2612 .await
2613 .map_err(|_| crate::Error::Shutdown)?;
2614 rx.await.map_err(|_| crate::Error::Shutdown)?
2615 }
2616
2617 pub async fn queue_position_down(&self, info_hash: Id20) -> crate::Result<()> {
2623 let (tx, rx) = oneshot::channel();
2624 self.cmd_tx
2625 .send(SessionCommand::QueuePositionDown {
2626 info_hash,
2627 reply: tx,
2628 })
2629 .await
2630 .map_err(|_| crate::Error::Shutdown)?;
2631 rx.await.map_err(|_| crate::Error::Shutdown)?
2632 }
2633
2634 pub async fn queue_position_top(&self, info_hash: Id20) -> crate::Result<()> {
2640 let (tx, rx) = oneshot::channel();
2641 self.cmd_tx
2642 .send(SessionCommand::QueuePositionTop {
2643 info_hash,
2644 reply: tx,
2645 })
2646 .await
2647 .map_err(|_| crate::Error::Shutdown)?;
2648 rx.await.map_err(|_| crate::Error::Shutdown)?
2649 }
2650
2651 pub async fn queue_position_bottom(&self, info_hash: Id20) -> crate::Result<()> {
2657 let (tx, rx) = oneshot::channel();
2658 self.cmd_tx
2659 .send(SessionCommand::QueuePositionBottom {
2660 info_hash,
2661 reply: tx,
2662 })
2663 .await
2664 .map_err(|_| crate::Error::Shutdown)?;
2665 rx.await.map_err(|_| crate::Error::Shutdown)?
2666 }
2667
2668 pub async fn ban_peer(&self, ip: IpAddr) -> crate::Result<()> {
2674 let (tx, rx) = oneshot::channel();
2675 self.cmd_tx
2676 .send(SessionCommand::BanPeer { ip, reply: tx })
2677 .await
2678 .map_err(|_| crate::Error::Shutdown)?;
2679 rx.await.map_err(|_| crate::Error::Shutdown)
2680 }
2681
2682 pub async fn unban_peer(&self, ip: IpAddr) -> crate::Result<bool> {
2688 let (tx, rx) = oneshot::channel();
2689 self.cmd_tx
2690 .send(SessionCommand::UnbanPeer { ip, reply: tx })
2691 .await
2692 .map_err(|_| crate::Error::Shutdown)?;
2693 rx.await.map_err(|_| crate::Error::Shutdown)
2694 }
2695
2696 pub async fn set_ip_filter(&self, filter: crate::ip_filter::IpFilter) -> crate::Result<()> {
2703 let (tx, rx) = oneshot::channel();
2704 self.cmd_tx
2705 .send(SessionCommand::SetIpFilter { filter, reply: tx })
2706 .await
2707 .map_err(|_| crate::Error::Shutdown)?;
2708 rx.await.map_err(|_| crate::Error::Shutdown)
2709 }
2710
2711 pub async fn ip_filter(&self) -> crate::Result<crate::ip_filter::IpFilter> {
2717 let (tx, rx) = oneshot::channel();
2718 self.cmd_tx
2719 .send(SessionCommand::GetIpFilter { reply: tx })
2720 .await
2721 .map_err(|_| crate::Error::Shutdown)?;
2722 rx.await.map_err(|_| crate::Error::Shutdown)
2723 }
2724
2725 pub async fn settings(&self) -> crate::Result<Settings> {
2731 let (tx, rx) = oneshot::channel();
2732 self.cmd_tx
2733 .send(SessionCommand::GetSettings { reply: tx })
2734 .await
2735 .map_err(|_| crate::Error::Shutdown)?;
2736 rx.await.map_err(|_| crate::Error::Shutdown)
2737 }
2738
2739 pub async fn apply_settings(&self, settings: Settings) -> crate::Result<()> {
2753 let _guard = self
2754 .reconfig_in_flight
2755 .try_lock()
2756 .ok_or(crate::Error::ConcurrentReconfig)?;
2757 let (tx, rx) = oneshot::channel();
2758 self.cmd_tx
2759 .send(SessionCommand::ApplySettings {
2760 settings: Box::new(settings),
2761 reply: tx,
2762 })
2763 .await
2764 .map_err(|_| crate::Error::Shutdown)?;
2765 rx.await.map_err(|_| crate::Error::Shutdown)?
2766 }
2767
2768 pub async fn apply_settings_classified(
2788 &self,
2789 settings: Settings,
2790 ) -> crate::Result<AppliedSettings> {
2791 let _guard = self
2792 .reconfig_in_flight
2793 .try_lock()
2794 .ok_or(crate::Error::ConcurrentReconfig)?;
2795 let snapshot = self.settings().await?;
2798 let immediate = classify_immediate(&snapshot, &settings);
2799 let restart_required = classify_restart_required(&snapshot, &settings);
2800 let (tx, rx) = oneshot::channel();
2805 self.cmd_tx
2806 .send(SessionCommand::ApplySettings {
2807 settings: Box::new(settings),
2808 reply: tx,
2809 })
2810 .await
2811 .map_err(|_| crate::Error::Shutdown)?;
2812 rx.await.map_err(|_| crate::Error::Shutdown)??;
2813 Ok(AppliedSettings {
2814 immediate,
2815 restart_required,
2816 })
2817 }
2818
2819 pub async fn dht_node_count(&self) -> crate::Result<usize> {
2831 let (tx, rx) = oneshot::channel();
2832 self.cmd_tx
2833 .send(SessionCommand::DhtNodeCount { reply: tx })
2834 .await
2835 .map_err(|_| crate::Error::Shutdown)?;
2836 rx.await.map_err(|_| crate::Error::Shutdown)
2837 }
2838
2839 pub async fn banned_peers(&self) -> crate::Result<Vec<IpAddr>> {
2845 let (tx, rx) = oneshot::channel();
2846 self.cmd_tx
2847 .send(SessionCommand::BannedPeers { reply: tx })
2848 .await
2849 .map_err(|_| crate::Error::Shutdown)?;
2850 rx.await.map_err(|_| crate::Error::Shutdown)
2851 }
2852
2853 pub async fn move_torrent_storage(
2859 &self,
2860 info_hash: Id20,
2861 new_path: std::path::PathBuf,
2862 ) -> crate::Result<()> {
2863 let (tx, rx) = oneshot::channel();
2864 self.cmd_tx
2865 .send(SessionCommand::MoveTorrentStorage {
2866 info_hash,
2867 new_path,
2868 reply: tx,
2869 })
2870 .await
2871 .map_err(|_| crate::Error::Shutdown)?;
2872 rx.await.map_err(|_| crate::Error::Shutdown)?
2873 }
2874
2875 pub async fn open_file(
2885 &self,
2886 info_hash: Id20,
2887 file_index: usize,
2888 ) -> crate::Result<crate::streaming::FileStream> {
2889 let (tx, rx) = oneshot::channel();
2890 self.cmd_tx
2891 .send(SessionCommand::OpenFile {
2892 info_hash,
2893 file_index,
2894 reply: tx,
2895 })
2896 .await
2897 .map_err(|_| crate::Error::Shutdown)?;
2898 rx.await.map_err(|_| crate::Error::Shutdown)?
2899 }
2900
2901 pub async fn force_reannounce(&self, info_hash: Id20) -> crate::Result<()> {
2907 let (tx, rx) = oneshot::channel();
2908 self.cmd_tx
2909 .send(SessionCommand::ForceReannounce {
2910 info_hash,
2911 reply: tx,
2912 })
2913 .await
2914 .map_err(|_| crate::Error::Shutdown)?;
2915 rx.await.map_err(|_| crate::Error::Shutdown)?
2916 }
2917
2918 pub async fn tracker_list(
2924 &self,
2925 info_hash: Id20,
2926 ) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
2927 let (tx, rx) = oneshot::channel();
2928 self.cmd_tx
2929 .send(SessionCommand::TrackerList {
2930 info_hash,
2931 reply: tx,
2932 })
2933 .await
2934 .map_err(|_| crate::Error::Shutdown)?;
2935 rx.await.map_err(|_| crate::Error::Shutdown)?
2936 }
2937
2938 pub async fn pex_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2946 let counts = self.peer_source_counts(info_hash).await?;
2947 Ok(counts.0)
2948 }
2949
2950 pub async fn lsd_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2958 let counts = self.peer_source_counts(info_hash).await?;
2959 Ok(counts.1)
2960 }
2961
2962 async fn peer_source_counts(&self, info_hash: Id20) -> crate::Result<(usize, usize)> {
2963 let (tx, rx) = oneshot::channel();
2964 self.cmd_tx
2965 .send(SessionCommand::GetPeerSourceCounts {
2966 info_hash,
2967 reply: tx,
2968 })
2969 .await
2970 .map_err(|_| crate::Error::Shutdown)?;
2971 rx.await.map_err(|_| crate::Error::Shutdown)?
2972 }
2973
2974 pub async fn web_seed_stats(
2982 &self,
2983 info_hash: Id20,
2984 ) -> crate::Result<Vec<irontide_core::WebSeedStats>> {
2985 let (tx, rx) = oneshot::channel();
2986 self.cmd_tx
2987 .send(SessionCommand::GetWebSeedStats {
2988 info_hash,
2989 reply: tx,
2990 })
2991 .await
2992 .map_err(|_| crate::Error::Shutdown)?;
2993 rx.await.map_err(|_| crate::Error::Shutdown)?
2994 }
2995
2996 pub async fn get_web_seeds(&self, info_hash: Id20) -> crate::Result<Vec<String>> {
3009 let (tx, rx) = oneshot::channel();
3010 self.cmd_tx
3011 .send(SessionCommand::GetWebSeeds {
3012 info_hash,
3013 reply: tx,
3014 })
3015 .await
3016 .map_err(|_| crate::Error::Shutdown)?;
3017 rx.await.map_err(|_| crate::Error::Shutdown)?
3018 }
3019
3020 pub async fn get_piece_states(&self, info_hash: Id20) -> crate::Result<Vec<u8>> {
3033 let (tx, rx) = oneshot::channel();
3034 self.cmd_tx
3035 .send(SessionCommand::GetPieceStates {
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 get_piece_hashes(
3059 &self,
3060 info_hash: Id20,
3061 offset: u32,
3062 limit: u32,
3063 ) -> crate::Result<Vec<String>> {
3064 let (tx, rx) = oneshot::channel();
3065 self.cmd_tx
3066 .send(SessionCommand::GetPieceHashes {
3067 info_hash,
3068 offset,
3069 limit,
3070 reply: tx,
3071 })
3072 .await
3073 .map_err(|_| crate::Error::Shutdown)?;
3074 rx.await.map_err(|_| crate::Error::Shutdown)?
3075 }
3076
3077 pub async fn scrape(
3083 &self,
3084 info_hash: Id20,
3085 ) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
3086 let (tx, rx) = oneshot::channel();
3087 self.cmd_tx
3088 .send(SessionCommand::Scrape {
3089 info_hash,
3090 reply: tx,
3091 })
3092 .await
3093 .map_err(|_| crate::Error::Shutdown)?;
3094 rx.await.map_err(|_| crate::Error::Shutdown)?
3095 }
3096
3097 pub async fn set_file_priority(
3103 &self,
3104 info_hash: Id20,
3105 index: usize,
3106 priority: irontide_core::FilePriority,
3107 ) -> crate::Result<()> {
3108 let (tx, rx) = oneshot::channel();
3109 self.cmd_tx
3110 .send(SessionCommand::SetFilePriority {
3111 info_hash,
3112 index,
3113 priority,
3114 reply: tx,
3115 })
3116 .await
3117 .map_err(|_| crate::Error::Shutdown)?;
3118 rx.await.map_err(|_| crate::Error::Shutdown)?
3119 }
3120
3121 pub async fn file_priorities(
3127 &self,
3128 info_hash: Id20,
3129 ) -> crate::Result<Vec<irontide_core::FilePriority>> {
3130 let (tx, rx) = oneshot::channel();
3131 self.cmd_tx
3132 .send(SessionCommand::FilePriorities {
3133 info_hash,
3134 reply: tx,
3135 })
3136 .await
3137 .map_err(|_| crate::Error::Shutdown)?;
3138 rx.await.map_err(|_| crate::Error::Shutdown)?
3139 }
3140
3141 pub async fn set_download_limit(
3147 &self,
3148 info_hash: Id20,
3149 bytes_per_sec: u64,
3150 ) -> crate::Result<()> {
3151 let (tx, rx) = oneshot::channel();
3152 self.cmd_tx
3153 .send(SessionCommand::SetDownloadLimit {
3154 info_hash,
3155 bytes_per_sec,
3156 reply: tx,
3157 })
3158 .await
3159 .map_err(|_| crate::Error::Shutdown)?;
3160 rx.await.map_err(|_| crate::Error::Shutdown)?
3161 }
3162
3163 pub async fn set_upload_limit(&self, info_hash: Id20, bytes_per_sec: u64) -> crate::Result<()> {
3169 let (tx, rx) = oneshot::channel();
3170 self.cmd_tx
3171 .send(SessionCommand::SetUploadLimit {
3172 info_hash,
3173 bytes_per_sec,
3174 reply: tx,
3175 })
3176 .await
3177 .map_err(|_| crate::Error::Shutdown)?;
3178 rx.await.map_err(|_| crate::Error::Shutdown)?
3179 }
3180
3181 pub async fn download_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3187 let (tx, rx) = oneshot::channel();
3188 self.cmd_tx
3189 .send(SessionCommand::DownloadLimit {
3190 info_hash,
3191 reply: tx,
3192 })
3193 .await
3194 .map_err(|_| crate::Error::Shutdown)?;
3195 rx.await.map_err(|_| crate::Error::Shutdown)?
3196 }
3197
3198 pub async fn upload_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3204 let (tx, rx) = oneshot::channel();
3205 self.cmd_tx
3206 .send(SessionCommand::UploadLimit {
3207 info_hash,
3208 reply: tx,
3209 })
3210 .await
3211 .map_err(|_| crate::Error::Shutdown)?;
3212 rx.await.map_err(|_| crate::Error::Shutdown)?
3213 }
3214
3215 pub async fn set_sequential_download(
3221 &self,
3222 info_hash: Id20,
3223 enabled: bool,
3224 ) -> crate::Result<()> {
3225 let (tx, rx) = oneshot::channel();
3226 self.cmd_tx
3227 .send(SessionCommand::SetSequentialDownload {
3228 info_hash,
3229 enabled,
3230 reply: tx,
3231 })
3232 .await
3233 .map_err(|_| crate::Error::Shutdown)?;
3234 rx.await.map_err(|_| crate::Error::Shutdown)?
3235 }
3236
3237 pub async fn is_sequential_download(&self, info_hash: Id20) -> crate::Result<bool> {
3243 let (tx, rx) = oneshot::channel();
3244 self.cmd_tx
3245 .send(SessionCommand::IsSequentialDownload {
3246 info_hash,
3247 reply: tx,
3248 })
3249 .await
3250 .map_err(|_| crate::Error::Shutdown)?;
3251 rx.await.map_err(|_| crate::Error::Shutdown)?
3252 }
3253
3254 pub async fn set_prioritize_first_last_pieces(
3262 &self,
3263 info_hash: Id20,
3264 enabled: bool,
3265 ) -> crate::Result<()> {
3266 let (tx, rx) = oneshot::channel();
3267 self.cmd_tx
3268 .send(SessionCommand::SetPrioritizeFirstLastPieces {
3269 info_hash,
3270 enabled,
3271 reply: tx,
3272 })
3273 .await
3274 .map_err(|_| crate::Error::Shutdown)?;
3275 rx.await.map_err(|_| crate::Error::Shutdown)?
3276 }
3277
3278 pub async fn is_prioritize_first_last_pieces(&self, info_hash: Id20) -> crate::Result<bool> {
3284 let (tx, rx) = oneshot::channel();
3285 self.cmd_tx
3286 .send(SessionCommand::IsPrioritizeFirstLastPieces {
3287 info_hash,
3288 reply: tx,
3289 })
3290 .await
3291 .map_err(|_| crate::Error::Shutdown)?;
3292 rx.await.map_err(|_| crate::Error::Shutdown)?
3293 }
3294
3295 pub async fn set_super_seeding(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3301 let (tx, rx) = oneshot::channel();
3302 self.cmd_tx
3303 .send(SessionCommand::SetSuperSeeding {
3304 info_hash,
3305 enabled,
3306 reply: tx,
3307 })
3308 .await
3309 .map_err(|_| crate::Error::Shutdown)?;
3310 rx.await.map_err(|_| crate::Error::Shutdown)?
3311 }
3312
3313 pub async fn is_super_seeding(&self, info_hash: Id20) -> crate::Result<bool> {
3319 let (tx, rx) = oneshot::channel();
3320 self.cmd_tx
3321 .send(SessionCommand::IsSuperSeeding {
3322 info_hash,
3323 reply: tx,
3324 })
3325 .await
3326 .map_err(|_| crate::Error::Shutdown)?;
3327 rx.await.map_err(|_| crate::Error::Shutdown)?
3328 }
3329
3330 pub async fn set_seed_mode(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3346 let (tx, rx) = oneshot::channel();
3347 self.cmd_tx
3348 .send(SessionCommand::SetSeedMode {
3349 info_hash,
3350 enabled,
3351 reply: tx,
3352 })
3353 .await
3354 .map_err(|_| crate::Error::Shutdown)?;
3355 rx.await.map_err(|_| crate::Error::Shutdown)?
3356 }
3357
3358 pub async fn add_tracker(&self, info_hash: Id20, url: String) -> crate::Result<()> {
3366 let (tx, rx) = oneshot::channel();
3367 self.cmd_tx
3368 .send(SessionCommand::AddTracker {
3369 info_hash,
3370 url,
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 replace_trackers(&self, info_hash: Id20, urls: Vec<String>) -> crate::Result<()> {
3384 let (tx, rx) = oneshot::channel();
3385 self.cmd_tx
3386 .send(SessionCommand::ReplaceTrackers {
3387 info_hash,
3388 urls,
3389 reply: tx,
3390 })
3391 .await
3392 .map_err(|_| crate::Error::Shutdown)?;
3393 rx.await.map_err(|_| crate::Error::Shutdown)?
3394 }
3395
3396 pub async fn force_recheck(&self, info_hash: Id20) -> crate::Result<()> {
3406 let (tx, rx) = oneshot::channel();
3407 self.cmd_tx
3408 .send(SessionCommand::ForceRecheck {
3409 info_hash,
3410 reply: tx,
3411 })
3412 .await
3413 .map_err(|_| crate::Error::Shutdown)?;
3414 rx.await.map_err(|_| crate::Error::Shutdown)?
3415 }
3416
3417 pub async fn rename_file(
3427 &self,
3428 info_hash: Id20,
3429 file_index: usize,
3430 new_name: String,
3431 ) -> crate::Result<()> {
3432 let (tx, rx) = oneshot::channel();
3433 self.cmd_tx
3434 .send(SessionCommand::RenameFile {
3435 info_hash,
3436 file_index,
3437 new_name,
3438 reply: tx,
3439 })
3440 .await
3441 .map_err(|_| crate::Error::Shutdown)?;
3442 rx.await.map_err(|_| crate::Error::Shutdown)?
3443 }
3444
3445 pub async fn set_max_connections(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3451 let (tx, rx) = oneshot::channel();
3452 self.cmd_tx
3453 .send(SessionCommand::SetMaxConnections {
3454 info_hash,
3455 limit,
3456 reply: tx,
3457 })
3458 .await
3459 .map_err(|_| crate::Error::Shutdown)?;
3460 rx.await.map_err(|_| crate::Error::Shutdown)?
3461 }
3462
3463 pub async fn max_connections(&self, info_hash: Id20) -> crate::Result<usize> {
3469 let (tx, rx) = oneshot::channel();
3470 self.cmd_tx
3471 .send(SessionCommand::MaxConnections {
3472 info_hash,
3473 reply: tx,
3474 })
3475 .await
3476 .map_err(|_| crate::Error::Shutdown)?;
3477 rx.await.map_err(|_| crate::Error::Shutdown)?
3478 }
3479
3480 pub async fn set_max_uploads(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3486 let (tx, rx) = oneshot::channel();
3487 self.cmd_tx
3488 .send(SessionCommand::SetMaxUploads {
3489 info_hash,
3490 limit,
3491 reply: tx,
3492 })
3493 .await
3494 .map_err(|_| crate::Error::Shutdown)?;
3495 rx.await.map_err(|_| crate::Error::Shutdown)?
3496 }
3497
3498 pub async fn max_uploads(&self, info_hash: Id20) -> crate::Result<usize> {
3504 let (tx, rx) = oneshot::channel();
3505 self.cmd_tx
3506 .send(SessionCommand::MaxUploads {
3507 info_hash,
3508 reply: tx,
3509 })
3510 .await
3511 .map_err(|_| crate::Error::Shutdown)?;
3512 rx.await.map_err(|_| crate::Error::Shutdown)?
3513 }
3514
3515 pub async fn get_peer_info(
3521 &self,
3522 info_hash: Id20,
3523 ) -> crate::Result<Vec<crate::types::PeerInfo>> {
3524 let (tx, rx) = oneshot::channel();
3525 self.cmd_tx
3526 .send(SessionCommand::GetPeerInfo {
3527 info_hash,
3528 reply: tx,
3529 })
3530 .await
3531 .map_err(|_| crate::Error::Shutdown)?;
3532 rx.await.map_err(|_| crate::Error::Shutdown)?
3533 }
3534
3535 pub async fn get_download_queue(
3541 &self,
3542 info_hash: Id20,
3543 ) -> crate::Result<Vec<crate::types::PartialPieceInfo>> {
3544 let (tx, rx) = oneshot::channel();
3545 self.cmd_tx
3546 .send(SessionCommand::GetDownloadQueue {
3547 info_hash,
3548 reply: tx,
3549 })
3550 .await
3551 .map_err(|_| crate::Error::Shutdown)?;
3552 rx.await.map_err(|_| crate::Error::Shutdown)?
3553 }
3554
3555 pub async fn have_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bool> {
3561 let (tx, rx) = oneshot::channel();
3562 self.cmd_tx
3563 .send(SessionCommand::HavePiece {
3564 info_hash,
3565 index,
3566 reply: tx,
3567 })
3568 .await
3569 .map_err(|_| crate::Error::Shutdown)?;
3570 rx.await.map_err(|_| crate::Error::Shutdown)?
3571 }
3572
3573 pub async fn piece_availability(&self, info_hash: Id20) -> crate::Result<Vec<u32>> {
3579 let (tx, rx) = oneshot::channel();
3580 self.cmd_tx
3581 .send(SessionCommand::PieceAvailability {
3582 info_hash,
3583 reply: tx,
3584 })
3585 .await
3586 .map_err(|_| crate::Error::Shutdown)?;
3587 rx.await.map_err(|_| crate::Error::Shutdown)?
3588 }
3589
3590 pub async fn file_progress(&self, info_hash: Id20) -> crate::Result<Vec<u64>> {
3596 let (tx, rx) = oneshot::channel();
3597 self.cmd_tx
3598 .send(SessionCommand::FileProgress {
3599 info_hash,
3600 reply: tx,
3601 })
3602 .await
3603 .map_err(|_| crate::Error::Shutdown)?;
3604 rx.await.map_err(|_| crate::Error::Shutdown)?
3605 }
3606
3607 pub async fn info_hashes(&self, info_hash: Id20) -> crate::Result<irontide_core::InfoHashes> {
3613 let (tx, rx) = oneshot::channel();
3614 self.cmd_tx
3615 .send(SessionCommand::InfoHashesQuery {
3616 info_hash,
3617 reply: tx,
3618 })
3619 .await
3620 .map_err(|_| crate::Error::Shutdown)?;
3621 rx.await.map_err(|_| crate::Error::Shutdown)?
3622 }
3623
3624 pub async fn torrent_file(
3632 &self,
3633 info_hash: Id20,
3634 ) -> crate::Result<Option<irontide_core::TorrentMetaV1>> {
3635 let (tx, rx) = oneshot::channel();
3636 self.cmd_tx
3637 .send(SessionCommand::TorrentFile {
3638 info_hash,
3639 reply: tx,
3640 })
3641 .await
3642 .map_err(|_| crate::Error::Shutdown)?;
3643 rx.await.map_err(|_| crate::Error::Shutdown)?
3644 }
3645
3646 pub async fn torrent_file_v2(
3655 &self,
3656 info_hash: Id20,
3657 ) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
3658 let (tx, rx) = oneshot::channel();
3659 self.cmd_tx
3660 .send(SessionCommand::TorrentFileV2 {
3661 info_hash,
3662 reply: tx,
3663 })
3664 .await
3665 .map_err(|_| crate::Error::Shutdown)?;
3666 rx.await.map_err(|_| crate::Error::Shutdown)?
3667 }
3668
3669 #[cfg(feature = "test-util")]
3685 pub async fn debug_inject_metadata(
3686 &self,
3687 info_hash: Id20,
3688 info_bytes: Vec<u8>,
3689 ) -> crate::Result<()> {
3690 let (tx, rx) = oneshot::channel();
3691 self.cmd_tx
3692 .send(SessionCommand::TestInjectMetadata {
3693 info_hash,
3694 info_bytes,
3695 reply: tx,
3696 })
3697 .await
3698 .map_err(|_| crate::Error::Shutdown)?;
3699 rx.await.map_err(|_| crate::Error::Shutdown)?
3700 }
3701
3702 pub async fn force_dht_announce(&self, info_hash: Id20) -> crate::Result<()> {
3708 let (tx, rx) = oneshot::channel();
3709 self.cmd_tx
3710 .send(SessionCommand::ForceDhtAnnounce {
3711 info_hash,
3712 reply: tx,
3713 })
3714 .await
3715 .map_err(|_| crate::Error::Shutdown)?;
3716 rx.await.map_err(|_| crate::Error::Shutdown)?
3717 }
3718
3719 pub async fn force_lsd_announce(&self, info_hash: Id20) -> crate::Result<()> {
3727 let (tx, rx) = oneshot::channel();
3728 self.cmd_tx
3729 .send(SessionCommand::ForceLsdAnnounce {
3730 info_hash,
3731 reply: tx,
3732 })
3733 .await
3734 .map_err(|_| crate::Error::Shutdown)?;
3735 rx.await.map_err(|_| crate::Error::Shutdown)?
3736 }
3737
3738 pub async fn read_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bytes::Bytes> {
3744 let (tx, rx) = oneshot::channel();
3745 self.cmd_tx
3746 .send(SessionCommand::ReadPiece {
3747 info_hash,
3748 index,
3749 reply: tx,
3750 })
3751 .await
3752 .map_err(|_| crate::Error::Shutdown)?;
3753 rx.await.map_err(|_| crate::Error::Shutdown)?
3754 }
3755
3756 pub async fn flush_cache(&self, info_hash: Id20) -> crate::Result<()> {
3762 let (tx, rx) = oneshot::channel();
3763 self.cmd_tx
3764 .send(SessionCommand::FlushCache {
3765 info_hash,
3766 reply: tx,
3767 })
3768 .await
3769 .map_err(|_| crate::Error::Shutdown)?;
3770 rx.await.map_err(|_| crate::Error::Shutdown)?
3771 }
3772
3773 pub async fn is_valid(&self, info_hash: Id20) -> bool {
3775 let (tx, rx) = oneshot::channel();
3776 if self
3777 .cmd_tx
3778 .send(SessionCommand::IsValid {
3779 info_hash,
3780 reply: tx,
3781 })
3782 .await
3783 .is_err()
3784 {
3785 return false;
3786 }
3787 rx.await.unwrap_or(false)
3788 }
3789
3790 pub async fn clear_error(&self, info_hash: Id20) -> crate::Result<()> {
3796 let (tx, rx) = oneshot::channel();
3797 self.cmd_tx
3798 .send(SessionCommand::ClearError {
3799 info_hash,
3800 reply: tx,
3801 })
3802 .await
3803 .map_err(|_| crate::Error::Shutdown)?;
3804 rx.await.map_err(|_| crate::Error::Shutdown)?
3805 }
3806
3807 pub async fn file_status(
3813 &self,
3814 info_hash: Id20,
3815 ) -> crate::Result<Vec<crate::types::FileStatus>> {
3816 let (tx, rx) = oneshot::channel();
3817 self.cmd_tx
3818 .send(SessionCommand::FileStatus {
3819 info_hash,
3820 reply: tx,
3821 })
3822 .await
3823 .map_err(|_| crate::Error::Shutdown)?;
3824 rx.await.map_err(|_| crate::Error::Shutdown)?
3825 }
3826
3827 pub async fn flags(&self, info_hash: Id20) -> crate::Result<crate::types::TorrentFlags> {
3833 let (tx, rx) = oneshot::channel();
3834 self.cmd_tx
3835 .send(SessionCommand::Flags {
3836 info_hash,
3837 reply: tx,
3838 })
3839 .await
3840 .map_err(|_| crate::Error::Shutdown)?;
3841 rx.await.map_err(|_| crate::Error::Shutdown)?
3842 }
3843
3844 pub async fn set_flags(
3850 &self,
3851 info_hash: Id20,
3852 flags: crate::types::TorrentFlags,
3853 ) -> crate::Result<()> {
3854 let (tx, rx) = oneshot::channel();
3855 self.cmd_tx
3856 .send(SessionCommand::SetFlags {
3857 info_hash,
3858 flags,
3859 reply: tx,
3860 })
3861 .await
3862 .map_err(|_| crate::Error::Shutdown)?;
3863 rx.await.map_err(|_| crate::Error::Shutdown)?
3864 }
3865
3866 pub async fn unset_flags(
3872 &self,
3873 info_hash: Id20,
3874 flags: crate::types::TorrentFlags,
3875 ) -> crate::Result<()> {
3876 let (tx, rx) = oneshot::channel();
3877 self.cmd_tx
3878 .send(SessionCommand::UnsetFlags {
3879 info_hash,
3880 flags,
3881 reply: tx,
3882 })
3883 .await
3884 .map_err(|_| crate::Error::Shutdown)?;
3885 rx.await.map_err(|_| crate::Error::Shutdown)?
3886 }
3887
3888 pub async fn connect_peer(&self, info_hash: Id20, addr: SocketAddr) -> crate::Result<()> {
3894 let (tx, rx) = oneshot::channel();
3895 self.cmd_tx
3896 .send(SessionCommand::ConnectPeer {
3897 info_hash,
3898 addr,
3899 reply: tx,
3900 })
3901 .await
3902 .map_err(|_| crate::Error::Shutdown)?;
3903 rx.await.map_err(|_| crate::Error::Shutdown)?
3904 }
3905
3906 pub async fn dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
3914 let (tx, rx) = oneshot::channel();
3915 self.cmd_tx
3916 .send(SessionCommand::DhtPutImmutable { value, reply: tx })
3917 .await
3918 .map_err(|_| crate::Error::Shutdown)?;
3919 rx.await.map_err(|_| crate::Error::Shutdown)?
3920 }
3921
3922 pub async fn dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
3930 let (tx, rx) = oneshot::channel();
3931 self.cmd_tx
3932 .send(SessionCommand::DhtGetImmutable { target, reply: tx })
3933 .await
3934 .map_err(|_| crate::Error::Shutdown)?;
3935 rx.await.map_err(|_| crate::Error::Shutdown)?
3936 }
3937
3938 pub async fn dht_put_mutable(
3946 &self,
3947 keypair_bytes: [u8; 32],
3948 value: Vec<u8>,
3949 seq: i64,
3950 salt: Vec<u8>,
3951 ) -> crate::Result<Id20> {
3952 let (tx, rx) = oneshot::channel();
3953 self.cmd_tx
3954 .send(SessionCommand::DhtPutMutable {
3955 keypair_bytes,
3956 value,
3957 seq,
3958 salt,
3959 reply: tx,
3960 })
3961 .await
3962 .map_err(|_| crate::Error::Shutdown)?;
3963 rx.await.map_err(|_| crate::Error::Shutdown)?
3964 }
3965
3966 pub async fn dht_get_mutable(
3974 &self,
3975 public_key: [u8; 32],
3976 salt: Vec<u8>,
3977 ) -> crate::Result<Option<(Vec<u8>, i64)>> {
3978 let (tx, rx) = oneshot::channel();
3979 self.cmd_tx
3980 .send(SessionCommand::DhtGetMutable {
3981 public_key,
3982 salt,
3983 reply: tx,
3984 })
3985 .await
3986 .map_err(|_| crate::Error::Shutdown)?;
3987 rx.await.map_err(|_| crate::Error::Shutdown)?
3988 }
3989
3990 #[allow(
4006 clippy::unused_async,
4007 reason = "async + Result signature kept for facade API stability (M245 D5); the snapshot read is synchronous and infallible"
4008 )]
4009 pub async fn list_torrent_summaries(&self) -> crate::Result<Vec<TorrentSummary>> {
4010 Ok(self.snapshot.load().summaries())
4011 }
4012
4013 pub async fn add_magnet_uri(&self, uri: &str) -> crate::Result<irontide_core::InfoHashes> {
4022 let magnet = irontide_core::Magnet::parse(uri)?;
4023 let info_hashes = magnet.info_hashes.clone();
4024 self.add_magnet(magnet).await?;
4025 Ok(info_hashes)
4026 }
4027
4028 pub async fn add_torrent_bytes(
4037 &self,
4038 bytes: &[u8],
4039 ) -> crate::Result<irontide_core::InfoHashes> {
4040 let meta = irontide_core::torrent_from_bytes_any(bytes)?;
4041 let info_hashes = meta.info_hashes();
4042 self.add_torrent_with_meta(meta, None).await?;
4043 Ok(info_hashes)
4044 }
4045}
4046
4047#[derive(Debug, Default, Clone)]
4063pub struct SessionSnapshot {
4064 by_id: std::collections::BTreeMap<Id20, TorrentSummary>,
4065}
4066
4067impl SessionSnapshot {
4068 #[must_use]
4071 pub fn summaries(&self) -> Vec<TorrentSummary> {
4072 self.by_id.values().cloned().collect()
4073 }
4074
4075 pub(crate) fn as_map(&self) -> &std::collections::BTreeMap<Id20, TorrentSummary> {
4078 &self.by_id
4079 }
4080
4081 pub(crate) fn from_map(by_id: std::collections::BTreeMap<Id20, TorrentSummary>) -> Self {
4083 Self { by_id }
4084 }
4085}
4086
4087struct SessionActor {
4092 settings: Settings,
4093 commit_tx: SessionCmdSender,
4100 torrents: HashMap<Id20, TorrentEntry>,
4101 snapshot: Arc<arc_swap::ArcSwap<SessionSnapshot>>,
4107 dht_v4: Option<DhtHandle>,
4108 dht_v6: Option<DhtHandle>,
4109 dht_v4_broadcast: irontide_dht::DhtBroadcast,
4116 dht_v6_broadcast: irontide_dht::DhtBroadcast,
4117 lsd: Option<crate::lsd::LsdHandle>,
4118 lsd_peers_rx: Option<mpsc::Receiver<(Id20, SocketAddr)>>,
4119 cmd_rx: mpsc::Receiver<(tokio::time::Instant, SessionCommand)>,
4120 alert_tx: broadcast::Sender<Alert>,
4121 alert_mask: Arc<AtomicU32>,
4122 global_upload_bucket: SharedBucket,
4123 global_download_bucket: SharedBucket,
4124 utp_socket: Option<irontide_utp::UtpSocket>,
4125 utp_socket_v6: Option<irontide_utp::UtpSocket>,
4126 nat: Option<irontide_nat::NatHandle>,
4127 nat_events_rx: Option<mpsc::Receiver<irontide_nat::NatEvent>>,
4128 ban_manager: SharedBanManager,
4129 ip_filter: SharedIpFilter,
4130 disk_manager: crate::disk::DiskManagerHandle,
4131 #[allow(dead_code)]
4132 disk_actor_handle: tokio::task::JoinHandle<()>,
4133 external_ip: Option<std::net::IpAddr>,
4135 external_tcp_port: Option<u16>,
4140 incoming_peer_connections: u64,
4143 dht_v4_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
4145 dht_v6_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
4147 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
4149 sam_session: Option<Arc<crate::i2p::SamSession>>,
4151 ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
4153 ssl_listener: Option<Box<dyn crate::transport::TransportListener>>,
4155 validated_conn_rx: mpsc::Receiver<crate::listener::IdentifiedConnection>,
4157 info_hash_registry: Arc<DashMap<Id20, ()>>,
4162 #[allow(dead_code)] _listener_task: crate::listener::ListenerHandle,
4168 max_connections_global: Arc<std::sync::atomic::AtomicI32>,
4173 #[allow(dead_code)] live_connections: Arc<std::sync::atomic::AtomicUsize>,
4180 counters: Arc<crate::stats::SessionCounters>,
4182 factory: Arc<crate::transport::NetworkFactory>,
4184 hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
4186 category_registry: Arc<parking_lot::RwLock<crate::category_manager::CategoryRegistry>>,
4188 tag_registry: Arc<parking_lot::RwLock<crate::tag_manager::TagRegistry>>,
4190 deletion_grace: Arc<parking_lot::Mutex<std::collections::HashSet<Id20>>>,
4194 #[allow(dead_code)] reconfig_in_flight: crate::apply::ReconfigInFlight,
4200 self_alert_rx: broadcast::Receiver<Alert>,
4201 resume_save_notify: Arc<tokio::sync::Notify>,
4202 resume_save_lock: Arc<tokio::sync::Mutex<()>>,
4208 notification_settings_tx: tokio::sync::watch::Sender<Settings>,
4214 #[allow(dead_code)]
4223 notification_shutdown_tx: oneshot::Sender<()>,
4224 watched_folder_changed: Arc<tokio::sync::Notify>,
4232 #[allow(dead_code)]
4235 watched_folder_shutdown_tx: oneshot::Sender<()>,
4236 geoip: Option<crate::geoip::GeoIpResolver>,
4240}
4241
4242impl SessionActor {
4243 async fn get_entry_meta(&self, info_hash: Id20) -> crate::Result<irontide_core::TorrentMetaV1> {
4253 let entry = self
4254 .torrents
4255 .get(&info_hash)
4256 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
4257 entry
4258 .handle
4259 .get_meta()
4260 .await?
4261 .ok_or(crate::Error::MetadataNotReady(info_hash))
4262 }
4263
4264 async fn run(mut self) {
4265 let mut refill_interval = tokio::time::interval(std::time::Duration::from_millis(100));
4266 refill_interval.tick().await; let auto_manage_secs = self.settings.auto_manage_interval.max(1);
4269 let mut auto_manage_interval =
4270 tokio::time::interval(std::time::Duration::from_secs(auto_manage_secs));
4271 auto_manage_interval.tick().await; let stats_interval_ms = self.settings.stats_report_interval;
4275 let mut stats_timer = if stats_interval_ms > 0 {
4276 Some(tokio::time::interval(std::time::Duration::from_millis(
4277 stats_interval_ms,
4278 )))
4279 } else {
4280 None
4281 };
4282 if let Some(ref mut t) = stats_timer {
4283 t.tick().await; }
4285
4286 let sample_interval_secs = self.settings.dht_sample_infohashes_interval;
4288 let mut sample_timer = if sample_interval_secs > 0 {
4289 Some(tokio::time::interval(std::time::Duration::from_secs(
4290 sample_interval_secs,
4291 )))
4292 } else {
4293 None
4294 };
4295 if let Some(ref mut t) = sample_timer {
4296 t.tick().await; }
4298
4299 let mut resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
4301 Some(tokio::time::interval(std::time::Duration::from_secs(
4302 self.settings.save_resume_interval_secs,
4303 )))
4304 } else {
4305 None
4306 };
4307 if let Some(ref mut t) = resume_save_interval {
4308 t.tick().await; }
4310
4311 {
4313 let resume_dir = self.effective_resume_dir();
4314 let resume_files = crate::resume_file::scan_resume_dir(&resume_dir);
4315 if !resume_files.is_empty() {
4316 match self.handle_load_resume_state().await {
4318 Ok(result) => {
4319 info!(
4320 restored = result.restored,
4321 skipped = result.skipped,
4322 failed = result.failed,
4323 "auto-restored torrents on startup"
4324 );
4325 }
4326 Err(e) => {
4327 warn!("auto-restore on startup failed: {e}");
4328 }
4329 }
4330
4331 if self.settings.queueing_enabled {
4336 self.evaluate_queue().await;
4337 }
4338
4339 let active_hashes: std::collections::HashSet<String> = self
4342 .torrents
4343 .keys()
4344 .map(|h| hex::encode(h.as_bytes()))
4345 .collect();
4346
4347 let current_files = crate::resume_file::scan_resume_dir(&resume_dir);
4349 for path in ¤t_files {
4350 if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
4351 && !active_hashes.contains(stem)
4352 {
4353 if let Err(e) = std::fs::remove_file(path) {
4354 warn!(path = %path.display(), "failed to remove orphan resume file: {e}");
4355 } else {
4356 debug!(path = %path.display(), "removed orphan resume file");
4357 }
4358 }
4359 }
4360 }
4361 }
4362
4363 loop {
4364 tokio::select! {
4365 cmd = self.cmd_rx.recv() => {
4366 let recv_at = tokio::time::Instant::now();
4375 let queue_wait_ms = cmd.as_ref().map_or(0.0, |(sent_at, _)| {
4376 recv_at.saturating_duration_since(*sent_at).as_secs_f64() * 1000.0
4377 });
4378 let cmd_name = cmd.as_ref().map_or("<closed>", |(_, c)| c.name());
4379 let handler_start = tokio::time::Instant::now();
4380 let cmd = cmd.map(|(_sent_at, c)| c);
4381 match cmd {
4382 Some(SessionCommand::AddTorrent {
4383 meta,
4384 storage,
4385 download_dir,
4386 reply,
4387 }) => {
4388 let setup: crate::Result<AddTorrentPrepBundle> = (|| {
4396 let info_hash = meta.as_v1().map_or_else(
4397 || meta.info_hashes().best_v1(),
4398 |v| v.info_hash,
4399 );
4400 if self.torrents.contains_key(&info_hash) {
4401 return Err(crate::Error::DuplicateTorrent(info_hash));
4402 }
4403 if self.torrents.len() >= self.settings.max_torrents {
4404 return Err(crate::Error::SessionAtCapacity(
4405 self.settings.max_torrents,
4406 ));
4407 }
4408 Ok(self.build_add_torrent_prep_bundle(
4409 *meta,
4410 storage,
4411 download_dir,
4412 Vec::new(),
4413 &AddConfigOverrides::default(),
4414 None,
4415 ))
4416 })();
4417 match setup {
4418 Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
4419 Err(e) => {
4420 let _ = reply.send(Err(e));
4421 }
4422 }
4423 }
4424 Some(SessionCommand::CommitAddTorrent { result, reply }) => {
4425 let id = self.commit_add_torrent(result).await;
4432 let _ = reply.send(id);
4433 }
4434 Some(SessionCommand::AddMagnet { magnet, download_dir, reply }) => {
4435 let result = self
4438 .handle_add_magnet(
4439 magnet,
4440 download_dir,
4441 Vec::new(),
4442 AddConfigOverrides::default(),
4443 )
4444 .await;
4445 let _ = reply.send(result);
4446 }
4447 Some(SessionCommand::RemoveTorrent { info_hash, reply }) => {
4448 let result = self.handle_remove_torrent(info_hash).await;
4449 let _ = reply.send(result);
4450 }
4451 Some(SessionCommand::PauseTorrent { info_hash, reply }) => {
4452 let result = self.handle_pause_torrent(info_hash).await;
4453 let _ = reply.send(result);
4454 }
4455 Some(SessionCommand::ResumeTorrent { info_hash, reply }) => {
4456 let result = self.handle_resume_torrent(info_hash).await;
4457 let _ = reply.send(result);
4458 }
4459 Some(SessionCommand::ForceResumeTorrent { info_hash, reply }) => {
4460 let result = self.handle_force_resume_torrent(info_hash).await;
4461 let _ = reply.send(result);
4462 }
4463 Some(SessionCommand::SetTorrentSeedRatio { info_hash, limit, reply }) => {
4464 let result = self.handle_set_torrent_seed_ratio(info_hash, limit).await;
4465 let _ = reply.send(result);
4466 }
4467 Some(SessionCommand::TorrentStats { info_hash, reply }) => {
4468 let result = self.handle_torrent_stats(info_hash).await;
4469 let _ = reply.send(result);
4470 }
4471 Some(SessionCommand::TorrentInfo { info_hash, reply }) => {
4472 let result = self.handle_torrent_info(info_hash).await;
4476 let _ = reply.send(result);
4477 }
4478 Some(SessionCommand::ListTorrents { reply }) => {
4479 let list: Vec<Id20> = self.torrents.keys().copied().collect();
4480 let _ = reply.send(list);
4481 }
4482 Some(SessionCommand::SessionStats { reply }) => {
4483 let stats = self.make_session_stats().await;
4484 let _ = reply.send(stats);
4485 }
4486 Some(SessionCommand::SaveTorrentResumeData { info_hash, reply }) => {
4487 let result = self.handle_save_torrent_resume(info_hash).await;
4488 let _ = reply.send(result);
4489 }
4490 Some(SessionCommand::SaveSessionState { reply }) => {
4491 let result = self.handle_save_session_state().await;
4492 let _ = reply.send(result);
4493 }
4494 Some(SessionCommand::LoadResumeState { reply }) => {
4495 let result = self.handle_load_resume_state().await;
4496 let _ = reply.send(result);
4497 }
4498 Some(SessionCommand::QueuePosition { info_hash, reply }) => {
4499 let result = match self.torrents.get(&info_hash) {
4500 Some(entry) => Ok(entry.queue_position),
4501 None => Err(crate::Error::TorrentNotFound(info_hash)),
4502 };
4503 let _ = reply.send(result);
4504 }
4505 Some(SessionCommand::SetQueuePosition { info_hash, pos, reply }) => {
4506 let result = self.handle_set_queue_position(info_hash, pos);
4507 let _ = reply.send(result);
4508 }
4509 Some(SessionCommand::SetAutoManaged { info_hash, enabled, reply }) => {
4510 let _ = reply.send(self.set_auto_managed_inner(info_hash, enabled));
4511 }
4512 Some(SessionCommand::QueuePositionUp { info_hash, reply }) => {
4513 let result = self.handle_queue_move(info_hash, crate::queue::move_up);
4514 let _ = reply.send(result);
4515 }
4516 Some(SessionCommand::QueuePositionDown { info_hash, reply }) => {
4517 let result = self.handle_queue_move(info_hash, crate::queue::move_down);
4518 let _ = reply.send(result);
4519 }
4520 Some(SessionCommand::QueuePositionTop { info_hash, reply }) => {
4521 let result = self.handle_queue_move(info_hash, crate::queue::move_top);
4522 let _ = reply.send(result);
4523 }
4524 Some(SessionCommand::QueuePositionBottom { info_hash, reply }) => {
4525 let result = self.handle_queue_move(info_hash, crate::queue::move_bottom);
4526 let _ = reply.send(result);
4527 }
4528 Some(SessionCommand::BanPeer { ip, reply }) => {
4529 self.ban_manager.write().ban(ip);
4530 let _ = reply.send(());
4531 }
4532 Some(SessionCommand::UnbanPeer { ip, reply }) => {
4533 let was_banned = self.ban_manager.write().unban(&ip);
4534 let _ = reply.send(was_banned);
4535 }
4536 Some(SessionCommand::BannedPeers { reply }) => {
4537 let list: Vec<IpAddr> = self.ban_manager.read()
4538 .banned_list().iter().copied().collect();
4539 let _ = reply.send(list);
4540 }
4541 Some(SessionCommand::SetIpFilter { filter, reply }) => {
4542 *self.ip_filter.write() = filter;
4543 let _ = reply.send(());
4544 }
4545 Some(SessionCommand::GetIpFilter { reply }) => {
4546 let filter = self.ip_filter.read().clone();
4547 let _ = reply.send(filter);
4548 }
4549 Some(SessionCommand::GetSettings { reply }) => {
4550 let _ = reply.send(self.settings.clone());
4551 }
4552 Some(SessionCommand::ApplySettings { settings, reply }) => {
4553 let result = self.handle_apply_settings(*settings);
4554 let _ = reply.send(result);
4555 }
4556 Some(SessionCommand::DhtNodeCount { reply }) => {
4557 let mut total: usize = 0;
4562 if let Some(dht) = &self.dht_v4
4563 && let Ok(c) = dht.node_count().await
4564 {
4565 total += c;
4566 }
4567 if let Some(dht) = &self.dht_v6
4568 && let Ok(c) = dht.node_count().await
4569 {
4570 total += c;
4571 }
4572 let _ = reply.send(total);
4573 }
4574 Some(SessionCommand::MoveTorrentStorage { info_hash, new_path, reply }) => {
4575 let result = self.handle_move_torrent_storage(info_hash, new_path).await;
4576 let _ = reply.send(result);
4577 }
4578 Some(SessionCommand::AddPeers { info_hash, peers, source, reply }) => {
4579 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4580 entry.handle.add_peers(peers, source).await
4581 } else {
4582 Err(crate::Error::TorrentNotFound(info_hash))
4583 };
4584 let _ = reply.send(result);
4585 }
4586 Some(SessionCommand::OpenFile { info_hash, file_index, reply }) => {
4587 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4588 entry.handle.open_file(file_index).await
4589 } else {
4590 Err(crate::Error::TorrentNotFound(info_hash))
4591 };
4592 let _ = reply.send(result);
4593 }
4594 Some(SessionCommand::ForceReannounce { info_hash, reply }) => {
4595 let result = match self.torrents.get(&info_hash) {
4596 Some(entry) => {
4597 entry.handle.force_reannounce().await
4598 }
4599 None => Err(crate::Error::TorrentNotFound(info_hash)),
4600 };
4601 let _ = reply.send(result);
4602 }
4603 Some(SessionCommand::TrackerList { info_hash, reply }) => {
4604 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4605 entry.handle.tracker_list().await
4606 } else {
4607 Err(crate::Error::TorrentNotFound(info_hash))
4608 };
4609 let _ = reply.send(result);
4610 }
4611 Some(SessionCommand::GetPeerSourceCounts { info_hash, reply }) => {
4612 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4613 entry.handle.peer_source_counts().await
4614 } else {
4615 Err(crate::Error::TorrentNotFound(info_hash))
4616 };
4617 let _ = reply.send(result);
4618 }
4619 Some(SessionCommand::QueryUnchokeDurations { info_hash, reply }) => {
4620 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4621 entry.handle.query_unchoke_durations().await.ok()
4622 } else {
4623 None
4624 };
4625 let _ = reply.send(result);
4626 }
4627 Some(SessionCommand::GetWebSeedStats { info_hash, reply }) => {
4628 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4629 entry.handle.get_web_seed_stats().await
4630 } else {
4631 Err(crate::Error::TorrentNotFound(info_hash))
4632 };
4633 let _ = reply.send(result);
4634 }
4635 Some(SessionCommand::GetWebSeeds { info_hash, reply }) => {
4636 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4637 entry.handle.get_web_seeds().await
4638 } else {
4639 Err(crate::Error::TorrentNotFound(info_hash))
4640 };
4641 let _ = reply.send(result);
4642 }
4643 Some(SessionCommand::GetPieceStates { info_hash, reply }) => {
4644 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4645 entry.handle.get_piece_states().await
4646 } else {
4647 Err(crate::Error::TorrentNotFound(info_hash))
4648 };
4649 let _ = reply.send(result);
4650 }
4651 Some(SessionCommand::GetPieceHashes { info_hash, offset, limit, reply }) => {
4652 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4653 entry.handle.get_piece_hashes(offset, limit).await
4654 } else {
4655 Err(crate::Error::TorrentNotFound(info_hash))
4656 };
4657 let _ = reply.send(result);
4658 }
4659 Some(SessionCommand::Scrape { info_hash, reply }) => {
4660 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4661 entry.handle.scrape().await
4662 } else {
4663 Err(crate::Error::TorrentNotFound(info_hash))
4664 };
4665 let _ = reply.send(result);
4666 }
4667 Some(SessionCommand::SetFilePriority { info_hash, index, priority, reply }) => {
4668 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4669 entry.handle.set_file_priority(index, priority).await
4670 } else {
4671 Err(crate::Error::TorrentNotFound(info_hash))
4672 };
4673 let _ = reply.send(result);
4674 }
4675 Some(SessionCommand::FilePriorities { info_hash, reply }) => {
4676 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4677 entry.handle.file_priorities().await
4678 } else {
4679 Err(crate::Error::TorrentNotFound(info_hash))
4680 };
4681 let _ = reply.send(result);
4682 }
4683 Some(SessionCommand::SetDownloadLimit { info_hash, bytes_per_sec, reply }) => {
4684 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4685 entry.handle.set_download_limit(bytes_per_sec).await
4686 } else {
4687 Err(crate::Error::TorrentNotFound(info_hash))
4688 };
4689 let _ = reply.send(result);
4690 }
4691 Some(SessionCommand::SetUploadLimit { info_hash, bytes_per_sec, reply }) => {
4692 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4693 entry.handle.set_upload_limit(bytes_per_sec).await
4694 } else {
4695 Err(crate::Error::TorrentNotFound(info_hash))
4696 };
4697 let _ = reply.send(result);
4698 }
4699 Some(SessionCommand::DownloadLimit { info_hash, reply }) => {
4700 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4701 entry.handle.download_limit().await
4702 } else {
4703 Err(crate::Error::TorrentNotFound(info_hash))
4704 };
4705 let _ = reply.send(result);
4706 }
4707 Some(SessionCommand::UploadLimit { info_hash, reply }) => {
4708 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4709 entry.handle.upload_limit().await
4710 } else {
4711 Err(crate::Error::TorrentNotFound(info_hash))
4712 };
4713 let _ = reply.send(result);
4714 }
4715 Some(SessionCommand::SetSequentialDownload { info_hash, enabled, reply }) => {
4716 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4717 entry.handle.set_sequential_download(enabled).await
4718 } else {
4719 Err(crate::Error::TorrentNotFound(info_hash))
4720 };
4721 let _ = reply.send(result);
4722 }
4723 Some(SessionCommand::IsSequentialDownload { info_hash, reply }) => {
4724 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4725 entry.handle.is_sequential_download().await
4726 } else {
4727 Err(crate::Error::TorrentNotFound(info_hash))
4728 };
4729 let _ = reply.send(result);
4730 }
4731 Some(SessionCommand::SetPrioritizeFirstLastPieces { info_hash, enabled, reply }) => {
4732 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4733 entry.handle.set_prioritize_first_last_pieces(enabled).await
4734 } else {
4735 Err(crate::Error::TorrentNotFound(info_hash))
4736 };
4737 let _ = reply.send(result);
4738 }
4739 Some(SessionCommand::IsPrioritizeFirstLastPieces { info_hash, reply }) => {
4740 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4741 entry.handle.is_prioritize_first_last_pieces().await
4742 } else {
4743 Err(crate::Error::TorrentNotFound(info_hash))
4744 };
4745 let _ = reply.send(result);
4746 }
4747 Some(SessionCommand::SetSuperSeeding { info_hash, enabled, reply }) => {
4748 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4749 entry.handle.set_super_seeding(enabled).await
4750 } else {
4751 Err(crate::Error::TorrentNotFound(info_hash))
4752 };
4753 let _ = reply.send(result);
4754 }
4755 Some(SessionCommand::IsSuperSeeding { info_hash, reply }) => {
4756 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4757 entry.handle.is_super_seeding().await
4758 } else {
4759 Err(crate::Error::TorrentNotFound(info_hash))
4760 };
4761 let _ = reply.send(result);
4762 }
4763 Some(SessionCommand::SetSeedMode { info_hash, enabled, reply }) => {
4764 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4765 entry.handle.set_seed_mode(enabled).await
4766 } else {
4767 Err(crate::Error::TorrentNotFound(info_hash))
4768 };
4769 let _ = reply.send(result);
4770 }
4771 Some(SessionCommand::AddTracker { info_hash, url, reply }) => {
4772 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4773 entry.handle.add_tracker(url).await
4774 } else {
4775 Err(crate::Error::TorrentNotFound(info_hash))
4776 };
4777 let _ = reply.send(result);
4778 }
4779 Some(SessionCommand::ReplaceTrackers { info_hash, urls, reply }) => {
4780 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4781 entry.handle.replace_trackers(urls).await
4782 } else {
4783 Err(crate::Error::TorrentNotFound(info_hash))
4784 };
4785 let _ = reply.send(result);
4786 }
4787 Some(SessionCommand::ForceRecheck { info_hash, reply }) => {
4788 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4789 entry.handle.force_recheck().await
4790 } else {
4791 Err(crate::Error::TorrentNotFound(info_hash))
4792 };
4793 let _ = reply.send(result);
4794 }
4795 Some(SessionCommand::RenameFile { info_hash, file_index, new_name, reply }) => {
4796 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4797 entry.handle.rename_file(file_index, new_name).await
4798 } else {
4799 Err(crate::Error::TorrentNotFound(info_hash))
4800 };
4801 let _ = reply.send(result);
4802 }
4803 Some(SessionCommand::SetMaxConnections { info_hash, limit, reply }) => {
4804 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4805 entry.handle.set_max_connections(limit).await
4806 } else {
4807 Err(crate::Error::TorrentNotFound(info_hash))
4808 };
4809 let _ = reply.send(result);
4810 }
4811 Some(SessionCommand::MaxConnections { info_hash, reply }) => {
4812 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4813 entry.handle.max_connections().await
4814 } else {
4815 Err(crate::Error::TorrentNotFound(info_hash))
4816 };
4817 let _ = reply.send(result);
4818 }
4819 Some(SessionCommand::SetMaxUploads { info_hash, limit, reply }) => {
4820 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4821 entry.handle.set_max_uploads(limit).await
4822 } else {
4823 Err(crate::Error::TorrentNotFound(info_hash))
4824 };
4825 let _ = reply.send(result);
4826 }
4827 Some(SessionCommand::MaxUploads { info_hash, reply }) => {
4828 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4829 entry.handle.max_uploads().await
4830 } else {
4831 Err(crate::Error::TorrentNotFound(info_hash))
4832 };
4833 let _ = reply.send(result);
4834 }
4835 Some(SessionCommand::GetPeerInfo { info_hash, reply }) => {
4836 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4837 entry.handle.get_peer_info().await.map(|mut peers| {
4838 crate::geoip::stamp_country_codes(
4841 &mut peers,
4842 self.geoip.as_ref(),
4843 );
4844 peers
4845 })
4846 } else {
4847 Err(crate::Error::TorrentNotFound(info_hash))
4848 };
4849 let _ = reply.send(result);
4850 }
4851 Some(SessionCommand::GetDownloadQueue { info_hash, reply }) => {
4852 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4853 entry.handle.get_download_queue().await
4854 } else {
4855 Err(crate::Error::TorrentNotFound(info_hash))
4856 };
4857 let _ = reply.send(result);
4858 }
4859 Some(SessionCommand::HavePiece { info_hash, index, reply }) => {
4860 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4861 entry.handle.have_piece(index).await
4862 } else {
4863 Err(crate::Error::TorrentNotFound(info_hash))
4864 };
4865 let _ = reply.send(result);
4866 }
4867 Some(SessionCommand::PieceAvailability { info_hash, reply }) => {
4868 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4869 entry.handle.piece_availability().await
4870 } else {
4871 Err(crate::Error::TorrentNotFound(info_hash))
4872 };
4873 let _ = reply.send(result);
4874 }
4875 Some(SessionCommand::FileProgress { info_hash, reply }) => {
4876 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4877 entry.handle.file_progress().await
4878 } else {
4879 Err(crate::Error::TorrentNotFound(info_hash))
4880 };
4881 let _ = reply.send(result);
4882 }
4883 Some(SessionCommand::InfoHashesQuery { info_hash, reply }) => {
4884 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4885 entry.handle.info_hashes().await
4886 } else {
4887 Err(crate::Error::TorrentNotFound(info_hash))
4888 };
4889 let _ = reply.send(result);
4890 }
4891 Some(SessionCommand::TorrentFile { info_hash, reply }) => {
4892 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4893 entry.handle.torrent_file().await
4894 } else {
4895 Err(crate::Error::TorrentNotFound(info_hash))
4896 };
4897 let _ = reply.send(result);
4898 }
4899 Some(SessionCommand::TorrentFileV2 { info_hash, reply }) => {
4900 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4901 entry.handle.torrent_file_v2().await
4902 } else {
4903 Err(crate::Error::TorrentNotFound(info_hash))
4904 };
4905 let _ = reply.send(result);
4906 }
4907 #[cfg(feature = "test-util")]
4908 Some(SessionCommand::TestInjectMetadata {
4909 info_hash,
4910 info_bytes,
4911 reply,
4912 }) => {
4913 let result = match self.torrents.get(&info_hash) {
4914 Some(entry) => {
4915 entry.handle.test_inject_metadata(info_bytes).await
4916 }
4917 None => Err(crate::Error::TorrentNotFound(info_hash)),
4918 };
4919 let _ = reply.send(result);
4920 }
4921 Some(SessionCommand::ForceDhtAnnounce { info_hash, reply }) => {
4922 let result = match self.torrents.get(&info_hash) {
4927 Some(entry) => {
4928 if entry.is_private().await {
4929 Err(crate::Error::InvalidSettings(
4930 "DHT disabled for private torrent".into(),
4931 ))
4932 } else {
4933 entry.handle.force_dht_announce().await
4934 }
4935 }
4936 None => Err(crate::Error::TorrentNotFound(info_hash)),
4937 };
4938 let _ = reply.send(result);
4939 }
4940 Some(SessionCommand::ForceLsdAnnounce { info_hash, reply }) => {
4941 let result = match self.torrents.get(&info_hash) {
4946 Some(entry) => {
4947 if entry.is_private().await {
4948 Err(crate::Error::InvalidSettings(
4950 "LSD disabled for private torrent".into(),
4951 ))
4952 } else {
4953 if let Some(ref lsd) = self.lsd {
4954 lsd.announce(vec![info_hash]).await;
4955 }
4956 Ok(())
4957 }
4958 }
4959 None => Err(crate::Error::TorrentNotFound(info_hash)),
4960 };
4961 let _ = reply.send(result);
4962 }
4963 Some(SessionCommand::ReadPiece { info_hash, index, reply }) => {
4964 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4965 entry.handle.read_piece(index).await
4966 } else {
4967 Err(crate::Error::TorrentNotFound(info_hash))
4968 };
4969 let _ = reply.send(result);
4970 }
4971 Some(SessionCommand::FlushCache { info_hash, reply }) => {
4972 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4973 entry.handle.flush_cache().await
4974 } else {
4975 Err(crate::Error::TorrentNotFound(info_hash))
4976 };
4977 let _ = reply.send(result);
4978 }
4979 Some(SessionCommand::IsValid { info_hash, reply }) => {
4980 let valid = self.torrents.get(&info_hash)
4981 .is_some_and(|e| e.handle.is_valid());
4982 let _ = reply.send(valid);
4983 }
4984 Some(SessionCommand::ClearError { info_hash, reply }) => {
4985 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4986 entry.handle.clear_error().await
4987 } else {
4988 Err(crate::Error::TorrentNotFound(info_hash))
4989 };
4990 let _ = reply.send(result);
4991 }
4992 Some(SessionCommand::FileStatus { info_hash, reply }) => {
4993 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4994 entry.handle.file_status().await
4995 } else {
4996 Err(crate::Error::TorrentNotFound(info_hash))
4997 };
4998 let _ = reply.send(result);
4999 }
5000 Some(SessionCommand::Flags { info_hash, reply }) => {
5001 let result = if let Some(entry) = self.torrents.get(&info_hash) {
5002 entry.handle.flags().await
5003 } else {
5004 Err(crate::Error::TorrentNotFound(info_hash))
5005 };
5006 let _ = reply.send(result);
5007 }
5008 Some(SessionCommand::SetFlags { info_hash, flags, reply }) => {
5009 let mut result = Ok(());
5014 if flags.contains(crate::types::TorrentFlags::AUTO_MANAGED) {
5015 result = self.set_auto_managed_inner(info_hash, true);
5016 }
5017 let rest = flags - crate::types::TorrentFlags::AUTO_MANAGED;
5018 if result.is_ok() {
5019 result = if let Some(entry) = self.torrents.get(&info_hash) {
5020 entry.handle.set_flags(rest).await
5021 } else {
5022 Err(crate::Error::TorrentNotFound(info_hash))
5023 };
5024 }
5025 let _ = reply.send(result);
5026 }
5027 Some(SessionCommand::UnsetFlags { info_hash, flags, reply }) => {
5028 let mut result = Ok(());
5030 if flags.contains(crate::types::TorrentFlags::AUTO_MANAGED) {
5031 result = self.set_auto_managed_inner(info_hash, false);
5032 }
5033 let rest = flags - crate::types::TorrentFlags::AUTO_MANAGED;
5034 if result.is_ok() {
5035 result = if let Some(entry) = self.torrents.get(&info_hash) {
5036 entry.handle.unset_flags(rest).await
5037 } else {
5038 Err(crate::Error::TorrentNotFound(info_hash))
5039 };
5040 }
5041 let _ = reply.send(result);
5042 }
5043 Some(SessionCommand::ConnectPeer { info_hash, addr, reply }) => {
5044 let result = if let Some(entry) = self.torrents.get(&info_hash) {
5045 entry.handle.connect_peer(addr).await
5046 } else {
5047 Err(crate::Error::TorrentNotFound(info_hash))
5048 };
5049 let _ = reply.send(result);
5050 }
5051 Some(SessionCommand::DhtPutImmutable { value, reply }) => {
5052 let result = self.handle_dht_put_immutable(value).await;
5053 let _ = reply.send(result);
5054 }
5055 Some(SessionCommand::DhtGetImmutable { target, reply }) => {
5056 let result = self.handle_dht_get_immutable(target).await;
5057 let _ = reply.send(result);
5058 }
5059 Some(SessionCommand::DhtPutMutable { keypair_bytes, value, seq, salt, reply }) => {
5060 let result = self.handle_dht_put_mutable(keypair_bytes, value, seq, salt).await;
5061 let _ = reply.send(result);
5062 }
5063 Some(SessionCommand::DhtGetMutable { public_key, salt, reply }) => {
5064 let result = self.handle_dht_get_mutable(public_key, salt).await;
5065 let _ = reply.send(result);
5066 }
5067 Some(SessionCommand::PostSessionStats) => {
5068 self.fire_stats_alert();
5069 }
5070 Some(SessionCommand::SaveResumeState { reply }) => {
5071 let lock = Arc::clone(&self.resume_save_lock);
5080 let (resume_dir, jobs) = self.snapshot_resume_jobs();
5081 tokio::spawn(async move {
5082 let _guard = lock.lock_owned().await;
5083 let count = run_resume_save_jobs(resume_dir, jobs).await;
5084 let _ = reply.send(Ok(count));
5085 });
5086 }
5087 Some(SessionCommand::AddTorrentM170 { params, reply }) => {
5088 self.dispatch_add_torrent_m170(*params, reply).await;
5092 }
5093 Some(SessionCommand::CreateCategory { name, save_path, reply }) => {
5094 let result = self.handle_create_category(name, save_path).await;
5095 let _ = reply.send(result);
5096 }
5097 Some(SessionCommand::EditCategory { name, save_path, reply }) => {
5098 let result = self.handle_edit_category(name, save_path).await;
5099 let _ = reply.send(result);
5100 }
5101 Some(SessionCommand::RemoveCategories { names, reply }) => {
5102 let result = self.handle_remove_categories(names).await;
5103 let _ = reply.send(result);
5104 }
5105 Some(SessionCommand::ListCategories { reply }) => {
5106 let snapshot = self.category_registry.read().list();
5107 let _ = reply.send(snapshot);
5108 }
5109 Some(SessionCommand::CreateTags { names, reply }) => {
5110 let results: Vec<_> = {
5111 let mut reg = self.tag_registry.write();
5112 names.into_iter().map(|n| reg.create(n)).collect()
5113 };
5114 if let Err(e) = self.persist_tag_registry().await {
5118 tracing::warn!(
5119 error = %e,
5120 "failed to persist tag registry after CreateTags"
5121 );
5122 }
5123 let _ = reply.send(results);
5124 }
5125 Some(SessionCommand::DeleteTags { names, reply }) => {
5126 let removed = self.handle_delete_tags(names).await;
5127 let _ = reply.send(removed);
5128 }
5129 Some(SessionCommand::ListTags { reply }) => {
5130 let names = self.tag_registry.read().list();
5131 let _ = reply.send(names);
5132 }
5133 Some(SessionCommand::AddTagsToTorrents { info_hashes, tags, reply }) => {
5134 let res = self.handle_add_tags_to_torrents(info_hashes, tags).await;
5135 let _ = reply.send(res);
5136 }
5137 Some(SessionCommand::RemoveTagsFromTorrents { info_hashes, tags, reply }) => {
5138 let res = self
5139 .handle_remove_tags_from_torrents(info_hashes, tags)
5140 .await;
5141 let _ = reply.send(res);
5142 }
5143 Some(SessionCommand::RemoveTorrentWithFiles { info_hash, reply }) => {
5144 let result = self.handle_remove_torrent_with_files(info_hash).await;
5145 let _ = reply.send(result);
5146 }
5147 Some(SessionCommand::DebugState { reply }) => {
5148 let state = self.make_debug_state().await;
5149 let _ = reply.send(state);
5150 }
5151 Some(SessionCommand::Shutdown) | None => {
5152 self.shutdown_all().await;
5153 return;
5154 }
5155 }
5156 let handler_ms = handler_start.elapsed().as_secs_f64() * 1000.0;
5163 info!(
5164 target: "irontide_session::cmd_timing",
5165 cmd = cmd_name,
5166 queue_wait_ms = queue_wait_ms,
5167 handler_ms = handler_ms,
5168 "session_cmd"
5169 );
5170 }
5171 result = async {
5172 match &mut self.lsd_peers_rx {
5173 Some(rx) => rx.recv().await,
5174 None => std::future::pending().await,
5175 }
5176 } => {
5177 if let Some((info_hash, peer_addr)) = result
5178 && let Some(entry) = self.torrents.get(&info_hash)
5179 {
5180 let is_priv = entry.is_private().await;
5183 if !is_priv {
5184 let _ = entry.handle.add_peers(vec![peer_addr], crate::peer_state::PeerSource::Lsd).await;
5186 }
5187 }
5188 }
5189 Some(conn) = self.validated_conn_rx.recv() => {
5191 self.handle_identified_inbound(conn);
5192 }
5193 result = async {
5195 if let Some(ref mut listener) = self.ssl_listener {
5196 listener.accept().await
5197 } else {
5198 std::future::pending().await
5199 }
5200 } => {
5201 if let Ok((stream, addr)) = result {
5202 self.handle_ssl_incoming(stream, addr).await;
5203 }
5204 }
5205 _ = refill_interval.tick() => {
5207 let elapsed = std::time::Duration::from_millis(100);
5208 self.global_upload_bucket.lock().refill(elapsed);
5209 self.global_download_bucket.lock().refill(elapsed);
5210 }
5211 _ = auto_manage_interval.tick() => {
5213 self.evaluate_queue().await;
5214 }
5215 alert = self.self_alert_rx.recv() => {
5219 if let Ok(alert) = alert
5220 && matches!(
5221 alert.kind,
5222 AlertKind::StateChanged {
5223 prev_state: TorrentState::Checking,
5224 new_state,
5225 ..
5226 } if new_state != TorrentState::Checking
5227 )
5228 {
5229 self.evaluate_queue().await;
5230 }
5231 }
5232 event = recv_nat_event(&mut self.nat_events_rx) => {
5234 match event {
5235 irontide_nat::NatEvent::MappingSucceeded { port, protocol } => {
5236 info!(port, %protocol, "port mapping succeeded");
5237 if protocol == "TCP" {
5238 self.external_tcp_port = Some(port);
5241 }
5242 post_alert(
5243 &self.alert_tx,
5244 &self.alert_mask,
5245 AlertKind::PortMappingSucceeded { port, protocol },
5246 );
5247 }
5248 irontide_nat::NatEvent::MappingFailed { port, message } => {
5249 warn!(port, %message, "port mapping failed");
5250 post_alert(
5251 &self.alert_tx,
5252 &self.alert_mask,
5253 AlertKind::PortMappingFailed { port, message },
5254 );
5255 }
5256 irontide_nat::NatEvent::ExternalIpDiscovered { ip } => {
5257 info!(%ip, "external IP discovered via NAT traversal");
5258 self.external_ip = Some(ip);
5259 for entry in self.torrents.values() {
5261 let _ = entry.handle.update_external_ip(ip).await;
5262 }
5263 if let Some(dht) = &self.dht_v4 {
5265 let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
5266 }
5267 if let Some(dht) = &self.dht_v6 {
5268 let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
5269 }
5270 }
5271 }
5272 }
5273 Some(ip) = recv_dht_ip(&mut self.dht_v4_ip_rx) => {
5275 info!(%ip, "external IP discovered via DHT v4 (BEP 42)");
5276 self.external_ip = Some(ip);
5277 for entry in self.torrents.values() {
5278 let _ = entry.handle.update_external_ip(ip).await;
5279 }
5280 }
5281 Some(ip) = recv_dht_ip(&mut self.dht_v6_ip_rx) => {
5283 info!(%ip, "external IP discovered via DHT v6 (BEP 42)");
5284 self.external_ip = Some(ip);
5285 for entry in self.torrents.values() {
5286 let _ = entry.handle.update_external_ip(ip).await;
5287 }
5288 }
5289 _ = async {
5291 match &mut stats_timer {
5292 Some(t) => t.tick().await,
5293 None => std::future::pending().await,
5294 }
5295 } => {
5296 let _ = self.make_session_stats().await;
5309 self.fire_stats_alert();
5310 }
5311 _ = async {
5313 match &mut sample_timer {
5314 Some(t) => t.tick().await,
5315 None => std::future::pending().await,
5316 }
5317 } => {
5318 self.fire_sample_infohashes().await;
5319 }
5320 _ = async {
5322 match &mut resume_save_interval {
5323 Some(t) => t.tick().await,
5324 None => std::future::pending().await,
5325 }
5326 } => {
5327 match Arc::clone(&self.resume_save_lock).try_lock_owned() {
5331 Ok(guard) => {
5332 let (resume_dir, jobs) = self.snapshot_resume_jobs();
5333 tokio::spawn(async move {
5334 let _guard = guard;
5335 let count = run_resume_save_jobs(resume_dir, jobs).await;
5336 if count > 0 {
5337 info!(count, "periodic resume save completed");
5338 }
5339 });
5340 }
5341 Err(_) => {
5342 debug!("resume save already in flight — skipping this periodic tick");
5343 }
5344 }
5345 }
5346 () = self.resume_save_notify.notified() => {
5348 resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
5349 Some(tokio::time::interval(std::time::Duration::from_secs(
5350 self.settings.save_resume_interval_secs,
5351 )))
5352 } else {
5353 None
5354 };
5355 if let Some(ref mut t) = resume_save_interval {
5356 t.tick().await; }
5358 }
5359 }
5360 }
5361 }
5362
5363 fn global_buckets_if_limited(&self) -> (Option<SharedBucket>, Option<SharedBucket>) {
5365 let up = if self.settings.upload_rate_limit > 0 {
5366 Some(Arc::clone(&self.global_upload_bucket))
5367 } else {
5368 None
5369 };
5370 let down = if self.settings.download_rate_limit > 0 {
5371 Some(Arc::clone(&self.global_download_bucket))
5372 } else {
5373 None
5374 };
5375 (up, down)
5376 }
5377
5378 fn make_slot_tuner(&self) -> crate::slot_tuner::SlotTuner {
5379 if self.settings.auto_upload_slots {
5380 crate::slot_tuner::SlotTuner::new(
5381 4, self.settings.auto_upload_slots_min,
5383 self.settings.auto_upload_slots_max,
5384 )
5385 } else {
5386 crate::slot_tuner::SlotTuner::disabled(4)
5387 }
5388 }
5389
5390 fn make_torrent_config(&self) -> TorrentConfig {
5391 TorrentConfig::from(&self.settings)
5392 }
5393
5394 fn next_queue_position(&self) -> i32 {
5396 self.torrents
5397 .values()
5398 .filter(|e| e.auto_managed)
5399 .map(|e| e.queue_position)
5400 .max()
5401 .map_or(0, |m| m + 1)
5402 }
5403
5404 async fn handle_add_torrent(
5413 &mut self,
5414 torrent_meta: irontide_core::TorrentMeta,
5415 storage: Option<Arc<dyn TorrentStorage>>,
5416 download_dir: Option<PathBuf>,
5417 tags: Vec<String>,
5418 overrides: AddConfigOverrides,
5419 ) -> crate::Result<Id20> {
5420 let info_hash = torrent_meta
5421 .as_v1()
5422 .map_or_else(|| torrent_meta.info_hashes().best_v1(), |v| v.info_hash);
5423 if self.torrents.contains_key(&info_hash) {
5424 return Err(crate::Error::DuplicateTorrent(info_hash));
5425 }
5426 if self.torrents.len() >= self.settings.max_torrents {
5427 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5428 }
5429 let bundle = self.build_add_torrent_prep_bundle(
5430 torrent_meta,
5431 storage,
5432 download_dir,
5433 tags,
5434 &overrides,
5435 None,
5436 );
5437 let prep = prepare_add_torrent_off_actor(bundle).await;
5438 self.commit_add_torrent(prep).await
5439 }
5440
5441 fn build_add_torrent_prep_bundle(
5446 &self,
5447 torrent_meta: irontide_core::TorrentMeta,
5448 storage: Option<Arc<dyn TorrentStorage>>,
5449 download_dir: Option<PathBuf>,
5450 tags: Vec<String>,
5451 overrides: &AddConfigOverrides,
5452 m170_post: Option<M170PostAdd>,
5453 ) -> AddTorrentPrepBundle {
5454 let mut torrent_config = self.make_torrent_config();
5455 if let Some(dir) = download_dir {
5456 torrent_config.download_dir = dir;
5457 }
5458 torrent_config.tags = tags;
5463 overrides.bake_into(&mut torrent_config);
5466
5467 let (global_up, global_down) = self.global_buckets_if_limited();
5468 let slot_tuner = self.make_slot_tuner();
5469
5470 AddTorrentPrepBundle {
5471 torrent_meta,
5472 storage_override: storage,
5473 torrent_config,
5474 disk_manager: self.disk_manager.clone(),
5475 dht_v4_broadcast: self.dht_v4_broadcast.clone(),
5476 dht_v6_broadcast: self.dht_v6_broadcast.clone(),
5477 global_up,
5478 global_down,
5479 slot_tuner,
5480 alert_tx: self.alert_tx.clone(),
5481 alert_mask: Arc::clone(&self.alert_mask),
5482 utp_socket: self.utp_socket.clone(),
5483 utp_socket_v6: self.utp_socket_v6.clone(),
5484 ban_manager: Arc::clone(&self.ban_manager),
5485 ip_filter: Arc::clone(&self.ip_filter),
5486 plugins: Arc::clone(&self.plugins),
5487 sam_session: self.sam_session.clone(),
5488 ssl_manager: self.ssl_manager.clone(),
5489 factory: Arc::clone(&self.factory),
5490 hash_pool: Arc::clone(&self.hash_pool),
5491 counters: Arc::clone(&self.counters),
5492 m170_post,
5493 auto_managed: overrides.auto_managed,
5494 }
5495 }
5496
5497 async fn commit_add_torrent(
5503 &mut self,
5504 prep: crate::Result<PreparedAddTorrent>,
5505 ) -> crate::Result<Id20> {
5506 let PreparedAddTorrent {
5507 handle,
5508 info_hash,
5509 is_private,
5510 m170_post,
5511 auto_managed,
5512 } = prep?;
5513 if self.torrents.contains_key(&info_hash) {
5519 drop(handle);
5520 return Err(crate::Error::DuplicateTorrent(info_hash));
5521 }
5522 if self.torrents.len() >= self.settings.max_torrents {
5523 drop(handle);
5524 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5525 }
5526 self.torrents.insert(
5527 info_hash,
5528 TorrentEntry {
5529 handle,
5530 queue_position: -1,
5531 auto_managed: auto_managed.unwrap_or(true),
5534 started_at: Some(tokio::time::Instant::now()),
5535 smoothed_download_rate: f64::MAX,
5536 smoothed_upload_rate: f64::MAX,
5537 },
5538 );
5539 self.info_hash_registry.insert(info_hash, ());
5540
5541 let pos = self.next_queue_position();
5543 if let Some(entry) = self.torrents.get_mut(&info_hash)
5544 && entry.auto_managed
5545 {
5546 entry.queue_position = pos;
5547 }
5548
5549 info!(%info_hash, "torrent added to session");
5550 if let Some(ref lsd) = self.lsd
5557 && !is_private
5558 {
5559 lsd.announce(vec![info_hash]).await;
5560 }
5561 if let Some(M170PostAdd { category, paused }) = m170_post {
5565 self.apply_post_add_m170(info_hash, category, paused);
5566 }
5567 self.snapshot_publish_one(info_hash).await;
5572 Ok(info_hash)
5573 }
5574
5575 fn try_spawn_add_torrent(
5579 &self,
5580 bundle: AddTorrentPrepBundle,
5581 reply: oneshot::Sender<crate::Result<Id20>>,
5582 ) {
5583 let commit_tx = self.commit_tx.clone();
5584 tokio::spawn(async move {
5585 let result = prepare_add_torrent_off_actor(bundle).await;
5586 if commit_tx
5587 .send(SessionCommand::CommitAddTorrent { result, reply })
5588 .await
5589 .is_err()
5590 {
5591 warn!("M223 prep task: commit_tx send failed (session shutting down)");
5596 }
5597 });
5598 }
5599
5600 async fn handle_add_magnet(
5601 &mut self,
5602 magnet: Magnet,
5603 download_dir: Option<PathBuf>,
5604 tags: Vec<String>,
5605 overrides: AddConfigOverrides,
5606 ) -> crate::Result<Id20> {
5607 let info_hash = magnet.info_hash();
5608 let display_name = magnet.display_name.clone().unwrap_or_default();
5609 if self.torrents.contains_key(&info_hash) {
5610 return Err(crate::Error::DuplicateTorrent(info_hash));
5611 }
5612 if self.torrents.len() >= self.settings.max_torrents {
5613 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5614 }
5615 let mut config = self.make_torrent_config();
5616 if let Some(dir) = download_dir {
5617 config.download_dir = dir;
5618 }
5619 config.tags = tags;
5624 overrides.bake_into(&mut config);
5626 let (global_up, global_down) = self.global_buckets_if_limited();
5627 let slot_tuner = self.make_slot_tuner();
5628 let handle = TorrentHandle::from_magnet(
5629 magnet,
5630 self.disk_manager.clone(),
5631 config,
5632 self.dht_v4_broadcast.subscribe(),
5633 self.dht_v6_broadcast.subscribe(),
5634 global_up,
5635 global_down,
5636 slot_tuner,
5637 self.alert_tx.clone(),
5638 Arc::clone(&self.alert_mask),
5639 self.utp_socket.clone(),
5640 self.utp_socket_v6.clone(),
5641 Arc::clone(&self.ban_manager),
5642 Arc::clone(&self.ip_filter),
5643 Arc::clone(&self.plugins),
5644 self.sam_session.clone(),
5645 self.ssl_manager.clone(),
5646 Arc::clone(&self.factory),
5647 Some(Arc::clone(&self.hash_pool)),
5648 Arc::clone(&self.counters),
5649 )
5650 .await?;
5651 self.spawn_metadata_resolver(info_hash, &handle);
5655
5656 self.torrents.insert(
5657 info_hash,
5658 TorrentEntry {
5659 handle,
5660 queue_position: -1,
5661 auto_managed: overrides.auto_managed.unwrap_or(true),
5664 started_at: Some(tokio::time::Instant::now()),
5665 smoothed_download_rate: f64::MAX,
5666 smoothed_upload_rate: f64::MAX,
5667 },
5668 );
5669 self.info_hash_registry.insert(info_hash, ());
5670
5671 let pos = self.next_queue_position();
5673 if let Some(entry) = self.torrents.get_mut(&info_hash)
5674 && entry.auto_managed
5675 {
5676 entry.queue_position = pos;
5677 }
5678
5679 info!(%info_hash, "magnet torrent added to session");
5680 post_alert(
5681 &self.alert_tx,
5682 &self.alert_mask,
5683 AlertKind::TorrentAdded {
5684 info_hash,
5685 name: display_name,
5686 },
5687 );
5688 if let Some(ref lsd) = self.lsd {
5692 lsd.announce(vec![info_hash]).await;
5693 }
5694 self.snapshot_publish_one(info_hash).await;
5699 Ok(info_hash)
5700 }
5701
5702 fn spawn_metadata_resolver(&self, info_hash: Id20, torrent_handle: &TorrentHandle) {
5709 let dht = match self.dht_v4 {
5710 Some(ref dht) => dht.clone(),
5711 None => return, };
5713 let factory = Arc::clone(&self.factory);
5714 let connect_timeout = std::time::Duration::from_secs(self.settings.peer_connect_timeout);
5715 let handle = torrent_handle.clone();
5716
5717 tokio::spawn(async move {
5718 let peer_rx = match dht.get_peers(info_hash).await {
5719 Ok(rx) => rx,
5720 Err(e) => {
5721 debug!(
5722 %info_hash,
5723 "metadata resolver: failed to start DHT get_peers: {e}"
5724 );
5725 return;
5726 }
5727 };
5728
5729 let peer_id = irontide_core::PeerId::generate().0;
5730 match crate::metadata_resolver::resolve_metadata(
5731 info_hash,
5732 peer_id,
5733 peer_rx,
5734 factory,
5735 connect_timeout,
5736 crate::metadata_resolver::DEFAULT_MAX_CONCURRENT,
5737 )
5738 .await
5739 {
5740 Ok((meta, peers)) => {
5741 let info_bytes = if let Some(b) = meta.info_bytes {
5742 b.to_vec()
5743 } else {
5744 match irontide_bencode::to_bytes(&meta.info) {
5745 Ok(bytes) => bytes,
5746 Err(e) => {
5747 debug!(
5748 %info_hash,
5749 "metadata resolver: failed to re-encode info dict: {e}"
5750 );
5751 return;
5752 }
5753 }
5754 };
5755 debug!(
5756 %info_hash,
5757 num_peers = peers.len(),
5758 "metadata resolver: pre-resolved metadata, sending to torrent actor"
5759 );
5760 handle.send_pre_resolved_metadata(info_bytes, peers);
5761 }
5762 Err(e) => {
5763 debug!(
5764 %info_hash,
5765 "metadata resolver: failed to resolve metadata: {e}"
5766 );
5767 }
5768 }
5769 });
5770 }
5771
5772 async fn handle_remove_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5773 let entry = self
5774 .torrents
5775 .remove(&info_hash)
5776 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5777 self.info_hash_registry.remove(&info_hash);
5778 self.snapshot_drop_one(info_hash);
5782 let was_auto_managed = entry.auto_managed;
5783 let removed_position = entry.queue_position;
5784 entry.handle.shutdown().await?;
5785 self.disk_manager.unregister_torrent(info_hash).await;
5786
5787 if was_auto_managed && removed_position >= 0 {
5789 let mut entries = self.queue_entries();
5790 let changed = crate::queue::remove_position(&mut entries, removed_position);
5791 self.apply_queue_changes(&changed);
5792 }
5793
5794 let resume_dir = self.effective_resume_dir();
5798 if let Err(e) = crate::resume_file::delete_resume_file(&resume_dir, &info_hash) {
5799 if e.kind() != std::io::ErrorKind::NotFound {
5801 warn!(%info_hash, "failed to delete resume file on removal: {e}");
5802 }
5803 }
5804
5805 info!(%info_hash, "torrent removed from session");
5806 post_alert(
5807 &self.alert_tx,
5808 &self.alert_mask,
5809 AlertKind::TorrentRemoved { info_hash },
5810 );
5811 Ok(())
5812 }
5813
5814 async fn dispatch_add_torrent_m170(
5828 &mut self,
5829 params: AddTorrentParams,
5830 reply: oneshot::Sender<crate::Result<Id20>>,
5831 ) {
5832 let (resolved_dir, resolved_category) =
5835 match self.resolve_download_dir_and_category(¶ms) {
5836 Ok(x) => x,
5837 Err(e) => {
5838 let _ = reply.send(Err(e));
5839 return;
5840 }
5841 };
5842
5843 let AddTorrentParams {
5844 source,
5845 tags,
5846 paused,
5847 skip_checking: _, content_layout,
5849 preallocate_mode,
5850 auto_managed,
5851 sequential_download,
5852 prioritize_first_last_pieces,
5853 file_priorities,
5854 ..
5855 } = params;
5856 let overrides = AddConfigOverrides {
5858 content_layout,
5859 sequential_download,
5860 prioritize_first_last_pieces,
5861 preallocate_mode,
5862 file_priorities,
5863 auto_managed,
5864 };
5865
5866 let paused = paused.unwrap_or(self.settings.default_add_paused);
5869
5870 match source {
5871 AddSource::Magnet(uri) => {
5872 let result: crate::Result<Id20> = async {
5874 let magnet = irontide_core::Magnet::parse(&uri)?;
5875 let info_hash = magnet.info_hash();
5876 self.reject_if_in_deletion_grace(info_hash)?;
5877 let id = self
5878 .handle_add_magnet(magnet, resolved_dir, tags, overrides)
5879 .await?;
5880 self.apply_post_add_m170(id, resolved_category, paused);
5881 Ok(id)
5882 }
5883 .await;
5884 let _ = reply.send(result);
5885 }
5886 AddSource::Bytes(bytes) => {
5887 let setup: crate::Result<AddTorrentPrepBundle> = (|| {
5889 let meta = irontide_core::torrent_from_bytes_any(&bytes)?;
5890 let info_hash = meta
5891 .as_v1()
5892 .map_or_else(|| meta.info_hashes().best_v1(), |v| v.info_hash);
5893 self.reject_if_in_deletion_grace(info_hash)?;
5894 if self.torrents.contains_key(&info_hash) {
5895 return Err(crate::Error::DuplicateTorrent(info_hash));
5896 }
5897 if self.torrents.len() >= self.settings.max_torrents {
5898 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5899 }
5900 Ok(self.build_add_torrent_prep_bundle(
5901 meta,
5902 None,
5903 resolved_dir,
5904 tags,
5905 &overrides,
5906 Some(M170PostAdd {
5907 category: resolved_category,
5908 paused,
5909 }),
5910 ))
5911 })();
5912 match setup {
5913 Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
5914 Err(e) => {
5915 let _ = reply.send(Err(e));
5916 }
5917 }
5918 }
5919 }
5920 }
5921
5922 fn resolve_download_dir_and_category(
5925 &self,
5926 params: &AddTorrentParams,
5927 ) -> crate::Result<(Option<PathBuf>, Option<String>)> {
5928 match (¶ms.download_dir, ¶ms.category) {
5929 (Some(explicit), cat) => {
5930 Ok((Some(explicit.clone()), cat.clone()))
5933 }
5934 (None, Some(name)) => {
5935 let registry = self.category_registry.read();
5936 match registry.get(name) {
5937 Some(meta) => Ok((Some(meta.save_path.clone()), Some(name.clone()))),
5938 None => Err(crate::Error::CategoryNotFound(name.clone())),
5939 }
5940 }
5941 (None, None) => Ok((None, None)),
5942 }
5943 }
5944
5945 fn reject_if_in_deletion_grace(&self, info_hash: Id20) -> crate::Result<()> {
5948 if self.deletion_grace.lock().contains(&info_hash) {
5949 return Err(crate::Error::TorrentBeingRemoved(info_hash));
5950 }
5951 Ok(())
5952 }
5953
5954 fn apply_post_add_m170(&self, info_hash: Id20, category: Option<String>, paused: bool) {
5958 if let Some(entry) = self.torrents.get(&info_hash) {
5959 if let Some(name) = category {
5963 let handle = entry.handle.clone();
5964 tokio::spawn(async move {
5965 if let Err(e) = handle.set_category(Some(name)).await {
5966 warn!(%info_hash, "failed to propagate category: {e}");
5967 }
5968 });
5969 }
5970 if paused {
5971 let handle = entry.handle.clone();
5972 tokio::spawn(async move {
5973 if let Err(e) = handle.pause().await {
5974 warn!(%info_hash, "failed to pause on add: {e}");
5975 }
5976 });
5977 }
5978 }
5979 }
5980
5981 async fn handle_create_category(
5983 &self,
5984 name: String,
5985 save_path: PathBuf,
5986 ) -> Result<(), crate::category_manager::CategoryError> {
5987 {
5988 let mut registry = self.category_registry.write();
5989 registry.create(name, save_path)?;
5990 }
5991 self.persist_category_registry().await
5992 }
5993
5994 async fn handle_edit_category(
5996 &self,
5997 name: String,
5998 save_path: PathBuf,
5999 ) -> Result<(), crate::category_manager::CategoryError> {
6000 {
6001 let mut registry = self.category_registry.write();
6002 registry.edit(&name, save_path)?;
6003 }
6004 self.persist_category_registry().await
6005 }
6006
6007 async fn handle_remove_categories(&self, names: Vec<String>) -> Vec<String> {
6011 let removed: Vec<String> = {
6012 let mut registry = self.category_registry.write();
6013 registry.remove(&names)
6014 };
6015 if removed.is_empty() {
6016 return removed;
6017 }
6018
6019 for entry in self.torrents.values() {
6023 let handle = entry.handle.clone();
6024 let to_check: Vec<String> = removed.clone();
6025 tokio::spawn(async move {
6026 if let Ok(stats) = handle.stats().await
6027 && let Some(current) = stats.category
6028 && to_check.iter().any(|n| n.as_str() == current.as_str())
6029 && let Err(e) = handle.set_category(None).await
6030 {
6031 warn!(
6032 cat = %current,
6033 "failed to clear category label after removeCategories: {e}"
6034 );
6035 }
6036 });
6037 }
6038
6039 if let Err(e) = self.persist_category_registry().await {
6040 warn!("failed to persist category registry after remove: {e}");
6041 }
6042 removed
6043 }
6044
6045 async fn persist_category_registry(
6047 &self,
6048 ) -> Result<(), crate::category_manager::CategoryError> {
6049 let registry = Arc::clone(&self.category_registry);
6050 let snapshot = registry.read().clone();
6053 tokio::task::spawn_blocking(move || snapshot.save())
6054 .await
6055 .map_err(|join_err| {
6056 crate::category_manager::CategoryError::Persistence(std::io::Error::other(format!(
6057 "category registry save join error: {join_err}"
6058 )))
6059 })?
6060 }
6061
6062 async fn handle_delete_tags(&self, names: Vec<String>) -> Vec<String> {
6069 let removed = {
6070 let mut reg = self.tag_registry.write();
6071 reg.delete(&names)
6072 };
6073 if !removed.is_empty() {
6074 let to_remove: std::collections::HashSet<String> = removed.iter().cloned().collect();
6075 for entry in self.torrents.values() {
6076 let handle = entry.handle.clone();
6077 let to_remove = to_remove.clone();
6078 tokio::spawn(async move {
6079 if let Ok(stats) = handle.stats().await {
6080 let new_tags: Vec<String> = stats
6081 .tags
6082 .into_iter()
6083 .filter(|t| !to_remove.contains(t))
6084 .collect();
6085 if let Err(e) = handle.set_tags(new_tags).await {
6086 tracing::warn!(error = %e, "failed to apply tag deletion to torrent");
6087 }
6088 }
6089 });
6090 }
6091 if let Err(e) = self.persist_tag_registry().await {
6092 tracing::warn!(error = %e, "persist tag registry after DeleteTags");
6093 }
6094 }
6095 removed
6096 }
6097
6098 async fn handle_add_tags_to_torrents(
6110 &self,
6111 info_hashes: Vec<Id20>,
6112 tags_to_add: Vec<String>,
6113 ) -> crate::Result<()> {
6114 for hash in info_hashes {
6115 let Some(entry) = self.torrents.get(&hash) else {
6116 continue;
6117 };
6118 let current = entry.handle.stats().await?;
6119 let mut new_tags = current.tags;
6120 for t in &tags_to_add {
6121 if !new_tags.contains(t) {
6122 new_tags.push(t.clone());
6123 }
6124 }
6125 new_tags.sort();
6126 new_tags.dedup();
6127 entry.handle.set_tags(new_tags).await?;
6128 }
6129 Ok(())
6130 }
6131
6132 async fn handle_remove_tags_from_torrents(
6141 &self,
6142 info_hashes: Vec<Id20>,
6143 tags_to_remove: Vec<String>,
6144 ) -> crate::Result<()> {
6145 for hash in info_hashes {
6146 let Some(entry) = self.torrents.get(&hash) else {
6147 continue;
6148 };
6149 let current = entry.handle.stats().await?;
6150 let new_tags: Vec<String> = current
6151 .tags
6152 .into_iter()
6153 .filter(|t| !tags_to_remove.contains(t))
6154 .collect();
6155 entry.handle.set_tags(new_tags).await?;
6156 }
6157 Ok(())
6158 }
6159
6160 async fn persist_tag_registry(&self) -> Result<(), crate::tag_manager::TagError> {
6163 let to_save: crate::tag_manager::TagRegistry = { self.tag_registry.read().clone() };
6164 tokio::task::spawn_blocking(move || to_save.save())
6165 .await
6166 .unwrap_or_else(|_| {
6167 Err(crate::tag_manager::TagError::Persistence(
6168 std::io::Error::other("spawn_blocking failed"),
6169 ))
6170 })
6171 }
6172
6173 async fn handle_remove_torrent_with_files(&mut self, info_hash: Id20) -> crate::Result<()> {
6175 let handle = {
6185 let entry = self
6186 .torrents
6187 .get(&info_hash)
6188 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6189 entry.handle.clone()
6190 };
6191 let file_paths: Vec<PathBuf> = match (handle.stats().await, handle.get_meta().await) {
6197 (Ok(stats), Ok(Some(meta))) => stats
6198 .content_layout
6199 .apply_to_files(meta.info.files())
6200 .iter()
6201 .map(|f| f.path.iter().collect::<PathBuf>())
6202 .collect(),
6203 _ => Vec::new(),
6207 };
6208 let download_dir = self.settings.download_dir.clone();
6209 let _ = handle.pause().await;
6210
6211 self.deletion_grace.lock().insert(info_hash);
6215
6216 let remove_result = self.handle_remove_torrent(info_hash).await;
6219 if let Err(e) = &remove_result {
6220 warn!(
6221 %info_hash,
6222 error = %e,
6223 "remove_torrent_with_files: in-memory removal failed; continuing with file delete"
6224 );
6225 }
6226
6227 let grace = Arc::clone(&self.deletion_grace);
6232 tokio::task::spawn_blocking(move || {
6233 irontide_storage::delete_torrent_files_sync(download_dir, file_paths);
6234 grace.lock().remove(&info_hash);
6235 });
6236
6237 Ok(())
6238 }
6239
6240 async fn handle_pause_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
6241 let entry = self
6242 .torrents
6243 .get(&info_hash)
6244 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6245 entry.handle.pause().await
6246 }
6247
6248 async fn handle_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
6249 let entry = self
6250 .torrents
6251 .get(&info_hash)
6252 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6253 entry.handle.resume().await
6254 }
6255
6256 async fn handle_force_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
6257 let entry = self
6258 .torrents
6259 .get(&info_hash)
6260 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6261 entry
6262 .handle
6263 .cmd_tx
6264 .send(crate::types::TorrentCommand::ForceResume)
6265 .await
6266 .map_err(|_| crate::Error::Shutdown)
6267 }
6268
6269 async fn handle_set_torrent_seed_ratio(
6270 &self,
6271 info_hash: Id20,
6272 limit: Option<f64>,
6273 ) -> crate::Result<()> {
6274 let entry = self
6275 .torrents
6276 .get(&info_hash)
6277 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6278 let (tx, rx) = oneshot::channel();
6279 entry
6280 .handle
6281 .cmd_tx
6282 .send(crate::types::TorrentCommand::SetSeedRatioLimit { limit, reply: tx })
6283 .await
6284 .map_err(|_| crate::Error::Shutdown)?;
6285 rx.await.map_err(|_| crate::Error::Shutdown)
6286 }
6287
6288 async fn handle_move_torrent_storage(
6289 &self,
6290 info_hash: Id20,
6291 new_path: std::path::PathBuf,
6292 ) -> crate::Result<()> {
6293 let entry = self
6294 .torrents
6295 .get(&info_hash)
6296 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6297 entry.handle.move_storage(new_path).await
6298 }
6299
6300 async fn handle_torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
6301 let entry = self
6302 .torrents
6303 .get(&info_hash)
6304 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6305 let mut stats = entry.handle.stats().await?;
6306 stats.queue_position = entry.queue_position;
6308 stats.auto_managed = entry.auto_managed;
6309 Ok(stats)
6310 }
6311
6312 async fn handle_torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
6313 let meta = self.get_entry_meta(info_hash).await?;
6318 let files: Vec<FileInfo> = if let Some(ref file_list) = meta.info.files {
6319 file_list
6320 .iter()
6321 .map(|f| FileInfo {
6322 path: f.path.iter().collect::<PathBuf>(),
6323 length: f.length,
6324 })
6325 .collect()
6326 } else {
6327 vec![FileInfo {
6328 path: PathBuf::from(&meta.info.name),
6329 length: meta.info.total_length(),
6330 }]
6331 };
6332
6333 Ok(TorrentInfo {
6334 info_hash,
6335 name: meta.info.name.clone(),
6336 total_length: meta.info.total_length(),
6337 piece_length: meta.info.piece_length,
6338 num_pieces: meta.info.num_pieces() as u32,
6339 files,
6340 private: meta.info.private == Some(1),
6341 })
6342 }
6343
6344 fn update_session_gauges(&self) {
6346 use crate::stats::{
6347 DHT_NODES, DHT_NODES_V4, DHT_NODES_V6, PEER_NUM_BANNED, SES_ACTIVE_TORRENTS,
6348 SES_NUM_TORRENTS,
6349 };
6350 let c = &self.counters;
6351 c.set(SES_NUM_TORRENTS, self.torrents.len() as i64);
6352 c.set(SES_ACTIVE_TORRENTS, self.torrents.len() as i64);
6353
6354 let dht_nodes = i64::from(self.dht_v4.is_some()) + i64::from(self.dht_v6.is_some());
6356 c.set(DHT_NODES, dht_nodes);
6357 c.set(DHT_NODES_V4, i64::from(self.dht_v4.is_some()));
6358 c.set(DHT_NODES_V6, i64::from(self.dht_v6.is_some()));
6359
6360 let ban_count = self.ban_manager.read().banned_list().len() as i64;
6362 c.set(PEER_NUM_BANNED, ban_count);
6363 }
6364
6365 fn fire_stats_alert(&self) {
6367 self.update_session_gauges();
6368 let values = self.counters.snapshot();
6369 crate::alert::post_alert(
6370 &self.alert_tx,
6371 &self.alert_mask,
6372 crate::alert::AlertKind::SessionStatsAlert { values },
6373 );
6374 }
6375
6376 async fn fire_sample_infohashes(&self) {
6378 let ((Some(dht), _) | (_, Some(dht))) = (&self.dht_v4, &self.dht_v6) else {
6379 return;
6380 };
6381 let mut buf = [0u8; 20];
6382 irontide_core::random_bytes(&mut buf);
6383 let target = Id20::from(buf);
6384 match dht.sample_infohashes(target).await {
6385 Ok(result) => {
6386 post_alert(
6387 &self.alert_tx,
6388 &self.alert_mask,
6389 AlertKind::DhtSampleInfohashes {
6390 num_samples: result.samples.len(),
6391 total_estimate: result.num,
6392 },
6393 );
6394 }
6395 Err(e) => {
6396 debug!("sample_infohashes failed: {e}");
6397 }
6398 }
6399 }
6400
6401 async fn collect_torrent_stats(&self) -> Vec<(Id20, TorrentStats)> {
6418 use futures::stream::{FuturesUnordered, StreamExt};
6419
6420 let mut futs: FuturesUnordered<_> = self
6421 .torrents
6422 .iter()
6423 .map(|(&info_hash, entry)| {
6424 let handle = entry.handle.clone();
6425 async move {
6426 tokio::time::timeout(std::time::Duration::from_millis(500), handle.stats())
6427 .await
6428 .ok()
6429 .and_then(Result::ok)
6430 .map(|stats| (info_hash, stats))
6431 }
6432 })
6433 .collect();
6434
6435 let mut out = Vec::with_capacity(self.torrents.len());
6436 while let Some(maybe) = futs.next().await {
6437 if let Some(pair) = maybe {
6438 out.push(pair);
6439 }
6440 }
6441 out
6442 }
6443
6444 async fn snapshot_publish_one(&self, info_hash: Id20) {
6452 let handle = match self.torrents.get(&info_hash) {
6453 Some(entry) => entry.handle.clone(),
6454 None => return,
6455 };
6456 if let Ok(Ok(stats)) =
6457 tokio::time::timeout(std::time::Duration::from_millis(500), handle.stats()).await
6458 {
6459 let mut map = self.snapshot.load().as_map().clone();
6460 map.insert(info_hash, TorrentSummary::from(&stats));
6461 self.snapshot
6462 .store(Arc::new(SessionSnapshot::from_map(map)));
6463 }
6464 }
6465
6466 fn snapshot_drop_one(&self, info_hash: Id20) {
6470 let mut map = self.snapshot.load().as_map().clone();
6471 if map.remove(&info_hash).is_some() {
6472 self.snapshot
6473 .store(Arc::new(SessionSnapshot::from_map(map)));
6474 }
6475 }
6476
6477 fn compute_external_address(
6484 external_ip: Option<std::net::IpAddr>,
6485 external_tcp_port: Option<u16>,
6486 listen_port: u16,
6487 ) -> Option<std::net::SocketAddr> {
6488 let ip = external_ip?;
6489 let port = external_tcp_port.or_else(|| (listen_port != 0).then_some(listen_port))?;
6490 Some(std::net::SocketAddr::new(ip, port))
6491 }
6492
6493 async fn make_session_stats(&self) -> SessionStats {
6494 self.update_session_gauges();
6495
6496 let active_torrents = self.torrents.len();
6497 let dht_nodes = usize::from(self.dht_v4.is_some()) + usize::from(self.dht_v6.is_some());
6498
6499 let collected = self.collect_torrent_stats().await;
6503
6504 let mut total_downloaded = 0u64;
6505 let mut total_uploaded = 0u64;
6506 let mut responded = std::collections::HashMap::with_capacity(collected.len());
6507 for (info_hash, stats) in collected {
6508 total_downloaded = total_downloaded.saturating_add(stats.downloaded);
6509 total_uploaded = total_uploaded.saturating_add(stats.uploaded);
6510 responded.insert(info_hash, stats);
6511 }
6512
6513 let prev = self.snapshot.load();
6519 let mut map = std::collections::BTreeMap::new();
6520 for &info_hash in self.torrents.keys() {
6521 if let Some(stats) = responded.get(&info_hash) {
6522 map.insert(info_hash, TorrentSummary::from(stats));
6523 } else if let Some(prev_summary) = prev.as_map().get(&info_hash) {
6524 map.insert(info_hash, prev_summary.clone());
6525 }
6526 }
6527 self.snapshot
6528 .store(Arc::new(SessionSnapshot::from_map(map)));
6529
6530 SessionStats {
6531 active_torrents,
6532 total_downloaded,
6533 total_uploaded,
6534 dht_nodes,
6535 external_address: Self::compute_external_address(
6536 self.external_ip,
6537 self.external_tcp_port,
6538 self.settings.listen_port,
6539 ),
6540 incoming_peer_connections: self.incoming_peer_connections,
6541 }
6542 }
6543
6544 async fn make_debug_state(&self) -> crate::types::DebugState {
6548 use crate::stats::{
6549 DISPATCH_ACQUIRE_NONE_TOTAL, DISPATCH_ACQUIRE_TOTAL, DISPATCH_ACQUIRE_US,
6550 DISPATCH_NOTIFY_WAKEUP_TOTAL,
6551 };
6552
6553 let snap = self.counters.snapshot();
6555 let dispatch = crate::types::DebugDispatchState {
6556 acquire_total: snap[DISPATCH_ACQUIRE_TOTAL],
6557 acquire_none_total: snap[DISPATCH_ACQUIRE_NONE_TOTAL],
6558 acquire_us: snap[DISPATCH_ACQUIRE_US],
6559 notify_wakeup_total: snap[DISPATCH_NOTIFY_WAKEUP_TOTAL],
6560 pieces_queued: 0,
6561 pieces_inflight: 0,
6562 };
6563
6564 let mut torrents = Vec::with_capacity(self.torrents.len());
6565 for (&info_hash, entry) in &self.torrents {
6566 let Ok(Ok(stats)) =
6568 tokio::time::timeout(std::time::Duration::from_millis(500), entry.handle.stats())
6569 .await
6570 else {
6571 continue;
6572 };
6573
6574 let peers_raw = match tokio::time::timeout(
6576 std::time::Duration::from_millis(500),
6577 entry.handle.get_peer_info(),
6578 )
6579 .await
6580 {
6581 Ok(Ok(p)) => p,
6582 _ => Vec::new(),
6583 };
6584
6585 let peers: Vec<crate::types::DebugPeerState> = peers_raw
6586 .iter()
6587 .map(|p| crate::types::DebugPeerState {
6588 addr: p.addr,
6589 in_flight: p.in_flight_requests,
6590 target_depth: p.target_pipeline_depth,
6591 choking: p.peer_choking,
6592 download_rate: p.download_rate,
6593 })
6594 .collect();
6595
6596 let mut per_torrent_dispatch = dispatch.clone();
6597 per_torrent_dispatch.pieces_queued = stats.dispatch_pieces_queued;
6598 per_torrent_dispatch.pieces_inflight = stats.dispatch_pieces_inflight;
6599
6600 torrents.push(crate::types::DebugTorrentState {
6601 info_hash: info_hash.to_hex(),
6602 state: format!("{:?}", stats.state),
6603 num_peers: stats.peers_connected,
6604 dispatch: per_torrent_dispatch,
6605 peers,
6606 });
6607 }
6608
6609 crate::types::DebugState { torrents }
6610 }
6611
6612 async fn handle_save_torrent_resume(
6613 &self,
6614 info_hash: Id20,
6615 ) -> crate::Result<irontide_core::FastResumeData> {
6616 let entry = self
6617 .torrents
6618 .get(&info_hash)
6619 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6620 let mut resume = entry.handle.save_resume_data().await?;
6621 resume.queue_position = i64::from(entry.queue_position);
6624 resume.auto_managed = i64::from(entry.auto_managed);
6625 Ok(resume)
6626 }
6627
6628 async fn handle_save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
6629 use crate::persistence::SessionState;
6630
6631 let mut torrents = Vec::new();
6632 for (info_hash, entry) in &self.torrents {
6633 match entry.handle.save_resume_data().await {
6634 Ok(rd) => torrents.push(rd),
6635 Err(e) => {
6636 warn!(%info_hash, "failed to save resume data: {e}");
6637 }
6638 }
6639 }
6640
6641 let (banned_peers, peer_strikes) = {
6643 let ban_mgr = self.ban_manager.read();
6644 let banned_peers: Vec<String> = ban_mgr
6645 .banned_list()
6646 .iter()
6647 .map(std::string::ToString::to_string)
6648 .collect();
6649 let peer_strikes: Vec<crate::persistence::PeerStrikeEntry> = ban_mgr
6650 .strikes_map()
6651 .iter()
6652 .map(|(ip, &count)| crate::persistence::PeerStrikeEntry {
6653 ip: ip.to_string(),
6654 count: i64::from(count),
6655 })
6656 .collect();
6657 (banned_peers, peer_strikes)
6658 };
6659
6660 let mut dht_entries = Vec::new();
6661 let mut dht_node_id = None;
6662 if let Some(ref dht) = self.dht_v4 {
6663 if let Ok(stats) = dht.stats().await {
6665 dht_node_id = Some(stats.node_id.to_hex());
6666 }
6667 for (_id, addr) in dht.get_routing_nodes().await {
6668 dht_entries.push(crate::persistence::DhtNodeEntry {
6669 host: addr.ip().to_string(),
6670 port: i64::from(addr.port()),
6671 });
6672 }
6673 }
6674 if let Some(ref dht) = self.dht_v6 {
6675 for (_id, addr) in dht.get_routing_nodes().await {
6676 dht_entries.push(crate::persistence::DhtNodeEntry {
6677 host: addr.ip().to_string(),
6678 port: i64::from(addr.port()),
6679 });
6680 }
6681 }
6682
6683 Ok(SessionState {
6684 dht_nodes: dht_entries,
6685 dht_node_id,
6686 torrents,
6687 banned_peers,
6688 peer_strikes,
6689 })
6690 }
6691
6692 fn effective_resume_dir(&self) -> PathBuf {
6694 self.settings
6695 .resume_data_dir
6696 .clone()
6697 .unwrap_or_else(crate::resume_file::default_resume_dir)
6698 }
6699
6700 async fn handle_load_resume_state(&mut self) -> crate::Result<ResumeLoadResult> {
6706 let resume_dir = self.effective_resume_dir();
6707 let paths = crate::resume_file::scan_resume_dir(&resume_dir);
6708
6709 let mut restored = 0usize;
6710 let mut skipped = 0usize;
6711 let mut failed = 0usize;
6712
6713 for path in &paths {
6714 let file_name = path
6715 .file_name()
6716 .and_then(|n| n.to_str())
6717 .unwrap_or("<unknown>");
6718
6719 let bytes = match std::fs::read(path) {
6721 Ok(b) => b,
6722 Err(e) => {
6723 warn!(file = %file_name, "failed to read resume file: {e}");
6724 failed = failed.saturating_add(1);
6725 continue;
6726 }
6727 };
6728
6729 let rd = match crate::resume_file::deserialize_resume(&bytes) {
6730 Ok(rd) => rd,
6731 Err(e) => {
6732 warn!(file = %file_name, "failed to deserialize resume file: {e}");
6733 failed = failed.saturating_add(1);
6734 continue;
6735 }
6736 };
6737
6738 if let Some(meta) = crate::resume_file::reconstruct_torrent_meta(&rd) {
6740 let info_hash = meta.info_hash;
6741 let pieces = rd.pieces.clone();
6742 let torrent_meta = irontide_core::TorrentMeta::V1(meta);
6743
6744 let restore_dir = if rd.save_path.is_empty() {
6746 None
6747 } else {
6748 Some(PathBuf::from(&rd.save_path))
6749 };
6750 let restore_tags = rd.tags.clone();
6755 let restore_overrides = AddConfigOverrides {
6762 content_layout: Some(rd.content_layout.unwrap_or_default()),
6763 sequential_download: Some(rd.sequential_download != 0),
6764 prioritize_first_last_pieces: Some(rd.prioritize_first_last_pieces != 0),
6765 preallocate_mode: None,
6766 file_priorities: rd
6771 .file_priority
6772 .iter()
6773 .map(|&v| irontide_core::FilePriority::from(u8::try_from(v).unwrap_or(4)))
6774 .collect(),
6775 auto_managed: Some(rd.auto_managed != 0),
6781 };
6782 match self
6783 .handle_add_torrent(
6784 torrent_meta,
6785 None,
6786 restore_dir,
6787 restore_tags,
6788 restore_overrides,
6789 )
6790 .await
6791 {
6792 Ok(added_hash) => {
6793 if !pieces.is_empty()
6795 && let Some(entry) = self.torrents.get(&added_hash)
6796 && let Err(e) = entry.handle.restore_resume_bitmap(pieces).await
6797 {
6798 warn!(
6799 %info_hash,
6800 "failed to restore piece bitmap, torrent will recheck: {e}"
6801 );
6802 }
6803 if let Some(ref cat) = rd.category
6805 && let Some(entry) = self.torrents.get(&added_hash)
6806 {
6807 let handle = entry.handle.clone();
6808 let cat_owned = cat.clone();
6809 tokio::spawn(async move {
6810 let _ = handle.set_category(Some(cat_owned)).await;
6811 });
6812 }
6813 if !rd.web_seed_stats.is_empty()
6816 && let Some(entry) = self.torrents.get(&added_hash)
6817 {
6818 let handle = entry.handle.clone();
6819 let stats_owned = rd.web_seed_stats.clone();
6820 tokio::spawn(async move {
6821 let _ = handle.restore_web_seed_stats(stats_owned).await;
6822 });
6823 }
6824 if self.settings.queueing_enabled
6825 && let Some(entry) = self.torrents.get(&added_hash)
6826 {
6827 let _ = entry.handle.queue().await;
6828 }
6829 if let Some(entry) = self.torrents.get_mut(&added_hash) {
6830 entry.queue_position = rd.queue_position as i32;
6831 entry.auto_managed = rd.auto_managed != 0;
6832 }
6833 info!(%info_hash, "restored torrent from resume file");
6834 restored = restored.saturating_add(1);
6835 }
6836 Err(crate::Error::DuplicateTorrent(_)) => {
6837 debug!(%info_hash, "skipped duplicate torrent from resume");
6838 skipped = skipped.saturating_add(1);
6839 }
6840 Err(e) => {
6841 warn!(%info_hash, "failed to add restored torrent: {e}");
6842 failed = failed.saturating_add(1);
6843 }
6844 }
6845 } else if let Some(magnet) = crate::resume_file::reconstruct_magnet(&rd) {
6846 let info_hash = magnet.info_hash();
6848 let restore_dir = if rd.save_path.is_empty() {
6849 None
6850 } else {
6851 Some(PathBuf::from(&rd.save_path))
6852 };
6853 let restore_tags = rd.tags.clone();
6855 let restore_overrides = AddConfigOverrides {
6859 content_layout: Some(rd.content_layout.unwrap_or_default()),
6860 sequential_download: Some(rd.sequential_download != 0),
6861 prioritize_first_last_pieces: Some(rd.prioritize_first_last_pieces != 0),
6862 preallocate_mode: None,
6863 file_priorities: rd
6864 .file_priority
6865 .iter()
6866 .map(|&v| irontide_core::FilePriority::from(u8::try_from(v).unwrap_or(4)))
6867 .collect(),
6868 auto_managed: Some(rd.auto_managed != 0),
6869 };
6870 match self
6871 .handle_add_magnet(magnet, restore_dir, restore_tags, restore_overrides)
6872 .await
6873 {
6874 Ok(added_hash) => {
6875 if let Some(ref cat) = rd.category
6877 && let Some(entry) = self.torrents.get(&added_hash)
6878 {
6879 let handle = entry.handle.clone();
6880 let cat_owned = cat.clone();
6881 tokio::spawn(async move {
6882 let _ = handle.set_category(Some(cat_owned)).await;
6883 });
6884 }
6885 if !rd.web_seed_stats.is_empty()
6889 && let Some(entry) = self.torrents.get(&added_hash)
6890 {
6891 let handle = entry.handle.clone();
6892 let stats_owned = rd.web_seed_stats.clone();
6893 tokio::spawn(async move {
6894 let _ = handle.restore_web_seed_stats(stats_owned).await;
6895 });
6896 }
6897 if self.settings.queueing_enabled
6898 && let Some(entry) = self.torrents.get(&added_hash)
6899 {
6900 let _ = entry.handle.queue().await;
6901 }
6902 if let Some(entry) = self.torrents.get_mut(&added_hash) {
6903 entry.queue_position = rd.queue_position as i32;
6904 entry.auto_managed = rd.auto_managed != 0;
6905 }
6906 info!(%info_hash, "restored magnet from resume file");
6907 restored = restored.saturating_add(1);
6908 }
6909 Err(crate::Error::DuplicateTorrent(_)) => {
6910 debug!(%info_hash, "skipped duplicate magnet from resume");
6911 skipped = skipped.saturating_add(1);
6912 }
6913 Err(e) => {
6914 warn!(%info_hash, "failed to add restored magnet: {e}");
6915 failed = failed.saturating_add(1);
6916 }
6917 }
6918 } else {
6919 warn!(file = %file_name, "resume file has no valid info dict and no valid info hash");
6920 failed = failed.saturating_add(1);
6921 }
6922 }
6923
6924 {
6928 let mut entries: Vec<(Id20, i32)> = self
6929 .torrents
6930 .iter()
6931 .filter(|(_, e)| e.auto_managed)
6932 .map(|(h, e)| (*h, e.queue_position))
6933 .collect();
6934 entries.sort_by_key(|&(_, pos)| pos);
6935 for (new_pos, (hash, _)) in entries.into_iter().enumerate() {
6936 if let Some(entry) = self.torrents.get_mut(&hash) {
6937 entry.queue_position = new_pos as i32;
6938 }
6939 }
6940 }
6941
6942 info!(restored, skipped, failed, "resume state loaded");
6943 Ok(ResumeLoadResult {
6944 restored,
6945 skipped,
6946 failed,
6947 })
6948 }
6949
6950 fn snapshot_resume_jobs(&self) -> (std::path::PathBuf, Vec<ResumeSaveJob>) {
6956 let resume_dir = self.effective_resume_dir();
6957 let jobs = self
6958 .torrents
6959 .iter()
6960 .map(|(info_hash, entry)| ResumeSaveJob {
6961 info_hash: *info_hash,
6962 handle: entry.handle.clone(),
6963 queue_position: i64::from(entry.queue_position),
6964 auto_managed: i64::from(entry.auto_managed),
6965 })
6966 .collect();
6967 (resume_dir, jobs)
6968 }
6969
6970 async fn save_dirty_resume_files(&self) -> usize {
6979 let _guard = self.resume_save_lock.lock().await;
6980 let (resume_dir, jobs) = self.snapshot_resume_jobs();
6981 run_resume_save_jobs(resume_dir, jobs).await
6982 }
6983
6984 fn handle_apply_settings(&mut self, new: Settings) -> crate::Result<()> {
7007 new.validate()?;
7010
7011 let old_upload_rate = self.settings.upload_rate_limit;
7015 let old_download_rate = self.settings.download_rate_limit;
7016 let old_alert_mask = self.settings.alert_mask;
7017 let old_settings = self.settings.clone();
7018 let old_settings_for_delta = self.settings.clone();
7019
7020 let new_upload_rate = new.upload_rate_limit;
7021 let new_download_rate = new.download_rate_limit;
7022 let new_alert_mask = new.alert_mask;
7023
7024 let upload_bucket = Arc::clone(&self.global_upload_bucket);
7029 let download_bucket = Arc::clone(&self.global_download_bucket);
7030 let alert_mask = Arc::clone(&self.alert_mask);
7031
7032 let phase1: crate::apply::Phase<Self> = crate::apply::Phase {
7033 name: "rate_limits_and_mask",
7034 forward: Box::new(move |this: &mut Self| {
7035 if new_upload_rate != old_upload_rate {
7036 upload_bucket.lock().set_rate(new_upload_rate);
7037 }
7038 if new_download_rate != old_download_rate {
7039 download_bucket.lock().set_rate(new_download_rate);
7040 }
7041 if new_alert_mask != old_alert_mask {
7042 alert_mask.store(new_alert_mask.bits(), Ordering::Relaxed);
7043 }
7044 this.settings = new;
7045 Ok(())
7046 }),
7047 rollback: Box::new(move |this: &mut Self| {
7048 this.settings = old_settings;
7050 if new_alert_mask != old_alert_mask {
7051 this.alert_mask
7052 .store(old_alert_mask.bits(), Ordering::Relaxed);
7053 }
7054 if new_download_rate != old_download_rate {
7055 this.global_download_bucket
7056 .lock()
7057 .set_rate(old_download_rate);
7058 }
7059 if new_upload_rate != old_upload_rate {
7060 this.global_upload_bucket.lock().set_rate(old_upload_rate);
7061 }
7062 }),
7063 };
7064
7065 let phases = vec![phase1];
7067
7068 match crate::apply::apply_phases_with_rollback(self, phases) {
7069 Ok(()) => {
7070 let _ = self.notification_settings_tx.send(self.settings.clone());
7078
7079 if (old_settings_for_delta.enable_dht != self.settings.enable_dht
7081 || old_settings_for_delta.anonymous_mode != self.settings.anonymous_mode)
7082 && (!self.settings.enable_dht || self.settings.anonymous_mode)
7083 {
7084 tracing::info!("DHT disabled via settings");
7085 self.dht_v4 = None;
7086 self.dht_v6 = None;
7087 self.dht_v4_broadcast.replace(None);
7088 self.dht_v6_broadcast.replace(None);
7089 }
7090
7091 self.max_connections_global.store(
7097 self.settings.max_connections_global,
7098 std::sync::atomic::Ordering::SeqCst,
7099 );
7100
7101 let delta =
7102 crate::types::SettingsDelta::from_diff(&old_settings_for_delta, &self.settings);
7103 if delta.save_resume_interval_secs.is_some() {
7104 self.resume_save_notify.notify_one();
7105 }
7106 if let Some(enabled) = delta.ip_filter_enabled {
7107 self.ip_filter.write().enabled = enabled;
7108 }
7109 if delta.watched_folder.is_some() || delta.delete_torrent_after_add.is_some() {
7115 self.watched_folder_changed.notify_one();
7116 }
7117 if delta.resolve_peer_countries.is_some() || delta.peer_country_db_path.is_some() {
7121 self.geoip = crate::geoip::build_geoip_resolver(&self.settings);
7122 }
7123 if !delta.is_empty() {
7124 let mut failed: Vec<irontide_core::Id20> = Vec::new();
7131 for (hash, entry) in &self.torrents {
7132 if entry
7133 .handle
7134 .cmd_tx
7135 .try_send(crate::types::TorrentCommand::UpdateSettings(Box::new(
7136 delta.clone(),
7137 )))
7138 .is_err()
7139 {
7140 failed.push(*hash);
7141 }
7142 }
7143 if !failed.is_empty() {
7144 tracing::warn!(
7145 count = failed.len(),
7146 "SettingsDelta fan-out: per-torrent channel saturated; \
7147 affected torrents will pick up the change on the next apply"
7148 );
7149 }
7150 }
7151 post_alert(&self.alert_tx, &self.alert_mask, AlertKind::SettingsChanged);
7152 Ok(())
7153 }
7154 Err(crate::apply::ApplyError::ValidationFailed(msg)) => {
7155 Err(crate::Error::InvalidSettings(msg))
7156 }
7157 Err(e) => Err(crate::Error::Config(format!("apply settings: {e}"))),
7158 }
7159 }
7160
7161 fn queue_entries(&self) -> Vec<crate::queue::QueueEntry> {
7163 self.torrents
7164 .iter()
7165 .filter(|(_, e)| e.auto_managed)
7166 .map(|(&hash, e)| crate::queue::QueueEntry {
7167 info_hash: hash,
7168 position: e.queue_position,
7169 })
7170 .collect()
7171 }
7172
7173 fn handle_set_queue_position(&mut self, info_hash: Id20, pos: i32) -> crate::Result<()> {
7174 if !self.torrents.contains_key(&info_hash) {
7175 return Err(crate::Error::TorrentNotFound(info_hash));
7176 }
7177 let mut entries = self.queue_entries();
7178 let changed = crate::queue::set_position(&mut entries, info_hash, pos);
7179 self.apply_queue_changes(&changed);
7180 Ok(())
7181 }
7182
7183 fn set_auto_managed_inner(&mut self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
7191 let Some(entry) = self.torrents.get_mut(&info_hash) else {
7192 return Err(crate::Error::TorrentNotFound(info_hash));
7193 };
7194 if entry.auto_managed == enabled {
7195 return Ok(());
7196 }
7197 entry.auto_managed = enabled;
7198 if enabled {
7199 if entry.queue_position < 0 {
7200 let pos = self.next_queue_position();
7201 self.apply_queue_changes(&[(info_hash, -1, pos)]);
7202 }
7203 } else {
7204 let old_pos = entry.queue_position;
7205 entry.queue_position = -1;
7206 if old_pos >= 0 {
7207 let mut entries = self.queue_entries();
7211 let changed = crate::queue::remove_position(&mut entries, old_pos);
7212 self.apply_queue_changes(&changed);
7213 }
7214 }
7215 Ok(())
7216 }
7217
7218 fn handle_queue_move(&mut self, info_hash: Id20, op: QueueMoveFn) -> crate::Result<()> {
7219 if !self.torrents.contains_key(&info_hash) {
7220 return Err(crate::Error::TorrentNotFound(info_hash));
7221 }
7222 let mut entries = self.queue_entries();
7223 let changed = op(&mut entries, info_hash);
7224 self.apply_queue_changes(&changed);
7225 Ok(())
7226 }
7227
7228 fn apply_queue_changes(&mut self, changed: &[(Id20, i32, i32)]) {
7230 for &(hash, old_pos, new_pos) in changed {
7231 if let Some(entry) = self.torrents.get_mut(&hash) {
7232 entry.queue_position = new_pos;
7233 }
7234 crate::alert::post_alert(
7235 &self.alert_tx,
7236 &self.alert_mask,
7237 crate::alert::AlertKind::TorrentQueuePositionChanged {
7238 info_hash: hash,
7239 old_pos,
7240 new_pos,
7241 },
7242 );
7243 }
7244 }
7245
7246 async fn evaluate_queue(&mut self) {
7247 if !self.settings.queueing_enabled {
7248 return;
7249 }
7250 let now = tokio::time::Instant::now();
7251 let startup_duration = std::time::Duration::from_secs(self.settings.auto_manage_startup);
7252 let mut candidates = Vec::new();
7253
7254 let hashes: Vec<Id20> = self.torrents.keys().copied().collect();
7256
7257 for &info_hash in &hashes {
7258 let (queue_position, started_at) = {
7259 let Some(entry) = self.torrents.get(&info_hash) else {
7260 continue;
7261 };
7262 if !entry.auto_managed {
7263 continue;
7264 }
7265 (entry.queue_position, entry.started_at)
7266 };
7267
7268 let stats = match self.torrents.get(&info_hash) {
7270 Some(entry) => match entry.handle.stats().await {
7271 Ok(s) => s,
7272 Err(_) => continue,
7273 },
7274 None => continue,
7275 };
7276
7277 let category = match stats.state {
7278 TorrentState::Checking | TorrentState::FetchingMetadata => {
7279 crate::queue::QueueCategory::Checking
7280 }
7281 TorrentState::Downloading => crate::queue::QueueCategory::Downloading,
7282 TorrentState::Seeding | TorrentState::Complete => {
7283 crate::queue::QueueCategory::Seeding
7284 }
7285 TorrentState::Queued => {
7286 if stats.progress >= 1.0 {
7287 crate::queue::QueueCategory::Seeding
7288 } else {
7289 crate::queue::QueueCategory::Downloading
7290 }
7291 }
7292 TorrentState::Paused | TorrentState::Stopped | TorrentState::Sharing => continue,
7293 };
7294
7295 let is_active = !matches!(stats.state, TorrentState::Paused | TorrentState::Queued);
7296
7297 let alpha = self.settings.queue_rate_ewma_alpha.clamp(0.0, 1.0);
7299 let (smoothed_dl, smoothed_ul) = if let Some(entry) = self.torrents.get_mut(&info_hash)
7300 {
7301 let raw_dl = stats.download_rate as f64;
7302 let raw_ul = stats.upload_rate as f64;
7303 entry.smoothed_download_rate =
7304 alpha.mul_add(raw_dl, (1.0 - alpha) * entry.smoothed_download_rate);
7305 entry.smoothed_upload_rate =
7306 alpha.mul_add(raw_ul, (1.0 - alpha) * entry.smoothed_upload_rate);
7307 (entry.smoothed_download_rate, entry.smoothed_upload_rate)
7308 } else {
7309 continue;
7310 };
7311
7312 let past_startup = started_at.is_none_or(|t| now.duration_since(t) > startup_duration);
7313
7314 let is_inactive = past_startup
7315 && match category {
7316 crate::queue::QueueCategory::Downloading => {
7317 (smoothed_dl as u64) < self.settings.inactive_down_rate
7318 }
7319 crate::queue::QueueCategory::Seeding => {
7320 (smoothed_ul as u64) < self.settings.inactive_up_rate
7321 }
7322 crate::queue::QueueCategory::Checking => false,
7323 };
7324
7325 let anti_flap_duration = if category == crate::queue::QueueCategory::Seeding {
7326 std::time::Duration::from_secs(self.settings.seed_queue_min_active_secs)
7327 } else {
7328 startup_duration
7329 };
7330 let recently_started =
7331 started_at.is_some_and(|t| now.duration_since(t) < anti_flap_duration);
7332
7333 let seed_rank = if category == crate::queue::QueueCategory::Seeding {
7334 Some(crate::queue::compute_seed_rank(
7335 stats.num_complete,
7336 stats.num_incomplete,
7337 ))
7338 } else {
7339 None
7340 };
7341
7342 candidates.push(crate::queue::QueueCandidate {
7343 info_hash,
7344 position: queue_position,
7345 category,
7346 is_active,
7347 is_inactive,
7348 recently_started,
7349 seed_rank,
7350 });
7351 }
7352
7353 let config = crate::queue::QueueConfig {
7354 active_downloads: self.settings.active_downloads,
7355 active_seeds: self.settings.active_seeds,
7356 active_checking: self.settings.active_checking,
7357 active_limit: self.settings.active_limit,
7358 dont_count_slow: self.settings.dont_count_slow_torrents,
7359 prefer_seeds: self.settings.auto_manage_prefer_seeds,
7360 };
7361 let mut decision = crate::queue::evaluate(&candidates, &config);
7362 crate::queue::apply_preemption(&mut decision, &candidates);
7363
7364 for hash in &decision.to_pause {
7366 if let Some(entry) = self.torrents.get(hash) {
7367 let _ = entry.handle.queue().await;
7368 }
7369 post_alert(
7370 &self.alert_tx,
7371 &self.alert_mask,
7372 AlertKind::TorrentAutoManaged {
7373 info_hash: *hash,
7374 paused: true,
7375 },
7376 );
7377 }
7378
7379 for hash in &decision.to_resume {
7380 if let Some(entry) = self.torrents.get_mut(hash) {
7381 let _ = entry.handle.resume().await;
7382 entry.started_at = Some(tokio::time::Instant::now());
7383 }
7384 post_alert(
7385 &self.alert_tx,
7386 &self.alert_mask,
7387 AlertKind::TorrentAutoManaged {
7388 info_hash: *hash,
7389 paused: false,
7390 },
7391 );
7392 }
7393 }
7394
7395 fn handle_identified_inbound(&mut self, conn: crate::listener::IdentifiedConnection) {
7397 if let Some(entry) = self.torrents.get(&conn.info_hash) {
7398 debug!(%conn.addr, %conn.info_hash, "routing validated inbound peer");
7399 self.incoming_peer_connections += 1;
7402 let handle = entry.handle.clone();
7403 tokio::spawn(async move {
7404 let _ = handle.send_incoming_peer(conn.stream, conn.addr).await;
7405 });
7406 } else {
7407 debug!(%conn.addr, %conn.info_hash, "validated peer for removed torrent, dropping");
7409 }
7410 }
7411
7412 async fn handle_ssl_incoming(
7419 &mut self,
7420 stream: crate::transport::BoxedStream,
7421 addr: std::net::SocketAddr,
7422 ) {
7423 use tokio_rustls::LazyConfigAcceptor;
7424
7425 let acceptor = LazyConfigAcceptor::new(rustls::server::Acceptor::default(), stream);
7426
7427 let start_handshake = match acceptor.await {
7428 Ok(sh) => sh,
7429 Err(e) => {
7430 debug!(%addr, error = %e, "SSL ClientHello read failed");
7431 return;
7432 }
7433 };
7434
7435 let client_hello = start_handshake.client_hello();
7437 let sni = if let Some(name) = client_hello.server_name() {
7438 name.to_string()
7439 } else {
7440 debug!(%addr, "SSL connection missing SNI");
7441 return;
7442 };
7443
7444 let Ok(info_hash) = Id20::from_hex(&sni) else {
7446 debug!(%addr, sni = %sni, "SSL SNI is not a valid info hash");
7447 return;
7448 };
7449
7450 let Some(torrent) = self.torrents.get(&info_hash) else {
7452 debug!(%addr, %info_hash, "SSL connection for unknown torrent");
7453 return;
7454 };
7455
7456 let meta = match torrent.handle.get_meta().await {
7463 Ok(Some(m)) => m,
7464 Ok(None) => {
7465 debug!(%addr, %info_hash, "SSL connection for torrent still resolving metadata");
7466 return;
7467 }
7468 Err(_) => {
7469 debug!(%addr, %info_hash, "SSL connection but TorrentActor shut down");
7470 return;
7471 }
7472 };
7473 let ssl_cert = if let Some(cert) = meta.ssl_cert.as_ref() {
7474 cert.clone()
7475 } else {
7476 debug!(%addr, %info_hash, "SSL connection for non-SSL torrent (no ssl_cert in info dict)");
7477 return;
7478 };
7479
7480 let server_config = if let Some(mgr) = self.ssl_manager.as_ref() {
7482 match mgr.server_config(&ssl_cert) {
7483 Ok(cfg) => cfg,
7484 Err(e) => {
7485 warn!(%addr, %info_hash, error = %e, "failed to build SSL server config");
7486 return;
7487 }
7488 }
7489 } else {
7490 debug!(%addr, "SSL manager not initialized");
7491 return;
7492 };
7493
7494 let tls_stream = match start_handshake.into_stream(server_config).await {
7496 Ok(s) => s,
7497 Err(e) => {
7498 warn!(%addr, %info_hash, error = %e, "SSL handshake failed");
7499 post_alert(
7500 &self.alert_tx,
7501 &self.alert_mask,
7502 AlertKind::SslTorrentError {
7503 info_hash,
7504 message: format!("inbound TLS handshake from {addr}: {e}"),
7505 },
7506 );
7507 return;
7508 }
7509 };
7510
7511 self.incoming_peer_connections += 1;
7515 let _ = torrent.handle.spawn_ssl_peer(addr, tls_stream).await;
7516 }
7517
7518 async fn handle_dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
7519 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7520 match dht.put_immutable(value.clone()).await {
7521 Ok(target) => {
7522 post_alert(
7523 &self.alert_tx,
7524 &self.alert_mask,
7525 AlertKind::DhtPutComplete { target },
7526 );
7527 Ok(target)
7528 }
7529 Err(e) => {
7530 let target = irontide_core::sha1(&value);
7531 post_alert(
7532 &self.alert_tx,
7533 &self.alert_mask,
7534 AlertKind::DhtItemError {
7535 target,
7536 message: e.to_string(),
7537 },
7538 );
7539 Err(crate::Error::Dht(e))
7540 }
7541 }
7542 }
7543
7544 async fn handle_dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
7545 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7546 match dht.get_immutable(target).await {
7547 Ok(value) => {
7548 post_alert(
7549 &self.alert_tx,
7550 &self.alert_mask,
7551 AlertKind::DhtGetResult {
7552 target,
7553 value: value.clone(),
7554 },
7555 );
7556 Ok(value)
7557 }
7558 Err(e) => {
7559 post_alert(
7560 &self.alert_tx,
7561 &self.alert_mask,
7562 AlertKind::DhtItemError {
7563 target,
7564 message: e.to_string(),
7565 },
7566 );
7567 Err(crate::Error::Dht(e))
7568 }
7569 }
7570 }
7571
7572 async fn handle_dht_put_mutable(
7573 &self,
7574 keypair_bytes: [u8; 32],
7575 value: Vec<u8>,
7576 seq: i64,
7577 salt: Vec<u8>,
7578 ) -> crate::Result<Id20> {
7579 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7580 match dht.put_mutable(keypair_bytes, value, seq, salt).await {
7581 Ok(target) => {
7582 post_alert(
7583 &self.alert_tx,
7584 &self.alert_mask,
7585 AlertKind::DhtMutablePutComplete { target, seq },
7586 );
7587 Ok(target)
7588 }
7589 Err(e) => {
7590 post_alert(
7591 &self.alert_tx,
7592 &self.alert_mask,
7593 AlertKind::DhtItemError {
7594 target: Id20::from([0u8; 20]),
7595 message: e.to_string(),
7596 },
7597 );
7598 Err(crate::Error::Dht(e))
7599 }
7600 }
7601 }
7602
7603 async fn handle_dht_get_mutable(
7604 &self,
7605 public_key: [u8; 32],
7606 salt: Vec<u8>,
7607 ) -> crate::Result<Option<(Vec<u8>, i64)>> {
7608 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7609 let target = irontide_dht::compute_mutable_target(&public_key, &salt);
7610 match dht.get_mutable(public_key, salt).await {
7611 Ok(result) => {
7612 let (value, seq) = match &result {
7613 Some((v, s)) => (Some(v.clone()), Some(*s)),
7614 None => (None, None),
7615 };
7616 post_alert(
7617 &self.alert_tx,
7618 &self.alert_mask,
7619 AlertKind::DhtMutableGetResult {
7620 target,
7621 value,
7622 seq,
7623 public_key,
7624 },
7625 );
7626 Ok(result)
7627 }
7628 Err(e) => {
7629 post_alert(
7630 &self.alert_tx,
7631 &self.alert_mask,
7632 AlertKind::DhtItemError {
7633 target,
7634 message: e.to_string(),
7635 },
7636 );
7637 Err(crate::Error::Dht(e))
7638 }
7639 }
7640 }
7641
7642 async fn shutdown_all(&mut self) {
7643 let save_count = self.save_dirty_resume_files().await;
7645 if save_count > 0 {
7646 info!(save_count, "saved resume files on shutdown");
7647 }
7648
7649 for (info_hash, entry) in self.torrents.drain() {
7650 debug!(%info_hash, "shutting down torrent");
7651 let _ = entry.handle.shutdown().await;
7652 }
7653 if let Some(ref dht) = self.dht_v4 {
7654 let _ = dht.shutdown().await;
7655 }
7656 if let Some(ref dht) = self.dht_v6 {
7657 let _ = dht.shutdown().await;
7658 }
7659 if let Some(ref nat) = self.nat {
7660 nat.shutdown().await;
7661 }
7662 if let Some(ref lsd) = self.lsd {
7663 lsd.shutdown().await;
7664 }
7665 if let Some(ref socket) = self.utp_socket
7666 && let Err(e) = socket.shutdown().await
7667 {
7668 debug!(error = %e, "uTP socket shutdown error");
7669 }
7670 if let Some(ref socket) = self.utp_socket_v6
7671 && let Err(e) = socket.shutdown().await
7672 {
7673 debug!(error = %e, "uTP v6 socket shutdown error");
7674 }
7675 self.disk_manager.shutdown().await;
7676 }
7677}
7678
7679async fn recv_nat_event(
7682 rx: &mut Option<mpsc::Receiver<irontide_nat::NatEvent>>,
7683) -> irontide_nat::NatEvent {
7684 match rx {
7685 Some(r) => match r.recv().await {
7686 Some(event) => event,
7687 None => std::future::pending().await,
7688 },
7689 None => std::future::pending().await,
7690 }
7691}
7692
7693async fn recv_dht_ip(
7695 rx: &mut Option<mpsc::Receiver<std::net::IpAddr>>,
7696) -> Option<std::net::IpAddr> {
7697 match rx {
7698 Some(r) => r.recv().await,
7699 None => std::future::pending().await,
7700 }
7701}
7702
7703async fn prepare_add_torrent_off_actor(
7723 bundle: AddTorrentPrepBundle,
7724) -> crate::Result<PreparedAddTorrent> {
7725 let AddTorrentPrepBundle {
7726 torrent_meta,
7727 storage_override,
7728 torrent_config,
7729 disk_manager,
7730 dht_v4_broadcast,
7731 dht_v6_broadcast,
7732 global_up,
7733 global_down,
7734 slot_tuner,
7735 alert_tx,
7736 alert_mask,
7737 utp_socket,
7738 utp_socket_v6,
7739 ban_manager,
7740 ip_filter,
7741 plugins,
7742 sam_session,
7743 ssl_manager,
7744 factory,
7745 hash_pool,
7746 counters,
7747 m170_post,
7748 auto_managed,
7749 } = bundle;
7750
7751 let version = torrent_meta.version();
7752 let meta_v2 = torrent_meta.as_v2().cloned();
7753
7754 let meta = if let Some(v1) = torrent_meta.as_v1() {
7758 v1.clone()
7759 } else {
7760 let v2 = torrent_meta.as_v2().unwrap();
7761 synthesize_v1_from_v2(v2)
7762 };
7763 let info_hash = meta.info_hash;
7764 let is_private = meta.info.private == Some(1);
7765
7766 let storage: Arc<dyn TorrentStorage> = if let Some(s) = storage_override {
7768 s
7769 } else {
7770 let lengths = Lengths::new(
7771 meta.info.total_length(),
7772 meta.info.piece_length,
7773 DEFAULT_CHUNK_SIZE,
7774 );
7775 let files = torrent_config
7778 .content_layout
7779 .apply_to_files(meta.info.files());
7780 let file_paths: Vec<PathBuf> = files
7781 .iter()
7782 .map(|f| f.path.iter().collect::<PathBuf>())
7783 .collect();
7784 let file_lengths: Vec<u64> = files.iter().map(|f| f.length).collect();
7785 let prealloc_mode = torrent_config.preallocate_mode;
7786 match irontide_storage::FilesystemStorage::new(
7787 &torrent_config.download_dir,
7788 file_paths,
7789 file_lengths,
7790 lengths.clone(),
7791 None,
7792 prealloc_mode,
7793 torrent_config.filesystem_direct_io,
7794 ) {
7795 Ok(s) => Arc::new(s),
7796 Err(e) => {
7797 warn!("failed to create filesystem storage: {e}, falling back to memory");
7798 Arc::new(irontide_storage::MemoryStorage::new(lengths))
7799 }
7800 }
7801 };
7802 let disk_handle = disk_manager.register_torrent(info_hash, storage).await;
7803
7804 let handle = TorrentHandle::from_torrent(
7805 meta.clone(),
7806 version,
7807 meta_v2,
7808 disk_handle,
7809 disk_manager,
7810 torrent_config,
7811 dht_v4_broadcast.subscribe(),
7812 dht_v6_broadcast.subscribe(),
7813 global_up,
7814 global_down,
7815 slot_tuner,
7816 alert_tx.clone(),
7817 Arc::clone(&alert_mask),
7818 utp_socket,
7819 utp_socket_v6,
7820 ban_manager,
7821 ip_filter,
7822 plugins,
7823 sam_session,
7824 ssl_manager,
7825 factory,
7826 Some(hash_pool),
7827 counters,
7828 )
7829 .await?;
7830
7831 post_alert(
7848 &alert_tx,
7849 &alert_mask,
7850 AlertKind::TorrentAdded {
7851 info_hash,
7852 name: meta.info.name.clone(),
7853 },
7854 );
7855 Ok(PreparedAddTorrent {
7856 handle,
7857 info_hash,
7858 is_private,
7859 m170_post,
7860 auto_managed,
7861 })
7862}
7863
7864struct ResumeSaveJob {
7870 info_hash: Id20,
7871 handle: TorrentHandle,
7872 queue_position: i64,
7873 auto_managed: i64,
7874}
7875
7876async fn run_resume_save_jobs(resume_dir: std::path::PathBuf, jobs: Vec<ResumeSaveJob>) -> usize {
7883 let torrents_dir = resume_dir.join("torrents");
7886 match tokio::task::spawn_blocking(move || std::fs::create_dir_all(&torrents_dir)).await {
7887 Ok(Ok(())) => {}
7888 Ok(Err(e)) => {
7889 warn!("failed to create resume dir: {e}");
7890 return 0;
7891 }
7892 Err(e) => {
7893 warn!("resume dir create task panicked: {e}");
7894 return 0;
7895 }
7896 }
7897
7898 let mut saved = 0usize;
7899 for job in &jobs {
7900 let mut rd = match job.handle.take_resume_if_dirty().await {
7907 Ok(Some(rd)) => rd,
7908 Ok(None) => continue,
7909 Err(e) => {
7910 warn!(info_hash = %job.info_hash, "failed to take resume data: {e}");
7911 continue;
7912 }
7913 };
7914 rd.queue_position = job.queue_position;
7915 rd.auto_managed = job.auto_managed;
7916
7917 let bytes = match crate::resume_file::serialize_resume(&rd) {
7922 Ok(b) => b,
7923 Err(e) => {
7924 warn!(info_hash = %job.info_hash, "failed to serialize resume data: {e}");
7925 redirty_after_failed_save(&job.handle, &job.info_hash).await;
7926 continue;
7927 }
7928 };
7929
7930 let path = crate::resume_file::resume_file_path(&resume_dir, &job.info_hash);
7932 let write_res =
7933 tokio::task::spawn_blocking(move || crate::resume_file::atomic_write(&path, &bytes))
7934 .await;
7935 match write_res {
7936 Ok(Ok(())) => {}
7937 Ok(Err(e)) => {
7938 warn!(info_hash = %job.info_hash, "failed to write resume file: {e}");
7939 redirty_after_failed_save(&job.handle, &job.info_hash).await;
7940 continue;
7941 }
7942 Err(e) => {
7943 warn!(info_hash = %job.info_hash, "resume write task panicked: {e}");
7944 redirty_after_failed_save(&job.handle, &job.info_hash).await;
7945 continue;
7946 }
7947 }
7948
7949 saved = saved.saturating_add(1);
7950 }
7951 saved
7952}
7953
7954async fn redirty_after_failed_save(handle: &TorrentHandle, info_hash: &Id20) {
7959 if let Err(e) = handle.mark_resume_dirty().await {
7960 warn!(info_hash = %info_hash, "failed to re-mark resume dirty after save failure: {e}");
7961 }
7962}
7963
7964fn synthesize_v1_from_v2(v2: &irontide_core::TorrentMetaV2) -> irontide_core::TorrentMetaV1 {
7965 use irontide_core::{FileEntry, InfoDict};
7966
7967 let info_hash = v2.info_hashes.best_v1();
7968
7969 let v2_files = v2.info.files();
7971 let file_entries: Vec<FileEntry> = v2_files
7972 .iter()
7973 .map(|f| FileEntry {
7974 length: f.attr.length,
7975 path: f.path.clone(),
7976 attr: None,
7977 mtime: None,
7978 symlink_path: None,
7979 })
7980 .collect();
7981
7982 let num_pieces = v2.info.num_pieces() as usize;
7985 let pieces = vec![0u8; num_pieces * 20];
7986
7987 let info = InfoDict {
7988 name: v2.info.name.clone(),
7989 piece_length: v2.info.piece_length,
7990 pieces,
7991 length: if file_entries.len() == 1 {
7992 Some(file_entries[0].length)
7993 } else {
7994 None
7995 },
7996 files: if file_entries.len() > 1 {
7997 Some(file_entries)
7998 } else {
7999 None
8000 },
8001 private: None,
8002 source: None,
8003 ssl_cert: v2.ssl_cert.clone(),
8004 similar: Vec::new(),
8005 collections: Vec::new(),
8006 };
8007
8008 irontide_core::TorrentMetaV1 {
8009 info_hash,
8010 announce: v2.announce.clone(),
8011 announce_list: v2.announce_list.clone(),
8012 comment: v2.comment.clone(),
8013 created_by: v2.created_by.clone(),
8014 creation_date: v2.creation_date,
8015 info,
8016 info_bytes: None,
8017 url_list: Vec::new(),
8018 httpseeds: Vec::new(),
8019 ssl_cert: v2.ssl_cert.clone(),
8020 }
8021}
8022
8023#[cfg(test)]
8024mod tests {
8025 use super::*;
8026 use crate::types::TorrentState;
8027 use irontide_core::{DEFAULT_CHUNK_SIZE, Lengths, TorrentMetaV1, torrent_from_bytes};
8028 use irontide_storage::MemoryStorage;
8029 use std::time::Duration;
8030
8031 #[test]
8034 fn m251_external_address_none_without_ip() {
8035 assert_eq!(
8036 SessionActor::compute_external_address(None, Some(6881), 6881),
8037 None
8038 );
8039 }
8040
8041 #[test]
8042 fn m251_external_address_prefers_mapped_port() {
8043 let ip = "203.0.113.7".parse().unwrap();
8044 assert_eq!(
8045 SessionActor::compute_external_address(Some(ip), Some(51413), 6881),
8046 Some("203.0.113.7:51413".parse().unwrap())
8047 );
8048 }
8049
8050 #[test]
8051 fn m251_external_address_falls_back_to_listen_port() {
8052 let ip = "203.0.113.7".parse().unwrap();
8053 assert_eq!(
8054 SessionActor::compute_external_address(Some(ip), None, 6881),
8055 Some("203.0.113.7:6881".parse().unwrap())
8056 );
8057 }
8058
8059 #[test]
8060 fn m251_external_address_none_for_ephemeral_unmapped() {
8061 let ip = "203.0.113.7".parse().unwrap();
8062 assert_eq!(
8063 SessionActor::compute_external_address(Some(ip), None, 0),
8064 None
8065 );
8066 }
8067
8068 fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
8069 use serde::Serialize;
8070
8071 #[derive(Serialize)]
8072 struct Info<'a> {
8073 length: u64,
8074 name: &'a str,
8075 #[serde(rename = "piece length")]
8076 piece_length: u64,
8077 #[serde(with = "serde_bytes")]
8078 pieces: &'a [u8],
8079 }
8080
8081 #[derive(Serialize)]
8082 struct Torrent<'a> {
8083 info: Info<'a>,
8084 }
8085
8086 let mut pieces = Vec::new();
8087 let mut offset = 0;
8088 while offset < data.len() {
8089 let end = (offset + piece_length as usize).min(data.len());
8090 let hash = irontide_core::sha1(&data[offset..end]);
8091 pieces.extend_from_slice(hash.as_bytes());
8092 offset = end;
8093 }
8094
8095 let t = Torrent {
8096 info: Info {
8097 length: data.len() as u64,
8098 name: "test",
8099 piece_length,
8100 pieces: &pieces,
8101 },
8102 };
8103
8104 let bytes = irontide_bencode::to_bytes(&t).unwrap();
8105 torrent_from_bytes(&bytes).unwrap()
8106 }
8107
8108 fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
8109 let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
8110 Arc::new(MemoryStorage::new(lengths))
8111 }
8112
8113 static TEST_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8114
8115 fn test_settings() -> Settings {
8116 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8117 let pid = std::process::id();
8118 let dl_dir = std::env::temp_dir().join(format!("irontide-session-lib-dl-{pid}-{n}"));
8119 let resume_dir =
8120 std::env::temp_dir().join(format!("irontide-session-lib-resume-{pid}-{n}"));
8121 let _ = std::fs::remove_dir_all(&dl_dir);
8122 let _ = std::fs::remove_dir_all(&resume_dir);
8123 let _ = std::fs::create_dir_all(&dl_dir);
8124
8125 Settings {
8126 listen_port: 0,
8127 download_dir: dl_dir,
8128 resume_data_dir: Some(resume_dir),
8129 max_torrents: 10,
8130 enable_dht: false,
8131 enable_pex: false,
8132 enable_lsd: false,
8133 enable_fast_extension: false,
8134 enable_utp: false,
8135 enable_upnp: false,
8136 enable_natpmp: false,
8137 enable_ipv6: false,
8138 alert_channel_size: 64,
8139 disk_io_threads: 2,
8140 disk_cache_size: 1024 * 1024,
8141 ..Settings::default()
8142 }
8143 }
8144
8145 #[tokio::test]
8148 async fn session_start_and_shutdown() {
8149 let session = SessionHandle::start(test_settings()).await.unwrap();
8150 let stats = session.session_stats().await.unwrap();
8151 assert_eq!(stats.active_torrents, 0);
8152 session.shutdown().await.unwrap();
8153 }
8154
8155 #[tokio::test]
8156 async fn peer_unchoke_durations_returns_none_for_missing_torrent() {
8157 let session = SessionHandle::start(test_settings()).await.unwrap();
8158 let bogus = Id20([0u8; 20]);
8159 let result = session.peer_unchoke_durations(bogus).await.unwrap();
8160 assert!(
8161 result.is_none(),
8162 "missing torrent must yield None, not an empty map"
8163 );
8164 session.shutdown().await.unwrap();
8165 }
8166
8167 #[tokio::test]
8168 async fn peer_unchoke_durations_returns_empty_map_for_known_torrent_with_no_peers() {
8169 let session = SessionHandle::start(test_settings()).await.unwrap();
8170 let data = vec![0xAB; 16384];
8171 let meta = make_test_torrent(&data, 16384);
8172 let storage = make_storage(&data, 16384);
8173 let info_hash = session
8174 .add_torrent_with_meta(meta.into(), Some(storage))
8175 .await
8176 .unwrap();
8177 let result = session
8178 .peer_unchoke_durations(info_hash)
8179 .await
8180 .unwrap()
8181 .expect("known torrent must yield Some, even with no peers");
8182 assert!(
8183 result.is_empty(),
8184 "fresh torrent with no peers has no unchoke history"
8185 );
8186 session.shutdown().await.unwrap();
8187 }
8188
8189 #[tokio::test]
8192 async fn add_and_list_torrent() {
8193 let session = SessionHandle::start(test_settings()).await.unwrap();
8194 let data = vec![0xAB; 16384];
8195 let meta = make_test_torrent(&data, 16384);
8196 let expected_hash = meta.info_hash;
8197
8198 let storage = make_storage(&data, 16384);
8199 let info_hash = session
8200 .add_torrent_with_meta(meta.into(), Some(storage))
8201 .await
8202 .unwrap();
8203 assert_eq!(info_hash, expected_hash);
8204
8205 let list = session.list_torrents().await.unwrap();
8206 assert_eq!(list.len(), 1);
8207 assert!(list.contains(&info_hash));
8208
8209 session.shutdown().await.unwrap();
8210 }
8211
8212 #[tokio::test]
8215 async fn remove_torrent() {
8216 let session = SessionHandle::start(test_settings()).await.unwrap();
8217 let data = vec![0xAB; 16384];
8218 let meta = make_test_torrent(&data, 16384);
8219 let storage = make_storage(&data, 16384);
8220
8221 let info_hash = session
8222 .add_torrent_with_meta(meta.into(), Some(storage))
8223 .await
8224 .unwrap();
8225 session.remove_torrent(info_hash).await.unwrap();
8226
8227 tokio::time::sleep(Duration::from_millis(50)).await;
8228
8229 let list = session.list_torrents().await.unwrap();
8230 assert!(list.is_empty());
8231
8232 session.shutdown().await.unwrap();
8233 }
8234
8235 #[tokio::test]
8238 async fn duplicate_torrent_rejected() {
8239 let session = SessionHandle::start(test_settings()).await.unwrap();
8240 let data = vec![0xAB; 16384];
8241 let meta = make_test_torrent(&data, 16384);
8242 let storage1 = make_storage(&data, 16384);
8243 let storage2 = make_storage(&data, 16384);
8244
8245 session
8246 .add_torrent_with_meta(meta.clone().into(), Some(storage1))
8247 .await
8248 .unwrap();
8249 let result = session
8250 .add_torrent_with_meta(meta.into(), Some(storage2))
8251 .await;
8252 assert!(result.is_err());
8253 assert!(result.unwrap_err().to_string().contains("duplicate"));
8254
8255 session.shutdown().await.unwrap();
8256 }
8257
8258 #[tokio::test]
8261 async fn session_at_capacity() {
8262 let mut config = test_settings();
8263 config.max_torrents = 1;
8264 let session = SessionHandle::start(config).await.unwrap();
8265
8266 let data1 = vec![0xAA; 16384];
8267 let meta1 = make_test_torrent(&data1, 16384);
8268 let storage1 = make_storage(&data1, 16384);
8269 session
8270 .add_torrent_with_meta(meta1.into(), Some(storage1))
8271 .await
8272 .unwrap();
8273
8274 let data2 = vec![0xBB; 16384];
8275 let meta2 = make_test_torrent(&data2, 16384);
8276 let storage2 = make_storage(&data2, 16384);
8277 let result = session
8278 .add_torrent_with_meta(meta2.into(), Some(storage2))
8279 .await;
8280 assert!(result.is_err());
8281 assert!(result.unwrap_err().to_string().contains("capacity"));
8282
8283 session.shutdown().await.unwrap();
8284 }
8285
8286 #[tokio::test]
8289 async fn torrent_stats_via_session() {
8290 let session = SessionHandle::start(test_settings()).await.unwrap();
8291 let data = vec![0xAB; 32768];
8292 let meta = make_test_torrent(&data, 16384);
8293 let storage = make_storage(&data, 16384);
8294
8295 let info_hash = session
8296 .add_torrent_with_meta(meta.into(), Some(storage))
8297 .await
8298 .unwrap();
8299 let stats = session.torrent_stats(info_hash).await.unwrap();
8300 assert_eq!(stats.state, TorrentState::Downloading);
8301 assert_eq!(stats.pieces_total, 2);
8302
8303 session.shutdown().await.unwrap();
8304 }
8305
8306 #[tokio::test]
8309 async fn torrent_info_via_session() {
8310 let session = SessionHandle::start(test_settings()).await.unwrap();
8311 let data = vec![0xAB; 32768];
8312 let meta = make_test_torrent(&data, 16384);
8313 let storage = make_storage(&data, 16384);
8314
8315 let info_hash = session
8316 .add_torrent_with_meta(meta.into(), Some(storage))
8317 .await
8318 .unwrap();
8319 let info = session.torrent_info(info_hash).await.unwrap();
8320 assert_eq!(info.info_hash, info_hash);
8321 assert_eq!(info.name, "test");
8322 assert_eq!(info.total_length, 32768);
8323 assert_eq!(info.num_pieces, 2);
8324 assert!(!info.private);
8325 assert_eq!(info.files.len(), 1);
8326 assert_eq!(info.files[0].length, 32768);
8327
8328 session.shutdown().await.unwrap();
8329 }
8330
8331 #[tokio::test]
8334 async fn pause_resume_via_session() {
8335 let session = SessionHandle::start(test_settings()).await.unwrap();
8336 let data = vec![0xAB; 16384];
8337 let meta = make_test_torrent(&data, 16384);
8338 let storage = make_storage(&data, 16384);
8339
8340 let info_hash = session
8341 .add_torrent_with_meta(meta.into(), Some(storage))
8342 .await
8343 .unwrap();
8344
8345 session.pause_torrent(info_hash).await.unwrap();
8346 tokio::time::sleep(Duration::from_millis(50)).await;
8347 let stats = session.torrent_stats(info_hash).await.unwrap();
8348 assert_eq!(stats.state, TorrentState::Paused);
8349
8350 session.resume_torrent(info_hash).await.unwrap();
8351 tokio::time::sleep(Duration::from_millis(50)).await;
8352 let stats = session.torrent_stats(info_hash).await.unwrap();
8353 assert_eq!(stats.state, TorrentState::Downloading);
8354
8355 session.shutdown().await.unwrap();
8356 }
8357
8358 #[tokio::test]
8361 async fn not_found_errors() {
8362 let session = SessionHandle::start(test_settings()).await.unwrap();
8363 let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8364
8365 assert!(session.torrent_stats(fake_hash).await.is_err());
8366 assert!(session.torrent_info(fake_hash).await.is_err());
8367 assert!(session.pause_torrent(fake_hash).await.is_err());
8368 assert!(session.resume_torrent(fake_hash).await.is_err());
8369 assert!(session.remove_torrent(fake_hash).await.is_err());
8370
8371 session.shutdown().await.unwrap();
8372 }
8373
8374 #[tokio::test]
8377 async fn session_stats_aggregate() {
8378 let session = SessionHandle::start(test_settings()).await.unwrap();
8379
8380 let data1 = vec![0xAA; 16384];
8381 let meta1 = make_test_torrent(&data1, 16384);
8382 let storage1 = make_storage(&data1, 16384);
8383 session
8384 .add_torrent_with_meta(meta1.into(), Some(storage1))
8385 .await
8386 .unwrap();
8387
8388 let data2 = vec![0xBB; 16384];
8389 let meta2 = make_test_torrent(&data2, 16384);
8390 let storage2 = make_storage(&data2, 16384);
8391 session
8392 .add_torrent_with_meta(meta2.into(), Some(storage2))
8393 .await
8394 .unwrap();
8395
8396 let stats = session.session_stats().await.unwrap();
8397 assert_eq!(stats.active_torrents, 2);
8398
8399 session.shutdown().await.unwrap();
8400 }
8401
8402 #[tokio::test]
8405 async fn add_magnet_and_list() {
8406 use irontide_core::Magnet;
8407
8408 let session = SessionHandle::start(test_settings()).await.unwrap();
8409 let magnet = Magnet {
8410 info_hashes: irontide_core::InfoHashes::v1_only(
8411 Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
8412 ),
8413 display_name: Some("test-magnet".into()),
8414 trackers: vec![],
8415 peers: vec![],
8416 selected_files: None,
8417 };
8418 let expected_hash = magnet.info_hash();
8419
8420 let info_hash = session.add_magnet(magnet).await.unwrap();
8421 assert_eq!(info_hash, expected_hash);
8422
8423 let list = session.list_torrents().await.unwrap();
8424 assert_eq!(list.len(), 1);
8425 assert!(list.contains(&info_hash));
8426
8427 let err = session.torrent_info(info_hash).await.unwrap_err();
8429 assert!(err.to_string().contains("metadata not yet available"));
8430
8431 session.shutdown().await.unwrap();
8432 }
8433
8434 #[tokio::test]
8437 async fn add_magnet_duplicate_rejected() {
8438 use irontide_core::Magnet;
8439
8440 let session = SessionHandle::start(test_settings()).await.unwrap();
8441 let magnet = Magnet {
8442 info_hashes: irontide_core::InfoHashes::v1_only(
8443 Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
8444 ),
8445 display_name: Some("test-magnet".into()),
8446 trackers: vec![],
8447 peers: vec![],
8448 selected_files: None,
8449 };
8450
8451 session.add_magnet(magnet.clone()).await.unwrap();
8452 let result = session.add_magnet(magnet).await;
8453 assert!(result.is_err());
8454 assert!(result.unwrap_err().to_string().contains("duplicate"));
8455
8456 session.shutdown().await.unwrap();
8457 }
8458
8459 #[tokio::test]
8462 async fn session_with_lsd_enabled() {
8463 use irontide_core::Magnet;
8464
8465 let mut config = test_settings();
8467 config.enable_lsd = true;
8468
8469 let session = SessionHandle::start(config).await.unwrap();
8470
8471 let data = vec![0xAB; 16384];
8473 let meta = make_test_torrent(&data, 16384);
8474 let storage = make_storage(&data, 16384);
8475 session
8476 .add_torrent_with_meta(meta.into(), Some(storage))
8477 .await
8478 .unwrap();
8479
8480 let magnet = Magnet {
8482 info_hashes: irontide_core::InfoHashes::v1_only(
8483 Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
8484 ),
8485 display_name: Some("lsd-test".into()),
8486 trackers: vec![],
8487 peers: vec![],
8488 selected_files: None,
8489 };
8490 session.add_magnet(magnet).await.unwrap();
8491
8492 let list = session.list_torrents().await.unwrap();
8493 assert_eq!(list.len(), 2);
8494
8495 session.shutdown().await.unwrap();
8496 }
8497
8498 #[tokio::test]
8501 async fn add_v2_only_torrent() {
8502 use irontide_bencode::BencodeValue;
8503 use std::collections::BTreeMap;
8504
8505 let session = SessionHandle::start(test_settings()).await.unwrap();
8506
8507 let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8509 attr_map.insert(b"length".to_vec(), BencodeValue::Integer(16384));
8510 let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8511 file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
8512 let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8513 ft_map.insert(b"test.dat".to_vec(), BencodeValue::Dict(file_node));
8514
8515 let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8516 info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
8517 info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
8518 info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"v2test".to_vec()));
8519 info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(16384));
8520
8521 let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8522 root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
8523
8524 let bytes = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
8525 let meta = irontide_core::torrent_from_bytes_any(&bytes).unwrap();
8526 assert!(meta.is_v2());
8527
8528 let info_hash = session.add_torrent_with_meta(meta, None).await.unwrap();
8530 let list = session.list_torrents().await.unwrap();
8531 assert!(list.contains(&info_hash));
8532
8533 session.shutdown().await.unwrap();
8534 }
8535
8536 #[tokio::test]
8539 async fn save_torrent_resume_data_via_session() {
8540 let session = SessionHandle::start(test_settings()).await.unwrap();
8541 let data = vec![0xAB; 32768];
8542 let meta = make_test_torrent(&data, 16384);
8543 let info_hash = meta.info_hash;
8544 let storage = make_storage(&data, 16384);
8545 session
8546 .add_torrent_with_meta(meta.into(), Some(storage))
8547 .await
8548 .unwrap();
8549
8550 let rd = session.save_torrent_resume_data(info_hash).await.unwrap();
8551 assert_eq!(rd.info_hash, info_hash.as_bytes().as_slice());
8552 assert_eq!(rd.name, "test");
8553 assert_eq!(rd.file_format, "libtorrent resume file");
8554 assert_eq!(rd.file_version, 1);
8555 assert!(!rd.pieces.is_empty());
8556 assert_eq!(rd.paused, 0);
8557
8558 session.shutdown().await.unwrap();
8559 }
8560
8561 #[tokio::test]
8564 async fn save_session_state_captures_all_torrents() {
8565 let session = SessionHandle::start(test_settings()).await.unwrap();
8566
8567 let data1 = vec![0xAA; 16384];
8568 let meta1 = make_test_torrent(&data1, 16384);
8569 let storage1 = make_storage(&data1, 16384);
8570 session
8571 .add_torrent_with_meta(meta1.into(), Some(storage1))
8572 .await
8573 .unwrap();
8574
8575 let data2 = vec![0xBB; 16384];
8576 let meta2 = make_test_torrent(&data2, 16384);
8577 let storage2 = make_storage(&data2, 16384);
8578 session
8579 .add_torrent_with_meta(meta2.into(), Some(storage2))
8580 .await
8581 .unwrap();
8582
8583 let state = session.save_session_state().await.unwrap();
8584 assert_eq!(state.torrents.len(), 2);
8585
8586 for rd in &state.torrents {
8587 assert_eq!(rd.file_format, "libtorrent resume file");
8588 assert_eq!(rd.info_hash.len(), 20);
8589 }
8590
8591 session.shutdown().await.unwrap();
8592 }
8593
8594 #[tokio::test]
8597 async fn save_resume_data_not_found() {
8598 let session = SessionHandle::start(test_settings()).await.unwrap();
8599 let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8600 let result = session.save_torrent_resume_data(fake_hash).await;
8601 assert!(result.is_err());
8602 assert!(result.unwrap_err().to_string().contains("not found"));
8603 session.shutdown().await.unwrap();
8604 }
8605
8606 #[tokio::test]
8609 async fn subscribe_receives_torrent_added_alert() {
8610 use crate::alert::AlertKind;
8611
8612 let session = SessionHandle::start(test_settings()).await.unwrap();
8613 let mut alerts = session.subscribe();
8614
8615 let data = vec![0xAB; 16384];
8616 let meta = make_test_torrent(&data, 16384);
8617 let storage = make_storage(&data, 16384);
8618 let _info_hash = session
8619 .add_torrent_with_meta(meta.into(), Some(storage))
8620 .await
8621 .unwrap();
8622
8623 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8624 .await
8625 .unwrap()
8626 .unwrap();
8627 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8628 session.shutdown().await.unwrap();
8629 }
8630
8631 #[tokio::test]
8634 async fn subscribe_receives_torrent_removed_alert() {
8635 use crate::alert::AlertKind;
8636 use crate::types::TorrentState;
8637
8638 let session = SessionHandle::start(test_settings()).await.unwrap();
8639 let mut alerts = session.subscribe();
8640
8641 let data = vec![0xAB; 16384];
8642 let meta = make_test_torrent(&data, 16384);
8643 let storage = make_storage(&data, 16384);
8644 let info_hash = session
8645 .add_torrent_with_meta(meta.into(), Some(storage))
8646 .await
8647 .unwrap();
8648
8649 while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_secs(1), alerts.recv()).await {
8651 if matches!(
8652 a.kind,
8653 AlertKind::StateChanged {
8654 new_state: TorrentState::Downloading,
8655 ..
8656 }
8657 ) {
8658 break;
8659 }
8660 }
8661
8662 session.remove_torrent(info_hash).await.unwrap();
8663
8664 loop {
8666 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8667 .await
8668 .unwrap()
8669 .unwrap();
8670 if matches!(alert.kind, AlertKind::TorrentRemoved { .. }) {
8671 break;
8672 }
8673 }
8674 session.shutdown().await.unwrap();
8675 }
8676
8677 #[tokio::test]
8680 async fn multiple_subscribers_each_receive_alerts() {
8681 use crate::alert::AlertKind;
8682
8683 let session = SessionHandle::start(test_settings()).await.unwrap();
8684 let mut sub1 = session.subscribe();
8685 let mut sub2 = session.subscribe();
8686
8687 let data = vec![0xAB; 16384];
8688 let meta = make_test_torrent(&data, 16384);
8689 let storage = make_storage(&data, 16384);
8690 session
8691 .add_torrent_with_meta(meta.into(), Some(storage))
8692 .await
8693 .unwrap();
8694
8695 let a1 = tokio::time::timeout(Duration::from_secs(2), sub1.recv())
8696 .await
8697 .unwrap()
8698 .unwrap();
8699 let a2 = tokio::time::timeout(Duration::from_secs(2), sub2.recv())
8700 .await
8701 .unwrap()
8702 .unwrap();
8703
8704 assert!(matches!(a1.kind, AlertKind::TorrentAdded { .. }));
8705 assert!(matches!(a2.kind, AlertKind::TorrentAdded { .. }));
8706 session.shutdown().await.unwrap();
8707 }
8708
8709 #[tokio::test]
8712 async fn set_alert_mask_filters_at_runtime() {
8713 use crate::alert::{AlertCategory, AlertKind};
8714
8715 let session = SessionHandle::start(test_settings()).await.unwrap();
8716 let mut alerts = session.subscribe();
8717
8718 let data = vec![0xAB; 16384];
8720 let meta = make_test_torrent(&data, 16384);
8721 let storage = make_storage(&data, 16384);
8722 session
8723 .add_torrent_with_meta(meta.into(), Some(storage))
8724 .await
8725 .unwrap();
8726
8727 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8728 .await
8729 .unwrap()
8730 .unwrap();
8731 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8732
8733 while tokio::time::timeout(Duration::from_millis(200), alerts.recv())
8735 .await
8736 .is_ok()
8737 {}
8738
8739 session.set_alert_mask(AlertCategory::empty());
8741
8742 let data2 = vec![0xBB; 16384];
8743 let meta2 = make_test_torrent(&data2, 16384);
8744 let storage2 = make_storage(&data2, 16384);
8745 session
8746 .add_torrent_with_meta(meta2.into(), Some(storage2))
8747 .await
8748 .unwrap();
8749
8750 let result = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await;
8752 assert!(result.is_err(), "should have timed out with empty mask");
8753
8754 session.set_alert_mask(AlertCategory::STATUS);
8756
8757 let data3 = vec![0xCC; 16384];
8758 let meta3 = make_test_torrent(&data3, 16384);
8759 let storage3 = make_storage(&data3, 16384);
8760 session
8761 .add_torrent_with_meta(meta3.into(), Some(storage3))
8762 .await
8763 .unwrap();
8764
8765 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8766 .await
8767 .unwrap()
8768 .unwrap();
8769 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8770
8771 session.shutdown().await.unwrap();
8772 }
8773
8774 #[tokio::test]
8777 async fn alert_stream_filters_per_subscriber() {
8778 use crate::alert::{AlertCategory, AlertKind};
8779
8780 let session = SessionHandle::start(test_settings()).await.unwrap();
8781
8782 let mut status_sub = session.subscribe_filtered(AlertCategory::STATUS);
8784 let mut peer_sub = session.subscribe_filtered(AlertCategory::PEER);
8786
8787 let data = vec![0xAB; 16384];
8788 let meta = make_test_torrent(&data, 16384);
8789 let storage = make_storage(&data, 16384);
8790 session
8791 .add_torrent_with_meta(meta.into(), Some(storage))
8792 .await
8793 .unwrap();
8794
8795 let alert = tokio::time::timeout(Duration::from_secs(2), status_sub.recv())
8797 .await
8798 .unwrap()
8799 .unwrap();
8800 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8801
8802 let result = tokio::time::timeout(Duration::from_millis(200), peer_sub.recv()).await;
8804 assert!(
8805 result.is_err(),
8806 "PEER subscriber should not get STATUS alerts"
8807 );
8808
8809 session.shutdown().await.unwrap();
8810 }
8811
8812 #[tokio::test]
8815 async fn state_changed_tracks_transitions() {
8816 use crate::alert::AlertKind;
8817
8818 let session = SessionHandle::start(test_settings()).await.unwrap();
8819 let mut alerts = session.subscribe();
8820
8821 let data = vec![0xAB; 16384];
8822 let meta = make_test_torrent(&data, 16384);
8823 let storage = make_storage(&data, 16384);
8824 let info_hash = session
8825 .add_torrent_with_meta(meta.into(), Some(storage))
8826 .await
8827 .unwrap();
8828
8829 let _ = tokio::time::timeout(Duration::from_secs(1), alerts.recv())
8831 .await
8832 .unwrap();
8833
8834 session.pause_torrent(info_hash).await.unwrap();
8836 tokio::time::sleep(Duration::from_millis(100)).await;
8837
8838 let mut state_changes = Vec::new();
8840 let mut paused_alerts = Vec::new();
8841 while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8842 {
8843 match &a.kind {
8844 AlertKind::StateChanged {
8845 prev_state,
8846 new_state,
8847 ..
8848 } => {
8849 state_changes.push((*prev_state, *new_state));
8850 }
8851 AlertKind::TorrentPaused { .. } => {
8852 paused_alerts.push(a);
8853 }
8854 _ => {} }
8856 }
8857
8858 assert!(
8859 state_changes.contains(&(TorrentState::Downloading, TorrentState::Paused)),
8860 "expected Downloading→Paused, got: {state_changes:?}"
8861 );
8862 assert!(!paused_alerts.is_empty(), "expected TorrentPaused alert");
8863
8864 session.resume_torrent(info_hash).await.unwrap();
8866 tokio::time::sleep(Duration::from_millis(100)).await;
8867
8868 let mut resume_state_changes = Vec::new();
8869 let mut resumed_alerts = Vec::new();
8870 while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8871 {
8872 match &a.kind {
8873 AlertKind::StateChanged {
8874 prev_state,
8875 new_state,
8876 ..
8877 } => {
8878 resume_state_changes.push((*prev_state, *new_state));
8879 }
8880 AlertKind::TorrentResumed { .. } => {
8881 resumed_alerts.push(a);
8882 }
8883 _ => {}
8884 }
8885 }
8886
8887 assert!(
8888 resume_state_changes.contains(&(TorrentState::Paused, TorrentState::Downloading)),
8889 "expected Paused→Downloading, got: {resume_state_changes:?}"
8890 );
8891 assert!(!resumed_alerts.is_empty(), "expected TorrentResumed alert");
8892
8893 session.shutdown().await.unwrap();
8894 }
8895
8896 #[tokio::test]
8897 async fn session_config_creates_utp_socket() {
8898 let mut config = test_settings();
8900 config.enable_utp = true;
8901 let session = SessionHandle::start(config).await.unwrap();
8902 let stats = session.session_stats().await.unwrap();
8903 assert_eq!(stats.active_torrents, 0);
8904 session.shutdown().await.unwrap();
8905 }
8906
8907 #[test]
8908 fn settings_nat_defaults() {
8909 let s = Settings::default();
8910 assert!(s.enable_upnp, "enable_upnp should default to true");
8911 assert!(s.enable_natpmp, "enable_natpmp should default to true");
8912 }
8913
8914 #[tokio::test]
8915 async fn session_with_nat_disabled() {
8916 let config = test_settings();
8917 assert!(!config.enable_upnp);
8919 assert!(!config.enable_natpmp);
8920 let session = SessionHandle::start(config).await.unwrap();
8921 let stats = session.session_stats().await.unwrap();
8922 assert_eq!(stats.active_torrents, 0);
8923 session.shutdown().await.unwrap();
8924 }
8925
8926 #[test]
8929 fn anonymous_mode_disables_discovery() {
8930 let mut config = test_settings();
8931 config.anonymous_mode = true;
8932 config.enable_dht = true;
8933 config.enable_lsd = true;
8934 config.enable_upnp = true;
8935 config.enable_natpmp = true;
8936
8937 if config.anonymous_mode {
8940 config.enable_dht = false;
8941 config.enable_lsd = false;
8942 config.enable_upnp = false;
8943 config.enable_natpmp = false;
8944 }
8945
8946 assert!(!config.enable_dht);
8947 assert!(!config.enable_lsd);
8948 assert!(!config.enable_upnp);
8949 assert!(!config.enable_natpmp);
8950 }
8951
8952 #[tokio::test]
8953 async fn anonymous_mode_session_starts_with_discovery_disabled() {
8954 let mut config = test_settings();
8955 config.anonymous_mode = true;
8956 config.enable_dht = true;
8958 config.enable_lsd = true;
8959
8960 let session = SessionHandle::start(config).await.unwrap();
8961 let stats = session.session_stats().await.unwrap();
8962 assert_eq!(stats.active_torrents, 0);
8963 session.shutdown().await.unwrap();
8964 }
8965
8966 #[test]
8967 fn force_proxy_requires_proxy_configured() {
8968 let mut config = test_settings();
8969 config.force_proxy = true;
8970 config.proxy = crate::proxy::ProxyConfig::default(); assert_eq!(config.proxy.proxy_type, crate::proxy::ProxyType::None);
8974 assert!(config.force_proxy);
8975 }
8977
8978 #[tokio::test]
8979 async fn force_proxy_errors_without_proxy() {
8980 let mut config = test_settings();
8981 config.force_proxy = true;
8982 let result = SessionHandle::start(config).await;
8985 assert!(result.is_err());
8986 match result {
8987 Err(e) => assert!(
8988 e.to_string().contains("force_proxy"),
8989 "error should mention force_proxy: {e}"
8990 ),
8991 Ok(_) => panic!("expected error"),
8992 }
8993 }
8994
8995 #[test]
8996 fn force_proxy_disables_features() {
8997 let mut config = test_settings();
8998 config.force_proxy = true;
8999 config.proxy = crate::proxy::ProxyConfig {
9000 proxy_type: crate::proxy::ProxyType::Socks5,
9001 hostname: "proxy.example.com".into(),
9002 port: 1080,
9003 ..Default::default()
9004 };
9005 config.enable_dht = true;
9006 config.enable_lsd = true;
9007 config.enable_upnp = true;
9008 config.enable_natpmp = true;
9009
9010 if config.force_proxy {
9012 config.enable_upnp = false;
9013 config.enable_natpmp = false;
9014 config.enable_dht = false;
9015 config.enable_lsd = false;
9016 }
9017
9018 assert!(!config.enable_dht);
9019 assert!(!config.enable_lsd);
9020 assert!(!config.enable_upnp);
9021 assert!(!config.enable_natpmp);
9022 }
9023
9024 #[test]
9025 fn proxy_config_round_trip() {
9026 let s = Settings {
9027 proxy: crate::proxy::ProxyConfig {
9028 proxy_type: crate::proxy::ProxyType::Socks5Password,
9029 hostname: "localhost".into(),
9030 port: 9050,
9031 username: Some("user".into()),
9032 password: Some("pass".into()),
9033 ..Default::default()
9034 },
9035 force_proxy: true,
9036 anonymous_mode: true,
9037 ..test_settings()
9038 };
9039
9040 assert_eq!(s.proxy.proxy_type, crate::proxy::ProxyType::Socks5Password);
9041 assert_eq!(s.proxy.hostname, "localhost");
9042 assert_eq!(s.proxy.port, 9050);
9043 assert!(s.force_proxy);
9044 assert!(s.anonymous_mode);
9045 assert_eq!(s.proxy.to_url(), "socks5://user:pass@localhost:9050");
9046 }
9047
9048 #[tokio::test]
9049 async fn apply_settings_runtime() {
9050 let session = SessionHandle::start(test_settings()).await.unwrap();
9051 let original = session.settings().await.unwrap();
9052 assert_eq!(original.max_torrents, 10);
9053
9054 let mut new = original.clone();
9055 new.max_torrents = 200;
9056 new.upload_rate_limit = 1_000_000;
9057 session.apply_settings(new).await.unwrap();
9058
9059 let updated = session.settings().await.unwrap();
9060 assert_eq!(updated.max_torrents, 200);
9061 assert_eq!(updated.upload_rate_limit, 1_000_000);
9062
9063 session.shutdown().await.unwrap();
9064 }
9065
9066 #[tokio::test]
9067 async fn apply_settings_validation_error() {
9068 let session = SessionHandle::start(test_settings()).await.unwrap();
9069
9070 let bad = Settings {
9072 force_proxy: true,
9073 ..Settings::default()
9074 };
9075 let result = session.apply_settings(bad).await;
9076 assert!(result.is_err());
9077
9078 let current = session.settings().await.unwrap();
9080 assert!(!current.force_proxy);
9081
9082 session.shutdown().await.unwrap();
9083 }
9084
9085 #[tokio::test]
9088 async fn session_stats_counters_accessible() {
9089 let session = SessionHandle::start(test_settings()).await.unwrap();
9090 let counters = session.counters();
9091 let _ = counters.uptime_secs();
9095 assert_eq!(counters.len(), crate::stats::NUM_METRICS);
9096 session.shutdown().await.unwrap();
9097 }
9098
9099 #[tokio::test]
9100 async fn post_session_stats_fires_alert() {
9101 use crate::alert::{AlertCategory, AlertKind};
9102
9103 let session = SessionHandle::start(test_settings()).await.unwrap();
9104 let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
9105
9106 session.post_session_stats().await.unwrap();
9107
9108 let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
9109 .await
9110 .expect("timed out waiting for SessionStatsAlert")
9111 .expect("recv error");
9112 assert!(
9113 matches!(alert.kind, AlertKind::SessionStatsAlert { ref values } if values.len() == crate::stats::NUM_METRICS),
9114 "expected SessionStatsAlert with {} values, got {:?}",
9115 crate::stats::NUM_METRICS,
9116 alert.kind,
9117 );
9118 session.shutdown().await.unwrap();
9119 }
9120
9121 #[tokio::test]
9122 async fn session_stats_include_torrent_count() {
9123 use crate::alert::{AlertCategory, AlertKind};
9124
9125 let session = SessionHandle::start(test_settings()).await.unwrap();
9126 let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
9127
9128 let data = vec![0xAB; 16384];
9130 let meta = make_test_torrent(&data, 16384);
9131 let storage = make_storage(&data, 16384);
9132 session
9133 .add_torrent_with_meta(meta.into(), Some(storage))
9134 .await
9135 .unwrap();
9136
9137 session.post_session_stats().await.unwrap();
9138
9139 let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
9140 .await
9141 .expect("timed out waiting for SessionStatsAlert")
9142 .expect("recv error");
9143 match alert.kind {
9144 AlertKind::SessionStatsAlert { values } => {
9145 assert!(
9146 values[crate::stats::SES_NUM_TORRENTS] > 0,
9147 "SES_NUM_TORRENTS should be > 0 after adding a torrent, got {}",
9148 values[crate::stats::SES_NUM_TORRENTS],
9149 );
9150 }
9151 other => panic!("expected SessionStatsAlert, got {other:?}"),
9152 }
9153 session.shutdown().await.unwrap();
9154 }
9155
9156 #[tokio::test]
9157 async fn stats_timer_disabled_when_zero() {
9158 use crate::alert::AlertCategory;
9159
9160 let mut config = test_settings();
9161 config.stats_report_interval = 0;
9162 let session = SessionHandle::start(config).await.unwrap();
9163 let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
9164
9165 let result = tokio::time::timeout(Duration::from_millis(200), stats_sub.recv()).await;
9167 assert!(
9168 result.is_err(),
9169 "no SessionStatsAlert should fire when stats_report_interval is 0"
9170 );
9171 session.shutdown().await.unwrap();
9172 }
9173
9174 #[tokio::test]
9175 async fn sample_infohashes_timer_disabled_when_zero() {
9176 use crate::alert::AlertCategory;
9177
9178 let mut config = test_settings();
9179 config.dht_sample_infohashes_interval = 0;
9180 let session = SessionHandle::start(config).await.unwrap();
9181 let mut dht_sub = session.subscribe_filtered(AlertCategory::DHT);
9182
9183 let result = tokio::time::timeout(Duration::from_millis(200), dht_sub.recv()).await;
9185 assert!(
9186 result.is_err(),
9187 "no DhtSampleInfohashes alert should fire when interval is 0"
9188 );
9189 session.shutdown().await.unwrap();
9190 }
9191
9192 #[tokio::test]
9195 async fn open_file_not_found() {
9196 let session = SessionHandle::start(test_settings()).await.unwrap();
9197 let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9198 let result = session.open_file(fake_hash, 0).await;
9199 assert!(result.is_err());
9200 let err = result.err().unwrap();
9201 assert!(err.to_string().contains("not found"));
9202 session.shutdown().await.unwrap();
9203 }
9204
9205 #[tokio::test]
9208 async fn open_file_routes_to_torrent() {
9209 let session = SessionHandle::start(test_settings()).await.unwrap();
9210 let data = vec![0xAB; 32768];
9211 let meta = make_test_torrent(&data, 16384);
9212 let storage = make_storage(&data, 16384);
9213
9214 let info_hash = session
9215 .add_torrent_with_meta(meta.into(), Some(storage))
9216 .await
9217 .unwrap();
9218
9219 let stream = session.open_file(info_hash, 0).await;
9221 assert!(stream.is_ok(), "open_file should succeed for file_index 0");
9222
9223 let result = session.open_file(info_hash, 999).await;
9225 assert!(
9226 result.is_err(),
9227 "open_file should fail for invalid file_index"
9228 );
9229
9230 session.shutdown().await.unwrap();
9231 }
9232
9233 #[tokio::test]
9236 async fn session_force_reannounce() {
9237 let session = SessionHandle::start(test_settings()).await.unwrap();
9238 let data = vec![0xAB; 16384];
9239 let meta = make_test_torrent(&data, 16384);
9240 let storage = make_storage(&data, 16384);
9241 let info_hash = session
9242 .add_torrent_with_meta(meta.into(), Some(storage))
9243 .await
9244 .unwrap();
9245
9246 let result = session.force_reannounce(info_hash).await;
9248 assert!(
9249 result.is_ok(),
9250 "force_reannounce should succeed: {result:?}"
9251 );
9252
9253 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9255 assert!(session.force_reannounce(fake).await.is_err());
9256
9257 session.shutdown().await.unwrap();
9258 }
9259
9260 #[tokio::test]
9263 async fn session_tracker_list() {
9264 let session = SessionHandle::start(test_settings()).await.unwrap();
9265 let data = vec![0xAB; 16384];
9266 let meta = make_test_torrent(&data, 16384);
9267 let storage = make_storage(&data, 16384);
9268 let info_hash = session
9269 .add_torrent_with_meta(meta.into(), Some(storage))
9270 .await
9271 .unwrap();
9272
9273 let trackers = session.tracker_list(info_hash).await.unwrap();
9275 assert!(trackers.is_empty(), "test torrent has no trackers");
9276
9277 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9279 assert!(session.tracker_list(fake).await.is_err());
9280
9281 session.shutdown().await.unwrap();
9282 }
9283
9284 #[tokio::test]
9287 async fn session_scrape() {
9288 let session = SessionHandle::start(test_settings()).await.unwrap();
9289 let data = vec![0xAB; 16384];
9290 let meta = make_test_torrent(&data, 16384);
9291 let storage = make_storage(&data, 16384);
9292 let info_hash = session
9293 .add_torrent_with_meta(meta.into(), Some(storage))
9294 .await
9295 .unwrap();
9296
9297 let scrape = session.scrape(info_hash).await.unwrap();
9299 assert!(scrape.is_none(), "test torrent has no trackers to scrape");
9300
9301 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9303 assert!(session.scrape(fake).await.is_err());
9304
9305 session.shutdown().await.unwrap();
9306 }
9307
9308 #[tokio::test]
9311 async fn session_set_file_priority() {
9312 let session = SessionHandle::start(test_settings()).await.unwrap();
9313 let data = vec![0xAB; 16384];
9314 let meta = make_test_torrent(&data, 16384);
9315 let storage = make_storage(&data, 16384);
9316 let info_hash = session
9317 .add_torrent_with_meta(meta.into(), Some(storage))
9318 .await
9319 .unwrap();
9320
9321 let result = session
9323 .set_file_priority(info_hash, 0, irontide_core::FilePriority::Normal)
9324 .await;
9325 assert!(
9326 result.is_ok(),
9327 "set_file_priority should succeed: {result:?}"
9328 );
9329
9330 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9332 assert!(
9333 session
9334 .set_file_priority(fake, 0, irontide_core::FilePriority::Normal)
9335 .await
9336 .is_err()
9337 );
9338
9339 session.shutdown().await.unwrap();
9340 }
9341
9342 #[tokio::test]
9345 async fn session_file_priorities() {
9346 let session = SessionHandle::start(test_settings()).await.unwrap();
9347 let data = vec![0xAB; 16384];
9348 let meta = make_test_torrent(&data, 16384);
9349 let storage = make_storage(&data, 16384);
9350 let info_hash = session
9351 .add_torrent_with_meta(meta.into(), Some(storage))
9352 .await
9353 .unwrap();
9354
9355 let priorities = session.file_priorities(info_hash).await.unwrap();
9357 assert_eq!(
9358 priorities.len(),
9359 1,
9360 "single-file torrent should have 1 file priority"
9361 );
9362 assert_eq!(priorities[0], irontide_core::FilePriority::Normal);
9363
9364 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9366 assert!(session.file_priorities(fake).await.is_err());
9367
9368 session.shutdown().await.unwrap();
9369 }
9370
9371 #[tokio::test]
9374 async fn set_download_limit_zero_means_unlimited() {
9375 let session = SessionHandle::start(test_settings()).await.unwrap();
9376 let data = vec![0xAB; 16384];
9377 let meta = make_test_torrent(&data, 16384);
9378 let storage = make_storage(&data, 16384);
9379 let info_hash = session
9380 .add_torrent_with_meta(meta.into(), Some(storage))
9381 .await
9382 .unwrap();
9383
9384 session.set_download_limit(info_hash, 50_000).await.unwrap();
9386 session.set_download_limit(info_hash, 0).await.unwrap();
9387 let limit = session.download_limit(info_hash).await.unwrap();
9388 assert_eq!(limit, 0, "0 means unlimited");
9389
9390 session.shutdown().await.unwrap();
9391 }
9392
9393 #[tokio::test]
9396 async fn set_upload_limit_persists() {
9397 let session = SessionHandle::start(test_settings()).await.unwrap();
9398 let data = vec![0xAB; 16384];
9399 let meta = make_test_torrent(&data, 16384);
9400 let storage = make_storage(&data, 16384);
9401 let info_hash = session
9402 .add_torrent_with_meta(meta.into(), Some(storage))
9403 .await
9404 .unwrap();
9405
9406 session.set_upload_limit(info_hash, 100_000).await.unwrap();
9407 let limit = session.upload_limit(info_hash).await.unwrap();
9408 assert_eq!(limit, 100_000);
9409
9410 session.shutdown().await.unwrap();
9411 }
9412
9413 #[tokio::test]
9416 async fn download_limit_default_is_zero() {
9417 let session = SessionHandle::start(test_settings()).await.unwrap();
9418 let data = vec![0xAB; 16384];
9419 let meta = make_test_torrent(&data, 16384);
9420 let storage = make_storage(&data, 16384);
9421 let info_hash = session
9422 .add_torrent_with_meta(meta.into(), Some(storage))
9423 .await
9424 .unwrap();
9425
9426 let limit = session.download_limit(info_hash).await.unwrap();
9428 assert_eq!(limit, 0, "default download limit should be 0 (unlimited)");
9429
9430 session.shutdown().await.unwrap();
9431 }
9432
9433 #[tokio::test]
9436 async fn rate_limit_round_trip() {
9437 let session = SessionHandle::start(test_settings()).await.unwrap();
9438 let data = vec![0xAB; 16384];
9439 let meta = make_test_torrent(&data, 16384);
9440 let storage = make_storage(&data, 16384);
9441 let info_hash = session
9442 .add_torrent_with_meta(meta.into(), Some(storage))
9443 .await
9444 .unwrap();
9445
9446 session
9448 .set_download_limit(info_hash, 1_000_000)
9449 .await
9450 .unwrap();
9451 session.set_upload_limit(info_hash, 500_000).await.unwrap();
9452
9453 let dl = session.download_limit(info_hash).await.unwrap();
9455 let ul = session.upload_limit(info_hash).await.unwrap();
9456 assert_eq!(dl, 1_000_000);
9457 assert_eq!(ul, 500_000);
9458
9459 session
9461 .set_download_limit(info_hash, 2_000_000)
9462 .await
9463 .unwrap();
9464 let dl = session.download_limit(info_hash).await.unwrap();
9465 assert_eq!(dl, 2_000_000);
9466
9467 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9469 assert!(session.download_limit(fake).await.is_err());
9470 assert!(session.upload_limit(fake).await.is_err());
9471 assert!(session.set_download_limit(fake, 100).await.is_err());
9472 assert!(session.set_upload_limit(fake, 100).await.is_err());
9473
9474 session.shutdown().await.unwrap();
9475 }
9476
9477 #[tokio::test]
9480 async fn sequential_download_toggle() {
9481 let session = SessionHandle::start(test_settings()).await.unwrap();
9482 let data = vec![0xAB; 16384];
9483 let meta = make_test_torrent(&data, 16384);
9484 let storage = make_storage(&data, 16384);
9485 let info_hash = session
9486 .add_torrent_with_meta(meta.into(), Some(storage))
9487 .await
9488 .unwrap();
9489
9490 session
9492 .set_sequential_download(info_hash, true)
9493 .await
9494 .unwrap();
9495 assert!(session.is_sequential_download(info_hash).await.unwrap());
9496
9497 session
9499 .set_sequential_download(info_hash, false)
9500 .await
9501 .unwrap();
9502 assert!(!session.is_sequential_download(info_hash).await.unwrap());
9503
9504 session.shutdown().await.unwrap();
9505 }
9506
9507 #[tokio::test]
9510 async fn super_seeding_toggle() {
9511 let session = SessionHandle::start(test_settings()).await.unwrap();
9512 let data = vec![0xAB; 16384];
9513 let meta = make_test_torrent(&data, 16384);
9514 let storage = make_storage(&data, 16384);
9515 let info_hash = session
9516 .add_torrent_with_meta(meta.into(), Some(storage))
9517 .await
9518 .unwrap();
9519
9520 session.set_super_seeding(info_hash, true).await.unwrap();
9522 assert!(session.is_super_seeding(info_hash).await.unwrap());
9523
9524 session.set_super_seeding(info_hash, false).await.unwrap();
9526 assert!(!session.is_super_seeding(info_hash).await.unwrap());
9527
9528 session.shutdown().await.unwrap();
9529 }
9530
9531 #[tokio::test]
9534 async fn sequential_download_default_false() {
9535 let session = SessionHandle::start(test_settings()).await.unwrap();
9536 let data = vec![0xAB; 16384];
9537 let meta = make_test_torrent(&data, 16384);
9538 let storage = make_storage(&data, 16384);
9539 let info_hash = session
9540 .add_torrent_with_meta(meta.into(), Some(storage))
9541 .await
9542 .unwrap();
9543
9544 assert!(!session.is_sequential_download(info_hash).await.unwrap());
9546
9547 session.shutdown().await.unwrap();
9548 }
9549
9550 #[tokio::test]
9553 async fn super_seeding_default_false() {
9554 let session = SessionHandle::start(test_settings()).await.unwrap();
9555 let data = vec![0xAB; 16384];
9556 let meta = make_test_torrent(&data, 16384);
9557 let storage = make_storage(&data, 16384);
9558 let info_hash = session
9559 .add_torrent_with_meta(meta.into(), Some(storage))
9560 .await
9561 .unwrap();
9562
9563 assert!(!session.is_super_seeding(info_hash).await.unwrap());
9565
9566 session.shutdown().await.unwrap();
9567 }
9568
9569 #[tokio::test]
9572 async fn seed_mode_flips_user_flag() {
9573 let session = SessionHandle::start(test_settings()).await.unwrap();
9574 let data = vec![0xAB; 16384];
9575 let meta = make_test_torrent(&data, 16384);
9576 let storage = make_storage(&data, 16384);
9577 let info_hash = session
9578 .add_torrent_with_meta(meta.into(), Some(storage))
9579 .await
9580 .unwrap();
9581
9582 let stats_before = session.torrent_stats(info_hash).await.unwrap();
9584 assert!(
9585 !stats_before.user_seed_mode,
9586 "new torrent should not start in user seed mode"
9587 );
9588
9589 session.set_seed_mode(info_hash, true).await.unwrap();
9591 let stats_on = session.torrent_stats(info_hash).await.unwrap();
9592 assert!(
9593 stats_on.user_seed_mode,
9594 "stats should reflect user_seed_mode=true after enabling"
9595 );
9596
9597 session.set_seed_mode(info_hash, false).await.unwrap();
9599 let stats_off = session.torrent_stats(info_hash).await.unwrap();
9600 assert!(
9601 !stats_off.user_seed_mode,
9602 "stats should reflect user_seed_mode=false after disabling"
9603 );
9604
9605 session.shutdown().await.unwrap();
9606 }
9607
9608 #[tokio::test]
9611 async fn seed_mode_round_trip() {
9612 let session = SessionHandle::start(test_settings()).await.unwrap();
9616 let data = vec![0xAB; 16384];
9617 let meta = make_test_torrent(&data, 16384);
9618 let storage = make_storage(&data, 16384);
9619 let info_hash = session
9620 .add_torrent_with_meta(meta.into(), Some(storage))
9621 .await
9622 .unwrap();
9623
9624 for (i, enabled) in [true, false, true, true, false].iter().enumerate() {
9625 session.set_seed_mode(info_hash, *enabled).await.unwrap();
9626 let stats = session.torrent_stats(info_hash).await.unwrap();
9627 assert_eq!(
9628 stats.user_seed_mode, *enabled,
9629 "iteration {i}: stats.user_seed_mode should track the toggle"
9630 );
9631 }
9632
9633 session.shutdown().await.unwrap();
9634 }
9635
9636 #[tokio::test]
9639 async fn seed_mode_missing_info_hash_errors() {
9640 let session = SessionHandle::start(test_settings()).await.unwrap();
9641 let fake =
9642 irontide_core::Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
9643 let err = session
9644 .set_seed_mode(fake, true)
9645 .await
9646 .expect_err("set_seed_mode on unknown info hash must return an error");
9647 match err {
9648 crate::Error::TorrentNotFound(h) => assert_eq!(h, fake),
9649 other => panic!("expected TorrentNotFound, got {other:?}"),
9650 }
9651 session.shutdown().await.unwrap();
9652 }
9653
9654 #[tokio::test]
9657 async fn seed_mode_idempotent() {
9658 let session = SessionHandle::start(test_settings()).await.unwrap();
9660 let data = vec![0xAB; 16384];
9661 let meta = make_test_torrent(&data, 16384);
9662 let storage = make_storage(&data, 16384);
9663 let info_hash = session
9664 .add_torrent_with_meta(meta.into(), Some(storage))
9665 .await
9666 .unwrap();
9667
9668 session.set_seed_mode(info_hash, true).await.unwrap();
9670 session.set_seed_mode(info_hash, true).await.unwrap();
9671 assert!(
9672 session
9673 .torrent_stats(info_hash)
9674 .await
9675 .unwrap()
9676 .user_seed_mode
9677 );
9678
9679 session.set_seed_mode(info_hash, false).await.unwrap();
9681 session.set_seed_mode(info_hash, false).await.unwrap();
9682 assert!(
9683 !session
9684 .torrent_stats(info_hash)
9685 .await
9686 .unwrap()
9687 .user_seed_mode
9688 );
9689
9690 session.shutdown().await.unwrap();
9691 }
9692
9693 #[tokio::test]
9696 async fn add_tracker_increases_count() {
9697 let session = SessionHandle::start(test_settings()).await.unwrap();
9698 let data = vec![0xAB; 16384];
9699 let meta = make_test_torrent(&data, 16384);
9700 let storage = make_storage(&data, 16384);
9701 let info_hash = session
9702 .add_torrent_with_meta(meta.into(), Some(storage))
9703 .await
9704 .unwrap();
9705
9706 let before = session.tracker_list(info_hash).await.unwrap();
9708 assert!(before.is_empty());
9709
9710 session
9712 .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9713 .await
9714 .unwrap();
9715
9716 let after = session.tracker_list(info_hash).await.unwrap();
9717 assert_eq!(after.len(), 1);
9718 assert_eq!(after[0].url, "udp://tracker.example.com:6969/announce");
9719
9720 session.shutdown().await.unwrap();
9721 }
9722
9723 #[tokio::test]
9726 async fn replace_trackers_replaces_all() {
9727 let session = SessionHandle::start(test_settings()).await.unwrap();
9728 let data = vec![0xAB; 16384];
9729 let meta = make_test_torrent(&data, 16384);
9730 let storage = make_storage(&data, 16384);
9731 let info_hash = session
9732 .add_torrent_with_meta(meta.into(), Some(storage))
9733 .await
9734 .unwrap();
9735
9736 session
9738 .add_tracker(info_hash, "udp://tracker1.example.com:6969/announce".into())
9739 .await
9740 .unwrap();
9741 session
9742 .add_tracker(info_hash, "http://tracker2.example.com/announce".into())
9743 .await
9744 .unwrap();
9745 assert_eq!(session.tracker_list(info_hash).await.unwrap().len(), 2);
9746
9747 session
9749 .replace_trackers(
9750 info_hash,
9751 vec!["http://replacement.example.com/announce".into()],
9752 )
9753 .await
9754 .unwrap();
9755
9756 let after = session.tracker_list(info_hash).await.unwrap();
9757 assert_eq!(after.len(), 1);
9758 assert_eq!(after[0].url, "http://replacement.example.com/announce");
9759
9760 session.shutdown().await.unwrap();
9761 }
9762
9763 #[tokio::test]
9766 async fn add_tracker_deduplicates() {
9767 let session = SessionHandle::start(test_settings()).await.unwrap();
9768 let data = vec![0xAB; 16384];
9769 let meta = make_test_torrent(&data, 16384);
9770 let storage = make_storage(&data, 16384);
9771 let info_hash = session
9772 .add_torrent_with_meta(meta.into(), Some(storage))
9773 .await
9774 .unwrap();
9775
9776 session
9778 .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9779 .await
9780 .unwrap();
9781 session
9782 .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9783 .await
9784 .unwrap();
9785
9786 let trackers = session.tracker_list(info_hash).await.unwrap();
9788 assert_eq!(trackers.len(), 1);
9789
9790 session.shutdown().await.unwrap();
9791 }
9792
9793 #[tokio::test]
9796 async fn info_hashes_matches_added_torrent() {
9797 let session = SessionHandle::start(test_settings()).await.unwrap();
9798 let data = vec![0xAB; 16384];
9799 let meta = make_test_torrent(&data, 16384);
9800 let expected_v1 = meta.info_hash;
9801 let storage = make_storage(&data, 16384);
9802
9803 let info_hash = session
9804 .add_torrent_with_meta(meta.into(), Some(storage))
9805 .await
9806 .unwrap();
9807 let hashes = session.info_hashes(info_hash).await.unwrap();
9808 assert_eq!(hashes.v1, Some(expected_v1));
9809 assert!(hashes.v2.is_none());
9811
9812 session.shutdown().await.unwrap();
9813 }
9814
9815 #[tokio::test]
9818 async fn torrent_file_returns_meta() {
9819 let session = SessionHandle::start(test_settings()).await.unwrap();
9820 let data = vec![0xAB; 32768];
9821 let meta = make_test_torrent(&data, 16384);
9822 let storage = make_storage(&data, 16384);
9823
9824 let info_hash = session
9825 .add_torrent_with_meta(meta.into(), Some(storage))
9826 .await
9827 .unwrap();
9828 let torrent = session.torrent_file(info_hash).await.unwrap();
9829 assert!(torrent.is_some());
9830 let torrent = torrent.unwrap();
9831 assert_eq!(torrent.info_hash, info_hash);
9832 assert_eq!(torrent.info.name, "test");
9833 assert_eq!(torrent.info.total_length(), 32768);
9834
9835 session.shutdown().await.unwrap();
9836 }
9837
9838 #[tokio::test]
9841 async fn torrent_file_none_before_metadata() {
9842 let session = SessionHandle::start(test_settings()).await.unwrap();
9843 let magnet = irontide_core::Magnet::parse(
9844 "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&dn=test",
9845 )
9846 .unwrap();
9847
9848 let info_hash = session.add_magnet(magnet).await.unwrap();
9849 let torrent = session.torrent_file(info_hash).await.unwrap();
9850 assert!(torrent.is_none());
9852
9853 session.shutdown().await.unwrap();
9854 }
9855
9856 #[tokio::test]
9859 async fn force_dht_announce_no_error() {
9860 let session = SessionHandle::start(test_settings()).await.unwrap();
9861 let data = vec![0xAB; 16384];
9862 let meta = make_test_torrent(&data, 16384);
9863 let storage = make_storage(&data, 16384);
9864 let info_hash = session
9865 .add_torrent_with_meta(meta.into(), Some(storage))
9866 .await
9867 .unwrap();
9868
9869 let result = session.force_dht_announce(info_hash).await;
9871 assert!(
9872 result.is_ok(),
9873 "force_dht_announce should succeed: {result:?}"
9874 );
9875
9876 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9878 assert!(session.force_dht_announce(fake).await.is_err());
9879
9880 session.shutdown().await.unwrap();
9881 }
9882
9883 #[tokio::test]
9886 async fn force_lsd_announce_no_error() {
9887 let session = SessionHandle::start(test_settings()).await.unwrap();
9888 let data = vec![0xAB; 16384];
9889 let meta = make_test_torrent(&data, 16384);
9890 let storage = make_storage(&data, 16384);
9891 let info_hash = session
9892 .add_torrent_with_meta(meta.into(), Some(storage))
9893 .await
9894 .unwrap();
9895
9896 let result = session.force_lsd_announce(info_hash).await;
9898 assert!(
9899 result.is_ok(),
9900 "force_lsd_announce should succeed: {result:?}"
9901 );
9902
9903 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9905 assert!(session.force_lsd_announce(fake).await.is_err());
9906
9907 session.shutdown().await.unwrap();
9908 }
9909
9910 #[tokio::test]
9913 async fn read_piece_after_download() {
9914 let data = vec![0xCD; 32768]; let meta = make_test_torrent(&data, 16384);
9916 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
9917 let storage = Arc::new(MemoryStorage::new(lengths));
9918 storage.write_chunk(0, 0, &data[..16384]).unwrap();
9920 storage.write_chunk(1, 0, &data[16384..]).unwrap();
9921
9922 let session = SessionHandle::start(test_settings()).await.unwrap();
9923 let info_hash = session
9924 .add_torrent_with_meta(meta.into(), Some(storage))
9925 .await
9926 .unwrap();
9927
9928 let piece_data = session.read_piece(info_hash, 0).await.unwrap();
9930 assert_eq!(piece_data.len(), 16384);
9931 assert!(piece_data.iter().all(|&b| b == 0xCD));
9932
9933 let piece_data = session.read_piece(info_hash, 1).await.unwrap();
9935 assert_eq!(piece_data.len(), 16384);
9936 assert!(piece_data.iter().all(|&b| b == 0xCD));
9937
9938 let result = session.read_piece(info_hash, 999).await;
9940 assert!(result.is_err(), "read_piece out of range should fail");
9941
9942 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9944 assert!(session.read_piece(fake, 0).await.is_err());
9945
9946 session.shutdown().await.unwrap();
9947 }
9948
9949 #[tokio::test]
9952 async fn flush_cache_completes() {
9953 let session = SessionHandle::start(test_settings()).await.unwrap();
9954 let data = vec![0xAB; 16384];
9955 let meta = make_test_torrent(&data, 16384);
9956 let storage = make_storage(&data, 16384);
9957 let info_hash = session
9958 .add_torrent_with_meta(meta.into(), Some(storage))
9959 .await
9960 .unwrap();
9961
9962 let result = session.flush_cache(info_hash).await;
9964 assert!(result.is_ok(), "flush_cache should succeed: {result:?}");
9965
9966 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9968 assert!(session.flush_cache(fake).await.is_err());
9969
9970 session.shutdown().await.unwrap();
9971 }
9972
9973 fn test_settings_with_dht() -> Settings {
9976 let mut s = test_settings();
9977 s.enable_dht = true;
9978 s
9979 }
9980
9981 fn test_settings_with_lsd() -> Settings {
9982 let mut s = test_settings();
9983 s.enable_lsd = true;
9984 s
9985 }
9986
9987 #[tokio::test]
9988 async fn test_dht_disabled_returns_error() {
9989 let session = SessionHandle::start(test_settings()).await.unwrap();
9990
9991 let err = session
9993 .dht_put_immutable(b"test".to_vec())
9994 .await
9995 .unwrap_err();
9996 assert!(
9997 format!("{err:?}").contains("DhtDisabled"),
9998 "expected DhtDisabled, got {err:?}"
9999 );
10000
10001 let target = Id20::from([0u8; 20]);
10002 let err = session.dht_get_immutable(target).await.unwrap_err();
10003 assert!(
10004 format!("{err:?}").contains("DhtDisabled"),
10005 "expected DhtDisabled, got {err:?}"
10006 );
10007
10008 let err = session
10009 .dht_put_mutable([42u8; 32], b"val".to_vec(), 1, Vec::new())
10010 .await
10011 .unwrap_err();
10012 assert!(
10013 format!("{err:?}").contains("DhtDisabled"),
10014 "expected DhtDisabled, got {err:?}"
10015 );
10016
10017 let err = session
10018 .dht_get_mutable([42u8; 32], Vec::new())
10019 .await
10020 .unwrap_err();
10021 assert!(
10022 format!("{err:?}").contains("DhtDisabled"),
10023 "expected DhtDisabled, got {err:?}"
10024 );
10025
10026 session.shutdown().await.unwrap();
10027 }
10028
10029 #[tokio::test]
10030 async fn test_dht_put_get_immutable_round_trip() {
10031 let session = SessionHandle::start(test_settings_with_dht())
10032 .await
10033 .unwrap();
10034
10035 let value = b"hello BEP 44".to_vec();
10037 let target = session.dht_put_immutable(value.clone()).await.unwrap();
10038
10039 let got = session.dht_get_immutable(target).await.unwrap();
10042 assert_eq!(got, Some(value));
10043
10044 session.shutdown().await.unwrap();
10045 }
10046
10047 #[tokio::test]
10048 async fn test_dht_put_immutable_fires_alert() {
10049 use crate::alert::{AlertCategory, AlertKind};
10050
10051 let session = SessionHandle::start(test_settings_with_dht())
10052 .await
10053 .unwrap();
10054 let mut alerts = session.subscribe_filtered(AlertCategory::DHT);
10055
10056 let value = b"alert test".to_vec();
10057 let target = session.dht_put_immutable(value).await.unwrap();
10058
10059 let alert = tokio::time::timeout(Duration::from_secs(5), alerts.recv())
10061 .await
10062 .expect("timeout waiting for alert")
10063 .expect("alert channel closed");
10064
10065 match alert.kind {
10066 AlertKind::DhtPutComplete { target: t } => {
10067 assert_eq!(t, target);
10068 }
10069 other => panic!("expected DhtPutComplete, got {other:?}"),
10070 }
10071
10072 session.shutdown().await.unwrap();
10073 }
10074
10075 fn make_private_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
10079 use serde::Serialize;
10080
10081 #[derive(Serialize)]
10082 struct Info<'a> {
10083 length: u64,
10084 name: &'a str,
10085 #[serde(rename = "piece length")]
10086 piece_length: u64,
10087 #[serde(with = "serde_bytes")]
10088 pieces: &'a [u8],
10089 private: i64,
10090 }
10091
10092 #[derive(Serialize)]
10093 struct Torrent<'a> {
10094 info: Info<'a>,
10095 }
10096
10097 let mut pieces = Vec::new();
10098 let mut offset = 0;
10099 while offset < data.len() {
10100 let end = (offset + piece_length as usize).min(data.len());
10101 let hash = irontide_core::sha1(&data[offset..end]);
10102 pieces.extend_from_slice(hash.as_bytes());
10103 offset = end;
10104 }
10105
10106 let t = Torrent {
10107 info: Info {
10108 length: data.len() as u64,
10109 name: "private-test",
10110 piece_length,
10111 pieces: &pieces,
10112 private: 1,
10113 },
10114 };
10115
10116 let bytes = irontide_bencode::to_bytes(&t).unwrap();
10117 torrent_from_bytes(&bytes).unwrap()
10118 }
10119
10120 #[test]
10121 fn is_private_true_via_parsed_meta() {
10122 let data = vec![0xAB; 16384];
10124 let meta = make_private_torrent(&data, 16384);
10125 assert_eq!(
10126 meta.info.private,
10127 Some(1),
10128 "private field should be Some(1)"
10129 );
10130 }
10131
10132 #[test]
10133 fn is_private_false_for_public_torrent() {
10134 let data = vec![0xAB; 16384];
10136 let meta = make_test_torrent(&data, 16384);
10137 assert_eq!(
10138 meta.info.private, None,
10139 "public torrent should have no private flag"
10140 );
10141 }
10142
10143 #[test]
10144 fn private_torrent_config_disables_lsd() {
10145 let config = TorrentConfig::default();
10147 assert!(
10148 config.enable_lsd,
10149 "default TorrentConfig should have LSD enabled"
10150 );
10151 }
10152
10153 #[tokio::test]
10154 async fn force_lsd_announce_private_torrent_returns_error() {
10155 let session = SessionHandle::start(test_settings()).await.unwrap();
10156 let data = vec![0xAB; 16384];
10157 let meta = make_private_torrent(&data, 16384);
10158 let storage = make_storage(&data, 16384);
10159 let info_hash = session
10160 .add_torrent_with_meta(meta.into(), Some(storage))
10161 .await
10162 .unwrap();
10163
10164 let result = session.force_lsd_announce(info_hash).await;
10166 assert!(
10167 result.is_err(),
10168 "force_lsd_announce on private torrent should return error, got: {result:?}"
10169 );
10170 let err_str = format!("{:?}", result.unwrap_err());
10171 assert!(
10172 err_str.contains("InvalidSettings") || err_str.contains("LSD disabled"),
10173 "expected InvalidSettings error, got: {err_str}"
10174 );
10175
10176 session.shutdown().await.unwrap();
10177 }
10178
10179 #[tokio::test]
10180 async fn force_lsd_announce_public_torrent_does_not_trigger_bep27_error() {
10181 let session = SessionHandle::start(test_settings_with_lsd())
10191 .await
10192 .unwrap();
10193 let data = vec![0xAB; 16384];
10194 let meta = make_test_torrent(&data, 16384);
10195 let storage = make_storage(&data, 16384);
10196 let info_hash = session
10197 .add_torrent_with_meta(meta.into(), Some(storage))
10198 .await
10199 .unwrap();
10200
10201 let result = session.force_lsd_announce(info_hash).await;
10202 if let Err(e) = &result {
10203 assert!(
10204 !format!("{e:?}").contains("LSD disabled for private torrent"),
10205 "public torrent must NOT trigger BEP 27 error; got {e:?}"
10206 );
10207 }
10208
10209 session.shutdown().await.unwrap();
10210 }
10211
10212 #[tokio::test]
10213 async fn force_dht_announce_private_torrent_returns_error() {
10214 let session = SessionHandle::start(test_settings_with_dht())
10215 .await
10216 .unwrap();
10217 let data = vec![0xAB; 16384];
10218 let meta = make_private_torrent(&data, 16384);
10219 let storage = make_storage(&data, 16384);
10220 let info_hash = session
10221 .add_torrent_with_meta(meta.into(), Some(storage))
10222 .await
10223 .unwrap();
10224
10225 let result = session.force_dht_announce(info_hash).await;
10227 assert!(
10228 result.is_err(),
10229 "force_dht_announce on private torrent should return error, got: {result:?}"
10230 );
10231 let err_str = format!("{:?}", result.unwrap_err());
10232 assert!(
10233 err_str.contains("InvalidSettings")
10234 || err_str.contains("DHT disabled for private torrent"),
10235 "expected InvalidSettings / DHT-disabled error, got: {err_str}"
10236 );
10237
10238 session.shutdown().await.unwrap();
10239 }
10240
10241 #[tokio::test]
10242 async fn force_dht_announce_public_torrent_does_not_trigger_bep27_error() {
10243 let session = SessionHandle::start(test_settings_with_dht())
10244 .await
10245 .unwrap();
10246 let data = vec![0xAB; 16384];
10247 let meta = make_test_torrent(&data, 16384);
10248 let storage = make_storage(&data, 16384);
10249 let info_hash = session
10250 .add_torrent_with_meta(meta.into(), Some(storage))
10251 .await
10252 .unwrap();
10253
10254 let result = session.force_dht_announce(info_hash).await;
10255 if let Err(e) = &result {
10261 assert!(
10262 !format!("{e:?}").contains("DHT disabled for private torrent"),
10263 "public torrent must NOT trigger BEP 27 error; got {e:?}"
10264 );
10265 }
10266
10267 session.shutdown().await.unwrap();
10268 }
10269
10270 fn resume_test_settings(dir: &std::path::Path) -> Settings {
10273 Settings {
10274 resume_data_dir: Some(dir.to_path_buf()),
10275 save_resume_interval_secs: 0, ..test_settings()
10277 }
10278 }
10279
10280 #[tokio::test]
10281 async fn save_resume_state_empty_session_returns_zero() {
10282 let tmp = tempfile::TempDir::new().unwrap();
10283 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10284 .await
10285 .unwrap();
10286
10287 let count = session.save_resume_state().await.unwrap();
10288 assert_eq!(count, 0, "empty session should save 0 resume files");
10289
10290 session.shutdown().await.unwrap();
10291 }
10292
10293 #[tokio::test]
10294 async fn save_resume_state_saves_dirty_torrents() {
10295 let tmp = tempfile::TempDir::new().unwrap();
10296 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10297 .await
10298 .unwrap();
10299
10300 let data1 = vec![0xAA; 16384];
10302 let meta1 = make_test_torrent(&data1, 16384);
10303 let hash1 = meta1.info_hash;
10304 let storage1 = make_storage(&data1, 16384);
10305 session
10306 .add_torrent_with_meta(meta1.into(), Some(storage1))
10307 .await
10308 .unwrap();
10309
10310 let data2 = vec![0xBB; 16384];
10311 let meta2 = make_test_torrent(&data2, 16384);
10312 let hash2 = meta2.info_hash;
10313 let storage2 = make_storage(&data2, 16384);
10314 session
10315 .add_torrent_with_meta(meta2.into(), Some(storage2))
10316 .await
10317 .unwrap();
10318
10319 tokio::time::sleep(Duration::from_millis(50)).await;
10322
10323 let count = session.save_resume_state().await.unwrap();
10324 assert!(count <= 2, "should save at most 2 resume files");
10328
10329 let torrents_dir = tmp.path().join("torrents");
10331 if count > 0 {
10332 assert!(torrents_dir.exists(), "torrents/ directory should exist");
10333 }
10334
10335 let path1 = crate::resume_file::resume_file_path(tmp.path(), &hash1);
10337 let path2 = crate::resume_file::resume_file_path(tmp.path(), &hash2);
10338 let files_exist = usize::from(path1.exists()) + usize::from(path2.exists());
10339 assert_eq!(
10340 files_exist, count,
10341 "number of files on disk should match returned count"
10342 );
10343
10344 session.shutdown().await.unwrap();
10345 }
10346
10347 #[tokio::test]
10348 async fn save_resume_state_round_trip() {
10349 let tmp = tempfile::TempDir::new().unwrap();
10350 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10351 .await
10352 .unwrap();
10353
10354 let data = vec![0xCD; 32768];
10355 let meta = make_test_torrent(&data, 16384);
10356 let info_hash = meta.info_hash;
10357 let storage = make_storage(&data, 16384);
10358 session
10359 .add_torrent_with_meta(meta.into(), Some(storage))
10360 .await
10361 .unwrap();
10362
10363 tokio::time::sleep(Duration::from_millis(50)).await;
10365
10366 let count = session.save_resume_state().await.unwrap();
10367
10368 if count > 0 {
10370 let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10371 assert!(path.exists(), "resume file should exist after save");
10372
10373 let bytes = std::fs::read(&path).unwrap();
10374 let rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
10375 assert_eq!(
10376 rd.info_hash,
10377 info_hash.as_bytes().to_vec(),
10378 "deserialized info_hash should match"
10379 );
10380 assert_eq!(rd.name, "test", "deserialized name should match");
10381 }
10382
10383 session.shutdown().await.unwrap();
10384 }
10385
10386 #[tokio::test]
10387 async fn save_resume_state_clears_dirty_flag() {
10388 let tmp = tempfile::TempDir::new().unwrap();
10389 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10390 .await
10391 .unwrap();
10392
10393 let data = vec![0xEE; 16384];
10394 let meta = make_test_torrent(&data, 16384);
10395 let storage = make_storage(&data, 16384);
10396 session
10397 .add_torrent_with_meta(meta.into(), Some(storage))
10398 .await
10399 .unwrap();
10400
10401 tokio::time::sleep(Duration::from_millis(50)).await;
10402
10403 let first_count = session.save_resume_state().await.unwrap();
10404
10405 let second_count = session.save_resume_state().await.unwrap();
10407 assert_eq!(
10408 second_count, 0,
10409 "second save should return 0 after dirty flag cleared (first saved {first_count})"
10410 );
10411
10412 session.shutdown().await.unwrap();
10413 }
10414
10415 #[tokio::test]
10416 async fn save_resume_state_second_save_skips_clean() {
10417 let tmp = tempfile::TempDir::new().unwrap();
10418 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10419 .await
10420 .unwrap();
10421
10422 let data1 = vec![0xAA; 16384];
10423 let meta1 = make_test_torrent(&data1, 16384);
10424 let storage1 = make_storage(&data1, 16384);
10425 session
10426 .add_torrent_with_meta(meta1.into(), Some(storage1))
10427 .await
10428 .unwrap();
10429
10430 let data2 = vec![0xBB; 16384];
10431 let meta2 = make_test_torrent(&data2, 16384);
10432 let storage2 = make_storage(&data2, 16384);
10433 session
10434 .add_torrent_with_meta(meta2.into(), Some(storage2))
10435 .await
10436 .unwrap();
10437
10438 tokio::time::sleep(Duration::from_millis(50)).await;
10439
10440 let first = session.save_resume_state().await.unwrap();
10442
10443 let second = session.save_resume_state().await.unwrap();
10445 assert_eq!(
10446 second, 0,
10447 "second save should skip all clean torrents (first saved {first})"
10448 );
10449
10450 session.shutdown().await.unwrap();
10451 }
10452
10453 #[tokio::test]
10458 async fn load_resume_empty_dir_returns_zeros() {
10459 let tmp = tempfile::TempDir::new().unwrap();
10460 let mut settings = test_settings();
10461 settings.resume_data_dir = Some(tmp.path().to_path_buf());
10462
10463 let session = SessionHandle::start(settings).await.unwrap();
10464 let result = session.load_resume_state().await.unwrap();
10465 assert_eq!(result.restored, 0);
10466 assert_eq!(result.skipped, 0);
10467 assert_eq!(result.failed, 0);
10468
10469 session.shutdown().await.unwrap();
10470 }
10471
10472 #[tokio::test]
10475 async fn load_resume_corrupt_file_counted_as_failed() {
10476 let tmp = tempfile::TempDir::new().unwrap();
10477 let torrents_dir = tmp.path().join("torrents");
10478 std::fs::create_dir_all(&torrents_dir).unwrap();
10479
10480 let mut settings = test_settings();
10481 settings.resume_data_dir = Some(tmp.path().to_path_buf());
10482
10483 let session = SessionHandle::start(settings).await.unwrap();
10485
10486 tokio::time::sleep(Duration::from_millis(50)).await;
10489
10490 std::fs::write(
10493 torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume"),
10494 b"this is not valid bencode",
10495 )
10496 .unwrap();
10497
10498 let result = session.load_resume_state().await.unwrap();
10499 assert_eq!(result.restored, 0);
10500 assert_eq!(result.skipped, 0);
10501 assert_eq!(result.failed, 1);
10502
10503 session.shutdown().await.unwrap();
10504 }
10505
10506 #[tokio::test]
10509 async fn load_resume_duplicate_skipped() {
10510 let tmp = tempfile::TempDir::new().unwrap();
10511 let mut settings = test_settings();
10512 settings.resume_data_dir = Some(tmp.path().to_path_buf());
10513
10514 let session = SessionHandle::start(settings).await.unwrap();
10515
10516 let data = vec![0xAB; 16384];
10518 let meta = make_test_torrent(&data, 16384);
10519 let info_hash = meta.info_hash;
10520 let storage = make_storage(&data, 16384);
10521 session
10522 .add_torrent_with_meta(meta.into(), Some(storage))
10523 .await
10524 .unwrap();
10525
10526 tokio::time::sleep(Duration::from_millis(50)).await;
10528
10529 let _ = session.save_resume_state().await;
10531
10532 let result = session.load_resume_state().await.unwrap();
10534 assert!(
10535 session.list_torrents().await.unwrap().contains(&info_hash),
10536 "original torrent should still exist"
10537 );
10538 assert_eq!(result.skipped, 1, "duplicate should be skipped");
10539 assert_eq!(result.failed, 0);
10540
10541 session.shutdown().await.unwrap();
10542 }
10543
10544 #[test]
10547 fn reconstruct_torrent_meta_returns_some_with_correct_fields() {
10548 use crate::resume_file::reconstruct_torrent_meta;
10549 use irontide_core::FastResumeData;
10550
10551 let data = vec![0xAB; 16384];
10552 let meta = make_test_torrent(&data, 16384);
10553 let info_hash = meta.info_hash;
10554
10555 let info_bytes = irontide_bencode::to_bytes(&meta.info).unwrap();
10557 let mut rd = FastResumeData::new(
10558 info_hash.as_bytes().to_vec(),
10559 "test-torrent".into(),
10560 "/downloads".into(),
10561 );
10562 rd.info = Some(info_bytes);
10563 rd.trackers = vec![
10564 vec!["http://tracker1.example.com/announce".into()],
10565 vec!["http://tracker2.example.com/announce".into()],
10566 ];
10567 rd.url_seeds = vec!["http://seed.example.com/".into()];
10568 rd.http_seeds = vec!["http://httpseed.example.com/".into()];
10569
10570 let reconstructed = reconstruct_torrent_meta(&rd).expect("should reconstruct");
10571
10572 assert_eq!(reconstructed.info_hash, info_hash);
10573 assert_eq!(
10574 reconstructed.announce.as_deref(),
10575 Some("http://tracker1.example.com/announce")
10576 );
10577 assert!(reconstructed.announce_list.is_some());
10578 assert_eq!(reconstructed.announce_list.as_ref().unwrap().len(), 2);
10579 assert_eq!(
10580 reconstructed.url_list,
10581 vec!["http://seed.example.com/".to_string()]
10582 );
10583 assert_eq!(
10584 reconstructed.httpseeds,
10585 vec!["http://httpseed.example.com/".to_string()]
10586 );
10587 assert!(reconstructed.info_bytes.is_some());
10588 assert!(reconstructed.comment.is_none());
10589 assert!(reconstructed.created_by.is_none());
10590 assert!(reconstructed.creation_date.is_none());
10591 }
10592
10593 #[test]
10596 fn reconstruct_torrent_meta_returns_none_without_info() {
10597 use crate::resume_file::reconstruct_torrent_meta;
10598 use irontide_core::FastResumeData;
10599
10600 let rd = FastResumeData::new(vec![0xAB; 20], "magnet".into(), "/tmp".into());
10601 assert!(rd.info.is_none());
10603 assert!(reconstruct_torrent_meta(&rd).is_none());
10604 }
10605
10606 #[test]
10609 fn reconstruct_magnet_returns_some_with_correct_fields() {
10610 use crate::resume_file::reconstruct_magnet;
10611 use irontide_core::FastResumeData;
10612
10613 let mut rd = FastResumeData::new(vec![0xCC; 20], "my-torrent".into(), "/downloads".into());
10614 rd.trackers = vec![
10615 vec!["http://tracker1.com/announce".into()],
10616 vec![
10617 "http://tracker2.com/announce".into(),
10618 "http://tracker3.com/announce".into(),
10619 ],
10620 ];
10621
10622 let magnet = reconstruct_magnet(&rd).expect("should reconstruct magnet");
10623
10624 assert!(magnet.info_hashes.v1.is_some());
10625 assert!(magnet.info_hashes.v2.is_none());
10626 assert_eq!(magnet.display_name.as_deref(), Some("my-torrent"));
10627 assert_eq!(magnet.trackers.len(), 3);
10629 assert!(magnet.peers.is_empty());
10630 assert!(magnet.selected_files.is_none());
10631 }
10632
10633 #[test]
10636 fn reconstruct_magnet_preserves_info_hash2() {
10637 use crate::resume_file::reconstruct_magnet;
10638 use irontide_core::FastResumeData;
10639
10640 let mut rd = FastResumeData::new(vec![0xDD; 20], "v2-magnet".into(), "/tmp".into());
10641 rd.info_hash2 = Some(vec![0xEE; 32]);
10642
10643 let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
10644 assert!(magnet.info_hashes.v1.is_some());
10645 assert!(magnet.info_hashes.v2.is_some());
10646
10647 let v2 = magnet.info_hashes.v2.unwrap();
10648 assert_eq!(v2.as_bytes(), &[0xEE; 32]);
10649 }
10650
10651 #[test]
10654 fn reconstruct_magnet_empty_name_is_none() {
10655 use crate::resume_file::reconstruct_magnet;
10656 use irontide_core::FastResumeData;
10657
10658 let rd = FastResumeData::new(vec![0xFF; 20], String::new(), "/tmp".into());
10659 let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
10660 assert!(
10661 magnet.display_name.is_none(),
10662 "empty name should map to None"
10663 );
10664 }
10665
10666 #[tokio::test]
10671 async fn shutdown_saves_resume_files() {
10672 let tmp = tempfile::TempDir::new().unwrap();
10673 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10674 .await
10675 .unwrap();
10676
10677 let data = vec![0xAB; 16384];
10678 let meta = make_test_torrent(&data, 16384);
10679 let info_hash = meta.info_hash;
10680 let storage = make_storage(&data, 16384);
10681 session
10682 .add_torrent_with_meta(meta.into(), Some(storage))
10683 .await
10684 .unwrap();
10685
10686 session.pause_torrent(info_hash).await.unwrap();
10688 tokio::time::sleep(Duration::from_millis(50)).await;
10689 session.resume_torrent(info_hash).await.unwrap();
10690 tokio::time::sleep(Duration::from_millis(50)).await;
10691
10692 let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10693
10694 session.shutdown().await.unwrap();
10698 tokio::time::sleep(Duration::from_millis(200)).await;
10699
10700 assert!(path.exists(), "resume file should exist after shutdown");
10701 }
10702
10703 #[tokio::test]
10706 async fn auto_restore_on_startup() {
10707 let tmp = tempfile::TempDir::new().unwrap();
10708
10709 let info_hash;
10710 {
10711 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10713 .await
10714 .unwrap();
10715
10716 let data = vec![0xAB; 16384];
10717 let meta = make_test_torrent(&data, 16384);
10718 info_hash = meta.info_hash;
10719 let storage = make_storage(&data, 16384);
10720 session
10721 .add_torrent_with_meta(meta.into(), Some(storage))
10722 .await
10723 .unwrap();
10724
10725 tokio::time::sleep(Duration::from_millis(50)).await;
10726 let _ = session.save_resume_state().await;
10727 session.shutdown().await.unwrap();
10728 }
10729
10730 let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10732 assert!(path.exists(), "resume file should exist before restart");
10733
10734 {
10735 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10737 .await
10738 .unwrap();
10739
10740 tokio::time::sleep(Duration::from_millis(100)).await;
10742
10743 let list = session.list_torrents().await.unwrap();
10744 assert!(
10745 list.contains(&info_hash),
10746 "torrent should be auto-restored on startup"
10747 );
10748
10749 session.shutdown().await.unwrap();
10750 }
10751 }
10752
10753 #[tokio::test]
10756 async fn shutdown_with_readonly_resume_dir_completes() {
10757 let tmp = tempfile::TempDir::new().unwrap();
10758 let readonly_dir = PathBuf::from("/proc/irontide-test-nonexistent");
10761 let mut settings = test_settings();
10762 settings.resume_data_dir = Some(readonly_dir);
10763
10764 let session = SessionHandle::start(settings).await.unwrap();
10765
10766 let data = vec![0xAB; 16384];
10767 let meta = make_test_torrent(&data, 16384);
10768 let storage = make_storage(&data, 16384);
10769 session
10770 .add_torrent_with_meta(meta.into(), Some(storage))
10771 .await
10772 .unwrap();
10773
10774 tokio::time::sleep(Duration::from_millis(50)).await;
10775
10776 session.shutdown().await.unwrap();
10779
10780 drop(tmp);
10782 }
10783
10784 #[tokio::test]
10787 async fn orphan_resume_file_deleted_on_startup() {
10788 let tmp = tempfile::TempDir::new().unwrap();
10789 let torrents_dir = tmp.path().join("torrents");
10790 std::fs::create_dir_all(&torrents_dir).unwrap();
10791
10792 let orphan_path = torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume");
10800 std::fs::write(&orphan_path, b"not valid bencode").unwrap();
10801 assert!(orphan_path.exists(), "orphan file should exist before test");
10802
10803 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10804 .await
10805 .unwrap();
10806
10807 tokio::time::sleep(Duration::from_millis(100)).await;
10809
10810 assert!(
10811 !orphan_path.exists(),
10812 "orphan resume file should be deleted on startup"
10813 );
10814
10815 session.shutdown().await.unwrap();
10816 }
10817
10818 #[tokio::test]
10827 async fn multi_torrent_save_load_round_trip() {
10828 let tmp = tempfile::TempDir::new().unwrap();
10829
10830 let datasets: [u8; 3] = [0xAA, 0xBB, 0xCC];
10832 let mut hashes = Vec::with_capacity(3);
10833
10834 {
10835 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10837 .await
10838 .unwrap();
10839
10840 for &byte in &datasets {
10841 let data = vec![byte; 16384];
10842 let meta = make_test_torrent(&data, 16384);
10843 let info_hash = meta.info_hash;
10844 let storage = make_storage(&data, 16384);
10845 session
10846 .add_torrent_with_meta(meta.into(), Some(storage))
10847 .await
10848 .unwrap();
10849 hashes.push(info_hash);
10850 }
10851
10852 tokio::time::sleep(Duration::from_millis(100)).await;
10854
10855 let saved = session.save_resume_state().await.unwrap();
10856 assert_eq!(saved, 3, "all 3 torrents should be saved");
10857
10858 let files = crate::resume_file::scan_resume_dir(tmp.path());
10860 assert_eq!(files.len(), 3, "3 .resume files should be on disk");
10861
10862 for hash in &hashes {
10863 let path = crate::resume_file::resume_file_path(tmp.path(), hash);
10864 assert!(
10865 path.exists(),
10866 "resume file for {} should exist",
10867 hex::encode(hash.as_bytes())
10868 );
10869 }
10870
10871 session.shutdown().await.unwrap();
10872 }
10873
10874 {
10875 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10883 .await
10884 .unwrap();
10885
10886 tokio::time::sleep(Duration::from_millis(200)).await;
10888
10889 let list = session.list_torrents().await.unwrap();
10890 assert_eq!(list.len(), 3, "all 3 torrents should be auto-restored");
10891
10892 for hash in &hashes {
10893 assert!(
10894 list.contains(hash),
10895 "torrent {} should be present after restore",
10896 hex::encode(hash.as_bytes())
10897 );
10898 }
10899
10900 session.shutdown().await.unwrap();
10901 }
10902 }
10903
10904 #[tokio::test]
10910 async fn corrupt_one_of_three_resume_files() {
10911 let tmp = tempfile::TempDir::new().unwrap();
10912
10913 let datasets: [u8; 3] = [0xDD, 0xEE, 0xFF];
10914 let mut hashes = Vec::with_capacity(3);
10915
10916 {
10917 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10919 .await
10920 .unwrap();
10921
10922 for &byte in &datasets {
10923 let data = vec![byte; 16384];
10924 let meta = make_test_torrent(&data, 16384);
10925 let info_hash = meta.info_hash;
10926 let storage = make_storage(&data, 16384);
10927 session
10928 .add_torrent_with_meta(meta.into(), Some(storage))
10929 .await
10930 .unwrap();
10931 hashes.push(info_hash);
10932 }
10933
10934 tokio::time::sleep(Duration::from_millis(100)).await;
10935
10936 let saved = session.save_resume_state().await.unwrap();
10937 assert_eq!(saved, 3, "all 3 torrents should be saved");
10938
10939 session.shutdown().await.unwrap();
10940 }
10941
10942 let corrupt_path = crate::resume_file::resume_file_path(tmp.path(), &hashes[1]);
10944 assert!(
10945 corrupt_path.exists(),
10946 "file to corrupt must exist before overwrite"
10947 );
10948 std::fs::write(&corrupt_path, b"CORRUPTED GARBAGE DATA 0xDEAD").unwrap();
10949
10950 {
10951 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10954 .await
10955 .unwrap();
10956
10957 tokio::time::sleep(Duration::from_millis(200)).await;
10959
10960 let list = session.list_torrents().await.unwrap();
10961 assert_eq!(
10962 list.len(),
10963 2,
10964 "2 torrents should be restored (1 corrupt skipped)"
10965 );
10966
10967 assert!(
10969 list.contains(&hashes[0]),
10970 "first torrent should be restored"
10971 );
10972 assert!(
10973 list.contains(&hashes[2]),
10974 "third torrent should be restored"
10975 );
10976
10977 assert!(
10979 !list.contains(&hashes[1]),
10980 "corrupted torrent should not be restored"
10981 );
10982
10983 assert!(
10985 !corrupt_path.exists(),
10986 "corrupt resume file should be deleted by orphan cleanup"
10987 );
10988
10989 session.shutdown().await.unwrap();
10990 }
10991 }
10992
10993 #[tokio::test]
11000 async fn remove_torrent_deletes_resume_file() {
11001 let tmp = tempfile::TempDir::new().unwrap();
11002
11003 let data = vec![0x42; 16384];
11004 let meta = make_test_torrent(&data, 16384);
11005 let info_hash = meta.info_hash;
11006 let storage = make_storage(&data, 16384);
11007
11008 let session = SessionHandle::start(resume_test_settings(tmp.path()))
11009 .await
11010 .unwrap();
11011
11012 session
11013 .add_torrent_with_meta(meta.into(), Some(storage))
11014 .await
11015 .unwrap();
11016
11017 tokio::time::sleep(Duration::from_millis(100)).await;
11019
11020 let saved = session.save_resume_state().await.unwrap();
11021 assert!(saved > 0, "torrent should be saved to a resume file");
11022
11023 let resume_path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
11024 assert!(resume_path.exists(), "resume file should exist after save");
11025
11026 session.remove_torrent(info_hash).await.unwrap();
11028 tokio::time::sleep(Duration::from_millis(50)).await;
11029
11030 let list = session.list_torrents().await.unwrap();
11031 assert!(
11032 !list.contains(&info_hash),
11033 "torrent should be gone from session after removal"
11034 );
11035
11036 assert!(
11037 !resume_path.exists(),
11038 "resume file should be deleted when torrent is removed"
11039 );
11040
11041 let remaining = crate::resume_file::scan_resume_dir(tmp.path());
11043 assert!(
11044 remaining.is_empty(),
11045 "no resume files should remain after removing the only torrent"
11046 );
11047
11048 session.shutdown().await.unwrap();
11049 }
11050
11051 fn test_settings_isolated_resume(resume_dir: &std::path::Path) -> Settings {
11057 Settings {
11058 resume_data_dir: Some(resume_dir.to_path_buf()),
11059 ..test_settings()
11060 }
11061 }
11062
11063 #[tokio::test]
11064 async fn remove_torrent_with_files_deletes_disk_files() {
11065 let download_dir = tempfile::tempdir().unwrap();
11069 let resume_dir = tempfile::tempdir().unwrap();
11070 let mut settings = test_settings_isolated_resume(resume_dir.path());
11071 settings.download_dir = download_dir.path().to_path_buf();
11072 let session = SessionHandle::start(settings).await.unwrap();
11073
11074 let data = vec![0xAB_u8; 16384];
11075 let meta = make_test_torrent(&data, 16384);
11076 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
11077 let storage: Arc<dyn TorrentStorage> = Arc::new(
11078 irontide_storage::FilesystemStorage::new(
11079 download_dir.path(),
11080 vec![PathBuf::from("test")],
11081 vec![data.len() as u64],
11082 lengths,
11083 None,
11084 irontide_storage::PreallocateMode::None,
11085 false,
11086 )
11087 .unwrap(),
11088 );
11089
11090 storage.write_chunk(0, 0, &data).unwrap();
11093
11094 let info_hash = session
11095 .add_torrent_with_meta(meta.into(), Some(storage))
11096 .await
11097 .unwrap();
11098
11099 let file_on_disk = download_dir.path().join("test");
11100 assert!(file_on_disk.exists(), "file should exist before delete");
11101
11102 session.remove_torrent_with_files(info_hash).await.unwrap();
11103
11104 for _ in 0..20 {
11106 if !file_on_disk.exists() {
11107 break;
11108 }
11109 tokio::time::sleep(Duration::from_millis(50)).await;
11110 }
11111 assert!(
11112 !file_on_disk.exists(),
11113 "file should have been removed from disk"
11114 );
11115 assert!(
11116 download_dir.path().exists(),
11117 "download_dir root must never be removed"
11118 );
11119
11120 session.shutdown().await.unwrap();
11121 }
11122
11123 #[tokio::test]
11124 async fn remove_torrent_with_files_tolerates_already_deleted_files() {
11125 let download_dir = tempfile::tempdir().unwrap();
11129 let resume_dir = tempfile::tempdir().unwrap();
11130 let mut settings = test_settings_isolated_resume(resume_dir.path());
11131 settings.download_dir = download_dir.path().to_path_buf();
11132 let session = SessionHandle::start(settings).await.unwrap();
11133
11134 let data = vec![0xCD_u8; 16384];
11135 let meta = make_test_torrent(&data, 16384);
11136 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
11137 let storage: Arc<dyn TorrentStorage> = Arc::new(
11138 irontide_storage::FilesystemStorage::new(
11139 download_dir.path(),
11140 vec![PathBuf::from("test")],
11141 vec![data.len() as u64],
11142 lengths,
11143 None,
11144 irontide_storage::PreallocateMode::None,
11145 false,
11146 )
11147 .unwrap(),
11148 );
11149 let info_hash = session
11150 .add_torrent_with_meta(meta.into(), Some(storage))
11151 .await
11152 .unwrap();
11153
11154 std::fs::remove_file(download_dir.path().join("test")).unwrap();
11156
11157 let result = session.remove_torrent_with_files(info_hash).await;
11159 assert!(
11160 result.is_ok(),
11161 "remove_torrent_with_files must return Ok on missing files"
11162 );
11163
11164 session.shutdown().await.unwrap();
11165 }
11166
11167 #[tokio::test]
11168 async fn remove_torrent_with_files_grace_guards_fast_re_add() {
11169 use serde::Serialize;
11176
11177 #[derive(Serialize)]
11178 struct Info<'a> {
11179 length: u64,
11180 name: &'a str,
11181 #[serde(rename = "piece length")]
11182 piece_length: u64,
11183 #[serde(with = "serde_bytes")]
11184 pieces: &'a [u8],
11185 }
11186 #[derive(Serialize)]
11187 struct Torrent<'a> {
11188 info: Info<'a>,
11189 }
11190
11191 let download_dir = tempfile::tempdir().unwrap();
11192 let resume_dir = tempfile::tempdir().unwrap();
11193 let mut settings = test_settings_isolated_resume(resume_dir.path());
11194 settings.download_dir = download_dir.path().to_path_buf();
11195 let session = SessionHandle::start(settings).await.unwrap();
11196
11197 let data = vec![0xEE_u8; 16384];
11200 let meta = make_test_torrent(&data, 16384);
11201 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
11202 let storage: Arc<dyn TorrentStorage> = Arc::new(
11203 irontide_storage::FilesystemStorage::new(
11204 download_dir.path(),
11205 vec![PathBuf::from("test")],
11206 vec![data.len() as u64],
11207 lengths,
11208 None,
11209 irontide_storage::PreallocateMode::None,
11210 false,
11211 )
11212 .unwrap(),
11213 );
11214 let mut pieces = Vec::new();
11217 let hash = irontide_core::sha1(&data);
11218 pieces.extend_from_slice(hash.as_bytes());
11219 let bytes = irontide_bencode::to_bytes(&Torrent {
11220 info: Info {
11221 length: data.len() as u64,
11222 name: "test",
11223 piece_length: 16384,
11224 pieces: &pieces,
11225 },
11226 })
11227 .unwrap();
11228
11229 let info_hash = session
11230 .add_torrent_with_meta(meta.into(), Some(storage))
11231 .await
11232 .unwrap();
11233
11234 session.remove_torrent_with_files(info_hash).await.unwrap();
11237
11238 let params = AddTorrentParams::bytes(bytes);
11244 let result = session.add_torrent(params).await;
11245 match result {
11246 Ok(_) => {
11247 }
11249 Err(crate::Error::TorrentBeingRemoved(h)) => {
11250 assert_eq!(h, info_hash, "grace error must name the same hash");
11251 }
11252 Err(e) => panic!("unexpected error on re-add: {e}"),
11253 }
11254
11255 session.shutdown().await.unwrap();
11256 }
11257
11258 #[cfg(feature = "test-util")]
11269 fn make_debug_inject_info() -> (Vec<u8>, Id20) {
11270 use serde::Serialize;
11271
11272 #[derive(Serialize)]
11273 struct Info<'a> {
11274 length: u64,
11275 name: &'a str,
11276 #[serde(rename = "piece length")]
11277 piece_length: u64,
11278 #[serde(with = "serde_bytes")]
11279 pieces: &'a [u8],
11280 }
11281
11282 let data = vec![0xAB_u8; 1024];
11283 let piece_hash = irontide_core::sha1(&data);
11284 let mut pieces = Vec::new();
11285 pieces.extend_from_slice(piece_hash.as_bytes());
11286
11287 let info = Info {
11288 length: data.len() as u64,
11289 name: "sync-inject-test",
11290 piece_length: 1024,
11291 pieces: &pieces,
11292 };
11293
11294 let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
11295 let info_hash = irontide_core::sha1(&info_bytes);
11296 (info_bytes, info_hash)
11297 }
11298
11299 #[cfg(feature = "test-util")]
11300 #[tokio::test]
11301 async fn debug_inject_metadata_resolves_magnet_meta_synchronously() {
11302 use crate::session::AddTorrentParams;
11303
11304 let (info_bytes, info_hash) = make_debug_inject_info();
11305
11306 let resume_dir = tempfile::tempdir().unwrap();
11310 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
11311 .await
11312 .unwrap();
11313
11314 let magnet_uri = format!(
11315 "magnet:?xt=urn:btih:{}&dn=sync-inject-test",
11316 info_hash.to_hex()
11317 );
11318 let added = session
11319 .add_torrent(AddTorrentParams::magnet(magnet_uri))
11320 .await
11321 .unwrap();
11322 assert_eq!(
11323 added, info_hash,
11324 "magnet info hash must equal synth info hash"
11325 );
11326
11327 session
11332 .debug_inject_metadata(info_hash, info_bytes)
11333 .await
11334 .expect("debug_inject_metadata must succeed");
11335
11336 let meta = session
11337 .torrent_file(info_hash)
11338 .await
11339 .expect("torrent_file call")
11340 .expect("metadata must be present immediately after sync inject");
11341 assert_eq!(meta.info_hash, info_hash);
11342
11343 session.shutdown().await.unwrap();
11344 }
11345
11346 #[cfg(feature = "test-util")]
11347 #[tokio::test]
11348 async fn debug_inject_metadata_returns_torrent_not_found_for_unknown_hash() {
11349 let resume_dir = tempfile::tempdir().unwrap();
11350 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
11351 .await
11352 .unwrap();
11353
11354 let bogus = Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
11355 let result = session.debug_inject_metadata(bogus, vec![]).await;
11356 assert!(
11357 matches!(result, Err(crate::Error::TorrentNotFound(_))),
11358 "expected TorrentNotFound for unknown hash; got {result:?}"
11359 );
11360
11361 session.shutdown().await.unwrap();
11362 }
11363
11364 #[cfg(feature = "test-util")]
11379 fn build_synth_info_bytes_with_options(
11380 name: &str,
11381 length_bytes: u64,
11382 piece_length: u64,
11383 private: Option<i64>,
11384 ssl_cert: Option<Vec<u8>>,
11385 ) -> Vec<u8> {
11386 use serde::Serialize;
11387
11388 #[derive(Serialize)]
11389 struct Info {
11390 length: u64,
11391 name: String,
11392 #[serde(rename = "piece length")]
11393 piece_length: u64,
11394 pieces: serde_bytes::ByteBuf,
11395 #[serde(skip_serializing_if = "Option::is_none")]
11396 private: Option<i64>,
11397 #[serde(rename = "ssl-cert", skip_serializing_if = "Option::is_none")]
11398 ssl_cert: Option<serde_bytes::ByteBuf>,
11399 }
11400
11401 let num_pieces = length_bytes.div_ceil(piece_length);
11406 let zero_piece_hash = irontide_core::sha1(&vec![0_u8; piece_length as usize]);
11407 let mut pieces = Vec::with_capacity(20 * num_pieces as usize);
11408 for _ in 0..num_pieces {
11409 pieces.extend_from_slice(zero_piece_hash.as_bytes());
11410 }
11411
11412 let info = Info {
11413 length: length_bytes,
11414 name: name.to_owned(),
11415 piece_length,
11416 pieces: serde_bytes::ByteBuf::from(pieces),
11417 private,
11418 ssl_cert: ssl_cert.map(serde_bytes::ByteBuf::from),
11419 };
11420 irontide_bencode::to_bytes(&info).expect("bencode synth info dict")
11421 }
11422
11423 #[cfg(feature = "test-util")]
11424 #[tokio::test]
11425 async fn ssl_cert_propagates_to_meta_after_inject() {
11426 use crate::session::AddTorrentParams;
11427
11428 let resume_dir = tempfile::tempdir().unwrap();
11429 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
11430 .await
11431 .unwrap();
11432
11433 let cert_pem = b"-----BEGIN CERT-----\nfake\n-----END CERT-----\n".to_vec();
11434 let info_bytes = build_synth_info_bytes_with_options(
11435 "ssl-fixture",
11436 16_384,
11437 16_384,
11438 None,
11439 Some(cert_pem.clone()),
11440 );
11441 let info_hash = irontide_core::sha1(&info_bytes);
11442
11443 let magnet = format!("magnet:?xt=urn:btih:{}&dn=ssl-fixture", info_hash.to_hex());
11444 let added = session
11445 .add_torrent(AddTorrentParams::magnet(magnet))
11446 .await
11447 .unwrap();
11448 assert_eq!(
11449 added, info_hash,
11450 "magnet info hash must equal synth info hash"
11451 );
11452
11453 session
11454 .debug_inject_metadata(info_hash, info_bytes)
11455 .await
11456 .expect("debug_inject_metadata must succeed");
11457
11458 let meta = session
11459 .torrent_file(info_hash)
11460 .await
11461 .expect("torrent_file Ok")
11462 .expect("metadata must be present immediately after sync inject");
11463 assert_eq!(
11464 meta.info.ssl_cert.as_ref(),
11465 Some(&cert_pem),
11466 "ssl_cert from synth info dict must propagate to meta.info.ssl_cert"
11467 );
11468
11469 session.shutdown().await.unwrap();
11470 }
11471
11472 #[cfg(feature = "test-util")]
11473 #[tokio::test]
11474 async fn ssl_cert_absent_remains_none_in_meta_after_inject() {
11475 use crate::session::AddTorrentParams;
11476
11477 let resume_dir = tempfile::tempdir().unwrap();
11478 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
11479 .await
11480 .unwrap();
11481
11482 let info_bytes =
11483 build_synth_info_bytes_with_options("no-ssl-fixture", 16_384, 16_384, None, None);
11484 let info_hash = irontide_core::sha1(&info_bytes);
11485
11486 let magnet = format!(
11487 "magnet:?xt=urn:btih:{}&dn=no-ssl-fixture",
11488 info_hash.to_hex()
11489 );
11490 let added = session
11491 .add_torrent(AddTorrentParams::magnet(magnet))
11492 .await
11493 .unwrap();
11494 assert_eq!(
11495 added, info_hash,
11496 "magnet info hash must equal synth info hash"
11497 );
11498
11499 session
11500 .debug_inject_metadata(info_hash, info_bytes)
11501 .await
11502 .expect("debug_inject_metadata must succeed");
11503
11504 let meta = session
11505 .torrent_file(info_hash)
11506 .await
11507 .expect("torrent_file Ok")
11508 .expect("metadata must be present immediately after sync inject");
11509 assert!(
11510 meta.info.ssl_cert.is_none(),
11511 "absent ssl-cert in info dict must remain None in meta; got {:?}",
11512 meta.info.ssl_cert
11513 );
11514
11515 session.shutdown().await.unwrap();
11516 }
11517
11518 #[tokio::test]
11521 async fn init_throttle_queues_restored_torrents() {
11522 let tmp = tempfile::TempDir::new().unwrap();
11523 let resume_dir = tmp.path().to_path_buf();
11524
11525 {
11527 let mut settings = resume_test_settings(&resume_dir);
11528 settings.queueing_enabled = false;
11529 let session = SessionHandle::start(settings).await.unwrap();
11530 for i in 0u8..5 {
11531 let data = vec![i.wrapping_add(0xA0); 16384];
11532 let meta = make_test_torrent(&data, 16384);
11533 let storage = make_storage(&data, 16384);
11534 session
11535 .add_torrent_with_meta(meta.into(), Some(storage))
11536 .await
11537 .unwrap();
11538 }
11539 tokio::time::sleep(Duration::from_millis(100)).await;
11540 let saved = session.save_resume_state().await.unwrap();
11541 assert!(saved >= 3, "should save most resume files, got {saved}");
11542 session.shutdown().await.unwrap();
11543 }
11544
11545 {
11547 let mut settings = resume_test_settings(&resume_dir);
11548 settings.queueing_enabled = true;
11549 settings.active_checking = 2;
11550 settings.active_downloads = 2;
11551 settings.active_seeds = 2;
11552 settings.active_limit = 4;
11553 let session = SessionHandle::start(settings).await.unwrap();
11554 let mut queued = 0;
11565 let mut active = 0;
11566 for _ in 0..60 {
11567 let list = session.list_torrent_summaries().await.unwrap();
11568 queued = list
11569 .iter()
11570 .filter(|t| t.state == TorrentState::Queued)
11571 .count();
11572 active = list
11573 .iter()
11574 .filter(|t| t.state != TorrentState::Queued)
11575 .count();
11576 if queued > 0 {
11577 break;
11578 }
11579 tokio::time::sleep(Duration::from_millis(50)).await;
11580 }
11581
11582 assert!(
11583 queued > 0,
11584 "at least one torrent should be Queued after a stats tick, but all {active} are active"
11585 );
11586 assert!(
11587 active <= 4,
11588 "active torrents ({active}) should not exceed active_limit (4)"
11589 );
11590 session.shutdown().await.unwrap();
11591 }
11592 }
11593
11594 #[tokio::test]
11595 async fn init_throttle_disabled_restores_all_immediately() {
11596 let tmp = tempfile::TempDir::new().unwrap();
11597 let resume_dir = tmp.path().to_path_buf();
11598
11599 {
11601 let settings = resume_test_settings(&resume_dir);
11602 let session = SessionHandle::start(settings).await.unwrap();
11603 for i in 0u8..3 {
11604 let data = vec![i.wrapping_add(0xC0); 16384];
11605 let meta = make_test_torrent(&data, 16384);
11606 let storage = make_storage(&data, 16384);
11607 session
11608 .add_torrent_with_meta(meta.into(), Some(storage))
11609 .await
11610 .unwrap();
11611 }
11612 tokio::time::sleep(Duration::from_millis(100)).await;
11613 session.save_resume_state().await.unwrap();
11614 session.shutdown().await.unwrap();
11615 }
11616
11617 {
11619 let mut settings = resume_test_settings(&resume_dir);
11620 settings.queueing_enabled = false;
11621 let session = SessionHandle::start(settings).await.unwrap();
11622 tokio::time::sleep(Duration::from_millis(200)).await;
11623
11624 let list = session.list_torrent_summaries().await.unwrap();
11625 let queued = list
11626 .iter()
11627 .filter(|t| t.state == TorrentState::Queued)
11628 .count();
11629 assert_eq!(
11630 queued, 0,
11631 "with queueing disabled, no torrents should be Queued"
11632 );
11633 session.shutdown().await.unwrap();
11634 }
11635 }
11636
11637 #[tokio::test]
11638 async fn checking_complete_triggers_immediate_eval() {
11639 use crate::alert::AlertKind;
11640
11641 let mut settings = test_settings();
11642 settings.queueing_enabled = true;
11643 settings.active_checking = 1;
11644 settings.active_downloads = 5;
11645 settings.active_seeds = 5;
11646 settings.active_limit = 10;
11647 settings.auto_manage_interval = 300;
11648 let session = SessionHandle::start(settings).await.unwrap();
11649 let mut alerts = session.subscribe();
11650
11651 let mut hashes = Vec::new();
11653 for i in 0u8..3 {
11654 let data = vec![i.wrapping_add(0xD0); 16384];
11655 let meta = make_test_torrent(&data, 16384);
11656 let storage = make_storage(&data, 16384);
11657 let h = session
11658 .add_torrent_with_meta(meta.into(), Some(storage))
11659 .await
11660 .unwrap();
11661 hashes.push(h);
11662 }
11663
11664 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
11668 let mut saw_checking_transition = false;
11669 while tokio::time::Instant::now() < deadline {
11670 if let Ok(Ok(alert)) =
11671 tokio::time::timeout(Duration::from_millis(500), alerts.recv()).await
11672 && matches!(
11673 alert.kind,
11674 AlertKind::StateChanged {
11675 prev_state: TorrentState::Checking,
11676 ..
11677 }
11678 )
11679 {
11680 saw_checking_transition = true;
11681 break;
11682 }
11683 }
11684
11685 assert!(
11686 saw_checking_transition,
11687 "should have seen a Checking→* state transition"
11688 );
11689
11690 tokio::time::sleep(Duration::from_millis(200)).await;
11694
11695 let list = session.list_torrent_summaries().await.unwrap();
11696 let active = list
11697 .iter()
11698 .filter(|t| t.state != TorrentState::Queued)
11699 .count();
11700 assert!(
11701 active >= 1,
11702 "at least one torrent should be active after checking-complete trigger"
11703 );
11704
11705 session.shutdown().await.unwrap();
11706 }
11707
11708 #[tokio::test]
11711 async fn resume_restores_queue_position() {
11712 let tmp = tempfile::TempDir::new().unwrap();
11713 let resume_dir = tmp.path().to_path_buf();
11714
11715 let data = vec![0xF0; 16384];
11716 let meta = make_test_torrent(&data, 16384);
11717 let info_hash = meta.info_hash;
11718
11719 {
11721 let settings = resume_test_settings(&resume_dir);
11722 let session = SessionHandle::start(settings).await.unwrap();
11723 let storage = make_storage(&data, 16384);
11724 session
11725 .add_torrent_with_meta(meta.clone().into(), Some(storage))
11726 .await
11727 .unwrap();
11728 session.set_queue_position(info_hash, 3).await.unwrap();
11729 tokio::time::sleep(Duration::from_millis(100)).await;
11730 session.save_resume_state().await.unwrap();
11731 session.shutdown().await.unwrap();
11732 }
11733
11734 {
11736 let settings = resume_test_settings(&resume_dir);
11737 let session = SessionHandle::start(settings).await.unwrap();
11738 tokio::time::sleep(Duration::from_millis(200)).await;
11739
11740 let pos = session.queue_position(info_hash).await.unwrap();
11741 assert_eq!(pos, 0, "single torrent renormalizes to position 0");
11744 session.shutdown().await.unwrap();
11745 }
11746 }
11747
11748 #[tokio::test]
11749 async fn resume_restores_auto_managed_false() {
11750 let tmp = tempfile::TempDir::new().unwrap();
11751 let resume_dir = tmp.path().to_path_buf();
11752
11753 let data = vec![0xF1; 16384];
11754 let meta = make_test_torrent(&data, 16384);
11755 let info_hash = meta.info_hash;
11756
11757 {
11759 let settings = resume_test_settings(&resume_dir);
11760 let session = SessionHandle::start(settings).await.unwrap();
11761 let storage = make_storage(&data, 16384);
11762 session
11763 .add_torrent_with_meta(meta.clone().into(), Some(storage))
11764 .await
11765 .unwrap();
11766 tokio::time::sleep(Duration::from_millis(100)).await;
11770 session.save_resume_state().await.unwrap();
11771 session.shutdown().await.unwrap();
11772 }
11773
11774 {
11776 let path = crate::resume_file::resume_file_path(&resume_dir, &info_hash);
11777 if path.exists() {
11778 let bytes = std::fs::read(&path).unwrap();
11779 let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11780 rd.auto_managed = 0;
11781 let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11782 std::fs::write(&path, patched).unwrap();
11783 }
11784 }
11785
11786 {
11788 let settings = resume_test_settings(&resume_dir);
11789 let session = SessionHandle::start(settings).await.unwrap();
11790 tokio::time::sleep(Duration::from_millis(200)).await;
11791
11792 let stats = session.torrent_stats(info_hash).await.unwrap();
11793 assert!(
11794 !stats.auto_managed,
11795 "auto_managed should be false after restore"
11796 );
11797 session.shutdown().await.unwrap();
11798 }
11799 }
11800
11801 #[tokio::test]
11802 async fn resume_renormalizes_duplicate_positions() {
11803 let tmp = tempfile::TempDir::new().unwrap();
11804 let resume_dir = tmp.path().to_path_buf();
11805
11806 let mut hashes = Vec::new();
11808 {
11809 let settings = resume_test_settings(&resume_dir);
11810 let session = SessionHandle::start(settings).await.unwrap();
11811 for i in 0u8..3 {
11812 let data = vec![i.wrapping_add(0xE0); 16384];
11813 let meta = make_test_torrent(&data, 16384);
11814 let storage = make_storage(&data, 16384);
11815 let h = session
11816 .add_torrent_with_meta(meta.into(), Some(storage))
11817 .await
11818 .unwrap();
11819 hashes.push(h);
11820 }
11821 tokio::time::sleep(Duration::from_millis(100)).await;
11822 session.save_resume_state().await.unwrap();
11823 session.shutdown().await.unwrap();
11824 }
11825
11826 for hash in &hashes {
11828 let path = crate::resume_file::resume_file_path(&resume_dir, hash);
11829 if path.exists() {
11830 let bytes = std::fs::read(&path).unwrap();
11831 let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11832 rd.queue_position = 0;
11833 let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11834 std::fs::write(&path, patched).unwrap();
11835 }
11836 }
11837
11838 {
11840 let settings = resume_test_settings(&resume_dir);
11841 let session = SessionHandle::start(settings).await.unwrap();
11842 tokio::time::sleep(Duration::from_millis(200)).await;
11843
11844 let mut positions = Vec::new();
11845 for hash in &hashes {
11846 if let Ok(pos) = session.queue_position(*hash).await {
11847 positions.push(pos);
11848 }
11849 }
11850 positions.sort_unstable();
11851 let expected: Vec<i32> = (0..positions.len() as i32).collect();
11852 assert_eq!(
11853 positions, expected,
11854 "positions should be contiguous 0..N-1 after renormalization"
11855 );
11856 session.shutdown().await.unwrap();
11857 }
11858 }
11859
11860 #[test]
11863 fn ewma_smooths_transient_drop() {
11864 let alpha = 0.3_f64;
11865 let prev = 100_000.0_f64;
11866 let sample = 0.0_f64;
11867 let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11868 assert!(
11869 (smoothed - 70_000.0).abs() < 1.0,
11870 "smoothed rate should be ~70000, got {smoothed}"
11871 );
11872 }
11873
11874 #[test]
11875 fn ewma_alpha_one_equals_raw() {
11876 let alpha = 1.0_f64;
11877 let prev = 100_000.0_f64;
11878 let sample = 42_000.0_f64;
11879 let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11880 assert!(
11881 (smoothed - sample).abs() < 0.001,
11882 "alpha=1.0 should produce raw rate, got {smoothed}"
11883 );
11884 }
11885
11886 #[test]
11889 fn seed_anti_flap_uses_longer_duration() {
11890 let seed_queue_min_active_secs = 1800_u64;
11891 let auto_manage_startup = 60_u64;
11892 let started_5_min_ago = std::time::Duration::from_mins(5);
11893 let seed_duration = std::time::Duration::from_secs(seed_queue_min_active_secs);
11894
11895 assert!(
11898 started_5_min_ago < seed_duration,
11899 "5 min < 30 min, seeding torrent should be recently_started"
11900 );
11901
11902 let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11905 assert!(
11906 started_5_min_ago > dl_duration,
11907 "5 min > 60s, downloading torrent should NOT be recently_started"
11908 );
11909 }
11910
11911 #[test]
11912 fn download_anti_flap_uses_startup_duration() {
11913 let auto_manage_startup = 60_u64;
11914 let started_5_min_ago = std::time::Duration::from_mins(5);
11915 let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11916 assert!(
11917 started_5_min_ago > dl_duration,
11918 "downloading torrent started 5 min ago should NOT be recently_started"
11919 );
11920 }
11921
11922 #[test]
11925 fn classify_restart_required_upnp_change() {
11926 let old = Settings::default();
11927 let mut new = old.clone();
11928 new.enable_upnp = !old.enable_upnp;
11929 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11930 assert_eq!(classify_restart_required(&old, &new), vec!["upnp"]);
11931 }
11932
11933 #[test]
11934 fn classify_restart_required_natpmp_change() {
11935 let old = Settings::default();
11936 let mut new = old.clone();
11937 new.enable_natpmp = !old.enable_natpmp;
11938 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11939 assert_eq!(classify_restart_required(&old, &new), vec!["natpmp"]);
11940 }
11941
11942 #[test]
11943 fn classify_immediate_max_connec_global_change() {
11944 let old = Settings::default();
11945 let mut new = old.clone();
11946 new.max_connections_global = if old.max_connections_global == 500 {
11947 501
11948 } else {
11949 500
11950 };
11951 assert_eq!(classify_immediate(&old, &new), vec!["max_connec_global"]);
11952 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11953 }
11954
11955 #[test]
11956 fn classify_immediate_max_uploads_per_torrent_change() {
11957 let old = Settings::default();
11961 let mut new = old.clone();
11962 new.max_uploads_per_torrent = 4;
11963 assert_eq!(
11964 classify_immediate(&old, &new),
11965 vec!["max_uploads_per_torrent"]
11966 );
11967 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11968 }
11969
11970 #[test]
11971 fn classify_restart_required_proxy_type_change() {
11972 let old = Settings::default();
11973 let mut new = old.clone();
11974 new.proxy.proxy_type = crate::proxy::ProxyType::Socks5;
11975 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11976 assert_eq!(classify_restart_required(&old, &new), vec!["proxy_type"]);
11977 }
11978
11979 #[test]
11980 fn classify_restart_required_proxy_credentials_change() {
11981 let old = Settings::default();
11982 let mut new = old.clone();
11983 new.proxy.username = Some("alice".into());
11984 new.proxy.password = Some("secret".into());
11985 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11986 let restart = classify_restart_required(&old, &new);
11987 let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
11990 assert_eq!(
11991 set,
11992 ["proxy_username", "proxy_password"]
11993 .into_iter()
11994 .collect::<std::collections::HashSet<_>>()
11995 );
11996 }
11997
11998 #[test]
11999 fn classify_combined_immediate_and_restart() {
12000 let old = Settings::default();
12004 let mut new = old.clone();
12005 new.max_connections_global = old.max_connections_global + 1;
12006 new.max_uploads_per_torrent = 4;
12007 new.enable_upnp = !old.enable_upnp;
12008 new.proxy.proxy_type = crate::proxy::ProxyType::Http;
12009
12010 let immediate = classify_immediate(&old, &new);
12011 let imm_set: std::collections::HashSet<&str> = immediate.iter().copied().collect();
12012 assert_eq!(
12013 imm_set,
12014 ["max_connec_global", "max_uploads_per_torrent"]
12015 .into_iter()
12016 .collect::<std::collections::HashSet<_>>()
12017 );
12018 let restart = classify_restart_required(&old, &new);
12019 let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
12020 assert_eq!(
12021 set,
12022 ["upnp", "proxy_type"]
12023 .into_iter()
12024 .collect::<std::collections::HashSet<_>>()
12025 );
12026 }
12027
12028 #[test]
12031 fn classify_immediate_seed_time_limit_change() {
12032 let old = Settings::default();
12033 let mut new = old.clone();
12034 new.seed_time_limit_secs = Some(3600);
12035 assert_eq!(classify_immediate(&old, &new), vec!["max_seeding_time"]);
12036 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12037 }
12038
12039 #[test]
12040 fn classify_immediate_inactive_seed_time_limit_change() {
12041 let old = Settings::default();
12042 let mut new = old.clone();
12043 new.inactive_seed_time_limit_secs = Some(1800);
12044 assert_eq!(
12045 classify_immediate(&old, &new),
12046 vec!["max_inactive_seeding_time"]
12047 );
12048 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12049 }
12050
12051 #[test]
12057 fn classify_immediate_save_resume_interval_change() {
12058 let old = Settings::default();
12062 let mut new = old.clone();
12063 new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(60);
12064 assert_eq!(classify_immediate(&old, &new), vec!["save_resume_interval"]);
12065 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12066 }
12067
12068 #[test]
12069 fn classify_immediate_hashing_threads_change() {
12070 let old = Settings::default();
12076 let mut new = old.clone();
12077 new.hashing_threads = old.hashing_threads.saturating_add(2);
12078 assert_eq!(classify_immediate(&old, &new), vec!["hashing_threads"]);
12079 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12080 }
12081
12082 #[test]
12083 fn classify_immediate_ip_filter_enabled_change() {
12084 let old = Settings::default();
12089 let mut new = old.clone();
12090 new.ip_filter_enabled = !old.ip_filter_enabled;
12091 assert_eq!(classify_immediate(&old, &new), vec!["ip_filter_enabled"]);
12092 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12093 }
12094
12095 #[test]
12096 fn settings_delta_from_diff_includes_save_resume_interval() {
12097 use crate::types::SettingsDelta;
12100 let old = Settings::default();
12101 let mut new = old.clone();
12102 new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(30);
12103 let d = SettingsDelta::from_diff(&old, &new);
12104 assert_eq!(
12105 d.save_resume_interval_secs,
12106 Some(new.save_resume_interval_secs)
12107 );
12108 assert!(d.hashing_threads.is_none());
12109 assert!(d.ip_filter_enabled.is_none());
12110 assert!(!d.is_empty());
12111 }
12112
12113 #[test]
12114 fn settings_delta_from_diff_includes_hashing_threads() {
12115 use crate::types::SettingsDelta;
12118 let old = Settings::default();
12119 let mut new = old.clone();
12120 new.hashing_threads = old.hashing_threads.saturating_add(1);
12121 let d = SettingsDelta::from_diff(&old, &new);
12122 assert_eq!(d.hashing_threads, Some(new.hashing_threads));
12123 assert!(d.save_resume_interval_secs.is_none());
12124 assert!(d.ip_filter_enabled.is_none());
12125 assert!(!d.is_empty());
12126 }
12127
12128 #[test]
12129 fn settings_delta_from_diff_includes_ip_filter_enabled() {
12130 use crate::types::SettingsDelta;
12133 let old = Settings::default();
12134 let mut new = old.clone();
12135 new.ip_filter_enabled = !old.ip_filter_enabled;
12136 let d = SettingsDelta::from_diff(&old, &new);
12137 assert_eq!(d.ip_filter_enabled, Some(new.ip_filter_enabled));
12138 assert!(d.save_resume_interval_secs.is_none());
12139 assert!(d.hashing_threads.is_none());
12140 assert!(!d.is_empty());
12141 }
12142
12143 #[test]
12144 fn settings_delta_is_empty_honours_m225_fields() {
12145 use crate::types::SettingsDelta;
12148 let mut d = SettingsDelta::default();
12149 assert!(d.is_empty());
12150 d.save_resume_interval_secs = Some(120);
12151 assert!(!d.is_empty());
12152 d = SettingsDelta::default();
12153 d.hashing_threads = Some(8);
12154 assert!(!d.is_empty());
12155 d = SettingsDelta::default();
12156 d.ip_filter_enabled = Some(false);
12157 assert!(!d.is_empty());
12158 }
12159
12160 fn m226_delta_and_classify_check<F>(mutate: F, alias: &'static str)
12166 where
12167 F: FnOnce(&mut Settings),
12168 {
12169 use crate::types::SettingsDelta;
12170 let old = Settings::default();
12171 let mut new = old.clone();
12172 mutate(&mut new);
12173 let d = SettingsDelta::from_diff(&old, &new);
12174 assert!(
12175 !d.is_empty(),
12176 "{alias}: delta must not be empty after toggle"
12177 );
12178 let imm = classify_immediate(&old, &new);
12179 assert!(
12180 imm.contains(&alias),
12181 "{alias}: classify_immediate must contain alias, got {imm:?}"
12182 );
12183 let rr = classify_restart_required(&old, &new);
12184 assert!(
12185 !rr.contains(&alias),
12186 "{alias}: must NOT appear in classify_restart_required"
12187 );
12188 }
12189
12190 #[test]
12191 fn m226_notify_on_complete_immediate() {
12192 m226_delta_and_classify_check(|s| s.notify_on_complete = true, "notify_on_complete");
12193 }
12194
12195 #[test]
12196 fn m226_notify_on_error_immediate() {
12197 m226_delta_and_classify_check(|s| s.notify_on_error = true, "notify_on_error");
12198 }
12199
12200 #[test]
12201 fn m226_on_complete_program_immediate() {
12202 m226_delta_and_classify_check(
12203 |s| s.on_complete_program = Some(std::path::PathBuf::from("/usr/local/bin/finish")),
12204 "on_complete_program",
12205 );
12206 }
12207
12208 #[test]
12209 fn m226_use_incomplete_dir_immediate() {
12210 m226_delta_and_classify_check(|s| s.use_incomplete_dir = true, "use_incomplete_dir");
12211 }
12212
12213 #[test]
12214 fn m226_incomplete_dir_immediate() {
12215 m226_delta_and_classify_check(
12216 |s| s.incomplete_dir = Some(std::path::PathBuf::from("/tmp/inc")),
12217 "incomplete_dir",
12218 );
12219 }
12220
12221 #[test]
12222 fn m226_default_skip_hash_check_immediate() {
12223 m226_delta_and_classify_check(
12224 |s| s.default_skip_hash_check = true,
12225 "default_skip_hash_check",
12226 );
12227 }
12228
12229 #[test]
12230 fn m226_incomplete_extension_enabled_immediate() {
12231 m226_delta_and_classify_check(
12233 |s| s.incomplete_extension_enabled = false,
12234 "incomplete_extension_enabled",
12235 );
12236 }
12237
12238 #[test]
12239 fn m226_watched_folder_immediate() {
12240 m226_delta_and_classify_check(
12241 |s| s.watched_folder = Some(std::path::PathBuf::from("/tmp/watched")),
12242 "watched_folder",
12243 );
12244 }
12245
12246 #[test]
12247 fn m226_delete_torrent_after_add_immediate() {
12248 m226_delta_and_classify_check(
12249 |s| s.delete_torrent_after_add = true,
12250 "delete_torrent_after_add",
12251 );
12252 }
12253
12254 #[test]
12255 fn m226_move_completed_enabled_immediate() {
12256 m226_delta_and_classify_check(
12257 |s| s.move_completed_enabled = true,
12258 "move_completed_enabled",
12259 );
12260 }
12261
12262 #[test]
12263 fn m226_move_completed_to_immediate() {
12264 m226_delta_and_classify_check(
12265 |s| s.move_completed_to = Some(std::path::PathBuf::from("/tmp/done")),
12266 "move_completed_to",
12267 );
12268 }
12269
12270 #[test]
12271 fn m226_ip_filter_auto_refresh_immediate() {
12272 m226_delta_and_classify_check(
12273 |s| s.ip_filter_auto_refresh = true,
12274 "ip_filter_auto_refresh",
12275 );
12276 }
12277
12278 #[test]
12279 fn m226_web_ui_https_enabled_immediate() {
12280 m226_delta_and_classify_check(|s| s.web_ui_https_enabled = true, "web_ui_https_enabled");
12281 }
12282
12283 #[test]
12284 fn m226_network_interface_immediate() {
12285 m226_delta_and_classify_check(
12286 |s| s.network_interface = Some("eth0".into()),
12287 "network_interface",
12288 );
12289 }
12290
12291 #[test]
12292 fn m226_default_add_paused_immediate() {
12293 m226_delta_and_classify_check(|s| s.default_add_paused = true, "default_add_paused");
12294 }
12295
12296 #[test]
12297 fn m257c_request_budget_per_torrent_immediate() {
12298 m226_delta_and_classify_check(
12299 |s| s.request_budget_per_torrent = 0,
12300 "request_budget_per_torrent",
12301 );
12302 }
12303
12304 #[test]
12305 fn m257c_request_budget_floor_immediate() {
12306 m226_delta_and_classify_check(|s| s.request_budget_floor = 16, "request_budget_floor");
12307 }
12308
12309 #[test]
12310 fn m226_delta_clears_optional_path_incomplete_dir() {
12311 use crate::types::SettingsDelta;
12314 let old = Settings {
12315 incomplete_dir: Some(std::path::PathBuf::from("/foo")),
12316 ..Settings::default()
12317 };
12318 let new = Settings {
12319 incomplete_dir: None,
12320 ..old.clone()
12321 };
12322 let d = SettingsDelta::from_diff(&old, &new);
12323 assert_eq!(d.incomplete_dir, Some(None), "must signal clear to None");
12324 assert!(!d.is_empty());
12325 }
12326
12327 #[test]
12328 fn m226_delta_clears_optional_path_watched_folder() {
12329 use crate::types::SettingsDelta;
12331 let old = Settings {
12332 watched_folder: Some(std::path::PathBuf::from("/tmp/watch")),
12333 ..Settings::default()
12334 };
12335 let new = Settings {
12336 watched_folder: None,
12337 ..old.clone()
12338 };
12339 let d = SettingsDelta::from_diff(&old, &new);
12340 assert_eq!(d.watched_folder, Some(None));
12341 assert!(!d.is_empty());
12342 }
12343
12344 #[test]
12345 fn m226_delta_is_empty_honours_new_fields() {
12346 use crate::types::SettingsDelta;
12348 let mut d = SettingsDelta::default();
12349 assert!(d.is_empty());
12350 d.notify_on_complete = Some(true);
12351 assert!(!d.is_empty());
12352 d = SettingsDelta::default();
12353 d.watched_folder = Some(None); assert!(!d.is_empty());
12355 d = SettingsDelta::default();
12356 d.default_add_paused = Some(true);
12357 assert!(!d.is_empty());
12358 }
12359
12360 #[test]
12361 fn m226_no_fields_appear_in_restart_required() {
12362 type Mutation = fn(&mut Settings);
12365 let mutations: [Mutation; 15] = [
12366 |s| s.notify_on_complete = true,
12367 |s| s.notify_on_error = true,
12368 |s| s.on_complete_program = Some(std::path::PathBuf::from("/p")),
12369 |s| s.use_incomplete_dir = true,
12370 |s| s.incomplete_dir = Some(std::path::PathBuf::from("/i")),
12371 |s| s.default_skip_hash_check = true,
12372 |s| s.incomplete_extension_enabled = false,
12373 |s| s.watched_folder = Some(std::path::PathBuf::from("/w")),
12374 |s| s.delete_torrent_after_add = true,
12375 |s| s.move_completed_enabled = true,
12376 |s| s.move_completed_to = Some(std::path::PathBuf::from("/m")),
12377 |s| s.ip_filter_auto_refresh = true,
12378 |s| s.web_ui_https_enabled = true,
12379 |s| s.network_interface = Some("eth0".into()),
12380 |s| s.default_add_paused = true,
12381 ];
12382 let old = Settings::default();
12383 for (idx, m) in mutations.iter().enumerate() {
12384 let mut new = old.clone();
12385 m(&mut new);
12386 let rr = classify_restart_required(&old, &new);
12387 assert!(
12388 rr.is_empty(),
12389 "mutation #{idx}: M226 fields must not surface restart_required, got {rr:?}"
12390 );
12391 }
12392 }
12393
12394 #[test]
12395 fn classify_immediate_seed_time_and_inactive_combined() {
12396 let old = Settings::default();
12399 let mut new = old.clone();
12400 new.seed_time_limit_secs = Some(7200);
12401 new.inactive_seed_time_limit_secs = Some(900);
12402 let imm = classify_immediate(&old, &new);
12403 let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
12404 assert_eq!(
12405 set,
12406 ["max_seeding_time", "max_inactive_seeding_time"]
12407 .into_iter()
12408 .collect::<std::collections::HashSet<_>>()
12409 );
12410 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12411 }
12412
12413 #[test]
12414 fn classify_combined_seed_time_and_hashing_both_immediate() {
12415 let old = Settings::default();
12419 let mut new = old.clone();
12420 new.seed_time_limit_secs = Some(1200);
12421 new.hashing_threads = old.hashing_threads.saturating_add(2);
12422 let imm = classify_immediate(&old, &new);
12423 let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
12424 assert_eq!(
12425 set,
12426 ["max_seeding_time", "hashing_threads"]
12427 .into_iter()
12428 .collect::<std::collections::HashSet<_>>()
12429 );
12430 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12431 }
12432
12433 #[test]
12434 fn classify_combined_hashing_and_save_resume_both_immediate() {
12435 let old = Settings::default();
12439 let mut new = old.clone();
12440 new.hashing_threads = old.hashing_threads.saturating_add(3);
12441 new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(120);
12442 let imm = classify_immediate(&old, &new);
12443 let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
12444 assert_eq!(
12445 set,
12446 ["hashing_threads", "save_resume_interval"]
12447 .into_iter()
12448 .collect::<std::collections::HashSet<_>>()
12449 );
12450 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12451 }
12452
12453 fn m226_make_torrent_bytes(data: &[u8], piece_length: u64) -> Vec<u8> {
12466 use serde::Serialize;
12467
12468 #[derive(Serialize)]
12469 struct Info<'a> {
12470 length: u64,
12471 name: &'a str,
12472 #[serde(rename = "piece length")]
12473 piece_length: u64,
12474 #[serde(with = "serde_bytes")]
12475 pieces: &'a [u8],
12476 }
12477 #[derive(Serialize)]
12478 struct Torrent<'a> {
12479 info: Info<'a>,
12480 }
12481
12482 let mut pieces = Vec::new();
12483 let mut offset = 0;
12484 while offset < data.len() {
12485 let end = (offset + piece_length as usize).min(data.len());
12486 let hash = irontide_core::sha1(&data[offset..end]);
12487 pieces.extend_from_slice(hash.as_bytes());
12488 offset = end;
12489 }
12490
12491 irontide_bencode::to_bytes(&Torrent {
12492 info: Info {
12493 length: data.len() as u64,
12494 name: "m226-test",
12495 piece_length,
12496 pieces: &pieces,
12497 },
12498 })
12499 .unwrap()
12500 }
12501
12502 #[tokio::test]
12505 async fn add_torrent_with_default_add_paused_true_pauses_torrent() {
12506 let mut settings = test_settings();
12507 settings.default_add_paused = true;
12508 let session = SessionHandle::start(settings).await.unwrap();
12509
12510 let data = vec![0xAB; 16384];
12511 let bytes = m226_make_torrent_bytes(&data, 16384);
12512 let info_hash = session
12513 .add_torrent(AddTorrentParams::bytes(bytes))
12514 .await
12515 .unwrap();
12516
12517 tokio::time::sleep(Duration::from_millis(100)).await;
12520 let stats = session.torrent_stats(info_hash).await.unwrap();
12521 assert_eq!(
12522 stats.state,
12523 TorrentState::Paused,
12524 "engine default_add_paused=true must pause the torrent when caller \
12525 passes AddTorrentParams::bytes() without an explicit .paused(...)"
12526 );
12527
12528 session.shutdown().await.unwrap();
12529 }
12530
12531 #[tokio::test]
12535 async fn add_torrent_with_explicit_paused_false_resumes_despite_default() {
12536 let mut settings = test_settings();
12537 settings.default_add_paused = true;
12538 let session = SessionHandle::start(settings).await.unwrap();
12539
12540 let data = vec![0xCD; 16384];
12541 let bytes = m226_make_torrent_bytes(&data, 16384);
12542 let info_hash = session
12543 .add_torrent(AddTorrentParams::bytes(bytes).paused(false))
12544 .await
12545 .unwrap();
12546
12547 tokio::time::sleep(Duration::from_millis(100)).await;
12550 let stats = session.torrent_stats(info_hash).await.unwrap();
12551 assert_ne!(
12552 stats.state,
12553 TorrentState::Paused,
12554 "explicit .paused(false) must override default_add_paused=true; \
12555 got state={:?}",
12556 stats.state
12557 );
12558
12559 session.shutdown().await.unwrap();
12560 }
12561
12562 #[tokio::test]
12566 async fn add_torrent_with_explicit_paused_true_pauses_despite_default_false() {
12567 let mut settings = test_settings();
12568 settings.default_add_paused = false;
12569 let session = SessionHandle::start(settings).await.unwrap();
12570
12571 let data = vec![0xEF; 16384];
12572 let bytes = m226_make_torrent_bytes(&data, 16384);
12573 let info_hash = session
12574 .add_torrent(AddTorrentParams::bytes(bytes).paused(true))
12575 .await
12576 .unwrap();
12577
12578 tokio::time::sleep(Duration::from_millis(100)).await;
12579 let stats = session.torrent_stats(info_hash).await.unwrap();
12580 assert_eq!(
12581 stats.state,
12582 TorrentState::Paused,
12583 "explicit .paused(true) must pause even when \
12584 default_add_paused=false"
12585 );
12586
12587 session.shutdown().await.unwrap();
12588 }
12589}