Summary
ReborrowVisitor::visit_assign picks the elaboration for an assignment from the type of inner_place, but inner_place == place whenever the assigned place does not end in Deref. For a place that is itself of pointer type — a Box<T>- or &mut T-typed struct/tuple field — the Ref/Box arms then borrow the place's pointee instead of the place, and the assignment
c.page = Box::new(5); // replace the box
is rewritten as if it were
*c.page = 5; // write through the old box
The environment afterwards records c.page as int where its declared type is own int, and the next use of c — a deref, or relating c to Cache when it is passed anywhere — aborts the compiler.
This is not a missing feature: every ingredient is supported on its own, the _ => arm two lines below already handles this shape correctly (which is why a Vec-typed field works), and the same statement on a Box local verifies fine. The analyzer's own diagnostic says the MIR it built is ill-typed:
thread 'rustc' panicked at src/rty/subtyping.rs:147:14:
inconsistent types: got=int, expected=own int
Replacing an owned sub-object held in a struct field (self.buffer = Box::new(..), self.current = next, cursor.at = &mut other) is routine Rust, so today no struct that owns a Box field and ever reassigns it can be analyzed at all.
Minimal reproduction
repro.rs:
struct Cache { page: Box<i64> }
impl thrust_models::Model for Cache { type Ty = Self; }
fn main() {
let mut c = Cache { page: Box::new(1) };
c.page = Box::new(5);
assert!(*c.page == 5);
}
$ cargo run -q -- -Adead_code -C debug-assertions=false repro.rs
thread 'rustc' (10095) panicked at src/refine/env.rs:388:48:
called `Option::unwrap()` on a `None` value
stack backtrace:
...
5: thrust::refine::env::PlaceType::deref
at ./src/refine/env.rs:388:48
6: thrust::refine::env::Env<T>::path_type
at ./src/refine/env.rs:1097:55
7: thrust::refine::env::Env<T>::place_type
at ./src/refine/env.rs:1107:14
Expected: safe. Actual: the compiler aborts.
Ground truth
All four failing programs below are valid, safe Rust that real rustc compiles and runs to completion (exit=0, assertion holds); none of them can panic.
$ rustc -Adead_code --edition 2021 -o gt gt.rs && ./gt; echo "exit=$?"
exit=0
The same defect, three different aborts
The reassigned field is left with its pointee's type, so which unwrap/expect fires just depends on how the value is used next.
| # |
Program (the only difference is the marked line) |
Result |
| f1 |
c.page = Box::new(5); then assert!(*c.page == 5) |
Option::unwrap() on None at src/refine/env.rs:388 (PlaceType::deref) |
| f2 |
tuple field: let mut t = (Box::new(1i64), 0i64); t.0 = Box::new(5); then assert!(*t.0 == 5) |
same abort at env.rs:388 |
| f3 |
&mut-typed field: c.at = &mut y; then *c.at = 20 |
borrowing unbound var at src/refine/env.rs:1014 (Env::borrow_var) |
| f4 |
c.page = Box::new(5); then read(&c) for fn read(c: &Cache) -> i64 { *c.page } |
inconsistent types: got=int, expected=own int at src/rty/subtyping.rs:147 |
f4 is the clearest statement of what went wrong: at the point where c is related to its declared type, field page carries int, not own int.
f2 / f3 / f4 sources
// f2
fn main() {
let mut t = (Box::new(1i64), 0i64);
t.0 = Box::new(5);
assert!(*t.0 == 5);
}
// f3
struct Cursor<'a> { at: &'a mut i64 }
fn main() {
let mut x = 1i64;
let mut y = 2i64;
let mut c = Cursor { at: &mut x };
*c.at = 10;
c.at = &mut y;
*c.at = 20;
assert!(y == 20);
}
// f4
struct Cache { page: Box<i64> }
impl thrust_models::Model for Cache { type Ty = Self; }
fn read(c: &Cache) -> i64 { *c.page }
fn main() {
let mut c = Cache { page: Box::new(1) };
c.page = Box::new(5);
assert!(read(&c) == 5);
}
Controls — everything adjacent verifies
| Program |
Result |
Why it is fine |
let mut b = Box::new(1i64); b = Box::new(5); assert!(*b == 5); |
safe |
empty projection → caught by the is_mut_local branch at reborrow.rs:51 |
let c = Cache { page: Box::new(1) }; assert!(*c.page == 1); |
safe |
no reassignment |
c = Cache { page: Box::new(5) }; (replace the whole struct) |
safe |
empty projection again |
c.page = Vec::new(); c.n = 5; for Cache { page: Vec<i64>, n: i64 } |
safe |
a non-pointer field takes the correct _ => arm |
*c.page = 5; (write through the box, don't replace it) |
safe |
place ends in Deref, so inner_place != place |
So the trigger is exactly: an assignment whose destination place has a non-empty projection and whose own type is Box<T> or &mut T.
One case looks like an exception and is worth calling out, because it shows the corruption is real rather than an artifact of main: the same assignment inside fn set(c: &mut Cache) { c.page = Box::new(5); } lets main verify (assert!(*c.page == 5) is accepted and the flipped == 1 is correctly rejected). The callee never touches c.page again, so the corrupted binding is simply never observed, and the caller works from set's inferred spec. Adding a single read inside set — fn set(c: &mut Cache) { c.page = Box::new(5); assert!(*c.page == 5); } — aborts at env.rs:388 exactly like f1.
Root cause
src/analyze/basic_block/visitor/reborrow.rs:62-90:
let inner_place = if place.projection.last() == Some(&mir::PlaceElem::Deref) {
// *m = *m + 1 => m1 = &mut m; *m1 = *m + 1
let mut projection = place.projection.as_ref().to_vec();
projection.pop();
mir::Place { local: place.local, projection: self.tcx.mk_place_elems(&projection) }
} else {
// s.0 = s.0 + 1 => m1 = &mut s.0; *m1 = *m1 + 1
*place
};
let ty = inner_place.ty(&self.analyzer.local_decls, self.tcx).ty;
let (new_local, new_place) = match ty.kind() {
mir_ty::TyKind::Ref(_, inner_ty, m) if m.is_mut() => {
let new_local = self.insert_reborrow(*place, *inner_ty); // borrows place's POINTEE
(new_local, new_local.into())
}
mir_ty::TyKind::Adt(adt, args) if adt.is_box() => {
let inner_ty = args.type_at(0);
let new_local = self.insert_borrow(*place, inner_ty); // borrows place's POINTEE
(new_local, new_local.into())
}
_ => {
let new_local = self.insert_borrow(*place, ty); // borrows the place itself
(new_local, self.tcx.mk_place_deref(new_local.into()))
}
};
The two pointer arms are written for — and are only correct in — the Deref-stripped case. There inner_place is the pointer (m, type &mut T/Box<T>) and place is its pointee (*m, type T), so insert_{re,}borrow(*place, inner_ty) correctly borrows a T, and substituting new_place = new_local (a pointer of the same shape) for inner_place in the rvalue keeps (*m) reading as (*m1). That is the *m = *m + 1 => m1 = &mut m; *m1 = *m + 1 case the comment describes, and it works.
In the else branch inner_place == place, so ty is the type of the assigned place itself. When that place happens to be pointer-typed, the arms are selected on a completely different meaning of ty: they borrow place's pointee, bind new_local as &mut T::Pointee, and rewrite the statement to *new_local = <rvalue> — a write of a whole Box<i64>/&mut i64 value into an i64 slot. Env::borrow_var also swaps the field's flow binding to Box(prophecy)/Mut(prophecy, ..) over the pointee var, which is why the field's type in the environment degrades from own int to int and stays wrong for the rest of the block.
The correct treatment for a non-Deref place is precisely the _ => arm — borrow the place itself and write through the fresh borrow — which is what a Vec-typed field already gets. Guarding the two pointer arms on inner_place != *place (i.e. on a trailing Deref actually having been stripped) makes all four programs above take that arm.
Relationship to #176
Same file and function, different arm, and the fix for one does not obviously give the other:
They plausibly share a theme ("reassigning an owning/reference-typed place does not re-establish its flow binding"), so a fix may well want to cover both, but they are distinct code paths with distinct triggers and distinct aborts.
Scope / when it bites
Any struct that owns a boxed sub-object and ever swaps it — self.buffer = Box::new(..), self.page = next_page, a cursor struct retargeted with cursor.at = &mut other — is unanalyzable, and the failure is a compiler abort rather than a diagnostic. It is independent of the enum-expansion depth limit and of recursive ADTs (Cache is a flat one-field struct), needs no annotations, and involves no numeric-range/overflow reasoning: every constant is 1, 2, 5, 10, 20. It reproduces with and without impl thrust_models::Model for Cache (without it the &mut-parameter variants first hit #266 instead), and at -C opt-level=0, so it is unrelated to #244/#248.
Environment
- thrust @
5c74a7f
- rustc
nightly-2025-09-08 (per rust-toolchain.toml), x86_64-unknown-linux-gnu
- Z3 5.0.0, default solver configuration
- Flags:
-Adead_code -C debug-assertions=false
Summary
ReborrowVisitor::visit_assignpicks the elaboration for an assignment from the type ofinner_place, butinner_place == placewhenever the assigned place does not end inDeref. For a place that is itself of pointer type — aBox<T>- or&mut T-typed struct/tuple field — theRef/Boxarms then borrow the place's pointee instead of the place, and the assignmentis rewritten as if it were
The environment afterwards records
c.pageasintwhere its declared type isown int, and the next use ofc— a deref, or relatingctoCachewhen it is passed anywhere — aborts the compiler.This is not a missing feature: every ingredient is supported on its own, the
_ =>arm two lines below already handles this shape correctly (which is why aVec-typed field works), and the same statement on aBoxlocal verifies fine. The analyzer's own diagnostic says the MIR it built is ill-typed:Replacing an owned sub-object held in a struct field (
self.buffer = Box::new(..),self.current = next,cursor.at = &mut other) is routine Rust, so today no struct that owns aBoxfield and ever reassigns it can be analyzed at all.Minimal reproduction
repro.rs:Expected:
safe. Actual: the compiler aborts.Ground truth
All four failing programs below are valid, safe Rust that real
rustccompiles and runs to completion (exit=0, assertion holds); none of them can panic.The same defect, three different aborts
The reassigned field is left with its pointee's type, so which
unwrap/expectfires just depends on how the value is used next.c.page = Box::new(5);thenassert!(*c.page == 5)Option::unwrap()onNoneatsrc/refine/env.rs:388(PlaceType::deref)let mut t = (Box::new(1i64), 0i64); t.0 = Box::new(5);thenassert!(*t.0 == 5)env.rs:388&mut-typed field:c.at = &mut y;then*c.at = 20borrowing unbound varatsrc/refine/env.rs:1014(Env::borrow_var)c.page = Box::new(5);thenread(&c)forfn read(c: &Cache) -> i64 { *c.page }inconsistent types: got=int, expected=own intatsrc/rty/subtyping.rs:147f4 is the clearest statement of what went wrong: at the point where
cis related to its declared type, fieldpagecarriesint, notown int.f2 / f3 / f4 sources
Controls — everything adjacent verifies
let mut b = Box::new(1i64); b = Box::new(5); assert!(*b == 5);safeis_mut_localbranch atreborrow.rs:51let c = Cache { page: Box::new(1) }; assert!(*c.page == 1);safec = Cache { page: Box::new(5) };(replace the whole struct)safec.page = Vec::new(); c.n = 5;forCache { page: Vec<i64>, n: i64 }safe_ =>arm*c.page = 5;(write through the box, don't replace it)safeplaceends inDeref, soinner_place != placeSo the trigger is exactly: an assignment whose destination place has a non-empty projection and whose own type is
Box<T>or&mut T.One case looks like an exception and is worth calling out, because it shows the corruption is real rather than an artifact of
main: the same assignment insidefn set(c: &mut Cache) { c.page = Box::new(5); }letsmainverify (assert!(*c.page == 5)is accepted and the flipped== 1is correctly rejected). The callee never touchesc.pageagain, so the corrupted binding is simply never observed, and the caller works fromset's inferred spec. Adding a single read insideset—fn set(c: &mut Cache) { c.page = Box::new(5); assert!(*c.page == 5); }— aborts atenv.rs:388exactly like f1.Root cause
src/analyze/basic_block/visitor/reborrow.rs:62-90:The two pointer arms are written for — and are only correct in — the
Deref-stripped case. Thereinner_placeis the pointer (m, type&mut T/Box<T>) andplaceis its pointee (*m, typeT), soinsert_{re,}borrow(*place, inner_ty)correctly borrows aT, and substitutingnew_place = new_local(a pointer of the same shape) forinner_placein the rvalue keeps(*m)reading as(*m1). That is the*m = *m + 1 => m1 = &mut m; *m1 = *m + 1case the comment describes, and it works.In the
elsebranchinner_place == place, sotyis the type of the assigned place itself. When that place happens to be pointer-typed, the arms are selected on a completely different meaning ofty: they borrowplace's pointee, bindnew_localas&mut T::Pointee, and rewrite the statement to*new_local = <rvalue>— a write of a wholeBox<i64>/&mut i64value into ani64slot.Env::borrow_varalso swaps the field's flow binding toBox(prophecy)/Mut(prophecy, ..)over the pointee var, which is why the field's type in the environment degrades fromown inttointand stays wrong for the rest of the block.The correct treatment for a non-
Derefplace is precisely the_ =>arm — borrow the place itself and write through the fresh borrow — which is what aVec-typed field already gets. Guarding the two pointer arms oninner_place != *place(i.e. on a trailingDerefactually having been stripped) makes all four programs above take that arm.Relationship to #176
Same file and function, different arm, and the fix for one does not obviously give the other:
&mutlocal #176 is the first branch,reborrow.rs:51(place.projection.is_empty() && is_mut_local(..)), and is about reassigning a&mutlocal. Its own trigger table is explicit that the crash needs a reference-typed local.reborrow.rs:77/81selected viainner_place's type at line 75.Boxlocal reassignment verifies (control row 1 above — it goes through Panic: "borrowing unbound var" when writing through a reassigned&mutlocal #176's branch and is fine there), while aBoxfield reassignment aborts.Box-typed places are not mentioned in Panic: "borrowing unbound var" when writing through a reassigned&mutlocal #176 at all.They plausibly share a theme ("reassigning an owning/reference-typed place does not re-establish its flow binding"), so a fix may well want to cover both, but they are distinct code paths with distinct triggers and distinct aborts.
Scope / when it bites
Any struct that owns a boxed sub-object and ever swaps it —
self.buffer = Box::new(..),self.page = next_page, a cursor struct retargeted withcursor.at = &mut other— is unanalyzable, and the failure is a compiler abort rather than a diagnostic. It is independent of the enum-expansion depth limit and of recursive ADTs (Cacheis a flat one-field struct), needs no annotations, and involves no numeric-range/overflow reasoning: every constant is1,2,5,10,20. It reproduces with and withoutimpl thrust_models::Model for Cache(without it the&mut-parameter variants first hit #266 instead), and at-C opt-level=0, so it is unrelated to #244/#248.Environment
5c74a7fnightly-2025-09-08(perrust-toolchain.toml), x86_64-unknown-linux-gnu-Adead_code -C debug-assertions=false