namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
///
/// Decorates an so that every wire operation on the device's
/// single FOCAS/2 socket is (1) serialized against all other operations and
/// (2) time-bounded.
///
///
/// FOCAS/2 over TCP:8193 is a strict request→response protocol on ONE socket. The
/// driver holds a single per device, but several independent loops
/// read from it concurrently — the equipment poll (), the
/// fixed-tree loop (FixedTreeLoopAsync), the connectivity probe, and the recycle loop.
/// Without serialization, two reads interleave their send(request); read(response) on the
/// same socket: one reader consumes the other's response PDU and the victim then blocks forever
/// waiting for bytes that never arrive — leaving the bound OPC UA node stuck at
/// BadWaitingForInitialData. This was the root cause of FOCAS equipment tags never
/// surfacing a value while the probe reported HEALTHY (the probe reads work single-threaded on a
/// dev box, but collide deployed once the fixed-tree loop runs concurrently).
///
/// The gate ( of count 1) makes each request→response atomic on
/// the socket. The per-call timeout ensures a stalled response can never hold the gate — and thus
/// the socket — indefinitely; a hung read surfaces as a recoverable error at the configured
/// Timeout budget instead of permanent silence. The gate and timeout are paired
/// deliberately: a lock around an unbounded read would deadlock all I/O for the device.
///
/// and are serialized but NOT bounded by
/// this decorator's call timeout — they carry their own budgets (the connect timeout argument and
/// the probe's caller-supplied linked token respectively), and double-bounding would shrink them.
///
public sealed class SynchronizedFocasClient : IFocasClient
{
private readonly IFocasClient _inner;
private readonly TimeSpan _callTimeout;
private readonly SemaphoreSlim _gate = new(1, 1);
/// Ops holding or queued for — what drains.
private int _pending;
private int _disposed;
/// Wraps with per-device serialization + a per-call timeout.
/// The underlying FOCAS client to serialize access to.
///
/// The budget applied to each data read/write. or negative disables
/// the per-call timeout (callers' own cancellation tokens still apply).
///
public SynchronizedFocasClient(IFocasClient inner, TimeSpan callTimeout)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_callTimeout = callTimeout;
}
///
public bool IsConnected => _inner.IsConnected;
///
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken) =>
RunGatedAsync(ct => _inner.ConnectAsync(address, timeout, ct), cancellationToken);
///
public Task ProbeAsync(CancellationToken cancellationToken) =>
RunGatedAsync(ct => _inner.ProbeAsync(ct), cancellationToken);
///
public Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.ReadAsync(address, type, ct), cancellationToken);
///
public Task<(IReadOnlyList? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.ReadPmcSlotsAsync(letter, slotType, start, end, ct), cancellationToken);
///
public Task WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.WriteAsync(address, type, value, ct), cancellationToken);
///
public Task> ReadAlarmsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.ReadAlarmsAsync(ct), cancellationToken);
///
public Task GetSysInfoAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSysInfoAsync(ct), cancellationToken);
///
public Task> GetAxisNamesAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetAxisNamesAsync(ct), cancellationToken);
///
public Task> GetSpindleNamesAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleNamesAsync(ct), cancellationToken);
///
public Task ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.ReadDynamicAsync(axisIndex, ct), cancellationToken);
///
public Task GetProgramInfoAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken);
///
public Task GetStatusAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetStatusAsync(ct), cancellationToken);
///
public Task GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetTimerAsync(kind, ct), cancellationToken);
///
public Task> GetServoLoadsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetServoLoadsAsync(ct), cancellationToken);
///
public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleLoadsAsync(ct), cancellationToken);
///
public Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken);
///
/// Disposes the inner client. Ops still queued on the call gate then fail with
/// instead of running on a disposed socket. The gate itself is NOT
/// disposed: an op that already holds it releases it on the way out, and releasing a disposed
/// threw out of a read that had otherwise completed.
///
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_inner.Dispose();
}
///
/// Disposes once every op that already holds (or is queued for) this client has finished, waiting at most
/// . Used by the handle-recycle loop, which detaches the client under the device's
/// connect gate first — so nothing new can reach it — and then must not kill a read that is mid-flight on
/// it (that read used to surface BadCommunicationError + Degraded on every recycle tick).
///
/// Upper bound on the wait; the client is disposed regardless once it passes.
/// A task that completes when the client is disposed.
public async Task DisposeWhenIdleAsync(TimeSpan maxWait)
{
var deadline = DateTime.UtcNow + maxWait;
while (Volatile.Read(ref _pending) > 0 && DateTime.UtcNow < deadline)
await Task.Delay(5, CancellationToken.None).ConfigureAwait(false);
Dispose();
}
/// Gets the number of ops currently holding or queued for the call gate. Test seam.
internal int PendingOperations => Volatile.Read(ref _pending);
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(SynchronizedFocasClient), "The FOCAS client was recycled or disposed.");
}
// Gate only — the caller already governs the budget (connect timeout arg / probe linked token).
private async Task RunGatedAsync(Func> op, CancellationToken ct)
{
Interlocked.Increment(ref _pending);
try
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try { ThrowIfDisposed(); return await op(ct).ConfigureAwait(false); }
finally { _gate.Release(); }
}
finally { Interlocked.Decrement(ref _pending); }
}
private async Task RunGatedAsync(Func op, CancellationToken ct)
{
Interlocked.Increment(ref _pending);
try
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try { ThrowIfDisposed(); await op(ct).ConfigureAwait(false); }
finally { _gate.Release(); }
}
finally { Interlocked.Decrement(ref _pending); }
}
// Gate + per-call timeout. A fired timeout surfaces as OperationCanceledException whose token is
// the linked (not the caller's) token — callers distinguish it from real cancellation by testing
// their own token's IsCancellationRequested.
private async Task RunBoundedAsync(Func> op, CancellationToken ct)
{
Interlocked.Increment(ref _pending);
try
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ThrowIfDisposed();
if (_callTimeout <= TimeSpan.Zero)
return await op(ct).ConfigureAwait(false);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(_callTimeout);
return await op(linked.Token).ConfigureAwait(false);
}
finally { _gate.Release(); }
}
finally { Interlocked.Decrement(ref _pending); }
}
}