Skip to main content

rpfm_server/
session.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Per-client session state and lifecycle.
12//!
13//! Each WebSocket connection (and each MCP client) is wrapped in a [`Session`]
14//! managed by a [`SessionManager`]. Sessions are isolated: open packs in one
15//! session aren't visible from another, and each one owns a dedicated
16//! background thread (see [`crate::background_thread`]) that processes its
17//! commands serially. The one exception is application settings, which live
18//! in [`crate::settings::SETTINGS`], a single store shared by every session
19//! in this process.
20//!
21//! ## Lifecycle
22//!
23//! 1. **Create.** A new session gets a unique [`SessionId`] from the manager
24//!    plus a fresh background thread spawned on the `tokio` runtime.
25//! 2. **Connect / disconnect.** Clients increment [`Session::connect`] on
26//!    attach and [`Session::disconnect`] on detach. The connection count is
27//!    what the timeout logic watches.
28//! 3. **Reconnect.** A client can pass its previous `session_id` back on the
29//!    next WebSocket handshake to adopt the same session and recover its
30//!    in-memory state. See [`SessionManager::get_or_create_session`].
31//! 4. **Timeout.** When the connection count drops to zero, the session
32//!    enters a [`DEFAULT_SESSION_TIMEOUT_SECS`]-long grace period. Reconnects
33//!    cancel the timeout; otherwise the cleanup task removes the session and
34//!    its background thread exits.
35//! 5. **Empty manager → process exit.** When the last session is removed the
36//!    server process terminates, so no orphaned backend lingers in the
37//!    background.
38//!
39//! ## MCP sessions
40//!
41//! MCP clients have no disconnect signal the server can rely on: the MCP
42//! transport keeps a session registered until the client sends an HTTP
43//! DELETE, which many clients never send. Sessions created for MCP clients
44//! ([`SessionManager::create_mcp_session`]) are therefore never counted as
45//! connected; instead, the periodic cleanup task reaps them once no command
46//! has been sent through them for [`DEFAULT_SESSION_TIMEOUT_SECS`].
47
48use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
49use tokio::time::{Duration, Instant};
50
51use std::collections::HashMap;
52use std::sync::{Arc, Mutex, RwLock, atomic::{AtomicBool, AtomicU32, Ordering}};
53
54use rpfm_ipc::helpers::SessionInfo;
55use rpfm_ipc::messages::{Command, Response};
56use rpfm_telemetry::info;
57
58use crate::background_thread;
59
60/// Error messages for session communication.
61pub const SESSION_SENDER_ERROR: &str = "Error in session communication system. Sender failed to send message.";
62
63/// Default session timeout in seconds (5 minutes).
64pub const DEFAULT_SESSION_TIMEOUT_SECS: u64 = 300;
65
66//-------------------------------------------------------------------------------//
67//                              Enums & Structs
68//-------------------------------------------------------------------------------//
69
70/// Unique identifier for a session.
71pub type SessionId = u64;
72
73/// Manages all active sessions.
74///
75/// Provides thread-safe access to create, retrieve, and remove sessions.
76/// Sessions persist for a configurable timeout after all clients disconnect.
77pub struct SessionManager {
78
79    /// Map of session IDs to managed sessions.
80    sessions: Mutex<HashMap<SessionId, ManagedSession>>,
81
82    /// Counter for generating unique session IDs.
83    next_id: Mutex<SessionId>,
84
85    /// Session timeout duration.
86    timeout: Duration,
87}
88
89/// Internal state for a managed session.
90struct ManagedSession {
91
92    /// The session itself.
93    session: Arc<Session>,
94
95    /// When the last client disconnected (None if clients are connected).
96    disconnected_at: Option<Instant>,
97}
98
99/// A session represents a single client's connection state.
100///
101/// Each session has its own background thread for processing commands,
102/// ensuring complete isolation between clients.
103pub struct Session {
104
105    /// Unique identifier for this session.
106    id: SessionId,
107
108    /// Whether this session was created for an MCP client.
109    ///
110    /// MCP sessions have no disconnect signal (the MCP transport keeps the
111    /// session registered until the client sends an HTTP DELETE, which many
112    /// clients never do), so they are reaped based on command inactivity
113    /// instead of connection count.
114    is_mcp: bool,
115
116    /// Instant of the last command sent to this session's background thread.
117    ///
118    /// Only updated by [`Session::send`], so it reflects actual work, not
119    /// transport-level pings.
120    last_activity: Mutex<Instant>,
121
122    /// Sender to communicate with this session's background thread.
123    sender: UnboundedSender<(UnboundedSender<Response>, Command)>,
124
125    /// Number of active connections using this session.
126    connection_count: AtomicU32,
127
128    /// Whether this session has been marked for shutdown.
129    shutdown_requested: AtomicBool,
130
131    /// Names of the pack files currently open in this session.
132    pack_names: RwLock<Vec<String>>,
133}
134
135//-------------------------------------------------------------------------------//
136//                             Implementations
137//-------------------------------------------------------------------------------//
138
139impl Session {
140
141    /// Create a new session with its own background thread.
142    ///
143    /// `is_mcp` marks sessions created for MCP clients, which are reaped by
144    /// inactivity instead of by connection count (see [`Session::is_mcp`]).
145    pub fn new(id: SessionId, is_mcp: bool) -> Arc<Self> {
146        let (sender, receiver) = unbounded_channel();
147
148        let session = Arc::new(Self {
149            id,
150            is_mcp,
151            last_activity: Mutex::new(Instant::now()),
152            sender,
153            connection_count: AtomicU32::new(0),
154            shutdown_requested: AtomicBool::new(false),
155            pack_names: RwLock::new(Vec::new()),
156        });
157
158        // Spawn a dedicated background thread for this session.
159        let session_clone = session.clone();
160        tokio::spawn(async move {
161            info!("Session {} background thread starting...", id);
162            background_thread::background_loop(receiver, session_clone).await;
163            info!("Session {} background thread terminated.", id);
164        });
165
166        session
167    }
168
169    /// Get the session ID.
170    pub fn id(&self) -> SessionId {
171        self.id
172    }
173
174    /// Whether this session was created for an MCP client.
175    pub fn is_mcp(&self) -> bool {
176        self.is_mcp
177    }
178
179    /// Record that a command was just sent to this session's background thread.
180    fn touch(&self) {
181        *self.last_activity.lock().unwrap() = Instant::now();
182    }
183
184    /// Get the instant of the last command sent to this session.
185    pub fn last_activity(&self) -> Instant {
186        *self.last_activity.lock().unwrap()
187    }
188
189    /// Increment the connection count.
190    pub fn connect(&self) {
191        self.connection_count.fetch_add(1, Ordering::SeqCst);
192    }
193
194    /// Decrement the connection count.
195    pub fn disconnect(&self) {
196        self.connection_count.fetch_sub(1, Ordering::SeqCst);
197    }
198
199    /// Get the current connection count.
200    pub fn connection_count(&self) -> u32 {
201        self.connection_count.load(Ordering::SeqCst)
202    }
203
204    /// Check if shutdown has been requested.
205    pub fn is_shutdown_requested(&self) -> bool {
206        self.shutdown_requested.load(Ordering::SeqCst)
207    }
208
209    /// Get the pack names for this session.
210    pub fn pack_names(&self) -> Vec<String> {
211        self.pack_names.read().unwrap().clone()
212    }
213
214    /// Add a pack name to this session.
215    pub fn add_pack_name(&self, name: &str) {
216        let mut names = self.pack_names.write().unwrap();
217        if !names.contains(&name.to_string()) {
218            names.push(name.to_string());
219        }
220    }
221
222    /// Remove a pack name from this session.
223    pub fn remove_pack_name(&self, name: &str) {
224        let mut names = self.pack_names.write().unwrap();
225        names.retain(|n| n != name);
226    }
227
228    /// Shutdown this session by sending an Exit command.
229    pub fn shutdown(&self) {
230        info!("Session {} shutting down...", self.id);
231
232        if self.shutdown_requested.swap(true, Ordering::SeqCst) {
233            info!("Session {} already marked for shutdown before...", self.id);
234            return;
235        }
236
237        // Send exit command - ignore errors if channel is already closed.
238        let (sender_back, _) = unbounded_channel();
239        let _ = self.sender.send((sender_back, Command::Exit));
240    }
241
242    /// Send a command to this session's background thread.
243    ///
244    /// Returns a receiver to get the response.
245    pub fn send(&self, command: Command) -> UnboundedReceiver<Response> {
246        self.touch();
247        let (sender_back, receiver_back) = unbounded_channel();
248        if let Err(error) = self.sender.send((sender_back, command)) {
249            let message = format!("{SESSION_SENDER_ERROR}: {error}");
250            info!("{message}");
251            let (sender_back, _) = error.0;
252            let _ = sender_back.send(Response::Error(message));
253        }
254        receiver_back
255    }
256}
257
258impl Default for SessionManager {
259    fn default() -> Self {
260        Self {
261            sessions: Mutex::new(HashMap::new()),
262            next_id: Mutex::new(1),
263            timeout: Duration::from_secs(DEFAULT_SESSION_TIMEOUT_SECS),
264        }
265    }
266}
267
268impl SessionManager {
269
270    /// Create a new session and return a reference to it.
271    pub fn create_session(&self) -> Arc<Session> {
272        self.create_session_internal(false)
273    }
274
275    /// Create a new session for an MCP client and return a reference to it.
276    ///
277    /// MCP sessions are never counted as connected: the MCP transport keeps
278    /// them registered until the client sends an HTTP DELETE, which many
279    /// clients never send. Instead, they are reaped by [`SessionManager::cleanup_expired_sessions`]
280    /// once no command has been sent through them for [`DEFAULT_SESSION_TIMEOUT_SECS`].
281    pub fn create_mcp_session(&self) -> Arc<Session> {
282        self.create_session_internal(true)
283    }
284
285    fn create_session_internal(&self, is_mcp: bool) -> Arc<Session> {
286        let id = {
287            let mut next_id = self.next_id.lock().unwrap();
288            let id = *next_id;
289            *next_id += 1;
290            id
291        };
292
293        let session = Session::new(id, is_mcp);
294
295        // MCP sessions start disconnected: their lifetime is governed by
296        // command inactivity, not by the connection count.
297        if !is_mcp {
298            session.connect();
299        }
300
301        self.sessions.lock().unwrap().insert(id, ManagedSession {
302            session: session.clone(),
303            disconnected_at: None,
304        });
305
306        info!("Created new {} session with ID: {}", if is_mcp { "MCP" } else { "WebSocket" }, id);
307        session
308    }
309
310    /// Get an existing session by ID, or create a new one if the ID doesn't exist.
311    ///
312    /// If `session_id` is `Some`, attempts to retrieve that session.
313    /// If the session doesn't exist or `session_id` is `None`, creates a new session.
314    ///
315    /// Returns the session and whether it was newly created.
316    pub fn get_or_create_session(&self, session_id: Option<SessionId>) -> (Arc<Session>, bool) {
317        if let Some(id) = session_id {
318            let mut sessions = self.sessions.lock().unwrap();
319            if let Some(managed) = sessions.get_mut(&id) {
320
321                // Check if the session is still valid (not shut down).
322                if !managed.session.is_shutdown_requested() {
323                    managed.session.connect();
324                    managed.disconnected_at = None;
325                    info!("Client reconnected to existing session {}", id);
326                    return (managed.session.clone(), false);
327                }
328            }
329        }
330
331        // Either no session_id provided, or session not found/invalid.
332        // Create a new session.
333        (self.create_session(), true)
334    }
335
336    /// Get a session by ID without incrementing the connection count.
337    pub fn get_session(&self, id: SessionId) -> Option<Arc<Session>> {
338        let sessions = self.sessions.lock().unwrap();
339        sessions.get(&id).map(|m| m.session.clone())
340    }
341
342    /// Mark a session as disconnected by a client.
343    ///
344    /// If no more clients are connected, starts the timeout countdown.
345    /// The session will be removed after the timeout unless a client reconnects.
346    pub fn client_disconnected(manager: Arc<Self>, id: SessionId) {
347        let should_schedule_cleanup = {
348            let mut sessions = manager.sessions.lock().unwrap();
349            if let Some(managed) = sessions.get_mut(&id) {
350                managed.session.disconnect();
351
352                if managed.session.connection_count() == 0 {
353                    managed.disconnected_at = Some(Instant::now());
354                    info!("Session {} has no active connections, will timeout in {:?}", id, manager.timeout);
355                    true
356                } else {
357                    false
358                }
359            } else {
360                false
361            }
362        };
363
364        if should_schedule_cleanup {
365            Self::schedule_cleanup(manager.clone(), id);
366        }
367    }
368
369    /// Schedule a cleanup check for a session after the timeout period.
370    fn schedule_cleanup(manager: Arc<Self>, id: SessionId) {
371        let timeout = manager.timeout;
372        let manager = manager.clone();
373
374        tokio::spawn(async move {
375            tokio::time::sleep(timeout).await;
376
377            // A client may have reconnected during the grace period. Only
378            // remove the session if it is still disconnected, otherwise the
379            // scheduled task would tear down a live session.
380            let still_disconnected = {
381                let sessions = manager.sessions.lock().unwrap();
382                sessions.get(&id).is_some_and(|managed| managed.session.connection_count() == 0)
383            };
384
385            if still_disconnected {
386                info!("Session {} timeout check triggered, removing session", id);
387                manager.remove_session(id);
388
389                // Check if this was the last session and shutdown the server if so.
390                if manager.session_count() == 0 {
391                    info!("No more active sessions, shutting down server...");
392                    std::process::exit(0);
393                }
394            } else {
395                info!("Session {} reconnected before timeout check, skipping cleanup", id);
396            }
397        });
398    }
399
400    /// Perform cleanup of expired sessions.
401    ///
402    /// This should be called periodically or after timeout events.
403    pub fn cleanup_expired_sessions(&self) {
404        let now = Instant::now();
405        let mut to_remove = Vec::new();
406
407        {
408            let sessions = self.sessions.lock().unwrap();
409            for (id, managed) in sessions.iter() {
410                if managed.session.is_mcp() {
411
412                    // MCP sessions have no disconnect signal, so they are reaped
413                    // by command inactivity instead of by connection count.
414                    if now.duration_since(managed.session.last_activity()) >= self.timeout {
415                        to_remove.push(*id);
416                    }
417                } else if let Some(disconnected_at) = managed.disconnected_at {
418                    if now.duration_since(disconnected_at) >= self.timeout
419                        && managed.session.connection_count() == 0
420                    {
421                        to_remove.push(*id);
422                    }
423                }
424            }
425        }
426
427        for id in to_remove {
428            self.remove_session(id);
429        }
430    }
431
432    /// Remove a session immediately.
433    pub fn remove_session(&self, id: SessionId) -> Option<Arc<Session>> {
434        let mut sessions = self.sessions.lock().unwrap();
435        if let Some(managed) = sessions.remove(&id) {
436            info!("Removing session {}", id);
437            managed.session.shutdown();
438            Some(managed.session)
439        } else {
440            None
441        }
442    }
443
444    /// Get the number of active sessions.
445    pub fn session_count(&self) -> usize {
446        let sessions = self.sessions.lock().unwrap();
447        sessions.len()
448    }
449
450    /// Get all active session IDs.
451    pub fn session_ids(&self) -> Vec<SessionId> {
452        let sessions = self.sessions.lock().unwrap();
453        sessions.keys().cloned().collect()
454    }
455
456    /// Get information about all active sessions.
457    ///
458    /// Returns a vector of [`SessionInfo`] structs containing session state snapshots
459    /// for use by session management tools.
460    pub fn get_sessions_info(&self) -> Vec<SessionInfo> {
461        let sessions = self.sessions.lock().unwrap();
462        let now = Instant::now();
463
464        sessions.values().map(|managed| {
465            let timeout_remaining_secs = managed.disconnected_at.map(|disconnected_at| {
466                let elapsed = now.duration_since(disconnected_at);
467                if elapsed < self.timeout {
468                    (self.timeout - elapsed).as_secs()
469                } else {
470                    0
471                }
472            });
473
474            SessionInfo::new(
475                managed.session.id(),
476                managed.session.connection_count(),
477                timeout_remaining_secs,
478                managed.session.is_shutdown_requested(),
479                managed.session.pack_names(),
480            )
481        }).collect()
482    }
483
484    /// Start a background task that periodically cleans up expired sessions.
485    pub fn start_cleanup_task(manager: Arc<Self>) {
486        let cleanup_interval = manager.timeout / 2; // Check twice per timeout period.
487
488        tokio::spawn(async move {
489            loop {
490                tokio::time::sleep(cleanup_interval).await;
491                manager.cleanup_expired_sessions();
492            }
493        });
494    }
495}
496
497/// Helper function to receive a response from a session.
498///
499/// This is async and will wait for the response.
500pub async fn recv_response(receiver: &mut UnboundedReceiver<Response>) -> Response {
501    match receiver.recv().await {
502        Some(response) => response,
503        None => {
504            info!("Session response channel closed unexpectedly.");
505            Response::Error("Session response channel closed unexpectedly".to_owned())
506        },
507    }
508}