Skip to content

ref_prop: do not collapse a mutable reborrow onto a multi-borrowed place#156896

Closed
invictustitan2 wants to merge 1 commit into
rust-lang:mainfrom
invictustitan2:fix-132898-refprop-mutable-aliasing
Closed

ref_prop: do not collapse a mutable reborrow onto a multi-borrowed place#156896
invictustitan2 wants to merge 1 commit into
rust-lang:mainfrom
invictustitan2:fix-132898-refprop-mutable-aliasing

Conversation

@invictustitan2
Copy link
Copy Markdown

@invictustitan2 invictustitan2 commented May 24, 2026

Fixes #132898.

The bug

ReferencePropagation rewrites the mutable two-phase repro from the issue as follows (current master, MIR opt-level 2):

  _3 = &mut (_1.0: u64);
- _2 = &raw mut (*_3);
  _5 = &mut _1;
- (*_2) = const 42_u64;
+ (*_3) = const 42_u64;

That partial collapse is fine, but the pass goes further when _2's direct uses are absent — collapsing *_3 onto the direct place _1.0, so the raw reborrow becomes _2 = &raw mut _1.0 (or, equivalently after replacement of all *_3, the write becomes (_1.0) = const 42). This shortens _2's provenance from _1 → _3 → _2 to _1 → _2, which races the independent _5 = &mut _1 (a 2-phase borrow created for the add call), and introduces Stacked Borrows UB into code that was sound without the optimisation. miri confirms this on current nightly.

@RalfJung's analysis in the issue identified the cause: the pass's "uniqueness" guard (fully_replaceable_locals) verifies only that each individual mutable reference is fully replaced, but does not see other pointers derived from the same place. Per his framing:

to replace a mutable pointer derived from a unique place, it does not suffice to replace all occurrences of that pointer; we have to replace all occurrences of all pointers derived from the place that may overlap in lifetime with the pointer in question.

The fix

A small counting sub-analysis (count_direct_borrows) records, for each local, the number of borrows that directly borrow it — borrows whose place is rooted at the local with no Deref in its projection (_1, _1.0; not *_1). Two such borrows of a local mean a mutable reference to one of its (sub-)places is not the unique path to that memory.

In compute_replacement, when about to set targets[ref] = Pointer(P, needs_unique=true) for a mutable reference whose target P is a direct place (first projection is not Deref), bail if direct_borrow_count[P.local] > 1.

The pass still performs its core sound transform (collapsing a raw reborrow through the &mut it was derived from); only the further collapse onto a direct place that aliases another borrow is blocked. This is the same conservative-bail pattern as the already-merged

Production diff is ~19 lines (one helper + a three-line gate).

Why an SB-only fix is still must-fix

On current nightly the pass-introduced UB is detectable under Stacked Borrows but not under Tree Borrows (TB has evolved since 2024 and now accepts the post-pass code on the raw-pointer variant). Per rust-lang/miri's own README, "the eventual final aliasing model of Rust will be stricter than Tree Borrows ... if you use Tree Borrows, even if your code is accepted today, it might be declared UB in the future. This is much less likely with Stacked Borrows." TB-acceptance is therefore not a valid defence; SB remains the operative model that miri checks by default and that mir-opt must respect, in line with the recent precedents above.

Test plan

  • tests/mir-opt/reference_prop_mutable_alias.rs — asserts _3 = &mut (_1.0) survives, the write goes through (*_3), and the unsound (_1.0) = const 42 never appears.
  • src/tools/miri/tests/pass/ref_prop_mutable_alias.rs and src/tools/miri/tests/pass/ref_prop_mutable_alias_opt.rs — the two repros from ReferencePropagation introduces UB into code that is accepted by Stacked Borrows #132898 run UB-free under Stacked Borrows with ReferencePropagation force-enabled, both with debug-assertions on (preserving the full reborrow chain) and off (exercising the residual (*_3) propagation).
  • Full local runs (stage1 on x86_64-unknown-linux-gnu):
    • ./x test tests/mir-opt — 395/0 (no expectation churn beyond the new test).
    • ./x miri — ui_test pass suites 2420/0, 956/0, 329/0; zero "Undefined Behavior" anywhere; no other regressions. (The single unrelated failure in -p std --lib is backtrace::tests::test_debug failing with unsupported operation: getcwd not available when isolation is enabled, which is a miri default-isolation limitation orthogonal to this fix.)

Notes

This patch was drafted with AI assistance (Claude); the diagnosis, the refutation of less-conservative candidate guards, and the verification against the existing test suite were performed locally and the co-authorship is recorded in the commit trailer.

r? @saethlin
cc @RalfJung @rust-lang/wg-mir-opt @rust-lang/opsem

@rustbot
Copy link
Copy Markdown
Collaborator

rustbot commented May 24, 2026

The Miri subtree was changed

cc @rust-lang/miri

Some changes occurred to MIR optimizations

cc @rust-lang/wg-mir-opt

@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue. labels May 24, 2026
@rustbot

This comment has been minimized.

@rust-log-analyzer

This comment has been minimized.

Fixes rust-lang#132898.

ReferencePropagation set `targets[_3] = Pointer(_1.0, true)` for a mutable
reference `_3 = &mut _1.0`, so the Replacer would rewrite every `*_3` to `_1.0`
— including inside the reborrow `_2 = &raw mut (*_3)`, yielding
`_2 = &raw mut _1.0`. That shortens `_2`'s provenance from `_1 → _3 → _2` to
`_1 → _2`, which races an independent borrow of the root local (e.g. the
2-phase `&mut _1` for a method call), and the pass introduced Stacked Borrows
UB into code that was sound without the optimisation.

The "uniqueness" guard (`fully_replaceable_locals`) verifies only that each
individual mutable reference is fully replaced; it does not see other borrows
of the referent's memory. As the issue notes, an independent borrow of the
root with overlapping lifetime breaks the required invariant.

Fix: count direct borrows of each local — borrows whose place is rooted at the
local with no Deref in its projection (_1, _1.0; not *_1). When propagating
a mutable reference whose target is a direct place rooted at a local with
more than one such borrow, leave the target as Unknown. The pass still
performs its core sound transform (collapsing a raw reborrow of a &mut through
the &mut); only the further collapse onto a direct place that aliases another
borrow is blocked. This follows the same conservative-bail pattern as the
already-merged GVN fix (rust-lang#132527) and CopyProp fix (rust-lang#143509).

Regression test: tests/mir-opt/reference_prop_mutable_alias.rs asserts that
the &mut survives, the write goes through (*_3), and the unsound
(_1.0) = const 42 never appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@invictustitan2 invictustitan2 force-pushed the fix-132898-refprop-mutable-aliasing branch from b6b0ad3 to ff4eee4 Compare May 25, 2026 00:11
@rustbot
Copy link
Copy Markdown
Collaborator

rustbot commented May 25, 2026

⚠️ Warning ⚠️

  • There are issue links (such as #123) in the commit messages of the following commits.
    Please move them to the PR description, to avoid spamming the issues with references to the commit, and so this bot can automatically canonicalize them to avoid issues with subtree.

@rust-log-analyzer
Copy link
Copy Markdown
Collaborator

The job x86_64-gnu-gcc failed! Check out the build log: (web) (plain enhanced) (plain)

Click to see the possible cause of the failure (guessed by this bot)
test [mir-opt] tests/mir-opt/unreachable_enum_branching.rs ... ok

failures:

---- [mir-opt] tests/mir-opt/pre-codegen/loops.rs stdout ----
5     debug end => _2;
6     let mut _0: ();
7     let mut _3: std::ops::Range<usize>;
-     let mut _9: std::option::Option<usize>;
-     let _11: ();
-     let mut _12: &mut std::ops::Range<usize>;
+     let mut _4: std::ops::Range<usize>;
+     let mut _5: &mut std::ops::Range<usize>;
+     let mut _11: std::option::Option<usize>;
+     let _13: ();
11     scope 1 {
-         debug iter => _3;
-         let _10: usize;
+         debug iter => _4;
+         let _12: usize;
14         scope 2 {
-             debug i => _10;
+             debug i => _12;
16         }
17         scope 4 (inlined iter::range::<impl Iterator for std::ops::Range<usize>>::next) {
-             debug self => _12;
+             debug self => _5;
19             scope 5 (inlined <std::ops::Range<usize> as iter::range::RangeIteratorImpl>::spec_next) {
-                 debug self => _12;
-                 let mut _6: bool;
-                 let _7: usize;
-                 let mut _8: usize;
-                 let mut _13: &usize;
+                 debug self => _5;
+                 let mut _8: bool;
+                 let _9: usize;
+                 let mut _10: usize;
25                 let mut _14: &usize;
+                 let mut _15: &usize;
26                 scope 6 {
-                     debug old => _7;
+                     debug old => _9;
28                     scope 8 (inlined <usize as Step>::forward_unchecked) {
-                         debug start => _7;
+                         debug start => _9;
30                         debug n => const 1_usize;
31                         scope 9 (inlined #[track_caller] core::num::<impl usize>::unchecked_add) {
-                             debug self => _7;
+                             debug self => _9;
33                             debug rhs => const 1_usize;
34                             scope 10 (inlined core::ub_checks::check_language_ub) {
35                                 scope 11 (inlined core::ub_checks::check_language_ub::runtime) {

39                     }
40                 }
41                 scope 7 (inlined std::cmp::impls::<impl PartialOrd for usize>::lt) {
-                     debug self => _13;
-                     debug other => _14;
-                     let mut _4: usize;
-                     let mut _5: usize;
+                     debug self => _14;
+                     debug other => _15;
+                     let mut _6: usize;
+                     let mut _7: usize;
46                 }
47             }
48         }

53 
---
58 

59     bb1: {
-         StorageLive(_9);
-         // DBG: _12 = &_3;
+         StorageLive(_11);
+         _5 = &mut _4;
+         StorageLive(_8);
+         // DBG: _14 = &(_4.0: usize);
+         // DBG: _15 = &(_4.1: usize);
62         StorageLive(_6);
-         // DBG: _13 = &(_3.0: usize);
-         // DBG: _14 = &(_3.1: usize);
-         StorageLive(_4);
-         _4 = copy (_3.0: usize);
-         StorageLive(_5);
-         _5 = copy (_3.1: usize);
-         _6 = Lt(move _4, move _5);
-         StorageDead(_5);
-         StorageDead(_4);
-         switchInt(move _6) -> [0: bb2, otherwise: bb3];
+         _6 = copy ((*_5).0: usize);
+         StorageLive(_7);
+         _7 = copy ((*_5).1: usize);
+         _8 = Lt(move _6, move _7);
+         StorageDead(_7);
+         StorageDead(_6);
+         switchInt(move _8) -> [0: bb2, otherwise: bb3];
73     }
74 
75     bb2: {

-         StorageDead(_6);
---
81     bb3: {
-         StorageLive(_7);
-         _7 = copy (_3.0: usize);
-         StorageLive(_8);
-         _8 = AddUnchecked(copy _7, const 1_usize);
-         (_3.0: usize) = move _8;
-         StorageDead(_8);
-         _9 = Option::<usize>::Some(copy _7);
-         StorageDead(_7);
-         StorageDead(_6);
+         StorageLive(_9);
+         _9 = copy (_4.0: usize);
91         StorageLive(_10);
-         _10 = copy ((_9 as Some).0: usize);
-         _11 = opaque::<usize>(move _10) -> [return: bb4, unwind continue];
+         _10 = AddUnchecked(copy _9, const 1_usize);
+         (_4.0: usize) = move _10;
+         StorageDead(_10);
+         _11 = Option::<usize>::Some(copy _9);
+         StorageDead(_9);
+         StorageDead(_8);
+         StorageLive(_12);
+         _12 = copy ((_11 as Some).0: usize);
+         _13 = opaque::<usize>(move _12) -> [return: bb4, unwind continue];
94     }
95 
96     bb4: {

-         StorageDead(_10);

For more information how to resolve CI failures of this job, visit this link.

@saethlin
Copy link
Copy Markdown
Member

This looks like vibecoding. I refuse to accept a vibecoded MIR opt change.

@saethlin saethlin closed this May 25, 2026
@rustbot rustbot removed the S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. label May 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-compiler Relevant to the compiler team, which will review and decide on the PR/issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ReferencePropagation introduces UB into code that is accepted by Stacked Borrows

4 participants