From 75d08e73eaa8d0e187ffecb7ad22f7a5a81cfc2a Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 8 Aug 2026 03:11:24 -0400 Subject: [PATCH] fix(#531): report the timeout window that was armed, not a rounded-up one The timer ran timeoutMs while the message printed (timeoutMs / 1000).ceil(), so every window in (4000, 5000] announced "timeout after 5 seconds". The owner's 0-hop window was 4074 ms and fired at 4.07 s while claiming 5, which is what made the behaviour look arbitrary rather than deterministic: the number shown was never the number used. The service now throws a typed RepeaterCommandTimeout carrying the window that was actually armed, formatted to one decimal. Every existing caller already stringifies the error, so all of them inherit an honest figure without being touched; the CLI screen additionally renders it through a new localized string rather than the generic error wrapper. Tests pin that 4074 reports 4.1 rather than 5, that the new 28748 ms budget reports 28.7 rather than 29, and that two windows inside the same second no longer collapse to the same text, which was the defect's signature. Part of epic #473, stacked on #528 and #529. --- lib/l10n/app_en.arb | 8 +++++ lib/l10n/app_localizations.dart | 6 ++++ lib/l10n/app_localizations_bg.dart | 5 +++ lib/l10n/app_localizations_de.dart | 5 +++ lib/l10n/app_localizations_en.dart | 5 +++ lib/l10n/app_localizations_es.dart | 5 +++ lib/l10n/app_localizations_fr.dart | 5 +++ lib/l10n/app_localizations_hu.dart | 5 +++ lib/l10n/app_localizations_it.dart | 5 +++ lib/l10n/app_localizations_ja.dart | 5 +++ lib/l10n/app_localizations_ko.dart | 5 +++ lib/l10n/app_localizations_nl.dart | 5 +++ lib/l10n/app_localizations_pl.dart | 5 +++ lib/l10n/app_localizations_pt.dart | 5 +++ lib/l10n/app_localizations_ru.dart | 5 +++ lib/l10n/app_localizations_sk.dart | 5 +++ lib/l10n/app_localizations_sl.dart | 5 +++ lib/l10n/app_localizations_sv.dart | 5 +++ lib/l10n/app_localizations_uk.dart | 5 +++ lib/l10n/app_localizations_zh.dart | 5 +++ lib/screens/repeater_cli_screen.dart | 7 ++++- lib/services/repeater_command_service.dart | 29 +++++++++++++++-- .../repeater_command_service_test.dart | 31 +++++++++++++++++++ 23 files changed, 168 insertions(+), 3 deletions(-) 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); + }); + }); }