Skip to content

Companion: keep a queued message until the sync reply is actually written 🤖🤖 - #3362

Open
notbucki wants to merge 1 commit into
meshcore-dev:devfrom
notbucki:fix/companion-sync-next-message-loss
Open

Companion: keep a queued message until the sync reply is actually written 🤖🤖#3362
notbucki wants to merge 1 commit into
meshcore-dev:devfrom
notbucki:fix/companion-sync-next-message-loss

Conversation

@notbucki

@notbucki notbucki commented Sep 5, 2026

Copy link
Copy Markdown

CMD_SYNC_NEXT_MESSAGE takes the oldest frame out of the offline queue and then ignores the return value of writeFrame(). Both BLE interfaces and the WiFi interface return 0 while their send queue is full, and a serial write can come back short; in each of those cases the message is gone for good, while the client saw nothing and asks for the next one.

Change

  • MyMesh: the frame stays queued until the transport reports that it took it whole. getFromOfflineQueue() is split into peekOfflineQueue() / popOfflineQueue(), and the sync handler pops only after a complete write. On failure the client simply repeats the request; nothing else changes for it.
  • MultiSerialInterface::writeFrame() counted a frame as written only when every enabled interface accepted it. An interface that is enabled but has nobody attached (BLE advertising, an idle port) fails each write, which would turn the retry above into resending the same message forever on any build with more than one interface. It now reports success once any enabled interface took the frame. Single-interface builds, which is everything upstream ships, are unaffected: with one interface "any" and "all" coincide.
  • ArduinoSerialInterface::writeFrame() reports 0 only when nothing went out. A torn frame (a short write on a CDC port whose host has stalled) counts as taken: a retry cannot mend it, the receiver would swallow the next header as the missing payload, so the message is consumed exactly as before this change. The contract is now spelled out on BaseSerialInterface; BLE, WiFi and Ethernet already behaved that way (0 while their send queue is full, len otherwise).

Testing

  • Two new native googletests: test_multi_serial_interface (4 cases; the first fails against the previous MultiSerialInterface, the other three pass on both versions, so it pins exactly the changed behaviour) and test_arduino_serial_interface (5 cases: whole, nothing, torn header, torn payload, oversized). ArduinoSerialInterface.cpp is added to the native build filter for that.
  • Built heltec_v4_r8_companion_radio_usb, Heltec_v3_companion_radio_usb, RAK_4631_companion_radio_ble, heltec_v4_companion_radio_wifi and Xiao_S3_WIO_companion_radio_serial (HWCDC, UART bridge, nRF52 BLE, WiFi, hardware serial).
  • The failing-write path was not exercised on hardware; it needs a full BLE send queue or a serial port that accepts nothing. The happy path is unchanged.

Known limits

  • An interface that accepts frames with nobody attached still counts as delivery. The plain serial interface is one: its isConnected() is unconditionally true and write() succeeds into the TX buffer whether or not a host reads it. Giving it a real connection state is what Companion USB interface: report real connection state, add flow control 🤖🤖 #3214 does; the two changes compose but do not depend on each other.
  • With two clients attached at once, a frame that one of them refuses is still consumed once the other took it, exactly as today. Judging by "all connected interfaces" instead would let a port with a charger on it veto every frame, so "any" is the safe choice.
  • A torn serial write still loses the message, as today: the retry only engages when nothing was written. Not tearing frames in the first place is a transport matter (Companion USB interface: report real connection state, add flow control 🤖🤖 #3214 does it with whole-frame writes and pacing).

Found while reviewing #3214, where flow control makes a 0 return from writeFrame() a normal event rather than a rare one.


Prepared with Claude Code (Claude Fable 5.1), which did the analysis, the patch and the test; a human reviewed and approved the change before submission. The patch was also put through an independent cross-provider review with Codex (gpt-6-astra, reasoning effort max) before posting; its one finding, that retrying after a torn serial write would corrupt the stream, led to the writeFrame() contract above.

…tten

CMD_SYNC_NEXT_MESSAGE took the oldest frame out of the offline queue and
then ignored the return value of writeFrame(). BLE and WiFi refuse a
frame while their send queue is full, and a serial write can come back
short, so in each of those cases the message was gone for good while the
client saw nothing and asked for the next one.

The frame now stays queued until the transport reports that it took it
whole; on failure the client simply repeats the request.

MultiSerialInterface reported success only when every enabled interface
accepted the frame. An interface that is enabled but has nobody attached
(BLE advertising, an idle port) fails each write, which would have made
the retry above resend the same message forever on any build with more
than one interface. It now counts a frame as delivered once any enabled
interface took it. Covered by a native googletest.

For that to be safe, ArduinoSerialInterface::writeFrame() now reports 0
only when nothing went out. A torn frame (short write on a CDC port with
a stalled host) counts as taken: a retry cannot mend it, the receiver
would swallow the next header as the missing payload, so the message is
consumed as it was before. The contract is spelled out on
BaseSerialInterface; BLE, WiFi and Ethernet already behaved that way.
Covered by a second native googletest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant