Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Async task cancellation #97

Merged
merged 3 commits into from
Sep 16, 2024
Merged

Async task cancellation #97

merged 3 commits into from
Sep 16, 2024

Conversation

jhugman
Copy link
Owner

@jhugman jhugman commented Sep 16, 2024

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.

@jhugman jhugman requested a review from zzorba September 16, 2024 13:00
@jhugman jhugman force-pushed the jhugman/73-74-task-cancellation branch from f257cd0 to 2cd85cb Compare September 16, 2024 13:03
Copy link
Collaborator

@zzorba zzorba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@jhugman jhugman force-pushed the jhugman/73-74-task-cancellation branch from 2cd85cb to 2ffa237 Compare September 16, 2024 18:31
@jhugman jhugman merged commit daec5f9 into main Sep 16, 2024
1 check passed
@jhugman jhugman deleted the jhugman/73-74-task-cancellation branch September 16, 2024 18:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants