Skip to content

esp32: Fix USB Serial/JTAG TX stall due to full EP buffer - #19656

Open
projectgus wants to merge 3 commits into
micropython:masterfrom
projectgus:bugfix/esp32_usj_serial_txfifo
Open

esp32: Fix USB Serial/JTAG TX stall due to full EP buffer#19656
projectgus wants to merge 3 commits into
micropython:masterfrom
projectgus:bugfix/esp32_usj_serial_txfifo

Conversation

@projectgus

@projectgus projectgus commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #19651.

I wasn't able to reproduce using standalone reproducer from this issue, but I was able to reproduce by installing micropython-stubber==1.28.3 and running stubber mcu --format py -s /dev/ttyACM0 as per issue description.

This failure is also visible by running ./tests/serial_test.py on the Espressif USB serial/jtag port. The echo test fails when the length is 64 bytes (a clue!) and is out of sync from then on:

❯ ./serial_test.py -t /dev/ttyACM0
REPL and data on /dev/ttyACM0
DATA ECHO: FAIL     
  sent 1 b'z'
  echo match
<snip matching iterations>
  sent 62 b'tgytyJXgyyXJHtHygJXHXyXyayMttgXJtyaXtagMtJXtatyygaggJygMJJMJyt'
  echo match
  sent 63 b'LeLcFFdL9dcFdeKed9K9FFddLFceeFK9KFcF9LeFeKF9dcccF9cdFFcecKLdeeL'
  echo match
  sent 64 b'yEuEbEyBb8byw8ubEBEywbyw8bbuw8ybu8uyEyB8uE8EyE8Bb8BBbub8EybBEu88'
  echo 64 b''
  sent 65 b'lMMWtklrkrl33l3kMWrrr3ll3rrrk3r3MrlltkWOOrWrkrrWMrkk3ttlOt33kO3rM'
  echo 65 b'yEuEbEyBb8byw8ubEBEywbyw8bbuw8ybu8uyEyB8uE8EyE8Bb8BBbub8EybBEu88l'
  sent 66 b'u1uw11w1FFsVVVQuIwsuFI1uuVsFwuuIVuQsIw1wwIFQQwIssVuFwuIFQFuuuuwwsw'
  echo 66 b'MMWtklrkrl33l3kMWrrr3ll3rrrk3r3MrlltkWOOrWrkrrWMrkk3ttlOt33kO3rMu1'
<snip failing iterations>

The Espressif USB Serial/JTAG peripheral (USJ) manages a 64 byte TX FIFO of bytes to send to the host (via the IN endpoint of the USB device). This TXFIFO automatically flushes whenever it is full, but if exactly 64 bytes (the endpoint length) is queued then it expects a manually triggered txfifo flush after it empties, in order to send a ZLP (zero length packet) to the host to signal that the transfer is done. Otherwise the host will assume there is more data coming and wait.

The bug is caused by this sequence of evens:

  1. USJ has no data to send to host (TXFIFO/IN EP), so the SERIAL_IN_EMPTY interrupt bit is set.
  2. MicroPython sends exactly 64 bytes to host, queued into the TXFIFO. The hardware will start flushing to the host.
  3. The SERIAL_IN_EMPTY interrupt is enabled, but because the bit was already set it triggers.
  4. ISR calls txfifo_flush() but this is a no-op as the TXFIFO is already being flushed.
  5. ISR is now disabled until the next time MicroPython tries to send data.
  6. The hardware sends the 64 bytes to the USB host.

Because there is exactly 64 bytes in the buffer (i.e. EP size), the USB host will assume there is more data to come and will wait for either a ZLP or more data. Because the interrupt is disabled this won't happen, and that data won't be received on the host until the next time MicroPython tries to TX serial data to the host.

Fix is to clear the SERIAL_IN_EMPTY interrupt status to avoid stale interrupts, and to be explicit about when we flush immediately on send versus when we need to wait for the TXFIFO to empty.

(Debugging tools used to figure this out were the Linux usbmon kernel module with Wireshark, and the "grabserial" tool to compare timings on the traditional UART - via grabserial -d /dev/ttyUSB0 -b 115200 --hex-ascii -T.)

As per the issue report, this is not a v1.29 regression (double checked by reproducing on ESP32_GENERIC_C3, MicroPython v1.28, and IDF v5.5.1.)

Test fix included

Second commit is a fix for the serial_test.py program when testing the USB Serial/JTAG Peripheral, as the DATA IN test would time out waiting for a soft reset when starting the next test in the sequence. This is because MicroPython flushes the plain UART peripheral on soft reset, and this could take more than the 0.1 seconds that was allowed for (for the 16384 byte test it could take a couple of seconds).

Testing

  • Ran the serial_test.py program, the "stubber mcu" test on ESP32_GENERIC_C3, ESP32_GENERIC_C5 and ESP32_GENERIC_P4 boards via the USB Serial/JTAG port. All three boards failed both tests without this fix, pass with this fix.
  • Also ran tools/mpremote/tests/run-mpremote-tests.py on all three boards. Pass before and after this fix.

Trade-offs and Alternatives

  • It'd probably be more convenient to use the ESP-IDF driver for the USB Serial/JTAG peripheral, however this driver is synchronous only (there's no way to get a callback when data is received from the host). Also it has its own buffer layer, so we'd end up with double buffering on stdin (the ESP-IDF driver buffer and then MicroPython's stdin ringbuffer).

Generative AI

I did not use generative AI tools when creating this PR.

Update to the fix in eb5d89c.

The sequence of events which caused this problem:

1. USJ has no data to send to host (TXFIFO/IN EP), so the
   SERIAL_IN_EMPTY interrupt bit is set.
2. MicroPython sends exactly 64 bytes to host, queued into the
   TXFIFO. The hardware will start flushing to the host.
3. The SERIAL_IN_EMPTY interrupt is enabled, but because the bit
   was already set it triggers.
4. ISR calls txfifo_flush() but this is a no-op as the TXFIFO is
   already being flushed.
5. ISR is now disabled until the next time MicroPython tries to
   send data.

Because there is exactly 64 bytes in the buffer (i.e. EP size), the host
will assume there is more data to come and will wait for either a Zero
Length Packet (ZLP) or more data. This won't happen and the data won't
be received until the next time MicroPython tries to TX serial data to the
host.

Fix is to clear the SERIAL_IN_EMPTY interrupt status to avoid stale
interrupts, and to be explicit about when we flush immediately on send
versus when we need to wait for the TXFIFO to empty.

This work was funded through GitHub Sponsors.

Signed-off-by: Angus Gratton <angus@redyak.com.au>
Necessary on ESP32 when testing a native USB or USJ, as the UART TX buffer
can be quite large and is flushed before the soft reset completes.

(Which from the perspective of the native port just looks like it doesn't
respond.)

This work was funded through GitHub Sponsors.

Signed-off-by: Angus Gratton <angus@redyak.com.au>
@projectgus

Copy link
Copy Markdown
Contributor Author

@Josverl if you have a chance, could you please run through the reproducers you have with this PR and see if you can break it? Thanks!

@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.58%. Comparing base (4d80ff0) to head (6e22f5a).

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #19656   +/-   ##
=======================================
  Coverage   98.58%   98.58%           
=======================================
  Files         182      182           
  Lines       23322    23322           
  Branches        5        5           
=======================================
  Hits        22993    22993           
  Misses        328      328           
  Partials        1        1           
Flag Coverage Δ
unix-coverage-32bit 98.59% <ø> (ø)
unix-coverage-64bit 98.52% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This work was funded through GitHub Sponsors.

Signed-off-by: Angus Gratton <angus@redyak.com.au>
@projectgus

Copy link
Copy Markdown
Contributor Author

Pushed one more one-line commit here to fix an integer overflow in the host timeout that was bugging me. 😁

@github-actions

Copy link
Copy Markdown

Code size report:

Reference:  tools/ci: Restore esp32 lockfiles when changing branches. [4d80ff0]
Comparison: esp32/usb_serial_jtag: Fix tick overflow issue in timeout. [merge of 6e22f5a]
  mpy-cross:    +0 +0.000% 
   bare-arm:    +0 +0.000% 
minimal x86:    +0 +0.000% 
   unix x64:    +0 +0.000% standard
      stm32:    +0 +0.000% PYBV10
      esp32:    +0 +0.000% ESP32_GENERIC
     mimxrt:    +0 +0.000% TEENSY40
        rp2:    +0 +0.000% RPI_PICO_W
       samd:    +0 +0.000% ADAFRUIT_ITSYBITSY_M4_EXPRESS
  qemu rv32:    +0 +0.000% VIRT_RV32

@Josverl

Josverl commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

@projectgus,

Many thanks for the quick fix.

I have tested on WIndows11 & WSL2 Ubuntu

board Windows run_repro.ps1 WSL2 run_repro.sh stubber mcu
ESP32-GENERIC-C3 PASS PASS PASS
ESP32-GENERIC-C5 PASS PASS PASS
ESP32-GENERIC-C6 PASS PASS PASS
ESP32-GENERIC-H2 PASS PASS PASS
ESP32-P4_PRE_REV3_C6_WIFI PASS PASS PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VFS‑over‑serial mount unreliable over ESP32 USB‑Serial/JTAG (C3/C5/C6) device

2 participants