镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Unsound: moving a &mut-capturing closure out of any aggregate (incl. enum/Option), with no higher-order call, drops its prophecy and verifies panicking programs as safe (generalizes #177) #207

Open

Description

@coord-e

Summary

Moving a non-Copy closure that captures a variable by &mut out of an aggregate (a tuple, a struct field, or an enum/Option variant) and then calling it silently drops the captured &mut's prophecy. The post-state collapses to a contradictory (⊥) environment — so the closure's mutation is never reflected onto the captured variable, and everything after the call verifies vacuously (including assert!(false)). Thrust reports programs that always panic at runtime as safe.

This is a generalization of #177. #177 documents the failure for a closure moved out of a tuple/struct field and passed by value to a higher-order function taking FnOnce/FnMut, and localizes it to the call-site projection check in rust_call.rs "case 3". The reproductions below show the real trigger is the move out of the aggregate itself:

Consequently a fix limited to rust_call.rs "case 3" (the if arg_closure_place.projection.is_empty() guard) would not close these holes.

Reproduction (minimal)

Both programs always panic under rustc but verify safe under Thrust. Neither uses a higher-order function or any annotation.

R1 — &mut-capturing closure moved out of an Option variant

fn main() {
    let mut x = 0i64;
    let e = Some(|| { x += 8; });   // closure captures `x` by &mut, stored in Option::Some
    if let Some(mut g) = e {        // moved out of the enum variant (Downcast projection)
        g();                        // called directly through a plain local — no HOF
    }
    assert!(x == 0);                // runtime: x == 8, so this panics; Thrust: SAFE
}
$ rustc --edition 2021 r1.rs -o r1 && ./r1 ; echo "exit:$?"
thread 'main' panicked at r1.rs:7:5:
assertion failed: x == 0
exit:101                                   # actually panics

$ THRUST_SOLVER=z3 thrust-rustc --edition 2021 -Adead_code -C debug-assertions=false r1.rs ; echo "exit:$?"
exit:0                                     # Thrust: SAFE

R2 — same failure with a struct field and no higher-order function

struct W<F> { f: F }

fn main() {
    let mut x = 0i64;
    let w = W { f: || { x += 8; } };   // &mut-capturing closure in a struct field
    let mut g = w.f;                   // moved out into a projection-less local
    g();                               // called directly — no HOF
    assert!(x == 0);                   // runtime: x == 8, panics; Thrust: SAFE
}

R3 — the ⊥-collapse tell-tale (assert!(false) verifies)

fn main() {
    let mut x = 0i64;
    let e = Some(|| { x += 8; });
    if let Some(mut g) = e { g(); }
    assert!(false);                    // ALWAYS panics; Thrust: SAFE (vacuous post-state)
}

assert!(false) passing is the tell-tale (same as #177) that the environment after the call is unsatisfiable, so any assertion is discharged.

What isolates the cause

All rows verified at commit a148b9d with Z3 4.15.4 (the version pinned by .github/actions/setup-z3). "safe" is wrong wherever rustc exits 101.

| Program (closure is || { x += 8; }, capturing x by &mut) | Thrust | Correct? |
| --- | --- | --- |
| Some(closure) → if let Some(mut g)=e { g(); } (enum, no HOF) | safe | ✗ (panics) |
| W{f:closure} → let mut g=w.f; g(); (struct, no HOF) | safe | ✗ |
| (closure,) → let mut g=t.0; g(); (1-tuple, no HOF) | safe | ✗ |
| (closure, 1) → let (mut g,_)=t; g(); (full destructure, no HOF) | safe | ✗ |
| Some(closure) → if let Some(g)=e { apply(g); } (enum, via HOF) | safe | ✗ |
| Control: let mut g = closure; g(); (never in an aggregate) | Unsat | ✓ |
| Control: let g0 = closure; let mut g = g0; g(); (local→local move) | Unsat | ✓ |
| Control: (\|\| 5i64,) → let g=t.0; let v=g(); (no &mut capture) | Unsat | ✓ |

Two facts fall out:

  1. The move out of an aggregate is the trigger. A closure that is never placed in an aggregate (control rows 1–2) is handled soundly, even after a local→local move. As soon as the same closure is extracted from a tuple/struct/enum, the mutation is lost. This holds for a full destructure too, so it is not a partial-move-remnant effect (cf. Unsoundness: partially-moved locals are still implicitly dropped, resolving prophecies of moved-out &mut borrows #121/Unsound: aggregate dropped wholesale after a partial field-move double-resolves the field's &mut prophecy #122).
  2. A captured &mut is required. A closure with no mutable capture (control row 3) is sound — the defect is specifically the loss of the captured &mut's prophecy.

Relationship to #177 (why this is broader)

#177's reproductions all pass the closure by value directly from the field to an HOF (apply(t.0)), so at the FnOnce::call_once terminator arg_closure_place has a non-empty projection and the guard

// src/analyze/basic_block/visitor/rust_call.rs, "case 3"
if arg_closure_place.projection.is_empty() {
    self.analyzer.drop_after_terminator(arg_closure_place.local);
}

is skipped. #177 lists "move through a projection-less local" as a sound control.

In R1/R2/R3 the closure is first moved out of the aggregate into a plain local (g) and then called; the call terminator's arg_closure_place is g (empty projection), so that guard is satisfied — yet the program is still accepted. The prophecy is therefore lost before the call, at the move-out of the aggregate, not at the case-3 call-site handling. The enum/Option variants additionally involve a Downcast extraction that #177 does not mention. So the unsoundness is not confined to the case-3 path #177 identifies.

Mechanism (what is established)

The captured &mut's prophecy obligation is not carried through when a closure value is moved out of an aggregate place (tuple element, struct field, or enum-variant field via Downcast). After the closure is invoked, the mutation performed through the captured &mut is never propagated back to the referent, and Thrust's post-state becomes ⊥ (hence assert!(false) verifies). The projection-less-origin path (closure born in a local) resolves the prophecy correctly, which is why the controls pass. This matches #177's ⊥-collapse symptom but is reached through the aggregate move-out rather than the by-value HOF call site.

Expected behavior

Moving a &mut-capturing closure out of an aggregate and calling it must resolve the captured &mut's prophecy exactly as when the closure is called from a projection-less local: after g() the referent (x) must reflect the closure's write, so R1/R2 report Unsat and R3 (with assert!(false)) reports Unsat.

Why this is unsound (not merely incomplete)

In every reproduction the program panics for its only execution (rustc exit 101), yet Thrust returns safe — a safe verdict for an always-panicking program. R3 makes the vacuous post-state explicit: assert!(false) is discharged.

Distinct from existing issues

Environment

  • thrust @ a148b9d
  • rustc nightly-2025-09-08 (per rust-toolchain.toml)
  • Z3 4.15.4 (the version pinned by .github/actions/setup-z3), default solver configuration; reproduces identically at the default edition and --edition 2021.

Activity

  1. coord-e commented on Sep 1, 2026

    @coord-e
    OwnerAuthor

    Evidence that this is the same defect as #121/#122 rather than a distinct one, in case it helps consolidate the three.

    The closure is not required — only that the moved-out value contains a &mut. Every one of these reproduces the ⊥ collapse on main @ 35eea46 (all report safe, including assert!(false); all panic under rustc -O), and none of them mentions a closure:

    // M1 — Option<&mut T> moved out of a tuple
    fn main() {
        let mut x = 0i64;
        {
            let t = (Some(&mut x), 0i64);
            let o = t.0;
            if let Some(r) = o { *r = 5; }
        }
        assert!(false);            // safe
    }
    
    // M2 — tuple-in-tuple, no enum at all
    fn main() {
        let mut x = 0i64;
        { let t = ((&mut x, 1i64), 0i64); let inner = t.0; *inner.0 = 5; }
        assert!(false);            // safe
    }
    
    // M3 — struct in struct
    struct Inner<'a> { m: &'a mut i64 }
    struct Outer<'a> { i: Inner<'a> }
    impl<'a> thrust_models::Model for Inner<'a> { type Ty = Inner<'a>; }
    impl<'a> thrust_models::Model for Outer<'a> { type Ty = Outer<'a>; }
    fn main() {
        let mut x = 0i64;
        { let o = Outer { i: Inner { m: &mut x } }; let inner = o.i; *inner.m = 5; }
        assert!(false);            // safe
    }
    
    // M4 — across a function boundary, the write performed by a callee
    fn write(o: Option<&mut i64>) { if let Some(r) = o { *r = 5; } }
    fn main() {
        let mut x = 0i64;
        { let t = (Some(&mut x), 0i64); write(t.0); }
        assert!(false);            // safe
    }

    The control that separates these from the sound cases is the one #121 already identifies: moving a bare &mut out of the same aggregate (let t = (&mut x, 0i64); let r = t.0; *r = 5;) is correctly Unsat, because ReborrowVisitor::visit_operand turns Ref-typed operands into reborrows. #121's own text predicts exactly the family above — "the hole is reachable whenever the moved projection's type is a non-reference type that (transitively) owns a mutable borrow" — and a &mut-capturing closure is precisely such a type: its upvar tuple owns the Mut.

    The full-destructure argument does not separate them. This issue rules out #121/#122 on the grounds that a full destructure also triggers the failure, but the plain-data version behaves the same way:

    let t = (Some(&mut x), 0i64);
    let (o, _n) = t;               // full destructure — still `safe` with assert!(false)

    which is what #121's mechanism predicts: at MIR level a "full" destructure is still a sequence of field moves (_o = move (_2.0); _n = copy (_2.1)), each with a non-empty projection, so moved_locals records nothing and the parent is still dropped wholesale.

    A moved_locals-level change fixes R1/R2/R3 here and #177's reproducer. As an experiment I widened moved_locals (src/analyze/basic_block/drop_point.rs) to record the base local of any Operand::Move whose moved place type is not a reference — i.e. exactly the whole-local-vs-projection distinction quoted in #121 and #122, and nothing else:

                 if let mir::Operand::Move(place) = operand {
    -                if place.projection.is_empty() && !self.body.local_decls[place.local].ty.is_ref() {
    -                    self.locals.insert(place.local);
    -                }
    +                let moved_ty = place.ty(&self.body.local_decls, self.tcx).ty;
    +                if !moved_ty.is_ref() {
    +                    self.locals.insert(place.local);
    +                }
                 }

    With that single change, on main @ 35eea46:

    reproducer before after
    R1 (Some(closure) → if let Some(mut g)) safe ❌ Unsat ✔
    R2 (W { f: closure } → let mut g = w.f) safe ❌ Unsat ✔
    R3 (assert!(false)) safe ❌ Unsat ✔
    #177's apply(t.0) safe ❌ Unsat ✔
    #121's ((&mut a,),) reproducer safe ❌ Unsat ✔
    M1–M4 above safe ❌ Unsat ✔

    The patch touches only which locals are excluded from the implicit-drop set, so the ⊥ in all of these comes from an implicit drop of a partially-moved local — #121's mechanism exactly. It is a deliberately crude experiment and not a proposed patch: it suppresses the parent's drop entirely instead of dropping the still-initialised sub-places, and one variant (the full destructure let (o, _n) = t;, where the parent dies at a later statement than the move) is still accepted, which is why #121's "track which projection was moved" direction is the one that matters.

    Two incidental notes:

    Suggest consolidating #177 and #207 into #121 (which PR #155 already targets together with #122), or at least cross-linking them so that a fix is validated against the closure reproducers too.


    Generated by Claude Code

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

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions