Skip to content

← Async From First Principles step 21 of 25

Medium Framework

Cancelling a future by dropping it

An unfinished async function keeps its local state in a future. Dropping that future runs the destructors for the values it still owns. In this exercise, those destructors write to a log so you can see exactly what gets cleaned up.

Fix cancel_log(polls_before_cancel: u32) -> Vec<String> so it returns the complete log, including cleanup entries. The starter takes its snapshot too early. Keep the supplied Guard, body and Yield2 behavior.

What the driver must do

Create and pin the body future, then poll it at most polls_before_cancel times. Stop immediately if a poll returns Ready; do not poll it again. If it has not completed, append "cancelling". Finally, drop the owned future and return the log in its original order.

body performs these steps:

  1. Write "started" and create the guard named inner.
  2. Await a new Yield2, write "resumed", and create the guard named late.
  3. Await another new Yield2, then write "finished".

Each Yield2 returns Pending twice and Ready on its third poll. Completing the first await continues straight into the second await in the same outer poll. The whole body therefore needs five polls, not six.

Poll budget Returned log
0 ['cancelling']
1 or 2 ['started', 'cancelling', 'drop:inner']
3 or 4 ['started', 'resumed', 'cancelling', 'drop:late', 'drop:inner']
5 or more ['started', 'resumed', 'finished', 'drop:late', 'drop:inner']

The table uses single quotes for readability; each entry is a Rust String. All u32 budgets are valid. Even a very large budget stops after five polls.

Cleanup depends on how far the body got

With zero polls, no statement inside body has run, so neither guard exists. Calling an async function still evaluates its arguments and stores them in its future. Dropping an unpolled future also drops those captured arguments.

After one or two polls, only inner exists. After three or four, both guards exist. These named locals are dropped in reverse creation order: late, then inner. At normal completion, both are dropped as the body exits, before the fifth poll returns Ready.

Cleanup does not undo earlier work. The "started" and "resumed" entries remain after cancellation. Likewise, dropping a future does not automatically reverse a file write or a request already sent to another service. Ordinary Drop cleanup is synchronous; it cannot await an asynchronous cleanup step.

Drop the owner before reading the log

The driver, body and guards share an Rc<RefCell<Vec<String>>>. Cloning the vector before dropping a suspended future misses the later destructor entries. That is why the starter fails for budgets 1 through 4, but happens to work for zero polls and for a completed body.

Here is a smaller, complete example of observing cleanup:

use std::cell::Cell;
use std::future::{Future, pending};
use std::rc::Rc;
use std::task::{Context, Waker};

struct Guard(Rc<Cell<bool>>);

impl Drop for Guard {
    fn drop(&mut self) {
        self.0.set(true);
    }
}

fn main() {
    let cleaned = Rc::new(Cell::new(false));
    let flag = Rc::clone(&cleaned);
    let mut future = Box::pin(async move {
        let _guard = Guard(flag);
        pending::<()>().await;
    });
    let mut context = Context::from_waker(Waker::noop());

    assert!(future.as_mut().poll(&mut context).is_pending());
    assert!(!cleaned.get());
    drop(future);
    assert!(cleaned.get());
}

Dropping the Pin<Box<_>> destroys the owned future. Dropping only a Pin<&mut _> obtained from as_mut() would discard a borrowed handle and leave the future alive.

Once the exercise's future has been dropped, Rc::into_inner(log) can take the vector's RefCell without cloning it. This returns Some only when log is the last strong owner; weak handles do not prevent it. That checks ownership of the log, not whether a future object still exists. A completed body has already released its copy of the log.

Where this cancellation model stops

The owner can drop an unpolled future or drop it after a poll returns. It cannot use dropping to interrupt that same future while its poll call is still executing. A long CPU loop or blocking operation inside poll delays the owner's next chance to act, even if other threads are running.

An .await yields control only when the awaited future returns Pending. A ready await continues immediately, and a hand-written future can return Pending without using .await at all. Cancellation tokens and runtime task APIs provide other ways to request cancellation. Dropping a handle to a separately running task does not necessarily stop that task.

This driver uses a no-op waker because Yield2 makes progress on every poll and requests another poll before returning Pending. It is a small teaching driver, not an executor for arbitrary I/O futures. Real executors use wakeups to decide when to poll again.

Finally, std::mem::forget(future) skips destruction of the future's remaining state. It does not undo destructors that already ran. Forgetting a suspended future can leak its resources, so it is not a substitute for cleanup.