fix(i2c): three I2C receive-path fixes (RX buffer sizing + NUL terminator + short-read resync) - #44
Open
ACEFGI wants to merge 3 commits into
Open
Conversation
i2c_read() reads the module response in MAX_PL_LEN (250-byte) chunks via requestFrom(). On ESP32 the Arduino TwoWire RX buffer defaults to 128 bytes (I2C_BUFFER_LENGTH) and requestFrom() does not clamp the requested size to the buffer capacity, so a full-size read overruns rxBuffer and corrupts the neighbouring heap (manifests as boot-loop Guru Meditation panics on dense-heap firmware). Call setBufferSize(PACKET_SIZE) in the I2C begin() path so a full packet fits. Guarded to ARDUINO_ARCH_ESP32 (the affected core; setBufferSize is not part of the generic TwoWire API).
fetch() and wait() NUL-terminate the receive buffer with `rx_buf[rx_end] = '\0'` after each read. The read clamp permits `len + rx_end == rx_len`, so rx_end can equal rx_len and that write lands one byte past the malloc'd buffer, corrupting the adjacent heap block. It stays latent in request/response use (the buffer drains every command) but is reached under a sustained free-run (invoke(-1)) backlog, where the buffer fills to rx_len — observed as wake-word tensor-arena corruption on a neighbouring allocation. Allocate size + 1 in set_rx_buffer so the terminator slot is always in-bounds; rx_len remains the usable data capacity. Stacks on the setBufferSize packet-size fix (both retained until upstream merges).
i2c_read() returned the requested length unconditionally, even when requestFrom()/readBytes() timed out or came up short. The caller advances its rx cursor by this value, so an under-read drove the byte stream out of sync with the \r\n response framing and never recovered (free-run pose then froze on a single ESP_ERR_TIMEOUT). Return the real byte count, keep writes contiguous, and stop on a short chunk so the rx buffer holds exactly the bytes received and the parser can resync on the next complete frame.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR contains three fixes to the I2C receive path in
src/Seeed_Arduino_SSCMA.cpp. Fixes 1 and 2 are heap corruption (a trampled neighbouring allocation); fix 3 is a permanent response-framing desync that freezes a free-running consumer. They share the same file and receive path, so they're bundled here.Fix 1 — grow the
TwoWireRX buffer to hold a full response packetProblem
SSCMA::i2c_read()reads the module response inMAX_PL_LEN(250-byte) chunks:On ESP32 the Arduino
TwoWireRX buffer defaults to 128 bytes (I2C_BUFFER_LENGTH), andrequestFrom()does not clamp the requested size to the buffer capacity. So any module response larger than 128 bytes (e.g. a populatedinvoke()result, or even theID()/name()strings read duringbegin()) overruns the internalrxBufferby up to 122 bytes — writing JSON response text past the end of the buffer and trampling the adjacent heap block.On a sparse heap the overrun usually lands in free space and goes unnoticed. On denser firmware it corrupts a live allocation and the device boot-loops with
Guru Meditation Error (LoadProhibited).How to reproduce
Arduino-ESP32 ships ESP-IDF "light" heap poisoning (every allocation carries head/tail canaries), so
heap_caps_check_integrity_all()walks the heap and reports any trampled canary. A minimal bring-up sketch bracketed with an integrity check surfaces it:With a model loaded (so responses exceed 128 bytes), the integrity check trips — the corrupted block's canary holds ASCII fragments of the JSON response, confirming the Wire RX buffer is the source.
Workaround (caller side)
Growing the RX buffer before
begin()keeps the read in bounds. This is what callers currently have to do — e.g. in a Grove Vision AI V2 bring-up project:Fix
Move that workaround into the library so callers don't need to know the internal chunk size:
begin()callssetBufferSize(PACKET_SIZE)(HEADER_LEN + MAX_PL_LEN + CHECKSUM_LEN= 256, already defined in the header). Guarded toARDUINO_ARCH_ESP32, sincesetBufferSize()is not part of the generic ArduinoTwoWireAPI and ESP32 is whererequestFrom()fails to self-clamp.With the fix in place, the reproduction above reports
heap okon every iteration where it previously detected corruption.A complementary core-side fix — making
TwoWire::requestFrom()clamp the request to the buffer instead of overrunning — is proposed at espressif/arduino-esp32#12723.Fix 2 — reserve a byte for the rx-buffer NUL terminator
Problem
Separate from the
TwoWirebuffer above, the library's own response buffer (rx_buf, allocated inset_rx_buffer()) has a one-byte overrun.fetch()andwait()NUL-terminate it after each read:rx_buf[rx_end] = '\0';The read clamp permits
len + rx_end == rx_len, sorx_endcan equalrx_lenexactly — andset_rx_buffer()allocates onlyrx_lenbytes. That terminating NUL then lands one byte past themalloc, corrupting the adjacent heap block.It stays latent in normal request/response use (the buffer drains after every command, so
rx_endnever reachesrx_len), but is reached under a sustained free-run (invoke(-1)) backlog where the buffer fills torx_len. We observed it as wake-word TFLite tensor-arena corruption on a neighbouring allocation.Fix
Allocate
size + 1inset_rx_buffer()so the terminator slot is always in-bounds.rx_lenstill tracks the usable data capacity, so no other logic changes.Fix 3 — return bytes actually read so short reads resync
Problem
SSCMA::i2c_read()returned the requestedlengthunconditionally, even when the read came up short:requestFrom()/readBytes()can return fewer bytes than requested — most commonly a clock-stretched read that times out (ESP_ERR_TIMEOUT, surfaced by the core asi2cRead returned Error 263). The caller (fetch()) advances its receive cursor by this return value:so an over-reported read walks
rx_endover partial/garbage bytes. The\r\nresponse framing then desyncs permanently:strnstr(RESPONSE_PREFIX/SUFFIX)no longer aligns to real event boundaries, the event callback stops firing, and a free-running consumer (invoke(-1)) that loops until it receives an event spins forever. The device keeps running, but that stream is dead until a reset.Fix
Accumulate and return the number of bytes actually read, keep writes contiguous (
data + total), and stop the read on a short chunk or a failedendTransmission().rx_bufthen holds exactly the bytes received, so the parser can resync on the next complete frame instead of desyncing forever.