Skip to content

Unsound: moving a &mut-capturing closure out of a tuple/struct field into a by-value FnOnce/FnMut verifies panicking programs as safe #177

Description

@coord-e

Summary

When a non-Copy closure that captures a &mut is moved out of a tuple or struct field (a place with a projection) and passed by value to a higher-order function taking FnOnce/FnMut, Thrust produces a contradictory (⊥) post-state. Everything after the call then verifies vacuously — including assert!(false) — so Thrust reports panicking programs as safe.

This is unsound: the program below always panics at runtime, yet Thrust accepts it.

Reproduction (minimal)

fnapply<F:FnOnce()>(f:F){f();}fnmain(){letmut x = 0;let r = &mut x;let t = (|| {*r += 1;},);// &mut-capturing closure, stored in a 1-tupleapply(t.0);// moved out of the tuple field (a projection)assert!(false);// unreachable-for-Thrust, but ALWAYS panics at runtime}
$ THRUST_SOLVER=z3 cargo run -q -- -Adead_code -C debug-assertions=false repro.rs ;echo"exit:$?"exit:0 # Thrust: SAFE
$ rustc --edition 2021 repro.rs -o repro && ./repro ;echo"exit:$?"thread 'main' panicked at repro.rs:7:5:assertion failed: falseexit:101 # actually panics

assert!(false) verifying as safe is the tell-tale of a vacuous (bottom) typing environment: after apply(t.0), Thrust's assumptions are unsatisfiable, so any assertion passes.

A more "natural" version that panics for an ordinary reason (the closure runs, so x == 1, not 2) is also wrongly accepted:

fnapply<F:FnOnce()>(f:F){f();}fnmain(){letmut x = 0;let r = &mut x;let t = (|| {*r += 1;},);apply(t.0);assert!(x == 2);// false at runtime (x == 1); Thrust: SAFE}

What isolates the cause

ProgramThrust verdictCorrect?
let t = (|| { *r += 1; },); apply(t.0); assert!(false);safe✗ (always panics)
let w = W { f: || { *r += 1; } }; apply(w.f); assert!(false); (struct field)safe
apply bound F: FnMut instead of FnOnce, same tuple-field movesafe
Control:let c = || { *r += 1; }; apply(c); assert!(x == 2); (projection-less move)Unsat (rejected)

The only difference between the last two rows is whether the closure is moved out of a field (t.0 / w.f) or through a projection-less local (c). The projection-less path is handled correctly; the field path is not.

Mechanism

src/analyze/basic_block/visitor/rust_call.rs, "case 3" ({closure}&mut {closure}, lines ~118-136):

// case 3: {closure} -> &mut {closure}let borrowed_closure_local = self.insert_mut_borrow(arg_closure_place, arg_closure_ty);
args[0].node = mir::Operand::Move(borrowed_closure_local.into());// FnOnce::call_once consumes the closure, but the resolved function only borrows it:// drop the borrow and the environment after the call to resolve the prophecies of the// captured mutable borrows.self.analyzer.drop_after_terminator(borrowed_closure_local);// The original MIR moves the closure into the call, so `moved_locals` dropped its drop// obligation, expecting the callee to consume it; we must restore it. `moved_locals` only// steals whole-local moves, so we only restore those: with a projection the obligation was// never stolen and the normal drop machinery still handles it (re-adding it would// double-drop). In practice a non-`Copy` closure (the only kind reaching this case) is// always moved through a projection-less temporary.if arg_closure_place.projection.is_empty(){self.analyzer.drop_after_terminator(arg_closure_place.local);}

The comment's closing assumption — "a non-Copy closure … is always moved through a projection-less temporary" — does not hold. When the closure is moved out of a tuple/struct field, arg_closure_place is _t.0 (non-empty projection), so the if is skipped. The captured &mut x's prophecy is then never resolved consistently for the enclosing local, and the post-call environment collapses to ⊥ (which is exactly why assert!(false) passes). The projection-less control case takes the if branch and is sound.

The same faulty path is reached whether the consumer is bound FnOnce or FnMut, and whether the field is a tuple element or a named struct field.

Notes

Environment

  • thrust @ af7cd99 (current main)
  • rustc nightly-2025-09-08 (per rust-toolchain.toml)
  • z3 4.16.0

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