fix: bound Serial.write() to prevent indefinite hang on stalled USB host

Adafruit_USBD_CDC::write() blocks as long as DTR is asserted, even if
the host stopped draining the FIFO. Every writeFrame() call and the
GPX trail export wrote directly to Serial, so a serial monitor left
open but idle could stall the main loop until the 30s watchdog reset
the device.

streamWriteUntil() caps the wait with an absolute deadline shared
across chained writes, and only enters the wait loop when
availableForWrite() is actually exhausted, so a healthy host sees no
behavior change. The GPX export latches after the first stall to
avoid paying the timeout once per write call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-23 11:24:02 +02:00
parent 5bb3111bb0
commit 687d126334
3 changed files with 87 additions and 4 deletions

View File

@@ -1,4 +1,9 @@
#include "ArduinoSerialInterface.h"
#include "StreamUtils.h"
// Worst-case time we'll let writeFrame() block waiting for the host to drain
// the link, before giving up on the rest of the frame. See StreamUtils.h.
#define SERIAL_WRITE_TIMEOUT_MS 300
#define RECV_STATE_IDLE 0
#define RECV_STATE_HDR_FOUND 1
@@ -32,8 +37,11 @@ size_t ArduinoSerialInterface::writeFrame(const uint8_t src[], size_t len) {
hdr[1] = (len & 0xFF); // LSB
hdr[2] = (len >> 8); // MSB
_serial->write(hdr, 3);
return _serial->write(src, len);
// Single deadline shared across header + payload, so a stalled host costs
// at most SERIAL_WRITE_TIMEOUT_MS total, not that much per write() call.
uint32_t deadline = millis() + SERIAL_WRITE_TIMEOUT_MS;
if (streamWriteUntil(*_serial, hdr, 3, deadline) < 3) return 0;
return streamWriteUntil(*_serial, src, len, deadline);
}
size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) {

40
src/helpers/StreamUtils.h Normal file
View File

@@ -0,0 +1,40 @@
#pragma once
// A USB-CDC serial monitor or companion app can leave the port open (DTR
// asserted) without ever draining it. Stream::write() then blocks inside the
// platform's "TX FIFO full" wait loop for as long as that holds true — which
// is forever in practice, since the main loop()'s hardware watchdog is only
// re-armed once per iteration. streamWriteUntil() caps that wait instead of
// trusting the platform to give up.
//
// Under normal conditions (host actually reading) this is a no-op compared to
// a plain write(): writes are never asked for more than availableForWrite()
// already reports free, so they return immediately without entering the
// platform's internal wait/yield loop at all. Only a genuinely stalled host
// (zero throughput) hits the deadline; a slow-but-alive one just takes longer,
// same as a plain write() would.
#include <Arduino.h>
// Returns the number of bytes actually written; less than `len` only when the
// host stopped draining the link before `deadline_ms` (an absolute millis()
// value — share one deadline across multiple calls that make up one logical
// message, so a stall costs a fixed total budget, not one per call).
static inline size_t streamWriteUntil(Stream& s, const uint8_t* buf, size_t len, uint32_t deadline_ms) {
size_t sent = 0;
while (sent < len) {
int avail = s.availableForWrite();
if (avail <= 0) {
if ((int32_t)(millis() - deadline_ms) >= 0) break; // host stalled — give up
yield(); // let the USB/BLE background task run so it can drain the FIFO
continue;
}
size_t chunk = (size_t)avail < (len - sent) ? (size_t)avail : (len - sent);
size_t w = s.write(buf + sent, chunk);
if (w == 0) {
if ((int32_t)(millis() - deadline_ms) >= 0) break;
yield();
continue;
}
sent += w;
}
return sent;
}