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}