fix(transport): name serial connect failures instead of calling them all access denials (closes #424) - #427
Conversation
…all access denials (closes #424) SerialStreamTransport.ConnectAsync forwarded whatever SerialPort.Open threw, so a port that simply does not exist arrived as "Access to the port '<name>' is denied." — pointing at a permissions problem for what is usually a typo or a stale port name, since USB serial device nodes are renumbered across replugs. Open failures are now translated into SerialPortConnectException (an IOException) carrying a typed SerialPortConnectFailure reason — NotFound, InUse, AccessDenied, Unknown — with the original platform exception always preserved as InnerException. This mirrors TcpStreamTransport, which already substitutes a TimeoutException for a misleading TaskCanceledException (daqifi-desktop#517). The reason cannot be recovered from the platform exception. Measured on macOS with System.IO.Ports 10.0.10, a missing port and a port held by another process produce identical exceptions, and the inner IOException's message varied between processes on the same machine for the same port ("Unknown error: 203" vs "No such file or directory") — its HResult is a stale errno, not a usable signal. The reason is therefore derived from evidence gathered at the moment of failure: whether the port is still present (the probe the transport already trusts for unplug detection), and whether a per-user permission gate could apply at all. Platforms that cannot answer degrade to the existing access-denied wording rather than misclassifying. Retry behavior is unchanged; a translated failure is still one failed attempt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoFix serial connect failures by translating to typed SerialPortConnectException
AI Description
Diagram
High-Level Assessment
Files changed (5)
|
Code Review by Qodo
1.
|
…onnect exception change Qodo review on #427. The missing-port tests hard-coded their absent port name, which is only absent by assumption — a host with a virtual COM port or a leftover device node would have made them open real hardware or fail with a different error shape. The name is now generated and checked at runtime: a GUID-suffixed device node on Unix, and on Windows the highest COM number SerialPort.GetPortNames() does not claim, since the Windows serial stack only accepts COM-prefixed names. Each test captures the name in a local, which the previous property-per-call form could not do safely. Also documents the deliberate behavioral change for callers: a connect that used to throw UnauthorizedAccessException now throws SerialPortConnectException, so that catch no longer matches, with the migration spelled out on both ConnectAsync and the exception type. No runtime behavior changes in this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 0d96dec |
… production guard Qodo round 2 on #427. CreateVerifiedAbsentPort() called SerialPort.GetPortNames() unguarded, so a host where enumeration throws (a container without /dev access, a locked-down machine) would have failed these tests from inside the helper — which reads as the feature under test breaking rather than the environment. An enumeration that cannot answer now contributes no names; File.Exists still gives an independent absence check on Unix, and an unclaimed high COM number remains the best answer on Windows. The production classification path was already guarded — TryObservePortPresence catches everything and returns null, which Classify treats as unknown presence and degrades to the access-denied wording rather than claiming NotFound on no evidence. That contract was untested, so it is now pinned via the PortPresenceProbe seam: a throwing probe must not replace the connect failure or escape. Test-only; no runtime behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit ff877f8 |
…uch for Qodo round 3 on #427. The round-2 fail-soft fix collapsed two states that are not equivalent: an enumeration that returned nothing, and one that threw. On Windows an empty result is a real answer — it means no COM ports exist, so any COM name is absent — while a throw is no evidence at all. Treating both as "no names claimed" let the Windows branch pick a COM name backed by nothing. The two are now tracked separately. An answered enumeration is sufficient on Windows because it is itself the authority there (it reads HARDWARE\DEVICEMAP\SERIALCOMM), and Windows has no independent absence check the way a Unix device node has File.Exists. A failed enumeration now refuses with a precise diagnostic rather than asserting against an unverified port. Unix is unchanged and needs no guard: the port name is a filesystem path, so File.Exists answers independently of the enumeration. Test-only; no runtime behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit c4e328e |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit c4e328e |
Why
Connecting to a serial port that doesn't exist reported
Access to the port '/dev/cu.doesnotexist' is denied.— sending users off to check permissions anddialoutgroup membership when the real problem was a wrong or stale port name. That's the common case, because USB serial device nodes get renumbered every time you replug. The TCP transport already avoids exactly this trap by translating a misleadingTaskCanceledExceptioninto a realTimeoutException; serial had no equivalent.What
A failed serial connect now says what actually went wrong: "Serial port 'X' was not found.", "Serial port 'X' is in use.", or the original access-denied wording when it really is a permission problem. Callers who want to branch on it get a typed
SerialPortConnectExceptionwith aReasonofNotFound,InUse,AccessDenied, orUnknown, instead of string-matching. The original platform exception is always kept asInnerException, so nothing is lost.How
The interesting part is that the reason cannot be recovered from the platform exception at all, which the issue's suggested fix assumed it could. On macOS a missing port and a port held by another process produce byte-identical exceptions — same type, same message, same inner
IOException. Worse, that inner message wasn't even stable: on this machine, for the same port, one process reportedUnknown error: 203and another reportedNo such file or directory. ItsHResultis a stale errno, not a signal.So the reason is derived from evidence gathered at the moment of the failure instead: whether the port is still present (the same probe the transport already trusts to detect an unplug), and whether a per-user permission gate could apply at all (a macOS
/dev/cu.*node iscrw-rw-rw-, so nobody can be denied on permission grounds — a denial there means someone else holds it; a Linuxdialoutport atcrw-rw----keeps the access-denied reading). Anything that can't be determined degrades to today's wording rather than asserting something false. Retry behaviour is untouched — a translated failure is still just one failed attempt.Bench test
Real Nq1 (FW 3.7.2), macOS, example CLI built against this branch.
USB/serial — the issue's own repro, before and after:
All three classifications against the real device:
USB happy path not broken — connect + SCPI init + clean disconnect still work, and discovery still identifies the unit:
WiFi/TCP — no-regression check only (this change is serial-only and touches zero TCP code). One consolidated connect to
192.168.1.30:9760: the TCP transport connected fine and got through toDaqifiDevice.InitializeAsync, which then hit the known 8 s channel-config timeout this unit is currently in (SD low-heap / degraded state, power cycle being arranged). That is pre-existing and unrelated.Streaming, end to end on the real device (after the unit was power cycled out of the channel-config window it had been stuck in):
237 sample messages across the 6 s window on three analog channels, timestamps monotonic, clean stop. That is ~79% of the requested 50 Hz, which is the known rate behaviour of this bench unit (fw#716) and not a shortfall introduced here.
This closes the one gap left open earlier in review: the happy path is now proven to connect and stream, not just connect.
Full suite green: 2496 passed on both net9.0 and net10.0, Release build clean with 0 warnings.
Note for daqifi-desktop
SerialStreamTransport.ConnectAsyncnow throwsSerialPortConnectException(anIOException) where it previously threw the rawUnauthorizedAccessException. Desktop'sSerialStreamingDevice.LogConnectFailureswitches onUnauthorizedAccessException/FileNotFoundExceptionto classify these as warnings rather than Sentry errors, so it will need one added case or those connect failures will start reporting as errors. Nothing inside Core regressed — serial discovery uses a rawSerialPortand has a catch-all fallback.Closes #424