RFA-144 · Case file with fixtures · Case 116 of 694 · Runtime evidence
Why a MutexGuard in while let Lives Through the Loop Body
The temporary scope of a pattern-matching while condition includes its consequent loop body. The unnamed MutexGuard therefore survives longer than the pop call; bind the result in a separate statement or helper so the guard drops before body work begins.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- targets with std::sync
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- The temporary MutexGuard created by the pattern-matching while condition has a scope covering the consequent loop body rather than ending after the pop expression.
- First discriminating check
- Bind the condition result in a separate statement or inner block and verify the guard drops before entering code that may lock the mutex again.
The failing program writes a compact worker loop:
while let Some(value) = queue.lock().unwrap().pop() {
// process value
}
Inside the body, a non-blocking second acquisition returns WouldBlock. The first MutexGuard looks like a temporary needed only for pop, but its drop scope covers the body.
Temporary values still have precise scopes
Rust often drops an unnamed temporary at the end of its statement. Pattern-matching control flow has more specific rules because values produced by the condition can be borrowed or moved into the consequent body.
The Reference temporary-scope section states that the pattern-matching condition and loop body of while form a temporary scope. The guard created while evaluating the condition therefore remains alive during the matching iteration.
The loop is closer to this ownership shape:
create guard
evaluate pop and match
run body for successful match
drop condition temporaries
begin next condition
It is not equivalent to calling a helper that returns an owned Option<T> after dropping its guard.
The runtime symptom may be a deadlock
std::sync::Mutex is not required to detect recursive acquisition by the same thread in a friendly way. Calling blocking lock() again can wait forever or have platform-dependent behaviour.
The fixture uses try_lock, which returns immediately. It reports WouldBlock, making the hidden guard lifetime deterministic without hanging the evidence runner.
Real code may block indirectly. The loop body can call another function, emit a callback, update a metric with the same state, or await work that later needs the mutex. The second lock site may be far from the condition that still owns the first guard.
Give the guard a smaller statement
The repaired program evaluates the pop in its own statement and inner block:
let next = { queue.lock().unwrap().pop() };
let Some(value) = next else { break };
The guard is not stored in next; only the owned Option<i32> is. It drops at the end of the initializer statement before the body processes value.
I like this form because the critical section is visually small. A named helper can make the contract even clearer:
fn pop_one<T>(queue: &Mutex<Vec<T>>) -> Option<T> {
queue.lock().unwrap().pop()
}
The function return boundary ends the guard before the caller begins processing.
Rust 2024 did not shorten this while let case
Rust 2024 narrowed some temporary lifetimes, notably if let temporaries before an else branch and block tail-expression temporaries. It is easy to overgeneralize that edition change and assume every pattern condition now drops early.
The Reference still lists the pattern-matching while condition together with its consequent body. The loop may need values bound by the pattern during that body, so the scope remains different.
I test the exact construct and edition rather than relying on a memory that “temporary lifetimes changed in 2024.”
Keep work outside the critical section
Even when no second lock exists, holding the guard across the body can serialize expensive processing. Queue throughput falls because producers and other consumers wait while one item is handled.
Popping an owned item under the lock and processing it afterward is usually the intended design. If the item borrows from protected storage, it cannot safely outlive the guard; I then redesign ownership or deliberately accept a longer critical section.
For async work, I use the runtime-appropriate primitive and avoid holding a synchronous guard across .await. The temporary-scope lesson still applies: I make acquisition and release visible before suspension or callbacks.
My debugging sequence
When a lock appears to outlive its expression, I do this:
- Identify the value that owns unlocking, normally a guard's destructor.
- Read the temporary scope of the complete
if let,while let, ormatch, not only the method call. - Reproduce with
try_lockor a timeout instead of allowing a test to hang. - Bind an owned result in a separate statement or helper.
- Keep callbacks, I/O, heavy computation, and awaits outside the critical section.
- Test the precise Rust edition when temporary-scope rules are relevant.
The broad principle is that source brevity does not imply a short ownership lifetime. An unnamed RAII guard still follows formal drop scopes. Giving the protected operation its own statement makes both the lock duration and the system's concurrency behaviour easier to reason about.