@tduncan / Cortana / commits / 9d59491

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