Skip to content

← The Expert Edge: Idiom, Review and Capstones step 7 of 14

Hard End-to-End

Capstone: an async executor with a logical-time reactor

Finish a single-threaded async runtime — a timer future, a Join2 and a Select2 — and return the complete event log it produces.

pub fn runtime(spec: Vec<String>) -> Vec<String>

Async Rust is famous for being hard to learn, and the reason is that people meet it as a framework rather than as a mechanism. It is a mechanism, and it is small: a Future is a state machine with one method, an executor is a loop that calls that method, and a reactor is the thing that decides when calling it again could possibly help. async/.await is syntax that writes the state machine for you. There is no magic left over once you have built one.

There is no real time here and no threads. The reactor holds a now: u64 which only moves when every task is blocked, and then jumps straight to the earliest deadline anyone registered. That makes the whole run deterministic: the log is the same, line for line, every time.

What is given

Reactor, the executor loop run, the spec parser and the task bodies. Read run before you write anything — it is fifteen lines and it is the entire concept:

loop {
    // poll every live task once, in spawn order
    // if none is still alive, stop
    // otherwise advance the clock to the earliest deadline and go again
}

The Context is built from Waker::noop(), which is honest for this design: because the executor polls every task on every round, no task ever needs to be told it became ready. A real executor uses the waker to keep a run-queue of exactly the tasks worth polling — that is the only difference, and it is a performance difference, not a semantic one.

What you must fix — there are four things

1. Timer::poll is not written. On the first poll ever, log t={now} poll {name}. If the clock has reached at, log t={now} ready {name} and return Poll::Ready(name). Otherwise register at with the reactor and return Poll::Pending.

::: question What breaks if poll returns Pending without registering the deadline? The executor asks the reactor for the earliest deadline, gets None because nobody registered anything, and logs stalled. That is a deliberately visible version of the most common bug in real async code: returning Pending without arranging to be woken. In a real runtime there is no stalled message — the task simply never runs again, and you get a hang with no diagnostic at all.

The rule is absolute: before you return Pending, you must have handed someone a way to make progress happen — a registered deadline here, a Waker clone in a real reactor. This is the async equivalent of remembering to release a lock. :::

2. Timer‘s Drop does nothing. A timer that is destroyed before it ever fired must log cancel {name}. A timer that completed must not. This is what makes the Select2 case observable, and it is not a testing gimmick: in Rust, cancelling a future is dropping it, which is a genuinely unusual design. There is no cancel() method and no cancellation token in the language; a future that is dropped simply stops existing, and any cleanup it needs has to be in its destructor. That is why “cancellation safety” is a phrase you will meet in every async library’s documentation.

3. Join2::poll short-circuits. It returns Pending the moment the first child is not ready, so the second child is never even polled until the first finishes — which turns concurrency into sequencing. A join must poll both children on every round. The test for this is the one whose expected log opens with t=0 poll a and t=0 poll b back to back: proof that both were started before either completed.

4. Select2::poll asks the wrong child first. With two timers due at the same instant, the winner is decided by polling order, and the expected output says the first-listed child wins ties. Real select! implementations differ here — some are strictly biased, some randomise to avoid starvation — but they all document it, because a race whose winner is unspecified is a race you cannot test.

Pin, and why Unpin shows up everywhere

Future::poll takes self: Pin<&mut Self>, because an async block that holds a reference across an .await is a struct containing a pointer into itself, and moving it would invalidate that pointer. Pin is the promise not to move.

Your Timer has no self-references, so it is Unpin — the promise costs nothing and Pin::new(&mut timer) is a safe function. That is why every combinator here carries A: Future + Unpin. Delete those bounds and you get E0277: A cannot be unpinned, pointing at self.get_mut(), with the compiler suggesting pin! or Box::pin. Real combinators avoid the bound with pin-projection (the pin-project crate, or a careful unsafe block); requiring Unpin is the honest simplification for a teaching runtime, and Box::pin is what the executor uses to hold the async blocks, which are emphatically not Unpin.

Two lints worth carrying away

clippy::await_holding_refcell_ref. Holding a RefCell guard across an .await is a latent panic: the task suspends with the borrow flag still set, another task borrows the same cell, and borrow_mut panics at runtime. Notice how the given task bodies read the clock — reactor.borrow().now inside a format! argument, where the temporary guard dies at the end of the statement. Never let g = cell.borrow(); something().await;.

clippy::async_yields_async. An async block whose final expression is itself a future almost always means a missing .await. The block resolves to a future that nobody is going to poll, so the work never happens and nothing fails loudly.

The spec language

sleep <name> <t> spawns a timer and logs t={t} done {name} after it fires. join <a> <ta> <b> <tb> joins two timers and logs join a+b. race <a> <ta> <b> <tb> races them and logs race <winner>. Anything unparsable logs bad <line>. The last line is always end t={now}.