54a00fd760
Review follow-up on c940349f: a cancelled caller recorded a connect failure, throttling every caller queued
on ConnectGate for ~1 s. The 200 ms settle delay in the concurrent-connect test is replaced by a
TaskCompletionSource the fake connect signals on entry. Both tests verified red with their fix reverted.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
133 lines
7.2 KiB
C#
133 lines
7.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Two callers reaching <c>EnsureConnectedAsync</c> 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
|
|
/// <c>ObjectDisposedException: FocasWireClient</c>, 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.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasConcurrentConnectTests
|
|
{
|
|
/// <summary>A fake whose connect parks until released, and which — like the real wire client — refuses to
|
|
/// finish connecting once disposed.</summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>Concurrent first reads share one connect; nobody's client is disposed under them.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>A failed connect does not poison a later one — the gate is released on failure.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|