From 7c12c8d909ec071d00f4b53f7eccb07ad5d08d31 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:27:35 +0000 Subject: [PATCH 1/4] perf: execute async disposers concurrently in DisposableBag Updates DisposableBag.dispose to collect futures returned by disposers and await them concurrently using Future.wait, rather than sequentially awaiting each async disposer. Exception handling is maintained by attaching onError callbacks to record any async errors before throwing DisposableBagException. Co-authored-by: esenmx <43244505+esenmx@users.noreply.github.com> --- lib/src/helpers/disposable_bag.dart | 17 +++++++++++- test/disposable_bag_test.dart | 42 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/lib/src/helpers/disposable_bag.dart b/lib/src/helpers/disposable_bag.dart index e7ea03e..5229cd3 100644 --- a/lib/src/helpers/disposable_bag.dart +++ b/lib/src/helpers/disposable_bag.dart @@ -61,17 +61,32 @@ class DisposableBag { } _disposed = true; final errors = []; + List>? futures; + for (final disposer in _disposers) { try { final result = disposer(); if (result is Future) { - await result; + futures ??= []; + futures.add( + result.then( + (_) => null, + onError: (Object e) { + errors.add(e); + }, + ), + ); } } on Object catch (e) { errors.add(e); } } _disposers.clear(); + + if (futures != null) { + await Future.wait(futures); + } + if (errors.isNotEmpty) { throw DisposableBagException(errors); } diff --git a/test/disposable_bag_test.dart b/test/disposable_bag_test.dart index 981768b..c6b2f1d 100644 --- a/test/disposable_bag_test.dart +++ b/test/disposable_bag_test.dart @@ -107,6 +107,48 @@ void main() { await check(bag.dispose()).throws(); check(calls).deepEquals(['a', 'c']); }); + + test('executes async disposers concurrently', () async { + final order = []; + final bag = DisposableBag() + ..add(() async { + order.add('start-a'); + await Future.delayed(const Duration(milliseconds: 20)); + order.add('end-a'); + }) + ..add(() async { + order.add('start-b'); + await Future.delayed(const Duration(milliseconds: 10)); + order.add('end-b'); + }); + + await bag.dispose(); + // Both disposers start synchronously before either completes. + // b takes 10ms, a takes 20ms, so b finishes before a. + check(order).deepEquals(['start-a', 'start-b', 'end-b', 'end-a']); + }); + + test('collects errors from async disposers', () async { + final bag = DisposableBag() + ..add(() async { + await Future.delayed(const Duration(milliseconds: 10)); + throw Exception('async error 1'); + }) + ..add(() async { + await Future.delayed(const Duration(milliseconds: 5)); + throw Exception('async error 2'); + }); + + DisposableBagException? exception; + try { + await bag.dispose(); + } on DisposableBagException catch (e) { + exception = e; + } + + check(exception).isNotNull(); + check(exception!.errors.length).equals(2); + }); }); group('DisposableBagException', () { From 313046bab4bc484fa4a3a96d7cf34c8d38bfc06d Mon Sep 17 00:00:00 2001 From: Mehmet Esen Date: Sun, 23 Aug 2026 16:54:24 +0300 Subject: [PATCH 2/4] refactor(DisposableBag): keep errors in registration order via slot indexing Drop the nullable futures juggling, record each disposer's error in a slot keyed by its registration index so DisposableBagException.errors stays in registration order, and document the invoke-in-order / await-together contract in the class and dispose() dartdoc. Co-Authored-By: Claude Fable 5 --- lib/src/helpers/disposable_bag.dart | 43 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/lib/src/helpers/disposable_bag.dart b/lib/src/helpers/disposable_bag.dart index 5229cd3..c10110e 100644 --- a/lib/src/helpers/disposable_bag.dart +++ b/lib/src/helpers/disposable_bag.dart @@ -15,9 +15,15 @@ part of '../../fluiver.dart'; /// bag.dispose(); /// ``` /// -/// Closures run in registration order. [dispose] is idempotent — calling it -/// twice runs the closures once. Adding after dispose runs the closure -/// immediately and does not retain it. +/// Closures are invoked in registration order. A synchronous disposer +/// completes before the next one starts; disposers that return a [Future] +/// are started in order but awaited together, so [dispose] settles when the +/// slowest one does and a stalled disposer never starves the ones after it. +/// A step that must wait for a previous async step (flush, then close) +/// belongs in one closure: `() async { await flush(); await close(); }`. +/// +/// [dispose] is idempotent — calling it twice runs the closures once. Adding +/// after dispose runs the closure immediately and does not retain it. class DisposableBag { final List Function()> _disposers = []; bool _disposed = false; @@ -53,40 +59,31 @@ class DisposableBag { disposers.forEach(add); } - /// Runs every registered disposer in order, then clears the bag. - /// Safe to call multiple times. + /// Invokes every registered disposer in order, awaits the ones that return + /// a [Future] together, then clears the bag. Throws [DisposableBagException] + /// listing every error in registration order. Safe to call multiple times. Future dispose() async { if (_disposed) { return; } _disposed = true; - final errors = []; - List>? futures; - - for (final disposer in _disposers) { + final slots = List.filled(_disposers.length, null); + final futures = >[]; + for (var i = 0; i < _disposers.length; i++) { try { - final result = disposer(); + final result = _disposers[i](); if (result is Future) { - futures ??= []; - futures.add( - result.then( - (_) => null, - onError: (Object e) { - errors.add(e); - }, - ), - ); + futures.add(result.then((_) {}, onError: (Object e) => slots[i] = e)); } } on Object catch (e) { - errors.add(e); + slots[i] = e; } } _disposers.clear(); - - if (futures != null) { + if (futures.isNotEmpty) { await Future.wait(futures); } - + final errors = slots.nonNulls.toList(); if (errors.isNotEmpty) { throw DisposableBagException(errors); } From dc31f47b7f9cc906db67fbab2a648664d406cc2a Mon Sep 17 00:00:00 2001 From: Mehmet Esen Date: Sun, 23 Aug 2026 16:54:24 +0300 Subject: [PATCH 3/4] test(DisposableBag): replace timer-based tests with Completer-driven oracles Cover concurrent start, every-async-error collection, and mixed sync/async error ordering deterministically. Co-Authored-By: Claude Fable 5 --- test/disposable_bag_test.dart | 63 ++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/test/disposable_bag_test.dart b/test/disposable_bag_test.dart index c6b2f1d..ef71ea0 100644 --- a/test/disposable_bag_test.dart +++ b/test/disposable_bag_test.dart @@ -108,46 +108,63 @@ void main() { check(calls).deepEquals(['a', 'c']); }); - test('executes async disposers concurrently', () async { + test('starts async disposers in order without awaiting each', () async { final order = []; + final a = Completer(); + final b = Completer(); final bag = DisposableBag() ..add(() async { order.add('start-a'); - await Future.delayed(const Duration(milliseconds: 20)); + await a.future; order.add('end-a'); }) ..add(() async { order.add('start-b'); - await Future.delayed(const Duration(milliseconds: 10)); + await b.future; order.add('end-b'); }); - - await bag.dispose(); - // Both disposers start synchronously before either completes. - // b takes 10ms, a takes 20ms, so b finishes before a. + final done = bag.dispose(); + check(order).deepEquals(['start-a', 'start-b']); + b.complete(); + a.complete(); + await done; check(order).deepEquals(['start-a', 'start-b', 'end-b', 'end-a']); }); - test('collects errors from async disposers', () async { + test('collects every async disposer error', () async { + final bag = DisposableBag() + ..add(() async => throw Exception('async error 1')) + ..add(() async => throw Exception('async error 2')); + await check(bag.dispose()).throws( + (e) => e.has((e) => e.errors, 'errors').length.equals(2), + ); + }); + + test('collects sync and async errors in registration order', () async { + final a = Completer(); + final b = Completer(); final bag = DisposableBag() ..add(() async { - await Future.delayed(const Duration(milliseconds: 10)); - throw Exception('async error 1'); + await a.future; + throw Exception('first'); }) ..add(() async { - await Future.delayed(const Duration(milliseconds: 5)); - throw Exception('async error 2'); - }); - - DisposableBagException? exception; - try { - await bag.dispose(); - } on DisposableBagException catch (e) { - exception = e; - } - - check(exception).isNotNull(); - check(exception!.errors.length).equals(2); + await b.future; + throw Exception('second'); + }) + ..add(() => throw Exception('third')); + final done = bag.dispose(); + b.complete(); + a.complete(); + await check(done).throws( + (e) => e + .has((e) => e.errors.map((o) => o.toString()).toList(), 'errors') + .deepEquals([ + 'Exception: first', + 'Exception: second', + 'Exception: third', + ]), + ); }); }); From 261ec3e54a1ab9c55f8434625388144126db6d24 Mon Sep 17 00:00:00 2001 From: Mehmet Esen Date: Sun, 23 Aug 2026 16:54:24 +0300 Subject: [PATCH 4/4] docs: add 4.1.0 changelog entry for concurrent DisposableBag disposal Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf67eb..3d82d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 4.1.0 + +- **Changed** — `DisposableBag.dispose` invokes disposers in registration order but awaits `Future`-returning ones together, so disposal takes as long as the slowest disposer instead of the sum, a stalled async disposer no longer starves the ones after it, and synchronous disposers queued behind an async one now run inside `dispose()` rather than after it settles. Steps that must wait for a previous async step (flush, then close) belong in one closure. `DisposableBagException.errors` stays in registration order. + ## 4.0.0 Strips every API whose job the framework or core libraries already do — fluiver keeps only genuine gaps.