@tduncan / Cortana / commits / 956c889

fix(providers): guard asynchronous Riverpod operations against disposed Ref access

De-obfuscate and fix production crashes caused by accessing `Ref` or mutating state on disposed Riverpod notifiers across asynchronous boundaries: - Guard `autoSelectFirstLoadedModelProvider` and `_loadDefaultModel` after async API calls and model resolution. (fixes #51) - Guard `ModelMetadataNotifier.loadForServer`, `toggleFavorite`, and `setNote` against unmounted ref/state mutations. (fixes #50) - Guard async operations, timers, and platform callbacks in `LmCatalogSearchNotifier`, `LmDownloadManagerNotifier`, `ChatNotifier`, `CloudSyncController`, `OnDeviceEngineNotifier`, `SttNotifier`, `VoiceModeNotifier`, `ServersNotifier`, `ConversationsNotifier`, `SavedMessagesNotifier`, and `PersonasNotifier`. (fixes #47) - Add disposal regression tests for `autoSelectFirstLoadedModelProvider` and `ModelMetadataNotifier`. Fixes #51, Fixes #50, Fixes #47

abdulmominsakib committed Aug 15, 2026 at 09:43 UTC 956c889af58911c8724c7253a4ab72449d9aebfa
14 files changed +296 -52
lib/features/chat/providers/chat_notifier.dart
+35 -15
@@ -231,6 +231,7 @@ class ChatNotifier extends Notifier<ChatState> {
231 Message finalMessage, {
232 required bool isCurrentContext,
233 }) async {
234 + if (!ref.mounted) return;
235 final timeline = isCurrentContext
236 ? state.messages
237 : MessageVariants.resolveActiveTimeline(
@@ -244,6 +245,7 @@ class ChatNotifier extends Notifier<ChatState> {
245 ),
246 );
247
248 + if (!ref.mounted) return;
249 final preview = finalMessage.content.length > 100
250 ? '${finalMessage.content.substring(0, 100)}...'
251 : finalMessage.content;
@@ -260,6 +262,7 @@ class ChatNotifier extends Notifier<ChatState> {
262 characterCount: totalChars,
263 preview: preview,
264 );
265 + if (!ref.mounted) return;
266 _recomputeConversationTotal(conversationId, messages: timeline);
267
268 if (isCurrentContext) {
@@ -315,6 +318,7 @@ class ChatNotifier extends Notifier<ChatState> {
318 }
319
320 Future<void> _tryResumeLastChat() async {
321 + if (!ref.mounted) return;
322 final settings = ref.read(settingsProvider);
323 if (!settings.resumeLastChat) return;
324 if (state.messages.isNotEmpty || _currentConversationId != null) return;
@@ -325,6 +329,7 @@ class ChatNotifier extends Notifier<ChatState> {
329
330 try {
331 final conversations = await ref.read(conv.conversationsProvider.future);
332 + if (!ref.mounted) return;
333 Conversation? target;
334 for (final conversation in conversations) {
335 if (conversation.id == lastId && !conversation.isTemporary) {
@@ -341,6 +346,7 @@ class ChatNotifier extends Notifier<ChatState> {
346 }
347
348 void _persistLastActiveConversation(String conversationId) {
349 + if (!ref.mounted) return;
350 if (!ref.read(settingsProvider).resumeLastChat) return;
351 ref
352 .read(sharedPreferencesProvider)
@@ -349,6 +355,7 @@ class ChatNotifier extends Notifier<ChatState> {
355
356 Future<void> loadConversation(Conversation conversation) async {
357 await cancelStream();
358 + if (!ref.mounted) return;
359 _currentConversationId = conversation.id;
360 ref.read(smartReplyServiceProvider).reset();
361 state = state.copyWith(
@@ -366,6 +373,8 @@ class ChatNotifier extends Notifier<ChatState> {
373 conversation.id,
374 );
375
376 + if (!ref.mounted) return;
377 +
378 state = ChatState(
379 allMessages: messages,
380 messages: MessageVariants.resolveActiveTimeline(messages),
@@ -385,10 +394,12 @@ class ChatNotifier extends Notifier<ChatState> {
394 .setEnabled(conversation.mcpEnabled ?? false);
395 } catch (e, stackTrace) {
396 Log.fatal(error: e, stackTrace: stackTrace);
388 - state = state.copyWith(
389 - isLoading: false,
390 - errorMessage: 'Failed to load conversation: $e',
391 - );
397 + if (ref.mounted) {
398 + state = state.copyWith(
399 + isLoading: false,
400 + errorMessage: 'Failed to load conversation: $e',
401 + );
402 + }
403 }
404 }
405
@@ -711,6 +722,7 @@ class ChatNotifier extends Notifier<ChatState> {
722 isTemporary: false,
723 folderId: ref.read(pendingNewChatFolderIdProvider.notifier).consume(),
724 );
725 + if (!ref.mounted) return;
726 _currentConversationId = conversation.id;
727 ref
728 .read(conv.activeConversationIdProvider.notifier)
@@ -1539,6 +1551,7 @@ class ChatNotifier extends Notifier<ChatState> {
1551 if (!_titleGenerationsInFlight.add(conversationId)) return;
1552 try {
1553 final title = await generateTitleWithAi(conversationId);
1554 + if (!ref.mounted) return;
1555 if (title == null || title.isEmpty || title == 'New Chat') return;
1556
1557 await ref
@@ -1551,6 +1564,7 @@ class ChatNotifier extends Notifier<ChatState> {
1564
1565 Future<String?> generateTitleWithAi(String conversationId) async {
1566 final messages = await _loadMessagesForConversation(conversationId);
1567 + if (!ref.mounted) return null;
1568 if (messages.isEmpty) return null;
1569
1570 final timeline = MessageVariants.resolveActiveTimeline(messages);
@@ -1583,6 +1597,7 @@ class ChatNotifier extends Notifier<ChatState> {
1597 params: ref.read(chatParamsProvider),
1598 );
1599
1600 + if (!ref.mounted) return null;
1601 if (generated != null && generated.isNotEmpty) {
1602 return generated;
1603 }
@@ -2085,18 +2100,20 @@ class ChatNotifier extends Notifier<ChatState> {
2100 stopReason: 'error',
2101 );
2102 await _saveMessage(streamingAssistantMessage);
2088 - if (isCurrentContext) {
2089 - _replaceMessageInAll(
2090 - streamingAssistantMessage,
2091 - clearStreaming: true,
2092 - );
2093 - state = state.copyWith(
2094 - isStreaming: false,
2095 - clearStreaming: true,
2096 - );
2103 + if (ref.mounted) {
2104 + if (isCurrentContext) {
2105 + _replaceMessageInAll(
2106 + streamingAssistantMessage,
2107 + clearStreaming: true,
2108 + );
2109 + state = state.copyWith(
2110 + isStreaming: false,
2111 + clearStreaming: true,
2112 + );
2113 + }
2114 + ref.read(isStreamingProvider.notifier).setStreaming(false);
2115 + ref.read(chatBackgroundServiceProvider).stop();
2116 }
2098 - ref.read(isStreamingProvider.notifier).setStreaming(false);
2099 - ref.read(chatBackgroundServiceProvider).stop();
2117 break;
2118 case ChatResponseType.done:
2119 if (response.stats != null) {
@@ -2127,6 +2144,7 @@ class ChatNotifier extends Notifier<ChatState> {
2144 stopReason: 'complete',
2145 );
2146 await _saveMessage(finalMessage);
2147 + if (!ref.mounted) return;
2148 if (isCurrentContext) {
2149 _replaceMessageInAll(finalMessage, clearStreaming: true);
2150 state = state.copyWith(
@@ -2141,6 +2159,7 @@ class ChatNotifier extends Notifier<ChatState> {
2159 finalMessage,
2160 isCurrentContext: isCurrentContext,
2161 );
2162 + if (!ref.mounted) return;
2163 _maybeRequestReviewAfterSuccessfulCompletion(
2164 finalMessage: finalMessage,
2165 server: server,
@@ -2176,6 +2195,7 @@ class ChatNotifier extends Notifier<ChatState> {
2195 isProcessing: false,
2196 );
2197 await _saveMessage(errorMessage);
2198 + if (!ref.mounted) return;
2199 if (isCurrentContext) {
2200 _replaceMessageInAll(errorMessage, clearStreaming: true);
2201 state = state.copyWith(
lib/features/chat/providers/model_selection_providers.dart
+14 -2
@@ -157,6 +157,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
157
158 if (activeServer == null) {
159 await Future.value();
160 + if (!ref.mounted) return;
161 ref.read(selectedModelProvider.notifier).clear();
162 return;
163 }
@@ -167,6 +168,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
168
169 if (selectedModel != null && selectedModel.serverId != activeServer.id) {
170 await Future.value();
171 + if (!ref.mounted) return;
172 ref.read(selectedModelProvider.notifier).clear();
173 } else if (selectedModel != null) {
174 return;
@@ -197,11 +199,13 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
199 } else {
200 final apiService = ref.read(serverApiServiceProvider);
201 loadedModels = await apiService.fetchRunningModels(activeServer);
202 + if (!ref.mounted) return;
203 }
204
205 final availableModels = await ref.read(
206 availableModelsProvider(activeServer.id).future,
207 );
208 + if (!ref.mounted) return;
209
210 final typedModels = availableModels.cast<ModelInfo>();
211 if (typedModels.isEmpty) return;
@@ -216,6 +220,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
220 // auto-select the default when it's the model the engine already
221 // has loaded; otherwise fall through to the loaded-model logic.
222 if (isModelKeyLoaded(loadedModels, defaultModel.id)) {
223 + if (!ref.mounted) return;
224 ref.read(selectedModelProvider.notifier).setModel(defaultModel);
225 return;
226 }
@@ -228,6 +233,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
233 if (!isModelKeyLoaded(loadedModels, defaultModel.id) &&
234 activeServer.type == ServerType.lmStudio) {
235 await _loadDefaultModel(ref, activeServer, defaultModel);
236 + if (!ref.mounted) return;
237 }
238 ref.read(selectedModelProvider.notifier).setModel(defaultModel);
239 return;
@@ -248,7 +254,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
254 .where((m) => isModelKeyLoaded(loadedModels, m.id))
255 .firstOrNull;
256
251 - if (firstLoadedModel != null) {
257 + if (firstLoadedModel != null && ref.mounted) {
258 ref.read(selectedModelProvider.notifier).setModel(firstLoadedModel);
259 }
260 } catch (e) {
@@ -263,6 +269,7 @@ Future<void> _loadDefaultModel(
269 Server activeServer,
270 ModelInfo model,
271 ) async {
272 + if (!ref.mounted) return;
273 final apiService = ref.read(serverApiServiceProvider);
274 final settings = ref.read(settingsProvider);
275 final contextLength = ref.read(chatParamsProvider).contextLength;
@@ -273,16 +280,21 @@ Future<void> _loadDefaultModel(
280 final instances = await ref.read(
281 loadedModelsProvider(activeServer).future,
282 );
283 + if (!ref.mounted) return;
284 await apiService.unloadAllInstances(activeServer, instances);
285 + if (!ref.mounted) return;
286 }
287 await apiService.loadModelWithInstanceId(
288 activeServer,
289 model.id,
290 contextLength: contextLength,
291 );
292 + if (!ref.mounted) return;
293 ref.invalidate(loadedModelsProvider(activeServer));
294 } finally {
285 - ref.read(modelLoadingProvider.notifier).setLoaded();
295 + if (ref.mounted) {
296 + ref.read(modelLoadingProvider.notifier).setLoaded();
297 + }
298 }
299 }
300
lib/features/cloud_sync/providers/cloud_sync_providers.dart
+13 -6
@@ -142,20 +142,25 @@ class CloudSyncController extends Notifier<CloudSyncStatus> {
142 }
143
144 Future<void> syncNow({bool rethrowErrors = false}) async {
145 - if (_running) {
145 + if (_running || !ref.mounted) {
146 _pending = true;
147 return;
148 }
149 final config = _local.loadConfig();
150 if (config == null || !config.enabled) return;
151 final credentials = await _local.loadCredentials();
152 + if (!ref.mounted) return;
153 final masterKey = await _local.loadMasterKey();
154 + if (!ref.mounted) return;
155 final salt = await _local.loadSalt();
156 + if (!ref.mounted) return;
157 if (credentials == null || masterKey == null || salt == null) {
155 - state = state.copyWith(
156 - phase: CloudSyncPhase.locked,
157 - message: 'Cloud sync must be unlocked again on this device.',
158 - );
158 + if (ref.mounted) {
159 + state = state.copyWith(
160 + phase: CloudSyncPhase.locked,
161 + message: 'Cloud sync must be unlocked again on this device.',
162 + );
163 + }
164 return;
165 }
166
@@ -174,6 +179,7 @@ class CloudSyncController extends Notifier<CloudSyncStatus> {
179 salt,
180 generation,
181 );
182 + if (!ref.mounted) return;
183 _ensureActive(generation);
184 _retryIndex = 0;
185 _retryTimer?.cancel();
@@ -185,6 +191,7 @@ class CloudSyncController extends Notifier<CloudSyncStatus> {
191 warnings: warnings,
192 );
193 } catch (error) {
194 + if (!ref.mounted) return;
195 if (error is CloudSyncFailure &&
196 error.kind == CloudSyncFailureKind.cancelled) {
197 return;
@@ -197,7 +204,7 @@ class CloudSyncController extends Notifier<CloudSyncStatus> {
204 if (rethrowErrors) rethrow;
205 } finally {
206 _running = false;
200 - if (_pending) {
207 + if (_pending && ref.mounted) {
208 _pending = false;
209 scheduleSync(delay: Duration.zero);
210 }
lib/features/conversations/providers/conversation_providers.dart
+2
@@ -26,6 +26,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
26 }
27
28 Future<List<Conversation>> _loadAll() async {
29 + if (!ref.mounted) return [];
30 final db = ref.read(databaseProvider);
31 return await db.store.runInTransactionAsync(
32 TxMode.read,
@@ -839,6 +840,7 @@ class ConversationFoldersNotifier extends AsyncNotifier<List<ConversationFolder>
840 Future<List<ConversationFolder>> build() async => _loadAll();
841
842 Future<List<ConversationFolder>> _loadAll() async {
843 + if (!ref.mounted) return [];
844 final db = ref.read(databaseProvider);
845 return db.store.runInTransactionAsync(
846 TxMode.read,
lib/features/lm_studio_catalog/providers/lm_studio_catalog_providers.dart
+25 -5
@@ -96,12 +96,12 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
96
97 try {
98 final staffPicks = await ref.read(lmStudioStaffPicksProvider.future);
99 - if (generation != _searchGeneration) return;
99 + if (!ref.mounted || generation != _searchGeneration) return;
100 final staffMatches = staffPicks
101 .where((m) => m.matchesQuery(trimmed))
102 .toList();
103 final page = await service.searchHuggingFace(query: trimmed);
104 - if (generation != _searchGeneration) return;
104 + if (!ref.mounted || generation != _searchGeneration) return;
105 final staffIds = staffMatches.map((m) => m.id).toSet();
106 final community = page.models
107 .where((m) => !staffIds.contains(m.id))
@@ -115,7 +115,7 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
115 isLoading: false,
116 );
117 } catch (e) {
118 - if (generation != _searchGeneration) return;
118 + if (!ref.mounted || generation != _searchGeneration) return;
119 state = state.copyWith(isLoading: false, error: e.toString());
120 }
121 }
@@ -128,6 +128,7 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
128
129 try {
130 final page = await service.searchHuggingFace(nextUrl: state.nextUrl);
131 + if (!ref.mounted) return;
132 final existingIds = state.allModels.map((m) => m.id).toSet();
133 final more = page.models
134 .where((m) => !existingIds.contains(m.id))
@@ -140,6 +141,7 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
141 isLoadingMore: false,
142 );
143 } catch (e) {
144 + if (!ref.mounted) return;
145 state = state.copyWith(isLoadingMore: false, error: e.toString());
146 }
147 }
@@ -205,6 +207,7 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
207 /// was last closed, so a restart doesn't silently abandon tracking of a
208 /// download that's still running on the LM Studio server.
209 Future<void> _restoreActiveDownloads() async {
210 + if (!ref.mounted) return;
211 final prefs = ref.read(sharedPreferencesProvider);
212 final raw = prefs.getString(_storageKey);
213 if (raw == null || raw.isEmpty) return;
@@ -213,14 +216,18 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
216 try {
217 entries = jsonDecode(raw) as List<dynamic>;
218 } catch (_) {
216 - await prefs.remove(_storageKey);
219 + if (ref.mounted) {
220 + await prefs.remove(_storageKey);
221 + }
222 return;
223 }
224
225 final servers = await ref.read(serversProvider.future);
226 + if (!ref.mounted) return;
227 final service = ref.read(lmStudioDownloadServiceProvider);
228
229 for (final entry in entries) {
230 + if (!ref.mounted) return;
231 if (entry is! Map) continue;
232 final serverId = entry['serverId'] as String?;
233 final jobId = entry['jobId'] as String?;
@@ -243,6 +250,7 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
250 server: server,
251 job: placeholder,
252 );
253 + if (!ref.mounted) return;
254 _jobServerIds[jobId] = serverId;
255 _replaceJob(updated);
256
@@ -251,6 +259,7 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
259 } else if (updated.status == LmDownloadStatus.completed ||
260 updated.status == LmDownloadStatus.alreadyDownloaded) {
261 await _notifyCompleted(updated);
262 + if (!ref.mounted) return;
263 ref.invalidate(availableModelsProvider(server.id));
264 } else if (updated.status == LmDownloadStatus.failed) {
265 await _notifyFailed(updated);
@@ -263,10 +272,13 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
272 }
273 }
274
266 - await _persistActiveDownloads();
275 + if (ref.mounted) {
276 + await _persistActiveDownloads();
277 + }
278 }
279
280 Future<void> _persistActiveDownloads() async {
281 + if (!ref.mounted) return;
282 final prefs = ref.read(sharedPreferencesProvider);
283 final active = state.jobs.where((j) => j.status.isActive).toList();
284 if (active.isEmpty) {
@@ -289,6 +301,7 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
301 }
302
303 void _setJobs(List<LmDownloadJob> jobs) {
304 + if (!ref.mounted) return;
305 state = state.copyWith(jobs: jobs);
306 unawaited(_persistActiveDownloads());
307 }
@@ -309,6 +322,7 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
322 required LmModelDetail detail,
323 LmModelQuantOption? quant,
324 }) async {
325 + if (!ref.mounted) return;
326 final downloadService = ref.read(lmStudioDownloadServiceProvider);
327 final request = downloadService.buildDownloadRequest(
328 model: model,
@@ -319,6 +333,7 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
333 server: server,
334 request: request,
335 );
336 + if (!ref.mounted) return;
337
338 final updatedJobs = [...state.jobs];
339 if (job.jobId.isNotEmpty) {
@@ -333,6 +348,7 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
348 if (job.status == LmDownloadStatus.alreadyDownloaded ||
349 job.status == LmDownloadStatus.completed) {
350 await _notifyCompleted(job);
351 + if (!ref.mounted) return;
352 ref.invalidate(availableModelsProvider(server.id));
353 return;
354 }
@@ -350,12 +366,14 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
366 }
367
368 Future<void> _poll({required Server server, required String jobId}) async {
369 + if (!ref.mounted) return;
370 final current = state.jobs.where((j) => j.jobId == jobId).firstOrNull;
371 if (current == null) return;
372
373 try {
374 final service = ref.read(lmStudioDownloadServiceProvider);
375 final updated = await service.fetchStatus(server: server, job: current);
376 + if (!ref.mounted) return;
377 _replaceJob(updated);
378
379 if (updated.status == LmDownloadStatus.completed ||
@@ -363,6 +381,7 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
381 _pollers[jobId]?.cancel();
382 _pollers.remove(jobId);
383 await _notifyCompleted(updated);
384 + if (!ref.mounted) return;
385 ref.invalidate(availableModelsProvider(server.id));
386 } else if (updated.status == LmDownloadStatus.failed) {
387 _pollers[jobId]?.cancel();
@@ -370,6 +389,7 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
389 await _notifyFailed(updated);
390 }
391 } on LmDownloadJobNotFoundException {
392 + if (!ref.mounted) return;
393 _pollers[jobId]?.cancel();
394 _pollers.remove(jobId);
395 removeJob(jobId);
lib/features/models/providers/model_metadata_providers.dart
+3
@@ -22,6 +22,7 @@ class ModelMetadataNotifier extends Notifier<Map<String, ModelMetadata>> {
22
23 void loadForServer(String serverId) {
24 _serverId = serverId;
25 + if (!ref.mounted) return;
26 state = ref.read(modelMetadataRepositoryProvider).getAllForServer(serverId);
27 }
28
@@ -32,6 +33,7 @@ class ModelMetadataNotifier extends Notifier<Map<String, ModelMetadata>> {
33 await ref
34 .read(modelMetadataRepositoryProvider)
35 .setFavorite(serverId, modelId, !current);
36 + if (!ref.mounted) return;
37 loadForServer(serverId);
38 }
39
@@ -41,6 +43,7 @@ class ModelMetadataNotifier extends Notifier<Map<String, ModelMetadata>> {
43 await ref
44 .read(modelMetadataRepositoryProvider)
45 .setNote(serverId, modelId, note);
46 + if (!ref.mounted) return;
47 loadForServer(serverId);
48 }
49 }
lib/features/on_device/providers/on_device_providers.dart
+21 -4
@@ -235,6 +235,7 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
235 ref.read(onDeviceLlamaServiceProvider);
236
237 Future<void> loadModel(String modelId, PreferredBackend backend) async {
238 + if (!ref.mounted) return;
239 final models = ref.read(onDeviceModelsProvider);
240 final model = models.firstWhere(
241 (m) => m.id == modelId,
@@ -251,6 +252,7 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
252 // Protect the heavy native model-load from Android CPU throttling
253 final bgService = ref.read(chatBackgroundServiceProvider);
254 await bgService.start();
255 + if (!ref.mounted) return;
256
257 try {
258 final effectiveBackend = model.isCpuOnly ? PreferredBackend.cpu : backend;
@@ -258,12 +260,16 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
260 // Yield control to the event loop so the UI has time to draw the loading indicator
261 // and stabilize before the native platform thread begins heavy model loading.
262 await Future.delayed(const Duration(milliseconds: 100));
263 + if (!ref.mounted) return;
264 +
265 if (model.runtime == OnDeviceModelRuntime.llamaCpp) {
266 final file = File(model.localPath ?? '');
267 if (!await file.exists()) {
268 + if (!ref.mounted) return;
269 await ref
270 .read(importedGgufModelsProvider.notifier)
271 .removeMissingModel(modelId);
272 + if (!ref.mounted) return;
273 state = state.copyWith(
274 status: OnDeviceEngineStatus.error,
275 loadedModelId: null,
@@ -275,13 +281,16 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
281
282 final settings = ref.read(settingsProvider);
283 await _gemmaService.unloadModel();
284 + if (!ref.mounted) return;
285 await _llamaService.loadModel(
286 model,
287 contextLength: settings.contextLength,
288 useGpu: !model.isCpuOnly && effectiveBackend == PreferredBackend.gpu,
289 );
290 + if (!ref.mounted) return;
291 } else {
292 final isInstalled = await _gemmaService.isModelInstalled(modelId);
293 + if (!ref.mounted) return;
294 if (!isInstalled) {
295 state = state.copyWith(
296 status: OnDeviceEngineStatus.error,
@@ -293,7 +302,9 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
302 }
303
304 await _llamaService.unloadModel();
305 + if (!ref.mounted) return;
306 await _gemmaService.loadModel(modelId, effectiveBackend);
307 + if (!ref.mounted) return;
308 }
309
310 state = state.copyWith(
@@ -306,6 +317,7 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
317 final activeServer = ref.read(activeServerProvider);
318 if (activeServer?.type != ServerType.onDevice) {
319 final servers = await ref.read(serversProvider.future);
320 + if (!ref.mounted) return;
321 final onDeviceServer = servers
322 .where((s) => s.type == ServerType.onDevice)
323 .firstOrNull;
@@ -321,13 +333,16 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
333 );
334
335 try {
324 - await ref
325 - .read(reviewPromptServiceProvider)
326 - .markOnDeviceModelLoaded(modelId);
336 + if (ref.mounted) {
337 + await ref
338 + .read(reviewPromptServiceProvider)
339 + .markOnDeviceModelLoaded(modelId);
340 + }
341 } catch (e) {
342 Log.error('Failed to record model load review signal: $e');
343 }
344 } catch (e) {
345 + if (!ref.mounted) return;
346 Log.error('Failed to load model $modelId: $e');
347 state = state.copyWith(
348 status: OnDeviceEngineStatus.error,
@@ -351,7 +366,9 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
366 } catch (e) {
367 Log.error('Failed to unload llama.cpp model: $e');
368 }
354 - state = const OnDeviceEngineState();
369 + if (ref.mounted) {
370 + state = const OnDeviceEngineState();
371 + }
372 }
373
374 Future<void> disposeService() async {
lib/features/personas/providers/personas_providers.dart
+23 -4
@@ -110,6 +110,9 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
110 String? category,
111 Map<String, dynamic>? preferredParams,
112 }) async {
113 + if (!ref.mounted) {
114 + throw StateError('Cannot create persona on unmounted notifier');
115 + }
116 final db = ref.read(databaseProvider);
117 final now = DateTime.now();
118 final persona = Persona(
@@ -125,11 +128,15 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
128 preferredParams: preferredParams,
129 );
130 db.personaBox.put(PersonaEntity.fromDomain(persona));
128 - state = AsyncData(await _loadAll());
131 + final data = await _loadAll();
132 + if (ref.mounted) {
133 + state = AsyncData(data);
134 + }
135 return persona;
136 }
137
138 Future<void> updatePersona(Persona updated) async {
139 + if (!ref.mounted) return;
140 final db = ref.read(databaseProvider);
141 final persona = updated.copyWith(updatedAt: DateTime.now());
142
@@ -145,10 +152,14 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
152 }
153 db.personaBox.put(entity);
154
148 - state = AsyncData(await _loadAll());
155 + final data = await _loadAll();
156 + if (ref.mounted) {
157 + state = AsyncData(data);
158 + }
159 }
160
161 Future<void> deletePersona(String id) async {
162 + if (!ref.mounted) return;
163 final db = ref.read(databaseProvider);
164 final query = db.personaBox.query(PersonaEntity_.id.equals(id)).build();
165 db.personaBox.removeMany(query.findIds());
@@ -163,12 +174,16 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
174 }
175 convQuery.close();
176
166 - state = AsyncData(await _loadWithoutSeeding());
177 + final data = await _loadWithoutSeeding();
178 + if (ref.mounted) {
179 + state = AsyncData(data);
180 + }
181 }
182
183 /// Re-adds any built-in personas that were deleted, without touching
184 /// custom ones or duplicating built-ins that are still present.
185 Future<void> restoreBuiltInPersonas() async {
186 + if (!ref.mounted) return;
187 final db = ref.read(databaseProvider);
188 final existingIds = db.personaBox.getAll().map((e) => e.id).toSet();
189 for (final preset in builtInPersonas) {
@@ -176,10 +191,14 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
191 db.personaBox.put(PersonaEntity.fromDomain(preset));
192 }
193 }
179 - state = AsyncData(await _loadWithoutSeeding());
194 + final data = await _loadWithoutSeeding();
195 + if (ref.mounted) {
196 + state = AsyncData(data);
197 + }
198 }
199
200 Future<List<Persona>> _loadWithoutSeeding() async {
201 + if (!ref.mounted) return [];
202 final db = ref.read(databaseProvider);
203 final entities = db.personaBox.getAll();
204 final personas = entities.map((e) => e.toDomain()).toList();
lib/features/saved_messages/providers/saved_message_providers.dart
+32 -6
@@ -161,6 +161,7 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
161 Future<List<SavedMessage>> build() async => _loadAll();
162
163 Future<List<SavedMessage>> _loadAll() async {
164 + if (!ref.mounted) return [];
165 final db = ref.read(databaseProvider);
166 final entities = db.savedMessageBox.getAll();
167 final messages = entities
@@ -184,6 +185,7 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
185 }
186
187 Future<bool> isMessageSaved(String sourceMessageId) async {
188 + if (!ref.mounted) return false;
189 final db = ref.read(databaseProvider);
190 final query = db.savedMessageBox
191 .query(SavedMessageEntity_.sourceMessageId.equals(sourceMessageId))
@@ -194,6 +196,7 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
196 }
197
198 Future<SavedMessage?> getBySourceMessageId(String sourceMessageId) async {
199 + if (!ref.mounted) return null;
200 final db = ref.read(databaseProvider);
201 final query = db.savedMessageBox
202 .query(SavedMessageEntity_.sourceMessageId.equals(sourceMessageId))
@@ -220,6 +223,7 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
223 String? folderId,
224 bool isTemporaryChat = false,
225 }) async {
226 + if (!ref.mounted) return;
227 final db = ref.read(databaseProvider);
228 final existingQuery = db.savedMessageBox
229 .query(SavedMessageEntity_.sourceMessageId.equals(message.id))
@@ -231,7 +235,10 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
235 existing.folderId = folderId;
236 existing.content = message.content;
237 db.savedMessageBox.put(existing);
234 - state = AsyncData(await _loadAll());
238 + final data = await _loadAll();
239 + if (ref.mounted) {
240 + state = AsyncData(data);
241 + }
242 return;
243 }
244
@@ -250,30 +257,42 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
257 savedAt: DateTime.now(),
258 );
259 db.savedMessageBox.put(entity);
253 - state = AsyncData(await _loadAll());
260 + final data = await _loadAll();
261 + if (ref.mounted) {
262 + state = AsyncData(data);
263 + }
264 }
265
266 Future<void> removeBySourceMessageId(String sourceMessageId) async {
267 + if (!ref.mounted) return;
268 final db = ref.read(databaseProvider);
269 final query = db.savedMessageBox
270 .query(SavedMessageEntity_.sourceMessageId.equals(sourceMessageId))
271 .build();
272 db.savedMessageBox.removeMany(query.findIds());
273 query.close();
263 - state = AsyncData(await _loadAll());
274 + final data = await _loadAll();
275 + if (ref.mounted) {
276 + state = AsyncData(data);
277 + }
278 }
279
280 Future<void> deleteSavedMessage(String id) async {
281 + if (!ref.mounted) return;
282 final db = ref.read(databaseProvider);
283 final query = db.savedMessageBox
284 .query(SavedMessageEntity_.id.equals(id))
285 .build();
286 db.savedMessageBox.removeMany(query.findIds());
287 query.close();
273 - state = AsyncData(await _loadAll());
288 + final data = await _loadAll();
289 + if (ref.mounted) {
290 + state = AsyncData(data);
291 + }
292 }
293
294 Future<void> moveToFolder(String savedMessageId, String? folderId) async {
295 + if (!ref.mounted) return;
296 final db = ref.read(databaseProvider);
297 final query = db.savedMessageBox
298 .query(SavedMessageEntity_.id.equals(savedMessageId))
@@ -283,10 +302,14 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
302 if (entity == null) return;
303 entity.folderId = folderId;
304 db.savedMessageBox.put(entity);
286 - state = AsyncData(await _loadAll());
305 + final data = await _loadAll();
306 + if (ref.mounted) {
307 + state = AsyncData(data);
308 + }
309 }
310
311 Future<void> setArchived(String savedMessageId, bool archived) async {
312 + if (!ref.mounted) return;
313 final db = ref.read(databaseProvider);
314 final query = db.savedMessageBox
315 .query(SavedMessageEntity_.id.equals(savedMessageId))
@@ -296,7 +319,10 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
319 if (entity == null) return;
320 entity.isArchived = archived;
321 db.savedMessageBox.put(entity);
299 - state = AsyncData(await _loadAll());
322 + final data = await _loadAll();
323 + if (ref.mounted) {
324 + state = AsyncData(data);
325 + }
326 }
327 }
328
lib/features/servers/providers/server_providers.dart
+21 -4
@@ -193,19 +193,25 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
193 }
194
195 Future<List<Server>> _loadAll() async {
196 + if (!ref.mounted) return [];
197 final db = ref.read(databaseProvider);
198 final entities = db.serverBox.getAll();
199 return entities.map((e) => e.toDomain()).toList();
200 }
201
202 Future<void> addServer(Server server) async {
203 + if (!ref.mounted) return;
204 final db = ref.read(databaseProvider);
205 db.serverBox.put(ServerEntity.fromDomain(server));
206 invalidateAvailableModelsCache(server.id);
205 - state = AsyncData(await _loadAll());
207 + final data = await _loadAll();
208 + if (ref.mounted) {
209 + state = AsyncData(data);
210 + }
211 }
212
213 Future<void> updateServer(Server server) async {
214 + if (!ref.mounted) return;
215 final db = ref.read(databaseProvider);
216 final query = db.serverBox
217 .query(ServerEntity_.id.equals(server.id))
@@ -219,19 +225,27 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
225 }
226 db.serverBox.put(entity);
227 invalidateAvailableModelsCache(server.id);
222 - state = AsyncData(await _loadAll());
228 + final data = await _loadAll();
229 + if (ref.mounted) {
230 + state = AsyncData(data);
231 + }
232 }
233
234 Future<void> deleteServer(String serverId) async {
235 + if (!ref.mounted) return;
236 final db = ref.read(databaseProvider);
237 final query = db.serverBox.query(ServerEntity_.id.equals(serverId)).build();
238 db.serverBox.removeMany(query.findIds());
239 query.close();
240 invalidateAvailableModelsCache(serverId);
231 - state = AsyncData(await _loadAll());
241 + final data = await _loadAll();
242 + if (ref.mounted) {
243 + state = AsyncData(data);
244 + }
245 }
246
247 Future<void> setDefault(String serverId) async {
248 + if (!ref.mounted) return;
249 final db = ref.read(databaseProvider);
250 final servers = state.value ?? [];
251 final updatedServers = servers.map((s) {
@@ -252,7 +266,10 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
266 db.serverBox.put(entity);
267 invalidateAvailableModelsCache(server.id);
268 }
255 - state = AsyncData(await _loadAll());
269 + final data = await _loadAll();
270 + if (ref.mounted) {
271 + state = AsyncData(data);
272 + }
273 }
274
275 Future<void> updateServerStatus(
lib/features/stt/providers/stt_providers.dart
+9
@@ -64,10 +64,12 @@ class SttNotifier extends Notifier<SttState> {
64 try {
65 final available = await _speech.initialize(
66 onError: (val) {
67 + if (!ref.mounted) return;
68 Log.error('STT error: ${val.errorMsg} - permanent: ${val.permanent}');
69 state = state.copyWith(error: val.errorMsg, isListening: false);
70 },
71 onStatus: (val) {
72 + if (!ref.mounted) return;
73 Log.debug('STT status: $val');
74 // Only honour status updates from the active session; stale
75 // callbacks from a previous listen() (or callbacks fired while
@@ -80,10 +82,12 @@ class SttNotifier extends Notifier<SttState> {
82 }
83 },
84 );
85 + if (!ref.mounted) return false;
86 _isInit = true;
87 state = state.copyWith(isAvailable: available);
88 return available;
89 } catch (e) {
90 + if (!ref.mounted) return false;
91 Log.error('STT initialization failed: $e');
92 state = state.copyWith(isAvailable: false, error: e.toString());
93 return false;
@@ -96,6 +100,7 @@ class SttNotifier extends Notifier<SttState> {
100 SoundLevelChange? onSoundLevelChange,
101 }) async {
102 final available = await initSpeech();
103 + if (!ref.mounted) return;
104 if (!available) {
105 state = state.copyWith(
106 error: 'Speech recognition not available or permission denied',
@@ -114,6 +119,7 @@ class SttNotifier extends Notifier<SttState> {
119 try {
120 await _speech.listen(
121 onResult: (result) {
122 + if (!ref.mounted) return;
123 state = state.copyWith(recognizedWords: result.recognizedWords);
124 onResult(result.recognizedWords);
125 if (result.finalResult && onFinal != null) {
@@ -135,6 +141,7 @@ class SttNotifier extends Notifier<SttState> {
141 ),
142 );
143 } catch (e) {
144 + if (!ref.mounted) return;
145 Log.error('STT listen error: $e');
146 state = state.copyWith(isListening: false, error: e.toString());
147 }
@@ -147,6 +154,7 @@ class SttNotifier extends Notifier<SttState> {
154 _activeSession = -1;
155 try {
156 await _speech.stop();
157 + if (!ref.mounted) return;
158 state = state.copyWith(isListening: false);
159 } catch (e) {
160 Log.error('STT stop error: $e');
@@ -158,6 +166,7 @@ class SttNotifier extends Notifier<SttState> {
166 _activeSession = -1;
167 try {
168 await _speech.cancel();
169 + if (!ref.mounted) return;
170 state = state.copyWith(isListening: false);
171 } catch (e) {
172 Log.error('STT cancel error: $e');
lib/features/voice_mode/providers/voice_mode_provider.dart
+8 -6
@@ -149,7 +149,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
149
150 /// Start listening for user speech.
151 Future<void> startListening() async {
152 - if (!_active) return;
152 + if (!_active || !ref.mounted) return;
153 if (!_ensureChatTarget()) return;
154
155 _isSendingTranscript = false;
@@ -167,13 +167,13 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
167 final stt = ref.read(sttProvider.notifier);
168 await stt.startListening(
169 onResult: (words) {
170 - if (!_active) return;
170 + if (!_active || !ref.mounted) return;
171 if (words.isNotEmpty) {
172 state = state.copyWith(transcript: words);
173 }
174 },
175 onFinal: (finalWords) async {
176 - if (!_active) return;
176 + if (!_active || !ref.mounted) return;
177 if (!state.autoListen) return;
178 if (_isSendingTranscript) return;
179 final text = finalWords.trim().isNotEmpty
@@ -184,7 +184,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
184 await stopListeningAndSend();
185 },
186 onSoundLevelChange: (level) {
187 - if (!_active) return;
187 + if (!_active || !ref.mounted) return;
188 // Skip the rebuild when the value hasn't materially changed
189 // (riverpod notifies listeners on every distinct copyWith).
190 if ((state.micLevel - level).abs() < 0.01) return;
@@ -195,10 +195,11 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
195
196 /// Stop listening and send the captured transcript to the LLM.
197 Future<void> stopListeningAndSend() async {
198 - if (!_active) return;
198 + if (!_active || !ref.mounted) return;
199
200 final stt = ref.read(sttProvider.notifier);
201 await stt.stopListening();
202 + if (!_active || !ref.mounted) return;
203
204 final transcript = state.transcript.trim();
205 if (transcript.isEmpty) {
@@ -225,6 +226,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
226 try {
227 await ref.read(chatProvider.notifier).sendMessage(transcript);
228 } catch (e) {
229 + if (!_active || !ref.mounted) return;
230 Log.error('Voice mode send error: $e');
231 state = state.copyWith(phase: VoiceModePhase.error, error: e.toString());
232 }
@@ -340,7 +342,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
342 conversationId: lastMessage.conversationId,
343 );
344 } catch (e) {
343 - if (!_active) return;
345 + if (!_active || !ref.mounted) return;
346 Log.error('Voice mode TTS speak failed: $e');
347 state = state.copyWith(
348 phase: VoiceModePhase.error,
test/features/chat/providers/model_selection_providers_test.dart
+36
@@ -137,6 +137,42 @@ void main() {
137 final selected = container.read(selectedModelProvider);
138 expect(selected?.id, 'model-a');
139 });
140 +
141 + test('does not throw or access disposed Ref when container is disposed during execution',
142 + () async {
143 + SharedPreferences.setMockInitialValues({
144 + 'appSettings': AppSettings(
145 + defaultModelId: 'model-a',
146 + defaultModelServerId: 'server-1',
147 + ).toJson(),
148 + });
149 + final prefs = await SharedPreferences.getInstance();
150 +
151 + final api = _FakeServerApiService(loadedModelIds: const {});
152 + final container = ProviderContainer(
153 + overrides: [
154 + sharedPreferencesProvider.overrideWithValue(prefs),
155 + activeServerProvider.overrideWith(_StubLmStudioActiveServer.new),
156 + connectionStatusProvider.overrideWith(
157 + _StubConnectedStatusNotifier.new,
158 + ),
159 + availableModelsProvider('server-1').overrideWith(
160 + (ref) async {
161 + await Future.delayed(const Duration(milliseconds: 50));
162 + return _models();
163 + },
164 + ),
165 + serverApiServiceProvider.overrideWithValue(api),
166 + ],
167 + );
168 +
169 + // Start the future and dispose container immediately
170 + final future = container.read(autoSelectFirstLoadedModelProvider.future);
171 + container.dispose();
172 +
173 + // Should complete without uncaught StateError
174 + expect(future, completes);
175 + });
176 });
177 }
178 List<ModelInfo> _models() => [
test/features/models/model_metadata_providers_test.dart new
+54
@@ -0,0 +1,54 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/core/providers/storage_providers.dart';
4 +import 'package:localmind/features/models/data/model_metadata_repository.dart';
5 +import 'package:localmind/features/models/providers/model_metadata_providers.dart';
6 +import 'package:shared_preferences/shared_preferences.dart';
7 +
8 +void main() {
9 + group('ModelMetadataNotifier (Issue #50)', () {
10 + test('loads metadata for server and toggles favorite', () async {
11 + SharedPreferences.setMockInitialValues({});
12 + final prefs = await SharedPreferences.getInstance();
13 +
14 + final container = ProviderContainer(
15 + overrides: [
16 + sharedPreferencesProvider.overrideWithValue(prefs),
17 + ],
18 + );
19 + addTearDown(container.dispose);
20 +
21 + final notifier = container.read(modelMetadataProvider.notifier);
22 + notifier.loadForServer('server-1');
23 +
24 + expect(container.read(modelMetadataProvider), isEmpty);
25 +
26 + await notifier.toggleFavorite('model-1');
27 + expect(container.read(modelMetadataProvider)['model-1']?.isFavorite, isTrue);
28 +
29 + await notifier.setNote('model-1', 'My favorite model');
30 + expect(container.read(modelMetadataProvider)['model-1']?.note, 'My favorite model');
31 + });
32 +
33 + test('does not throw or access disposed Ref when disposed during async write', () async {
34 + SharedPreferences.setMockInitialValues({});
35 + final prefs = await SharedPreferences.getInstance();
36 +
37 + final container = ProviderContainer(
38 + overrides: [
39 + sharedPreferencesProvider.overrideWithValue(prefs),
40 + ],
41 + );
42 +
43 + final notifier = container.read(modelMetadataProvider.notifier);
44 + notifier.loadForServer('server-1');
45 +
46 + // Start async toggleFavorite and dispose container immediately
47 + final future = notifier.toggleFavorite('model-1');
48 + container.dispose();
49 +
50 + // Completing without throwing StateError confirms ref.mounted guard works
51 + await expectLater(future, completes);
52 + });
53 + });
54 +}