fix(focas): serialize per-device wire I/O + bound reads; tolerate AdminUI config formats
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).
This commit is contained in:
@@ -245,6 +245,32 @@ public sealed class FocasReadWriteTests
|
||||
/// <summary>Verifies that cancellation signals are propagated.</summary>
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(cts.Token),
|
||||
};
|
||||
|
||||
// A CANCELLATION of the caller's token must propagate (abort the read). This is distinct
|
||||
// from a per-call timeout — an OCE raised while the caller's token is still live is swallowed
|
||||
// to a per-tag BadCommunicationError (see Swallows_a_spurious_read_OCE_when_caller_not_cancelled).
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An OperationCanceledException from the wire read while the CALLER'S token is NOT cancelled
|
||||
/// (e.g. a per-call timeout firing) must be turned into a per-tag BadCommunicationError, not
|
||||
/// propagated — otherwise one stalled tag would abort the whole poll batch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Swallows_a_spurious_read_OCE_when_caller_not_cancelled()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
@@ -255,8 +281,8 @@ public sealed class FocasReadWriteTests
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], CancellationToken.None));
|
||||
var snap = (await drv.ReadAsync(["X"], CancellationToken.None)).Single();
|
||||
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ShutdownAsync disposes the client.</summary>
|
||||
|
||||
Reference in New Issue
Block a user