if let Temporary Lifetime Trap: The MutexGuard That Won't Let Go2026-05-06
This Cache looks textbook-safe: lock the map, look up the key, and if it's missing, compute the value and insert it. Production runs fine for weeks — until one day the service hangs and a thread dump shows every worker frozen inside get_or_compute.
use std::collections::HashMap;
use std::sync::Mutex;
struct Cache {
data: Mutex<HashMap<String, String>>,
}
impl Cache {
fn new() -> Self {
Self { data: Mutex::new(HashMap::new()) }
}
fn get_or_compute(&self, key: &str) -> String {
if let Some(v) = self.data.lock().unwrap().get(key) {
v.clone()
} else {
let computed = expensive(key);
self.data.lock().unwrap()
.insert(key.into(), computed.clone());
computed
}
}
}
fn expensive(k: &str) -> String { format!("v_{}", k) }
It's a self-deadlock, and it only fires on cache misses.
The expression self.data.lock().unwrap() produces a MutexGuard. That guard is a temporary, and under Rust 2021 (and earlier) edition rules, temporaries created in an if let scrutinee live until the end of the entire if let/else construct — not just the condition.
So on a cache hit, we clone the value and the guard drops at the closing } — fine. But on a miss, we enter the else branch still holding the guard from the condition. The next line calls self.data.lock() again on the same mutex from the same thread. std::sync::Mutex is not reentrant: the second lock blocks waiting for the first to be released, which can only happen when the else branch exits — which it never will. Thread dead.
What makes this fiendish:
else path, so unit tests that pre-populate the cache miss it.parking_lot::Mutex or RwLock, the same pattern can produce different failure modes (livelock, writer starvation), making the root cause harder to spot.Drop the guard explicitly by giving it its own scope, or — better — use the entry API so you only lock once:
fn get_or_compute(&self, key: &str) -> String {
{
let map = self.data.lock().unwrap();
if let Some(v) = map.get(key) {
return v.clone();
}
} // guard dropped here, before we re-lock
let computed = expensive(key);
self.data.lock().unwrap()
.insert(key.into(), computed.clone());
computed
}
Or, idiomatically:
fn get_or_compute(&self, key: &str) -> String {
self.data.lock().unwrap()
.entry(key.to_string())
.or_insert_with(|| expensive(key))
.clone()
}
The entry version also fixes a second latent bug in the original: a TOCTOU race where two threads both miss, both compute, and the second insert silently overwrites the first.
if let scrutinee live through the entire else branch in Rust 2021 — never call .lock() there inline; bind the guard to a name with an explicit scope.
