Rust's if let Temporary Lifetime Trap: The MutexGuard That Won't Let Go

2026-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) }

The Bug

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:

The Fix

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.

Key Takeaway: Temporaries in an 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.

All newsletters