diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs index e7aa2b01..865b6ab5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs @@ -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; /// -/// Cheap TCP-connect probe for the -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 -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 +/// cnc_allclibhndl3(host, port, timeoutSec, out handle) and immediately frees it with +/// cnc_freelibhndl. A handle that allocates (EW_OK) confirms the remote endpoint +/// is a real FOCAS CNC, not just a TCP listener. +/// +/// The P/Invoke is issued directly (it does NOT route through +/// , whose EnsureUsable() 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 +/// (ct + CancelAfter(timeout)) — the probe always returns within the timeout +/// budget even if FWLIB hangs. +/// +/// +/// Degrade guard (the crux). On a host without the FWLIB native library — this dev box +/// (macOS) and the Linux CI containers — the cnc_allclibhndl3 P/Invoke fails to bind +/// and throws (or a related load failure: +/// , , +/// , ). Those are +/// caught and the probe falls back to Ok=true 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). +/// /// public sealed class FocasDriverProbe : IDriverProbe { + /// FANUC FWLIB return code for success (EW_OK). + 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); + } + } + + /// + /// Attempts the FWLIB handle handshake against /. + /// On success the handle is freed immediately. Returns degraded=true when the native + /// library cannot be loaded (FWLIB absent — the dev/CI reality); otherwise + /// degraded=false with the FWLIB return code (EW_OK = handle allocated). + /// + 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); } + + /// + /// Minimal P/Invoke surface for the two FANUC FWLIB entry points the probe needs: + /// cnc_allclibhndl3 to allocate a CNC handle against a host/port, and + /// cnc_freelibhndl to release it. The native library (fwlib32.dll / + /// fwlib64.dll on Windows, libfwlib32.so 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 — caught by the + /// probe's degrade guard. + /// + 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); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs new file mode 100644 index 00000000..626925e7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Unit tests for . 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 cnc_allclibhndl3 P/Invoke throws at JIT bind +/// time, so a TCP-reachable target must still report Ok=true with a "FWLIB absent" +/// note — never worse than the pre-Phase-5 TCP-only probe. +/// +/// Live-verify DEFERRED. The happy path (a real CNC answers cnc_allclibhndl3 +/// with EW_OK → "FOCAS handle OK") and the CNC-error path (FWLIB present but the +/// remote returns e.g. EW_SOCKET/EW_PROTOCOL → "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. +/// +/// +[Trait("Category", "Unit")] +public sealed class FocasDriverProbeTests +{ + private static readonly FocasDriverProbe Probe = new(); + + // ------------------------------------------------------------------------- + // 1. Invalid JSON + // ------------------------------------------------------------------------- + + /// Invalid JSON returns Ok=false with a message containing "invalid". + [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 + // ------------------------------------------------------------------------- + + /// A config with an empty Devices list returns Ok=false with a "no host/port" message. + [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(); + } + + /// + /// A config whose first device carries a malformed host address (not focas://) returns + /// Ok=false with a "no host/port" message because + /// returns null for the address. + /// + [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 + // ------------------------------------------------------------------------- + + /// + /// A closed port causes ConnectAsync to throw ; 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). + /// + [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) + // ------------------------------------------------------------------------- + + /// + /// Against an in-process that accepts the connection, the TCP + /// preflight succeeds. On this box the FANUC FWLIB native library is absent, so the + /// cnc_allclibhndl3 P/Invoke throws (or a + /// related load failure). The probe MUST degrade gracefully — return Ok=true with + /// a "FWLIB absent ... TCP reachability only" note — proving no regression versus the + /// pre-Phase-5 TCP-only probe on FWLIB-less hosts. + /// + [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. + } + } +}