cancelable_future 0.3.1 copy "cancelable_future: ^0.3.1" to clipboard
cancelable_future: ^0.3.1 copied to clipboard

Exploring the possibility of adding a cancelable future.

Attention #

Never use this package in production. This code has only academic purpose.

What is it? #

This package shows the possibility of creating a cancelable future in Dart. Yes, it can be done. But you don't have to. Read below.

Background #

There is a frequent task: to cancel execution of Future. But architecture of Future assumes atomicity of the operation and does not assume the ability to cancel code execution from outside. If a developer writes

Future<SomeClass> someMethod() async {
  ...
}

he must be sure that this code will not be arbitrarily interrupted anywhere. If a developer writes

await future;

he must be sure that future will either return the result or throw an exception. This is architecture of Future. It may not be the best option. Maybe it would be better if the cancel method was added to architecture of Future right away. But to add the ability to cancel Future now is to introduce chaos into all the existing code on Dart. In order to add cancel to the code, the developer must agree to it. As if signing a contract. And that possibility exists. It's an asynchronous Stream's generator async*:

Stream<SomeClass> f() async* {
  try {
    yield someResult;
    ...
    yield await someFuture;
    ...
    yield* someStream;
  } finally {
    ..
  }
}

This code can be externally terminated on any yield after calculating its value. (await someFuture will still be an atomic indivisible operation. And even if the operation is canceled before yield this someFuture will execute its code to completion) The code will cancel and not proceed to the next step. There will be no exception thrown. But the finally block will be executed. Under the hood, it looks like this:

Stream<SomeClass> f() async* {
  try {
    yield someResult;
    // 1. value = someResult
    // 2. if (canceled) return
    // 3. send value
    ...
    yield await someFuture;
    // 1. value = await someFuture
    // 2. if (canceled) return
    // 3. send value
    ...
    yield* someStream;
    // 1. subscription = listen someStream
    // 2. if (canceled)
    //      cancel subscription
    //      return
    // 3. wait for someStream to complete
  } finally {
    ..
  }
}

So Dart has everything you need to cancel asynchronous operations. But it so happens that Future is much more clear and convenient to use than Stream. Especially when we are talking about a single result, not a stream of results. For this reason CancelableOperation appears. "An asynchronous operation that can be canceled" - as it is written in the documentation. CancelableOperation is not named CancelableFuture as a matter of principle, so as not to confuse the developer. Future cannot be canceled, while some asynchronous operation as if it could. But in fact no asynchronous operation can be cancelable unless it is implemented within itself.

Future<SomeClass> f() async {
  await ....;
  await ....;
  await ....;
  await ....;
}

You can choose not to wait for code execution to complete and return a value (such as null) or throw an exception before code execution, for example by adding timeout, thus creating the illusion of canceling an asynchronous operation. But the specified code will continue working although nobody will need its result anymore. It will still continue to go to the network, parse JSON, save values to the storage and create other side-effects. And the developer will be surprised by unexpected behavior of his program or the fact that the application slows down for some reason.

The important conclusion from all of this is that Future is atomic and cannot be canceled from the outside. This is a fundamental architectural decision of Dart's team. And no external decision can change that. Some people get used to living with it. Someone switches to async*. Someone keeps dreaming about the appearance of cancelable Future in Dart.

Cancellable Future cannot appear by adding a cancel method. But only if there is some new architectural solution where the developer makes it clear that his code is ready to cancel. For example, it could look like this (note the new keyphrase async cancelable that I invented):

CancelableFuture<SomeClass> f() async cancelable {
  final SomeResource resource1 = ....;
  late final SomeResource resource2;

  await safeAsyncCodeWithoutExceptions;

  try {
    resource2 = ....;
    await someFuture1;
    await someFuture2;
    await someFuture3;
  } catch (e,s) {
    ...
  }
  } finally {
    resource2.dispose();
  }
  resource1.dispose();
}

And in this case, the developer will have to be aware that the code may be interrupted not only at await someFuture1, await someFuture2 or await someFuture3, but also at await safeAsyncCodeWithoutExceptions. And then resource1 will never be disposed, there will be a resource leak. This is a developer's mistake, but by the phrase async cancelable he signed a contract that he is responsible for his mistakes. But if the developer writes normal async code, he didn't sign up for this behavior - his code should definitely complete and resource1 should be disposed of.

Read about the issue of creating a cancelable future here: https://github.com/dart-lang/sdk/issues/1806

Then what does this package do? #

This is a hack that adds a cancelable Future to Dart. With which you can try playing around with a future that has a cancelable Future )

There is no async cancelable contract here, so you can now actually cancel any async code on any await. This is done via the timer creation hook available in Zone. This does not make it possible to cancel await in the same way as it is done for yield. But you can actually abort code execution by canceling all created timers, and call the Future completion handler passed in the then method, which is implicitly called when you write await. You can "complete" Future by passing a value to the onValue handler, or you can "throw" an exception by calling the onError handler. But since we have nowhere to get the ready value of the unknown class T for the generic Future<T>, we only have to "throw" the exception AsyncCancelException. As a result, we can cancel await at any level of asynchronous code nesting, but the original CancelableFuture itself will terminate with an exception when canceled. If an exception is not what you need, you can use the orNull getter or the onCancel method.

Even if you ever decide to use this package in your working project (which I don't agree with, as it breaks await logic), remember that you can only use your own async methods in the your async code. Third-party async methods that you do not control will not know anything about your experiments and will not be able to free the resources they use when you cancel them.

Use this package for academic purposes only!

Usage #

Future<void> someOperation(int i) async {
  try {
    print('operation $i 0%');

    await Future<void>.delayed(const Duration(milliseconds: 100));
    print('operation $i 25%');

    await Future<void>.delayed(const Duration(milliseconds: 100));
    print('operation $i 50%');

    await Future<void>.delayed(const Duration(milliseconds: 100));
    print('operation $i 75%');

    await Future<void>.delayed(const Duration(milliseconds: 100));
    print('operation $i 100%');
  } finally {
    print('operation $i finally');
  }
}

Future<void> someLongOperation() async {
  try {
    print('operation 1');
    await someOperation(1);

    print('operation 2');
    await someOperation(2);

    print('operation 3');
    await someOperation(3);

    print('operation 4');
    await someOperation(4);
  } finally {
    print('operations finally');
  }
}

final f = CancelableFuture(() async {
  try {
    await someLongOperation();

    return 'result';
  } finally {
    print('main finally');
  }
});

final cancelfuture = Future<void>.delayed(
  const Duration(milliseconds: 650),
  () async {
    print('--- cancel ---');
    await f1.cancel();
    print('--- really canceled ---');
  },
);

print('result: ${await f1.orNull}');
print('result: ${await f1.onCancel(() => 'canceled')}');
try {
  print(await f1);
} on AsyncCancelException catch (error, stackTrace) {
  print('exception: [${error.runtimeType}] $error');
  if (printStackTrace) {
    print(Chain.forTrace(stackTrace).terse);
  }
}
await cancelfuture;

It'll be taken out:

operation 1
operation 1 0%
operation 1 25%
operation 1 50%
operation 1 75%
--- cancel ---
operation 1 100%
operation 1 finally
operation 2
operation 2 0%      <- nearest breakpoint
operation 2 finally
operations finally
main finally
result: null
result: canceled
exception: [AsyncCancelException] Async operation canceled
--- really canceled ---

As you can see, cancel doesn't work immediately. Unlike yield, which can be interrupted after calculating value, await can be interrupted only before the code enters the event loop. But not everything that starts with await really gets there. The function tries to execute synchronously as much as possible. Future.value and Future.sync return the result synchronously. Future.microtask is executed outside the event loop. Therefore, to cancel the code after cancel, we have to find the await where we can interrupt the code execution. await cancel() will help you wait for CancelableFuture to really interrupt.

See the /example and /test folders for other examples of usage.

0
likes
130
pub points
21%
popularity

Publisher

verified publisheryet-another.dev

Exploring the possibility of adding a cancelable future.

Repository (GitHub)
View/report issues

Documentation

API reference

License

BSD-3-Clause (LICENSE)

Dependencies

meta

More

Packages that depend on cancelable_future