Skip to content

Make operational semantics of pattern matching independent of crate and module#150681

Merged
rust-bors[bot] merged 8 commits intorust-lang:mainfrom
meithecatte:always-discriminate
Feb 14, 2026
Merged

Make operational semantics of pattern matching independent of crate and module#150681
rust-bors[bot] merged 8 commits intorust-lang:mainfrom
meithecatte:always-discriminate

Conversation

@meithecatte
Copy link
Contributor

@meithecatte meithecatte commented Jan 4, 2026

The question of "when does matching an enum against a pattern of one of its variants read its discriminant" is currently an underspecified part of the language, causing weird behavior around borrowck, drop order, and UB.

Of course, in the common cases, the discriminant must be read to distinguish the variant of the enum, but currently the following exceptions are implemented:

  1. If the enum has only one variant, we currently skip the discriminant read.

    • This has the advantage that single-variant enums behave the same way as structs in this regard.

    • However, it means that if the discriminant exists in the layout, we can't say that this discriminant being invalid is UB. This makes me particularly uneasy in its interactions with niches – consider the following example (playground), where miri currently doesn't detect any UB (because the semantics don't specify any):

      Example 1
      #![allow(dead_code)]
      use core::mem::{size_of, transmute};
      
      #[repr(u8)]
      enum Inner {
          X(u8),
      }
      
      enum Outer {
          A(Inner),
          B(u8),
      }
      
      fn f(x: &Inner) {
          match x {
              Inner::X(v) => {
                  println!("{v}");
              }
          }
      }
      
      fn main() {
          assert_eq!(size_of::<Inner>(), 2);
          assert_eq!(size_of::<Outer>(), 2);
          let x = Outer::B(42);
          let y = &x;
          f(unsafe { transmute(y) });
      }
  2. For the purpose of the above, enums with marked with #[non_exhaustive] are always considered to have multiple variants when observed from foreign crates, but the actual number of variants is considered in the current crate.

  3. Moreover, we currently make a more specific check: we only read the discriminant if there is more than one inhabited variant in the enum.

    • This means that the semantics can differ between foo<!>, and a copy of foo where T was manually replaced with !: Adding generics affect whether code has UB or not, according to Miri #146803

    • Moreover, due to the privacy rules for inhabitedness, it means that the semantics of code can depend on the module in which it is located.

    • Additionally, this inhabitedness rule is even uglier due to the fact that closure capture analysis needs to happen before we can determine whether types are uninhabited, which means that whether the discriminant read happens has a different answer specifically for capture analysis.

    • For the two above points, see the following example (playground):

      Example 2
      #![allow(unused)]
      
      mod foo {
          enum Never {}
          struct PrivatelyUninhabited(Never);
          pub enum A {
              V(String, String),
              Y(PrivatelyUninhabited),
          }
          
          fn works(mut x: A) {
              let a = match x {
                  A::V(ref mut a, _) => a,
                  _ => unreachable!(),
              };
              
              let b = match x {
                  A::V(_, ref mut b) => b,
                  _ => unreachable!(),
              };
          
              a.len(); b.len();
          }
          
          fn fails(mut x: A) {
              let mut f = || match x {
                  A::V(ref mut a, _) => (),
                  _ => unreachable!(),
              };
              
              let mut g = || match x {
                  A::V(_, ref mut b) => (),
                  _ => unreachable!(),
              };
          
              f(); g();
          }
      }
      
      use foo::A;
      
      fn fails(mut x: A) {
          let a = match x {
              A::V(ref mut a, _) => a,
              _ => unreachable!(),
          };
          
          let b = match x {
              A::V(_, ref mut b) => b,
              _ => unreachable!(),
          };
      
          a.len(); b.len();
      }
      
      
      fn fails2(mut x: A) {
          let mut f = || match x {
              A::V(ref mut a, _) => (),
              _ => unreachable!(),
          };
          
          let mut g = || match x {
              A::V(_, ref mut b) => (),
              _ => unreachable!(),
          };
      
          f(); g();
      }

In light of the above, and following the discussion at #138961 and #147722, this PR makes it so that, operationally, matching on an enum always reads its discriminant. introduces the following changes to this behavior:

  • matching on a #[non_exhaustive] enum will always introduce a discriminant read, regardless of whether the enum is from an external crate
  • uninhabited variants now count just like normal ones, and don't get skipped in the checks

As per the discussion below, the resolution for point (1) above is that it should land as part of a separate PR, so that the subtler decision can be more carefully considered.

Note that this is a breaking change, due to the aforementioned changes in borrow checking behavior, new UB (or at least UB newly detected by miri), as well as drop order around closure captures. However, it seems to me that the combination of this PR with #138961 should have smaller real-world impact than #138961 by itself.

Fixes #142394
Fixes #146590
Fixes #146803 (though already marked as duplicate)
Fixes parts of #147722
Fixes rust-lang/miri#4778

r? @Nadrieril @RalfJung

@rustbot label +A-closures +A-patterns +T-opsem +T-lang

@rustbot
Copy link
Collaborator

rustbot commented Jan 4, 2026

Some changes occurred in match lowering

cc @Nadrieril

The Miri subtree was changed

cc @rust-lang/miri

@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 Jan 4, 2026
@rustbot
Copy link
Collaborator

rustbot commented Jan 4, 2026

Nadrieril is not on the review rotation at the moment.
They may take a while to respond.

@rustbot rustbot added A-closures Area: Closures (`|…| { … }`) A-patterns Relating to patterns and pattern matching T-lang Relevant to the language team T-opsem Relevant to the opsem team labels Jan 4, 2026
@meithecatte
Copy link
Contributor Author

Looks like I messed up the syntax slightly 😅

r? @RalfJung

@rustbot rustbot assigned RalfJung and unassigned Nadrieril Jan 4, 2026
@meithecatte meithecatte changed the title Always discriminate Remove the single-variant exception in pattern matching Jan 4, 2026
@meithecatte
Copy link
Contributor Author

Ah, it's just that you can only request a review from one person at a time. Makes sense. I trust that the PR will make its way to the interested parties either way, but just in case, cc @theemathas and @traviscross, who were involved in previous discussions.

Preparing a sister PR to the Rust Reference is on my TODO list.

@RalfJung
Copy link
Member

RalfJung commented Jan 4, 2026

I'm also not really on the review rotation, so I won't be able to do the lead review here -- sorry. I'll try to take a look at the parts relevant to Miri, but the match lowering itself is outside my comfort zone.

@rustbot reroll

@rustbot rustbot assigned JonathanBrouwer and unassigned RalfJung Jan 4, 2026
@meithecatte meithecatte changed the title Remove the single-variant exception in pattern matching Remove the single-variant enum special case in pattern matching Jan 4, 2026
@rust-log-analyzer

This comment has been minimized.

@rust-log-analyzer

This comment has been minimized.

@Zalathar
Copy link
Member

Zalathar commented Jan 5, 2026

For the tests that need to be adjusted, is it possible to make the adjustments before the main changes, and still have the tests pass?

That can be a nicer approach, if it's viable.

@rustbot

This comment has been minimized.

@meithecatte
Copy link
Contributor Author

For the tests that need to be adjusted, is it possible to make the adjustments before the main changes, and still have the tests pass?

That can be a nicer approach, if it's viable.

Depends on which tests we're talking about. The FileCheck changes in mir-opt/unreachable_enum_branching.rs could be turned into a separate commit that'd pass when put first. For the rest of the changes in mir-opt and codegen-llvm, it is at the very least an iffy idea, as it is at the very least quite difficult to perform the canonicalization that's necessary within FileCheck.

As for the changes in tests/ui, it is also not viable, because the semantics are being changed and the tests reflect that.

@theemathas theemathas added the needs-crater This change needs a crater run to check for possible breakage in the ecosystem. label Jan 5, 2026
@traviscross traviscross added needs-fcp This change is insta-stable, or significant enough to need a team FCP to proceed. I-lang-nominated Nominated for discussion during a lang team meeting. I-lang-radar Items that are on lang's radar and will need eventual work or consideration. labels Jan 5, 2026
@meithecatte
Copy link
Contributor Author

The way #138961 comes up as a breaking change in practice is when it causes closure captures to be more granular, and as a result some values get borrowed when previously they would get moved together with the enum they were contained in. The change this forces to the user's code is typically very simple, see e.g. https://github.com/tryandromeda/andromeda/pull/43/changes

With the initial, full version of the current PR, we would again be capturing the entire enum in these cases, thus avoiding any similar instances of that breakage that happened to not get caught by crater.

However, the cut down version of this PR, which only affects enums containing uninhabited variants or marked non-exhaustive, this effect is less likely to occur (I can imagine the non-exhaustive case happening in real code, but it essentially limits it to when the code pattern in question happens to be matching on an enum that's part of the public API of the crate being compiled).

Either way, I think this is not worth backporting, because user code the previous PR broke should be really easy to fix, while this PR also has practical breakage of its own.

@workingjubilee workingjubilee removed the beta-nominated Nominated for backporting to the compiler in the beta channel. label Feb 3, 2026
@workingjubilee
Copy link
Member

workingjubilee commented Feb 3, 2026

Then I am withdrawing the beta nomination as the inclination of T-compiler members reviewing the backport decision in #t-compiler/backports > #150681: beta-nominated was also to decline.

traviscross added a commit to rust-lang/reference that referenced this pull request Feb 5, 2026
When matching against a single-variant enum marked `non_exhaustive`
and defined in a different crate, we pretend that there might be
multiple variants and force a read of the discriminant.  In [Rust PR
150681], the lang team decided to remove the same-crate exception.
Now matching against a single-variant `non_exhaustive` enum in the
same crate will also cause a discriminant read.

Separately, prior to [Rust PR 150681], `rustc` was eliding the
discriminant read when an enum has only one *inhabited* variant.  This
contradicts what the Reference says in:

> r[type.closure.capture.precision.discriminants.uninhabited-variants]
>
> Even if all variants but the one being matched against are
> uninhabited, making the pattern [irrefutable][patterns.refutable],
> the discriminant is still read if it otherwise would be.

The lang FCP in the PR decided to confirm the behavior already encoded
in the Reference, so no change is needed there.

[Rust PR 150681]: rust-lang/rust#150681
traviscross added a commit to rust-lang/reference that referenced this pull request Feb 5, 2026
When matching against a single-variant enum marked `non_exhaustive`
and defined in a different crate, we pretend that there might be
multiple variants and force a read of the discriminant.  In [Rust PR
150681], we decided to remove the same-crate exception.  Now matching
against a single-variant `non_exhaustive` enum in the same crate will
also cause a discriminant read.

Separately, prior to [Rust PR 150681], `rustc` was eliding the
discriminant read when an enum has only one *inhabited* variant.  This
contradicts what the Reference says in:

> r[type.closure.capture.precision.discriminants.uninhabited-variants]
>
> Even if all variants but the one being matched against are
> uninhabited, making the pattern [irrefutable][patterns.refutable],
> the discriminant is still read if it otherwise would be.

We decided with our lang FCP to confirm the behavior already encoded
in the Reference, so no change is needed there.

[Rust PR 150681]: rust-lang/rust#150681
@traviscross
Copy link
Contributor

Let's think about Reference text (cc @rust-lang/lang-docs). Probably we'll change type.closure.capture.precision.discriminants.non_exhaustive in this way:

r[type.closure.capture.precision.discriminants.non_exhaustive]
If [#[non_exhaustive]][attributes.type-system.non_exhaustive] is applied to an enum defined in an external crate, the enum is treated as having multiple variants for the purpose of deciding whether a read occurs, even if it actually has only one variant.

For item 3, the Reference was already correct, I think. Anything else?

I've posted a Reference PR for this change in:

(It still needs to be approved before we merge this PR; @meithecatte, if you would, have a look and confirm that fully covers what we're doing here.)

@traviscross traviscross added the S-waiting-on-documentation Status: Waiting on approved PRs to documentation before merging label Feb 5, 2026
@rust-rfcbot rust-rfcbot added finished-final-comment-period The final comment period is finished for this PR / Issue. to-announce Announce this issue on triage meeting and removed final-comment-period In the final comment period and will be merged soon unless new substantive objections are raised. labels Feb 11, 2026
@rust-rfcbot
Copy link
Collaborator

The final comment period, with a disposition to merge, as per the review above, is now complete.

As the automated representative of the governance process, I would like to thank the author for their work and everyone else who contributed.

@traviscross
Copy link
Contributor

(I'll unsubcribe from this PR, when this is ready for T-compiler review, feel free to ping or reroll)

@JonathanBrouwer: The FCP is complete. The Reference PR is ready. This is good to go as far as lang is concerned.

@JonathanBrouwer
Copy link
Contributor

I reviewed the code change and it looks like a relatively simple code change with good tests :)
I am not familiar with the code, and would usually request another reviewer to verify, but looks like @Nadrieril already did so I'm happy :)

@bors r=JonathanBrouwer,Nadrieril rollup=never

@rust-bors
Copy link
Contributor

rust-bors bot commented Feb 14, 2026

📌 Commit af302a6 has been approved by JonathanBrouwer,Nadrieril

It is now in the queue for this repository.

@rust-bors rust-bors bot added the S-waiting-on-bors Status: Waiting on bors to run and complete tests. Bors will change the label on completion. label Feb 14, 2026
@rust-bors

This comment has been minimized.

@rust-bors rust-bors bot added merged-by-bors This PR was explicitly merged by bors. and removed S-waiting-on-bors Status: Waiting on bors to run and complete tests. Bors will change the label on completion. labels Feb 14, 2026
@rust-bors
Copy link
Contributor

rust-bors bot commented Feb 14, 2026

☀️ Test successful - CI
Approved by: JonathanBrouwer,Nadrieril
Duration: 3h 36m 16s
Pushing f846389 to main...

@rust-bors rust-bors bot merged commit f846389 into rust-lang:main Feb 14, 2026
13 checks passed
@rustbot rustbot added this to the 1.95.0 milestone Feb 14, 2026
@github-actions
Copy link
Contributor

What is this? This is an experimental post-merge analysis report that shows differences in test outcomes between the merged PR and its parent PR.

Comparing 5d04477 (parent) -> f846389 (this PR)

Test differences

Show 15 test diffs

Stage 1

  • [ui] tests/ui/closures/2229_closure_analysis/match/partial-move-drop-order.rs: [missing] -> pass (J1)
  • [ui] tests/ui/closures/2229_closure_analysis/match/partial-move.rs: [missing] -> pass (J1)
  • [ui] tests/ui/match/borrowck-uninhabited.rs: [missing] -> pass (J1)
  • [ui] tests/ui/match/uninhabited-granular-moves.rs: [missing] -> pass (J1)

Stage 2

  • [ui] tests/ui/closures/2229_closure_analysis/match/partial-move-drop-order.rs: [missing] -> pass (J0)
  • [ui] tests/ui/closures/2229_closure_analysis/match/partial-move.rs: [missing] -> pass (J0)
  • [ui] tests/ui/match/borrowck-uninhabited.rs: [missing] -> pass (J0)
  • [ui] tests/ui/match/uninhabited-granular-moves.rs: [missing] -> pass (J0)

Additionally, 7 doctest diffs were found. These are ignored, as they are noisy.

Job group index

Test dashboard

Run

cargo run --manifest-path src/ci/citool/Cargo.toml -- \
    test-dashboard f8463896a9b36a04899c013bd8825a7fd29dd7a4 --output-dir test-dashboard

And then open test-dashboard/index.html in your browser to see an overview of all executed tests.

Job duration changes

  1. dist-x86_64-apple: 1h 58m -> 2h 24m (+22.0%)
  2. aarch64-apple: 2h 55m -> 3h 28m (+18.8%)
  3. pr-check-2: 42m 59s -> 34m 56s (-18.7%)
  4. x86_64-gnu-stable: 2h 24m -> 2h 1m (-15.5%)
  5. pr-check-1: 32m 59s -> 27m 53s (-15.5%)
  6. i686-gnu-2: 1h 41m -> 1h 27m (-13.7%)
  7. i686-gnu-1: 2h 18m -> 2h 1m (-12.1%)
  8. aarch64-msvc-1: 1h 58m -> 2h 10m (+10.4%)
  9. x86_64-gnu: 2h 6m -> 2h 19m (+10.0%)
  10. x86_64-gnu-llvm-20: 1h 22m -> 1h 14m (-9.6%)
How to interpret the job duration changes?

Job durations can vary a lot, based on the actual runner instance
that executed the job, system noise, invalidated caches, etc. The table above is provided
mostly for t-infra members, for simpler debugging of potential CI slow-downs.

@rust-bors rust-bors bot mentioned this pull request Feb 14, 2026
@rust-timer
Copy link
Collaborator

Finished benchmarking commit (f846389): comparison URL.

Overall result: ❌✅ regressions and improvements - please read the text below

Our benchmarks found a performance regression caused by this PR.
This might be an actual regression, but it can also be just noise.

Next Steps:

  • If the regression was expected or you think it can be justified,
    please write a comment with sufficient written justification, and add
    @rustbot label: +perf-regression-triaged to it, to mark the regression as triaged.
  • If you think that you know of a way to resolve the regression, try to create
    a new PR with a fix for the regression.
  • If you do not understand the regression or you think that it is just noise,
    you can ask the @rust-lang/wg-compiler-performance working group for help (members of this group
    were already notified of this PR).

@rustbot label: +perf-regression
cc @rust-lang/wg-compiler-performance

Instruction count

Our most reliable metric. Used to determine the overall result above. However, even this metric can be noisy.

mean range count
Regressions ❌
(primary)
0.6% [0.1%, 1.1%] 7
Regressions ❌
(secondary)
0.2% [0.1%, 0.2%] 12
Improvements ✅
(primary)
-2.6% [-2.6%, -2.6%] 1
Improvements ✅
(secondary)
-2.3% [-2.3%, -2.3%] 1
All ❌✅ (primary) 0.2% [-2.6%, 1.1%] 8

Max RSS (memory usage)

Results (primary 0.3%, secondary -1.1%)

A less reliable metric. May be of interest, but not used to determine the overall result above.

mean range count
Regressions ❌
(primary)
3.6% [0.5%, 7.8%] 5
Regressions ❌
(secondary)
1.7% [1.5%, 1.8%] 2
Improvements ✅
(primary)
-2.4% [-3.5%, -1.8%] 6
Improvements ✅
(secondary)
-1.9% [-2.9%, -0.7%] 7
All ❌✅ (primary) 0.3% [-3.5%, 7.8%] 11

Cycles

Results (primary -2.3%, secondary -0.6%)

A less reliable metric. May be of interest, but not used to determine the overall result above.

mean range count
Regressions ❌
(primary)
- - 0
Regressions ❌
(secondary)
3.2% [2.7%, 3.7%] 3
Improvements ✅
(primary)
-2.3% [-2.6%, -2.0%] 2
Improvements ✅
(secondary)
-4.4% [-7.4%, -2.0%] 3
All ❌✅ (primary) -2.3% [-2.6%, -2.0%] 2

Binary size

Results (primary 0.4%, secondary 0.2%)

A less reliable metric. May be of interest, but not used to determine the overall result above.

mean range count
Regressions ❌
(primary)
0.9% [0.0%, 2.9%] 7
Regressions ❌
(secondary)
0.5% [0.1%, 0.9%] 2
Improvements ✅
(primary)
-0.3% [-0.5%, -0.1%] 6
Improvements ✅
(secondary)
-0.1% [-0.1%, -0.1%] 3
All ❌✅ (primary) 0.4% [-0.5%, 2.9%] 13

Bootstrap: 480.975s -> 480.91s (-0.01%)
Artifact size: 397.98 MiB -> 397.91 MiB (-0.02%)

@rustbot rustbot added the perf-regression Performance regression. label Feb 14, 2026
@traviscross traviscross added the relnotes Marks issues that should be documented in the release notes of the next release. label Feb 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-closures Area: Closures (`|…| { … }`) A-patterns Relating to patterns and pattern matching disposition-merge This issue / PR is in PFCP or FCP with a disposition to merge it. finished-final-comment-period The final comment period is finished for this PR / Issue. I-lang-radar Items that are on lang's radar and will need eventual work or consideration. merged-by-bors This PR was explicitly merged by bors. needs-crater This change needs a crater run to check for possible breakage in the ecosystem. needs-fcp This change is insta-stable, or significant enough to need a team FCP to proceed. perf-regression Performance regression. relnotes Marks issues that should be documented in the release notes of the next release. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue. T-lang Relevant to the language team T-opsem Relevant to the opsem team to-announce Announce this issue on triage meeting

Projects

None yet