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_fg_type_against_allowlist, validate_foreground_service_type, LifecycleMode,
LifecycleState, LifecycleStatus, PluginEvent, ServiceContext, ServiceState as ServiceLifecycle,
ServiceStatus, StartConfig, StopReason, ValidationIssue,
};
use crate::notifier::{Notifier, NotifierPolicy, NotifySink};
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>,
ios_processing_ceiling_multiplier: Option<f64>,
) -> Result<(), ServiceError>;
fn stop_keepalive(&self) -> Result<(), ServiceError>;
fn scheduling_is_advisory(&self) -> bool {
false
}
fn get_android_service_state(
&self,
) -> Result<Option<crate::models::AndroidServiceState>, ServiceError> {
Ok(None)
}
fn enforces_foreground_service_type(&self) -> bool {
false
}
fn update_keepalive_type(&self, _foreground_service_type: &str) -> Result<(), ServiceError> {
Ok(())
}
fn show_incoming_call(
&self,
_call_id: &str,
_caller_name: &str,
_is_video: bool,
) -> Result<(), ServiceError> {
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn show_message_notification(
&self,
_notification_id: i32,
_chat_id: &str,
_message_id: &str,
_title: &str,
_body: &str,
_route_uri: &str,
) -> Result<(), ServiceError> {
Ok(())
}
fn cancel_incoming_call(&self, _call_id: &str) -> Result<(), ServiceError> {
Ok(())
}
fn set_call_audio_route(&self, _call_id: &str, _route: &str) -> Result<(), ServiceError> {
Ok(())
}
fn open_app_settings(&self) -> Result<(), ServiceError> {
Ok(())
}
fn mirror_desired_state(
&self,
_desired_running: bool,
_last_start_config: Option<&serde_json::Value>,
) -> Result<(), ServiceError> {
Ok(())
}
fn query_native_state(&self) -> Result<Option<NativeAuthority>, ServiceError> {
Ok(self
.get_android_service_state()?
.map(NativeAuthority::Android))
}
#[allow(dead_code)]
fn get_ios_native_state(&self) -> Result<Option<crate::models::IosNativeState>, ServiceError> {
Ok(None)
}
}
#[allow(dead_code)] pub(crate) enum NativeAuthority {
Android(crate::models::AndroidServiceState),
Ios(crate::models::IosNativeState),
}
#[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>>,
},
StopWithReason {
reason: StopReason,
reply: oneshot::Sender<Result<(), ServiceError>>,
},
UpdateForegroundServiceType {
foreground_service_type: String,
reply: oneshot::Sender<Result<(), ServiceError>>,
},
NotifyIncomingCall {
call_id: String,
caller_name: String,
is_video: bool,
reply: oneshot::Sender<Result<(), ServiceError>>,
},
NotifyMessage {
notification_id: i32,
chat_id: String,
message_id: String,
title: String,
body: String,
route_uri: String,
reply: oneshot::Sender<Result<(), ServiceError>>,
},
CancelIncomingCall {
call_id: String,
reply: oneshot::Sender<Result<(), ServiceError>>,
},
SetCallAudioRoute {
call_id: String,
route: String,
reply: oneshot::Sender<Result<(), ServiceError>>,
},
OpenAppSettings {
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>>,
},
NativeLifecycleEvent {
event: crate::models::NativeLifecycleEvent,
reply: oneshot::Sender<Result<(), ServiceError>>,
},
GetLifecycleStatus {
desktop_mode: Option<String>,
reply: oneshot::Sender<LifecycleStatus>,
},
ShutdownGracefully {
reply: oneshot::Sender<Result<(), ServiceError>>,
},
}
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 async fn update_foreground_service_type(
&self,
foreground_service_type: String,
) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::UpdateForegroundServiceType {
foreground_service_type,
reply,
})
.await
.map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
rx.await
.map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
}
pub async fn notify_incoming_call(
&self,
call_id: String,
caller_name: String,
is_video: bool,
) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::NotifyIncomingCall {
call_id,
caller_name,
is_video,
reply,
})
.await
.map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
rx.await
.map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
}
#[allow(clippy::too_many_arguments)]
pub async fn notify_message(
&self,
notification_id: i32,
chat_id: String,
message_id: String,
title: String,
body: String,
route_uri: String,
) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::NotifyMessage {
notification_id,
chat_id,
message_id,
title,
body,
route_uri,
reply,
})
.await
.map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
rx.await
.map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
}
pub async fn cancel_incoming_call(&self, call_id: String) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::CancelIncomingCall { call_id, reply })
.await
.map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
rx.await
.map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
}
pub async fn set_call_audio_route(
&self,
call_id: String,
route: String,
) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::SetCallAudioRoute {
call_id,
route,
reply,
})
.await
.map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
rx.await
.map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
}
pub async fn open_app_settings(&self) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::OpenAppSettings { 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 stop_with_reason(&self, reason: StopReason) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::StopWithReason { reason, reply })
.await
.map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
rx.await
.map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
}
pub async fn shutdown_gracefully(&self) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::ShutdownGracefully { 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_with_reason(&self, reason: StopReason) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.blocking_send(ManagerCommand::StopWithReason { reason, 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()
})
}
#[doc(hidden)]
pub async fn send_native_lifecycle_event(
&self,
event: crate::models::NativeLifecycleEvent,
) -> Result<(), ServiceError> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(ManagerCommand::NativeLifecycleEvent { event, reply })
.await
.map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
rx.await
.map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
}
}
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>>,
app: Option<AppHandle<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,
ios_processing_ceiling_multiplier: f64,
lifecycle_state: Arc<Mutex<ServiceLifecycle>>,
last_error: Arc<Mutex<Option<String>>>,
scheduling_degraded: Arc<Mutex<Option<String>>>,
terminal_reason: Arc<Mutex<Option<(u64, StopReason)>>>,
desired_state: Option<Arc<dyn DesiredStateBackend>>,
lifecycle_mode: LifecycleMode,
android_fg_service_types: Vec<String>,
android_validate_fg_type: bool,
notifier_policy: NotifierPolicy,
notify_sink: Option<Arc<dyn NotifySink>>,
}
#[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,
ios_processing_ceiling_multiplier: f64,
desired_state_backend: Option<Arc<dyn DesiredStateBackend>>,
android_fg_service_types: Vec<String>,
android_validate_fg_type: bool,
notifier_policy: NotifierPolicy,
notify_sink: Option<Arc<dyn NotifySink>>,
boot_app: Option<AppHandle<R>>,
consent_allows_auto_unlock: bool,
) {
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,
app: 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,
ios_processing_ceiling_multiplier,
lifecycle_state: Arc::new(Mutex::new(ServiceLifecycle::Idle)),
last_error: Arc::new(Mutex::new(None)),
scheduling_degraded: Arc::new(Mutex::new(None)),
terminal_reason: Arc::new(Mutex::new(None)),
desired_state: desired_state_backend,
lifecycle_mode,
android_fg_service_types,
android_validate_fg_type,
notifier_policy,
notify_sink,
};
if matches!(
state.lifecycle_mode,
LifecycleMode::DesktopInProcess | LifecycleMode::DesktopOsService
) && boot_app.is_some()
&& consent_allows_auto_unlock
{
if let Some(ref backend) = state.desired_state {
if let Ok(ds) = backend.load() {
if should_replay_on_boot(&ds) {
if let Some(app) = boot_app.as_ref() {
let config_result = ds
.last_start_config
.as_ref()
.map(|v| serde_json::from_value::<StartConfig>(v.clone()));
match config_result {
None => {
}
Some(Ok(config)) => match handle_start(&mut state, app.clone(), config)
{
Ok(()) => {
log::info!(
"BGS-05: replayed Start on boot (desired_running=true)"
);
}
Err(e) => {
log::warn!("BGS-05: boot Start-replay failed: {e}");
}
},
Some(Err(e)) => {
log::warn!(
"BGS-05: boot Start-replay skipped — \
malformed persisted last_start_config: {e}"
);
}
}
}
}
}
}
}
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::StopWithReason { reason, reply } => {
let _ = reply.send(handle_stop_with_reason(&mut state, reason));
}
ManagerCommand::ShutdownGracefully { reply } => {
let result = handle_shutdown_gracefully(&mut state).await;
let _ = reply.send(result);
}
ManagerCommand::UpdateForegroundServiceType {
foreground_service_type,
reply,
} => {
let _ = reply.send(handle_update_foreground_service_type(
&mut state,
foreground_service_type,
));
}
ManagerCommand::NotifyIncomingCall {
call_id,
caller_name,
is_video,
reply,
} => {
let _ = reply.send(handle_notify_incoming_call(
&state,
call_id,
caller_name,
is_video,
));
}
ManagerCommand::NotifyMessage {
notification_id,
chat_id,
message_id,
title,
body,
route_uri,
reply,
} => {
let _ = reply.send(handle_notify_message(
&state,
notification_id,
chat_id,
message_id,
title,
body,
route_uri,
));
}
ManagerCommand::CancelIncomingCall { call_id, reply } => {
let _ = reply.send(handle_cancel_incoming_call(&state, call_id));
}
ManagerCommand::SetCallAudioRoute {
call_id,
route,
reply,
} => {
let _ = reply.send(handle_set_call_audio_route(&state, call_id, route));
}
ManagerCommand::OpenAppSettings { reply } => {
let _ = reply.send(handle_open_app_settings(&state));
}
ManagerCommand::IsRunning { reply } => {
let _ = reply.send(reconcile_running_with_native(&state));
}
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));
}
ManagerCommand::NativeLifecycleEvent { event, reply } => {
let _ = reply.send(handle_native_lifecycle_event(&mut state, event));
}
ManagerCommand::GetLifecycleStatus {
desktop_mode,
reply,
} => {
let _ = reply.send(build_lifecycle_status(&state, desktop_mode.as_deref()));
}
}
}
}
fn handle_start<R: Runtime>(
state: &mut ServiceState<R>,
app: AppHandle<R>,
config: StartConfig,
) -> Result<(), ServiceError> {
log::info!("handle_start: entry (label={})", config.service_label);
let mut guard = state.token.lock().unwrap();
if guard.is_some() {
return Err(ServiceError::AlreadyRunning);
}
if state
.mobile
.as_ref()
.is_some_and(|m| m.enforces_foreground_service_type())
{
validate_foreground_service_type(&config.foreground_service_type)?;
}
validate_fg_type_against_allowlist(
&config.foreground_service_type,
&state.android_fg_service_types,
state.android_validate_fg_type,
)?;
state.app = Some(app.clone());
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;
*state.scheduling_degraded.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),
Some(state.ios_processing_ceiling_multiplier),
) {
if mobile.scheduling_is_advisory() {
log::warn!(
"start_keepalive: advisory scheduling unavailable ({e}); \
starting Core foreground-only (degraded)"
);
*state.scheduling_degraded.lock().unwrap() = Some(e.to_string());
let _ = app.emit(
"background-service:state-degraded",
serde_json::json!({
"degraded": true,
"reason": "scheduling_degraded_foreground_only",
"error": e.to_string(),
}),
);
} else {
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 terminal_reason_ref = state.terminal_reason.clone();
let desired_state_ref = state.desired_state.clone();
let mobile_ref = state.mobile.clone();
let lifecycle_mode_ref = state.lifecycle_mode;
let mut service = (state.factory)();
let ctx = ServiceContext {
notifier: Notifier { app: app.clone() },
app: app.clone(),
shutdown,
#[cfg(mobile)]
service_label: config.service_label.clone(),
#[cfg(mobile)]
foreground_service_type: config.foreground_service_type.clone(),
};
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(),
},
);
{
let mut tok = token_ref.lock().unwrap();
if gen_ref.load(Ordering::Acquire) == my_gen {
tok.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;
let explicit_reason: Option<StopReason> = {
let tr = terminal_reason_ref.lock().unwrap();
tr.and_then(|(gen, reason)| if gen == my_gen { Some(reason) } else { None })
};
if let Some(r) = explicit_reason {
let _ = app.emit(
"background-service://event",
PluginEvent::Stopped { reason: r },
);
} else {
match &result {
Ok(()) => {
let _ = app.emit(
"background-service://event",
PluginEvent::Stopped {
reason: StopReason::TaskCompleted,
},
);
}
Err(e) => {
let _ = app.emit(
"background-service://event",
PluginEvent::Error {
message: e.to_string(),
},
);
}
}
}
if explicit_reason.is_none() && result.is_ok() {
save_desired_running_via(
&desired_state_ref,
&mobile_ref,
lifecycle_mode_ref,
false,
None,
);
}
if let Some(cb) = captured_callback {
cb(result.is_ok());
}
{
let mut tok = token_ref.lock().unwrap();
if gen_ref.load(Ordering::Acquire) == my_gen {
tok.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(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> {
handle_stop_with_reason(state, StopReason::UserStop)
}
async fn handle_shutdown_gracefully<R: Runtime>(
state: &mut ServiceState<R>,
) -> Result<(), ServiceError> {
let Some(app) = state.app.clone() else {
return Ok(());
};
let mut service = (state.factory)();
let ctx = ServiceContext {
notifier: Notifier { app: app.clone() },
app,
shutdown: CancellationToken::new(),
#[cfg(mobile)]
service_label: String::new(),
#[cfg(mobile)]
foreground_service_type: String::new(),
};
service.shutdown_gracefully(&ctx).await
}
fn handle_stop_with_reason<R: Runtime>(
state: &mut ServiceState<R>,
reason: StopReason,
) -> Result<(), ServiceError> {
let mut guard = state.token.lock().unwrap();
match guard.take() {
Some(token) => {
*state.terminal_reason.lock().unwrap() =
Some((state.generation.load(Ordering::Acquire), reason));
token.cancel();
state.is_running.store(false, Ordering::SeqCst);
*state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Stopped;
*state.last_error.lock().unwrap() = None;
*state.scheduling_degraded.lock().unwrap() = None;
drop(guard);
if should_stop_keepalive(reason) {
if let Some(ref mobile) = state.mobile {
if let Err(e) = mobile.stop_keepalive() {
log::warn!("stop_keepalive failed: {e}");
}
}
}
if should_clear_desired_state(reason) {
save_desired_running(state, false, None);
} else if should_reconcile_resubmit(reason) {
if let Some(ds) = state.desired_state.as_ref().and_then(|b| b.load().ok()) {
if ds.desired_running {
log::warn!(
"background service degraded after platform timeout; \
desired_running=true — re-submitting native scheduling"
);
let config: Option<StartConfig> = ds
.last_start_config
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
mirror_desired_to_native(state, true, config.as_ref());
}
}
}
if state.notifier_policy.on_timeout && should_notify_timeout(reason) {
if let Some(ref sink) = state.notify_sink {
sink.notify(
"bg-timeout",
"Background service paused",
"The OS paused background delivery; it will resume automatically.",
);
}
}
Ok(())
}
None => Err(ServiceError::NotRunning),
}
}
fn handle_update_foreground_service_type<R: Runtime>(
state: &mut ServiceState<R>,
foreground_service_type: String,
) -> Result<(), ServiceError> {
let running = state.token.lock().unwrap().is_some();
if !running {
return Err(ServiceError::NotRunning);
}
let enforces = state
.mobile
.as_ref()
.is_some_and(|m| m.enforces_foreground_service_type());
if enforces {
validate_foreground_service_type(&foreground_service_type)?;
}
validate_fg_type_against_allowlist(
&foreground_service_type,
&state.android_fg_service_types,
state.android_validate_fg_type,
)?;
if enforces {
if let Some(ref mobile) = state.mobile {
mobile.update_keepalive_type(&foreground_service_type)?;
}
}
Ok(())
}
fn handle_notify_incoming_call<R: Runtime>(
state: &ServiceState<R>,
call_id: String,
caller_name: String,
is_video: bool,
) -> Result<(), ServiceError> {
if let Some(ref mobile) = state.mobile {
mobile.show_incoming_call(&call_id, &caller_name, is_video)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn handle_notify_message<R: Runtime>(
state: &ServiceState<R>,
notification_id: i32,
chat_id: String,
message_id: String,
title: String,
body: String,
route_uri: String,
) -> Result<(), ServiceError> {
if let Some(ref mobile) = state.mobile {
mobile.show_message_notification(
notification_id,
&chat_id,
&message_id,
&title,
&body,
&route_uri,
)?;
}
Ok(())
}
fn handle_cancel_incoming_call<R: Runtime>(
state: &ServiceState<R>,
call_id: String,
) -> Result<(), ServiceError> {
if let Some(ref mobile) = state.mobile {
mobile.cancel_incoming_call(&call_id)?;
}
Ok(())
}
fn handle_set_call_audio_route<R: Runtime>(
state: &ServiceState<R>,
call_id: String,
route: String,
) -> Result<(), ServiceError> {
if let Some(ref mobile) = state.mobile {
mobile.set_call_audio_route(&call_id, &route)?;
}
Ok(())
}
fn handle_open_app_settings<R: Runtime>(state: &ServiceState<R>) -> Result<(), ServiceError> {
if let Some(ref mobile) = state.mobile {
mobile.open_app_settings()?;
}
Ok(())
}
fn handle_native_lifecycle_event<R: Runtime>(
state: &mut ServiceState<R>,
event: crate::models::NativeLifecycleEvent,
) -> Result<(), ServiceError> {
if event.is_recovery_acceptance() {
if state.notifier_policy.on_recovery {
if let Some(ref sink) = state.notify_sink {
sink.notify(
"bg-recovery",
"Background service restored",
"Background delivery restored.",
);
}
}
return Ok(());
}
handle_stop_with_reason(state, event.to_stop_reason())
}
fn should_replay_on_boot(ds: &crate::desired_state::DesiredState) -> bool {
ds.desired_running
}
fn should_clear_desired_state(reason: StopReason) -> bool {
matches!(
reason,
StopReason::UserStop
| StopReason::AppStop
| StopReason::NativeNotificationStop
| StopReason::TaskCompleted
)
}
fn should_notify_timeout(reason: StopReason) -> bool {
!should_clear_desired_state(reason)
&& matches!(
reason,
StopReason::PlatformTimeout | StopReason::PlatformExpiration
)
}
fn should_stop_keepalive(reason: StopReason) -> bool {
!matches!(
reason,
StopReason::PlatformExpiration | StopReason::PlatformTimeout | StopReason::ProcessExit
)
}
fn should_reconcile_resubmit(reason: StopReason) -> bool {
matches!(reason, StopReason::PlatformTimeout)
}
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 save_desired_running_via(
desired_state: &Option<std::sync::Arc<dyn DesiredStateBackend>>,
mobile: &Option<std::sync::Arc<dyn MobileKeepalive>>,
lifecycle_mode: LifecycleMode,
desired: bool,
config: Option<&StartConfig>,
) {
if let Some(backend) = desired_state {
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 from spawned task: {e}");
}
}
if matches!(lifecycle_mode, LifecycleMode::IosBgTaskScheduler) {
if let Some(m) = mobile {
let config_value = config.map(|c| serde_json::to_value(c).unwrap_or_default());
if let Err(e) = m.mirror_desired_state(desired, config_value.as_ref()) {
log::warn!("failed to mirror desired state to native from spawned task: {e}");
}
}
}
}
fn mirror_desired_to_native<R: Runtime>(
state: &ServiceState<R>,
desired: bool,
config: Option<&StartConfig>,
) {
let Some(ref mobile) = state.mobile else {
return;
};
let config_value = config.map(|c| serde_json::to_value(c).unwrap_or_default());
if let Err(e) = mobile.mirror_desired_state(desired, config_value.as_ref()) {
log::warn!("failed to mirror desired state to native: {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());
mirror_desired_to_native(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());
mirror_desired_to_native(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);
mirror_desired_to_native(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())
}
fn reconcile_running_with_native<R: Runtime>(state: &ServiceState<R>) -> bool {
let rust_running = state.is_running.load(Ordering::Acquire);
let Some(authority) = state
.mobile
.as_ref()
.and_then(|m| m.query_native_state().ok().flatten())
else {
return rust_running;
};
let native = match authority {
NativeAuthority::Android(ns) => ns,
NativeAuthority::Ios(_) => return rust_running,
};
if native.native_running == rust_running {
return rust_running;
}
log::warn!(
"running-state diverged from native authority \
(rust_running={rust_running}, native_running={}, durable_state={}); \
converging to native",
native.native_running,
native.durable_state,
);
state
.is_running
.store(native.native_running, Ordering::Release);
*state.lifecycle_state.lock().unwrap() = if native.native_running {
crate::models::ServiceState::Running
} else {
crate::models::ServiceState::Stopped
};
if !native.native_running {
if let Some(token) = state.token.lock().unwrap().take() {
token.cancel();
*state.terminal_reason.lock().unwrap() = Some((
state.generation.load(Ordering::Acquire),
crate::models::StopReason::NativeNotificationStop,
));
}
}
native.native_running
}
fn build_lifecycle_status<R: Runtime>(
state: &ServiceState<R>,
desktop_mode: Option<&str>,
) -> LifecycleStatus {
let last_error = state.last_error.lock().unwrap().clone();
let desired = state.desired_state.as_ref().and_then(|b| b.load().ok());
let desired_running = desired.as_ref().is_some_and(|d| d.desired_running);
let recovery_enabled = desired_running;
let recovery_reason = desired.as_ref().and_then(|d| d.recovery_reason.clone());
let last_start_config = desired
.as_ref()
.and_then(|d| d.last_start_config.clone())
.and_then(|v| serde_json::from_value(v).ok());
let mut last_platform_state = desired.as_ref().and_then(|d| d.last_native_state.clone());
let mut last_platform_error = desired.as_ref().and_then(|d| d.last_platform_error.clone());
let authority = state
.mobile
.as_ref()
.and_then(|m| m.query_native_state().ok().flatten());
let native_state = match &authority {
Some(NativeAuthority::Android(ns)) => Some(ns.clone()),
_ => None,
};
let ios_native = match &authority {
Some(NativeAuthority::Ios(s)) => Some(s.clone()),
_ => None,
};
let (mut native_running, mut native_foreground) = match &native_state {
Some(ns) => (Some(ns.native_running), Some(ns.native_foreground)),
None => (None, None),
};
let rust_running = state.is_running.load(Ordering::Acquire);
let (adopted, degraded, degraded_reason, should_emit) = match &native_state {
Some(ns) if ns.native_running && !rust_running => {
state.is_running.store(true, Ordering::Release);
*state.lifecycle_state.lock().unwrap() = crate::models::ServiceState::Running;
(Some(true), Some(false), None, false)
}
Some(ns) if !ns.native_running && rust_running => {
log::warn!(
"running-state diverged from native authority \
(rust_running=true, native_running=false, durable_state={}); \
converging to native (auto-heal)",
ns.durable_state,
);
state.is_running.store(false, Ordering::Release);
*state.lifecycle_state.lock().unwrap() = crate::models::ServiceState::Stopped;
(
None,
Some(true),
Some("native_stopped_rust_running".into()),
true,
)
}
Some(ns) if ns.native_running && rust_running => {
(Some(false), Some(false), None, false)
}
Some(_ns) => {
(Some(false), Some(false), None, false)
}
None => {
(None, None, None, false)
}
};
let (mut degraded, mut degraded_reason) = if degraded == Some(true) {
(degraded, degraded_reason)
} else if let Some(ns) = &native_state {
if ns.durable_state == "timeout" {
let reason = if rust_running {
"native_timeout"
} else {
"stale_timeout"
};
(Some(true), Some(reason.into()))
} else {
(degraded, degraded_reason)
}
} else {
(degraded, degraded_reason)
};
let recovery_pending = desired.as_ref().is_some_and(|d| d.recovery_pending)
|| native_state.as_ref().is_some_and(|ns| ns.recovery_pending);
if let Some(ios) = &ios_native {
native_running = Some(ios.active_task_kind.is_some());
native_foreground = Some(false);
let phase = if ios.active_task_kind.is_some() {
"running"
} else if ios.pending_task.is_some() {
"pendingBgTask"
} else if ios.desired_running {
"waitingForBgTask"
} else {
"stopped"
};
last_platform_state = Some(phase.to_string());
let schedule_error = ios
.last_refresh_error
.as_ref()
.or(ios.last_processing_error.as_ref());
if let Some(err) = schedule_error {
last_platform_error = Some(err.clone());
degraded = Some(true);
degraded_reason = Some("ios_scheduling_error".to_string());
} else if ios.desired_running && !ios.in_budget {
degraded = Some(true);
degraded_reason = Some("ios_out_of_budget".to_string());
} else {
degraded = Some(false);
degraded_reason = None;
}
}
if degraded != Some(true) {
if let Some(reason) = state.scheduling_degraded.lock().unwrap().as_ref() {
last_platform_error = Some(reason.clone());
degraded = Some(true);
degraded_reason = Some("scheduling_degraded_foreground_only".to_string());
}
}
if should_emit
|| (degraded == Some(true)
&& matches!(
degraded_reason.as_deref(),
Some("native_timeout")
| Some("stale_timeout")
| Some("ios_scheduling_error")
| Some("ios_out_of_budget")
| Some("scheduling_degraded_foreground_only")
))
{
if let Some(ref app) = state.app {
let _ = app.emit(
"background-service:state-degraded",
serde_json::json!({
"degraded": true,
"reason": degraded_reason,
"native_running": native_running,
"rust_running": rust_running,
}),
);
}
}
let mut lifecycle_state: LifecycleState = (*state.lifecycle_state.lock().unwrap()).into();
if let Some(ns) = &native_state {
match ns.durable_state.as_str() {
"setup_idle" => lifecycle_state = LifecycleState::SetupIdle,
"locked_idle" => lifecycle_state = LifecycleState::LockedIdle,
_ => {}
}
}
if let Some(ios) = &ios_native {
if ios.active_task_kind.is_none() && !ios.desired_running {
lifecycle_state = LifecycleState::Stopped;
}
}
let data_dir = native_state.as_ref().map(|ns| ns.data_dir.clone());
let (platform, detected_mode) =
crate::capabilities::CapabilityProvider::detect_platform(desktop_mode);
let capabilities = crate::capabilities::CapabilityProvider::capabilities(
platform,
detected_mode,
false,
);
let report = crate::validator::SetupValidator::validate(platform);
let mut issues: Vec<ValidationIssue> = report
.errors
.into_iter()
.map(|i| ValidationIssue {
severity: crate::models::Severity::Error,
code: i.code,
message: i.message,
fix: i.fix,
platform,
})
.collect();
issues.extend(report.warnings.into_iter().map(|i| ValidationIssue {
severity: crate::models::Severity::Warning,
code: i.code,
message: i.message,
fix: i.fix,
platform,
}));
LifecycleStatus {
state: lifecycle_state,
desired_running,
recovery_enabled,
recovery_pending,
recovery_reason,
last_start_config,
last_platform_state,
last_platform_error,
last_error,
platform,
capabilities,
issues,
native_running,
native_foreground,
adopted,
degraded,
degraded_reason,
data_dir,
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
use crate::desired_state::DesiredState;
use crate::models::{NativeLifecycleEvent, NativeState};
use async_trait::async_trait;
use std::sync::atomic::{AtomicI8, AtomicU8, AtomicUsize};
use tauri::Listener;
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>>,
last_processing_ceiling_multiplier: std::sync::Mutex<Option<f64>>,
mirror_calls: std::sync::Mutex<Vec<(bool, Option<serde_json::Value>)>>,
advisory_scheduling: bool,
enforces_fst: bool,
update_type_calls: std::sync::Mutex<Vec<String>>,
}
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),
last_processing_ceiling_multiplier: std::sync::Mutex::new(None),
mirror_calls: std::sync::Mutex::new(Vec::new()),
advisory_scheduling: false,
enforces_fst: false,
update_type_calls: std::sync::Mutex::new(Vec::new()),
})
}
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),
last_processing_ceiling_multiplier: std::sync::Mutex::new(None),
mirror_calls: std::sync::Mutex::new(Vec::new()),
advisory_scheduling: false,
enforces_fst: false,
update_type_calls: std::sync::Mutex::new(Vec::new()),
})
}
fn new_failing_advisory() -> 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),
last_processing_ceiling_multiplier: std::sync::Mutex::new(None),
mirror_calls: std::sync::Mutex::new(Vec::new()),
advisory_scheduling: true,
enforces_fst: false,
update_type_calls: std::sync::Mutex::new(Vec::new()),
})
}
fn new_enforcing() -> 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),
last_processing_ceiling_multiplier: std::sync::Mutex::new(None),
mirror_calls: std::sync::Mutex::new(Vec::new()),
advisory_scheduling: false,
enforces_fst: true,
update_type_calls: std::sync::Mutex::new(Vec::new()),
})
}
fn update_type_calls(&self) -> Vec<String> {
self.update_type_calls.lock().unwrap().clone()
}
}
#[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>,
ios_processing_ceiling_multiplier: Option<f64>,
) -> 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;
*mock.last_processing_ceiling_multiplier.lock().unwrap() =
ios_processing_ceiling_multiplier;
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>,
ios_processing_ceiling_multiplier: Option<f64>,
) -> 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,
ios_processing_ceiling_multiplier,
)
}
fn stop_keepalive(&self) -> Result<(), ServiceError> {
self.stop_called.fetch_add(1, Ordering::Release);
Ok(())
}
fn scheduling_is_advisory(&self) -> bool {
self.advisory_scheduling
}
fn enforces_foreground_service_type(&self) -> bool {
self.enforces_fst
}
fn update_keepalive_type(&self, foreground_service_type: &str) -> Result<(), ServiceError> {
self.update_type_calls
.lock()
.unwrap()
.push(foreground_service_type.to_string());
Ok(())
}
fn mirror_desired_state(
&self,
desired_running: bool,
last_start_config: Option<&serde_json::Value>,
) -> Result<(), ServiceError> {
self.mirror_calls
.lock()
.unwrap()
.push((desired_running, last_start_config.cloned()));
Ok(())
}
}
struct RecordingSink {
calls: std::sync::Mutex<Vec<(String, String, String)>>,
}
impl RecordingSink {
fn new() -> Arc<Self> {
Arc::new(Self {
calls: std::sync::Mutex::new(Vec::new()),
})
}
fn calls(&self) -> Vec<(String, String, String)> {
self.calls.lock().unwrap().clone()
}
}
impl NotifySink for RecordingSink {
fn notify(&self, id: &str, title: &str, body: &str) {
self.calls
.lock()
.unwrap()
.push((id.into(), title.into(), body.into()));
}
}
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> {
setup_manager_with_backend_and_allowlist(backend, vec!["remoteMessaging".into()], true)
}
fn setup_manager_with_backend_and_allowlist(
backend: Option<Arc<dyn DesiredStateBackend>>,
android_fg_service_types: Vec<String>,
android_validate_fg_type: bool,
) -> 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,
4.0,
backend,
android_fg_service_types,
android_validate_fg_type,
NotifierPolicy::default(),
None,
None,
false,
));
handle
}
fn setup_manager_with_sink(
policy: NotifierPolicy,
sink: Arc<dyn NotifySink>,
) -> ServiceManagerHandle<tauri::test::MockRuntime> {
setup_manager_with_policy_and_sink(policy, Some(sink))
}
fn setup_manager_with_policy_and_sink(
policy: NotifierPolicy,
sink: Option<Arc<dyn NotifySink>>,
) -> 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,
4.0,
None,
vec!["remoteMessaging".into()],
true,
policy,
sink,
None,
false,
));
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> {
setup_manager_with_factory_backend_and_boot_app(factory, backend, None, false)
}
fn setup_manager_with_factory_backend_and_boot_app(
factory: ServiceFactory<tauri::test::MockRuntime>,
backend: Option<Arc<dyn DesiredStateBackend>>,
boot_app: Option<AppHandle<tauri::test::MockRuntime>>,
consent_allows_auto_unlock: bool,
) -> 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,
4.0,
backend,
vec!["remoteMessaging".into()],
true,
NotifierPolicy::default(),
None,
boot_app,
consent_allows_auto_unlock,
));
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(BlockingService)
}
}));
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_error_recorded(&handle).await;
let result = send_start(&handle, app.handle().clone()).await;
assert!(
result.is_ok(),
"second start should succeed after init failure: {result:?}"
);
wait_until_running(&handle).await;
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 advisory_scheduling_failure_starts_core_degraded() {
let mock = MockMobile::new_failing_advisory();
let handle = setup_manager();
let app = tauri::test::mock_app();
let event_received = Arc::new(AtomicBool::new(false));
let event_received_clone = event_received.clone();
let _listener = app
.handle()
.listen("background-service:state-degraded", move |_event| {
event_received_clone.store(true, Ordering::Release);
});
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
assert_eq!(
mock.start_called.load(Ordering::Acquire),
1,
"start_keepalive should still be attempted once",
);
assert!(
send_is_running(&handle).await,
"Core must start despite advisory scheduling failure (no rollback)",
);
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(
status.degraded,
Some(true),
"advisory scheduling failure must report degraded",
);
assert_eq!(
status.degraded_reason,
Some("scheduling_degraded_foreground_only".into()),
"degraded reason must name the foreground-only fallback",
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
event_received.load(Ordering::Acquire),
"a non-fatal scheduling-degraded warning must be emitted",
);
}
#[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>,
_ios_processing_ceiling_multiplier: Option<f64>,
) -> 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,
4.0,
None,
vec!["remoteMessaging".into()],
true,
NotifierPolicy::default(),
None,
None,
false,
));
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,
4.0,
None,
vec!["remoteMessaging".into()],
true,
NotifierPolicy::default(),
None,
None,
false,
));
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"
);
}
#[tokio::test]
async fn ios_processing_ceiling_multiplier_default_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,
0.0,
15.0,
15.0,
false,
false,
4.0,
None,
vec!["remoteMessaging".into()],
true,
NotifierPolicy::default(),
None,
None,
false,
));
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
let multiplier = *mock.last_processing_ceiling_multiplier.lock().unwrap();
assert_eq!(
multiplier,
Some(4.0),
"default ios_processing_ceiling_multiplier should be passed to mobile"
);
}
#[tokio::test]
async fn ios_processing_ceiling_multiplier_override_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,
0.0,
15.0,
15.0,
false,
false,
6.0,
None,
vec!["remoteMessaging".into()],
true,
NotifierPolicy::default(),
None,
None,
false,
));
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
let multiplier = *mock.last_processing_ceiling_multiplier.lock().unwrap();
assert_eq!(
multiplier,
Some(6.0),
"overridden ios_processing_ceiling_multiplier 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_with_backend_and_allowlist(None, vec![], false);
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() {
let all_types: Vec<String> = crate::models::VALID_FOREGROUND_SERVICE_TYPES
.iter()
.map(|s| (*s).to_string())
.collect();
for &valid_type in crate::models::VALID_FOREGROUND_SERVICE_TYPES {
let handle = setup_manager_with_backend_and_allowlist(None, all_types.clone(), true);
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();
}
}
#[tokio::test]
async fn allowlist_rejected_type_returns_platform_error() {
let handle =
setup_manager_with_backend_and_allowlist(None, vec!["remoteMessaging".into()], true);
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "test".into(),
foreground_service_type: "specialUse".into(),
};
let result = send_start_with_config(&handle, config, app.handle().clone()).await;
assert!(
matches!(result, Err(ServiceError::Platform(ref msg)) if msg.contains("not allowed")),
"disallowed type should return Platform error: {result:?}"
);
assert!(
!send_is_running(&handle).await,
"should not be running after allowlist rejection"
);
}
#[tokio::test]
async fn allowlist_allowed_type_succeeds() {
let handle =
setup_manager_with_backend_and_allowlist(None, vec!["remoteMessaging".into()], true);
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "test".into(),
foreground_service_type: "remoteMessaging".into(),
};
let result = send_start_with_config(&handle, config, app.handle().clone()).await;
assert!(result.is_ok(), "allowed type should succeed: {result:?}");
assert!(send_is_running(&handle).await);
send_stop(&handle).await.unwrap();
}
#[tokio::test]
async fn update_foreground_service_type_not_running_returns_not_running() {
let handle = setup_manager_with_backend_and_allowlist(
None,
vec!["remoteMessaging".into(), "phoneCall".into()],
true,
);
let result = handle
.update_foreground_service_type("phoneCall".into())
.await;
assert!(
matches!(result, Err(ServiceError::NotRunning)),
"update with no running service should be NotRunning: {result:?}"
);
}
#[tokio::test]
async fn update_foreground_service_type_allowlisted_phonecall_succeeds() {
let handle = setup_manager_with_backend_and_allowlist(
None,
vec!["remoteMessaging".into(), "phoneCall".into()],
true,
);
let app = tauri::test::mock_app();
send_start_with_config(
&handle,
StartConfig {
service_label: "call".into(),
foreground_service_type: "remoteMessaging".into(),
},
app.handle().clone(),
)
.await
.unwrap();
let result = handle
.update_foreground_service_type("phoneCall".into())
.await;
assert!(
result.is_ok(),
"allowlisted phoneCall update should succeed: {result:?}"
);
send_stop(&handle).await.unwrap();
}
#[tokio::test]
async fn update_foreground_service_type_rejected_by_allowlist() {
let handle =
setup_manager_with_backend_and_allowlist(None, vec!["remoteMessaging".into()], true);
let app = tauri::test::mock_app();
send_start_with_config(
&handle,
StartConfig {
service_label: "call".into(),
foreground_service_type: "remoteMessaging".into(),
},
app.handle().clone(),
)
.await
.unwrap();
let result = handle
.update_foreground_service_type("phoneCall".into())
.await;
assert!(
matches!(result, Err(ServiceError::Platform(ref msg)) if msg.contains("not allowed")),
"non-allowlisted phoneCall update should be rejected: {result:?}"
);
assert!(send_is_running(&handle).await);
send_stop(&handle).await.unwrap();
}
#[tokio::test]
async fn ios_like_bridge_validates_no_fgs_type_on_start() {
let handle = setup_manager_with_backend_and_allowlist(None, vec![], false);
let mock = MockMobile::new(); send_set_mobile(&handle, mock).await;
let app = tauri::test::mock_app();
let result = send_start_with_config(
&handle,
StartConfig {
service_label: "call".into(),
foreground_service_type: "iosBackgroundDelivery".into(),
},
app.handle().clone(),
)
.await;
assert!(
result.is_ok(),
"iOS start with a non-Android FGS type should succeed (M6): {result:?}"
);
send_stop(&handle).await.unwrap();
}
#[tokio::test]
async fn android_like_bridge_validates_fgs_type_on_start() {
let handle = setup_manager_with_backend_and_allowlist(None, vec![], false);
let mock = MockMobile::new_enforcing(); send_set_mobile(&handle, mock).await;
let app = tauri::test::mock_app();
let result = send_start_with_config(
&handle,
StartConfig {
service_label: "call".into(),
foreground_service_type: "notAValidType".into(),
},
app.handle().clone(),
)
.await;
assert!(
matches!(result, Err(ServiceError::Platform(ref m)) if m.contains("invalid foreground_service_type")),
"Android start with an invalid FGS type should be rejected (M6): {result:?}"
);
}
#[tokio::test]
async fn ios_like_bridge_emits_zero_update_foreground_service_type() {
let handle = setup_manager_with_backend_and_allowlist(None, vec![], false);
let mock = MockMobile::new(); send_set_mobile(&handle, mock.clone()).await;
let app = tauri::test::mock_app();
send_start_with_config(
&handle,
StartConfig {
service_label: "call".into(),
foreground_service_type: "remoteMessaging".into(),
},
app.handle().clone(),
)
.await
.unwrap();
let answer = handle
.update_foreground_service_type("phoneCall".into())
.await;
let end = handle
.update_foreground_service_type("remoteMessaging".into())
.await;
assert!(
answer.is_ok(),
"iOS FGS swap should be a no-op Ok: {answer:?}"
);
assert!(end.is_ok(), "iOS FGS revert should be a no-op Ok: {end:?}");
assert!(
mock.update_type_calls().is_empty(),
"iOS must fire zero updateForegroundServiceType calls (M5), got: {:?}",
mock.update_type_calls()
);
send_stop(&handle).await.unwrap();
}
#[tokio::test]
async fn android_like_bridge_swaps_fgs_type() {
let handle = setup_manager_with_backend_and_allowlist(
None,
vec!["remoteMessaging".into(), "phoneCall".into()],
true,
);
let mock = MockMobile::new_enforcing(); send_set_mobile(&handle, mock.clone()).await;
let app = tauri::test::mock_app();
send_start_with_config(
&handle,
StartConfig {
service_label: "call".into(),
foreground_service_type: "remoteMessaging".into(),
},
app.handle().clone(),
)
.await
.unwrap();
handle
.update_foreground_service_type("phoneCall".into())
.await
.unwrap();
handle
.update_foreground_service_type("remoteMessaging".into())
.await
.unwrap();
assert_eq!(
mock.update_type_calls(),
vec!["phoneCall".to_string(), "remoteMessaging".to_string()],
"Android must swap the FGS type via the native handler (M5)"
);
send_stop(&handle).await.unwrap();
}
#[tokio::test]
async fn allowlist_empty_type_rejected() {
let handle = setup_manager_with_backend_and_allowlist(None, vec!["dataSync".into()], true);
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "test".into(),
foreground_service_type: "".into(),
};
let result = send_start_with_config(&handle, config, app.handle().clone()).await;
assert!(
matches!(result, Err(ServiceError::Platform(ref msg)) if msg.contains("must not be empty")),
"empty type should be rejected: {result:?}"
);
}
#[tokio::test]
async fn allowlist_case_insensitive_match() {
let handle =
setup_manager_with_backend_and_allowlist(None, vec!["remoteMessaging".into()], true);
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "test".into(),
foreground_service_type: "RemoteMessaging".into(),
};
let result = send_start_with_config(&handle, config, app.handle().clone()).await;
assert!(
result.is_ok(),
"case-insensitive match should succeed: {result:?}"
);
assert!(send_is_running(&handle).await);
send_stop(&handle).await.unwrap();
}
#[tokio::test]
async fn allowlist_validation_disabled_accepts_any_type() {
let handle = setup_manager_with_backend_and_allowlist(None, vec!["dataSync".into()], false);
let app = tauri::test::mock_app();
let config = StartConfig {
service_label: "test".into(),
foreground_service_type: "specialUse".into(),
};
let result = send_start_with_config(&handle, config, app.handle().clone()).await;
assert!(
result.is_ok(),
"validation disabled should accept any type: {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()
}
async fn wait_until_running(handle: &ServiceManagerHandle<tauri::test::MockRuntime>) {
for _ in 0..200 {
if send_get_state(handle).await.state == ServiceLifecycle::Running {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
panic!("service did not reach Running within ~1s");
}
async fn wait_until_error_recorded(handle: &ServiceManagerHandle<tauri::test::MockRuntime>) {
for _ in 0..200 {
if send_get_state(handle).await.last_error.is_some() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
panic!("service error was not recorded within ~1s");
}
#[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();
wait_until_running(&handle).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();
wait_until_error_recorded(&handle).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();
wait_until_running(&handle).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();
wait_until_error_recorded(&handle).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();
wait_until_error_recorded(&handle2).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();
wait_until_stopped(&handle2, 1000).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,
4.0,
None,
vec!["remoteMessaging".into()],
true,
NotifierPolicy::default(),
None,
None,
false,
));
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 poll_is_running(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
timeout_ms: u64,
) -> bool {
let start = std::time::Instant::now();
loop {
if send_is_running(handle).await {
return true;
}
if start.elapsed().as_millis() >= timeout_ms as u128 {
return false;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
#[test]
fn bgs05_should_replay_on_boot_decision() {
let on = DesiredState {
desired_running: true,
..Default::default()
};
let off = DesiredState {
desired_running: false,
..Default::default()
};
assert!(should_replay_on_boot(&on), "desired_running=true ⇒ replay");
assert!(
!should_replay_on_boot(&off),
"desired_running=false ⇒ no replay"
);
}
#[tokio::test]
async fn bgs05_replay_starts_on_boot_when_desired() {
let backend = MockDesiredStateBackend::new();
backend
.save(&DesiredState {
desired_running: true,
last_start_config: Some(
serde_json::to_value(StartConfig {
service_label: "App".into(),
foreground_service_type: "remoteMessaging".into(),
})
.unwrap(),
),
..Default::default()
})
.unwrap();
let app = tauri::test::mock_app();
let handle = setup_manager_with_factory_backend_and_boot_app(
Box::new(|| Box::new(BlockingService)),
Some(backend),
Some(app.handle().clone()),
true,
);
assert!(
poll_is_running(&handle, 1000).await,
"boot replay should start the service when desired_running=true"
);
let _ = send_stop(&handle).await;
}
#[tokio::test]
async fn bgs05_no_replay_when_desired_false() {
let backend = MockDesiredStateBackend::new();
backend
.save(&DesiredState {
desired_running: false,
..Default::default()
})
.unwrap();
let app = tauri::test::mock_app();
let handle = setup_manager_with_factory_backend_and_boot_app(
Box::new(|| Box::new(BlockingService)),
Some(backend),
Some(app.handle().clone()),
true,
);
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
assert!(
!send_is_running(&handle).await,
"no boot replay when desired_running=false"
);
}
#[tokio::test]
async fn bgs05_no_replay_without_backend() {
let app = tauri::test::mock_app();
let handle = setup_manager_with_factory_backend_and_boot_app(
Box::new(|| Box::new(BlockingService)),
None,
Some(app.handle().clone()),
true,
);
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
assert!(
!send_is_running(&handle).await,
"no boot replay without a desired-state backend"
);
}
#[tokio::test]
async fn bgs05_no_replay_when_consent_allows_auto_unlock_false() {
let backend = MockDesiredStateBackend::new();
backend
.save(&DesiredState {
desired_running: true,
last_start_config: Some(
serde_json::to_value(StartConfig {
service_label: "App".into(),
foreground_service_type: "remoteMessaging".into(),
})
.unwrap(),
),
..Default::default()
})
.unwrap();
let app = tauri::test::mock_app();
let handle = setup_manager_with_factory_backend_and_boot_app(
Box::new(|| Box::new(BlockingService)),
Some(backend),
Some(app.handle().clone()),
false,
);
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
assert!(
!send_is_running(&handle).await,
"consent_allows_auto_unlock=false ⇒ no boot replay even with desired_running=true"
);
}
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_backend_and_allowlist(
Some(backend.clone()),
vec!["specialUse".into()],
true,
);
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_backend_and_allowlist(
Some(backend.clone()),
vec!["specialUse".into()],
true,
);
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 enable_auto_restart_mirrors_desired_state_to_native() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_backend(Some(backend.clone()));
let mock = MockMobile::new();
send_set_mobile(&handle, mock.clone()).await;
let config = StartConfig {
service_label: "Mirror".into(),
foreground_service_type: "specialUse".into(),
};
send_enable_auto_restart(&handle, Some(config.clone()))
.await
.unwrap();
let mirrors = mock.mirror_calls.lock().unwrap();
assert_eq!(
mirrors.len(),
1,
"enableAutoRestart must mirror exactly once to native (H4), got {mirrors:?}"
);
let (desired, cfg) = &mirrors[0];
assert!(*desired, "mirror should request desired_running=true");
let cfg = cfg.as_ref().expect("config should be mirrored to native");
assert_eq!(cfg["serviceLabel"], "Mirror");
assert_eq!(cfg["foregroundServiceType"], "specialUse");
}
#[tokio::test]
async fn disable_auto_restart_mirrors_false_to_native() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_backend(Some(backend.clone()));
let mock = MockMobile::new();
send_set_mobile(&handle, mock.clone()).await;
send_disable_auto_restart(&handle).await.unwrap();
let mirrors = mock.mirror_calls.lock().unwrap();
assert_eq!(
mirrors.len(),
1,
"disableAutoRestart must mirror exactly once to native (H4)"
);
let (desired, cfg) = &mirrors[0];
assert!(!*desired, "mirror should request desired_running=false");
assert!(cfg.is_none(), "disable must not mirror a start config");
}
#[tokio::test]
async fn set_desired_running_mirrors_to_native() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_backend(Some(backend.clone()));
let mock = MockMobile::new();
send_set_mobile(&handle, mock.clone()).await;
send_set_desired_running(&handle, true, None).await.unwrap();
let mirrors = mock.mirror_calls.lock().unwrap();
assert_eq!(
mirrors.len(),
1,
"setDesiredRunning must mirror exactly once to native (H4)"
);
assert!(mirrors[0].0, "mirror should request desired_running=true");
}
#[tokio::test]
async fn enable_auto_restart_without_mobile_does_not_panic() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_backend(Some(backend.clone()));
send_enable_auto_restart(&handle, None).await.unwrap();
let ds = backend
.last_save()
.expect("should still save without mobile");
assert!(ds.desired_running);
}
#[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"
);
}
use crate::desired_state::FileDesiredStateBackend;
use std::path::PathBuf;
fn temp_state_dir() -> PathBuf {
tempfile::tempdir().unwrap().keep()
}
fn file_backend(dir: PathBuf) -> Arc<dyn DesiredStateBackend> {
Arc::new(FileDesiredStateBackend::new(dir))
}
#[tokio::test]
async fn enable_auto_restart_persists_desired_running_true_to_file() {
let dir = temp_state_dir();
let backend = file_backend(dir.clone());
let handle = setup_manager_with_backend(Some(backend));
send_enable_auto_restart(&handle, None).await.unwrap();
let file_backend = FileDesiredStateBackend::new(dir);
let state = file_backend.load().unwrap();
assert!(
state.desired_running,
"file should contain desired_running=true after enable_auto_restart"
);
}
#[tokio::test]
async fn simulated_process_restart_loads_persisted_state() {
let dir = temp_state_dir();
let backend = file_backend(dir.clone());
let config = StartConfig {
service_label: "PersistentSvc".into(),
foreground_service_type: "dataSync".into(),
};
let handle1 = setup_manager_with_backend(Some(backend));
send_enable_auto_restart(&handle1, Some(config.clone()))
.await
.unwrap();
drop(handle1);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let backend2 = file_backend(dir.clone());
let handle2 = setup_manager_with_backend(Some(backend2));
let ds = send_get_desired_state(&handle2)
.await
.expect("should return persisted state");
assert!(
ds.desired_running,
"persisted desired_running should be true after simulated restart"
);
let saved_config = ds
.last_start_config
.expect("config should be persisted across restart");
assert_eq!(saved_config["serviceLabel"], "PersistentSvc");
}
#[tokio::test]
async fn disable_auto_restart_clears_file_backed_state() {
let dir = temp_state_dir();
let backend = file_backend(dir.clone());
let handle = setup_manager_with_backend(Some(backend));
send_enable_auto_restart(&handle, None).await.unwrap();
let ds = send_get_desired_state(&handle)
.await
.expect("should return state");
assert!(ds.desired_running, "should be true after enable");
send_disable_auto_restart(&handle).await.unwrap();
let file_backend = FileDesiredStateBackend::new(dir);
let state = file_backend.load().unwrap();
assert!(
!state.desired_running,
"file should contain desired_running=false after disable"
);
assert!(
state.last_start_config.is_none(),
"config should be cleared"
);
assert!(
state.last_start_epoch_ms.is_none(),
"epoch should be cleared"
);
assert!(!state.recovery_pending, "recovery should be cleared");
assert_eq!(state.restart_attempt, 0, "restart_attempt should be 0");
}
#[tokio::test]
async fn file_backend_get_desired_state_returns_none_without_backend() {
let handle = setup_manager();
let ds = send_get_desired_state(&handle).await;
assert!(
ds.is_none(),
"get_desired_state should return None without backend (existing behavior preserved)"
);
}
async fn send_stop_with_reason(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
reason: StopReason,
) -> Result<(), ServiceError> {
let (tx, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::StopWithReason { reason, reply: tx })
.await
.unwrap();
rx.await.unwrap()
}
#[tokio::test]
async fn stop_with_reason_user_stop_clears_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();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
send_stop_with_reason(&handle, StopReason::UserStop)
.await
.unwrap();
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before + 1,
"UserStop should save a new desired state"
);
let last = saves.last().unwrap();
assert!(
!last.desired_running,
"UserStop should clear desired_running"
);
assert!(last.last_start_config.is_none(), "config should be cleared");
}
#[tokio::test]
async fn stop_with_reason_app_stop_clears_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();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
send_stop_with_reason(&handle, StopReason::AppStop)
.await
.unwrap();
let saves = backend.saves.lock().unwrap();
assert_eq!(saves.len(), saves_before + 1);
assert!(
!saves.last().unwrap().desired_running,
"AppStop should clear desired_running"
);
}
#[tokio::test]
async fn recording_sink_records_notify_calls() {
let sink = RecordingSink::new();
sink.notify("bg-timeout", "title", "body");
assert_eq!(
sink.calls(),
vec![("bg-timeout".into(), "title".into(), "body".into())]
);
}
#[tokio::test]
async fn timeout_stop_with_default_policy_fires_nothing() {
let sink = RecordingSink::new();
let handle = setup_manager_with_sink(NotifierPolicy::default(), sink.clone());
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop_with_reason(&handle, StopReason::PlatformTimeout)
.await
.unwrap();
assert!(
sink.calls().is_empty(),
"default policy must not notify on PlatformTimeout"
);
}
#[tokio::test]
async fn timeout_stop_fires_bg_timeout_when_policy_on() {
let sink = RecordingSink::new();
let policy = NotifierPolicy {
on_timeout: true,
on_recovery: false,
};
let handle = setup_manager_with_sink(policy, sink.clone());
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop_with_reason(&handle, StopReason::PlatformTimeout)
.await
.unwrap();
let calls = sink.calls();
assert_eq!(
calls.len(),
1,
"exactly one notification on PlatformTimeout"
);
assert_eq!(calls[0].0, "bg-timeout", "stable id so repeats replace");
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop_with_reason(&handle, StopReason::PlatformExpiration)
.await
.unwrap();
let calls = sink.calls();
assert_eq!(calls.len(), 2, "PlatformExpiration also notifies");
assert_eq!(calls[1].0, "bg-timeout");
}
#[tokio::test]
async fn user_stop_fires_nothing_even_with_timeout_policy_on() {
let sink = RecordingSink::new();
let policy = NotifierPolicy {
on_timeout: true,
on_recovery: true,
};
let handle = setup_manager_with_sink(policy, sink.clone());
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop_with_reason(&handle, StopReason::UserStop)
.await
.unwrap();
assert!(
sink.calls().is_empty(),
"intentional user stop must never notify"
);
}
#[tokio::test]
async fn recovery_acceptance_fires_bg_recovery_not_the_stop_path() {
let sink = RecordingSink::new();
let policy = NotifierPolicy {
on_timeout: false,
on_recovery: true,
};
let handle = setup_manager_with_sink(policy, sink.clone());
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop_with_reason(&handle, StopReason::OsRestart)
.await
.unwrap();
assert!(
sink.calls().is_empty(),
"stop with reason OsRestart is not recovery acceptance"
);
send_native_event(&handle, NativeLifecycleEvent::AndroidOsRestartAccepted)
.await
.unwrap();
let calls = sink.calls();
assert_eq!(calls.len(), 1, "exactly one notification on acceptance");
assert_eq!(calls[0].0, "bg-recovery", "stable id so repeats replace");
send_native_event(&handle, NativeLifecycleEvent::AndroidBootRecoveryAccepted)
.await
.unwrap();
let calls = sink.calls();
assert_eq!(calls.len(), 2, "boot-recovery acceptance also notifies");
assert_eq!(calls[1].0, "bg-recovery");
}
#[tokio::test]
async fn fire_points_with_no_sink_installed_do_not_panic() {
let policy = NotifierPolicy {
on_timeout: true,
on_recovery: true,
};
let handle = setup_manager_with_policy_and_sink(policy, None);
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop_with_reason(&handle, StopReason::PlatformTimeout)
.await
.unwrap();
send_native_event(&handle, NativeLifecycleEvent::AndroidOsRestartAccepted)
.await
.unwrap();
}
#[tokio::test]
async fn android_derived_policy_suppresses_both_fire_points() {
let config = crate::models::PluginConfig {
notify_on_timeout: true,
notify_on_recovery: true,
android_on_timeout: "notifyUser".into(),
..Default::default()
};
let policy = NotifierPolicy::derive(&config, true);
let sink = RecordingSink::new();
let handle = setup_manager_with_sink(policy, sink.clone());
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop_with_reason(&handle, StopReason::PlatformTimeout)
.await
.unwrap();
send_native_event(&handle, NativeLifecycleEvent::AndroidOsRestartAccepted)
.await
.unwrap();
assert!(
sink.calls().is_empty(),
"android-derived policy must suppress both fire points (DEC-002)"
);
}
#[tokio::test]
async fn stop_with_reason_native_notification_stop_clears_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();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
send_stop_with_reason(&handle, StopReason::NativeNotificationStop)
.await
.unwrap();
let saves = backend.saves.lock().unwrap();
assert_eq!(saves.len(), saves_before + 1);
assert!(
!saves.last().unwrap().desired_running,
"NativeNotificationStop should clear desired_running"
);
}
#[tokio::test]
async fn stop_with_reason_task_completed_clears_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();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
send_stop_with_reason(&handle, StopReason::TaskCompleted)
.await
.unwrap();
let saves = backend.saves.lock().unwrap();
assert_eq!(saves.len(), saves_before + 1);
assert!(
!saves.last().unwrap().desired_running,
"TaskCompleted should clear desired_running"
);
}
#[tokio::test]
async fn stop_with_reason_platform_expiration_preserves_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();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
send_stop_with_reason(&handle, StopReason::PlatformExpiration)
.await
.unwrap();
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before,
"PlatformExpiration should not save new desired state"
);
assert!(
saves.last().unwrap().desired_running,
"desired_running should remain true"
);
}
#[tokio::test]
async fn stop_with_reason_platform_timeout_preserves_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();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
send_stop_with_reason(&handle, StopReason::PlatformTimeout)
.await
.unwrap();
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before,
"PlatformTimeout should not save new desired state"
);
assert!(
saves.last().unwrap().desired_running,
"desired_running should remain true"
);
}
#[tokio::test]
async fn stop_with_reason_error_preserves_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();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
send_stop_with_reason(&handle, StopReason::Error)
.await
.unwrap();
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before,
"Error should not save new desired state"
);
assert!(
saves.last().unwrap().desired_running,
"desired_running should remain true"
);
}
#[tokio::test]
async fn stop_with_reason_not_running_returns_not_running() {
let handle = setup_manager();
let result = send_stop_with_reason(&handle, StopReason::UserStop).await;
assert!(
matches!(result, Err(ServiceError::NotRunning)),
"StopWithReason should return NotRunning when idle"
);
}
#[tokio::test]
async fn stop_with_reason_cancels_service() {
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_with_reason(&handle, StopReason::UserStop)
.await
.unwrap();
assert!(
!send_is_running(&handle).await,
"service should be stopped after StopWithReason"
);
}
#[tokio::test]
async fn stop_with_reason_stops_mobile_keepalive() {
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);
send_stop_with_reason(&handle, StopReason::UserStop)
.await
.unwrap();
assert_eq!(
mock.stop_called.load(Ordering::Acquire),
1,
"stop_keepalive should be called once after StopWithReason"
);
}
#[tokio::test]
async fn stop_delegates_to_stop_with_reason_user_stop_clears_desired() {
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 saves_before = backend.saves.lock().unwrap().len();
send_stop(&handle).await.unwrap();
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before + 1,
"Stop should save desired state (delegates to StopWithReason(UserStop))"
);
assert!(
!saves.last().unwrap().desired_running,
"Stop should clear desired_running"
);
}
#[tokio::test]
async fn stop_with_reason_handle_method_stops_service() {
let handle = setup_manager();
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
assert!(send_is_running(&handle).await);
handle.stop_with_reason(StopReason::UserStop).await.unwrap();
assert!(
!send_is_running(&handle).await,
"service should be stopped after stop_with_reason"
);
}
#[tokio::test]
async fn stop_with_reason_handle_method_preserves_desired_for_platform_timeout() {
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 saves_before = backend.saves.lock().unwrap().len();
handle
.stop_with_reason(StopReason::PlatformTimeout)
.await
.unwrap();
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before,
"PlatformTimeout should not save new desired state"
);
assert!(
saves.last().unwrap().desired_running,
"desired_running should remain true"
);
}
#[tokio::test]
async fn stop_with_reason_handle_method_returns_not_running_when_idle() {
let handle = setup_manager();
let result = handle.stop_with_reason(StopReason::UserStop).await;
assert!(
matches!(result, Err(ServiceError::NotRunning)),
"stop_with_reason should return NotRunning when idle"
);
}
#[tokio::test]
async fn stop_blocking_with_reason_stops_service() {
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_with_reason(StopReason::AppStop))
.await
.expect("spawn_blocking panicked");
assert!(
result.is_ok(),
"stop_blocking_with_reason should succeed: {result:?}"
);
assert!(
!send_is_running(&handle).await,
"service should be stopped after stop_blocking_with_reason"
);
}
#[tokio::test]
async fn stop_blocking_with_reason_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_with_reason(StopReason::UserStop))
.await
.expect("spawn_blocking panicked");
assert!(
matches!(result, Err(ServiceError::NotRunning)),
"stop_blocking_with_reason should return NotRunning when idle: {result:?}"
);
}
#[tokio::test]
async fn stop_with_reason_idempotent_second_returns_not_running() {
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;
send_stop_with_reason(&handle, StopReason::UserStop)
.await
.unwrap();
let saves_after_first = backend.saves.lock().unwrap().len();
let result = send_stop_with_reason(&handle, StopReason::UserStop).await;
assert!(
matches!(result, Err(ServiceError::NotRunning)),
"second StopWithReason should return NotRunning: {result:?}"
);
let saves_after_second = backend.saves.lock().unwrap().len();
assert_eq!(
saves_after_first, saves_after_second,
"second StopWithReason should not produce additional desired-state saves"
);
}
#[tokio::test]
async fn stop_with_reason_platform_expiration_skips_stop_keepalive() {
let mock = MockMobile::new();
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_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(
mock.stop_called.load(Ordering::Acquire),
0,
"stop_keepalive should not be called yet"
);
let saves_before = backend.saves.lock().unwrap().len();
send_stop_with_reason(&handle, StopReason::PlatformExpiration)
.await
.unwrap();
assert!(!send_is_running(&handle).await, "service should be stopped");
assert_eq!(
mock.stop_called.load(Ordering::Acquire),
0,
"PlatformExpiration should NOT call stop_keepalive"
);
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before,
"PlatformExpiration should not save new desired state"
);
assert!(
saves.last().unwrap().desired_running,
"desired_running should remain true"
);
}
#[test]
fn stop_reason_matrix_matches_design_5_4() {
let matrix = [
(StopReason::UserStop, true, true),
(StopReason::AppStop, true, true),
(StopReason::NativeNotificationStop, true, true),
(StopReason::TaskCompleted, true, true),
(StopReason::OsRestart, true, false),
(StopReason::BootRecovery, true, false),
(StopReason::Error, true, false),
(StopReason::PlatformExpiration, false, false),
(StopReason::PlatformTimeout, false, false),
(StopReason::ProcessExit, false, false),
];
for (reason, expect_stop_keepalive, expect_clear_desired) in matrix {
assert_eq!(
should_stop_keepalive(reason),
expect_stop_keepalive,
"should_stop_keepalive mismatch for {reason:?}"
);
assert_eq!(
should_clear_desired_state(reason),
expect_clear_desired,
"should_clear_desired_state mismatch for {reason:?}"
);
}
}
#[tokio::test]
async fn process_exit_preserves_desired_and_keepalive() {
let mock = MockMobile::new();
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_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
send_stop_with_reason(&handle, StopReason::ProcessExit)
.await
.unwrap();
assert!(!send_is_running(&handle).await, "service should be stopped");
assert_eq!(
mock.stop_called.load(Ordering::Acquire),
0,
"ProcessExit should NOT call stop_keepalive (H2)"
);
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before,
"ProcessExit should not save new desired state"
);
assert!(
saves.last().unwrap().desired_running,
"ProcessExit must preserve desired_running=true"
);
}
fn capture_plugin_events<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
) -> (Arc<std::sync::Mutex<Vec<String>>>, tauri::EventId) {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured_clone = captured.clone();
let id = app.listen("background-service://event", move |event: tauri::Event| {
captured_clone
.lock()
.unwrap()
.push(event.payload().to_string());
});
(captured, id)
}
struct YieldingOkService;
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for YieldingOkService {
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(())
}
}
struct BlockingOkService;
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for BlockingOkService {
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> {
let _ = ctx.shutdown.cancelled().await;
Ok(())
}
}
#[tokio::test]
async fn core01_natural_completion_emits_task_completed_exactly_once() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(YieldingOkService)));
let app = tauri::test::mock_app();
let (captured, _guard) = capture_plugin_events(app.handle());
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_stopped(&handle, 2000).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let events = captured.lock().unwrap();
assert!(
events.iter().any(|e| e.contains("\"type\":\"started\"")),
"expected a Started event, got: {events:?}"
);
let stopped: Vec<_> = events
.iter()
.filter(|e| e.contains("\"type\":\"stopped\""))
.collect();
assert_eq!(
stopped.len(),
1,
"exactly one terminal Stopped event, got: {stopped:?}"
);
assert!(
stopped[0].contains("\"reason\":\"taskCompleted\""),
"natural Ok must emit taskCompleted, got: {}",
stopped[0]
);
}
#[tokio::test]
async fn core01_user_stop_emits_explicit_reason_over_cooperative_ok() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(BlockingOkService)));
let app = tauri::test::mock_app();
let (captured, _guard) = capture_plugin_events(app.handle());
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop(&handle).await.unwrap();
wait_until_stopped(&handle, 2000).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let events = captured.lock().unwrap();
let stopped: Vec<_> = events
.iter()
.filter(|e| e.contains("\"type\":\"stopped\""))
.collect();
assert_eq!(stopped.len(), 1, "exactly one Stopped: {stopped:?}");
assert!(
stopped[0].contains("\"reason\":\"userStop\""),
"explicit user stop must win over cooperative Ok, got: {}",
stopped[0]
);
}
#[tokio::test]
async fn core01_platform_timeout_emits_platform_timeout_reason() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(BlockingOkService)));
let app = tauri::test::mock_app();
let (captured, _guard) = capture_plugin_events(app.handle());
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop_with_reason(&handle, StopReason::PlatformTimeout)
.await
.unwrap();
wait_until_stopped(&handle, 2000).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let events = captured.lock().unwrap();
let stopped: Vec<_> = events
.iter()
.filter(|e| e.contains("\"type\":\"stopped\""))
.collect();
assert_eq!(stopped.len(), 1);
assert!(
stopped[0].contains("\"reason\":\"platformTimeout\""),
"got: {}",
stopped[0]
);
}
#[tokio::test]
async fn core01_unprompted_error_emits_error_event() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateErrorService)));
let app = tauri::test::mock_app();
let (captured, _guard) = capture_plugin_events(app.handle());
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_stopped(&handle, 2000).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let events = captured.lock().unwrap();
assert!(
events.iter().any(|e| e.contains("\"type\":\"error\"")),
"unprompted Err must emit Error, got: {events:?}"
);
let stopped = events
.iter()
.filter(|e| e.contains("\"type\":\"stopped\""))
.count();
assert_eq!(stopped, 0, "Error path must not double-emit Stopped");
}
#[tokio::test]
async fn core01_natural_completion_clears_desired_running() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(YieldingOkService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_stopped(&handle, 2000).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves = backend.saves.lock().unwrap();
let last = saves.last().expect("expected at least one save");
assert!(
!last.desired_running,
"natural completion must clear desired_running; last save = {last:?}"
);
}
#[tokio::test]
async fn core01_unprompted_error_preserves_desired_running() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(ImmediateErrorService)),
Some(backend.clone()),
);
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_stopped(&handle, 2000).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves = backend.saves.lock().unwrap();
let last = saves.last().expect("expected at least one save");
assert!(
last.desired_running,
"unprompted error must preserve desired_running=true; last save = {last:?}"
);
}
#[tokio::test]
async fn core01_restart_after_user_stop_succeeds_with_correct_reason() {
let handle = setup_manager_with_factory(Box::new(|| Box::new(BlockingOkService)));
let app = tauri::test::mock_app();
let (captured, _guard) = capture_plugin_events(app.handle());
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop(&handle).await.unwrap();
wait_until_stopped(&handle, 2000).await;
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
send_stop_with_reason(&handle, StopReason::PlatformExpiration)
.await
.unwrap();
wait_until_stopped(&handle, 2000).await;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let n = captured
.lock()
.unwrap()
.iter()
.filter(|e| e.contains("\"type\":\"stopped\""))
.count();
if n >= 2 || std::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let events = captured.lock().unwrap();
let stopped: Vec<_> = events
.iter()
.filter(|e| e.contains("\"type\":\"stopped\""))
.collect();
assert_eq!(
stopped.len(),
2,
"expected one Stopped per generation, got: {stopped:?}"
);
assert!(
stopped[0].contains("\"reason\":\"userStop\""),
"first stop reason: {}",
stopped[0]
);
assert!(
stopped[1].contains("\"reason\":\"platformExpiration\""),
"second stop reason must be the new generation's, got: {}",
stopped[1]
);
}
#[tokio::test]
async fn core02_reconcile_native_stopped_then_restart_succeeds() {
let mock = MockNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
mock.set_native_state(native_state(false));
assert!(
!send_is_running(&handle).await,
"reconcile must converge is_running to false"
);
let result = send_start(&handle, app.handle().clone()).await;
assert!(
result.is_ok(),
"restart after native-stopped reconcile must succeed (CORE-02): {result:?}"
);
mock.set_native_state(native_state(true));
assert!(
send_is_running(&handle).await,
"service must be observable as running after restart + native agreement"
);
}
#[tokio::test]
async fn core05_malformed_boot_replay_is_skipped_cleanly_and_restart_works() {
let backend = MockDesiredStateBackend::new();
backend
.save(&crate::desired_state::DesiredState {
desired_running: true,
last_start_config: Some(serde_json::json!({"serviceLabel": 12345})),
..Default::default()
})
.unwrap();
let app = tauri::test::mock_app();
let handle = setup_manager_with_factory_backend_and_boot_app(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
Some(app.handle().clone()),
true,
);
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
assert!(
!send_is_running(&handle).await,
"malformed boot replay must not start the service"
);
send_start(&handle, app.handle().clone()).await.unwrap();
assert!(
send_is_running(&handle).await,
"explicit start after malformed-replay skip must succeed"
);
}
#[tokio::test]
async fn cancel_listener_platform_timeout_preserves_desired_and_resubmits() {
let mock = MockMobile::new();
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_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
let mirror_before = mock.mirror_calls.lock().unwrap().len();
send_stop_with_reason(&handle, StopReason::PlatformTimeout)
.await
.unwrap();
assert!(!send_is_running(&handle).await, "service should be stopped");
assert_eq!(
mock.stop_called.load(Ordering::Acquire),
0,
"PlatformTimeout should NOT call stop_keepalive (M13)"
);
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before,
"PlatformTimeout should not save new desired state"
);
assert!(
saves.last().unwrap().desired_running,
"desired_running should remain true"
);
let mirror = mock.mirror_calls.lock().unwrap();
assert_eq!(
mirror.len(),
mirror_before + 1,
"PlatformTimeout should re-submit native scheduling exactly once"
);
assert!(
mirror.last().unwrap().0,
"reconcile must mirror desired_running=true (never false)"
);
}
#[tokio::test]
async fn cancel_listener_user_stop_clears_desired_and_stops_keepalive() {
let mock = MockMobile::new();
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_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
send_stop(&handle).await.unwrap();
assert!(!send_is_running(&handle).await, "service should be stopped");
assert_eq!(
mock.stop_called.load(Ordering::Acquire),
1,
"UserStop should call stop_keepalive"
);
let last = backend
.last_save()
.expect("should have saved desired state");
assert!(
!last.desired_running,
"UserStop should clear desired_running to false"
);
}
async fn send_native_event(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
event: NativeLifecycleEvent,
) -> Result<(), ServiceError> {
let (tx, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::NativeLifecycleEvent { event, reply: tx })
.await
.unwrap();
rx.await.unwrap()
}
#[tokio::test]
async fn native_lifecycle_notification_stop_clears_desired_state() {
let mock = MockMobile::new();
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_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
send_native_event(&handle, NativeLifecycleEvent::AndroidNotificationStop)
.await
.unwrap();
assert!(!send_is_running(&handle).await, "service should be stopped");
let saves = backend.saves.lock().unwrap();
assert_eq!(saves.len(), saves_before + 1);
assert!(
!saves.last().unwrap().desired_running,
"AndroidNotificationStop should clear desired_running"
);
assert_eq!(
mock.stop_called.load(Ordering::Acquire),
1,
"AndroidNotificationStop should call stop_keepalive"
);
}
#[tokio::test]
async fn native_lifecycle_timeout_preserves_desired_state() {
let mock = MockMobile::new();
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_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let saves_before = backend.saves.lock().unwrap().len();
send_native_event(
&handle,
NativeLifecycleEvent::AndroidTimeout {
fgs_type: Some("dataSync".into()),
},
)
.await
.unwrap();
assert!(!send_is_running(&handle).await, "service should be stopped");
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before,
"AndroidTimeout should not save new desired state"
);
assert!(
saves.last().unwrap().desired_running,
"desired_running should remain true"
);
assert_eq!(
mock.stop_called.load(Ordering::Acquire),
0,
"AndroidTimeout (PlatformTimeout) should NOT call stop_keepalive (M13)"
);
}
#[tokio::test]
async fn native_lifecycle_event_idempotent_when_already_stopped() {
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;
send_stop(&handle).await.unwrap();
assert!(!send_is_running(&handle).await);
let saves_before = backend.saves.lock().unwrap().len();
let result =
send_native_event(&handle, NativeLifecycleEvent::AndroidNotificationStop).await;
assert!(
matches!(result, Err(ServiceError::NotRunning)),
"native event while stopped should return NotRunning: {result:?}"
);
{
let saves = backend.saves.lock().unwrap();
assert_eq!(
saves.len(),
saves_before,
"no additional saves when already stopped"
);
}
let result = send_native_event(
&handle,
NativeLifecycleEvent::AndroidTimeout { fgs_type: None },
)
.await;
assert!(
matches!(result, Err(ServiceError::NotRunning)),
"timeout while stopped should return NotRunning: {result:?}"
);
}
async fn send_get_lifecycle_status(
handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
) -> LifecycleStatus {
let (reply, rx) = oneshot::channel();
handle
.cmd_tx
.send(ManagerCommand::GetLifecycleStatus {
desktop_mode: None,
reply,
})
.await
.expect("send GetLifecycleStatus");
rx.await.expect("receive LifecycleStatus")
}
#[tokio::test]
async fn get_lifecycle_status_returns_idle_initially() {
let handle = setup_manager();
let status = send_get_lifecycle_status(&handle).await;
assert!(
matches!(status.state, LifecycleState::Idle),
"expected Idle, got {:?}",
status.state
);
assert!(!status.desired_running);
assert!(!status.recovery_enabled);
assert!(!status.recovery_pending);
assert!(status.last_error.is_none());
assert!(status.last_start_config.is_none());
}
#[tokio::test]
async fn get_lifecycle_status_returns_running_after_start() {
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
let status = send_get_lifecycle_status(&handle).await;
assert!(
matches!(status.state, LifecycleState::Running),
"expected Running, got {:?}",
status.state
);
}
#[tokio::test]
async fn get_lifecycle_status_reflects_desired_state() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
send_enable_auto_restart(&handle, None).await.unwrap();
let status = send_get_lifecycle_status(&handle).await;
assert!(
status.desired_running,
"expected desired_running=true after enable_auto_restart"
);
assert!(
status.recovery_enabled,
"expected recovery_enabled=true when desired_running=true"
);
}
#[tokio::test]
async fn get_lifecycle_status_clears_after_disable_recovery() {
let backend = MockDesiredStateBackend::new();
let handle = setup_manager_with_factory_and_backend(
Box::new(|| Box::new(BlockingService)),
Some(backend.clone()),
);
send_enable_auto_restart(&handle, None).await.unwrap();
send_disable_auto_restart(&handle).await.unwrap();
let status = send_get_lifecycle_status(&handle).await;
assert!(
!status.desired_running,
"expected desired_running=false after disable"
);
assert!(
!status.recovery_enabled,
"expected recovery_enabled=false after disable"
);
}
#[tokio::test]
async fn get_lifecycle_status_includes_platform_and_capabilities() {
let handle = setup_manager();
let status = send_get_lifecycle_status(&handle).await;
#[cfg(target_os = "linux")]
assert!(
matches!(status.platform, crate::models::Platform::Linux),
"expected Linux platform, got {:?}",
status.platform
);
assert!(
!status.capabilities.limitations.is_empty()
|| !status.capabilities.required_setup.is_empty(),
"capabilities should have some content"
);
}
#[tokio::test]
async fn get_lifecycle_status_returns_stopped_after_stop() {
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
send_stop(&handle).await.unwrap();
let status = send_get_lifecycle_status(&handle).await;
assert!(
matches!(status.state, LifecycleState::Stopped),
"expected Stopped, got {:?}",
status.state
);
}
struct MockNativeState {
native_state: std::sync::Mutex<Option<crate::models::AndroidServiceState>>,
android_calls: AtomicUsize,
}
impl MockNativeState {
fn new() -> Arc<Self> {
Arc::new(Self {
native_state: std::sync::Mutex::new(None),
android_calls: AtomicUsize::new(0),
})
}
fn set_native_state(&self, state: crate::models::AndroidServiceState) {
*self.native_state.lock().unwrap() = Some(state);
}
fn android_call_count(&self) -> usize {
self.android_calls.load(Ordering::Acquire)
}
}
impl MobileKeepalive for MockNativeState {
#[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>,
_ios_processing_ceiling_multiplier: Option<f64>,
) -> Result<(), ServiceError> {
Ok(())
}
fn stop_keepalive(&self) -> Result<(), ServiceError> {
Ok(())
}
fn get_android_service_state(
&self,
) -> Result<Option<crate::models::AndroidServiceState>, ServiceError> {
self.android_calls.fetch_add(1, Ordering::AcqRel);
Ok(self.native_state.lock().unwrap().clone())
}
}
fn native_state(running: bool) -> crate::models::AndroidServiceState {
crate::models::AndroidServiceState {
native_running: running,
native_foreground: running,
desired_running: running,
durable_state: if running { "running" } else { "stopped" }.into(),
service_label: None,
foreground_service_type: None,
notification_id: None,
notification_channel_id: None,
recovery_pending: false,
recovery_reason: None,
last_platform_error: None,
data_dir: "/data".into(),
}
}
#[tokio::test]
async fn merge_adopt_native_when_running() {
let mock = MockNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
send_stop(&handle).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
mock.set_native_state(native_state(true));
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(status.adopted, Some(true), "should adopt native");
assert_eq!(status.degraded, Some(false), "adopt is not degraded");
assert_eq!(status.native_running, Some(true));
assert!(
matches!(status.state, LifecycleState::Running),
"expected Running after adopt, got {:?}",
status.state,
);
}
#[tokio::test]
async fn merge_autoheal_when_native_stopped() {
let mock = MockNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
mock.set_native_state(native_state(false));
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(
status.degraded,
Some(true),
"transient degraded on mismatch"
);
assert_eq!(
status.degraded_reason,
Some("native_stopped_rust_running".into()),
"should explain the mismatch"
);
assert!(
matches!(status.state, LifecycleState::Stopped | LifecycleState::Idle),
"expected Stopped or Idle after auto-heal, got {:?}",
status.state,
);
}
#[tokio::test]
async fn actor_converges_to_native_stopped() {
let mock = MockNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
assert!(
send_is_running(&handle).await,
"precondition: actor should believe it is running after start",
);
mock.set_native_state(native_state(false));
assert!(
!send_is_running(&handle).await,
"is_running() must converge to native-stopped (no stuck 'running')",
);
assert_eq!(
send_get_state(&handle).await.state,
ServiceLifecycle::Stopped,
"lifecycle state must converge to Stopped on native-authority reconcile",
);
}
#[tokio::test]
async fn merge_normal_both_running() {
let mock = MockNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
mock.set_native_state(native_state(true));
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(status.degraded, Some(false));
assert_eq!(status.adopted, Some(false));
assert!(
matches!(status.state, LifecycleState::Running),
"expected Running, got {:?}",
status.state,
);
}
#[tokio::test]
async fn merge_normal_both_idle() {
let mock = MockNativeState::new();
let handle = setup_manager();
send_set_mobile(&handle, mock.clone()).await;
mock.set_native_state(native_state(false));
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(status.degraded, Some(false));
assert!(
matches!(status.state, LifecycleState::Idle),
"expected Idle, got {:?}",
status.state,
);
}
#[tokio::test]
async fn merge_timeout_detection() {
let mock = MockNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let mut ns = native_state(true);
ns.durable_state = "timeout".into();
mock.set_native_state(ns);
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(status.degraded, Some(true));
assert_eq!(
status.degraded_reason,
Some("native_timeout".into()),
"should report timeout degradation"
);
}
#[tokio::test]
async fn merge_recovery_pending_surfaces() {
let mock = MockNativeState::new();
let handle = setup_manager();
send_set_mobile(&handle, mock.clone()).await;
let mut ns = native_state(false);
ns.recovery_pending = true;
ns.recovery_reason = Some("core_start_failed".into());
mock.set_native_state(ns);
let status = send_get_lifecycle_status(&handle).await;
assert!(
status.recovery_pending,
"recovery_pending should be true from native state"
);
}
#[tokio::test]
async fn merge_degraded_event_emitted() {
let mock = MockNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
let event_received = Arc::new(AtomicBool::new(false));
let event_received_clone = event_received.clone();
let _listener = app
.handle()
.listen("background-service:state-degraded", move |_event| {
event_received_clone.store(true, Ordering::Release);
});
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
mock.set_native_state(native_state(false));
let _status = send_get_lifecycle_status(&handle).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
event_received.load(Ordering::Acquire),
"state-degraded event should be emitted on mismatch"
);
}
#[tokio::test]
async fn merge_degraded_clears_on_convergence() {
let mock = MockNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
mock.set_native_state(native_state(false));
let status1 = send_get_lifecycle_status(&handle).await;
assert_eq!(
status1.degraded,
Some(true),
"first query should be degraded"
);
let status2 = send_get_lifecycle_status(&handle).await;
assert_eq!(
status2.degraded,
Some(false),
"degraded should clear on convergence"
);
assert_eq!(status2.degraded_reason, None);
}
struct MockIosNativeState {
ios_state: std::sync::Mutex<Option<crate::models::IosNativeState>>,
android_calls: AtomicUsize,
}
impl MockIosNativeState {
fn new() -> Arc<Self> {
Arc::new(Self {
ios_state: std::sync::Mutex::new(None),
android_calls: AtomicUsize::new(0),
})
}
fn set_ios_state(&self, state: crate::models::IosNativeState) {
*self.ios_state.lock().unwrap() = Some(state);
}
fn android_call_count(&self) -> usize {
self.android_calls.load(Ordering::Acquire)
}
}
impl MobileKeepalive for MockIosNativeState {
#[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>,
_ios_processing_ceiling_multiplier: Option<f64>,
) -> Result<(), ServiceError> {
Ok(())
}
fn stop_keepalive(&self) -> Result<(), ServiceError> {
Ok(())
}
fn get_android_service_state(
&self,
) -> Result<Option<crate::models::AndroidServiceState>, ServiceError> {
self.android_calls.fetch_add(1, Ordering::AcqRel);
Ok(None)
}
fn get_ios_native_state(
&self,
) -> Result<Option<crate::models::IosNativeState>, ServiceError> {
Ok(self.ios_state.lock().unwrap().clone())
}
fn query_native_state(&self) -> Result<Option<NativeAuthority>, ServiceError> {
Ok(self.get_ios_native_state()?.map(NativeAuthority::Ios))
}
}
fn ios_state(desired_running: bool) -> crate::models::IosNativeState {
crate::models::IosNativeState {
desired_running,
refresh_scheduled: false,
processing_scheduled: false,
active_task_kind: None,
pending_task: None,
last_completed_at: None,
last_completion_reason: None,
last_refresh_error: None,
last_processing_error: None,
in_budget: true,
}
}
#[tokio::test]
async fn ios_status_reflects_scheduling_failure() {
let mock = MockIosNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
send_set_mobile(&handle, mock.clone()).await;
let mut s = ios_state(true);
s.last_refresh_error = Some("BGTaskSchedulerErrorDomain code 1".into());
mock.set_ios_state(s);
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(
status.degraded,
Some(true),
"iOS scheduling failure must report degraded",
);
assert_eq!(
status.degraded_reason,
Some("ios_scheduling_error".into()),
"degraded reason must name the iOS scheduling error",
);
assert_eq!(
status.last_platform_error,
Some("BGTaskSchedulerErrorDomain code 1".into()),
"the real native error string must surface",
);
assert_eq!(mock.android_call_count(), 0, "L4: no Android round-trip");
}
#[tokio::test]
async fn ios_status_reflects_waiting_for_next_bgtask() {
let mock = MockIosNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
mock.set_ios_state(ios_state(true));
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(
status.last_platform_state,
Some("waitingForBgTask".into()),
"status must reflect the native waiting phase, not stale actor memory",
);
assert_eq!(
status.native_running,
Some(false),
"no BGTask is executing natively",
);
assert_eq!(status.degraded, Some(false));
assert_eq!(mock.android_call_count(), 0, "L4: no Android round-trip");
}
#[tokio::test]
async fn ios_status_out_of_budget_is_degraded() {
let mock = MockIosNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
send_set_mobile(&handle, mock.clone()).await;
let mut s = ios_state(true);
s.in_budget = false;
mock.set_ios_state(s);
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(status.degraded, Some(true));
assert_eq!(status.degraded_reason, Some("ios_out_of_budget".into()));
}
#[tokio::test]
async fn ios_status_poll_does_not_call_get_android_service_state() {
let mock = MockIosNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
send_set_mobile(&handle, mock.clone()).await;
mock.set_ios_state(ios_state(true));
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(status.last_platform_state, Some("waitingForBgTask".into()));
assert_eq!(
mock.android_call_count(),
0,
"iOS status poll must not call getAndroidServiceState (L4)",
);
}
#[tokio::test]
async fn ios_reconcile_keeps_actor_belief_without_android_call() {
let mock = MockIosNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
wait_until_running(&handle).await;
mock.set_ios_state(ios_state(true));
assert!(
send_is_running(&handle).await,
"iOS reconcile must keep the actor's running belief",
);
assert_eq!(
mock.android_call_count(),
0,
"iOS reconcile must not call getAndroidServiceState (L4)",
);
}
#[tokio::test]
async fn android_status_poll_still_queries_android_state() {
let mock = MockNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
send_set_mobile(&handle, mock.clone()).await;
mock.set_native_state(native_state(true));
let _ = send_get_lifecycle_status(&handle).await;
assert!(
mock.android_call_count() >= 1,
"Android path must still query getAndroidServiceState",
);
}
#[tokio::test]
async fn adoption_native_start_ui_attach_events() {
let mock = MockNativeState::new();
let handle =
setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
let app = tauri::test::mock_app();
let event_received = Arc::new(AtomicBool::new(false));
let event_received_clone = event_received.clone();
let _listener = app
.handle()
.listen("background-service:state-degraded", move |_event| {
event_received_clone.store(true, Ordering::Release);
});
send_set_mobile(&handle, mock.clone()).await;
send_start(&handle, app.handle().clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
send_stop(&handle).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
mock.set_native_state(native_state(true));
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(status.adopted, Some(true), "should adopt native state");
assert_eq!(status.degraded, Some(false), "adoption is not degraded");
assert!(
matches!(status.state, LifecycleState::Running),
"expected Running after adoption, got {:?}",
status.state,
);
mock.set_native_state(native_state(false));
let _ = send_get_lifecycle_status(&handle).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
event_received.load(Ordering::Acquire),
"late subscriber should receive events after adoption"
);
}
#[tokio::test]
async fn adoption_data_dir_surfaces_in_status() {
let mock = MockNativeState::new();
let handle = setup_manager();
send_set_mobile(&handle, mock.clone()).await;
let mut ns = native_state(false);
ns.data_dir = "/data/app/com.example".into();
mock.set_native_state(ns);
let status = send_get_lifecycle_status(&handle).await;
assert_eq!(
status.data_dir,
Some("/data/app/com.example".to_string()),
"data_dir from native should surface in status"
);
}
#[tokio::test]
async fn adoption_setup_idle_is_healthy() {
let mock = MockNativeState::new();
let handle = setup_manager();
send_set_mobile(&handle, mock.clone()).await;
let mut ns = native_state(true);
ns.durable_state = "setup_idle".into();
mock.set_native_state(ns);
let status = send_get_lifecycle_status(&handle).await;
assert!(
!matches!(status.state, LifecycleState::Error),
"setup_idle should not be reported as Error"
);
assert_ne!(
status.degraded,
Some(true),
"setup_idle should not be degraded"
);
assert!(
matches!(status.state, LifecycleState::SetupIdle),
"expected SetupIdle, got {:?}",
status.state,
);
}
#[tokio::test]
async fn adoption_locked_idle_is_healthy() {
let mock = MockNativeState::new();
let handle = setup_manager();
send_set_mobile(&handle, mock.clone()).await;
let mut ns = native_state(true);
ns.durable_state = "locked_idle".into();
mock.set_native_state(ns);
let status = send_get_lifecycle_status(&handle).await;
assert!(
!matches!(status.state, LifecycleState::Error),
"locked_idle should not be reported as Error"
);
assert_ne!(
status.degraded,
Some(true),
"locked_idle should not be degraded"
);
assert!(
matches!(status.state, LifecycleState::LockedIdle),
"expected LockedIdle, got {:?}",
status.state,
);
}
#[tokio::test]
async fn stale_timeout_surfaces_in_status_when_not_running() {
let mock = MockNativeState::new();
let handle = setup_manager();
send_set_mobile(&handle, mock.clone()).await;
let mut ns = native_state(false);
ns.durable_state = "timeout".into();
ns.last_platform_error = Some("FGS timeout (type: remoteMessaging)".into());
mock.set_native_state(ns);
let status = send_get_lifecycle_status(&handle).await;
assert!(
status.degraded == Some(true) || status.degraded_reason.is_some(),
"stale timeout DurableState should be reflected in status even when Rust is not running — \
got degraded={:?}, degraded_reason={:?}",
status.degraded,
status.degraded_reason,
);
}
#[cfg(all(unix, feature = "desktop-service"))]
struct GracefulShutdownService {
drained: Arc<AtomicBool>,
}
#[cfg(all(unix, feature = "desktop-service"))]
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for GracefulShutdownService {
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(())
}
async fn shutdown_gracefully(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
self.drained.store(true, Ordering::SeqCst);
Ok(())
}
}
#[cfg(all(unix, feature = "desktop-service"))]
#[tokio::test]
async fn bgs31_sigterm_graceful_stop() {
use crate::desktop::headless::graceful_sigterm_shutdown;
let drained = Arc::new(AtomicBool::new(false));
let drained_for_factory = drained.clone();
let handle = setup_manager_with_factory(Box::new(move || {
Box::new(GracefulShutdownService {
drained: drained_for_factory.clone(),
})
}));
let app = tauri::test::mock_app();
send_start(&handle, app.handle().clone())
.await
.expect("Start should succeed");
wait_until_running(&handle).await;
assert!(
handle.is_running().await,
"precondition: service should be running before SIGTERM"
);
graceful_sigterm_shutdown(&handle.cmd_tx).await;
assert!(
!handle.is_running().await,
"Stop did not reach manager_loop (is_running still true)"
);
assert!(
drained.load(Ordering::SeqCst),
"shutdown_gracefully hook did not run (bounded drain not driven)"
);
}
}