1use std::sync::atomic::{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::{PluginEvent, ServiceContext, StartConfig};
20use crate::notifier::Notifier;
21use crate::service_trait::BackgroundService;
22
23#[doc(hidden)]
25pub type OnCompleteCallback = Box<dyn Fn(bool) + Send + Sync>;
26
27pub(crate) trait MobileKeepalive: Send + Sync {
33 fn start_keepalive(&self, label: &str, foreground_service_type: &str, ios_safety_timeout_secs: Option<f64>, ios_processing_safety_timeout_secs: Option<f64>) -> Result<(), ServiceError>;
35 fn stop_keepalive(&self) -> Result<(), ServiceError>;
37}
38
39#[doc(hidden)]
41pub type ServiceFactory<R> =
42 Box<dyn Fn() -> Box<dyn BackgroundService<R>> + Send + Sync>;
43
44#[non_exhaustive]
53pub enum ManagerCommand<R: Runtime> {
54 Start {
55 config: StartConfig,
56 reply: oneshot::Sender<Result<(), ServiceError>>,
57 app: AppHandle<R>,
58 },
59 Stop {
60 reply: oneshot::Sender<Result<(), ServiceError>>,
61 },
62 IsRunning {
63 reply: oneshot::Sender<bool>,
64 },
65 SetOnComplete {
66 callback: OnCompleteCallback,
67 },
68 #[allow(dead_code, private_interfaces)]
69 SetMobile {
70 mobile: Arc<dyn MobileKeepalive>,
71 },
72}
73
74pub struct ServiceManagerHandle<R: Runtime> {
82 pub(crate) cmd_tx: mpsc::Sender<ManagerCommand<R>>,
83}
84
85impl<R: Runtime> ServiceManagerHandle<R> {
86 pub fn new(cmd_tx: mpsc::Sender<ManagerCommand<R>>) -> Self {
88 Self { cmd_tx }
89 }
90
91 pub async fn start(
96 &self,
97 app: AppHandle<R>,
98 config: StartConfig,
99 ) -> Result<(), ServiceError> {
100 let (reply, rx) = oneshot::channel();
101 self.cmd_tx
102 .send(ManagerCommand::Start {
103 config,
104 reply,
105 app,
106 })
107 .await
108 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
109 rx.await
110 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
111 }
112
113 pub async fn stop(&self) -> Result<(), ServiceError> {
118 let (reply, rx) = oneshot::channel();
119 self.cmd_tx
120 .send(ManagerCommand::Stop { reply })
121 .await
122 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
123 rx.await
124 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
125 }
126
127 pub async fn is_running(&self) -> bool {
129 let (reply, rx) = oneshot::channel();
130 if self
131 .cmd_tx
132 .send(ManagerCommand::IsRunning { reply })
133 .await
134 .is_err()
135 {
136 return false;
137 }
138 rx.await.unwrap_or(false)
139 }
140
141 #[doc(hidden)]
146 pub async fn set_on_complete(&self, callback: OnCompleteCallback) {
147 let _ = self
148 .cmd_tx
149 .send(ManagerCommand::SetOnComplete { callback })
150 .await;
151 }
152}
153
154struct ServiceState<R: Runtime> {
158 token: Arc<Mutex<Option<CancellationToken>>>,
162 generation: Arc<AtomicU64>,
165 on_complete: Option<OnCompleteCallback>,
169 factory: ServiceFactory<R>,
171 mobile: Option<Arc<dyn MobileKeepalive>>,
173 ios_safety_timeout_secs: f64,
176 ios_processing_safety_timeout_secs: f64,
180}
181
182#[doc(hidden)]
189pub async fn manager_loop<R: Runtime>(
190 mut rx: mpsc::Receiver<ManagerCommand<R>>,
191 factory: ServiceFactory<R>,
192 ios_safety_timeout_secs: f64,
196 ios_processing_safety_timeout_secs: f64,
199) {
200 let mut state = ServiceState {
201 token: Arc::new(Mutex::new(None)),
202 generation: Arc::new(AtomicU64::new(0)),
203 on_complete: None,
204 factory,
205 mobile: None,
206 ios_safety_timeout_secs,
207 ios_processing_safety_timeout_secs,
208 };
209
210 while let Some(cmd) = rx.recv().await {
211 match cmd {
212 ManagerCommand::Start { config, reply, app } => {
213 let _ = reply.send(handle_start(&mut state, app, config));
214 }
215 ManagerCommand::Stop { reply } => {
216 let _ = reply.send(handle_stop(&mut state));
217 }
218 ManagerCommand::IsRunning { reply } => {
219 let _ = reply.send(state.token.lock().unwrap().is_some());
220 }
221 ManagerCommand::SetOnComplete { callback } => {
222 state.on_complete = Some(callback);
223 }
224 ManagerCommand::SetMobile { mobile } => {
225 state.mobile = Some(mobile);
226 }
227 }
228 }
229}
230
231fn handle_start<R: Runtime>(
242 state: &mut ServiceState<R>,
243 app: AppHandle<R>,
244 config: StartConfig,
245) -> Result<(), ServiceError> {
246 let mut guard = state.token.lock().unwrap();
247
248 if guard.is_some() {
249 return Err(ServiceError::AlreadyRunning);
250 }
251
252 let token = CancellationToken::new();
253 let shutdown = token.clone();
254 *guard = Some(token);
255 let my_gen = state.generation.fetch_add(1, Ordering::Release) + 1;
256
257 drop(guard);
258
259 let captured_callback = state.on_complete.take();
262
263 if let Some(ref mobile) = state.mobile {
266 let processing_timeout = if state.ios_processing_safety_timeout_secs > 0.0 {
267 Some(state.ios_processing_safety_timeout_secs)
268 } else {
269 None
270 };
271 if let Err(e) = mobile.start_keepalive(&config.service_label, &config.foreground_service_type, Some(state.ios_safety_timeout_secs), processing_timeout) {
272 state.token.lock().unwrap().take();
274 state.on_complete = captured_callback;
276 return Err(e);
277 }
278 }
279
280 let token_ref = state.token.clone();
282 let gen_ref = state.generation.clone();
283
284 let mut service = (state.factory)();
285
286 let ctx = ServiceContext {
287 notifier: Notifier { app: app.clone() },
288 app: app.clone(),
289 shutdown,
290 service_label: Some(config.service_label),
291 foreground_service_type: Some(config.foreground_service_type),
292 };
293
294 tauri::async_runtime::spawn(async move {
298 if let Err(e) = service.init(&ctx).await {
300 let _ = app.emit(
301 "background-service://event",
302 PluginEvent::Error {
303 message: e.to_string(),
304 },
305 );
306 if gen_ref.load(Ordering::Acquire) == my_gen {
308 token_ref.lock().unwrap().take();
309 }
310 if let Some(cb) = captured_callback {
312 cb(false);
313 }
314 return;
315 }
316
317 let _ = app.emit("background-service://event", PluginEvent::Started);
319
320 let result = service.run(&ctx).await;
322
323 match result {
325 Ok(()) => {
326 let _ = app.emit(
327 "background-service://event",
328 PluginEvent::Stopped {
329 reason: "completed".into(),
330 },
331 );
332 }
333 Err(ref e) => {
334 let _ = app.emit(
335 "background-service://event",
336 PluginEvent::Error {
337 message: e.to_string(),
338 },
339 );
340 }
341 }
342
343 if let Some(cb) = captured_callback {
347 cb(result.is_ok());
348 }
349
350 if gen_ref.load(Ordering::Acquire) == my_gen {
352 token_ref.lock().unwrap().take();
353 }
354 });
355
356 Ok(())
357}
358
359fn handle_stop<R: Runtime>(state: &mut ServiceState<R>) -> Result<(), ServiceError> {
364 let mut guard = state.token.lock().unwrap();
365 match guard.take() {
366 Some(token) => {
367 token.cancel();
368 drop(guard);
369 if let Some(ref mobile) = state.mobile {
371 if let Err(e) = mobile.stop_keepalive() {
372 log::warn!("stop_keepalive failed (service already cancelled): {e}");
373 }
374 }
375 Ok(())
376 }
377 None => Err(ServiceError::NotRunning),
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use async_trait::async_trait;
385 use std::sync::atomic::{AtomicI8, AtomicU8, AtomicUsize};
386
387 struct MockMobile {
391 start_called: AtomicUsize,
392 stop_called: AtomicUsize,
393 start_fail: bool,
394 last_label: std::sync::Mutex<Option<String>>,
395 last_fst: std::sync::Mutex<Option<String>>,
396 last_timeout_secs: std::sync::Mutex<Option<f64>>,
397 last_processing_timeout_secs: std::sync::Mutex<Option<f64>>,
398 }
399
400 impl MockMobile {
401 fn new() -> Arc<Self> {
402 Arc::new(Self {
403 start_called: AtomicUsize::new(0),
404 stop_called: AtomicUsize::new(0),
405 start_fail: false,
406 last_label: std::sync::Mutex::new(None),
407 last_fst: std::sync::Mutex::new(None),
408 last_timeout_secs: std::sync::Mutex::new(None),
409 last_processing_timeout_secs: std::sync::Mutex::new(None),
410 })
411 }
412
413 fn new_failing() -> Arc<Self> {
414 Arc::new(Self {
415 start_called: AtomicUsize::new(0),
416 stop_called: AtomicUsize::new(0),
417 start_fail: true,
418 last_label: std::sync::Mutex::new(None),
419 last_fst: std::sync::Mutex::new(None),
420 last_timeout_secs: std::sync::Mutex::new(None),
421 last_processing_timeout_secs: std::sync::Mutex::new(None),
422 })
423 }
424 }
425
426 impl MobileKeepalive for MockMobile {
427 fn start_keepalive(&self, label: &str, foreground_service_type: &str, ios_safety_timeout_secs: Option<f64>, ios_processing_safety_timeout_secs: Option<f64>) -> Result<(), ServiceError> {
428 self.start_called.fetch_add(1, Ordering::Release);
429 *self.last_label.lock().unwrap() = Some(label.to_string());
430 *self.last_fst.lock().unwrap() = Some(foreground_service_type.to_string());
431 *self.last_timeout_secs.lock().unwrap() = ios_safety_timeout_secs;
432 *self.last_processing_timeout_secs.lock().unwrap() = ios_processing_safety_timeout_secs;
433 if self.start_fail {
434 return Err(ServiceError::Platform("mock keepalive failure".into()));
435 }
436 Ok(())
437 }
438
439 fn stop_keepalive(&self) -> Result<(), ServiceError> {
440 self.stop_called.fetch_add(1, Ordering::Release);
441 Ok(())
442 }
443 }
444
445 struct BlockingService;
448
449 #[async_trait]
450 impl BackgroundService<tauri::test::MockRuntime> for BlockingService {
451 async fn init(
452 &mut self,
453 _ctx: &ServiceContext<tauri::test::MockRuntime>,
454 ) -> Result<(), ServiceError> {
455 Ok(())
456 }
457
458 async fn run(
459 &mut self,
460 ctx: &ServiceContext<tauri::test::MockRuntime>,
461 ) -> Result<(), ServiceError> {
462 ctx.shutdown.cancelled().await;
463 Ok(())
464 }
465 }
466
467 fn setup_manager() -> ServiceManagerHandle<tauri::test::MockRuntime> {
469 let (cmd_tx, cmd_rx) = mpsc::channel(16);
470 let handle = ServiceManagerHandle::new(cmd_tx);
471 let factory: ServiceFactory<tauri::test::MockRuntime> =
472 Box::new(|| Box::new(BlockingService));
473 tokio::spawn(manager_loop(cmd_rx, factory, 28.0, 0.0));
474 handle
475 }
476
477 async fn send_start(
478 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
479 app: AppHandle<tauri::test::MockRuntime>,
480 ) -> Result<(), ServiceError> {
481 let (tx, rx) = oneshot::channel();
482 handle
483 .cmd_tx
484 .send(ManagerCommand::Start {
485 config: StartConfig::default(),
486 reply: tx,
487 app,
488 })
489 .await
490 .unwrap();
491 rx.await.unwrap()
492 }
493
494 async fn send_stop(
495 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
496 ) -> Result<(), ServiceError> {
497 let (tx, rx) = oneshot::channel();
498 handle
499 .cmd_tx
500 .send(ManagerCommand::Stop { reply: tx })
501 .await
502 .unwrap();
503 rx.await.unwrap()
504 }
505
506 async fn send_is_running(
507 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
508 ) -> bool {
509 let (tx, rx) = oneshot::channel();
510 handle
511 .cmd_tx
512 .send(ManagerCommand::IsRunning { reply: tx })
513 .await
514 .unwrap();
515 rx.await.unwrap()
516 }
517
518 #[tokio::test]
521 async fn start_from_idle() {
522 let handle = setup_manager();
523 let app = tauri::test::mock_app();
524
525 let result = send_start(&handle, app.handle().clone()).await;
526 assert!(result.is_ok(), "start should succeed from idle");
527 assert!(
528 send_is_running(&handle).await,
529 "should be running after start"
530 );
531 }
532
533 #[tokio::test]
536 async fn stop_from_running() {
537 let handle = setup_manager();
538 let app = tauri::test::mock_app();
539
540 send_start(&handle, app.handle().clone()).await.unwrap();
541
542 let result = send_stop(&handle).await;
543 assert!(result.is_ok(), "stop should succeed from running");
544 assert!(
545 !send_is_running(&handle).await,
546 "should not be running after stop"
547 );
548 }
549
550 #[tokio::test]
553 async fn double_start_returns_already_running() {
554 let handle = setup_manager();
555 let app = tauri::test::mock_app();
556
557 send_start(&handle, app.handle().clone()).await.unwrap();
558
559 let result = send_start(&handle, app.handle().clone()).await;
560 assert!(
561 matches!(result, Err(ServiceError::AlreadyRunning)),
562 "second start should return AlreadyRunning"
563 );
564 }
565
566 #[tokio::test]
569 async fn stop_when_not_running_returns_not_running() {
570 let handle = setup_manager();
571
572 let result = send_stop(&handle).await;
573 assert!(
574 matches!(result, Err(ServiceError::NotRunning)),
575 "stop should return NotRunning when idle"
576 );
577 }
578
579 #[tokio::test]
582 async fn start_stop_restart_cycle() {
583 let handle = setup_manager();
584 let app = tauri::test::mock_app();
585
586 send_start(&handle, app.handle().clone()).await.unwrap();
588 assert!(send_is_running(&handle).await);
589
590 send_stop(&handle).await.unwrap();
592 assert!(!send_is_running(&handle).await);
593
594 let result = send_start(&handle, app.handle().clone()).await;
596 assert!(result.is_ok(), "restart should succeed after stop");
597 assert!(
598 send_is_running(&handle).await,
599 "should be running after restart"
600 );
601 }
602
603 struct ImmediateSuccessService;
607
608 #[async_trait]
609 impl BackgroundService<tauri::test::MockRuntime> for ImmediateSuccessService {
610 async fn init(
611 &mut self,
612 _ctx: &ServiceContext<tauri::test::MockRuntime>,
613 ) -> Result<(), ServiceError> {
614 Ok(())
615 }
616
617 async fn run(
618 &mut self,
619 _ctx: &ServiceContext<tauri::test::MockRuntime>,
620 ) -> Result<(), ServiceError> {
621 Ok(())
622 }
623 }
624
625 struct ImmediateErrorService;
627
628 #[async_trait]
629 impl BackgroundService<tauri::test::MockRuntime> for ImmediateErrorService {
630 async fn init(
631 &mut self,
632 _ctx: &ServiceContext<tauri::test::MockRuntime>,
633 ) -> Result<(), ServiceError> {
634 Ok(())
635 }
636
637 async fn run(
638 &mut self,
639 _ctx: &ServiceContext<tauri::test::MockRuntime>,
640 ) -> Result<(), ServiceError> {
641 Err(ServiceError::Runtime("run error".into()))
642 }
643 }
644
645 struct FailingInitService;
647
648 #[async_trait]
649 impl BackgroundService<tauri::test::MockRuntime> for FailingInitService {
650 async fn init(
651 &mut self,
652 _ctx: &ServiceContext<tauri::test::MockRuntime>,
653 ) -> Result<(), ServiceError> {
654 Err(ServiceError::Init("init error".into()))
655 }
656
657 async fn run(
658 &mut self,
659 _ctx: &ServiceContext<tauri::test::MockRuntime>,
660 ) -> Result<(), ServiceError> {
661 Ok(())
662 }
663 }
664
665 fn setup_manager_with_factory(
667 factory: ServiceFactory<tauri::test::MockRuntime>,
668 ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
669 let (cmd_tx, cmd_rx) = mpsc::channel(16);
670 let handle = ServiceManagerHandle::new(cmd_tx);
671 tokio::spawn(manager_loop(cmd_rx, factory, 28.0, 0.0));
672 handle
673 }
674
675 async fn send_set_on_complete(
676 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
677 callback: OnCompleteCallback,
678 ) {
679 handle
680 .cmd_tx
681 .send(ManagerCommand::SetOnComplete { callback })
682 .await
683 .unwrap();
684 }
685
686 async fn wait_until_stopped(
689 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
690 timeout_ms: u64,
691 ) {
692 let start = std::time::Instant::now();
693 while start.elapsed().as_millis() < timeout_ms as u128 {
694 if !send_is_running(handle).await {
695 return;
696 }
697 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
698 }
699 panic!("Service did not stop within {timeout_ms}ms");
700 }
701
702 #[tokio::test]
705 async fn callback_fires_on_success() {
706 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
707 let app = tauri::test::mock_app();
708
709 let called = Arc::new(AtomicI8::new(-1));
710 let called_clone = called.clone();
711 send_set_on_complete(
712 &handle,
713 Box::new(move |success| {
714 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
715 }),
716 )
717 .await;
718
719 send_start(&handle, app.handle().clone()).await.unwrap();
720 wait_until_stopped(&handle, 1000).await;
721
722 assert_eq!(
723 called.load(Ordering::Acquire),
724 1,
725 "callback should be called with true"
726 );
727 }
728
729 #[tokio::test]
732 async fn callback_fires_on_error() {
733 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateErrorService)));
734 let app = tauri::test::mock_app();
735
736 let called = Arc::new(AtomicI8::new(-1));
737 let called_clone = called.clone();
738 send_set_on_complete(
739 &handle,
740 Box::new(move |success| {
741 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
742 }),
743 )
744 .await;
745
746 send_start(&handle, app.handle().clone()).await.unwrap();
747 wait_until_stopped(&handle, 1000).await;
748
749 assert_eq!(
750 called.load(Ordering::Acquire),
751 0,
752 "callback should be called with false on error"
753 );
754 }
755
756 #[tokio::test]
759 async fn callback_fires_on_init_failure() {
760 let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
761 let app = tauri::test::mock_app();
762
763 let called = Arc::new(AtomicI8::new(-1));
764 let called_clone = called.clone();
765 send_set_on_complete(
766 &handle,
767 Box::new(move |success| {
768 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
769 }),
770 )
771 .await;
772
773 send_start(&handle, app.handle().clone()).await.unwrap();
774
775 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
778
779 assert_eq!(
780 called.load(Ordering::Acquire),
781 0,
782 "callback should be called with false on init failure"
783 );
784 assert!(
785 !send_is_running(&handle).await,
786 "should not be running after init failure"
787 );
788 }
789
790 #[tokio::test]
793 async fn no_callback_no_panic() {
794 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
795 let app = tauri::test::mock_app();
796
797 let result = send_start(&handle, app.handle().clone()).await;
799 assert!(result.is_ok(), "start without callback should succeed");
800
801 wait_until_stopped(&handle, 1000).await;
802 }
804
805 #[tokio::test]
808 async fn generation_guard_prevents_stale_cleanup() {
809 let call_count = Arc::new(AtomicU8::new(0));
813 let call_count_clone = call_count.clone();
814
815 let handle = setup_manager_with_factory(Box::new(move || {
816 let cc = call_count_clone.clone();
817 if cc.fetch_add(1, Ordering::AcqRel) == 0 {
820 Box::new(FailingInitService) as Box<dyn BackgroundService<tauri::test::MockRuntime>>
821 } else {
822 Box::new(ImmediateSuccessService)
823 }
824 }));
825 let app = tauri::test::mock_app();
826
827 send_start(&handle, app.handle().clone()).await.unwrap();
829 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
830
831 let result = send_start(&handle, app.handle().clone()).await;
833 assert!(
834 result.is_ok(),
835 "second start should succeed after init failure: {result:?}"
836 );
837 assert!(
838 send_is_running(&handle).await,
839 "should be running after second start"
840 );
841 }
842
843 #[tokio::test]
846 async fn callback_captured_at_spawn_time() {
847 let handle = setup_manager_with_factory(Box::new(|| Box::new(BlockingService)));
848 let app = tauri::test::mock_app();
849
850 let which = Arc::new(AtomicU8::new(0)); let which_clone_a = which.clone();
854 let which_clone_b = which.clone();
855
856 send_set_on_complete(
857 &handle,
858 Box::new(move |_| {
859 which_clone_a.store(1, Ordering::Release);
860 }),
861 )
862 .await;
863
864 send_start(&handle, app.handle().clone()).await.unwrap();
865
866 send_set_on_complete(
868 &handle,
869 Box::new(move |_| {
870 which_clone_b.store(2, Ordering::Release);
871 }),
872 )
873 .await;
874
875 send_stop(&handle).await.unwrap();
877 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
878
879 assert_eq!(
880 which.load(Ordering::Acquire),
881 1,
882 "callback A should fire, not B"
883 );
884 }
885
886 async fn send_set_mobile(
889 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
890 mobile: Arc<dyn MobileKeepalive>,
891 ) {
892 handle
893 .cmd_tx
894 .send(ManagerCommand::SetMobile { mobile })
895 .await
896 .unwrap();
897 }
898
899 #[tokio::test]
902 async fn start_keepalive_called_on_start() {
903 let mock = MockMobile::new();
904 let handle = setup_manager();
905 let app = tauri::test::mock_app();
906
907 send_set_mobile(&handle, mock.clone()).await;
908 send_start(&handle, app.handle().clone()).await.unwrap();
909
910 assert_eq!(
911 mock.start_called.load(Ordering::Acquire),
912 1,
913 "start_keepalive should be called once"
914 );
915 assert_eq!(
916 mock.last_label.lock().unwrap().as_deref(),
917 Some("Service running"),
918 "label should be forwarded"
919 );
920 }
921
922 #[tokio::test]
925 async fn start_keepalive_failure_rollback() {
926 let mock = MockMobile::new_failing();
927 let handle = setup_manager();
928 let app = tauri::test::mock_app();
929
930 let callback_called = Arc::new(AtomicI8::new(-1));
931 let cb_clone = callback_called.clone();
932 send_set_on_complete(
933 &handle,
934 Box::new(move |success| {
935 cb_clone.store(if success { 1 } else { 0 }, Ordering::Release);
936 }),
937 )
938 .await;
939
940 send_set_mobile(&handle, mock.clone()).await;
941
942 let result = send_start(&handle, app.handle().clone()).await;
943 assert!(
944 matches!(result, Err(ServiceError::Platform(_))),
945 "start should return Platform error on keepalive failure: {result:?}"
946 );
947
948 assert!(
950 !send_is_running(&handle).await,
951 "token should be rolled back after keepalive failure"
952 );
953
954 let callback_called2 = Arc::new(AtomicI8::new(-1));
956 let cb_clone2 = callback_called2.clone();
957 send_set_on_complete(
958 &handle,
959 Box::new(move |success| {
960 cb_clone2.store(if success { 1 } else { 0 }, Ordering::Release);
961 }),
962 )
963 .await;
964
965 let handle2 = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
968 let callback_restored = Arc::new(AtomicI8::new(-1));
969 let cb_r = callback_restored.clone();
970 send_set_on_complete(
971 &handle2,
972 Box::new(move |success| {
973 cb_r.store(if success { 1 } else { 0 }, Ordering::Release);
974 }),
975 )
976 .await;
977 send_start(&handle2, app.handle().clone()).await.unwrap();
978 wait_until_stopped(&handle2, 1000).await;
979 assert_eq!(
980 callback_restored.load(Ordering::Acquire),
981 1,
982 "callback should fire after successful start (proves rollback restored it)"
983 );
984 }
985
986 #[tokio::test]
989 async fn stop_keepalive_called_on_stop() {
990 let mock = MockMobile::new();
991 let handle = setup_manager();
992 let app = tauri::test::mock_app();
993
994 send_set_mobile(&handle, mock.clone()).await;
995 send_start(&handle, app.handle().clone()).await.unwrap();
996
997 assert_eq!(
998 mock.stop_called.load(Ordering::Acquire),
999 0,
1000 "stop_keepalive should not be called yet"
1001 );
1002
1003 send_stop(&handle).await.unwrap();
1004
1005 assert_eq!(
1006 mock.stop_called.load(Ordering::Acquire),
1007 1,
1008 "stop_keepalive should be called once after stop"
1009 );
1010 }
1011
1012 struct MockMobileFailingStop;
1016
1017 impl MobileKeepalive for MockMobileFailingStop {
1018 fn start_keepalive(&self, _label: &str, _foreground_service_type: &str, _ios_safety_timeout_secs: Option<f64>, _ios_processing_safety_timeout_secs: Option<f64>) -> Result<(), ServiceError> {
1019 Ok(())
1020 }
1021
1022 fn stop_keepalive(&self) -> Result<(), ServiceError> {
1023 Err(ServiceError::Platform("mock stop failure".into()))
1024 }
1025 }
1026
1027 #[tokio::test]
1028 async fn stop_keepalive_failure_does_not_propagate() {
1029 let handle = setup_manager();
1030 let app = tauri::test::mock_app();
1031
1032 send_set_mobile(&handle, Arc::new(MockMobileFailingStop)).await;
1033 send_start(&handle, app.handle().clone()).await.unwrap();
1034
1035 let result = send_stop(&handle).await;
1036 assert!(result.is_ok(), "stop should succeed even when stop_keepalive fails");
1037
1038 assert!(
1039 !send_is_running(&handle).await,
1040 "service should not be running after stop"
1041 );
1042 }
1043
1044 #[tokio::test]
1047 async fn ios_safety_timeout_passed_to_mobile() {
1048 let mock = MockMobile::new();
1049 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1050 let handle = ServiceManagerHandle::new(cmd_tx);
1051 let factory: ServiceFactory<tauri::test::MockRuntime> =
1052 Box::new(|| Box::new(BlockingService));
1053 tokio::spawn(manager_loop(cmd_rx, factory, 15.0, 0.0));
1055
1056 let app = tauri::test::mock_app();
1057
1058 send_set_mobile(&handle, mock.clone()).await;
1059 send_start(&handle, app.handle().clone()).await.unwrap();
1060
1061 let timeout = *mock.last_timeout_secs.lock().unwrap();
1063 assert_eq!(
1064 timeout,
1065 Some(15.0),
1066 "ios_safety_timeout_secs should be passed to mobile"
1067 );
1068 }
1069
1070 #[tokio::test]
1073 async fn ios_processing_timeout_passed_to_mobile() {
1074 let mock = MockMobile::new();
1075 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1076 let handle = ServiceManagerHandle::new(cmd_tx);
1077 let factory: ServiceFactory<tauri::test::MockRuntime> =
1078 Box::new(|| Box::new(BlockingService));
1079 tokio::spawn(manager_loop(cmd_rx, factory, 28.0, 60.0));
1081
1082 let app = tauri::test::mock_app();
1083
1084 send_set_mobile(&handle, mock.clone()).await;
1085 send_start(&handle, app.handle().clone()).await.unwrap();
1086
1087 let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
1089 assert_eq!(
1090 timeout,
1091 Some(60.0),
1092 "ios_processing_safety_timeout_secs should be passed to mobile"
1093 );
1094 }
1095
1096 #[tokio::test]
1097 async fn ios_processing_timeout_zero_passes_as_none() {
1098 let mock = MockMobile::new();
1099 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1100 let handle = ServiceManagerHandle::new(cmd_tx);
1101 let factory: ServiceFactory<tauri::test::MockRuntime> =
1102 Box::new(|| Box::new(BlockingService));
1103 tokio::spawn(manager_loop(cmd_rx, factory, 28.0, 0.0));
1105
1106 let app = tauri::test::mock_app();
1107
1108 send_set_mobile(&handle, mock.clone()).await;
1109 send_start(&handle, app.handle().clone()).await.unwrap();
1110
1111 let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
1113 assert_eq!(
1114 timeout,
1115 None,
1116 "ios_processing_safety_timeout_secs of 0.0 should pass None to mobile"
1117 );
1118 }
1119}