1#[cfg(any(all(feature = "test", feature = "kobo"), doc))]
40mod dbus_monitor;
41pub mod dictionary_index;
42#[cfg(any(feature = "test", doc))]
43mod hello_world;
44pub mod import;
45pub mod thumbnail;
46#[cfg(any(feature = "kobo", doc))]
47pub mod time_sync;
48#[cfg(any(feature = "kobo", doc))]
49mod wifi_status_monitor;
50
51use std::collections::{HashMap, VecDeque};
52use std::sync::atomic::{AtomicBool, Ordering};
53use std::sync::mpsc::{self, Receiver, Sender};
54use std::thread::{self, JoinHandle};
55use std::time::Duration;
56
57use thiserror::Error;
58
59use crate::context::Context;
60use crate::db::Database;
61use crate::fl;
62use crate::input::DeviceEvent;
63use crate::settings::Settings;
64use crate::view::{EntryId, Event, NotificationEvent};
65
66#[derive(Error, Debug)]
68pub enum TaskError {
69 #[error("task '{0}' is already running")]
71 AlreadyRunning(TaskId),
72
73 #[error("task '{0}' is not running")]
75 NotRunning(TaskId),
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80pub enum TaskId {
81 Placeholder,
83 Import,
85 ThumbnailExtraction,
87 DictionaryIndex,
89 #[cfg(any(feature = "test", doc))]
91 HelloWorld,
92 #[cfg(any(all(feature = "test", feature = "kobo"), doc))]
94 DbusMonitor,
95 #[cfg(any(feature = "kobo", doc))]
97 WifiStatusMonitor,
98 #[cfg(any(feature = "kobo", doc))]
100 TimeSync,
101 #[cfg(test)]
103 TestTask,
104 #[cfg(test)]
106 TestTask2,
107}
108
109impl std::fmt::Display for TaskId {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 TaskId::Placeholder => write!(f, "placeholder"),
113 TaskId::Import => write!(f, "import"),
114 TaskId::ThumbnailExtraction => write!(f, "thumbnail_extraction"),
115 TaskId::DictionaryIndex => write!(f, "dictionary_index"),
116 #[cfg(feature = "test")]
117 TaskId::HelloWorld => write!(f, "hello_world"),
118 #[cfg(all(feature = "test", feature = "kobo"))]
119 TaskId::DbusMonitor => write!(f, "dbus_monitor"),
120 #[cfg(feature = "kobo")]
121 TaskId::WifiStatusMonitor => write!(f, "wifi_status_monitor"),
122 #[cfg(feature = "kobo")]
123 TaskId::TimeSync => write!(f, "time_sync"),
124 #[cfg(test)]
125 TaskId::TestTask => write!(f, "test_task"),
126 #[cfg(test)]
127 TaskId::TestTask2 => write!(f, "test_task_2"),
128 }
129 }
130}
131
132pub struct ShutdownSignal {
137 receiver: Receiver<()>,
138 _sender_anchor: Option<Sender<()>>,
141 stopped: AtomicBool,
142}
143
144impl ShutdownSignal {
145 fn new(receiver: Receiver<()>) -> Self {
146 Self {
147 receiver,
148 _sender_anchor: None,
149 stopped: AtomicBool::new(false),
150 }
151 }
152
153 pub fn never() -> Self {
158 let (tx, rx) = mpsc::channel();
159 Self {
160 receiver: rx,
161 _sender_anchor: Some(tx),
162 stopped: AtomicBool::new(false),
163 }
164 }
165
166 #[cfg(test)]
172 pub fn new_for_test(receiver: Receiver<()>) -> Self {
173 Self::new(receiver)
174 }
175
176 pub fn should_stop(&self) -> bool {
182 if self.stopped.load(Ordering::Acquire) {
183 return true;
184 }
185 if self.receiver.try_recv().is_ok() {
186 self.stopped.store(true, Ordering::Release);
187 return true;
188 }
189 false
190 }
191
192 pub fn wait(&self, duration: Duration) -> bool {
198 if self.stopped.load(Ordering::Acquire) {
199 return true;
200 }
201 match self.receiver.recv_timeout(duration) {
202 Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
203 self.stopped.store(true, Ordering::Release);
204 true
205 }
206 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => false,
207 }
208 }
209}
210
211pub trait BackgroundTask: Send {
217 fn id(&self) -> TaskId;
219
220 fn run(&mut self, hub: &Sender<Event>, shutdown: &ShutdownSignal);
225
226 fn stop(&mut self) {}
230
231 fn finished_event(&self) -> Option<Event> {
236 None
237 }
238}
239
240struct RunningTask {
241 handle: JoinHandle<()>,
242 shutdown: Sender<()>,
243 finished_event: Option<Event>,
245}
246
247pub struct TaskManager {
252 tasks: HashMap<TaskId, RunningTask>,
253 pending_import_indices: VecDeque<(Option<usize>, bool)>,
255 pending_thumbnail_indices: VecDeque<Option<usize>>,
257 buffered_events: Vec<Event>,
259}
260
261impl TaskManager {
262 pub fn new() -> Self {
264 Self {
265 tasks: HashMap::new(),
266 pending_import_indices: VecDeque::new(),
267 pending_thumbnail_indices: VecDeque::new(),
268 buffered_events: Vec::new(),
269 }
270 }
271
272 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, task, hub), fields(task_id = tracing::field::Empty), ret))]
279 pub fn start(
280 &mut self,
281 task: Box<dyn BackgroundTask>,
282 hub: Sender<Event>,
283 ) -> Result<TaskId, TaskError> {
284 let id = task.id();
285
286 #[cfg(feature = "tracing")]
287 tracing::Span::current().record("task_id", tracing::field::display(&id));
288
289 if self.is_running(&id) {
290 return Err(TaskError::AlreadyRunning(id));
291 }
292
293 let (shutdown_tx, shutdown_rx) = mpsc::channel();
294 let shutdown_signal = ShutdownSignal::new(shutdown_rx);
295
296 let finished_event = task.finished_event();
297
298 let handle = thread::spawn(move || {
299 let mut task = task;
300 tracing::info!("task started");
301 task.run(&hub, &shutdown_signal);
302 task.stop();
303 tracing::info!("task stopped");
304 });
305
306 self.tasks.insert(
307 id.clone(),
308 RunningTask {
309 handle,
310 shutdown: shutdown_tx,
311 finished_event,
312 },
313 );
314
315 tracing::info!("task registered");
316 Ok(id)
317 }
318
319 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(task_id = %id), ret))]
324 pub fn stop(&mut self, id: &TaskId) -> Result<(), TaskError> {
325 self.cleanup_finished();
326 if let Some(task) = self.tasks.remove(id) {
327 tracing::info!("sending shutdown signal");
328 if let Err(e) = task.shutdown.send(()) {
329 tracing::error!(error = %e, "failed to send shutdown signal");
330 }
331 if task.handle.join().is_err() {
332 tracing::error!("task thread panicked");
333 }
334 Ok(())
335 } else {
336 Err(TaskError::NotRunning(id.clone()))
337 }
338 }
339
340 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(task_count = tracing::field::Empty)))]
344 pub fn stop_all(&mut self) {
345 let tasks: Vec<_> = self.tasks.drain().collect();
346
347 #[cfg(feature = "tracing")]
348 tracing::Span::current().record("task_count", tasks.len());
349
350 if !tasks.is_empty() {
351 tracing::info!("stopping all tasks");
352 }
353 for (_, task) in &tasks {
354 if let Err(e) = task.shutdown.send(()) {
355 tracing::error!(error = %e, "failed to send shutdown signal");
356 }
357 }
358 for (_, task) in tasks {
359 if task.handle.join().is_err() {
360 tracing::error!("task thread panicked");
361 }
362 }
363 }
364
365 fn cleanup_finished(&mut self) {
368 let finished: Vec<TaskId> = self
369 .tasks
370 .iter()
371 .filter(|(_, task)| task.handle.is_finished())
372 .map(|(id, _)| id.clone())
373 .collect();
374
375 for id in finished {
376 if let Some(task) = self.tasks.remove(&id) {
377 if task.handle.join().is_ok() {
378 if let Some(evt) = task.finished_event {
379 self.buffered_events.push(evt);
380 }
381 } else {
382 tracing::error!(task_id = %id, "task thread panicked");
383 }
384 }
385 }
386 }
387
388 fn flush_buffered_events(&mut self, hub: &Sender<Event>) {
390 for evt in self.buffered_events.drain(..) {
391 hub.send(evt).ok();
392 }
393 }
394
395 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, hub, context)))]
400 pub fn handle_event(&mut self, evt: &Event, hub: &Sender<Event>, context: &Context) -> bool {
401 self.cleanup_finished();
402 self.flush_buffered_events(hub);
403
404 match evt {
405 Event::ImportLibrary {
406 library_index,
407 force,
408 } => {
409 self.schedule_import(
410 *library_index,
411 *force,
412 hub,
413 &context.database,
414 &context.settings,
415 );
416 }
417 Event::ImportFinished { library_index } => {
418 self.drain_pending_imports(hub, &context.database, &context.settings);
419 self.schedule_thumbnail_extraction(
420 *library_index,
421 hub,
422 &context.database,
423 &context.settings,
424 );
425 }
426 Event::ThumbnailExtractionFinished { .. } => {
427 self.drain_pending_thumbnails(hub, &context.database, &context.settings);
428 }
429 Event::ReindexDictionaries => {
430 self.schedule_dictionary_index(hub, &context.database);
431 }
432 Event::Device(DeviceEvent::NetUp) => {
433 #[cfg(feature = "kobo")]
434 {
435 if context.settings.auto_time {
436 self.schedule_time_sync(false, hub);
437 }
438 }
439 }
440 Event::Select(EntryId::SyncTime) => {
441 #[cfg(feature = "kobo")]
442 {
443 if !context.online {
444 hub.send(Event::Notification(NotificationEvent::Show(fl!(
445 "notification-not-online"
446 ))))
447 .ok();
448 } else {
449 self.schedule_time_sync(true, hub);
450 }
451 }
452 }
453 _ => {}
454 }
455 false
456 }
457
458 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
460 fn schedule_import(
461 &mut self,
462 library_index: Option<usize>,
463 force: bool,
464 hub: &Sender<Event>,
465 database: &Database,
466 settings: &Settings,
467 ) {
468 if self.is_running(&TaskId::Import) {
469 tracing::info!(library_index = ?library_index, force, "import already running, queueing");
470 self.pending_import_indices
471 .push_back((library_index, force));
472 return;
473 }
474
475 self.flush_buffered_events(hub);
476
477 let task = Box::new(import::ImportTask::new(
478 database.clone(),
479 settings.clone(),
480 library_index,
481 force,
482 ));
483
484 if let Err(e) = self.start(task, hub.clone()) {
485 tracing::warn!(error = %e, "failed to start import task");
486 }
487 }
488
489 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
491 fn drain_pending_imports(
492 &mut self,
493 hub: &Sender<Event>,
494 database: &Database,
495 settings: &Settings,
496 ) {
497 if self.is_running(&TaskId::Import) || self.pending_import_indices.is_empty() {
498 return;
499 }
500
501 let Some((next, force)) = self.pending_import_indices.pop_front() else {
502 return;
503 };
504 self.schedule_import(next, force, hub, database, settings);
505 }
506
507 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
509 fn schedule_dictionary_index(&mut self, hub: &Sender<Event>, database: &Database) {
510 if self.is_running(&TaskId::DictionaryIndex) {
511 tracing::debug!("stopping running dictionary index task for restart");
512 if let Err(e) = self.stop(&TaskId::DictionaryIndex) {
513 tracing::warn!(error = %e, "failed to stop dictionary_index task for restart");
514 }
515 }
516
517 self.flush_buffered_events(hub);
518
519 let task = Box::new(dictionary_index::DictionaryIndexTask::new(database.clone()));
520
521 if let Err(e) = self.start(task, hub.clone()) {
522 tracing::warn!(error = %e, "failed to start dictionary_index task");
523 }
524 }
525
526 #[cfg(feature = "kobo")]
527 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
528 fn schedule_time_sync(&mut self, manual: bool, hub: &Sender<Event>) {
529 if self.is_running(&TaskId::TimeSync) {
530 tracing::warn!("Time sync task already running, not scheduling");
531
532 return;
533 }
534
535 match crate::device::CURRENT_DEVICE.time_manager() {
536 Ok(time_manager) => {
537 let task = Box::new(time_sync::TimeSyncTask::new(time_manager, manual));
538 if let Err(e) = self.start(task, hub.clone()) {
539 tracing::warn!(error = %e, "failed to start time sync task");
540 }
541 }
542 Err(e) => {
543 tracing::warn!(error = %e, "time manager unavailable, cannot sync time");
544 }
545 }
546 }
547
548 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
550 pub fn schedule_thumbnail_extraction(
551 &mut self,
552 library_index: Option<usize>,
553 hub: &Sender<Event>,
554 database: &Database,
555 settings: &Settings,
556 ) {
557 if self.is_running(&TaskId::ThumbnailExtraction) {
558 tracing::info!(library_index = ?library_index, "thumbnail extraction already running, queueing");
559 self.pending_thumbnail_indices.push_back(library_index);
560 return;
561 }
562
563 self.flush_buffered_events(hub);
564
565 let task = Box::new(thumbnail::ThumbnailExtractionTask::new(
566 database.clone(),
567 settings.clone(),
568 library_index,
569 ));
570
571 if let Err(e) = self.start(task, hub.clone()) {
572 tracing::warn!(error = %e, "failed to start thumbnail extraction task");
573 }
574 }
575
576 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
578 fn drain_pending_thumbnails(
579 &mut self,
580 hub: &Sender<Event>,
581 database: &Database,
582 settings: &Settings,
583 ) {
584 if self.is_running(&TaskId::ThumbnailExtraction)
585 || self.pending_thumbnail_indices.is_empty()
586 {
587 return;
588 }
589
590 let Some(next) = self.pending_thumbnail_indices.pop_front() else {
591 return;
592 };
593 self.schedule_thumbnail_extraction(next, hub, database, settings);
594 }
595
596 pub fn is_running(&mut self, id: &TaskId) -> bool {
598 self.cleanup_finished();
599 self.tasks.contains_key(id)
600 }
601
602 pub fn running_tasks(&mut self) -> Vec<TaskId> {
604 self.cleanup_finished();
605 self.tasks.keys().cloned().collect()
606 }
607}
608
609impl Default for TaskManager {
610 fn default() -> Self {
611 Self::new()
612 }
613}
614
615impl Drop for TaskManager {
616 fn drop(&mut self) {
617 self.stop_all();
618 }
619}
620
621pub fn register_startup_tasks(
631 manager: &mut TaskManager,
632 hub: Sender<Event>,
633 settings: &Settings,
634 database: &Database,
635) {
636 #[cfg(feature = "kobo")]
637 {
638 let task = Box::new(wifi_status_monitor::WifiStatusMonitorTask);
639 if let Err(e) = manager.start(task, hub.clone()) {
640 tracing::warn!(error = %e, "failed to start wifi_status_monitor task");
641 }
642 }
643
644 #[cfg(feature = "test")]
645 {
646 let task = Box::new(hello_world::HelloWorldTask);
647 if let Err(e) = manager.start(task, hub.clone()) {
648 tracing::warn!(error = %e, "failed to start hello_world task");
649 }
650
651 #[cfg(feature = "kobo")]
652 if settings.logging.enable_dbus_log {
653 let task = Box::new(dbus_monitor::DbusMonitorTask);
654 if let Err(e) = manager.start(task, hub.clone()) {
655 tracing::warn!(error = %e, "failed to start dbus_monitor task");
656 }
657 }
658 }
659
660 manager.schedule_import(None, false, &hub, database, settings);
661
662 let task = Box::new(dictionary_index::DictionaryIndexTask::new(database.clone()));
663 if let Err(e) = manager.start(task, hub.clone()) {
664 tracing::warn!(error = %e, "failed to start dictionary_index task");
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use crate::context::test_helpers::create_test_context;
672 use std::sync::mpsc;
673 use std::time::{Duration, Instant};
674
675 fn wait_until_not_running(manager: &mut TaskManager, id: &TaskId) {
676 let deadline = Instant::now() + Duration::from_secs(5);
677 while Instant::now() < deadline {
678 if !manager.is_running(id) {
679 return;
680 }
681 std::thread::sleep(Duration::from_millis(1));
682 }
683 panic!("task '{id}' did not finish within timeout");
684 }
685
686 struct InstantTask;
687
688 impl BackgroundTask for InstantTask {
689 fn id(&self) -> TaskId {
690 TaskId::TestTask2
691 }
692
693 fn run(&mut self, _hub: &Sender<Event>, _shutdown: &ShutdownSignal) {}
694 }
695
696 struct WaitingTask;
697
698 impl BackgroundTask for WaitingTask {
699 fn id(&self) -> TaskId {
700 TaskId::TestTask
701 }
702
703 fn run(&mut self, _hub: &Sender<Event>, shutdown: &ShutdownSignal) {
704 shutdown.wait(Duration::from_secs(60));
705 }
706 }
707
708 #[test]
709 fn start_and_stop() {
710 let mut manager = TaskManager::new();
711 let (hub, _rx) = mpsc::channel();
712
713 let id = manager.start(Box::new(WaitingTask), hub).unwrap();
714 assert!(manager.is_running(&id));
715
716 manager.stop(&id).unwrap();
717 assert!(!manager.is_running(&id));
718 }
719
720 #[test]
721 fn duplicate_start_returns_error() {
722 let mut manager = TaskManager::new();
723 let (hub, _rx) = mpsc::channel();
724
725 manager.start(Box::new(WaitingTask), hub.clone()).unwrap();
726 let err = manager.start(Box::new(WaitingTask), hub).unwrap_err();
727
728 assert!(matches!(err, TaskError::AlreadyRunning(TaskId::TestTask)));
729 }
730
731 #[test]
732 fn finished_task_is_cleaned_up() {
733 let mut manager = TaskManager::new();
734 let (hub, _rx) = mpsc::channel();
735
736 let id = manager.start(Box::new(InstantTask), hub).unwrap();
737
738 wait_until_not_running(&mut manager, &id);
739 assert!(!manager.is_running(&id));
740 }
741
742 #[test]
743 fn stop_finished_task_returns_not_running() {
744 let mut manager = TaskManager::new();
745 let (hub, _rx) = mpsc::channel();
746
747 let id = manager.start(Box::new(InstantTask), hub).unwrap();
748
749 wait_until_not_running(&mut manager, &id);
750 let err = manager.stop(&id).unwrap_err();
751
752 assert!(matches!(err, TaskError::NotRunning(TaskId::TestTask2)));
753 }
754
755 #[test]
756 fn running_tasks_excludes_finished() {
757 let mut manager = TaskManager::new();
758 let (hub, _rx) = mpsc::channel();
759
760 manager.start(Box::new(WaitingTask), hub.clone()).unwrap();
761 let instant_id = manager.start(Box::new(InstantTask), hub).unwrap();
762
763 wait_until_not_running(&mut manager, &instant_id);
764 let running = manager.running_tasks();
765
766 assert_eq!(running.len(), 1);
767 assert_eq!(running[0], TaskId::TestTask);
768
769 manager.stop_all();
770 }
771
772 #[test]
773 fn stop_all_stops_everything() {
774 let mut manager = TaskManager::new();
775 let (hub, _rx) = mpsc::channel();
776
777 manager.start(Box::new(WaitingTask), hub).unwrap();
778 manager.stop_all();
779
780 assert!(!manager.is_running(&TaskId::TestTask));
781 }
782
783 #[test]
784 fn test_thumbnail_extraction_task_lifecycle() {
785 let mut manager = TaskManager::new();
786 let (hub, _rx) = mpsc::channel();
787 let database = Database::new(":memory:").unwrap();
788 database.migrate().unwrap();
789 let settings = Settings::default();
790
791 manager.schedule_thumbnail_extraction(None, &hub, &database, &settings);
792
793 wait_until_not_running(&mut manager, &TaskId::ThumbnailExtraction);
796 assert!(!manager.is_running(&TaskId::ThumbnailExtraction));
797
798 let err = manager.stop(&TaskId::ThumbnailExtraction).unwrap_err();
799 assert!(matches!(
800 err,
801 TaskError::NotRunning(TaskId::ThumbnailExtraction)
802 ));
803 }
804
805 #[test]
806 fn thumbnail_extraction_queues_when_running() {
807 let mut manager = TaskManager::new();
808 let (hub, _rx) = mpsc::channel();
809
810 let (shutdown_tx, shutdown_rx) = mpsc::channel();
812 let blocking_handle = thread::spawn(move || {
813 let _ = shutdown_rx.recv();
814 });
815 manager.tasks.insert(
816 TaskId::ThumbnailExtraction,
817 RunningTask {
818 handle: blocking_handle,
819 shutdown: shutdown_tx,
820 finished_event: None,
821 },
822 );
823
824 let database = Database::new(":memory:").unwrap();
825 database.migrate().unwrap();
826 let settings = Settings::default();
827
828 manager.schedule_thumbnail_extraction(Some(0), &hub, &database, &settings);
829 manager.schedule_thumbnail_extraction(Some(1), &hub, &database, &settings);
830
831 assert_eq!(manager.pending_thumbnail_indices.len(), 2);
832
833 manager.stop(&TaskId::ThumbnailExtraction).unwrap();
834
835 manager.drain_pending_thumbnails(&hub, &database, &settings);
836 assert_eq!(manager.pending_thumbnail_indices.len(), 1);
837
838 wait_until_not_running(&mut manager, &TaskId::ThumbnailExtraction);
839
840 manager.drain_pending_thumbnails(&hub, &database, &settings);
841 assert!(manager.pending_thumbnail_indices.is_empty());
842
843 wait_until_not_running(&mut manager, &TaskId::ThumbnailExtraction);
844 }
845
846 #[test]
847 fn import_queue_preserves_force_flag() {
848 let mut manager = TaskManager::new();
849 let (hub, _rx) = mpsc::channel();
850 let context = create_test_context();
851
852 let (shutdown_tx, shutdown_rx) = mpsc::channel();
854 let blocking_handle = thread::spawn(move || {
855 let _ = shutdown_rx.recv();
856 });
857 manager.tasks.insert(
858 TaskId::Import,
859 RunningTask {
860 handle: blocking_handle,
861 shutdown: shutdown_tx,
862 finished_event: None,
863 },
864 );
865
866 manager.handle_event(
867 &Event::ImportLibrary {
868 library_index: Some(0),
869 force: true,
870 },
871 &hub,
872 &context,
873 );
874
875 assert_eq!(
876 manager.pending_import_indices.front(),
877 Some(&(Some(0), true))
878 );
879
880 manager.stop(&TaskId::Import).unwrap();
881 }
882
883 #[test]
884 fn import_queue_preserves_force_false_flag() {
885 let mut manager = TaskManager::new();
886 let (hub, _rx) = mpsc::channel();
887 let context = create_test_context();
888
889 let (shutdown_tx, shutdown_rx) = mpsc::channel();
890 let blocking_handle = thread::spawn(move || {
891 let _ = shutdown_rx.recv();
892 });
893 manager.tasks.insert(
894 TaskId::Import,
895 RunningTask {
896 handle: blocking_handle,
897 shutdown: shutdown_tx,
898 finished_event: None,
899 },
900 );
901
902 manager.handle_event(
903 &Event::ImportLibrary {
904 library_index: None,
905 force: false,
906 },
907 &hub,
908 &context,
909 );
910
911 assert_eq!(manager.pending_import_indices.front(), Some(&(None, false)));
912
913 manager.stop(&TaskId::Import).unwrap();
914 }
915}