Flow control equivalent to `throw` in other languages

Hello folks,

I have an unconventional question in terms of flow control, and would appreciate any advice that could point me to the right direction.

In short, I like to have something like try catch or throw recover in other languages, and wonder what could be a good way to do it?

But, before you go "that's not what you're supposed to do in Rust", I'm very well aware that's the case, and I'm not a newbie.

It's for a library that there's a legitimate need to exit the running program early, and the caller need to be able to resume based on what the context of the early exit is.

Here's a sample code to help with some reference.

let Some(func) = self.funcs.get(&fn_id) else {
  return Err("foobar");
}

let input = Input::new()

// This need to be able to catch specific type of errors that exit the function call early.
let res = (func.func)(&input);

The func.func here is an anonymous function provided by the users of the library.
Inside that, users could be using other features of the library, that could throw intentionally to exit the function execution.

So Result here is pretty much useless, because that can't stop the program as I need it to.

I've checked catch_unwind but there are a lot of do no recommend, and also state in certain cases, it might not even unwind (e.g. if someone set panic = abort in Cargo, then it's pretty much pointless).

After doing some more searching, it doesn't seem like there are other options available besides that, or I might just be looking at the wrong place entirely.

So any advice would be appreciated.

You want the caller to decide whether to stop the program or not, so Result seems appropriate. The caller can stop the program upon receiving Err.

Let's provide another example then.

caller (within library)

let res = (func.func)(&input, step);

inside func.func (user provided code)

|input: &Input, step: StepTool| {
  let one = step.run("do something", || {
    Ok("hello".to_string)
  })

  step.sleep("wait", "5m");

  Ok("done")
}

In the user provided code, step.run and step.sleep could cause the function to terminate early.

In other words, when it's the first time step.run happens, I need it to not continue to go to step.sleep and terminate the program right there and propagate the control back to the caller.

Using Result won't work, because it will continue, and there are cases I don't want it to.

step.run itself returns a Result, where the user can determine what to do with the result they defined.
The library on the other hand need a separate way to do flow control.

Hope that makes sense.

Maybe this visualization could make more sense.

library code (caller)
                   /|\
--------------------|-------
                    |
user code (func)    |  return control all the way back up
                    |
--------------------|-------
                    |
library code (step) |

how do I do this other that using catch_unwind ?

Actually, it doesn't have to be panic. If there are anything that can allow the return of control skipping a stack in between, then that works too.
I'm just not aware there's anything like that in Rust, and panic is the closest one I can find.

Your library can take a callback which has to return a result type the library itself supplies. One of its variant the user can use to signal to the library to stop.

2 Likes

The library determines when it should stop, not the user.

And if the lib provide an error type, wouldn’t the user need to return the error to stop, and becomes an implementation detail leak?

Well you need to make the case you don't want unrepresentable.

What about asking the user to supply the steps in different closures? Using a builder pattern maybe

Sidenote, I'd be pretty upset by a library deciding when the program aborts while there's no data corruption and without telling its user.

1 Like

The users using us knows exactly why, and that’s not a problem in terms of expectations for the behavior.

We have SDKs in other languages, and this behavior is exactly the same.

A builder pattern is still an implementation detail leak so that’s unfortunately not acceptable.

Result is just a value—nothing more, nothing less. It doesn’t inherently affect the control flow of a program.

However, the question mark operator (?) can be used to check if a Result is an error. If it is, the operator will return that error early. For example, when you write (note the question mark):

fn some_func(...) -> Result<...> {...}

let x = some_func(...)?;

This gets translated to (simplified):

let x = match some_func(...) {
    Ok(value) => value,
    Err(error) => return Err(error),
};

A caller cannot force a called function to behave in a specific way. In the simplest case, the user-supplied function could enter an infinite loop, or call library functions in an arbitrary order. The user could even install a panic handler and alter how it handles a panic from a library function.

Ultimately, the user-supplied function must follow a semantic protocol. In your case, the protocol says that if the user calls a library function and that function signals the user to stop, the user function should respect that signal. You can’t strictly enforce this rule in any common programming language, including Rust.

Not necessarily. If the library uses Result to signal the user function to stop, and the user doesn’t want to return the library’s Result type, they can handle it without using the question mark operator. For example:

fn some_user_function(...) {
    if some_library_function(...).is_err() {
        return whatever_value_you_want;
    }
}

There are many ways to handle this, and Result provides additional functionality to manage error cases. Reading the core::result - Rust and the Result in core::result - Rust is time well spent.

4 Likes

salsa (the incremental query engine rust-analyzer uses) uses unwinding (panic()/catch_unwind()) for this case (db.unwind_if_cancelled()), and e.g. rust-analyzer uses that to cancel a request if the user is no longer interested in the result. See RFC: Opinionated cancelation by nikomatsakis · Pull Request #262 · salsa-rs/salsa · GitHub.

Essentially, if you have a some "subprogram" which you spawn, and you use unwinding to abort it (without recovering from this specific instance of the subprogram), then this is a supported use case for catch_unwind(), which is essentially like abort with subprocess spawning.

This does require the user to set panic = "unwind" (the default), so you need to control it somehow.

4 Likes

Can you articulate why that specific interface is an "implementation detail leak" ?

In the general case I would think that on the contrary the builder pattern/interface allows for a greatly flexible public interface.

Nonetheless, my basic idea is to split your user supplied closures in a way that is more ergonomic for you and your users. You could achieve that any way you want. The best way you'll get help is if you write a playground example.

3 Likes

You could maybe play some games with the async/await machinery to make this happen-- The outermost code would be a custom executor, and the user supplies a future that does whatever they need it to. When the inner library code needs to trigger an abort, it signals the executor to cancel the current task and awaits a future that never becomes Ready.


Edit: If you're in a tokio async environment already, you can do something like this. I suspect that there's a more battle-tested versionÂą available somewhere, but I'm not too familiar with that part of the ecosystem.

pub struct Abort<E>(mpsc::Sender<E>);

impl<E> Abort<E> {
    pub async fn scope<F:Future>(f: impl FnOnce(Self)->F)->Result<F::Output,E>
    {
        let (tx, mut rx) = mpsc::channel(1);
        tokio::select! {
            Some(e) = rx.recv() => Err(e),
            x = f(Abort(tx)) => Ok(x)
        }
    }
    
    pub async fn abort(&self, err:E)->! {
        self.0.send(err).await.expect("Attempted to abort completed task");
        loop { let () = std::future::pending().await; }
    }
}

Âą Maybe something that leverages HRTBs to prevent the Abort instance from escaping its useful scope, for instance. I tried to do that, but couldn't work out the right bounds.

2 Likes

Even with a panic + catch_unwind, the user code, too, can catch the panic just as easily as your code. There are no guarantees here. User code can decide not to terminate in other ways, too, anyways. And the same should apply to “something like try catch or throw recover in other languages”.

If you want to prevent further calls to methods of step: StepTool, then document the API contract properly; often such things need not be 100% strictly enforced, as long as you make it easy not to accidentally misuse the API. Perhaps even feel free to have subsequent calls to methods panic if an Result::Err of a previous invocation wasn’t properly propagated. Or you could consider APIs where step is passed-around by-value [turning a call-site like let one = step.run(|| …); into let (one, step) = step.run(|| …)?;], disallowing further use as soon as a method call errors, though that can make the API less ergonomic.

As a maximally restrictive approach, you could even require the StepTool to be passed back at the end of the closure in the Ok case, which effectively forces propagation of errors. (Though of course, the user can still do arbitrary operations unrelated to the StepTool in-between.) So then the user callback is something like Fn(&Input, StepTool) -> Result<(NormalReturnType, StepTool), StepToolError> and the methods of StepTool, too, have signatures of this style this.[1]


Even when the user code doesn’t use catch_unwind, something that definitely runs (unless you want to start memory leaks) is the destructors of the user code. And these can also do arbitrarily complex operations; furthermore, destructors would be run even in async/Future-based approaches like what @2e71828 just talked about.


  1. The only way to use this kind of API wrong would be by re-entreance, getting hold of multiple StepTools and returning them in the wrong place. One can also prevent such usage, if this is a desirable thing to enforce, by checking identity of the returned StepTool at run-time; or by marking it with an invariant phantom lifetime parameter. ↩︎

9 Likes

Thank you everyone for chiming in. This has been very insightful.

I did some experiments using panic and catch_unwind, and decided to not go with it.
I was already on the fence when attempting this approach since it's going against Rust convention.

But the PoC actually provide more insights that made it an non-option. Mainly because

  1. it doesn't work with Mutex or anything related that works on thread safety
  2. it requires the UnwindSafe and similar traits for it to work when bound with generics

This basically will result in even more unfavorable implementation detail leaks to the users, and on top of that make it very unstable as a control flow due to no guarantees for thread safety.

After that, exploring Result and I might have found a path forward using macros instead.
And having users use the macros extensively and discourage direct usage of APIs like step.run, which should allow me to extend the AST to add additional matches to control the flow better without the user needing to know the details.

There seems to be not a perfect way forward with what I want to do, but that seems good enough for now, and doesn't violate Rust conventions in a gross way.

Again, appreciate all of your inputs.

Not trying to advertise here, though in case folks are curious why I asked such a weird question to begin with.
I'm working on a Rust SDK for Inngest.

This is the GitHub repo.

While macros can be incredibly useful, they can also become cumbersome at times, at least in my opinion.

It's always interesting how a question can lead to solutions that, while functional, may not be the most ideal direction to take.

I’m not very familiar with Inngest, but after a quick look, it seems to be some sort of workflow framework with steps and dependencies between steps.

From what I understand, your issue isn’t that some code throws exceptions and the user code needs to handle them in a specific manner, but rather you're dealing with a problem more like managing a state machine or functions that can be retried until they succeed. That’s a different perspective.

@2e71828 mentioned using async, which is a great fit for problems like this. One key thing about Rust's Future is that it’s passive—it doesn’t do anything until it’s explicitly driven forward. This matches well with a workflow-style system where steps can be checkpointed and resumed, which seems to be what you’re aiming to build.

Here's a small example to demonstrate how this could work with async:

The idea is that user-supplied functions return a Future, so your library can restart the user's code at any time by simply re-calling the function to get a fresh Future. The library drives the Future forward as long as everything is okay. If a stopping condition occurs, the library discards the Future and tries again later with a fresh Future.

The question then becomes how the library knows when to stop the user’s Future? This can be done using a flag that’s set in the API and checked by the run method that drives the Future.

Here's an example structure. A Cell is used to be able to modify the flag from a shared &Api reference.

struct Api {
    should_stop: Cell<bool>,
}

The API is passed to the user’s function:

let mut api = Api::new(...);

api.run(|api| async {
    api.some_api_call().await;
    some_other_code();
})
.await;

Inside some_api_call, when a stop is requested, the flag is set, and the execution of the Future is suspended:

impl Api {
    async fn some_api_call(&self) {
        if user_code_should_stop {
            self.should_stop.set(true);
            let () = pending().await;
            unreachable!()
        }
    }
}

The run method of the API takes a &mut Api to make sure, only one user function can run at a time. run checks whether this flag has been set, and if so, stops driving the Future:

impl Api {
    fn run<'a, F, R>(&'a mut self, mut user_code: F) -> impl Future<Output = Option<R::Output>> + 'a
    where
        F: FnMut(&'a Api) -> R,
        R: Future + 'a,
    {
        let mut future = Box::pin(user_code(self));
        let this = &*self;

        poll_fn(move |cx| match future.as_mut().poll(cx) {
            Poll::Ready(x) => Poll::Ready(Some(x)),
            Poll::Pending => {
                if this.should_stop.get() {
                    Poll::Ready(None)
                } else {
                    Poll::Pending
                }
            }
        })
    }
}

You can check out the full example—it’s just a few dozen lines of code:

1 Like

Thank you for the notes :slight_smile:

It is different approach, where the SDK is stateless and lightweight, and instead the core executor work as a scheduler, and passes in state for the SDK when it runs.
So the state machine handling, and other heavy lifting is already done elsewhere.

So what the SDK really needs to do is just run the user code when the executor instructs it to, and when it hits a step/checkpoint, if it haven't seen it before, run the logic, and report the result back to the executor right away.

Hence my question, because the scope is very limited.
I will support async regardless since the checkpoint itself could take time depending on how users do their thing, and letting it execute async is better to not block the thread.

Still, thanks for the notes. There are some stuff I was gonna look up to check if futures has changed since I last used it, and it's helpful as a reference.

If the user code takes an input you can instead provide that input wrapped in a struct with a private field. Then when the user call any library function you require the wrapper struct (or something derived from it) as an argument. The library code can set a flag in that struct that your top-level code fan analyse on return and decide if to stop the program or go on. The user code will call some method on the struct to extract the real input it wants. Everybody is happy. :wink:

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.