fixed WS race condition issue

This commit is contained in:
2025-12-25 14:12:39 +08:00
parent 42f798c8b7
commit b168d674bd
5 changed files with 50 additions and 67 deletions

View File

@@ -1,11 +1,13 @@
use std::time::Duration; use std::time::Duration;
use tokio::time::{sleep, Instant}; use tokio::time::{sleep, Instant};
use tracing::info; use tracing::info;
use crate::{ use crate::{
services::{ services::{
auth::get_tokens, auth::{get_access_token, get_tokens},
scene::{close_splash_window, open_scene_window, open_splash_window}, scene::{close_splash_window, open_scene_window, open_splash_window},
ws::init_ws_client,
}, },
state::init_app_data, state::init_app_data,
system_tray::init_system_tray, system_tray::init_system_tray,
@@ -16,30 +18,31 @@ pub async fn start_fdoll() {
bootstrap().await; bootstrap().await;
} }
async fn init_ws_after_auth() {
const MAX_ATTEMPTS: u8 = 5;
const BACKOFF: Duration = Duration::from_millis(300);
for attempt in 1..=MAX_ATTEMPTS {
if get_access_token().await.is_some() {
init_ws_client().await;
return;
}
sleep(BACKOFF).await;
}
}
async fn construct_app() { async fn construct_app() {
open_splash_window(); open_splash_window();
// Record start time for minimum splash duration // Record start time for minimum splash duration
let start = Instant::now(); let start = Instant::now();
// Spawn initialization tasks in parallel // Initialize app data first so we only start WebSocket after auth is fully available
// We want to wait for them to finish, but they run concurrently
let init_data = tauri::async_runtime::spawn(async {
init_app_data().await; init_app_data().await;
});
let init_ws = tauri::async_runtime::spawn(async { // Initialize WebSocket client after we know auth is present
// init_ws_client calls get_access_token().await. init_ws_after_auth().await;
// During a fresh login, this token might be in the process of being saved/refreshed
// or the client initialization might be racing.
// However, construct_app is called after auth success, so tokens should be there.
// The issue might be that init_ws_client is idempotent but if called twice or early...
// Actually, init_ws_client handles creating the socket.
crate::services::ws::init_ws_client().await;
});
// Wait for both to complete
let _ = tokio::join!(init_data, init_ws);
// Ensure splash stays visible for at least 3 seconds // Ensure splash stays visible for at least 3 seconds
let elapsed = start.elapsed(); let elapsed = start.elapsed();
@@ -54,16 +57,15 @@ async fn construct_app() {
pub async fn bootstrap() { pub async fn bootstrap() {
match get_tokens().await { match get_tokens().await {
Some(_) => { Some(tokens) => {
info!("User session restored"); info!("Tokens found in keyring - restoring user session");
construct_app().await; construct_app().await;
} }
None => { None => {
info!("No active session, user needs to authenticate"); info!("No active session found - user needs to authenticate");
match crate::services::auth::init_auth_code_retrieval(|| { match crate::services::auth::init_auth_code_retrieval(|| {
info!("Authentication successful, creating scene..."); info!("Authentication successful, creating scene...");
tauri::async_runtime::spawn(async { tauri::async_runtime::spawn(async {
info!("Creating scene after auth success...");
construct_app().await; construct_app().await;
}); });
}) { }) {

View File

@@ -60,11 +60,10 @@ fn on_initialized(payload: Payload, _socket: RawClient) {
if let Some(first_value) = values.first() { if let Some(first_value) = values.first() {
info!("Received initialized event: {:?}", first_value); info!("Received initialized event: {:?}", first_value);
// Mark WebSocket as initialized // Mark WebSocket as initialized and reset backoff timer
let mut guard = lock_w!(FDOLL); let mut guard = lock_w!(FDOLL);
if let Some(clients) = guard.clients.as_mut() { if let Some(clients) = guard.clients.as_mut() {
clients.is_ws_initialized = true; clients.is_ws_initialized = true;
info!("WebSocket marked as initialized and ready for business");
} }
} else { } else {
info!("Received initialized event with empty payload"); info!("Received initialized event with empty payload");
@@ -239,8 +238,6 @@ fn on_friend_active_doll_changed(payload: Payload, _socket: RawClient) {
get_app_handle().emit(WS_EVENT::FRIEND_ACTIVE_DOLL_CHANGED, first_value) get_app_handle().emit(WS_EVENT::FRIEND_ACTIVE_DOLL_CHANGED, first_value)
{ {
error!("Failed to emit friend-active-doll-changed event: {:?}", e); error!("Failed to emit friend-active-doll-changed event: {:?}", e);
} else {
info!("Emitted friend-active-doll-changed to frontend");
} }
// Refresh friends list only (optimized - friend's active doll is part of friends data) // Refresh friends list only (optimized - friend's active doll is part of friends data)
@@ -353,20 +350,23 @@ fn on_doll_deleted(payload: Payload, _socket: RawClient) {
pub async fn report_cursor_data(cursor_position: CursorPosition) { pub async fn report_cursor_data(cursor_position: CursorPosition) {
// Only attempt to get clients if lock_r succeeds (it should, but safety first) // Only attempt to get clients if lock_r succeeds (it should, but safety first)
// and if clients are actually initialized. // and if clients are actually initialized.
let client_opt = { let (client_opt, is_initialized) = {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
if let Some(clients) = &guard.clients { if let Some(clients) = &guard.clients {
if clients.is_ws_initialized { (
clients.ws_client.as_ref().cloned() clients.ws_client.as_ref().cloned(),
clients.is_ws_initialized,
)
} else { } else {
None (None, false)
}
} else {
None
} }
}; };
if let Some(client) = client_opt { if let Some(client) = client_opt {
if !is_initialized {
return;
}
match async_runtime::spawn_blocking(move || { match async_runtime::spawn_blocking(move || {
client.emit( client.emit(
WS_EVENT::CURSOR_REPORT_POSITION, WS_EVENT::CURSOR_REPORT_POSITION,
@@ -379,8 +379,6 @@ pub async fn report_cursor_data(cursor_position: CursorPosition) {
Ok(Err(e)) => error!("Failed to emit cursor report: {}", e), Ok(Err(e)) => error!("Failed to emit cursor report: {}", e),
Err(e) => error!("Failed to execute blocking task for cursor report: {}", e), Err(e) => error!("Failed to execute blocking task for cursor report: {}", e),
} }
} else {
// Quietly fail if client is not ready (e.g. during startup/shutdown or before initialization)
} }
} }
@@ -395,8 +393,8 @@ pub async fn init_ws_client() {
let mut guard = lock_w!(FDOLL); let mut guard = lock_w!(FDOLL);
if let Some(clients) = guard.clients.as_mut() { if let Some(clients) = guard.clients.as_mut() {
clients.ws_client = Some(ws_client); clients.ws_client = Some(ws_client);
clients.is_ws_initialized = false; // wait for initialized event
} }
info!("WebSocket client initialized after authentication");
} }
Err(e) => { Err(e) => {
error!("Failed to initialize WebSocket client: {}", e); error!("Failed to initialize WebSocket client: {}", e);
@@ -415,8 +413,6 @@ pub async fn build_ws_client(
None => return Err("No access token available for WebSocket connection".to_string()), None => return Err("No access token available for WebSocket connection".to_string()),
}; };
info!("Building WebSocket client with authentication");
let api_base_url = app_config let api_base_url = app_config
.api_base_url .api_base_url
.clone() .clone()

View File

@@ -25,7 +25,9 @@ type FriendCursorData = {
// The exported store will only expose the position part to consumers, // The exported store will only expose the position part to consumers,
// but internally we manage the full data. // but internally we manage the full data.
// Actually, it's easier if we just export the positions and manage state internally. // Actually, it's easier if we just export the positions and manage state internally.
export let friendsCursorPositions = writable<Record<string, CursorPositions>>({}); export let friendsCursorPositions = writable<Record<string, CursorPositions>>(
{},
);
export let friendsActiveDolls = writable<Record<string, DollDto | null>>({}); export let friendsActiveDolls = writable<Record<string, DollDto | null>>({});
let unlistenCursor: UnlistenFn | null = null; let unlistenCursor: UnlistenFn | null = null;
@@ -90,10 +92,7 @@ export async function initCursorTracking() {
try { try {
payload = JSON.parse(payload); payload = JSON.parse(payload);
} catch (e) { } catch (e) {
console.error( console.error("Failed to parse friend disconnected payload:", e);
"[Cursor] Failed to parse friend disconnected payload:",
e,
);
return; return;
} }
} }
@@ -129,7 +128,7 @@ export async function initCursorTracking() {
data = JSON.parse(data); data = JSON.parse(data);
} catch (e) { } catch (e) {
console.error( console.error(
"[Cursor] Failed to parse friend-active-doll-changed payload:", "Failed to parse friend-active-doll-changed payload:",
e, e,
); );
return; return;
@@ -139,16 +138,8 @@ export async function initCursorTracking() {
// Cast to expected type after parsing // Cast to expected type after parsing
const payload = data as { friendId: string; doll: DollDto | null }; const payload = data as { friendId: string; doll: DollDto | null };
console.log(
"[Cursor] Received friend-active-doll-changed event:",
payload,
);
if (!payload.doll) { if (!payload.doll) {
// If doll is null, it means the friend deactivated their doll. // If doll is null, it means the friend deactivated their doll.
console.log(
`[Cursor] Removing doll for friend ${payload.friendId} due to deactivation`,
);
// Update the active dolls store to explicitly set this friend's doll to null // Update the active dolls store to explicitly set this friend's doll to null
// We MUST set it to null instead of deleting it, otherwise the UI might // We MUST set it to null instead of deleting it, otherwise the UI might
@@ -167,10 +158,6 @@ export async function initCursorTracking() {
}); });
} else { } else {
// Update or add the new doll configuration // Update or add the new doll configuration
console.log(
`[Cursor] Updating doll for friend ${payload.friendId}:`,
payload.doll,
);
friendsActiveDolls.update((current) => { friendsActiveDolls.update((current) => {
return { return {
...current, ...current,
@@ -182,7 +169,7 @@ export async function initCursorTracking() {
isListening = true; isListening = true;
} catch (err) { } catch (err) {
console.error("[Cursor] Failed to initialize cursor tracking:", err); console.error("Failed to initialize cursor tracking:", err);
throw err; throw err;
} }
} }

View File

@@ -11,7 +11,7 @@
await initCursorTracking(); await initCursorTracking();
await initAppDataListener(); await initAppDataListener();
} catch (err) { } catch (err) {
console.error("[Scene] Failed to initialize event listeners:", err); console.error("Failed to initialize event listeners:", err);
} }
}); });

View File

@@ -17,13 +17,10 @@
} }
function getFriendDollConfig(userId: string) { function getFriendDollConfig(userId: string) {
// 1. Try to get from real-time store (most up-to-date)
// Check if key exists to distinguish between "unknown" (undefined) and "no doll" (null)
if (userId in $friendsActiveDolls) { if (userId in $friendsActiveDolls) {
return $friendsActiveDolls[userId]?.configuration; return $friendsActiveDolls[userId]?.configuration;
} }
// 2. Fallback to initial app data (snapshot on load)
const friend = $appData?.friends?.find((f) => f.friend.id === userId); const friend = $appData?.friends?.find((f) => f.friend.id === userId);
return friend?.friend.activeDoll?.configuration; return friend?.friend.activeDoll?.configuration;
} }
@@ -55,6 +52,7 @@
<p class="text-sm font-semibold opacity-75">Friends Online</p> <p class="text-sm font-semibold opacity-75">Friends Online</p>
<div> <div>
{#each Object.entries($friendsCursorPositions) as [userId, position]} {#each Object.entries($friendsCursorPositions) as [userId, position]}
{@const dollConfig = getFriendDollConfig(userId)}
<div <div
class="bg-base-200/50 p-2 rounded text-xs text-left flex gap-2 flex-col" class="bg-base-200/50 p-2 rounded text-xs text-left flex gap-2 flex-col"
> >