Merge #593/#594: Ab.Transport offline/simulator test gaps, libplctag BadParam per-tag path, fixture suites skip when absent

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 06:58:44 -04:00
15 changed files with 406 additions and 20 deletions
@@ -39,7 +39,7 @@ public sealed class EncapsulationClient : IAsyncDisposable
private static readonly TimeSpan UnregisterBudget = TimeSpan.FromMilliseconds(500);
private readonly TcpClient _tcp;
private readonly NetworkStream _stream;
private readonly Stream _stream;
// Never disposed: an abandoned exchange may release it after DisposeAsync returns, and
// SemaphoreSlim.Release on a disposed instance throws. It holds no unmanaged resource unless
@@ -56,12 +56,17 @@ public sealed class EncapsulationClient : IAsyncDisposable
/// <summary>False once the connection has faulted or been disposed.</summary>
public bool IsConnected => !_faulted && Volatile.Read(ref _disposed) == 0 && _tcp.Connected;
private EncapsulationClient(TcpClient tcp)
private EncapsulationClient(TcpClient tcp, Stream? stream = null)
{
_tcp = tcp;
_stream = tcp.GetStream();
_stream = stream ?? tcp.GetStream();
}
/// <summary>Test seam: wraps an already-connected <paramref name="tcp"/> but routes all frame I/O through
/// <paramref name="stream"/> (e.g. one whose write never completes), skipping RegisterSession. The TCP
/// client is still what <see cref="Fault"/> closes and <see cref="IsConnected"/> consults.</summary>
internal static EncapsulationClient ForStream(TcpClient tcp, Stream stream) => new(tcp, stream);
/// <summary>Open the TCP connection and register an encapsulation session. <paramref name="timeout"/>
/// bounds the TCP connect and the RegisterSession exchange separately.</summary>
public static async Task<EncapsulationClient> ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
@@ -37,6 +37,32 @@ internal static class LibplctagStatusTranslation
? ToAbStatus(s)
: AbStatus.BadConnection;
/// <summary>
/// True when <paramref name="ex"/> is libplctag refusing the tag's own attributes (<c>ErrorBadParam</c>:
/// the name or a create attribute does not parse), as opposed to a transport failure.
/// </summary>
/// <param name="ex">The exception.</param>
public static bool IsTagParameterRefusal(LibPlcTagException ex) =>
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorBadParam;
/// <summary>
/// Runs a tag's <b>initialisation</b>: <c>ErrorBadParam</c> there means libplctag rejected the tag's own
/// name before any I/O, so it is raised as <see cref="AbCipTagConfigurationException"/> (per-tag
/// <c>BadConfigurationError</c>, no backoff, no health signal — Gitea #570/#593) instead of falling through
/// to the generic <see cref="AbStatus.BadConnection"/> default that backs the whole device off. Only the
/// create phase: a BadParam from a read or write on a live handle is not a name problem.
/// </summary>
/// <param name="op">The libplctag initialisation.</param>
/// <returns>A task completing when the initialisation does.</returns>
public static async Task WrapInitialize(Func<Task> op)
{
try { await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) when (IsTagParameterRefusal(ex))
{
throw new AbCipTagConfigurationException($"libplctag rejected the tag's name or attributes: {ex.Message}", ex);
}
}
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <param name="op">The libplctag operation.</param>
/// <returns>A task completing when the operation does.</returns>
@@ -52,7 +52,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
RunReleasingOrphanAsync(() => LibplctagStatusTranslation.WrapInitialize(() => _tag.InitializeAsync(cancellationToken)));
/// <inheritdoc />
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>