fix(api): typed DeviceNotConnectedException from Core's connectivity guards (closes #395) - #402
fix(api): typed DeviceNotConnectedException from Core's connectivity guards (closes #395)#402tylerkron wants to merge 2 commits into
Conversation
…uards
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
PR Summary by Qodofix(api): Add typed DeviceNotConnectedException for connectivity guards
AI Description
Diagram
High-Level Assessment
Files changed (20)
|
|
/agentic_review |
Code Review by Qodo🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)
Great, no issues found!Qodo reviewed your code and found no material issues that require reviewTo customize comments, go to the Qodo configuration screen, or learn more in the docs. |
Problem
Core's up-front connectivity guards threw a plain
InvalidOperationException("Device is not connected."). A client that wants to log "the device went away" (ordinary, expected — the user pressed Disconnect while a refresh was in flight, WiFi dropped mid-transfer) at warning with reconnect guidance, and genuine defects at error with a Sentry issue, had no type to match on. The only lever was the message string, which breaks silently on any wording change — and the failure mode is quiet: a regression to false alerts, visible only in alert volume.That sat badly beside Core's own direction elsewhere:
TransportNotConnectedException,SdCardNotPresentException,FeatureNotSupportedExceptionandScpiInitializationErrorExceptionare all typed precisely so callers don't read messages.The issue also flags a real asymmetry: deeper in
ExecuteTextCommandAsyncthe transport check does throw the typedTransportNotConnectedException, butDisconnect()sets_isDisconnectingand updatesStatusbefore that point — so in an actual disconnect race the untyped guard fired first and the typed one was unreachable.The change
New
DeviceNotConnectedException, thrown from every guard that previously threw the untyped exception.DaqifiStreamingDevice.cs, 3 inDaqifiDevice.cs(Send<T>,ExecuteRawCaptureAsync,ExecuteTextCommandCoreAsync)InitializeAsync's!IsConnectedguard (same condition, different wording)ExecuteTextCommandCoreAsync's_disposed || _isDisconnectingcheck, and theObjectDisposedExceptionrace on_textExchangeLock— both throwDeviceNotConnectedExceptionwithIsShuttingDown = trueType choice: a new type, not
TransportNotConnectedExceptionThe issue offered either. I added a new type because the two conditions are genuinely different, and reusing the transport type would be a lie in the disconnect race: a device can fail this guard while holding a perfectly healthy transport (mid-
Disconnect()), and a transport can drop while the device still reportsIsConnected. They are documented as deliberate siblings, not a hierarchy — neither derives from the other, and a test pins that in both directions.Base class: derives from
InvalidOperationExceptionDeliberate, and the same trade
TransportNotConnectedExceptionalready made one layer down. Existingcatch (InvalidOperationException)sites keep working unchanged, so consumers migrate on their own schedule. Verified against a real device (see below): a legacycatch (InvalidOperationException)still catches the guard.The messages are also byte-for-byte unchanged, so downstream code still matching on the string keeps working during migration.
Disposing/disconnecting: a property, not a second type
IsShuttingDowndistinguishes "aDisconnect()/Dispose()is in flight or done" from "was never connected". A second exception type would have been more machinery than the distinction earns: the downstream workaround this issue was filed from treats both cases identically (log at warning, offer reconnect). The flag is there for callers that want to go further — e.g. suppress a retry prompt when the user themselves initiated the disconnect.What stayed an
InvalidOperationExceptionThe line is exactly "device went away" vs "the app has a bug". These are bugs and were deliberately left alone — a test asserts the re-entrancy guard is not assignable to
DeviceNotConnectedException:ExecuteTextCommandAsyncre-entrancy from asetupActionBreaking-change surface & caller audit
Source-compatible for
catch; NOT compatible with exact-type assertions. That is the honest headline: xUnit'sAssert.Throws<T>(and MSTest'sAssert.ThrowsException<T>) match the exact type and reject derived types.catch (InvalidOperationException)keeps working;Assert.Throws<InvalidOperationException>(...)does not.Repo-wide sweep (
src/,src/Daqifi.Mcp, tests,docs/):DeviceNotConnectedException. Tests assertingInvalidOperationExceptionfor other conditions (_WhenLogging_,_OverNonUsbConnection_,_BeyondBitmaskRange_,_ProducerlessConstructor_, re-entrancy) are deliberately untouched — they now double as regression coverage for the line drawn above.ThrowsInvalidOperationExceptionfor a disconnect scenario were renamed to name the real type.catch (InvalidOperationException)in Core sits on a device-guard path. The two that exist (ProcessExternalProcessRunner,SerialStreamTransport) are unrelated.src/Daqifi.Mcpleft unchanged. ItsRequire(deviceId)guards are application-level (registry lookup / stale-entry eviction, with their own messages), not the Core device API this issue is about. Its tests assert message substrings and all 23 still pass.DaqifiStreamingDevice,DaqifiDevice,ISdCardOperations,IDeviceDiagnostics. Two stale compound docs were split and corrected while I was in them: "not connected or currently logging" now names both exceptions separately, and "not connected or not using a USB/serial transport" was simply wrong — that path throwsFeatureNotSupportedExceptionnow (SD-over-WiFi gating), not anInvalidOperationException.docs/DEVICE_INTERFACES.mdgains a "Telling 'the device went away' apart from a bug" section under Error Handling, with the catch-ordering rule and the sibling relationship spelled out.What downstream can delete
The Avalonia port (daqifi-avalonia#2) can delete its string-matching workaround outright:
…and replace every call site with
catch (DeviceNotConnectedException). Both branches of that predicate map onto the one type, so nothing is lost;IsShuttingDownrecovers the distinction the second branch was reaching for. That helper also silently missed the"…because the device is disposed."race path — which now carriesIsShuttingDown = truelike its siblings, so the typed catch is strictly more complete than the string match ever was.daqifi-desktophas no Core-message matching (itsNOT_CONNECTED_MESSAGEconst guards its own throws inAbstractStreamingDevice), so it needs no change to keep building, andDaqifi.Desktop.Testasserts message text rather than the Core type, so those keep passing too.Tests
New
DeviceNotConnectedExceptionTests:InvalidOperationException; not related toTransportNotConnectedExceptionin either direction; default message; ctor behavior;IsShuttingDowndefaults.IsShuttingDown == false.false) vs_isDisconnecting(true) vs_disposed(true) vs the racing-Dispose()-disposed-semaphore path (true).catch (InvalidOperationException)still catches the guard, and a disconnect is separable from an unrelatedInvalidOperationExceptionwithout reading either message.Full suite green on net9.0 and net10.0 — 1987 passed, 2 skipped, plus 23 in
Daqifi.Mcp.Tests. Release build clean, 0 warnings (the project already runsTreatWarningsAsErrors+GenerateDocumentationFile, so every newcrefresolves).Bench evidence
Real DAQiFi Nyquist 1, firmware 3.7.2, USB CDC on
/dev/cu.usbmodem1101. Non-destructive throughout — no format, no delete, no reboot, no firmware flash, no WiFi reconfiguration, nothing unplugged. The disconnect below is a clean software-onlyDisconnect(); the physical-unplug case belongs to #382.No regression while connected (example CLI built against this branch):
Post-
Disconnect(), every guard surfaces the typed exception:That last line is the back-compat claim verified on hardware, not just in a unit test.
Sibling PRs
Developed in parallel with other issues touching adjacent code; textual conflicts are possible, semantic overlap is not:
ExecuteTextCommandCoreAsync's body. This PR only re-types the guards at the top of these methods and changes no timeout behavior.SdCardFileReceiver— a distinct site this PR does not touch.Follow-up (not in scope here)
HidLibraryTransportstill throws a plainInvalidOperationException("HID transport is not connected.")whereTransportNotConnectedExceptionwould fit. That's a transport-layer site, not one of the device guards this issue covers, so it's left for a separate change.closes #395
Not merging — opened for review.
🤖 Generated with Claude Code