Mehdi Akiki
Rust Failure Atlas / Language and diagnostics

RFA-073 · Case file with fixtures · Case 45 of 694 · Compiler evidence

E0733: Why Direct Recursion Makes an Async Future Infinitely Sized

Each suspended recursive call becomes state stored inside its parent future. Introduce pointer indirection with a boxed future, or rewrite the traversal iteratively when depth and allocation matter.

Reviewed
Rust
Rust 1.98.1
Targets
all targets
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
Each async call stores the child future as part of its state, so direct recursion creates an infinitely expanding concrete future type without indirection.
First discriminating check
Box the future at the recursive function boundary and verify that each state now stores a fixed-size pointer rather than another inline copy.

A synchronous recursive function has a fixed stack-frame layout. An async recursive function first has to create one concrete value representing every suspended state:

async fn countdown(remaining: u32) {
    if remaining > 0 {
        countdown(remaining - 1).await;
    }
}

Rust 1.98.1 emits E0733: recursion in an async function requires boxing. The failing fixture contains no runtime or executor because the failure happens while the future type is formed.

Expand one generated state

An async fn returns an anonymous future. When it reaches an await that is not ready, the future must retain everything needed to resume. In this example that includes the child future returned by countdown(remaining - 1).

Without indirection, the layout equation looks like:

CountdownFuture = state + CountdownFuture

Expanding the child gives another child forever. No finite size_of::<CountdownFuture>() can satisfy that recursive inline layout.

A synchronous recursive call does not put the child frame inside the type of the parent frame. Runtime stack allocation provides indirection between calls. Async state machines are values, so equivalent indirection must be represented in their types.

Box the recursive future boundary

One explicit repair returns a pinned trait-object future:

use std::future::Future;
use std::pin::Pin;

fn countdown(
    remaining: u32,
) -> Pin<Box<dyn Future<Output = ()>>> {
    Box::pin(async move {
        if remaining > 0 {
            countdown(remaining - 1).await;
        }
    })
}

Each recursive state now stores a fixed-size box pointer. The allocation behind the pointer can contain the next future without making the parent's inline size recursive. Box::pin allocates and pins the future in one operation.

The repaired fixture constructs the recursive future successfully on Rust 1.98.1. The fixture does not claim that construction alone tests an executor; it verifies the compile-time layout repair.

Borrowed input needs a lifetime on the box

If the recursion borrows a tree, the returned object normally includes the borrow:

fn visit<'a>(node: &'a Node)
    -> Pin<Box<dyn Future<Output = ()> + 'a>>

Without + 'a, a trait-object lifetime default may imply a static requirement and produce a separate error. Boxing solves size recursion; it does not make borrowed data owned or static.

Send requirements are also separate. A task-spawning API may need + Send, and every value held across await must then support it.

Boxing every level has a cost

The boxed version can allocate once per recursive call and dynamically dispatch polling. For a shallow control-plane traversal, that can be perfectly reasonable. For deep trees or hot parsing, I consider an explicit work stack:

let mut pending = vec![root];
while let Some(node) = pending.pop() {
    process(node).await;
    pending.extend(node.children().rev());
}

This uses one growable collection, makes traversal order visible, and avoids one future allocation per level. It also changes when siblings are stored and may alter cancellation points, so I test behavior rather than assuming it is mechanically equivalent.

Neither version automatically protects against excessive logical depth. Boxing prevents an infinitely sized type, while a hostile or cyclic input can still create unbounded allocations or work at runtime. I add a depth or visited-node policy when the data is not already guaranteed to be a finite tree.

Cancellation is another difference worth testing. Dropping a boxed recursive future drops the currently constructed chain of child futures. An iterative traversal may keep pending nodes in one collection instead. Cleanup and partial side effects should have an explicit invariant in both forms.

Indirect recursion can hide the cycle

The cycle may pass through two functions:

load_directory -> load_entry -> load_directory

The compiler diagnostic can point at only one await. I draw a call graph containing async edges and find a place where one edge can introduce boxing or become iterative. Boxing every function is unnecessary; one indirection in the recursive type cycle is enough.

A macro can package, not remove, the decision

Helper crates and macros can transform recursive async functions into boxed futures. They reduce syntax, but the allocation, lifetime, Send bound, and cancellation behavior still exist. For a reference resource, I prefer showing the expanded signature before recommending convenience tooling.

The official E0733 page gives the essential size explanation. My practical check is to write the future layout equation. If the anonymous state contains itself inline, introduce one deliberate pointer boundary or replace call-stack recursion with an explicit data structure.