Drop a guest's GamesServ games on disconnect so a recycled uid can't inherit one
All checks were successful
CI / check (push) Successful in 5m39s

This commit is contained in:
Jean Chevronnet 2026-07-21 10:23:52 +00:00
parent 62b275fd01
commit 9e8449b40d
No known key found for this signature in database
3 changed files with 42 additions and 3 deletions

View file

@ -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);
}

View file

@ -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<Side> {
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");
}
}

View file

@ -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 {