gtk-rs-core icon indicating copy to clipboard operation
gtk-rs-core copied to clipboard

No way to know when a cancelled Future async operation is actually cancelled

Open sdroege opened this issue 6 years ago • 5 comments

https://github.com/rust-lang-nursery/futures-rs/issues/1278#issuecomment-428071710

Problem here is that the callback-based API would give us a Cancelled error at that time, while simply dropping the future allows for no way of signalling that the cancellation has happened. But before cancellation happened, it is impossible to start a new async operation on most objects.

sdroege avatar Oct 09 '18 06:10 sdroege

It can actually be known: once the closure is not used anymore and is destroyed, i.e. at the end of the callback. We could add a method to the gio::GioFuture that allows to cancel an operation and gives back a handle back that can be awaited on.

sdroege avatar Oct 27 '21 13:10 sdroege

I just got curious about this myself when looking into cancellation for futures running via MainContext.spawn()/.spawn_local().

My use case here is a file/folder-view widget. I want to asynchronously enumerate and load files into the view, but if the user navigates to a new folder while the enumeration is still happening, it should cancel the in-progress enumeration. I experimented with holding onto the SourceId and then calling .remove() on it, and that does seem to work? The stuff in the source does indeed stop running, though I can't be 100% sure that the GioFuture that was running inside it actually got dropped (thus causing the Cancellable to trigger cancellation on the GIO operation). (Reading through the TaskSource source, I do think this does work how I think it does, though.)

I guess this is also more about Rust async/await behavior: I hope that when there is a Future that wraps an async function, and that function has another Future that is being .await'ed on, I expect that the inner future gets dropped as well when the outer future gets dropped; I can't imagine how all this would work otherwise. But I also expected the GIO operation to return with gio::IOErrorEnum::Cancelled, but I guess I can see why that doesn't happen (since the outer future is no longer running at all?).

At any rate, I'm not even sure how this would work in my situation. Since I'm using spawn(), I "lose" the GioFuture, so the TaskSource that ends up getting built doesn't even know there's a GIO operation running inside it.

One solution I can think of that doesn't involve API breakage might be to add a .cancellable() function to GioFuture, which returns the underlying gio::Cancellable. Then that could have a new function that returns a Future that waits on the underlying GIO cancellation to finish. (Or, hmm, Cancellable itself could just implement Future?) So any time I call a function that creates a GioFuture, I could grab the Cancellable, connect to its cancelled signal, stash it somewhere, and .await if I need to. The downside here is that the call sites get much more verbose; I would have to do something like:

let future = gio_file.enumerate_children_future(...);
let cancellable = future.cancellable();
cancellable.connect_cancelled(|cancellable| {
    MainContext::default().spawn_local(async move {
        cancellable.await;  // or cancellable.completion().await or whatever
        println!("operation cancelled");
    });
});
match future.await {
   // ...
}

... when it used to be just a simpler match gio_file.enumerate_children_future(...).await { ... }.

(Sorry this is all a bit stream-of-consciousness; in some ways I'm thinking out loud here.)

At any rate, I'd be interested in having a go at implementing whatever seems to make the most sense, given some guidance from y'all.

kelnos avatar Jun 27 '22 05:06 kelnos

The problem here is that you can cancel a GioFuture / remove a SourceId while the future itself is running, too. It's not guaranteed that it is actually finished running at that point but it might continue running for a while.

My plan for GioFutures here is to add a way to await on the future to be finished and to also expose the Cancellable inside it for explicit cancellation, but I didn't get to that yet. A GioFuture is fully done once its callback is called, so that can be used as a waiting point. The callback will be called with gio::IOError::Cancelled if it was cancelled.

sdroege avatar Jun 27 '22 06:06 sdroege

Makes sense. I think in the case where someone manually cancels the GIO operation (using the gio::Cancellable that is still yet to be exposed), there's nothing extra to do, right? The GioFuture can still wait, and then will return the Cancelled error, and that's how the caller knows the GIO operation has finished being cancelled.

But in the case where the GioFuture is dropped, or doesn't get run at all for whatever reason, I think that's a bit trickier to implement. Err... hmm... is this already implemented, actually, just not in the way I'm thinking? Looking at git master, I see there's a CancellableExtManual trait not in the latest stable release, and it includes a .future() method that waits until the cancelled signal is emitted on the Cancellable. That's not quite the same thing as the async callback returning a Cancelled error, but is it maybe good enough?

kelnos avatar Jun 27 '22 07:06 kelnos

Makes sense. I think in the case where someone manually cancels the GIO operation (using the gio::Cancellable that is still yet to be exposed), there's nothing extra to do, right? The GioFuture can still wait, and then will return the Cancelled error, and that's how the caller knows the GIO operation has finished being cancelled.

Yes, that's correct. For convenience we would also provide some kind of JoinHandle though that can be awaited until the operation is cancelled or otherwise finished. That could be just a oneshot channel that is filled at the end of the GIO callback.

But in the case where the GioFuture is dropped, or doesn't get run at all for whatever reason, I think that's a bit trickier to implement.

That's also not really a problem and can be handled the same way as above. The JoinHandle would also continue functioning.


The main problem here is that the async / completion-based nature of GIO operations makes it impossible to borrow references to them. You can't synchronously (!) guarantee that the reference is not used anymore when the future is dropped.

That's the underlying reason why e.g. InputStream::read_future() requires passing an owned buffer (and returns it back to the caller) instead of just allowing to borrow e.g. a &mut [u8].

sdroege avatar Jun 27 '22 08:06 sdroege