fix(runtime): close async cleanup gaps
Chat actions can outlive their Riverpod notifier while an active stream is being aborted, and stopping TTS previously left stale audio sources available. Recheck provider lifetime after awaits and clear the player playlist when sessions stop or restart.
abdulmominsakib committed
Sep 21, 2026 at 11:55 UTC
9d59491bc4eb9cd6d2c17516de9ff1ae7d5b679b
4 files changed
+51
lib/features/chat/providers/chat_notifier.dart
+4
@@ -501,6 +501,7 @@ class ChatNotifier extends Notifier<ChatState> {
501
502
Future<void> startNewConversation() async {
503
await _abortStreamImmediately();
504
+ if (!ref.mounted) return;
505
506
_currentConversationId = null;
507
_ephemeralConversationId = null;
@@ -778,6 +779,7 @@ class ChatNotifier extends Notifier<ChatState> {
779
final chatService = ref.read(chatServiceProvider);
780
781
await _abortStreamImmediately();
782
+ if (!ref.mounted) return;
783
784
// Stop the STT listener so the mic doesn't stay red while the model
785
// thinks. The chat input bar's `ref.listen` will re-start it once TTS
@@ -2033,6 +2035,7 @@ class ChatNotifier extends Notifier<ChatState> {
2035
if (server == null || _activeConversationId == null) return;
2036
2037
await _abortStreamImmediately();
2038
+ if (!ref.mounted) return;
2039
2040
final assistantMessage = Message(
2041
id: generateUuid(),
@@ -2474,6 +2477,7 @@ class ChatNotifier extends Notifier<ChatState> {
2477
},
2478
);
2479
} catch (e) {
2480
+ if (!ref.mounted) return;
2481
state = state.copyWith(
2482
isStreaming: false,
2483
errorMessage: e.toString(),
lib/features/tts/providers/tts_providers.dart
+2
@@ -830,6 +830,7 @@ class TtsNotifier extends Notifier<TtsState> {
830
_playlistBuffer.clear();
831
_nextPlaylistIndexToAdd = 0;
832
await _player.stop();
833
+ await _player.clearAudioSources();
834
835
try {
836
AudioSession.instance.then((session) {
@@ -1094,6 +1095,7 @@ class TtsNotifier extends Notifier<TtsState> {
1095
}
1096
try {
1097
await _player.stop();
1098
+ await _player.clearAudioSources();
1099
} catch (_) {}
1100
await _flutterTts?.stop();
1101
_cleanupSessionFiles(_currentSessionId, _chunks.length);
test/features/chat/providers/chat_notifier_disposed_ref_test.dart
+17
@@ -41,6 +41,23 @@ void main() {
41
);
42
},
43
);
44
+
45
+ test(
46
+ 'startNewConversation stops safely when disposed during stream cleanup',
47
+ () async {
48
+ final container = ProviderContainer(
49
+ overrides: [
50
+ activeServerProvider.overrideWith(_ConnectedServerNotifier.new),
51
+ onDeviceEngineProvider.overrideWith(_EmptyEngineNotifier.new),
52
+ chatProvider.overrideWith(_DisposedChatNotifier.new),
53
+ ],
54
+ );
55
+ final notifier = container.read(chatProvider.notifier);
56
+ container.dispose();
57
+
58
+ await expectLater(notifier.startNewConversation(), completes);
59
+ },
60
+ );
61
}
62
63
Message _dummyMessage() => Message(
test/features/issue_crash_fixes_test.dart
+28
@@ -1129,6 +1129,34 @@ void main() {
1129
expect(notifier.player.audioSource, isNull);
1130
},
1131
);
1132
+
1133
+ test('stopping playback can clear a previously loaded playlist', () async {
1134
+ final tempFile = File(
1135
+ '${Directory.systemTemp.path}/tts_chunk_test_86_clear.wav',
1136
+ );
1137
+ await tempFile.writeAsBytes(Uint8List(100));
1138
+ final player = AudioPlayer();
1139
+ addTearDown(() async {
1140
+ await player.dispose();
1141
+ if (await tempFile.exists()) await tempFile.delete();
1142
+ });
1143
+ await player.setAudioSources([
1144
+ AudioSource.file(
1145
+ tempFile.path,
1146
+ tag: const MediaItem(
1147
+ id: 'tts_chunk_clear',
1148
+ album: 'LocalMind TTS',
1149
+ title: 'Clear playlist',
1150
+ ),
1151
+ ),
1152
+ ], preload: false);
1153
+ expect(player.audioSources, hasLength(1));
1154
+
1155
+ await player.stop();
1156
+ await player.clearAudioSources();
1157
+
1158
+ expect(player.audioSources, isEmpty);
1159
+ });
1160
});
1161
}
1162