diff --git a/src-tauri/src/init/lifecycle.rs b/src-tauri/src/init/lifecycle.rs index 9d95fa0..982038c 100644 --- a/src-tauri/src/init/lifecycle.rs +++ b/src-tauri/src/init/lifecycle.rs @@ -9,6 +9,7 @@ use crate::{ services::{ close_all_windows, health_manager::open_health_manager_window, + health_monitor::{start_health_monitor, stop_health_monitor}, scene::open_scene_window, ws::client::{clear_ws_client, establish_websocket_connection}, }, @@ -39,13 +40,15 @@ async fn connect_user_profile() { init_app_data_scoped(AppDataRefreshScope::All).await; establish_websocket_connection().await; start_background_token_refresh().await; + start_health_monitor().await; } /// Clears the user profile and WebSocket connection. async fn disconnect_user_profile() { + stop_health_monitor(); + stop_background_token_refresh(); clear_app_data(); clear_ws_client().await; - stop_background_token_refresh(); } /// Destructs the user session and show health manager window diff --git a/src-tauri/src/services/health_monitor.rs b/src-tauri/src/services/health_monitor.rs new file mode 100644 index 0000000..d0b94d3 --- /dev/null +++ b/src-tauri/src/services/health_monitor.rs @@ -0,0 +1,72 @@ +use crate::{ + init::lifecycle::{handle_disasterous_failure, validate_server_health}, + lock_w, + services::ws::client::establish_websocket_connection, + state::FDOLL, +}; +use tokio::time::{self, Duration}; +use tokio_util::sync::CancellationToken; +use tracing::{info, warn}; + +/// Starts a periodic health monitor that validates server connectivity +/// and attempts to recover WebSocket connection if health checks fail. +pub async fn start_health_monitor() { + stop_health_monitor(); + + let cancel_token = CancellationToken::new(); + { + let mut guard = lock_w!(FDOLL); + guard.network.health_monitor_token = Some(cancel_token.clone()); + } + + tokio::spawn(async move { + let mut interval = time::interval(Duration::from_secs(30)); // Check every 30 seconds + let mut consecutive_failures = 0u8; + const MAX_FAILURES: u8 = 3; + + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + info!("Health monitor stopped"); + break; + } + _ = interval.tick() => { + match validate_server_health().await { + Ok(_) => { + consecutive_failures = 0; + } + Err(e) => { + consecutive_failures = consecutive_failures.saturating_add(1); + warn!( + "Health check failed ({}/{}): {}", + consecutive_failures, MAX_FAILURES, e + ); + + if consecutive_failures >= MAX_FAILURES { + info!("Server appears unreachable after {} attempts, triggering recovery", MAX_FAILURES); + handle_disasterous_failure(Some(format!( + "Lost connection to server: {}", + e + ))) + .await; + break; + } else { + // Try to re-establish WebSocket connection + info!("Attempting to re-establish WebSocket connection"); + establish_websocket_connection().await; + } + } + } + } + } + } + }); +} + +/// Stops the health monitor loop. +pub fn stop_health_monitor() { + let mut guard = lock_w!(FDOLL); + if let Some(token) = guard.network.health_monitor_token.take() { + token.cancel(); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 4775760..5a5fce0 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -9,6 +9,7 @@ pub mod client_config_manager; pub mod cursor; pub mod doll_editor; pub mod health_manager; +pub mod health_monitor; pub mod interaction; pub mod scene; pub mod sprite_recolor; diff --git a/src-tauri/src/services/ws/client.rs b/src-tauri/src/services/ws/client.rs index cbe1961..f1ea516 100644 --- a/src-tauri/src/services/ws/client.rs +++ b/src-tauri/src/services/ws/client.rs @@ -19,15 +19,27 @@ pub async fn establish_websocket_connection() { for _attempt in 1..=MAX_ATTEMPTS { if get_access_token().await.is_some() { - init_ws_client().await; - return; + if init_ws_client().await { + return; // Success + } else { + // Connection failed, trigger disaster recovery + crate::init::lifecycle::handle_disasterous_failure( + Some("WebSocket connection failed. Please check your network and try again.".to_string()) + ).await; + return; + } } sleep(BACKOFF).await; } + + // If we exhausted retries without valid token + crate::init::lifecycle::handle_disasterous_failure( + Some("Failed to authenticate. Please restart and sign in again.".to_string()) + ).await; } -pub async fn init_ws_client() { +pub async fn init_ws_client() -> bool { let app_config = { let guard = lock_r!(FDOLL); guard.app_config.clone() @@ -40,10 +52,12 @@ pub async fn init_ws_client() { clients.ws_client = Some(ws_client); clients.is_ws_initialized = false; // wait for initialized event } + true } Err(e) => { error!("Failed to initialize WebSocket client: {}", e); clear_ws_client().await; + false } } } @@ -53,6 +67,7 @@ pub async fn clear_ws_client() { if let Some(clients) = guard.network.clients.as_mut() { clients.ws_client = None; clients.is_ws_initialized = false; + clients.ws_emit_failures = 0; } } diff --git a/src-tauri/src/services/ws/connection.rs b/src-tauri/src/services/ws/connection.rs index ab77947..9dc10cf 100644 --- a/src-tauri/src/services/ws/connection.rs +++ b/src-tauri/src/services/ws/connection.rs @@ -2,10 +2,7 @@ use rust_socketio::{Payload, RawClient}; use tracing::info; use crate::{ - lock_w, - services::health_manager::close_health_manager_window, - services::scene::open_scene_window, - state::{init_app_data_scoped, AppDataRefreshScope, FDOLL}, + init::lifecycle::construct_user_session, lock_w, services::health_manager::close_health_manager_window, state::FDOLL }; use super::{types::WS_EVENT, utils}; @@ -26,33 +23,30 @@ pub fn on_connected(_payload: Payload, socket: RawClient) { /// Handler for initialized event pub fn on_initialized(payload: Payload, _socket: RawClient) { if utils::extract_text_value(payload, "initialized").is_ok() { - let needs_data_refresh = check_and_mark_initialized(); - restore_connection_ui(); + let is_reconnection = mark_ws_initialized(); - if needs_data_refresh { - info!("Reconnection detected: refreshing app data"); + if is_reconnection { + info!("Reconnection detected: reconstructing user session"); tauri::async_runtime::spawn(async { - init_app_data_scoped(AppDataRefreshScope::All).await; + construct_user_session().await; }); + } else { + // First-time initialization, just close health manager if open + close_health_manager_window(); } } } -/// Mark WebSocket as initialized and check if app data needs refreshing +/// Mark WebSocket as initialized and check if this is a reconnection. /// /// Returns true if user data is missing (indicating a reconnection /// after session teardown where app data was cleared). -fn check_and_mark_initialized() -> bool { +fn mark_ws_initialized() -> bool { let mut guard = lock_w!(FDOLL); if let Some(clients) = guard.network.clients.as_mut() { clients.is_ws_initialized = true; + clients.ws_emit_failures = 0; } - // If user data is gone, we need to re-fetch everything + // If user data is gone, we need full session reconstruction guard.user_data.user.is_none() } - -/// Restore UI after successful connection -fn restore_connection_ui() { - close_health_manager_window(); - open_scene_window(); -} diff --git a/src-tauri/src/services/ws/emitter.rs b/src-tauri/src/services/ws/emitter.rs index a4dff77..2e5b994 100644 --- a/src-tauri/src/services/ws/emitter.rs +++ b/src-tauri/src/services/ws/emitter.rs @@ -3,7 +3,9 @@ use serde::Serialize; use tauri::{async_runtime, Emitter}; use tracing::{error, warn}; -use crate::{get_app_handle, init::lifecycle::handle_disasterous_failure, lock_r, state::FDOLL}; +use crate::{ + get_app_handle, init::lifecycle::handle_disasterous_failure, lock_r, lock_w, state::FDOLL, +}; /// Acquire WebSocket client and initialization state from app state fn get_ws_state() -> (Option, bool) { @@ -47,6 +49,27 @@ async fn do_emit( } } +async fn handle_soft_emit_failure(err_msg: &str) { + const MAX_FAILURES: u8 = 10; + let should_reinit = { + let mut guard = lock_w!(FDOLL); + if let Some(clients) = guard.network.clients.as_mut() { + clients.ws_emit_failures = clients.ws_emit_failures.saturating_add(1); + clients.ws_emit_failures >= MAX_FAILURES + } else { + false + } + }; + + if should_reinit { + warn!("WebSocket emit failed {} times, reinitializing connection", MAX_FAILURES); + let _ = crate::services::ws::client::clear_ws_client().await; + crate::services::ws::client::establish_websocket_connection().await; + } else { + warn!("[non-critical] {}", err_msg); + } +} + /// Emit critical data to WebSocket server /// /// On failure, triggers disaster recovery (session teardown + health manager). @@ -78,7 +101,7 @@ pub async fn ws_emit_soft( match do_emit(event, payload).await { Ok(_) => Ok(()), Err(err_msg) => { - warn!("[non-critical] {}", err_msg); + handle_soft_emit_failure(&err_msg).await; Err(err_msg) } } diff --git a/src-tauri/src/state/network.rs b/src-tauri/src/state/network.rs index 94b6be7..4275595 100644 --- a/src-tauri/src/state/network.rs +++ b/src-tauri/src/state/network.rs @@ -1,19 +1,22 @@ - - #[derive(Clone)] pub struct Clients { pub http_client: reqwest::Client, pub ws_client: Option, pub is_ws_initialized: bool, + pub ws_emit_failures: u8, } pub struct NetworkState { pub clients: Option, + pub health_monitor_token: Option, } impl Default for NetworkState { fn default() -> Self { - Self { clients: None } + Self { + clients: None, + health_monitor_token: None, + } } } @@ -30,6 +33,8 @@ pub fn init_network_state() -> NetworkState { http_client, ws_client: None, is_ws_initialized: false, + ws_emit_failures: 0, }), + health_monitor_token: None, } -} \ No newline at end of file +}