diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6e0de1c..993aba5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1566,6 +1566,14 @@ } } }, + "repeater_cliCommandTimeout": "Command timed out after {seconds} seconds", + "@repeater_cliCommandTimeout": { + "placeholders": { + "seconds": { + "type": "String" + } + } + }, "repeater_cliLateResponse": "Late response to \"{command}\" (arrived {seconds}s after timeout)", "@repeater_cliLateResponse": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ff7b326..0e25ba2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5126,6 +5126,12 @@ abstract class AppLocalizations { /// **'Error: {error}'** String repeater_cliCommandError(String error); + /// No description provided for @repeater_cliCommandTimeout. + /// + /// In en, this message translates to: + /// **'Command timed out after {seconds} seconds'** + String repeater_cliCommandTimeout(String seconds); + /// No description provided for @repeater_cliLateResponse. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index bc3b38a..c85488c 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -2905,6 +2905,11 @@ class AppLocalizationsBg extends AppLocalizations { return 'Грешка: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4796cc0..7424d8f 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2908,6 +2908,11 @@ class AppLocalizationsDe extends AppLocalizations { return 'Fehler: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a2f753c..74606aa 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2849,6 +2849,11 @@ class AppLocalizationsEn extends AppLocalizations { return 'Error: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index ada7780..b1e9d30 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2899,6 +2899,11 @@ class AppLocalizationsEs extends AppLocalizations { return 'Error: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 48cc33d..9ba508b 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2921,6 +2921,11 @@ class AppLocalizationsFr extends AppLocalizations { return 'Erreur : $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index c03f5cc..190275c 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -2912,6 +2912,11 @@ class AppLocalizationsHu extends AppLocalizations { return 'Hiba: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 74f1361..a175f99 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2905,6 +2905,11 @@ class AppLocalizationsIt extends AppLocalizations { return 'Errore: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 736587e..d31f6a3 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2785,6 +2785,11 @@ class AppLocalizationsJa extends AppLocalizations { return 'エラー:$error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 0892237..dbd50b5 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2783,6 +2783,11 @@ class AppLocalizationsKo extends AppLocalizations { return '오류: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 3e9e5bc..686f34b 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2885,6 +2885,11 @@ class AppLocalizationsNl extends AppLocalizations { return 'Fout: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 1eb1a6a..4e40642 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -2913,6 +2913,11 @@ class AppLocalizationsPl extends AppLocalizations { return 'Błąd: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ae4403e..422038f 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2897,6 +2897,11 @@ class AppLocalizationsPt extends AppLocalizations { return 'Erro: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 269db9b..03f237c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2903,6 +2903,11 @@ class AppLocalizationsRu extends AppLocalizations { return 'Ошибка: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 2acb38d..55c6e02 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -2884,6 +2884,11 @@ class AppLocalizationsSk extends AppLocalizations { return 'Chyba: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 26e1fa6..102f725 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -2883,6 +2883,11 @@ class AppLocalizationsSl extends AppLocalizations { return 'Napaka: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 4111819..de72b5a 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -2869,6 +2869,11 @@ class AppLocalizationsSv extends AppLocalizations { return 'Fel: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 997fe73..0953a80 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2901,6 +2901,11 @@ class AppLocalizationsUk extends AppLocalizations { return 'Помилка: $error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f4eadf1..5882b65 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2737,6 +2737,11 @@ class AppLocalizationsZh extends AppLocalizations { return '错误:$error'; } + @override + String repeater_cliCommandTimeout(String seconds) { + return 'Command timed out after $seconds seconds'; + } + @override String repeater_cliLateResponse(String command, String seconds) { return 'Late response to \"$command\" (arrived ${seconds}s after timeout)'; diff --git a/lib/screens/repeater_cli_screen.dart b/lib/screens/repeater_cli_screen.dart index 82875ac..46039d5 100644 --- a/lib/screens/repeater_cli_screen.dart +++ b/lib/screens/repeater_cli_screen.dart @@ -191,10 +191,15 @@ class _RepeaterCliScreenState extends State { } } catch (e) { if (mounted) { + // A timeout knows the exact window that was armed, so it reports that + // rather than a rounded-up guess (#531). + final text = e is RepeaterCommandTimeout + ? context.l10n.repeater_cliCommandTimeout(e.secondsText) + : context.l10n.repeater_cliCommandError(e.toString()); setState(() { _commandHistory.add({ 'type': 'response', - 'text': context.l10n.repeater_cliCommandError(e.toString()), + 'text': text, 'timestamp': DateTime.now().toString(), }); }); diff --git a/lib/services/repeater_command_service.dart b/lib/services/repeater_command_service.dart index f5b98e5..1378ba1 100644 --- a/lib/services/repeater_command_service.dart +++ b/lib/services/repeater_command_service.dart @@ -29,6 +29,32 @@ class UnmatchedRepeaterResponse { bool get isLateReply => command != null; } +/// Thrown when a CLI command's window closes with no reply. +/// +/// Carries the window that was actually armed, so callers can report the real +/// figure. The previous message printed `(timeoutMs / 1000).ceil()`, so a +/// 4074 ms window announced "timeout after 5 seconds": every window in +/// (4000, 5000] reported the same 5, and the number shown was never the number +/// used (#531). +class RepeaterCommandTimeout implements Exception { + final String command; + final int timeoutMs; + + const RepeaterCommandTimeout({ + required this.command, + required this.timeoutMs, + }); + + /// The armed window in seconds to one decimal, e.g. `28.7`. + String get secondsText => (timeoutMs / 1000).toStringAsFixed(1); + + /// Non-localized fallback for logs and for callers without a + /// [BuildContext]. UI paths should prefer [secondsText] with their own + /// localized string. + @override + String toString() => 'Command timed out after $secondsText seconds'; +} + class _ExpiredCommand { final String command; final DateTime expiredAt; @@ -136,7 +162,6 @@ class RepeaterCommandService { pathLength: pathLengthValue, messageBytes: responseBytes, ); - final timeoutSeconds = (timeoutMs / 1000).ceil(); await _connector.sendFrame(frame); _commandTimeouts[commandId]?.cancel(); _commandTimeouts[commandId] = Timer( @@ -151,7 +176,7 @@ class RepeaterCommandService { expiredAt: DateTime.now(), ); completer.completeError( - 'Command timeout after $timeoutSeconds seconds', + RepeaterCommandTimeout(command: command, timeoutMs: timeoutMs), ); _cleanup(commandId); } diff --git a/test/services/repeater_command_service_test.dart b/test/services/repeater_command_service_test.dart index df75aa8..3cf9091 100644 --- a/test/services/repeater_command_service_test.dart +++ b/test/services/repeater_command_service_test.dart @@ -114,4 +114,35 @@ void main() { expect(seen.single.command, isNull); }); + + group('timeout reporting (#531)', () { + // The old message printed (timeoutMs / 1000).ceil(), so every window in + // (4000, 5000] announced "5 seconds" and the figure shown was never the + // one armed. The owner's 0-hop window was 4074 ms and it claimed 5. + test('reports the armed window to one decimal, not rounded up', () { + const e = RepeaterCommandTimeout(command: 'ver', timeoutMs: 4074); + expect(e.secondsText, '4.1'); + expect(e.toString(), 'Command timed out after 4.1 seconds'); + }); + + test('does not round a sub-second remainder up to the next second', () { + // 28748 ms is the new CLI budget on the owner's preset. ceil() would say + // 29; the armed window is 28.7. + const e = RepeaterCommandTimeout(command: 'status', timeoutMs: 28748); + expect(e.secondsText, '28.7'); + }); + + test('distinct windows in the same second are distinguishable', () { + // The defect's signature: 4001 and 4999 both printed "5 seconds". + const a = RepeaterCommandTimeout(command: 'a', timeoutMs: 4001); + const b = RepeaterCommandTimeout(command: 'b', timeoutMs: 4999); + expect(a.secondsText, isNot(b.secondsText)); + }); + + test('carries the command so a caller can name what timed out', () { + const e = RepeaterCommandTimeout(command: 'get tx', timeoutMs: 1234); + expect(e.command, 'get tx'); + expect(e.timeoutMs, 1234); + }); + }); }