Coroutines/generators for stable Rust via code transformation — no
async, no Pin, no allocation, no unsafe code.
The #[diapause::coroutine] attribute rewrites a function into a state
machine enum: the body is analyzed as a control-flow graph and each
yield_! suspension point becomes an enum variant holding the live
variables.
Try it in your browser: the playground shows the expanded code and control-flow graph for any annotated function — no installation required.
use diapause::{Coroutine, CoroutineState};
#[diapause::coroutine(yield = u32, resume = u32)]
fn running_total(n: u32) -> u32 {
let mut sum: u32 = 0;
for i in 0u32..n {
let bonus = yield_!(sum);
sum += i + bonus;
}
sum
}
fn main() {
let mut c = running_total(3);
assert_eq!(c.start(), CoroutineState::Yielded(0));
assert_eq!(c.resume(10), CoroutineState::Yielded(10));
assert_eq!(c.resume(0), CoroutineState::Yielded(11));
assert_eq!(c.resume(5), CoroutineState::Complete(18));
}Calling the annotated function returns the initial state without running
any code. start() runs the body up to the first yield_!; each
resume(value) continues from the previous suspension point with
value as the result of the let x = yield_!(..) binding.
- Control flow:
yield_!works insideif/if let/match/loop/while/while let/for— including edition-2024 let chains (if let Some(v) = opt && v > 0 { yield_!(v); }) — and inside the diverging block oflet ... else, at any nesting depth, mixed withbreak,continue(including labeled forms), earlyreturn, and the?operator onResultandOption. The generatedresumeis a dispatch loop over basic blocks, so join points are never duplicated. - Value-producing control flow: a yield-containing
if/match/loop/ block may initialize aletbinding (let x: T = if c { yield_!(1); a } else { b };— the annotation is required when the value crosses the join) or stand as the function's trailing expression, includingbreakwith a value from such aloop. - Expression-position yield with a pure prefix: a
yield_!inside an expression is hoisted into its ownlet __tmpN = yield_!(..);statement when everything evaluated before it is a path, a literal, or anotheryield_!—f(yield_!(1), yield_!(2), g()),yield_!(1) + 2,x = f(yield_!(1));,x += yield_!(..);, a trailingyield_!(e)(evaluating to the resume value),if f(yield_!(1)) { .. },match g(yield_!(1)) { .. }, andfor x in g(yield_!(1)) { .. }all work. Effectful or panicking code evaluated before the yield would be reordered across the suspension by the hoist, so such positions remain errors (see Constraints). - Resume arguments are ordinary values passed to
resume, typed via the attribute (resume = String), with a separate zero-argumentstart()so no resume value can be silently dropped on the first call. - Delegation:
yield_all!(sub)runs another coroutine to completion — every value it yields is forwarded to the caller, every resume value is forwarded back in, and the expression evaluates to its completion value (Python'syield from). The inner state enum is stored by value inside the outer one — no boxing — soCloneand serde derives compose across arbitrary nesting depth. The operand is a variable holding the coroutine or a direct call of a coroutine function (yield_all!(sub(x))), whose delegate type is derived from the callee.yield_all_resume!(sub, rv)delegates to a coroutine that is already started (e.g. one deserialized mid-run), entering withresume(rv)instead ofstart(). A?applied to the delegation (let v: T = yield_all!(sub)?;) unwraps the completion value and exits early on Err. Theboxmodifier (yield_all!(box sub)) stores the delegate boxed instead, enabling recursive delegation; boxing is lazy — a delegate that completes on entry never allocates — and requires theallocfeature (on by default, the only part of the crate that can allocate). - Generics, where clauses, reference arguments, and
impl Traitarguments are carried over to the generated state enum. Elided lifetimes are named automatically. - Destructuring argument patterns (
fn f((a, b): (u32, u32)), struct patterns,_,ref,@): the value is stored in the state under a fresh name and the pattern is rebound at the top of the body. A component crossing a yield needs an annotated rebind (see Constraints). - Snapshots:
#[derive(Clone)]written under the attribute is moved to the state enum, so a suspended coroutine can be cloned and both copies resumed independently. - Suspended-state persistence: because the state enum stores only
concrete types (a
forloop's iterator is stored as<T as IntoIterator>::IntoIter, not boxed), serde derives work with their ordinary semantics. A suspended coroutine can be serialized, shipped to another process, deserialized, and resumed — something that is fundamentally impossible for async-based generator crates. An opt-infingerprintflag detects a persisted state meeting edited source instead of resuming at a wrong program point. - In-place resume: when every suspension reachable from a resume
point leads back to the same state variant (the typical
loop { yield_!(x); .. }hot path), the generatedresumemutates the stored variables through&mut selfinstead of moving the whole enum out and back — the cost of resuming does not scale with the size of the state, and large buffers held across yields run at handwritten speed (see docs/benchmarks.md). Ineligible shapes fall back to the move-out codegen automatically;in_place = falseopts a coroutine out entirely (see Panic safety). - Panic safety: a panicking coroutine is never left in a state that
is unsafe to touch. On an in-place resume path (see above) a panic
leaves the partially updated
Suspendedstate behind — resuming it is memory-safe but unspecified; everywhere else the state becomesPoisonedand panics on further use.in_place = falserestores the unconditionalPoisonedguarantee. no_stdcompatible: the runtime crate has nostddependencies and works inno_stdenvironments. No allocation, no unsafe code.
yield_all! composes coroutines without giving up any of the state
machinery: a coroutine suspended inside a delegation is still a plain
enum value, holding the inner coroutine's state in place.
use diapause::{Coroutine, CoroutineState};
use serde::{Deserialize, Serialize};
#[diapause::coroutine(yield = u32, resume = u32)]
#[derive(Serialize, Deserialize)]
fn chunk(start: u32) -> u32 {
let a = yield_!(start);
start + a
}
#[diapause::coroutine(yield = u32, resume = u32)]
#[derive(Serialize, Deserialize)]
fn totals(n: u32) -> u32 {
// Delegating to a fresh `chunk`: the delegate's type is derived
// from the callee (`chunk::State`). Equivalently, in two lines:
// `let sub: chunk::State = chunk(n); let first: u32 = yield_all!(sub);`
let first: u32 = yield_all!(chunk(n)); // run a `chunk` to completion
let again = yield_!(first);
first + again
}
fn main() {
let mut c = totals(5);
assert_eq!(c.start(), CoroutineState::Yielded(5)); // chunk's yield
// Suspended inside the delegation: the outer state holds the inner state as
// an ordinary nested value, so persistence works across the nesting.
let json = serde_json::to_string(&c).unwrap();
let mut restored: totals::State = serde_json::from_str(&json).unwrap();
assert_eq!(restored.resume(1), CoroutineState::Yielded(6)); // chunk completes with 5 + 1
assert_eq!(restored.resume(2), CoroutineState::Complete(8));
}use diapause::{Coroutine, CoroutineState};
use serde::{Deserialize, Serialize};
#[diapause::coroutine(yield = u32)]
#[derive(Serialize, Deserialize)]
fn countdown(n: u32) -> u32 {
let mut sum: u32 = 0;
for i in 0u32..n {
yield_!(i);
sum += i;
}
sum
}
fn main() {
let mut c = countdown(3);
assert_eq!(c.start(), CoroutineState::Yielded(0));
// Persist mid-iteration: the state holds the Range cursor and sum.
let json = serde_json::to_string(&c).unwrap();
// Elsewhere, later: restore and resume.
let mut restored: countdown::State = serde_json::from_str(&json).unwrap();
assert_eq!(restored.resume(()), CoroutineState::Yielded(1));
assert_eq!(restored.resume(()), CoroutineState::Yielded(2));
assert_eq!(restored.resume(()), CoroutineState::Complete(3));
}Serde support follows directly from what the state stores:
- Range-based iteration (
for i in 0u32..n) round-trips fully —Rangehas serde impls and the mid-iteration cursor is plain data. - Iterators without serde impls (closures,
mapadapters,vec::IntoIter, …) fail at the derive bound, as they would in any struct. - A coroutine with
impl Traitarguments has an unnameable state type, so it can be serialized but not deserialized. - Variant names (
S1..,B1..) are assigned in yield and block order; editing the coroutine body can renumber them, so persisted states are only compatible with the exact source they were built from. Restoring a state into edited source can succeed structurally and silently resume at the wrong program point — thefingerprintflag below exists to catch exactly this.
Adding fingerprint to the attribute stamps every state with a hash of
the coroutine's source and validates it before resuming:
use diapause::{Coroutine, CoroutineState, Fingerprinted};
use serde::{Deserialize, Serialize};
#[diapause::coroutine(yield = u32, fingerprint)]
#[derive(Serialize, Deserialize)]
fn countdown(n: u32) -> u32 {
let mut sum: u32 = 0;
for i in 0u32..n {
yield_!(i);
sum += i;
}
sum
}
fn main() {
let mut c = countdown(3);
c.start();
let json = serde_json::to_string(&c).unwrap();
let mut restored: countdown::State = serde_json::from_str(&json).unwrap();
// Err(diapause::FingerprintMismatch { .. }) if the state was
// persisted by a different version of `countdown`.
restored.check_fingerprint().unwrap();
assert_eq!(restored.resume(()), CoroutineState::Yielded(1));
}- Every state enum has a
State::FINGERPRINT: u64associated const, flag or not: an FNV-1a hash of the attribute arguments, signature, and body tokens. Editing the coroutine changes it; comments and formatting do not. - The
fingerprintflag additionally stores that hash in every state as a plain__fp: u64field — any derive (serde,Clone, ...) handles it as ordinary data — makesstart()/resume()panic on a mismatch as a last line of defense, and implements thediapause::Fingerprintedtrait, whosefn check_fingerprint(&self) -> Result<(), diapause::FingerprintMismatch>validates gracefully right after deserializing. - Enabling the flag is itself a breaking change for previously
persisted states: they lack the
__fpfield and fail to deserialize. fingerprint = "some-tag"hashes the tag instead of the source: an escape hatch to declare states compatible across an edit you know preserves the state layout (e.g. resuming states persisted before a hot fix).- The hash is computed from the macro's token-stream stringification, which is stable in practice but not formally guaranteed across rustc/proc-macro2 versions. Treat the fingerprint as a best-effort guard against accidental skew, not a versioned format.
The macro works purely syntactically — it never sees rustc's type information — and every state transition is a compile-time rewrite. This shows up as the following rules; each unsupported construct produces a dedicated compile error with the workaround in the message.
yield_!needs a hoistable position:yield_!(expr);,let x = yield_!(expr);, or an expression position where everything evaluated before the yield (in Rust's evaluation order) is a path, a literal, or anotheryield_!— the yield is then hoisted into aletin front of the statement. It cannot follow effectful or panicking code in the same statement (f(g(), yield_!(1)),f() + yield_!(1)— bind the yield with aletfirst), sit in a conditionally evaluated position (c && yield_!(1), match guards,if/matcharms nested inside an expression, closures), in awhilecondition orwhile letscrutinee (re-evaluated every iteration), in a let chain link other than the leading one (if a && let Some(v) = f(yield_!(1))), in method-call arguments (receiver autoderef may run userDerefcode first), inunsafeblocks, or inside other macro invocations. Yield-containing control flow produces a value only as a wholeletinitializer or as the function's trailing expression (see Features); in any other expression position, assign into anOptionin each branch andunwrap()after the join.yield_all!takes a variable or a coroutine call, not an arbitrary expression: the state stores the inner coroutine, so its type must be spellable — either from a variable with a syntactically known type (let sub: chunk::State = chunk(..); ... yield_all!(sub)) or from the callee of a direct call, which is turned into the state type (yield_all!(chunk(n))stores achunk::State,f::<u32>(x)anf::State<u32>;use m::f as g;works too, since the import brings both the function and the module of the same name into scope). A callee that is not a coroutine leavesf::Stateunresolved — a compile error, never a silently wrong program. Two cases still need the two-line form: a generic coroutine whose type parameters the call does not spell (add a turbofish, or annotate the binding), and a coroutine taking a reference, whose state is generic over a lifetime that the outer state cannot elide (let sub: chunk::State<'a> = ..). Anything else — a method call, a call through a qualified path, a computed callee — must be bound first. Its yield and resume types must match the outer coroutine's; mismatches surface as ordinary type errors. The inner coroutine must not have been started yet (start()panics otherwise);yield_all_resume!(sub, rv)enters a started one instead, and takes a variable only — a freshly called coroutine is never started — with a resume value that may be any expression not containingyield_!. Supported positions are the same as for value-producing control flow: a statement (yield_all!(sub);, completion value discarded), a wholeletinitializer, or a trailing expression — of the function body, or of a block,if/elsebranch, or match arm that is itself in one of these positions, recursively (let v: u32 = match x { A => yield_all!(sub), _ => 0 };) — in each case optionally followed by?(let v: T = yield_all!(sub)?;unwraps the completion value, exiting early on Err). The completion binding needs no annotation: its type is derived from the operand as<SubTy as Coroutine<R>>::Return.- Value bindings crossing the join need an annotation: in
let x = if c { yield_!(1); a } else { b };the join is a state variant storingx, so writelet x: T = ...(the usual annotate-the-type error otherwise). - Syntactic types: every variable held across a
yield_!needs a syntactically determinable type: an explicit annotation, a suffixed or unambiguous literal (123u8,true), a move from a known variable, a function argument, or a range with known endpoints (0u32..n). Pattern bindings — match arms,if let,while let,let ... else, destructuringfor, destructuring argument patterns — have nowhere to write a type annotation, so if they cross a yield, rebind first:let v2: Type = v;right after they are bound. - A
let ... elseblock containing ayield_!must still diverge, but the macro cannot make rustc check that across suspension points; a non-diverging block panics at run time when it falls through instead of failing to compile. - Borrows: references are never stored in the state (the state
machine is always
Unpin; there is no self-reference). A direct borrow (let y = &x;/let y = &mut x;) crossing a yield is reconstructed after resume; other reference-holding values crossing a yield are compile errors. Aforloop cannot iterate over a borrow of a local (for x in &local) — iterate by value, or borrow an argument. - Jumps out of suspending loops: a
break/continuetargeting a loop that contains ayield_!works from anywhere in the loop body (a plainif done { break; }after a yield is fine), with two rules:breakwith a value can target such a loop only when the loop is aletinitializer or the function's trailing expression, and a jump from inside a yield-free statement moves the variables of the target state by name, so a binding declared in that same statement that shadows one of them is rejected — rename the inner binding. ?is supported onResultandOptiononly. It desugars to calls on the internalTry/FromResidualtraits (visible in error messages when?is used on other types); implementing them for custom types is not supported.- Visibility: the generated state enum is as public as the function,
so argument and return types must be at least that visible or rustc
reports
E0446(private type in public interface). - In-place resumes access stored variables through references (see
Features): code on such a hot path that moves a stored variable out
and re-initializes it before the next yield (
let old = s;on a non-Copysthat is later re-assigned) can fail withE0507; usemem::take/mem::replace, or opt out within_place = false. - Variables not carried into the next state are dropped at the
transition, which can be earlier than the end of their lexical scope.
On an in-place resume path, values that die on a completion path are
dropped at the state's move-out instead, which can be slightly later
within the same
resumecall.
Crates such as genawaiter
implement generators on stable Rust by driving an async block and
smuggling values through a shared cell. diapause generates the state
machine itself instead:
- no
Pin: the generated states are plain enums and alwaysUnpin; - resume arguments are real arguments, not channel tricks;
- the state enum is an inspectable, nameable type that supports
derived snapshots and serde persistence of suspended coroutines; - the trade-off is that the body must stick to the syntactic rules above, whereas async-based generators accept arbitrary control flow.