Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Miscompilation where binding only some fields leaks the others #90752

Closed
mk12 opened this issue Nov 10, 2021 · 14 comments
Closed

Miscompilation where binding only some fields leaks the others #90752

mk12 opened this issue Nov 10, 2021 · 14 comments
Assignees
Labels
A-destructors Area: Destructors (`Drop`, …) A-MIR Area: Mid-level IR (MIR) - https://blog.rust-lang.org/2016/04/19/MIR.html A-patterns Relating to patterns and pattern matching C-bug Category: This is a bug. P-critical Critical priority regression-from-stable-to-stable Performance or correctness regression from one stable version to another. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue.

Comments

@mk12
Copy link
Contributor

mk12 commented Nov 10, 2021

I found a bug where Rust leaks values (never calls drop). This is the root cause of a memory leak we saw in a Fuchsia test, caught by LeakSanitizer: https://bugs.fuchsia.dev/p/fuchsia/issues/detail?id=87961.

This is as far as I could minimize it:

#![allow(unused)]

struct S(u32);

impl Drop for S {
    fn drop(&mut self) {
        println!("dropping {}", self.0);
    }
}

enum E {
    A,
    B((S, S)),
}

fn main() {
    let mut foo = Some(E::A);
    match foo {
        Some(E::A) => (),
        _ => return,
    }
    foo.insert(E::B((S(1), S(2))));
    match foo {
        Some(E::B((x, _))) => {}
        _ => {}
    }
}

Expected output:

dropping 2
dropping 1

Actual output:

dropping 1

It leaked S(2). If you change (x, _) to (_, x) in the second match statement, it leaks S(1) instead. If you bind neither (_, _) or both (x, y), then both get dropped as expected.

If you use Box<u8> instead of S, you can detect a memory leak using LeakSanitizer or Valgrind.

Meta

I reproduced this on stable:

rustc 1.56.1 (59eed8a2a 2021-11-01)
binary: rustc
commit-hash: 59eed8a2aac0230a8b53e89d4e99d55912ba6b35
commit-date: 2021-11-01
host: x86_64-unknown-linux-gnu
release: 1.56.1
LLVM version: 13.0.0

And on nightly:

rustc 1.58.0-nightly (8b09ba6a5 2021-11-09)
binary: rustc
commit-hash: 8b09ba6a5d5c644fe0f1c27c7f9c80b334241707
commit-date: 2021-11-09
host: x86_64-unknown-linux-gnu
release: 1.58.0-nightly
LLVM version: 13.0.0
@mk12 mk12 added the C-bug Category: This is a bug. label Nov 10, 2021
@tmandry tmandry added A-patterns Relating to patterns and pattern matching A-destructors Area: Destructors (`Drop`, …) T-compiler Relevant to the compiler team, which will review and decide on the PR/issue. I-compiler-nominated Nominated for discussion during a compiler team meeting. I-prioritize Issue: Indicates that prioritization has been requested for this issue. labels Nov 10, 2021
@jyn514
Copy link
Member

jyn514 commented Nov 10, 2021

Note that the miscompilation goes away if you change foo.insert((E::B) to foo = Some(E::B). So I suspect the unreachable_unchecked in insert is what's causing this somehow.

unsafe { self.as_mut().unwrap_unchecked() }

@jyn514
Copy link
Member

jyn514 commented Nov 10, 2021

Also, the miscompilation is in MIR, not LLVM, because Miri also fails to run the destructor.

@jyn514
Copy link
Member

jyn514 commented Nov 10, 2021

Very slightly smaller (I removed the fields in S):

struct S;

impl Drop for S {
    fn drop(&mut self) {
        println!("dropping");
    }
}

enum E {
    A,
    B((S, S)),
}

fn main() {
    let mut foo = Some(E::A);
    match foo {
        Some(E::A) => (),
        Some(E::B(_)) | None => return,
    }
    foo.insert(E::B((S, S)));
    match foo {
        Some(E::B((x, _))) => {}
        _ => {},
    };
}

@jyn514
Copy link
Member

jyn514 commented Nov 10, 2021

Oh strangely this is not related to the unwrap_unchecked, it still reproduces with a normal unwrap. https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=6e6a87916e94f4012b2d93615a7ce1d1

@jyn514
Copy link
Member

jyn514 commented Nov 10, 2021

Here's a version without std::option::Option (just an in-crate option): https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=025985867f32378dad0785597377a680

@nbdd0121
Copy link
Contributor

A quick manual bisection on godbolt shows that this regresses in Rust 1.43 (is correct in Rust 1.42).

@rustbot label: regression-from-stable-to-stable

@rustbot rustbot added the regression-from-stable-to-stable Performance or correctness regression from one stable version to another. label Nov 10, 2021
@nbdd0121
Copy link
Contributor

nbdd0121 commented Nov 10, 2021

Bisection reveals that this issue is introduced in #68528.

I think the issue is that precise_enum_drop_elaboration didn't re-mark all variants as possibly active after the mutable reference to the enum is used. The miscompilation goes away with -Z precise_enum_drop_elaboration=n. I filed #90756 to disable precise_enum_drop_elaboration by default, and we can turn this back on once the issue is fixed.

cc @ecstatic-morse
@rustbot label: +A-mir

@rustbot rustbot added the A-MIR Area: Mid-level IR (MIR) - https://blog.rust-lang.org/2016/04/19/MIR.html label Nov 10, 2021
@ecstatic-morse
Copy link
Contributor

Thanks for the detailed bug report. I think I know what the root cause is, but it's not a trivial fix. I'll work on this later today.

@ecstatic-morse
Copy link
Contributor

ecstatic-morse commented Nov 10, 2021

The root cause

Here's my preferred minimization, using an indirect write instead of Option::insert and Option instead of the inner enum. I've annotated the code to show how it is affected by the "active enum variant" logic in #68528 and where things go wrong.

struct S(i32);

impl Drop for S {
    fn drop(&mut self) {
        println!("dropping {}", self.0);
    }
}

fn main() {
    let mut foo = None;
    match foo {
        None => (),
        _ => return,
    }

    // Here we know that `foo` is `None`, otherwise we would have returned above.

    // Overwrite `foo` indirectly. `foo` is no longer `None`, but we fail to update our
    // initialization data to reflect this.
    *(&mut foo) = Some((S(0), S(1)));

    // Move from `(foo as Some).0` (despite the fact that we think it is uninitialized),
    // creating a drop flag for `(foo as Some).0` and making the subsequent
    // drop of `foo` an open one.
    match foo {
        Some((_x, _)) => {
        } // `_x` is dropped: "dropping 0"

        _ => {}
    }

    // `foo` is dropped, but it is an "open" drop, so the move paths associated with its
    //`Some` variants are still marked as uninitialized and the `Drop` is eliminated.
}

I was a bit surprised that the second match statement was necessary to make the bug manifest. Without the partial move of (_1 as Some).0 into _x, the drop of _1 is a shallow one (its subpaths aren't considered), and since _1 is still initialized (only its variants are cleared by #68582), everything gets dropped correctly.

As @nbdd0121 suggests, it's the write through the mutable borrow that causes the problem. Maybe{Un}InitializedPlaces was able to ignore indirect writes in the past because it's illegal to create a mutable reference to uninitialized memory in the first place. Things get more complex when raw pointers and unsafe code is involved (see #90718), but the original behavior was correct for realistic code.

However, once we started marking inactive enum variants as uninitialized, it became possible to have a mutable reference that can update an uninitialized place in safe code, which is what happened here.

A quick fix

To fix the memory leak in the near term, it should suffice to modify Maybe{Un}InitializedPlaces to mark all children of a place as "maybe initialized" when it sees a mutable borrow of that place. This solution is very brittle, since it relies on the fact that a match on some variable introduces a shallow borrow on that variable that prevents its discriminant from changing inside the match body. That shallow borrow would prevent code like the following:

let mut foo = None;
let pfoo = &mut foo; // Mutable borrow here

match foo {
    Some(_) => return,
    None => (),
}
// `Some` variants are marked as uninitialized, despite the fact that a mutable borrow is outstanding.

*pfoo = Some(...); // Reinitialize `Some` variants, without creating a new mutable borrow.

However, there may be ways to circumvent this. For example, converting pfoo to a raw pointer before the first match and then using it to write to foo after it would pass the borrow checker, although I believe that's a violation of stacked borrows. Additionally, there may be a way to generate MIR that looks like a match on a discriminant to Maybe{Un}InitializedPlaces, but does not cause a shallow borrow of foo.

Perhaps there are even more cases where this fix falls short. I've opened a PR that implements this (#90788) so I can discuss it further with the compiler team.

A long-term solution

The real mistake here is conflating the active variant of a local with its initializedness. In the long-term, we should use a bespoke dataflow analysis to track the possible active variants of an enum, and incorporate those results into drop elaboration. The approach in #68582 was taken because it required the smallest number of changes to drop elaboration, a complex part of the compiler whose original author is no longer active and where it is easy to introduce subtle miscompilations.

One safeguard that would have caught this is to check the results of MaybeUninitializedPlaces against the MIR to see if a possibly uninitialized place is moved from. Such places should cause move errors, so we can delay_span_bug if we see a suspicious move. This would be relatively expensive, however.

Even if the quick fix is acceptable for now, I think we should pursue something more robust going forward.

ecstatic-morse added a commit to ecstatic-morse/rust that referenced this issue Nov 13, 2021
ecstatic-morse added a commit to ecstatic-morse/rust that referenced this issue Nov 13, 2021
@apiraino
Copy link
Contributor

Assigning priority as discussed in the Zulip thread of the Prioritization Working Group.

@rustbot label -I-prioritize +P-critical

@rustbot rustbot added P-critical Critical priority and removed I-prioritize Issue: Indicates that prioritization has been requested for this issue. labels Nov 18, 2021
@wesleywiser wesleywiser self-assigned this Nov 18, 2021
bors added a commit to rust-lang-ci/rust that referenced this issue Nov 23, 2021
…iser

Mark places as initialized when mutably borrowed

Fixes the example in rust-lang#90752, but does not handle some corner cases involving raw pointers and unsafe. See [this comment](rust-lang#90752 (comment)) for more information, or the second test.

Although I talked about both `MaybeUninitializedPlaces` and `MaybeInitializedPlaces` in rust-lang#90752, this PR only changes the latter. That's because "maybe uninitialized" is the conservative choice, and marking them as definitely initialized (`!maybe_uninitialized`) when a mutable borrow is created could lead to problems if `addr_of_mut` to an uninitialized local is allowed. Additionally, places cannot become uninitialized via a mutable reference, so if a place is definitely initialized, taking a mutable reference to it should not change that.

I think it's correct to ignore interior mutability as nbdd0121 suggests below. Their analysis doesn't work inside of `core::cell`, which *does* have access to `UnsafeCell`'s field, but that won't be an issue unless we explicitly instantiate one with an `enum` within that module.

r? `@wesleywiser`
@pnkfelix
Copy link
Member

pnkfelix commented Dec 2, 2021

FYI This is addressed by PR #90788, which will be part of the Rust 1.58 release (stable in six weeks).

@pnkfelix pnkfelix removed the I-compiler-nominated Nominated for discussion during a compiler team meeting. label Dec 2, 2021
@apiraino
Copy link
Contributor

Checking progress, @mk12 did you have a chance to test against the new stable 1.58 if it solves the issue? thanks

@mk12
Copy link
Contributor Author

mk12 commented Jan 22, 2022

@apiraino The example I gave in my first comment now behaves as expected on the Rust playground with 1.58.1 stable. I'm fairly sure this solves the original Fuchsia bug, but it's difficult to reproduce that because a workaround was used at the time and that code has changed a lot since.

@apiraino
Copy link
Contributor

I'm going to close this issue as we think it's solved. In case it can be reopened or follow-up issues can be created

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-destructors Area: Destructors (`Drop`, …) A-MIR Area: Mid-level IR (MIR) - https://blog.rust-lang.org/2016/04/19/MIR.html A-patterns Relating to patterns and pattern matching C-bug Category: This is a bug. P-critical Critical priority regression-from-stable-to-stable Performance or correctness regression from one stable version to another. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue.
Projects
None yet
Development

No branches or pull requests

9 participants