Why this, why now

On 2026-10-22 rustc gained a nightly-only flag, -Zasync-panic (rust-lang/rust#164683 by diondokter, reviewed by wesleywiser). The state machines generated for async fn and async blocks contain a branch that panics if the future is polled again after it has completed. With this flag, that branch is replaced by one that returns Poll::Pending. The motivation is binary size in embedded firmware: the MCP (compiler-team#1016) reports roughly a 2% reduction, and up to 5%, on async embedded applications.

The interesting part is less the change itself than the argument around it. At its 2026-10-07 meeting the lang team agreed, as tmandry summarized, that the experiment may go ahead on nightly, but stabilization would need a lang FCP and is unlikely in the current form. The core objection is that returning Pending without registering a wakeup breaks the contract of Poll::Pending. Ralf Jung's reply is that polling after completion already violates poll's precondition, so failing to meet the postcondition (registering a wakeup) is fully within spec. Underneath it all is a design gap in async Rust: a future has no way to say "I'm done."

The problem

The compiler lowers an async fn into a coroutine, a state machine with roughly these states:

Every poll starts with a switch on the state number. The language spec does not say what Returned and Poisoned must do. The Future::poll docs say: "Once a future has completed (returned Ready from poll), calling its poll method again may panic, block forever, or cause other kinds of problems; the Future trait places no requirements on the effects of such a call." Today rustc calls panic_const_async_fn_resumed ("async fn resumed after completion") there, and the corresponding panic for the poisoned state.

Two groups of people are hurt by this.

  1. Embedded developers squeezing binary size. A correct program never takes this branch, but LLVM doesn't know that and can't remove it, and the presence of a panic path makes surrounding optimizations more conservative.
  2. People writing panic-free code, meaning no panic machinery linked into the final artifact at all. rust#147041 (ZhekaS, 2025-09) showed a no_std custom executor where the LLVM IR still contained a call to panic_const_async_fn_resumed. Using a single async function drags in the panic machinery.

How it works

What changes in the generated code

Conceptually, the generated poll looks like this:

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Output> {
    match self.state {
        UNRESUMED => { /* run the body from the start */ }
        SUSPEND_0 => { /* resume after the first .await */ }
        RETURNED  => panic!("`async fn` resumed after completion"),
        POISONED  => panic!("`async fn` resumed after panicking"),
    }
}

With the flag turned off (the PR's "set to false"), the RETURNED and POISONED arms become return Poll::Pending. PR #164683 is a small change: in the coroutine transform in rustc_mir_transform, the spot that inserts the panic block now inserts a block that returns Pending instead. It adds an unstable book page and a test that checks no panic remains when the flag is set.

Why panic = "abort" isn't enough

With panic = "abort" the panic call is still there; it just doesn't unwind. The nightly immediate-abort panic strategy (panic_immediate_abort, which needs build-std) does turn a panic into a single trap instruction, but it applies to every panic in the program. ZhekaS put it this way: business logic needs a chance to handle errors case by case, and aborting immediately isn't graceful failure handling. scottmcm suggested a -Zasync-panic=trap mode (a minimal trap like int3 on x86) as an alternative, and diondokter answered that it wouldn't help people aiming for panic-free code. The project goal update of 2026-09-03 (rust-project-goals#623, "Async statemachine optimisation", funded by Tweede golf) also listed this as the open question: is the flag wanted if immediate-abort reaches the same optimization?