From bf3bd3dff5212dee8bacbdb8677c51ee9bd2521aadf233d28a7802a33dd3b95e Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Tue, 20 Jan 2026 11:29:22 -0600 Subject: [PATCH] fix: teleport should never show reconnect dialog --- crates/chattyness-db/src/ws_messages.rs | 8 ++ .../chattyness-user-ui/src/api/websocket.rs | 31 ++++- .../src/components/reconnection_overlay.rs | 8 ++ .../src/components/ws_client.rs | 118 ++++++++++++++++-- crates/chattyness-user-ui/src/pages/realm.rs | 8 +- 5 files changed, 153 insertions(+), 20 deletions(-) diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 0068dc7..3dd17fc 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -20,6 +20,14 @@ pub struct WsConfig { pub ping_interval_secs: u64, } +/// WebSocket close codes (custom range: 4000-4999). +pub mod close_codes { + /// Scene change (user navigating to different scene). + pub const SCENE_CHANGE: u16 = 4000; + /// Server timeout (no message received within timeout period). + pub const SERVER_TIMEOUT: u16 = 4001; +} + /// Reason for member disconnect. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 934825c..56f8e08 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -20,14 +20,11 @@ use uuid::Uuid; use chattyness_db::{ models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, queries::{avatars, channel_members, loose_props, realms, scenes}, - ws_messages::{ClientMessage, DisconnectReason, ServerMessage, WsConfig}, + ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig}, }; use chattyness_error::AppError; use chattyness_shared::WebSocketConfig; -/// Close code for scene change (custom code). -const SCENE_CHANGE_CLOSE_CODE: u16 = 4000; - use crate::auth::AuthUser; /// Channel state for broadcasting updates. @@ -397,8 +394,13 @@ async fn handle_socket( // Create recv timeout from config let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs); + // Channel for sending close frame requests from recv_task to send_task + let (close_tx, mut close_rx) = mpsc::channel::<(u16, String)>(1); + // Spawn task to handle incoming messages from client + let close_tx_for_recv = close_tx.clone(); let recv_task = tokio::spawn(async move { + let close_tx = close_tx_for_recv; let pool = pool_for_recv; let ws_state = ws_state_for_recv; let mut disconnect_reason = DisconnectReason::Graceful; @@ -817,7 +819,7 @@ async fn handle_socket( Message::Close(close_frame) => { // Check close code for scene change if let Some(CloseFrame { code, .. }) = close_frame { - if code == SCENE_CHANGE_CLOSE_CODE { + if code == close_codes::SCENE_CHANGE { disconnect_reason = DisconnectReason::SceneChange; } else { disconnect_reason = DisconnectReason::Graceful; @@ -843,6 +845,12 @@ async fn handle_socket( Err(_) => { // Timeout elapsed - connection likely lost tracing::info!("[WS] Connection timeout for user {}", user_id); + // Send close frame with timeout code so client can attempt silent reconnection + let _ = close_tx + .send((close_codes::SERVER_TIMEOUT, "timeout".to_string())) + .await; + // Brief delay to allow close frame to be sent + tokio::time::sleep(Duration::from_millis(100)).await; disconnect_reason = DisconnectReason::Timeout; break; } @@ -852,10 +860,21 @@ async fn handle_socket( (recv_conn, disconnect_reason) }); - // Spawn task to forward broadcasts and direct messages to this client + // Spawn task to forward broadcasts, direct messages, and close frames to this client let send_task = tokio::spawn(async move { loop { tokio::select! { + // Handle close frame requests (from timeout) + Some((code, reason)) = close_rx.recv() => { + #[cfg(debug_assertions)] + tracing::debug!("[WS->Client] Sending close frame: code={}, reason={}", code, reason); + let close_frame = CloseFrame { + code, + reason: reason.into(), + }; + let _ = sender.send(Message::Close(Some(close_frame))).await; + break; + } // Handle broadcast messages Ok(msg) = rx.recv() => { if let Ok(json) = serde_json::to_string(&msg) { diff --git a/crates/chattyness-user-ui/src/components/reconnection_overlay.rs b/crates/chattyness-user-ui/src/components/reconnection_overlay.rs index c74c0cf..43c3f44 100644 --- a/crates/chattyness-user-ui/src/components/reconnection_overlay.rs +++ b/crates/chattyness-user-ui/src/components/reconnection_overlay.rs @@ -112,6 +112,14 @@ pub fn ReconnectionOverlay( } set_overlay_state.set(OverlayState::Hidden); } + WsState::SilentReconnecting(_) => { + // Silent reconnection in progress - keep overlay hidden + // The ws_client handles the reconnection attempts internally + if let Some(timer) = timer_handle.borrow_mut().take() { + drop(timer); + } + set_overlay_state.set(OverlayState::Hidden); + } WsState::Disconnected | WsState::Error => { // Check current state - only start countdown if we're hidden let current = overlay_state.get_untracked(); diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index 8ec1877..a5845ef 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -9,18 +9,21 @@ use leptos::reactive::owner::LocalStorage; #[cfg(feature = "hydrate")] use chattyness_db::models::EmotionState; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp}; -use chattyness_db::ws_messages::ClientMessage; +use chattyness_db::ws_messages::{close_codes, ClientMessage}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::{DisconnectReason, ServerMessage}; use super::chat_types::ChatMessage; -/// Close code for scene change (must match server constant). -pub const SCENE_CHANGE_CLOSE_CODE: u16 = 4000; - /// Duration for fade-out animation in milliseconds. pub const FADE_DURATION_MS: i64 = 5000; +/// Maximum number of silent reconnection attempts before showing overlay. +pub const MAX_SILENT_RECONNECT_ATTEMPTS: u8 = 3; + +/// Delay between silent reconnection attempts in milliseconds. +pub const SILENT_RECONNECT_DELAY_MS: u32 = 1000; + /// A member that is currently fading out after a timeout disconnect. #[derive(Clone, Debug)] pub struct FadingMember { @@ -43,6 +46,9 @@ pub enum WsState { Disconnected, /// Connection error occurred. Error, + /// Silently attempting to reconnect after server timeout. + /// The u8 is the current attempt number (1-based). + SilentReconnecting(u8), } /// Sender function type for WebSocket messages. @@ -91,7 +97,7 @@ pub struct TeleportInfo { pub fn use_channel_websocket( realm_slug: Signal, channel_id: Signal>, - reconnect_trigger: Signal, + reconnect_trigger: RwSignal, on_members_update: Callback>, on_chat_message: Callback, on_loose_props_sync: Callback>, @@ -110,6 +116,11 @@ pub fn use_channel_websocket( let (ws_state, set_ws_state) = signal(WsState::Disconnected); let ws_ref: Rc>> = Rc::new(RefCell::new(None)); let members: Rc>> = Rc::new(RefCell::new(Vec::new())); + // Track current user's ID to ignore self MemberLeft during reconnection + let current_user_id: Rc>> = Rc::new(RefCell::new(None)); + // Flag to track intentional closes (teleport, scene change) - guarantees local state + // even if close code doesn't arrive correctly due to browser/server quirks + let is_intentional_close: Rc> = Rc::new(RefCell::new(false)); // Create a stored sender function (using new_local for WASM single-threaded environment) let ws_ref_for_send = ws_ref.clone(); @@ -129,6 +140,7 @@ pub fn use_channel_websocket( // Effect to manage WebSocket lifecycle let ws_ref_clone = ws_ref.clone(); let members_clone = members.clone(); + let is_intentional_close_for_cleanup = is_intentional_close.clone(); Effect::new(move |_| { let slug = realm_slug.get(); @@ -138,7 +150,14 @@ pub fn use_channel_websocket( // Cleanup previous connection if let Some(old_ws) = ws_ref_clone.borrow_mut().take() { - let _ = old_ws.close(); + #[cfg(debug_assertions)] + web_sys::console::log_1( + &format!("[WS] Closing old connection, readyState={}", old_ws.ready_state()).into(), + ); + // Set flag BEFORE closing - guarantees local state even if close code doesn't arrive + *is_intentional_close_for_cleanup.borrow_mut() = true; + // Close with SCENE_CHANGE code so onclose handler knows this was intentional + let _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change"); } let Some(ch_id) = ch_id else { @@ -205,6 +224,8 @@ pub fn use_channel_websocket( let ws_ref_for_heartbeat = ws_ref.clone(); let heartbeat_started: Rc> = Rc::new(RefCell::new(false)); let heartbeat_started_clone = heartbeat_started.clone(); + // For tracking current user ID to ignore self MemberLeft during reconnection + let current_user_id_for_msg = current_user_id.clone(); let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| { if let Ok(text) = e.data().dyn_into::() { let text: String = text.into(); @@ -219,6 +240,9 @@ pub fn use_channel_websocket( .. } = msg { + // Track current user ID for MemberLeft filtering + *current_user_id_for_msg.borrow_mut() = member.user_id; + if !*heartbeat_started_clone.borrow() { *heartbeat_started_clone.borrow_mut() = true; let ping_interval_ms = config.ping_interval_secs * 1000; @@ -269,6 +293,7 @@ pub fn use_channel_websocket( &on_member_fading_clone, &on_error_clone, &on_teleport_approved_clone, + ¤t_user_id_for_msg, ); } } @@ -278,22 +303,82 @@ pub fn use_channel_websocket( // onerror let set_ws_state_err = set_ws_state; + let ws_state_for_err = ws_state; + let reconnect_trigger_for_error = reconnect_trigger; let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| { #[cfg(debug_assertions)] web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into()); - set_ws_state_err.set(WsState::Error); + + // Check if we're in silent reconnection mode + let current_state = ws_state_for_err.get_untracked(); + if let WsState::SilentReconnecting(attempt) = current_state { + if attempt < MAX_SILENT_RECONNECT_ATTEMPTS { + // Try another silent reconnection + let next_attempt = attempt + 1; + #[cfg(debug_assertions)] + web_sys::console::log_1( + &format!( + "[WS] Silent reconnection attempt {} failed, trying attempt {}", + attempt, next_attempt + ) + .into(), + ); + set_ws_state_err.set(WsState::SilentReconnecting(next_attempt)); + // Schedule next reconnection attempt + let reconnect_trigger = reconnect_trigger_for_error; + gloo_timers::callback::Timeout::new(SILENT_RECONNECT_DELAY_MS, move || { + reconnect_trigger.update(|v| *v = v.wrapping_add(1)); + }) + .forget(); + } else { + // Max attempts reached, fall back to showing overlay + #[cfg(debug_assertions)] + web_sys::console::log_1( + &"[WS] Silent reconnection failed, showing reconnection overlay".into(), + ); + set_ws_state_err.set(WsState::Error); + } + } else { + set_ws_state_err.set(WsState::Error); + } }) as Box); ws.set_onerror(Some(onerror.as_ref().unchecked_ref())); onerror.forget(); // onclose let set_ws_state_close = set_ws_state; + let reconnect_trigger_for_close = reconnect_trigger; + let is_intentional_close_for_onclose = is_intentional_close.clone(); let onclose = Closure::wrap(Box::new(move |e: CloseEvent| { + let code = e.code(); #[cfg(debug_assertions)] web_sys::console::log_1( - &format!("[WS] Closed: code={}, reason={}", e.code(), e.reason()).into(), + &format!("[WS] Closed: code={}, reason={}", code, e.reason()).into(), ); - set_ws_state_close.set(WsState::Disconnected); + + // Handle based on close code with defense-in-depth using flag + if code == close_codes::SERVER_TIMEOUT { + // Server timeout - attempt silent reconnection (highest priority) + #[cfg(debug_assertions)] + web_sys::console::log_1(&"[WS] Server timeout, attempting silent reconnection".into()); + set_ws_state_close.set(WsState::SilentReconnecting(1)); + // Schedule reconnection after delay + let reconnect_trigger = reconnect_trigger_for_close; + gloo_timers::callback::Timeout::new(SILENT_RECONNECT_DELAY_MS, move || { + reconnect_trigger.update(|v| *v = v.wrapping_add(1)); + }) + .forget(); + } else if code == close_codes::SCENE_CHANGE || *is_intentional_close_for_onclose.borrow() { + // Intentional close (scene change/teleport) - don't show disconnection + // Check both code AND flag for defense-in-depth (flag is guaranteed local state) + #[cfg(debug_assertions)] + web_sys::console::log_1(&"[WS] Intentional close, not setting Disconnected".into()); + // Reset the flag for future connections + *is_intentional_close_for_onclose.borrow_mut() = false; + } else { + // Other close codes - treat as disconnection + set_ws_state_close.set(WsState::Disconnected); + } }) as Box); ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); onclose.forget(); @@ -317,6 +402,7 @@ fn handle_server_message( on_member_fading: &Callback, on_error: &Option>, on_teleport_approved: &Option>, + current_user_id: &std::rc::Rc>>, ) { let mut members_vec = members.borrow_mut(); @@ -343,6 +429,18 @@ fn handle_server_message( guest_session_id, reason, } => { + // Check if this is our own MemberLeft due to timeout - ignore it during reconnection + // so we don't see our own avatar fade out + let own_user_id = *current_user_id.borrow(); + let is_self = own_user_id.is_some() && user_id == own_user_id; + if is_self && reason == DisconnectReason::Timeout { + #[cfg(debug_assertions)] + web_sys::console::log_1( + &"[WS] Ignoring self MemberLeft during reconnection".into(), + ); + return; + } + // Find the member before removing let leaving_member = members_vec .iter() @@ -488,7 +586,7 @@ fn handle_server_message( pub fn use_channel_websocket( _realm_slug: Signal, _channel_id: Signal>, - _reconnect_trigger: Signal, + _reconnect_trigger: RwSignal, _on_members_update: Callback>, _on_chat_message: Callback, _on_loose_props_sync: Callback>, diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 980f71f..b2034c4 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -121,7 +121,7 @@ pub fn RealmPage() -> impl IntoView { let (error_message, set_error_message) = signal(Option::::None); // Reconnection trigger - increment to force WebSocket reconnection - let (reconnect_trigger, set_reconnect_trigger) = signal(0u32); + let reconnect_trigger = RwSignal::new(0u32); // Current scene (changes when teleporting) let (current_scene, set_current_scene) = signal(Option::::None); @@ -383,7 +383,7 @@ pub fn RealmPage() -> impl IntoView { set_members.set(Vec::new()); // Trigger a reconnect to ensure fresh connection - set_reconnect_trigger.update(|t| *t += 1); + reconnect_trigger.update(|t| *t += 1); }); }); @@ -391,7 +391,7 @@ pub fn RealmPage() -> impl IntoView { let (ws_state, ws_sender) = use_channel_websocket( slug, Signal::derive(move || channel_id.get()), - Signal::derive(move || reconnect_trigger.get()), + reconnect_trigger, on_members_update, on_chat_message, on_loose_props_sync, @@ -1087,7 +1087,7 @@ pub fn RealmPage() -> impl IntoView { }