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_room_transport, 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| room_is_stale(state, entry.value(), now).then(|| entry.key().clone()))
76        .collect::<Vec<_>>();
77
78    for room_id in rooms_to_remove {
79        info!(
80            "[cleanup] removing stale room {}",
81            &room_id[..8.min(room_id.len())]
82        );
83        remove_room_and_clear_sessions(state, &room_id, GameEndReason::StaleExpired);
84    }
85}
86
87fn room_is_stale(state: &Arc<ServerState>, room: &Room, now: Instant) -> bool {
88    if room.parent_limited_room.is_some() {
89        return !paired_room_has_participants(state, room, now);
90    }
91    match room.status {
92        RoomStatus::Lobby => room.all_disconnected(),
93        RoomStatus::InGame => in_game_room_expired(state, room, now),
94    }
95}
96
97pub(crate) fn paired_room_has_participants(
98    state: &Arc<ServerState>,
99    room: &Room,
100    now: Instant,
101) -> bool {
102    let grace = Duration::from_secs(room.reconnect_timeout_s as u64) + RECONNECT_ABORT_MARGIN;
103    room.players
104        .iter()
105        .filter(|seat| !seat.is_bot)
106        .map(|seat| seat.player_id.as_str())
107        .chain(
108            room.observers
109                .iter()
110                .map(|observer| observer.player_id.as_str()),
111        )
112        .any(|player_id| {
113            state.players.get(player_id).is_some_and(|player| {
114                player.room_id.as_deref() == Some(room.room_id.as_str())
115                    && (player.connected
116                        || player
117                            .disconnected_at
118                            .is_some_and(|since| now.duration_since(since) < grace))
119            })
120        })
121}
122
123fn abort_humanless_room(state: &Arc<ServerState>, room_id: &str) {
124    let Some((info, notify)) = lobby::reset_room_to_lobby(state, room_id, GameEndReason::Abandoned)
125    else {
126        return;
127    };
128    info!(
129        "[cleanup] in-game room {} had no connected human players -- reset to lobby",
130        &room_id[..8.min(room_id.len())]
131    );
132    broadcast_to_room(state, room_id, &ServerMessage::RoomUpdate { room: info });
133    let aborted = ServerMessage::GameAborted {
134        room_id: room_id.to_string(),
135    };
136    if let Ok(json) = serde_json::to_string(&aborted) {
137        for pid in &notify {
138            emit_to(state, pid, &aborted, &json);
139        }
140    }
141}
142
143pub fn schedule_host_resume_abort(
144    state: Arc<ServerState>,
145    room_id: String,
146    host_player_id: String,
147) {
148    let Some(timeout_s) = state
149        .rooms
150        .get(&room_id)
151        .map(|room| room.reconnect_timeout_s)
152    else {
153        return;
154    };
155    let timeout = Duration::from_secs(timeout_s as u64);
156
157    tokio::spawn(async move {
158        tokio::time::sleep(timeout + RECONNECT_ABORT_MARGIN).await;
159
160        // ResumeRoom rotates host_player_id and session reclaim flips the
161        // player back to connected; either one disarms this abort.
162        let host_still_gone = state
163            .rooms
164            .get(&room_id)
165            .map(|room| room.status == RoomStatus::InGame && room.host_player_id == host_player_id)
166            .unwrap_or(false)
167            && state
168                .players
169                .get(&host_player_id)
170                .map(|player| !player.connected)
171                .unwrap_or(true);
172        if !host_still_gone {
173            return;
174        }
175
176        info!(
177            "[cleanup] hosted in-game room {} host never resumed -- removing",
178            &room_id[..8.min(room_id.len())]
179        );
180        broadcast_to_room(
181            &state,
182            &room_id,
183            &ServerMessage::GameAborted {
184                room_id: room_id.clone(),
185            },
186        );
187        remove_room_and_clear_sessions(&state, &room_id, GameEndReason::HostLost);
188    });
189}
190
191pub fn schedule_peer_host_loss(state: Arc<ServerState>, room_id: String, player_id: String) {
192    let Some(timeout_s) = state
193        .rooms
194        .get(&room_id)
195        .filter(|room| room.parent_limited_room.is_some() || !room.is_limited_session())
196        .map(|room| room.reconnect_timeout_s)
197    else {
198        return;
199    };
200    let timeout = Duration::from_secs(timeout_s as u64);
201    let disconnected_at = state
202        .players
203        .get(&player_id)
204        .and_then(|p| p.disconnected_at);
205
206    tokio::spawn(async move {
207        tokio::time::sleep(timeout + RECONNECT_ABORT_MARGIN).await;
208
209        let still_hosting = state
210            .rooms
211            .get(&room_id)
212            .map(|room| room.status == RoomStatus::InGame && room.host_player_id == player_id)
213            .unwrap_or(false);
214        let still_gone = state
215            .players
216            .get(&player_id)
217            .map(|player| !player.connected && player.disconnected_at == disconnected_at)
218            .unwrap_or(true);
219        if !still_hosting || !still_gone {
220            return;
221        }
222
223        let Some((info, notify)) =
224            lobby::reset_room_to_lobby(&state, &room_id, GameEndReason::HostLost)
225        else {
226            return;
227        };
228        info!(
229            "[cleanup] room {} lost the seat holding its engine -- game ended, room reset to lobby",
230            &room_id[..8.min(room_id.len())]
231        );
232        broadcast_to_room(&state, &room_id, &ServerMessage::RoomUpdate { room: info });
233        let aborted = ServerMessage::GameAborted {
234            room_id: room_id.clone(),
235        };
236        if let Ok(json) = serde_json::to_string(&aborted) {
237            for pid in &notify {
238                emit_to(&state, pid, &aborted, &json);
239            }
240        }
241    });
242}
243
244pub fn schedule_seat_forfeit(state: Arc<ServerState>, room_id: String, player_id: String) {
245    let Some(timeout_s) = state
246        .rooms
247        .get(&room_id)
248        .filter(|room| room.parent_limited_room.is_some() || !room.is_limited_session())
249        .map(|room| room.reconnect_timeout_s)
250    else {
251        return;
252    };
253    let timeout = Duration::from_secs(timeout_s as u64);
254    let disconnected_at = state
255        .players
256        .get(&player_id)
257        .and_then(|p| p.disconnected_at);
258
259    tokio::spawn(async move {
260        tokio::time::sleep(timeout + RECONNECT_ABORT_MARGIN).await;
261
262        // A reconnect flips the player back to connected; a rejoin re-keys the
263        // seat's player_id (disarming via the seat check below). No player
264        // entry at all means a pending-rejoin seat resurrected after a relay
265        // restart — nobody is connected under that id, so it counts as gone.
266        let still_gone = state
267            .players
268            .get(&player_id)
269            .map(|player| {
270                !player.connected
271                    && player.room_id.as_deref() == Some(room_id.as_str())
272                    && player.disconnected_at == disconnected_at
273            })
274            .unwrap_or(disconnected_at.is_none());
275        let retained_seat = state
276            .rooms
277            .get(&room_id)
278            .map(|room| {
279                (room.status == RoomStatus::InGame || room.parent_limited_room.is_some())
280                    && room.players.iter().any(|slot| slot.player_id == player_id)
281            })
282            .unwrap_or(false);
283        if !still_gone || !retained_seat {
284            return;
285        }
286        forfeit_seat(&state, &room_id, &player_id);
287    });
288}
289
290fn forfeit_seat(state: &Arc<ServerState>, room_id: &str, player_id: &str) {
291    let (username, info, parent_id) = {
292        let Some(mut room) = state.rooms.get_mut(room_id) else {
293            return;
294        };
295        let Some(slot) = room.remove_player(player_id) else {
296            return;
297        };
298        (
299            slot.username,
300            room.to_room_info(),
301            room.parent_limited_room.clone(),
302        )
303    };
304    release_room_session(state, room_id, player_id, parent_id.as_deref());
305    let abandoned = state.rooms.get(room_id).is_some_and(|room| {
306        room.parent_limited_room.is_some()
307            && !paired_room_has_participants(state, &room, Instant::now())
308    });
309    if abandoned {
310        remove_room_and_clear_sessions(state, room_id, GameEndReason::Abandoned);
311        return;
312    }
313    info!(
314        "[cleanup] '{}' did not reconnect within grace -- forfeiting seat in room {}",
315        username,
316        &room_id[..8.min(room_id.len())]
317    );
318    broadcast_to_room(
319        state,
320        room_id,
321        &ServerMessage::PlayerLeft {
322            room_id: room_id.to_string(),
323            username,
324        },
325    );
326    broadcast_to_room(state, room_id, &ServerMessage::RoomUpdate { room: info });
327}
328
329fn in_game_room_expired(state: &Arc<ServerState>, room: &Room, now: Instant) -> bool {
330    if !room.all_disconnected() {
331        return false;
332    }
333
334    room.players
335        .iter()
336        .map(|slot| slot.player_id.as_str())
337        .chain(
338            room.observers
339                .iter()
340                .map(|observer| observer.player_id.as_str()),
341        )
342        .filter_map(|player_id| state.players.get(player_id).and_then(|p| p.disconnected_at))
343        .max()
344        .is_some_and(|latest| now.duration_since(latest) >= IN_GAME_DISCONNECTED_GRACE)
345}
346
347pub fn mark_disconnected(state: &Arc<ServerState>, player_id: &str, our_generation: u64) {
348    mark_disconnected_inner(state, player_id, our_generation);
349    crate::connection::broadcast_player_list(state);
350}
351
352fn mark_disconnected_inner(state: &Arc<ServerState>, player_id: &str, our_generation: u64) {
353    let (username, room_id) = {
354        if let Some(mut player) = state.players.get_mut(player_id) {
355            if player.generation != our_generation {
356                info!(
357                    "[disconnect] '{}' old connection cleaned up (session reclaimed by new connection)",
358                    player.username
359                );
360                return;
361            }
362            player.connected = false;
363            player.disconnected_at = Some(Instant::now());
364            (player.username.clone(), player.room_id.clone())
365        } else {
366            return;
367        }
368    };
369    let parent_id = room_id.as_ref().and_then(|id| {
370        crate::draft_clock::pause_disconnected_host(state, id, player_id);
371        state
372            .rooms
373            .get(id)
374            .and_then(|room| room.parent_limited_room.clone())
375    });
376    if let Some(parent_id) = parent_id {
377        let lost_host = state
378            .rooms
379            .get_mut(&parent_id)
380            .map(|mut room| {
381                room.set_connected(player_id, false);
382                room.is_host(player_id)
383            })
384            .unwrap_or(false);
385        let info = state.rooms.get(&parent_id).map(|room| room.to_room_info());
386        if let Some(room) = info {
387            broadcast_to_room(state, &parent_id, &ServerMessage::RoomUpdate { room });
388        }
389        if lost_host {
390            schedule_peer_host_loss(state.clone(), parent_id, player_id.to_string());
391        }
392    }
393
394    if let Some(rid) = &room_id {
395        let room_status = state.rooms.get(rid).map(|r| r.status.clone());
396
397        match room_status {
398            Some(RoomStatus::InGame) => {
399                let host_without_player = state
400                    .rooms
401                    .get(rid)
402                    .map(|room| room.is_host(player_id) && !room.host_is_player())
403                    .unwrap_or(false);
404                if host_without_player {
405                    // Give the host the same reconnect grace as a player: it can
406                    // come back via session reclaim or ResumeRoom. Killing the
407                    // room here would abort every guest's game on a host blip.
408                    if let Some(mut room) = state.rooms.get_mut(rid) {
409                        room.set_connected(player_id, false);
410                    } else {
411                        return;
412                    }
413                    info!(
414                        "[disconnect] hosted in-game room {} lost its non-playing host -- awaiting resume",
415                        &rid[..8]
416                    );
417                    broadcast_to_room(
418                        state,
419                        rid,
420                        &ServerMessage::PlayerDisconnected {
421                            username: username.clone(),
422                        },
423                    );
424                    schedule_host_resume_abort(state.clone(), rid.clone(), player_id.to_string());
425                    return;
426                }
427
428                let peer_host_lost = state
429                    .rooms
430                    .get(rid)
431                    .map(|room| !room.hosted && room.is_host(player_id) && room.host_is_player())
432                    .unwrap_or(false);
433
434                let paired_limited = state
435                    .rooms
436                    .get(rid)
437                    .is_some_and(|room| room.parent_limited_room.is_some());
438                let holds_seat = {
439                    if let Some(mut room) = state.rooms.get_mut(rid) {
440                        room.set_connected(player_id, false);
441                        room.players.iter().any(|slot| slot.player_id == player_id)
442                    } else {
443                        return;
444                    }
445                };
446
447                if peer_host_lost {
448                    info!(
449                        "[disconnect] in-game room {} lost the seat holding its engine -- awaiting resume",
450                        &rid[..8]
451                    );
452                    broadcast_to_room(
453                        state,
454                        rid,
455                        &ServerMessage::PlayerDisconnected {
456                            username: username.clone(),
457                        },
458                    );
459                    schedule_peer_host_loss(state.clone(), rid.clone(), player_id.to_string());
460                    if paired_limited {
461                        schedule_seat_forfeit(state.clone(), rid.clone(), player_id.to_string());
462                    }
463                    return;
464                }
465
466                info!(
467                    "[disconnect] '{}' marked disconnected in in-game room {} (session preserved)",
468                    username,
469                    &rid[..8]
470                );
471                if holds_seat {
472                    schedule_seat_forfeit(state.clone(), rid.clone(), player_id.to_string());
473                }
474                broadcast_to_room(
475                    state,
476                    rid,
477                    &ServerMessage::PlayerDisconnected {
478                        username: username.clone(),
479                    },
480                );
481
482                let info = state.rooms.get(rid).map(|room| room.to_room_info());
483                if let Some(room) = info {
484                    broadcast_to_room(state, rid, &ServerMessage::RoomUpdate { room });
485                }
486            }
487            Some(RoomStatus::Lobby) => {
488                let paired_limited = state
489                    .rooms
490                    .get(rid)
491                    .is_some_and(|room| room.parent_limited_room.is_some());
492                if paired_limited {
493                    if let Some(mut room) = state.rooms.get_mut(rid) {
494                        room.set_connected(player_id, false);
495                    }
496                    schedule_seat_forfeit(state.clone(), rid.clone(), player_id.to_string());
497                    broadcast_to_room(
498                        state,
499                        rid,
500                        &ServerMessage::PlayerDisconnected {
501                            username: username.clone(),
502                        },
503                    );
504                    let info = state.rooms.get(rid).map(|room| room.to_room_info());
505                    if let Some(info) = info {
506                        broadcast_to_room(state, rid, &ServerMessage::RoomUpdate { room: info });
507                    }
508                    return;
509                }
510                info!(
511                    "[disconnect] '{}' disconnected from lobby room {} -- treating as leave",
512                    username,
513                    &rid[..8]
514                );
515
516                let remove_hosted_room = state
517                    .rooms
518                    .get(rid)
519                    .map(|room| room.is_host(player_id) && !room.host_is_player())
520                    .unwrap_or(false);
521                if remove_hosted_room {
522                    info!(
523                        "[cleanup] hosted lobby room {} lost its non-playing host -- removing",
524                        &rid[..8]
525                    );
526                    remove_room_and_clear_sessions(state, rid, GameEndReason::HostLost);
527                    return;
528                }
529
530                let room_empty = {
531                    if let Some(mut room) = state.rooms.get_mut(rid) {
532                        room.remove_participant(player_id);
533                        let empty = room.is_empty();
534                        if !empty && room.players.is_empty() {
535                            room.reset_lobby_settings();
536                        }
537                        empty
538                    } else {
539                        false
540                    }
541                };
542
543                if let Some(mut player) = state.players.get_mut(player_id) {
544                    player.room_id = None;
545                }
546
547                if room_empty {
548                    info!(
549                        "[cleanup] lobby room {} is now empty -- removing",
550                        &rid[..8]
551                    );
552                    remove_room_and_clear_sessions(state, rid, GameEndReason::Abandoned);
553                } else {
554                    broadcast_to_room(
555                        state,
556                        rid,
557                        &ServerMessage::PlayerLeft {
558                            room_id: rid.clone(),
559                            username: username.clone(),
560                        },
561                    );
562                    if let Some(room) = state.rooms.get(rid) {
563                        broadcast_to_room(
564                            state,
565                            rid,
566                            &ServerMessage::RoomUpdate {
567                                room: room.to_room_info(),
568                            },
569                        );
570                    }
571                    broadcast_room_transport(state, rid);
572                }
573
574                info!("[cleanup] '{}' removed (disconnected from lobby)", username);
575                state.players.remove(player_id);
576            }
577            None => {
578                info!("[cleanup] '{}' removed (room no longer exists)", username);
579                state.players.remove(player_id);
580            }
581        }
582    } else {
583        info!("[cleanup] '{}' removed (was not in a room)", username);
584        state.players.remove(player_id);
585    }
586}
587
588pub fn remove_room_and_clear_sessions(
589    state: &Arc<ServerState>,
590    room_id: &str,
591    reason: GameEndReason,
592) {
593    let Some((_, room)) = state.rooms.remove_if(room_id, |_, room| {
594        reason != GameEndReason::StaleExpired || room_is_stale(state, room, Instant::now())
595    }) else {
596        return;
597    };
598    clear_removed_room(state, room, reason);
599}
600
601fn clear_removed_room(state: &Arc<ServerState>, room: Room, reason: GameEndReason) {
602    if let Some(replay) = room.replay.as_ref() {
603        analytics::emit_game_ended(&state.analytics, &room, replay, reason);
604    }
605    if let Some(parent_id) = &room.parent_limited_room {
606        if let Some(mut parent) = state.rooms.get_mut(parent_id) {
607            parent.limited_matches.retain(|id| id != &room.room_id);
608        }
609    }
610    for child_id in &room.limited_matches {
611        let active = state
612            .rooms
613            .get(child_id)
614            .is_some_and(|child| child.status == RoomStatus::InGame);
615        if active {
616            broadcast_to_room(
617                state,
618                child_id,
619                &ServerMessage::GameAborted {
620                    room_id: child_id.clone(),
621                },
622            );
623        }
624        if let Some((_, child)) = state.rooms.remove(child_id) {
625            clear_removed_room(state, child, reason);
626        }
627    }
628    release_room_sessions(state, &room.room_id, room.parent_limited_room.as_deref());
629}
630
631fn release_room_sessions(state: &Arc<ServerState>, room_id: &str, parent_id: Option<&str>) {
632    let player_ids = state
633        .players
634        .iter()
635        .filter_map(|entry| {
636            entry
637                .value()
638                .room_id
639                .as_deref()
640                .is_some_and(|rid| rid == room_id)
641                .then(|| entry.key().clone())
642        })
643        .collect::<Vec<_>>();
644    for player_id in player_ids {
645        release_room_session(state, room_id, &player_id, parent_id);
646    }
647}
648
649fn release_room_session(
650    state: &Arc<ServerState>,
651    room_id: &str,
652    player_id: &str,
653    parent_id: Option<&str>,
654) {
655    let parent = parent_id.and_then(|id| state.rooms.get(id)).filter(|room| {
656        room.is_limited_session() && room.players.iter().any(|seat| seat.player_id == player_id)
657    });
658    state.players.remove_if_mut(player_id, |_, player| {
659        if player.room_id.as_deref() != Some(room_id) {
660            return false;
661        }
662        if let Some(parent) = &parent {
663            player.room_id = Some(parent.room_id.clone());
664            false
665        } else if player.connected {
666            player.room_id = None;
667            false
668        } else {
669            true
670        }
671    });
672}