Blog

Async Suspension Without Coroutines

How pd-vm yields to the host for I/O, and how fuel and epoch interrupts keep scripts under control


The Problem: I/O in a Single-Threaded VM

pd-vm runs inside a proxy runtime on top of Tokio. Scripts need to trigger I/O, but the VM itself is a synchronous bytecode machine. It does not expose script-level coroutines, green threads, or language-native async.

The design answer is explicit suspension at host-call boundaries.

The Four Outcomes

Every host call resolves to one of four outcomes:

enum CallOutcome {
    Return(Vec<Value>),
    Halt,
    Yield,
    Pending(HostOpId),
}

Return

The call completed synchronously and the interpreter continues.

Halt

The host requests termination and the VM stops.

Yield

The host asks the VM to retry the same call later. The VM rewinds to that call boundary and returns VmStatus::Yielded.

Pending

The host has started an asynchronous operation and returns a HostOpId. The VM records that waiting state, advances past the call, and returns VmStatus::Waiting.

How pd-edge Integrates This With Tokio

The async integration is direct because the VM already exposes a host-operation polling boundary:

loop {
    match vm.run()? {
        VmStatus::Halted => break,
        VmStatus::Yielded => {
            match vm.last_yield_reason() {
                Some(VmYieldReason::Fuel) => {
                    /* recharge fuel if needed */
                }
                Some(VmYieldReason::Epoch) => {
                    /* re-arm deadline */
                }
                Some(VmYieldReason::Host) | None => {
                    /* cooperative reschedule */
                }
            }
        }
        VmStatus::Waiting(_) => {
            vm.await_waiting_host_op().await?;
        }
    }
}

await_waiting_host_op() is an ordinary async boundary. Tokio suspends the task while the host operation is still pending and wakes it when the bridge reports completion.

Why Not Coroutines?

Coroutines would allow suspension from more places, but they would also require a different runtime shape:

pd-vm keeps suspension narrower on purpose. The continuation is just VM state the runtime already owns: instruction pointer, operand stack, locals, and optional pending host-op bookkeeping.

That choice keeps the embedding surface simpler and makes pause, resume, pooling, and debugging more direct.

Fuel: Instruction-Level Budgeting

Fuel limits compute by counting execution progress inside the VM:

vm.set_fuel(10_000);
vm.set_fuel_check_interval(256);

When fuel is exhausted, the VM yields with VmYieldReason::Fuel. The host can then refill, terminate, or log the script.

Fuel is per VM. That matters because it bounds the work of one request independently of others.

Epoch: Wall-Clock Deadlines

Epoch is the wall-clock side of interruption. The host advances a shared epoch counter from outside the VM, and each VM carries its own deadline:

vm.set_epoch_deadline(5)?;
vm.set_epoch_check_interval(512)?;

When the current epoch passes the deadline, the VM yields with VmYieldReason::Epoch.

This gives the runtime time-based interruption without putting clocks or timers inside the VM itself.

Fuel vs Epoch

FuelEpoch
TracksVM compute workHost-defined time slices
SourceBudget stored on the VMShared counter advanced by the host
Use caseCPU budget enforcementRequest timeout enforcement
ScopePer VMShared time source, per-VM deadlines

In practice, pd-edge uses both: fuel limits how much work one script can do, while epoch limits how long it may hold onto the request timeline.

Instruction-Boundary Atomicity

Fuel and epoch checks happen at instruction boundaries. Host-call suspension happens at call boundaries. That means the VM never resumes from a half-executed opcode.

This is the key safety property of the design. Suspension is explicit and structurally aligned with the VM's own execution model.

Where This Falls Short

Summary

pd-vm does not need coroutines to integrate with async I/O. It uses a narrower and more operationally direct mechanism: suspend at host-call boundaries, surface waiting through the VM state machine, and let the embedding runtime drive completion through normal async polling.