namespace Mbproxy.Proxy.Multiplexing; /// /// Allocates 16-bit MBAP transaction IDs (proxy TxIds) used to multiplex many upstream /// clients onto a single shared backend connection per PLC. The allocator tracks which /// IDs are currently in flight and scans forward from a rolling cursor to find the next /// free slot, mimicking the natural cadence of Modbus clients while keeping reuse /// distance maximally large in steady state. /// /// State is protected by a single lock. Contention is /// negligible in practice — the allocator is per-PLC and one PLC's wire rate is bounded /// by the controller's internal scan time (a few ms per request on an H2-ECOM100). /// The lock is preferred over a lock-free approach for readability and worst-case /// determinism (Polly retries, cascade cleanup, and saturation paths must not race). /// /// Memory: bool[65536] (~64 KB) per PLC. With ~54 PLCs that is /// ~3.4 MB total — well within budget for a service that already ships at ~30 MB working /// set under load. /// /// Wrap counter: increments every time the rolling cursor rolls over /// 0xFFFF → 0x0000 during a successful allocation scan. Frequent wraps indicate either /// very high churn or extreme in-flight depth and are surfaced as a telemetry signal, /// not an error. /// internal sealed class TxIdAllocator { // 65,536 slots total — the full uint16 space. private const int SlotCount = 65536; private readonly object _lock = new(); private readonly bool[] _inUse = new bool[SlotCount]; private ushort _next; // rolling cursor; 0 on construction private int _inFlightCount; // 0..65536 private long _wrapCount; // monotonic; never resets private long _doubleReleaseCount; // monotonic; Release called on an already-free slot /// /// Number of currently-in-flight proxy TxIds (i.e., allocated but not yet released). /// Read under the same lock that mutates it; the snapshot is a simple atomic read of /// an int but we still hold the lock for cross-field consistency with _inUse. /// public int InFlightCount { get { lock (_lock) { return _inFlightCount; } } } /// /// Number of times the rolling cursor has wrapped 0xFFFF → 0x0000 during a /// successful allocation since the allocator was constructed. Read without locking /// via for the hot status-page path. /// public long WrapCount => Interlocked.Read(ref _wrapCount); /// /// Number of times was called on a slot that was already free. /// A double-release is normally a benign cascade-vs-timeout race, but a sustained /// non-zero rate points at the documented TearDownBackendAsync gate-not-held /// race actually firing — making the otherwise-silent request drop observable. /// public long DoubleReleaseCount => Interlocked.Read(ref _doubleReleaseCount); /// /// Attempts to allocate the next free proxy TxId. /// Returns true with set when an ID was allocated. /// Returns false when every slot in the 16-bit space is currently in use; /// the caller is responsible for emitting mbproxy.multiplex.saturated and /// returning a Modbus exception (code 04 / Slave Device Failure) to the upstream. /// public bool TryAllocate(out ushort id) { lock (_lock) { if (_inFlightCount >= SlotCount) { id = 0; return false; } // Scan forward from _next for the next free slot. _inFlightCount < SlotCount // guarantees at least one free slot, so the loop terminates within at most // SlotCount iterations even in the pathological full-minus-one case. ushort start = _next; ushort cursor = start; do { if (!_inUse[cursor]) { _inUse[cursor] = true; _inFlightCount++; // Advance the cursor; track wrap. unchecked { ushort nextCursor = (ushort)(cursor + 1); if (nextCursor == 0) Interlocked.Increment(ref _wrapCount); _next = nextCursor; } id = cursor; return true; } unchecked { cursor = (ushort)(cursor + 1); } } while (cursor != start); // Defensive: should be unreachable given the InFlightCount check above. id = 0; return false; } } /// /// Releases a previously-allocated proxy TxId. Releasing an ID that is not currently /// allocated is a no-op (defensive: cascade-on-disconnect can call /// after a concurrent timeout path has already done so). /// public void Release(ushort id) { lock (_lock) { if (_inUse[id]) { _inUse[id] = false; _inFlightCount--; } else { // Double-release: the slot was already free. Harmless to the allocator // (idempotent) but tracked so the rare cascade-vs-timeout race is visible. Interlocked.Increment(ref _doubleReleaseCount); } } } /// /// Test-only: returns whether the given proxy TxId is currently marked in use. /// Internal so it remains usable from unit tests via InternalsVisibleTo. /// internal bool IsAllocated(ushort id) { lock (_lock) { return _inUse[id]; } } }