@tduncan / Cortana / commits / 1b3cbb6

fix(chat): guard _saveMessage against disposed Ref before accessing state

`_saveMessage` evaluated `_isInMemoryChat` (which reads `state`) before checking `ref.mounted`. On a disposed notifier, the `state` getter throws "Cannot use the Ref of NotifierProvider ... after it has been disposed", so the mounted check never ran and the app crashed. Reorder the guard so `ref.mounted` is checked first, then short-circuit on `_isInMemoryChat`. Also add a `ref.mounted` check between the two `_saveMessage` awaits and the subsequent `_isInMemoryChat` access in `sendMessage` (line 870 in the stack trace). Add a regression test that disposes the container and then invokes `_saveMessage` to confirm it no longer throws. Fixes #73 Co-Authored-By: Opus 4.8 <noreply@puku.sh>

abdulmominsakib committed Aug 31, 2026 at 12:40 UTC 1b3cbb6de4038bff61d4c8fc7c967dad4b650ee6
2 files changed +89 -1
lib/features/chat/providers/chat_notifier.dart
+13 -1
@@ -2,6 +2,7 @@ import 'dart:async';
2 import 'dart:io';
3 import 'dart:math';
4
5 +import 'package:flutter/foundation.dart';
6 import 'package:flutter_riverpod/flutter_riverpod.dart';
7 import 'package:localmind/core/logger/app_logger.dart';
8 import 'package:localmind/core/models/enums.dart';
@@ -869,6 +870,8 @@ class ChatNotifier extends Notifier<ChatState> {
870 await _saveMessage(userMessage);
871 await _saveMessage(assistantMessage);
872
873 + if (!ref.mounted) return;
874 +
875 if (!_isInMemoryChat && _currentConversationId != null) {
876 final preview = trimmedContent.isNotEmpty
877 ? (trimmedContent.length > 100
@@ -2567,7 +2570,10 @@ class ChatNotifier extends Notifier<ChatState> {
2570 }
2571
2572 Future<void> _saveMessage(Message message) async {
2570 - if (_isInMemoryChat || !ref.mounted) return;
2573 + // Check `ref.mounted` before touching `state` (via `_isInMemoryChat`).
2574 + // On a disposed notifier, accessing `state` throws "Cannot use the Ref of
2575 + // NotifierProvider ... after it has been disposed" — see issue #73.
2576 + if (!ref.mounted || _isInMemoryChat) return;
2577 final db = ref.read(databaseProvider);
2578 await db.store.runInTransactionAsync(
2579 TxMode.write,
@@ -2576,6 +2582,12 @@ class ChatNotifier extends Notifier<ChatState> {
2582 );
2583 }
2584
2585 + /// Test-only hook that exposes [_saveMessage] so we can verify it is safe
2586 + /// to call on a disposed notifier (issue #73).
2587 + @visibleForTesting
2588 + Future<void> debugSaveMessageForTest(Message message) =>
2589 + _saveMessage(message);
2590 +
2591 static void _saveMessageInBackground(Store store, Message message) {
2592 final box = store.box<MessageEntity>();
2593 final convBox = store.box<ConversationEntity>();
test/features/chat/providers/chat_notifier_disposed_ref_test.dart new
+76
@@ -0,0 +1,76 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/core/models/enums.dart';
4 +import 'package:localmind/features/chat/data/models/message.dart';
5 +import 'package:localmind/features/chat/providers/chat_notifier.dart';
6 +import 'package:localmind/features/on_device/providers/on_device_providers.dart';
7 +import 'package:localmind/features/servers/data/models/server.dart';
8 +import 'package:localmind/features/servers/providers/server_providers.dart';
9 +
10 +// Regression test for issue #73:
11 +// "Crash: Cannot use the Ref of NotifierProvider<ChatNotifier, ChatState>...
12 +// after it has been disposed."
13 +//
14 +// The bug: `_saveMessage` evaluated `_isInMemoryChat` (which reads `state`)
15 +// BEFORE checking `ref.mounted`. On a disposed notifier, reading `state`
16 +// throws. The fix is to check `ref.mounted` first, then fall back to the
17 +// `_isInMemoryChat` short-circuit.
18 +void main() {
19 + test(
20 + '_saveMessage does not throw when the provider is disposed before call',
21 + () async {
22 + final container = ProviderContainer(
23 + overrides: [
24 + activeServerProvider.overrideWith(_ConnectedServerNotifier.new),
25 + onDeviceEngineProvider.overrideWith(_EmptyEngineNotifier.new),
26 + chatProvider.overrideWith(_DisposedChatNotifier.new),
27 + ],
28 + );
29 +
30 + // Read the notifier once to instantiate it, then dispose the container
31 + // so any subsequent call to `state` would normally throw
32 + // "Cannot use the Ref ... after it has been disposed".
33 + final notifier = container.read(chatProvider.notifier);
34 + container.dispose();
35 +
36 + // This used to throw before the fix because `_saveMessage` touched
37 + // `_isInMemoryChat` (=> `state`) before `ref.mounted`.
38 + expect(
39 + () => notifier.debugSaveMessageForTest(_dummyMessage()),
40 + returnsNormally,
41 + );
42 + },
43 + );
44 +}
45 +
46 +Message _dummyMessage() => Message(
47 + id: 'msg-1',
48 + conversationId: 'conv-1',
49 + role: MessageRole.user,
50 + content: 'hello',
51 + createdAt: DateTime.utc(2026, 8, 29),
52 +);
53 +
54 +class _EmptyEngineNotifier extends OnDeviceEngineNotifier {
55 + @override
56 + OnDeviceEngineState build() => const OnDeviceEngineState();
57 +}
58 +
59 +class _ConnectedServerNotifier extends ActiveServerNotifier {
60 + @override
61 + Server? build() => Server(
62 + id: 'on-device',
63 + name: 'On-Device',
64 + type: ServerType.onDevice,
65 + host: '',
66 + port: 0,
67 + createdAt: DateTime.utc(2026, 8, 13),
68 + lastConnectedAt: DateTime.utc(2026, 8, 13),
69 + status: ConnectionStatus.connected,
70 + );
71 +}
72 +
73 +class _DisposedChatNotifier extends ChatNotifier {
74 + @override
75 + ChatState build() => const ChatState();
76 +}
\ No newline at end of file