From 3d07f2d96f81306676f0bc81c26ff26cbf832108 Mon Sep 17 00:00:00 2001 From: Michael Pham <61564344+Mikefly123@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:47:13 -0700 Subject: [PATCH 1/2] usb: device_next: cdc_acm: clear TX_FIFO_BUSY on disable; retry when stuck Problem: if the USB host stops issuing IN tokens while a CDC-ACM TX transfer is in flight (e.g. a host-side driver that pauses polling without disconnecting), CDC_ACM_TX_FIFO_BUSY is left set indefinitely. The existing error-completion path clears the flag on disconnect or transfer cancellation, but a passive stall that never fires a completion leaves it stuck. Once stuck, uart_poll_out() keeps scheduling cdc_acm_tx_fifo_handler(), but the handler returns immediately on every invocation because the busy check short-circuits it, so the TX path stops draining permanently even after the host resumes polling. Mechanism: usbd_cdc_acm_disable() now clears CDC_ACM_TX_FIFO_BUSY, guaranteeing the flag starts clean on the next enable/reconnect cycle. Additionally, when cdc_acm_tx_fifo_handler() finds BUSY already set and the ring buffer still has data pending, it reschedules itself after a 10 ms delay instead of returning permanently. If BUSY clears normally when the in-flight transfer completes, the retry fires once more and is a no-op (the ring buffer is already drained). If BUSY is stuck because the host stopped acknowledging the IN endpoint, the handler keeps retrying every 10 ms at negligible CPU cost until the host resumes polling and the transfer completes. Fix: the pending retry re-arms the IN endpoint as soon as the host resumes issuing IN tokens, instead of requiring an explicit disable/enable cycle (e.g. USB replug) to recover the TX path. Signed-off-by: Michael Pham --- subsys/usb/device_next/class/usbd_cdc_acm.c | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/subsys/usb/device_next/class/usbd_cdc_acm.c b/subsys/usb/device_next/class/usbd_cdc_acm.c index 933396a597e0..b20115a60f38 100644 --- a/subsys/usb/device_next/class/usbd_cdc_acm.c +++ b/subsys/usb/device_next/class/usbd_cdc_acm.c @@ -383,6 +383,10 @@ static void usbd_cdc_acm_disable(struct usbd_class_data *const c_data) atomic_clear_bit(&data->state, CDC_ACM_CLASS_ENABLED); atomic_clear_bit(&data->state, CDC_ACM_CLASS_SUSPENDED); + /* Clear TX_FIFO_BUSY on disable so a stuck IN transfer does not block + * the TX path after the host reconnects and re-enables the class. + */ + atomic_clear_bit(&data->state, CDC_ACM_TX_FIFO_BUSY); LOG_INF("Configuration disabled"); } @@ -653,6 +657,13 @@ static void cdc_acm_tx_fifo_handler(struct k_work *work) if (atomic_test_and_set_bit(&data->state, CDC_ACM_TX_FIFO_BUSY)) { LOG_DBG("TX transfer already in progress"); + /* Reschedule if data is waiting - guards against a stuck IN transfer + * where the host stops issuing IN tokens (e.g. macOS flow-control). + * The retry is a no-op once the completion fires and clears BUSY. + */ + if (!ring_buf_is_empty(data->tx_fifo.rb)) { + cdc_acm_work_schedule(&data->tx_fifo_work, K_MSEC(10)); + } return; } From 3c87e0965711c26bd861ff51156a4bd24f4d2743 Mon Sep 17 00:00:00 2001 From: Michael Pham <61564344+Mikefly123@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:48:35 -0700 Subject: [PATCH 2/2] usb: device_next: cdc_acm: bound poll_out backpressure wait Problem: cdc_acm_poll_out() sleep-retries in an unbounded loop while the TX ring buffer is full and flow control is active. When a host session is attached but stalled (e.g. a host-side USB driver that stops issuing IN tokens for an extended period without disconnecting), this loop blocks for the full duration of the stall on every console byte written. Any thread that logs while this is happening backs up behind the stalled writer, and if enough producers share the same downstream queues, the backpressure cascades into a system-wide livelock rather than staying contained to the console path. Fix: cap the retry loop at 20 iterations of the existing 1 ms sleep (~20 ms total). Once the retry budget is exhausted, treat the still-attached-but-unresponsive session the same as the already-handled detached case: log once and discard the pending byte instead of continuing to block. This keeps the console best-effort under sustained backpressure while leaving the normal (non-stalled) flow-controlled path unaffected, since the loop still exits immediately once the ring buffer drains. Signed-off-by: Michael Pham --- subsys/usb/device_next/class/usbd_cdc_acm.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/subsys/usb/device_next/class/usbd_cdc_acm.c b/subsys/usb/device_next/class/usbd_cdc_acm.c index 933396a597e0..79e8e500947e 100644 --- a/subsys/usb/device_next/class/usbd_cdc_acm.c +++ b/subsys/usb/device_next/class/usbd_cdc_acm.c @@ -998,6 +998,7 @@ static void cdc_acm_poll_out(const struct device *dev, const unsigned char c) struct cdc_acm_uart_data *const data = dev->data; k_spinlock_key_t key; uint32_t wrote; + int retries = 20; while (true) { key = k_spin_lock(&data->lock); @@ -1008,7 +1009,15 @@ static void cdc_acm_poll_out(const struct device *dev, const unsigned char c) break; } - if (k_is_in_isr() || !data->flow_ctrl) { + /* Bounded wait: with an attached-but-stalled host session (macOS + * ceases IN polling for minutes at a time), an unbounded sleep-retry + * here makes every console write take the full stall duration. Any + * thread that logs (event text loggers, assert reporting) then backs + * up its own queues and cascades into a system-wide com livelock. + * After ~20 ms of backpressure, treat the console as best-effort and + * discard, exactly like the detached (!flow_ctrl) case below. + */ + if (k_is_in_isr() || !data->flow_ctrl || retries-- <= 0) { LOG_WRN_ONCE("Ring buffer full, discard data"); break; }