Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
32 changes: 22 additions & 10 deletions lib/src/helpers/disposable_bag.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<FutureOr<void> Function()> _disposers = [];
bool _disposed = false;
Expand Down Expand Up @@ -53,25 +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<void> dispose() async {
if (_disposed) {
return;
}
_disposed = true;
final errors = <Object>[];
for (final disposer in _disposers) {
final slots = List<Object?>.filled(_disposers.length, null);
final futures = <Future<void>>[];
for (var i = 0; i < _disposers.length; i++) {
try {
final result = disposer();
final result = _disposers[i]();
if (result is Future) {
await result;
futures.add(result.then((_) {}, onError: (Object e) => slots[i] = e));
}
} on Object catch (e) {
errors.add(e);
slots[i] = e;
}
}
_disposers.clear();
if (futures.isNotEmpty) {
await Future.wait(futures);
}
final errors = slots.nonNulls.toList();
if (errors.isNotEmpty) {
throw DisposableBagException(errors);
}
Expand Down
59 changes: 59 additions & 0 deletions test/disposable_bag_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,65 @@ void main() {
await check(bag.dispose()).throws<DisposableBagException>();
check(calls).deepEquals(['a', 'c']);
});

test('starts async disposers in order without awaiting each', () async {
final order = <String>[];
final a = Completer<void>();
final b = Completer<void>();
final bag = DisposableBag()
..add(() async {
order.add('start-a');
await a.future;
order.add('end-a');
})
..add(() async {
order.add('start-b');
await b.future;
order.add('end-b');
});
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 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<DisposableBagException>(
(e) => e.has((e) => e.errors, 'errors').length.equals(2),
);
});

test('collects sync and async errors in registration order', () async {
final a = Completer<void>();
final b = Completer<void>();
final bag = DisposableBag()
..add(() async {
await a.future;
throw Exception('first');
})
..add(() async {
await b.future;
throw Exception('second');
})
..add(() => throw Exception('third'));
final done = bag.dispose();
b.complete();
a.complete();
await check(done).throws<DisposableBagException>(
(e) => e
.has((e) => e.errors.map((o) => o.toString()).toList(), 'errors')
.deepEquals([
'Exception: first',
'Exception: second',
'Exception: third',
]),
);
});
});

group('DisposableBagException', () {
Expand Down