From 903b1ecc12dbb0fe3ffc2345abed0f6dbf46c302 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:40:44 -0500 Subject: [PATCH] fix(#1063): close the adversarial-review findings on the 1061 stack Gemini gate (gemini-2.5-pro) on the pre-push stack, four findings: - Held channel message frames are now keyed per radio: verifying the unbounded-growth finding exposed that frames held from radio X would have flushed into radio Y's conversations after a radio switch. A parked radio's frames flush when that radio next completes a sync. No cap is added: held frames are popped messages, dropping one is guaranteed loss, and growth is traffic-bounded (<=172 bytes/frame). - _loadChannelMessages merges messages that arrived during the disk read instead of clobbering the list, so the slot-identity reload cannot hide a message the held-frame flush just filed (disk was never at risk: saves merge by identity, #343). - sendChannelMessage refuses the write for a slot this connection has not confirmed (the persisted copy would fall back to the legacy slot-index key and be orphaned), and the chat screen surfaces a persistent error at every send path instead of greying anything. New English UI string; untranslated.json regenerated (#1063). Three integration tests cover the fixes (#1064); with the guards behaviorally disabled exactly those three fail (+3 -3). Full suite 1807 passed / 1 skipped. OUTCOMES.md records the gate and dispositions. Co-Authored-By: Claude Fable 5 --- .../1061-channel-slot-resync/OUTCOMES.md | 9 +- lib/connector/meshcore_connector.dart | 63 ++++++++- lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_bg.dart | 4 + lib/l10n/app_localizations_de.dart | 4 + lib/l10n/app_localizations_en.dart | 4 + lib/l10n/app_localizations_es.dart | 4 + lib/l10n/app_localizations_fr.dart | 4 + lib/l10n/app_localizations_hu.dart | 4 + lib/l10n/app_localizations_it.dart | 4 + lib/l10n/app_localizations_ja.dart | 4 + lib/l10n/app_localizations_ko.dart | 4 + lib/l10n/app_localizations_nl.dart | 4 + lib/l10n/app_localizations_pl.dart | 4 + lib/l10n/app_localizations_pt.dart | 4 + lib/l10n/app_localizations_ru.dart | 4 + lib/l10n/app_localizations_sk.dart | 4 + lib/l10n/app_localizations_sl.dart | 4 + lib/l10n/app_localizations_sv.dart | 4 + lib/l10n/app_localizations_uk.dart | 4 + lib/l10n/app_localizations_zh.dart | 4 + lib/screens/channel_chat_screen.dart | 27 +++- .../channel_slot_resync_integration_test.dart | 124 ++++++++++++++++++ untranslated.json | 17 +++ 25 files changed, 307 insertions(+), 12 deletions(-) diff --git a/docs/initiatives/standalone/1061-channel-slot-resync/OUTCOMES.md b/docs/initiatives/standalone/1061-channel-slot-resync/OUTCOMES.md index 9ac25a7c..3a98ea0c 100644 --- a/docs/initiatives/standalone/1061-channel-slot-resync/OUTCOMES.md +++ b/docs/initiatives/standalone/1061-channel-slot-resync/OUTCOMES.md @@ -12,6 +12,13 @@ Status: BUILT and machine-verified; PRs opened on the owner's word (decision bel ## Gap found by the integration tests (fixed in `2a9d1e16`) Epic A's flush documented that a held frame for a still-unconfirmed slot re-holds, but the handler only gated on the list-level barrier: after a PARTIAL sync completed, a held message for an unconfirmed slot would have filed by raw index against an unknown identity and persisted under the legacy index key. `_handleIncomingChannelMessage` now re-holds such frames until a sync confirms the slot. This is the "prove the check can fail" discipline paying for itself; the flaky-sync scenario below fails without the fix. +## Gemini adversarial gate (gemini-2.5-pro, 2026-11-03, at the pre-push stack head) +Four findings, all addressed before push: +1. **HIGH, unbounded held frames:** cap deliberately NOT added (held frames are popped messages; dropping one is guaranteed loss; growth is traffic-bounded at <=172 bytes/frame and drains on the next confirming sync). Verifying it exposed a sharper defect the review did not name: held frames survived a radio SWITCH and would have flushed into the other radio's conversations. Fixed: held message frames are keyed per radio and flush only when their own radio completes a sync. +2. **MEDIUM, identity-change reload race:** confirmed for in-memory state only (saves merge by identity, #343). Fixed: `_loadChannelMessages` merges messages that arrived during the disk read instead of clobbering. +3. **MEDIUM, outgoing send on an unconfirmed slot orphans the persisted copy:** confirmed. Fixed: `sendChannelMessage` refuses the write (fail closed, logged) and the chat screen shows a persistent error at every send path. New English UI string, untranslated.json regenerated. +4. **Test gaps:** three integration tests added (race, refused send, cross-radio hold); with the three guards behaviorally disabled, exactly those three fail (+3 -3). + ## Evidence Machine verification, all on the stacked branch: - `flutter analyze` clean on every touched file. @@ -21,7 +28,7 @@ Machine verification, all on the stacked branch: 2. **Manual device-side reorder:** a message persisted under Alpha's PSK follows Alpha to its new slot after the re-sync; nothing renders under Beta. 3. **Flaky sync mid-reconnect:** a message for a never-confirmed slot is held, not filed by raw index, and no notify decision is made for it; after a confirming re-sync it files once, against the right PSK and mode. - **Negative evidence:** with Epic B's drop/reload and the slot-level re-hold reverted in the working tree, all three scenarios fail (`+0 -3`) on exactly the guarded assertions; restored, all pass. The checks can fail. -- Full suite at Epic B: 1791 passed / 1 skipped, exit 0. Full suite at Epic C: 1794 passed / 1 skipped, exit 0. +- Full suite at Epic B: 1791 passed / 1 skipped, exit 0. Full suite at Epic C: 1794 passed / 1 skipped, exit 0. Full suite at the pushed head (post-rebase, post-review fixes): 1807 passed / 1 skipped, exit 0. ## Decisions logged - Epic B re-scope approved by the owner 2026-11-03 ("Agree, proceed"); storage was already PSK-keyed since #194, so no migration was needed. diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b30b2df0..0ce68841 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -593,9 +593,16 @@ class MeshCoreConnector extends ChangeNotifier { // #1062: ingress barrier. Live channel traffic is held while this // connection's channel list is not yet synced, then flushed, so a message - // is never filed against a guessed slot map. + // is never filed against a guessed slot map. Held MESSAGE frames are keyed + // by the radio that delivered them (#1063 review): they survive reconnects + // because the radio already popped them, but a switch to a DIFFERENT radio + // must not flush them into its conversations. A parked radio's frames flush + // when that radio next completes a sync; '' holds frames from before the + // connection identified itself and is adopted by the next flush. bool _channelListSynced = false; - final List _heldChannelMsgFrames = []; + final Map> _heldChannelMsgFramesByRadio = {}; + List get _heldChannelMsgFrames => + _heldChannelMsgFramesByRadio.putIfAbsent(selfPublicKeyHex, () => []); final List _heldRawChannelFrames = []; static const int _maxHeldChannelFrames = 64; @@ -2355,9 +2362,24 @@ class MeshCoreConnector extends ChangeNotifier { ); if (allMessages.isNotEmpty) { // Keep only the most recent N messages in memory to bound memory usage - final windowedMessages = allMessages.length > _messageWindowSize - ? allMessages.sublist(allMessages.length - _messageWindowSize) - : allMessages; + final windowedMessages = List.from( + allMessages.length > _messageWindowSize + ? allMessages.sublist(allMessages.length - _messageWindowSize) + : allMessages, + ); + + // A message can land in memory while this load is reading the store + // (the slot-identity reload racing a flushed held frame, #1063 review). + // The store save merges, so disk already has it; keep it visible too + // instead of clobbering the list with the pre-arrival history. + final current = _channelMessages[channelIndex]; + if (current != null) { + for (final m in current) { + if (_findChannelRepeatIndex(windowedMessages, m) < 0) { + windowedMessages.add(m); + } + } + } _channelMessages[channelIndex] = windowedMessages; notifyListeners(); @@ -4198,8 +4220,9 @@ class MeshCoreConnector extends ChangeNotifier { _previousChannelsCache.clear(); // #1062: a new connection starts with an unconfirmed list. Held MESSAGE // frames are kept: the radio already popped them from its queue, so they - // exist nowhere else, and the next confirmed sync files them. Raw sniff - // frames are lossy observations; a dead connection's are stale. + // exist nowhere else, and the holding radio's next confirmed sync files + // them (per-radio, so a radio switch cannot leak them, #1063 review). + // Raw sniff frames are lossy observations; a dead connection's are stale. _channelListSynced = false; _unconfirmedChannelSlots.clear(); _slotIdentityChanges.clear(); @@ -5733,6 +5756,14 @@ class MeshCoreConnector extends ChangeNotifier { return ts; } + /// Whether an outgoing write for [channelIndex] is allowed (#1063 review). + /// False while this connection has not confirmed the slot: the PSK resolver + /// would return null and the persisted copy would fall back to the legacy + /// slot-index key, orphaning it (adoption only runs on an empty PSK key). + /// Callers must refuse the write and tell the user why, not grey a button. + bool canSendOnChannel(int channelIndex) => + _channelListSynced && !_unconfirmedChannelSlots.contains(channelIndex); + Future sendChannelMessage( Channel channel, String text, { @@ -5741,6 +5772,16 @@ class MeshCoreConnector extends ChangeNotifier { String? translationModelId, }) async { if (!isConnected || text.isEmpty) return; + if (!canSendOnChannel(channel.index)) { + // Fail closed at the write, loudly: the message would persist under an + // unknown identity. The UI surfaces this via [canSendOnChannel]. + _appDebugLogService?.error( + 'Refused to send on channel slot ${channel.index}: the slot is not ' + 'confirmed by this connection (#1063). Retry the channel sync.', + tag: 'ChannelSync', + ); + return; + } // Check if this is a reaction - if so, process it immediately instead of adding as a message final reactionInfo = ReactionHelper.parseReaction(text); @@ -6512,6 +6553,14 @@ class MeshCoreConnector extends ChangeNotifier { /// Runs after [_channelListSynced] flips true, so the handlers file against /// the fresh list; a frame whose slot is still unconfirmed re-holds. void _flushHeldChannelFrames() { + // Frames held before this connection learned its radio key belong to this + // connection (nothing flows before the handshake); adopt them first. + if (selfPublicKeyHex.isNotEmpty) { + final unattributed = _heldChannelMsgFramesByRadio.remove(''); + if (unattributed != null) { + _heldChannelMsgFrames.insertAll(0, unattributed); + } + } if (_heldChannelMsgFrames.isEmpty && _heldRawChannelFrames.isEmpty) return; final msgFrames = List.from(_heldChannelMsgFrames); final rawFrames = List.from(_heldRawChannelFrames); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 317b1ee5..fc5a4677 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1205,6 +1205,7 @@ } }, "channel_senderAddNotConnected": "Not connected to a radio, so this sender can't be added yet", + "channel_slotUnconfirmedSendBlocked": "Message not sent: the radio hasn't confirmed this channel slot yet. Retry the channel sync, then send again.", "channel_senderAddFailed": "Couldn't add this sender: {error}", "@channel_senderAddFailed": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3044aeca..c64ec3c1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3730,6 +3730,12 @@ abstract class AppLocalizations { /// **'Not connected to a radio, so this sender can\'t be added yet'** String get channel_senderAddNotConnected; + /// No description provided for @channel_slotUnconfirmedSendBlocked. + /// + /// In en, this message translates to: + /// **'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'** + String get channel_slotUnconfirmedSendBlocked; + /// No description provided for @channel_senderAddFailed. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index b1b6f5ae..1de8aee6 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -2137,6 +2137,10 @@ class AppLocalizationsBg extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 77afe12f..2ff95514 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2135,6 +2135,10 @@ class AppLocalizationsDe extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 39478abc..a54bed81 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2106,6 +2106,10 @@ class AppLocalizationsEn extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 2183176d..9d2ecb51 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2132,6 +2132,10 @@ class AppLocalizationsEs extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 7f729f47..e5765685 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2139,6 +2139,10 @@ class AppLocalizationsFr extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index a6c3b9a4..68aa77eb 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -2144,6 +2144,10 @@ class AppLocalizationsHu extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 5b4b9d0f..f11a8a5c 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2134,6 +2134,10 @@ class AppLocalizationsIt extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 74a35229..1fb331a8 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2069,6 +2069,10 @@ class AppLocalizationsJa extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index cc8b2ad4..9a7c320c 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2065,6 +2065,10 @@ class AppLocalizationsKo extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 9a206eed..2962b084 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2122,6 +2122,10 @@ class AppLocalizationsNl extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 846c6b3f..9f412ea4 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -2147,6 +2147,10 @@ class AppLocalizationsPl extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 111aecc5..b65a487e 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2131,6 +2131,10 @@ class AppLocalizationsPt extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d2724854..fc66e2db 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2135,6 +2135,10 @@ class AppLocalizationsRu extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index de469971..324dbb65 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -2124,6 +2124,10 @@ class AppLocalizationsSk extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 77cfd12c..7d2d1f8c 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -2120,6 +2120,10 @@ class AppLocalizationsSl extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index b8fec2a9..5d93902f 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -2115,6 +2115,10 @@ class AppLocalizationsSv extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index b1728c8f..38f71fa0 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2130,6 +2130,10 @@ class AppLocalizationsUk extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 64f10bc4..9620e163 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2044,6 +2044,10 @@ class AppLocalizationsZh extends AppLocalizations { String get channel_senderAddNotConnected => 'Not connected to a radio, so this sender can\'t be added yet'; + @override + String get channel_slotUnconfirmedSendBlocked => + 'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'; + @override String channel_senderAddFailed(String error) { return 'Couldn\'t add this sender: $error'; diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index dd2cfbf4..9ae7fd14 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1771,6 +1771,9 @@ class _ChannelChatScreenState extends State { } // end transform + // Refuse before clearing so the draft survives the refusal. + if (_refuseIfSlotUnconfirmed(connector)) return; + _textController.clear(); _cancelReply(); _textFieldFocusNode.requestFocus(); @@ -1783,6 +1786,21 @@ class _ChannelChatScreenState extends State { ); } + /// #1063: an outgoing write on a slot this connection has not confirmed + /// would persist under an unknown identity and be orphaned, so refuse at + /// the attempt and say why (a sync can confirm the slot seconds later, so + /// greying the composer would mislead). + bool _refuseIfSlotUnconfirmed(MeshCoreConnector connector) { + if (connector.canSendOnChannel(_currentChannel.index)) return false; + showDismissibleSnackBar( + context, + content: Text(context.l10n.channel_slotUnconfirmedSendBlocked), + backgroundColor: Theme.of(context).colorScheme.error, + persist: true, + ); + return true; + } + /// One-click reply quoting the route a message took, hop count + path, /// truncated with … to fit the channel byte budget, fired directly. (#106) void _sendRouteReply(ChannelMessage message) { @@ -1816,6 +1834,7 @@ class _ChannelChatScreenState extends State { if (connector.isChannelCyr2LatEnabled(_currentChannel.index)) { text = Cyr2Lat.encode(text); } + if (_refuseIfSlotUnconfirmed(connector)) return; connector.sendChannelMessage(_currentChannel, text); } @@ -2051,10 +2070,9 @@ class _ChannelChatScreenState extends State { } void _retryChannelMessage(ChannelMessage message) { - context.read().sendChannelMessage( - _currentChannel, - message.text, - ); + final connector = context.read(); + if (_refuseIfSlotUnconfirmed(connector)) return; + connector.sendChannelMessage(_currentChannel, message.text); showDismissibleSnackBar( context, content: Text(context.l10n.chat_sendingAgain), @@ -2229,6 +2247,7 @@ class _ChannelChatScreenState extends State { message.text, ); final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex); + if (_refuseIfSlotUnconfirmed(connector)) return; connector.sendChannelMessage(_currentChannel, reactionText); } diff --git a/test/connector/channel_slot_resync_integration_test.dart b/test/connector/channel_slot_resync_integration_test.dart index b7b633a1..cecbfe89 100644 --- a/test/connector/channel_slot_resync_integration_test.dart +++ b/test/connector/channel_slot_resync_integration_test.dart @@ -288,4 +288,128 @@ void main() { expect(decision.pskHex, beta.pskHex); expect(decision.mode, ChannelNotifyMode.off); }); + + test( + 'identity-change reload does not hide a message flushed meanwhile', + () async { + // Beta builds history at slot 1, then moves to slot 0. A live message for + // slot 0 arrives MID-resync (held), so the flush files it while the + // identity-change reload is still reading the store. Both must be visible + // afterwards: the reload merges, it does not clobber (#1063 review). + await syncWith([_channel(0, 'Alpha', 10), _channel(1, 'Beta', 30)]); + c.handleFrameForTest( + _v3ChannelMsgFrame(channelIdx: 1, text: 'B: beta history'), + ); + await _settle(); + await Future.delayed(const Duration(milliseconds: 50)); + + await c.getChannels(force: true, maxChannels: 2); + // Mid-resync: the barrier is closed, so this is held, and the flush will + // file it in the same turn that starts the slot-0 reload. + c.handleFrameForTest( + _v3ChannelMsgFrame(channelIdx: 0, text: 'B: live during sync'), + ); + c.handleFrameForTest( + _channelInfoFrame(index: 0, name: 'Beta', pskSeed: 30), + ); + await _settle(2); + c.handleFrameForTest( + _channelInfoFrame(index: 1, name: 'Alpha', pskSeed: 10), + ); + await _settle(); + await Future.delayed(const Duration(milliseconds: 50)); + await _settle(); + + final slot0Texts = + (c.channelMessagesForTest[0] ?? const []) + .map((m) => m.text) + .toSet(); + expect( + slot0Texts, + containsAll({'beta history', 'live during sync'}), + reason: + 'the reload must merge with what the flush just filed, ' + 'not clobber it', + ); + }, + ); + + test( + 'outgoing send to an unconfirmed slot is refused, not orphaned', + () async { + final alpha = _channel(0, 'Alpha', 10); + final beta = _channel(1, 'Beta', 30); + c.seedCachedChannelsForTest([alpha, beta]); + + // Slot 0 confirms, slot 1 never does. + await c.getChannels(maxChannels: 2); + c.handleFrameForTest( + _channelInfoFrame(index: 0, name: 'Alpha', pskSeed: 10), + ); + await _settle(2); + for (var i = 0; i <= 3; i++) { + c.handleChannelSyncTimeoutForTest(1); + await _settle(2); + } + await _settle(); + expect(c.unconfirmedChannelSlots, {1}); + expect(c.canSendOnChannel(0), isTrue); + expect(c.canSendOnChannel(1), isFalse); + + final sentFrames = []; + c.sendFrameOverrideForTest = sentFrames.add; + await c.sendChannelMessage(beta, 'must not be written'); + await _settle(); + + expect(sentFrames, isEmpty, reason: 'nothing may go to the radio'); + expect( + c.channelMessagesForTest[1] ?? const [], + isEmpty, + reason: + 'the refused message must not be filed (a persisted copy would ' + 'fall back to the legacy slot-index key and be orphaned)', + ); + }, + ); + + test( + 'held frames never cross radios; they flush when their radio returns', + () async { + // Radio X delivers a message before its list is confirmed; the frame is + // held. The user then connects radio Y: Y's sync must NOT flush X's frame + // into Y's conversations. When X reconnects and syncs, the frame files. + final radioX = Uint8List.fromList(List.generate(32, (i) => i)); + final radioY = Uint8List.fromList(List.generate(32, (i) => 99 + i)); + + c.handleFrameForTest( + _v3ChannelMsgFrame(channelIdx: 0, text: 'A: from X'), + ); + await _settle(); + + // Switch to radio Y and complete its sync. + c.resetConnectionHandshakeStateForTest(); + c.setConnectedForTest(); + c.setSelfPublicKeyForTest(radioY); + c.wireMessageStoreForTest(); + await syncWith([_channel(0, 'Gamma', 70)]); + expect( + c.channelMessagesForTest[0] ?? const [], + isEmpty, + reason: "radio X's held frame must not file into radio Y's channel", + ); + + // Back to radio X: its held frame now files against its own list. + c.resetConnectionHandshakeStateForTest(); + c.setConnectedForTest(); + c.setSelfPublicKeyForTest(radioX); + c.wireMessageStoreForTest(); + await syncWith([_channel(0, 'Alpha', 10)]); + expect( + (c.channelMessagesForTest[0] ?? const []).map( + (m) => m.text, + ), + ['from X'], + ); + }, + ); } diff --git a/untranslated.json b/untranslated.json index ea2bfb1d..3af74f33 100644 --- a/untranslated.json +++ b/untranslated.json @@ -159,6 +159,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -502,6 +503,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -845,6 +847,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -1188,6 +1191,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -1531,6 +1535,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -1874,6 +1879,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -2217,6 +2223,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -2560,6 +2567,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -2903,6 +2911,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -3246,6 +3255,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -3589,6 +3599,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -3932,6 +3943,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -4275,6 +4287,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -4618,6 +4631,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -4961,6 +4975,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -5304,6 +5319,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames", @@ -5647,6 +5663,7 @@ "channel_senderAlreadyContact", "channel_senderMultipleMatches", "channel_senderAddNotConnected", + "channel_slotUnconfirmedSendBlocked", "channel_senderAddFailed", "channel_senderCandidate", "map_alwaysShowNames",