Skip to content

MIR GVN merges by-value argument temporaries with drop glue, causing double free in safe code (1.95/1.96, aarch64-apple-darwin) #158830

Closed as duplicate

Description

@ikematta

MIR GVN merges two identical constant-constructed argument temporaries that are passed by value into a callee that mutates and drops its argument. Under the indirect by-value ABI the callee's drop runs in the caller-provided slot, so the second call receives memory the first call already dropped through — a double free in 100% safe code (no unsafe, no FFI, no dependencies).

Reproducer

use std::sync::atomic::{AtomicUsize, Ordering};

static NEWS: AtomicUsize = AtomicUsize::new(0);
static DROPS: AtomicUsize = AtomicUsize::new(0);

struct Key(u64);

impl Key {
    fn mint(v: u64) -> Box<Key> {
        NEWS.fetch_add(1, Ordering::SeqCst);
        Box::new(Key(v))
    }
}

impl Drop for Key {
    fn drop(&mut self) {
        DROPS.fetch_add(1, Ordering::SeqCst);
    }
}

#[derive(Debug, Clone, Copy)]
struct Options {
    temperature: f32,
    top_p: f32,
    top_k: u32,
    min_p: f32,
    seed: u64,
    explicit_seed: bool,
}

struct Sampler {
    options: Options,
    key: Option<Box<Key>>,
}

impl Sampler {
    fn new(options: Options) -> Self {
        Self { options, key: None }
    }

    fn sample(&mut self) -> u64 {
        let key = match self.key.take() {
            Some(key) => key,
            None => Key::mint(self.options.seed),
        };
        let v = key.0;
        self.key = Some(Key::mint(v + 1));
        v
    }
}

fn main() {
    let opts = Options {
        temperature: 1.0,
        top_p: 0.95,
        top_k: 0,
        min_p: 0.0,
        seed: 42,
        explicit_seed: false,
    };
    let draw = |mut sampler: Sampler| -> Vec<u64> { (0..16).map(|_| sampler.sample()).collect() };
    let a = draw(Sampler::new(opts));
    let b = draw(Sampler::new(opts));
    assert_eq!(a, b, "second sampler inherited state from the first");
    let (news, drops) = (NEWS.load(Ordering::SeqCst), DROPS.load(Ordering::SeqCst));
    assert_eq!(news, drops, "key double-free or leak: {news} news vs {drops} drops");
    println!("ok: streams match, {news} keys minted, {drops} dropped");
}

Steps

$ rustc -C opt-level=2 -o repro main.rs && ./repro
zsh: trace trap  ./repro          # exit 133, no output
$ rustc -C opt-level=3 -o repro main.rs && ./repro
zsh: trace trap  ./repro          # exit 133, no output
$ rustc -C opt-level=1 -o repro main.rs && ./repro
ok: streams match, 34 keys minted, 34 dropped

lldb on the opt-level ≥ 2 binary shows the macOS allocator's double-free trap:

* thread #1, name = 'main', stop reason = EXC_BREAKPOINT
    frame #0: libsystem_malloc.dylib`mfm_free.cold.4 + 36
->  0x18a6bcfd4 <+36>: brk    #0x1

I expected the program to print ok: streams match, 34 keys minted, 34 dropped at every opt-level (as it does at 0 and 1).

Isolation to MIR GVN

Disabling only the GVN pass on the identical source at opt-level 3 runs clean (obtained on stable via RUSTC_BOOTSTRAP=1):

$ RUSTC_BOOTSTRAP=1 rustc -C opt-level=3 -Zmir-enable-passes=-GVN -o repro main.rs && ./repro
ok: streams match, 34 keys minted, 34 dropped

MIR evidence

--emit=mir at opt-level 3 — fn main() builds the Sampler and the argument tuple once, then passes the same local to both calls (copy _5, then move _5):

bb0: {
    _1 = Options { temperature: const 1f32, top_p: const 0.949999988f32, top_k: const 0_u32, min_p: const 0f32, seed: const 42_u64, explicit_seed: const false };
    ...
    _6 = Sampler { options: copy _1, key: const Option::<Box<Key>>::None };
    _5 = (copy _6,);
    _3 = <{closure@main.rs:75:16: 75:50} as Fn<(Sampler,)>>::call(move _4, copy _5) -> [return: bb1, unwind continue];
}

bb1: {
    ...
    _7 = <{closure@main.rs:75:16: 75:50} as Fn<(Sampler,)>>::call(move _8, move _5) -> [return: bb2, unwind: bb11];
}

Sampler has drop glue (it is not Copy), and the closure takes it by value: the callee advances the key chain and drops the sampler — with the indirect ABI, in the caller-provided argument memory. Before GVN, each call had its own freshly-constructed temporary. After GVN merges them, copy _5 for call 1 and move _5 for call 2 refer to one slot; at runtime call 2 observes call 1's end state: a stale Some holding an already-freed Box<Key>. The next self.key.take() frees it again (double free), and when the allocator doesn't trap, the key chain visibly resumes from call 1's counter value instead of restarting (the assert_eq!(a, b) fires).

So the unsound combination appears to be: GVN proving the two temporaries value-identical and merging them, while codegen for indirect by-value arguments does not materialize a defensive copy for the copy _5 operand — the callee mutates and drops in place, invalidating the "same value" assumption for the second use.

Affected versions / status on nightly

  • Reproduces: rustc 1.95.0 (59807616e 2026-04-14) and rustc 1.96.1 (31fca3adb 2026-06-26), both aarch64-apple-darwin — so this is not new in 1.96; I have not bisected the introduction.
  • Does not reproduce: rustc 1.98.0-nightly (c397dae80 2026-07-02) — but nightly emits the identical merged MIR (same single _6/_5 construction, same copy _5/move _5 call pair). So on nightly either the codegen side now materializes the copy (in which case the fixing PR should be identified and evaluated for beta/stable backport) or the crash is merely masked and the latent pattern remains. I could not determine which from the outside.

Original (real-world) manifestation

Found as a deterministic release-only SIGSEGV/SIGBUS in the test suite of an MLX-based LLM inference server on Apple Silicon: the production-shaped struct holds an Option<FfiHandle> where FfiHandle wraps a plain raw pointer (no niche, so the Option carries an explicit discriminant), and the crash surfaced as free() of a garbage FFI handle. That shape triggers at opt-level ≥ 1; the minimized reproducer above (niched Option<Box>) needs ≥ 2. The difference tracks inlining thresholds (the trigger requires the constructor and closure bodies to be inlined so the constant construction is visible to GVN) — an artifact of the same defect, not a separate bug.

Meta

rustc --version --verbose:

rustc 1.96.1 (31fca3adb 2026-06-26)
binary: rustc
commit-hash: 31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd
commit-date: 2026-06-26
host: aarch64-apple-darwin
release: 1.96.1
LLVM version: 22.1.2

@rustbot label +T-compiler +C-bug +I-unsound +A-mir-opt +A-mir-opt-GVN

Activity

  1. added
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    A-mir-optArea: MIR optimizations
    A-mir-opt-GVNArea: MIR opt Global Value Numbering (GVN)
    C-bugCategory: This is a bug.
    I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/Soundness
    T-compilerRelevant to the compiler team, which will review and decide on the PR/issue.
    I-prioritizeIssue needs a team member to assess the impact. Will be replaced by P-{low,medium,high,critical}
    on Jul 5, 2026
  2. saethlin commented on Jul 6, 2026

    @saethlin
    Member

    Most of this text is LLM nonsense. A trivial bisection of this indicates the actual problem in stable:

    searched nightlies: from nightly-2026-04-01 to nightly-2026-07-05
    regressed nightly: nightly-2026-04-23
    searched commit range: 9ec5d5f...913e4be
    regressed commit: f676c20

    bisected with cargo-bisect-rustc v0.6.11

    Host triple: x86_64-unknown-linux-gnu
    Reproduce with:

    cargo bisect-rustc --start=2026-04-01 --regress=success --script script.sh 

    The fix is coming in 1.97.0, which will be released in about 4 days.

  3. added
    A-codegenArea: Code generation
    and removed
    A-mir-optArea: MIR optimizations
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    A-mir-opt-GVNArea: MIR opt Global Value Numbering (GVN)
    on Jul 6, 2026
  4. removed
    I-prioritizeIssue needs a team member to assess the impact. Will be replaced by P-{low,medium,high,critical}
    on Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-codegenArea: Code generationC-bugCategory: This is a bug.I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/SoundnessT-compilerRelevant to the compiler team, which will review and decide on the PR/issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions