Skip to content

Moving an Unpin coroutine while it has local variables whose drop-flags are false is unsound #161026

Description

@ais523

Related to #159443, but different because it does not involve a reborrow: if you move an Unpin coroutine while it has a false drop flag on one of its local variables, this will do a typed move on the coroutine field that represents the global variable, even though the field is in a moved-from state (which is what the false drop flag represents). This can be undefined behavior if the field has a type that makes it dangerous to move out of the same field twice without moving a value into it in between.

I tried this code under Miri (playground):

#![feature(gen_blocks)]

// a struct that has a drop flag and contains a reference
struct DropMut<T: 'static>(&'static mut T);
impl<T: 'static> Drop for DropMut<T> { fn drop(&mut self) {} }

fn main() {
    let mut a = gen {
        let b = DropMut(Box::leak(Box::new(1)));

        // create a drop flag on `b`
        let c;
        if true { c = b; } // and ensure it's set to false
        else { c = DropMut(Box::leak(Box::new(2))); }

        *c.0 = 3;
        4.yield;
        *c.0 = 5;
    };
    let _ = a.next();
    let mut d = a;
    let _ = d.next();
}

I expected the correct behavior to be: No Miri errors are reported until the program exits (at which point it reports a memory leak, caused by the call to Box::leak).

Instead, this happened: Miri reported undefined behavior under both Stacked Borrows and Tree Borrows.

As usual, simpler examples will produce UB under Stacked Borrows, but this example produces UB under Tree Borrows too.

What's happening here is that the field of a that represents the coroutine's local variable b is being moved from twice without having a value moved into it in between: the first move is into the field of a that represents c, and the second move is into the field of d that represents b. The coroutine contains a drop flag that reflects the fact that b is no longer valid; however, the drop flag is stored as a product type rather than a sum type (i.e. the field is represented as (DropMut, bool) rather than Option<DropMut>), so even if the drop flag is false, the field gets moved anyway. Some types of fields are dangerous to move from twice; this was famously a problem with Box in the past, but nowadays I think the only such types are those which have a destructor and contain a reference as a struct/enum field.

This could be fixed either by wrapping the coroutine in MaybeDangling, or by using the equivalent of Option<T> as the representation for drop-flagged variables rather than (T, bool). (Note that the same problem can probably happen with drop-flagged struct fields of local variables, which may rule out the latter option.) (EDIT: and the former option doesn't work either because there are cases where it doesn't help; see the next comment.)

I noticed this bug while working on an aliasing model that's intended to be more suitable for coroutines than the current aliasing models, which forced me to consider all the possible things you might be able to do to a local variable via manipulating the coroutine it belonged to.

Meta

@rustbot label +A-coroutines +A-destructors +requires-nightly +I-unsound +S-has-mcve

This is nightly-only, because the only coroutines you can create on stable are futures generated by async, and those are !Unpin.

Tested on the Rust playground, version 1.99.0-nightly (2026-08-12 c98d0cb27cc63afdd626)

Backtrace

error: Undefined Behavior: write access through <565> at alloc303[0x0] is forbidden
  --> src/main.rs:14:9
   |
14 |         *c.0 = 5;
   |         ^^^^^^^^ Undefined Behavior occurred here
   |
   = help: this indicates a potential bug in the program: it performed an invalid operation, but the Tree Borrows rules it violated are still experimental
   = help: see https://github.com/rust-lang/unsafe-code-guidelines/blob/master/wip/tree-borrows.md for further information
   = help: the accessed tag <565> is a child of the conflicting tag <560>
   = help: the conflicting tag <560> has state Frozen which forbids this child write access
help: the accessed tag <565> was created here
  --> src/main.rs:17:17
   |
17 |     let mut d = a;
   |                 ^
help: the conflicting tag <560> was created here, in the initial state Reserved
  --> src/main.rs:10:19
   |
10 |         if true { c = b; }
   |                   ^
help: the conflicting tag <560> later transitioned to Unique due to a child write access at offsets [0x0..0x4]
  --> src/main.rs:12:9
   |
12 |         *c.0 = 3;
   |         ^^^^^^^^
   = help: this transition corresponds to the first write to a 2-phase borrowed mutable reference
help: the conflicting tag <560> later transitioned to Frozen due to a reborrow (acting as a foreign read access) at offsets [0x0..0x4]
  --> src/main.rs:17:17
   |
17 |     let mut d = a;
   |                 ^
   = help: this transition corresponds to a loss of write permissions
   = note: stack backtrace:
           0: main::{closure#0}
               at src/main.rs:14:9: 14:17
           1: main
               at src/main.rs:18:13: 18:21

note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace

error: aborting due to 1 previous error

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-coroutinesArea: CoroutinesA-destructorsArea: Destructors (`Drop`, …)C-bugCategory: This is a bug.I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/SoundnessS-has-mcveStatus: A Minimal Complete and Verifiable Example has been found for this issueT-compilerRelevant to the compiler team, which will review and decide on the PR/issue.requires-nightlyThis issue requires a nightly compiler in some way. When possible, use a F-* label instead.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions