diff --git a/src/channels.rs b/src/channels.rs index 5f781d2..b3136f6 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -608,6 +608,28 @@ impl Server { } } + /// Drop per-member flood state left behind by users who have left a channel + /// (a slow leak on high-churn channels — Uids are never reused, so departed + /// entries never get overwritten), and +J rejoin-block entries whose window has + /// elapsed. Called from the background tick. + pub fn purge_flood_state(&mut self) { + let now = now(); + for ch in self.channels.values_mut() { + // +f message-time counters only matter for current members + if !ch.msgflood_hits.is_empty() { + let members = &ch.members; + ch.msgflood_hits.retain(|uid, _| members.contains_key(uid)); + } + // +J recent-kick timestamps only matter within the (current) rejoin-block + // window; once it's elapsed — or +J is off — the entry can't block anyone. + if !ch.recent_kicks.is_empty() { + let window = ch.modes.kicknorejoin.unwrap_or(0) as u64; + ch.recent_kicks + .retain(|_, ts| window > 0 && now.saturating_sub(*ts) < window); + } + } + } + /// Force `uid` out of `chan` (SAPART / SVSPART enforcement): announce the PART /// to the channel and to links, drop the membership, reap the channel if empty. /// No-op if the user isn't a member. diff --git a/src/ircd.rs b/src/ircd.rs index 83766aa..30125de 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -639,6 +639,7 @@ impl Ircd { self.server.ping_links(); // keepalive on every server link self.server.purge_xlines(); // drop expired server bans self.server.purge_tbans(); // lift expired timed channel bans (TBAN) + self.server.purge_flood_state(); // reclaim per-member +f/+J state of departed users for m in &mut self.modules { m.on_tick(&mut self.server); // timer-driven modules (e.g. reputation) } diff --git a/src/server.rs b/src/server.rs index e407995..fa59498 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1278,6 +1278,28 @@ mod tests { Server::new(Config::default(), tx, Arc::new(AtomicU64::new(1))) } + // The tick purge reclaims per-member flood state of users who left, and +J + // rejoin-block entries whose window elapsed — the slow high-churn leak. + #[test] + fn purge_flood_state_reclaims_departed_and_expired() { + use crate::channels::{Channel, Member}; + let mut s = srv(); + let mut c = Channel::new("#c"); + c.members.insert(1, Member::default()); // uid 1 is a current member + c.msgflood_hits.insert(1, vec![now()]); // member -> kept + c.msgflood_hits.insert(99, vec![now()]); // departed -> dropped + c.modes.kicknorejoin = Some(60); + c.recent_kicks.insert(99, now().saturating_sub(120)); // expired -> dropped + c.recent_kicks.insert(88, now()); // still within window -> kept + s.channels.insert("#c".into(), c); + s.purge_flood_state(); + let ch = &s.channels["#c"]; + assert!(ch.msgflood_hits.contains_key(&1)); + assert!(!ch.msgflood_hits.contains_key(&99), "departed member's +f state dropped"); + assert!(!ch.recent_kicks.contains_key(&99), "expired +J entry dropped"); + assert!(ch.recent_kicks.contains_key(&88), "fresh +J entry kept"); + } + #[test] fn resolved_host_applied_only_when_configured() { let mut s = srv(); // use_resolved_host = true (default)