using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Two callers reaching EnsureConnectedAsync while the device has no live session (the alarm poll loop /// and the fixed-tree loop both start right after init) must open ONE session between them. Found live on the /// Makino Z-34184 (hw2 gate, 2026-10-08): the fixed-tree bootstrap failed with /// ObjectDisposedException: FocasWireClient, because the second caller saw the first caller's /// still-connecting client as "not connected", disposed it, and opened a second one — and the first caller's /// failure path then disposed the SECOND caller's client. Two TCP sessions to a production CNC with a small /// handle pool, a disposed client handed back to a caller, and a spurious connect-backoff failure. /// [Trait("Category", "Unit")] public sealed class FocasConcurrentConnectTests { /// A fake whose connect parks until released, and which — like the real wire client — refuses to /// finish connecting once disposed. private sealed class GatedConnectClient(Task release, TaskCompletionSource? entered = null) : FakeFocasClient { private bool _disposed; public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) { entered?.TrySetResult(); await release.WaitAsync(ct); if (_disposed) throw new ObjectDisposedException("FocasWireClient"); await base.ConnectAsync(address, timeout, ct); } public override void Dispose() { _disposed = true; base.Dispose(); } } /// Concurrent first reads share one connect; nobody's client is disposed under them. [Fact] public async Task Concurrent_first_reads_open_one_session_and_both_succeed() { var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new FakeFocasClientFactory { Customise = () => new GatedConnectClient(release.Task, entered) }; await using var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], RawTags = FocasRawTags.Entries([ new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte)]), Probe = new FocasProbeOptions { Enabled = false }, Timeout = TimeSpan.FromSeconds(5), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var first = drv.ReadAsync(["A"], CancellationToken.None); // Deterministic: the first caller is now parked INSIDE its connect (client created, not yet connected) — // the exact window the race needs. The second caller then runs synchronously up to its first real await: // pre-fix that is "dispose the first client, Create() a second"; with the gate it parks on ConnectGate. await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); var second = drv.ReadAsync(["B"], CancellationToken.None); release.SetResult(); var results = await Task.WhenAll(first, second); factory.Clients.Count.ShouldBe(1, "two concurrent callers must share one FOCAS session, not race two"); factory.Clients[0].DisposeCount.ShouldBe(0, "no caller may dispose a client another caller is using"); results[0].Single().StatusCode.ShouldBe(FocasStatusMapper.Good); results[1].Single().StatusCode.ShouldBe(FocasStatusMapper.Good); } /// A failed connect does not poison a later one — the gate is released on failure. [Fact] public async Task A_failed_connect_releases_the_gate_for_the_next_caller() { var factory = new FakeFocasClientFactory(); var attempt = 0; factory.Customise = () => new FakeFocasClient { ThrowOnConnect = Interlocked.Increment(ref attempt) == 1 }; await using var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], RawTags = FocasRawTags.Entries([new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)]), Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); (await drv.ReadAsync(["A"], CancellationToken.None)).Single().StatusCode .ShouldNotBe(FocasStatusMapper.Good); // Past the 1 s initial connect backoff window, the next caller must be able to connect. await Task.Delay(TimeSpan.FromMilliseconds(1100)); var read = drv.ReadAsync(["A"], CancellationToken.None); (await read.WaitAsync(TimeSpan.FromSeconds(5))).Single().StatusCode.ShouldBe(FocasStatusMapper.Good); } /// /// A caller cancelled during its connect must not open the backoff window: the cancellation is the caller's, /// not the CNC's, and a recorded failure would throttle every caller queued behind it on the gate. /// [Fact] public async Task A_caller_cancelled_during_connect_does_not_throttle_the_next_caller() { var never = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new FakeFocasClientFactory(); var created = 0; // First client parks in connect until cancelled; every later client connects immediately. factory.Customise = () => Interlocked.Increment(ref created) == 1 ? new GatedConnectClient(never.Task, entered) : new FakeFocasClient(); await using var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], RawTags = FocasRawTags.Entries([new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)]), Probe = new FocasProbeOptions { Enabled = false }, Timeout = TimeSpan.FromSeconds(5), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); using var cts = new CancellationTokenSource(); var cancelled = drv.ReadAsync(["A"], cts.Token); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); cts.Cancel(); try { await cancelled; } catch (OperationCanceledException) { /* either surfaced or mapped — both fine */ } // Immediately — well inside the 1 s initial backoff window a recorded failure would have opened. var next = await drv.ReadAsync(["A"], CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); next.Single().StatusCode.ShouldBe(FocasStatusMapper.Good, "a caller's own cancellation must not throttle the next connect"); factory.Clients.Count.ShouldBe(2); } }