Skip to main content

tauri_plugin_background_service/
manager.rs

1//! Actor-based service manager.
2//!
3//! The [`manager_loop`] function runs as a single-owner Tokio task that receives
4//! [`ManagerCommand`] messages through an `mpsc` channel. This serialises all
5//! state mutations (start, stop, is_running) and prevents concurrent interleaving.
6//!
7//! Most of this module is `pub(crate)` — the public API surface is re-exported
8//! from the crate root. Items that are `pub` only for the iOS lifecycle bridge
9//! are marked `#[doc(hidden)]`.
10
11use 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/// Callback fired when the service task completes. Receives `true` on success.
24#[doc(hidden)]
25pub type OnCompleteCallback = Box<dyn Fn(bool) + Send + Sync>;
26
27/// Abstraction over mobile keepalive operations.
28///
29/// Defined here (not behind `#[cfg(mobile)]`) so the actor can reference it
30/// on all platforms. On desktop, `ServiceState.mobile` is `None` and these
31/// methods are never called. On mobile, `MobileLifecycle` implements this trait.
32pub(crate) trait MobileKeepalive: Send + Sync {
33    /// Start the OS-specific keepalive (Android foreground service / iOS BGTask).
34    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    /// Stop the OS-specific keepalive.
36    fn stop_keepalive(&self) -> Result<(), ServiceError>;
37}
38
39/// Type-erased factory: produces a fresh `Box<dyn BackgroundService<R>>` on demand.
40#[doc(hidden)]
41pub type ServiceFactory<R> =
42    Box<dyn Fn() -> Box<dyn BackgroundService<R>> + Send + Sync>;
43
44// ─── Commands ───────────────────────────────────────────────────────────
45
46/// Commands sent to the service manager actor.
47///
48/// Internal implementation detail — not part of the public API.
49///
50/// This enum is `#[non_exhaustive]` to prevent external construction.
51/// Use [`ServiceManagerHandle`] methods instead.
52#[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
74// ─── Handle ────────────────────────────────────────────────────────────
75
76/// Handle to the service manager actor. Stored as Tauri managed state.
77///
78/// Tauri commands send messages through the internal channel; the actor
79/// task processes them sequentially, preventing concurrent start/stop
80/// interleaving.
81pub struct ServiceManagerHandle<R: Runtime> {
82    pub(crate) cmd_tx: mpsc::Sender<ManagerCommand<R>>,
83}
84
85impl<R: Runtime> ServiceManagerHandle<R> {
86    /// Create a new handle backed by the given channel sender.
87    pub fn new(cmd_tx: mpsc::Sender<ManagerCommand<R>>) -> Self {
88        Self { cmd_tx }
89    }
90
91    /// Start a background service.
92    ///
93    /// Sends a `Start` command to the actor. Returns `AlreadyRunning` if a
94    /// service is already active.
95    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    /// Stop the running background service.
114    ///
115    /// Sends a `Stop` command to the actor. Returns `NotRunning` if no
116    /// service is active.
117    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    /// Check whether a background service is currently running.
128    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    /// Set the callback fired when the service task completes.
142    ///
143    /// The callback is captured at spawn time (generation-guarded), so calling
144    /// this while a service is running will only affect the *next* start.
145    #[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
154// ─── Actor State ───────────────────────────────────────────────────────
155
156/// Internal state owned exclusively by the actor task.
157struct ServiceState<R: Runtime> {
158    /// Cancellation token: `Some` means a service is running.
159    /// Shared with the spawned service task via `Arc<Mutex<>>` so it can
160    /// clear the slot when the task finishes.
161    token: Arc<Mutex<Option<CancellationToken>>>,
162    /// Generation counter for the race-condition guard.
163    /// Incremented on each start; shared via `Arc<AtomicU64>`.
164    generation: Arc<AtomicU64>,
165    /// Callback fired once when the service task completes.
166    /// Captured via `take()` at spawn time so a new callback can be set
167    /// for the next start.
168    on_complete: Option<OnCompleteCallback>,
169    /// Factory that creates fresh service instances.
170    factory: ServiceFactory<R>,
171    /// Mobile keepalive handle. Set via `SetMobile` command on mobile platforms.
172    mobile: Option<Arc<dyn MobileKeepalive>>,
173    /// iOS safety timeout in seconds (from PluginConfig, default 28.0).
174    /// Passed to mobile via `start_keepalive`. Android ignores this field.
175    ios_safety_timeout_secs: f64,
176    /// iOS BGProcessingTask safety timeout in seconds (from PluginConfig, default 0.0).
177    /// When > 0.0, caps processing task duration. Passed as `Some(value)` to mobile.
178    /// When 0.0, passed as `None` (no cap).
179    ios_processing_safety_timeout_secs: f64,
180}
181
182// ─── Actor Loop ────────────────────────────────────────────────────────
183
184/// Main actor loop: receives commands and dispatches to handlers.
185///
186/// Runs as a spawned Tokio task. The loop exits when all `Sender` halves
187/// are dropped (i.e., the handle is dropped).
188#[doc(hidden)]
189pub async fn manager_loop<R: Runtime>(
190    mut rx: mpsc::Receiver<ManagerCommand<R>>,
191    factory: ServiceFactory<R>,
192    // iOS safety timeout in seconds. From PluginConfig.
193    // Default: 28.0 (Apple recommends keeping BG tasks under ~30s).
194    // Passed to mobile via actor's `start_keepalive` call.
195    ios_safety_timeout_secs: f64,
196    // iOS BGProcessingTask safety timeout in seconds. From PluginConfig.
197    // Default: 0.0 (no cap). When > 0.0, passed as Some(value) to mobile.
198    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
231// ─── Command Handlers ──────────────────────────────────────────────────
232
233/// Handle a `Start` command.
234///
235/// Order of operations (critical for the race-condition fix):
236/// 1. Check `AlreadyRunning` — reject early, no side-effects.
237/// 2. Create token, increment generation.
238/// 3. Start mobile keepalive (AFTER AlreadyRunning check).
239///    On failure: rollback token and callback, return error.
240/// 4. Spawn service task (init -> run -> cleanup).
241fn 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    // Capture on_complete at spawn time (generation-guarded).
260    // Takes the callback out of the slot so a new start can set a fresh one.
261    let captured_callback = state.on_complete.take();
262
263    // Start mobile keepalive AFTER AlreadyRunning check.
264    // On failure: rollback (clear token, restore callback).
265    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            // Rollback: clear the token we just set.
273            state.token.lock().unwrap().take();
274            // Rollback: restore the callback we took.
275            state.on_complete = captured_callback;
276            return Err(e);
277        }
278    }
279
280    // Shared refs for the spawned task's cleanup logic.
281    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    // Use tauri::async_runtime::spawn() instead of tokio::spawn() because
295    // the plugin setup closure may run before a Tokio runtime context is
296    // entered on the current thread (e.g. Android auto-start in setup).
297    tauri::async_runtime::spawn(async move {
298        // Phase 1: init
299        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            // Clear token only if generation hasn't advanced.
307            if gen_ref.load(Ordering::Acquire) == my_gen {
308                token_ref.lock().unwrap().take();
309            }
310            // Fire callback with false on init failure.
311            if let Some(cb) = captured_callback {
312                cb(false);
313            }
314            return;
315        }
316
317        // Emit Started
318        let _ = app.emit("background-service://event", PluginEvent::Started);
319
320        // Phase 2: run
321        let result = service.run(&ctx).await;
322
323        // Emit terminal event.
324        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        // Fire on_complete callback (captured at spawn time).
344        // MUST fire before clearing the token so that
345        // `wait_until_stopped` only returns after the callback ran.
346        if let Some(cb) = captured_callback {
347            cb(result.is_ok());
348        }
349
350        // Clear token only if generation hasn't advanced.
351        if gen_ref.load(Ordering::Acquire) == my_gen {
352            token_ref.lock().unwrap().take();
353        }
354    });
355
356    Ok(())
357}
358
359/// Handle a `Stop` command.
360///
361/// Takes the token from state and cancels it, then stops mobile keepalive.
362/// Returns `NotRunning` if no service is active.
363fn 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            // Stop mobile keepalive after token cancellation.
370            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    // ── Mock mobile for keepalive testing ─────────────────────────────
388
389    /// Mock mobile that records start/stop_keepalive calls.
390    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    /// Service that blocks in run() until cancelled.
446    /// Used for lifecycle tests where is_running must remain true.
447    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    /// Create a manager actor with a BlockingService factory.
468    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    // ── AC1: Start from idle succeeds ────────────────────────────────
519
520    #[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    // ── AC2: Stop from running succeeds ──────────────────────────────
534
535    #[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    // ── AC3: Double start returns AlreadyRunning ────────────────────
551
552    #[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    // ── AC4: Stop when not running returns NotRunning ────────────────
567
568    #[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    // ── AC5: Start-stop-restart cycle works ──────────────────────────
580
581    #[tokio::test]
582    async fn start_stop_restart_cycle() {
583        let handle = setup_manager();
584        let app = tauri::test::mock_app();
585
586        // Start
587        send_start(&handle, app.handle().clone()).await.unwrap();
588        assert!(send_is_running(&handle).await);
589
590        // Stop
591        send_stop(&handle).await.unwrap();
592        assert!(!send_is_running(&handle).await);
593
594        // Restart
595        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    // ── Test services for callback testing ────────────────────────────
604
605    /// Service that completes run() immediately with success.
606    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    /// Service whose run() returns an error immediately.
626    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    /// Service whose init() fails.
646    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    /// Create a manager actor with a custom factory.
666    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    /// Wait for the service to finish (is_running becomes false).
687    /// Polls with a short sleep between attempts.
688    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    // ── AC6 (Step 3): Callback fires on success ──────────────────────
703
704    #[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    // ── AC7 (Step 3): Callback fires on error ────────────────────────
730
731    #[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    // ── AC8 (Step 3): Callback fires on init failure ─────────────────
757
758    #[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        // Init failure: service was never truly running, so token gets cleared quickly.
776        // Wait a short time for the spawned task to complete.
777        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    // ── AC9 (Step 3): No callback no panic ───────────────────────────
791
792    #[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        // Deliberately do NOT call SetOnComplete.
798        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        // If we get here without panicking, the test passes.
803    }
804
805    // ── AC10 (Step 3): Generation guard prevents stale cleanup ───────
806
807    #[tokio::test]
808    async fn generation_guard_prevents_stale_cleanup() {
809        // First start with FailingInit (generation 1) — clears its own token.
810        // Second start with ImmediateSuccess (generation 2) — should succeed
811        // because the old task's cleanup shouldn't corrupt the new state.
812        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            // First call: FailingInit. Second call: ImmediateSuccess.
818            // Use AtomicU8 to track which invocation this is.
819            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        // First start: init fails, token cleared by spawned task.
828        send_start(&handle, app.handle().clone()).await.unwrap();
829        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
830
831        // Second start: should succeed — generation guard prevented stale cleanup.
832        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    // ── AC11 (Step 3): Callback captured at spawn time ───────────────
844
845    #[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        // Set callback A, start, then set callback B.
851        // When the service completes, A should fire (not B).
852        let which = Arc::new(AtomicU8::new(0)); // 0=none, 1=A, 2=B
853        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        // Service is blocking — set a NEW callback while it runs.
867        send_set_on_complete(
868            &handle,
869            Box::new(move |_| {
870                which_clone_b.store(2, Ordering::Release);
871            }),
872        )
873        .await;
874
875        // Stop the service — this triggers cleanup and callback.
876        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    // ── Mobile keepalive helpers ──────────────────────────────────────
887
888    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    // ── AC1 (Step 5): start_keepalive called on start ────────────────
900
901    #[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    // ── AC2 (Step 5): start_keepalive failure rollback ───────────────
923
924    #[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        // Token should be cleared (not running).
949        assert!(
950            !send_is_running(&handle).await,
951            "token should be rolled back after keepalive failure"
952        );
953
954        // Callback should be restored — can be set again.
955        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        // Without the failing mobile, a start should succeed and callback should work.
966        // Use a fresh manager without mobile to test callback restoration.
967        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    // ── AC3 (Step 5): stop_keepalive called on stop ──────────────────
987
988    #[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    // ── stop_keepalive failure does not propagate ──────────────────────────
1013
1014    /// Mock mobile where `stop_keepalive` always fails.
1015    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    // ── iOS safety timeout passed to mobile ──────────────────────────────
1045
1046    #[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        // Use a custom timeout value (not default 28.0)
1054        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        // Verify the timeout was passed through to the mock
1062        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    // ── iOS processing timeout passed to mobile ──────────────────────────────
1071
1072    #[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        // Use a custom processing timeout value
1080        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        // Verify the processing timeout was passed through to the mock
1088        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        // Processing timeout = 0.0 (default, no cap)
1104        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        // Zero timeout should be passed as None
1112        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}