mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the production-ready single-listener / 1:1-backend transparent Modbus TCP proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260 fleet. Phase 9 replaces the connection layer with a single backend socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's 4-concurrent-client cap as an operational ceiling. Phase 9 additions of note: - PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap - InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing for Phase 10 read coalescing — do not collapse to a single field) - Per-request watchdog: surfaces Modbus exception 0x0B to upstream on BackendRequestTimeoutMs, defending against lost responses, dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed- request bug (its ServerRequestHandler.last_pdu state race) - Status DTO + HTML gain inFlight / maxInFlight / txIdWraps / disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md) Tests: 263 unit + 38 E2E. Multiplexer correctness under truly concurrent backend traffic is proved against a stub backend in PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus 3.13's single-PDU framer stays in known-good mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Mbproxy.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// One accepted upstream client socket, exposed as an asynchronous frame pipe to the
|
||||
/// owning <see cref="PlcMultiplexer"/>. The pipe reads complete MBAP frames from the
|
||||
/// upstream socket and hands each frame to a multiplexer-supplied <c>onFrame</c> callback;
|
||||
/// it also exposes a write channel that the multiplexer drains to send response frames
|
||||
/// back to the upstream client.
|
||||
///
|
||||
/// <para><b>Lifecycle:</b> constructed by <see cref="PlcListener"/> on accept; attached
|
||||
/// to the multiplexer; runs its read loop until the upstream socket closes, the pipe is
|
||||
/// disposed, or the multiplexer cascades a backend disconnect.</para>
|
||||
///
|
||||
/// <para><b>Concurrency model:</b> each pipe runs exactly two tasks — a read task and a
|
||||
/// write task. The read task drives the multiplexer (one frame at a time, which preserves
|
||||
/// the per-upstream-client one-in-flight invariant); the write task drains
|
||||
/// <see cref="_responseChannel"/> and writes each frame to the socket. No third task ever
|
||||
/// touches the socket.</para>
|
||||
///
|
||||
/// <para><b>One-in-flight-per-upstream:</b> the read loop processes frames sequentially.
|
||||
/// A multi-PDU-pipelined client would still get correct service because the multiplexer
|
||||
/// can have multiple distinct <c>OnFrame</c> calls outstanding from <i>different</i>
|
||||
/// upstream pipes; a single upstream cannot multi-PDU-pipeline itself.</para>
|
||||
/// </summary>
|
||||
internal sealed partial class UpstreamPipe : IAsyncDisposable
|
||||
{
|
||||
// Capacity 16: enough to buffer responses while the upstream's TCP send buffer drains,
|
||||
// small enough that backpressure kicks in on a wedged consumer. Drop-on-fault behaviour
|
||||
// applies — if the upstream is dead, _alive flips to false and pending writes are
|
||||
// discarded by the multiplexer before they ever enter the channel.
|
||||
private const int ResponseChannelCapacity = 16;
|
||||
|
||||
private readonly Socket _upstream;
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _plcName;
|
||||
|
||||
private readonly Channel<byte[]> _responseChannel = Channel.CreateBounded<byte[]>(
|
||||
new BoundedChannelOptions(ResponseChannelCapacity)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait, // backpressure, not drop
|
||||
SingleReader = true,
|
||||
SingleWriter = false, // multiplexer adds; potential future paths too
|
||||
});
|
||||
|
||||
// Internal CTS lets the multiplexer signal "drop this pipe now" without waiting for
|
||||
// the upstream socket to close cleanly.
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private bool _disposed;
|
||||
|
||||
// Phase 9: per-pipe forwarded-PDU counter (replaces the per-pair counter from the
|
||||
// 1:1 model). Read by the status page.
|
||||
private long _pdusForwardedCount;
|
||||
|
||||
/// <summary>Stable identity for status-page reporting and cascade cleanup.</summary>
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>The upstream client's remote endpoint, captured at construction.</summary>
|
||||
public IPEndPoint? RemoteEp { get; }
|
||||
|
||||
/// <summary>UTC time at which the upstream socket was accepted.</summary>
|
||||
public DateTimeOffset ConnectedAtUtc { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Number of request PDUs read from this upstream and forwarded into the multiplexer.
|
||||
/// Incremented by <see cref="RunReadLoopAsync"/> after each successful frame parse.
|
||||
/// </summary>
|
||||
public long PdusForwardedCount => Interlocked.Read(ref _pdusForwardedCount);
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> while the pipe's read+write tasks are running. Flips to <c>false</c>
|
||||
/// on disposal or any fault on either direction.
|
||||
/// </summary>
|
||||
public bool IsAlive => !_disposed && !_cts.IsCancellationRequested;
|
||||
|
||||
public UpstreamPipe(Socket upstream, string plcName, ILogger logger)
|
||||
{
|
||||
_upstream = upstream;
|
||||
_upstream.NoDelay = true;
|
||||
RemoteEp = upstream.RemoteEndPoint as IPEndPoint;
|
||||
_plcName = plcName;
|
||||
_logger = logger;
|
||||
|
||||
string remoteStr = RemoteEp?.ToString() ?? "?";
|
||||
MultiplexerLogEvents.ClientConnected(_logger, _plcName, remoteStr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the read side of the pipe. Reads complete MBAP frames from the upstream
|
||||
/// socket and invokes <paramref name="onFrame"/> for each. Returns when:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The upstream closes cleanly (clean EOF on the first byte of a frame).</description></item>
|
||||
/// <item><description>The pipe is disposed (CTS fires).</description></item>
|
||||
/// <item><description>An exception is thrown by <paramref name="onFrame"/>.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>The frame buffer is owned by this loop; <paramref name="onFrame"/> receives
|
||||
/// a fresh <see cref="byte"/>[] each call (the multiplexer needs to retain a copy to
|
||||
/// build <see cref="InFlightRequest"/>, so we don't try to share the buffer).</para>
|
||||
/// </summary>
|
||||
public async Task RunReadLoopAsync(
|
||||
Func<byte[], CancellationToken, ValueTask> onFrame,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
|
||||
var token = linked.Token;
|
||||
|
||||
// 7-byte header + max 253-byte PDU body = 260 bytes per frame.
|
||||
byte[] headerBuf = new byte[MbapFrame.HeaderSize];
|
||||
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
// Read the 7-byte MBAP header.
|
||||
if (!await FillAsync(_upstream, headerBuf, 0, MbapFrame.HeaderSize, token).ConfigureAwait(false))
|
||||
return; // clean EOF — upstream went away.
|
||||
|
||||
if (!MbapFrame.TryParseHeader(headerBuf.AsSpan(),
|
||||
out _, out _, out ushort length, out _))
|
||||
return;
|
||||
|
||||
if (length < 1)
|
||||
{
|
||||
// Length field claims no body — forward the header alone via a fresh buffer.
|
||||
byte[] degenerate = new byte[MbapFrame.HeaderSize];
|
||||
Buffer.BlockCopy(headerBuf, 0, degenerate, 0, MbapFrame.HeaderSize);
|
||||
await onFrame(degenerate, token).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref _pdusForwardedCount);
|
||||
continue;
|
||||
}
|
||||
|
||||
int pduBodyLen = length - 1;
|
||||
if (pduBodyLen > MbapFrame.MaxPduBodySize)
|
||||
{
|
||||
// Frame too large for the buffer — close the upstream.
|
||||
_logger.LogWarning(
|
||||
"Oversized upstream frame: Plc={Plc} PduBody={Body} > Max={Max}",
|
||||
_plcName, pduBodyLen, MbapFrame.MaxPduBodySize);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allocate a fresh frame buffer per PDU; the multiplexer retains it.
|
||||
byte[] frame = new byte[MbapFrame.HeaderSize + pduBodyLen];
|
||||
Buffer.BlockCopy(headerBuf, 0, frame, 0, MbapFrame.HeaderSize);
|
||||
|
||||
if (!await FillAsync(_upstream, frame, MbapFrame.HeaderSize, pduBodyLen, token)
|
||||
.ConfigureAwait(false))
|
||||
return;
|
||||
|
||||
Interlocked.Increment(ref _pdusForwardedCount);
|
||||
await onFrame(frame, token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown.
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// Upstream socket closed by remote end — normal.
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Socket disposed by write loop or DisposeAsync — normal.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the write side of the pipe. Drains <see cref="_responseChannel"/> and writes
|
||||
/// each frame to the upstream socket. Returns when the channel completes or the
|
||||
/// upstream socket fails.
|
||||
/// </summary>
|
||||
public async Task RunWriteLoopAsync(CancellationToken ct)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
|
||||
var token = linked.Token;
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var frame in _responseChannel.Reader.ReadAllAsync(token).ConfigureAwait(false))
|
||||
{
|
||||
await SendAllAsync(_upstream, frame.AsMemory(), token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown.
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// Upstream remote closed — normal.
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Socket disposed elsewhere — normal.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues <paramref name="frame"/> for delivery on the upstream socket. Returns
|
||||
/// without blocking when the pipe is no longer alive (the multiplexer will discover
|
||||
/// the dead pipe on its next correlation lookup and drop responses bound for it).
|
||||
/// </summary>
|
||||
public async ValueTask SendResponseAsync(byte[] frame, CancellationToken ct)
|
||||
{
|
||||
if (!IsAlive)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await _responseChannel.Writer.WriteAsync(frame, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
// Pipe disposed mid-write — drop silently.
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Caller cancelled — drop silently.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the pipe: cancels the read+write loops and shuts down the socket. Idempotent.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try { _responseChannel.Writer.TryComplete(); } catch { /* already complete */ }
|
||||
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
try { _upstream.Shutdown(SocketShutdown.Both); } catch { /* already closed */ }
|
||||
_upstream.Dispose();
|
||||
_cts.Dispose();
|
||||
|
||||
string remoteStr = RemoteEp?.ToString() ?? "?";
|
||||
MultiplexerLogEvents.ClientDisconnected(_logger, _plcName, remoteStr, "Pipe disposed");
|
||||
}
|
||||
|
||||
// ── Low-level I/O helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<bool> FillAsync(
|
||||
Socket socket, byte[] buf, int offset, int count, CancellationToken ct)
|
||||
{
|
||||
int remaining = count;
|
||||
bool firstRead = true;
|
||||
|
||||
while (remaining > 0)
|
||||
{
|
||||
int received = await socket.ReceiveAsync(
|
||||
buf.AsMemory(offset + (count - remaining), remaining),
|
||||
SocketFlags.None,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (received == 0)
|
||||
return firstRead && remaining == count ? false : false;
|
||||
|
||||
remaining -= received;
|
||||
firstRead = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task SendAllAsync(Socket socket, Memory<byte> memory, CancellationToken ct)
|
||||
{
|
||||
while (memory.Length > 0)
|
||||
{
|
||||
int sent = await socket.SendAsync(memory, SocketFlags.None, ct).ConfigureAwait(false);
|
||||
if (sent == 0) throw new SocketException((int)SocketError.ConnectionReset);
|
||||
memory = memory[sent..];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user