From e0fd11e904ec80a17c97d90e9b2dbb77ba706f2c Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Fri, 3 Apr 2026 23:10:55 -0600 Subject: [PATCH 1/2] feat: optimize serial device discovery performance - Filter COM ports by USB VID/PID before probing, using platform-specific detection (Windows registry, macOS ioreg, Linux sysfs) to skip non-DAQiFi devices without opening serial ports - Reduce probe commands to only GetDeviceInfo (SYSTem:SYSInfoPB?), deferring setup commands (DisableEcho, StopStreaming, etc.) to the connection phase - Shorten discovery timeouts: wake-up 200ms (was 1000ms), response 1000ms (was 4000ms), retry interval 300ms (was 1000ms), max retries 2 (was 3) - Probe filtered candidate ports in parallel via Task.WhenAll Closes #157 Co-Authored-By: Claude Opus 4.6 --- .../SerialDeviceFinderFilterTests.cs | 144 ++++++++++ .../Discovery/SerialPortUsbDetectorTests.cs | 46 ++++ .../Device/Discovery/SerialDeviceFinder.cs | 136 ++++++---- .../Device/Discovery/SerialPortUsbDetector.cs | 256 ++++++++++++++++++ 4 files changed, 530 insertions(+), 52 deletions(-) create mode 100644 src/Daqifi.Core.Tests/Device/Discovery/SerialDeviceFinderFilterTests.cs create mode 100644 src/Daqifi.Core.Tests/Device/Discovery/SerialPortUsbDetectorTests.cs create mode 100644 src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs diff --git a/src/Daqifi.Core.Tests/Device/Discovery/SerialDeviceFinderFilterTests.cs b/src/Daqifi.Core.Tests/Device/Discovery/SerialDeviceFinderFilterTests.cs new file mode 100644 index 0000000..c48f741 --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Discovery/SerialDeviceFinderFilterTests.cs @@ -0,0 +1,144 @@ +using Daqifi.Core.Device.Discovery; + +namespace Daqifi.Core.Tests.Device.Discovery; + +public class SerialDeviceFinderFilterTests +{ + #region FilterProbableDaqifiPorts + + [Fact] + public void FilterProbableDaqifiPorts_ExcludesBluetoothPorts() + { + var ports = new[] { "COM1", "COM3", "/dev/tty.Bluetooth-Incoming-Port" }; + var result = SerialDeviceFinder.FilterProbableDaqifiPorts(ports).ToList(); + + Assert.DoesNotContain(result, p => p.Contains("Bluetooth")); + } + + [Fact] + public void FilterProbableDaqifiPorts_ExcludesDebugPorts() + { + var ports = new[] { "COM1", "/dev/cu.debug-console" }; + var result = SerialDeviceFinder.FilterProbableDaqifiPorts(ports).ToList(); + + Assert.DoesNotContain(result, p => p.Contains("debug")); + } + + [Fact] + public void FilterProbableDaqifiPorts_ExcludesWlanPorts() + { + var ports = new[] { "COM1", "/dev/cu.wlan-debug" }; + var result = SerialDeviceFinder.FilterProbableDaqifiPorts(ports).ToList(); + + Assert.DoesNotContain(result, p => p.Contains("wlan")); + } + + [Fact] + public void FilterProbableDaqifiPorts_EmptyInput_ReturnsEmpty() + { + var result = SerialDeviceFinder.FilterProbableDaqifiPorts(Array.Empty()).ToList(); + Assert.Empty(result); + } + + #endregion + + #region FilterByUsbVidPid + + [Fact] + public void FilterByUsbVidPid_EmptyUsbInfo_ReturnsAllPorts() + { + var ports = new[] { "COM1", "COM2", "COM3" }; + var usbInfo = new Dictionary(); + + var result = SerialDeviceFinder.FilterByUsbVidPid(ports, usbInfo).ToList(); + + Assert.Equal(3, result.Count); + Assert.Equal(ports, result); + } + + [Fact] + public void FilterByUsbVidPid_KeepsDaqifiVendorPorts() + { + var ports = new[] { "COM1", "COM2" }; + var usbInfo = new Dictionary + { + ["COM1"] = new(SerialPortUsbDetector.DaqifiVendorId, 0x003C), + ["COM2"] = new(SerialPortUsbDetector.DaqifiVendorId, 0x0042) + }; + + var result = SerialDeviceFinder.FilterByUsbVidPid(ports, usbInfo).ToList(); + + Assert.Equal(2, result.Count); + Assert.Contains("COM1", result); + Assert.Contains("COM2", result); + } + + [Fact] + public void FilterByUsbVidPid_RejectsNonDaqifiVendorPorts() + { + var ports = new[] { "COM1", "COM2", "COM3" }; + var usbInfo = new Dictionary + { + ["COM1"] = new(0x1234, 0x5678), // Random USB device + ["COM2"] = new(SerialPortUsbDetector.DaqifiVendorId, 0x003C), // DAQiFi + ["COM3"] = new(0x2341, 0x0043) // Arduino + }; + + var result = SerialDeviceFinder.FilterByUsbVidPid(ports, usbInfo).ToList(); + + Assert.Single(result); + Assert.Contains("COM2", result); + } + + [Fact] + public void FilterByUsbVidPid_KeepsPortsWithNoUsbInfo() + { + // Ports not in usbInfo are kept (could be non-USB serial or detection missed them) + var ports = new[] { "COM1", "COM2", "COM3" }; + var usbInfo = new Dictionary + { + ["COM1"] = new(0x1234, 0x5678) // Known non-DAQiFi + }; + + var result = SerialDeviceFinder.FilterByUsbVidPid(ports, usbInfo).ToList(); + + // COM1 rejected (known non-DAQiFi), COM2 and COM3 kept (unknown = safe to probe) + Assert.Equal(2, result.Count); + Assert.DoesNotContain("COM1", result); + Assert.Contains("COM2", result); + Assert.Contains("COM3", result); + } + + [Fact] + public void FilterByUsbVidPid_MixedScenario() + { + var ports = new[] { "/dev/cu.usbmodem101", "/dev/cu.usbserial-1420", "/dev/cu.SLAB_USBtoUART", "COM4" }; + var usbInfo = new Dictionary + { + ["/dev/cu.usbmodem101"] = new(SerialPortUsbDetector.DaqifiVendorId, 0x003C), // DAQiFi + ["/dev/cu.usbserial-1420"] = new(0x0403, 0x6001), // FTDI chip + ["/dev/cu.SLAB_USBtoUART"] = new(0x10C4, 0xEA60) // Silicon Labs + // COM4 not in usbInfo + }; + + var result = SerialDeviceFinder.FilterByUsbVidPid(ports, usbInfo).ToList(); + + Assert.Equal(2, result.Count); + Assert.Contains("/dev/cu.usbmodem101", result); // DAQiFi VID match + Assert.Contains("COM4", result); // Unknown = kept + } + + [Fact] + public void FilterByUsbVidPid_EmptyInput_ReturnsEmpty() + { + var usbInfo = new Dictionary + { + ["COM1"] = new(SerialPortUsbDetector.DaqifiVendorId, 0x003C) + }; + + var result = SerialDeviceFinder.FilterByUsbVidPid(Enumerable.Empty(), usbInfo).ToList(); + Assert.Empty(result); + } + + #endregion +} diff --git a/src/Daqifi.Core.Tests/Device/Discovery/SerialPortUsbDetectorTests.cs b/src/Daqifi.Core.Tests/Device/Discovery/SerialPortUsbDetectorTests.cs new file mode 100644 index 0000000..bf6478a --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Discovery/SerialPortUsbDetectorTests.cs @@ -0,0 +1,46 @@ +using Daqifi.Core.Device.Discovery; + +namespace Daqifi.Core.Tests.Device.Discovery; + +public class SerialPortUsbDetectorTests +{ + [Fact] + public void IsDaqifiVendor_WithDaqifiVid_ReturnsTrue() + { + Assert.True(SerialPortUsbDetector.IsDaqifiVendor(0x04D8)); + } + + [Fact] + public void IsDaqifiVendor_WithOtherVid_ReturnsFalse() + { + Assert.False(SerialPortUsbDetector.IsDaqifiVendor(0x1234)); + Assert.False(SerialPortUsbDetector.IsDaqifiVendor(0x0000)); + Assert.False(SerialPortUsbDetector.IsDaqifiVendor(0xFFFF)); + } + + [Fact] + public void DaqifiVendorId_MatchesHidDeviceFinderVendorId() + { + // Ensure serial and HID discovery use the same vendor ID + Assert.Equal(HidDeviceFinder.DefaultVendorId, SerialPortUsbDetector.DaqifiVendorId); + } + + [Fact] + public void GetPortUsbInfo_ReturnsNonNullDictionary() + { + // Should never throw; returns empty dictionary on failure + var result = SerialPortUsbDetector.GetPortUsbInfo(); + Assert.NotNull(result); + } + + [Fact] + public void UsbId_RecordEquality() + { + var a = new SerialPortUsbDetector.UsbId(0x04D8, 0x003C); + var b = new SerialPortUsbDetector.UsbId(0x04D8, 0x003C); + var c = new SerialPortUsbDetector.UsbId(0x1234, 0x5678); + + Assert.Equal(a, b); + Assert.NotEqual(a, c); + } +} diff --git a/src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs b/src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs index b350737..057879a 100644 --- a/src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs +++ b/src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs @@ -15,7 +15,7 @@ namespace Daqifi.Core.Device.Discovery; /// /// Discovers DAQiFi devices connected via USB/Serial ports. -/// Probes each port by sending SCPI commands and validating protobuf responses. +/// Filters ports by USB VID/PID, then probes candidates in parallel using SCPI commands. /// public class SerialDeviceFinder : IDeviceFinder, IDisposable { @@ -23,10 +23,14 @@ public class SerialDeviceFinder : IDeviceFinder, IDisposable private const int DefaultBaudRate = 9600; private const int ProbeTimeoutMs = 1000; - private const int DeviceWakeUpDelayMs = 1000; - private const int ResponseTimeoutMs = 4000; - private const int MaxRetries = 3; - private const int RetryIntervalMs = 1000; + + /// + /// Discovery-specific timeouts (shorter than connection timeouts for fast scanning). + /// + private const int DiscoveryWakeUpDelayMs = 200; + private const int DiscoveryResponseTimeoutMs = 1000; + private const int DiscoveryMaxRetries = 2; + private const int DiscoveryRetryIntervalMs = 300; private const int PollIntervalMs = 100; #endregion @@ -77,7 +81,7 @@ public SerialDeviceFinder(int baudRate) /// /// Discovers devices asynchronously with a cancellation token. - /// Probes each serial port to identify DAQiFi devices. + /// Filters ports by USB VID/PID, then probes candidates in parallel. /// /// Cancellation token to abort the operation. /// A task containing the collection of discovered DAQiFi devices. @@ -89,32 +93,21 @@ public async Task> DiscoverAsync(CancellationToken canc await _discoverySemaphore.WaitAsync(cancellationToken); try { - var discoveredDevices = new List(); - var availablePorts = FilterProbableDaqifiPorts(SerialStreamTransport.GetAvailablePortNames()); + var allPorts = FilterProbableDaqifiPorts(SerialStreamTransport.GetAvailablePortNames()); - foreach (var portName in availablePorts) - { - if (cancellationToken.IsCancellationRequested) - break; + // Filter by USB VID/PID before probing (avoids sending SCPI to non-DAQiFi devices) + var candidatePorts = FilterByUsbVidPid(allPorts).ToList(); - try - { - var deviceInfo = await TryGetDeviceInfoAsync(portName, cancellationToken); - if (deviceInfo != null) - { - discoveredDevices.Add(deviceInfo); - OnDeviceDiscovered(deviceInfo); - } - } - catch (OperationCanceledException) - { - break; - } - catch (Exception) - { - // Skip ports that fail to open or respond - // This is normal as not all serial ports are DAQiFi devices - } + // Probe candidate ports in parallel + var probeTasks = candidatePorts.Select(portName => + TryGetDeviceInfoAsync(portName, cancellationToken)); + + var results = await Task.WhenAll(probeTasks); + var discoveredDevices = results.Where(d => d != null).Cast().ToList(); + + foreach (var device in discoveredDevices) + { + OnDeviceDiscovered(device); } OnDiscoveryCompleted(); @@ -144,6 +137,7 @@ public async Task> DiscoverAsync(TimeSpan timeout) /// /// Attempts to probe a serial port and retrieve device information. /// Opens the port, sends GetDeviceInfo command, and waits for a status response. + /// Uses reduced timeouts and minimal commands optimized for fast discovery. /// /// The serial port name. /// Cancellation token. @@ -167,8 +161,8 @@ public async Task> DiscoverAsync(TimeSpan timeout) port.Open(); port.DtrEnable = true; - // Wait for device to wake up (devices need time after DTR is enabled) - await Task.Delay(DeviceWakeUpDelayMs, cancellationToken); + // Brief delay for device to wake up after DTR is enabled + await Task.Delay(DiscoveryWakeUpDelayMs, cancellationToken); // Set up message producer and consumer var stream = port.BaseStream; @@ -197,36 +191,23 @@ public async Task> DiscoverAsync(TimeSpan timeout) consumer.Start(); - // Initialize device - send commands to prepare for communication - // These are required for the device to respond properly - producer.Send(ScpiMessageProducer.DisableDeviceEcho); - await Task.Delay(100, cancellationToken); - - producer.Send(ScpiMessageProducer.StopStreaming); - await Task.Delay(100, cancellationToken); - - producer.Send(ScpiMessageProducer.TurnDeviceOn); - await Task.Delay(100, cancellationToken); - - producer.Send(ScpiMessageProducer.SetProtobufStreamFormat); - await Task.Delay(100, cancellationToken); - - // Send GetDeviceInfo command with retry logic - var timeout = DateTime.UtcNow.AddMilliseconds(ResponseTimeoutMs); + // Discovery only needs GetDeviceInfo — no setup commands required. + // The full initialization sequence (DisableEcho, StopStreaming, TurnDeviceOn, + // SetProtobufStreamFormat) is handled during connection, not discovery. + var timeout = DateTime.UtcNow.AddMilliseconds(DiscoveryResponseTimeoutMs); var lastRequestTime = DateTime.MinValue; var retryCount = 0; while (statusMessage == null && DateTime.UtcNow < timeout && !cancellationToken.IsCancellationRequested) { - // Send request every RetryIntervalMs, up to MaxRetries times - if ((DateTime.UtcNow - lastRequestTime).TotalMilliseconds >= RetryIntervalMs && retryCount < MaxRetries) + if ((DateTime.UtcNow - lastRequestTime).TotalMilliseconds >= DiscoveryRetryIntervalMs && + retryCount < DiscoveryMaxRetries) { producer.Send(ScpiMessageProducer.GetDeviceInfo); lastRequestTime = DateTime.UtcNow; retryCount++; } - // Wait a bit for response var remainingTime = Math.Min(PollIntervalMs, (int)(timeout - DateTime.UtcNow).TotalMilliseconds); if (remainingTime > 0) { @@ -308,13 +289,64 @@ await Task.WhenAny( } } + /// + /// Filters ports by USB VID/PID using the system's USB device information. + /// + /// Ports to filter. + /// Ports that are candidates for DAQiFi devices. + internal static IEnumerable FilterByUsbVidPid(IEnumerable ports) + { + return FilterByUsbVidPid(ports, SerialPortUsbDetector.GetPortUsbInfo()); + } + + /// + /// Filters ports by USB VID/PID, keeping only ports that belong to known DAQiFi vendors. + /// Ports with unknown USB identity (non-USB or detection failed) are included as candidates. + /// If VID/PID detection is entirely unavailable (empty dictionary), all ports are returned. + /// + /// Ports to filter. + /// USB VID/PID info per port from . + /// Ports that are candidates for DAQiFi devices. + internal static IEnumerable FilterByUsbVidPid( + IEnumerable ports, Dictionary usbInfo) + { + var portList = ports.ToList(); + + if (usbInfo.Count == 0) + { + // VID/PID detection unavailable — probe all ports + return portList; + } + + var candidates = new List(); + foreach (var port in portList) + { + if (usbInfo.TryGetValue(port, out var id)) + { + // USB info available — only include if VID matches DAQiFi + if (SerialPortUsbDetector.IsDaqifiVendor(id.VendorId)) + { + candidates.Add(port); + } + } + else + { + // No USB info for this port — include to be safe + // (could be a non-USB serial port or detection missed it) + candidates.Add(port); + } + } + + return candidates; + } + /// /// Filters the list of available ports to only include those likely to be DAQiFi devices. /// Excludes debug ports, Bluetooth ports, and on macOS prefers /dev/cu.* over /dev/tty.*. /// /// All available serial port names. /// Filtered list of ports to probe. - private static IEnumerable FilterProbableDaqifiPorts(string[] allPorts) + internal static IEnumerable FilterProbableDaqifiPorts(string[] allPorts) { // Skip debug and bluetooth ports which are unlikely to be DAQiFi devices var excludePatterns = new[] diff --git a/src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs b/src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs new file mode 100644 index 0000000..88f85d7 --- /dev/null +++ b/src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs @@ -0,0 +1,256 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; + +namespace Daqifi.Core.Device.Discovery; + +/// +/// Detects USB Vendor ID and Product ID for serial ports using platform-specific APIs. +/// Used to filter serial ports before probing, avoiding sending SCPI commands to non-DAQiFi devices. +/// +internal static class SerialPortUsbDetector +{ + /// + /// Known DAQiFi USB Vendor ID (Microchip Technology Inc.). + /// Shared with for bootloader mode. + /// + internal const int DaqifiVendorId = 0x04D8; + + /// + /// Represents USB identification for a serial port. + /// + internal sealed record UsbId(int VendorId, int ProductId); + + /// + /// Gets USB VID/PID information for serial ports on the system. + /// Returns a dictionary mapping port names to their USB IDs (null values not included). + /// Returns empty dictionary if detection is unavailable or fails. + /// + internal static Dictionary GetPortUsbInfo() + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return GetPortUsbInfoWindows(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return GetPortUsbInfoMacOS(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return GetPortUsbInfoLinux(); + } + catch + { + // Platform detection failed — caller will probe all ports + } + + return new Dictionary(); + } + + /// + /// Checks whether the given USB Vendor ID matches a known DAQiFi vendor. + /// + internal static bool IsDaqifiVendor(int vendorId) => vendorId == DaqifiVendorId; + + #region Windows + + [SupportedOSPlatform("windows")] + private static Dictionary GetPortUsbInfoWindows() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + // Enumerate USB devices in the registry to find COM port mappings + // Path: HKLM\SYSTEM\CurrentControlSet\Enum\USB\VID_xxxx&PID_xxxx\{serial}\Device Parameters\PortName + using var usbKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Enum\USB"); + if (usbKey == null) return result; + + foreach (var vidPidKeyName in usbKey.GetSubKeyNames()) + { + var match = Regex.Match(vidPidKeyName, + @"VID_([0-9A-Fa-f]{4})&PID_([0-9A-Fa-f]{4})", RegexOptions.IgnoreCase); + if (!match.Success) continue; + + var vid = Convert.ToInt32(match.Groups[1].Value, 16); + var pid = Convert.ToInt32(match.Groups[2].Value, 16); + + using var vidPidKey = usbKey.OpenSubKey(vidPidKeyName); + if (vidPidKey == null) continue; + + foreach (var instanceKeyName in vidPidKey.GetSubKeyNames()) + { + using var deviceParamsKey = vidPidKey.OpenSubKey($@"{instanceKeyName}\Device Parameters"); + var portName = deviceParamsKey?.GetValue("PortName") as string; + if (!string.IsNullOrEmpty(portName)) + { + result[portName] = new UsbId(vid, pid); + } + } + } + } + catch + { + // Registry access failed — return what we have + } + + return result; + } + + #endregion + + #region macOS + + private static Dictionary GetPortUsbInfoMacOS() + { + // Try AppleUSBDevice first (most macOS versions), fall back to IOUSBHostDevice + var result = ParseIoregUsbSerialPorts("AppleUSBDevice"); + if (result.Count == 0) + result = ParseIoregUsbSerialPorts("IOUSBHostDevice"); + return result; + } + + /// + /// Parses ioreg output for USB devices of the specified class to find serial port mappings. + /// The output from ioreg -r -c {className} -l -w0 lists each USB device and its descendants, + /// including any IOSerialBSDClient entries that contain IOCalloutDevice paths. + /// + private static Dictionary ParseIoregUsbSerialPorts(string usbClassName) + { + var result = new Dictionary(); + + var output = RunProcess("/usr/sbin/ioreg", $"-r -c {usbClassName} -l -w0"); + if (string.IsNullOrEmpty(output)) return result; + + // Walk through the ioreg output tracking the current USB device's VID/PID. + // USB device properties (idVendor, idProduct) appear before any descendant + // IOSerialBSDClient entries containing IOCalloutDevice. + // When a new top-level USB device is encountered, VID/PID is naturally overwritten. + int? currentVid = null; + int? currentPid = null; + + foreach (var line in output.Split('\n')) + { + var vidMatch = Regex.Match(line, @"""idVendor""\s*=\s*(\d+)"); + if (vidMatch.Success) + { + currentVid = int.Parse(vidMatch.Groups[1].Value); + continue; + } + + var pidMatch = Regex.Match(line, @"""idProduct""\s*=\s*(\d+)"); + if (pidMatch.Success) + { + currentPid = int.Parse(pidMatch.Groups[1].Value); + continue; + } + + var portMatch = Regex.Match(line, @"""IOCalloutDevice""\s*=\s*""([^""]+)"""); + if (portMatch.Success && currentVid.HasValue && currentPid.HasValue) + { + result[portMatch.Groups[1].Value] = new UsbId(currentVid.Value, currentPid.Value); + } + } + + return result; + } + + #endregion + + #region Linux + + private static Dictionary GetPortUsbInfoLinux() + { + var result = new Dictionary(); + + try + { + // Read USB VID/PID from sysfs for each tty device + var ttyDir = "/sys/class/tty"; + if (!Directory.Exists(ttyDir)) return result; + + foreach (var ttyPath in Directory.GetDirectories(ttyDir)) + { + var ttyName = Path.GetFileName(ttyPath); + var deviceLink = Path.Combine(ttyPath, "device"); + if (!Directory.Exists(deviceLink)) continue; + + // Walk up the sysfs tree to find the USB device with idVendor/idProduct + var usbDevicePath = FindUsbParent(deviceLink); + if (usbDevicePath == null) continue; + + var vidFile = Path.Combine(usbDevicePath, "idVendor"); + var pidFile = Path.Combine(usbDevicePath, "idProduct"); + + if (!File.Exists(vidFile) || !File.Exists(pidFile)) continue; + + var vidStr = File.ReadAllText(vidFile).Trim(); + var pidStr = File.ReadAllText(pidFile).Trim(); + + if (int.TryParse(vidStr, System.Globalization.NumberStyles.HexNumber, null, out var vid) && + int.TryParse(pidStr, System.Globalization.NumberStyles.HexNumber, null, out var pid)) + { + result[$"/dev/{ttyName}"] = new UsbId(vid, pid); + } + } + } + catch + { + // sysfs access failed — return what we have + } + + return result; + } + + private static string? FindUsbParent(string devicePath) + { + var current = Path.GetFullPath(devicePath); + + for (var i = 0; i < 10; i++) + { + if (File.Exists(Path.Combine(current, "idVendor"))) + return current; + + var parent = Path.GetDirectoryName(current); + if (parent == null || parent == current) + break; + + current = parent; + } + + return null; + } + + #endregion + + #region Helpers + + private static string RunProcess(string fileName, string arguments) + { + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(5000); + return output; + } + catch + { + return string.Empty; + } + } + + #endregion +} From c59810a36602846c65ae2403683f17fa2f840145 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Fri, 3 Apr 2026 23:15:59 -0600 Subject: [PATCH 2/2] fix: prevent RunProcess from blocking indefinitely on stalled ioreg Use async stdout read with WaitForExit timeout instead of synchronous ReadToEnd() which blocks before the timeout can take effect. Kill the process tree if either times out. Co-Authored-By: Claude Opus 4.6 --- .../Device/Discovery/SerialPortUsbDetector.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs b/src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs index 88f85d7..2c2b413 100644 --- a/src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs +++ b/src/Daqifi.Core/Device/Discovery/SerialPortUsbDetector.cs @@ -225,6 +225,8 @@ private static Dictionary GetPortUsbInfoLinux() #region Helpers + private const int ProcessTimeoutMs = 5000; + private static string RunProcess(string fileName, string arguments) { try @@ -242,9 +244,18 @@ private static string RunProcess(string fileName, string arguments) }; process.Start(); - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(5000); - return output; + + // Read stdout asynchronously so we can enforce a timeout. + // ReadToEnd() before WaitForExit() can block indefinitely if the process stalls. + var readTask = process.StandardOutput.ReadToEndAsync(); + if (process.WaitForExit(ProcessTimeoutMs) && readTask.Wait(ProcessTimeoutMs)) + { + return readTask.Result; + } + + // Process or read timed out — kill and return empty + try { process.Kill(entireProcessTree: true); } catch { /* best effort */ } + return string.Empty; } catch {