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: at -C opt-level >= 1 a compound assignment through a &mut that sits behind a projection is reborrowed twice, the two prophecy resolutions contradict the write, and the function verifies vacuously — even assert!(false) is safe #248
ReborrowVisitor::visit_operand (src/analyze/basic_block/visitor/reborrow.rs:109-128) turns every&mut-typed operand place into a fresh reborrow and rewrites the operand to Move(new_local):
operand.place() matches Operand::Moveand Operand::Copy. For a Move this is right — Rust's &mut is not Copy, so the source is consumed and the new reborrow is the only live handle. But MIR optimizations do produce Operand::Copy of a &mut-typed place: Derefer hoists the base of a deref chain into a temp _t = deref_copy P (Rvalue::CopyForDeref), and GVN rewrites that into _t = copy P (GVN runs from mir_opt_level >= 2, i.e. from -C opt-level=1 upward — the same rewrite that #244 reports for Box).
Such a temp is a pointer copy that exists only to be dereferenced, not an aliasing use. When a single statement carries two of them for the same reference — which is exactly what a compound assignment *p += e through a &mut that sits behind a projection lowers to — Thrust creates two simultaneously-live reborrows of the same reference. At the end of the block both are dropped and each prophecy is resolved to the identity, while the write constrains one of them to current + 1. The three constraints are jointly unsatisfiable, so the block's Horn clause has a false body, its exit predicate is left unconstrained, and every obligation downstream of the call is discharged vacuously.
The result is a silent, unsound, optimization-level-dependent safe: with -C opt-level=1 (also s and z, and 2/3 when the closure crosses a function boundary) a program that panics at run time verifies as safe, and so does assert!(false).
At -C opt-level=0 the temp is still Rvalue::CopyForDeref, which is not an Operand and therefore never reaches visit_operand; rvalue_type binds it with place_type(elaborate_place(P)), a single reborrow is created, and the prophecy is threaded correctly.
Minimal reproduction
No annotations, no Box, no slices:
fnmain(){letmut cnt = 0_i64;letmut f = || { cnt += 1;};f();assert!(false);// <- unreachable only if the environment is inconsistent}
$ cargo run -q -- -Adead_code -C debug-assertions=false min.rs &&echo safeerror: verification error: Unsaterror: aborting due to 1 previous error
$ cargo run -q -- -Adead_code -C debug-assertions=false -C opt-level=1 min.rs &&echo safesafe
assert!(false) verifying is the clearest statement of the problem, but the same holds for an assertion that is merely false about the program:
Unlike #244, the accepting side here is not "the environment says something else"; the environment is inconsistent, so every mutually contradictory assertion is accepted at once at -C opt-level=1:
assertion after f()
opt-level 0
opt-level 1
assert!(cnt == 1) (true)
safe ✔
safe
assert!(cnt == 100) (false)
Unsat ✔
safe ❌
assert!(false)
Unsat ✔
safe ❌
A closure is not required
Any &mut reached through one more projection reproduces it — the closure upvar is just the most common such place. Both of these are ordinary Rust:
// &mut field of a struct, mutated through a &mut to the structstructH<'a>{m:&'amuti64}impl<'a> thrust_models::ModelforH<'a>{typeTy = Self;}fnbump(h:&mutH){*h.m += 1;}fnmain(){letmut x = 0_i64;{letmut h = H{m:&mut x };bump(&mut h);}assert!(x == 100);// false: x == 1 — `safe` at -C opt-level=1}
// &mut in a tuple, mutated through a &mut to the tuplefnbump(t:&mut(&muti64,)){*t.0 += 1;}fnmain(){letmut x = 0_i64;{letmut t = (&mut x,);bump(&mut t);}assert!(x == 100);// false: x == 1 — `safe` at -C opt-level=1}
Both panic under rustc -O (exit 101) and both verify as safe at -C opt-level=1.
Behaviour matrix
All with -Adead_code -C debug-assertions=off. A = the closure reproducer, B = the struct-field one, C = the tuple one, E = the closure passed to a generic HOF (fn call<F: FnMut() -> i64>(mut f: F) -> i64 { f() }), D = let mut r = &mut x; let rr = &mut r; **rr += 1;.
program
0
1
2
3
s
z
A assert!(cnt == 100) — false
Unsat ✔
safe ❌
Unsat ✔
Unsat ✔
safe ❌
safe ❌
A assert!(false)
Unsat ✔
safe ❌
Unsat ✔
Unsat ✔
safe ❌
safe ❌
A assert!(cnt == 1) — true
safe ✔
safe
safe ✔
safe ✔
safe
safe
B assert!(x == 100) — false
Unsat ✔
safe ❌
Unsat ✔
Unsat ✔
safe ❌
safe ❌
C assert!(x == 100) — false
Unsat ✔
safe ❌
Unsat ✔
Unsat ✔
safe ❌
safe ❌
E assert!(r == 100) — false
Unsat ✔
safe ❌
safe ❌
safe ❌
safe ❌
safe ❌
D assert!(x == 100) — false
Unsat ✔
ICE
ICE
ICE
ICE
ICE
2/3 recover for A/B/C only because the callee is inlined into main there (scope 3 (inlined main::{closure#0}) in the -Zunpretty=mir output), which folds *_2 = Add(copy (*_3), 1) back onto the caller's own &mut local and makes the two-temp shape disappear; as soon as the closure crosses a function boundary that inlining cannot undo (row E) the wrong safe is back at every level from 1 to z.
Row D (&mut &mut) instead ICEs at src/refine/env.rs:1014 with borrowing unbound var from -C opt-level=1 upward — noted as an observation, not as a claim that it is the same defect.
Root cause, step by step
cnt += 1 inside the closure lowers to two deref temps for the upvar. At -C opt-level=0:
Rvalue::CopyForDeref holds a Place, not an Operand, so at 0 the visitor never sees it. At 1 both statements are Rvalue::Use(Operand::Copy(place)) with place: &mut i64, so visit_operand fires on each. RUST_LOG=thrust=info shows exactly that — one reborrow of the upvar at 0, two at 1:
v15 = v14 = v4 from the drops, and v15 = v4 + 1 from the write. The body is unsatisfiable, so c4 says nothing about its head p3; p3 may be false, which makes the caller's clause c1 vacuous, p5 unconstrained, and the panic clause c2 (head false) trivially satisfiable. The system is sat — reported as safe.
At -C opt-level=0 the same clause has a single reborrow and is satisfiable:
Writing the increment so the two temps do not live at the same time is handled correctly at every level, which pins the trigger to the overlap rather than to the rewrite alone:
letmut f = || { cnt = cnt + 1;};// Unsat at 0 and at 1 — correctletmut f = || { cnt = 1;};// Unsat at 0 and at 1 — correctletmut f = || { cnt += 1;};// Unsat at 0, safe at 1 — the bug
MIR for the middle one at -C opt-level=1 still contains two copy ((*_1).0) statements, but the first temp is read and dead before the second is created, so the reborrows are created and resolved one at a time.
Not a numerical-range, overflow or unsigned issue: the only values are 0, 1 and 100.
Reachable from the existing suite
tests/ui/pass/closure_mut_capture_pre_post.rs is row E. It verifies at every optimization level, but from -C opt-level=1 upward it does so vacuously: appending assert!(false); to its main still gives safe at 1, 2, 3, s and z, and Unsat only at 0. CI never notices because the suite runs at the default optimization level.
A sweep of the whole tests/ui/pass suite with assert!(false) appended to main finds no vacuous test at -C opt-level=0 — 168 of the 169 tests are correctly Unsat there (the 16 pcsat-backed ones included; iterators/annot_range_next.rs is the one the mechanical injection could not apply to) — and exactly this one at -C opt-level=2.
Summary
ReborrowVisitor::visit_operand(src/analyze/basic_block/visitor/reborrow.rs:109-128) turns every&mut-typed operand place into a fresh reborrow and rewrites the operand toMove(new_local):operand.place()matchesOperand::MoveandOperand::Copy. For aMovethis is right — Rust's&mutis notCopy, so the source is consumed and the new reborrow is the only live handle. But MIR optimizations do produceOperand::Copyof a&mut-typed place:Dereferhoists the base of a deref chain into a temp_t = deref_copy P(Rvalue::CopyForDeref), and GVN rewrites that into_t = copy P(GVN runs frommir_opt_level >= 2, i.e. from-C opt-level=1upward — the same rewrite that #244 reports forBox).Such a temp is a pointer copy that exists only to be dereferenced, not an aliasing use. When a single statement carries two of them for the same reference — which is exactly what a compound assignment
*p += ethrough a&mutthat sits behind a projection lowers to — Thrust creates two simultaneously-live reborrows of the same reference. At the end of the block both are dropped and each prophecy is resolved to the identity, while the write constrains one of them tocurrent + 1. The three constraints are jointly unsatisfiable, so the block's Horn clause has afalsebody, its exit predicate is left unconstrained, and every obligation downstream of the call is discharged vacuously.The result is a silent, unsound, optimization-level-dependent
safe: with-C opt-level=1(alsosandz, and2/3when the closure crosses a function boundary) a program that panics at run time verifies assafe, and so doesassert!(false).At
-C opt-level=0the temp is stillRvalue::CopyForDeref, which is not anOperandand therefore never reachesvisit_operand;rvalue_typebinds it withplace_type(elaborate_place(P)), a single reborrow is created, and the prophecy is threaded correctly.Minimal reproduction
No annotations, no
Box, no slices:assert!(false)verifying is the clearest statement of the problem, but the same holds for an assertion that is merely false about the program:This is vacuity, not a wrong value
Unlike #244, the accepting side here is not "the environment says something else"; the environment is inconsistent, so every mutually contradictory assertion is accepted at once at
-C opt-level=1:f()assert!(cnt == 1)(true)assert!(cnt == 100)(false)assert!(false)A closure is not required
Any
&mutreached through one more projection reproduces it — the closure upvar is just the most common such place. Both of these are ordinary Rust:Both panic under
rustc -O(exit 101) and both verify assafeat-C opt-level=1.Behaviour matrix
All with
-Adead_code -C debug-assertions=off.A= the closure reproducer,B= the struct-field one,C= the tuple one,E= the closure passed to a generic HOF (fn call<F: FnMut() -> i64>(mut f: F) -> i64 { f() }),D=let mut r = &mut x; let rr = &mut r; **rr += 1;.0123szassert!(cnt == 100)— falseassert!(false)assert!(cnt == 1)— trueassert!(x == 100)— falseassert!(x == 100)— falseassert!(r == 100)— falseassert!(x == 100)— false2/3recover for A/B/C only because the callee is inlined intomainthere (scope 3 (inlined main::{closure#0})in the-Zunpretty=miroutput), which folds*_2 = Add(copy (*_3), 1)back onto the caller's own&mutlocal and makes the two-temp shape disappear; as soon as the closure crosses a function boundary that inlining cannot undo (row E) the wrongsafeis back at every level from1toz.Row D (
&mut &mut) instead ICEs atsrc/refine/env.rs:1014withborrowing unbound varfrom-C opt-level=1upward — noted as an observation, not as a claim that it is the same defect.Root cause, step by step
cnt += 1inside the closure lowers to two deref temps for the upvar. At-C opt-level=0:At
-C opt-level=1GVN rewrites both:Rvalue::CopyForDerefholds aPlace, not anOperand, so at0the visitor never sees it. At1both statements areRvalue::Use(Operand::Copy(place))withplace: &mut i64, sovisit_operandfires on each.RUST_LOG=thrust=infoshows exactly that — one reborrow of the upvar at0, two at1:CHC evidence
The closure body's clause (
c4inTHRUST_OUTPUT_DIR) at-C opt-level=1, with themutequalities propagated:v15 = v14 = v4from the drops, andv15 = v4 + 1from the write. The body is unsatisfiable, soc4says nothing about its headp3;p3may befalse, which makes the caller's clausec1vacuous,p5unconstrained, and the panic clausec2(headfalse) trivially satisfiable. The system issat— reported assafe.At
-C opt-level=0the same clause has a single reborrow and is satisfiable:The two copies must overlap
Writing the increment so the two temps do not live at the same time is handled correctly at every level, which pins the trigger to the overlap rather than to the rewrite alone:
MIR for the middle one at
-C opt-level=1still contains twocopy ((*_1).0)statements, but the first temp is read and dead before the second is created, so the reborrows are created and resolved one at a time.Distinct from the known issues
-C opt-level >= 1GVN rewrites theBoxderef temp fromderef_copytocopy, sounelaborate_derefsmisses it and every write through a nestedBoxlands on a copy — panicking programs verify assafe#244 / PR Unelaborate theBoxderef temps GVN rewrites into copies #245 share the trigger (GVN rewriting aderef_copytemp into acopy) but nothing else. Unsound: at-C opt-level >= 1GVN rewrites theBoxderef temp fromderef_copytocopy, sounelaborate_derefsmisses it and every write through a nestedBoxlands on a copy — panicking programs verify assafe#244 is aboutBox:extract_elaborated_dereffails to unelaborate the deref chain,Boxis modeled by value, and the write lands on an independent copy — a wrong value, explicitly not vacuity. Here there is noBoxanywhere, nothing goes throughunelaborate_derefs, and the failure is an inconsistent environment. I built the Unelaborate theBoxderef temps GVN rewrites into copies #245 branch (9d842ec) and re-ran every reproducer above: all of them still reportsafeat-C opt-level=1. That is expected — Unelaborate theBoxderef temps GVN rewrites into copies #245's newUse(Copy)case is gated onbox_deref_temps, a set populated only fromElaborateBoxDerefsTransmutechains, and a&mut i64temp is never in it.v.len()on a&mut [T]leaves an unresolved reborrow that havocs the referent, so every safe&mutslice program that guards an index withlen()is rejected at any-C opt-level >= 1#240 is the other optimization-level-dependent one: aReborrowVisitor-created reborrow whose prophecy is never resolved, which havocs the referent and only ever over-rejects, triggered byPtrMetadataon&mut [T]. Here the prophecies are all resolved — too many times — and the result is a wrongsafe. No slices and nolen()are involved.v.push(v.len())) havocs the receiver before the arguments are evaluated, so reads in the argument see the prophecy — panicking programs verify assafe#209 (two-phase borrows) needs a method call taking&mut selfwith the receiver read in an argument; these reproducers have no two-phase borrow and no such call, and they behave correctly at-C opt-level=0.&mut-capturing closure out of a tuple/struct field into a by-valueFnOnce/FnMutverifies panicking programs assafe#177 / 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 assafe(generalizes #177) #207 are about a&mut-capturing closure moved out of an aggregate; here the closure is called directly (row A) or absent entirely (rows B, C), and the verdict is correct at the default optimization level.0,1and100.Reachable from the existing suite
tests/ui/pass/closure_mut_capture_pre_post.rsis row E. It verifies at every optimization level, but from-C opt-level=1upward it does so vacuously: appendingassert!(false);to itsmainstill givessafeat1,2,3,sandz, andUnsatonly at0. CI never notices because the suite runs at the default optimization level.A sweep of the whole
tests/ui/passsuite withassert!(false)appended tomainfinds no vacuous test at-C opt-level=0— 168 of the 169 tests are correctlyUnsatthere (the 16 pcsat-backed ones included;iterators/annot_range_next.rsis the one the mechanical injection could not apply to) — and exactly this one at-C opt-level=2.Environment
main@cd6b330, on35eea46, and on the Unelaborate theBoxderef temps GVN rewrites into copies #245 branch @9d842ecnightly-2025-09-08(perrust-toolchain.toml)