Repository navigation
Unsound: moving a &mut-capturing closure out of any aggregate (incl. enum/Option), with no higher-order call, drops its prophecy and verifies panicking programs as safe (generalizes #177) #207
Description
Summary
Moving a non-Copy closure that captures a variable by &mut out of an aggregate (a tuple, a struct field, or an enum/Option variant) and then calling it silently drops the captured &mut's prophecy. The post-state collapses to a contradictory (⊥) environment — so the closure's mutation is never reflected onto the captured variable, and everything after the call verifies vacuously (including assert!(false)). Thrust reports programs that always panic at runtime as safe.
This is a generalization of #177. #177 documents the failure for a closure moved out of a tuple/struct field and passed by value to a higher-order function taking FnOnce/FnMut, and localizes it to the call-site projection check in rust_call.rs "case 3". The reproductions below show the real trigger is the move out of the aggregate itself:
- it needs no higher-order function — the closure is called directly through an ordinary local;
- it fires for
enum/Optionvariants (aDowncastextraction), not only tuple/struct fields; - the call goes through a projection-less local, which Unsound: moving a
&mut-capturing closure out of a tuple/struct field into a by-valueFnOnce/FnMutverifies panicking programs assafe#177 explicitly lists as a sound control.
Consequently a fix limited to rust_call.rs "case 3" (the if arg_closure_place.projection.is_empty() guard) would not close these holes.
Reproduction (minimal)
Both programs always panic under rustc but verify safe under Thrust. Neither uses a higher-order function or any annotation.
R1 — &mut-capturing closure moved out of an Option variant
fn main() {
let mut x = 0i64;
let e = Some(|| { x += 8; }); // closure captures `x` by &mut, stored in Option::Some
if let Some(mut g) = e { // moved out of the enum variant (Downcast projection)
g(); // called directly through a plain local — no HOF
}
assert!(x == 0); // runtime: x == 8, so this panics; Thrust: SAFE
}$ rustc --edition 2021 r1.rs -o r1 && ./r1 ; echo "exit:$?"
thread 'main' panicked at r1.rs:7:5:
assertion failed: x == 0
exit:101 # actually panics
$ THRUST_SOLVER=z3 thrust-rustc --edition 2021 -Adead_code -C debug-assertions=false r1.rs ; echo "exit:$?"
exit:0 # Thrust: SAFER2 — same failure with a struct field and no higher-order function
struct W<F> { f: F }
fn main() {
let mut x = 0i64;
let w = W { f: || { x += 8; } }; // &mut-capturing closure in a struct field
let mut g = w.f; // moved out into a projection-less local
g(); // called directly — no HOF
assert!(x == 0); // runtime: x == 8, panics; Thrust: SAFE
}R3 — the ⊥-collapse tell-tale (assert!(false) verifies)
fn main() {
let mut x = 0i64;
let e = Some(|| { x += 8; });
if let Some(mut g) = e { g(); }
assert!(false); // ALWAYS panics; Thrust: SAFE (vacuous post-state)
}assert!(false) passing is the tell-tale (same as #177) that the environment after the call is unsatisfiable, so any assertion is discharged.
What isolates the cause
All rows verified at commit a148b9d with Z3 4.15.4 (the version pinned by .github/actions/setup-z3). "safe" is wrong wherever rustc exits 101.
| Program (closure is || { x += 8; }, capturing x by &mut) | Thrust | Correct? |
| --- | --- | --- |
| Some(closure) → if let Some(mut g)=e { g(); } (enum, no HOF) | safe | ✗ (panics) |
| W{f:closure} → let mut g=w.f; g(); (struct, no HOF) | safe | ✗ |
| (closure,) → let mut g=t.0; g(); (1-tuple, no HOF) | safe | ✗ |
| (closure, 1) → let (mut g,_)=t; g(); (full destructure, no HOF) | safe | ✗ |
| Some(closure) → if let Some(g)=e { apply(g); } (enum, via HOF) | safe | ✗ |
| Control: let mut g = closure; g(); (never in an aggregate) | Unsat | ✓ |
| Control: let g0 = closure; let mut g = g0; g(); (local→local move) | Unsat | ✓ |
| Control: (\|\| 5i64,) → let g=t.0; let v=g(); (no &mut capture) | Unsat | ✓ |
Two facts fall out:
- The move out of an aggregate is the trigger. A closure that is never placed in an aggregate (control rows 1–2) is handled soundly, even after a local→local move. As soon as the same closure is extracted from a tuple/struct/enum, the mutation is lost. This holds for a full destructure too, so it is not a partial-move-remnant effect (cf. Unsoundness: partially-moved locals are still implicitly dropped, resolving prophecies of moved-out
&mutborrows #121/Unsound: aggregate dropped wholesale after a partial field-move double-resolves the field's &mut prophecy #122). - A captured
&mutis required. A closure with no mutable capture (control row 3) is sound — the defect is specifically the loss of the captured&mut's prophecy.
Relationship to #177 (why this is broader)
#177's reproductions all pass the closure by value directly from the field to an HOF (apply(t.0)), so at the FnOnce::call_once terminator arg_closure_place has a non-empty projection and the guard
// src/analyze/basic_block/visitor/rust_call.rs, "case 3"
if arg_closure_place.projection.is_empty() {
self.analyzer.drop_after_terminator(arg_closure_place.local);
}is skipped. #177 lists "move through a projection-less local" as a sound control.
In R1/R2/R3 the closure is first moved out of the aggregate into a plain local (g) and then called; the call terminator's arg_closure_place is g (empty projection), so that guard is satisfied — yet the program is still accepted. The prophecy is therefore lost before the call, at the move-out of the aggregate, not at the case-3 call-site handling. The enum/Option variants additionally involve a Downcast extraction that #177 does not mention. So the unsoundness is not confined to the case-3 path #177 identifies.
Mechanism (what is established)
The captured &mut's prophecy obligation is not carried through when a closure value is moved out of an aggregate place (tuple element, struct field, or enum-variant field via Downcast). After the closure is invoked, the mutation performed through the captured &mut is never propagated back to the referent, and Thrust's post-state becomes ⊥ (hence assert!(false) verifies). The projection-less-origin path (closure born in a local) resolves the prophecy correctly, which is why the controls pass. This matches #177's ⊥-collapse symptom but is reached through the aggregate move-out rather than the by-value HOF call site.
Expected behavior
Moving a &mut-capturing closure out of an aggregate and calling it must resolve the captured &mut's prophecy exactly as when the closure is called from a projection-less local: after g() the referent (x) must reflect the closure's write, so R1/R2 report Unsat and R3 (with assert!(false)) reports Unsat.
Why this is unsound (not merely incomplete)
In every reproduction the program panics for its only execution (rustc exit 101), yet Thrust returns safe — a safe verdict for an always-panicking program. R3 makes the vacuous post-state explicit: assert!(false) is discharged.
Distinct from existing issues
- Generalizes Unsound: moving a
&mut-capturing closure out of a tuple/struct field into a by-valueFnOnce/FnMutverifies panicking programs assafe#177, but is not covered by it: Unsound: moving a&mut-capturing closure out of a tuple/struct field into a by-valueFnOnce/FnMutverifies panicking programs assafe#177 is scoped to a tuple/struct field closure passed by value to an HOF, with the mechanism at therust_call.rscase-3 call-site projection guard. These repros need no HOF, fire for enum/Optionvariants, and go through a projection-less local (Unsound: moving a&mut-capturing closure out of a tuple/struct field into a by-valueFnOnce/FnMutverifies panicking programs assafe#177's own sound control), so the case-3 guard is satisfied and fixing only that guard would leave them unsound. - Not Ill-typed SMT: a closure that mutates a captured variable, passed to a generic HOF spec'd with
pre!/post!, has its pre/postcondition predicate declared at the FnMut receiver sortMut<env>but applied at the bareenvsort, so verification aborts with a solver sort mismatch #206: Ill-typed SMT: a closure that mutates a captured variable, passed to a generic HOF spec'd withpre!/post!, has its pre/postcondition predicate declared at the FnMut receiver sortMut<env>but applied at the bareenvsort, so verification aborts with a solver sort mismatch #206 is an ill-typed SMT abort frompre!/post!closure specs; these produce a wrongsafeverdict and use no annotations. - Not Unsoundness: partially-moved locals are still implicitly dropped, resolving prophecies of moved-out
&mutborrows #121/Unsound: aggregate dropped wholesale after a partial field-move double-resolves the field's &mut prophecy #122: those concern dropping partially-moved data aggregates; here a full destructure also triggers it and the subject is a closure's captured&mut, and the outcome is a ⊥ post-state. - Not a lack-of-support panic (no
panicked at/not implemented) and not numeric-range/overflow (only0and8).
Environment
- thrust @
a148b9d - rustc
nightly-2025-09-08(perrust-toolchain.toml) - Z3 4.15.4 (the version pinned by
.github/actions/setup-z3), default solver configuration; reproduces identically at the default edition and--edition 2021.
Activity
coord-e commentedon Sep 1, 2026
Evidence that this is the same defect as #121/#122 rather than a distinct one, in case it helps consolidate the three.
The closure is not required — only that the moved-out value contains a
&mut. Every one of these reproduces the ⊥ collapse onmain@35eea46(all reportsafe, includingassert!(false); all panic underrustc -O), and none of them mentions a closure:The control that separates these from the sound cases is the one #121 already identifies: moving a bare
&mutout of the same aggregate (let t = (&mut x, 0i64); let r = t.0; *r = 5;) is correctlyUnsat, becauseReborrowVisitor::visit_operandturnsRef-typed operands into reborrows. #121's own text predicts exactly the family above — "the hole is reachable whenever the moved projection's type is a non-reference type that (transitively) owns a mutable borrow" — and a&mut-capturing closure is precisely such a type: its upvar tuple owns theMut.The full-destructure argument does not separate them. This issue rules out #121/#122 on the grounds that a full destructure also triggers the failure, but the plain-data version behaves the same way:
which is what #121's mechanism predicts: at MIR level a "full" destructure is still a sequence of field moves (
_o = move (_2.0); _n = copy (_2.1)), each with a non-empty projection, somoved_localsrecords nothing and the parent is still dropped wholesale.A
moved_locals-level change fixes R1/R2/R3 here and #177's reproducer. As an experiment I widenedmoved_locals(src/analyze/basic_block/drop_point.rs) to record the base local of anyOperand::Movewhose moved place type is not a reference — i.e. exactly the whole-local-vs-projection distinction quoted in #121 and #122, and nothing else:if let mir::Operand::Move(place) = operand { - if place.projection.is_empty() && !self.body.local_decls[place.local].ty.is_ref() { - self.locals.insert(place.local); - } + let moved_ty = place.ty(&self.body.local_decls, self.tcx).ty; + if !moved_ty.is_ref() { + self.locals.insert(place.local); + } }With that single change, on
main@35eea46:Some(closure)→if let Some(mut g))Unsat✔W { f: closure }→let mut g = w.f)Unsat✔assert!(false))Unsat✔apply(t.0)Unsat✔((&mut a,),)reproducerUnsat✔Unsat✔The patch touches only which locals are excluded from the implicit-drop set, so the ⊥ in all of these comes from an implicit drop of a partially-moved local — #121's mechanism exactly. It is a deliberately crude experiment and not a proposed patch: it suppresses the parent's drop entirely instead of dropping the still-initialised sub-places, and one variant (the full destructure
let (o, _n) = t;, where the parent dies at a later statement than the move) is still accepted, which is why #121's "track which projection was moved" direction is the one that matters.Two incidental notes:
main.struct Wrap<'a> { r: &'a mut i32 }withfn apply(w: Wrap) { bump(w.r); }now givesUnsatforassert!(x == 999)and forassert!(false), andsafefor the trueassert!(x == 2)— the moved field there is a bare&mut, whichReborrowVisitorreborrows. What is still open under Unsound: aggregate dropped wholesale after a partial field-move double-resolves the field's &mut prophecy #122's title is the case where the moved field is a value that owns a&mut, i.e. M1–M4 and this issue.THRUST_ENUM_EXPANSION_DEPTH_LIMIT.Suggest consolidating #177 and #207 into #121 (which PR #155 already targets together with #122), or at least cross-linking them so that a fix is validated against the closure reproducers too.
Generated by Claude Code
Check the aliased-prophecy drop-rule repro against 4 open upstream is…