Fix race conditions

This commit is contained in:
zach
2025-12-30 21:42:14 -07:00
parent 83b2817cc4
commit be97e5c7fc
4 changed files with 197 additions and 10 deletions
+45 -1
View File
@@ -117,6 +117,7 @@ class MeshCoreConnector extends ChangeNotifier {
final ContactStore _contactStore = ContactStore(); final ContactStore _contactStore = ContactStore();
final UnreadStore _unreadStore = UnreadStore(); final UnreadStore _unreadStore = UnreadStore();
final Map<int, bool> _channelSmazEnabled = {}; final Map<int, bool> _channelSmazEnabled = {};
bool _lastSentWasCliCommand = false; // Track if last sent message was a CLI command
final Map<String, bool> _contactSmazEnabled = {}; final Map<String, bool> _contactSmazEnabled = {};
final Set<String> _knownContactKeys = {}; final Set<String> _knownContactKeys = {};
final Map<String, int> _contactLastReadMs = {}; final Map<String, int> _contactLastReadMs = {};
@@ -459,6 +460,7 @@ class MeshCoreConnector extends ChangeNotifier {
addMessageCallback: _addMessage, addMessageCallback: _addMessage,
updateMessageCallback: _updateMessage, updateMessageCallback: _updateMessage,
clearContactPathCallback: clearContactPath, clearContactPathCallback: clearContactPath,
setContactPathCallback: setContactPath,
calculateTimeoutCallback: (pathLength, messageBytes) => calculateTimeoutCallback: (pathLength, messageBytes) =>
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes), calculateTimeout(pathLength: pathLength, messageBytes: messageBytes),
appSettingsService: appSettingsService, appSettingsService: appSettingsService,
@@ -1040,6 +1042,24 @@ class MeshCoreConnector extends ChangeNotifier {
// The device will send updated contact info with path_len = -1 // The device will send updated contact info with path_len = -1
} }
void updateContactInMemory(
String publicKeyHex, {
Uint8List? pathBytes,
int? pathLength,
}) {
final existingIndex =
_contacts.indexWhere((c) => c.publicKeyHex == publicKeyHex);
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
_contacts[existingIndex] = existing.copyWith(
pathLength: pathLength,
path: pathBytes,
);
notifyListeners();
unawaited(_persistContacts());
}
}
Future<void> syncTime() async { Future<void> syncTime() async {
if (!isConnected) return; if (!isConnected) return;
@@ -1080,6 +1100,7 @@ class MeshCoreConnector extends ChangeNotifier {
// CLI commands are sent as UTF-8 text with a special prefix // CLI commands are sent as UTF-8 text with a special prefix
final commandBytes = utf8.encode(command); final commandBytes = utf8.encode(command);
final bytes = Uint8List.fromList([0x01, ...commandBytes, 0x00]); final bytes = Uint8List.fromList([0x01, ...commandBytes, 0x00]);
_lastSentWasCliCommand = true;
await sendFrame(bytes); await sendFrame(bytes);
} }
@@ -1120,7 +1141,10 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildGetChannelFrame(i)); await sendFrame(buildGetChannelFrame(i));
} }
// Wait a bit for responses to arrive, then apply final sort
await Future.delayed(const Duration(seconds: 2));
_isLoadingChannels = false; _isLoadingChannels = false;
_applyChannelOrder();
notifyListeners(); notifyListeners();
} }
@@ -1416,6 +1440,12 @@ class MeshCoreConnector extends ChangeNotifier {
} }
_knownContactKeys.add(contact.publicKeyHex); _knownContactKeys.add(contact.publicKeyHex);
_loadMessagesForContact(contact.publicKeyHex); _loadMessagesForContact(contact.publicKeyHex);
// Add path to history if we have a valid path
if (_pathHistoryService != null && contact.pathLength >= 0) {
_pathHistoryService!.handlePathUpdated(contact);
}
notifyListeners(); notifyListeners();
// Show notification for new contact (advertisement) // Show notification for new contact (advertisement)
@@ -1797,6 +1827,14 @@ class MeshCoreConnector extends ChangeNotifier {
final ackHash = Uint8List.fromList(frame.sublist(2, 6)); final ackHash = Uint8List.fromList(frame.sublist(2, 6));
final timeoutMs = readUint32LE(frame, 6); final timeoutMs = readUint32LE(frame, 6);
// Check if this is a CLI command ACK - if so, ignore it
if (_lastSentWasCliCommand) {
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
debugPrint('Ignoring CLI command ACK (sent): $ackHashHex');
_lastSentWasCliCommand = false;
return;
}
if (_retryService != null) { if (_retryService != null) {
_retryService!.updateMessageFromSent(ackHash, timeoutMs); _retryService!.updateMessageFromSent(ackHash, timeoutMs);
} }
@@ -1824,6 +1862,8 @@ class MeshCoreConnector extends ChangeNotifier {
final ackHash = Uint8List.fromList(frame.sublist(1, 5)); final ackHash = Uint8List.fromList(frame.sublist(1, 5));
final tripTimeMs = readUint32LE(frame, 5); final tripTimeMs = readUint32LE(frame, 5);
// CLI command ACKs are already filtered in _handleMessageSent, so this should only see real messages
// Handle ACK in retry service // Handle ACK in retry service
if (_retryService != null) { if (_retryService != null) {
_retryService!.handleAckReceived(ackHash, tripTimeMs); _retryService!.handleAckReceived(ackHash, tripTimeMs);
@@ -1846,7 +1886,11 @@ class MeshCoreConnector extends ChangeNotifier {
final channel = Channel.fromFrame(frame); final channel = Channel.fromFrame(frame);
if (channel != null && !channel.isEmpty) { if (channel != null && !channel.isEmpty) {
_channels.add(channel); _channels.add(channel);
_applyChannelOrder(); // Only sort and notify if we're not currently loading channels
// This prevents the list from jumping around as channels arrive during refresh
if (!_isLoadingChannels) {
_applyChannelOrder();
}
notifyListeners(); notifyListeners();
} }
} }
+12 -2
View File
@@ -413,10 +413,20 @@ class _ChatScreenState extends State<ChatScreen> {
return; return;
} }
final pathBytes = Uint8List.fromList(path.pathBytes);
final pathLength = path.pathBytes.length;
await connector.setContactPath( await connector.setContactPath(
widget.contact, widget.contact,
Uint8List.fromList(path.pathBytes), pathBytes,
path.pathBytes.length, pathLength,
);
// Update contact in memory directly for immediate UI feedback
connector.updateContactInMemory(
widget.contact.publicKeyHex,
pathBytes: pathBytes,
pathLength: pathLength,
); );
if (!context.mounted) return; if (!context.mounted) return;
+18 -1
View File
@@ -184,7 +184,24 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
} }
if (_fetchedSettings.containsKey('tx')) { if (_fetchedSettings.containsKey('tx')) {
_txPowerController.text = _fetchedSettings['tx']!; final txValue = _fetchedSettings['tx']!;
// Extract just the power value if it's part of a larger response
// Handle formats like "10", "10 dBm", or "908.205017,62.5,10,7"
final parts = txValue.split(',');
if (parts.length >= 3) {
// If comma-separated (likely radio format), TX power is typically the 3rd or 4th value
// Format: freq,bandwidth,sf,cr OR freq,bandwidth,power,sf,cr
final powerCandidate = parts.length > 3 ? parts[2].trim() : parts.last.trim();
final powerInt = int.tryParse(powerCandidate.replaceAll(RegExp(r'[^0-9-]'), ''));
if (powerInt != null && powerInt >= 1 && powerInt <= 30) {
_txPowerController.text = powerInt.toString();
} else {
_txPowerController.text = txValue.replaceAll(RegExp(r'[^0-9-]'), '');
}
} else {
// Simple format, just extract the number
_txPowerController.text = txValue.replaceAll(RegExp(r'[^0-9-]'), '');
}
} }
if (_fetchedSettings.containsKey('lat')) { if (_fetchedSettings.containsKey('lat')) {
+122 -6
View File
@@ -7,19 +7,35 @@ import '../models/path_selection.dart';
import 'storage_service.dart'; import 'storage_service.dart';
import 'app_settings_service.dart'; import 'app_settings_service.dart';
class _AckHistoryEntry {
final String messageId;
final List<Uint8List> ackHashes;
final DateTime timestamp;
_AckHistoryEntry({
required this.messageId,
required this.ackHashes,
required this.timestamp,
});
}
class MessageRetryService extends ChangeNotifier { class MessageRetryService extends ChangeNotifier {
static const int maxRetries = 5; static const int maxRetries = 5;
static const int maxAckHistorySize = 100;
final StorageService _storage; final StorageService _storage;
final Map<String, Timer> _timeoutTimers = {}; final Map<String, Timer> _timeoutTimers = {};
final Map<String, Message> _pendingMessages = {}; final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {}; final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {}; final Map<String, PathSelection> _pendingPathSelections = {};
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries
final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes
Function(Contact, String, int, int)? _sendMessageCallback; Function(Contact, String, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback; Function(String, Message)? _addMessageCallback;
Function(Message)? _updateMessageCallback; Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback; Function(Contact)? _clearContactPathCallback;
Function(Contact, Uint8List, int)? _setContactPathCallback;
Function(int, int)? _calculateTimeoutCallback; Function(int, int)? _calculateTimeoutCallback;
AppSettingsService? _appSettingsService; AppSettingsService? _appSettingsService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback; Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
@@ -31,6 +47,7 @@ class MessageRetryService extends ChangeNotifier {
required Function(String, Message) addMessageCallback, required Function(String, Message) addMessageCallback,
required Function(Message) updateMessageCallback, required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback, Function(Contact)? clearContactPathCallback,
Function(Contact, Uint8List, int)? setContactPathCallback,
Function(int pathLength, int messageBytes)? calculateTimeoutCallback, Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
AppSettingsService? appSettingsService, AppSettingsService? appSettingsService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback, Function(String, PathSelection, bool, int?)? recordPathResultCallback,
@@ -39,6 +56,7 @@ class MessageRetryService extends ChangeNotifier {
_addMessageCallback = addMessageCallback; _addMessageCallback = addMessageCallback;
_updateMessageCallback = updateMessageCallback; _updateMessageCallback = updateMessageCallback;
_clearContactPathCallback = clearContactPathCallback; _clearContactPathCallback = clearContactPathCallback;
_setContactPathCallback = setContactPathCallback;
_calculateTimeoutCallback = calculateTimeoutCallback; _calculateTimeoutCallback = calculateTimeoutCallback;
_appSettingsService = appSettingsService; _appSettingsService = appSettingsService;
_recordPathResultCallback = recordPathResultCallback; _recordPathResultCallback = recordPathResultCallback;
@@ -89,6 +107,23 @@ class MessageRetryService extends ChangeNotifier {
if (message == null || contact == null) return; if (message == null || contact == null) return;
// Sync path settings with device before sending
// Use the path that was captured when the message was first sent
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) {
// Flood mode - clear the path
debugPrint('Setting flood mode for retry attempt ${message.retryCount}');
_clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty
? 'direct'
: message.pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
debugPrint('Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}');
await _setContactPathCallback!(contact, message.pathBytes, message.pathLength!);
}
}
final attempt = message.retryCount.clamp(0, 3); final attempt = message.retryCount.clamp(0, 3);
if (_sendMessageCallback != null) { if (_sendMessageCallback != null) {
@@ -105,10 +140,21 @@ class MessageRetryService extends ChangeNotifier {
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
for (var entry in _pendingMessages.entries) { for (var entry in _pendingMessages.entries) {
final message = entry.value; final message = entry.value;
if (message.status == MessageStatus.pending) { // Only update if pending (waiting to send) or already sent with matching ACK
if (message.status == MessageStatus.pending ||
(message.status == MessageStatus.sent &&
message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash))) {
final contact = _pendingContacts[entry.key]; final contact = _pendingContacts[entry.key];
final selection = _pendingPathSelections[entry.key]; final selection = _pendingPathSelections[entry.key];
// Add this ACK hash to the list of expected ACKs for this message
_expectedAckHashes[entry.key] ??= [];
if (!_expectedAckHashes[entry.key]!.any((hash) => listEquals(hash, ackHash))) {
_expectedAckHashes[entry.key]!.add(Uint8List.fromList(ackHash));
debugPrint('Added ACK hash ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()} to message ${entry.key} (total: ${_expectedAckHashes[entry.key]!.length})');
}
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid // Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
int actualTimeout = timeoutMs; int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null && contact != null) { if (timeoutMs <= 0 && _calculateTimeoutCallback != null && contact != null) {
@@ -127,7 +173,7 @@ class MessageRetryService extends ChangeNotifier {
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
status: MessageStatus.sent, status: MessageStatus.sent,
expectedAckHash: ackHash, expectedAckHash: ackHash, // Keep the most recent one for display
estimatedTimeoutMs: actualTimeout, estimatedTimeoutMs: actualTimeout,
sentAt: DateTime.now(), sentAt: DateTime.now(),
); );
@@ -139,9 +185,11 @@ class MessageRetryService extends ChangeNotifier {
} }
_startTimeoutTimer(entry.key, actualTimeout); _startTimeoutTimer(entry.key, actualTimeout);
debugPrint('Updated message ${entry.key} with ACK hash: ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}');
return; return;
} }
} }
debugPrint('No pending message found for ACK hash: ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}');
} }
void _startTimeoutTimer(String messageId, int timeoutMs) { void _startTimeoutTimer(String messageId, int timeoutMs) {
@@ -158,12 +206,15 @@ class MessageRetryService extends ChangeNotifier {
if (message == null || contact == null) return; if (message == null || contact == null) return;
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})');
if (message.retryCount < maxRetries - 1) { if (message.retryCount < maxRetries - 1) {
final backoffMs = 1000 * (1 << message.retryCount); final backoffMs = 1000 * (1 << message.retryCount);
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
retryCount: message.retryCount + 1, retryCount: message.retryCount + 1,
status: MessageStatus.pending, status: MessageStatus.pending,
// Keep expectedAckHash - it will be updated when the new attempt is sent
); );
_pendingMessages[messageId] = updatedMessage; _pendingMessages[messageId] = updatedMessage;
@@ -172,6 +223,7 @@ class MessageRetryService extends ChangeNotifier {
_updateMessageCallback!(updatedMessage); _updateMessageCallback!(updatedMessage);
} }
debugPrint('Scheduling retry after ${backoffMs}ms');
Timer(Duration(milliseconds: backoffMs), () { Timer(Duration(milliseconds: backoffMs), () {
_attemptSend(messageId); _attemptSend(messageId);
}); });
@@ -179,6 +231,9 @@ class MessageRetryService extends ChangeNotifier {
// Max retries reached - mark as failed // Max retries reached - mark as failed
final failedMessage = message.copyWith(status: MessageStatus.failed); final failedMessage = message.copyWith(status: MessageStatus.failed);
// Move ACK hashes to history before removing
_moveAckHashesToHistory(messageId);
_pendingMessages.remove(messageId); _pendingMessages.remove(messageId);
_pendingContacts.remove(messageId); _pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId); _pendingPathSelections.remove(messageId);
@@ -201,22 +256,71 @@ class MessageRetryService extends ChangeNotifier {
} }
} }
void _moveAckHashesToHistory(String messageId) {
final ackHashes = _expectedAckHashes.remove(messageId);
if (ackHashes != null && ackHashes.isNotEmpty) {
_ackHistory.add(_AckHistoryEntry(
messageId: messageId,
ackHashes: ackHashes,
timestamp: DateTime.now(),
));
// Trim history to max size (rolling buffer)
while (_ackHistory.length > maxAckHistorySize) {
_ackHistory.removeAt(0);
}
debugPrint('Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})');
}
}
bool _checkAckHistory(Uint8List ackHash) {
for (final entry in _ackHistory) {
for (final expectedHash in entry.ackHashes) {
if (listEquals(expectedHash, ackHash)) {
debugPrint('Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s');
return true;
}
}
}
return false;
}
void handleAckReceived(Uint8List ackHash, int tripTimeMs) { void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
String? matchedMessageId; String? matchedMessageId;
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
debugPrint('Pending messages:');
for (var entry in _pendingMessages.entries) { for (var entry in _pendingMessages.entries) {
final message = entry.value; final message = entry.value;
if (message.expectedAckHash != null && final expectedHex = message.expectedAckHash?.map((b) => b.toRadixString(16).padLeft(2, '0')).join() ?? 'none';
listEquals(message.expectedAckHash, ackHash)) { final allExpectedHashes = _expectedAckHashes[entry.key]?.map((h) => h.map((b) => b.toRadixString(16).padLeft(2, '0')).join()).join(', ') ?? 'none';
matchedMessageId = entry.key; debugPrint(' ${entry.key}: status=${message.status}, latestAck=$expectedHex, allAcks=[$allExpectedHashes], retry=${message.retryCount}');
break; }
// Check against ALL expected ACK hashes (from all retry attempts)
for (var entry in _expectedAckHashes.entries) {
final messageId = entry.key;
final expectedHashes = entry.value;
for (final expectedHash in expectedHashes) {
if (listEquals(expectedHash, ackHash)) {
matchedMessageId = messageId;
debugPrint('Matched ACK to message: $matchedMessageId (matched hash from attempt ${expectedHashes.indexOf(expectedHash)})');
break;
}
} }
if (matchedMessageId != null) break;
} }
if (matchedMessageId != null) { if (matchedMessageId != null) {
final message = _pendingMessages[matchedMessageId]!; final message = _pendingMessages[matchedMessageId]!;
final contact = _pendingContacts[matchedMessageId]; final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId]; final selection = _pendingPathSelections[matchedMessageId];
// Cancel any pending timeout or retry
_timeoutTimers[matchedMessageId]?.cancel(); _timeoutTimers[matchedMessageId]?.cancel();
_timeoutTimers.remove(matchedMessageId); _timeoutTimers.remove(matchedMessageId);
@@ -226,6 +330,9 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs: tripTimeMs, tripTimeMs: tripTimeMs,
); );
// Move ACK hashes to history before removing
_moveAckHashesToHistory(matchedMessageId);
_pendingMessages.remove(matchedMessageId); _pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId); _pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId); _pendingPathSelections.remove(matchedMessageId);
@@ -239,6 +346,13 @@ class MessageRetryService extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
} else {
// Check ACK history for recently completed messages
if (_checkAckHistory(ackHash)) {
debugPrint('ACK matched a recently completed message from history');
} else {
debugPrint('No matching message found for ACK: $ackHashHex');
}
} }
} }
@@ -326,6 +440,8 @@ class MessageRetryService extends ChangeNotifier {
_pendingMessages.clear(); _pendingMessages.clear();
_pendingContacts.clear(); _pendingContacts.clear();
_pendingPathSelections.clear(); _pendingPathSelections.clear();
_expectedAckHashes.clear();
_ackHistory.clear();
super.dispose(); super.dispose();
} }
} }