1d7a4b7a53
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
142 lines
6.4 KiB
C#
142 lines
6.4 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasHandleRecycleTests
|
|
{
|
|
/// <summary>Verifies that the recycle loop disposes clients on interval and reopens fresh ones.</summary>
|
|
[Fact]
|
|
public async Task Recycle_loop_disposes_client_on_interval_reads_reopen_fresh_one()
|
|
{
|
|
var factory = new FakeFocasClientFactory();
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
HandleRecycle = new FocasHandleRecycleOptions
|
|
{
|
|
Enabled = true,
|
|
Interval = TimeSpan.FromMilliseconds(80),
|
|
},
|
|
}, "drv-1", factory);
|
|
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// First read forces the initial connect.
|
|
await drv.ReadAsync(["R"], CancellationToken.None);
|
|
var initialClients = factory.Clients.Count;
|
|
initialClients.ShouldBe(1);
|
|
|
|
// Wait for a recycle tick, then read again — a new client must have been created.
|
|
await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
|
|
await drv.ReadAsync(["R"], CancellationToken.None);
|
|
|
|
factory.Clients.Count.ShouldBeGreaterThan(initialClients);
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>Verifies that the recycle loop stays off when not enabled.</summary>
|
|
[Fact]
|
|
public async Task Recycle_loop_stays_off_when_not_enabled()
|
|
{
|
|
var factory = new FakeFocasClientFactory();
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "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(["R"], CancellationToken.None);
|
|
await Task.Delay(150, TestContext.Current.CancellationToken);
|
|
|
|
// With recycle off the same client stays live — no Dispose during the window.
|
|
factory.Clients.Count.ShouldBe(1);
|
|
factory.Clients[0].DisposeCount.ShouldBe(0);
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>A client whose reads take a while — long enough for a recycle tick to land mid-read.</summary>
|
|
private sealed class SlowReadClient : FakeFocasClient
|
|
{
|
|
public override async Task<(object? value, uint status)> ReadAsync(
|
|
FocasAddress address, FocasDataType type, CancellationToken ct)
|
|
{
|
|
await Task.Delay(200, ct).ConfigureAwait(false);
|
|
if (!IsConnected) throw new ObjectDisposedException(nameof(SlowReadClient), "disposed mid-read");
|
|
return ((sbyte)5, FocasStatusMapper.Good);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #740 / R07:L6 — the recycle loop disposed the device client with no gate, so a recycle tick landing
|
|
/// mid-read killed that read (BadCommunicationError + Degraded, every interval). The recycle now takes the
|
|
/// device's connect gate and lets the in-flight read finish before disposing the client it detached.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Recycle_tick_landing_mid_read_lets_the_read_finish()
|
|
{
|
|
var factory = new FakeFocasClientFactory { Customise = () => new SlowReadClient() };
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
HandleRecycle = new FocasHandleRecycleOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Each 200 ms read spans at least three recycle ticks.
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var snap = (await drv.ReadAsync(["R"], CancellationToken.None))[0];
|
|
snap.StatusCode.ShouldBe(FocasStatusMapper.Good, $"read {i} was killed by a recycle tick");
|
|
snap.Value.ShouldBe((sbyte)5);
|
|
}
|
|
drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Healthy);
|
|
|
|
// Positive control: the recycle really ran — the first client was disposed and a fresh one opened.
|
|
await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
|
|
factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0);
|
|
factory.Clients.Count.ShouldBeGreaterThan(1);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
factory.Clients.ShouldAllBe(c => !c.IsConnected, "no client opened around a recycle may be orphaned");
|
|
}
|
|
|
|
/// <summary>DisposeWhenIdleAsync drains the in-flight op, then disposes; a later op is refused, not run on a dead socket.</summary>
|
|
[Fact]
|
|
public async Task DisposeWhenIdle_drains_the_in_flight_op_then_refuses_new_ones()
|
|
{
|
|
var inner = new SlowReadClient();
|
|
await inner.ConnectAsync(new FocasHostAddress("10.0.0.5", 8193), TimeSpan.FromSeconds(1), CancellationToken.None);
|
|
var sync = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5));
|
|
var addr = FocasAddress.TryParse("R100")!;
|
|
|
|
var read = sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None);
|
|
await Task.Delay(20, TestContext.Current.CancellationToken);
|
|
sync.PendingOperations.ShouldBe(1);
|
|
var dispose = sync.DisposeWhenIdleAsync(TimeSpan.FromSeconds(5));
|
|
|
|
(await read).status.ShouldBe(FocasStatusMapper.Good);
|
|
await dispose;
|
|
inner.DisposeCount.ShouldBe(1);
|
|
await Should.ThrowAsync<ObjectDisposedException>(() => sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None));
|
|
}
|
|
|
|
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (pred()) return;
|
|
await Task.Delay(20);
|
|
}
|
|
}
|
|
}
|