Skip to content

AI generated issue-ticket: CDC_Send_DATA blocks indefinitely under USB TX backpressure — WWDG reset on STM32 VCP targets #1107

@nerdCopter

Description

@nerdCopter

AI generated issue-ticket

  • part of the CLI-exit bug found thinking it was HELIO only, but not. and not wholly configurator bug.

Summary

CDC_Send_DATA() in src/main/vcp_hal/usbd_cdc_interface.c contains an indefinite blocking spin that stalls the STM32 task scheduler when the USB TX ring buffer is full. If the scheduler is blocked long enough, the WWDG (Window Watchdog) times out and resets the MCU — producing a spurious USB disconnect that is indistinguishable from a user-initiated reboot.

This is reproduced reliably when leaving the CLI tab and switching to the Sensors tab in EmuConfigurator: the sensors tab begins high-frequency MSP polling immediately, which fills the 2048-byte USB TX ring faster than the host drains it, triggering the block.


Steps to Reproduce

  1. Connect FC over USB (STM32F4 or F7 target).
  2. Open EmuConfigurator → CLI tab. Wait for autocomplete build.
  3. Exit CLI tab → navigate to Sensors tab.
  4. Within 2–5 seconds: second USB disconnect occurs. Sensors gyro goes flat.
  5. Log shows: SERIAL: Connection with ID: 1 closed / PortHandler - Removed: /dev/ttyACM0

Reproducible with stock EmuFlight on any high-polling-rate tab after CLI exit.


Root Cause

CDC_Send_DATA() — indefinite block

// src/main/vcp_hal/usbd_cdc_interface.c
uint32_t CDC_Send_DATA(const uint8_t *ptrBuffer, uint32_t sendLength)
{
    for (uint32_t i = 0; i < sendLength; i++) {
        while (CDC_Send_FreeBytes() == 0) {
            delay(1);   // <-- spins indefinitely if host is not draining
        }
        ATOMIC_BLOCK(NVIC_BUILD_PRIORITY(6, 0)) {
            UserTxBuffer[UserTxBufPtrIn] = ptrBuffer[i];
            UserTxBufPtrIn = (UserTxBufPtrIn + 1) % APP_TX_DATA_SIZE;
        }
    }
    return sendLength;
}

When CDC_Send_FreeBytes() == 0, the function never returns. The STM32 scheduler is blocked. The WWDG is not fed. The MCU resets.

USB_TIMEOUT guard is unreachable

usbVcpWriteBuf() has a 50ms timeout that appears to protect against this:

while (count > 0) {
    uint32_t txed = CDC_Send_DATA(p, count);  // never returns partial
    count -= txed;
    if (millis() - start > USB_TIMEOUT) break;  // never reached
}

CDC_Send_DATA does not return until it has written all bytes. The timeout check in the outer loop never executes.

isUsbVcpTransmitBufferEmpty() is a stub

// src/main/drivers/serial_usb_vcp.c
static bool isUsbVcpTransmitBufferEmpty(const serialPort_t *instance) {
    UNUSED(instance);
    return true;   // always reports empty
}

waitForSerialPortToFinishTransmitting() in cliRebootEx() is therefore a no-op for VCP ports.


Proposed Fix

1. Add per-byte timeout to CDC_Send_DATA — return partial count

uint32_t CDC_Send_DATA(const uint8_t *ptrBuffer, uint32_t sendLength)
{
    uint32_t sent = 0;
    uint32_t start = millis();
    for (uint32_t i = 0; i < sendLength; i++) {
        while (CDC_Send_FreeBytes() == 0) {
            if (millis() - start > USB_TIMEOUT) {
                return sent;   // partial write — caller handles via outer loop
            }
            delay(1);
        }
        ATOMIC_BLOCK(NVIC_BUILD_PRIORITY(6, 0)) {
            UserTxBuffer[UserTxBufPtrIn] = ptrBuffer[i];
            UserTxBufPtrIn = (UserTxBufPtrIn + 1) % APP_TX_DATA_SIZE;
        }
        sent++;
    }
    return sent;
}

This returns partial sent count on timeout. usbVcpWriteBuf's outer count -= txed + USB_TIMEOUT check becomes correctly reachable, no other callers need changes.

2. Fix isUsbVcpTransmitBufferEmpty stub

static bool isUsbVcpTransmitBufferEmpty(const serialPort_t *instance)
{
    UNUSED(instance);
    return CDC_Send_FreeBytes() == APP_TX_DATA_SIZE - 1;
}

Impact

Scenario Before After
Sensors tab after CLI exit WWDG reset → spurious disconnect TX bytes dropped on timeout, no reset
USB host disconnects mid-session FC wedges in spin loop → reset Graceful timeout bailout
Normal MSP traffic No regression Identical — buffer never fills under normal load
waitForSerialPortToFinishTransmitting No-op (always true) Actually waits for TX ring to drain

Related

  • Same bug confirmed in Betaflight: src/platform/STM32/vcp_hal/usbd_cdc_interface.c and src/platform/STM32/vcpf4/usbd_cdc_vcp.c — identical blocking pattern.
  • EmuConfigurator configurator-side CLI exit fixes: nerdCopter/EmuConfigurator_nerdRepo rethink-WM branch.

Identified during EmuConfigurator Electron/Forge migration analysis — 2026-04-04

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions