1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
12use std::sync::{Arc, Mutex};
13
14use tauri::{AppHandle, Emitter, Runtime};
15use tokio::sync::{mpsc, oneshot};
16use tokio_util::sync::CancellationToken;
17
18use crate::error::ServiceError;
19use crate::models::{
20 validate_foreground_service_type, PluginEvent, ServiceContext,
21 ServiceState as ServiceLifecycle, ServiceStatus, StartConfig,
22};
23use crate::notifier::Notifier;
24use crate::service_trait::BackgroundService;
25
26#[doc(hidden)]
28pub type OnCompleteCallback = Box<dyn Fn(bool) + Send + Sync>;
29
30pub(crate) trait MobileKeepalive: Send + Sync {
36 #[allow(clippy::too_many_arguments)]
38 fn start_keepalive(
39 &self,
40 label: &str,
41 foreground_service_type: &str,
42 ios_safety_timeout_secs: Option<f64>,
43 ios_processing_safety_timeout_secs: Option<f64>,
44 ios_earliest_refresh_begin_minutes: Option<f64>,
45 ios_earliest_processing_begin_minutes: Option<f64>,
46 ios_requires_external_power: Option<bool>,
47 ios_requires_network_connectivity: Option<bool>,
48 ) -> Result<(), ServiceError>;
49 fn stop_keepalive(&self) -> Result<(), ServiceError>;
51}
52
53#[doc(hidden)]
55pub type ServiceFactory<R> = Box<dyn Fn() -> Box<dyn BackgroundService<R>> + Send + Sync>;
56
57#[non_exhaustive]
66pub enum ManagerCommand<R: Runtime> {
67 Start {
68 config: StartConfig,
69 reply: oneshot::Sender<Result<(), ServiceError>>,
70 app: AppHandle<R>,
71 },
72 Stop {
73 reply: oneshot::Sender<Result<(), ServiceError>>,
74 },
75 IsRunning {
76 reply: oneshot::Sender<bool>,
77 },
78 GetState {
79 reply: oneshot::Sender<ServiceStatus>,
80 },
81 SetOnComplete {
82 callback: OnCompleteCallback,
83 },
84 #[allow(dead_code, private_interfaces)]
85 SetMobile {
86 mobile: Arc<dyn MobileKeepalive>,
87 },
88}
89
90pub struct ServiceManagerHandle<R: Runtime> {
98 pub(crate) cmd_tx: mpsc::Sender<ManagerCommand<R>>,
99}
100
101impl<R: Runtime> ServiceManagerHandle<R> {
102 pub fn new(cmd_tx: mpsc::Sender<ManagerCommand<R>>) -> Self {
104 Self { cmd_tx }
105 }
106
107 pub async fn start(&self, app: AppHandle<R>, config: StartConfig) -> Result<(), ServiceError> {
112 let (reply, rx) = oneshot::channel();
113 self.cmd_tx
114 .send(ManagerCommand::Start { config, reply, app })
115 .await
116 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
117 rx.await
118 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
119 }
120
121 pub async fn stop(&self) -> Result<(), ServiceError> {
126 let (reply, rx) = oneshot::channel();
127 self.cmd_tx
128 .send(ManagerCommand::Stop { reply })
129 .await
130 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
131 rx.await
132 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
133 }
134
135 pub fn stop_blocking(&self) -> Result<(), ServiceError> {
141 let (reply, rx) = oneshot::channel();
142 self.cmd_tx
143 .blocking_send(ManagerCommand::Stop { reply })
144 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
145 rx.blocking_recv()
146 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
147 }
148
149 pub async fn is_running(&self) -> bool {
151 let (reply, rx) = oneshot::channel();
152 if self
153 .cmd_tx
154 .send(ManagerCommand::IsRunning { reply })
155 .await
156 .is_err()
157 {
158 return false;
159 }
160 rx.await.unwrap_or(false)
161 }
162
163 #[doc(hidden)]
168 pub async fn set_on_complete(&self, callback: OnCompleteCallback) {
169 let _ = self
170 .cmd_tx
171 .send(ManagerCommand::SetOnComplete { callback })
172 .await;
173 }
174
175 pub async fn get_state(&self) -> ServiceStatus {
177 let (reply, rx) = oneshot::channel();
178 if self
179 .cmd_tx
180 .send(ManagerCommand::GetState { reply })
181 .await
182 .is_err()
183 {
184 return ServiceStatus {
185 state: ServiceLifecycle::Idle,
186 last_error: None,
187 };
188 }
189 rx.await.unwrap_or(ServiceStatus {
190 state: ServiceLifecycle::Idle,
191 last_error: None,
192 })
193 }
194}
195
196struct ServiceState<R: Runtime> {
200 is_running: Arc<AtomicBool>,
204 token: Arc<Mutex<Option<CancellationToken>>>,
208 generation: Arc<AtomicU64>,
211 on_complete: Option<OnCompleteCallback>,
215 factory: ServiceFactory<R>,
217 mobile: Option<Arc<dyn MobileKeepalive>>,
219 ios_safety_timeout_secs: f64,
222 ios_processing_safety_timeout_secs: f64,
226 ios_earliest_refresh_begin_minutes: f64,
228 ios_earliest_processing_begin_minutes: f64,
230 ios_requires_external_power: bool,
232 ios_requires_network_connectivity: bool,
234 lifecycle_state: Arc<Mutex<ServiceLifecycle>>,
237 last_error: Arc<Mutex<Option<String>>>,
240}
241
242#[doc(hidden)]
249#[allow(clippy::too_many_arguments)]
250pub async fn manager_loop<R: Runtime>(
251 mut rx: mpsc::Receiver<ManagerCommand<R>>,
252 factory: ServiceFactory<R>,
253 ios_safety_timeout_secs: f64,
257 ios_processing_safety_timeout_secs: f64,
260 ios_earliest_refresh_begin_minutes: f64,
262 ios_earliest_processing_begin_minutes: f64,
264 ios_requires_external_power: bool,
266 ios_requires_network_connectivity: bool,
268) {
269 let mut state = ServiceState {
270 is_running: Arc::new(AtomicBool::new(false)),
271 token: Arc::new(Mutex::new(None)),
272 generation: Arc::new(AtomicU64::new(0)),
273 on_complete: None,
274 factory,
275 mobile: None,
276 ios_safety_timeout_secs,
277 ios_processing_safety_timeout_secs,
278 ios_earliest_refresh_begin_minutes,
279 ios_earliest_processing_begin_minutes,
280 ios_requires_external_power,
281 ios_requires_network_connectivity,
282 lifecycle_state: Arc::new(Mutex::new(ServiceLifecycle::Idle)),
283 last_error: Arc::new(Mutex::new(None)),
284 };
285
286 while let Some(cmd) = rx.recv().await {
287 match cmd {
288 ManagerCommand::Start { config, reply, app } => {
289 let _ = reply.send(handle_start(&mut state, app, config));
290 }
291 ManagerCommand::Stop { reply } => {
292 let _ = reply.send(handle_stop(&mut state));
293 }
294 ManagerCommand::IsRunning { reply } => {
295 let _ = reply.send(state.is_running.load(Ordering::SeqCst));
296 }
297 ManagerCommand::SetOnComplete { callback } => {
298 state.on_complete = Some(callback);
299 }
300 ManagerCommand::SetMobile { mobile } => {
301 state.mobile = Some(mobile);
302 }
303 ManagerCommand::GetState { reply } => {
304 let status = ServiceStatus {
305 state: *state.lifecycle_state.lock().unwrap(),
306 last_error: state.last_error.lock().unwrap().clone(),
307 };
308 let _ = reply.send(status);
309 }
310 }
311 }
312}
313
314fn handle_start<R: Runtime>(
325 state: &mut ServiceState<R>,
326 app: AppHandle<R>,
327 config: StartConfig,
328) -> Result<(), ServiceError> {
329 let mut guard = state.token.lock().unwrap();
330
331 if guard.is_some() {
332 return Err(ServiceError::AlreadyRunning);
333 }
334
335 if cfg!(mobile) {
339 validate_foreground_service_type(&config.foreground_service_type)?;
340 }
341
342 let token = CancellationToken::new();
343 let shutdown = token.clone();
344 *guard = Some(token);
345 let my_gen = state.generation.fetch_add(1, Ordering::Release) + 1;
346 state.is_running.store(true, Ordering::SeqCst);
347 *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Initializing;
348 *state.last_error.lock().unwrap() = None;
349
350 drop(guard);
351
352 let captured_callback = state.on_complete.take();
355
356 if let Some(ref mobile) = state.mobile {
359 let processing_timeout = if state.ios_processing_safety_timeout_secs > 0.0 {
360 Some(state.ios_processing_safety_timeout_secs)
361 } else {
362 None
363 };
364 if let Err(e) = mobile.start_keepalive(
365 &config.service_label,
366 &config.foreground_service_type,
367 Some(state.ios_safety_timeout_secs),
368 processing_timeout,
369 Some(state.ios_earliest_refresh_begin_minutes),
370 Some(state.ios_earliest_processing_begin_minutes),
371 Some(state.ios_requires_external_power),
372 Some(state.ios_requires_network_connectivity),
373 ) {
374 state.token.lock().unwrap().take();
376 state.is_running.store(false, Ordering::SeqCst);
377 *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Idle;
378 state.on_complete = captured_callback;
380 return Err(e);
381 }
382 }
383
384 let token_ref = state.token.clone();
386 let gen_ref = state.generation.clone();
387 let is_running_ref = state.is_running.clone();
388 let lifecycle_ref = state.lifecycle_state.clone();
389 let last_error_ref = state.last_error.clone();
390
391 let mut service = (state.factory)();
392
393 let ctx = ServiceContext {
394 notifier: Notifier { app: app.clone() },
395 app: app.clone(),
396 shutdown,
397 #[cfg(mobile)]
398 service_label: config.service_label,
399 #[cfg(mobile)]
400 foreground_service_type: config.foreground_service_type,
401 };
402
403 tauri::async_runtime::spawn(async move {
407 if let Err(e) = service.init(&ctx).await {
409 let _ = app.emit(
410 "background-service://event",
411 PluginEvent::Error {
412 message: e.to_string(),
413 },
414 );
415 if gen_ref.load(Ordering::Acquire) == my_gen {
417 token_ref.lock().unwrap().take();
418 is_running_ref.store(false, Ordering::SeqCst);
419 {
421 let mut lc = lifecycle_ref.lock().unwrap();
422 if *lc == ServiceLifecycle::Initializing {
423 *lc = ServiceLifecycle::Stopped;
424 }
425 }
426 *last_error_ref.lock().unwrap() = Some(e.to_string());
427 }
428 if let Some(cb) = captured_callback {
430 cb(false);
431 }
432 return;
433 }
434
435 if gen_ref.load(Ordering::Acquire) == my_gen {
437 let mut lc = lifecycle_ref.lock().unwrap();
438 if *lc == ServiceLifecycle::Initializing {
439 *lc = ServiceLifecycle::Running;
440 }
441 }
442
443 let _ = app.emit("background-service://event", PluginEvent::Started);
445
446 let result = service.run(&ctx).await;
448
449 match result {
451 Ok(()) => {
452 let _ = app.emit(
453 "background-service://event",
454 PluginEvent::Stopped {
455 reason: "completed".into(),
456 },
457 );
458 }
459 Err(ref e) => {
460 let _ = app.emit(
461 "background-service://event",
462 PluginEvent::Error {
463 message: e.to_string(),
464 },
465 );
466 }
467 }
468
469 if let Some(cb) = captured_callback {
473 cb(result.is_ok());
474 }
475
476 if gen_ref.load(Ordering::Acquire) == my_gen {
478 token_ref.lock().unwrap().take();
479 is_running_ref.store(false, Ordering::SeqCst);
480 {
482 let mut lc = lifecycle_ref.lock().unwrap();
483 if matches!(
484 *lc,
485 ServiceLifecycle::Initializing | ServiceLifecycle::Running
486 ) {
487 *lc = ServiceLifecycle::Stopped;
488 }
489 }
490 if let Err(ref e) = result {
491 *last_error_ref.lock().unwrap() = Some(e.to_string());
492 }
493 }
494 });
495
496 Ok(())
497}
498
499fn handle_stop<R: Runtime>(state: &mut ServiceState<R>) -> Result<(), ServiceError> {
504 let mut guard = state.token.lock().unwrap();
505 match guard.take() {
506 Some(token) => {
507 token.cancel();
508 state.is_running.store(false, Ordering::SeqCst);
509 *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Stopped;
510 *state.last_error.lock().unwrap() = None;
511 drop(guard);
512 if let Some(ref mobile) = state.mobile {
514 if let Err(e) = mobile.stop_keepalive() {
515 log::warn!("stop_keepalive failed (service already cancelled): {e}");
516 }
517 }
518 Ok(())
519 }
520 None => Err(ServiceError::NotRunning),
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use async_trait::async_trait;
528 use std::sync::atomic::{AtomicI8, AtomicU8, AtomicUsize};
529
530 struct MockMobile {
534 start_called: AtomicUsize,
535 stop_called: AtomicUsize,
536 start_fail: bool,
537 last_label: std::sync::Mutex<Option<String>>,
538 last_fst: std::sync::Mutex<Option<String>>,
539 last_timeout_secs: std::sync::Mutex<Option<f64>>,
540 last_processing_timeout_secs: std::sync::Mutex<Option<f64>>,
541 last_earliest_refresh_begin_minutes: std::sync::Mutex<Option<f64>>,
542 last_earliest_processing_begin_minutes: std::sync::Mutex<Option<f64>>,
543 last_requires_external_power: std::sync::Mutex<Option<bool>>,
544 last_requires_network_connectivity: std::sync::Mutex<Option<bool>>,
545 }
546
547 impl MockMobile {
548 fn new() -> Arc<Self> {
549 Arc::new(Self {
550 start_called: AtomicUsize::new(0),
551 stop_called: AtomicUsize::new(0),
552 start_fail: false,
553 last_label: std::sync::Mutex::new(None),
554 last_fst: std::sync::Mutex::new(None),
555 last_timeout_secs: std::sync::Mutex::new(None),
556 last_processing_timeout_secs: std::sync::Mutex::new(None),
557 last_earliest_refresh_begin_minutes: std::sync::Mutex::new(None),
558 last_earliest_processing_begin_minutes: std::sync::Mutex::new(None),
559 last_requires_external_power: std::sync::Mutex::new(None),
560 last_requires_network_connectivity: std::sync::Mutex::new(None),
561 })
562 }
563
564 fn new_failing() -> Arc<Self> {
565 Arc::new(Self {
566 start_called: AtomicUsize::new(0),
567 stop_called: AtomicUsize::new(0),
568 start_fail: true,
569 last_label: std::sync::Mutex::new(None),
570 last_fst: std::sync::Mutex::new(None),
571 last_timeout_secs: std::sync::Mutex::new(None),
572 last_processing_timeout_secs: std::sync::Mutex::new(None),
573 last_earliest_refresh_begin_minutes: std::sync::Mutex::new(None),
574 last_earliest_processing_begin_minutes: std::sync::Mutex::new(None),
575 last_requires_external_power: std::sync::Mutex::new(None),
576 last_requires_network_connectivity: std::sync::Mutex::new(None),
577 })
578 }
579 }
580
581 #[allow(clippy::too_many_arguments)]
582 fn mock_start_keepalive(
583 mock: &MockMobile,
584 label: &str,
585 foreground_service_type: &str,
586 ios_safety_timeout_secs: Option<f64>,
587 ios_processing_safety_timeout_secs: Option<f64>,
588 ios_earliest_refresh_begin_minutes: Option<f64>,
589 ios_earliest_processing_begin_minutes: Option<f64>,
590 ios_requires_external_power: Option<bool>,
591 ios_requires_network_connectivity: Option<bool>,
592 ) -> Result<(), ServiceError> {
593 mock.start_called.fetch_add(1, Ordering::Release);
594 *mock.last_label.lock().unwrap() = Some(label.to_string());
595 *mock.last_fst.lock().unwrap() = Some(foreground_service_type.to_string());
596 *mock.last_timeout_secs.lock().unwrap() = ios_safety_timeout_secs;
597 *mock.last_processing_timeout_secs.lock().unwrap() = ios_processing_safety_timeout_secs;
598 *mock.last_earliest_refresh_begin_minutes.lock().unwrap() =
599 ios_earliest_refresh_begin_minutes;
600 *mock.last_earliest_processing_begin_minutes.lock().unwrap() =
601 ios_earliest_processing_begin_minutes;
602 *mock.last_requires_external_power.lock().unwrap() = ios_requires_external_power;
603 *mock.last_requires_network_connectivity.lock().unwrap() =
604 ios_requires_network_connectivity;
605 if mock.start_fail {
606 return Err(ServiceError::Platform("mock keepalive failure".into()));
607 }
608 Ok(())
609 }
610
611 impl MobileKeepalive for MockMobile {
612 #[allow(clippy::too_many_arguments)]
613 fn start_keepalive(
614 &self,
615 label: &str,
616 foreground_service_type: &str,
617 ios_safety_timeout_secs: Option<f64>,
618 ios_processing_safety_timeout_secs: Option<f64>,
619 ios_earliest_refresh_begin_minutes: Option<f64>,
620 ios_earliest_processing_begin_minutes: Option<f64>,
621 ios_requires_external_power: Option<bool>,
622 ios_requires_network_connectivity: Option<bool>,
623 ) -> Result<(), ServiceError> {
624 mock_start_keepalive(
625 self,
626 label,
627 foreground_service_type,
628 ios_safety_timeout_secs,
629 ios_processing_safety_timeout_secs,
630 ios_earliest_refresh_begin_minutes,
631 ios_earliest_processing_begin_minutes,
632 ios_requires_external_power,
633 ios_requires_network_connectivity,
634 )
635 }
636
637 fn stop_keepalive(&self) -> Result<(), ServiceError> {
638 self.stop_called.fetch_add(1, Ordering::Release);
639 Ok(())
640 }
641 }
642
643 struct BlockingService;
646
647 #[async_trait]
648 impl BackgroundService<tauri::test::MockRuntime> for BlockingService {
649 async fn init(
650 &mut self,
651 _ctx: &ServiceContext<tauri::test::MockRuntime>,
652 ) -> Result<(), ServiceError> {
653 Ok(())
654 }
655
656 async fn run(
657 &mut self,
658 ctx: &ServiceContext<tauri::test::MockRuntime>,
659 ) -> Result<(), ServiceError> {
660 ctx.shutdown.cancelled().await;
661 Ok(())
662 }
663 }
664
665 fn setup_manager() -> ServiceManagerHandle<tauri::test::MockRuntime> {
667 let (cmd_tx, cmd_rx) = mpsc::channel(16);
668 let handle = ServiceManagerHandle::new(cmd_tx);
669 let factory: ServiceFactory<tauri::test::MockRuntime> =
670 Box::new(|| Box::new(BlockingService));
671 tokio::spawn(manager_loop(
672 cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false,
673 ));
674 handle
675 }
676
677 async fn send_start(
678 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
679 app: AppHandle<tauri::test::MockRuntime>,
680 ) -> Result<(), ServiceError> {
681 send_start_with_config(handle, StartConfig::default(), app).await
682 }
683
684 async fn send_start_with_config(
685 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
686 config: StartConfig,
687 app: AppHandle<tauri::test::MockRuntime>,
688 ) -> Result<(), ServiceError> {
689 let (tx, rx) = oneshot::channel();
690 handle
691 .cmd_tx
692 .send(ManagerCommand::Start {
693 config,
694 reply: tx,
695 app,
696 })
697 .await
698 .unwrap();
699 rx.await.unwrap()
700 }
701
702 async fn send_stop(
703 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
704 ) -> Result<(), ServiceError> {
705 let (tx, rx) = oneshot::channel();
706 handle
707 .cmd_tx
708 .send(ManagerCommand::Stop { reply: tx })
709 .await
710 .unwrap();
711 rx.await.unwrap()
712 }
713
714 async fn send_is_running(handle: &ServiceManagerHandle<tauri::test::MockRuntime>) -> bool {
715 let (tx, rx) = oneshot::channel();
716 handle
717 .cmd_tx
718 .send(ManagerCommand::IsRunning { reply: tx })
719 .await
720 .unwrap();
721 rx.await.unwrap()
722 }
723
724 #[tokio::test]
727 async fn start_from_idle() {
728 let handle = setup_manager();
729 let app = tauri::test::mock_app();
730
731 let result = send_start(&handle, app.handle().clone()).await;
732 assert!(result.is_ok(), "start should succeed from idle");
733 assert!(
734 send_is_running(&handle).await,
735 "should be running after start"
736 );
737 }
738
739 #[tokio::test]
742 async fn stop_from_running() {
743 let handle = setup_manager();
744 let app = tauri::test::mock_app();
745
746 send_start(&handle, app.handle().clone()).await.unwrap();
747
748 let result = send_stop(&handle).await;
749 assert!(result.is_ok(), "stop should succeed from running");
750 assert!(
751 !send_is_running(&handle).await,
752 "should not be running after stop"
753 );
754 }
755
756 #[tokio::test]
759 async fn double_start_returns_already_running() {
760 let handle = setup_manager();
761 let app = tauri::test::mock_app();
762
763 send_start(&handle, app.handle().clone()).await.unwrap();
764
765 let result = send_start(&handle, app.handle().clone()).await;
766 assert!(
767 matches!(result, Err(ServiceError::AlreadyRunning)),
768 "second start should return AlreadyRunning"
769 );
770 }
771
772 #[tokio::test]
775 async fn stop_when_not_running_returns_not_running() {
776 let handle = setup_manager();
777
778 let result = send_stop(&handle).await;
779 assert!(
780 matches!(result, Err(ServiceError::NotRunning)),
781 "stop should return NotRunning when idle"
782 );
783 }
784
785 #[tokio::test]
788 async fn start_stop_restart_cycle() {
789 let handle = setup_manager();
790 let app = tauri::test::mock_app();
791
792 send_start(&handle, app.handle().clone()).await.unwrap();
794 assert!(send_is_running(&handle).await);
795
796 send_stop(&handle).await.unwrap();
798 assert!(!send_is_running(&handle).await);
799
800 let result = send_start(&handle, app.handle().clone()).await;
802 assert!(result.is_ok(), "restart should succeed after stop");
803 assert!(
804 send_is_running(&handle).await,
805 "should be running after restart"
806 );
807 }
808
809 struct ImmediateSuccessService;
813
814 #[async_trait]
815 impl BackgroundService<tauri::test::MockRuntime> for ImmediateSuccessService {
816 async fn init(
817 &mut self,
818 _ctx: &ServiceContext<tauri::test::MockRuntime>,
819 ) -> Result<(), ServiceError> {
820 Ok(())
821 }
822
823 async fn run(
824 &mut self,
825 _ctx: &ServiceContext<tauri::test::MockRuntime>,
826 ) -> Result<(), ServiceError> {
827 Ok(())
828 }
829 }
830
831 struct ImmediateErrorService;
833
834 #[async_trait]
835 impl BackgroundService<tauri::test::MockRuntime> for ImmediateErrorService {
836 async fn init(
837 &mut self,
838 _ctx: &ServiceContext<tauri::test::MockRuntime>,
839 ) -> Result<(), ServiceError> {
840 Ok(())
841 }
842
843 async fn run(
844 &mut self,
845 _ctx: &ServiceContext<tauri::test::MockRuntime>,
846 ) -> Result<(), ServiceError> {
847 Err(ServiceError::Runtime("run error".into()))
848 }
849 }
850
851 struct FailingInitService;
853
854 #[async_trait]
855 impl BackgroundService<tauri::test::MockRuntime> for FailingInitService {
856 async fn init(
857 &mut self,
858 _ctx: &ServiceContext<tauri::test::MockRuntime>,
859 ) -> Result<(), ServiceError> {
860 Err(ServiceError::Init("init error".into()))
861 }
862
863 async fn run(
864 &mut self,
865 _ctx: &ServiceContext<tauri::test::MockRuntime>,
866 ) -> Result<(), ServiceError> {
867 Ok(())
868 }
869 }
870
871 fn setup_manager_with_factory(
873 factory: ServiceFactory<tauri::test::MockRuntime>,
874 ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
875 let (cmd_tx, cmd_rx) = mpsc::channel(16);
876 let handle = ServiceManagerHandle::new(cmd_tx);
877 tokio::spawn(manager_loop(
878 cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false,
879 ));
880 handle
881 }
882
883 async fn send_set_on_complete(
884 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
885 callback: OnCompleteCallback,
886 ) {
887 handle
888 .cmd_tx
889 .send(ManagerCommand::SetOnComplete { callback })
890 .await
891 .unwrap();
892 }
893
894 async fn wait_until_stopped(
897 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
898 timeout_ms: u64,
899 ) {
900 let start = std::time::Instant::now();
901 while start.elapsed().as_millis() < timeout_ms as u128 {
902 if !send_is_running(handle).await {
903 return;
904 }
905 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
906 }
907 panic!("Service did not stop within {timeout_ms}ms");
908 }
909
910 #[tokio::test]
913 async fn callback_fires_on_success() {
914 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
915 let app = tauri::test::mock_app();
916
917 let called = Arc::new(AtomicI8::new(-1));
918 let called_clone = called.clone();
919 send_set_on_complete(
920 &handle,
921 Box::new(move |success| {
922 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
923 }),
924 )
925 .await;
926
927 send_start(&handle, app.handle().clone()).await.unwrap();
928 wait_until_stopped(&handle, 1000).await;
929
930 assert_eq!(
931 called.load(Ordering::Acquire),
932 1,
933 "callback should be called with true"
934 );
935 }
936
937 #[tokio::test]
940 async fn callback_fires_on_error() {
941 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateErrorService)));
942 let app = tauri::test::mock_app();
943
944 let called = Arc::new(AtomicI8::new(-1));
945 let called_clone = called.clone();
946 send_set_on_complete(
947 &handle,
948 Box::new(move |success| {
949 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
950 }),
951 )
952 .await;
953
954 send_start(&handle, app.handle().clone()).await.unwrap();
955 wait_until_stopped(&handle, 1000).await;
956
957 assert_eq!(
958 called.load(Ordering::Acquire),
959 0,
960 "callback should be called with false on error"
961 );
962 }
963
964 #[tokio::test]
967 async fn callback_fires_on_init_failure() {
968 let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
969 let app = tauri::test::mock_app();
970
971 let called = Arc::new(AtomicI8::new(-1));
972 let called_clone = called.clone();
973 send_set_on_complete(
974 &handle,
975 Box::new(move |success| {
976 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
977 }),
978 )
979 .await;
980
981 send_start(&handle, app.handle().clone()).await.unwrap();
982
983 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
986
987 assert_eq!(
988 called.load(Ordering::Acquire),
989 0,
990 "callback should be called with false on init failure"
991 );
992 assert!(
993 !send_is_running(&handle).await,
994 "should not be running after init failure"
995 );
996 }
997
998 #[tokio::test]
1001 async fn no_callback_no_panic() {
1002 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1003 let app = tauri::test::mock_app();
1004
1005 let result = send_start(&handle, app.handle().clone()).await;
1007 assert!(result.is_ok(), "start without callback should succeed");
1008
1009 wait_until_stopped(&handle, 1000).await;
1010 }
1012
1013 #[tokio::test]
1016 async fn is_running_false_after_natural_completion() {
1017 struct YieldingService;
1020
1021 #[async_trait]
1022 impl BackgroundService<tauri::test::MockRuntime> for YieldingService {
1023 async fn init(
1024 &mut self,
1025 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1026 ) -> Result<(), ServiceError> {
1027 Ok(())
1028 }
1029
1030 async fn run(
1031 &mut self,
1032 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1033 ) -> Result<(), ServiceError> {
1034 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1037 Ok(())
1038 }
1039 }
1040
1041 let handle = setup_manager_with_factory(Box::new(|| Box::new(YieldingService)));
1042 let app = tauri::test::mock_app();
1043
1044 send_start(&handle, app.handle().clone()).await.unwrap();
1045 assert!(
1046 send_is_running(&handle).await,
1047 "should be running immediately after start"
1048 );
1049
1050 wait_until_stopped(&handle, 2000).await;
1052
1053 assert!(
1054 !send_is_running(&handle).await,
1055 "is_running should be false after natural completion"
1056 );
1057 }
1058
1059 #[tokio::test]
1062 async fn generation_guard_prevents_stale_cleanup() {
1063 let call_count = Arc::new(AtomicU8::new(0));
1067 let call_count_clone = call_count.clone();
1068
1069 let handle = setup_manager_with_factory(Box::new(move || {
1070 let cc = call_count_clone.clone();
1071 if cc.fetch_add(1, Ordering::AcqRel) == 0 {
1074 Box::new(FailingInitService) as Box<dyn BackgroundService<tauri::test::MockRuntime>>
1075 } else {
1076 Box::new(ImmediateSuccessService)
1077 }
1078 }));
1079 let app = tauri::test::mock_app();
1080
1081 send_start(&handle, app.handle().clone()).await.unwrap();
1083 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1084
1085 let result = send_start(&handle, app.handle().clone()).await;
1087 assert!(
1088 result.is_ok(),
1089 "second start should succeed after init failure: {result:?}"
1090 );
1091 assert!(
1092 send_is_running(&handle).await,
1093 "should be running after second start"
1094 );
1095 }
1096
1097 #[tokio::test]
1100 async fn callback_captured_at_spawn_time() {
1101 let handle = setup_manager_with_factory(Box::new(|| Box::new(BlockingService)));
1102 let app = tauri::test::mock_app();
1103
1104 let which = Arc::new(AtomicU8::new(0)); let which_clone_a = which.clone();
1108 let which_clone_b = which.clone();
1109
1110 send_set_on_complete(
1111 &handle,
1112 Box::new(move |_| {
1113 which_clone_a.store(1, Ordering::Release);
1114 }),
1115 )
1116 .await;
1117
1118 send_start(&handle, app.handle().clone()).await.unwrap();
1119
1120 send_set_on_complete(
1122 &handle,
1123 Box::new(move |_| {
1124 which_clone_b.store(2, Ordering::Release);
1125 }),
1126 )
1127 .await;
1128
1129 send_stop(&handle).await.unwrap();
1131 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1132
1133 assert_eq!(
1134 which.load(Ordering::Acquire),
1135 1,
1136 "callback A should fire, not B"
1137 );
1138 }
1139
1140 async fn send_set_mobile(
1143 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1144 mobile: Arc<dyn MobileKeepalive>,
1145 ) {
1146 handle
1147 .cmd_tx
1148 .send(ManagerCommand::SetMobile { mobile })
1149 .await
1150 .unwrap();
1151 }
1152
1153 #[tokio::test]
1156 async fn start_keepalive_called_on_start() {
1157 let mock = MockMobile::new();
1158 let handle = setup_manager();
1159 let app = tauri::test::mock_app();
1160
1161 send_set_mobile(&handle, mock.clone()).await;
1162 send_start(&handle, app.handle().clone()).await.unwrap();
1163
1164 assert_eq!(
1165 mock.start_called.load(Ordering::Acquire),
1166 1,
1167 "start_keepalive should be called once"
1168 );
1169 assert_eq!(
1170 mock.last_label.lock().unwrap().as_deref(),
1171 Some("Service running"),
1172 "label should be forwarded"
1173 );
1174 }
1175
1176 #[tokio::test]
1179 async fn start_keepalive_failure_rollback() {
1180 let mock = MockMobile::new_failing();
1181 let handle = setup_manager();
1182 let app = tauri::test::mock_app();
1183
1184 let callback_called = Arc::new(AtomicI8::new(-1));
1185 let cb_clone = callback_called.clone();
1186 send_set_on_complete(
1187 &handle,
1188 Box::new(move |success| {
1189 cb_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1190 }),
1191 )
1192 .await;
1193
1194 send_set_mobile(&handle, mock.clone()).await;
1195
1196 let result = send_start(&handle, app.handle().clone()).await;
1197 assert!(
1198 matches!(result, Err(ServiceError::Platform(_))),
1199 "start should return Platform error on keepalive failure: {result:?}"
1200 );
1201
1202 assert!(
1204 !send_is_running(&handle).await,
1205 "token should be rolled back after keepalive failure"
1206 );
1207
1208 let callback_called2 = Arc::new(AtomicI8::new(-1));
1210 let cb_clone2 = callback_called2.clone();
1211 send_set_on_complete(
1212 &handle,
1213 Box::new(move |success| {
1214 cb_clone2.store(if success { 1 } else { 0 }, Ordering::Release);
1215 }),
1216 )
1217 .await;
1218
1219 let handle2 = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1222 let callback_restored = Arc::new(AtomicI8::new(-1));
1223 let cb_r = callback_restored.clone();
1224 send_set_on_complete(
1225 &handle2,
1226 Box::new(move |success| {
1227 cb_r.store(if success { 1 } else { 0 }, Ordering::Release);
1228 }),
1229 )
1230 .await;
1231 send_start(&handle2, app.handle().clone()).await.unwrap();
1232 wait_until_stopped(&handle2, 1000).await;
1233 assert_eq!(
1234 callback_restored.load(Ordering::Acquire),
1235 1,
1236 "callback should fire after successful start (proves rollback restored it)"
1237 );
1238 }
1239
1240 #[tokio::test]
1243 async fn stop_keepalive_called_on_stop() {
1244 let mock = MockMobile::new();
1245 let handle = setup_manager();
1246 let app = tauri::test::mock_app();
1247
1248 send_set_mobile(&handle, mock.clone()).await;
1249 send_start(&handle, app.handle().clone()).await.unwrap();
1250
1251 assert_eq!(
1252 mock.stop_called.load(Ordering::Acquire),
1253 0,
1254 "stop_keepalive should not be called yet"
1255 );
1256
1257 send_stop(&handle).await.unwrap();
1258
1259 assert_eq!(
1260 mock.stop_called.load(Ordering::Acquire),
1261 1,
1262 "stop_keepalive should be called once after stop"
1263 );
1264 }
1265
1266 struct MockMobileFailingStop;
1270
1271 #[allow(clippy::too_many_arguments)]
1272 impl MobileKeepalive for MockMobileFailingStop {
1273 fn start_keepalive(
1274 &self,
1275 _label: &str,
1276 _foreground_service_type: &str,
1277 _ios_safety_timeout_secs: Option<f64>,
1278 _ios_processing_safety_timeout_secs: Option<f64>,
1279 _ios_earliest_refresh_begin_minutes: Option<f64>,
1280 _ios_earliest_processing_begin_minutes: Option<f64>,
1281 _ios_requires_external_power: Option<bool>,
1282 _ios_requires_network_connectivity: Option<bool>,
1283 ) -> Result<(), ServiceError> {
1284 Ok(())
1285 }
1286
1287 fn stop_keepalive(&self) -> Result<(), ServiceError> {
1288 Err(ServiceError::Platform("mock stop failure".into()))
1289 }
1290 }
1291
1292 #[tokio::test]
1293 async fn stop_keepalive_failure_does_not_propagate() {
1294 let handle = setup_manager();
1295 let app = tauri::test::mock_app();
1296
1297 send_set_mobile(&handle, Arc::new(MockMobileFailingStop)).await;
1298 send_start(&handle, app.handle().clone()).await.unwrap();
1299
1300 let result = send_stop(&handle).await;
1301 assert!(
1302 result.is_ok(),
1303 "stop should succeed even when stop_keepalive fails"
1304 );
1305
1306 assert!(
1307 !send_is_running(&handle).await,
1308 "service should not be running after stop"
1309 );
1310 }
1311
1312 #[tokio::test]
1315 async fn ios_safety_timeout_passed_to_mobile() {
1316 let mock = MockMobile::new();
1317 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1318 let handle = ServiceManagerHandle::new(cmd_tx);
1319 let factory: ServiceFactory<tauri::test::MockRuntime> =
1320 Box::new(|| Box::new(BlockingService));
1321 tokio::spawn(manager_loop(
1323 cmd_rx, factory, 15.0, 0.0, 15.0, 15.0, false, false,
1324 ));
1325
1326 let app = tauri::test::mock_app();
1327
1328 send_set_mobile(&handle, mock.clone()).await;
1329 send_start(&handle, app.handle().clone()).await.unwrap();
1330
1331 let timeout = *mock.last_timeout_secs.lock().unwrap();
1333 assert_eq!(
1334 timeout,
1335 Some(15.0),
1336 "ios_safety_timeout_secs should be passed to mobile"
1337 );
1338 }
1339
1340 #[tokio::test]
1343 async fn ios_processing_timeout_passed_to_mobile() {
1344 let mock = MockMobile::new();
1345 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1346 let handle = ServiceManagerHandle::new(cmd_tx);
1347 let factory: ServiceFactory<tauri::test::MockRuntime> =
1348 Box::new(|| Box::new(BlockingService));
1349 tokio::spawn(manager_loop(
1351 cmd_rx, factory, 28.0, 60.0, 15.0, 15.0, false, false,
1352 ));
1353
1354 let app = tauri::test::mock_app();
1355
1356 send_set_mobile(&handle, mock.clone()).await;
1357 send_start(&handle, app.handle().clone()).await.unwrap();
1358
1359 let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
1361 assert_eq!(
1362 timeout,
1363 Some(60.0),
1364 "ios_processing_safety_timeout_secs should be passed to mobile"
1365 );
1366 }
1367
1368 #[cfg(mobile)]
1374 struct ContextCapturingService {
1375 captured_label: Arc<std::sync::Mutex<Option<String>>>,
1376 captured_fst: Arc<std::sync::Mutex<Option<String>>>,
1377 }
1378
1379 #[cfg(mobile)]
1380 #[async_trait]
1381 impl BackgroundService<tauri::test::MockRuntime> for ContextCapturingService {
1382 async fn init(
1383 &mut self,
1384 ctx: &ServiceContext<tauri::test::MockRuntime>,
1385 ) -> Result<(), ServiceError> {
1386 *self.captured_label.lock().unwrap() = Some(ctx.service_label.clone());
1387 *self.captured_fst.lock().unwrap() = Some(ctx.foreground_service_type.clone());
1388 Ok(())
1389 }
1390
1391 async fn run(
1392 &mut self,
1393 ctx: &ServiceContext<tauri::test::MockRuntime>,
1394 ) -> Result<(), ServiceError> {
1395 ctx.shutdown.cancelled().await;
1396 Ok(())
1397 }
1398 }
1399
1400 #[cfg(mobile)]
1403 #[tokio::test]
1404 async fn service_context_fields_populated_on_mobile() {
1405 let captured_label: Arc<std::sync::Mutex<Option<String>>> =
1406 Arc::new(std::sync::Mutex::new(None));
1407 let captured_fst: Arc<std::sync::Mutex<Option<String>>> =
1408 Arc::new(std::sync::Mutex::new(None));
1409 let cl = captured_label.clone();
1410 let cf = captured_fst.clone();
1411
1412 let handle = setup_manager_with_factory(Box::new(move || {
1413 let cl = cl.clone();
1414 let cf = cf.clone();
1415 Box::new(ContextCapturingService {
1416 captured_label: cl,
1417 captured_fst: cf,
1418 })
1419 }));
1420 let app = tauri::test::mock_app();
1421
1422 let config = StartConfig {
1423 service_label: "Syncing".into(),
1424 foreground_service_type: "dataSync".into(),
1425 };
1426
1427 send_start_with_config(&handle, config, app.handle().clone())
1428 .await
1429 .unwrap();
1430
1431 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1433
1434 assert_eq!(
1436 captured_label.lock().unwrap().as_deref(),
1437 Some("Syncing"),
1438 "service_label should be 'Syncing' on mobile"
1439 );
1440 assert_eq!(
1441 captured_fst.lock().unwrap().as_deref(),
1442 Some("dataSync"),
1443 "foreground_service_type should be 'dataSync' on mobile"
1444 );
1445
1446 send_stop(&handle).await.unwrap();
1447 }
1448
1449 #[tokio::test]
1452 async fn handle_start_accepts_invalid_foreground_service_type_on_desktop() {
1453 let handle = setup_manager();
1456 let app = tauri::test::mock_app();
1457
1458 let config = StartConfig {
1459 service_label: "test".into(),
1460 foreground_service_type: "bogusType".into(),
1461 };
1462
1463 let result = send_start_with_config(&handle, config, app.handle().clone()).await;
1464 assert!(
1465 result.is_ok(),
1466 "start with invalid fg type should succeed on desktop: {result:?}"
1467 );
1468 assert!(
1469 send_is_running(&handle).await,
1470 "service should be running after start with invalid type on desktop"
1471 );
1472
1473 send_stop(&handle).await.unwrap();
1474 }
1475
1476 #[tokio::test]
1479 async fn handle_start_accepts_all_valid_foreground_service_types() {
1480 for &valid_type in crate::models::VALID_FOREGROUND_SERVICE_TYPES {
1481 let handle = setup_manager();
1482 let app = tauri::test::mock_app();
1483
1484 let config = StartConfig {
1485 service_label: "test".into(),
1486 foreground_service_type: valid_type.into(),
1487 };
1488
1489 let result = send_start_with_config(&handle, config, app.handle().clone()).await;
1490 assert!(
1491 result.is_ok(),
1492 "start with valid type '{valid_type}' should succeed: {result:?}"
1493 );
1494 assert!(send_is_running(&handle).await);
1495 send_stop(&handle).await.unwrap();
1497 }
1498 }
1499
1500 async fn send_get_state(
1503 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1504 ) -> ServiceStatus {
1505 let (tx, rx) = oneshot::channel();
1506 handle
1507 .cmd_tx
1508 .send(ManagerCommand::GetState { reply: tx })
1509 .await
1510 .unwrap();
1511 rx.await.unwrap()
1512 }
1513
1514 #[tokio::test]
1517 async fn get_state_returns_idle_initially() {
1518 let handle = setup_manager();
1519 let status = send_get_state(&handle).await;
1520 assert_eq!(status.state, ServiceLifecycle::Idle);
1521 assert_eq!(status.last_error, None);
1522 }
1523
1524 #[tokio::test]
1527 async fn lifecycle_idle_to_running_to_stopped() {
1528 let handle = setup_manager();
1530 let app = tauri::test::mock_app();
1531
1532 let status = send_get_state(&handle).await;
1534 assert_eq!(status.state, ServiceLifecycle::Idle);
1535
1536 send_start(&handle, app.handle().clone()).await.unwrap();
1538
1539 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1541 let status = send_get_state(&handle).await;
1542 assert_eq!(status.state, ServiceLifecycle::Running);
1543
1544 send_stop(&handle).await.unwrap();
1546 let status = send_get_state(&handle).await;
1547 assert_eq!(status.state, ServiceLifecycle::Stopped);
1548 assert_eq!(status.last_error, None);
1549 }
1550
1551 #[tokio::test]
1554 async fn lifecycle_init_failure_sets_stopped_with_error() {
1555 let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1556 let app = tauri::test::mock_app();
1557
1558 send_start(&handle, app.handle().clone()).await.unwrap();
1559
1560 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1562
1563 let status = send_get_state(&handle).await;
1564 assert_eq!(status.state, ServiceLifecycle::Stopped);
1565 assert!(
1566 status.last_error.is_some(),
1567 "last_error should be set on init failure"
1568 );
1569 assert!(
1570 status.last_error.unwrap().contains("init error"),
1571 "error should mention init error"
1572 );
1573 }
1574
1575 #[tokio::test]
1578 async fn lifecycle_explicit_stop_sets_stopped_clears_error() {
1579 let handle = setup_manager();
1580 let app = tauri::test::mock_app();
1581
1582 send_start(&handle, app.handle().clone()).await.unwrap();
1583 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1584
1585 let status = send_get_state(&handle).await;
1586 assert_eq!(status.state, ServiceLifecycle::Running);
1587
1588 send_stop(&handle).await.unwrap();
1589
1590 let status = send_get_state(&handle).await;
1591 assert_eq!(status.state, ServiceLifecycle::Stopped);
1592 assert_eq!(
1593 status.last_error, None,
1594 "explicit stop should clear last_error"
1595 );
1596 }
1597
1598 #[tokio::test]
1601 async fn restart_clears_stale_last_error() {
1602 let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1604 let app = tauri::test::mock_app();
1605
1606 send_start(&handle, app.handle().clone()).await.unwrap();
1607 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1608
1609 let status = send_get_state(&handle).await;
1610 assert_eq!(status.state, ServiceLifecycle::Stopped);
1611 assert!(
1612 status.last_error.is_some(),
1613 "should have error after init failure"
1614 );
1615
1616 let call_count = Arc::new(AtomicUsize::new(0));
1622 let count_clone = call_count.clone();
1623 let handle2 = setup_manager_with_factory(Box::new(move || {
1624 let n = count_clone.fetch_add(1, Ordering::SeqCst);
1625 if n == 0 {
1626 Box::new(FailingInitService)
1627 } else {
1628 Box::new(ImmediateSuccessService)
1629 }
1630 }));
1631 let app2 = tauri::test::mock_app();
1632
1633 send_start(&handle2, app2.handle().clone()).await.unwrap();
1635 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1636
1637 let status = send_get_state(&handle2).await;
1638 assert_eq!(status.state, ServiceLifecycle::Stopped);
1639 assert!(
1640 status.last_error.is_some(),
1641 "first run should set last_error"
1642 );
1643
1644 send_start(&handle2, app2.handle().clone()).await.unwrap();
1646 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1647
1648 let status = send_get_state(&handle2).await;
1649 assert_eq!(
1652 status.last_error, None,
1653 "last_error must be cleared on restart, not stale from previous failure"
1654 );
1655 }
1656
1657 #[tokio::test]
1660 async fn get_state_handle_method_returns_idle() {
1661 let handle = setup_manager();
1662 let status = handle.get_state().await;
1663 assert_eq!(status.state, ServiceLifecycle::Idle);
1664 assert_eq!(status.last_error, None);
1665 }
1666
1667 #[tokio::test]
1670 async fn stop_blocking_returns_success_from_running() {
1671 let handle = Arc::new(setup_manager());
1672 let app = tauri::test::mock_app();
1673
1674 send_start(&handle, app.handle().clone()).await.unwrap();
1675 assert!(send_is_running(&handle).await);
1676
1677 let h = handle.clone();
1679 let result = tokio::task::spawn_blocking(move || h.stop_blocking())
1680 .await
1681 .expect("spawn_blocking panicked");
1682 assert!(
1683 result.is_ok(),
1684 "stop_blocking should succeed from running: {result:?}"
1685 );
1686 assert!(
1687 !send_is_running(&handle).await,
1688 "should not be running after stop_blocking"
1689 );
1690 }
1691
1692 #[tokio::test]
1695 async fn stop_blocking_returns_not_running_when_idle() {
1696 let handle = Arc::new(setup_manager());
1697
1698 let h = handle.clone();
1699 let result = tokio::task::spawn_blocking(move || h.stop_blocking())
1700 .await
1701 .expect("spawn_blocking panicked");
1702 assert!(
1703 matches!(result, Err(ServiceError::NotRunning)),
1704 "stop_blocking should return NotRunning when idle: {result:?}"
1705 );
1706 }
1707
1708 #[tokio::test]
1709 async fn ios_processing_timeout_zero_passes_as_none() {
1710 let mock = MockMobile::new();
1711 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1712 let handle = ServiceManagerHandle::new(cmd_tx);
1713 let factory: ServiceFactory<tauri::test::MockRuntime> =
1714 Box::new(|| Box::new(BlockingService));
1715 tokio::spawn(manager_loop(
1717 cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false,
1718 ));
1719
1720 let app = tauri::test::mock_app();
1721
1722 send_set_mobile(&handle, mock.clone()).await;
1723 send_start(&handle, app.handle().clone()).await.unwrap();
1724
1725 let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
1727 assert_eq!(
1728 timeout, None,
1729 "ios_processing_safety_timeout_secs of 0.0 should pass None to mobile"
1730 );
1731 }
1732}