PluginBench
Skill
Review
Audit score 70

async-io-model

tursodatabase/turso

Cooperative async patterns for Turso: IOResult state machines, re-entrancy safety, and CompletionGroup aggregation.

What is async-io-model?

Guide to Turso's explicit state-machine async model using IOResult, Completion, and CompletionGroup instead of Rust async/await. Use these patterns in core when performing I/O to avoid re-entrancy bugs and ensure correct operation across multiple yields.

  • Explains IOResult enum for signaling operation completion or pending I/O
  • Describes Completion and CompletionGroup for tracking and aggregating I/O operations
  • Provides state machine pattern templates for operations that yield
  • Documents re-entrancy pitfalls and safe mutation strategies
  • Includes helper macros (return_if_io!, io_yield_one!) for common patterns
  • References testing tools for validating async behavior under different IO timings

How to install async-io-model

npx skills add https://github.com/tursodatabase/turso --skill async-io-model
Prerequisites
  • Familiarity with Rust enums and pattern matching
  • Understanding of cooperative multitasking vs. async/await
  • Access to Turso codebase (core/types.rs, core/io/completions.rs, core/storage/)
Claude Code
Cursor
Windsurf
Cline

How to use async-io-model

  1. 1.Review the IOResult and Completion types in core/types.rs and core/io/completions.rs
  2. 2.Identify your operation's state transitions and create a state enum
  3. 3.Implement the operation as a loop matching on state variants
  4. 4.Use return_if_io! to propagate IO yields up the call stack
  5. 5.Ensure all shared state mutations occur after yield points, not before
  6. 6.Test with the deterministic simulator (testing/simulator/) to catch re-entrancy bugs

Use cases

Good for
  • Implementing storage operations that may block on page reads or writes
  • Coordinating multiple concurrent I/O operations using CompletionGroup
  • Refactoring blocking code into state machines that yield control
  • Debugging re-entrancy bugs where state mutates unexpectedly across yields
  • Testing async code paths with deterministic simulation and fault injection
Who it's for
  • Turso core developers writing I/O-bound code
  • Engineers maintaining the storage or pager subsystems
  • Contributors implementing new async operations in the database engine

async-io-model FAQ

What's the difference between IOResult::Done and IOResult::IO?

Done(T) means the operation completed and returned a result. IO(IOCompletions) means the operation needs to wait for I/O; the caller must wait for the completion to finish, then call the function again.

Why does state mutation before a yield cause bugs?

If a function yields and is called again after the I/O completes, any mutations made before the yield will execute again on re-entry, causing double-increments, duplicate inserts, or other state corruption.

When should I use CompletionGroup?

Use CompletionGroup when you need to wait for multiple independent I/O operations to all finish before proceeding. It aggregates them into a single Completion you can yield on.

How do I test async code for re-entrancy bugs?

Use Turso's deterministic simulator (testing/simulator/) or concurrent simulator with fault injection to force yields at different points and expose timing-dependent bugs.

Can I nest CompletionGroups?

Yes. You can add a group's built completion to another group, allowing hierarchical aggregation of I/O operations.

Full instructions (SKILL.md)

Source of truth, from tursodatabase/turso.


name: async-io-model description: Explanations of common asynchronous patterns used in tursodb. Involves IOResult, state machines, re-entrancy pitfalls, CompletionGroup. Always use these patterns in core when doing anything IO

Async I/O Model Guide

Turso uses cooperative yielding with explicit state machines instead of Rust async/await.

Core Types

pub enum IOCompletions {
    Single(Completion),
}

#[must_use]
pub enum IOResult<T> {
    Done(T),      // Operation complete, here's the result
    IO(IOCompletions),  // Need I/O, call me again after completions finish
}

Functions returning IOResult must be called repeatedly until Done.

Completion and CompletionGroup

A Completion tracks a single I/O operation:

pub struct Completion { /* ... */ }

impl Completion {
    pub fn finished(&self) -> bool;
    pub fn succeeded(&self) -> bool;
    pub fn get_error(&self) -> Option<CompletionError>;
}

To wait for multiple I/O operations, use CompletionGroup:

let mut group = CompletionGroup::new(|_| {});

// Add individual completions
group.add(&completion1);
group.add(&completion2);

// Build into single completion that finishes when all complete
let combined = group.build();
io_yield_one!(combined);

CompletionGroup features:

  • Aggregates multiple completions into one
  • Calls callback when all complete (or any errors)
  • Can nest groups (add a group's completion to another group)
  • Cancellable via group.cancel()

Helper Macros

return_if_io!

Unwraps IOResult, propagates IO variant up the call stack:

let result = return_if_io!(some_io_operation());
// Only reaches here if operation returned Done

io_yield_one!

Yields a single completion:

io_yield_one!(completion);  // Returns Ok(IOResult::IO(Single(completion)))

State Machine Pattern

Operations that may yield use explicit state enums:

enum MyOperationState {
    Start,
    WaitingForRead { page: PageRef },
    Processing { data: Vec<u8> },
    Done,
}

The function loops, matching on state and transitioning:

fn my_operation(&mut self) -> Result<IOResult<Output>> {
    loop {
        match &mut self.state {
            MyOperationState::Start => {
                let (page, completion) = start_read();
                self.state = MyOperationState::WaitingForRead { page };
                io_yield_one!(completion);
            }
            MyOperationState::WaitingForRead { page } => {
                let data = page.get_contents();
                self.state = MyOperationState::Processing { data: data.to_vec() };
                // No yield, continue loop
            }
            MyOperationState::Processing { data } => {
                let result = process(data);
                self.state = MyOperationState::Done;
                return Ok(IOResult::Done(result));
            }
            MyOperationState::Done => unreachable!(),
        }
    }
}

Re-Entrancy: The Critical Pitfall

State mutations before yield points cause bugs on re-entry.

Wrong

fn bad_example(&mut self) -> Result<IOResult<()>> {
    self.counter += 1;  // Mutates state
    return_if_io!(something_that_might_yield());  // If yields, re-entry will increment again!
    Ok(IOResult::Done(()))
}

If something_that_might_yield() returns IO, caller waits for completion, then calls bad_example() again. counter gets incremented twice (or more).

Correct: Mutate After Yield

fn good_example(&mut self) -> Result<IOResult<()>> {
    return_if_io!(something_that_might_yield());
    self.counter += 1;  // Only reached once, after IO completes
    Ok(IOResult::Done(()))
}

Correct: Use State Machine

enum State { Start, AfterIO }

fn good_example(&mut self) -> Result<IOResult<()>> {
    loop {
        match self.state {
            State::Start => {
                // Don't mutate shared state here
                self.state = State::AfterIO;
                return_if_io!(something_that_might_yield());
            }
            State::AfterIO => {
                self.counter += 1;  // Safe: only entered once
                return Ok(IOResult::Done(()));
            }
        }
    }
}

Common Re-Entrancy Bugs

PatternProblem
vec.push(x); return_if_io!(...)Vec grows on each re-entry
idx += 1; return_if_io!(...)Index advances multiple times
map.insert(k,v); return_if_io!(...)Duplicate inserts or overwrites
flag = true; return_if_io!(...)Usually ok, but check logic

State Enum Design

Encode progress in state variants:

// Good: index is part of state, preserved across yields
enum ProcessState {
    Start,
    ProcessingItem { idx: usize, items: Vec<Item> },
    Done,
}

// Loop advances idx only when transitioning states
ProcessingItem { idx, items } => {
    return_if_io!(process_item(&items[idx]));
    if idx + 1 < items.len() {
        self.state = ProcessingItem { idx: idx + 1, items };
    } else {
        self.state = Done;
    }
}

Turso Implementation

Key files:

  • core/types.rs - IOResult, IOCompletions, return_if_io!, return_and_restore_if_io!
  • core/io/completions.rs - Completion, CompletionGroup
  • core/util.rs - io_yield_one! macro
  • core/state_machine.rs - Generic StateMachine wrapper
  • core/storage/btree.rs - Many state machine examples
  • core/storage/pager.rs - CompletionGroup usage examples

Testing Async Code

Re-entrancy bugs often only manifest under specific IO timing. Use:

  • Deterministic simulation (testing/simulator/)
  • Whopper concurrent DST (testing/concurrent-simulator/)
  • Fault injection to force yields at different points

References

  • docs/manual.md section on I/O