fix(#528): surface late repeater CLI replies instead of discarding them

A repeater reply that arrived after its command's window closed hit
`if (commandId.isEmpty) return;` in RepeaterCommandService.handleResponse
and was dropped with no log and no UI. Since the command timeout can be
shorter than the app's own message-retrieval budget, that made "the
command ran but the response was never reported" the normal outcome
rather than an edge case, and it affected every repeater screen, not
just the CLI one.

RepeaterCommandService now remembers a timed-out command's prefix for two
minutes, so a reply arriving afterwards can be attributed to the request
it answers. Replies that reach no waiting command are handed to a new
onUnmatchedResponse sink and logged through appLogger. No path through
handleResponse returns without either completing a command, surfacing the
payload, or logging why it could not.

The CLI screen renders these as a distinct history entry naming the
original command and how late it was. The settings screen applies the
value if it is a `get` reply and tells the user it arrived late.
repeater_status_screen already parsed responses independently of the
service, so it had no silent-loss path to fix.

Part of epic #473. Does not change the timeout window itself (#529),
the reported duration (#531), or the stale-prefix fallback (#532).
pull/561/head
Strycher 2 months ago
parent d5789d8e96
commit ff320c17b6

@ -1566,6 +1566,19 @@
} }
} }
}, },
"repeater_cliLateResponse": "Late response to \"{command}\" (arrived {seconds}s after timeout)",
"@repeater_cliLateResponse": {
"placeholders": {
"command": {
"type": "String"
},
"seconds": {
"type": "String"
}
}
},
"repeater_cliUnmatchedResponse": "Unrequested response from repeater",
"repeater_lateResponseReceived": "A late response arrived after the command timed out. See the CLI screen.",
"repeater_cliQuickGetName": "Get Name", "repeater_cliQuickGetName": "Get Name",
"repeater_cliQuickGetRadio": "Get Radio", "repeater_cliQuickGetRadio": "Get Radio",
"repeater_cliQuickGetTx": "Get TX", "repeater_cliQuickGetTx": "Get TX",

@ -5126,6 +5126,24 @@ abstract class AppLocalizations {
/// **'Error: {error}'** /// **'Error: {error}'**
String repeater_cliCommandError(String error); String repeater_cliCommandError(String error);
/// No description provided for @repeater_cliLateResponse.
///
/// In en, this message translates to:
/// **'Late response to \"{command}\" (arrived {seconds}s after timeout)'**
String repeater_cliLateResponse(String command, String seconds);
/// No description provided for @repeater_cliUnmatchedResponse.
///
/// In en, this message translates to:
/// **'Unrequested response from repeater'**
String get repeater_cliUnmatchedResponse;
/// No description provided for @repeater_lateResponseReceived.
///
/// In en, this message translates to:
/// **'A late response arrived after the command timed out. See the CLI screen.'**
String get repeater_lateResponseReceived;
/// No description provided for @repeater_cliQuickGetName. /// No description provided for @repeater_cliQuickGetName.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

@ -2905,6 +2905,19 @@ class AppLocalizationsBg extends AppLocalizations {
return 'Грешка: $error'; return 'Грешка: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Получи име'; String get repeater_cliQuickGetName => 'Получи име';

@ -2908,6 +2908,19 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Fehler: $error'; return 'Fehler: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Name erhalten'; String get repeater_cliQuickGetName => 'Name erhalten';

@ -2849,6 +2849,19 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Error: $error'; return 'Error: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Get Name'; String get repeater_cliQuickGetName => 'Get Name';

@ -2899,6 +2899,19 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Error: $error'; return 'Error: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Obtener Nombre'; String get repeater_cliQuickGetName => 'Obtener Nombre';

@ -2921,6 +2921,19 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Erreur : $error'; return 'Erreur : $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Obtenir le nom'; String get repeater_cliQuickGetName => 'Obtenir le nom';

@ -2912,6 +2912,19 @@ class AppLocalizationsHu extends AppLocalizations {
return 'Hiba: $error'; return 'Hiba: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Kapcsold össze a nevet'; String get repeater_cliQuickGetName => 'Kapcsold össze a nevet';

@ -2905,6 +2905,19 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Errore: $error'; return 'Errore: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Ottieni Nome'; String get repeater_cliQuickGetName => 'Ottieni Nome';

@ -2785,6 +2785,19 @@ class AppLocalizationsJa extends AppLocalizations {
return 'エラー:$error'; return 'エラー:$error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => '名前を取得する'; String get repeater_cliQuickGetName => '名前を取得する';

@ -2783,6 +2783,19 @@ class AppLocalizationsKo extends AppLocalizations {
return '오류: $error'; return '오류: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => '이름을 알려주세요'; String get repeater_cliQuickGetName => '이름을 알려주세요';

@ -2885,6 +2885,19 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Fout: $error'; return 'Fout: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Naam opvragen'; String get repeater_cliQuickGetName => 'Naam opvragen';

@ -2913,6 +2913,19 @@ class AppLocalizationsPl extends AppLocalizations {
return 'Błąd: $error'; return 'Błąd: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Pobierz nazwę'; String get repeater_cliQuickGetName => 'Pobierz nazwę';

@ -2897,6 +2897,19 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Erro: $error'; return 'Erro: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Obter Nome'; String get repeater_cliQuickGetName => 'Obter Nome';

@ -2903,6 +2903,19 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Ошибка: $error'; return 'Ошибка: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Получить имя'; String get repeater_cliQuickGetName => 'Получить имя';

@ -2884,6 +2884,19 @@ class AppLocalizationsSk extends AppLocalizations {
return 'Chyba: $error'; return 'Chyba: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Zísť meno'; String get repeater_cliQuickGetName => 'Zísť meno';

@ -2883,6 +2883,19 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Napaka: $error'; return 'Napaka: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Pridobi ime'; String get repeater_cliQuickGetName => 'Pridobi ime';

@ -2869,6 +2869,19 @@ class AppLocalizationsSv extends AppLocalizations {
return 'Fel: $error'; return 'Fel: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Hämta namn'; String get repeater_cliQuickGetName => 'Hämta namn';

@ -2901,6 +2901,19 @@ class AppLocalizationsUk extends AppLocalizations {
return 'Помилка: $error'; return 'Помилка: $error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => 'Отримати ім\'я'; String get repeater_cliQuickGetName => 'Отримати ім\'я';

@ -2737,6 +2737,19 @@ class AppLocalizationsZh extends AppLocalizations {
return '错误:$error'; return '错误:$error';
} }
@override
String repeater_cliLateResponse(String command, String seconds) {
return 'Late response to \"$command\" (arrived ${seconds}s after timeout)';
}
@override
String get repeater_cliUnmatchedResponse =>
'Unrequested response from repeater';
@override
String get repeater_lateResponseReceived =>
'A late response arrived after the command timed out. See the CLI screen.';
@override @override
String get repeater_cliQuickGetName => '获取名称'; String get repeater_cliQuickGetName => '获取名称';

@ -52,9 +52,31 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
super.initState(); super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector); _commandService = RepeaterCommandService(connector);
_commandService!.onUnmatchedResponse = _handleUnmatchedResponse;
_setupMessageListener(); _setupMessageListener();
} }
/// A reply the command future never received, because its window had already
/// closed. It is still the repeater's real answer, so it goes into the
/// history instead of being dropped (#528).
void _handleUnmatchedResponse(UnmatchedRepeaterResponse unmatched) {
if (!mounted) return;
setState(() {
_commandHistory.add({
'type': 'late',
'text': unmatched.response,
'command': unmatched.command ?? '',
'seconds': unmatched.sinceTimeout == null
? ''
: (unmatched.sinceTimeout!.inMilliseconds / 1000).toStringAsFixed(
1,
),
'timestamp': DateTime.now().toString(),
});
});
_scrollToBottom();
}
@override @override
void dispose() { void dispose() {
_frameSubscription?.cancel(); _frameSubscription?.cancel();
@ -103,11 +125,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
if (parsed == null) return; if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return; if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling) // The service routes this either to the waiting command's future, which
// _sendCommand appends to history, or to onUnmatchedResponse when the
// window has already closed. Both paths reach the transcript (#528).
_commandService?.handleResponse(widget.repeater, parsed.text); _commandService?.handleResponse(widget.repeater, parsed.text);
// Note: The command service will handle the response via the Future
// We don't need to add it to history here anymore as _sendCommand will do it
} }
bool _matchesRepeaterPrefix(Uint8List prefix) { bool _matchesRepeaterPrefix(Uint8List prefix) {
@ -184,7 +205,19 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
_historyIndex = -1; _historyIndex = -1;
_commandFocusNode.requestFocus(); _commandFocusNode.requestFocus();
// Auto-scroll to bottom _scrollToBottom();
}
String _lateResponseLabel(Map<String, String> entry) {
final command = entry['command'] ?? '';
final seconds = entry['seconds'] ?? '';
if (command.isEmpty || seconds.isEmpty) {
return context.l10n.repeater_cliUnmatchedResponse;
}
return context.l10n.repeater_cliLateResponse(command, seconds);
}
void _scrollToBottom() {
Future.delayed(const Duration(milliseconds: 100), () { Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
_scrollController.animateTo( _scrollController.animateTo(
@ -450,6 +483,25 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = _commandHistory[index]; final entry = _commandHistory[index];
final isCommand = entry['type'] == 'command'; final isCommand = entry['type'] == 'command';
final isLate = entry['type'] == 'late';
final scheme = Theme.of(context).colorScheme;
final Color badgeColor;
final Color badgeIconColor;
final IconData badgeIcon;
if (isCommand) {
badgeColor = scheme.primaryContainer;
badgeIconColor = scheme.onPrimaryContainer;
badgeIcon = Icons.chevron_right;
} else if (isLate) {
badgeColor = scheme.tertiaryContainer;
badgeIconColor = scheme.onTertiaryContainer;
badgeIcon = Icons.history;
} else {
badgeColor = scheme.secondaryContainer;
badgeIconColor = scheme.onSecondaryContainer;
badgeIcon = Icons.arrow_back;
}
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@ -459,32 +511,34 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Container( Container(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isCommand color: badgeColor,
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Icon( child: Icon(badgeIcon, size: 16, color: badgeIconColor),
isCommand ? Icons.chevron_right : Icons.arrow_back,
size: 16,
color: isCommand
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (isLate)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
_lateResponseLabel(entry),
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: scheme.tertiary,
),
),
),
SelectableText( SelectableText(
entry['text']!, entry['text']!,
style: TextStyle( style: TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 13, fontSize: 13,
color: isCommand color: isCommand ? scheme.primary : scheme.onSurface,
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
), ),
), ),
], ],

@ -183,10 +183,26 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
super.initState(); super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector); _commandService = RepeaterCommandService(connector);
_commandService!.onUnmatchedResponse = _handleUnmatchedResponse;
_setupMessageListener(); _setupMessageListener();
_loadSettings(); _loadSettings();
} }
/// A repeater reply whose command had already timed out. This screen has no
/// transcript to append it to, so it is applied like any other response and
/// the user is told it arrived late rather than the reply being dropped
/// (#528).
void _handleUnmatchedResponse(UnmatchedRepeaterResponse unmatched) {
if (!mounted) return;
if (unmatched.command != null) {
_handleGetResponse(unmatched.command!, unmatched.response);
}
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_lateResponseReceived),
);
}
@override @override
void dispose() { void dispose() {
_frameSubscription?.cancel(); _frameSubscription?.cancel();

@ -1,8 +1,40 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../utils/app_logger.dart';
/// A repeater reply that arrived with no command waiting to receive it.
///
/// [command] carries the original request when the reply could be traced back
/// to one that already timed out, and is null when the reply cannot be
/// attributed to anything this service sent.
class UnmatchedRepeaterResponse {
final String repeaterKeyHex;
final String response;
final String? command;
final Duration? sinceTimeout;
const UnmatchedRepeaterResponse({
required this.repeaterKeyHex,
required this.response,
this.command,
this.sinceTimeout,
});
/// True when this answers a request that timed out rather than arriving
/// unsolicited.
bool get isLateReply => command != null;
}
class _ExpiredCommand {
final String command;
final DateTime expiredAt;
const _ExpiredCommand({required this.command, required this.expiredAt});
}
class RepeaterCommandService { class RepeaterCommandService {
final MeshCoreConnector _connector; final MeshCoreConnector _connector;
@ -10,10 +42,20 @@ class RepeaterCommandService {
final Map<String, Timer> _commandTimeouts = {}; final Map<String, Timer> _commandTimeouts = {};
final Map<String, String> _commandPrefixes = {}; final Map<String, String> _commandPrefixes = {};
final Map<String, String> _pendingByPrefix = {}; final Map<String, String> _pendingByPrefix = {};
final Map<String, _ExpiredCommand> _expiredCommands = {};
int _prefixCounter = 0; int _prefixCounter = 0;
static const int maxRetries = 5; static const int maxRetries = 5;
/// How long a timed-out command stays remembered so a reply arriving after
/// its window can still be presented with the request it answers.
static const Duration lateReplyRetention = Duration(minutes: 2);
/// Invoked when a reply cannot be handed to a waiting command. Consumers
/// must surface this to the user: the reply is a real answer from the
/// repeater and dropping it silently loses it for good (#528).
void Function(UnmatchedRepeaterResponse)? onUnmatchedResponse;
RepeaterCommandService(this._connector); RepeaterCommandService(this._connector);
/// Send a CLI command to a repeater with automatic retries /// Send a CLI command to a repeater with automatic retries
@ -27,6 +69,7 @@ class RepeaterCommandService {
}) async { }) async {
final attemptCount = retries < 1 ? 1 : retries; final attemptCount = retries < 1 ? 1 : retries;
final selection = await _connector.preparePathForContactSend(repeater); final selection = await _connector.preparePathForContactSend(repeater);
final attemptPrefixes = <String>[];
for (int attempt = 0; attempt < attemptCount; attempt++) { for (int attempt = 0; attempt < attemptCount; attempt++) {
onAttempt?.call(attempt + 1); onAttempt?.call(attempt + 1);
@ -36,7 +79,13 @@ class RepeaterCommandService {
command, command,
selection, selection,
attempt, attempt,
attemptPrefixes,
); );
// The caller has its answer, so a straggler from an earlier attempt of
// this same command is noise rather than a lost response.
for (final prefix in attemptPrefixes) {
_expiredCommands.remove(prefix);
}
onResponse?.call(response); onResponse?.call(response);
return response; return response;
} catch (e) { } catch (e) {
@ -52,6 +101,7 @@ class RepeaterCommandService {
String command, String command,
PathSelection selection, PathSelection selection,
int attempt, int attempt,
List<String> attemptPrefixes,
) async { ) async {
final repeaterKey = repeater.publicKeyHex; final repeaterKey = repeater.publicKeyHex;
final prefix = _nextPrefixToken(); final prefix = _nextPrefixToken();
@ -60,6 +110,7 @@ class RepeaterCommandService {
_pendingCommands[commandId] = completer; _pendingCommands[commandId] = completer;
_commandPrefixes[commandId] = prefix; _commandPrefixes[commandId] = prefix;
_pendingByPrefix[prefix] = commandId; _pendingByPrefix[prefix] = commandId;
attemptPrefixes.add(prefix);
try { try {
final framedCommand = '$prefix$command'; final framedCommand = '$prefix$command';
@ -93,6 +144,12 @@ class RepeaterCommandService {
() { () {
final completer = _pendingCommands[commandId]; final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) { if (completer != null && !completer.isCompleted) {
// Remember what this prefix asked so a reply arriving after the
// window can still reach the user with its question attached.
_expiredCommands[prefix] = _ExpiredCommand(
command: command,
expiredAt: DateTime.now(),
);
completer.completeError( completer.completeError(
'Command timeout after $timeoutSeconds seconds', 'Command timeout after $timeoutSeconds seconds',
); );
@ -114,29 +171,89 @@ class RepeaterCommandService {
/// Call this when a text message response is received from a repeater /// Call this when a text message response is received from a repeater
void handleResponse(Contact repeater, String responseText) { void handleResponse(Contact repeater, String responseText) {
// Find pending command for this repeater and complete it
final repeaterKey = repeater.publicKeyHex; final repeaterKey = repeater.publicKeyHex;
_pruneExpiredCommands();
String? commandId; String? prefix;
String responsePayload = responseText; String responsePayload = responseText;
if (responseText.length >= 3 && responseText[2] == '|') { if (responseText.length >= 3 && responseText[2] == '|') {
final prefix = responseText.substring(0, 3); prefix = responseText.substring(0, 3);
commandId = _pendingByPrefix[prefix];
responsePayload = responseText.substring(3).trimLeft(); responsePayload = responseText.substring(3).trimLeft();
} }
commandId ??= _pendingCommands.keys.firstWhere( final matchedId = prefix != null ? _pendingByPrefix[prefix] : null;
(id) => id.startsWith(repeaterKey), final commandId =
orElse: () => '', matchedId ??
); _pendingCommands.keys.firstWhere(
(id) => id.startsWith(repeaterKey),
orElse: () => '',
);
if (commandId.isNotEmpty) {
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.complete(responsePayload);
_cleanup(commandId);
return;
}
}
// Nothing is waiting for this reply. It is still a real answer from the
// repeater, so it gets surfaced and logged rather than dropped (#528).
_surfaceUnmatchedResponse(repeaterKey, prefix, responsePayload);
}
if (commandId.isEmpty) return; void _surfaceUnmatchedResponse(
String repeaterKey,
String? prefix,
String responsePayload,
) {
final expired = prefix != null ? _expiredCommands.remove(prefix) : null;
final sinceTimeout = expired == null
? null
: DateTime.now().difference(expired.expiredAt);
final completer = _pendingCommands[commandId]; if (expired != null) {
if (completer != null && !completer.isCompleted) { appLogger.warn(
completer.complete(responsePayload); 'Late reply to "${expired.command}" from $repeaterKey arrived '
_cleanup(commandId); '${sinceTimeout!.inMilliseconds}ms after its window closed',
tag: 'RepeaterCommand',
);
} else {
appLogger.warn(
'Reply from $repeaterKey matched no pending or recently expired '
'command (prefix: ${prefix ?? 'none'})',
tag: 'RepeaterCommand',
);
} }
onUnmatchedResponse?.call(
UnmatchedRepeaterResponse(
repeaterKeyHex: repeaterKey,
response: responsePayload,
command: expired?.command,
sinceTimeout: sinceTimeout,
),
);
}
/// Records a timed-out command so a reply arriving later can still be
/// attributed to it. Exposed because a test cannot drive a real
/// send-then-time-out cycle without a connected transport.
@visibleForTesting
void recordExpiredCommandForTest(String prefix, String command) {
_expiredCommands[prefix] = _ExpiredCommand(
command: command,
expiredAt: DateTime.now(),
);
}
void _pruneExpiredCommands() {
if (_expiredCommands.isEmpty) return;
final cutoff = DateTime.now().subtract(lateReplyRetention);
_expiredCommands.removeWhere(
(_, entry) => entry.expiredAt.isBefore(cutoff),
);
} }
void _cleanup(String commandId) { void _cleanup(String commandId) {
@ -157,6 +274,8 @@ class RepeaterCommandService {
_pendingCommands.clear(); _pendingCommands.clear();
_commandPrefixes.clear(); _commandPrefixes.clear();
_pendingByPrefix.clear(); _pendingByPrefix.clear();
_expiredCommands.clear();
onUnmatchedResponse = null;
} }
String _nextPrefixToken() { String _nextPrefixToken() {

@ -0,0 +1,117 @@
// #528 (epic #473): a repeater CLI reply that arrives with no command waiting
// for it used to hit `if (commandId.isEmpty) return;` and vanish. No log, no
// UI, nothing. Since a command's window can close before the app has even
// finished fetching the message (MSG_WAITING then SYNC_NEXT_MESSAGE), that
// made "the command ran but the response was never reported" the normal
// outcome rather than an edge case.
//
// These tests pin the contract that no reply is ever dropped silently.
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/services/repeater_command_service.dart';
import 'package:meshcore_open/storage/prefs_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
Contact _repeater() => Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
name: 'Test Repeater',
type: 2,
pathLength: 0,
path: Uint8List(0),
lastSeen: DateTime.fromMillisecondsSinceEpoch(0),
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late RepeaterCommandService service;
late Contact repeater;
late List<UnmatchedRepeaterResponse> surfaced;
setUp(() async {
SharedPreferences.setMockInitialValues({});
PrefsManager.reset();
await PrefsManager.initialize();
service = RepeaterCommandService(MeshCoreConnector());
repeater = _repeater();
surfaced = [];
service.onUnmatchedResponse = surfaced.add;
});
tearDown(() => service.dispose());
test('a reply with no pending command is surfaced, not discarded', () {
service.handleResponse(repeater, 'Version: 1.2.3');
expect(surfaced, hasLength(1));
expect(surfaced.single.response, 'Version: 1.2.3');
expect(surfaced.single.repeaterKeyHex, repeater.publicKeyHex);
});
test('a reply for a command that already timed out names that command', () {
service.recordExpiredCommandForTest('A3|', 'ver');
service.handleResponse(repeater, 'A3|Version: 1.2.3');
expect(surfaced, hasLength(1));
final reply = surfaced.single;
expect(reply.isLateReply, isTrue);
expect(reply.command, 'ver');
expect(reply.response, 'Version: 1.2.3', reason: 'prefix must be stripped');
expect(reply.sinceTimeout, isNotNull);
});
test('an unattributable reply is still surfaced, just without a command', () {
service.handleResponse(repeater, 'ZZ|unsolicited chatter');
expect(surfaced, hasLength(1));
expect(surfaced.single.isLateReply, isFalse);
expect(surfaced.single.command, isNull);
expect(surfaced.single.response, 'unsolicited chatter');
});
test('an expired command is only consumed once', () {
service.recordExpiredCommandForTest('A3|', 'ver');
service.handleResponse(repeater, 'A3|first');
service.handleResponse(repeater, 'A3|second');
expect(surfaced, hasLength(2));
expect(surfaced[0].command, 'ver');
expect(
surfaced[1].command,
isNull,
reason: 'the record is consumed by the first reply that claims it',
);
});
test('a reply is never swallowed when no callback is wired', () {
service.onUnmatchedResponse = null;
// The contract is that this cannot throw and cannot hang. The logging path
// still runs; the absence of a listener must not resurrect the silent drop
// as an unhandled error.
expect(
() => service.handleResponse(repeater, 'orphan reply'),
returnsNormally,
);
});
test('dispose clears the late-reply records', () {
service.recordExpiredCommandForTest('A3|', 'ver');
service.dispose();
// Re-arm a listener on the disposed service and confirm the stale record is
// gone, so a reply after disposal cannot be mis-attributed to it.
final seen = <UnmatchedRepeaterResponse>[];
service.onUnmatchedResponse = seen.add;
service.handleResponse(repeater, 'A3|Version: 1.2.3');
expect(seen.single.command, isNull);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.