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
Incompleteness: v.len() on a &mut [T] leaves an unresolved reborrow that havocs the referent, so every safe &mut slice program that guards an index with len() is rejected at any -C opt-level >= 1 #240
<[T]>::len is lowered by rustc to PtrMetadata. When the receiver is a &mut [T], ReborrowVisitor::visit_operand first rewrites the operand into a fresh &mut reborrow of the slice, and analyze_assignment's PtrMetadata branch then throws that reborrow away — it takes a new shared borrow of the reborrow's referent and passes that to slice_len instead. Nothing ever resolves the discarded &mut reborrow's prophecy, and because borrowing a place replaces its value with the borrow's prophecy, *v is havoc'd for the rest of the function.
Consequence: after a single v.len() on a &mut [T], every later fact about (*v) — including the length that len() itself just returned — is lost. The guard if i < v.len() therefore no longer discharges the index < (*slice).length precondition of _extern_spec_slice_index/_extern_spec_slice_index_mut, and trivially safe programs are rejected with Unsat.
At -C opt-level=0 rustc always routes the metadata read through an explicit shared reborrow (_5 = &(*_1); _4 = PtrMetadata(move _5)), so the &mut operand shape — and the bug — never appears. From -C opt-level=1 upward (including -O, -C opt-level=s, -C opt-level=z) rustc emits _4 = PtrMetadata(copy _1) directly on the &mut [T] local and the bug fires. Thrust's verdict therefore depends on the optimization level: the same source is safe in a debug build and Unsat in a release build.
This is a completeness failure (over-rejection), not an unsoundness — the havoc'd referent is genuinely free, so false assertions after such a call are still correctly rejected (see "Not vacuity" below).
Minimal reproducer
fnmain(){}#[thrust::callable]fncheck(v:&mut[i32],i:usize){if i < v.len(){let x = v[i];assert!(x == x);}}
$ cargo run -- -Adead_code -C debug-assertions=false min.rs &&echo safesafe
$ cargo run -- -Adead_code -C debug-assertions=false -C opt-level=2 min.rs &&echo safeerror: verification error: Unsaterror: aborting due to 1 previous error
The index is guarded by the immediately preceding v.len(), so v[i] is in bounds by construction.
Changing the parameter to a shared slice makes the program verify at every optimization level:
#[thrust::callable]fncheck(v:&[i32],i:usize){// &[i32] instead of &mut [i32]if i < v.len(){let x = v[i];assert!(x == x);}}
A realistic case — the standard "walk a mutable slice by index" loop:
fndouble_all(v:&mut[i32]){letmut i = 0;while i < v.len(){
v[i] = v[i]*2;
i += 1;}}#[thrust::callable]fncheck(v:&mut[i32]){double_all(v);}fnmain(){}
safe at the default optimization level, Unsat at -C opt-level=2.
Behavior matrix (all with -Adead_code -C debug-assertions=false)
-C opt-level
&mut [i32] reader (min.rs)
&[i32] reader
&mut [i32] writer (if i < v.len() { v[i] = 5; })
double_all
0 (default)
safe ✔
safe ✔
safe ✔
safe ✔
1
Unsat ❌
safe ✔
Unsat ❌
Unsat ❌
2
Unsat ❌
safe ✔
Unsat ❌
Unsat ❌
3
Unsat ❌
safe ✔
Unsat ❌
Unsat ❌
s / z
Unsat ❌
safe ✔
Unsat ❌
Unsat ❌
Programs that only read the length keep working at -O (let n = v.len(); let m = v.len(); assert!(n == m);, if i < v.len() { assert!(i < v.len()); }): rustc CSEs the two metadata reads into one, so nothing reads *v after the reborrow. It is specifically a read of (*v)after a len() — which every indexing operation performs, since the reconstructed Index::index receiver is &(*v) — that observes the havoc.
Root cause
v.len() is PtrMetadata in MIR. The two shapes rustc emits for a &mut [i32] receiver:
analyze_statements runs ReborrowVisitor on the statement before analyze_assignment sees it. ReborrowVisitor::visit_operand (src/analyze/basic_block/visitor/reborrow.rs) reborrows every&mut-typed operand:
if m.is_mut(){let new_local = self.insert_reborrow(self.tcx.mk_place_deref(p),*inner_ty);*operand = mir::Operand::Move(new_local.into());}
so the statement becomes _4 = PtrMetadata(move _new) with _new = &mut (*_1). insert_reborrow binds _new via borrow_place_, which allocates a prophecy and — as everywhere in the prophecy encoding — replaces the value of *_1 with that prophecy.
analyze_assignment then takes the mutable branch (src/analyze/basic_block.rs):
let operand = if mutability.is_mut(){let place = operand.place().expect("mutable slice metadata operand must be a place");
...let rty = self.immut_borrow_place(self.tcx.mk_place_deref(place));// &(*_new)self.bind_local(local, rty);Operand::Copy(local.into())}else{
operand.clone()};
It reads _new's current half through a fresh shared borrow (so _4 is the correct, pre-borrow length) and never touches _new again. _new is a synthetic local that does not exist in the MIR the liveness-based DropPoints was computed from, so it is never passed to drop_local and its prophecy is never equated to its current value. ReborrowVisitor::insert_reborrow/insert_borrow do not call drop_after_terminator either (only visitor/rust_call.rs does that, for closure receivers).
Every later read of (*_1) — in particular the _7 = &(*_1) receiver that reconstruct_slice_indexing::receiver_operand inserts for the reconstructed Index::index call — therefore reads that unconstrained prophecy.
CHC evidence
For the minimal reproducer at -C opt-level=2, the query clause for the index precondition (THRUST_OUTPUT_DIR, clause c2) contains, among the assumptions:
; the length the guard was taken on: len = tuple_proj.1(current half of _1 at the PtrMetadata)
(= v11 (< v10 v12)) ; _3 = Lt(_2, _4)
(not (= v11 false)) ; the `if` was taken
(= (mut (tuple v21 v22) v20) (mut (tuple v15 v16) v19)) ; current = (v15,v16), final = v19; the receiver of Index::index is built from v19, the *final* half above
(= (mut (tuple v3 v4) v2) (mut v19 v14))
(= v8 (mut_current (mut (tuple v3 v4) v2)))
(= v1 v8)
and the head obliges (< v0 (tuple_proj<...>.1 v1)), i.e. _2 < v4. The guard gives v10 < v12 with v12 = tuple_proj.1(tuple(v15, v16)) = v16, but the receiver's length is v4, taken from v19 — the unresolved prophecy. Nothing in the system relates v19 to tuple(v15, v16), so the obligation is unprovable and the system is unsat.
Confirmed by experiment
Resolving the discarded reborrow right after the shared borrow is taken:
let rty = self.immut_borrow_place(self.tcx.mk_place_deref(place));
self.bind_local(local, rty);
+ self.drop_local(place.local);
Operand::Copy(local.into())
makes every reproducer above verify as safe at -C opt-level=0..3, while the unsafe variants stay rejected (if i < v.len() { v[i] = 7; assert!(v[i] == 8); } → Unsat, unguarded v[i] → Unsat, assert!(false) after the call → Unsat). This is offered as evidence for the diagnosis, not as a proposed patch — not reborrowing the operand of a read-only PtrMetadata in ReborrowVisitor at all looks like the cleaner place to fix it.
Not vacuity
The havoc is a genuine over-approximation, so the failure mode is only over-rejection:
if i < v.len() { let x = v[i]; assert!(false); } → Unsat at -C opt-level=2 (the env is not made inconsistent).
if i < v.len() { v[i] = 7; assert!(v[i] == 8); } → Unsat at both optimization levels.
unguarded let x = v[i]; → Unsat at both optimization levels.
Notes
No existing test covers this: tests/ui/pass/slice_index_mut.rs, slice_first_mut.rs, slice_last_mut.rs and slice_methods_mut.rs all verify at -C opt-level=2 too, because they index a #[thrust::trusted] slice at constant positions and never guard a symbolic index with v.len(). The suite otherwise runs at the default optimization level, so CI never sees the PtrMetadata(copy <&mut>) shape at all — even though tests/ui/pass/list_sum_const.rs shows -C opt-level=3 is a supported configuration.
The mutability.is_mut() branch in analyze_assignment exists precisely for this optimized shape; in every -C opt-level=0 MIR I inspected (v.len(), (*v).len(), <[T]>::len(v), &mut &mut [T], indexing loops) the PtrMetadata operand is a shared &[T], so the branch appears to be reachable only with optimizations enabled.
Summary
<[T]>::lenis lowered by rustc toPtrMetadata. When the receiver is a&mut [T],ReborrowVisitor::visit_operandfirst rewrites the operand into a fresh&mutreborrow of the slice, andanalyze_assignment'sPtrMetadatabranch then throws that reborrow away — it takes a new shared borrow of the reborrow's referent and passes that toslice_leninstead. Nothing ever resolves the discarded&mutreborrow's prophecy, and because borrowing a place replaces its value with the borrow's prophecy,*vis havoc'd for the rest of the function.Consequence: after a single
v.len()on a&mut [T], every later fact about(*v)— including the length thatlen()itself just returned — is lost. The guardif i < v.len()therefore no longer discharges theindex < (*slice).lengthprecondition of_extern_spec_slice_index/_extern_spec_slice_index_mut, and trivially safe programs are rejected withUnsat.At
-C opt-level=0rustc always routes the metadata read through an explicit shared reborrow (_5 = &(*_1); _4 = PtrMetadata(move _5)), so the&mutoperand shape — and the bug — never appears. From-C opt-level=1upward (including-O,-C opt-level=s,-C opt-level=z) rustc emits_4 = PtrMetadata(copy _1)directly on the&mut [T]local and the bug fires. Thrust's verdict therefore depends on the optimization level: the same source issafein a debug build andUnsatin a release build.This is a completeness failure (over-rejection), not an unsoundness — the havoc'd referent is genuinely free, so false assertions after such a call are still correctly rejected (see "Not vacuity" below).
Minimal reproducer
The index is guarded by the immediately preceding
v.len(), sov[i]is in bounds by construction.Changing the parameter to a shared slice makes the program verify at every optimization level:
A realistic case — the standard "walk a mutable slice by index" loop:
safeat the default optimization level,Unsatat-C opt-level=2.Behavior matrix (all with
-Adead_code -C debug-assertions=false)-C opt-level&mut [i32]reader (min.rs)&[i32]reader&mut [i32]writer (if i < v.len() { v[i] = 5; })double_all0(default)123s/zPrograms that only read the length keep working at
-O(let n = v.len(); let m = v.len(); assert!(n == m);,if i < v.len() { assert!(i < v.len()); }): rustc CSEs the two metadata reads into one, so nothing reads*vafter the reborrow. It is specifically a read of(*v)after alen()— which every indexing operation performs, since the reconstructedIndex::indexreceiver is&(*v)— that observes the havoc.Root cause
v.len()isPtrMetadatain MIR. The two shapes rustc emits for a&mut [i32]receiver:analyze_statementsrunsReborrowVisitoron the statement beforeanalyze_assignmentsees it.ReborrowVisitor::visit_operand(src/analyze/basic_block/visitor/reborrow.rs) reborrows every&mut-typed operand:so the statement becomes
_4 = PtrMetadata(move _new)with_new = &mut (*_1).insert_reborrowbinds_newviaborrow_place_, which allocates a prophecy and — as everywhere in the prophecy encoding — replaces the value of*_1with that prophecy.analyze_assignmentthen takes the mutable branch (src/analyze/basic_block.rs):It reads
_new's current half through a fresh shared borrow (so_4is the correct, pre-borrow length) and never touches_newagain._newis a synthetic local that does not exist in the MIR the liveness-basedDropPointswas computed from, so it is never passed todrop_localand its prophecy is never equated to its current value.ReborrowVisitor::insert_reborrow/insert_borrowdo not calldrop_after_terminatoreither (onlyvisitor/rust_call.rsdoes that, for closure receivers).Every later read of
(*_1)— in particular the_7 = &(*_1)receiver thatreconstruct_slice_indexing::receiver_operandinserts for the reconstructedIndex::indexcall — therefore reads that unconstrained prophecy.CHC evidence
For the minimal reproducer at
-C opt-level=2, the query clause for the index precondition (THRUST_OUTPUT_DIR, clausec2) contains, among the assumptions:and the head obliges
(< v0 (tuple_proj<...>.1 v1)), i.e._2 < v4. The guard givesv10 < v12withv12 = tuple_proj.1(tuple(v15, v16)) = v16, but the receiver's length isv4, taken fromv19— the unresolved prophecy. Nothing in the system relatesv19totuple(v15, v16), so the obligation is unprovable and the system isunsat.Confirmed by experiment
Resolving the discarded reborrow right after the shared borrow is taken:
let rty = self.immut_borrow_place(self.tcx.mk_place_deref(place)); self.bind_local(local, rty); + self.drop_local(place.local); Operand::Copy(local.into())makes every reproducer above verify as
safeat-C opt-level=0..3, while the unsafe variants stay rejected (if i < v.len() { v[i] = 7; assert!(v[i] == 8); }→Unsat, unguardedv[i]→Unsat,assert!(false)after the call →Unsat). This is offered as evidence for the diagnosis, not as a proposed patch — not reborrowing the operand of a read-onlyPtrMetadatainReborrowVisitorat all looks like the cleaner place to fix it.Not vacuity
The havoc is a genuine over-approximation, so the failure mode is only over-rejection:
if i < v.len() { let x = v[i]; assert!(false); }→Unsatat-C opt-level=2(the env is not made inconsistent).if i < v.len() { v[i] = 7; assert!(v[i] == 8); }→Unsatat both optimization levels.let x = v[i];→Unsatat both optimization levels.Notes
tests/ui/pass/slice_index_mut.rs,slice_first_mut.rs,slice_last_mut.rsandslice_methods_mut.rsall verify at-C opt-level=2too, because they index a#[thrust::trusted]slice at constant positions and never guard a symbolic index withv.len(). The suite otherwise runs at the default optimization level, so CI never sees thePtrMetadata(copy <&mut>)shape at all — even thoughtests/ui/pass/list_sum_const.rsshows-C opt-level=3is a supported configuration.mutability.is_mut()branch inanalyze_assignmentexists precisely for this optimized shape; in every-C opt-level=0MIR I inspected (v.len(),(*v).len(),<[T]>::len(v),&mut &mut [T], indexing loops) thePtrMetadataoperand is a shared&[T], so the branch appears to be reachable only with optimizations enabled.&mutstored in aVec(Seq/array-backed container) never has its prophecy resolved at drop, so safe programs are wrongly rejected asUnsat#202 (a&mutstored in a Seq/array-backed container never has its prophecy resolved at drop): here the unresolved&mutis a synthetic reborrow of the container itself, created byReborrowVisitor, and the referent it havocs is the slice's own(array, length)model.usize/u32/u64) are modeled as unconstrained integers, so their>= 0lower bound is not assumed and safe programs are wrongly rejected #165 (usizemodeled as an unconstrained integer): the reproducers are rejected even though the guardi < v.len()supplies the upper bound directly, and the identical program with&[i32]verifies.Environment
main@cd6b330