From a957cce4d840cca1c8b9d3e92a55037bcd4cb1cf Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 29 Jul 2026 08:47:21 -0600 Subject: [PATCH 1/4] fix(api): throw typed DeviceNotConnectedException from connectivity guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's up-front connectivity guards threw a plain InvalidOperationException("Device is not connected."), so clients that need to tell "the device went away" (ordinary, expected) from "the app has a bug" had to match on the exception message — fragile, and it fails silently in the direction of false error-tracker alerts. Adds DeviceNotConnectedException : InvalidOperationException and throws it from every guard that previously threw the untyped exception (46 sites across DaqifiStreamingDevice and DaqifiDevice), plus the disposed/disconnecting guards on the text-command path, which carry IsShuttingDown = true. Deriving from InvalidOperationException keeps existing catch (InvalidOperationException) sites working unchanged. Messages are byte-for-byte identical so any remaining message matching also survives. Closes #395 Co-Authored-By: Claude Opus 5 --- docs/DEVICE_INTERFACES.md | 44 +++ .../Device/DaqifiDeviceInitializeTests.cs | 2 +- .../Device/DaqifiDeviceTests.cs | 2 +- .../DaqifiDeviceTextCommandLockTests.cs | 10 +- .../DaqifiDeviceWithMessageProducerTests.cs | 2 +- .../Device/DaqifiDeviceWithTransportTests.cs | 2 +- ...fiStreamingDeviceChannelManagementTests.cs | 18 +- .../DaqifiStreamingDeviceFriendlyNameTests.cs | 2 +- .../DaqifiStreamingDevicePwmFrequencyTests.cs | 2 +- .../Device/DaqifiStreamingDeviceTests.cs | 12 +- .../DeviceNotConnectedExceptionTests.cs | 320 ++++++++++++++++++ .../Diagnostics/DeviceDiagnosticsTests.cs | 6 +- .../Device/GetLanChipInfoAsyncTests.cs | 2 +- .../Network/NetworkConfigurableTests.cs | 10 +- .../Device/SdCard/SdCardOperationsTests.cs | 18 +- src/Daqifi.Core/Device/DaqifiDevice.cs | 48 ++- .../Device/DaqifiStreamingDevice.cs | 125 +++---- .../Device/DeviceNotConnectedException.cs | 107 ++++++ .../Device/Diagnostics/IDeviceDiagnostics.cs | 16 +- .../Device/SdCard/ISdCardOperations.cs | 28 +- 20 files changed, 637 insertions(+), 139 deletions(-) create mode 100644 src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs create mode 100644 src/Daqifi.Core/Device/DeviceNotConnectedException.cs diff --git a/docs/DEVICE_INTERFACES.md b/docs/DEVICE_INTERFACES.md index 937488b4..eada558a 100644 --- a/docs/DEVICE_INTERFACES.md +++ b/docs/DEVICE_INTERFACES.md @@ -373,6 +373,50 @@ catch (OperationCanceledException) } ``` +#### Telling "the device went away" apart from a bug + +Every device operation opens with a connectivity guard. When it fails, it throws +`DeviceNotConnectedException` — a typed exception, so a disconnect can be classified without +matching on the exception message: + +```csharp +using Daqifi.Core.Device; + +try +{ + var files = await sdCard.GetSdCardFilesAsync(); +} +catch (DeviceNotConnectedException ex) +{ + // Ordinary and expected: the user pressed Disconnect mid-refresh, or WiFi dropped. + // Log at warning with reconnect guidance — do NOT raise an error-tracker issue. + logger.LogWarning(ex, ex.IsShuttingDown + ? "Device is disconnecting; skipping refresh." + : "Device is not connected. Reconnect and try again."); +} +catch (InvalidOperationException ex) +{ + // Everything else on this path really is a defect — e.g. calling a text command + // re-entrantly, or using a device constructed without a transport. + logger.LogError(ex, "Bug: invalid SD card operation."); +} +``` + +Notes: + +- `DeviceNotConnectedException` derives from `InvalidOperationException`, which is what these + guards threw before, so existing `catch (InvalidOperationException)` blocks keep working. Order + the `catch` clauses most-specific-first, as above. +- `IsShuttingDown` is `true` when the guard fired because a `Disconnect()` or `Dispose()` is in + flight (or already finished) rather than because the device was never connected. Both mean "the + device is unavailable"; the flag is there for callers that want to suppress a retry prompt when + the user initiated the disconnect themselves. +- `TransportNotConnectedException` is its sibling, not its base: it reports that the underlying + stream is gone (a serial unplug, a dropped TCP socket) while the device still believed it was + connected. Catch `DeviceNotConnectedException` for the device-state case and + `TransportNotConnectedException` for the transport case — or both, since they classify the same + way for reporting purposes. + ### Connection Status Monitoring ```csharp diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs index f56f319e..a21cf713 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs @@ -87,7 +87,7 @@ public async Task InitializeAsync_WhenDisconnected_Throws() // Not connected // Act & Assert - var ex = await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => device.InitializeAsync()); Assert.Equal("Device must be connected before initialization.", ex.Message); } diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs index 7a7c7fa9..51d2b1a1 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs @@ -77,7 +77,7 @@ public void Send_WhenDisconnected_ThrowsInvalidOperationException() var device = new DaqifiDevice("TestDevice"); // Act & Assert - Assert.Throws(() => device.Send(new Daqifi.Core.Communication.Messages.ScpiMessage(""))); + Assert.Throws(() => device.Send(new Daqifi.Core.Communication.Messages.ScpiMessage(""))); } [Fact] diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs index 56736799..d1d399aa 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs @@ -45,7 +45,7 @@ public async Task ExecuteTextCommandAsync_WhenDisposing_ThrowsInvalidOperation() var device = new TextCommandTestableDevice("TestDevice"); SetIsDisconnecting(device, true); - var ex = await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => device.CallExecuteTextCommandAsync(() => { })); Assert.Contains("disposing or disconnecting", ex.Message); } @@ -56,7 +56,7 @@ public async Task ExecuteTextCommandAsync_WhenDisposed_ThrowsInvalidOperation() var device = new TextCommandTestableDevice("TestDevice"); SetDisposed(device, true); - var ex = await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => device.CallExecuteTextCommandAsync(() => { })); Assert.Contains("disposing or disconnecting", ex.Message); } @@ -70,12 +70,12 @@ public async Task ExecuteTextCommandAsync_ReleasesLockAfterValidationFailure() // not block on WaitAsync. var device = new TextCommandTestableDevice("TestDevice"); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.CallExecuteTextCommandAsync(() => { })); // Second call: also throws, but ONLY if the lock was released. // If the lock leaked, this would deadlock and xunit's per-test // budget would time it out instead. - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.CallExecuteTextCommandAsync(() => { })); } @@ -87,7 +87,7 @@ public async Task ExecuteTextCommandAsync_AsyncLocalClearedAfterReturn() // the same flow doesn't false-positive the re-entrancy check. var device = new TextCommandTestableDevice("TestDevice"); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.CallExecuteTextCommandAsync(() => { })); Assert.False(GetIsInsideTextExchange(device).Value); diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithMessageProducerTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithMessageProducerTests.cs index 925d9586..c6c51902 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithMessageProducerTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithMessageProducerTests.cs @@ -77,7 +77,7 @@ public void DaqifiDevice_SendMessage_WhenDisconnected_ShouldThrowException() using var device = new DaqifiDevice("Test Device", stream); // Act & Assert - Assert.Throws(() => device.Send(ScpiMessageProducer.GetDeviceInfo)); + Assert.Throws(() => device.Send(ScpiMessageProducer.GetDeviceInfo)); } [Fact] diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithTransportTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithTransportTests.cs index aa3882bb..6e0b405d 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithTransportTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithTransportTests.cs @@ -83,7 +83,7 @@ public void DaqifiDevice_SendMessage_WithoutConnection_ShouldThrowException() using var device = new DaqifiDevice("Test Device", transport); // Act & Assert - Assert.Throws(() => device.Send(ScpiMessageProducer.GetDeviceInfo)); + Assert.Throws(() => device.Send(ScpiMessageProducer.GetDeviceInfo)); } [Fact] diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs index e4b0b5a0..e97a490a 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs @@ -577,7 +577,7 @@ public void SetPwmEnabled_WhenDisconnected_ThrowsInvalidOperationException() var channel = DigitalChannelAt(device, 4); device.Disconnect(); - Assert.Throws(() => device.SetPwmEnabled(channel, true)); + Assert.Throws(() => device.SetPwmEnabled(channel, true)); } [Fact] @@ -701,14 +701,14 @@ public void ChannelManagement_WhenDisconnected_ThrowsInvalidOperationException() var analog = AnalogChannelAt(device, 0); var digital = DigitalChannelAt(device, 0); - Assert.Throws(() => device.EnableChannel(analog)); - Assert.Throws(() => device.EnableChannels(new[] { analog })); - Assert.Throws(() => device.DisableChannel(analog)); - Assert.Throws(() => device.DisableAllChannels()); - Assert.Throws(() => device.SetDioDirection(digital, ChannelDirection.Output)); - Assert.Throws(() => device.SetDioValue(digital, true)); - Assert.Throws(() => device.SetAnalogOutput(0, 1.0)); - Assert.Throws(() => device.Reboot()); + Assert.Throws(() => device.EnableChannel(analog)); + Assert.Throws(() => device.EnableChannels(new[] { analog })); + Assert.Throws(() => device.DisableChannel(analog)); + Assert.Throws(() => device.DisableAllChannels()); + Assert.Throws(() => device.SetDioDirection(digital, ChannelDirection.Output)); + Assert.Throws(() => device.SetDioValue(digital, true)); + Assert.Throws(() => device.SetAnalogOutput(0, 1.0)); + Assert.Throws(() => device.Reboot()); } #endregion diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceFriendlyNameTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceFriendlyNameTests.cs index a9488b9d..79227663 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceFriendlyNameTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceFriendlyNameTests.cs @@ -36,7 +36,7 @@ public async Task SetFriendlyNameAsync_InvalidName_Throws(string name) public async Task SetFriendlyNameAsync_NotConnected_Throws() { var device = new CapturingStreamingDevice(); // not connected - await Assert.ThrowsAsync(() => device.SetFriendlyNameAsync("Lab Nq1")); + await Assert.ThrowsAsync(() => device.SetFriendlyNameAsync("Lab Nq1")); } [Fact] diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDevicePwmFrequencyTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDevicePwmFrequencyTests.cs index 573de3ad..2b9613f3 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDevicePwmFrequencyTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDevicePwmFrequencyTests.cs @@ -75,7 +75,7 @@ public void SetPwmFrequency_AfterReconnect_SendsAgainEvenIfUnchanged() public void SetPwmFrequency_NotConnected_ThrowsAndSendsNothing() { var device = new CapturingStreamingDevice(); // not connected - Assert.Throws(() => device.SetPwmFrequency(2000)); + Assert.Throws(() => device.SetPwmFrequency(2000)); Assert.Empty(device.PwmFrequencySends); } diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs index e034efc8..00e882cc 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs @@ -139,7 +139,7 @@ public void StartStreaming_WhenDisconnected_ThrowsInvalidOperationException() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - var exception = Assert.Throws(() => device.StartStreaming()); + var exception = Assert.Throws(() => device.StartStreaming()); Assert.Equal("Device is not connected.", exception.Message); } @@ -150,7 +150,7 @@ public void StopStreaming_WhenDisconnected_ThrowsInvalidOperationException() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - var exception = Assert.Throws(() => device.StopStreaming()); + var exception = Assert.Throws(() => device.StopStreaming()); Assert.Equal("Device is not connected.", exception.Message); } @@ -208,7 +208,7 @@ public void NvmPersistence_WhenDisconnected_ThrowsInvalidOperationException(stri var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - var exception = Assert.Throws(() => InvokeNvmMethod(device, methodName)); + var exception = Assert.Throws(() => InvokeNvmMethod(device, methodName)); Assert.Equal("Device is not connected.", exception.Message); } @@ -267,7 +267,7 @@ public void UseAdcCalibration_WhenConnected_SendsCorrectCommand(int bank) public void SetAdcCalibrationSlope_WhenDisconnected_ThrowsInvalidOperationException() { var device = new DaqifiStreamingDevice("TestDevice"); - var exception = Assert.Throws(() => device.SetAdcCalibrationSlope(0, 1.0)); + var exception = Assert.Throws(() => device.SetAdcCalibrationSlope(0, 1.0)); Assert.Equal("Device is not connected.", exception.Message); } @@ -275,7 +275,7 @@ public void SetAdcCalibrationSlope_WhenDisconnected_ThrowsInvalidOperationExcept public void SetAdcCalibrationOffset_WhenDisconnected_ThrowsInvalidOperationException() { var device = new DaqifiStreamingDevice("TestDevice"); - var exception = Assert.Throws(() => device.SetAdcCalibrationOffset(0, 1.0)); + var exception = Assert.Throws(() => device.SetAdcCalibrationOffset(0, 1.0)); Assert.Equal("Device is not connected.", exception.Message); } @@ -283,7 +283,7 @@ public void SetAdcCalibrationOffset_WhenDisconnected_ThrowsInvalidOperationExcep public void UseAdcCalibration_WhenDisconnected_ThrowsInvalidOperationException() { var device = new DaqifiStreamingDevice("TestDevice"); - var exception = Assert.Throws(() => device.UseAdcCalibration(1)); + var exception = Assert.Throws(() => device.UseAdcCalibration(1)); Assert.Equal("Device is not connected.", exception.Message); } diff --git a/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs b/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs new file mode 100644 index 00000000..14dba28a --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs @@ -0,0 +1,320 @@ +using Daqifi.Core.Communication.Messages; +using Daqifi.Core.Communication.Transport; +using Daqifi.Core.Device; +using Daqifi.Core.Device.Network; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Daqifi.Core.Tests.Device +{ + /// + /// Tests for issue #395 — the up-front connectivity guards must throw the typed + /// so clients can classify "the device went away" + /// (ordinary and expected) apart from a genuine application defect, without matching on the + /// exception message. + /// + public class DeviceNotConnectedExceptionTests + { + // ── Type contract ─────────────────────────────────────────────────── + + [Fact] + public void DeviceNotConnectedException_DerivesFromInvalidOperationException() + { + // The guards previously threw a plain InvalidOperationException. Deriving keeps + // existing catch (InvalidOperationException) sites working unchanged. + Assert.IsAssignableFrom(new DeviceNotConnectedException()); + } + + [Fact] + public void DeviceNotConnectedException_IsNotATransportNotConnectedException() + { + // The two typed connectivity exceptions are deliberate siblings, not a hierarchy: a + // device can fail its guard while its transport is healthy (mid-Disconnect), and a + // transport can drop while the device still reports Connected. + Assert.IsNotType(new DeviceNotConnectedException()); + Assert.IsNotAssignableFrom(new TransportNotConnectedException()); + } + + [Fact] + public void DeviceNotConnectedException_DefaultMessage_MatchesThePreviousGuardWording() + { + // Kept byte-for-byte so downstream code still matching on the message keeps working + // while it migrates to the type. + Assert.Equal("Device is not connected.", new DeviceNotConnectedException().Message); + } + + [Fact] + public void DeviceNotConnectedException_IsShuttingDown_DefaultsToFalse() + { + Assert.False(new DeviceNotConnectedException().IsShuttingDown); + Assert.False(new DeviceNotConnectedException("custom").IsShuttingDown); + Assert.False(new DeviceNotConnectedException("custom", new Exception("inner")).IsShuttingDown); + } + + [Fact] + public void DeviceNotConnectedException_PreservesMessageAndInnerException() + { + var inner = new Exception("inner"); + var ex = new DeviceNotConnectedException("custom", inner); + + Assert.Equal("custom", ex.Message); + Assert.Same(inner, ex.InnerException); + } + + [Fact] + public void DeviceNotConnectedException_ShuttingDownConstructor_SetsTheFlag() + { + var ex = new DeviceNotConnectedException("tearing down", isShuttingDown: true); + + Assert.True(ex.IsShuttingDown); + Assert.Equal("tearing down", ex.Message); + } + + // ── Guard sites: synchronous API ──────────────────────────────────── + + public static IEnumerable SynchronousGuardSites() + { + yield return Site("StartStreaming", d => d.StartStreaming()); + yield return Site("StopStreaming", d => d.StopStreaming()); + yield return Site("Reboot", d => d.Reboot()); + yield return Site("DisableAllChannels", d => d.DisableAllChannels()); + yield return Site("SetAnalogOutput", d => d.SetAnalogOutput(0, 1.0)); + yield return Site("SetPwmFrequency", d => d.SetPwmFrequency(1000)); + yield return Site("SaveAdcCalibration", d => d.SaveAdcCalibration()); + yield return Site("LoadAdcCalibration", d => d.LoadAdcCalibration()); + yield return Site("SaveFactoryAdcCalibration", d => d.SaveFactoryAdcCalibration()); + yield return Site("LoadFactoryAdcCalibration", d => d.LoadFactoryAdcCalibration()); + yield return Site("UseAdcCalibration", d => d.UseAdcCalibration(1)); + yield return Site("SetAdcCalibrationSlope", d => d.SetAdcCalibrationSlope(0, 1.0)); + yield return Site("SetAdcCalibrationOffset", d => d.SetAdcCalibrationOffset(0, 0.0)); + yield return Site("SaveVoltagePrecision", d => d.SaveVoltagePrecision()); + yield return Site("LoadVoltagePrecision", d => d.LoadVoltagePrecision()); + yield return Site("PrepareSdInterface", d => d.PrepareSdInterface()); + yield return Site("PrepareLanInterface", d => d.PrepareLanInterface()); + yield return Site("SetSdCardMinimumFreeSpace", d => d.SetSdCardMinimumFreeSpace(52_428_800)); + yield return Site("Send", d => d.Send(new ScpiMessage("*IDN?"))); + + static object[] Site(string name, Action call) => [name, call]; + } + + [Theory] + [MemberData(nameof(SynchronousGuardSites))] + public void SynchronousGuard_WhenDisconnected_ThrowsDeviceNotConnected( + string siteName, + Action call) + { + _ = siteName; + var device = new DaqifiStreamingDevice("TestDevice"); + + var ex = Assert.Throws(() => call(device)); + + Assert.Equal("Device is not connected.", ex.Message); + Assert.False(ex.IsShuttingDown); + } + + // ── Guard sites: asynchronous API (SD, network, diagnostics) ──────── + + public static IEnumerable AsynchronousGuardSites() + { + yield return Site("GetSdCardFilesAsync", d => d.GetSdCardFilesAsync()); + yield return Site("GetSdCardStorageAsync", d => d.GetSdCardStorageAsync()); + yield return Site("CheckSdCardSpaceAsync", d => d.CheckSdCardSpaceAsync()); + yield return Site("StartSdCardLoggingAsync", d => d.StartSdCardLoggingAsync()); + yield return Site("StartSdCardLoggingSessionAsync", d => d.StartSdCardLoggingSessionAsync()); + yield return Site("StopSdCardLoggingAsync", d => d.StopSdCardLoggingAsync()); + yield return Site("DeleteSdCardFileAsync", d => d.DeleteSdCardFileAsync("test.bin")); + yield return Site("FormatSdCardAsync", d => d.FormatSdCardAsync()); + yield return Site("DownloadSdCardFileAsync", d => d.DownloadSdCardFileAsync("test.bin", new MemoryStream())); + yield return Site("UpdateNetworkConfigurationAsync", d => d.UpdateNetworkConfigurationAsync( + new NetworkConfiguration(WifiMode.ExistingNetwork, WifiSecurityType.WpaPskPhrase, "ssid", "pw"))); + yield return Site("LoadNetworkConfigurationAsync", d => d.LoadNetworkConfigurationAsync()); + yield return Site("FactoryResetNetworkAsync", d => d.FactoryResetNetworkAsync()); + yield return Site("GetLanChipInfoAsync", d => d.GetLanChipInfoAsync()); + yield return Site("GetSystemLogAsync", d => d.GetSystemLogAsync()); + yield return Site("ClearSystemLogAsync", d => d.ClearSystemLogAsync()); + yield return Site("SetLogLevelAsync", d => d.SetLogLevelAsync("STREAM", 2)); + yield return Site("GetCommandHistoryAsync", d => d.GetCommandHistoryAsync()); + yield return Site("TestSystemLogAsync", d => d.TestSystemLogAsync()); + yield return Site("GetSystemErrorCountAsync", d => d.GetSystemErrorCountAsync()); + yield return Site("GetStreamStatsAsync", d => d.GetStreamStatsAsync()); + yield return Site("GetMemoryDiagnosticsAsync", d => d.GetMemoryDiagnosticsAsync()); + yield return Site("SetFriendlyNameAsync", d => d.SetFriendlyNameAsync("Lab Nq1")); + + static object[] Site(string name, Func call) => [name, call]; + } + + [Theory] + [MemberData(nameof(AsynchronousGuardSites))] + public async Task AsynchronousGuard_WhenDisconnected_ThrowsDeviceNotConnected( + string siteName, + Func call) + { + _ = siteName; + var device = new DaqifiStreamingDevice("TestDevice"); + + var ex = await Assert.ThrowsAsync(() => call(device)); + + Assert.Equal("Device is not connected.", ex.Message); + Assert.False(ex.IsShuttingDown); + } + + [Fact] + public async Task InitializeAsync_WhenDisconnected_ThrowsDeviceNotConnected() + { + // This guard keeps its own wording, so it is worth pinning separately: the type is + // what callers classify on, not the message. + var device = new DaqifiStreamingDevice("TestDevice"); + + var ex = await Assert.ThrowsAsync(() => device.InitializeAsync()); + + Assert.Equal("Device must be connected before initialization.", ex.Message); + Assert.False(ex.IsShuttingDown); + } + + // ── The disposing / disconnecting distinction ─────────────────────── + + [Fact] + public async Task TextCommand_WhenNotConnected_ThrowsWithIsShuttingDownFalse() + { + var device = new TextCommandTestableDevice("TestDevice"); + + var ex = await Assert.ThrowsAsync( + () => device.CallExecuteTextCommandAsync()); + + Assert.Equal("Device is not connected.", ex.Message); + Assert.False(ex.IsShuttingDown); + } + + [Fact] + public async Task TextCommand_WhenDisconnecting_ThrowsWithIsShuttingDownTrue() + { + // The real disconnect race the issue describes: Disconnect() sets _isDisconnecting + // before the transport check further down is ever reached, so this guard is what an + // in-flight caller actually sees. + var device = new TextCommandTestableDevice("TestDevice"); + SetPrivateField(device, "_isDisconnecting", true); + + var ex = await Assert.ThrowsAsync( + () => device.CallExecuteTextCommandAsync()); + + Assert.True(ex.IsShuttingDown); + Assert.Contains("disposing or disconnecting", ex.Message); + } + + [Fact] + public async Task TextCommand_WhenDisposed_ThrowsWithIsShuttingDownTrue() + { + var device = new TextCommandTestableDevice("TestDevice"); + SetPrivateField(device, "_disposed", true); + + var ex = await Assert.ThrowsAsync( + () => device.CallExecuteTextCommandAsync()); + + Assert.True(ex.IsShuttingDown); + Assert.Contains("disposing or disconnecting", ex.Message); + } + + [Fact] + public async Task TextCommand_WhenLockAlreadyDisposedByRacingDispose_ThrowsWithIsShuttingDownTrue() + { + // Dispose() disposed the text-exchange semaphore while this caller was about to wait + // on it. That path is also "the device went away", so it carries the same flag rather + // than leaking a low-level ObjectDisposedException. + var device = new TextCommandTestableDevice("TestDevice"); + var semaphore = (SemaphoreSlim)typeof(DaqifiDevice) + .GetField("_textExchangeLock", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(device)!; + semaphore.Dispose(); + + var ex = await Assert.ThrowsAsync( + () => device.CallExecuteTextCommandAsync()); + + Assert.True(ex.IsShuttingDown); + Assert.Contains("disposed", ex.Message); + } + + [Fact] + public async Task TextCommand_ReEntrancyGuard_StaysAPlainInvalidOperationException() + { + // Re-entering ExecuteTextCommandAsync from a setupAction is an application defect, + // not a connectivity condition — it must NOT be classified as "the device went away". + var device = new TextCommandTestableDevice("TestDevice"); + var flag = (AsyncLocal)typeof(DaqifiDevice) + .GetField("_isInsideTextExchange", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(device)!; + flag.Value = true; + + var ex = await Assert.ThrowsAsync( + () => device.CallExecuteTextCommandAsync()); + + Assert.IsNotAssignableFrom(ex); + Assert.Contains("not re-entrant", ex.Message); + } + + // ── Source compatibility for existing consumers ───────────────────── + + [Fact] + public void ExistingCatchOfInvalidOperationException_StillCatchesTheGuard() + { + var device = new DaqifiStreamingDevice("TestDevice"); + var caught = false; + + try + { + device.StartStreaming(); + } + catch (InvalidOperationException) + { + caught = true; + } + + Assert.True(caught); + } + + [Fact] + public void GuardException_CanBeClassifiedApartFromAnUnrelatedInvalidOperation() + { + // The whole point of the issue: a disconnect is separable from a real defect without + // reading either exception's message. + var device = new DaqifiStreamingDevice("TestDevice"); + + var disconnect = Record.Exception(() => device.StartStreaming()); + Exception defect = new InvalidOperationException("a genuine bug"); + + Assert.IsType(disconnect); + Assert.IsNotAssignableFrom(defect); + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private static void SetPrivateField(DaqifiDevice device, string fieldName, object value) + { + typeof(DaqifiDevice) + .GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(device, value); + } + + /// + /// Exposes the protected ExecuteTextCommandAsync so the text-path guards can be + /// exercised directly. The real method runs, guards included. + /// + private sealed class TextCommandTestableDevice : DaqifiDevice + { + public TextCommandTestableDevice(string name, IPAddress? ipAddress = null) + : base(name, ipAddress) + { + } + + public Task> CallExecuteTextCommandAsync() + { + return ExecuteTextCommandAsync(() => { }, responseTimeoutMs: 100, completionTimeoutMs: 50); + } + } + } +} diff --git a/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs b/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs index 714a3322..e1c129a6 100644 --- a/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs +++ b/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs @@ -17,7 +17,7 @@ public async Task GetSystemLogAsync_WhenDisconnected_Throws() { var device = new TestableDiagnosticsDevice("TestDevice"); - await Assert.ThrowsAsync(() => device.GetSystemLogAsync()); + await Assert.ThrowsAsync(() => device.GetSystemLogAsync()); } [Fact] @@ -91,7 +91,7 @@ public async Task ClearSystemLogAsync_WhenDisconnected_Throws() { var device = new TestableDiagnosticsDevice("TestDevice"); - await Assert.ThrowsAsync(() => device.ClearSystemLogAsync()); + await Assert.ThrowsAsync(() => device.ClearSystemLogAsync()); } [Fact] @@ -276,7 +276,7 @@ public async Task GetMemoryDiagnosticsAsync_WhenDisconnected_Throws() { var device = new TestableDiagnosticsDevice("TestDevice"); - await Assert.ThrowsAsync(() => device.GetMemoryDiagnosticsAsync()); + await Assert.ThrowsAsync(() => device.GetMemoryDiagnosticsAsync()); } /// diff --git a/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs b/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs index 6d449e09..372e5247 100644 --- a/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs +++ b/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs @@ -17,7 +17,7 @@ public async Task GetLanChipInfoAsync_WhenDisconnected_Throws() { var device = new TestableLanChipInfoDevice("TestDevice"); - await Assert.ThrowsAsync(() => device.GetLanChipInfoAsync()); + await Assert.ThrowsAsync(() => device.GetLanChipInfoAsync()); } [Fact] diff --git a/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs b/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs index 7b65038b..b6066f25 100644 --- a/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs +++ b/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs @@ -38,7 +38,7 @@ public async Task UpdateNetworkConfigurationAsync_WhenDisconnected_ThrowsInvalid "TestPassword"); // Act & Assert - var exception = await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => device.UpdateNetworkConfigurationAsync(config)); Assert.Equal("Device is not connected.", exception.Message); } @@ -483,7 +483,7 @@ public void PrepareSdInterface_WhenDisconnected_ThrowsInvalidOperationException( var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - var exception = Assert.Throws(() => device.PrepareSdInterface()); + var exception = Assert.Throws(() => device.PrepareSdInterface()); Assert.Equal("Device is not connected.", exception.Message); } @@ -512,7 +512,7 @@ public void PrepareLanInterface_WhenDisconnected_ThrowsInvalidOperationException var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - var exception = Assert.Throws(() => device.PrepareLanInterface()); + var exception = Assert.Throws(() => device.PrepareLanInterface()); Assert.Equal("Device is not connected.", exception.Message); } @@ -645,7 +645,7 @@ public async Task LoadNetworkConfigurationAsync_WhenDisconnected_ThrowsInvalidOp { var device = new DaqifiStreamingDevice("TestDevice"); - var exception = await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => device.LoadNetworkConfigurationAsync()); Assert.Equal("Device is not connected.", exception.Message); } @@ -680,7 +680,7 @@ public async Task FactoryResetNetworkAsync_WhenDisconnected_ThrowsInvalidOperati { var device = new DaqifiStreamingDevice("TestDevice"); - var exception = await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => device.FactoryResetNetworkAsync()); Assert.Equal("Device is not connected.", exception.Message); } diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index 17fab924..f8af2e6c 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -24,7 +24,7 @@ public async Task GetSdCardFilesAsync_WhenDisconnected_Throws() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.GetSdCardFilesAsync()); } @@ -616,7 +616,7 @@ public async Task StartSdCardLoggingAsync_WhenDisconnected_Throws() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.StartSdCardLoggingAsync()); } @@ -627,7 +627,7 @@ public async Task StopSdCardLoggingAsync_WhenDisconnected_Throws() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.StopSdCardLoggingAsync()); } @@ -690,7 +690,7 @@ public async Task DeleteSdCardFileAsync_WhenDisconnected_Throws() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.DeleteSdCardFileAsync("data.bin")); } @@ -1156,7 +1156,7 @@ public async Task FormatSdCardAsync_WhenDisconnected_Throws() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.FormatSdCardAsync()); } @@ -1253,7 +1253,7 @@ public async Task GetSdCardStorageAsync_WhenDisconnected_Throws() { var device = new DaqifiStreamingDevice("TestDevice"); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.GetSdCardStorageAsync()); } @@ -1509,7 +1509,7 @@ public async Task CheckSdCardSpaceAsync_WhenDisconnected_Throws() { var device = new TestableSdCardStreamingDevice("TestDevice"); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.CheckSdCardSpaceAsync()); } @@ -1547,7 +1547,7 @@ public void SetSdCardMinimumFreeSpace_WhenDisconnected_Throws() { var device = new DaqifiStreamingDevice("TestDevice"); - Assert.Throws(() => device.SetSdCardMinimumFreeSpace(52428800)); + Assert.Throws(() => device.SetSdCardMinimumFreeSpace(52428800)); } [Fact] @@ -1571,7 +1571,7 @@ public async Task DownloadSdCardFileAsync_WhenDisconnected_Throws() using var stream = new MemoryStream(); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.DownloadSdCardFileAsync("test.bin", stream)); } diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index 13be9768..3257a80e 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -593,16 +593,16 @@ public void Disconnect() /// /// The type of the message data payload. /// The message to send to the device. + /// Thrown when the device is not connected. /// - /// Thrown when the device is not connected, or when connected but has no transport or - /// stream to send on (e.g. the producer-less - /// constructor). + /// Thrown when the device is connected but has no transport or stream to send on + /// (e.g. the producer-less constructor). /// public virtual void Send(IOutboundMessage message) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Use the queued message producer when available and the message is string-based; @@ -638,14 +638,15 @@ public virtual void Send(IOutboundMessage message) /// /// A cancellation token to observe. /// A task representing the asynchronous operation. - /// Thrown when the device is not connected or has no transport. + /// Thrown when the device is not connected. + /// Thrown when the device has no transport-based connection. protected virtual async Task ExecuteRawCaptureAsync( Func rawAction, CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (_transport == null) @@ -743,7 +744,13 @@ private void RestartMessageConsumerAfterSwap() /// exchange opened — late replies to earlier commands — are excluded: only what arrived once /// had begun sending is returned. /// - /// Thrown when the device is not connected or has no transport. + /// + /// Thrown when the device is not connected, or — with + /// set — when the device is + /// disposed, disposing, or disconnecting. + /// + /// Thrown when the device has no transport-based connection. + /// Thrown when the underlying transport has dropped. /// Thrown when the operation is canceled. protected virtual Task> ExecuteTextCommandAsync( Action setupAction, @@ -769,7 +776,13 @@ protected virtual Task> ExecuteTextCommandAsync( /// The time in milliseconds of inactivity after the first response before considering the response complete. Defaults to 250ms. /// A cancellation token to observe while waiting for the task to complete. /// A list of text lines received from the device. - /// Thrown when the device is not connected or has no transport. + /// + /// Thrown when the device is not connected, or — with + /// set — when the device is + /// disposed, disposing, or disconnecting. + /// + /// Thrown when the device has no transport-based connection. + /// Thrown when the underlying transport has dropped. /// Thrown when the operation is canceled. protected virtual Task> ExecuteTextCommandAsync( Func setupActionAsync, @@ -821,8 +834,9 @@ private async Task> ExecuteTextCommandCoreAsync( // Surface the same clean failure as the post-acquisition // _disposed check below, instead of leaking a low-level // teardown exception to callers. - throw new InvalidOperationException( - "ExecuteTextCommandAsync cannot run because the device is disposed."); + throw new DeviceNotConnectedException( + "ExecuteTextCommandAsync cannot run because the device is disposed.", + isShuttingDown: true); } _isInsideTextExchange.Value = true; @@ -835,14 +849,15 @@ private async Task> ExecuteTextCommandCoreAsync( // documented in #186). if (_disposed || _isDisconnecting) { - throw new InvalidOperationException( + throw new DeviceNotConnectedException( "ExecuteTextCommandAsync cannot run while the device is " - + "disposing or disconnecting."); + + "disposing or disconnecting.", + isShuttingDown: true); } if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (_transport == null) @@ -1070,7 +1085,8 @@ private async Task> ExecuteTextCommandCoreAsync( /// A cancellation token to observe while waiting for the task to complete. /// The list of error strings popped from the queue (empty if the queue was already clean). /// Thrown when is not positive. - /// Thrown when the device is not connected or has no transport. + /// Thrown when the device is not connected. + /// Thrown when the device has no transport-based connection. /// Thrown when the operation is canceled. public virtual async Task> DrainErrorQueueAsync( int maxIterations = 256, @@ -1189,7 +1205,7 @@ public void Dispose() /// surfaces a ). /// /// Thrown when is not positive. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the device returns a SCPI error during initialization that persists after an internal retry. /// Thrown when the device does not report its channel configuration within . /// Thrown when the operation is canceled. @@ -1199,7 +1215,7 @@ public virtual async Task InitializeAsync( { if (!IsConnected) { - throw new InvalidOperationException("Device must be connected before initialization."); + throw new DeviceNotConnectedException("Device must be connected before initialization."); } if (_isInitialized) diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 625386fa..b8be5185 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -315,12 +315,12 @@ protected override async Task OnDeviceInitializingAsync(CancellationToken cancel /// /// Starts streaming data from the device at the configured frequency. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. public void StartStreaming() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (IsStreaming) return; @@ -349,12 +349,12 @@ public void StartStreaming() /// /// Stops streaming data from the device. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. public void StopStreaming() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (!IsStreaming) return; @@ -711,7 +711,7 @@ public void DisableAllChannels() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } foreach (var channel in SnapshotChannels()) @@ -743,7 +743,7 @@ public void SetDioDirection(IChannel channel, ChannelDirection direction) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } EnsureChannelBelongs(channel); @@ -769,7 +769,7 @@ public void SetDioValue(IChannel channel, bool value) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } EnsureChannelBelongs(channel); @@ -838,7 +838,7 @@ public void SetPwmEnabled(IChannel channel, bool enabled) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } EnsureChannelBelongs(channel); @@ -889,7 +889,7 @@ public void SetPwmDutyCycle(IChannel channel, int dutyCyclePercent) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } EnsureChannelBelongs(channel); @@ -914,7 +914,7 @@ public void SetPwmFrequency(int frequencyHz) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Skip the redundant round-trip when the device already has this frequency (from a @@ -958,7 +958,7 @@ public void SetPwmFrequency(int frequencyHz) /// A task that completes once both commands have been sent. /// Thrown when is null. /// Thrown when fails validation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is cancelled. public Task SetFriendlyNameAsync(string name, CancellationToken cancellationToken = default) { @@ -976,7 +976,7 @@ public Task SetFriendlyNameAsync(string name, CancellationToken cancellationToke if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -1019,7 +1019,7 @@ public void SetAnalogOutput(int channelNumber, double voltage) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Analog-output (DAC) channels are addressed by number; they are not part of the @@ -1034,7 +1034,7 @@ public void Reboot() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.RebootDevice); @@ -1049,7 +1049,7 @@ public void SaveAdcCalibration() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SaveAdcCalibration); @@ -1060,7 +1060,7 @@ public void LoadAdcCalibration() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.LoadAdcCalibration); @@ -1076,7 +1076,7 @@ public void SetAdcCalibrationSlope(int channelNumber, double calM) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SetAdcCalibrationSlope(channelNumber, calM)); @@ -1092,7 +1092,7 @@ public void SetAdcCalibrationOffset(int channelNumber, double calB) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SetAdcCalibrationOffset(channelNumber, calB)); @@ -1103,7 +1103,7 @@ public void SaveFactoryAdcCalibration() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SaveFactoryAdcCalibration); @@ -1114,7 +1114,7 @@ public void LoadFactoryAdcCalibration() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.LoadFactoryAdcCalibration); @@ -1130,7 +1130,7 @@ public void UseAdcCalibration(int bank) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.UseAdcCalibration(bank)); @@ -1141,7 +1141,7 @@ public void SaveVoltagePrecision() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SaveVoltagePrecision); @@ -1152,7 +1152,7 @@ public void LoadVoltagePrecision() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.LoadVoltagePrecision); @@ -1167,7 +1167,7 @@ private void SetChannelsEnabled(IReadOnlyList channels, bool enabled) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Validate everything up front so a bad entry can't leave a partially-applied state. @@ -1318,7 +1318,7 @@ private void EnsureChannelBelongs(IChannel channel) /// The new network configuration to apply. /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when is null. /// Thrown when an unsupported WiFi mode or security type is specified. /// @@ -1338,7 +1338,7 @@ public async Task UpdateNetworkConfigurationAsync(NetworkConfiguration configura if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Stop streaming if active @@ -1471,7 +1471,7 @@ public async Task UpdateNetworkConfigurationAsync(NetworkConfiguration configura /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public Task LoadNetworkConfigurationAsync(CancellationToken cancellationToken = default) { @@ -1479,7 +1479,7 @@ public Task LoadNetworkConfigurationAsync(CancellationToken cancellationToken = if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Re-check right before the state-changing send so a cancellation requested after the @@ -1494,7 +1494,7 @@ public Task LoadNetworkConfigurationAsync(CancellationToken cancellationToken = /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public Task FactoryResetNetworkAsync(CancellationToken cancellationToken = default) { @@ -1502,7 +1502,7 @@ public Task FactoryResetNetworkAsync(CancellationToken cancellationToken = defau if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Re-check right before the state-changing send so a cancellation requested after the @@ -1520,12 +1520,12 @@ public Task FactoryResetNetworkAsync(CancellationToken cancellationToken = defau /// very TCP channel that requested it, so disabling LAN would drop the control channel /// mid-operation. Only the SD subsystem is enabled in that case. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. public void PrepareSdInterface() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (IsUsbConnection) @@ -1542,12 +1542,12 @@ public void PrepareSdInterface() /// ). Over WiFi/TCP the LAN was never disabled, so it is /// left alone — re-enabling it would re-initialize the WiFi module and drop the connection. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. public void PrepareLanInterface() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.DisableStorageSd); @@ -1591,7 +1591,7 @@ private void EnsureSdFileTransferSupportedOnTransport() /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation, containing the list of files. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. /// Thrown when no SD card is installed in the device. /// Thrown when the SD card filesystem cannot satisfy the request (corrupt card, unreadable directory). @@ -1633,7 +1633,7 @@ public async Task> GetSdCardFilesAsync(Cancellatio { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } EnsureSdFileTransferSupportedOnTransport(); @@ -1776,7 +1776,8 @@ private static bool TrySplitAtSdListTerminator( /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation, containing the SD card storage info. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when the operation is canceled. /// Thrown when no SD card is installed in the device. /// @@ -1791,7 +1792,7 @@ public async Task GetSdCardStorageAsync(CancellationToken can { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (_isLoggingToSdCard) @@ -1940,7 +1941,7 @@ public void SetSdCardMinimumFreeSpace(long bytes) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SetSdMinFreeSpace(bytes)); @@ -1956,7 +1957,7 @@ public void SetSdCardMinimumFreeSpace(long bytes) /// The logging format to use. Defaults to . /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) => StartSdCardLoggingSessionAsync(fileName, channelMask, format, cancellationToken); @@ -1980,13 +1981,13 @@ public Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask /// A task that resolves to an carrying the effective on-card /// file name (supplied or auto-generated) and the logging format. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public async Task StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (!IsUsbConnection) @@ -2051,13 +2052,13 @@ public async Task StartSdCardLoggingSessionAsync(string? f /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public Task StopSdCardLoggingAsync(CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2089,14 +2090,15 @@ public Task StopSdCardLoggingAsync(CancellationToken cancellationToken = default /// The name of the file to delete. /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when the filename is null, empty, or contains invalid characters. /// Thrown when the operation is canceled. public async Task DeleteSdCardFileAsync(string fileName, CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (_isLoggingToSdCard) @@ -2183,13 +2185,14 @@ public async Task DeleteSdCardFileAsync(string fileName, CancellationToken cance /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when the operation is canceled. public Task FormatSdCardAsync(CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (_isLoggingToSdCard) @@ -2217,7 +2220,8 @@ public Task FormatSdCardAsync(CancellationToken cancellationToken = default) /// Optional progress reporting. /// Cancellation token. /// Metadata about the downloaded file. - /// Thrown when the device is not connected or is not using a USB/serial transport. + /// Thrown when the device is not connected. + /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. /// Thrown when the filename is null, empty, or contains invalid characters. /// /// Thrown when the device serves a marker-only (0-byte) transfer for the file across all @@ -2232,7 +2236,7 @@ public async Task DownloadSdCardFileAsync( { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Over WiFi/TCP this requires firmware >= v3.7.0 (#598/#599); over USB it is always @@ -2332,7 +2336,8 @@ await ExecuteRawCaptureAsync(async (stream, ct) => /// Optional progress reporting. /// Cancellation token. /// Metadata about the downloaded file, including the local file path. - /// Thrown when the device is not connected or is not using a USB/serial transport. + /// Thrown when the device is not connected. + /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. /// Thrown when the filename is null, empty, or contains invalid characters. public async Task DownloadSdCardFileAsync( string fileName, @@ -2487,7 +2492,7 @@ private static void ValidateSdCardFileName(string fileName) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } var lines = await ExecuteTextCommandAsync( @@ -2577,7 +2582,7 @@ public async Task> GetSystemLogAsync(CancellationT { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2603,7 +2608,7 @@ public async Task ClearSystemLogAsync(CancellationToken cancellationToken = defa { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2628,7 +2633,7 @@ public async Task SetLogLevelAsync(string module, int level, Ca if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2660,7 +2665,7 @@ public async Task> GetCommandHistoryAsync(CancellationToke { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2685,7 +2690,7 @@ public async Task TestSystemLogAsync(CancellationToken cancellationToken = defau { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2705,7 +2710,7 @@ public async Task GetSystemErrorCountAsync(CancellationToken cancellationTo { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2738,7 +2743,7 @@ public async Task GetStreamStatsAsync(CancellationToken cancellatio { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2763,7 +2768,7 @@ public async Task GetMemoryDiagnosticsAsync(CancellationToken { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Daqifi.Core/Device/DeviceNotConnectedException.cs b/src/Daqifi.Core/Device/DeviceNotConnectedException.cs new file mode 100644 index 00000000..5f8b179d --- /dev/null +++ b/src/Daqifi.Core/Device/DeviceNotConnectedException.cs @@ -0,0 +1,107 @@ +using System; + +#nullable enable + +namespace Daqifi.Core.Device +{ + /// + /// Thrown by a device operation's up-front connectivity guard when the device is not in a state + /// that can carry the request — it was never connected, it has already disconnected, or a + /// / is in flight on + /// another thread. + /// + /// + /// + /// Derives from — the type these guards threw before — + /// so existing catch (InvalidOperationException) sites keep working unchanged, while new + /// code can catch this specific type. This mirrors + /// , which made the same + /// trade at the transport layer. + /// + /// + /// The distinction this type exists to give callers is between an ordinary, expected + /// condition — the device went away, the user pressed Disconnect while a refresh was in flight, + /// WiFi dropped mid-transfer — and a genuine application defect. Clients that classify failures + /// for user-facing reporting or error tracking should log this at warning level with reconnect + /// guidance, and reserve error level (and alerting) for the exceptions that really do indicate + /// a bug. Before this type existed the only way to tell them apart was to match on the + /// exception message, which broke silently on any wording change. + /// + /// + /// This is the device-level counterpart of + /// , which reports that the + /// underlying transport's stream is gone. The two are deliberately siblings rather than one + /// deriving from the other: a device can fail this guard while holding a perfectly healthy + /// transport (for instance, mid-), and a transport can drop + /// while the device still reports . + /// + /// + public class DeviceNotConnectedException : InvalidOperationException + { + /// + /// The message used when no explicit message is supplied. Kept byte-for-byte identical to + /// the message these guards threw before this type existed, so any downstream code still + /// matching on it continues to work during migration. + /// + internal const string DefaultMessage = "Device is not connected."; + + /// + /// Gets a value indicating whether the guard fired because the device is tearing down — + /// a or is in + /// flight, or has already completed — rather than because the device was simply not + /// connected in the first place. + /// + /// + /// Both cases are "the device is unavailable" and most callers can treat them the same way. + /// The flag is here for callers that want to say something more specific, such as + /// suppressing a retry prompt when the user themselves initiated the disconnect. + /// + public bool IsShuttingDown { get; } + + /// + /// Initializes a new instance of the class with + /// the default message. + /// + public DeviceNotConnectedException() + : this(DefaultMessage, isShuttingDown: false) + { + } + + /// + /// Initializes a new instance of the class with a + /// specified error message. + /// + /// The message that describes the device's connectivity state. + public DeviceNotConnectedException(string message) + : this(message, isShuttingDown: false) + { + } + + /// + /// Initializes a new instance of the class with a + /// specified error message and teardown state. + /// + /// The message that describes the device's connectivity state. + /// + /// true when the guard fired because the device is disconnecting or disposing; + /// otherwise false. See . + /// + public DeviceNotConnectedException(string message, bool isShuttingDown) + : base(message) + { + IsShuttingDown = isShuttingDown; + } + + /// + /// Initializes a new instance of the class with a + /// specified error message and a reference to the inner exception that is the cause of this + /// exception. + /// + /// The message that describes the device's connectivity state. + /// The exception that caused the current exception, or null. + public DeviceNotConnectedException(string message, Exception? innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs b/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs index faa3c851..a52beb73 100644 --- a/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs +++ b/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs @@ -24,7 +24,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// The buffered log entries, oldest first; empty when the buffer was empty. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task> GetSystemLogAsync(CancellationToken cancellationToken = default); /// @@ -32,7 +32,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task ClearSystemLogAsync(CancellationToken cancellationToken = default); /// @@ -42,7 +42,7 @@ public interface IDeviceDiagnostics /// The log level: 0 = None, 1 = Error, 2 = Info, 3 = Debug. /// A cancellation token to observe while waiting for the task to complete. /// The level actually applied, as echoed by the device (may be capped by the module's ceiling). - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when is null, empty, or contains invalid characters. /// Thrown when is outside 0–3. /// Thrown when the device rejected the request or returned an unparseable response. @@ -53,7 +53,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// The remembered commands, newest first; empty when there is no history. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task> GetCommandHistoryAsync(CancellationToken cancellationToken = default); /// @@ -61,7 +61,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task TestSystemLogAsync(CancellationToken cancellationToken = default); /// @@ -70,7 +70,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// The current error-queue depth. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the device returned an unparseable response. Task GetSystemErrorCountAsync(CancellationToken cancellationToken = default); @@ -79,7 +79,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// The parsed streaming statistics. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the device returned an unparseable response. Task GetStreamStatsAsync(CancellationToken cancellationToken = default); @@ -88,7 +88,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// The parsed memory diagnostics. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the device returned an unparseable response. Task GetMemoryDiagnosticsAsync(CancellationToken cancellationToken = default); } diff --git a/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs b/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs index 884a04a0..f01c1a62 100644 --- a/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs +++ b/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs @@ -36,7 +36,7 @@ public interface ISdCardOperations /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation, containing the list of files. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when no SD card is installed in the device. /// Thrown when the SD card filesystem cannot satisfy the request (e.g. corrupt card, unreadable directory). /// Thrown when the device returned an SCPI error that did not match a more specific condition. An empty directory returns an empty list rather than throwing. @@ -52,7 +52,8 @@ public interface ISdCardOperations /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation, containing the SD card storage info. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when no SD card is installed in the device. /// Thrown when the device's firmware does not recognize the storage query (SCPI -113 "Undefined header"), typically because it predates the minimum supported firmware. /// Thrown when the device returned an SCPI error or an unparseable response. @@ -75,7 +76,8 @@ public interface ISdCardOperations /// A task that resolves to the evaluated . The check never blocks /// logging; callers decide whether to proceed based on . /// - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when no SD card is installed in the device. /// Thrown when the device returned an SCPI error or an unparseable response. Task CheckSdCardSpaceAsync( @@ -90,7 +92,7 @@ Task CheckSdCardSpaceAsync( /// firmware gate; the client-side remains the primary UX surface. /// /// The minimum free space to keep available, in bytes. Use 0 to disable the gate. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when is negative. void SetSdCardMinimumFreeSpace(long bytes); @@ -120,7 +122,7 @@ Task CheckSdCardSpaceAsync( /// instead, which returns an /// . /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default); /// @@ -144,7 +146,7 @@ Task CheckSdCardSpaceAsync( /// A task that resolves to an with the effective on-card /// file name and logging format. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default); /// @@ -152,7 +154,7 @@ Task CheckSdCardSpaceAsync( /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task StopSdCardLoggingAsync(CancellationToken cancellationToken = default); /// @@ -161,7 +163,8 @@ Task CheckSdCardSpaceAsync( /// The name of the file to delete. /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when the filename is null, empty, or contains invalid characters. Task DeleteSdCardFileAsync(string fileName, CancellationToken cancellationToken = default); @@ -170,7 +173,8 @@ Task CheckSdCardSpaceAsync( /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. Task FormatSdCardAsync(CancellationToken cancellationToken = default); /// @@ -181,7 +185,8 @@ Task CheckSdCardSpaceAsync( /// Optional progress reporting. /// Cancellation token. /// Metadata about the downloaded file. - /// Thrown when the device is not connected or is not using a USB/serial transport. + /// Thrown when the device is not connected. + /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. /// Thrown when the filename is null, empty, or contains invalid characters. Task DownloadSdCardFileAsync( string fileName, @@ -196,7 +201,8 @@ Task DownloadSdCardFileAsync( /// Optional progress reporting. /// Cancellation token. /// Metadata about the downloaded file, including the local . - /// Thrown when the device is not connected or is not using a USB/serial transport. + /// Thrown when the device is not connected. + /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. /// Thrown when the filename is null, empty, or contains invalid characters. Task DownloadSdCardFileAsync( string fileName, From 8268bca999e4a5fd5a38ab07bd1bbf1dd914c4a4 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 29 Jul 2026 08:50:36 -0600 Subject: [PATCH 2/4] refactor(tests): rename guard tests to name the typed exception Self-review pass: test names that said ThrowsInvalidOperationException now name DeviceNotConnectedException; the sibling-type assertion uses IsNotAssignableFrom in both directions instead of an exact-type check; DefaultMessage is private (nothing else needs it); trimmed a duplicated paragraph in the exception's XML docs and made the docs sample compile- shaped. Co-Authored-By: Claude Opus 5 --- docs/DEVICE_INTERFACES.md | 5 +++- .../Device/DaqifiDeviceTests.cs | 2 +- .../DaqifiDeviceTextCommandLockTests.cs | 4 ++-- ...fiStreamingDeviceChannelManagementTests.cs | 4 ++-- .../Device/DaqifiStreamingDeviceTests.cs | 12 +++++----- .../DeviceNotConnectedExceptionTests.cs | 2 +- .../Network/NetworkConfigurableTests.cs | 10 ++++---- .../Device/DeviceNotConnectedException.cs | 23 ++++++++----------- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/docs/DEVICE_INTERFACES.md b/docs/DEVICE_INTERFACES.md index eada558a..3e0ea77e 100644 --- a/docs/DEVICE_INTERFACES.md +++ b/docs/DEVICE_INTERFACES.md @@ -381,10 +381,13 @@ matching on the exception message: ```csharp using Daqifi.Core.Device; +using Daqifi.Core.Device.SdCard; + +var sdCard = (ISdCardOperations)device; try { - var files = await sdCard.GetSdCardFilesAsync(); + IReadOnlyList files = await sdCard.GetSdCardFilesAsync(); } catch (DeviceNotConnectedException ex) { diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs index 51d2b1a1..82dcd264 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs @@ -71,7 +71,7 @@ public void Disconnect_ChangesStatusAndRaisesEvent() } [Fact] - public void Send_WhenDisconnected_ThrowsInvalidOperationException() + public void Send_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiDevice("TestDevice"); diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs index d1d399aa..5fb9deb3 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs @@ -40,7 +40,7 @@ public async Task ExecuteTextCommandAsync_WhenAlreadyInsideAsyncFlow_ThrowsInval } [Fact] - public async Task ExecuteTextCommandAsync_WhenDisposing_ThrowsInvalidOperation() + public async Task ExecuteTextCommandAsync_WhenDisposing_ThrowsDeviceNotConnected() { var device = new TextCommandTestableDevice("TestDevice"); SetIsDisconnecting(device, true); @@ -51,7 +51,7 @@ public async Task ExecuteTextCommandAsync_WhenDisposing_ThrowsInvalidOperation() } [Fact] - public async Task ExecuteTextCommandAsync_WhenDisposed_ThrowsInvalidOperation() + public async Task ExecuteTextCommandAsync_WhenDisposed_ThrowsDeviceNotConnected() { var device = new TextCommandTestableDevice("TestDevice"); SetDisposed(device, true); diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs index e97a490a..d155768a 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs @@ -571,7 +571,7 @@ public void SetPwmFrequency_OutOfRange_ThrowsArgumentOutOfRangeException(int fre } [Fact] - public void SetPwmEnabled_WhenDisconnected_ThrowsInvalidOperationException() + public void SetPwmEnabled_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = CreateConnectedDevice(digitalChannels: 8); var channel = DigitalChannelAt(device, 4); @@ -688,7 +688,7 @@ public void DisableChannel_WithNullChannel_ThrowsArgumentNullException() } [Fact] - public void ChannelManagement_WhenDisconnected_ThrowsInvalidOperationException() + public void ChannelManagement_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Populate channels but do not connect. var device = new TestableDaqifiStreamingDevice("TestDevice"); diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs index 00e882cc..9c27dd6d 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs @@ -133,7 +133,7 @@ public void StopStreaming_WhenConnected_SendsCorrectCommandAndSetsIsStreaming() } [Fact] - public void StartStreaming_WhenDisconnected_ThrowsInvalidOperationException() + public void StartStreaming_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiStreamingDevice("TestDevice"); @@ -144,7 +144,7 @@ public void StartStreaming_WhenDisconnected_ThrowsInvalidOperationException() } [Fact] - public void StopStreaming_WhenDisconnected_ThrowsInvalidOperationException() + public void StopStreaming_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiStreamingDevice("TestDevice"); @@ -201,7 +201,7 @@ public void NvmPersistence_WhenConnected_SendsCorrectCommand(string methodName, [Theory] [MemberData(nameof(NvmPersistenceCommands))] - public void NvmPersistence_WhenDisconnected_ThrowsInvalidOperationException(string methodName, string expectedCommand) + public void NvmPersistence_WhenDisconnected_ThrowsDeviceNotConnectedException(string methodName, string expectedCommand) { // Arrange _ = expectedCommand; @@ -264,7 +264,7 @@ public void UseAdcCalibration_WhenConnected_SendsCorrectCommand(int bank) } [Fact] - public void SetAdcCalibrationSlope_WhenDisconnected_ThrowsInvalidOperationException() + public void SetAdcCalibrationSlope_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = new DaqifiStreamingDevice("TestDevice"); var exception = Assert.Throws(() => device.SetAdcCalibrationSlope(0, 1.0)); @@ -272,7 +272,7 @@ public void SetAdcCalibrationSlope_WhenDisconnected_ThrowsInvalidOperationExcept } [Fact] - public void SetAdcCalibrationOffset_WhenDisconnected_ThrowsInvalidOperationException() + public void SetAdcCalibrationOffset_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = new DaqifiStreamingDevice("TestDevice"); var exception = Assert.Throws(() => device.SetAdcCalibrationOffset(0, 1.0)); @@ -280,7 +280,7 @@ public void SetAdcCalibrationOffset_WhenDisconnected_ThrowsInvalidOperationExcep } [Fact] - public void UseAdcCalibration_WhenDisconnected_ThrowsInvalidOperationException() + public void UseAdcCalibration_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = new DaqifiStreamingDevice("TestDevice"); var exception = Assert.Throws(() => device.UseAdcCalibration(1)); diff --git a/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs b/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs index 14dba28a..968747f8 100644 --- a/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs +++ b/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs @@ -37,7 +37,7 @@ public void DeviceNotConnectedException_IsNotATransportNotConnectedException() // The two typed connectivity exceptions are deliberate siblings, not a hierarchy: a // device can fail its guard while its transport is healthy (mid-Disconnect), and a // transport can drop while the device still reports Connected. - Assert.IsNotType(new DeviceNotConnectedException()); + Assert.IsNotAssignableFrom(new DeviceNotConnectedException()); Assert.IsNotAssignableFrom(new TransportNotConnectedException()); } diff --git a/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs b/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs index b6066f25..ff1fca7c 100644 --- a/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs +++ b/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs @@ -27,7 +27,7 @@ public void NetworkConfiguration_InitializedOnConstruction() } [Fact] - public async Task UpdateNetworkConfigurationAsync_WhenDisconnected_ThrowsInvalidOperationException() + public async Task UpdateNetworkConfigurationAsync_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiStreamingDevice("TestDevice"); @@ -477,7 +477,7 @@ public async Task UpdateNetworkConfigurationAsync_CanceledDuringRestartWait_Comp } [Fact] - public void PrepareSdInterface_WhenDisconnected_ThrowsInvalidOperationException() + public void PrepareSdInterface_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiStreamingDevice("TestDevice"); @@ -506,7 +506,7 @@ public void PrepareSdInterface_WhenConnected_SendsCorrectCommands() } [Fact] - public void PrepareLanInterface_WhenDisconnected_ThrowsInvalidOperationException() + public void PrepareLanInterface_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiStreamingDevice("TestDevice"); @@ -641,7 +641,7 @@ public async Task UpdateNetworkConfigurationAsync_WithUnsupportedSecurityType_Th } [Fact] - public async Task LoadNetworkConfigurationAsync_WhenDisconnected_ThrowsInvalidOperationException() + public async Task LoadNetworkConfigurationAsync_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = new DaqifiStreamingDevice("TestDevice"); @@ -676,7 +676,7 @@ await Assert.ThrowsAsync( } [Fact] - public async Task FactoryResetNetworkAsync_WhenDisconnected_ThrowsInvalidOperationException() + public async Task FactoryResetNetworkAsync_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = new DaqifiStreamingDevice("TestDevice"); diff --git a/src/Daqifi.Core/Device/DeviceNotConnectedException.cs b/src/Daqifi.Core/Device/DeviceNotConnectedException.cs index 5f8b179d..ccff4e17 100644 --- a/src/Daqifi.Core/Device/DeviceNotConnectedException.cs +++ b/src/Daqifi.Core/Device/DeviceNotConnectedException.cs @@ -12,13 +12,6 @@ namespace Daqifi.Core.Device /// /// /// - /// Derives from — the type these guards threw before — - /// so existing catch (InvalidOperationException) sites keep working unchanged, while new - /// code can catch this specific type. This mirrors - /// , which made the same - /// trade at the transport layer. - /// - /// /// The distinction this type exists to give callers is between an ordinary, expected /// condition — the device went away, the user pressed Disconnect while a refresh was in flight, /// WiFi dropped mid-transfer — and a genuine application defect. Clients that classify failures @@ -28,12 +21,16 @@ namespace Daqifi.Core.Device /// exception message, which broke silently on any wording change. /// /// - /// This is the device-level counterpart of + /// Derives from — the type these guards threw before — + /// so existing catch (InvalidOperationException) sites keep working unchanged, while new + /// code can catch this specific type. + /// + /// /// , which reports that the - /// underlying transport's stream is gone. The two are deliberately siblings rather than one - /// deriving from the other: a device can fail this guard while holding a perfectly healthy - /// transport (for instance, mid-), and a transport can drop - /// while the device still reports . + /// underlying transport's stream is gone, made the same trade one layer down. The two are + /// deliberately siblings rather than one deriving from the other: a device can fail this guard + /// while holding a perfectly healthy transport (for instance, mid-), + /// and a transport can drop while the device still reports . /// /// public class DeviceNotConnectedException : InvalidOperationException @@ -43,7 +40,7 @@ public class DeviceNotConnectedException : InvalidOperationException /// the message these guards threw before this type existed, so any downstream code still /// matching on it continues to work during migration. /// - internal const string DefaultMessage = "Device is not connected."; + private const string DefaultMessage = "Device is not connected."; /// /// Gets a value indicating whether the guard fired because the device is tearing down — From bc6e7ec252b892457b9bf8596fc5d991cfa45a62 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 29 Jul 2026 11:17:27 -0600 Subject: [PATCH 3/4] fix(api): preserve the inner exception when translating a dispose race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExecuteTextCommandCoreAsync catches the ObjectDisposedException that a racing Dispose() raises from _textExchangeLock.WaitAsync and translates it to DeviceNotConnectedException(IsShuttingDown: true). It discarded the original, so the rare teardown race lost its root type and stack — and the exception offered no constructor carrying both an inner exception and the flag, so the call site could not have preserved it. Adds DeviceNotConnectedException(string, Exception?, bool) and passes the caught exception through. Swept the rest of the change: this is the only catch that translates a causal exception; every other guard is a plain state check with nothing to preserve. Co-Authored-By: Claude Opus 5 --- .../DeviceNotConnectedExceptionTests.cs | 15 +++++++++++++ src/Daqifi.Core/Device/DaqifiDevice.cs | 6 +++-- .../Device/DeviceNotConnectedException.cs | 22 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs b/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs index 968747f8..c83cc0e9 100644 --- a/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs +++ b/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs @@ -237,6 +237,21 @@ public async Task TextCommand_WhenLockAlreadyDisposedByRacingDispose_ThrowsWithI Assert.True(ex.IsShuttingDown); Assert.Contains("disposed", ex.Message); + + // The translation must not discard the cause: the original + // ObjectDisposedException survives so this rare race stays diagnosable. + Assert.IsType(ex.InnerException); + } + + [Fact] + public void DeviceNotConnectedException_CanCarryBothAnInnerExceptionAndTheShutdownFlag() + { + var inner = new ObjectDisposedException("SemaphoreSlim"); + var ex = new DeviceNotConnectedException("tearing down", inner, isShuttingDown: true); + + Assert.Equal("tearing down", ex.Message); + Assert.Same(inner, ex.InnerException); + Assert.True(ex.IsShuttingDown); } [Fact] diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index 3257a80e..22255a88 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -828,14 +828,16 @@ private async Task> ExecuteTextCommandCoreAsync( { await _textExchangeLock.WaitAsync(cancellationToken).ConfigureAwait(false); } - catch (ObjectDisposedException) + catch (ObjectDisposedException ex) { // Dispose() raced ahead of us and disposed the semaphore. // Surface the same clean failure as the post-acquisition // _disposed check below, instead of leaking a low-level - // teardown exception to callers. + // teardown exception to callers. The original is kept as + // InnerException so this rare race stays diagnosable. throw new DeviceNotConnectedException( "ExecuteTextCommandAsync cannot run because the device is disposed.", + ex, isShuttingDown: true); } diff --git a/src/Daqifi.Core/Device/DeviceNotConnectedException.cs b/src/Daqifi.Core/Device/DeviceNotConnectedException.cs index ccff4e17..254bc497 100644 --- a/src/Daqifi.Core/Device/DeviceNotConnectedException.cs +++ b/src/Daqifi.Core/Device/DeviceNotConnectedException.cs @@ -97,8 +97,30 @@ public DeviceNotConnectedException(string message, bool isShuttingDown) /// The message that describes the device's connectivity state. /// The exception that caused the current exception, or null. public DeviceNotConnectedException(string message, Exception? innerException) + : this(message, innerException, isShuttingDown: false) + { + } + + /// + /// Initializes a new instance of the class with a + /// specified error message, the inner exception that caused it, and the teardown state. + /// + /// + /// Use this when a guard translates a lower-level teardown exception — such as the + /// from a racing + /// ahead of an in-flight call — so the original type and stack survive on + /// rather than being discarded. + /// + /// The message that describes the device's connectivity state. + /// The exception that caused the current exception, or null. + /// + /// true when the guard fired because the device is disconnecting or disposing; + /// otherwise false. See . + /// + public DeviceNotConnectedException(string message, Exception? innerException, bool isShuttingDown) : base(message, innerException) { + IsShuttingDown = isShuttingDown; } } } From d23e263fa7a49eda33eeaf4791a6add6b56319fc Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 29 Jul 2026 12:09:05 -0600 Subject: [PATCH 4/4] fix(api): type the connectivity guard on ReadCapabilityDocumentAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #390/#404 landed on main after this branch's guard sweep, adding a new public API whose not-connected guard still threw the untyped InvalidOperationException. Merging main brought it in unconverted, which would have shipped #395 with one untyped guard surviving in a brand-new entry point — leaving downstream's string-matching workaround un-deletable and the file inconsistent (48 typed throws, one untyped). Also update that method's test, which asserted the exact exception type. DeviceNotConnectedException derives from InvalidOperationException, so a consumer's existing catch is unaffected; only xUnit's exact-match assertion needed widening. Co-Authored-By: Claude Opus 5 --- .../Capabilities/DaqifiDeviceCapabilityDocumentTests.cs | 4 +++- src/Daqifi.Core/Device/DaqifiDevice.cs | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs index 5d46a7d5..918f9644 100644 --- a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs +++ b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs @@ -35,7 +35,9 @@ public async Task ReadCapabilityDocumentAsync_WhenDisconnected_Throws() { var device = new TestableCapabilityDevice("BenchNq1"); - await Assert.ThrowsAsync( + // Typed since #395; still an InvalidOperationException by inheritance, so a consumer's + // existing catch keeps working. ThrowsAsync matches exactly, hence the derived type here. + await Assert.ThrowsAsync( () => device.ReadCapabilityDocumentAsync()); } diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index f4031c8e..64a347cf 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -1254,14 +1254,14 @@ public virtual async Task> DrainErrorQueueAsync( /// The parsed document, which has already been applied to ; or /// null when the device did not supply one this parser can trust. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public virtual async Task ReadCapabilityDocumentAsync( CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (!Supports(DeviceFeature.CapabilityDocument))