You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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/Option variants (a Downcast extraction), not only tuple/struct fields;
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
fnmain(){letmut x = 0i64;let e = Some(|| { x += 8;});// closure captures `x` by &mut, stored in Option::SomeifletSome(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}
R2 — same failure with a struct field and no higher-order function
structW<F>{f:F}fnmain(){letmut x = 0i64;let w = W{f: || { x += 8;}};// &mut-capturing closure in a struct fieldletmut g = w.f;// moved out into a projection-less localg();// called directly — no HOFassert!(x == 0);// runtime: x == 8, panics; Thrust: SAFE}
R3 — the ⊥-collapse tell-tale (assert!(false) verifies)
fnmain(){letmut x = 0i64;let e = Some(|| { x += 8;});ifletSome(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 | ✓ |
A captured &mut is required. A closure with no mutable capture (control row 3) is sound — the defect is specifically the loss of the captured &mut's prophecy.
#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
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.
Z3 4.15.4 (the version pinned by .github/actions/setup-z3), default solver configuration; reproduces identically at the default edition and --edition 2021.
Summary
Moving a non-
Copyclosure that captures a variable by&mutout of an aggregate (a tuple, a struct field, or anenum/Optionvariant) 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 (includingassert!(false)). Thrust reports programs that always panic at runtime assafe.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 inrust_call.rs"case 3". The reproductions below show the real trigger is the move out of the aggregate itself:enum/Optionvariants (aDowncastextraction), not only tuple/struct fields;&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" (theif arg_closure_place.projection.is_empty()guard) would not close these holes.Reproduction (minimal)
Both programs always panic under
rustcbut verifysafeunder Thrust. Neither uses a higher-order function or any annotation.R1 —
&mut-capturing closure moved out of anOptionvariantR2 — same failure with a struct field and no higher-order function
R3 — the ⊥-collapse tell-tale (
assert!(false)verifies)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
a148b9dwith Z3 4.15.4 (the version pinned by.github/actions/setup-z3). "safe" is wrong whereverrustcexits 101.| Program (closure is
|| { x += 8; }, capturingxby&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&mutcapture) |Unsat| ✓ |Two facts fall out:
&mutborrows #121/Unsound: aggregate dropped wholesale after a partial field-move double-resolves the field's &mut prophecy #122).&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 theFnOnce::call_onceterminatorarg_closure_placehas a non-empty projection and the guardis 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'sarg_closure_placeisg(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/Optionvariants additionally involve aDowncastextraction 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 viaDowncast). After the closure is invoked, the mutation performed through the captured&mutis never propagated back to the referent, and Thrust's post-state becomes ⊥ (henceassert!(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: afterg()the referent (x) must reflect the closure's write, so R1/R2 reportUnsatand R3 (withassert!(false)) reportsUnsat.Why this is unsound (not merely incomplete)
In every reproduction the program panics for its only execution (
rustcexit 101), yet Thrust returnssafe— asafeverdict for an always-panicking program. R3 makes the vacuous post-state explicit:assert!(false)is discharged.Distinct from existing issues
&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.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.&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.panicked at/not implemented) and not numeric-range/overflow (only0and8).Environment
a148b9dnightly-2025-09-08(perrust-toolchain.toml).github/actions/setup-z3), default solver configuration; reproduces identically at the default edition and--edition 2021.