Skip to main content

manabrew_server/
cleanup.rs

1use std::sync::Arc;
2use std::time::{Duration, Instant};
3
4use tracing::{info, warn};
5
6use crate::analytics::{self, GameEndReason};
7use crate::connection::{broadcast_to_room, emit_to};
8use crate::lobby;
9use crate::protocol::{RoomStatus, ServerMessage};
10use crate::room::Room;
11use crate::state::ServerState;
12
13const CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
14const STALE_CONNECTED_TIMEOUT: Duration = Duration::from_secs(180);
15const IN_GAME_DISCONNECTED_GRACE: Duration = Duration::from_secs(3600);
16const RECONNECT_ABORT_MARGIN: Duration = Duration::from_secs(5);
17
18pub async fn cleanup_loop(state: Arc<ServerState>) {
19    let mut ticker = tokio::time::interval(CLEANUP_INTERVAL);
20    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
21
22    loop {
23        ticker.tick().await;
24        cleanup_stale_state(&state);
25    }
26}
27
28fn cleanup_stale_state(state: &Arc<ServerState>) {
29    let now = Instant::now();
30    let stale_players = state
31        .players
32        .iter()
33        .filter_map(|entry| {
34            let player = entry.value();
35            (player.connected && now.duration_since(player.last_seen) >= STALE_CONNECTED_TIMEOUT)
36                .then(|| {
37                    (
38                        entry.key().clone(),
39                        player.username.clone(),
40                        player.generation,
41                    )
42                })
43        })
44        .collect::<Vec<_>>();
45
46    for (player_id, username, generation) in stale_players {
47        warn!(
48            "[cleanup] '{}' had no websocket frames for {}s -- marking disconnected",
49            username,
50            STALE_CONNECTED_TIMEOUT.as_secs()
51        );
52        mark_disconnected(state, &player_id, generation);
53    }
54
55    let mut humanless_rooms = Vec::new();
56    for mut entry in state.rooms.iter_mut() {
57        let room = entry.value_mut();
58        if room.status != RoomStatus::InGame || room.has_connected_human() {
59            room.humanless_since = None;
60            continue;
61        }
62        let since = *room.humanless_since.get_or_insert(now);
63        let grace = Duration::from_secs(room.reconnect_timeout_s as u64) + RECONNECT_ABORT_MARGIN;
64        if now.duration_since(since) >= grace {
65            humanless_rooms.push(entry.key().clone());
66        }
67    }
68    for room_id in humanless_rooms {
69        abort_humanless_room(state, &room_id);
70    }
71
72    let rooms_to_remove = state
73        .rooms
74        .iter()
75        .filter_map(|entry| {
76            let room = entry.value();
77            match room.status {
78                RoomStatus::Lobby => room
79                    .connected_player_ids()
80                    .is_empty()
81                    .then(|| entry.key().clone()),
82                RoomStatus::InGame => {
83                    in_game_room_expired(state, room, now).then(|| entry.key().clone())
84                }
85            }
86        })
87        .collect::<Vec<_>>();
88
89    for room_id in rooms_to_remove {
90        info!(
91            "[cleanup] removing stale room {}",
92            &room_id[..8.min(room_id.len())]
93        );
94        remove_room_and_clear_sessions(state, &room_id, GameEndReason::StaleExpired);
95    }
96}
97
98fn abort_humanless_room(state: &Arc<ServerState>, room_id: &str) {
99    let Some((info, notify)) = lobby::reset_room_to_lobby(state, room_id, GameEndReason::Abandoned)
100    else {
101        return;
102    };
103    info!(
104        "[cleanup] in-game room {} had no connected human players -- reset to lobby",
105        &room_id[..8.min(room_id.len())]
106    );
107    broadcast_to_room(state, room_id, &ServerMessage::RoomUpdate { room: info });
108    let aborted = ServerMessage::GameAborted {
109        room_id: room_id.to_string(),
110    };
111    if let Ok(json) = serde_json::to_string(&aborted) {
112        for pid in &notify {
113            emit_to(state, pid, &aborted, &json);
114        }
115    }
116}
117
118pub fn schedule_host_resume_abort(
119    state: Arc<ServerState>,
120    room_id: String,
121    host_player_id: String,
122) {
123    let Some(timeout_s) = state
124        .rooms
125        .get(&room_id)
126        .map(|room| room.reconnect_timeout_s)
127    else {
128        return;
129    };
130    let timeout = Duration::from_secs(timeout_s as u64);
131
132    tokio::spawn(async move {
133        tokio::time::sleep(timeout + RECONNECT_ABORT_MARGIN).await;
134
135        // ResumeRoom rotates host_player_id and session reclaim flips the
136        // player back to connected; either one disarms this abort.
137        let host_still_gone = state
138            .rooms
139            .get(&room_id)
140            .map(|room| room.status == RoomStatus::InGame && room.host_player_id == host_player_id)
141            .unwrap_or(false)
142            && state
143                .players
144                .get(&host_player_id)
145                .map(|player| !player.connected)
146                .unwrap_or(true);
147        if !host_still_gone {
148            return;
149        }
150
151        info!(
152            "[cleanup] hosted in-game room {} host never resumed -- removing",
153            &room_id[..8.min(room_id.len())]
154        );
155        broadcast_to_room(
156            &state,
157            &room_id,
158            &ServerMessage::GameAborted {
159                room_id: room_id.clone(),
160            },
161        );
162        remove_room_and_clear_sessions(&state, &room_id, GameEndReason::HostLost);
163    });
164}
165
166pub fn schedule_seat_forfeit(state: Arc<ServerState>, room_id: String, player_id: String) {
167    let Some(timeout_s) = state
168        .rooms
169        .get(&room_id)
170        .map(|room| room.reconnect_timeout_s)
171    else {
172        return;
173    };
174    let timeout = Duration::from_secs(timeout_s as u64);
175
176    tokio::spawn(async move {
177        tokio::time::sleep(timeout + RECONNECT_ABORT_MARGIN).await;
178
179        // A reconnect flips the player back to connected; a rejoin re-keys the
180        // seat's player_id (disarming via the seat check below). No player
181        // entry at all means a pending-rejoin seat resurrected after a relay
182        // restart — nobody is connected under that id, so it counts as gone.
183        let still_gone = state
184            .players
185            .get(&player_id)
186            .map(|player| !player.connected)
187            .unwrap_or(true);
188        let seat_in_live_game = state
189            .rooms
190            .get(&room_id)
191            .map(|room| {
192                room.status == RoomStatus::InGame
193                    && room.players.iter().any(|slot| slot.player_id == player_id)
194            })
195            .unwrap_or(false);
196        if !still_gone || !seat_in_live_game {
197            return;
198        }
199        forfeit_seat(&state, &room_id, &player_id);
200    });
201}
202
203fn forfeit_seat(state: &Arc<ServerState>, room_id: &str, player_id: &str) {
204    let (username, info) = {
205        let Some(mut room) = state.rooms.get_mut(room_id) else {
206            return;
207        };
208        let Some(slot) = room.remove_player(player_id) else {
209            return;
210        };
211        (slot.username, room.to_room_info())
212    };
213    state.players.remove(player_id);
214    info!(
215        "[cleanup] '{}' did not reconnect within grace -- forfeiting seat in room {}",
216        username,
217        &room_id[..8.min(room_id.len())]
218    );
219    broadcast_to_room(
220        state,
221        room_id,
222        &ServerMessage::PlayerLeft {
223            room_id: room_id.to_string(),
224            username,
225        },
226    );
227    broadcast_to_room(state, room_id, &ServerMessage::RoomUpdate { room: info });
228}
229
230fn in_game_room_expired(state: &Arc<ServerState>, room: &Room, now: Instant) -> bool {
231    if !room.all_disconnected() {
232        return false;
233    }
234
235    room.players
236        .iter()
237        .map(|slot| slot.player_id.as_str())
238        .chain(
239            room.observers
240                .iter()
241                .map(|observer| observer.player_id.as_str()),
242        )
243        .filter_map(|player_id| state.players.get(player_id).and_then(|p| p.disconnected_at))
244        .max()
245        .is_some_and(|latest| now.duration_since(latest) >= IN_GAME_DISCONNECTED_GRACE)
246}
247
248pub fn mark_disconnected(state: &Arc<ServerState>, player_id: &str, our_generation: u64) {
249    let (username, room_id) = {
250        if let Some(mut player) = state.players.get_mut(player_id) {
251            if player.generation != our_generation {
252                info!(
253                    "[disconnect] '{}' old connection cleaned up (session reclaimed by new connection)",
254                    player.username
255                );
256                return;
257            }
258            player.connected = false;
259            player.disconnected_at = Some(Instant::now());
260            (player.username.clone(), player.room_id.clone())
261        } else {
262            return;
263        }
264    };
265
266    if let Some(rid) = &room_id {
267        let room_status = state.rooms.get(rid).map(|r| r.status.clone());
268
269        match room_status {
270            Some(RoomStatus::InGame) => {
271                let host_without_player = state
272                    .rooms
273                    .get(rid)
274                    .map(|room| room.is_host(player_id) && !room.host_is_player())
275                    .unwrap_or(false);
276                if host_without_player {
277                    // Give the host the same reconnect grace as a player: it can
278                    // come back via session reclaim or ResumeRoom. Killing the
279                    // room here would abort every guest's game on a host blip.
280                    if let Some(mut room) = state.rooms.get_mut(rid) {
281                        room.set_connected(player_id, false);
282                    } else {
283                        return;
284                    }
285                    info!(
286                        "[disconnect] hosted in-game room {} lost its non-playing host -- awaiting resume",
287                        &rid[..8]
288                    );
289                    broadcast_to_room(
290                        state,
291                        rid,
292                        &ServerMessage::PlayerDisconnected {
293                            username: username.clone(),
294                        },
295                    );
296                    schedule_host_resume_abort(state.clone(), rid.clone(), player_id.to_string());
297                    return;
298                }
299
300                let holds_seat = {
301                    if let Some(mut room) = state.rooms.get_mut(rid) {
302                        room.set_connected(player_id, false);
303                        room.players.iter().any(|slot| slot.player_id == player_id)
304                    } else {
305                        return;
306                    }
307                };
308
309                info!(
310                    "[disconnect] '{}' marked disconnected in in-game room {} (session preserved)",
311                    username,
312                    &rid[..8]
313                );
314                if holds_seat {
315                    schedule_seat_forfeit(state.clone(), rid.clone(), player_id.to_string());
316                }
317                broadcast_to_room(
318                    state,
319                    rid,
320                    &ServerMessage::PlayerDisconnected {
321                        username: username.clone(),
322                    },
323                );
324
325                if let Some(room) = state.rooms.get(rid) {
326                    broadcast_to_room(
327                        state,
328                        rid,
329                        &ServerMessage::RoomUpdate {
330                            room: room.to_room_info(),
331                        },
332                    );
333                }
334            }
335            Some(RoomStatus::Lobby) => {
336                info!(
337                    "[disconnect] '{}' disconnected from lobby room {} -- treating as leave",
338                    username,
339                    &rid[..8]
340                );
341
342                let remove_hosted_room = state
343                    .rooms
344                    .get(rid)
345                    .map(|room| room.is_host(player_id) && !room.host_is_player())
346                    .unwrap_or(false);
347                if remove_hosted_room {
348                    info!(
349                        "[cleanup] hosted lobby room {} lost its non-playing host -- removing",
350                        &rid[..8]
351                    );
352                    remove_room_and_clear_sessions(state, rid, GameEndReason::HostLost);
353                    return;
354                }
355
356                let room_empty = {
357                    if let Some(mut room) = state.rooms.get_mut(rid) {
358                        room.remove_participant(player_id);
359                        let empty = room.is_empty();
360                        if !empty && room.players.is_empty() {
361                            room.reset_lobby_settings();
362                        }
363                        empty
364                    } else {
365                        false
366                    }
367                };
368
369                if let Some(mut player) = state.players.get_mut(player_id) {
370                    player.room_id = None;
371                }
372
373                if room_empty {
374                    info!(
375                        "[cleanup] lobby room {} is now empty -- removing",
376                        &rid[..8]
377                    );
378                    remove_room_and_clear_sessions(state, rid, GameEndReason::Abandoned);
379                } else {
380                    broadcast_to_room(
381                        state,
382                        rid,
383                        &ServerMessage::PlayerLeft {
384                            room_id: rid.clone(),
385                            username: username.clone(),
386                        },
387                    );
388                    if let Some(room) = state.rooms.get(rid) {
389                        broadcast_to_room(
390                            state,
391                            rid,
392                            &ServerMessage::RoomUpdate {
393                                room: room.to_room_info(),
394                            },
395                        );
396                    }
397                }
398
399                info!("[cleanup] '{}' removed (disconnected from lobby)", username);
400                state.players.remove(player_id);
401            }
402            None => {
403                info!("[cleanup] '{}' removed (room no longer exists)", username);
404                state.players.remove(player_id);
405            }
406        }
407    } else {
408        info!("[cleanup] '{}' removed (was not in a room)", username);
409        state.players.remove(player_id);
410    }
411}
412
413pub fn remove_room_and_clear_sessions(
414    state: &Arc<ServerState>,
415    room_id: &str,
416    reason: GameEndReason,
417) {
418    if let Some((_, room)) = state.rooms.remove(room_id) {
419        if let Some(replay) = room.replay.as_ref() {
420            analytics::emit_game_ended(&state.analytics, &room, replay, reason);
421        }
422    }
423    release_room_sessions(state, room_id);
424}
425
426fn release_room_sessions(state: &Arc<ServerState>, room_id: &str) {
427    let player_ids = state
428        .players
429        .iter()
430        .filter_map(|entry| {
431            entry
432                .value()
433                .room_id
434                .as_deref()
435                .is_some_and(|rid| rid == room_id)
436                .then(|| entry.key().clone())
437        })
438        .collect::<Vec<_>>();
439    for player_id in player_ids {
440        let connected = state
441            .players
442            .get(&player_id)
443            .map(|player| player.connected)
444            .unwrap_or(false);
445        if connected {
446            if let Some(mut player) = state.players.get_mut(&player_id) {
447                player.room_id = None;
448            }
449        } else {
450            state.players.remove(&player_id);
451        }
452    }
453}