Skip to content

esp32 uart: Fix read blocking on a stale event - #2428

Merged
bettio merged 2 commits into
atomvm:release-0.7from
iobee-io:uart-stale-event
Oct 16, 2026
Merged

bettio merged 2 commits into
atomvm:release-0.7from
iobee-io:uart-stale-event

Conversation

@gabrielmancini

Copy link
Copy Markdown

The ESP32 UART listener reads event.size bytes for each UART_DATA event with portMAX_DELAY. But uart:read/1,2 reads every buffered byte when it finds data (uart_get_buffered_data_len), including the bytes of UART_DATA events still waiting in the queue. When the listener later handles one of those events, the buffer holds fewer bytes than the event announces (often none), and uart_read_bytes blocks the scheduler until that many new bytes arrive. If the peer goes quiet, nothing ever arrives: without SMP the whole VM stops, and the task watchdog resets the chip.

How we hit it: a SIM7600 modem left in PPP data mode keeps sending LCP frames. The application sends +++ to return the modem to command mode, then ATH. The modem goes quiet, a stale event is handled, and the task watchdog (both cores idle, the AtomVM task blocked) resets the board. It happened the first time we tried it, on an ESP32 (no SMP build, release-0.7 based; our application sets a 30 s task watchdog).

The fix clamps the count to what the buffer holds, reads without waiting, and skips an event that finds the buffer empty (the reader stays registered for the next one). The USB Serial/JTAG path already reads what is available and is unchanged.

Tested on hardware (ESP32, LilyGO T-Internet-COM with a SIM7600SA on UART2): the same sequence now returns to command mode and dials again (twice from data mode, plus several dials from command mode, with the modem then carrying a PPP link to NervesHub). I did not find a way to reproduce this in QEMU, since it needs a peer on a second UART; happy to add a test if you have a preferred harness for it.

A read that finds data takes every buffered byte, including the bytes
of UART_DATA events still queued. When the listener then handles one
of those events, uart_read_bytes waits forever for bytes that were
already delivered, blocking the scheduler: with a peer that then goes
quiet (a modem after ATH), the task watchdog resets the chip.

Read only what the buffer holds, without waiting, and skip an event
that finds it empty.

Signed-off-by: Gabriel Mancini <gabriel.mancini@gmail.com>

@bettio bettio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a small issue.

Also, not introduced by this PR, so it might be an additional comment or a new open issue: the blocking read in uart_driver_do_read is the other half of this race.

uart_driver_do_read still calls uart_read_bytes(..., count, portMAX_DELAY) after sampling count with uart_get_buffered_data_len. On the default dual-core (SMP) builds the listener on the other scheduler can consume part of those bytes in between (a uart:read/2 timeout does cancel_read + read while a data event is being handled), so that call can still park a scheduler thread until the peer sends more, which after ATH is never. A 0 timeout there too, registering the reader with safe_update_reader_data when nothing was read (as the USJ branch in the same function already does) and building the binary from the returned length, would close it.

Comment thread src/platforms/esp32/components/avm_builtins/uart_driver.c Outdated
With a 0 tick timeout uart_read_bytes can return -1 (rx mutex busy) or
fewer bytes than counted (the other scheduler drained the buffer), and
the result was dropped: {ok, Bin} went out with uninitialized bytes.

uart:read/2 had the same race with portMAX_DELAY: after counting the
buffered bytes, the listener on the other scheduler could take some,
and the read then waited for the peer to send more.

Both paths now read into a temporary buffer without waiting, build the
binary from the returned length, and keep the reader registered when
nothing was read. The UART and USB-Serial/JTAG branches share it.

Signed-off-by: Gabriel Mancini <gabriel.mancini@gmail.com>
@gabrielmancini

Copy link
Copy Markdown
Author

Thanks! I fixed uart_driver_do_read in this PR too, in the same commit, since it is the same race and the fix shares the helpers: it counts, makes room on the heap first (so on failure the bytes stay for the next read), reads without waiting, and registers the reader when the read came back empty, as the USJ branch did. CHANGELOG entry updated to cover it.

Tested:

  • QEMU, memory-check build (ESP-IDF v5.5.5): esp32 and esp32s3 suites pass, except test_socket, which needs Internet hosts and fails here on plain release-0.7 too.
  • Hardware: LilyGO T-Internet-COM (ESP32-WROVER-E, dual core, ESP-IDF v5.5.5) with a LilyGO SIM7600SA Mini PCIe modem on UART2 (TX 33, RX 35, 115200), the board where the original task watchdog reset showed up. Same commit applied on our fork's build. AT queries (ATI, AT+CPIN?, AT+CSQ, AT+CREG?, AT+COPS?), then two cycles of dialing PPP over the same UART (AT dialogue with uart:read/2 timeouts, +++/ATH), a session over LTE, hang-up and AT queries again: all replies complete, no task watchdog, no reset.

@gabrielmancini
gabrielmancini requested a review from bettio October 15, 2026 21:26
@bettio
bettio merged commit 18b3587 into atomvm:release-0.7 Oct 16, 2026
52 checks passed
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.

2 participants