fix: recover the auth rate limiter from a poisoned mutex

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 14:02:54 +02:00
parent f5cb460aec
commit 4fe435cc76
4 changed files with 37 additions and 6 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.124.19" version = "0.124.20"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.124.19" version = "0.124.20"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -141,10 +141,10 @@ impl AuthRateLimiter {
return self return self
.global .global
.lock() .lock()
.expect("rate limiter mutex poisoned") .unwrap_or_else(|e| e.into_inner())
.try_take(&self.cfg, now); .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) { if map.len() >= MAX_TRACKED_IPS && !map.contains_key(&ip) {
map.retain(|_, b| !b.is_idle(&self.cfg, now)); map.retain(|_, b| !b.is_idle(&self.cfg, now));
if map.len() >= MAX_TRACKED_IPS { if map.len() >= MAX_TRACKED_IPS {
@@ -155,7 +155,7 @@ impl AuthRateLimiter {
return self return self
.global .global
.lock() .lock()
.expect("rate limiter mutex poisoned") .unwrap_or_else(|e| e.into_inner())
.try_take(&self.cfg, now); .try_take(&self.cfg, now);
} }
} }
@@ -287,4 +287,35 @@ mod tests {
} }
assert!(rl.per_ip.lock().unwrap().len() <= MAX_TRACKED_IPS); 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);
}
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.124.19", "version": "0.124.20",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {