Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Rust

Published:

Tags:
  • rust
  • async
  • drop
  • tokio
  • raii
  • resource-management

Rust's Async Drop Doesn't Exist: Why You Can't Await Inside a Destructor

Here's a struct that looks entirely reasonable:

struct Session {
    stream: tokio::net::TcpStream,
}

impl Drop for Session {
    fn drop(&mut self) {
        // send a goodbye frame before the socket closes
        self.stream.write_all(b"BYE\r\n").await; // does not compile
    }
}

The compiler stops you immediately: await is only allowed inside async functions and blocks. Drop::drop is defined as fn drop(&mut self), a perfectly ordinary synchronous method. There is no async fn drop variant to opt into, and there never has been. If you go looking for one, what you'll find instead is a long-running, unresolved design discussion in the async working group about whether such a thing can even exist in a sound form. It isn't a missing convenience method. It's a gap in the language that nobody has found a satisfying way to close.

Why the signature can't just be made async

The obvious fix looks like it should work: make drop return a future, and have the runtime poll it to completion before actually deallocating the value. The problem is that "have the runtime poll it" assumes a runtime is available, willing, and able to do that at the exact point the drop glue runs, and Rust drops values in far too many places to guarantee that.

Consider where drop glue actually executes:

  • At the end of a scope, which might be inside a synchronous function that never touches an executor at all.
  • During panic unwinding, where the stack is being torn down specifically to get out of trouble as fast as possible, not to schedule more work.
  • When a Future is dropped mid-poll, for example because tokio::select! chose a different branch, or a JoinHandle was aborted. The value being dropped is often a local variable inside another future's poll implementation. There is no waker context in which to register interest in a nested future's progress; you are already inside somebody else's poll.

That last case is the one that actually kills the idea. An async fn doesn't run to completion when you call it; it produces a Future that has to be polled, and polling can return Poll::Pending arbitrarily many times before it finishes. If drop returned such a future, something would need to keep polling it, potentially across multiple wake-ups, after the value that owns the resource is already logically gone. Nothing in the ownership model has a slot for "this value is dead but still needs CPU time later". You'd effectively need a second, hidden lifecycle bolted onto every value that implements it, which is a different and much harder feature than the one anyone was asking for.

The workarounds people actually reach for

None of this stops resources needing async cleanup, so the ecosystem has settled on a few well-worn patterns. All of them have sharp edges.

The first is spawning a detached task from inside drop:

impl Drop for Session {
    fn drop(&mut self) {
        let stream = std::mem::replace(&mut self.stream, /* dummy */);
        tokio::spawn(async move {
            let mut stream = stream;
            let _ = stream.write_all(b"BYE\r\n").await;
        });
    }
}

This compiles and often "works", but it quietly gives up two guarantees you probably wanted. First, tokio::spawn requires an active runtime context; call this from a thread with no tokio runtime and it panics on the spot, which is a spectacularly unwelcome thing for a destructor to do. Second, a spawned task is detached: nothing waits for it, so if the process exits, or the runtime shuts down, shortly after the drop runs, the goodbye frame may simply never be sent. You've turned "always runs synchronously" into "runs eventually, maybe, if nothing more urgent happens first".

The second workaround blocks the current thread to force progress:

impl Drop for Session {
    fn drop(&mut self) {
        if let Ok(handle) = tokio::runtime::Handle::try_current() {
            let _ = tokio::task::block_in_place(|| {
                handle.block_on(self.stream.write_all(b"BYE\r\n"))
            });
        }
    }
}

This avoids the "never actually runs" problem but introduces a worse one: calling block_on from inside a runtime worker thread that's already driving other futures can deadlock or panic outright ("Cannot start a runtime from within a runtime"), which is precisely why block_in_place exists to work around it on the multi-threaded scheduler, and precisely why it doesn't help at all on the current-thread scheduler, which has no other thread to hand the remaining work to.

The pattern that actually holds up

The workaround the ecosystem has converged on is to stop pretending Drop can do the job, and instead make the async cleanup an explicit, consuming method that the caller is expected to invoke before the value goes out of scope, with Drop demoted to a safety net that only complains if you forgot:

pub struct Session {
    stream: Option,
}

impl Session {
    pub async fn close(mut self) -> std::io::Result<()> {
        if let Some(mut stream) = self.stream.take() {
            stream.write_all(b"BYE\r\n").await?;
            stream.shutdown().await?;
        }
        Ok(())
    }
}

impl Drop for Session {
    fn drop(&mut self) {
        if self.stream.is_some() {
            tracing::warn!("Session dropped without calling close(); cleanup skipped");
        }
    }
}

This is not a workaround bolted on for this article's example: it's the same shape tokio itself uses. AsyncWriteExt::shutdown exists precisely because dropping an async writer, or a BufWriter wrapping one, does not flush it. TLS streams need shutdown to send a proper close_notify rather than just severing the connection. If you never call it, the data you thought you wrote may simply not have gone anywhere; Drop cannot rescue you at that point, it can only log that you forgot.

The honest way to read this pattern is that Rust never actually got async destructors: it got a convention where the "real" destructor is a method you have to remember to call, and the compiler-invoked Drop::drop is reduced to a diagnostic tool for catching the times you didn't. That's a reasonable trade-off given the constraints, but it does mean the type system quietly stops enforcing the one thing RAII exists to guarantee: that cleanup happens whether or not the caller remembers to ask for it. For synchronous resources that guarantee still holds. For anything that needs a network round trip to close cleanly, it's back to being your problem.