State of art "cancellabe" (`Option<Result<...>>` or `Result<Option<..>>`) handling

This might be the same question as:

just 4 years later.

Again and again in my code I hit a necessity to handle "something that can either get cancelled, error-out, or return a value". Once I have a need to deal with it it becomes rather vast in scope. Eg. I have a large program that needs to handle shutdown event in many parts of its code that are semi-blocking (in logical sense) and need to be able to exit prematurely without returning Err.

I do know .transpose() is here, but it's just a small scale bandaid.

Are there any better options nowadays? Crates? Would rfcs/3058-try-trait-v2.md at master · rust-lang/rfcs · GitHub potentially help?

I guess what I'd like is for ? and From to work smoothly between all variations of a fallible and non-fallible operations etc.

Would it make sense to haved enum CancelableResult { ... } and impl all the interoperability with Option and Result and their nested variants?

Pinging @matklad , since you were seeking answer for it before, so you might have some insight on it today.

What works quite splendidly for rust-analyzer is doing control flow via exceptions :slight_smile: We essentially panic! to cancel, and catch_unwind to recover from cancellation at the top-level. Thus cancellation is not reflected in the type system, but as there's basically a single entry point to cancellable part of the codebase, it creates zero problems. (if you do go that way, use panic::resume_unwind to initiate (sic) cancellation, to avoid invoking the panic handler).

I also since learned about "cancellation is a serendipitous success" framing, and it helps when thinking about this problem (if not actually solving it):

I was thinking that maybe in such situations it might work to just quickly return a neutral value (Default) on cancellation. Not sure how that would work out.

cc @scottmcm, try trait mastermind

1 Like

Can you elaborate on why it's unacceptable for cancellation to be reported as an Err?

For example, you could automatically support any user error type that's Cancelled: Into<E>, and ? would work naturally on it -- even the (unstable until we get a real keyword) yeet Cancelled; would work.

That feels like it'd be the least-impactful way in regards to other code, as that other code that doesn't care would just ?, and there's always if let Err(Cancelled) if something wants to see cancellation explicitly.

1 Like

It did cross my mind, but seems ... hacky?

In my case some of the code would could be randomly included in tests etc. and then having to remember about setting up panic handlers might be a big of a problem.

Not sure about unacceptable, but in the case of cancellation I don't want any part of the code to get confused, start reporting errors in the logs or even .expect. I'd like type system to "explain" developers to handle it.

Hmmm... Do you mean I could type Cancellable<T> = Result<T, Cancelled>;?, so I would e.g. use Cancellable<anyhow::Result<T>>?

The Try trait does let you do this fairly ergonomically imo

Playground

#![feature(try_trait_v2)]

use std::{
    convert::Infallible,
    error::Error,
    ops::{ControlFlow, FromResidual, Try},
};

#[derive(Debug)]
pub enum CancellableResult<T, E, C = ()> {
    Ok(T),
    Err(E),
    Cancelled(C),
}

impl<T, E, C> Try for CancellableResult<T, E, C> {
    type Output = T;

    type Residual = CancellableResult<Infallible, E, C>;

    fn from_output(output: Self::Output) -> Self {
        Self::Ok(output)
    }

    fn branch(self) -> ControlFlow<Self::Residual, Self::Output> {
        use CancellableResult::*;

        match self {
            Ok(ok) => ControlFlow::Continue(ok),
            Err(err) => ControlFlow::Break(Err(err)),
            Cancelled(c) => ControlFlow::Break(Cancelled(c)),
        }
    }
}

impl<T, E, F, C> FromResidual<CancellableResult<Infallible, E, C>> for CancellableResult<T, F, C>
where
    F: From<E>,
{
    fn from_residual(residual: CancellableResult<Infallible, E, C>) -> Self {
        use CancellableResult::*;
        match residual {
            Ok(i) => match i {},
            Err(err) => Err(err.into()),
            Cancelled(c) => Cancelled(c),
        }
    }
}

impl<T, E, F, C> FromResidual<Result<Infallible, E>> for CancellableResult<T, F, C>
where
    F: From<E>,
{
    fn from_residual(residual: Result<Infallible, E>) -> Self {
        match residual {
            Ok(i) => match i {},
            Err(err) => CancellableResult::Err(err.into()),
        }
    }
}

fn always_cancels() -> CancellableResult<String, Infallible> {
    CancellableResult::Cancelled(())
}

fn do_stuff() -> CancellableResult<usize, Box<dyn Error>> {
    let s = always_cancels()?;

    println!("Parsing!");
    CancellableResult::Ok(s.parse()?)
}

fn main() {
    let value = do_stuff();
    println!("{value:?}");
}

1 Like

They way to unsee hackiness is to look at the runtime semantics. For cancellation, you really want to just unwind whatever it is on the stack, and you don't want for intermediate code to care. Stack unwinding is the perfect mechanism here, if you do compile with panic=unwind.

Type-level semantics is yeah, suboptimal. The way this works in rust-analyzer, there's a rather specific cancellation boundary, and everything below it uses unwinding, and everything above it Cancellable<T> (well, the layer itself uses Cancellable, but the caller of that layer is in the anyhow-land, so Cancelled gets anyhowed, and then top-level handler checks if anyhow::Err is benign cancellation, or something to be logged). And, crucially, stuff below the boundary can't cause cancellation, so the tests which use unwinding API can't fail due to cancellation not happening. Where cancellation could happen in tests, we already have typed API.

Yet another horrible idea is to mark everything async, so that you can drop the future.

2 Likes

One problem with this one is that e.g. tokio will "swallow" panic in an async task until its JoinHandle is polled, which is not great for debugging issues (panics in one thread might cause weird behavior in another and so on). Also - there might be things that on cancellation want to do some last moment things, and async drop is not read yet.

That's what brings me here - I'm trying to come up with a general strategy of handling a multi-thread/task program in a way that: does not ignore errors and allows graceful shutdown.

Unplanned stack unwinding (typically on panic) is setting an is_shutting_down: Arc<AtomiBool> and then other parts of the code either use that flag or channel Receiver disconnection as a termination status. This is so that unexpected problem in one thread, makes other ones shut down. This is also used for graceful termination on ctrl+c and SIGINT.

The problem? While most tasks in a process are really a one big high level loop handling events/requests/messages, some task can have and be in some specific semi-blocking inner-loop, nested multiple times, and need to be able to notice shutdown and then get out somehow.

I guess I'm thinking that not even needing to know about it in the type signature has its own niceness too -- everything just ?s the errors as normal, and one of them happens to be that it was cancelled, and great, it works out. With things that particularly care about cancellation being able to look for it specifically just like they'd look for any other specific "didn't produce the Ok value for some reason" case.

Hmm, that makes me think that you could try something like CancelledOr<E>. You might need nightly again to make it work (so you can say that a Cancel type is !Error so the Froms don't overlap), but then you could have Result<T, CancelledOr<anyhow::Error>>.

That would then just need the right Froms, rather than Trys. Not sure whether the GitHub - rust-lang/negative-impls-initiative: Lang team negative impls initiative is likely to stabilize before or after the ?-related traits, though -- looks like semicoleon had an impl that can work.

If you need another example of ? on a three-variant enum, you might check out https://api.rocket.rs/v0.4/rocket/outcome/enum.Outcome.html#impl-Try.

1 Like

Yes, as long as these ? do the right thing by default, it's best option.

I like this one, at least as far as I understand it. But it seems all the nice solutions require unstable features, which is a bit undesirable. :slight_smile:

Thanks for pointing out all these possible approaches.

BTW. It just occurred to me that whatever OrCancellable<T> wrapper or something is used, it should be wrapping either whole Result or OK part of it, as so it can work with functions that are otherwise non-fallible.