Skip to content
todeskingPublic

About

Coroutines for stable Rust via proc-macro state machine transformation — no async, no Pin, no alloc, no unsafe

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

diapause

crates.io docs.rs CI MSRV license

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.

Features

  • Control flow: yield_! works inside if / 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 of let ... else, at any nesting depth, mixed with break, continue (including labeled forms), early return, and the ? operator on Result and Option. The generated resume is 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 a let binding (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, including break with a value from such a loop.
  • Expression-position yield with a pure prefix: a yield_! inside an expression is hoisted into its own let __tmpN = yield_!(..); statement when everything evaluated before it is a path, a literal, or another yield_! — f(yield_!(1), yield_!(2), g()), yield_!(1) + 2, x = f(yield_!(1));, x += yield_!(..);, a trailing yield_!(e) (evaluating to the resume value), if f(yield_!(1)) { .. }, match g(yield_!(1)) { .. }, and for 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-argument start() 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's yield from). The inner state enum is stored by value inside the outer one — no boxing — so Clone and 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 with resume(rv) instead of start(). A ? applied to the delegation (let v: T = yield_all!(sub)?;) unwraps the completion value and exits early on Err. The box modifier (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 the alloc feature (on by default, the only part of the crate that can allocate).
  • Generics, where clauses, reference arguments, and impl Trait arguments 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 for loop'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-in fingerprint flag 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 generated resume mutates the stored variables through &mut self instead 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 = false opts 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 Suspended state behind — resuming it is memory-safe but unspecified; everywhere else the state becomes Poisoned and panics on further use. in_place = false restores the unconditional Poisoned guarantee.
  • no_std compatible: the runtime crate has no std dependencies and works in no_std environments. No allocation, no unsafe code.

Delegating to a sub-coroutine

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));
}

Persisting a suspended coroutine

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 — Range has serde impls and the mid-iteration cursor is plain data.
  • Iterators without serde impls (closures, map adapters, vec::IntoIter, …) fail at the derive bound, as they would in any struct.
  • A coroutine with impl Trait arguments 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 — the fingerprint flag below exists to catch exactly this.

Detecting stale states: fingerprint

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: u64 associated 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 fingerprint flag additionally stores that hash in every state as a plain __fp: u64 field — any derive (serde, Clone, ...) handles it as ordinary data — makes start()/resume() panic on a mismatch as a last line of defense, and implements the diapause::Fingerprinted trait, whose fn 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 __fp field 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.

Constraints

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 another yield_! — the yield is then hoisted into a let in 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 a let first), sit in a conditionally evaluated position (c && yield_!(1), match guards, if/match arms nested inside an expression, closures), in a while condition or while let scrutinee (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 user Deref code first), in unsafe blocks, or inside other macro invocations. Yield-containing control flow produces a value only as a whole let initializer or as the function's trailing expression (see Features); in any other expression position, assign into an Option in each branch and unwrap() 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 a chunk::State, f::<u32>(x) an f::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 leaves f::State unresolved — 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 containing yield_!. Supported positions are the same as for value-producing control flow: a statement (yield_all!(sub);, completion value discarded), a whole let initializer, or a trailing expression — of the function body, or of a block, if/else branch, 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 storing x, so write let 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, destructuring for, 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 ... else block containing a yield_! 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. A for loop 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/continue targeting a loop that contains a yield_! works from anywhere in the loop body (a plain if done { break; } after a yield is fine), with two rules: break with a value can target such a loop only when the loop is a let initializer 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 on Result and Option only. It desugars to calls on the internal Try / FromResidual traits (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-Copy s that is later re-assigned) can fail with E0507; use mem::take / mem::replace, or opt out with in_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 resume call.

Comparison with existing crates

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 always Unpin;
  • 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.

About

Coroutines for stable Rust via proc-macro state machine transformation — no async, no Pin, no alloc, no unsafe

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages