rust-lang / rust-lang/rust-clippy

let _ = captured_var; silently elides capture in move closures and async move blocks

Open
#17,003 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

A-ui
Dominant language
Rust
Stars
13.5k
Forks
2.2k
Avg merge
2d 10h
Merged PRs (30d)
32

Description

Inside a move || closure or async move {} block, a statement of the form let _ = x; where x is a non-Copy variable from the enclosing scope does not cause x to be captured by the closure / future. Reading the code, this looks like "drop the captured value here"; in reality the line is effectively dead code and the variable is dropped wherever its original owner goes out of scope.

This is technically consistent with the wildcard _ pattern not being a binding, but in move contexts it's a sharp footgun: a one-character change (_ vs _keep) is the difference between an RAII guard being properly held by a spawned task and being silently destroyed before the task ever runs.

Minimal reproducer (zero deps, std-only, any edition — verified on 2018 / 2021 / 2024):

struct Tracker(&'static str);
impl Drop for Tracker {
    fn drop(&mut self) {
        println!("    DROP Tracker({})", self.0);
    }
}

fn main() {
    println!("[case 1] move closure: `let _ = t;`");
    let t = Tracker("closure-let-_");
    let f = move || { let _ = t; };
    drop(f);
    println!("  after drop(closure)\n");

    println!("[case 2] async move: `let _ = t;`");
    let t = Tracker("async-let-_");
    let fut = async move { let _ = t; };
    drop(fut);
    println!("  after drop(future)\n");

    println!("[case 3] async move: `drop(t);`");
    let t = Tracker("async-drop");
    let fut = async move { drop(t); };
    drop(fut);
    println!("  after drop(future)\n");

    println!("[case 4] async move: `let _x = t;`");
    let t = Tracker("async-let-_x");
    let fut = async move { let _x = t; };
    drop(fut);
    println!("  after drop(future)\n");

    println!("=== end of main ===");
}

Actual output:

[case 1] move closure: `let _ = t;`
  after drop(closure)

[case 2] async move: `let _ = t;`
  after drop(future)

[case 3] async move: `drop(t);`
    DROP Tracker(async-drop)
  after drop(future)

[case 4] async move: `let _x = t;`
    DROP Tracker(async-let-_x)
  after drop(future)

=== end of main ===
    DROP Tracker(async-let-_)
    DROP Tracker(closure-let-_)

Cases 1 & 2: t survives drop(f) / drop(fut) and dies only at end of main — i.e. the closure / future never captured it.
Cases 3 & 4: t is captured normally; dropping the closure / future drops t.

Why this is bad in practice:

I just spent several hours debugging an event-bus subscription that was being silently un-registered. The original code was roughly:

let _subs = event_system().subscribe(...);  // RAII guard
spawn_task(async move {
    wait_for_something().await;
    spawn_task(async move {
        // keep-alive task: holds _subs until other side closes
        while !rx.is_closed() {
            sleep(Duration::from_secs(5)).await;
        }
        let _ = _subs;                      // <-- the bug
    });
});

The author's intent: "INNER task owns _subs, channel-close triggers its drop." The actual behavior: _subs is never captured anywhere, it dies the moment the outer function returns, and every subsequent event broadcast skips the (already unregistered) subscriber. The bug was invisible at compile time and exhibited as "events go nowhere" several seconds into runtime.

Suggested lint (or extension of let_underscore_drop):
Warn when, inside a move closure or async move block, a statement let _ = x; (or let _ = path::to::x;) refers to a non-Copy variable from the enclosing scope.
Suggested fix: drop(x); if the intent is to drop, or let _x = x; if the intent is to hold.

Workarounds verified to work:

  • drop(x);
  • let _x = x; (or any named binding, even underscore-prefixed)
  • Any real use, e.g. x.method();
  • Pattern let _: Tracker = x; (typed wildcard) also works

Verified on: stable rustc (1.x), edition 2021 / 2024.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reproducing the std-only example across Rust editions 2018, 2021, and 2024, then investigate the existing let_underscore_drop lint. Done means the move-closure and async-move cases involving non-Copy enclosing variables receive an appropriate warning or lint extension, with suitable fixes for dropping or retaining the value.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
tooling
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.