Fix serial protobuf corruption on short USB CDC writes (#10976)

* Fix serial protobuf corruption on short writes

SerialConsole shared raw debug output and framed protobuf traffic on one
HWCDC stream. Raw text could interleave inside an active frame when API
logging was disabled. Separately, HWCDC deliberately returns a short write
after bounded backpressure; StreamAPI abandoned that frame after PhoneAPI
had already advanced, so the next 0x94c3 header landed inside the previous
declared payload.

Suppress all unframed output after protobuf mode starts and honor the
existing config-replay log pause. For HWCDC, retain short frame tails in the
persistent tx buffers and finish them on later loop passes before dequeuing
another FromRadio packet. Logs remain best-effort and only start when their
complete frame fits; main packets are deferred rather than dropped if a
synchronous log is pending. Do not call HWCDC flush for framed serial output,
since its no-progress path can discard queued bytes. TCP and non-HWCDC
transports keep their existing behavior.

Validated on Cardputer ADV (200-node DB) and Heltec Tracker V2: 400 initial
full-DB sessions plus 140 final sessions across both API-log settings, zero
malformed frames/timeouts/incomplete DBs; 2-20s forced reader stalls resume
with complete DBs; abrupt stalled-client replacement 5/5 per board; 150s
post-close reboot counts stable. Builds pass for Cardputer, Heltec, tbeam,
and rak4631.

* Add serial frame continuation regression tests

Extract the HWCDC pending/deferred frame state machine into a small
transport-independent helper so native tests exercise the same production
logic used by SerialConsole. Keep framing, persistent buffer ownership and
locking in SerialConsole.

Cover short-tail continuation, deferred main-frame ordering, best-effort log
admission, bounded zero-progress calls, generic StreamAPI failure semantics,
PhoneAPI advancement gating, framed-log gating and raw output suppression.
The coverage suite passes 31/31 suites and 582/582 tests.

* Address review: guard flush, assert deferred invariant, dedup framing

- Make SerialConsole::flush() a no-op in protobuf mode: HWCDC::flush()'s
  no-progress path discards queued TX bytes, which would tear a framed
  stream when the sleep path flushes with a stalled host.
- Assert the single required-frame producer invariant in
  StreamFrameWriter::writeFrame() instead of silently dropping a second
  required frame.
- Hoist 0x94C3 header construction into StreamAPI::buildFrameHeader() so
  SerialConsole no longer re-hardcodes the framing constants.

* Test retained serial tail across client replacement

Model a replacement client arriving while an older required frame has an
unwritten tail. Require the old frame to complete before the new frame starts,
so a new 0x94C3 header can never land inside the old declared payload.

* Document serial frame APIs and regression tests

Add concise Doxygen comments for the frame continuation hooks, production
helper, native test doubles, and regression scenarios. Document why retained
frame tails intentionally survive client disconnects: HWCDC may still hold the
accepted prefix, so dropping metadata could insert a new frame header inside
the old declared payload.
This commit is contained in:
p0ns
2026-07-11 06:27:50 -05:00
committed by GitHub
parent 8e27a8c715
commit ca833d944c
8 changed files with 675 additions and 30 deletions
+20 -7
View File
@@ -2,6 +2,7 @@
#include "RedirectablePrint.h"
#include "StreamAPI.h"
#include "mesh/StreamFrameWriter.h"
/**
* Provides both debug printing and, if the client starts sending protobufs to us, switches to send/receive protobufs
* (and starts dropping debug printing - FIXME, eventually those prints should be encapsulated in protobufs).
@@ -14,6 +15,7 @@ class SerialConsole : public StreamAPI, public RedirectablePrint, private concur
bool usingProtobufs = false;
public:
/// Initialize the shared serial stream for console and protobuf traffic.
SerialConsole();
/**
@@ -22,35 +24,46 @@ class SerialConsole : public StreamAPI, public RedirectablePrint, private concur
*/
virtual bool handleToRadio(const uint8_t *buf, size_t len) override;
virtual size_t write(uint8_t c) override
{
if (c == '\n') // prefix any newlines with carriage return
RedirectablePrint::write('\r');
return RedirectablePrint::write(c);
}
/// Write a raw console byte unless the stream is in protobuf mode.
virtual size_t write(uint8_t c) override;
/// Service serial input, pending output, and connection state.
virtual int32_t runOnce() override;
/// Flush raw console output when explicitly requested.
void flush();
/// Wake the serial thread after receive activity.
void rxInt();
protected:
/// Check the current underlying physical link to see if the client is currently connected
virtual bool checkIsConnected() override;
/// Wake the serial thread when PhoneAPI queues output.
virtual void onNowHasData(uint32_t fromRadioNum) override;
/// Track serial API connect/disconnect so we can make console writes
/// non-blocking while no host is listening (see setHostDraining()).
virtual void onConnectionChanged(bool connected) override;
/// Possibly switch to protobufs if we see a valid protobuf message
/// Emit a framed API log when enabled, or raw output before protobuf mode.
virtual void log_to_serial(const char *logLevel, const char *format, va_list arg);
/// Continue retained USB CDC output before PhoneAPI advances.
virtual bool finishPendingFrame() override;
/// Return whether the dedicated log buffer can be safely overwritten.
virtual bool canEncodeLogRecord() override;
/// Write or retain one framed USB CDC message.
virtual bool writeFrame(uint8_t *buf, size_t len, bool bestEffort) override;
private:
/// On USB CDC targets, keep console TX non-blocking unless a host is draining the
/// port, so a dead host can't stall the main loop and trip the task watchdog.
void setHostDraining(bool draining);
#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
StreamFrameWriter frameWriter;
#endif
};
// A simple wrapper to allow non class aware code write to the console