use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter, Runtime};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use crate::desired_state::DesiredStateBackend;
use crate::error::ServiceError;
use crate::models::{
validate_foreground_service_type, LifecycleMode, PluginEvent, ServiceContext,
ServiceState as ServiceLifecycle, ServiceStatus, StartConfig,
};
use crate::notifier::Notifier;
use crate::service_trait::BackgroundService;
#[doc(hidden)]
pub type OnCompleteCallback = Box<dyn Fn(bool) + Send + Sync>;
pub(crate) trait MobileKeepalive: Send + Sync {
#[allow(clippy::too_many_arguments)]
fn start_keepalive(
&self,
label: &str,
foreground_service_type: &str,
ios_safety_timeout_secs: Option<f64>,
ios_processing_safety_timeout_secs: Option<f64>,
ios_earliest_refresh_begin_minutes: Option<f64>,
ios_earliest_processing_begin_minutes: Option<f64>,
ios_requires_external_power: Option<bool>,
ios_requires_network_connectivity: Option<bool>,
) -> Result<(), ServiceError>;
fn stop_keepalive(&self) -> Result<(), ServiceError>;
}
#[doc(hidden)]
pub type ServiceFactory<R> = Box<dyn Fn() -> Box<dyn BackgroundService<R>> + Send + Sync>;
#[non_exhaustive]
pub enum ManagerCommand<R: Runtime> {
Start {
config: StartConfig,
reply: oneshot::Sender<Result<(), ServiceError>>,
app: AppHandle<R>,
},
Stop {
reply: oneshot::Sender<Result<(), ServiceError>>,
},
IsRunning {
reply: oneshot::Sender<bool>,
},
GetState {
reply: oneshot::Sender<ServiceStatus>,
},
SetOnComplete {
callback: OnCompleteCallback,
},
#[allow(dead_code, private_interfaces)]
SetMobile {
mobile: Arc<dyn MobileKeepalive>,
},
SetDesiredRunning {
desired: bool,
config: Option<StartConfig>,
reply: oneshot::Sender<Result<(), ServiceError>>,
},
EnableAutoRestart {
config: Option<StartConfig>,
reply: oneshot::Sender<Result<(), ServiceError>>,
},
DisableAutoRestart {
reply: oneshot::Sender<Result<(), ServiceError>>,
},
GetDesiredState {
reply: oneshot::Sender<Option<crate::desired_state::DesiredState>>,
},
}
pub struct ServiceManagerHandle<R: Runtime> {
pub(crate) cmd_tx: mpsc::Sender<ManagerCommand<R>>,
}
impl<R: Runtime> ServiceManagerHandle<R> {
pub fn new(cmd_tx: mpsc::Sender<ManagerCommand<R>>) -> Self {
Self { cmd_tx }
}
pub async fn start(&self, app: AppHandle<R>, config: StartConfig) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::Start { config, reply, app })
.await
.map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
rx.await
.map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
}
pub async fn stop(&self) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::Stop { reply })
.await
.map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
rx.await
.map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
}
pub fn stop_blocking(&self) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.blocking_send(ManagerCommand::Stop { reply })
.map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
rx.blocking_recv()
.map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
}
pub async fn is_running(&self) -> bool {
let (reply, rx) = oneshot::channel();
if self
.cmd_tx
.send(ManagerCommand::IsRunning { reply })
.await
.is_err()
{
return false;
}
rx.await.unwrap_or(false)
}
#[doc(hidden)]
pub async fn set_on_complete(&self, callback: OnCompleteCallback) {
let _ = self
.cmd_tx
.send(ManagerCommand::SetOnComplete { callback })
.await;
}
pub async fn get_state(&self) -> ServiceStatus {
let (reply, rx) = oneshot::channel();
if self
.cmd_tx
.send(ManagerCommand::GetState { reply })
.await
.is_err()
{
return ServiceStatus {
state: ServiceLifecycle::Idle,
..Default::default()
};
}
rx.await.unwrap_or(ServiceStatus {
state: ServiceLifecycle::Idle,
..Default::default()
})
}
}
struct ServiceState<R: Runtime> {
is_running: Arc<AtomicBool>,
token: Arc<Mutex<Option<CancellationToken>>>,
generation: Arc<AtomicU64>,
on_complete: Option<OnCompleteCallback>,
factory: ServiceFactory<R>,
mobile: Option<Arc<dyn MobileKeepalive>>,
ios_safety_timeout_secs: f64,
ios_processing_safety_timeout_secs: f64,
ios_earliest_refresh_begin_minutes: f64,
ios_earliest_processing_begin_minutes: f64,
ios_requires_external_power: bool,
ios_requires_network_connectivity: bool,
lifecycle_state: Arc<Mutex<ServiceLifecycle>>,
last_error: Arc<Mutex<Option<String>>>,
desired_state: Option<Arc<dyn DesiredStateBackend>>,
lifecycle_mode: LifecycleMode,
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub async fn manager_loop<R: Runtime>(
mut rx: mpsc::Receiver<ManagerCommand<R>>,
factory: ServiceFactory<R>,
ios_safety_timeout_secs: f64,
ios_processing_safety_timeout_secs: f64,
ios_earliest_refresh_begin_minutes: f64,
ios_earliest_processing_begin_minutes: f64,
ios_requires_external_power: bool,
ios_requires_network_connectivity: bool,
desired_state_backend: Option<Arc<dyn DesiredStateBackend>>,
) {
let lifecycle_mode = {
#[cfg(target_os = "android")]
{
LifecycleMode::AndroidForegroundService
}
#[cfg(target_os = "ios")]
{
LifecycleMode::IosBgTaskScheduler
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
LifecycleMode::DesktopInProcess
}
};
let mut state = ServiceState {
is_running: Arc::new(AtomicBool::new(false)),
token: Arc::new(Mutex::new(None)),
generation: Arc::new(AtomicU64::new(0)),
on_complete: None,
factory,
mobile: None,
ios_safety_timeout_secs,
ios_processing_safety_timeout_secs,
ios_earliest_refresh_begin_minutes,
ios_earliest_processing_begin_minutes,
ios_requires_external_power,
ios_requires_network_connectivity,
lifecycle_state: Arc::new(Mutex::new(ServiceLifecycle::Idle)),
last_error: Arc::new(Mutex::new(None)),
desired_state: desired_state_backend,
lifecycle_mode,
};
while let Some(cmd) = rx.recv().await {
match cmd {
ManagerCommand::Start { config, reply, app } => {
let _ = reply.send(handle_start(&mut state, app, config));
}
ManagerCommand::Stop { reply } => {
let _ = reply.send(handle_stop(&mut state));
}
ManagerCommand::IsRunning { reply } => {
let _ = reply.send(state.is_running.load(Ordering::SeqCst));
}
ManagerCommand::SetOnComplete { callback } => {
state.on_complete = Some(callback);
}
ManagerCommand::SetMobile { mobile } => {
state.mobile = Some(mobile);
}
ManagerCommand::GetState { reply } => {
let mut status = ServiceStatus {
state: *state.lifecycle_state.lock().unwrap(),
last_error: state.last_error.lock().unwrap().clone(),
platform_mode: Some(state.lifecycle_mode),
..Default::default()
};
if let Some(ref backend) = state.desired_state {
if let Ok(ds) = backend.load() {
status.desired_running = Some(ds.desired_running);
status.native_state = ds
.last_native_state
.as_deref()
.and_then(|s| serde_json::from_str(&format!("\"{s}\"")).ok());
status.last_start_config = ds
.last_start_config
.and_then(|v| serde_json::from_value(v).ok());
status.last_heartbeat_at = ds.last_heartbeat_epoch_ms;
status.restart_attempt = if ds.restart_attempt > 0 {
Some(ds.restart_attempt)
} else {
None
};
status.recovery_reason = ds.recovery_reason;
status.platform_error = ds.last_platform_error;
}
}
let _ = reply.send(status);
}
ManagerCommand::SetDesiredRunning {
desired,
config,
reply,
} => {
let _ = reply.send(handle_set_desired_running(&mut state, desired, config));
}
ManagerCommand::EnableAutoRestart { config, reply } => {
let _ = reply.send(handle_enable_auto_restart(&mut state, config));
}
ManagerCommand::DisableAutoRestart { reply } => {
let _ = reply.send(handle_disable_auto_restart(&mut state));
}
ManagerCommand::GetDesiredState { reply } => {
let _ = reply.send(handle_get_desired_state(&state));
}
}
}
}
fn handle_start<R: Runtime>(
state: &mut ServiceState<R>,
app: AppHandle<R>,
config: StartConfig,
) -> Result<(), ServiceError> {
let mut guard = state.token.lock().unwrap();
if guard.is_some() {
return Err(ServiceError::AlreadyRunning);
}
if cfg!(mobile) {
validate_foreground_service_type(&config.foreground_service_type)?;
}
let token = CancellationToken::new();
let shutdown = token.clone();
*guard = Some(token);
let my_gen = state.generation.fetch_add(1, Ordering::Release) + 1;
state.is_running.store(true, Ordering::SeqCst);
*state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Initializing;
*state.last_error.lock().unwrap() = None;
drop(guard);
let captured_callback = state.on_complete.take();
if let Some(ref mobile) = state.mobile {
let processing_timeout = if state.ios_processing_safety_timeout_secs > 0.0 {
Some(state.ios_processing_safety_timeout_secs)
} else {
None
};
if let Err(e) = mobile.start_keepalive(
&config.service_label,
&config.foreground_service_type,
Some(state.ios_safety_timeout_secs),
processing_timeout,
Some(state.ios_earliest_refresh_begin_minutes),
Some(state.ios_earliest_processing_begin_minutes),
Some(state.ios_requires_external_power),
Some(state.ios_requires_network_connectivity),
) {
state.token.lock().unwrap().take();
state.is_running.store(false, Ordering::SeqCst);
*state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Idle;
state.on_complete = captured_callback;
return Err(e);
}
}
let token_ref = state.token.clone();
let gen_ref = state.generation.clone();
let is_running_ref = state.is_running.clone();
let lifecycle_ref = state.lifecycle_state.clone();
let last_error_ref = state.last_error.clone();
let mut service = (state.factory)();
let ctx = ServiceContext {
notifier: Notifier { app: app.clone() },
app: app.clone(),
shutdown,
#[cfg(mobile)]
service_label: config.service_label,
#[cfg(mobile)]
foreground_service_type: config.foreground_service_type,
};
tauri::async_runtime::spawn(async move {
if let Err(e) = service.init(&ctx).await {
let _ = app.emit(
"background-service://event",
PluginEvent::Error {
message: e.to_string(),
},
);
if gen_ref.load(Ordering::Acquire) == my_gen {
token_ref.lock().unwrap().take();
is_running_ref.store(false, Ordering::SeqCst);
{
let mut lc = lifecycle_ref.lock().unwrap();
if *lc == ServiceLifecycle::Initializing {
*lc = ServiceLifecycle::Stopped;
}
}
*last_error_ref.lock().unwrap() = Some(e.to_string());
}
if let Some(cb) = captured_callback {
cb(false);
}
return;
}
if gen_ref.load(Ordering::Acquire) == my_gen {
let mut lc = lifecycle_ref.lock().unwrap();
if *lc == ServiceLifecycle::Initializing {
*lc = ServiceLifecycle::Running;
}
}
let _ = app.emit("background-service://event", PluginEvent::Started);
let result = service.run(&ctx).await;
match result {
Ok(()) => {
let _ = app.emit(
"background-service://event",
PluginEvent::Stopped {
reason: "completed".into(),
},
);
}
Err(ref e) => {
let _ = app.emit(
"background-service://event",
PluginEvent::Error {
message: e.to_string(),
},
);
}
}
if let Some(cb) = captured_callback {
cb(result.is_ok());
}
if gen_ref.load(Ordering::Acquire) == my_gen {
token_ref.lock().unwrap().take();
is_running_ref.store(false, Ordering::SeqCst);
{
let mut lc = lifecycle_ref.lock().unwrap();
if matches!(
*lc,
ServiceLifecycle::Initializing | ServiceLifecycle::Running
) {
*lc = ServiceLifecycle::Stopped;
}
}
if let Err(ref e) = result {
*last_error_ref.lock().unwrap() = Some(e.to_string());
}
}
});
save_desired_running(state, true, Some(&config));
Ok(())
}
fn handle_stop<R: Runtime>(state: &mut ServiceState<R>) -> Result<(), ServiceError> {
let mut guard = state.token.lock().unwrap();
match guard.take() {
Some(token) => {
token.cancel();
state.is_running.store(false, Ordering::SeqCst);
*state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Stopped;
*state.last_error.lock().unwrap() = None;
drop(guard);
if let Some(ref mobile) = state.mobile {
if let Err(e) = mobile.stop_keepalive() {
log::warn!("stop_keepalive failed (service already cancelled): {e}");
}
}
save_desired_running(state, false, None);
Ok(())
}
None => Err(ServiceError::NotRunning),
}
}
fn save_desired_running<R: Runtime>(
state: &ServiceState<R>,
desired: bool,
config: Option<&StartConfig>,
) {
let Some(ref backend) = state.desired_state else {
return;
};
let mut ds = backend.load().unwrap_or_default();
ds.desired_running = desired;
if desired {
ds.last_start_config = config.map(|c| serde_json::to_value(c).unwrap_or_default());
ds.last_start_epoch_ms = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
);
} else {
ds.last_start_config = None;
ds.last_start_epoch_ms = None;
ds.recovery_pending = false;
ds.recovery_reason = None;
ds.restart_attempt = 0;
}
if let Err(e) = backend.save(&ds) {
log::warn!("failed to save desired state: {e}");
}
}
fn handle_set_desired_running<R: Runtime>(
state: &mut ServiceState<R>,
desired: bool,
config: Option<StartConfig>,
) -> Result<(), ServiceError> {
save_desired_running(state, desired, config.as_ref());
Ok(())
}
fn handle_enable_auto_restart<R: Runtime>(
state: &mut ServiceState<R>,
config: Option<StartConfig>,
) -> Result<(), ServiceError> {
save_desired_running(state, true, config.as_ref());
Ok(())
}
fn handle_disable_auto_restart<R: Runtime>(
state: &mut ServiceState<R>,
) -> Result<(), ServiceError> {
save_desired_running(state, false, None);
Ok(())
}
fn handle_get_desired_state<R: Runtime>(
state: &ServiceState<R>,
) -> Option<crate::desired_state::DesiredState> {
state
.desired_state
.as_ref()
.and_then(|backend| backend.load().ok())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::desired_state::DesiredState;
use crate::models::NativeState;
use async_trait::async_trait;
use std::sync::atomic::{AtomicI8, AtomicU8, AtomicUsize};
struct MockMobile {
start_called: AtomicUsize,
stop_called: AtomicUsize,
start_fail: bool,
last_label: std::sync::Mutex<Option<String>>,
last_fst: std::sync::Mutex<Option<String>>,
last_timeout_secs: std::sync::Mutex<Option<f64>>,
last_processing_timeout_secs: std::sync::Mutex<Option<f64>>,
last_earliest_refresh_begin_minutes: std::sync::Mutex<Option<f64>>,
last_earliest_processing_begin_minutes: std::sync::Mutex<Option<f64>>,
last_requires_external_power: std::sync::Mutex<Option<bool>>,
last_requires_network_connectivity: std::sync::Mutex<Option<bool>>,
}
impl MockMobile {
fn new() -> Arc<Self> {
Arc::new(Self {
start_called: AtomicUsize::new(0),
stop_called: AtomicUsize::new(0),
start_fail: false,
last_label: std::sync::Mutex::new(None),
last_fst: std::sync::Mutex::new(None),
last_timeout_secs: std::sync::Mutex::new(None),
last_processing_timeout_secs: std::sync::Mutex::new(None),
last_earliest_refresh_begin_minutes: std::sync::Mutex::new(None),
last_earliest_processing_begin_minutes: std::sync::Mutex::new(None),
last_requires_external_power: std::sync::Mutex::new(None),
last_requires_network_connectivity: std::sync::Mutex::new(None),
})
}
fn new_failing() -> Arc<Self> {
Arc::new(Self {
start_called: AtomicUsize::new(0),
stop_called: AtomicUsize::new(0),
start_fail: true,
last_label: std::sync::Mutex::new(None),
last_fst: std::sync::Mutex::new(None),
last_timeout_secs: std::sync::Mutex::new(None),
last_processing_timeout_secs: std::sync::Mutex::new(None),
last_earliest_refresh_begin_minutes: std::sync::Mutex::new(None),
last_earliest_processing_begin_minutes: std::sync::Mutex::new(None),
last_requires_external_power: std::sync::Mutex::new(None),
last_requires_network_connectivity: std::sync::Mutex::new(None),
})
}
}
#[allow(clippy::too_many_arguments)]
fn mock_start_keepalive(
mock: &MockMobile,
label: &str,
foreground_service_type: &str,
ios_safety_timeout_secs: Option<f64>,
ios_processing_safety_timeout_secs: Option<f64>,
ios_earliest_refresh_begin_minutes: Option<f64>,
ios_earliest_processing_begin_minutes: Option<f64>,
ios_requires_external_power: Option<bool>,
ios_requires_network_connectivity: Option<bool>,
) -> Result<(), ServiceError> {
mock.start_called.fetch_add(1, Ordering::Release);
*mock.last_label.lock().unwrap() = Some(label.to_string());
*mock.last_fst.lock().unwrap() = Some(foreground_service_type.to_string());
*mock.last_timeout_secs.lock().unwrap() = ios_safety_timeout_secs;
*mock.last_processing_timeout_secs.lock().unwrap() = ios_processing_safety_timeout_secs;
*mock.last_earliest_refresh_begin_minutes.lock().unwrap() =
ios_earliest_refresh_begin_minutes;
*mock.last_earliest_processing_begin_minutes.lock().unwrap() =
ios_earliest_processing_begin_minutes;
*mock.last_requires_external_power.lock().unwrap() = ios_requires_external_power;
*mock.last_requires_network_connectivity.lock().unwrap() =
ios_requires_network_connectivity;
if mock.start_fail {
return Err(ServiceError::Platform("mock keepalive failure".into()));
}
Ok(())
}
impl MobileKeepalive for MockMobile {
#[allow(clippy::too_many_arguments)]
fn start_keepalive(
&self,
label: &str,
foreground_service_type: &str,
ios_safety_timeout_secs: Option<f64>,
ios_processing_safety_timeout_secs: Option<f64>,
ios_earliest_refresh_begin_minutes: Option<f64>,
ios_earliest_processing_begin_minutes: Option<f64>,
ios_requires_external_power: Option<bool>,
ios_requires_network_connectivity: Option<bool>,
) -> Result<(), ServiceError> {
mock_start_keepalive(
self,
label,
foreground_service_type,
ios_safety_timeout_secs,
ios_processing_safety_timeout_secs,
ios_earliest_refresh_begin_minutes,
ios_earliest_processing_begin_minutes,
ios_requires_external_power,
ios_requires_network_connectivity,
)
}
fn stop_keepalive(&self) -> Result<(), ServiceError> {
self.stop_called.fetch_add(1, Ordering::Release);
Ok(())
}
}
struct BlockingService;
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for BlockingService {
async fn init(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Ok(())
}
async fn run(
&mut self,
ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
ctx.shutdown.cancelled().await;
Ok(())
}
}
fn setup_manager() -> ServiceManagerHandle<tauri::test::MockRuntime> {
setup_manager_with_backend(None)
}
fn setup_manager_with_backend(
backend: Option<Arc<dyn DesiredStateBackend>>,
) -> ServiceManagerHandle<tauri::test::MockRuntime> {
let (cmd_tx, cmd_rx) = mpsc::channel(16);
let handle = ServiceManagerHandle::new(cmd_tx);
let factory: ServiceFactory<tauri::test::MockRuntime> =
Box::new(|| Box::new(BlockingService));
tokio::spawn(manager_loop(
cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, backend,
));
handle
}
async fn send_start(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
app: AppHandle<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
send_start_with_config(handle, StartConfig::default(), app).await
}
async fn send_start_with_config(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
config: StartConfig,
app: AppHandle<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
let (tx, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::Start {
config,
reply: tx,
app,
})
.await
.unwrap();
rx.await.unwrap()
}
async fn send_stop(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
let (tx, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::Stop { reply: tx })
.await
.unwrap();
rx.await.unwrap()
}
async fn send_is_running(handle: &ServiceManagerHandle<tauri::test::MockRuntime>) -> bool {
let (tx, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::IsRunning { reply: tx })
.await
.unwrap();
rx.await.unwrap()
}
#[tokio::test]
async fn start_from_idle() {
let handle = setup_manager();
let app = tauri::test::mock_app();
let result = send_start(&handle, app.handle().clone()).await;
assert!(result.is_ok(), "start should succeed from idle");
assert!(
send_is_running(&handle).await,
"should be running after start"
);
}
#[tokio::test]
async fn stop_from_running() {
let handle = setup_manager();
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
let result = send_stop(&handle).await;
assert!(result.is_ok(), "stop should succeed from running");
assert!(
!send_is_running(&handle).await,
"should not be running after stop"
);
}
#[tokio::test]
async fn double_start_returns_already_running() {
let handle = setup_manager();
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
let result = send_start(&handle, app.handle().clone()).await;
assert!(
matches!(result, Err(ServiceError::AlreadyRunning)),
"second start should return AlreadyRunning"
);
}
#[tokio::test]
async fn stop_when_not_running_returns_not_running() {
let handle = setup_manager();
let result = send_stop(&handle).await;
assert!(
matches!(result, Err(ServiceError::NotRunning)),
"stop should return NotRunning when idle"
);
}
#[tokio::test]
async fn start_stop_restart_cycle() {
let handle = setup_manager();
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
assert!(send_is_running(&handle).await);
send_stop(&handle).await.unwrap();
assert!(!send_is_running(&handle).await);
let result = send_start(&handle, app.handle().clone()).await;
assert!(result.is_ok(), "restart should succeed after stop");
assert!(
send_is_running(&handle).await,
"should be running after restart"
);
}
struct ImmediateSuccessService;
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for ImmediateSuccessService {
async fn init(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Ok(())
}
async fn run(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Ok(())
}
}
struct ImmediateErrorService;
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for ImmediateErrorService {
async fn init(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Ok(())
}
async fn run(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Err(ServiceError::Runtime("run error".into()))
}
}
struct FailingInitService;
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for FailingInitService {
async fn init(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Err(ServiceError::Init("init error".into()))
}
async fn run(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Ok(())
}
}
fn setup_manager_with_factory(
factory: ServiceFactory<tauri::test::MockRuntime>,
) -> ServiceManagerHandle<tauri::test::MockRuntime> {
setup_manager_with_factory_and_backend(factory, None)
}
fn setup_manager_with_factory_and_backend(
factory: ServiceFactory<tauri::test::MockRuntime>,
backend: Option<Arc<dyn DesiredStateBackend>>,
) -> ServiceManagerHandle<tauri::test::MockRuntime> {
let (cmd_tx, cmd_rx) = mpsc::channel(16);
let handle = ServiceManagerHandle::new(cmd_tx);
tokio::spawn(manager_loop(
cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, backend,
));
handle
}
async fn send_set_on_complete(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
callback: OnCompleteCallback,
) {
handle
.cmd_tx
.send(ManagerCommand::SetOnComplete { callback })
.await
.unwrap();
}
async fn wait_until_stopped(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
timeout_ms: u64,
) {
let start = std::time::Instant::now();
while start.elapsed().as_millis() < timeout_ms as u128 {
if !send_is_running(handle).await {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
panic!("Service did not stop within {timeout_ms}ms");
}
#[tokio::test]
async fn callback_fires_on_success() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
let app = tauri::test::mock_app();
let called = Arc::new(AtomicI8::new(-1));
let called_clone = called.clone();
send_set_on_complete(
&handle,
Box::new(move |success| {
called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
}),
)
.await;
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_stopped(&handle, 1000).await;
assert_eq!(
called.load(Ordering::Acquire),
1,
"callback should be called with true"
);
}
#[tokio::test]
async fn callback_fires_on_error() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateErrorService)));
let app = tauri::test::mock_app();
let called = Arc::new(AtomicI8::new(-1));
let called_clone = called.clone();
send_set_on_complete(
&handle,
Box::new(move |success| {
called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
}),
)
.await;
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_stopped(&handle, 1000).await;
assert_eq!(
called.load(Ordering::Acquire),
0,
"callback should be called with false on error"
);
}
#[tokio::test]
async fn callback_fires_on_init_failure() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
let app = tauri::test::mock_app();
let called = Arc::new(AtomicI8::new(-1));
let called_clone = called.clone();
send_set_on_complete(
&handle,
Box::new(move |success| {
called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
}),
)
.await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(
called.load(Ordering::Acquire),
0,
"callback should be called with false on init failure"
);
assert!(
!send_is_running(&handle).await,
"should not be running after init failure"
);
}
#[tokio::test]
async fn no_callback_no_panic() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
let app = tauri::test::mock_app();
let result = send_start(&handle, app.handle().clone()).await;
assert!(result.is_ok(), "start without callback should succeed");
wait_until_stopped(&handle, 1000).await;
}
#[tokio::test]
async fn is_running_false_after_natural_completion() {
struct YieldingService;
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for YieldingService {
async fn init(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Ok(())
}
async fn run(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
Ok(())
}
}
let handle = setup_manager_with_factory(Box::new(|| Box::new(YieldingService)));
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
assert!(
send_is_running(&handle).await,
"should be running immediately after start"
);
wait_until_stopped(&handle, 2000).await;
assert!(
!send_is_running(&handle).await,
"is_running should be false after natural completion"
);
}
#[tokio::test]
async fn generation_guard_prevents_stale_cleanup() {
let call_count = Arc::new(AtomicU8::new(0));
let call_count_clone = call_count.clone();
let handle = setup_manager_with_factory(Box::new(move || {
let cc = call_count_clone.clone();
if cc.fetch_add(1, Ordering::AcqRel) == 0 {
Box::new(FailingInitService) as Box<dyn BackgroundService<tauri::test::MockRuntime>>
} else {
Box::new(ImmediateSuccessService)
}
}));
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let result = send_start(&handle, app.handle().clone()).await;
assert!(
result.is_ok(),
"second start should succeed after init failure: {result:?}"
);
assert!(
send_is_running(&handle).await,
"should be running after second start"
);
}
#[tokio::test]
async fn callback_captured_at_spawn_time() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(BlockingService)));
let app = tauri::test::mock_app();
let which = Arc::new(AtomicU8::new(0)); let which_clone_a = which.clone();
let which_clone_b = which.clone();
send_set_on_complete(
&handle,
Box::new(move |_| {
which_clone_a.store(1, Ordering::Release);
}),
)
.await;
send_start(&handle, app.handle().clone()).await.unwrap();
send_set_on_complete(
&handle,
Box::new(move |_| {
which_clone_b.store(2, Ordering::Release);
}),
)
.await;
send_stop(&handle).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(
which.load(Ordering::Acquire),
1,
"callback A should fire, not B"
);
}
async fn send_set_mobile(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
mobile: Arc<dyn MobileKeepalive>,
) {
handle
.cmd_tx
.send(ManagerCommand::SetMobile { mobile })
.await
.unwrap();
}
#[tokio::test]
async fn start_keepalive_called_on_start() {
let mock = MockMobile::new();
let handle = setup_manager();
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
assert_eq!(
mock.start_called.load(Ordering::Acquire),
1,
"start_keepalive should be called once"
);
assert_eq!(
mock.last_label.lock().unwrap().as_deref(),
Some("Service running"),
"label should be forwarded"
);
}
#[tokio::test]
async fn start_keepalive_failure_rollback() {
let mock = MockMobile::new_failing();
let handle = setup_manager();
let app = tauri::test::mock_app();
let callback_called = Arc::new(AtomicI8::new(-1));
let cb_clone = callback_called.clone();
send_set_on_complete(
&handle,
Box::new(move |success| {
cb_clone.store(if success { 1 } else { 0 }, Ordering::Release);
}),
)
.await;
send_set_mobile(&handle, mock.clone()).await;
let result = send_start(&handle, app.handle().clone()).await;
assert!(
matches!(result, Err(ServiceError::Platform(_))),
"start should return Platform error on keepalive failure: {result:?}"
);
assert!(
!send_is_running(&handle).await,
"token should be rolled back after keepalive failure"
);
let callback_called2 = Arc::new(AtomicI8::new(-1));
let cb_clone2 = callback_called2.clone();
send_set_on_complete(
&handle,
Box::new(move |success| {
cb_clone2.store(if success { 1 } else { 0 }, Ordering::Release);
}),
)
.await;
let handle2 = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
let callback_restored = Arc::new(AtomicI8::new(-1));
let cb_r = callback_restored.clone();
send_set_on_complete(
&handle2,
Box::new(move |success| {
cb_r.store(if success { 1 } else { 0 }, Ordering::Release);
}),
)
.await;
send_start(&handle2, app.handle().clone()).await.unwrap();
wait_until_stopped(&handle2, 1000).await;
assert_eq!(
callback_restored.load(Ordering::Acquire),
1,
"callback should fire after successful start (proves rollback restored it)"
);
}
#[tokio::test]
async fn stop_keepalive_called_on_stop() {
let mock = MockMobile::new();
let handle = setup_manager();
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
assert_eq!(
mock.stop_called.load(Ordering::Acquire),
0,
"stop_keepalive should not be called yet"
);
send_stop(&handle).await.unwrap();
assert_eq!(
mock.stop_called.load(Ordering::Acquire),
1,
"stop_keepalive should be called once after stop"
);
}
struct MockMobileFailingStop;
#[allow(clippy::too_many_arguments)]
impl MobileKeepalive for MockMobileFailingStop {
fn start_keepalive(
&self,
_label: &str,
_foreground_service_type: &str,
_ios_safety_timeout_secs: Option<f64>,
_ios_processing_safety_timeout_secs: Option<f64>,
_ios_earliest_refresh_begin_minutes: Option<f64>,
_ios_earliest_processing_begin_minutes: Option<f64>,
_ios_requires_external_power: Option<bool>,
_ios_requires_network_connectivity: Option<bool>,
) -> Result<(), ServiceError> {
Ok(())
}
fn stop_keepalive(&self) -> Result<(), ServiceError> {
Err(ServiceError::Platform("mock stop failure".into()))
}
}
#[tokio::test]
async fn stop_keepalive_failure_does_not_propagate() {
let handle = setup_manager();
let app = tauri::test::mock_app();
send_set_mobile(&handle, Arc::new(MockMobileFailingStop)).await;
send_start(&handle, app.handle().clone()).await.unwrap();
let result = send_stop(&handle).await;
assert!(
result.is_ok(),
"stop should succeed even when stop_keepalive fails"
);
assert!(
!send_is_running(&handle).await,
"service should not be running after stop"
);
}
#[tokio::test]
async fn ios_safety_timeout_passed_to_mobile() {
let mock = MockMobile::new();
let (cmd_tx, cmd_rx) = mpsc::channel(16);
let handle = ServiceManagerHandle::new(cmd_tx);
let factory: ServiceFactory<tauri::test::MockRuntime> =
Box::new(|| Box::new(BlockingService));
tokio::spawn(manager_loop(
cmd_rx, factory, 15.0, 0.0, 15.0, 15.0, false, false, None,
));
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
let timeout = *mock.last_timeout_secs.lock().unwrap();
assert_eq!(
timeout,
Some(15.0),
"ios_safety_timeout_secs should be passed to mobile"
);
}
#[tokio::test]
async fn ios_processing_timeout_passed_to_mobile() {
let mock = MockMobile::new();
let (cmd_tx, cmd_rx) = mpsc::channel(16);
let handle = ServiceManagerHandle::new(cmd_tx);
let factory: ServiceFactory<tauri::test::MockRuntime> =
Box::new(|| Box::new(BlockingService));
tokio::spawn(manager_loop(
cmd_rx, factory, 28.0, 60.0, 15.0, 15.0, false, false, None,
));
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
assert_eq!(
timeout,
Some(60.0),
"ios_processing_safety_timeout_secs should be passed to mobile"
);
}
#[cfg(mobile)]
struct ContextCapturingService {
captured_label: Arc<std::sync::Mutex<Option<String>>>,
captured_fst: Arc<std::sync::Mutex<Option<String>>>,
}
#[cfg(mobile)]
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for ContextCapturingService {
async fn init(
&mut self,
ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
*self.captured_label.lock().unwrap() = Some(ctx.service_label.clone());
*self.captured_fst.lock().unwrap() = Some(ctx.foreground_service_type.clone());
Ok(())
}
async fn run(
&mut self,
ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
ctx.shutdown.cancelled().await;
Ok(())
}
}
#[cfg(mobile)]
#[tokio::test]
async fn service_context_fields_populated_on_mobile() {
let captured_label: Arc<std::sync::Mutex<Option<String>>> =
Arc::new(std::sync::Mutex::new(None));
let captured_fst: Arc<std::sync::Mutex<Option<String>>> =
Arc::new(std::sync::Mutex::new(None));
let cl = captured_label.clone();
let cf = captured_fst.clone();
let handle = setup_manager_with_factory(Box::new(move || {
let cl = cl.clone();
let cf = cf.clone();
Box::new(ContextCapturingService {
captured_label: cl,
captured_fst: cf,
})
}));
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "Syncing".into(),
foreground_service_type: "dataSync".into(),
};
send_start_with_config(&handle, config, app.handle().clone())
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(
captured_label.lock().unwrap().as_deref(),
Some("Syncing"),
"service_label should be 'Syncing' on mobile"
);
assert_eq!(
captured_fst.lock().unwrap().as_deref(),
Some("dataSync"),
"foreground_service_type should be 'dataSync' on mobile"
);
send_stop(&handle).await.unwrap();
}
#[tokio::test]
async fn handle_start_accepts_invalid_foreground_service_type_on_desktop() {
let handle = setup_manager();
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "test".into(),
foreground_service_type: "bogusType".into(),
};
let result = send_start_with_config(&handle, config, app.handle().clone()).await;
assert!(
result.is_ok(),
"start with invalid fg type should succeed on desktop: {result:?}"
);
assert!(
send_is_running(&handle).await,
"service should be running after start with invalid type on desktop"
);
send_stop(&handle).await.unwrap();
}
#[tokio::test]
async fn handle_start_accepts_all_valid_foreground_service_types() {
for &valid_type in crate::models::VALID_FOREGROUND_SERVICE_TYPES {
let handle = setup_manager();
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "test".into(),
foreground_service_type: valid_type.into(),
};
let result = send_start_with_config(&handle, config, app.handle().clone()).await;
assert!(
result.is_ok(),
"start with valid type '{valid_type}' should succeed: {result:?}"
);
assert!(send_is_running(&handle).await);
send_stop(&handle).await.unwrap();
}
}
async fn send_get_state(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
) -> ServiceStatus {
let (tx, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::GetState { reply: tx })
.await
.unwrap();
rx.await.unwrap()
}
#[tokio::test]
async fn get_state_returns_idle_initially() {
let handle = setup_manager();
let status = send_get_state(&handle).await;
assert_eq!(status.state, ServiceLifecycle::Idle);
assert_eq!(status.last_error, None);
}
#[tokio::test]
async fn lifecycle_idle_to_running_to_stopped() {
let handle = setup_manager();
let app = tauri::test::mock_app();
let status = send_get_state(&handle).await;
assert_eq!(status.state, ServiceLifecycle::Idle);
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let status = send_get_state(&handle).await;
assert_eq!(status.state, ServiceLifecycle::Running);
send_stop(&handle).await.unwrap();
let status = send_get_state(&handle).await;
assert_eq!(status.state, ServiceLifecycle::Stopped);
assert_eq!(status.last_error, None);
}
#[tokio::test]
async fn lifecycle_init_failure_sets_stopped_with_error() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let status = send_get_state(&handle).await;
assert_eq!(status.state, ServiceLifecycle::Stopped);
assert!(
status.last_error.is_some(),
"last_error should be set on init failure"
);
assert!(
status.last_error.unwrap().contains("init error"),
"error should mention init error"
);
}
#[tokio::test]
async fn lifecycle_explicit_stop_sets_stopped_clears_error() {
let handle = setup_manager();
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let status = send_get_state(&handle).await;
assert_eq!(status.state, ServiceLifecycle::Running);
send_stop(&handle).await.unwrap();
let status = send_get_state(&handle).await;
assert_eq!(status.state, ServiceLifecycle::Stopped);
assert_eq!(
status.last_error, None,
"explicit stop should clear last_error"
);
}
#[tokio::test]
async fn restart_clears_stale_last_error() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let status = send_get_state(&handle).await;
assert_eq!(status.state, ServiceLifecycle::Stopped);
assert!(
status.last_error.is_some(),
"should have error after init failure"
);
let call_count = Arc::new(AtomicUsize::new(0));
let count_clone = call_count.clone();
let handle2 = setup_manager_with_factory(Box::new(move || {
let n = count_clone.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Box::new(FailingInitService)
} else {
Box::new(ImmediateSuccessService)
}
}));
let app2 = tauri::test::mock_app();
send_start(&handle2, app2.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let status = send_get_state(&handle2).await;
assert_eq!(status.state, ServiceLifecycle::Stopped);
assert!(
status.last_error.is_some(),
"first run should set last_error"
);
send_start(&handle2, app2.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let status = send_get_state(&handle2).await;
assert_eq!(
status.last_error, None,
"last_error must be cleared on restart, not stale from previous failure"
);
}
#[tokio::test]
async fn get_state_handle_method_returns_idle() {
let handle = setup_manager();
let status = handle.get_state().await;
assert_eq!(status.state, ServiceLifecycle::Idle);
assert_eq!(status.last_error, None);
}
#[tokio::test]
async fn stop_blocking_returns_success_from_running() {
let handle = Arc::new(setup_manager());
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
assert!(send_is_running(&handle).await);
let h = handle.clone();
let result = tokio::task::spawn_blocking(move || h.stop_blocking())
.await
.expect("spawn_blocking panicked");
assert!(
result.is_ok(),
"stop_blocking should succeed from running: {result:?}"
);
assert!(
!send_is_running(&handle).await,
"should not be running after stop_blocking"
);
}
#[tokio::test]
async fn stop_blocking_returns_not_running_when_idle() {
let handle = Arc::new(setup_manager());
let h = handle.clone();
let result = tokio::task::spawn_blocking(move || h.stop_blocking())
.await
.expect("spawn_blocking panicked");
assert!(
matches!(result, Err(ServiceError::NotRunning)),
"stop_blocking should return NotRunning when idle: {result:?}"
);
}
#[tokio::test]
async fn ios_processing_timeout_zero_passes_as_none() {
let mock = MockMobile::new();
let (cmd_tx, cmd_rx) = mpsc::channel(16);
let handle = ServiceManagerHandle::new(cmd_tx);
let factory: ServiceFactory<tauri::test::MockRuntime> =
Box::new(|| Box::new(BlockingService));
tokio::spawn(manager_loop(
cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, None,
));
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
assert_eq!(
timeout, None,
"ios_processing_safety_timeout_secs of 0.0 should pass None to mobile"
);
}
struct MockDesiredStateBackend {
saves: std::sync::Mutex<Vec<DesiredState>>,
}
impl MockDesiredStateBackend {
fn new() -> Arc<Self> {
Arc::new(Self {
saves: std::sync::Mutex::new(Vec::new()),
})
}
fn last_save(&self) -> Option<DesiredState> {
self.saves.lock().unwrap().last().cloned()
}
#[allow(dead_code)]
fn save_count(&self) -> usize {
self.saves.lock().unwrap().len()
}
#[allow(dead_code)]
fn saves(&self) -> std::sync::MutexGuard<'_, Vec<DesiredState>> {
self.saves.lock().unwrap()
}
}
impl DesiredStateBackend for MockDesiredStateBackend {
fn load(&self) -> Result<DesiredState, String> {
Ok(self
.saves
.lock()
.unwrap()
.last()
.cloned()
.unwrap_or_default())
}
fn save(&self, state: &DesiredState) -> Result<(), String> {
self.saves.lock().unwrap().push(state.clone());
Ok(())
}
fn clear(&self) -> Result<(), String> {
self.saves.lock().unwrap().clear();
Ok(())
}
}
async fn send_set_desired_running(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
desired: bool,
config: Option<StartConfig>,
) -> Result<(), ServiceError> {
let (tx, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::SetDesiredRunning {
desired,
config,
reply: tx,
})
.await
.unwrap();
rx.await.unwrap()
}
#[tokio::test]
async fn start_saves_desired_running_true() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "Syncing".into(),
..Default::default()
};
send_start_with_config(&handle, config, app.handle().clone())
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let last = backend
.last_save()
.expect("should have saved desired state");
assert!(
last.desired_running,
"desired_running should be true after start"
);
assert!(
last.last_start_config.is_some(),
"last_start_config should be set"
);
assert!(
last.last_start_epoch_ms.is_some(),
"last_start_epoch_ms should be set"
);
}
#[tokio::test]
async fn stop_saves_desired_running_false_with_cleared_recovery() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
{
let mut saves = backend.saves.lock().unwrap();
let last = saves.last_mut().unwrap();
last.recovery_pending = true;
last.recovery_reason = Some("boot".into());
last.restart_attempt = 3;
}
send_stop(&handle).await.unwrap();
let last = backend.last_save().expect("should have saved on stop");
assert!(
!last.desired_running,
"desired_running should be false after stop"
);
assert!(
last.last_start_config.is_none(),
"last_start_config should be cleared"
);
assert!(
last.last_start_epoch_ms.is_none(),
"last_start_epoch_ms should be cleared"
);
assert!(!last.recovery_pending, "recovery_pending should be cleared");
assert_eq!(
last.recovery_reason, None,
"recovery_reason should be cleared"
);
assert_eq!(last.restart_attempt, 0, "restart_attempt should be cleared");
}
#[tokio::test]
async fn set_desired_running_saves_without_affecting_is_running() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_backend(Some(backend.clone()));
assert!(!send_is_running(&handle).await);
let config = StartConfig {
service_label: "AutoRestart".into(),
..Default::default()
};
send_set_desired_running(&handle, true, Some(config.clone()))
.await
.unwrap();
assert!(
!send_is_running(&handle).await,
"SetDesiredRunning should not affect is_running"
);
let last = backend.last_save().expect("should have saved");
assert!(last.desired_running);
assert!(last.last_start_config.is_some());
send_set_desired_running(&handle, false, None)
.await
.unwrap();
assert!(!send_is_running(&handle).await);
let last = backend.last_save().expect("should have saved");
assert!(!last.desired_running);
}
#[tokio::test]
async fn no_backend_means_no_panic() {
let handle = setup_manager();
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
send_stop(&handle).await.unwrap();
send_set_desired_running(&handle, true, None).await.unwrap();
}
#[tokio::test]
async fn start_config_serialized_in_desired_state() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "CustomLabel".into(),
foreground_service_type: "specialUse".into(),
};
send_start_with_config(&handle, config, app.handle().clone())
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let last = backend.last_save().expect("should have saved");
let saved_config = last.last_start_config.expect("config should be set");
assert_eq!(saved_config["serviceLabel"], "CustomLabel");
assert_eq!(saved_config["foregroundServiceType"], "specialUse");
}
#[tokio::test]
async fn get_state_returns_desired_running_true_after_start() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let status = send_get_state(&handle).await;
assert_eq!(
status.desired_running,
Some(true),
"desired_running should be Some(true) after start with backend"
);
}
#[tokio::test]
async fn get_state_returns_desired_running_false_after_stop() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
send_stop(&handle).await.unwrap();
let status = send_get_state(&handle).await;
assert_eq!(
status.desired_running,
Some(false),
"desired_running should be Some(false) after stop with backend"
);
}
#[tokio::test]
async fn get_state_returns_none_fields_when_no_backend() {
let handle = setup_manager();
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let status = send_get_state(&handle).await;
assert_eq!(status.desired_running, None);
assert_eq!(status.native_state, None);
assert_eq!(status.last_start_config, None);
assert_eq!(status.last_heartbeat_at, None);
assert_eq!(status.restart_attempt, None);
assert_eq!(status.recovery_reason, None);
assert_eq!(status.platform_error, None);
}
#[tokio::test]
async fn get_state_returns_last_start_config_from_backend() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "TestService".into(),
foreground_service_type: "specialUse".into(),
};
send_start_with_config(&handle, config, app.handle().clone())
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let status = send_get_state(&handle).await;
let cfg = status
.last_start_config
.expect("last_start_config should be populated from backend");
assert_eq!(cfg.service_label, "TestService");
assert_eq!(cfg.foreground_service_type, "specialUse");
}
#[tokio::test]
async fn get_state_populates_all_desired_state_fields() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
{
let mut saves = backend.saves.lock().unwrap();
let last = saves.last_mut().unwrap();
last.last_native_state = Some("timeout".into());
last.last_platform_error = Some("FGS timed out".into());
last.restart_attempt = 3;
last.recovery_reason = Some("boot recovery".into());
last.last_heartbeat_epoch_ms = Some(1700000005000);
}
let status = send_get_state(&handle).await;
assert_eq!(status.desired_running, Some(true));
assert_eq!(status.native_state, Some(NativeState::Timeout));
assert_eq!(status.platform_error, Some("FGS timed out".into()));
assert_eq!(status.restart_attempt, Some(3));
assert_eq!(status.recovery_reason, Some("boot recovery".into()));
assert_eq!(status.last_heartbeat_at, Some(1700000005000));
}
#[tokio::test]
async fn get_state_returns_platform_mode() {
let handle = setup_manager();
let status = send_get_state(&handle).await;
assert_eq!(
status.platform_mode,
Some(LifecycleMode::DesktopInProcess),
"platform_mode should be populated even without backend"
);
}
async fn send_enable_auto_restart(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
config: Option<StartConfig>,
) -> Result<(), ServiceError> {
let (tx, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::EnableAutoRestart { config, reply: tx })
.await
.unwrap();
rx.await.unwrap()
}
async fn send_disable_auto_restart(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
let (tx, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::DisableAutoRestart { reply: tx })
.await
.unwrap();
rx.await.unwrap()
}
async fn send_get_desired_state(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
) -> Option<DesiredState> {
let (tx, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::GetDesiredState { reply: tx })
.await
.unwrap();
rx.await.unwrap()
}
#[tokio::test]
async fn enable_auto_restart_saves_true_without_starting() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_backend(Some(backend.clone()));
assert!(!send_is_running(&handle).await);
send_enable_auto_restart(&handle, None).await.unwrap();
assert!(
!send_is_running(&handle).await,
"enableAutoRestart should not start the service"
);
let ds = backend.last_save().expect("should have saved");
assert!(ds.desired_running, "desired_running should be true");
}
#[tokio::test]
async fn disable_auto_restart_saves_false_without_stopping() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
assert!(send_is_running(&handle).await);
send_disable_auto_restart(&handle).await.unwrap();
assert!(
send_is_running(&handle).await,
"disableAutoRestart should not stop the service"
);
let ds = backend.last_save().expect("should have saved");
assert!(!ds.desired_running, "desired_running should be false");
}
#[tokio::test]
async fn enable_auto_restart_with_config_stores_config() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_backend(Some(backend.clone()));
let config = StartConfig {
service_label: "MyService".into(),
foreground_service_type: "specialUse".into(),
};
send_enable_auto_restart(&handle, Some(config.clone()))
.await
.unwrap();
let ds = backend.last_save().expect("should have saved");
assert!(ds.desired_running);
let saved_config = ds.last_start_config.expect("config should be stored");
assert_eq!(saved_config["serviceLabel"], "MyService");
assert_eq!(saved_config["foregroundServiceType"], "specialUse");
assert!(
ds.last_start_epoch_ms.is_some(),
"should set last_start_epoch_ms"
);
}
#[tokio::test]
async fn disable_auto_restart_clears_recovery_fields() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_backend(Some(backend.clone()));
send_enable_auto_restart(&handle, None).await.unwrap();
{
let mut saves = backend.saves.lock().unwrap();
let last = saves.last_mut().unwrap();
last.recovery_pending = true;
last.recovery_reason = Some("boot".into());
last.restart_attempt = 5;
}
send_disable_auto_restart(&handle).await.unwrap();
let ds = backend.last_save().expect("should have saved");
assert!(!ds.desired_running);
assert!(!ds.recovery_pending, "recovery_pending should be cleared");
assert_eq!(
ds.recovery_reason, None,
"recovery_reason should be cleared"
);
assert_eq!(ds.restart_attempt, 0, "restart_attempt should be cleared");
}
#[tokio::test]
async fn get_desired_state_returns_current_state() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_backend(Some(backend.clone()));
let ds = send_get_desired_state(&handle).await;
assert!(ds.is_some());
assert!(!ds.unwrap().desired_running);
let config = StartConfig {
service_label: "Test".into(),
..Default::default()
};
send_enable_auto_restart(&handle, Some(config))
.await
.unwrap();
let ds = send_get_desired_state(&handle)
.await
.expect("should return state");
assert!(ds.desired_running);
assert!(ds.last_start_config.is_some());
}
#[tokio::test]
async fn get_desired_state_returns_none_without_backend() {
let handle = setup_manager();
let ds = send_get_desired_state(&handle).await;
assert!(
ds.is_none(),
"GetDesiredState should return None without a backend"
);
}
#[tokio::test]
async fn enable_disable_no_backend_no_panic() {
let handle = setup_manager();
send_enable_auto_restart(&handle, None).await.unwrap();
send_disable_auto_restart(&handle).await.unwrap();
}
#[tokio::test]
async fn get_state_stop_clears_start_config_and_recovery() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "Syncing".into(),
..Default::default()
};
send_start_with_config(&handle, config, app.handle().clone())
.await
.unwrap();
send_stop(&handle).await.unwrap();
let status = send_get_state(&handle).await;
assert_eq!(status.desired_running, Some(false));
assert_eq!(
status.last_start_config, None,
"last_start_config should be None after stop"
);
assert_eq!(
status.restart_attempt, None,
"restart_attempt should be None after stop"
);
assert_eq!(
status.recovery_reason, None,
"recovery_reason should be None after stop"
);
}
}