Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 22 additions & 99 deletions compiler/rustc_const_eval/src/check_consts/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@
//!
//! This contains the dataflow analysis used to track `Qualif`s on complex control-flow graphs.

use std::fmt;
use std::marker::PhantomData;

use rustc_index::bit_set::MixedBitSet;
use rustc_middle::mir::visit::Visitor;
use rustc_middle::mir::{
self, BasicBlock, CallReturnPlaces, Local, Location, Statement, StatementKind,
};
use rustc_mir_dataflow::fmt::DebugWithContext;
use rustc_mir_dataflow::{Analysis, JoinSemiLattice};
use rustc_mir_dataflow::Analysis;

use super::{ConstCx, Qualif, qualifs};

Expand All @@ -24,26 +22,25 @@ use super::{ConstCx, Qualif, qualifs};
/// borrowed place must contain the qualif.
struct TransferFunction<'mir, 'tcx, Q> {
ccx: &'mir ConstCx<'mir, 'tcx>,
state: &'mir mut State,
state: &'mir mut MixedBitSet<Local>,
_qualif: PhantomData<Q>,
}

impl<'mir, 'tcx, Q> TransferFunction<'mir, 'tcx, Q>
where
Q: Qualif,
{
fn new(ccx: &'mir ConstCx<'mir, 'tcx>, state: &'mir mut State) -> Self {
fn new(ccx: &'mir ConstCx<'mir, 'tcx>, state: &'mir mut MixedBitSet<Local>) -> Self {
TransferFunction { ccx, state, _qualif: PhantomData }
}

fn initialize_state(&mut self) {
self.state.qualif.clear();
self.state.borrow.clear();
self.state.clear();

for arg in self.ccx.body.args_iter() {
let arg_ty = self.ccx.body.local_decls[arg].ty;
if Q::in_any_value_of_ty(self.ccx, arg_ty) {
self.state.qualif.insert(arg);
self.state.insert(arg);
}
}
}
Expand All @@ -63,15 +60,15 @@ where

match (value, place.as_ref()) {
(true, mir::PlaceRef { local, .. }) => {
self.state.qualif.insert(local);
self.state.insert(local);
}

// For now, we do not clear the qualif if a local is overwritten in full by
// an unqualified rvalue (e.g. `y = 5`). This is to be consistent
// with aggregates where we overwrite all fields with assignments, which would not
// get this feature.
(false, mir::PlaceRef { local: _, projection: &[] }) => {
// self.state.qualif.remove(*local);
// self.state.remove(*local);
}

_ => {}
Expand Down Expand Up @@ -140,12 +137,8 @@ where
if let mir::Operand::Move(place) = operand
&& let Some(local) = place.as_local()
{
// For backward compatibility with the MaybeMutBorrowedLocals used in an earlier
// implementation we retain qualif if a local had been borrowed before. This might
// not be strictly necessary since the local is no longer initialized.
if !self.state.borrow.contains(local) {
self.state.qualif.remove(local);
}
// The local is no longer initialized so we can remove the qualif.
self.state.remove(local);
Comment on lines +140 to +141

@tmiasko tmiasko Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With proposed semantics from move elimination RFC, move deallocates the local, and any borrows become invalid. In contrast, this is far from obvious why it would be correct now. Consider the following example running under tree borrows:

struct PanicOnDrop;
impl Drop for PanicOnDrop { fn drop(&mut self) { panic!(); } }
pub const fn main() {
    let mut a = None;
    let p = &raw mut a;
    std::mem::forget(a);
    a = None;
    unsafe { std::ptr::write(p, Some(PanicOnDrop)) };
    a = a;
}
const _: () = main();
$ rustc +stage1 a.rs
error[E0080]: calling non-const function `<PanicOnDrop as Drop>::drop`
  --> a.rs:11:15
   |
11 | const _: () = main();
   |               ^^^^^^ evaluation of `_` failed inside this call
   |
note: inside `main`
  --> a.rs:10:1
   |
10 | }
   | ^
note: inside `std::ptr::drop_glue::<Option<PanicOnDrop>> - shim(Some(Option<PanicOnDrop>))`
  --> library/core/src/ptr/mod.rs:848:0
note: inside `std::ptr::drop_glue::<PanicOnDrop> - shim(Some(PanicOnDrop))`
  --> library/core/src/ptr/mod.rs:848:0

error: aborting due to 1 previous error; 1 warning emitted

For more information about this error, try `rustc --explain E0080`.

View changes since the review

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code only runs when IS_CLEARED_ON_MOVE is set. So qualifs remain in control of whether this should be done. And for drop it seems fine since the drop flag is indeed cleared on move.

@tmiasko tmiasko Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example passes const checks and executes live drop. I can't see how this can be fine, besides arguing that program has undefined behavior.

If the flag was actually unset at drop time, the drop wouldn't run.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a = a; does indeed not clear the drop flag on a, so if this analysis thinks that that's a move from a then that does seem incorrect -- but that doesn't invalidate the general point about moves I think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is definitely a room for some kind of justification, my point is simply that it requires something beyond operational semantics.

a = a is irrelevant, you can remove it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is what was happening before this PR 😆

Yeah, but not in the right spot -- the move does unconditionally clear the drop flag. It does not make sense to me that we'd check borrow.contains there.
It is the a = None; where we should check borrow.contains: if a has been borrowed before, then we should not analyze the value on the right.

struct PanicOnDrop;
impl Drop for PanicOnDrop { fn drop(&mut self) { panic!(); } }
pub const fn main() {
    let mut a = None;
    let p = &raw mut a;
    std::mem::forget(a);
    a = None; // <-- this one
    unsafe { std::ptr::write(p, Some(PanicOnDrop)) };
    a = a;
}
const _: () = main();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting. Yeah that does not sound great. This sounds like even before this PR it should be possible to write code that triggers the "calling non-const function" error. We do these days detect the UB from mutating &i32 even in CTFE, but on something like &(i32, Cell<i32>) we don't detect the UB from mutating the first field.

Ah no it just works out -- the UB we detect is just barely enough to avoid "calling non-const function" in the code that we accept based on shared_borrow_allows_mutation.

That is an accident though...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The const-checker doesn't care about UB. It cares about making sure that a const only calls const fn. As in, syntactically, even in dead code or code unreachable because of UB, all function calls must be const fn calls. This is important because CTFE does not actually detect all UB and we never want to run non-const-fn during CTFE. This is mostly trivial except for drop calls which are not yet explicit in MIR by the time we do const checking, so we need this non-trivial analysis to predict whether a call will be present after drop lowering.

The analysis, as implemented now, is in some ways both more precise and less precise than drop elaboration. It doesn't predict a syntactical absence of non-const drops after drop elaboration. Rather, it tries to predict whether they might be executed when the code runs.

For example, it concludes that let a: Option<NonConstDrop> = None; is a not live non-const drop, even though drop is present after drop elaboration.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah so it's a funny mix. For let a: Option<NonConstDrop> = None; with nothing else in the MIR body we don't have to rely on any UB though. So while I agree it makes the correctness statement more tricky, I am not otherwise concerned.

The shared_borrow_allows_mutation situation is more tricky though. But I guess it's not worth a breaking change, we just have to update the comments to explain that this is fine because const-eval reliably aborts on the UB this is based on.

Which brings us back to this PR and the new example -- there, const-eval does not reliably abort, so even if this is UB, we can't use that UB. I think ideally we'd make it so that we do indeed mark a local as "does not need drop" when it is moved-out-of (what the PR does), and additionally we inhibit value-based reasoning on assignments to locals after the local has been (mutably) borrowed. That should work?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With MIR move elimination semantics, an allocation is deallocated after move. Once implemented, I would expect it would take an extra effort to miss such UB.

}
}

Expand All @@ -155,8 +148,7 @@ where
rvalue: &mir::Rvalue<'tcx>,
location: Location,
) {
let qualif =
qualifs::in_rvalue::<Q, _>(self.ccx, &mut |l| self.state.qualif.contains(l), rvalue);
let qualif = qualifs::in_rvalue::<Q, _>(self.ccx, &mut |l| self.state.contains(l), rvalue);
if !place.is_indirect() {
self.assign_qualif_direct(place, qualif);
}
Expand All @@ -174,8 +166,7 @@ where
if !borrowed_place.is_indirect() && self.address_of_allows_mutation() {
let place_ty = borrowed_place.ty(self.ccx.body, self.ccx.tcx).ty;
if Q::in_any_value_of_ty(self.ccx, place_ty) {
self.state.qualif.insert(borrowed_place.local);
self.state.borrow.insert(borrowed_place.local);
self.state.insert(borrowed_place.local);
}
}
}
Expand All @@ -185,8 +176,7 @@ where
{
let place_ty = borrowed_place.ty(self.ccx.body, self.ccx.tcx).ty;
if Q::in_any_value_of_ty(self.ccx, place_ty) {
self.state.qualif.insert(borrowed_place.local);
self.state.borrow.insert(borrowed_place.local);
self.state.insert(borrowed_place.local);
}
}
}
Expand All @@ -198,8 +188,7 @@ where
&& (mutability.is_mut() || !target.is_freeze(self.ccx.tcx, self.ccx.typing_env))
{
if Q::in_any_value_of_ty(self.ccx, *target) {
self.state.qualif.insert(borrowed_place.local);
self.state.borrow.insert(borrowed_place.local);
self.state.insert(borrowed_place.local);
}
}
}
Expand All @@ -220,8 +209,7 @@ where
fn visit_statement(&mut self, statement: &Statement<'tcx>, location: Location) {
match statement.kind {
StatementKind::StorageDead(local) => {
self.state.qualif.remove(local);
self.state.borrow.remove(local);
self.state.remove(local);
}
_ => self.super_statement(statement, location),
}
Expand Down Expand Up @@ -251,91 +239,26 @@ where
FlowSensitiveAnalysis { ccx, _qualif: PhantomData }
}

fn transfer_function(&self, state: &'mir mut State) -> TransferFunction<'mir, 'tcx, Q> {
fn transfer_function(
&self,
state: &'mir mut MixedBitSet<Local>,
) -> TransferFunction<'mir, 'tcx, Q> {
TransferFunction::<Q>::new(self.ccx, state)
}
}

#[derive(Debug, PartialEq, Eq)]
/// The state for the `FlowSensitiveAnalysis` dataflow analysis. This domain is likely homogeneous,
/// and has a big size, so we use a bitset that can be sparse (c.f. issue #134404).
pub(super) struct State {
/// Describes whether a local contains qualif.
pub qualif: MixedBitSet<Local>,
/// Describes whether a local's address escaped and it might become qualified as a result an
/// indirect mutation.
pub borrow: MixedBitSet<Local>,
Comment on lines -265 to -267

@RalfJung RalfJung Sep 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks kind of suspicious. The comment is correct that mutable borrows might change the qualif. We use qualif also e.g. for interior mutability, where None is fine but Some(Cell::new(...)) is not, and the code could use a mutable borrow to swap from one to the other.

Why is it correct to entirely stop tracking borrows here?
@oli-obk could be great to get your take as well since I think you know this code better than I do. :)

View changes since the review

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we barely used borrow before... it was only ever checked if IS_CLEARED_ON_MOVE is true, which is only the case for the drop qualifs. I guess we must have some other check to adjust qualifs when a borrow is taken.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a difference between

  1. behave differently during some access of a local if there is a borrow, and
  2. collect qualifs through borrows.

Qualif collection eagerly walks through a borrow to its local and collects everything and acts upon it. The thing that was changed here is that const qualif stops doing 1.

}

impl Clone for State {
fn clone(&self) -> Self {
State { qualif: self.qualif.clone(), borrow: self.borrow.clone() }
}

// Data flow engine when possible uses `clone_from` for domain values.
// Providing an implementation will avoid some intermediate memory allocations.
fn clone_from(&mut self, other: &Self) {
self.qualif.clone_from(&other.qualif);
self.borrow.clone_from(&other.borrow);
}
}

impl State {
#[inline]
pub(super) fn contains(&self, local: Local) -> bool {
self.qualif.contains(local)
}
}

impl<C> DebugWithContext<C> for State {
fn fmt_with(&self, ctxt: &C, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("qualif: ")?;
self.qualif.fmt_with(ctxt, f)?;
f.write_str(" borrow: ")?;
self.borrow.fmt_with(ctxt, f)?;
Ok(())
}

fn fmt_diff_with(&self, old: &Self, ctxt: &C, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self == old {
return Ok(());
}

if self.qualif != old.qualif {
f.write_str("qualif: ")?;
self.qualif.fmt_diff_with(&old.qualif, ctxt, f)?;
f.write_str("\n")?;
}

if self.borrow != old.borrow {
f.write_str("borrow: ")?;
self.borrow.fmt_diff_with(&old.borrow, ctxt, f)?;
f.write_str("\n")?;
}

Ok(())
}
}

impl JoinSemiLattice for State {
fn join(&mut self, other: &Self) -> bool {
self.qualif.join(&other.qualif) || self.borrow.join(&other.borrow)
}
}

impl<'tcx, Q> Analysis<'tcx> for FlowSensitiveAnalysis<'_, 'tcx, Q>
where
Q: Qualif,
{
type Domain = State;
/// Describes whether a local contains qualif. This domain is likely homogeneous, and has a big
/// size, so we use a bitset that can be sparse (c.f. issue #134404).
type Domain = MixedBitSet<Local>;

const NAME: &'static str = Q::ANALYSIS_NAME;

fn bottom_value(&self, body: &mir::Body<'tcx>) -> Self::Domain {
State {
qualif: MixedBitSet::new_empty(body.local_decls.len()),
borrow: MixedBitSet::new_empty(body.local_decls.len()),
}
MixedBitSet::new_empty(body.local_decls.len())
}

fn initialize_start_block(&self, _body: &mir::Body<'tcx>, state: &mut Self::Domain) {
Expand Down
16 changes: 7 additions & 9 deletions tests/ui/consts/const-eval/issue-65394.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
//@ revisions: stock precise_drops
//@[precise_drops] check-pass
//@ check-pass

// This test originated from #65394. We conservatively assume that `x` is still `LiveDrop` even
// after it has been moved because a mutable reference to it exists at some point in the const body.
//
// With `&mut` in `const` being stable, this surprising behavior is now observable.
// `const_precise_live_drops` fixes that.
#![cfg_attr(precise_drops, feature(const_precise_live_drops))]
// This test originated from #65394. It used to fail to compile without
// `feature(const_precise_live_drops)` because we conservatively assumed that `x` is still
// `LiveDrop` even after it has been moved because a mutable reference to it exists at some point
// in the const body. With `&mut` in `const` being stable, this surprising behavior was observable.
// Later fixes to drop handling fixed that (#161627).

const _: Vec<i32> = {
let mut x = Vec::<i32>::new(); //[stock]~ ERROR destructor of
let mut x = Vec::<i32>::new();
let r = &mut x;
let y = x;
y
Expand Down
16 changes: 0 additions & 16 deletions tests/ui/consts/const-eval/issue-65394.stock.stderr

This file was deleted.

Loading