feat(probe): FOCAS Test-Connect attempts a cnc_allclibhndl3 handshake (degrade-guarded)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -7,14 +8,38 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="FocasDriverOptions"/>-shaped driver config.
|
||||
/// Opens a socket to the first device's FOCAS Ethernet address + port and closes
|
||||
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
|
||||
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any FOCAS/2 bytes —
|
||||
/// a richer FOCAS handshake (cnc_allclibhndl3) probe is a documented follow-up.
|
||||
/// Two-phase Test-Connect probe for the <see cref="FocasDriverOptions"/>-shaped driver config.
|
||||
/// Phase 1: bare TCP connect to the first device's FOCAS Ethernet address + port to quickly
|
||||
/// reject unreachable targets (preserves the original "Connect failed" / "timed out"
|
||||
/// messages). Phase 2: attempts the FANUC FWLIB handle handshake — allocates a CNC handle via
|
||||
/// <c>cnc_allclibhndl3(host, port, timeoutSec, out handle)</c> and immediately frees it with
|
||||
/// <c>cnc_freelibhndl</c>. A handle that allocates (<c>EW_OK</c>) confirms the remote endpoint
|
||||
/// is a real FOCAS CNC, not just a TCP listener.
|
||||
/// <para>
|
||||
/// The P/Invoke is issued directly (it does NOT route through
|
||||
/// <see cref="UnimplementedFocasClientFactory"/>, whose <c>EnsureUsable()</c> throws by
|
||||
/// design) so the handshake works on a real Windows+FWLIB host and degrades everywhere else.
|
||||
/// The synchronous native call can block, so it runs on a worker bounded by a linked CTS
|
||||
/// (<c>ct</c> + <c>CancelAfter(timeout)</c>) — the probe always returns within the timeout
|
||||
/// budget even if FWLIB hangs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Degrade guard (the crux).</b> On a host without the FWLIB native library — this dev box
|
||||
/// (macOS) and the Linux CI containers — the <c>cnc_allclibhndl3</c> P/Invoke fails to bind
|
||||
/// and throws <see cref="DllNotFoundException"/> (or a related load failure:
|
||||
/// <see cref="TypeInitializationException"/>, <see cref="NotSupportedException"/>,
|
||||
/// <see cref="BadImageFormatException"/>, <see cref="EntryPointNotFoundException"/>). Those are
|
||||
/// caught and the probe falls back to <c>Ok=true</c> with a "FWLIB absent — TCP reachability
|
||||
/// only" note, so the probe is never worse than the original TCP-only behaviour on FWLIB-less
|
||||
/// hosts. The happy path and the FWLIB-present CNC-error path are live-verify deferred (no CNC
|
||||
/// and no FWLIB on the rig).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class FocasDriverProbe : IDriverProbe
|
||||
{
|
||||
/// <summary>FANUC FWLIB return code for success (<c>EW_OK</c>).</summary>
|
||||
private const short EwOk = 0;
|
||||
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -37,12 +62,12 @@ public sealed class FocasDriverProbe : IDriverProbe
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts. Messages unchanged.
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(host, port, ct);
|
||||
sw.Stop();
|
||||
return new(true, null, sw.Elapsed);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
@@ -56,6 +81,75 @@ public sealed class FocasDriverProbe : IDriverProbe
|
||||
{
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
|
||||
// Phase 2: FOCAS handle handshake via cnc_allclibhndl3. The native call is synchronous and
|
||||
// can block, so run it on a worker bounded by a linked CTS = ct + CancelAfter(timeout).
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var budget = timeout > TimeSpan.Zero ? timeout : TimeSpan.FromSeconds(1);
|
||||
handshakeCts.CancelAfter(budget);
|
||||
|
||||
try
|
||||
{
|
||||
var (degraded, rc) = await Task.Run(
|
||||
() => TryAllocateAndFreeHandle(host, port, budget),
|
||||
handshakeCts.Token);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
if (degraded)
|
||||
{
|
||||
// FWLIB absent / cannot load — never worse than the original TCP-only probe.
|
||||
return new(
|
||||
true,
|
||||
$"Reachable at {host}:{port} (FOCAS handshake unavailable on this host — " +
|
||||
"FWLIB absent, TCP reachability only)",
|
||||
sw.Elapsed);
|
||||
}
|
||||
|
||||
if (rc == EwOk)
|
||||
return new(true, "FOCAS handle OK", sw.Elapsed);
|
||||
|
||||
// FWLIB present but the remote returned an error — reachable TCP but not a CNC.
|
||||
return new(false, $"Reachable at {host}:{port} but FOCAS handshake failed: focas_rc={rc}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// ct cancelled or the native handshake exceeded the timeout budget.
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts the FWLIB handle handshake against <paramref name="host"/>/<paramref name="port"/>.
|
||||
/// On success the handle is freed immediately. Returns <c>degraded=true</c> when the native
|
||||
/// library cannot be loaded (FWLIB absent — the dev/CI reality); otherwise
|
||||
/// <c>degraded=false</c> with the FWLIB return code (<c>EW_OK</c> = handle allocated).
|
||||
/// </summary>
|
||||
private static (bool degraded, short rc) TryAllocateAndFreeHandle(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
var timeoutSeconds = (int)Math.Ceiling(timeout.TotalSeconds);
|
||||
if (timeoutSeconds <= 0) timeoutSeconds = 1;
|
||||
|
||||
ushort handle = 0;
|
||||
try
|
||||
{
|
||||
var rc = NativeFwlib.cnc_allclibhndl3(host, (ushort)port, timeoutSeconds, out handle);
|
||||
return (degraded: false, rc);
|
||||
}
|
||||
catch (DllNotFoundException) { return (degraded: true, rc: default); }
|
||||
catch (TypeInitializationException) { return (degraded: true, rc: default); }
|
||||
catch (NotSupportedException) { return (degraded: true, rc: default); }
|
||||
catch (BadImageFormatException) { return (degraded: true, rc: default); }
|
||||
catch (EntryPointNotFoundException) { return (degraded: true, rc: default); }
|
||||
finally
|
||||
{
|
||||
// Best-effort free if a handle was actually allocated (incl. after a timeout race).
|
||||
if (handle != 0)
|
||||
{
|
||||
try { NativeFwlib.cnc_freelibhndl(handle); }
|
||||
catch { /* best-effort — never let teardown hide the probe result */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(FocasDriverOptions opts)
|
||||
@@ -69,4 +163,28 @@ public sealed class FocasDriverProbe : IDriverProbe
|
||||
|
||||
return (parsed.Host, parsed.Port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal P/Invoke surface for the two FANUC FWLIB entry points the probe needs:
|
||||
/// <c>cnc_allclibhndl3</c> to allocate a CNC handle against a host/port, and
|
||||
/// <c>cnc_freelibhndl</c> to release it. The native library (<c>fwlib32.dll</c> /
|
||||
/// <c>fwlib64.dll</c> on Windows, <c>libfwlib32.so</c> on Linux) is only present on a host
|
||||
/// with the FANUC FWLIB redistributable installed. On every other host the JIT fails to
|
||||
/// bind these entry points and throws <see cref="DllNotFoundException"/> — caught by the
|
||||
/// probe's degrade guard.
|
||||
/// </summary>
|
||||
private static class NativeFwlib
|
||||
{
|
||||
private const string Library = "fwlib32";
|
||||
|
||||
[DllImport(Library, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||
internal static extern short cnc_allclibhndl3(
|
||||
[MarshalAs(UnmanagedType.LPStr)] string ipaddr,
|
||||
ushort port,
|
||||
int timeout,
|
||||
out ushort handle);
|
||||
|
||||
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern short cnc_freelibhndl(ushort handle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FocasDriverProbe"/>. Covers the offline-determinable failure
|
||||
/// paths (invalid JSON, missing host/port, unreachable closed port) plus the degrade path:
|
||||
/// on a host with no FANUC FWLIB native library present (this dev box / CI Linux containers),
|
||||
/// the <c>cnc_allclibhndl3</c> P/Invoke throws <see cref="DllNotFoundException"/> at JIT bind
|
||||
/// time, so a TCP-reachable target must still report <c>Ok=true</c> with a "FWLIB absent"
|
||||
/// note — never worse than the pre-Phase-5 TCP-only probe.
|
||||
/// <para>
|
||||
/// <b>Live-verify DEFERRED.</b> The happy path (a real CNC answers <c>cnc_allclibhndl3</c>
|
||||
/// with <c>EW_OK</c> → "FOCAS handle OK") and the CNC-error path (FWLIB present but the
|
||||
/// remote returns e.g. <c>EW_SOCKET</c>/<c>EW_PROTOCOL</c> → "FOCAS handshake failed:
|
||||
/// focas_rc=...") cannot run on this rig: there is neither a FANUC CNC nor the FWLIB native
|
||||
/// library available. Those two paths are verified manually against a real Windows+FWLIB host.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasDriverProbeTests
|
||||
{
|
||||
private static readonly FocasDriverProbe Probe = new();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Invalid JSON
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Invalid JSON returns Ok=false with a message containing "invalid".</summary>
|
||||
[Fact]
|
||||
public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage()
|
||||
{
|
||||
var result = await Probe.ProbeAsync(
|
||||
"not-valid-json{{{",
|
||||
TimeSpan.FromSeconds(3),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ToLowerInvariant().ShouldContain("invalid");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Config with no device / no host
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>A config with an empty Devices list returns Ok=false with a "no host/port" message.</summary>
|
||||
[Fact]
|
||||
public async Task NoDevices_Returns_OkFalse_WithNoHostPortMessage()
|
||||
{
|
||||
var result = await Probe.ProbeAsync(
|
||||
"{\"devices\":[]}",
|
||||
TimeSpan.FromSeconds(3),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ShouldContain("no host/port");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A config whose first device carries a malformed host address (not focas://) returns
|
||||
/// Ok=false with a "no host/port" message because <see cref="FocasHostAddress.TryParse"/>
|
||||
/// returns null for the address.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
|
||||
{
|
||||
// "not-a-focas-url" is not a focas:// URL — TryParse returns null.
|
||||
var result = await Probe.ProbeAsync(
|
||||
"{\"devices\":[{\"hostAddress\":\"not-a-focas-url\"}]}",
|
||||
TimeSpan.FromSeconds(3),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ShouldContain("no host/port");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Unreachable target — TCP connect fails at preflight
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A closed port causes <c>ConnectAsync</c> to throw <see cref="SocketException"/>; the
|
||||
/// probe must return Ok=false with a "Connect failed" message. The port is reserved then
|
||||
/// released so the OS refuses the connection immediately (no black-hole delay needed).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage()
|
||||
{
|
||||
// Reserve an ephemeral port then release it so the port is definitely closed.
|
||||
var reserved = new TcpListener(IPAddress.Loopback, 0);
|
||||
reserved.Start();
|
||||
var port = ((IPEndPoint)reserved.LocalEndpoint).Port;
|
||||
reserved.Stop();
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
TestContext.Current.CancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
|
||||
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
|
||||
var result = await Probe.ProbeAsync(
|
||||
configJson,
|
||||
TimeSpan.FromSeconds(3),
|
||||
cts.Token);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ShouldContain("Connect failed");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Degrade path — TCP reachable, FWLIB absent (the key test)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Against an in-process <see cref="TcpListener"/> that accepts the connection, the TCP
|
||||
/// preflight succeeds. On this box the FANUC FWLIB native library is absent, so the
|
||||
/// <c>cnc_allclibhndl3</c> P/Invoke throws <see cref="DllNotFoundException"/> (or a
|
||||
/// related load failure). The probe MUST degrade gracefully — return <c>Ok=true</c> with
|
||||
/// a "FWLIB absent ... TCP reachability only" note — proving no regression versus the
|
||||
/// pre-Phase-5 TCP-only probe on FWLIB-less hosts.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TcpReachable_FwlibAbsent_Degrades_To_OkTrue_WithReachabilityNote()
|
||||
{
|
||||
// Accept-only listener: completes the TCP handshake but speaks no FOCAS bytes.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
// Keep accepting so the connect always completes; ignore the accepted socket.
|
||||
_ = AcceptLoopAsync(listener, TestContext.Current.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
TestContext.Current.CancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
|
||||
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
|
||||
var result = await Probe.ProbeAsync(
|
||||
configJson,
|
||||
TimeSpan.FromSeconds(3),
|
||||
cts.Token);
|
||||
|
||||
// No FWLIB here → degrade, never worse than TCP-only.
|
||||
result.Ok.ShouldBeTrue(
|
||||
$"Expected degrade to Ok=true on an FWLIB-less host but got: {result.Message}");
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ShouldContain("FWLIB absent");
|
||||
result.Message!.ShouldContain("TCP reachability only");
|
||||
result.Latency.ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var socket = await listener.AcceptSocketAsync(ct);
|
||||
// Drop the connection immediately — we only need the TCP handshake to complete.
|
||||
socket.Dispose();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Listener stopped / token cancelled — expected at test teardown.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user