Merge #863 TwinCAT Test Connect: drop the AmsNetId-as-IP TCP pre-check; probe via the local AMS router ADS handshake

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 12:14:58 -05:00
4 changed files with 298 additions and 334 deletions
@@ -1,5 +1,4 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using TwinCAT.Ads;
@@ -8,59 +7,40 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Two-phase, degrade-guarded Test-Connect probe for the <see cref="TwinCATDriverOptions"/>-shaped
/// driver config. Phase 1: a bare TCP connect to the first device's AMS router host (first four
/// octets of the AMS Net ID) on the AMS port — fast rejection of unreachable targets. Phase 2:
/// a real ADS handshake — <c>AdsClient.Connect(netId, port)</c> + <c>ReadStateAsync</c> — to
/// confirm the endpoint speaks ADS and report the controller's run-state, not just that a TCP
/// socket opened.
/// Test-Connect probe for the <see cref="TwinCATDriverOptions"/>-shaped driver config. It takes
/// exactly the path the driver takes (<see cref="AdsTwinCATClient.ConnectAsync"/>):
/// <c>AdsClient.Connect(netId, port)</c> through the <b>local AMS router</b> (TwinCAT XAR, or
/// <c>Beckhoff.TwinCAT.Ads.TcpRouter</c> hosted in-process), followed by one read-only
/// <c>ReadStateAsync</c>, all inside the caller's time budget.
/// <para>
/// <b>Outcome classification</b> — three cases:
/// <list type="number">
/// <item>
/// <b>ADS connected + ReadState OK</b> → <c>Ok=true</c>, message <c>"ADS state: {AdsState}"</c>
/// (e.g. "Run" / "Config" / "Stop"), with latency.
/// </item>
/// <item>
/// <b>Route/auth rejection from a reachable router</b> — an <see cref="AdsErrorException"/>
/// (or a non-success <c>ReadStateAsync</c> result) whose <see cref="AdsErrorCode"/> means the
/// router answered but won't let us in (e.g. <see cref="AdsErrorCode.TargetPortNotFound"/>,
/// <see cref="AdsErrorCode.TargetMachineNotFound"/>, <see cref="AdsErrorCode.PortNotConnected"/>,
/// <see cref="AdsErrorCode.PortDisabled"/>) → <c>Ok=false</c>, message
/// <c>"Reachable at {host}:{port} but ADS handshake failed: {code} — check the target's ADS
/// route table authorizes this host"</c>. This is a TRUE red: the driver itself also needs
/// the route, so a green tick here would be a false positive.
/// </item>
/// <item>
/// <b>Handshake could not be ATTEMPTED on this host</b> — the managed AMS router cannot run
/// headless (Beckhoff's <c>AdsClient.Connect</c> throws a server exception
/// "Check for a running TwinCAT router instance!"), or a <see cref="PlatformNotSupportedException"/>
/// / <see cref="TypeInitializationException"/> / <see cref="DllNotFoundException"/> /
/// <see cref="NotSupportedException"/> surfaces, or <c>ReadStateAsync</c> reports a client-side
/// port-not-open status → <b>DEGRADE</b>: <c>Ok=true</c>, message
/// <c>"Reachable at {host}:{port} (ADS handshake unavailable on this host — TCP reachability
/// only)"</c>, with latency. The probe NEVER produces a result worse than the old TCP-only
/// probe.
/// </item>
/// <b>No TCP pre-check (#863).</b> The driver has no IP field: an AMS Net ID is a Beckhoff
/// identifier that the router maps to a host through its route table, and the AMS port
/// (801/851/…) is not a TCP port. The probe therefore never opens a socket of its own, and never
/// dials anything derived from the AMS Net ID. The former first-phase check dialled the first four
/// Net ID octets as an IPv4 address on the AMS port — a host unrelated to the target (often a
/// public internet address) on a port ADS does not use — so Test Connect timed out against every
/// real runtime.
/// </para>
/// <para>
/// <b>Outcome classification</b> — <c>Ok=true</c> only when the target answered:
/// <list type="bullet">
/// <item><b>ReadState OK</b> → <c>Ok=true</c>, <c>"ADS state: {AdsState}"</c>, with latency.</item>
/// <item><b>No local AMS router</b> — <c>AdsClient.Connect</c> cannot reach a router on this
/// host (Beckhoff's <c>AdsServerException</c> "Check for a running TwinCAT router
/// instance!"), the client port never opened, or the ADS client cannot load here →
/// <c>Ok=false</c>, "No local AMS router on this host …". The target was not contacted.</item>
/// <item><b>No route / target unreachable</b> — the router answered but has no route to the Net
/// ID, the target refused the route or port, or the router's own connection to the target
/// failed → <c>Ok=false</c>, "No route to … or target unreachable: {code} …".</item>
/// <item><b>Timeout</b> — a sync/device timeout code, or the caller's budget expired →
/// <c>Ok=false</c>, "Probe timed out after {n}s …".</item>
/// <item>Any other ADS error → <c>Ok=false</c>, "ADS handshake with … failed: {code}".</item>
/// </list>
/// </para>
/// </summary>
/// <remarks>
/// The line between case 2 (device-rejected → RED) and case 3 (can't-attempt → DEGRADE) is the
/// crux. Classification rests on the exception's identity: only an <see cref="AdsErrorException"/>
/// (or a result <see cref="AdsErrorCode"/>) carrying a route/target-port code is a RED — that is the
/// ADS router answering and refusing the route. Beckhoff's <c>TwinCAT.Ads.Server.AdsServerException</c>
/// ("running TwinCAT router instance!") derives from plain <see cref="Exception"/>, NOT from
/// <see cref="AdsErrorException"/>, so it is correctly classified as "can't attempt → DEGRADE".
/// When genuinely ambiguous, the probe DEGRADES (Ok=true, TCP-only note) rather than emit a false RED.
/// <para>
/// AMS Net ID format is six dot-separated octets (e.g. <c>192.168.1.10.1.1</c>); the first four
/// are typically the host IPv4 address by Beckhoff convention. The AMS router resolves the real IP
/// route server-side; the probe uses the first-four-octet heuristic for the TCP preflight target,
/// which is reliable for the overwhelming majority of production deployments.
/// </para>
/// The probe is read-only — <c>ReadStateAsync</c> never mutates PLC state — and always disposes the
/// <see cref="AdsClient"/>.
/// The probe is read-only — <c>ReadStateAsync</c> never mutates PLC state, and the probe sends no
/// AddRoute — and always disposes the <see cref="AdsClient"/>.
/// </remarks>
public sealed class TwinCATDriverProbe : IDriverProbe
{
@@ -71,22 +51,57 @@ public sealed class TwinCATDriverProbe : IDriverProbe
Converters = { new JsonStringEnumConverter() },
};
/// <summary>
/// AMS error codes that mean "the router answered but refused the route / target port" — a
/// genuine RED. The driver itself would also be denied, so a green tick would be a false
/// positive. Everything NOT in this set (client-side port/connection errors, sync timeouts,
/// router-not-initialised, etc.) is treated as "couldn't attempt the handshake" → DEGRADE.
/// </summary>
private static readonly HashSet<AdsErrorCode> _routeRejectCodes =
/// <summary>Codes meaning the router on this host is absent, inactive or never gave us a port.</summary>
private static readonly HashSet<AdsErrorCode> _noRouterCodes =
[
AdsErrorCode.ClientPortNotOpen,
AdsErrorCode.ClientNoAmsAddr,
AdsErrorCode.RouterNotInitialized,
AdsErrorCode.RouterNotActive,
];
/// <summary>
/// Codes meaning the router answered but cannot get us to the target: no route, the route or
/// port was refused, or the router's connection to the target host failed.
/// </summary>
private static readonly HashSet<AdsErrorCode> _noRouteCodes =
[
AdsErrorCode.TargetPortNotFound,
AdsErrorCode.TargetMachineNotFound,
AdsErrorCode.TargetPortNotFound,
AdsErrorCode.PortNotConnected,
AdsErrorCode.PortDisabled,
AdsErrorCode.AccessDenied,
AdsErrorCode.DeviceAccessDenied,
AdsErrorCode.HostUnreachable,
AdsErrorCode.WSA_NetworkUnreachable,
AdsErrorCode.WSA_HostDown,
AdsErrorCode.WSA_HostUnreachable,
AdsErrorCode.WSA_HostNotFound,
];
/// <summary>Codes meaning a route exists but the target did not answer in time.</summary>
private static readonly HashSet<AdsErrorCode> _timeoutCodes =
[
AdsErrorCode.ClientSyncTimeOut,
AdsErrorCode.SyncTimeOut,
AdsErrorCode.DeviceTimeOut,
AdsErrorCode.WSA_TimedOut,
AdsErrorCode.RouterFragmentTimeout,
];
/// <summary>
/// The ADS handshake: connect through the local AMS router and read the target's state.
/// Throws <see cref="AdsErrorException"/> for a non-success ReadState result. Swappable for tests.
/// </summary>
private readonly Func<TwinCATAmsAddress, TimeSpan, CancellationToken, Task<AdsState>> _readState;
/// <summary>Initializes the probe with the real ADS handshake through the local AMS router.</summary>
public TwinCATDriverProbe() : this(ReadStateViaLocalRouterAsync) { }
/// <summary>Test seam — substitutes the ADS handshake.</summary>
internal TwinCATDriverProbe(Func<TwinCATAmsAddress, TimeSpan, CancellationToken, Task<AdsState>> readState)
=> _readState = readState;
/// <inheritdoc />
public string DriverType => "TwinCAT";
@@ -98,110 +113,90 @@ public sealed class TwinCATDriverProbe : IDriverProbe
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port, parsed) = ExtractTarget(opts);
if (parsed is null || string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var target = TwinCATAmsAddress.TryParse(opts.Devices.FirstOrDefault()?.HostAddress);
if (target is null)
return new(false, "Config has no host/port to probe (expected a device hostAddress ads://{netId}:{port}).", null);
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts. Messages here are
// UNCHANGED from the original TCP-only probe.
var label = $"{target.NetId}:{target.Port}";
var timedOut = $"Probe timed out after {timeout.TotalSeconds:F0}s waiting for ADS state from {label}";
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
// Phase 2: real ADS handshake. Connect + ReadStateAsync. The crux is the three-way
// classification of how this can fail — see the class-doc and ClassifyHandshakeFailure.
var degradeNote = $"Reachable at {host}:{port} (ADS handshake unavailable on this host — TCP reachability only)";
try
{
using var client = new AdsClient();
// Bound the ADS round-trip by the caller's timeout (clamped >=1s, mirrors AdsTwinCATClient).
client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds);
// Connect can throw a server exception ("running TwinCAT router instance!") on a headless
// host with no AMS router — that is a can't-attempt DEGRADE, classified below.
client.Connect(AmsNetId.Parse(parsed.NetId), parsed.Port);
var state = await client.ReadStateAsync(ct).ConfigureAwait(false);
// Task.Run + WaitAsync keep the whole handshake — including the synchronous
// AdsClient.Connect — inside the caller's budget.
var state = await Task.Run(() => _readState(target, timeout, ct), ct)
.WaitAsync(timeout, ct).ConfigureAwait(false);
sw.Stop();
if (state.Succeeded)
return new(true, $"ADS state: {state.State.AdsState}", sw.Elapsed);
// Non-throwing failure carried in the result's error code.
return state.ErrorCode == AdsErrorCode.ClientPortNotOpen
? new(true, degradeNote, sw.Elapsed) // client never opened — DEGRADE
: ClassifyHandshakeFailure(state.ErrorCode, host, port, sw, degradeNote);
return new(true, $"ADS state: {state}", sw.Elapsed);
}
catch (TimeoutException)
{
return new(false, $"{timedOut} — the target did not answer.", null);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Caller timeout — keep the original timed-out message.
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
return new(false, $"{timedOut}.", null);
}
catch (AdsErrorException ex)
{
// The router answered with an ADS-level error. Route/auth rejection → RED; anything
// else (sync timeout, client port issues, …) → DEGRADE.
return ClassifyHandshakeFailure(ex.ErrorCode, host, port, sw, degradeNote);
return Classify(ex.ErrorCode, label, timedOut);
}
catch (Exception)
catch (Exception ex) when (IsNoRouter(ex))
{
// Everything else — TwinCAT.Ads.Server.AdsServerException ("running TwinCAT router
// instance!"), PlatformNotSupportedException, TypeInitializationException,
// DllNotFoundException, NotSupportedException, etc. — means the handshake could not be
// ATTEMPTED on this host. DEGRADE: never worse than the TCP-only probe.
sw.Stop();
return new(true, degradeNote, sw.Elapsed);
return new(false, NoRouterMessage(ex.Message), null);
}
catch (Exception ex)
{
return new(false, $"ADS handshake with {label} failed: {ex.Message}", null);
}
}
/// <summary>Default handshake — the same calls <see cref="AdsTwinCATClient.ConnectAsync"/> makes.</summary>
private static async Task<AdsState> ReadStateViaLocalRouterAsync(
TwinCATAmsAddress target, TimeSpan timeout, CancellationToken ct)
{
using var client = new AdsClient();
client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds);
client.Connect(AmsNetId.Parse(target.NetId), target.Port);
var state = await client.ReadStateAsync(ct).ConfigureAwait(false);
if (!state.Succeeded)
throw new AdsErrorException($"ReadState failed: {state.ErrorCode}", state.ErrorCode);
return state.State.AdsState;
}
/// <summary>Maps an ADS error code to one of the probe's failure classes.</summary>
internal static DriverProbeResult Classify(AdsErrorCode code, string label, string timedOut)
{
if (_noRouterCodes.Contains(code))
return new(false, NoRouterMessage(code.ToString()), null);
if (_noRouteCodes.Contains(code))
return new(false,
$"No route to {label} or target unreachable: {code} — check the local AMS router has a route to this AMS Net ID and the target's route table authorizes this host.",
null);
if (_timeoutCodes.Contains(code))
return new(false, $"{timedOut} ({code}) — the router has a route but the target did not answer.", null);
return new(false, $"ADS handshake with {label} failed: {code}", null);
}
private static string NoRouterMessage(string detail) =>
$"No local AMS router on this host — start TwinCAT XAR or host the Beckhoff TcpRouter; the target was not contacted ({detail}).";
/// <summary>
/// Classifies an ADS-level failure (from an <see cref="AdsErrorException"/> or a non-success
/// <c>ReadStateAsync</c> result). A route/target-port/access code means the router answered
/// but refused the route → RED. Any other code is treated as "couldn't attempt" → DEGRADE,
/// so the probe never under-reports a host with no usable ADS runtime.
/// True when the ADS client could not reach a router on this host, or cannot load here at all.
/// Beckhoff's <c>AdsServerException</c> / <c>AmsServerException</c> ("Check for a running TwinCAT
/// router instance!") derive from plain <see cref="Exception"/>, so they are matched by type name.
/// </summary>
private static DriverProbeResult ClassifyHandshakeFailure(
AdsErrorCode code, string host, int port, Stopwatch sw, string degradeNote)
private static bool IsNoRouter(Exception ex)
{
if (_routeRejectCodes.Contains(code))
return new(false,
$"Reachable at {host}:{port} but ADS handshake failed: {code} — check the target's ADS route table authorizes this host",
null);
sw.Stop();
return new(true, degradeNote, sw.Elapsed);
}
private static (string host, int port, TwinCATAmsAddress? parsed) ExtractTarget(TwinCATDriverOptions opts)
{
// Parse the first device's ads:// address. AMS Net ID is six-octet; by Beckhoff
// convention the first four octets are the host IPv4. Extract those as the TCP target.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (string.Empty, 0, null);
var parsed = TwinCATAmsAddress.TryParse(firstDevice.HostAddress);
if (parsed is null) return (string.Empty, 0, null);
// NetId = "a.b.c.d.e.f" — take the first 4 octets as the host IP.
var parts = parsed.NetId.Split('.');
if (parts.Length < 4) return (string.Empty, 0, null);
var hostIp = string.Join('.', parts[0], parts[1], parts[2], parts[3]);
return (hostIp, parsed.Port, parsed);
for (var e = ex; e is not null; e = e.InnerException)
{
if (e is PlatformNotSupportedException or TypeInitializationException or DllNotFoundException)
return true;
var name = e.GetType().FullName;
if (name is "TwinCAT.Ads.Server.AdsServerException" or "TwinCAT.Ams.AmsServerException")
return true;
}
return false;
}
}