Skip to content

ncm: retry link-state notification, fix carrier lost on collision#3761

Open
morse-cedricvandenbergh wants to merge 2 commits into
hathach:masterfrom
morse-cedricvandenbergh:fix/ncm-link-state-notify-retry
Open

ncm: retry link-state notification, fix carrier lost on collision#3761
morse-cedricvandenbergh wants to merge 2 commits into
hathach:masterfrom
morse-cedricvandenbergh:fix/ncm-link-state-notify-retry

Conversation

@morse-cedricvandenbergh

Copy link
Copy Markdown
Contributor

ncm: retry link-state notification, fix carrier lost on collision

Closes #3760

Problem

tud_network_link_state() delivers the CDC-NCM NETWORK_CONNECTION notification in an edge-triggered, fire-once way. If a previous notification is still in flight when the link state changes, the new notification is silently dropped and never retried, so the host keeps the previously reported carrier state. In practice a link up can be lost, leaving the host interface permanently at NO-CARRIER (kernel drops all TX → DHCP fails) even though the device thinks the link is up.

Current code:

void tud_network_link_state(uint8_t rhport, bool is_up) {
  if (ncm_interface.link_is_up == is_up) return;      // (A) dedup
  ncm_interface.link_is_up = is_up;                   // (B) commit BEFORE send
  if (ncm_interface.itf_data_alt != 1) return;
  ncm_interface.notification_xmit_state = NOTIFICATION_SPEED;
  notification_xmit(rhport, false);                   // (C) single attempt
}

static void notification_xmit(uint8_t rhport, bool force_next) {
  if (!force_next && ncm_interface.notification_xmit_is_running) return;  // (D) silent drop
  ...
}

Three issues combine:

  1. Silent drop (D): if a notification is in flight (notification_xmit_is_running), notification_xmit(false) returns and the NETWORK_CONNECTION for the new state is never queued.
  2. No retry + dedup lock-out (A)+(B): link_is_up is committed before the dropped send, so a later call with the same value short-circuits at (A). Nothing re-attempts it → the host's last NETWORK_CONNECTION keeps the old wValue (e.g. 0 from a prior link-down) → permanent NO-CARRIER.
  3. Cross-context race: notification_xmit_state / _is_running / link_is_up are mutated from both tud_network_link_state() (caller context) and netd_xfer_cb() (usbd context) with no serialisation. On RTOS ports where tud_network_link_state() runs in a task other than tud_task(), the two race — which is what non-deterministically produces the in-flight collision above.

An in-flight notification is present at a transition when, e.g., a preceding link-down notification is still completing, or the SET_INTERFACE(alt=1) speed→connection chain (after a bus reset / re-enumeration) is still in flight when a link-state change arrives. Rapid link toggling and/or a bus reset around the transition make this likely.

It is largely latent for single-threaded/super-loop apps that don't toggle the link rapidly, but reproducible for multi-threaded RTOS apps that drive link state from a dedicated task.

Fix

Make link-state delivery level-triggered and race-safe:

  • Add link_notify_pending: set when link_is_up changes, cleared only when a NETWORK_CONNECTION carrying the current state is actually submitted.
  • Re-fire from the notify-endpoint completion in netd_xfer_cb(): if pending and the channel is free, (re)send — so a collided send is retried on the next completion instead of dropped.
  • Defer the send onto the usbd task via usbd_defer_func(), so a caller on another task cannot race the completion callback.

Net: the NETWORK_CONNECTION for the current link state is guaranteed to reach the host — immediately if the channel is free, otherwise on the next completion — regardless of caller context or in-flight collisions.

Behavioural note for reviewers

On a link change this now re-sends only NETWORK_CONNECTION, whereas the previous code reset to NOTIFICATION_SPEED and re-sent CONNECTION_SPEED_CHANGE as well. A link toggle doesn't change the link speed, and CONNECTION_SPEED_CHANGE is still sent at SET_INTERFACE, so this is intentional (and is the variant that was validated). If strict behaviour-preservation is preferred, ncm_flush_link_notification() can reset to NOTIFICATION_SPEED instead — a one-line change.

Testing

  • Reproduced the original failure on an RTOS application that drives tud_network_link_state() from a separate task with frequent link down/up transitions plus USB re-enumeration around them: intermittent permanent NO-CARRIER after a link-up, DHCP failing on the host.
  • With this change, validated over 250 link up/down cycles (two long soak runs) with zero recurrences; every link-up delivered NETWORK_CONNECTION(wValue=1) and the host acquired DHCP. Instrumentation counters confirmed the previously-dropped case is now retried and delivered.

Notes

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

Top 10 targets by memory change (%) (out of 2404 targets) View Project Dashboard →

target .text .rodata .data .bss total % diff
frdm_k32l2a4s/net_lwip_webserver 47,252 → 47,428 (+176) 47,456 → 47,632 (+176) +0.4%
lpcxpresso1769/net_lwip_webserver 41,604 → 41,760 (+156) 42,176 → 42,332 (+156) +0.4%
mm32f327x_mb39/net_lwip_webserver 37,436 → 37,592 (+156) 42,716 → 42,872 (+156) +0.4%
da14695_dk_usb/net_lwip_webserver 43,484 → 43,640 (+156) 43,688 → 43,844 (+156) +0.4%
ek_tm4c123gxl/net_lwip_webserver 43,604 → 43,760 (+156) 43,800 → 43,956 (+156) +0.4%
stm32c071nucleo/net_lwip_webserver 44,308 → 44,484 (+176) 49,492 → 49,668 (+176) +0.4%
lpcxpresso18s37/net_lwip_webserver 43,308 → 43,464 (+156) 43,946 → 44,102 (+156) +0.4%
frdm_k64f/net_lwip_webserver 44,496 → 44,652 (+156) 44,708 → 44,864 (+156) +0.3%
fomu/net_lwip_webserver 70,436 → 70,680 (+244) 70,657 → 70,901 (+244) +0.3%
metro_m4_express/net_lwip_webserver 41,520 → 41,664 (+144) 41,724 → 41,868 (+144) +0.3%

tud_network_link_state() delivered the NETWORK_CONNECTION notification
edge-triggered and fire-once: if a previous notification was still in
flight, notification_xmit() returned early and the notification for the
new link state was never queued. Because link_is_up is committed before
the send, the dedup at the top then suppressed any resend, so the host
kept the stale carrier state - e.g. a permanent NO-CARRIER after a link
up. The notification state (notification_xmit_state / _is_running /
link_is_up) was also mutated from both the caller and the notify
xfer-completion callback with no serialisation, so on RTOS ports where
tud_network_link_state() runs in a task other than tud_task() the two
race.

Make link-state delivery level-triggered and race-safe:
- add link_notify_pending, set when the link state changes and cleared
  only when a NETWORK_CONNECTION carrying the current state is actually
  submitted;
- re-fire from the notify xfer-completion callback, so a send that
  collides with an in-flight notification is retried on the next
  completion instead of being silently dropped;
- defer the send onto the usbd task via usbd_defer_func(), so a caller
  on another task cannot race the completion callback.

A link toggle does not change the link speed, so only NETWORK_CONNECTION
is re-sent on a change (CONNECTION_SPEED_CHANGE is still sent at
SET_INTERFACE as before).

Closes hathach#3760
The self-contained net_ncm fuzz harness #includes ncm_device.c and stubs
the usbd symbols it references rather than linking the device stack.
tud_network_link_state() now calls usbd_defer_func(), so add a matching
no-op stub to keep the harness linking.
@morse-cedricvandenbergh morse-cedricvandenbergh force-pushed the fix/ncm-link-state-notify-retry branch from 362c719 to 8abd13b Compare July 8, 2026 14:04
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Hardware-in-the-loop (HIL) Test Report

hfp.json

✅ 52 passed · ❌ 0 failed · ⚪ 0 skipped · blank not run

Board cdc_dual_ports cdc_msc dfu cdc_msc_throughput audio_test_freertos dfu_runtime cdc_msc_freertos hid_boot_interface msc_dual_lun hid_generic_inout printer_to_cdc midi_test mtp
stm32l412nucleo ✅ CDC 628k/393k MSC 829k/765k
stm32f746disco ✅ CDC 12.4M/9.4M MSC 15.5M/28.7M
stm32f746disco-DMA ✅ CDC 13.9M/10.1M MSC 18.8M/31.4M
lpcxpresso43s67 ✅ CDC 12.7M/11.4M MSC 29.9M/29.2M

tinyusb.json

✅ 314 passed · ❌ 25 failed · ⚪ 11 skipped · blank not run

Board cdc_dual_ports cdc_msc dfu cdc_msc_throughput audio_test_freertos dfu_runtime cdc_msc_freertos hid_boot_interface msc_dual_lun hid_generic_inout printer_to_cdc midi_test mtp host_info_to_device_cdc cdc_msc_hid msc_file_explorer msc_file_explorer_freertos device_info hid_composite_freertos
ek_tm4c123gxl ✅ CDC 751k/748k MSC 806k/775k
espressif_p4_function_ev
espressif_p4_function_ev-DMA
espressif_s3_devkitm rd 409KB/s
espressif_s3_devkitm-DMA
feather_nrf52840_express ✅ CDC 580k/469k MSC 686k/465k
max32666fthr ✅ CDC 6.8M/12.9M MSC 9.9M/15M
metro_m4_express ✅ CDC 477k/499k MSC 490k/508k
mimxrt1015_evk ✅ CDC 6.6M/7.1M MSC 6.5M/7M
mimxrt1064_evk ✅ CDC 6.4M/10.1M MSC 6.7M/6.3M rd 1368KB/s rd 1365KB/s
lpcxpresso11u37 ✅ CDC 346k/379k MSC 720k/756k
ra4m1_ek ✅ CDC 420k/408k MSC 807k/755k
raspberry_pi_pico ✅ CDC 507k/539k MSC 561k/534k rd 62KB/s rd 61KB/s
raspberry_pi_pico_w rd 1022KB/s
raspberry_pi_pico2 rd 1110KB/s rd 1022KB/s
adafruit_fruit_jam ✅ CDC 557k/520k MSC 578k/572k
stm32f072disco ✅ CDC 448k/317k MSC 559k/512k
stm32f407disco
stm32f723disco ✅ CDC 924k/882k MSC 943k/877k rd 14563KB/s rd 4032KB/s
stm32f723disco-DMA ✅ CDC 922k/877k MSC 941k/880k rd 14169KB/s rd 4032KB/s
stm32h743nucleo ✅ CDC 738k/716k MSC 818k/788k
stm32h743nucleo-DMA ✅ CDC 837k/826k MSC 841k/795k
stm32g0b1nucleo ✅ CDC 575k/485k MSC 810k/755k
stm32l476disco ✅ CDC 515k/539k MSC 593k/634k
stm32u083nucleo ✅ CDC 556k/398k MSC 596k/592k
nanoch32v203-fsdev ✅ CDC 508k/511k MSC 511k/511k
nanoch32v203-usbfs ✅ CDC 464k/348k MSC 511k/511k
ch32v103r_r1_1v0 ✅ CDC 508k/509k MSC 511k/511k
ch582m_evt ✅ CDC 231k/197k MSC 475k/471k

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NCM: link-state NETWORK_CONNECTION notification can be silently dropped, leaving the host stuck at NO-CARRIER

1 participant