Repository navigation
MIR GVN merges by-value argument temporaries with drop glue, causing double free in safe code (1.95/1.96, aarch64-apple-darwin) #158830
Description
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 droppedlldb 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 droppedMIR 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)andrustc 1.96.1 (31fca3adb 2026-06-26), bothaarch64-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/_5construction, samecopy _5/move _5call 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
saethlin commentedon Jul 6, 2026
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:
The fix is coming in 1.97.0, which will be released in about 4 days.
FnOnce(Vec<usize>)#155241