fix: bind UDP discovery per-NIC and skip virtual adapters (#179)#180
Conversation
Review Summary by QodoBind UDP discovery per-NIC and skip virtual adapters
WalkthroughsDescription• Bind one UdpClient per NIC to prevent Windows routing broadcasts to wrong adapter • Filter virtual/tunnel adapters (WSL2, Hyper-V, VirtualBox, VMware, TAP) by name/description • Replace subnet-match heuristic with socket's bound IP as authoritative local interface • Add concurrent per-NIC receive loops with thread-safe device deduplication • Remove unused FindLocalInterfaceForRemoteAddress and SubnetMask field Diagramflowchart LR
A["Single UdpClient<br/>IPAddress.Any"] -->|Old: Wrong NIC<br/>picked by Windows| B["Broadcast<br/>sent"]
C["Per-NIC UdpClient<br/>bound to LocalAddress"] -->|New: OS routes<br/>to correct NIC| D["Broadcast<br/>sent"]
E["Virtual Adapter<br/>Filter"] -->|Skip vEthernet,<br/>Hyper-V, etc| F["GetAllNetworkInterfaces"]
G["Socket's bound IP"] -->|Authoritative<br/>LocalInterfaceAddress| H["ParseDeviceInfo"]
File Changes1. src/Daqifi.Core/Device/Discovery/WiFiDeviceFinder.cs
|
Code Review by Qodo
1.
|
|
/improve |
PR Code Suggestions ✨Latest suggestions up to 9aeb434
Previous suggestions✅ Suggestions up to commit 0dac743
✅ Suggestions up to commit 487872b
✅ Suggestions up to commit 47c705f
|
|||||||||||||||||||||||||||||||||||||||||||||||
|
Pass 1 skip notes:
|
|
/improve |
|
Persistent suggestions updated to latest commit 487872b |
|
/improve |
|
Persistent suggestions updated to latest commit 0dac743 |
|
/improve |
|
Persistent suggestions updated to latest commit 9aeb434 |
|
/improve |
PR Code Suggestions ✨No code suggestions found for the PR. |
|
/improve |
PR Code Suggestions ✨No code suggestions found for the PR. |
|
/review |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
On Windows hosts where a virtual NIC (WSL2 mirrored, Hyper-V vEthernet, VPN/TAP) shares a /24 with the real adapter, sending broadcasts from a single UdpClient bound to IPAddress.Any caused Windows routing to pick the wrong egress NIC and discovery silently returned zero devices. - WiFiDeviceFinder now binds one UdpClient per NIC at (LocalAddress, 0) for both send and receive. The OS routes each broadcast out the intended adapter, replies arrive on the same socket, and the socket's bound IP is the authoritative LocalInterfaceAddress (no more subnet-match guessing across virtual NICs that share a subnet). - Filter virtual/tunnel adapters in GetAllNetworkInterfaces by Name/Description (vEthernet, Hyper-V, WSL, VirtualBox, VMware, TAP). - Drop FindLocalInterfaceForRemoteAddress and the unused SubnetMask field; the per-socket bound IP supersedes them. - Add unit tests for the IsVirtualOrTunnelInterface predicate covering the common virtual-adapter shapes plus physical-adapter negatives.
- Wrap OnDeviceDiscovered in try/catch inside ReceiveLoopAsync. With parallel per-NIC receive loops awaited via Task.WhenAll under the infinite-timeout overload, a faulted receive task would hang DiscoverAsync indefinitely. Subscriber exceptions now no-op the current device and the loop continues. (Bug, Action required.) - Extract ShouldIncludeInterface(name, description, status, type, supportsIPv4) so the full NIC selection filter (Up + IPv4 + Ethernet/Wireless80211 + non-virtual) can be exercised in unit tests without instantiating real NetworkInterface objects. - Add ShouldIncludeInterface_MixedNicList_ExcludesVirtualAndIneligible test covering the realistic Windows multi-NIC scenario: physical WiFi + physical Ethernet + WSL2 vEthernet + Hyper-V vEthernet + VirtualBox + a Down NIC + Loopback + Tunnel + non-IPv4 — asserts only the two physical adapters survive. (Requirement gap.)
- Bind each per-NIC UdpClient to (LocalAddress, _discoveryPort) with ReuseAddress + ExclusiveAddressUse=false instead of ephemeral port 0. Distinct (LocalAddress, port) tuples + SO_REUSEADDR let the per-NIC sockets coexist while preserving the well-known port semantics that any firmware variant hardcoded to reply to port 30303 would expect. (Qodo importance 9/10 — High impact, defensive against firmware variants that don't reply to source port.) - Verified live: DAQiFi-95A7 at 192.168.1.160 still discovered correctly with LocalInterfaceAddress = 192.168.1.133 (real 10G NIC). SKIPPED: "Avoid indefinite discovery hang" (importance 1/10) — Qodo's own Why explicitly notes the change "would alter the API's intended semantics". Infinite-timeout behavior is part of the public contract and matches the pre-fix behavior.
… TAP filter Three Qodo suggestions, all applied (importance 8, 8, 7): - Dispose sockets on all setup failures. Broaden the catch from SocketException to a catch-all and add a finally block with an `added` flag so any unexpected exception during socket creation, bind, or send still releases the UdpClient. (Importance 8/10.) - Wrap the per-packet processing block in ReceiveLoopAsync in a defensive try/catch so malformed UDP payloads or unexpected subscriber exceptions cannot fault the receive task and hang Task.WhenAll under the infinite-timeout overload. Replaces the narrower OnDeviceDiscovered try/catch with the broader guard. (Importance 8/10.) - Refine the TAP substring match in IsVirtualOrTunnelInterface to the more specific "TAP-Windows", "TAP-", and "TAP " prefixes, so legitimate physical adapter descriptions that happen to contain the three letters TAP aren't excluded. Also trim whitespace before substring checks. (Importance 7/10.) Live discovery on the WSL2/Hyper-V Windows host: 4/4 successful runs, DAQiFi-95A7 at 192.168.1.160 each time, LocalInterfaceAddress correctly populated as 192.168.1.133. All 833 tests pass on net8.0 and net9.0 with no warnings.
- Match every virtual/tunnel keyword against BOTH the adapter name AND description (was: vEthernet against name, the rest against description only). Virtualization markers can land in either field depending on platform/locale; checking both prevents missed virtual NICs when one field is generic or empty. Extracted local Contains helper to keep the predicate compact. (Importance 7/10.) - Add a catch-all `catch` after the typed catches around udp.ReceiveAsync so any unexpected exception type breaks the loop cleanly instead of faulting the receive task and stalling Task.WhenAll under the infinite-timeout overload. (Importance 6/10 — defense-in-depth, consistent with pass 2.) - Extend IsVirtualOrTunnelInterface_VirtualAdapters_ReturnsTrue with cases where the keyword is only in the name field (empty or unrelated description) to lock in the broader filter. Live discovery on the WSL2/Hyper-V Windows host with both DAQiFi devices online: 6/6 successful runs, both DAQiFi-95A7 (.160) and DAQiFi-A781 (.60) discovered each time, both via local=192.168.1.133. All 836 tests pass on net8.0 and net9.0 with no warnings.
- Wrap the per-NIC bind to (LocalAddress, _discoveryPort) in a try/catch; on SocketException fall back to (LocalAddress, 0). Devices reply to source port either way, so the fallback keeps discovery working on platforms or scenarios where the well-known port can't be acquired (another process holds it, restricted privilege contexts, etc.). (Importance 7/10.) - Move OnDeviceDiscovered inside the lock(discoveredDevices) block so subscriber callbacks are serialized across parallel per-NIC receive loops. This restores the original single-socket sequential-callback contract that subscribers may have implicitly depended on, removing the concurrency caveat the PR description noted earlier. (Importance 7/10.) Live discovery on the WSL2/Hyper-V Windows host with both DAQiFi devices online: 3/3 successful runs, both DAQiFi-95A7 (.160) and DAQiFi-A781 (.60) discovered each time. All 836 tests pass on net8.0 and net9.0 with no warnings.
7e3c15b to
555446b
Compare
Closes #179.
Summary
WiFiDeviceFindernow binds oneUdpClientper NIC at(LocalAddress, 0)for both send and receive. The OS routes each broadcast out the intended adapter; replies arrive on the same socket; the socket's bound IP becomes the authoritativeLocalInterfaceAddress(no more subnet-match guessing across virtual NICs that share a subnet).GetAllNetworkInterfacesnow skips virtual/tunnel adapters by Name/Description (vEthernet,Hyper-V,WSL,VirtualBox,VMware,TAP).FindLocalInterfaceForRemoteAddresshelper and unusedSubnetMaskfield onNetworkInterfaceInfo.IsVirtualOrTunnelInterface(internal static,InternalsVisibleToalready in place) plus xUnitTheorycases covering common virtual-adapter shapes and physical-adapter negatives.Why
On Windows hosts with WSL2 mirrored networking (or Hyper-V/VPN/TAP) the virtual NIC frequently shares a
/24with the real WiFi/Ethernet adapter. Sending broadcasts from a singleUdpClientbound toIPAddress.Anylets Windows pick the wrong egress NIC, and the subnet-match reply heuristic could attribute a reply to the virtual NIC. Discovery would silently return zero devices even though the device was reachable via TCP from the same host.Behavior note
DeviceDiscoveredmay now be invoked concurrently from multiple per-NIC receive loops (was strictly sequential before). The existing public contract did not guarantee single-threaded invocation; subscribers should already be thread-safe.Test plan
dotnet build Daqifi.Core.sln— clean (net8.0 + net9.0), 0 warningsdotnet test Daqifi.Core.sln— 830 passed / 0 failed / 2 skipped (both target frameworks)IsVirtualOrTunnelInterface_*cover vEthernet (WSL), vEthernet (Default Switch),Ethernet 3w/ Hyper-V description,Ethernet 4w/ WSL description, VirtualBox, VMware, TAP-Windows — plus Wi-Fi/Ethernet/WLAN negatives and null inputsvEthernet (Default Switch)172.25.176.1 + Intel 10GEthernet 3192.168.1.133): DAQiFi-95A7 at 192.168.1.160 was discovered andLocalInterfaceAddresscorrectly populated as192.168.1.133(the real NIC, not the Hyper-V adapter)🤖 Generated with Claude Code