Skip to content

release-mode heap corruption with non-generic FnOnce(Vec<usize>) #155241

Closed

Description

@AndreiKrutikov

View all comments

I tried this code:

#![cfg_attr(not(test), no_std)]

extern crate alloc;

use alloc::vec::Vec;

fn with(f: impl FnOnce(Vec<usize>)) {
    f(Vec::new())
}

#[test]
fn repro_len_ne_unreachable() {
    with(|mut v| v.resize(2, 1));
    with(|v| {
        if v.len() != 0 {
            unreachable!();
        }
    });
}

I expected to see this happen: the test should pass, because each with call receives a fresh empty Vec<usize>, so the second closure should observe v.len() == 0 and never enter the unreachable!() branch.

Instead, this happened: in --profile release, the test process aborts due to allocator corruption.

Observed results:

Windows x86_64-pc-windows-msvc:
    process aborts with 0xc0000374 (STATUS_HEAP_CORRUPTION)

Linux x86_64-unknown-linux-gnu (Docker rust:latest image):
    free(): double free detected in tcache 2
    process aborts with SIGABRT

Two nearby controls do not reproduce:

fn with_value<T>(f: impl FnOnce(Vec<T>)) {
    f(Vec::new())
}

#[test]
fn control_generic_helper() {
    with_value(|mut v: Vec<usize>| v.resize(2, 1));
    with_value(|v: Vec<usize>| {
        if v.len() != 0 {
            unreachable!();
        }
    });
}

#[test]
fn repro_len_ne_unreachable_unchecked() {
    with(|mut v| v.resize(2, 1));
    with(|v| {
        if v.len() != 0 {
            unsafe {
                core::hint::unreachable_unchecked();
            }
        }
    });
}

Commands used:

cargo +nightly miri test repro_len_ne_unreachable -- --exact --nocapture
$env:CARGO_TARGET_DIR='target/report-repro'; cargo test --profile release repro_len_ne_unreachable -- --exact --nocapture
$env:CARGO_TARGET_DIR='target/report-control-generic'; cargo test --profile release control_generic_helper -- --exact --nocapture
$env:CARGO_TARGET_DIR='target/report-control-unchecked'; cargo test --profile release repro_len_ne_unreachable_unchecked -- --exact --nocapture
docker run --rm -v ${PWD}:/work -w /work rust:latest cargo test --profile release repro_len_ne_unreachable -- --exact --nocapture

Miri did not report UB on an earlier form of this reproducer.

Version history:

stable-1.74.1: ok
stable-1.75.0: ok
stable-1.76.0: ok
stable-1.77.0: first wrong result, panics at `unreachable!()`
stable-1.85.0: still panics at `unreachable!()`
stable-1.90.0: still panics at `unreachable!()`
stable-1.91.0: first stable with STATUS_HEAP_CORRUPTION
stable-1.92.0: STATUS_HEAP_CORRUPTION
stable-1.93.0: STATUS_HEAP_CORRUPTION
stable-1.94.0: STATUS_HEAP_CORRUPTION
nightly-2026-04-12: STATUS_HEAP_CORRUPTION

Nightly boundaries I checked:

last good nightly: nightly-2024-01-16, rustc 1.77.0-nightly (714b29a17 2024-01-15)
first bad nightly: nightly-2024-01-17, rustc 1.77.0-nightly (098d4fd74 2024-01-16), wrong panic at `unreachable!()`
earliest tested nightly with heap corruption: nightly-2024-01-18, rustc 1.77.0-nightly (6ae4cfbbb 2024-01-17)

last tested nightly with wrong panic before the later stable corruption window: nightly-2025-08-12, rustc 1.91.0-nightly (1ebbd87a6 2025-08-11)
first tested nightly with heap corruption in that window: nightly-2025-08-14, rustc 1.91.0-nightly (3672a55b7 2025-08-13)

So on stable, the bug first appears as an incorrect panic in 1.77.0 and becomes heap corruption in 1.91.0. On nightly, the first wrong result is between 2024-01-16 and 2024-01-17.

Meta

rustc --version --verbose:

rustc 1.94.0 (4a4ef493e 2026-03-02)
binary: rustc
commit-hash: 4a4ef493e3a1488c6e321570238084b38948f6db
commit-date: 2026-03-02
host: x86_64-pc-windows-msvc
release: 1.94.0
LLVM version: 21.1.8

Nightly also reproduces:

rustc 1.97.0-nightly (14196dbfa 2026-04-12)
binary: rustc
commit-hash: 14196dbfa3eb7c30195251eac092b1b86c8a2d84
commit-date: 2026-04-12
host: x86_64-pc-windows-msvc
release: 1.97.0-nightly
LLVM version: 22.1.2

Linux container reproduction:

running 1 test
free(): double free detected in tcache 2
error: test failed, to rerun pass `--lib`

Caused by:
    process didn't exit successfully: `/work/target/release/deps/repro_test-9864353ff121d9c3 repro_len_ne_unreachable --exact --nocapture` (signal: 6, SIGABRT: process abort signal)

Additional stable versions checked:

rustc 1.74.1 (a28077b28 2023-12-04)
rustc 1.75.0 (82e1608df 2023-12-21)
rustc 1.76.0 (07dca489a 2024-02-04)
rustc 1.77.0 (aedd173a2 2024-03-17)
rustc 1.85.0 (4d91de4e4 2025-02-17)
rustc 1.90.0 (1159e78c4 2025-09-14)
rustc 1.91.0 (f8297e351 2025-10-28)
rustc 1.92.0 (ded5c06cf 2025-12-08)
rustc 1.93.0 (254b59607 2026-01-19)
Backtrace

I reran the reproducer with RUST_BACKTRACE=1, but there was still no Rust backtrace.
The process aborts immediately with:

running 1 test
error: test failed, to rerun pass `--lib`

Caused by:
  process didn't exit successfully: `C:\Users\akrut\repro_test\target/report-backtrace\release\deps\repro_test-1f8b8a2035f2538a.exe repro_len_ne_unreachable --exact --nocapture` (exit code: 0xc0000374, STATUS_HEAP_CORRUPTION)

Pinned by theemathas

Activity

  1. added
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Apr 13, 2026
  2. added
    I-miscompileIssue: Correct Rust code lowers to incorrect machine code
    on Apr 13, 2026
  3. added
    I-prioritizeIssue needs a team member to assess the impact. Will be replaced by P-{low,medium,high,critical}
    on Apr 13, 2026
  4. bjorn3 commented on Apr 13, 2026

    @bjorn3
    Member

    This needs at least Zmir-opt-level=2 and -Copt-level=2 to reproduce. Reducing either opt level to 1 makes the program work fine. And it doesn't reproduce in miri even with MIR optimizations enabled.

  5. added
    T-compilerRelevant to the compiler team, which will review and decide on the PR/issue.
    on Apr 13, 2026
  6. matthiaskrgr commented on Apr 13, 2026

    @matthiaskrgr
    Member

    relevant mir passes: -Zmir-enable-passes=+DataflowConstProp -Zmir-enable-passes=+GVN -Zmir-enable-passes=+Inline

  7. matthiaskrgr commented on Apr 13, 2026

    @matthiaskrgr
    Member

    last good nightly: nightly-2024-01-16, rustc 1.77.0-nightly (714b29a 2024-01-15)
    first bad nightly: nightly-2024-01-17, rustc 1.77.0-nightly (098d4fd 2024-01-16), wrong panic at unreachable!()

    If I read the git log correctly, #119439 falls into this window, @cjgillot can you see if this is related?

  8. saethlin commented on Apr 14, 2026

    @saethlin
    Member

    Anyone interested can do a few things: look at exactly what transformation is being produced by GVN using -Zdump_mir=all, and use LLVM's opt-bisect-limit to identify the LLVM transformation at which things go wrong.

    @matthiaskrgr you said DataFlowConstProp is required but the rest of this issue is about -Zmir-opt-level=2. That's very strange

  9. matthiaskrgr commented on Apr 14, 2026

    @matthiaskrgr
    Member

    🤔

    I ran

    extern crate alloc;
    
    use alloc::vec::Vec;
    
    fn with(f: impl FnOnce(Vec<usize>)) {
        f(Vec::new())
    }
    
    fn main() {
        with(|mut v| v.resize(2, 1));
        with(|v| {
            if v.len() != 0 {
                unreachable!();
            }
        });
    }

    with

     rustc a.rs -Zmir-enable-passes=+DataflowConstProp   -Zmir-enable-passes=+GVN  -Zmir-enable-passes=+Inline -Copt-level=0  ; ./a
    
    thread 'main' (770578) panicked at a.rs:14:13:
    internal error: entered unreachable code
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
    free(): double free detected in tcache 2
    [1]    770578 IOT instruction  ./a
    
  10. matthiaskrgr commented on Apr 14, 2026

    @matthiaskrgr
    Member

    with -Zmir-opt-level=2 -Copt-level=2 I can reproduce it with codegen backends llvm, gcc but not cranelift

  11. pitust commented on Apr 14, 2026

    @pitust
    struct Inner {
        x: u64,
        y: u64,
    }
    struct NonCopy {
        inner: Inner,
        y: u64,
    }
    impl Drop for NonCopy {
        #[inline(never)]
        fn drop(&mut self) {}
    }
    
    #[inline(never)]
    fn no_mir_inliner() {}
    #[inline(always)]
    fn invoke(x: impl FnOnce(NonCopy)) {
        let y = NonCopy {
            inner: Inner { x: 0, y: 8 },
            y: 0,
        };
        x(y)
    }
    fn main() {
        invoke(|mut x| {
            no_mir_inliner();
            x.y = 1234;
        });
        invoke(|mut x| {
            no_mir_inliner();
            println!("{}", x.y);
        });
    }

    Reproducer without vec

  12. theemathas commented on Apr 14, 2026

    @theemathas
    Contributor

    Another reproducer (prints 0 in debug mode, prints 1 in release mode)

    #![expect(unused)]
    
    struct Thing {
        x: usize,
        y: usize,
        z: usize,
    }
    
    fn with(f: impl FnOnce(Thing)) {
        f(Thing { x: 0, y: 0, z: 0 })
    }
    
    fn main() {
        with(#[inline(never)] |mut thing| {
            thing.z = 1;
            std::hint::black_box(&thing);
        });
        with(#[inline(never)] |thing| {
            println!("{}", thing.z)
        });
    }
  13. 23 remaining items

  14. RalfJung commented on Apr 15, 2026

    @RalfJung
    Member

    No UB is reported by Miri. I think this is entirely a codegen bug based on miscompiling a MIR pattern that GVN produces, it's just hard to get the relevant MIR without GVN.

    Okay, thanks. #155296 should likely be closed then as it has nothing to do with the actual problem.

    the tuple argument is a copy of a variable in the MIR and the caller continues to use that same variable for something else afterwards.

    So a copy somehow turns into a by-move argument during codegen? Uh-oh.

  15. RalfJung commented on Apr 15, 2026

    @RalfJung
    Member

    miscompiling a MIR pattern that GVN produces, it's just hard to get the relevant MIR without GVN.

    Would be good to have a custom MIR testcase with this pattern, to sidestep any dependency on GVN.

  16. theemathas commented on Apr 15, 2026

    @theemathas
  17. RalfJung commented on Apr 15, 2026

    @RalfJung
    Member
  18. pitust commented on Apr 15, 2026

    @pitust

    So a copy somehow turns into a by-move argument during codegen? Uh-oh.

    My understanding is that copying by-move arguments that use a mir copy (and not move) occurs here which only runs if the argument is not a rust-call "untupled" argument (since the loop is over first_args...). Which yes, is really bad.

  19. theemathas commented on Apr 15, 2026

    @theemathas
    Contributor

    Is the fix in #155291 the correct fix or not?

    (The test in that PR makes zero sense though.)

  20. dianqk commented on Apr 15, 2026

    @dianqk
    Member

    Is the fix in #155291 the correct fix or not?

    (The test in that PR makes zero sense though.)

    AFAIK, I can only say the fix just works. #155343 is an attempt to clarify what exactly we should fix.

  21. added a commit that references this issue on Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

A-closuresArea: Closures (`|…| { … }`)A-codegenArea: Code generationC-bugCategory: This is a bug.I-miscompileIssue: Correct Rust code lowers to incorrect machine codeP-highHigh priorityT-compilerRelevant to the compiler team, which will review and decide on the PR/issue.regression-from-stable-to-stablePerformance or correctness regression from one stable version to another.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions