How to test if a tokio task has been aborted without access to the `JoinHandle`?

I'm writing yet another task group implementation (One of these days I'll settle on something), and this time I'm actually bothering with tests. The task group should abort all tasks when dropped, but how do I "remotely" test whether a task has been aborted, particularly when the only JoinHandle was inside the just-dropped task group? Is there some sort of channel-like type where one end, when awaited, blocks indefinitely, and the other end reports whether the blocking has ended? A quick glance at tokio::sync didn't turn up anything that looked useful.

if you cannot share the JoinHandle, you can use wrappers for the futures before spawning them as tasks. then the wrapper can instrument the future however you like, e.g. you can use channels to report the status.

here's an example:

async fn instrument<F: Future>(f: F, reporter: Channel<Status>) -> F::Output {
    let guard = call_on_drop(|| reporter.send(Status::Cancel));
    reporter.send(Status::Run);
    let value = f.await;
    dismiss(guard);
    reporter.send(Status::Done);
    value
}

That tests for dropping, though, not aborting. I already tried a test that uses Arc::strong_count() to test whether a variable in a future was dropped, but the strong count didn't decrease, so either my code isn't working (but I don't see where the problem is) or else tokio doesn't drop aborted tasks immediately.

Isn't tokio's abortion is just dropping instead of polling until completion?

Then why isn't Arc::strong_count() going down in this test?

A oneshot channel which never carries a message will behave this way. If you make the message type uninhabited, then it is impossible for it to do anything else:

enum Nothing {}

let (tx, rx) = tokio::sync::oneshot::channel::<Nothing>();
spawn(async move {
    do_something().await;
    drop(tx);
});

// rx will wait until tx is dropped, and then will always return Err.
let Err(_) = rx.await; 

I would expect that the task is not dropped immediately, but when the scheduler gets around to it — so, perhaps, not until the current task that is executing (the one that dropped the handle) suspends, since #[tokio::test] defaults to single-threaded execution. Certainly if the task is never dropped, that would be a memory leak, and you should assume Tokio (and any other good Rust library) does not default to having memory leaks. (Aborting/cancelling a task is, by definition, dropping the task’s future.)

In fact, making this change to your test makes it pass:

assert_eq!(Arc::strong_count(&token), 2);
drop(nursery_stream);
tokio::task::yield_now().await;
assert_eq!(Arc::strong_count(&token), 1);

Of course, this is not a robust way to write the test, because nothing guarantees that the task will be dropped that soon (it's just likely to happen in a well-behaved scheduler).


There is actually a specific reason why it makes sense to defer task abort until the scheduler can do it: dropping a task future means dropping all its local variables, and those might have non-trivial side effects such as panicking — it's effectively the same as “unwinding” the async function’s “stack frame”. It’s desirable for those side effects to execute at a time and on a thread when it’s expected for that task’s code to be running, rather than inside of whatever code performed the abort, which would make tasks less isolated from each other.

6 Likes