diff --git a/src/Daqifi.Core.Tests/Communication/Transport/SerialPortConnectExceptionTests.cs b/src/Daqifi.Core.Tests/Communication/Transport/SerialPortConnectExceptionTests.cs
new file mode 100644
index 00000000..4cda2c3a
--- /dev/null
+++ b/src/Daqifi.Core.Tests/Communication/Transport/SerialPortConnectExceptionTests.cs
@@ -0,0 +1,191 @@
+using Daqifi.Core.Communication.Transport;
+
+namespace Daqifi.Core.Tests.Communication.Transport;
+
+///
+/// Pins the connect-failure translation added for #424: no
+/// longer forwards the platform's exception for a failed open, because that exception cannot be
+/// classified. Measured on macOS with System.IO.Ports 10.0.10, a missing port and a port held by
+/// another process both produce
+/// UnauthorizedAccessException("Access to the port 'X' is denied.") wrapping an
+/// IOException("Unknown error: 203") — identical type, text, and HResult — so the reason has
+/// to come from evidence gathered around the failure instead.
+///
+public class SerialPortConnectExceptionTests
+{
+ ///
+ /// The exact exception shape a failed open produces on macOS/Linux, for every cause.
+ ///
+ private static UnauthorizedAccessException PlatformDenied(string portName = "/dev/cu.fake") =>
+ new($"Access to the port '{portName}' is denied.", new IOException("Unknown error: 203"));
+
+ [Fact]
+ public void Classify_WhenPortIsAbsent_ReportsNotFound()
+ {
+ // The reported bug: a port that does not exist was called an access denial.
+ var reason = SerialPortConnectException.Classify(PlatformDenied(), portPresent: false,
+ permissionGateRuledOut: null);
+
+ Assert.Equal(SerialPortConnectFailure.NotFound, reason);
+ }
+
+ [Fact]
+ public void Classify_WhenPortIsAbsent_IgnoresThePermissionGate()
+ {
+ // Absence is conclusive; a device node that has already vanished cannot be a permission
+ // or an exclusivity problem, whatever the gate probe managed to say.
+ Assert.Equal(SerialPortConnectFailure.NotFound,
+ SerialPortConnectException.Classify(PlatformDenied(), false, permissionGateRuledOut: true));
+ Assert.Equal(SerialPortConnectFailure.NotFound,
+ SerialPortConnectException.Classify(PlatformDenied(), false, permissionGateRuledOut: false));
+ }
+
+ [Fact]
+ public void Classify_FileNotFoundException_ReportsNotFoundWithoutCorroboration()
+ {
+ // Windows names this case outright, so it needs no presence probe to agree with it.
+ var reason = SerialPortConnectException.Classify(
+ new FileNotFoundException("The port 'COM254' does not exist."),
+ portPresent: true,
+ permissionGateRuledOut: true);
+
+ Assert.Equal(SerialPortConnectFailure.NotFound, reason);
+ }
+
+ [Fact]
+ public void Classify_FileNotFoundExceptionNestedInside_ReportsNotFound()
+ {
+ var reason = SerialPortConnectException.Classify(
+ new UnauthorizedAccessException("wrapped", new FileNotFoundException("no such port")),
+ portPresent: true,
+ permissionGateRuledOut: true);
+
+ Assert.Equal(SerialPortConnectFailure.NotFound, reason);
+ }
+
+ [Fact]
+ public void Classify_PresentAndDeniedWhereNoPermissionGateCanApply_ReportsInUse()
+ {
+ // A macOS /dev/cu.* node is crw-rw-rw-, so nobody can be denied on permission grounds:
+ // the port exists and refuses to open because another process holds it.
+ var reason = SerialPortConnectException.Classify(PlatformDenied(), portPresent: true,
+ permissionGateRuledOut: true);
+
+ Assert.Equal(SerialPortConnectFailure.InUse, reason);
+ }
+
+ [Fact]
+ public void Classify_PresentAndDeniedWhereAPermissionGateCouldApply_ReportsAccessDenied()
+ {
+ // A Linux dialout-owned node at crw-rw---- is the genuine permission case.
+ var reason = SerialPortConnectException.Classify(PlatformDenied("/dev/ttyUSB0"),
+ portPresent: true, permissionGateRuledOut: false);
+
+ Assert.Equal(SerialPortConnectFailure.AccessDenied, reason);
+ }
+
+ [Fact]
+ public void Classify_WhenTheGateCannotBeDetermined_DegradesToAccessDenied()
+ {
+ // An unfamiliar platform keeps today's wording rather than asserting something false.
+ var reason = SerialPortConnectException.Classify(PlatformDenied(), portPresent: true,
+ permissionGateRuledOut: null);
+
+ Assert.Equal(SerialPortConnectFailure.AccessDenied, reason);
+ }
+
+ [Fact]
+ public void Classify_WhenPresenceCannotBeObserved_DoesNotInventAbsence()
+ {
+ // A probe that could not answer is not evidence the port is gone. Reporting NotFound here
+ // would turn any unrelated connect failure into a bogus "port was not found".
+ var reason = SerialPortConnectException.Classify(PlatformDenied(), portPresent: null,
+ permissionGateRuledOut: false);
+
+ Assert.Equal(SerialPortConnectFailure.AccessDenied, reason);
+ }
+
+ [Fact]
+ public void Classify_AnUnrecognizedIoFailure_StaysUnknown()
+ {
+ var reason = SerialPortConnectException.Classify(new IOException("the port hardware failed"),
+ portPresent: true, permissionGateRuledOut: true);
+
+ Assert.Equal(SerialPortConnectFailure.Unknown, reason);
+ }
+
+ [Fact]
+ public void Classify_NullError_Throws()
+ {
+ Assert.Throws(() =>
+ SerialPortConnectException.Classify(null!, true, true));
+ }
+
+ [Theory]
+ [InlineData(SerialPortConnectFailure.NotFound, "was not found")]
+ [InlineData(SerialPortConnectFailure.InUse, "is in use")]
+ [InlineData(SerialPortConnectFailure.AccessDenied, "is denied")]
+ [InlineData(SerialPortConnectFailure.Unknown, "could not be opened")]
+ public void DescribeFailure_NamesThePortAndTheCause(SerialPortConnectFailure reason, string expected)
+ {
+ var message = SerialPortConnectException.DescribeFailure("/dev/cu.usbmodem1101", reason);
+
+ Assert.Contains("/dev/cu.usbmodem1101", message);
+ Assert.Contains(expected, message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void DescribeFailure_AccessDenied_KeepsTheFrameworkWording()
+ {
+ // The one case the platform always got right; anything already keying on that phrasing for
+ // a real permission problem keeps matching.
+ Assert.Equal("Access to the port '/dev/ttyUSB0' is denied.",
+ SerialPortConnectException.DescribeFailure("/dev/ttyUSB0", SerialPortConnectFailure.AccessDenied));
+ }
+
+ [Fact]
+ public void FromOpenFailure_PreservesTheOriginalAsInnerException()
+ {
+ var original = PlatformDenied();
+
+ var ex = SerialPortConnectException.FromOpenFailure("/dev/cu.fake", original,
+ portPresent: false, permissionGateRuledOut: null);
+
+ Assert.Same(original, ex.InnerException);
+ Assert.Equal("/dev/cu.fake", ex.PortName);
+ Assert.Equal(SerialPortConnectFailure.NotFound, ex.Reason);
+ Assert.Contains("was not found", ex.Message);
+ }
+
+ [Theory]
+ [InlineData(false, null, SerialPortConnectFailure.NotFound)]
+ [InlineData(true, true, SerialPortConnectFailure.InUse)]
+ [InlineData(true, false, SerialPortConnectFailure.AccessDenied)]
+ public void FromOpenFailure_KeepsTheInnerExceptionForEveryReason(
+ bool present, bool? gateRuledOut, SerialPortConnectFailure expected)
+ {
+ var original = PlatformDenied();
+
+ var ex = SerialPortConnectException.FromOpenFailure("/dev/cu.fake", original, present, gateRuledOut);
+
+ Assert.Equal(expected, ex.Reason);
+ Assert.Same(original, ex.InnerException);
+ }
+
+ [Fact]
+ public void SerialPortConnectException_IsAnIoException()
+ {
+ // Failing to open an I/O device is an I/O error, and Windows already reports a missing port
+ // as an IOException-derived type, so catch (IOException) around a connect keeps working.
+ var ex = new SerialPortConnectException("COM3", SerialPortConnectFailure.NotFound, "nope");
+
+ Assert.IsAssignableFrom(ex);
+ }
+
+ [Fact]
+ public void SerialPortConnectException_RequiresAPortName()
+ {
+ Assert.Throws(() =>
+ new SerialPortConnectException(null!, SerialPortConnectFailure.NotFound, "nope"));
+ }
+}
diff --git a/src/Daqifi.Core.Tests/Communication/Transport/SerialStreamTransportTests.cs b/src/Daqifi.Core.Tests/Communication/Transport/SerialStreamTransportTests.cs
index 725b47a9..0a6e7ca8 100644
--- a/src/Daqifi.Core.Tests/Communication/Transport/SerialStreamTransportTests.cs
+++ b/src/Daqifi.Core.Tests/Communication/Transport/SerialStreamTransportTests.cs
@@ -141,6 +141,182 @@ public async Task SerialStreamTransport_ConnectAsync_WithInvalidPort_ShouldThrow
Assert.False(transport.IsConnected);
}
+ ///
+ /// Produces a port name checked to be absent on the running host, so the missing-port tests
+ /// assert the translation and nothing else.
+ ///
+ ///
+ /// Verified at runtime rather than hard-coded: a fixed name is only absent by assumption, and a
+ /// host that happens to have it — a virtual COM port, a leftover device node — would make these
+ /// tests either open real hardware or fail with a different error shape.
+ ///
+ private static string CreateVerifiedAbsentPort()
+ {
+ // Whether the enumeration *answered* is tracked separately from what it returned, because
+ // the two failure modes are not equivalent and must not be collapsed. An empty result is a
+ // real answer — the host has no serial ports. A throw is no answer at all.
+ bool enumerationAnswered;
+ HashSet enumerated;
+ try
+ {
+ enumerated = new HashSet(SerialPort.GetPortNames(), StringComparer.OrdinalIgnoreCase);
+ enumerationAnswered = true;
+ }
+ catch (Exception)
+ {
+ // Enumeration can throw on some hosts (a container without /dev access, a locked-down
+ // machine). It must not take the suite down from inside a test helper — that reads as
+ // the feature under test breaking rather than the environment.
+ enumerated = new HashSet(StringComparer.OrdinalIgnoreCase);
+ enumerationAnswered = false;
+ }
+
+ if (OperatingSystem.IsWindows())
+ {
+ // Windows has no absence check independent of the enumeration: a COM name is not a
+ // filesystem path, so there is no File.Exists equivalent, and the enumeration is itself
+ // the authority (it reads the HARDWARE\DEVICEMAP\SERIALCOMM registry map). That makes
+ // an answered enumeration sufficient — including an empty one, which positively means
+ // no COM ports exist — but leaves a *failed* enumeration with no evidence whatsoever.
+ // Choosing a name in that state would assert against an unverified port and could pass
+ // or fail for reasons unrelated to the translation being tested, so refuse instead.
+ if (!enumerationAnswered)
+ {
+ throw new InvalidOperationException(
+ "Cannot verify an absent COM name: SerialPort.GetPortNames() failed and Windows " +
+ "offers no independent check that a COM name is unused. Refusing to assert " +
+ "against an unverified port.");
+ }
+
+ // The Windows serial stack only accepts COM-prefixed names, so a random suffix is not
+ // an option; take the highest COM number the enumeration does not claim.
+ for (var number = 255; number >= 200; number--)
+ {
+ var candidate = $"COM{number}";
+ if (!enumerated.Contains(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ throw new InvalidOperationException(
+ "No unused COM name available to exercise the missing-port path.");
+ }
+
+ // Unix needs no such guard: the port name is a filesystem path, so File.Exists answers
+ // independently of the enumeration and a failed enumeration costs the check nothing.
+
+ for (var attempt = 0; attempt < 8; attempt++)
+ {
+ var candidate = $"/dev/tty.daqifi-core-absent-424-{Guid.NewGuid():N}";
+ if (!File.Exists(candidate) && !enumerated.Contains(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ throw new InvalidOperationException(
+ "Could not generate an absent device node path to exercise the missing-port path.");
+ }
+
+ [Fact]
+ public async Task SerialStreamTransport_ConnectAsync_WithMissingPort_ReportsPortNotFound()
+ {
+ // #424: SerialPort.Open reports a port that does not exist as "Access to the port '...' is
+ // denied.", sending users after a permissions problem for what is almost always a typo or
+ // a stale name (USB device nodes are renumbered across replugs).
+ var portName = CreateVerifiedAbsentPort();
+ using var transport = new SerialStreamTransport(portName);
+
+ var ex = await Assert.ThrowsAsync(() => transport.ConnectAsync());
+
+ Assert.Equal(SerialPortConnectFailure.NotFound, ex.Reason);
+ Assert.Equal(portName, ex.PortName);
+ Assert.Contains(portName, ex.Message);
+ Assert.Contains("was not found", ex.Message);
+ Assert.DoesNotContain("denied", ex.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.False(transport.IsConnected);
+ }
+
+ [Fact]
+ public async Task SerialStreamTransport_ConnectAsync_WithMissingPort_KeepsThePlatformException()
+ {
+ // The translation adds a name for the failure; it must not throw the diagnosis away.
+ using var transport = new SerialStreamTransport(CreateVerifiedAbsentPort());
+
+ var ex = await Assert.ThrowsAsync(() => transport.ConnectAsync());
+
+ Assert.NotNull(ex.InnerException);
+ }
+
+ [Fact]
+ public async Task SerialStreamTransport_ConnectAsync_WithMissingPort_StillCatchableAsIoException()
+ {
+ // The chosen base type: a caller bracketing a connect with catch (IOException) keeps working.
+ using var transport = new SerialStreamTransport(CreateVerifiedAbsentPort());
+
+ var ex = await Assert.ThrowsAnyAsync(() => transport.ConnectAsync());
+
+ Assert.IsType(ex);
+ }
+
+ [Fact]
+ public async Task SerialStreamTransport_ConnectAsync_WithMissingPort_ReportsTheTypedErrorOnStatusChanged()
+ {
+ // The status event carries the same translated exception, so a subscriber classifying a
+ // failed connect sees the reason rather than the platform's guess.
+ using var transport = new SerialStreamTransport(CreateVerifiedAbsentPort());
+ Exception? reported = null;
+ transport.StatusChanged += (_, e) => reported ??= e.Error;
+
+ await Assert.ThrowsAsync(() => transport.ConnectAsync());
+
+ var typed = Assert.IsType(reported);
+ Assert.Equal(SerialPortConnectFailure.NotFound, typed.Reason);
+ }
+
+ [Fact]
+ public async Task SerialStreamTransport_ConnectAsync_WhenThePresenceProbeThrows_DoesNotLeakIt()
+ {
+ // The production classification path calls SerialPort.GetPortNames(), which can throw on
+ // some hosts. That must degrade the reason, never replace the connect failure with the
+ // probe's own exception or escape as an unhandled error.
+ using var transport = new SerialStreamTransport(CreateVerifiedAbsentPort())
+ {
+ PortPresenceProbe = _ => throw new UnauthorizedAccessException("the probe could not run")
+ };
+
+ var ex = await Assert.ThrowsAsync(() => transport.ConnectAsync());
+
+ // The caller still gets the real open failure, with the platform exception preserved, and
+ // no trace of the probe's failure anywhere in the chain.
+ Assert.NotNull(ex.InnerException);
+ Assert.DoesNotContain("the probe could not run", ex.ToString());
+ }
+
+ [Fact]
+ public async Task SerialStreamTransport_ConnectAsync_WithMissingPort_StillHonorsRetryPolicy()
+ {
+ // Translation happens inside a connect attempt, so it is still just a failed attempt: the
+ // retry loop runs the configured number of times and surfaces the typed exception at the end.
+ using var transport = new SerialStreamTransport(CreateVerifiedAbsentPort());
+ var options = new ConnectionRetryOptions
+ {
+ Enabled = true,
+ MaxAttempts = 3,
+ InitialDelay = TimeSpan.Zero,
+ MaxDelay = TimeSpan.Zero
+ };
+ var failures = 0;
+ transport.StatusChanged += (_, e) => { if (!e.IsConnected) failures++; };
+
+ var ex = await Assert.ThrowsAsync(
+ () => transport.ConnectAsync(options));
+
+ Assert.Equal(SerialPortConnectFailure.NotFound, ex.Reason);
+ Assert.Equal(3, failures);
+ }
+
[Fact]
public void SerialStreamTransport_Disconnect_WhenNotConnected_ShouldNotThrow()
{
diff --git a/src/Daqifi.Core/Communication/Transport/SerialPortConnectException.cs b/src/Daqifi.Core/Communication/Transport/SerialPortConnectException.cs
new file mode 100644
index 00000000..51cfee14
--- /dev/null
+++ b/src/Daqifi.Core/Communication/Transport/SerialPortConnectException.cs
@@ -0,0 +1,187 @@
+namespace Daqifi.Core.Communication.Transport;
+
+///
+/// Thrown when
+/// cannot open its serial port, carrying a stable that says why.
+///
+///
+///
+/// What this replaces. reports a missing port
+/// on macOS and Linux as UnauthorizedAccessException: Access to the port '...' is denied.,
+/// which sends users looking for a permissions problem when the real cause is a wrong or stale
+/// port name — the common case, because USB serial device nodes are renumbered across replugs.
+/// This is the serial analog of the that
+/// substitutes for a misleading TaskCanceledException
+/// (daqifi-desktop#517): the same failure, named accurately.
+///
+///
+/// Why a typed reason. The platform exception cannot be classified after the fact. Measured
+/// on macOS with System.IO.Ports 10.0.10, a missing port and a port held by another process
+/// produce byte-identical exceptions — same type, same message, and an inner
+/// with the same text and the same (bogus, non-errno)
+/// . Matching on message text or on the inner exception is
+/// therefore not merely unportable, it does not work at all. is derived from
+/// evidence gathered around the failure instead — see
+/// .
+///
+///
+/// Base type. Derives from : failing to open an I/O device is an
+/// I/O error, and on Windows a non-existent port already surfaces as an
+/// -derived exception, so a caller that brackets a connect with
+/// catch (IOException) keeps working. The original platform exception is always preserved
+/// as .
+///
+///
+/// Migrating. A connect that previously threw
+/// now throws this instead, so catch (UnauthorizedAccessException) no longer matches.
+/// Replace it with catch (SerialPortConnectException ex) and switch on
+/// — that distinction is the thing the old exception could not express, since on macOS a missing
+/// port and a busy port threw the very same exception.
+///
+///
+/// Retries. This type does not change retry behavior — a failed open is still one failed
+/// attempt under . It does let a caller decide whether
+/// retrying is worthwhile: can clear on its own,
+/// while and
+/// will not.
+///
+///
+public class SerialPortConnectException : IOException
+{
+ ///
+ /// Gets the name of the port that could not be opened (for example COM3 or
+ /// /dev/cu.usbmodem1101).
+ ///
+ public string PortName { get; }
+
+ ///
+ /// Gets the classified reason the port could not be opened.
+ ///
+ public SerialPortConnectFailure Reason { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The port that could not be opened.
+ /// The classified reason for the failure.
+ /// The message that describes the failure.
+ ///
+ /// The platform exception that caused the failure, or null.
+ ///
+ /// Thrown when is null.
+ public SerialPortConnectException(
+ string portName,
+ SerialPortConnectFailure reason,
+ string message,
+ Exception? innerException = null)
+ : base(message, innerException)
+ {
+ PortName = portName ?? throw new ArgumentNullException(nameof(portName));
+ Reason = reason;
+ }
+
+ ///
+ /// Builds the translated exception for a failed ,
+ /// preserving as the inner exception.
+ ///
+ /// The port that could not be opened.
+ /// The platform exception the open threw.
+ ///
+ /// Whether the port is still visible to the system, or null if that could not be
+ /// observed. See .
+ ///
+ ///
+ /// Whether a permission failure has been positively ruled out, or null if unknown.
+ /// See .
+ ///
+ internal static SerialPortConnectException FromOpenFailure(
+ string portName,
+ Exception error,
+ bool? portPresent,
+ bool? permissionGateRuledOut)
+ {
+ var reason = Classify(error, portPresent, permissionGateRuledOut);
+ return new SerialPortConnectException(portName, reason, DescribeFailure(portName, reason), error);
+ }
+
+ ///
+ /// Classifies a failed serial open from evidence collected around it, rather than from the
+ /// platform exception's type or text.
+ ///
+ /// The platform exception the open threw.
+ ///
+ /// true if the port is still enumerated (or its device node still exists),
+ /// false if it is definitely absent, null if the probe could not answer. A
+ /// failure to observe is never treated as absence.
+ ///
+ ///
+ /// true when the platform cannot be denying access on permission grounds — a Unix
+ /// device node that grants read and write to every user, or a Windows COM port, which has no
+ /// equivalent per-user gate. false when a permission failure is possible, null
+ /// when it could not be determined.
+ ///
+ /// The classified reason.
+ ///
+ ///
+ /// The order matters. An explicit — what Windows reports
+ /// for a port that no longer exists — is conclusive on its own. Otherwise absence of the port
+ /// is the deciding signal, and it is the one that fixes the reported bug: it is portable, it
+ /// does not read any exception text, and it is the same probe the transport already trusts to
+ /// detect an unplug on an established connection.
+ ///
+ ///
+ /// Only once the port is known (or assumed) to exist does an access-denied exception have to be
+ /// split between "someone else has it" and "you may not have it", and that split is the one
+ /// piece the platform genuinely cannot answer on macOS. It is resolved from
+ /// , and when that is unavailable the result stays
+ /// — the status-quo wording — so an
+ /// unfamiliar platform degrades to today's behavior instead of asserting something false.
+ ///
+ ///
+ internal static SerialPortConnectFailure Classify(
+ Exception error,
+ bool? portPresent,
+ bool? permissionGateRuledOut)
+ {
+ ArgumentNullException.ThrowIfNull(error);
+
+ // Windows names the missing-port case outright. Checked before anything else, and through
+ // the inner exception too, because it needs no corroboration.
+ if (error is FileNotFoundException || error.InnerException is FileNotFoundException)
+ {
+ return SerialPortConnectFailure.NotFound;
+ }
+
+ // The port is definitely gone. This is the case the issue is about, and the only one where
+ // "access is denied" was actively misleading.
+ if (portPresent == false)
+ {
+ return SerialPortConnectFailure.NotFound;
+ }
+
+ if (error is UnauthorizedAccessException || error.InnerException is UnauthorizedAccessException)
+ {
+ return permissionGateRuledOut == true
+ ? SerialPortConnectFailure.InUse
+ : SerialPortConnectFailure.AccessDenied;
+ }
+
+ return SerialPortConnectFailure.Unknown;
+ }
+
+ ///
+ /// Produces the user-facing message for a classified failure.
+ ///
+ /// The port that could not be opened.
+ /// The classified reason.
+ /// A single-sentence description naming the port.
+ internal static string DescribeFailure(string portName, SerialPortConnectFailure reason) => reason switch
+ {
+ SerialPortConnectFailure.NotFound => $"Serial port '{portName}' was not found.",
+ SerialPortConnectFailure.InUse => $"Serial port '{portName}' is in use.",
+ // Deliberately the framework's own wording for the one case where it was always accurate,
+ // so anything already keying on that phrasing for a real permission problem still matches.
+ SerialPortConnectFailure.AccessDenied => $"Access to the port '{portName}' is denied.",
+ _ => $"Serial port '{portName}' could not be opened."
+ };
+}
diff --git a/src/Daqifi.Core/Communication/Transport/SerialPortConnectFailure.cs b/src/Daqifi.Core/Communication/Transport/SerialPortConnectFailure.cs
new file mode 100644
index 00000000..e5a323ec
--- /dev/null
+++ b/src/Daqifi.Core/Communication/Transport/SerialPortConnectFailure.cs
@@ -0,0 +1,42 @@
+namespace Daqifi.Core.Communication.Transport;
+
+///
+/// Why a serial port could not be opened, as reported by
+/// .
+///
+///
+/// This is the stable, typed alternative to inspecting the platform exception a failed
+/// produces. That exception carries no reliable
+/// classification of its own: on macOS a missing port and a busy port both surface as
+/// ("Access to the port '...' is denied.") wrapping an
+/// whose message and are the same in
+/// both cases, so neither the type, the text, nor the errno can tell them apart.
+///
+public enum SerialPortConnectFailure
+{
+ ///
+ /// The reason could not be determined. The platform exception is still available as
+ /// .
+ ///
+ Unknown = 0,
+
+ ///
+ /// The port does not exist. Usually a typo or a stale port name — USB serial device nodes are
+ /// renumbered across replugs, so a name captured earlier in a session may no longer resolve.
+ /// Retrying does not help; re-enumerate the available ports instead.
+ ///
+ NotFound,
+
+ ///
+ /// The port exists but another process already holds it open. Retrying can succeed once the
+ /// other process releases the port.
+ ///
+ InUse,
+
+ ///
+ /// The port exists but the current process is not permitted to open it — for example a Linux
+ /// device node owned by a group the user is not a member of (commonly dialout).
+ /// Retrying does not help until the permission is granted.
+ ///
+ AccessDenied
+}
diff --git a/src/Daqifi.Core/Communication/Transport/SerialStreamTransport.cs b/src/Daqifi.Core/Communication/Transport/SerialStreamTransport.cs
index 507104d9..78468b69 100644
--- a/src/Daqifi.Core/Communication/Transport/SerialStreamTransport.cs
+++ b/src/Daqifi.Core/Communication/Transport/SerialStreamTransport.cs
@@ -226,6 +226,10 @@ public string ConnectionInfo
/// Establishes the serial connection asynchronously.
///
/// A task representing the asynchronous connect operation.
+ ///
+ /// Thrown when the port cannot be opened, with
+ /// naming the cause.
+ ///
public async Task ConnectAsync()
{
await ConnectAsync(null);
@@ -236,6 +240,10 @@ public async Task ConnectAsync()
///
/// Configuration for retry behavior. If null, uses default single attempt.
/// A task representing the asynchronous connect operation.
+ ///
+ /// Thrown when the final attempt cannot open the port, with
+ /// naming the cause.
+ ///
public async Task ConnectAsync(ConnectionRetryOptions? retryOptions)
{
await ConnectAsync(retryOptions, CancellationToken.None);
@@ -249,12 +257,42 @@ public async Task ConnectAsync(CancellationToken cancellationToken)
///
///
+ ///
/// Cancellation is observed between attempts and while waiting out a backoff delay, and is
/// checked immediately before each . It cannot interrupt the
/// Open call itself — the framework offers no cancellable form of it — so on a port that
/// hangs open, cancellation takes effect when that call returns. In practice opening a serial
/// port either succeeds or fails quickly; the long wait worth cancelling is the retry loop.
+ ///
+ ///
+ /// A failed is reported as a
+ /// naming the port and why it could not be opened,
+ /// rather than the platform's own exception, which on macOS and Linux calls every failure —
+ /// including a port that simply does not exist — an access denial (#424). The original is kept
+ /// as the inner exception. The reason is decided from what can be observed at the moment of
+ /// the failure: whether the port is still present, and whether the platform could be applying
+ /// a permission gate at all. Retry behavior is unchanged; a translated failure is still one
+ /// failed attempt.
+ ///
+ ///
+ /// Behavioral change for callers. This method used to surface whatever
+ /// threw — on macOS and Linux always an
+ /// whatever the real cause, on Windows a
+ /// or an . Those
+ /// are now wrapped, so catch (UnauthorizedAccessException) around a connect no longer
+ /// matches and any branching on the platform exception's type has to move. Catch
+ /// and switch on
+ /// , which is the classification the platform
+ /// exception never reliably carried; catch (IOException) still matches for callers that
+ /// only need the broad case. The original exception remains available as
+ /// , so nothing is lost — only the type that arrives at
+ /// the catch site changes.
+ ///
///
+ ///
+ /// Thrown when the final attempt cannot open the port, with
+ /// naming the cause.
+ ///
public async Task ConnectAsync(ConnectionRetryOptions? retryOptions, CancellationToken cancellationToken)
{
ThrowIfDisposed();
@@ -280,7 +318,21 @@ await ConnectRetryExecutor.ExecuteAsync(
// honor a cancel that arrived while the previous attempt was backing off.
attemptToken.ThrowIfCancellationRequested();
- _serialPort.Open();
+ try
+ {
+ _serialPort.Open();
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
+ {
+ // Name the failure instead of forwarding the platform's guess at it (#424).
+ // The evidence is gathered here, immediately after the failed open, because it
+ // is only meaningful in that moment; the classification itself lives in
+ // SerialPortConnectException.Classify. Argument and state exceptions are left
+ // alone — a bad baud rate or an already-open port is a caller bug, not a port
+ // that could not be opened.
+ throw SerialPortConnectException.FromOpenFailure(
+ _portName, ex, TryObservePortPresence(), TryRuleOutPermissionGate());
+ }
// After a successful open, swap the connect timeouts for the (shorter)
// operational ones — both directions, not just reads (#399).
@@ -441,6 +493,60 @@ internal void StartDropDetection()
_livenessCheckInterval);
}
+ ///
+ /// Observes whether the port exists, for classifying a failed open. Returns null when
+ /// the probe could not answer — a failure to observe is not evidence of absence, and reporting
+ /// it as absence would turn an unrelated connect failure into a bogus "port was not found".
+ ///
+ private bool? TryObservePortPresence()
+ {
+ try
+ {
+ return IsPortPresent();
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Reports whether the platform can be ruled out as the source of an access-denied failure,
+ /// which is what separates a port held by another process from one the caller may not open.
+ ///
+ ///
+ /// true when no per-user permission gate can apply, false when one could,
+ /// null when it could not be determined.
+ ///
+ ///
+ /// A Unix device node that grants read and write to every user cannot produce a permission
+ /// failure for anybody, so a denial on such a node is exclusivity — the case macOS reports with
+ /// the same exception it uses for every other open failure, and /dev/cu.* nodes there are
+ /// crw-rw-rw-. A node that does not grant that (a Linux dialout-owned port at
+ /// crw-rw----, say) keeps the access-denied reading, so the genuine permission case is
+ /// still reported as one. Windows COM ports have no comparable gate: a port that exists and
+ /// refuses to open is one another process holds.
+ ///
+ private bool? TryRuleOutPermissionGate()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return true;
+ }
+
+ try
+ {
+ var mode = File.GetUnixFileMode(_portName);
+ return mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.OtherWrite);
+ }
+ catch (Exception)
+ {
+ // No stat (the port is gone, the name is not a path, or the platform will not say).
+ // Unknown, never a guess: the caller falls back to the access-denied wording.
+ return null;
+ }
+ }
+
///
/// Reports whether the configured port is still present on the system.
///