diff --git a/api/src/lib.rs b/api/src/lib.rs index 55c7c3c..aeff949 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -2463,6 +2463,11 @@ pub trait Service: Send { fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { ("", &[]) } + // A user left the network (QUIT / KILL / netsplit). Lets a service drop any + // per-connection state it keyed on that departing uid before the uid is recycled + // to a new connection. Default: nothing to do. + fn on_user_quit(&mut self, _uid: &str) {} + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, store: &mut dyn Store); } diff --git a/modules/gameserv/src/lib.rs b/modules/gameserv/src/lib.rs index 982c58f..c3b0624 100644 --- a/modules/gameserv/src/lib.rs +++ b/modules/gameserv/src/lib.rs @@ -108,13 +108,24 @@ impl GameServ { } // A player's game identity: their account if registered, else their connection - // UID (not their nick, which is mutable — keying on it lets a nick-reuser hijack - // an abandoned guest game, and lets a nick-cycler evade the per-user game cap). - // `nick_a`/`nick_b` hold the display name separately. + // UID (not their nick, which is mutable — keying on it lets a nick-cycler evade + // the per-user game cap). `nick_a`/`nick_b` hold the display name separately. + // A guest's UID is a per-connection handle, so their games are dropped the moment + // they disconnect (see `on_user_quit`) — a recycled UID can never inherit one, and + // a guest can't resume anyway (they reconnect under a fresh UID). fn ident<'a>(from: &'a Sender) -> &'a str { from.account.unwrap_or(from.uid) } + // Drop every game a departing GUEST was in. A guest is keyed by their connection + // uid; that uid dies with this QUIT and is recycled to some later connection, so + // an abandoned guest game must go now or a stranger could inherit it. Registered + // parties are keyed by their (stable) account, never a uid, so their games survive + // a disconnect and resume once they re-identify. + fn drop_guest_games(&mut self, uid: &str) { + self.games.retain(|_, g| g.acc_a != uid && g.acc_b != uid); + } + // The side an account plays in a game (A = challenger), or None if not a party. fn side_of(g: &Game, account: &str) -> Option { if g.acc_a == account { @@ -488,6 +499,10 @@ impl Service for GameServ { (BLURB, TOPICS) } + fn on_user_quit(&mut self, uid: &str) { + self.drop_guest_games(uid); + } + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, _store: &mut dyn Store) { let me = self.uid.clone(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { @@ -543,4 +558,18 @@ mod tests { // Casual (unranked) game: no delta. assert_eq!(read_stats_with_delta(&counters, GameType::Ttt, "Win", Side::A, None).wins, 3); } + + // A guest is keyed by uid; when they disconnect their game must go (so a recycled + // uid can't inherit it), while a registered-vs-registered game is untouched. + #[test] + fn a_departing_guest_drops_only_their_games() { + let mut gs = GameServ::new("42G".to_string()); + // guest (uid 001AAAAAA) vs registered "bob" + gs.games.insert(1, Game::new(1, GameType::Ttt, "001AAAAAA".to_string(), false, "bob".to_string(), true, "guest".to_string(), "bob".to_string())); + // registered "alice" vs registered "bob" + gs.games.insert(2, Game::new(2, GameType::Chess, "alice".to_string(), true, "bob".to_string(), true, "alice".to_string(), "bob".to_string())); + gs.drop_guest_games("001AAAAAA"); + assert!(!gs.games.contains_key(&1), "the departing guest's game is gone"); + assert!(gs.games.contains_key(&2), "a registered-vs-registered game is untouched"); + } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4f762a2..0066ad2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -737,6 +737,11 @@ impl Engine { // Forget a user who left the network (QUIT or KILL): drop their membership, // any half-finished SASL exchange, and their pending nick-protection timer. fn forget_user(&mut self, uid: &str) { + // Let services drop state keyed on this uid (e.g. GamesServ guest games) + // before the uid can be recycled to a new connection. + for svc in self.services.iter_mut() { + svc.on_user_quit(uid); + } let chans = self.network.channels_of(uid); self.network.user_quit(uid); for c in &chans {