Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConcurrentConnectTests.cs
T
Joseph Doherty c940349fe8 fix(focas): serialize per-device connect so concurrent loops share one session; docs: signed PMC Byte in the #259 override recipe + Makino live notes
The hw2 read-only Makino gate (Z-34184, 31i-B) logged the fixed-tree bootstrap failing with
ObjectDisposedException: FocasWireClient. EnsureConnectedAsync was unserialized: the alarm poll
loop and the fixed-tree loop both reached it right after init, the second saw the first's
still-connecting client as not connected, disposed it and opened a second session, and the first
caller's catch then disposed the second caller's client (and recorded a spurious backoff failure).
A per-device ConnectGate now serializes the connect with a re-check after acquire, and the failure
path disposes only its own client. FocasConcurrentConnectTests goes red without the gate.

Docs: a Byte PMC tag is signed, so G12 at 100% feed override reads -101 on the Makino and the
recipe's 255 - value gave 356; recipe now masks. Adds the live statinfo/alarm-watch observations.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 21:16:31 -04:00

93 lines
4.7 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) : FakeFocasClient
{
private bool _disposed;
public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
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 factory = new FakeFocasClientFactory { Customise = () => new GatedConnectClient(release.Task) };
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);
var second = drv.ReadAsync(["B"], CancellationToken.None);
// Give both callers time to reach the connect path before the (single) connect is allowed to finish.
await Task.Delay(200);
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);
}
}