Skip to content

Unsoundness: explicit pin projection into a #[repr(packed)] field breaks Pin without #[pin_v2] #157615

@Dnreikronos

Description

@Dnreikronos

So when the destructor of a repr(packed) struct runs, a field that's unaligned and has a destructor gets moved to an aligned place before that field's destructor runs. (See also #157011, #143411, taiki-e/pin-project-lite#89, taiki-e/pin-project#34.)

#157011 covers this for #[pin_v2], and the fix there (#157542) just bans #[pin_v2] + #[repr(packed)]. But afaict the #[pin_v2] attribute only gates the implicit projection (the default binding-mode shift, like plain Foo { x, y } matched against a Pin<&mut Foo>). The explicit &pin mut / ref pin mut patterns project in place no matter whether the type opted in, so banning the attribute combo doesn't really close this. You can hit the exact same move-on-drop with no #[pin_v2] anywhere. Filing this as the standalone issue for the leftover case that #157542 left open under the pin ergonomics tracking issue #130494.

The projection soundness check lives in rustc_hir_typeck/src/pat.rs and only runs under default_binding_modes. Spelling the deref out (&pin mut .. ref pin mut ..) kinda walks right past it, which is why no attribute is needed and why the #157011 fix doesn't catch this.

I tried this code:

#![feature(pin_ergonomics)]
#![allow(incomplete_features)]

use std::{marker::PhantomPinned, pin::{Pin, pin}};

// no #[pin_v2] on either type
#[repr(C, packed(4))]
struct One<T>(T);

#[repr(C, align(4096))]
struct Two(Thing);

struct Thing(#[expect(dead_code)] i32, PhantomPinned);

fn main() {
    let pinned_one: Pin<&mut One<Two>> = pin!(One(Two(Thing(0, PhantomPinned))));
    let &pin mut One(Two(ref pin mut pinned_thing)) = pinned_one;
    access(pinned_thing);
}

fn access(x: Pin<&mut Thing>) {
    println!("Pinned access at {x:p}")
}

impl Drop for Thing {
    fn drop(&mut self) {
        println!("Dropped at       {self:p}");
    }
}

I expected to see this happen: the explicit &pin mut / ref pin mut projection through a packed type should get rejected, like the #[pin_v2] case in #157011. A Pin<&mut Thing> you hand out should sure keep a stable address for the rest of the value's life.

Instead, this happened: it just compiles and runs, and the pinned-access addr and the drop addr come out different, so the Pin invariant is broken. One is packed(4), so its Thing leaf is aligned to 4. That's fine on its own, so unaligned_references doesn't complain. The thing that actually moves on drop is the parent Two (align 4096), and we never take a reference to Two, so nothing catches it. (One has to be generic to dodge E0588.)

Couple more notes: I used align(4096) instead of #157011's 65536 just so it prints cleanly. At 65536 it straight up segfaults on my machine (stack realignment during the drop-glue move), same unsoundness, just louder. Also the &pin mut <place> borrow operator (like &pin mut one.0.0) does not repro this, imo because it copies the packed field out to an aligned temp first, so it stays safe. It's specifically the pattern form that pins in place.

Meta

rustc --version --verbose:

rustc 1.98.0-nightly (f20a92ec0 2026-06-07)
binary: rustc
commit-hash: f20a92ec01483dc5c58e90e246f266bdad822d86
commit-date: 2026-06-07
host: x86_64-pc-windows-msvc
release: 1.98.0-nightly
LLVM version: 22.1.6

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-alignArea: alignment control (`repr(align(N))` and so on)A-destructorsArea: Destructors (`Drop`, …)A-pinArea: PinA-repr-packedArea: the naughtiest reprC-bugCategory: This is a bug.F-pin_ergonomics`#![feature(pin_ergonomics)]`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
    No fields configured for issues without a type.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions