fix(settings): refuse overlapping runAsync at runtime

This commit is contained in:
edde746
2026-06-12 17:12:03 +02:00
parent a6d82b4637
commit 0de856e929
2 changed files with 70 additions and 15 deletions
@@ -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);
});
}