fix(settings): refuse overlapping runAsync at runtime
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
/// Mixin for stateful screens that wrap their async work in a busy + error
|
||||
/// scaffolding. Exposes [busy] and [errorText] state plus a [runAsync] helper
|
||||
/// that clears the prior error, sets busy, runs the body, captures any
|
||||
@@ -13,7 +15,9 @@ import 'package:flutter/widgets.dart';
|
||||
/// [runAsync] calls must be sequenced, never overlapped: there is a single
|
||||
/// busy flag, so the first call to finish clears it while the other is still
|
||||
/// running. Don't start a second runAsync (or fire one with `unawaited`)
|
||||
/// while another that can still apply state is in flight — debug-asserted.
|
||||
/// while another that can still apply state is in flight — debug-asserted,
|
||||
/// and the overlapping call is refused (returns null) in release so it can't
|
||||
/// corrupt the busy flag.
|
||||
/// Overlapping a *stale* run whose [runAsync]'s `shouldApplyState` has gone
|
||||
/// false (e.g. a cancelled poll unwinding) is fine; it no longer touches busy.
|
||||
mixin AsyncFormStateMixin<T extends StatefulWidget> on State<T> {
|
||||
@@ -47,16 +51,17 @@ mixin AsyncFormStateMixin<T extends StatefulWidget> on State<T> {
|
||||
}) async {
|
||||
bool canApplyState() => mounted && (shouldApplyState?.call() ?? true);
|
||||
if (!canApplyState()) return null;
|
||||
assert(
|
||||
!_activeRunAsyncGuards.any((stillApplies) => stillApplies()),
|
||||
'runAsync overlapped with another runAsync that can still apply state: '
|
||||
'the first to finish clears busy out from under the other. Sequence the '
|
||||
'calls (await the first) instead of nesting or unawaiting them.',
|
||||
);
|
||||
assert(() {
|
||||
_activeRunAsyncGuards.add(canApplyState);
|
||||
return true;
|
||||
}());
|
||||
if (_activeRunAsyncGuards.any((stillApplies) => stillApplies())) {
|
||||
assert(
|
||||
false,
|
||||
'runAsync overlapped with another runAsync that can still apply state: '
|
||||
'the first to finish clears busy out from under the other. Sequence the '
|
||||
'calls (await the first) instead of nesting or unawaiting them.',
|
||||
);
|
||||
appLogger.w('runAsync overlapped with an active run; ignoring this call');
|
||||
return null;
|
||||
}
|
||||
_activeRunAsyncGuards.add(canApplyState);
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_errorText = null;
|
||||
@@ -69,10 +74,7 @@ mixin AsyncFormStateMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
assert(() {
|
||||
_activeRunAsyncGuards.remove(canApplyState);
|
||||
return true;
|
||||
}());
|
||||
_activeRunAsyncGuards.remove(canApplyState);
|
||||
if (canApplyState()) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/screens/settings/async_form_state_mixin.dart';
|
||||
|
||||
class _Host extends StatefulWidget {
|
||||
const _Host();
|
||||
|
||||
@override
|
||||
State<_Host> createState() => _HostState();
|
||||
}
|
||||
|
||||
class _HostState extends State<_Host> with AsyncFormStateMixin<_Host> {
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('sequential runAsync calls manage busy/error state', (tester) async {
|
||||
await tester.pumpWidget(const _Host());
|
||||
final state = tester.state<_HostState>(find.byType(_Host));
|
||||
|
||||
final result = await state.runAsync(() async => 42);
|
||||
expect(result, 42);
|
||||
expect(state.busy, isFalse);
|
||||
expect(state.errorText, isNull);
|
||||
|
||||
final failed = await state.runAsync<int>(() async => throw Exception('boom'), errorMapper: (_) => 'mapped');
|
||||
expect(failed, isNull);
|
||||
expect(state.errorText, 'mapped');
|
||||
expect(state.busy, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('overlapping runAsync is refused', (tester) async {
|
||||
await tester.pumpWidget(const _Host());
|
||||
final state = tester.state<_HostState>(find.byType(_Host));
|
||||
|
||||
final gate = Completer<int>();
|
||||
final first = state.runAsync(() => gate.future);
|
||||
await tester.pump();
|
||||
expect(state.busy, isTrue);
|
||||
|
||||
// Debug builds assert (loud failure during development); release builds
|
||||
// refuse the call with null so the busy flag can't be corrupted.
|
||||
await expectLater(state.runAsync(() async => 1), throwsAssertionError);
|
||||
expect(state.busy, isTrue, reason: 'the in-flight run is unaffected');
|
||||
|
||||
gate.complete(7);
|
||||
expect(await first, 7);
|
||||
expect(state.busy, isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user