usbd: hold OUT endpoints with RX_PENDING until the buffer is consumed (#3937)

XFER_COMPLETE cleared BUSY|CLAIMED before the class copied ep_buf, so another
task could claim and re-arm the endpoint into an unconsumed buffer (#1292).
Completion now sets RX_PENDING in the same store; claim() refuses it until the
class calls usbd_edpt_rx_consume() or re-arms with usbd_edpt_xfer(). Applies to
every build, not only OSAL_MUTEX_REQUIRED.

Also: mtp example supports FreeRTOS on ESP-IDF; test_ci_select reads sources as UTF-8.
diff --git a/examples/device/mtp/src/CMakeLists.txt b/examples/device/mtp/src/CMakeLists.txt
new file mode 100644
index 0000000..5d01524
--- /dev/null
+++ b/examples/device/mtp/src/CMakeLists.txt
@@ -0,0 +1,6 @@
+# This file is for ESP-IDF only
+# WHOLE_ARCHIVE: mtp_fs_example.c only overrides weak tud_mtp_*_cb, so the linker would never pull it in
+idf_component_register(SRCS "main.c" "mtp_fs_example.c" "usb_descriptors.c"
+                    INCLUDE_DIRS "."
+                    REQUIRES boards tinyusb_src
+                    WHOLE_ARCHIVE)
diff --git a/examples/device/mtp/src/main.c b/examples/device/mtp/src/main.c
index 6ffa435..71a3890 100644
--- a/examples/device/mtp/src/main.c
+++ b/examples/device/mtp/src/main.c
@@ -47,12 +47,20 @@
 
 static uint32_t blink_interval_ms = BLINK_NOT_MOUNTED;
 
-void led_blinking_task(void);
+void led_blinking_task(void* param);
+
+#if CFG_TUSB_OS == OPT_OS_FREERTOS
+void usb_device_task(void* param);
+void freertos_init(void);
+#endif
 
 /*------------- MAIN -------------*/
 int main(void) {
   board_init();
 
+#if CFG_TUSB_OS == OPT_OS_FREERTOS
+  freertos_init();
+#else
   // init device stack on configured roothub port
   tusb_rhport_init_t dev_init = {
     .role = TUSB_ROLE_DEVICE,
@@ -63,8 +71,9 @@
 
   while (1) {
     tud_task(); // tinyusb device task
-    led_blinking_task();
+    led_blinking_task(NULL);
   }
+#endif
 }
 
 //--------------------------------------------------------------------+
@@ -97,14 +106,85 @@
 //--------------------------------------------------------------------+
 // BLINKING TASK
 //--------------------------------------------------------------------+
-void led_blinking_task(void) {
-  static uint32_t start_ms = 0;
+void led_blinking_task(void* param) {
+  (void) param;
   static bool led_state = false;
 
+#if CFG_TUSB_OS == OPT_OS_FREERTOS
+  while (1) {
+    vTaskDelay(blink_interval_ms / portTICK_PERIOD_MS);
+    board_led_write(led_state);
+    led_state = 1 - led_state; // toggle
+  }
+#else
+  static uint32_t start_ms = 0;
+
   // Blink every interval ms
   if (tusb_time_millis_api() - start_ms < blink_interval_ms) return; // not enough time
   start_ms += blink_interval_ms;
 
   board_led_write(led_state);
   led_state = 1 - led_state; // toggle
+#endif
 }
+
+//--------------------------------------------------------------------+
+// FreeRTOS
+//--------------------------------------------------------------------+
+#if CFG_TUSB_OS == OPT_OS_FREERTOS
+
+#define BLINKY_STACK_SIZE   configMINIMAL_STACK_SIZE
+
+#ifdef ESP_PLATFORM
+  #define USBD_STACK_SIZE   4096
+  void app_main(void) {
+    main();
+  }
+#else
+  // Increase stack size when debug log is enabled
+  #define USBD_STACK_SIZE   ((3*configMINIMAL_STACK_SIZE/2) * (CFG_TUSB_DEBUG ? 2 : 1))
+#endif
+
+#if configSUPPORT_STATIC_ALLOCATION
+StackType_t  blinky_stack[BLINKY_STACK_SIZE];
+StaticTask_t blinky_taskdef;
+
+StackType_t  usb_device_stack[USBD_STACK_SIZE];
+StaticTask_t usb_device_taskdef;
+#endif
+
+// USB Device Driver task: processes all usb events and invokes callbacks
+void usb_device_task(void* param) {
+  (void) param;
+
+  // init device stack on configured roothub port. Must be called after the
+  // scheduler starts: the USB IRQ handler uses RTOS queue APIs.
+  tusb_rhport_init_t dev_init = {
+    .role = TUSB_ROLE_DEVICE,
+    .speed = TUSB_SPEED_AUTO
+  };
+  tusb_init(BOARD_TUD_RHPORT, &dev_init);
+  board_init_after_tusb();
+
+  while (1) {
+    tud_task(); // put thread to waiting state until there is a new event
+  }
+}
+
+void freertos_init(void) {
+  #if configSUPPORT_STATIC_ALLOCATION
+  TU_ASSERT(xTaskCreateStatic(led_blinking_task, "blinky", BLINKY_STACK_SIZE, NULL, 1,
+                              blinky_stack, &blinky_taskdef) != NULL, );
+  TU_ASSERT(xTaskCreateStatic(usb_device_task, "usbd", USBD_STACK_SIZE, NULL, configMAX_PRIORITIES - 1,
+                              usb_device_stack, &usb_device_taskdef) != NULL, );
+  #else
+  TU_ASSERT(xTaskCreate(led_blinking_task, "blinky", BLINKY_STACK_SIZE, NULL, 1, NULL) == pdPASS, );
+  TU_ASSERT(xTaskCreate(usb_device_task, "usbd", USBD_STACK_SIZE, NULL, configMAX_PRIORITIES - 1, NULL) == pdPASS, );
+  #endif
+
+  // espressif starts the scheduler before app_main
+  #ifndef ESP_PLATFORM
+  vTaskStartScheduler();
+  #endif
+}
+#endif
diff --git a/examples/device/mtp/src/tusb_config.h b/examples/device/mtp/src/tusb_config.h
index 5224cd7..2bf64ff 100644
--- a/examples/device/mtp/src/tusb_config.h
+++ b/examples/device/mtp/src/tusb_config.h
@@ -57,6 +57,11 @@
 #define CFG_TUSB_OS           OPT_OS_NONE
 #endif
 
+// Espressif IDF requires "freertos/" prefix in include path
+#ifdef ESP_PLATFORM
+#define CFG_TUSB_OS_INC_PATH  freertos/
+#endif
+
 #ifndef CFG_TUSB_DEBUG
 #define CFG_TUSB_DEBUG        0
 #endif
diff --git a/src/class/midi/midi2_device.c b/src/class/midi/midi2_device.c
index b0a9e25..1b06331 100644
--- a/src/class/midi/midi2_device.c
+++ b/src/class/midi/midi2_device.c
@@ -989,6 +989,8 @@
         _nego_process_rx(p_midi);
       }
       tud_midi2_rx_cb(idx);
+    } else {
+      tu_edpt_stream_read_xfer_complete(ep_rx, 0); // release ep_buf for the re-arm below
     }
     tu_edpt_stream_read_xfer(ep_rx);
   } else if (ep_addr == ep_tx->ep_addr && result == XFER_RESULT_SUCCESS) {
diff --git a/src/class/midi/midi_device.c b/src/class/midi/midi_device.c
index 8a12221..2be56a9 100644
--- a/src/class/midi/midi_device.c
+++ b/src/class/midi/midi_device.c
@@ -534,6 +534,8 @@
     if (result == XFER_RESULT_SUCCESS) {
       tu_edpt_stream_read_xfer_complete(ep_st_rx, xferred_bytes);
       tud_midi_rx_cb(idx);                      // invoke callback
+    } else {
+      tu_edpt_stream_read_xfer_complete(ep_st_rx, 0); // release ep_buf for the re-arm below
     }
     tu_edpt_stream_read_xfer(ep_st_rx);         // prepare for next data
   } else if (ep_addr == ep_st_tx->ep_addr && result == XFER_RESULT_SUCCESS) {
diff --git a/src/class/mtp/mtp_device.c b/src/class/mtp/mtp_device.c
index 275c9f8..f867bb3 100644
--- a/src/class/mtp/mtp_device.c
+++ b/src/class/mtp/mtp_device.c
@@ -215,6 +215,7 @@
 
   TU_LOG_DRV("  MTP Data OUT: xferred_len/total_len=%lu/%lu, xact_len=%u\r\n", p_mtp->xferred_len, p_mtp->total_len,
              xact_len);
+  usbd_edpt_rx_consume(p_mtp->rhport, p_mtp->ep_out); // arming a new receive relinquishes the previous data
   TU_VERIFY(usbd_edpt_claim(p_mtp->rhport, p_mtp->ep_out));
   TU_ASSERT(usbd_edpt_xfer(p_mtp->rhport, p_mtp->ep_out, _mtpd_epbuf.buf, xact_len, false));
   return true;
@@ -475,6 +476,7 @@
 
         if (need_zlp) {
           TU_LOG_DRV("  queue ZLP\r\n");
+          usbd_edpt_rx_consume(p_mtp->rhport, ep_addr); // arming a new receive relinquishes the previous data
           TU_VERIFY(usbd_edpt_claim(p_mtp->rhport, ep_addr));
           TU_ASSERT(usbd_edpt_xfer(p_mtp->rhport, ep_addr, NULL, 0, false));
           return true;
diff --git a/src/class/vendor/vendor_device.c b/src/class/vendor/vendor_device.c
index c4a550e..595513c 100644
--- a/src/class/vendor/vendor_device.c
+++ b/src/class/vendor/vendor_device.c
@@ -224,6 +224,7 @@
 
 TU_ATTR_UNUSED static inline bool vendord_ep_read_xfer(vendord_interface_t *p_itf, uint8_t ep, uint8_t *epbuf, uint16_t xfer_len) {
   TU_VERIFY(ep > 0); // must be opened
+  usbd_edpt_rx_consume(p_itf->rhport, ep); // arming a new receive relinquishes the previous data
   TU_VERIFY(usbd_edpt_claim(p_itf->rhport, ep));
   return usbd_edpt_xfer(p_itf->rhport, ep, epbuf, xfer_len, false);
 }
diff --git a/src/common/tusb_private.h b/src/common/tusb_private.h
index b91fc06..f45273f 100644
--- a/src/common/tusb_private.h
+++ b/src/common/tusb_private.h
@@ -31,6 +31,7 @@
 #define TU_EDPT_STATE_BUSY    0x01u
 #define TU_EDPT_STATE_STALLED 0x02u
 #define TU_EDPT_STATE_CLAIMED 0x04u
+#define TU_EDPT_STATE_RX_PENDING 0x08u // OUT xfer completed, ep buffer not yet consumed by the class (#1292)
 
 typedef struct {
   uint8_t  hwid;    // device: rhport, host: daddr
@@ -144,12 +145,22 @@
 // Start an usb transfer if endpoint is not busy
 uint32_t tu_edpt_stream_read_xfer(tu_edpt_stream_t *s);
 
+#if CFG_TUD_ENABLED
+// Release an OUT endpoint's RX_PENDING hold once its buffer is consumed, allowing it to be claimed
+void usbd_edpt_rx_consume(uint8_t rhport, uint8_t ep_addr);
+#else
+  #define usbd_edpt_rx_consume(_rhport, _ep_addr)
+#endif
+
 // Complete read transfer by writing EP -> FIFO. Must be called in the transfer complete callback
 TU_ATTR_ALWAYS_INLINE static inline
 void tu_edpt_stream_read_xfer_complete(tu_edpt_stream_t* s, uint32_t xferred_bytes) {
   if (s->ep_buf != NULL) {
     tu_fifo_write_n(&s->ff, s->ep_buf, (uint16_t)xferred_bytes);
   }
+  if (!s->is_host) {
+    usbd_edpt_rx_consume(s->hwid, s->ep_addr);
+  }
 }
 
 // Complete read transfer with provided buffer
diff --git a/src/device/usbd.c b/src/device/usbd.c
index 21d865c..f702bce 100644
--- a/src/device/usbd.c
+++ b/src/device/usbd.c
@@ -753,10 +753,19 @@
 
         TU_LOG_USBD("on EP %02X with %u bytes\r\n", ep_addr, (unsigned int) event.xfer_complete.len);
 
-        // Clear busy + claimed
-        _usbd_dev.ep_status[epnum][ep_dir] &= (uint8_t) ~(TU_EDPT_STATE_BUSY | TU_EDPT_STATE_CLAIMED);
+        usbd_class_driver_t const* driver = NULL; // EP0 has no class driver
+        if (0 != epnum) {
+          driver = get_driver(_usbd_dev.ep2drv[epnum][ep_dir]);
+          TU_ASSERT(driver,);
+        }
 
-        if (0 == epnum) {
+        // Clear busy + claimed. An OUT endpoint changed to RX_PENDING, so no other task can claim and re-arm it before
+        // its buffer is consumed
+        uint8_t const rx_pending = (0 != epnum && ep_dir == TUSB_DIR_OUT) ? TU_EDPT_STATE_RX_PENDING : 0u;
+        _usbd_dev.ep_status[epnum][ep_dir] = (uint8_t) (
+          (_usbd_dev.ep_status[epnum][ep_dir] & ~(TU_EDPT_STATE_BUSY | TU_EDPT_STATE_CLAIMED)) | rx_pending);
+
+        if (driver == NULL) {
           // Not stalled on failure: a DCD refuses an EP0 prime when a newer setup is already
           // latched, and EP0 stalls are cleared by hardware when that setup arrives - so a stall
           // issued here lands after the auto-clear and would stall the transfer that superseded
@@ -766,9 +775,6 @@
             TU_LOG_USBD("  Control stage not continued\r\n");
           }
         } else {
-          usbd_class_driver_t const* driver = get_driver(_usbd_dev.ep2drv[epnum][ep_dir]);
-          TU_ASSERT(driver,);
-
           TU_LOG_USBD("  %s xfer callback\r\n", driver->name);
           driver->xfer_cb(event.rhport, ep_addr, (xfer_result_t) event.xfer_complete.result, event.xfer_complete.len);
         }
@@ -1589,6 +1595,21 @@
   return tu_edpt_release(&_usbd_dev.ep_status[epnum][dir], _usbd_mutex);
 }
 
+void usbd_edpt_rx_consume(uint8_t rhport, uint8_t ep_addr) {
+  (void) rhport;
+
+  uint8_t const epnum = tu_edpt_number(ep_addr);
+  uint8_t const dir = tu_edpt_dir(ep_addr);
+  volatile uint8_t *ep_state = &_usbd_dev.ep_status[epnum][dir];
+
+  TU_VERIFY((*ep_state & TU_EDPT_STATE_RX_PENDING) != 0,);
+  (void) osal_mutex_lock(_usbd_mutex, OSAL_TIMEOUT_WAIT_FOREVER);
+  if ((*ep_state & TU_EDPT_STATE_RX_PENDING) != 0) {
+    *ep_state &= (uint8_t) ~TU_EDPT_STATE_RX_PENDING;
+  }
+  (void) osal_mutex_unlock(_usbd_mutex);
+}
+
 bool usbd_edpt_xfer(uint8_t rhport, uint8_t ep_addr, uint8_t* buffer, uint16_t total_bytes, bool is_isr) {
   rhport = _usbd_rhport;
 
@@ -1610,7 +1631,8 @@
 
   // Set busy first since the actual transfer can be complete before dcd_edpt_xfer()
   // could return and USBD task can preempt and clear the busy
-  _usbd_dev.ep_status[epnum][dir] |= TU_EDPT_STATE_BUSY;
+  _usbd_dev.ep_status[epnum][dir] =
+    (uint8_t) ((_usbd_dev.ep_status[epnum][dir] & ~TU_EDPT_STATE_RX_PENDING) | TU_EDPT_STATE_BUSY);
 
   if (dcd_edpt_xfer(rhport, ep_addr, buffer, total_bytes, is_isr)) {
     return true;
@@ -1643,7 +1665,8 @@
 
   // Set busy first since the actual transfer can be complete before dcd_edpt_xfer() could return
   // and usbd task can preempt and clear the busy
-  _usbd_dev.ep_status[epnum][dir] |= TU_EDPT_STATE_BUSY;
+  _usbd_dev.ep_status[epnum][dir] =
+    (uint8_t) ((_usbd_dev.ep_status[epnum][dir] & ~TU_EDPT_STATE_RX_PENDING) | TU_EDPT_STATE_BUSY);
 
   if (dcd_edpt_xfer_fifo(rhport, ep_addr, ff, total_bytes, is_isr)) {
     TU_LOG_USBD("OK\r\n");
diff --git a/src/device/usbd_pvt.h b/src/device/usbd_pvt.h
index abf136e..a68e7f4 100644
--- a/src/device/usbd_pvt.h
+++ b/src/device/usbd_pvt.h
@@ -74,6 +74,8 @@
 
 // Claim an endpoint before submitting a transfer.
 // If caller does not make any transfer, it must release endpoint for others.
+// From an OUT xfer_cb, call usbd_edpt_rx_consume() (common/tusb_private.h) before claiming, or re-arm with
+// usbd_edpt_xfer() directly: the claim is refused until the buffer is consumed.
 bool usbd_edpt_claim(uint8_t rhport, uint8_t ep_addr);
 
 // Release claimed endpoint without submitting a transfer
diff --git a/src/tusb.c b/src/tusb.c
index e1548e8..ca298cd 100644
--- a/src/tusb.c
+++ b/src/tusb.c
@@ -209,11 +209,11 @@
   (void) mutex;
 
   // pre-check to help reducing mutex lock
-  TU_VERIFY((*ep_state & (TU_EDPT_STATE_BUSY | TU_EDPT_STATE_CLAIMED)) == 0);
+  TU_VERIFY((*ep_state & (TU_EDPT_STATE_BUSY | TU_EDPT_STATE_CLAIMED | TU_EDPT_STATE_RX_PENDING)) == 0);
   (void) osal_mutex_lock(mutex, OSAL_TIMEOUT_WAIT_FOREVER);
 
-  // can only claim the endpoint if it is not busy and not claimed yet.
-  bool const available = (*ep_state & (TU_EDPT_STATE_BUSY | TU_EDPT_STATE_CLAIMED)) == 0;
+  // can only claim the endpoint if it is not busy, not claimed yet and its received data is consumed.
+  bool const available = (*ep_state & (TU_EDPT_STATE_BUSY | TU_EDPT_STATE_CLAIMED | TU_EDPT_STATE_RX_PENDING)) == 0;
   if (available) {
     *ep_state |= TU_EDPT_STATE_CLAIMED;
   }
diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py
index 6f8be2f..8028e5a 100644
--- a/test/hil/test/test_ci_select.py
+++ b/test/hil/test/test_ci_select.py
@@ -31,7 +31,8 @@
 
 
 def _read(path):
-    with open(path) as f:
+    # Fixed encoding, like ci_select._read: a non-UTF-8 locale would fail on tracked non-ASCII bytes.
+    with open(path, encoding='utf-8', errors='replace') as f:
         return f.read()
 
 
@@ -1680,8 +1681,8 @@
     def test_espressif_prunes_to_what_its_build_path_can_build(self):
         # build.py's espressif branch builds get_examples('espressif') only (the
         # *_freertos examples plus a short extra list), so keeping espressif for a
-        # device/mtp diff spins CircleCI's most expensive leg up to skip everything
-        s = ci_select.classify_build(['examples/device/mtp/src/main.c'], REPO)
+        # device/cdc_msc diff spins CircleCI's most expensive leg up to skip everything
+        s = ci_select.classify_build(['examples/device/cdc_msc/src/main.c'], REPO)
         self.assertFalse(s['full'])
         self.assertNotIn('espressif', s['families'])
 
diff --git a/test/hil/tinyusb.json b/test/hil/tinyusb.json
index 4f8283e..250bd2b 100644
--- a/test/hil/tinyusb.json
+++ b/test/hil/tinyusb.json
@@ -57,6 +57,7 @@
                     "device/cdc_msc_throughput",
                     "device/hid_composite_freertos",
                     "device/audio_test_freertos",
+                    "device/mtp",
                     "device/usbtest",
                     "host/device_info",
                     "host/msc_file_explorer_freertos"
@@ -98,6 +99,7 @@
                     "device/cdc_msc_throughput",
                     "device/hid_composite_freertos",
                     "device/audio_test_freertos",
+                    "device/mtp",
                     "device/usbtest",
                     "host/device_info",
                     "host/msc_file_explorer_freertos"
diff --git a/test/unit-test/test/device/usbd/test_usbd.c b/test/unit-test/test/device/usbd/test_usbd.c
index 8490973..a221f86 100644
--- a/test/unit-test/test/device/usbd/test_usbd.c
+++ b/test/unit-test/test/device/usbd/test_usbd.c
@@ -381,3 +381,146 @@
 
   tud_task();
 }
+
+//--------------------------------------------------------------------+
+// OUT endpoint held by RX_PENDING until its buffer is consumed (#1292)
+//--------------------------------------------------------------------+
+enum {
+  EDPT_MSC_OUT = 0x01,
+  EDPT_MSC_IN  = 0x81
+};
+
+uint8_t const msc_desc_configuration[] = {
+  TUD_CONFIG_DESCRIPTOR(1, 1, 0, TUD_CONFIG_DESC_LEN + TUD_MSC_DESC_LEN, 0, 100),
+  TUD_MSC_DESCRIPTOR(0, 0, EDPT_MSC_OUT, EDPT_MSC_IN, 64),
+};
+
+tusb_control_request_t const req_set_configuration = {
+  .bmRequestType = 0x00,
+  .bRequest      = TUSB_REQ_SET_CONFIGURATION,
+  .wValue        = 1,
+  .wIndex        = 0,
+  .wLength       = 0
+};
+
+static uint8_t msc_out_buf[64];
+
+// Bind the bulk endpoints to the (mocked) MSC driver, then claim and arm the OUT endpoint
+static void msc_out_armed(void) {
+  mscd_reset_Ignore();
+  dcd_event_bus_reset(rhport, TUSB_SPEED_FULL, false);
+  tud_task();
+
+  desc_configuration = msc_desc_configuration;
+  dcd_event_setup_received(rhport, (uint8_t*) &req_set_configuration, false);
+  mscd_open_ExpectAndReturn(rhport, (tusb_desc_interface_t const*) (msc_desc_configuration + TUD_CONFIG_DESC_LEN),
+                            TUD_MSC_DESC_LEN, TUD_MSC_DESC_LEN);
+  dcd_edpt_xfer_ExpectAndReturn(rhport, EDPT_CTRL_IN, NULL, 0, false, true);
+  dcd_event_xfer_complete(rhport, EDPT_CTRL_IN, 0, 0, false);
+  dcd_edpt0_status_complete_ExpectWithArray(rhport, &req_set_configuration, 1);
+  tud_task();
+
+  TEST_ASSERT_TRUE(usbd_edpt_claim(rhport, EDPT_MSC_OUT));
+  dcd_edpt_xfer_ExpectAndReturn(rhport, EDPT_MSC_OUT, msc_out_buf, sizeof(msc_out_buf), false, true);
+  TEST_ASSERT_TRUE(usbd_edpt_xfer(rhport, EDPT_MSC_OUT, msc_out_buf, sizeof(msc_out_buf), false));
+}
+
+static void msc_out_complete(CMOCK_mscd_xfer_cb_CALLBACK xfer_cb) {
+  mscd_xfer_cb_Stub(xfer_cb);
+  dcd_event_xfer_complete(rhport, EDPT_MSC_OUT, sizeof(msc_out_buf), XFER_RESULT_SUCCESS, false);
+  tud_task();
+}
+
+static bool xfer_cb_claim_after_consume(uint8_t rhport_, uint8_t ep_addr, xfer_result_t result, uint32_t xferred_bytes,
+                                        int num_calls) {
+  (void) result; (void) xferred_bytes; (void) num_calls;
+  TEST_ASSERT_FALSE(usbd_edpt_busy(rhport_, ep_addr));
+  TEST_ASSERT_FALSE(usbd_edpt_claim(rhport_, ep_addr)); // buffer not yet consumed
+
+  usbd_edpt_rx_consume(rhport_, ep_addr);
+  TEST_ASSERT_TRUE(usbd_edpt_claim(rhport_, ep_addr));
+  TEST_ASSERT_TRUE(usbd_edpt_release(rhport_, ep_addr));
+  return true;
+}
+
+void test_usbd_out_complete_refuses_claim_until_consumed(void) {
+  msc_out_armed();
+  msc_out_complete(xfer_cb_claim_after_consume);
+  TEST_ASSERT_TRUE(usbd_edpt_claim(rhport, EDPT_MSC_OUT));
+}
+
+static bool xfer_cb_no_consume(uint8_t rhport_, uint8_t ep_addr, xfer_result_t result, uint32_t xferred_bytes,
+                               int num_calls) {
+  (void) result; (void) xferred_bytes; (void) num_calls;
+  TEST_ASSERT_FALSE(usbd_edpt_claim(rhport_, ep_addr));
+  return true;
+}
+
+// xfer_cb that neither consumes nor re-arms: the hold outlives xfer_cb until the class consumes
+void test_usbd_out_complete_held_after_xfer_cb_until_consumed(void) {
+  msc_out_armed();
+  msc_out_complete(xfer_cb_no_consume);
+  TEST_ASSERT_FALSE(usbd_edpt_claim(rhport, EDPT_MSC_OUT));
+  usbd_edpt_rx_consume(rhport, EDPT_MSC_OUT);
+  TEST_ASSERT_TRUE(usbd_edpt_claim(rhport, EDPT_MSC_OUT));
+}
+
+static bool xfer_cb_consume_rearm(uint8_t rhport_, uint8_t ep_addr, xfer_result_t result, uint32_t xferred_bytes,
+                                  int num_calls) {
+  (void) result; (void) xferred_bytes; (void) num_calls;
+  usbd_edpt_rx_consume(rhport_, ep_addr);
+  TEST_ASSERT_TRUE(usbd_edpt_claim(rhport_, ep_addr));
+  dcd_edpt_xfer_ExpectAndReturn(rhport_, ep_addr, msc_out_buf, sizeof(msc_out_buf), false, true);
+  TEST_ASSERT_TRUE(usbd_edpt_xfer(rhport_, ep_addr, msc_out_buf, sizeof(msc_out_buf), false));
+  return true;
+}
+
+// a transfer re-armed inside xfer_cb stays BUSY and is not held by RX_PENDING
+void test_usbd_out_rearmed_in_xfer_cb_stays_busy(void) {
+  msc_out_armed();
+  msc_out_complete(xfer_cb_consume_rearm);
+  TEST_ASSERT_TRUE(usbd_edpt_busy(rhport, EDPT_MSC_OUT));
+  TEST_ASSERT_FALSE(usbd_edpt_claim(rhport, EDPT_MSC_OUT));
+}
+
+static bool xfer_cb_rearm_refused(uint8_t rhport_, uint8_t ep_addr, xfer_result_t result, uint32_t xferred_bytes,
+                                  int num_calls) {
+  (void) result; (void) xferred_bytes; (void) num_calls;
+  // direct re-arm without claim, as MSC does; the DCD refuses it
+  dcd_edpt_xfer_ExpectAndReturn(rhport_, ep_addr, msc_out_buf, sizeof(msc_out_buf), false, false);
+  TEST_ASSERT_FALSE(usbd_edpt_xfer(rhport_, ep_addr, msc_out_buf, sizeof(msc_out_buf), false));
+  return true;
+}
+
+// a failed re-arm leaves the endpoint idle: neither BUSY nor still held by RX_PENDING
+void test_usbd_out_failed_rearm_in_xfer_cb_stays_idle(void) {
+  msc_out_armed();
+  msc_out_complete(xfer_cb_rearm_refused);
+  TEST_ASSERT_FALSE(usbd_edpt_busy(rhport, EDPT_MSC_OUT));
+  TEST_ASSERT_TRUE(usbd_edpt_claim(rhport, EDPT_MSC_OUT));
+}
+
+static bool xfer_cb_rearm_dropped(uint8_t rhport_, uint8_t ep_addr, xfer_result_t result, uint32_t xferred_bytes,
+                                  int num_calls) {
+  (void) result; (void) xferred_bytes; (void) num_calls;
+  dcd_edpt_xfer_ExpectAndReturn(rhport_, ep_addr, msc_out_buf, sizeof(msc_out_buf), false, true);
+  TEST_ASSERT_TRUE(usbd_edpt_xfer(rhport_, ep_addr, msc_out_buf, sizeof(msc_out_buf), false));
+
+  // fill the queue with events that leave endpoints alone, so the re-armed completion is dropped
+  for (unsigned i = 0; i < CFG_TUD_TASK_QUEUE_SZ; i++) {
+    dcd_event_bus_signal(rhport_, DCD_EVENT_SUSPEND, false);
+  }
+  dcd_event_xfer_complete(rhport_, ep_addr, sizeof(msc_out_buf), XFER_RESULT_SUCCESS, false);
+  return true;
+}
+
+// a re-armed transfer whose completion is dropped leaves the endpoint idle
+void test_usbd_out_dropped_rearm_in_xfer_cb_stays_idle(void) {
+  msc_out_armed();
+  msc_out_complete(xfer_cb_rearm_dropped);
+  for (unsigned i = 0; i < (CFG_TUD_TASK_QUEUE_SZ / CFG_TUD_TASK_EVENTS_PER_RUN) + 1; i++) {
+    tud_task();
+  }
+  TEST_ASSERT_FALSE(usbd_edpt_busy(rhport, EDPT_MSC_OUT));
+  TEST_ASSERT_TRUE(usbd_edpt_claim(rhport, EDPT_MSC_OUT));
+}
diff --git a/tools/build.py b/tools/build.py
index 473ecc4..4a63536 100755
--- a/tools/build.py
+++ b/tools/build.py
@@ -83,6 +83,7 @@
     if family == 'espressif':
         all_examples.append('device/board_test')
         all_examples.append('device/cdc_msc_throughput')
+        all_examples.append('device/mtp')
         all_examples.append('device/usbtest')
         all_examples.append('device/video_capture')
         all_examples.append('host/device_info')