cdc_msc_throughput: support FreeRTOS and run it on ESP32-S3/P4 HIL (#3935)
diff --git a/examples/device/cdc_msc_throughput/README.md b/examples/device/cdc_msc_throughput/README.md
index 3de49b8..7252084 100644
--- a/examples/device/cdc_msc_throughput/README.md
+++ b/examples/device/cdc_msc_throughput/README.md
@@ -52,6 +52,13 @@
 make BOARD=raspberry_pi_pico all
 ```
 
+FreeRTOS: add `-DRTOS=freertos` (CMake) or `RTOS=freertos` (Make). ESP-IDF builds
+always use FreeRTOS (from the repository root, with ESP-IDF exported):
+
+```bash
+python3 tools/build.py -b espressif_s3_devkitm -e device/cdc_msc_throughput
+```
+
 ## Measuring throughput (Linux)
 
 The MSC disk appears as a raw block device (e.g. `/dev/sdX`); the CDC port as `/dev/ttyACMx`.
diff --git a/examples/device/cdc_msc_throughput/src/CMakeLists.txt b/examples/device/cdc_msc_throughput/src/CMakeLists.txt
new file mode 100644
index 0000000..cef2b46
--- /dev/null
+++ b/examples/device/cdc_msc_throughput/src/CMakeLists.txt
@@ -0,0 +1,4 @@
+# This file is for ESP-IDF only
+idf_component_register(SRCS "main.c" "usb_descriptors.c"
+                    INCLUDE_DIRS "."
+                    REQUIRES boards tinyusb_src)
diff --git a/examples/device/cdc_msc_throughput/src/main.c b/examples/device/cdc_msc_throughput/src/main.c
index 116cbe1..3ca52bc 100644
--- a/examples/device/cdc_msc_throughput/src/main.c
+++ b/examples/device/cdc_msc_throughput/src/main.c
@@ -32,17 +32,25 @@
 // (partition table, GPT header). Higher LBAs return whatever is already in the
 // transfer buffer, so `dd` numbers reflect the USB/driver ceiling, not any
 // simulated storage or per-byte memset cost.
-// CDC path drains RX in tud_cdc_rx_cb and sources TX from a static filler in the
-// main loop so `dd` can target /dev/ttyACMx in either direction.
+// CDC path drains RX in tud_cdc_rx_cb and sources TX from a static filler after every
+// tud_task() pass, so `dd` can target /dev/ttyACMx in either direction.
+// Builds bare-metal or with FreeRTOS (CFG_TUSB_OS, always FreeRTOS on ESP-IDF).
 
 static void cdc_throughput_task(void);
 
+#if CFG_TUSB_OS == OPT_OS_FREERTOS
+static void freertos_init(void);
+#endif
+
 //--------------------------------------------------------------------+
 // Main
 //--------------------------------------------------------------------+
 int main(void) {
   board_init();
 
+#if CFG_TUSB_OS == OPT_OS_FREERTOS
+  freertos_init();
+#else
   tusb_rhport_init_t dev_init = {.role = TUSB_ROLE_DEVICE, .speed = TUSB_SPEED_AUTO};
   tusb_init(BOARD_TUD_RHPORT, &dev_init);
 
@@ -52,6 +60,7 @@
     tud_task();
     cdc_throughput_task();
   }
+#endif
 }
 
 //--------------------------------------------------------------------+
@@ -149,3 +158,53 @@
   tud_msc_set_sense(lun, SCSI_SENSE_ILLEGAL_REQUEST, 0x20, 0x00);
   return -1;
 }
+
+//--------------------------------------------------------------------+
+// FreeRTOS
+//--------------------------------------------------------------------+
+#if CFG_TUSB_OS == OPT_OS_FREERTOS
+
+#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
+static StackType_t  usb_device_stack[USBD_STACK_SIZE];
+static StaticTask_t usb_device_taskdef;
+#endif
+
+static void usb_device_task(void *param) {
+  (void) param;
+
+  // 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();
+
+  // tud_task() blocks until an event: pump CDC after each event batch without waiting for a tick
+  while (1) {
+    tud_task();
+    cdc_throughput_task();
+  }
+}
+
+static void freertos_init(void) {
+  #if configSUPPORT_STATIC_ALLOCATION
+  xTaskCreateStatic(usb_device_task, "usbd", USBD_STACK_SIZE, NULL, configMAX_PRIORITIES-1, usb_device_stack, &usb_device_taskdef);
+  #else
+  xTaskCreate(usb_device_task, "usbd", USBD_STACK_SIZE, NULL, configMAX_PRIORITIES-1, NULL);
+  #endif
+
+  // only start scheduler for non-espressif mcu (espressif starts it in startup code)
+  #ifndef ESP_PLATFORM
+  vTaskStartScheduler();
+  #endif
+}
+#endif
diff --git a/examples/device/cdc_msc_throughput/src/tusb_config.h b/examples/device/cdc_msc_throughput/src/tusb_config.h
index 6c86557..16c6244 100644
--- a/examples/device/cdc_msc_throughput/src/tusb_config.h
+++ b/examples/device/cdc_msc_throughput/src/tusb_config.h
@@ -54,6 +54,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/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py
index 65315db..4e33538 100644
--- a/test/hil/test/test_ci_select.py
+++ b/test/hil/test/test_ci_select.py
@@ -485,6 +485,17 @@
         for board in boards:
             self.assertEqual(s['boards'][board], ['device/hid_composite_freertos'])
 
+    def test_dual_mode_example_change_selects_only_list_boards(self):
+        # cdc_msc_throughput is not a *_freertos example: espressif builds it only via
+        # build.py's extra list, and selects it only via the roster only-list
+        boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+        s = ci_select.classify(['examples/device/cdc_msc_throughput/src/main.c'], REPO, real_rosters())
+        self.assertFalse(s['full'])
+        for board in boards:
+            self.assertEqual(s['boards'][board], ['device/cdc_msc_throughput'])
+        self.assertIn('espressif', ci_select.classify_build(
+            ['examples/device/cdc_msc_throughput/src/main.c'], REPO)['families'])
+
     def test_class_change_includes_only_list_boards(self):
         boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
         s = ci_select.classify(['src/class/hid/hid_device.c'], REPO, real_rosters())
@@ -2543,6 +2554,16 @@
             else:
                 self.assertEqual(pool, allex, f'{fam}: build.py narrows this family')
 
+    def test_espressif_builds_every_example_its_rig_boards_run(self):
+        # an only-list example missing from get_examples('espressif') has no binary,
+        # so HIL reports it skipped rather than failing
+        pool = set(self.build_py.get_examples('espressif'))
+        for _, boards in real_rosters():
+            for b in boards:
+                if b.get('flasher', {}).get('name') == 'esptool':
+                    for t in b.get('tests', {}).get('only', []):
+                        self.assertIn(t, pool, f"{b['name']}: {t}")
+
     def test_selections_are_what_the_espressif_only_rule_gave(self):
         # espressif's own list is the one value that ever differed from the unfiltered
         # example set. Recomputed from build.py rather than pinned as literals: a new
diff --git a/test/hil/tinyusb.json b/test/hil/tinyusb.json
index f5f7228..4f8283e 100644
--- a/test/hil/tinyusb.json
+++ b/test/hil/tinyusb.json
@@ -54,6 +54,7 @@
             "tests": {
                 "only": [
                     "device/cdc_msc_freertos",
+                    "device/cdc_msc_throughput",
                     "device/hid_composite_freertos",
                     "device/audio_test_freertos",
                     "device/usbtest",
@@ -94,6 +95,7 @@
             "tests": {
                 "only": [
                     "device/cdc_msc_freertos",
+                    "device/cdc_msc_throughput",
                     "device/hid_composite_freertos",
                     "device/audio_test_freertos",
                     "device/usbtest",
diff --git a/tools/build.py b/tools/build.py
index 5df6dc1..36fec06 100755
--- a/tools/build.py
+++ b/tools/build.py
@@ -98,6 +98,7 @@
 
     if family == 'espressif':
         all_examples.append('device/board_test')
+        all_examples.append('device/cdc_msc_throughput')
         all_examples.append('device/usbtest')
         all_examples.append('device/video_capture')
         all_examples.append('host/device_info')