From 4fe435cc7695ae15c6bfe8b927f40de9fc7d7cf4 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 11 Jul 2026 14:02:54 +0200 Subject: [PATCH] fix: recover the auth rate limiter from a poisoned mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit try_acquire used .expect on lock(), so a single panic while holding either limiter mutex would poison it and make every subsequent auth request re-panic — one blip became a persistent auth outage. Recover the guard with unwrap_or_else(|e| e.into_inner()) so the limiter keeps serving. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/src/auth/rate_limit.rs | 37 +++++++++++++++++++++++++++++++--- frontend/package.json | 2 +- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index db05a69..93679d3 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.124.19" +version = "0.124.20" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e637ccd..52f3cb0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.124.19" +version = "0.124.20" edition = "2021" default-run = "mangalord" diff --git a/backend/src/auth/rate_limit.rs b/backend/src/auth/rate_limit.rs index 38a9236..098d12e 100644 --- a/backend/src/auth/rate_limit.rs +++ b/backend/src/auth/rate_limit.rs @@ -141,10 +141,10 @@ impl AuthRateLimiter { return self .global .lock() - .expect("rate limiter mutex poisoned") + .unwrap_or_else(|e| e.into_inner()) .try_take(&self.cfg, now); }; - let mut map = self.per_ip.lock().expect("rate limiter mutex poisoned"); + let mut map = self.per_ip.lock().unwrap_or_else(|e| e.into_inner()); if map.len() >= MAX_TRACKED_IPS && !map.contains_key(&ip) { map.retain(|_, b| !b.is_idle(&self.cfg, now)); if map.len() >= MAX_TRACKED_IPS { @@ -155,7 +155,7 @@ impl AuthRateLimiter { return self .global .lock() - .expect("rate limiter mutex poisoned") + .unwrap_or_else(|e| e.into_inner()) .try_take(&self.cfg, now); } } @@ -287,4 +287,35 @@ mod tests { } assert!(rl.per_ip.lock().unwrap().len() <= MAX_TRACKED_IPS); } + + #[test] + fn survives_a_poisoned_mutex() { + // If any thread ever panics while holding a limiter mutex, the lock + // becomes poisoned. With the old `.expect(...)` every later auth request + // would re-panic — one blip turned into a permanent auth outage. Recover + // the guard via `into_inner()` instead so the limiter keeps serving. + let rl = AuthRateLimiter::new(RateLimitConfig { + per_sec: 5, + burst: 5, + }); + + // Poison per_ip by panicking while holding its guard. + let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _g = rl.per_ip.lock().unwrap(); + panic!("poison the per-IP mutex"); + })); + assert!(poisoned.is_err(), "the panic must unwind"); + assert!(rl.per_ip.is_poisoned(), "the mutex must now be poisoned"); + + // The per-IP path (Some(ip)) must still work despite the poison. + assert_eq!(rl.try_acquire(ip("198.51.100.9")), AcquireResult::Allowed); + + // And poison the global bucket too — the None-key path must recover. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _g = rl.global.lock().unwrap(); + panic!("poison the global mutex"); + })); + assert!(rl.global.is_poisoned()); + assert_eq!(rl.try_acquire(None), AcquireResult::Allowed); + } } diff --git a/frontend/package.json b/frontend/package.json index da98562..b7a8469 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.124.19", + "version": "0.124.20", "private": true, "type": "module", "scripts": {