jhugman / uniffi-bindgen-react-native

A uniffi bindings generator for calling Rust from react-native
https://jhugman.github.io/uniffi-bindgen-react-native/
Other
50 stars 5 forks source link

Async task cancellation #97

Closed jhugman closed 2 months ago

jhugman commented 2 months ago

According to The Big O of Code Reviews, this is a O(n) change.

Fixes #73 Fixes #74

Background

Task cancellation is cooperative in both Rust and Javascript. i.e. they rely on cooperation of the task itself to perform cancellation.

In Javascript, there is API for the canceller: the AbortController. The AbortController has an AbortSignal object which can be given to the task.

For example:

function cancellableDelayPromise(
  delayMs: number,
  abortSignal: AbortSignal,
): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, delayMs);
    abortSignal.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(abortSignal.reason);
    });
  });
}

This might be used like so:

const abortController = new AbortController();
setTimeout(() => abortController.abort(), 1000); // Abort the task below after 1 second.
try {
  await cancellableDelayPromise(24 * 60 * 60 * 1000, abortController.signal);
  throw new Error("You're too late! It's 24 hours afterwards");
} catch (e: any) {
  assertTrue(e instanceof Error && e.name === "AbortError");
  console.log("Phew, you didn't wait all that time");
}

Cancelling Rust tasks

Uniffi's machinery provides a cancelFunc. As of v0.28.0, this causes the Future to be dropped. The Rust should be written in such a way as to do any cleanup for the task when this happens.

This cancelFunc can be called indirectly by passing an { signal: AbortSignal; } when calling any async function.

There is no way for uniffi to know which Futures can be cancelled, so all async functions have an optional argument of asyncOpts_?: { signal: AbortSignal; } appended to their argument list.

Thus:

await fetchUser(userId);

may also be called with an AbortSignal.

await fetchUser(userId, { signal });

Since these are optional arguments, it is up to the Typescript caller whether or not to include them.

Cancelling async Javascript callbacks

The futures_util crate provides structures similar to AbortController and AbortSignal. In this example, obj is a JS callback interface.

async fn cancel_delay_using_trait(obj: Arc<dyn AsyncParser>, delay_ms: i32) {
    let (abort_handle, abort_registration) = AbortHandle::new_pair();
    thread::spawn(move || {
        // Simulate a different thread aborting the process
        thread::sleep(Duration::from_millis(1));
        abort_handle.abort();
    });
    let future = Abortable::new(obj.delay(delay_ms), abort_registration);
    assert_eq!(future.await, Err(Aborted));
}

The obj.delay(delay_ms) call translates to a call to a Javascript function.

delay(delayMs: number, asyncOpts_?: { signal: AbortSignal })`

When abort_handle.abort() is called, the Abortable future is dropped. The AbortSignal in Javascript is told to abort when it is being cleaned up, and hasn't yet settled.

Because uniffi can't tell which Javascript callbacks support an AbortSignal, all async functions have an optional argument of asyncOpts_?: { signal: AbortSignal; } appended to their argument list.

Since these are optional arguments, it is up to the Typescript implementer whether or not to include them.

Caveat emptor

Because of the different APIs across languages and the co-operative nature of task cancellation in Rust, there is a diversity of API support for task cancellation across the various backend languages that uniffi supports. This PR brings uniffi-bindgen-react-native to parity with the Mozilla supported languages.

However, the uniffi docs currently suggest more modest support:

We don't directly support cancellation in UniFFI even when the underlying platforms do.
You should build your cancellation in a separate, library specific channel; for example, exposing a `cancel()` method that sets a flag that the library checks periodically.

Cancellation can then be exposed in the API and be mapped to one of the error variants, or None/empty-vec/whatever makes sense.
There's no builtin way to cancel a future, nor to cause/raise a platform native async cancellation error (eg, a swift `CancellationError`).

I would expect this to change over time.