Skip to content

Commit

Permalink
Async task cancellation (#97)
Browse files Browse the repository at this point in the history
According to [The Big O of Code
Reviews](https://www.egorand.dev/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:

```typescript
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:

```typescript
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:

```typscript
await fetchUser(userId);
```

may also be called with an `AbortSignal`.
```typescript
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.

```rust
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.

```typescript
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:

```md
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.
  • Loading branch information
jhugman authored Sep 16, 2024
1 parent 9f380f2 commit daec5f9
Show file tree
Hide file tree
Showing 10 changed files with 320 additions and 71 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,21 @@ const {{ trait_impl }}: { vtable: {{ vtable|ffi_type_name }}; register: () => vo
{%- endif %}
) => {
const uniffiMakeCall = {# space #}
{%- call ts::async(meth) -%}
(): {% call ts::return_type(meth) %} => {
{%- if meth.is_async() %}
async (signal: AbortSignal)
{%- else %}
()
{%- endif %}
: {% call ts::return_type(meth) %} => {
const jsCallback = {{ ffi_converter_name }}.lift(uniffiHandle);
return {% call ts::await(meth) %}jsCallback.{{ meth.name()|fn_name }}(
{%- for arg in meth.arguments() %}
{{ arg|ffi_converter_name(self) }}.lift({{ arg.name()|var_name }}){% if !loop.last %}, {% endif %}
{%- endfor %}
{%- if meth.is_async() -%}
{%- if !meth.arguments().is_empty() %}, {% endif -%}
{ signal }
{%- endif %}
)
}
{%- if !meth.is_async() %}
Expand Down Expand Up @@ -60,7 +68,7 @@ const {{ trait_impl }}: { vtable: {{ vtable|ffi_type_name }}; register: () => vo
/*lowerString:*/ FfiConverterString.lower
)
{%- endmatch %}
{%- else %}
{%- else %} {# // is_async = true #}

const uniffiHandleSuccess = (returnValue: {% call ts::raw_return_type(meth) %}) => {
uniffiFutureCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
/*liftFunc:*/ (_v) => {},
{%- endmatch %}
/*liftString:*/ FfiConverterString.lift,
/*asyncOpts:*/ asyncOpts_,
{%- match callable.throws_type() %}
{%- when Some with (e) %}
/*errorHandler:*/ {{ e|lift_error_fn(self) }}
Expand Down Expand Up @@ -164,6 +165,11 @@
{%- endmatch %}
{%- if !loop.last %}, {% endif -%}
{%- endfor %}
{%- if func.is_async() %}
{%- if !func.arguments().is_empty() %}, {% endif -%}
asyncOpts_?: { signal: AbortSignal }
{%- endif %}

{%- endmacro %}

{#-
Expand Down Expand Up @@ -207,11 +213,20 @@
{%- endif -%}
{%- endmacro %}

{#-
// This macros is almost identical to `arg_list_decl`,
// but is for interface methods, which do not allow
// default values for arguments.
#}
{% macro arg_list_protocol(func) %}
{%- for arg in func.arguments() -%}
{{ arg.name()|var_name }}: {{ arg|type_name(self) -}}
{%- if !loop.last %}, {% endif -%}
{%- endfor %}
{%- if func.is_async() %}
{%- if !func.arguments().is_empty() %}, {% endif -%}
asyncOpts_?: { signal: AbortSignal }
{%- endif %}
{%- endmacro %}

{%- macro async(func) %}
Expand Down
152 changes: 125 additions & 27 deletions fixtures/futures/tests/bindings/test_futures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
uniffiRustFutureHandleCount,
uniffiForeignFutureHandleCount,
} from "uniffi-bindgen-react-native";
import { console } from "@/hermes";
import "@/polyfills";

// Initialize the callbacks for the module.
// This will be hidden in the installation process.
Expand All @@ -49,6 +49,19 @@ function delayPromise(delayMs: number): Promise<void> {
});
}

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);
});
});
}

function checkRemainingFutures(t: Asserts) {
t.assertEqual(
0,
Expand Down Expand Up @@ -282,27 +295,62 @@ function checkRemainingFutures(t: Asserts) {
t.end();
});

class CancellableTsAsyncParser extends TsAsyncParser {
/**
* Each async callback method has an additional optional argument
* `asyncOptions_`. This contains an `AbortSignal`.
*
* If the Rust task is cancelled, then this abort signal is
* told, which can be used to co-operatively cancel the
* async callback.
*
* @param delayMs
* @param asyncOptions_
*/
async delay(
delayMs: number,
asyncOptions_?: { signal: AbortSignal },
): Promise<void> {
await this.doCancellableDelay(delayMs, asyncOptions_?.signal);
}

private async doCancellableDelay(
ms: number,
signal?: AbortSignal,
): Promise<void> {
if (signal) {
await cancellableDelayPromise(ms, signal);
} else {
await delayPromise(ms);
}
this.completedDelays += 1;
}
}

/**
* Skipping this test as it is testing an abort being propogated to Javascript.
* Rust supports task cancellation, but it's not automatic. It is rather like
* Javascript's.
*
* In Javascript, an `AbortController` is used to make an `AbortSignal`.
*
* The task itself periodically checks the `AbortSignal` (or listens for an `abort` event),
* then takes abortive actions. This usually happens when the `AbortController.abort` method
* is called.
*
* Cancellable promises aren't a standard in JS, so there is nothing to cancel.
* In Rust, an `AbortHandle` is analagous to the `AbortController`.
*
* Even then, the single threaded-ness of JS means that this test would rely on client
* code, i.e. `doDelay` checking if the Promise had been cancelled before incrementing
* the `completedDelays` count.
* This test checks if that signal is being triggered by a Rust.
*/
await xasyncTest("cancellation of async JS callbacks", async (t) => {
const traitObj = new TsAsyncParser();
await asyncTest("cancellation of async JS callbacks", async (t) => {
const traitObj = new CancellableTsAsyncParser();

// #JS_TASK_CANCELLATION
const completedDelaysBefore = traitObj.completedDelays;
const promise = cancelDelayUsingTrait(traitObj, 100);
// sleep long enough so that the `delay()` call would finish if it wasn't cancelled.
await delayPromise(1000);
await promise;
// If the task was cancelled, then completedDelays won't have increased
// however, this is cancelling the async callback, which doesn't really make any sense
// in Javascript.
// This method calls into the async callback to sleep (in Javascript) for 100 seconds.
// On a different thread, in Rust, it cancels the task. This sets the `AbortSignal` passed to the
// callback function.
await cancelDelayUsingTrait(traitObj, 10000);
// If the task was cancelled, then completedDelays won't have increased.
t.assertEqual(
traitObj.completedDelays,
completedDelaysBefore,
Expand Down Expand Up @@ -422,6 +470,22 @@ function checkRemainingFutures(t: Asserts) {
t.end();
});

await asyncTest(
"future method… which is cancelled before it starts",
async (t) => {
// The polyfill doesn't support AbortSignal.abort(), so we have
// to make do with making one ourselves.
const abortController = new AbortController();
abortController.abort();

await t.assertThrowsAsync(
(err: any) => err instanceof Error && err.name == "AbortError",
async () => fallibleMe(true, { signal: abortController.signal }),
),
t.end();
},
);

await asyncTest(
"a future that uses a lock and that is not cancelled",
async (t) => {
Expand All @@ -441,28 +505,61 @@ function checkRemainingFutures(t: Asserts) {
},
);

await xasyncTest(
class Counter {
expectedCount = 0;
unexpectedCount = 0;
ok() {
return () => this.expectedCount++;
}
wat() {
return () => this.unexpectedCount++;
}
}

await asyncTest(
"a future that uses a lock and that is cancelled from JS",
async (t) => {
const errors = new Counter();
const success = new Counter();

// Task 1 should hold the resource for 100 seconds.
// We make an abort controller and get the signal from it, and pass it to
// Rust.
// Cancellation is done by dropping the future, so the Rust should be prepared
// for that.
const abortController = new AbortController();
const task1 = useSharedResource(
SharedResourceOptions.create({
releaseAfterMs: 5000,
releaseAfterMs: 100000,
timeoutMs: 100,
}),
);
// #RUST_TASK_CANCELLATION
//
// Again this test is not really applicable for JS, as it has no standard way of
// cancelling a task.
// task1.cancel()

// Try accessing the shared resource again. The initial task should release the shared resource
// before the timeout expires.
{ signal: abortController.signal },
).then(success.wat(), errors.ok());

// Task 2 should try to grab the resource, but timeout after 1 second.
// Unless we abort task 1, then task 1 will hold on, but task 2 will timeout and
// fail.
const task2 = useSharedResource(
SharedResourceOptions.create({ releaseAfterMs: 0, timeoutMs: 1000 }),
).then(success.ok(), errors.wat());

// We wait for 500 ms, then call the abortController.abort().
const delay = delayPromise(500).then(() => abortController.abort());

await Promise.allSettled([task1, task2, delay]);
t.assertEqual(errors.expectedCount, 1, "only task1 should have failed");
t.assertEqual(
success.expectedCount,
1,
"only task2 should have succeeded",
);

await Promise.allSettled([task1, task2]);
t.assertEqual(errors.unexpectedCount, 0, "task2 should not have failed");
t.assertEqual(
success.unexpectedCount,
0,
"task1 should not have succeeded",
);
checkRemainingFutures(t);
t.end();
},
Expand All @@ -478,6 +575,7 @@ function checkRemainingFutures(t: Asserts) {
}),
);

console.info("Expect a timeout here");
await t.assertThrowsAsync(AsyncError.Timeout.instanceOf, async () => {
await useSharedResource(
SharedResourceOptions.create({
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"uniffi-bindgen-react-native": "./bin/cli"
},
"devDependencies": {
"abortcontroller-polyfill": "^1.7.5",
"metro": "^0.80.8",
"metro-core": "^0.80.8",
"prettier": "^3.2.5",
Expand Down
Loading

0 comments on commit daec5f9

Please sign in to comment.