Future cancellation is Drop
; there's no difference. The guarantee which is needed for scoped async to be sound is exactly that the future isn't forgotten; cancellation/drop is perfectly acceptable.
The following is adapted from the linked blog post.
Scoped futures are fundamentally unsound so long as they can be forgotten.
The fundamental difference with scoped threads is that a thread scope blocks when you exit it.
let data = ..;
thread::scope(|s| {
s.spawn(|| worker_thread());
s.spawn(|| { victim(&data); });
// here we block until all scoped threads have finished
});
The same API would actually be sound for async:
task::scope(|s| {
s.spawn(worker_task());
s.spawn(async { victim(&data).await; });
// here we block until all scoped tasks have finished
});
but only if task::scope
is synchronous; this is, in effect, the equivalent of writing
task::block_on(async {
join! {
worker_task(),
async { victim(&data); },
}
});
and hey, this is sound and allowed in all implementations of block_on
! But blocking obviously isn't what we want, but if we return a future from scope
, I can forget it:
let tasks = task::scope(|s| {
s.spawn(worker_task());
s.spawn(async { victim(&data).await; });
// tasks continue running until the scope is awaited
});
// oops, I didn't await the scoped tasks
forget(tasks);
// the scope lifetime is over but the tasks are still running
// and now I can cause all sorts of UAF havock, like just
drop(data);
Well, alright, make it a macro and include .await
in the macro to ensure it gets awaited. Unfortunately, we've only temporarily deferred the issue:
let tasks = Box::pin(async {
task::scope!(|s| {
s.spawn(worker_task());
s.spawn(async { victim(&data).await; });
// tasks are awaited here
});
});
// advance `tasks` far enough to spawn the subtasks
runtime::poll_once(tasks);
// oops I forgot to drive the future to completion
forget(tasks);
// subtasks are still running, lifetime over, mayhem time
drop(data);
The only way in which spawning a scoped task can be sound is if the root future is 'static
(thus leaking it is also leaking any resources internal scoped tasks borrow), or if you somehow require the futures to be polled to completion and/or dropped.
Of course, this doesn't matter all that much, actually, because the real scoped task concurrency is just join!
. If polls are relatively short and don't block, there's not much difference between join!(spawn(a), spawn(b), spawn(c))
and join!(Box::new(a), Box::new(b), Box::new(c))
; that's the entire point of async
: concurrency without spawning.
I'm somewhat tempted to implement a task::scope
-looking interface around FuturesUnordered
to prove a point here. Yeah, it's unfortunate that a single task having an unexpectedly long poll prevents making progress on the others in the same cluster, but that's the ticket price for multiplexing many tasks on one system thread.