fix: teleport should never show reconnect dialog

This commit is contained in:
Evan Carroll 2026-01-20 11:29:22 -06:00
parent 32e5e42462
commit bf3bd3dff5
5 changed files with 153 additions and 20 deletions

View file

@ -20,6 +20,14 @@ pub struct WsConfig {
pub ping_interval_secs: u64, 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. /// Reason for member disconnect.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]

View file

@ -20,14 +20,11 @@ use uuid::Uuid;
use chattyness_db::{ use chattyness_db::{
models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
queries::{avatars, channel_members, loose_props, realms, scenes}, 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_error::AppError;
use chattyness_shared::WebSocketConfig; use chattyness_shared::WebSocketConfig;
/// Close code for scene change (custom code).
const SCENE_CHANGE_CLOSE_CODE: u16 = 4000;
use crate::auth::AuthUser; use crate::auth::AuthUser;
/// Channel state for broadcasting updates. /// Channel state for broadcasting updates.
@ -397,8 +394,13 @@ async fn handle_socket(
// Create recv timeout from config // Create recv timeout from config
let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs); 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 // Spawn task to handle incoming messages from client
let close_tx_for_recv = close_tx.clone();
let recv_task = tokio::spawn(async move { let recv_task = tokio::spawn(async move {
let close_tx = close_tx_for_recv;
let pool = pool_for_recv; let pool = pool_for_recv;
let ws_state = ws_state_for_recv; let ws_state = ws_state_for_recv;
let mut disconnect_reason = DisconnectReason::Graceful; let mut disconnect_reason = DisconnectReason::Graceful;
@ -817,7 +819,7 @@ async fn handle_socket(
Message::Close(close_frame) => { Message::Close(close_frame) => {
// Check close code for scene change // Check close code for scene change
if let Some(CloseFrame { code, .. }) = close_frame { if let Some(CloseFrame { code, .. }) = close_frame {
if code == SCENE_CHANGE_CLOSE_CODE { if code == close_codes::SCENE_CHANGE {
disconnect_reason = DisconnectReason::SceneChange; disconnect_reason = DisconnectReason::SceneChange;
} else { } else {
disconnect_reason = DisconnectReason::Graceful; disconnect_reason = DisconnectReason::Graceful;
@ -843,6 +845,12 @@ async fn handle_socket(
Err(_) => { Err(_) => {
// Timeout elapsed - connection likely lost // Timeout elapsed - connection likely lost
tracing::info!("[WS] Connection timeout for user {}", user_id); 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; disconnect_reason = DisconnectReason::Timeout;
break; break;
} }
@ -852,10 +860,21 @@ async fn handle_socket(
(recv_conn, disconnect_reason) (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 { let send_task = tokio::spawn(async move {
loop { loop {
tokio::select! { 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 // Handle broadcast messages
Ok(msg) = rx.recv() => { Ok(msg) = rx.recv() => {
if let Ok(json) = serde_json::to_string(&msg) { if let Ok(json) = serde_json::to_string(&msg) {

View file

@ -112,6 +112,14 @@ pub fn ReconnectionOverlay(
} }
set_overlay_state.set(OverlayState::Hidden); 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 => { WsState::Disconnected | WsState::Error => {
// Check current state - only start countdown if we're hidden // Check current state - only start countdown if we're hidden
let current = overlay_state.get_untracked(); let current = overlay_state.get_untracked();

View file

@ -9,18 +9,21 @@ use leptos::reactive::owner::LocalStorage;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::models::EmotionState; use chattyness_db::models::EmotionState;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp}; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp};
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::{close_codes, ClientMessage};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::{DisconnectReason, ServerMessage}; use chattyness_db::ws_messages::{DisconnectReason, ServerMessage};
use super::chat_types::ChatMessage; 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. /// Duration for fade-out animation in milliseconds.
pub const FADE_DURATION_MS: i64 = 5000; 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. /// A member that is currently fading out after a timeout disconnect.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct FadingMember { pub struct FadingMember {
@ -43,6 +46,9 @@ pub enum WsState {
Disconnected, Disconnected,
/// Connection error occurred. /// Connection error occurred.
Error, 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. /// Sender function type for WebSocket messages.
@ -91,7 +97,7 @@ pub struct TeleportInfo {
pub fn use_channel_websocket( pub fn use_channel_websocket(
realm_slug: Signal<String>, realm_slug: Signal<String>,
channel_id: Signal<Option<uuid::Uuid>>, channel_id: Signal<Option<uuid::Uuid>>,
reconnect_trigger: Signal<u32>, reconnect_trigger: RwSignal<u32>,
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>, on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
on_chat_message: Callback<ChatMessage>, on_chat_message: Callback<ChatMessage>,
on_loose_props_sync: Callback<Vec<LooseProp>>, on_loose_props_sync: Callback<Vec<LooseProp>>,
@ -110,6 +116,11 @@ pub fn use_channel_websocket(
let (ws_state, set_ws_state) = signal(WsState::Disconnected); let (ws_state, set_ws_state) = signal(WsState::Disconnected);
let ws_ref: Rc<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None)); let ws_ref: Rc<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None));
let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new())); let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new()));
// Track current user's ID to ignore self MemberLeft during reconnection
let current_user_id: Rc<RefCell<Option<uuid::Uuid>>> = 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<RefCell<bool>> = Rc::new(RefCell::new(false));
// Create a stored sender function (using new_local for WASM single-threaded environment) // Create a stored sender function (using new_local for WASM single-threaded environment)
let ws_ref_for_send = ws_ref.clone(); let ws_ref_for_send = ws_ref.clone();
@ -129,6 +140,7 @@ pub fn use_channel_websocket(
// Effect to manage WebSocket lifecycle // Effect to manage WebSocket lifecycle
let ws_ref_clone = ws_ref.clone(); let ws_ref_clone = ws_ref.clone();
let members_clone = members.clone(); let members_clone = members.clone();
let is_intentional_close_for_cleanup = is_intentional_close.clone();
Effect::new(move |_| { Effect::new(move |_| {
let slug = realm_slug.get(); let slug = realm_slug.get();
@ -138,7 +150,14 @@ pub fn use_channel_websocket(
// Cleanup previous connection // Cleanup previous connection
if let Some(old_ws) = ws_ref_clone.borrow_mut().take() { 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 { 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 ws_ref_for_heartbeat = ws_ref.clone();
let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false)); let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
let heartbeat_started_clone = heartbeat_started.clone(); 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| { let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() { if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
let text: String = text.into(); let text: String = text.into();
@ -219,6 +240,9 @@ pub fn use_channel_websocket(
.. ..
} = msg } = msg
{ {
// Track current user ID for MemberLeft filtering
*current_user_id_for_msg.borrow_mut() = member.user_id;
if !*heartbeat_started_clone.borrow() { if !*heartbeat_started_clone.borrow() {
*heartbeat_started_clone.borrow_mut() = true; *heartbeat_started_clone.borrow_mut() = true;
let ping_interval_ms = config.ping_interval_secs * 1000; let ping_interval_ms = config.ping_interval_secs * 1000;
@ -269,6 +293,7 @@ pub fn use_channel_websocket(
&on_member_fading_clone, &on_member_fading_clone,
&on_error_clone, &on_error_clone,
&on_teleport_approved_clone, &on_teleport_approved_clone,
&current_user_id_for_msg,
); );
} }
} }
@ -278,22 +303,82 @@ pub fn use_channel_websocket(
// onerror // onerror
let set_ws_state_err = set_ws_state; 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| { let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into()); web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into());
// 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); set_ws_state_err.set(WsState::Error);
}
} else {
set_ws_state_err.set(WsState::Error);
}
}) as Box<dyn FnMut(ErrorEvent)>); }) as Box<dyn FnMut(ErrorEvent)>);
ws.set_onerror(Some(onerror.as_ref().unchecked_ref())); ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
onerror.forget(); onerror.forget();
// onclose // onclose
let set_ws_state_close = set_ws_state; 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 onclose = Closure::wrap(Box::new(move |e: CloseEvent| {
let code = e.code();
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
web_sys::console::log_1( web_sys::console::log_1(
&format!("[WS] Closed: code={}, reason={}", e.code(), e.reason()).into(), &format!("[WS] Closed: code={}, reason={}", code, e.reason()).into(),
); );
// 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); set_ws_state_close.set(WsState::Disconnected);
}
}) as Box<dyn FnMut(CloseEvent)>); }) as Box<dyn FnMut(CloseEvent)>);
ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
onclose.forget(); onclose.forget();
@ -317,6 +402,7 @@ fn handle_server_message(
on_member_fading: &Callback<FadingMember>, on_member_fading: &Callback<FadingMember>,
on_error: &Option<Callback<WsError>>, on_error: &Option<Callback<WsError>>,
on_teleport_approved: &Option<Callback<TeleportInfo>>, on_teleport_approved: &Option<Callback<TeleportInfo>>,
current_user_id: &std::rc::Rc<std::cell::RefCell<Option<uuid::Uuid>>>,
) { ) {
let mut members_vec = members.borrow_mut(); let mut members_vec = members.borrow_mut();
@ -343,6 +429,18 @@ fn handle_server_message(
guest_session_id, guest_session_id,
reason, 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 // Find the member before removing
let leaving_member = members_vec let leaving_member = members_vec
.iter() .iter()
@ -488,7 +586,7 @@ fn handle_server_message(
pub fn use_channel_websocket( pub fn use_channel_websocket(
_realm_slug: Signal<String>, _realm_slug: Signal<String>,
_channel_id: Signal<Option<uuid::Uuid>>, _channel_id: Signal<Option<uuid::Uuid>>,
_reconnect_trigger: Signal<u32>, _reconnect_trigger: RwSignal<u32>,
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>, _on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
_on_chat_message: Callback<ChatMessage>, _on_chat_message: Callback<ChatMessage>,
_on_loose_props_sync: Callback<Vec<LooseProp>>, _on_loose_props_sync: Callback<Vec<LooseProp>>,

View file

@ -121,7 +121,7 @@ pub fn RealmPage() -> impl IntoView {
let (error_message, set_error_message) = signal(Option::<String>::None); let (error_message, set_error_message) = signal(Option::<String>::None);
// Reconnection trigger - increment to force WebSocket reconnection // 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) // Current scene (changes when teleporting)
let (current_scene, set_current_scene) = signal(Option::<Scene>::None); let (current_scene, set_current_scene) = signal(Option::<Scene>::None);
@ -383,7 +383,7 @@ pub fn RealmPage() -> impl IntoView {
set_members.set(Vec::new()); set_members.set(Vec::new());
// Trigger a reconnect to ensure fresh connection // 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( let (ws_state, ws_sender) = use_channel_websocket(
slug, slug,
Signal::derive(move || channel_id.get()), Signal::derive(move || channel_id.get()),
Signal::derive(move || reconnect_trigger.get()), reconnect_trigger,
on_members_update, on_members_update,
on_chat_message, on_chat_message,
on_loose_props_sync, on_loose_props_sync,
@ -1087,7 +1087,7 @@ pub fn RealmPage() -> impl IntoView {
<ReconnectionOverlay <ReconnectionOverlay
ws_state=ws_state_for_overlay ws_state=ws_state_for_overlay
on_reconnect=Callback::new(move |_: ()| { on_reconnect=Callback::new(move |_: ()| {
set_reconnect_trigger.update(|t| *t += 1); reconnect_trigger.update(|t| *t += 1);
}) })
/> />
} }