├── dart_test.yaml ├── lib ├── structured_async.dart └── src │ ├── error.dart │ ├── context.dart │ ├── _state.dart │ └── core.dart ├── dartle.dart ├── pubspec.yaml ├── .gitignore ├── .github └── workflows │ ├── publish.yml │ └── dart.yml ├── structured_async.iml ├── analysis_options.yaml ├── CHANGELOG.md ├── example ├── advanced_example.dart ├── structured_async_example.dart └── readme_examples.dart ├── test ├── readme_examples_test.dart ├── group_test.dart └── structured_async_test.dart ├── LICENSE └── README.md /dart_test.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/dart-lang/test/blob/master/pkgs/test/doc/configuration.md 2 | 3 | file_reporters: 4 | json: build/test-results.json -------------------------------------------------------------------------------- /lib/structured_async.dart: -------------------------------------------------------------------------------- 1 | /// Structured asynchronous programming support for Dart. 2 | library structured_async; 3 | 4 | export 'src/core.dart'; 5 | export 'src/error.dart'; 6 | export 'src/context.dart'; 7 | -------------------------------------------------------------------------------- /dartle.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartle/dartle_dart.dart'; 2 | 3 | final dartleDart = DartleDart(); 4 | 5 | void main(List args) { 6 | run(args, tasks: { 7 | ...dartleDart.tasks, 8 | }, defaultTasks: { 9 | dartleDart.build 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: structured_async 2 | description: Structured asynchronous programming in Dart. 3 | version: 0.3.2 4 | homepage: https://github.com/renatoathaydes/structured_async 5 | 6 | environment: 7 | sdk: '>=2.19.0 <4.0.0' 8 | 9 | dev_dependencies: 10 | dartle: ^0.22.2 11 | lints: ^1.0.0 12 | test: ^1.16.0 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub. 2 | .dart_tool/ 3 | .packages 4 | 5 | .dartle_tool/ 6 | 7 | # Conventional directory for build outputs. 8 | build/ 9 | 10 | # Omit committing pubspec.lock for library packages; see 11 | # https://dart.dev/guides/libraries/private-files#pubspeclock. 12 | pubspec.lock 13 | 14 | .idea/ 15 | -------------------------------------------------------------------------------- /lib/src/error.dart: -------------------------------------------------------------------------------- 1 | /// An Exception thrown when an asynchronous computation is attempted after a 2 | /// [CancellableFuture] has been cancelled or completed. 3 | /// 4 | /// Every [Future] and [Timer] running inside a [CancellableFuture]'s 5 | /// [Zone] terminates early when this Exception occurs, and attempting to 6 | /// create new ones causes this Exception to be thrown synchronously. 7 | class FutureCancelled implements Exception { 8 | const FutureCancelled(); 9 | 10 | @override 11 | String toString() { 12 | return 'FutureCancelled'; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to pub.dev 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+*' # tag pattern on pub.dev: 'v' 7 | 8 | # Publish using custom workflow 9 | jobs: 10 | publish: 11 | permissions: 12 | id-token: write # This is required for authentication using OIDC 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: dart-lang/setup-dart@v1 17 | - name: Install dependencies 18 | run: dart pub get 19 | - name: Build 20 | run: dart dartle.dart 21 | - name: Publish 22 | run: dart pub publish -f 23 | -------------------------------------------------------------------------------- /structured_async.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/dart.yml: -------------------------------------------------------------------------------- 1 | name: Project CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | sdk: [2.19.0, stable] 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: dart-lang/setup-dart@v1.0 15 | with: 16 | sdk: ${{ matrix.sdk }} 17 | - name: Install dependencies 18 | run: dart pub get 19 | - name: Build 20 | run: dart dartle.dart 21 | - name: Test Report 22 | uses: dorny/test-reporter@v1 23 | if: success() || failure() 24 | with: 25 | name: structured_async tests 26 | path: build/*.json 27 | reporter: dart-json -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the static analysis results for your project (errors, 2 | # warnings, and lints). 3 | # 4 | # This enables the 'recommended' set of lints from `package:lints`. 5 | # This set helps identify many issues that may lead to problems when running 6 | # or consuming Dart code, and enforces writing Dart using a single, idiomatic 7 | # style and format. 8 | # 9 | # If you want a smaller set of lints you can change this to specify 10 | # 'package:lints/core.yaml'. These are just the most critical lints 11 | # (the recommended set includes the core lints). 12 | # The core lints are also what is used by pub.dev for scoring packages. 13 | 14 | include: package:lints/recommended.yaml 15 | 16 | # Uncomment the following section to specify additional rules. 17 | 18 | # linter: 19 | # rules: 20 | # - camel_case_types 21 | 22 | # analyzer: 23 | # exclude: 24 | # - path/to/excluded/files/** 25 | 26 | # For more information about the core and recommended set of lints, see 27 | # https://dart.dev/go/core-lints 28 | 29 | # For additional information about configuring this file, see 30 | # https://dart.dev/guides/language/analysis-options 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.2 - 2023-05-11 2 | 3 | ### Changed 4 | - Updated dependencies, allow Dart 3. 5 | 6 | ## 0.3.1 - 2022-05-30 7 | 8 | ### Changed 9 | - Descendant `CancellableContext.scheduleOnCancel` callbacks are now called when ancestor is cancelled. 10 | 11 | ## 0.3.0 - 2022-05-29 12 | 13 | ### Added 14 | - New `CancellableContext.cancel` method. 15 | - New `CancellableContext.scheduleOnCompletion` method. 16 | - New `CancellableFuture.stream` factory method. 17 | 18 | ### Changed 19 | - `CancellableFuture.group` factory method signature changed and returns `CancellableFuture`. 20 | - `CancellableFuture` constructors take new parameters `debugName` and `uncaughtErrorHandler`. 21 | - Changed behavior when cancelling `CancellableFuture` to always complete pending `Future`s and `Timer`s. 22 | 23 | ### Removed 24 | - `toList`, `toSet` and `toNothing` accumulators as the `group` method no longer takes accumulators. 25 | 26 | ## 0.2.0 - 2022-05-28 27 | 28 | ### Added 29 | - Also cancel periodicTimers, not just simple timers. 30 | - New `CancellableContext` class and `CancellableFuture.ctx` constructor. 31 | - New `ctx.scheduleOnCancel` function to run callbacks on cancellation. 32 | - Added `currentCancellableContext()` function. 33 | 34 | ## 0.1.0 - 2022-05-27 35 | 36 | - Initial version. 37 | -------------------------------------------------------------------------------- /lib/src/context.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'core.dart' show CancellableFuture; 4 | 5 | /// Context within which a [CancellableFuture] runs. 6 | /// 7 | /// See [CancellableFuture.ctx]. 8 | mixin CancellableContext { 9 | /// Check if the computation within this context has been cancelled. 10 | /// 11 | /// If any [CancellableFuture] or one of its ancestors is cancelled, 12 | /// then every child is also automatically cancelled. 13 | bool isComputationCancelled(); 14 | 15 | /// Schedule a callback to run once the [CancellableFuture] 16 | /// this context belongs to is cancelled. 17 | /// 18 | /// If the [CancellableFuture] completes before being cancelled, this callback 19 | /// is never invoked. 20 | /// 21 | /// The callback is always executed from the root [Zone] because the current 22 | /// [Zone] during a cancellation would be hostile to starting any new 23 | /// asynchronous computations. The [CancellableFuture.cancel] method does not 24 | /// wait for callbacks registered via this method to complete before returning. 25 | void scheduleOnCancel(void Function() onCancelled); 26 | 27 | /// Schedule a callback to run when the [CancellableFuture] is about to 28 | /// complete. 29 | /// 30 | /// The result of the given callback is ignored even if it fails. 31 | /// 32 | /// The callback is always executed from the root [Zone] because the current 33 | /// [Zone] during a cancellation would be hostile to starting any new 34 | /// asynchronous computations. 35 | void scheduleOnCompletion(void Function() onCompletion); 36 | 37 | /// Cancel the [CancellableFuture] this context belongs to. 38 | void cancel(); 39 | } 40 | -------------------------------------------------------------------------------- /example/advanced_example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:math'; 3 | 4 | import 'package:structured_async/structured_async.dart'; 5 | 6 | import 'structured_async_example.dart' show sleep; 7 | 8 | final random = Random(); 9 | 10 | /// This is the action executed by all CancellableFutures. 11 | Future cycle(String group) async { 12 | for (var i = 0; i < 100; i++) { 13 | if (i == random.nextInt(100)) { 14 | throw 'XXXX group $group killing itself XXXXX'; 15 | } 16 | print('Group $group: $i'); 17 | await sleep(const Duration(seconds: 1)); 18 | } 19 | print('Group $group done'); 20 | } 21 | 22 | /// Create a CancellableFuture group with [count] members. 23 | /// All Futures in the group get cancelled together, if one dies, 24 | /// all other members die too. 25 | CancellableFuture createGroup(String prefix, int count) => 26 | CancellableFuture.group( 27 | List.generate(count, (index) => () => cycle('$prefix-${index + 1}'))); 28 | 29 | main() async { 30 | // run in an error Zone so the main process does not die when 31 | // any of the groups die. 32 | await runZonedGuarded(() async { 33 | // group A will have 4 members: A-1, A-2, A-3 and A-4 34 | final groupA = createGroup('A', 4); 35 | 36 | // because Groups B and C are inside the same group, if any of their 37 | // members die, all members of the other group also die! 38 | final groupsBAndC = CancellableFuture.group([ 39 | () => createGroup('B', 2), 40 | () => createGroup('C', 2), 41 | ]); 42 | 43 | // randomly cancel groups from "outside" 44 | final randomKiller = CancellableFuture(() async { 45 | void maybeCancel() { 46 | switch (random.nextInt(100)) { 47 | case 10: 48 | case 20: 49 | print('Cancelling all of group A from outside'); 50 | return groupA.cancel(); 51 | case 30: 52 | case 40: 53 | print('Cancelling parent of groups B and C from outside'); 54 | return groupsBAndC.cancel(); 55 | } 56 | } 57 | 58 | for (var i = 0; i < 100; i++) { 59 | await sleep(const Duration(seconds: 1), maybeCancel); 60 | } 61 | }); 62 | 63 | final waiterA = groupA.then((_) { 64 | print('Group A ended successfully'); 65 | }, onError: (e) { 66 | print('Group A did not finish successfully: $e'); 67 | }); 68 | 69 | final waiterBAndC = groupsBAndC.then((_) { 70 | print('Groups B and C ended successfully'); 71 | }, onError: (e) { 72 | print('Groups B and C did not finish successfully: $e'); 73 | }); 74 | 75 | // when all groups are "done", kill the killer! 76 | await waiterA; 77 | await waiterBAndC; 78 | randomKiller.cancel(); 79 | }, (e, st) { 80 | print(e); 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /example/structured_async_example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:structured_async/structured_async.dart'; 4 | 5 | Future theAnswer() async => 42; 6 | 7 | /// Run with the "cancel" argument to show what happens when 8 | /// a Future gets cancelled. 9 | Future main(List args) async { 10 | final cancel = args.contains('cancel'); 11 | final startTime = now(); 12 | 13 | print('Time | Message\n' 14 | '------+--------'); 15 | 16 | runZoned(() async { 17 | await simpleExample(cancel); 18 | await groupExample(cancel); 19 | }, zoneSpecification: ZoneSpecification(print: (self, parent, zone, line) { 20 | parent.print(zone, '${(now() - startTime).toString().padRight(5)} | $line'); 21 | })); 22 | } 23 | 24 | Future simpleExample(bool cancel) async { 25 | // create a cancellable from any async function 26 | final cancellableAnswer = CancellableFuture(() async { 27 | // the function can only be cancelled at async points, 28 | // so returning a number here, for example, would be impossible 29 | // to cancel! So we call another async function instead. 30 | return await theAnswer(); 31 | }); 32 | 33 | if (cancel) { 34 | cancellableAnswer.cancel(); 35 | } 36 | 37 | try { 38 | print('The answer is ${await cancellableAnswer}'); 39 | } on FutureCancelled { 40 | print('Could not compute the answer!'); 41 | } 42 | 43 | // it is also possible to check if a computation has been cancelled 44 | // explicitly by calling isComputationCancelled()... 45 | // Also notice we can use the factory constructor instead of cancellable(). 46 | final cancelFriendlyTask = CancellableFuture.ctx((ctx) async { 47 | if (ctx.isComputationCancelled()) { 48 | // the caller will get a FutureCancelled Exception as long as they await 49 | // after the task has been cancelled. 50 | return null; 51 | } 52 | return 42; 53 | }); 54 | 55 | if (cancel) { 56 | cancelFriendlyTask.cancel(); 57 | } 58 | 59 | try { 60 | print('The answer is still ${await cancelFriendlyTask}'); 61 | } on FutureCancelled { 62 | print('Still cannot compute the answer!'); 63 | } 64 | } 65 | 66 | Future groupExample(bool cancel) async { 67 | final results = []; 68 | 69 | final group = CancellableFuture.group([ 70 | () async => sleep(Duration(milliseconds: 30), () => 1), 71 | () async => sleep(Duration(milliseconds: 10), () => 2), 72 | () async => sleep(Duration(milliseconds: 20), () => 3), 73 | ], receiver: (int? n) { 74 | // values are received in the order they are emitted! 75 | results.add(n); 76 | }); 77 | 78 | if (cancel) { 79 | // group.cancel(); 80 | } 81 | 82 | try { 83 | await group; 84 | print('Group basic result: $results'); 85 | } on FutureCancelled { 86 | print('Group basic result was cancelled'); 87 | } 88 | 89 | final group2 = CancellableFuture.group([ 90 | () async => print('Started group 2, should print every second, up to 3s.'), 91 | () => sleep(Duration(seconds: 1), () => print('1 second')), 92 | () => sleep(Duration(seconds: 2), () => print('2 seconds')), 93 | () => sleep(Duration(seconds: 3), () => print('3 seconds')), 94 | ]); 95 | 96 | scheduleMicrotask(() async { 97 | if (cancel) { 98 | print('Will cancel group 2 after 1 second, approximately...'); 99 | await sleep(Duration(milliseconds: 1100)); 100 | print('Cancelling!'); 101 | group2.cancel(); 102 | } 103 | }); 104 | 105 | try { 106 | await group2; 107 | print('Done'); 108 | } on FutureCancelled { 109 | print('Group2 interrupted!'); 110 | } 111 | } 112 | 113 | int now() => DateTime.now().millisecondsSinceEpoch; 114 | 115 | /// Sleep for the given duration of time using small "ticks" 116 | /// to check if it's time to "wake up" yet. 117 | /// 118 | /// This is done because it's not possible to cancel a submitted 119 | /// [Future.delayed] call. In the real world, actual async code 120 | /// would be used rather than [Future.delayed] anyway, so this 121 | /// should not be a problem in most applications. 122 | Future sleep(Duration duration, [T Function()? function]) async { 123 | final stopTime = now() + duration.inMilliseconds; 124 | while (now() < stopTime) { 125 | await Future.delayed(const Duration(milliseconds: 50)); 126 | } 127 | return function?.call(); 128 | } 129 | -------------------------------------------------------------------------------- /lib/src/_state.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'context.dart'; 4 | import 'error.dart'; 5 | 6 | /// A symbol that is used as key for accessing the status of a 7 | /// [CancellableFuture]. 8 | const Symbol _stateZoneKey = #structured_async_zone_state; 9 | 10 | class _TimerEntry { 11 | final Timer timer; 12 | final Function() function; 13 | 14 | _TimerEntry(this.timer, this.function); 15 | } 16 | 17 | class StructuredAsyncZoneState with CancellableContext { 18 | bool _isCancelled; 19 | 20 | bool get isCancelled => _isCancelled; 21 | List<_TimerEntry>? _timers; 22 | 23 | List? _cancellables; 24 | 25 | List Function()>? _completions; 26 | 27 | StructuredAsyncZoneState([this._isCancelled = false]) { 28 | nearestCancellableContext()?.scheduleOnCancel(cancel); 29 | } 30 | 31 | @override 32 | bool isComputationCancelled() => _isCancelled || isCurrentZoneCancelled(); 33 | 34 | @override 35 | void scheduleOnCancel(void Function() onCancelled) { 36 | if (_isCancelled) return; 37 | (_cancellables ??= []).add(onCancelled); 38 | } 39 | 40 | @override 41 | void scheduleOnCompletion(void Function() onCompletion) { 42 | if (_isCancelled) return; 43 | (_completions ??= []).add(onCompletion); 44 | } 45 | 46 | Timer remember(Timer timer, Function() function) { 47 | if (_isCancelled) { 48 | timer.cancel(); 49 | throw const FutureCancelled(); 50 | } 51 | // periodicTimers are cancelled "early" because user-code cannot 52 | // block Future completion based on a periodic timer, normally, 53 | // so these timers are normally "fire-and-forget" as opposed to regular 54 | // timers, which the user might be waiting for, so we can't cancel. 55 | var timers = _timers; 56 | if (timers == null) { 57 | _timers = timers = <_TimerEntry>[]; 58 | } 59 | timers.add(_TimerEntry(timer, function)); 60 | if (timers.length % 10 == 0) { 61 | timers.removeWhere((t) => !t.timer.isActive); 62 | } 63 | return timer; 64 | } 65 | 66 | @override 67 | void cancel([bool isCompletion = false]) { 68 | if (isCompletion) { 69 | _completions = _callOnRootZone(_completions); 70 | } else { 71 | // cancellables only run when future is explicitly cancelled... 72 | _cancellables = _callOnRootZone(_cancellables); 73 | } 74 | _timers = _stopTimers(_timers); 75 | _isCancelled = true; 76 | } 77 | 78 | // ignore: prefer_void_to_null 79 | static Null _stopTimers(Iterable<_TimerEntry>? timers) { 80 | if (timers == null) return; 81 | for (final t in timers) { 82 | if (t.timer.isActive) { 83 | t.timer.cancel(); 84 | // wake up the timer function so the caller may continue 85 | scheduleMicrotask(t.function); 86 | } 87 | } 88 | } 89 | 90 | // ignore: prefer_void_to_null 91 | static Null _callOnRootZone(Iterable? functions) { 92 | if (functions != null) { 93 | for (final c in functions) { 94 | Zone.root.run(c); 95 | } 96 | } 97 | } 98 | 99 | @override 100 | String toString() { 101 | var s = StringBuffer('_StructuredAsyncZoneState{'); 102 | _forEachZone((zone) { 103 | final isCancelled = zone[_stateZoneKey]?.isCancelled; 104 | if (isCancelled == null) { 105 | s.write(' x'); 106 | } else { 107 | s.write(' $isCancelled'); 108 | } 109 | return true; 110 | }); 111 | s.write('}'); 112 | return s.toString(); 113 | } 114 | 115 | Map createZoneValues() => {_stateZoneKey: this}; 116 | } 117 | 118 | CancellableContext? nearestCancellableContext() { 119 | CancellableContext? result; 120 | _forEachZone((zone) { 121 | final ctx = zone[_stateZoneKey]; 122 | if (ctx is CancellableContext) { 123 | result = ctx; 124 | return false; 125 | } 126 | return true; 127 | }); 128 | return result; 129 | } 130 | 131 | bool isCurrentZoneCancelled() { 132 | var isCancelled = false; 133 | _forEachZone((zone) { 134 | if (zone[_stateZoneKey]?.isCancelled == true) { 135 | isCancelled = true; 136 | return false; // stop iteration 137 | } 138 | return true; 139 | }); 140 | return isCancelled; 141 | } 142 | 143 | void _forEachZone(bool Function(Zone) action) { 144 | Zone? zone = Zone.current; 145 | while (zone != null) { 146 | if (!action(zone)) break; 147 | zone = zone.parent; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /test/readme_examples_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | import 'dart:isolate'; 4 | 5 | import 'package:test/test.dart'; 6 | 7 | void main() { 8 | group('Can run README examples', () { 9 | test('example 1', () async { 10 | final result = await _run(1, timeout: Duration(seconds: 3)); 11 | expect(await result.firstError, isNull); 12 | expect(result.sysout, 13 | equals(['Tick', 'Tick', 'Tick', 'Stopped', 'Tick', 'Tick', 'Tick'])); 14 | }); 15 | 16 | test('example 2', () async { 17 | final result = await _run(2); 18 | expect(await result.firstError, isNull); 19 | expect( 20 | result.sysout, equals(['Tick', 'Tick', 'Tick', 'Tick', 'Stopped'])); 21 | }); 22 | 23 | test('example 3', () async { 24 | final result = await _run(3); 25 | expect(await result.firstError, contains('not great')); 26 | expect(result.sysout, equals([])); 27 | }); 28 | 29 | test('example 4', () async { 30 | final result = await _run(4); 31 | expect(await result.firstError, isNull); 32 | expect(result.sysout, equals(['ERROR: not great', 'Stopped'])); 33 | }); 34 | 35 | test('example 5', () async { 36 | final result = await _run(5); 37 | expect(await result.firstError, isNull); 38 | expect(result.sysout, 39 | equals(['Tic', 'Tac', 'Tic', 'Tac', 'Future was cancelled'])); 40 | }); 41 | 42 | test('example 6', () async { 43 | final result = await _run(6); 44 | expect(await result.firstError, isNull); 45 | expect(result.sysout, equals(['Result: 30'])); 46 | }); 47 | 48 | test('example 7', () async { 49 | final result = await _run(7); 50 | expect(await result.firstError, isNull); 51 | expect( 52 | result.sysout, 53 | equals([ 54 | 'Result: ${[10, 20]}' 55 | ])); 56 | }); 57 | 58 | test('example 8', () async { 59 | final result = await _run(8, timeout: Duration(seconds: 10)); 60 | expect(await result.firstError, isNull); 61 | expect(result.sysout, equals(['Tick', 'Tick', 'Tick', '10'])); 62 | }); 63 | 64 | test('example 9', () async { 65 | final start = DateTime.now(); 66 | final result = await _run(9); 67 | expect(await result.firstError, isNull); 68 | expect(DateTime.now().difference(start).inMilliseconds, lessThan(1900), 69 | reason: 'Future is cancelled after 1 second and ' 70 | 'should not wait 2 seconds for dormant Future'); 71 | expect(result.sysout, equals(['2 seconds later'])); 72 | }); 73 | 74 | test('example 10', () async { 75 | final start = DateTime.now(); 76 | final result = await _run(10); 77 | expect(await result.firstError, isNull); 78 | expect(DateTime.now().difference(start).inMilliseconds, lessThan(1900), 79 | reason: 'Future is cancelled after 1 second and ' 80 | 'should not wait 2 seconds for dormant Future'); 81 | expect(result.sysout, equals(['Cancelled'])); 82 | }); 83 | 84 | test('example 11', () async { 85 | final result = await _run(11); 86 | expect(await result.firstError, isNull); 87 | final hello = 'Isolate says: hello'; 88 | expect( 89 | result.sysout, 90 | equals([ 91 | hello, 92 | hello, 93 | hello, 94 | 'XXX Task was cancelled now! XXX', 95 | 'CancellableFuture finished', 96 | 'Killing ISO', 97 | 'Done', 98 | ])); 99 | }); 100 | }, retry: 1); 101 | } 102 | 103 | class _RunResult { 104 | final List sysout; 105 | final ReceivePort errors; 106 | 107 | _RunResult(this.sysout, this.errors); 108 | 109 | FutureOr get firstError async { 110 | try { 111 | return await errors.timeout(Duration(milliseconds: 50)).first; 112 | } on TimeoutException { 113 | return null; 114 | } 115 | } 116 | } 117 | 118 | Future<_RunResult> _run(int example, 119 | {Duration timeout = const Duration(seconds: 5)}) async { 120 | final exitPort = ReceivePort(); 121 | final errors = ReceivePort(); 122 | final messages = ReceivePort(); 123 | final sysout = []; 124 | 125 | final iso = await Isolate.spawnUri( 126 | File('example/readme_examples.dart').absolute.uri, 127 | [example.toString()], 128 | messages.sendPort, 129 | onExit: exitPort.sendPort, 130 | onError: errors.sendPort); 131 | 132 | Future.delayed(timeout, iso.kill); 133 | 134 | messages.listen((message) { 135 | sysout.add(message.toString()); 136 | }); 137 | 138 | await exitPort.first; 139 | return _RunResult(sysout, errors); 140 | } 141 | -------------------------------------------------------------------------------- /test/group_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:structured_async/structured_async.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | class FunctionCounter { 7 | int count = 0; 8 | final R Function() delegate; 9 | 10 | FunctionCounter(this.delegate); 11 | 12 | R call() { 13 | count++; 14 | return delegate(); 15 | } 16 | } 17 | 18 | void main() { 19 | Future call10TimesIn100Ms(int Function() f) async { 20 | final i = f(); 21 | for (var i = 0; i < 9; i++) { 22 | await Future.delayed(Duration(milliseconds: 10), f); 23 | } 24 | return i; 25 | } 26 | 27 | group('Group of Cancellable actions', () { 28 | test('can run successfully', () async { 29 | Future do1() async => 1; 30 | Future do2() async => 2; 31 | Future do3() async => 3; 32 | final items = CancellableFuture.stream([do1, do2, do3]); 33 | expect(await items.toList(), equals([1, 2, 3])); 34 | }); 35 | 36 | test('can be cancelled before all complete', () async { 37 | var f1 = FunctionCounter(() => 1); 38 | var f2 = FunctionCounter(() => 2); 39 | var f3 = FunctionCounter(() => 3); 40 | 41 | final future = CancellableFuture.group([ 42 | () => call10TimesIn100Ms(f1), 43 | () => call10TimesIn100Ms(f2), 44 | () => call10TimesIn100Ms(f3), 45 | ]); 46 | 47 | Future.delayed(Duration(milliseconds: 20), future.cancel); 48 | 49 | try { 50 | await future; 51 | fail('Unexpected success after cancelling tasks'); 52 | } on FutureCancelled { 53 | // good 54 | } 55 | 56 | // wait until the functions would've been called too many times 57 | await Future.delayed(Duration(milliseconds: 60)); 58 | 59 | expect(f1.count, allOf(greaterThan(1), lessThan(5))); 60 | expect(f2.count, allOf(greaterThan(1), lessThan(5))); 61 | expect(f3.count, allOf(greaterThan(1), lessThan(5))); 62 | }); 63 | 64 | test('can have sub-groups that get cancelled without affecting others', 65 | () async { 66 | var f1 = FunctionCounter(() => 1); 67 | var f2 = FunctionCounter(() => 2); 68 | // will run in a separate group, to completion 69 | var f3 = FunctionCounter(() => 3); 70 | 71 | var subGroupCancelled = false; 72 | 73 | final values = CancellableFuture.stream([ 74 | () async { 75 | final subGroup = CancellableFuture.group([ 76 | () => call10TimesIn100Ms(f1), 77 | () => call10TimesIn100Ms(f2), 78 | ]); 79 | 80 | Future.delayed(Duration(milliseconds: 20), subGroup.cancel); 81 | 82 | try { 83 | await subGroup; 84 | } on FutureCancelled { 85 | subGroupCancelled = true; 86 | } 87 | // should get here after cancellation 88 | return 10; 89 | }, 90 | () async => 2 * await call10TimesIn100Ms(f3), 91 | ]).toList(); 92 | 93 | expect(await values, equals([10, 6])); 94 | expect(subGroupCancelled, isTrue); 95 | 96 | expect(f1.count, allOf(greaterThan(1), lessThan(5))); 97 | expect(f2.count, allOf(greaterThan(1), lessThan(5))); 98 | expect(f3.count, equals(10)); 99 | }); 100 | 101 | test('starts roughly at the same time', () async { 102 | int now() => DateTime.now().millisecondsSinceEpoch; 103 | 104 | final results = []; 105 | final startTime = now(); 106 | 107 | final future = CancellableFuture.group([ 108 | () async => now(), 109 | () async { 110 | final start = now(); 111 | await Future.delayed(Duration(milliseconds: 100)); 112 | return start; 113 | }, 114 | () async { 115 | final start = now(); 116 | await Future.delayed(Duration(milliseconds: 200)); 117 | return start; 118 | } 119 | ], receiver: results.add); 120 | 121 | await future; 122 | 123 | final endTime = now(); 124 | 125 | expect(results, hasLength(equals(3))); 126 | 127 | // all actions should have started immediately 128 | expect(results[0] - startTime, lessThanOrEqualTo(80), 129 | reason: 'first action took too long to start'); 130 | expect(results[1] - startTime, lessThanOrEqualTo(80), 131 | reason: 'second action took too long to start'); 132 | expect(results[2] - startTime, lessThanOrEqualTo(80), 133 | reason: 'third action took too long to start'); 134 | 135 | // the whole computation needs to take at least 136 | // as much as the longest computation 137 | expect(endTime, greaterThanOrEqualTo(200)); 138 | }); 139 | 140 | test('stop on the first error and propagates that error to the caller', 141 | () async { 142 | var f1 = FunctionCounter(() => 1); 143 | var f3 = FunctionCounter(() => 3); 144 | var counter = 0; 145 | final future = CancellableFuture.group([ 146 | () => call10TimesIn100Ms(f1), 147 | () => call10TimesIn100Ms(() { 148 | counter++; 149 | if (counter == 3) { 150 | throw StateError(''); 151 | } 152 | return counter; 153 | }), 154 | () => call10TimesIn100Ms(f3), 155 | ]); 156 | 157 | try { 158 | await future; 159 | fail('Should not have succeeded'); 160 | } on StateError { 161 | // good 162 | } 163 | 164 | expect(counter, equals(3), reason: 'failing function should run up to 3'); 165 | expect(f1.count, allOf(greaterThan(1), lessThan(5))); 166 | expect(f3.count, allOf(greaterThan(1), lessThan(5))); 167 | }); 168 | 169 | test('where a receiver throws error should stop and report that error', 170 | () async { 171 | final future = CancellableFuture.group([ 172 | () async => 1, 173 | () async => 2, 174 | ], receiver: (n) { 175 | if (n == 2) { 176 | throw StateError('no 2 please'); 177 | } 178 | }); 179 | 180 | expect(future, throwsA(isA())); 181 | }); 182 | }, timeout: Timeout(Duration(seconds: 5))); 183 | } 184 | -------------------------------------------------------------------------------- /example/readme_examples.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:isolate'; 3 | import 'package:structured_async/structured_async.dart'; 4 | 5 | int now() => DateTime.now().millisecondsSinceEpoch; 6 | 7 | void main(List args, [SendPort? testIsolatePort]) { 8 | if (args.isEmpty) { 9 | args = ['1']; 10 | } 11 | final logTime = args.length > 1 && args[1] == 'time'; 12 | final exampleIndex = int.parse(args[0]) - 1; 13 | final startTime = now(); 14 | 15 | if (logTime) { 16 | print('Time | Message\n' 17 | '------+--------'); 18 | } 19 | 20 | runZoned(() { 21 | _run(exampleIndex); 22 | }, zoneSpecification: ZoneSpecification(print: (self, parent, zone, line) { 23 | if (testIsolatePort == null) { 24 | parent.print( 25 | zone, 26 | logTime 27 | ? '${(now() - startTime).toString().padRight(5)} | $line' 28 | : line); 29 | } else { 30 | testIsolatePort.send(line); 31 | } 32 | })); 33 | } 34 | 35 | Future _run(int exampleIndex) async { 36 | final examples = [ 37 | futureNeverStops, 38 | cancellableFutureStopsWhenItReturns, 39 | futureWillNotPropagateThisError, 40 | cancellableFutureDoesPropagateThisError, 41 | explicitCancel, 42 | groupExample, 43 | streamExample, 44 | periodicTimerIsCancelledOnCompletion, 45 | scheduledFutureWillRun, 46 | explicitCheckForCancellation, 47 | stoppingIsolates, 48 | ]; 49 | 50 | if (exampleIndex < examples.length) { 51 | return await examples[exampleIndex](); 52 | } 53 | throw 'Cannot recognize arguments. Give a number from 1 to ${examples.length}.'; 54 | } 55 | 56 | _runForever() async { 57 | while (true) { 58 | print('Tick'); 59 | await Future.delayed(Duration(milliseconds: 500)); 60 | } 61 | } 62 | 63 | Future futureNeverStops() async { 64 | await Future(() { 65 | _runForever(); 66 | return Future.delayed(Duration(milliseconds: 1200)); 67 | }); 68 | print('Stopped'); 69 | } 70 | 71 | Future cancellableFutureStopsWhenItReturns() async { 72 | await CancellableFuture(() { 73 | // <--- this is the only difference! 74 | _runForever(); // no await here 75 | return Future.delayed(Duration(milliseconds: 1200)); 76 | }); 77 | print('Stopped'); 78 | } 79 | 80 | _throw() async { 81 | throw 'not great'; 82 | } 83 | 84 | Future futureWillNotPropagateThisError() async { 85 | try { 86 | await Future(() { 87 | _throw(); 88 | return Future.delayed(Duration(milliseconds: 100)); 89 | }); 90 | } catch (e) { 91 | print('ERROR: $e'); 92 | } finally { 93 | print('Stopped'); 94 | } 95 | } 96 | 97 | Future cancellableFutureDoesPropagateThisError() async { 98 | try { 99 | await CancellableFuture(() { 100 | _throw(); 101 | return Future.delayed(Duration(milliseconds: 100)); 102 | }); 103 | } catch (e) { 104 | print('ERROR: $e'); 105 | } finally { 106 | print('Stopped'); 107 | } 108 | } 109 | 110 | Future explicitCancel() async { 111 | Future printForever(String message) async { 112 | while (true) { 113 | await Future.delayed(Duration(seconds: 1), () => print(message)); 114 | } 115 | } 116 | 117 | final future = CancellableFuture(() async { 118 | printForever('Tic'); // no await 119 | await Future.delayed(Duration(milliseconds: 500)); 120 | await printForever('Tac'); 121 | }); 122 | 123 | // cancel after 2 seconds 124 | Future.delayed(Duration(seconds: 2), future.cancel); 125 | 126 | try { 127 | await future; 128 | } on FutureCancelled { 129 | print('Future was cancelled'); 130 | } 131 | } 132 | 133 | Future groupExample() async { 134 | var result = 0; 135 | final group = CancellableFuture.group([ 136 | () async => 10, 137 | () async => 20, 138 | ], receiver: (int item) => result += item); 139 | await group; 140 | print('Result: $result'); 141 | } 142 | 143 | Future streamExample() async { 144 | final group = CancellableFuture.stream([ 145 | () async => 10, 146 | () async => 20, 147 | ]); 148 | print('Result: ${await group.toList()}'); 149 | } 150 | 151 | Future periodicTimerIsCancelledOnCompletion() async { 152 | final task = CancellableFuture(() async { 153 | // fire and forget a periodic timer 154 | Timer.periodic(Duration(milliseconds: 500), (_) => print('Tick')); 155 | await Future.delayed(Duration(milliseconds: 1200)); 156 | return 10; 157 | }); 158 | print(await task); 159 | } 160 | 161 | Future scheduledFutureWillRun() async { 162 | final task = CancellableFuture(() => 163 | Future.delayed(Duration(seconds: 2), () => print('2 seconds later'))); 164 | await Future.delayed(Duration(seconds: 1), task.cancel); 165 | await task; 166 | } 167 | 168 | Future explicitCheckForCancellation() async { 169 | final task = 170 | CancellableFuture.ctx((ctx) => Future.delayed(Duration(seconds: 2), () { 171 | if (ctx.isComputationCancelled()) return 'Cancelled'; 172 | return '2 seconds later'; 173 | })); 174 | await Future.delayed(Duration(seconds: 1), task.cancel); 175 | print(await task); 176 | } 177 | 178 | Future stoppingIsolates() async { 179 | final task = CancellableFuture.ctx((ctx) async { 180 | final responsePort = ReceivePort()..listen(print); 181 | 182 | final iso = await Isolate.spawn((message) async { 183 | for (var i = 0; i < 10; i++) { 184 | await Future.delayed(Duration(milliseconds: 500), 185 | () => message.send('Isolate says: hello')); 186 | } 187 | message.send('Isolate finished'); 188 | }, responsePort.sendPort); 189 | 190 | Zone zone = Zone.current; 191 | // this runs in the root Zone 192 | ctx.scheduleOnCompletion(() { 193 | // ensure Isolate is terminated on completion 194 | zone.print('Killing ISO'); 195 | responsePort.close(); 196 | iso.kill(); 197 | }); 198 | 199 | // let this Future continue to run for a few seconds by 200 | // pretending to do some work 201 | for (var i = 0; i < 20; i++) { 202 | try { 203 | await Future.delayed(Duration(milliseconds: 200)); 204 | } on FutureCancelled { 205 | break; 206 | } 207 | } 208 | // no more async computations, so it completes normally 209 | print('CancellableFuture finished'); 210 | }); 211 | 212 | Future.delayed(Duration(seconds: 2), () async { 213 | task.cancel(); 214 | print('XXX Task was cancelled now! XXX'); 215 | }); 216 | 217 | await task; 218 | 219 | print('Done'); 220 | } 221 | -------------------------------------------------------------------------------- /lib/src/core.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import '_state.dart'; 4 | import 'error.dart'; 5 | import 'context.dart'; 6 | 7 | /// A [Future] that may be cancelled. 8 | /// 9 | /// To cancel a [CancellableFuture], call [CancellableFuture.cancel]. 10 | /// 11 | /// Notice that any computation occurring within a [CancellableFuture] is 12 | /// automatically cancelled by an error being thrown within it, so a simple 13 | /// way to cancel a computation from "within" is to throw an Exception, 14 | /// including [FutureCancelled] to make the intent explicit (though any 15 | /// error has the effect of stopping computation). 16 | /// 17 | /// Once a [CancellableFuture] has been cancelled, any async function call, 18 | /// or [Future] and [Timer] creation, will fail 19 | /// within the same [Zone] and its descendants. 20 | /// Isolates created within the same computation, however, will not be killed 21 | /// automatically. Use [CancellableContext.scheduleOnCompletion] to ensure 22 | /// Isolates are killed appropriately in such cases. 23 | /// 24 | /// Only the first error within a [CancellableFuture] propagates to any 25 | /// potential listeners. 26 | /// If the `cancel` method was called while the computation had not completed, 27 | /// the first error will be a [FutureCancelled] Exception. 28 | class CancellableFuture implements Future { 29 | final StructuredAsyncZoneState _state; 30 | final Future _delegate; 31 | 32 | CancellableFuture._(this._state, this._delegate); 33 | 34 | /// Default constructor of [CancellableFuture]. 35 | factory CancellableFuture(Future Function() function, 36 | {String? debugName, Function(Object, StackTrace)? uncaughtErrorHandler}) { 37 | return _createCancellableFuture( 38 | (_) => function(), debugName, uncaughtErrorHandler); 39 | } 40 | 41 | /// Create a [CancellableFuture] that accepts a function whose single 42 | /// argument is the returned Future's [CancellableContext]. 43 | /// 44 | /// This context can be used within the function to obtain information about 45 | /// the status of the computation (e.g. whether it's been cancelled) 46 | /// and access other functionality related to its lifecycle. 47 | factory CancellableFuture.ctx(Future Function(CancellableContext) function, 48 | {String? debugName, Function(Object, StackTrace)? uncaughtErrorHandler}) { 49 | return _createCancellableFuture(function, debugName, uncaughtErrorHandler); 50 | } 51 | 52 | /// Create a group of asynchronous computations. 53 | /// 54 | /// Each of the provided `functions` is called immediately and asynchronously. 55 | /// As the `Future` they return complete, the optional `receiver` function 56 | /// is called in whatever order the results are emitted. 57 | /// 58 | /// The returned [CancellableFuture] completes when all computations complete, 59 | /// or on the first error. When a computation fails, all other computations 60 | /// are immediately cancelled and this Future completes with the initial 61 | /// error. 62 | /// 63 | /// If this Future is cancelled, all computations are cancelled immediately 64 | /// and this Future completes with the [FutureCancelled] error. 65 | /// 66 | /// If you prefer to collect the computation results in a [Stream], use the 67 | /// [CancellableFuture.stream] method instead. 68 | static CancellableFuture group( 69 | Iterable Function()> functions, 70 | {FutureOr Function(T)? receiver, 71 | String? debugName, 72 | Function(Object, StackTrace)? uncaughtErrorHandler}) { 73 | final counterStream = StreamController(); 74 | final callback = receiver ?? (T _) {}; 75 | return CancellableFuture(() async { 76 | var elementCount = 0; 77 | for (final function in functions) { 78 | elementCount++; 79 | function() 80 | .then(callback, onError: counterStream.addError) 81 | .whenComplete(() => counterStream.add(false)); 82 | } 83 | final totalElements = elementCount; 84 | try { 85 | await for (var _ in counterStream.stream.take(totalElements)) {} 86 | } finally { 87 | counterStream.close(); 88 | } 89 | }); 90 | } 91 | 92 | /// Create a group of asynchronous computations, sending their completions 93 | /// to a [Stream] as they are emitted. 94 | /// 95 | /// The [CancellableFuture.group] method is used to run the provided 96 | /// `functions`. See that method for more details. 97 | static Stream stream(Iterable Function()> functions, 98 | {String? debugName, Function(Object, StackTrace)? uncaughtErrorHandler}) { 99 | final controller = StreamController(); 100 | group(functions, 101 | receiver: controller.add, 102 | debugName: debugName, 103 | uncaughtErrorHandler: uncaughtErrorHandler) 104 | .then((_) {}, onError: controller.addError) 105 | .whenComplete(controller.close); 106 | return controller.stream; 107 | } 108 | 109 | @override 110 | Stream asStream() { 111 | return _delegate.asStream(); 112 | } 113 | 114 | @override 115 | Future catchError(Function onError, {bool Function(Object error)? test}) { 116 | return _delegate.catchError(onError, test: test); 117 | } 118 | 119 | @override 120 | Future then(FutureOr Function(T value) onValue, 121 | {Function? onError}) { 122 | return _delegate.then(onValue, onError: onError); 123 | } 124 | 125 | @override 126 | Future timeout(Duration timeLimit, {FutureOr Function()? onTimeout}) { 127 | return _delegate.timeout(timeLimit, onTimeout: onTimeout); 128 | } 129 | 130 | @override 131 | Future whenComplete(FutureOr Function() action) { 132 | return _delegate.whenComplete(action); 133 | } 134 | 135 | /// Cancel this [CancellableFuture]. 136 | /// 137 | /// Currently dormant computations will not be immediately interrupted, 138 | /// but any [Future] or [Timer] started within this computation 139 | /// after a call to this method will throw [FutureCancelled]. 140 | /// 141 | /// Any "nested" [CancellableFuture]s started within this computation 142 | /// will also be cancelled. 143 | void cancel() { 144 | _state.cancel(); 145 | } 146 | } 147 | 148 | /// Get the nearest [CancellableContext] if this computation is running within 149 | /// a [CancellableFuture], otherwise, return null. 150 | /// 151 | /// Prefer to use [CancellableFuture.ctx] to obtain the non-nullable 152 | /// [CancellableContext] object as an argument to the Future's async function. 153 | CancellableContext? currentCancellableContext() { 154 | return nearestCancellableContext(); 155 | } 156 | 157 | ZoneSpecification _createZoneSpec(StructuredAsyncZoneState state) => 158 | ZoneSpecification(createTimer: (self, parent, zone, d, f) { 159 | return state.remember(parent.createTimer(zone, d, f), f); 160 | }, createPeriodicTimer: (self, parent, zone, d, f) { 161 | final timer = parent.createPeriodicTimer(zone, d, f); 162 | return state.remember(timer, () => f(timer)); 163 | }); 164 | 165 | CancellableFuture _createCancellableFuture( 166 | Future Function(CancellableContext) function, 167 | String? debugName, 168 | Function(Object, StackTrace)? uncaughtErrorHandler) { 169 | final state = StructuredAsyncZoneState(); 170 | final result = Completer(); 171 | 172 | void onError(e, st) { 173 | state.cancel(); 174 | try { 175 | uncaughtErrorHandler?.call(e, st); 176 | // ignore: empty_catches 177 | } catch (ignore) {} 178 | if (result.isCompleted) return; 179 | result.completeError(e, st); 180 | } 181 | 182 | scheduleMicrotask(() { 183 | runZonedGuarded(() async { 184 | if (state.isComputationCancelled()) { 185 | throw const FutureCancelled(); 186 | } 187 | function(state) 188 | .then((v) => scheduleMicrotask(() => result.complete(v))) 189 | .catchError(onError) 190 | .whenComplete(() { 191 | // make sure that nothing can run after Future returns 192 | return state.cancel(true); 193 | }); 194 | }, onError, 195 | zoneValues: state.createZoneValues(), 196 | zoneSpecification: _createZoneSpec(state)); 197 | }); 198 | 199 | return CancellableFuture._(state, result.future); 200 | } 201 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 --- Renato Athaydes --- 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /test/structured_async_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:structured_async/structured_async.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | group('Should be able to', () { 8 | Future async10() => Future(() => 10); 9 | 10 | test('run CancellableFuture', () async { 11 | final future = CancellableFuture(() async => 'run'); 12 | expect(await future, equals('run')); 13 | }); 14 | 15 | test('get context of CancellableFuture', () async { 16 | final future = CancellableFuture(() async => currentCancellableContext()); 17 | expect(await future, isNotNull); 18 | expect(currentCancellableContext(), isNull); 19 | }); 20 | 21 | test( 22 | 'know that async actions within CancellableFuture stop after it terminates', 23 | () async { 24 | var counter = 0; 25 | final future = CancellableFuture(() async { 26 | // schedule Future but don't wait 27 | Future(() async { 28 | counter++; 29 | await Future(() {}); 30 | counter++; 31 | }); 32 | }); 33 | 34 | await future; 35 | await Future.delayed(Duration(milliseconds: 10)); 36 | 37 | expect(counter, equals(1), 38 | reason: 'As the Future within CancellableFuture was not awaited, ' 39 | 'it should be interrupted after CancellableFuture returns'); 40 | }); 41 | 42 | test('collect uncaught errors', () async { 43 | final errors = []; 44 | final future = CancellableFuture(() async { 45 | Future(() { 46 | throw 'error 1'; 47 | }); 48 | // no await, so inner Future should failed as it gets cancelled 49 | Future(() async { 50 | await Future(() {}); 51 | }); 52 | }, uncaughtErrorHandler: (e, st) => errors.add(e)); 53 | 54 | await future; 55 | await Future.delayed(Duration.zero); 56 | 57 | expect(errors, equals(const ['error 1', FutureCancelled()])); 58 | }); 59 | 60 | group('cancel future before it starts', () { 61 | var isRun = false, interrupted = false, index = 0; 62 | setUp(() { 63 | isRun = false; 64 | interrupted = false; 65 | }); 66 | final interruptableActions = Function()>[ 67 | () async => isRun = true, 68 | () => (() async => isRun = true)(), 69 | () => runZoned(() async => isRun = true), 70 | () async => runZoned(() => isRun = true), 71 | () async => scheduleMicrotask(() => isRun = true), 72 | () => Future(() => isRun = true), 73 | () => Future.delayed(Duration.zero, () => isRun = true), 74 | () async => await Future(() => isRun = true), 75 | () async => Timer.run(() => isRun = true), 76 | ]; 77 | 78 | for (final action in interruptableActions) { 79 | test('for action-${index++}', () async { 80 | final future = CancellableFuture(action); 81 | future.cancel(); 82 | try { 83 | await future; 84 | } on FutureCancelled { 85 | interrupted = true; 86 | } 87 | expect([isRun, interrupted], equals([false, true]), 88 | reason: 'should not have run (ran? $isRun), ' 89 | 'should be interrupted (was? $interrupted)'); 90 | }); 91 | } 92 | }); 93 | 94 | group('cancel async actions after CancellableFuture started', () { 95 | var isRun = false, interrupted = false, index = 0; 96 | setUp(() { 97 | isRun = false; 98 | interrupted = false; 99 | }); 100 | final interruptableActions = Function()>[ 101 | () => Future(() { 102 | isRun = true; 103 | return Future(() {}); 104 | }), 105 | () async { 106 | isRun = true; 107 | await Future.delayed(Duration.zero); 108 | return Future(() {}); 109 | }, 110 | () async { 111 | isRun = true; 112 | await async10(); 113 | return Future(() {}); 114 | }, 115 | () async { 116 | isRun = true; 117 | await async10(); 118 | await async10(); 119 | }, 120 | () { 121 | isRun = true; 122 | return async10().then((x) { 123 | return async10().then((y) => x * y); 124 | }); 125 | }, 126 | ]; 127 | 128 | for (final action in interruptableActions) { 129 | test('for action-${index++}', () async { 130 | final future = CancellableFuture(action); 131 | Future.delayed(Duration.zero, future.cancel); 132 | try { 133 | await future; 134 | } on FutureCancelled { 135 | interrupted = true; 136 | } 137 | 138 | expect([isRun, interrupted], equals([true, true]), 139 | reason: 'should have run (ran? $isRun), ' 140 | 'should be interrupted (was? $interrupted)'); 141 | }); 142 | } 143 | 144 | test('causing even child CancellableFutures to get cancelled', () async { 145 | var isChildCancelled = false; 146 | final startTime = DateTime.now().millisecondsSinceEpoch; 147 | final parent = CancellableFuture(() { 148 | return CancellableFuture(() async { 149 | await CancellableFuture.ctx((ctx) async { 150 | ctx.scheduleOnCancel(() { 151 | isChildCancelled = true; 152 | }); 153 | await Future.delayed(Duration(seconds: 1)); 154 | isRun = true; 155 | }); 156 | try { 157 | await Future.delayed(Duration.zero); 158 | } on FutureCancelled { 159 | interrupted = true; 160 | } 161 | }); 162 | }); 163 | 164 | await Future.delayed(Duration(milliseconds: 100)); 165 | 166 | parent.cancel(); 167 | await parent; 168 | final futureEndTime = DateTime.now().millisecondsSinceEpoch; 169 | await Future.delayed(Duration.zero); 170 | 171 | expect(futureEndTime - startTime, lessThan(600), 172 | reason: 'Child Future would block for 1 second, but ' 173 | 'after cancellation all should be stopped before that'); 174 | expect( 175 | [isRun, interrupted, isChildCancelled], equals([true, true, true]), 176 | reason: 'should have run (ran? $isRun), ' 177 | 'should be interrupted (was? $interrupted), ' 178 | 'child should be cancelled (was? $isChildCancelled)'); 179 | }); 180 | 181 | test('causing periodic timers to be cancelled', () async { 182 | final ticks = []; 183 | Timer? timer; 184 | final startTime = DateTime.now().millisecondsSinceEpoch; 185 | 186 | final future = CancellableFuture(() async { 187 | timer = Timer.periodic(Duration(milliseconds: 20), (_) { 188 | ticks.add(true); 189 | }); 190 | await Future.delayed(Duration(seconds: 5)); 191 | }); 192 | 193 | await Future.delayed(Duration(milliseconds: 50)); 194 | 195 | future.cancel(); 196 | await future; 197 | final futureEndTime = DateTime.now().millisecondsSinceEpoch; 198 | 199 | expect(ticks.length, allOf(greaterThan(1), lessThan(5))); 200 | expect(timer?.isActive, isFalse); 201 | expect(futureEndTime - startTime, lessThan(200), 202 | reason: 'Future would block for 5 seconds, but ' 203 | 'after cancellation it should be stopped before that'); 204 | }); 205 | }); 206 | 207 | test('check for cancellation explicitly from within a computation', () { 208 | CancellableFuture createFuture() => 209 | CancellableFuture.ctx((ctx) async { 210 | if (ctx.isComputationCancelled()) { 211 | throw const FutureCancelled(); 212 | } 213 | return 1; 214 | }); 215 | 216 | // interrupted if cancelled 217 | expect(Future(() { 218 | final future = createFuture(); 219 | future.cancel(); 220 | return future; 221 | }), throwsA(isA())); 222 | 223 | // not interrupted otherwise 224 | expect(Future(() { 225 | final future = createFuture(); 226 | return future; 227 | }), completion(equals(1))); 228 | }); 229 | 230 | test('schedule onCancel callback that does not run if not cancelled', 231 | () async { 232 | var onCancelRun = false; 233 | final future = CancellableFuture.ctx((ctx) async { 234 | ctx.scheduleOnCancel(() { 235 | onCancelRun = true; 236 | }); 237 | return await async10() + await async10(); 238 | }); 239 | 240 | await future; 241 | expect(onCancelRun, isFalse); 242 | }); 243 | 244 | test('schedule onCancel callback that runs on cancellation', () async { 245 | Zone? onCancelZone; 246 | final future = CancellableFuture.ctx((ctx) async { 247 | ctx.scheduleOnCancel(() { 248 | onCancelZone = Zone.current; 249 | }); 250 | return await async10() + await async10(); 251 | }); 252 | 253 | Future(future.cancel); 254 | 255 | try { 256 | await future; 257 | fail('Future should have been cancelled'); 258 | } on FutureCancelled { 259 | // good 260 | } 261 | expect(onCancelZone, same(Zone.root), 262 | reason: 'onCancel callback should run at the root Zone'); 263 | }); 264 | 265 | test('cancel Future from within itself', () async { 266 | int value = 0; 267 | final future = CancellableFuture.ctx((ctx) async { 268 | value += await async10(); 269 | ctx.cancel(); 270 | value += await async10(); 271 | }); 272 | 273 | try { 274 | await future; 275 | fail('Future should have been cancelled'); 276 | } on FutureCancelled { 277 | // good 278 | } 279 | expect(value, equals(10), 280 | reason: 281 | 'only the async call before cancellation should have executed'); 282 | }); 283 | }, timeout: Timeout(Duration(seconds: 5))); 284 | 285 | group('Should not be able to cancel future that has already been started', 286 | () { 287 | var isRun = false, interrupted = false, index = 0; 288 | setUp(() { 289 | isRun = false; 290 | interrupted = false; 291 | }); 292 | 293 | // if no async action is performed within the Futures, there's no 294 | // suspend point to be able to cancel 295 | final nonInterruptableActions = Function()>[ 296 | () => Future(() { 297 | isRun = true; 298 | return 10; 299 | }), 300 | () async { 301 | isRun = true; 302 | return 10; 303 | }, 304 | () async { 305 | isRun = true; 306 | return Future(() => 10); 307 | }, 308 | () async { 309 | isRun = true; 310 | scheduleMicrotask(() {}); 311 | return 10; 312 | }, 313 | ]; 314 | 315 | for (final action in nonInterruptableActions) { 316 | test('for action-${index++}', () async { 317 | final future = CancellableFuture(action); 318 | await Future.delayed(Duration.zero, future.cancel); 319 | int result = 0; 320 | try { 321 | result = await future; 322 | } on FutureCancelled { 323 | interrupted = true; 324 | } 325 | 326 | expect([isRun, interrupted, result], equals([true, false, 10]), 327 | reason: 'should have run (ran? $isRun), ' 328 | 'should be interrupted (was? $interrupted), ' 329 | 'should return 10 (v=$result)'); 330 | }); 331 | } 332 | }, timeout: Timeout(Duration(seconds: 5))); 333 | 334 | group('When an error occurs within a CancellableFuture', () { 335 | Future badAction() async { 336 | await Future.delayed(Duration(milliseconds: 10)); 337 | throw 'bad'; 338 | } 339 | 340 | test('it propagates to the caller', () async { 341 | expect(badAction, throwsA(equals('bad'))); 342 | final cancellableBadAction = CancellableFuture(badAction); 343 | expect(() => cancellableBadAction, throwsA(equals('bad'))); 344 | }); 345 | 346 | test('if cancelled first, the caller gets FutureCancelled', () async { 347 | final cancellableBadAction = CancellableFuture(badAction); 348 | cancellableBadAction.cancel(); 349 | expect(() => cancellableBadAction, throwsA(isA())); 350 | }); 351 | 352 | test('the error propagates to the caller even after a delay', () async { 353 | final cancellableBadAction = CancellableFuture(badAction); 354 | cancellableBadAction 355 | .then(expectAsync1((_) {}, count: 0)) 356 | .catchError(expectAsync1((e) { 357 | expect(e, equals('bad')); 358 | })); 359 | await Future.delayed(Duration(milliseconds: 100)); 360 | }); 361 | 362 | test('if cancelled later, the original error propagates to the caller', 363 | () async { 364 | final cancellableBadAction = CancellableFuture(badAction); 365 | cancellableBadAction 366 | .then(expectAsync1((_) {}, count: 0)) 367 | .catchError(expectAsync1((e) { 368 | expect(e, equals('bad')); 369 | })); 370 | await Future.delayed(Duration(milliseconds: 100)); 371 | cancellableBadAction.cancel(); 372 | }); 373 | }, timeout: Timeout(Duration(seconds: 5))); 374 | } 375 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # structured_async 2 | 3 | ![Project CI](https://github.com/renatoathaydes/structured_async/workflows/Project%20CI/badge.svg) 4 | [![pub package](https://img.shields.io/pub/v/structured_async.svg)](https://pub.dev/packages/structured_async) 5 | 6 | [Structured concurrency](https://en.wikipedia.org/wiki/Structured_concurrency) 7 | for the [Dart](https://dart.dev/) Programming Language. 8 | 9 | ## User Guide 10 | 11 | ### CancellableFuture 12 | 13 | The basic construct in `structured_async` is `CancellableFuture`. It looks like a normal Dart `Future` 14 | but with the following differences: 15 | 16 | * it has a `cancel` method. 17 | * if any unhandled error occurs within it: 18 | * all asynchronous computations started within it are stopped. 19 | * the error is propagated to the caller even if the `Future` it comes from was not `await`-ed. 20 | * when it completes, anything[^1] it started but not waited for is cancelled. 21 | 22 | [^1]: See the [_Limitations_](#limitations) section for computations that may _escape_ the context of a `CancellableFuture`. 23 | Please file a bug if you find any other cases. 24 | 25 | This example shows the basic difference: 26 | 27 | ```dart 28 | _runForever() async { 29 | while (true) { 30 | print('Tick'); 31 | await Future.delayed(Duration(milliseconds: 500)); 32 | } 33 | } 34 | 35 | Future futureNeverStops() async { 36 | await Future(() { 37 | _runForever(); // no await here 38 | return Future.delayed(Duration(milliseconds: 1200)); 39 | }); 40 | print('Stopped'); 41 | } 42 | 43 | Future cancellableFutureStopsWhenItReturns() async { 44 | await CancellableFuture(() { // <--- this is the only difference! 45 | _runForever(); // no await here 46 | return Future.delayed(Duration(milliseconds: 1200)); 47 | }); 48 | print('Stopped'); 49 | } 50 | ``` 51 | 52 | If you run `futureNeverStops`, you'll see this: 53 | 54 | ``` 55 | Tick 56 | Tick 57 | Tick 58 | Stopped 59 | Tick 60 | Tick 61 | Tick 62 | ... 63 | ``` 64 | 65 | The program never ends because Dart's `Future`s that are not _await_'ed for don't stop after their 66 | _parent `Future` completes. 67 | 68 | However, running `cancellableFutureStopsWhenItReturns`, you should see: 69 | 70 | ``` 71 | Tick 72 | Tick 73 | Tick 74 | Tick 75 | Stopped 76 | ``` 77 | 78 | And the program dies. When a `CancellableFuture` completes, _most_ asynchronous computation within it 79 | are terminated (see also [_Limitations_](#limitations)). Any pending `Future`s and `Timer`s are completed 80 | immediately, and attempting to create any new `Future` and `Timer` within the `CancellableFuture` computation 81 | will fail from that point on. 82 | 83 | To make it clearer what is going on, run example 2 (shown above) with the `time` option, 84 | which will print the time since the program started (in ms) when each `print` is called: 85 | 86 | ```shell 87 | dart example/readme_examples.dart 2 time 88 | ``` 89 | 90 | Result: 91 | 92 | ``` 93 | Time | Message 94 | ------+-------- 95 | 26 | Tick 96 | 542 | Tick 97 | 1046 | Tick 98 | 1243 | Tick 99 | 1249 | Stopped 100 | ``` 101 | 102 | Notice how the `Tick` messages are initially printed every 500ms, as the code intended, but once the `CancellableFuture` 103 | completes, at `t=1200` approximately, the delayed `Future` in `_runForever` is awakened _early_, the loop continues 104 | by again printing `Tick` (hence the last `Tick` message), and when it tries to await again on a new delayed `Future`, 105 | its computation is aborted with a `FutureCancelled` Exception (because the `CancellableFuture` ended, any pending 106 | computation is thus automatically cancelled) which in this example happens to be silently ignored. 107 | 108 | You can register a callback to receive uncaught errors when you create a `CancellableFuture` 109 | (notice that uncaught errors may be received just **after** the `CancellableFuture` returns, but are otherwise 110 | unobservable): 111 | 112 | ```dart 113 | await CancellableFuture(() { 114 | ... 115 | }, uncaughtErrorHandler: (e, st) { 116 | print('Error: $e\n$st'); 117 | }); 118 | ``` 119 | 120 | Running the example again, the result would be: 121 | 122 | ``` 123 | Time | Message 124 | ------+-------- 125 | 35 | Tick 126 | 550 | Tick 127 | 1051 | Tick 128 | 1252 | Tick 129 | 1257 | Error: FutureCancelled 130 | #0 StructuredAsyncZoneState.remember (package:structured_async/src/_state.dart:47:7) 131 | #1 _createZoneSpec. (package:structured_async/src/core.dart:164:20) 132 | #2 _CustomZone.createTimer (dart:async/zone.dart:1388:19) 133 | #3 new Timer (dart:async/timer.dart:54:10) 134 | #4 new Future.delayed (dart:async/future.dart:388:9) 135 | #5 _runForever (file:///projects/structured_async/example/readme_examples.dart:59:18) 136 | 137 | 138 | 1258 | Stopped 139 | ``` 140 | 141 | ### Cancelling computations 142 | 143 | To cancel a `CancellableFuture`, you've guessed it: call the `cancel()` method. 144 | 145 | > From within the `CancellableFuture` computation itself, throwing an error has a similar effect as 146 | > being cancelled from the _outside_, i.e. stop everything and complete with an error. 147 | > However, to be more explicit, you can either build the Future with `CancellableFuture.ctx`, 148 | > then call `cancel()` on the provided context object (after which no more async computations may succeed 149 | > within the same computation), or more simply, use `throw const FutureCancelled()`. 150 | 151 | Example: 152 | 153 | ```dart 154 | Future explicitCancel() async { 155 | Future printForever(String message) async { 156 | while(true) { 157 | await Future.delayed(Duration(seconds: 1), () => print(message)); 158 | } 159 | } 160 | final future = CancellableFuture(() async { 161 | printForever('Tic'); // no await 162 | await Future.delayed(Duration(milliseconds: 500)); 163 | await printForever('Tac'); 164 | }); 165 | 166 | // cancel after 3 seconds 167 | Future.delayed(Duration(seconds: 3), future.cancel); 168 | 169 | try { 170 | await future; 171 | } on FutureCancelled { 172 | print('Future was cancelled'); 173 | } 174 | } 175 | ``` 176 | 177 | Result: 178 | 179 | ``` 180 | Tic 181 | Tac 182 | Tic 183 | Tac 184 | Tic 185 | Future was cancelled 186 | Tac 187 | ``` 188 | 189 | ### Error Propagation 190 | 191 | With `CancellableFuture`, any error that occurs within its computation, even on non-awaited `Future`s it has 192 | started, propagate to the caller as long as the `CancellableFuture` has not completed yet. 193 | 194 | To illustrate the difference with `Future`, let's look at what happens when we run this: 195 | 196 | ```dart 197 | _throw() async { 198 | throw 'not great'; 199 | } 200 | 201 | Future futureWillNotPropagateThisError() async { 202 | try { 203 | await Future(() { 204 | _throw(); 205 | return Future.delayed(Duration(milliseconds: 100)); 206 | }); 207 | } catch (e) { 208 | print('ERROR: $e'); 209 | } finally { 210 | print('Stopped'); 211 | } 212 | } 213 | ``` 214 | 215 | Result: 216 | 217 | ``` 218 | Unhandled exception: 219 | not great 220 | #0 _throw 221 | ... 222 | ``` 223 | 224 | The program crashes without running the `catch` block. 225 | 226 | Replacing `Future` with `CancellableFuture`, this is the result: 227 | 228 | ``` 229 | ERROR: not great 230 | Stopped 231 | ``` 232 | 233 | The error is handled correctly and the program terminates successfully. 234 | 235 | > If you want to explicitly allow a computation to fail, use Dart's 236 | > [runZoneGuarded](https://api.dart.dev/stable/dart-async/runZonedGuarded.html). 237 | 238 | ### Periodic Timers 239 | 240 | Any periodic timers started within a `CancellableFuture` will be cancelled when the `CancellableFuture` itself 241 | completes, successfully or not, including when it gets cancelled. 242 | 243 | This example shows how that works: 244 | 245 | ```dart 246 | Future periodicTimerIsCancelledOnCompletion() async { 247 | final task = CancellableFuture(() async { 248 | // fire and forget a periodic timer 249 | Timer.periodic(Duration(milliseconds: 500), (_) => print('Tick')); 250 | await Future.delayed(Duration(milliseconds: 1200)); 251 | return 10; 252 | }); 253 | print(await task); 254 | } 255 | ``` 256 | 257 | We fire and forget a periodic timer, wait a second or so, then finish the `CancellableFuture` with the value `10`. 258 | 259 | Outside the `CancellableFuture`, we `await` its completion and print its result. 260 | 261 | Result: 262 | 263 | ``` 264 | Tick 265 | Tick 266 | 10 267 | ``` 268 | 269 | As you can see, the periodic timer is immediately stopped when the `CancellableFuture` that created it completes. 270 | 271 | ### CancellableFuture.group() and stream() 272 | 273 | `CancellableFuture.group()` makes it easier to run multiple asynchronous computations within the same 274 | `CancellableFuture` and waiting for all their results. 275 | 276 | Example: 277 | 278 | ```dart 279 | Future groupExample() async { 280 | var result = 0; 281 | final group = CancellableFuture.group([ 282 | () async => 10, 283 | () async => 20, 284 | ], receiver: (int item) => result += item); 285 | await group; 286 | print('Result: $result'); 287 | } 288 | ``` 289 | 290 | Result: 291 | 292 | ``` 293 | Result: 30 294 | ``` 295 | 296 | As with any `CancellableFuture`, if some error happens in any of the computations within a group, 297 | all other computations are stopped and the error propagates to the `await`-er. 298 | 299 | For convenience, there's also a `stream` factory method that returns a `Stream` instead 300 | of `CancellableFuture`, but which has the exact same semantics as a group: 301 | 302 | ```dart 303 | Future streamExample() async { 304 | final group = CancellableFuture.stream([ 305 | () async => 10, 306 | () async => 20, 307 | ]); 308 | print('Result: ${await group.toList()}'); 309 | } 310 | ``` 311 | 312 | Result: 313 | 314 | ``` 315 | Result: [10, 20] 316 | ``` 317 | 318 | ### Limitations 319 | 320 | Not everything can be stopped immediately when a `CancellableFuture` is cancelled. 321 | 322 | Known issues are listed below. 323 | 324 | #### stopped `Timer`s and `Future`s. 325 | 326 | When `CancellableFuture` completes or is cancelled explicitly, any pending `Future` and `Timer` within it 327 | will be immediately awakened so the synchronous code that follows them will be executed. 328 | 329 | For example, this simple code probably won't work as you think it should: 330 | 331 | ```dart 332 | Future scheduledFutureWillRun() async { 333 | final task = CancellableFuture(() => 334 | Future.delayed(Duration(seconds: 2), () => print('2 seconds later'))); 335 | await Future.delayed(Duration(seconds: 1), task.cancel); 336 | await task; 337 | } 338 | ``` 339 | 340 | This will actually print `2 seconds later`, but after only 1 second, and the program will terminate successfully because 341 | no more asynchronous calls were made within the Future. 342 | 343 | If you ever run into this problem, you can try to insert a few explicit checks to see if your task has been cancelled 344 | before doing anything. 345 | 346 | That's what the `isComputationCancelled()` function is for, as this example demonstrates: 347 | 348 | ```dart 349 | Future explicitCheckForCancellation() async { 350 | final task = CancellableFuture.ctx((ctx) => 351 | Future.delayed(Duration(seconds: 2), () { 352 | if (ctx.isComputationCancelled()) return 'Cancelled'; 353 | return '2 seconds later'; 354 | })); 355 | await Future.delayed(Duration(seconds: 1), task.cancel); 356 | print(await task); 357 | } 358 | ``` 359 | 360 | Result: 361 | 362 | ``` 363 | Cancelled 364 | ``` 365 | 366 | Running with the `time` option proves that the `CancellableFuture` indeed returned after around 1 second: 367 | 368 | ``` 369 | Time | Message 370 | ------+-------- 371 | 1032 | Cancelled 372 | ``` 373 | 374 | > Notice that calling any async method or creating a `Future` from within a task 375 | > would have caused the above `CancellableFuture` to abort with a `FutureCancelled` Exception. 376 | 377 | As shown above, the `CancellableFuture.ctx` constructor must be used to get access to the context object which exposes 378 | `isComputationCancelled()`, amongst other helper functions. 379 | 380 | If passing the context object into where it's needed gets cumbersome, you can use the top-level function 381 | `CancellableContext? currentCancellableContext()`, which returns `null` when it's not executed from within a 382 | `CancellableFuture` computation. 383 | 384 | #### `Isolate`s. 385 | 386 | Dart `Isolate`s started within a `CancellableFuture` may continue running even after the `CancellableFuture` completes. 387 | 388 | To work around this problem, use the context's `scheduleOnCompletion` function and the following general pattern 389 | to ensure the `Isolate`s don't survive after a `CancellableFuture` it was created from returns: 390 | 391 | ```dart 392 | Future stoppingIsolates() async { 393 | final task = CancellableFuture.ctx((ctx) async { 394 | final responsePort = ReceivePort()..listen(print); 395 | 396 | final iso = await Isolate.spawn((message) async { 397 | for (var i = 0; i < 10; i++) { 398 | await Future.delayed(Duration(milliseconds: 500), 399 | () => message.send('Isolate says: hello')); 400 | } 401 | message.send('Isolate finished'); 402 | }, responsePort.sendPort); 403 | 404 | Zone zone = Zone.current; 405 | // this runs in the root Zone 406 | ctx.scheduleOnCompletion(() { 407 | // ensure Isolate is terminated on completion 408 | zone.print('Killing ISO'); 409 | responsePort.close(); 410 | iso.kill(); 411 | }); 412 | 413 | // let this Future continue to run for a few seconds by 414 | // pretending to do some work 415 | for (var i = 0; i < 20; i++) { 416 | try { 417 | await Future.delayed(Duration(milliseconds: 200)); 418 | } on FutureCancelled { 419 | break; 420 | } 421 | } 422 | // no more async computations, so it completes normally 423 | print('CancellableFuture finished'); 424 | }); 425 | 426 | Future.delayed(Duration(seconds: 2), () async { 427 | task.cancel(); 428 | print('XXX Task was cancelled now! XXX'); 429 | }); 430 | 431 | await task; 432 | 433 | print('Done'); 434 | } 435 | ``` 436 | 437 | Result: 438 | 439 | ``` 440 | Isolate says: hello 441 | Isolate says: hello 442 | Isolate says: hello 443 | XXX Task was cancelled now! XXX 444 | CancellableFuture finished 445 | Killing ISO 446 | Done 447 | ``` 448 | 449 | ## Examples 450 | 451 | All examples on this page, and more, can be found in the [example](example) directory. 452 | --------------------------------------------------------------------------------