fix: surface typed exception when serial port closes mid-operation (#238)#240
Conversation
) SerialStreamTransport.Stream returned `_serialPort?.BaseStream ?? throw`, but when the port is non-null yet closed (device unplugged, or a DTR-triggered MCU reset re-enumerated the COM port mid-connect), the BaseStream getter itself throws a raw InvalidOperationException ("The BaseStream is only available when the port is open.") before the ?? guard runs. That framework message reads like an app bug and gets auto-filed as an error by consumers' crash reporting (daqifi-desktop#588). Mirror #237 (TCP connect timeout -> TimeoutException): translate the condition into a clear, typed signal at the boundary. - Add TransportNotConnectedException : InvalidOperationException so consumers can classify a dropped/closed transport as a transient, environmental condition by type, while existing catch (InvalidOperationException) blocks keep working. - SerialStreamTransport.Stream guards on _serialPort?.IsOpen before touching BaseStream and throws the typed exception. IsConnected already reflects the closed-port state (=> IsOpen == true). - TcpStreamTransport.Stream throws the same typed exception for cross-transport consistency. - ExecuteTextCommandCoreAsync detects a dropped transport (_transport.IsConnected == false) and fails with the typed exception rather than dereferencing Stream and leaking the framework message. Tests cover closed-port stream access (typed exception, and message is not the raw BaseStream text), the disconnected case via assignability, and the device-level silent-drop path. Verified against a real device on COM3. Behavioral API change worth a release-notes mention: consumers catching the raw InvalidOperationException to detect a dropped serial transport should catch TransportNotConnectedException. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code Review by Qodo
Context used 1.
|
PR Summary by QodoSurface TransportNotConnectedException when serial/TCP transports drop WalkthroughsDescription• Add TransportNotConnectedException for classifying dropped/not-connected transports by type. • Guard SerialStreamTransport.Stream against closed ports to avoid leaking BaseStream exceptions. • Fail device text commands when transport silently drops; add targeted regression tests. Diagramgraph TD
CON{{"Consumer"}} --> DEV["DaqifiDevice"] --> API["IStreamTransport"]
API --> SER["SerialStreamTransport"]
API --> TCP["TcpStreamTransport"]
SER --> EX(("TransportNotConnectedException"))
TCP --> EX
DEV --> EX
subgraph Legend
direction LR
_ext{{"External"}} ~~~ _mod["Module/Class"] ~~~ _exc(("Exception"))
end
High-Level AssessmentThe following are alternative approaches to this PR: 1. Translate BaseStream failure by catching InvalidOperationException
2. Keep throwing InvalidOperationException with standardized messages only
3. Change API to TryGetStream()/nullable Stream instead of throwing
Recommendation: The PR’s approach (introducing TransportNotConnectedException derived from InvalidOperationException and throwing it at transport/device boundaries) is the right compatibility/perf tradeoff and improves diagnosability. Consider additionally wrapping the SerialPort.BaseStream access in a try/catch that translates the framework InvalidOperationException into TransportNotConnectedException to handle the rare race where the port closes between the IsOpen check and BaseStream getter. File ChangesEnhancement (1)
Bug fix (3)
Tests (3)
Documentation (1)
|
…ship Round-1 review feedback on #240: - SerialStreamTransport.Stream: capture _serialPort into a local before the check-then-access, so a concurrent Disconnect()/Dispose() that nulls the field can't turn the BaseStream dereference into a NullReferenceException instead of TransportNotConnectedException. Also wrap the BaseStream getter in a try/catch that translates its InvalidOperationException into the typed exception (preserving the original as InnerException) — this closes the exact TOCTOU the issue describes, where the port closes between the IsOpen check and the getter. (SerialPort.BaseStream's only InvalidOperationException is the "only available when the port is open" case, so the translation can't mask an unrelated error.) - SetSerialPortForTesting: make the implementation match its documented ownership contract — dispose the previously held port on swap/clear (no-op on same-instance re-assign). Add a test covering assign, same-ref early-return, swap-dispose, and clear-dispose. All 1013 tests pass on net9.0 and net10.0; Release build clean with warnings as errors. The try/catch translation path is defensive-only and not unit- testable through the seam (SerialPort.IsOpen/BaseStream are non-virtual, so the "open at check, throws at access" race can't be forced without real hardware). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks @qodo-code-review — both findings were valid and are addressed in e293b5d. 1. Serial Stream race / NRE (Action required) — fixed. Agreed: the getter read
2. Test seam leaks SerialPort (Review recommended) — fixed. Made the implementation match its documented ownership contract: One note on coverage: the new All 1013 tests pass on net9.0 and net10.0; Release build clean with warnings-as-errors. |
Summary
Fixes #238 — the serial analog of #237.
SerialStreamTransport.Streamwas:When the underlying
SerialPortis non-null but closed (device unplugged, or a DTR-triggered MCU reset re-enumerated the COM port mid-connect), theSerialPort.BaseStreamgetter itself throws before the?? throwguard can run:So the intended typed signal never surfaced; consumers got a raw framework exception that reads like an app bug and is auto-filed as an error by crash reporting (daqifi/daqifi-desktop#588).
Changes
TransportNotConnectedException : InvalidOperationException(Communication/Transport/). Deriving fromInvalidOperationExceptionkeeps backward compatibility — existingcatch (InvalidOperationException)blocks and theIStreamTransport.Streamcontract still hold — while giving consumers a precise type to classify a dropped/closed transport as a transient, environmental condition (the same tier asFileNotFoundException/UnauthorizedAccessExceptionand the TCPTimeoutExceptionfrom fix: surface TCP connect timeout as TimeoutException #237).SerialStreamTransport.Streamnow guards on_serialPort?.IsOpen != truebefore touchingBaseStream, throwing the typed exception with a message that names the transport state instead of leaking the raw framework message. (IsConnectedalready returnsfalseonce the port closes, since it is=> _serialPort?.IsOpen == true— issue point 2 was already satisfied.)TcpStreamTransport.Streamthrows the same typed exception for cross-transport consistency (the WiFi twin, daqifi-desktop#595).DaqifiDevice.ExecuteTextCommandCoreAsyncdetects a dropped transport (_transport.IsConnected == false) and fails with the typed exception rather than dereferencingStreamand leaking the framework message (issue point 3). The device-levelIsConnectedis status-based and can still reportConnectedwhen the transport has silently dropped (a serial unplug/DTR-reset raises no status event).Tests
SerialStreamTransport_Stream_WhenPortClosedMidOperation_ThrowsTypedException_NotRawBaseStreamMessage— injects a constructed-but-unopenedSerialPort(via an internal test seam), asserts the typed exception and that the message is not the raw"BaseStream is only available..."text, and thatIsConnectedisfalse. No hardware needed.Assert.ThrowsAny<InvalidOperationException>to lock in the preserved assignability contract.DaqifiDevice_ExecuteTextCommand_WhenTransportDroppedSilently_ThrowsTransportNotConnectedException— drivesInitializeAsync→ExecuteTextCommandCoreAsyncagainst a transport that silently drops, asserting the typed exception andDeviceState.Error.All 1011 tests pass on both
net9.0andnet10.0; Release solution build is clean withTreatWarningsAsErrors. Also verified end-to-end against a real device on COM3: the happy path returns the liveSerialStreamunchanged, and the closed-port path surfaces the typed exception without leaking the raw message.Consumer impact / release notes
Behavioral API change worth a release-notes mention (as with #237): consumers catching the raw
InvalidOperationExceptionaround stream access / connect to detect a dropped serial transport should catchTransportNotConnectedException. Because it derives fromInvalidOperationException, existing broad catches keep working unchanged; consumers opt in to the warning-tier classification by adding the specific type.References
TimeoutException)🤖 Generated with Claude Code