235b8b8e6d
Equipment tags were stuck at Bad_WaitingForInitialData on the deployed driver: the equipment poll, fixed-tree loop, probe and recycle shared one FOCAS/2 socket with no serialization, and the steady-state read had no timeout — concurrent reads collided and a stalled read hung forever, never overwriting the node's initial-data seed.
- SynchronizedFocasClient: per-device SemaphoreSlim gate + per-call timeout around every wire op (Connect/Probe gated, not double-bounded); wired in EnsureConnectedAsync. ReadAsync/WriteAsync map a per-call timeout to BadCommunicationError instead of rethrowing.
- FlexibleStringConverter on FOCAS config Series: the AdminUI persists the enum as a number ("series":6); accept number-or-string instead of throwing -> stub.
- FocasHostAddress.TryParse tolerates a scheme-less {ip}[:{port}] (AdminUI hostAddress form); canonical focas:// unchanged, malformed schemes still rejected.
247 FOCAS tests green; each fix has a regression test. Live-validated on wonder-app-vd03 (tags read Good).
208 lines
8.8 KiB
C#
208 lines
8.8 KiB
C#
using System.Diagnostics;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
/// <summary>
|
|
/// Coverage for the FOCAS data-plane fix (2026-06-25 equipment-tag investigation): all wire I/O
|
|
/// on a device's single FOCAS/2 socket must be serialized (request→response cannot interleave)
|
|
/// and every steady-state read/write must be time-bounded so a stalled CNC read surfaces as a
|
|
/// recoverable error instead of hanging forever at BadWaitingForInitialData. See
|
|
/// <c>docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md</c>.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasIoSerializationTests
|
|
{
|
|
private static readonly FocasAddress Macro500 = new(FocasAreaKind.Macro, null, 500, null);
|
|
|
|
// ---- SynchronizedFocasClient: serialization ----
|
|
|
|
[Fact]
|
|
public async Task Concurrent_reads_are_serialized_onto_the_inner_client()
|
|
{
|
|
var inner = new RecordingClient { ReadDelay = TimeSpan.FromMilliseconds(20) };
|
|
await using var _ = NoopDispose(inner);
|
|
var client = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5));
|
|
|
|
var reads = Enumerable.Range(0, 8)
|
|
.Select(_ => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None));
|
|
await Task.WhenAll(reads);
|
|
|
|
inner.MaxConcurrency.ShouldBe(1); // never more than one wire op on the socket at a time
|
|
inner.ReadCount.ShouldBe(8);
|
|
}
|
|
|
|
// ---- SynchronizedFocasClient: per-call timeout ----
|
|
|
|
[Fact]
|
|
public async Task A_hung_read_is_bounded_by_the_call_timeout()
|
|
{
|
|
var inner = new RecordingClient { BlockReadUntilCancelled = true };
|
|
var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(100));
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
await Should.ThrowAsync<OperationCanceledException>(
|
|
() => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None));
|
|
sw.Stop();
|
|
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // bounded, not the indefinite OS TCP wait
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_hung_read_does_not_hold_the_socket_for_the_next_call()
|
|
{
|
|
// The gate must be released when a bounded call times out, otherwise one stall would wedge
|
|
// every subsequent op on the device. Read #1 hangs (times out); read #2 must still proceed.
|
|
var inner = new TimeoutThenServeClient { FirstCallBlocks = true };
|
|
var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(100));
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(
|
|
() => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None));
|
|
|
|
var (value, status) = await client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None);
|
|
status.ShouldBe(FocasStatusMapper.Good);
|
|
value.ShouldBe(42);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Probe_is_not_bounded_by_the_call_timeout()
|
|
{
|
|
// Connect/Probe carry their own budgets; the decorator must not shrink them to its read budget.
|
|
var inner = new RecordingClient { ProbeDelay = TimeSpan.FromMilliseconds(200) };
|
|
var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(50));
|
|
|
|
var result = await client.ProbeAsync(CancellationToken.None);
|
|
|
|
result.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Zero_call_timeout_disables_the_per_call_bound()
|
|
{
|
|
var inner = new RecordingClient { ReadDelay = TimeSpan.FromMilliseconds(120) };
|
|
var client = new SynchronizedFocasClient(inner, TimeSpan.Zero);
|
|
|
|
var (value, status) = await client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None);
|
|
|
|
status.ShouldBe(FocasStatusMapper.Good);
|
|
value.ShouldBe(42);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_disposes_the_inner_client()
|
|
{
|
|
var inner = new RecordingClient();
|
|
var client = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(1));
|
|
|
|
client.Dispose();
|
|
|
|
inner.DisposeCount.ShouldBe(1);
|
|
}
|
|
|
|
// ---- Driver level: a timed-out read overwrites the seed with a recoverable status ----
|
|
|
|
[Fact]
|
|
public async Task Driver_read_that_times_out_returns_BadCommunicationError_not_a_hang()
|
|
{
|
|
var factory = new FakeFocasClientFactory { Customise = () => new RecordingClient { BlockReadUntilCancelled = true } };
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
Timeout = TimeSpan.FromMilliseconds(150),
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
var snap = (await drv.ReadAsync(["CustomVar"], CancellationToken.None)).Single();
|
|
sw.Stop();
|
|
|
|
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // bounded by Timeout, not hung
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Driver_read_does_not_propagate_a_call_timeout_as_cancellation()
|
|
{
|
|
// The per-call timeout must NOT bubble out of ReadAsync as OperationCanceledException — that
|
|
// would abort the whole poll batch. It must be caught and turned into a per-tag Bad status.
|
|
var factory = new FakeFocasClientFactory { Customise = () => new RecordingClient { BlockReadUntilCancelled = true } };
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
Timeout = TimeSpan.FromMilliseconds(120),
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Should complete (not throw) with a Bad snapshot, even though the caller's token is never cancelled.
|
|
var snaps = await drv.ReadAsync(["CustomVar"], CancellationToken.None);
|
|
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
|
}
|
|
|
|
private static DisposeGuard NoopDispose(IDisposable d) => new(d);
|
|
|
|
private sealed class DisposeGuard(IDisposable inner) : IAsyncDisposable
|
|
{
|
|
public ValueTask DisposeAsync() { inner.Dispose(); return ValueTask.CompletedTask; }
|
|
}
|
|
|
|
/// <summary>Fake that records concurrency + optionally delays/blocks reads and probes.</summary>
|
|
private class RecordingClient : FakeFocasClient
|
|
{
|
|
private int _current;
|
|
public int MaxConcurrency;
|
|
public int ReadCount;
|
|
public TimeSpan ReadDelay = TimeSpan.Zero;
|
|
public bool BlockReadUntilCancelled;
|
|
public TimeSpan ProbeDelay = TimeSpan.Zero;
|
|
|
|
public override async Task<(object? value, uint status)> ReadAsync(
|
|
FocasAddress address, FocasDataType type, CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref ReadCount);
|
|
var observed = Interlocked.Increment(ref _current);
|
|
InterlockedMax(ref MaxConcurrency, observed);
|
|
try
|
|
{
|
|
if (BlockReadUntilCancelled) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
|
else if (ReadDelay > TimeSpan.Zero) await Task.Delay(ReadDelay, ct).ConfigureAwait(false);
|
|
return ((object?)42, FocasStatusMapper.Good);
|
|
}
|
|
finally { Interlocked.Decrement(ref _current); }
|
|
}
|
|
|
|
public override async Task<bool> ProbeAsync(CancellationToken ct)
|
|
{
|
|
if (ProbeDelay > TimeSpan.Zero) await Task.Delay(ProbeDelay, ct).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
|
|
private static void InterlockedMax(ref int target, int value)
|
|
{
|
|
int seen;
|
|
do { seen = Volatile.Read(ref target); if (value <= seen) return; }
|
|
while (Interlocked.CompareExchange(ref target, value, seen) != seen);
|
|
}
|
|
}
|
|
|
|
/// <summary>First read blocks until cancelled; subsequent reads serve a Good value immediately.</summary>
|
|
private sealed class TimeoutThenServeClient : FakeFocasClient
|
|
{
|
|
public bool FirstCallBlocks;
|
|
private int _calls;
|
|
|
|
public override async Task<(object? value, uint status)> ReadAsync(
|
|
FocasAddress address, FocasDataType type, CancellationToken ct)
|
|
{
|
|
var n = Interlocked.Increment(ref _calls);
|
|
if (n == 1 && FirstCallBlocks) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
|
return ((object?)42, FocasStatusMapper.Good);
|
|
}
|
|
}
|
|
}
|