4c4dfaadcc
The handle-recycle loop disposed the device client with no gate. It could null a client a concurrent EnsureConnectedAsync had just installed (orphaning a live two-socket session), or dispose one a poll was mid-read on (ObjectDisposedException -> BadCommunicationError + Degraded on every recycle interval). The recycle now takes the device's ConnectGate, detaches the client, and disposes it via SynchronizedFocasClient.DisposeWhenIdleAsync, which waits (bounded by the call timeout plus slack) for ops that already hold the client. The gate stays held through the drain, so no fresh session opens beside the old one. SynchronizedFocasClient no longer disposes its call gate. Releasing a disposed semaphore threw out of reads that had otherwise completed. Ops that reach a disposed client now get ObjectDisposedException and are never run on the dead socket. Refs #740 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
142 lines
6.3 KiB
C#
142 lines
6.3 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);
|
|
|
|
// 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);
|
|
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);
|
|
}
|
|
}
|
|
}
|