Files
wwtools/mbproxy/src/Mbproxy/Proxy/BcdPduPipeline.cs
T
Joseph Doherty 7ead3581ab mbproxy: Wave 3 cleanups, docs, and test gaps from 2026-05-14 review
Closes the Wave 3 (cleanup) tier of codereviews/2026-05-14/RemediationPlan.md.
Tests: 378 pass / 0 fail (baseline 370 + 8 new W3 regression tests).

Code cleanups:
  * PlcMultiplexer: removed dead `elapsedMs` calculation (the actual EWMA
    conversion uses Stopwatch ticks two lines below).
  * UpstreamPipe.FillAsync: dropped the meaningless `firstRead && remaining
    == count ? false : false` ternary; both branches were `false`.
  * InFlightByKeyMap.TryAttachOrCreate (always returned `true`) renamed to
    `AttachOrCreate` and made `void`. Test sites updated to drop the dead
    `bool ok = ...; ok.ShouldBeTrue();` assertions.
  * BcdCodec.HasBadNibble promoted from private to internal; the duplicate
    copy in BcdPduPipeline removed and the call sites updated to
    `BcdCodec.HasBadNibble`.
  * PlcMultiplexer watchdog comment fixed: said "1-second floor", code uses
    100 ms. Now both agree.
  * StatusSnapshotBuilder: simplified the unreachable
    `RemoteEp?.ToString() ?? RemoteEp?.Address.ToString() ?? "?"` to
    `RemoteEp?.ToString() ?? "?"`.
  * Mbproxy.csproj: stale "deferred" Polly comment replaced with a real
    description of where Polly is used (BackendConnect + ListenerRecovery).

Doc updates:
  * README: added a callout about the unconventional 32-bit BCD wire format
    ("two base-10000 digits in CDAB", not standard binary CDAB Int32) so
    integrators using off-the-shelf clients learn about the silent-corruption
    hazard before configuring writes.
  * docs/design.md: clarified `cacheMissCount` and `coalescedMissCount`
    semantics — "miss" means "did not find a fresh entry / did not coalesce",
    NOT "produced a backend round-trip". Operators wanting actual backend
    traffic should compute `miss − coalescedHit − exception04`.
  * docs/Architecture/ResponseCache.md: documented the structural
    "skip invalidation while recovering" gating (no backend reader during
    recovery → no FC06/FC16 response → no invalidation).
  * docs/Operations/Configuration.md: noted that the Event Log sink is the
    custom EventLogBridge, not Serilog.Sinks.EventLog (W2.23 cached check).
  * docs/plan/README.md: added a Phase 12 row pointing at the remediation
    plan and linking out to codereviews/2026-05-14/.

Test additions (W3 high-value gaps):
  * BcdPduPipelineTests:
    - FC16_WriteStartsOnHighWord_Of32BitPair_PassesThroughRaw_WithPartialWarning
      (symmetric inverse of the existing low-side partial-overlap test).
    - FC03_Mixed_16Bit_32Bit_AndNonBcd_InOneRead_OnlyConfiguredSlotsRewritten
      (mixed-slot routing in a single FC03 read).
    - FC16_Response_PassesThroughUnchanged_RegardlessOfTagMap (FC16 response
      carries no register data; rewriter must pass through).
  * AdminEndpointTests:
    - NonGetMethod_AgainstAdminRoutes_Returns405 (Theory: POST/PUT/DELETE/
      PATCH against `/` and `/status.json` must return 405; guards against
      an accidental MapPost being added later).
  * HotReloadE2ETests:
    - E2E_TagListReload_OnCacheablePlc_EmitsCacheFlushedEvent (validates the
      W2.8 cache.flushed wiring end-to-end via the real FileSystemWatcher
      reload path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:06:52 -04:00

479 lines
20 KiB
C#

using Mbproxy.Bcd;
namespace Mbproxy.Proxy;
/// <summary>
/// BCD-rewriting PDU pipeline. Registered as the singleton <see cref="IPduPipeline"/>
/// in production (replaces <see cref="NoopPduPipeline"/> from Phase 03).
///
/// FC scope (per design.md):
/// FC03 / FC04 response — decode covered BCD slots from raw nibbles → binary integer.
/// FC06 request — encode binary integer → BCD nibbles.
/// FC16 request — per-register over the configured slots.
/// All other FCs — pass through byte-for-byte.
///
/// MBAP transparency contract: the MBAP length field is NEVER modified. Re-encoded slots
/// are the same byte width as the originals (ushort → ushort), so the PDU length is stable.
///
/// <para><b>Phase 9 — request correlation:</b> FC03/FC04 responses do not carry the
/// original start address. The multiplexer builds an <see cref="Multiplexing.InFlightRequest"/>
/// on the request path, stores it in its <see cref="Multiplexing.CorrelationMap"/>, and
/// attaches it to the per-call <see cref="PerPlcContext.CurrentRequest"/> on the response
/// path. The rewriter consumes <c>CurrentRequest</c> instead of a per-pair last-request
/// slot, so concurrent responses from different upstream clients each decode against
/// their own request range without cross-talk.</para>
///
/// <para>This class is stateless. All per-call state arrives via <see cref="PduContext"/>
/// (specifically <see cref="PerPlcContext.CurrentRequest"/> on response). It is safe to
/// call concurrently from multiple upstream-read tasks and the single backend reader task.</para>
/// </summary>
internal sealed class BcdPduPipeline : IPduPipeline
{
// ── IPduPipeline.Process ─────────────────────────────────────────────────
public void Process(
MbapDirection direction,
ReadOnlySpan<byte> mbapHeader,
Span<byte> pdu,
PduContext context)
{
// PerPlcContext carries the BCD map, counters, and logger.
// If the caller passes a plain PduContext (e.g. in unit tests using NoopPduPipeline
// alongside this one), we skip BCD processing gracefully.
if (context is not PerPlcContext ctx)
return;
if (pdu.Length < 1)
return;
byte fc = pdu[0];
ctx.Counters.IncrementPdusForwarded();
ctx.Counters.IncrementFcCount(fc);
if (direction == MbapDirection.RequestToBackend)
{
ProcessRequest(fc, pdu, ctx);
}
else
{
ProcessResponse(fc, pdu, ctx);
}
}
// ── Request processing (FC06 / FC16) ────────────────────────────────────
private static void ProcessRequest(byte fc, Span<byte> pdu, PerPlcContext ctx)
{
switch (fc)
{
case 0x06:
ProcessFc06Request(pdu, ctx);
break;
case 0x10:
ProcessFc16Request(pdu, ctx);
break;
// All other FCs: transparent pass-through.
}
}
/// <summary>
/// FC06 Write Single Register request: [fc=06][addrHi][addrLo][valHi][valLo]
/// If the address is a configured 16-bit BCD tag, encode the client's binary integer
/// as BCD nibbles before forwarding to the PLC.
/// Partial-overlap (address is part of a 32-bit pair): warn + pass through raw.
/// </summary>
private static void ProcessFc06Request(Span<byte> pdu, PerPlcContext ctx)
{
if (pdu.Length < 5)
return;
ushort address = (ushort)((pdu[1] << 8) | pdu[2]);
ushort value = (ushort)((pdu[3] << 8) | pdu[4]);
// Direct point lookup at the exact address.
if (!ctx.TagMap.TryGet(address, out var tag))
{
// Not a BCD address — but check whether this address is the HIGH register
// of a 32-bit pair (Address+1 where Address is configured as 32-bit).
// TryGetForRange with qty=1 will catch that partial-overlap case.
if (ctx.TagMap.TryGetForRange(address, 1, out var hits) && hits.Count > 0)
{
// The only hit should be a 32-bit tag whose high register is at `address`.
foreach (var hit in hits)
{
if (hit.Tag.IsThirtyTwoBit && hit.OffsetWords < 0)
{
// This address is the high register of the 32-bit pair.
RewriterLogEvents.PartialBcd(ctx.Logger, ctx.PlcName, address, address, 1);
ctx.Counters.IncrementPartialBcd();
return;
}
}
}
return;
}
if (tag.IsThirtyTwoBit)
{
// FC06 writes exactly one register. If this is the LOW address of a 32-bit tag,
// that's a partial write. Per design partial-overlap policy: warn + pass through.
RewriterLogEvents.PartialBcd(ctx.Logger, ctx.PlcName, address, address, 1);
ctx.Counters.IncrementPartialBcd();
return;
}
// 16-bit tag: encode client's binary integer as BCD nibbles.
ushort encoded;
try
{
encoded = BcdCodec.Encode16(value);
}
catch (ArgumentOutOfRangeException)
{
// Value is outside [0, 9999] — cannot represent as 4-digit BCD.
RewriterLogEvents.InvalidBcd(ctx.Logger, ctx.PlcName, address, value, "Write");
ctx.Counters.IncrementInvalidBcd();
return; // pass through raw
}
pdu[3] = (byte)(encoded >> 8);
pdu[4] = (byte)(encoded & 0xFF);
ctx.Counters.AddRewrittenSlots(1);
}
/// <summary>
/// FC16 Write Multiple Registers request:
/// [fc=10][startHi][startLo][qtyHi][qtyLo][byteCount][reg0Hi][reg0Lo]...
/// Re-encodes binary integers at configured BCD addresses to BCD nibbles.
/// </summary>
private static void ProcessFc16Request(Span<byte> pdu, PerPlcContext ctx)
{
// Minimum FC16 request PDU: fc(1) + start(2) + qty(2) + byteCount(1) = 6 bytes.
if (pdu.Length < 6)
return;
ushort startAddress = (ushort)((pdu[1] << 8) | pdu[2]);
ushort qty = (ushort)((pdu[3] << 8) | pdu[4]);
// Phase 12 (W2.14) — validate the request is fully sized for `qty` registers
// (each 2 bytes after the byteCount byte). A client claiming qty=10 with only
// 4 bytes of register data would otherwise have its BCD slots silently skipped
// by the per-slot bounds check below — half the request rewritten, half not.
// Returning here passes the malformed PDU through unchanged so the PLC's own
// validator surfaces the protocol error.
if (pdu.Length < 6 + qty * 2)
return;
if (!ctx.TagMap.TryGetForRange(startAddress, qty, out var hits))
return; // no BCD tags in this range
int dataOffset = 6; // pdu[6..] = register data, 2 bytes per register
foreach (var hit in hits)
{
int offsetWords = hit.OffsetWords;
var tag = hit.Tag;
if (tag.IsThirtyTwoBit)
{
// Full 32-bit pair fits if both low (offsetWords) and high (offsetWords+1)
// are within the [0, qty) range.
bool lowInRange = offsetWords >= 0 && offsetWords < qty;
bool highInRange = (offsetWords + 1) >= 0 && (offsetWords + 1) < qty;
if (!lowInRange || !highInRange)
{
// Partial overlap — one of the two registers is outside the write range.
RewriterLogEvents.PartialBcd(ctx.Logger, ctx.PlcName,
tag.Address, startAddress, qty);
ctx.Counters.IncrementPartialBcd();
continue;
}
// Both registers are in range. Read the low/high words from the PDU.
int lowByteOff = dataOffset + offsetWords * 2;
int highByteOff = dataOffset + (offsetWords + 1) * 2;
if (lowByteOff + 2 > pdu.Length || highByteOff + 2 > pdu.Length)
continue; // malformed PDU — skip safely
// Per CDAB layout:
// pdu[lowByteOff..+2] = low register (low 4 BCD digits of value)
// pdu[highByteOff..+2] = high register (high 4 BCD digits of value)
// The client sends binary integers; encode to BCD nibbles.
//
// Design note: for a 32-bit write the client sends a 32-bit binary value
// split across two registers in CDAB order (low word at Address,
// high word at Address+1). We reconstruct the int and encode it.
ushort clientLow = (ushort)((pdu[lowByteOff] << 8) | pdu[lowByteOff + 1]);
ushort clientHigh = (ushort)((pdu[highByteOff] << 8) | pdu[highByteOff + 1]);
// Phase 12 (W2.13) — validate that BOTH input words are within the
// base-10000-digit range BEFORE reconstructing. Without this guard, a
// client writing (high=9999, low=9999) silently mutates to (high=9998,
// low=9999) because `9999 * 10_000 + 9999 = 99_989_999` is still <= the
// 32-bit BCD ceiling, so Encode32 accepts it and rewrites — losing 1 from
// the high word. The unconventional wire format ("two base-10000 CDAB
// digits", per design.md:123) means each word independently must be
// 0..9999 to round-trip cleanly.
if (clientLow > 9999 || clientHigh > 9999)
{
RewriterLogEvents.InvalidBcd(ctx.Logger, ctx.PlcName, tag.Address,
clientLow, "Write");
ctx.Counters.IncrementInvalidBcd();
continue;
}
// Reconstruct the 32-bit binary value (CDAB: low-word = low digits).
int binaryValue = clientHigh * 10_000 + clientLow;
ushort bcdLow, bcdHigh;
try
{
(bcdLow, bcdHigh) = BcdCodec.Encode32(binaryValue);
}
catch (ArgumentOutOfRangeException)
{
RewriterLogEvents.InvalidBcd(ctx.Logger, ctx.PlcName, tag.Address,
clientLow, "Write");
ctx.Counters.IncrementInvalidBcd();
continue;
}
pdu[lowByteOff] = (byte)(bcdLow >> 8);
pdu[lowByteOff + 1] = (byte)(bcdLow & 0xFF);
pdu[highByteOff] = (byte)(bcdHigh >> 8);
pdu[highByteOff + 1] = (byte)(bcdHigh & 0xFF);
ctx.Counters.AddRewrittenSlots(2);
}
else
{
// 16-bit tag.
if (offsetWords < 0 || offsetWords >= qty)
continue; // outside range (shouldn't happen for 16-bit but be defensive)
int byteOff = dataOffset + offsetWords * 2;
if (byteOff + 2 > pdu.Length)
continue;
ushort clientValue = (ushort)((pdu[byteOff] << 8) | pdu[byteOff + 1]);
ushort encoded;
try
{
encoded = BcdCodec.Encode16(clientValue);
}
catch (ArgumentOutOfRangeException)
{
RewriterLogEvents.InvalidBcd(ctx.Logger, ctx.PlcName, tag.Address,
clientValue, "Write");
ctx.Counters.IncrementInvalidBcd();
continue;
}
pdu[byteOff] = (byte)(encoded >> 8);
pdu[byteOff + 1] = (byte)(encoded & 0xFF);
ctx.Counters.AddRewrittenSlots(1);
}
}
}
// ── Response processing (FC03 / FC04) ───────────────────────────────────
private static void ProcessResponse(byte fc, Span<byte> pdu, PerPlcContext ctx)
{
// Check for Modbus exception response (high bit of FC is set).
if ((fc & 0x80) != 0)
{
// Exception response: [fc|0x80][exceptionCode]
byte originalFc = (byte)(fc & 0x7F);
byte exceptionCode = pdu.Length >= 2 ? pdu[1] : (byte)0;
RewriterLogEvents.ExceptionPassthrough(ctx.Logger, ctx.PlcName, originalFc, exceptionCode);
ctx.Counters.IncrementBackendException(exceptionCode);
return; // pass through raw
}
switch (fc)
{
case 0x03:
case 0x04:
// Handled below.
break;
case 0x06:
// FC06 response echoes [fc][addrHi][addrLo][valHi][valLo].
// Since the proxy re-encoded the request (binary→BCD), the PLC echoes back
// BCD nibbles. The client expects its original binary value. Decode here.
ProcessFc06Response(pdu, ctx);
return;
case 0x10:
// FC16 response: [fc][startHi][startLo][qtyHi][qtyLo] — no register data.
return;
default:
return; // all other FCs pass through
}
// FC03/04 response: [fc][byteCount][reg0Hi][reg0Lo]...
// The start address is NOT in the response — the multiplexer attaches the matched
// InFlightRequest to ctx.CurrentRequest on the response path. Without it (e.g., a
// unit-test fixture invoking the pipeline directly without correlation) we cannot
// decode safely; pass the bytes through.
var currentReq = ctx.CurrentRequest;
if (currentReq is null)
return;
// Only FC03/04 responses should consult start/qty.
if (currentReq.Fc != 0x03 && currentReq.Fc != 0x04)
return;
ushort startAddress = currentReq.StartAddress;
ushort qty = currentReq.Qty;
if (pdu.Length < 2)
return;
int byteCount = pdu[1];
int wordsInResponse = byteCount / 2;
// Sanity: the qty in the request should match the words in the response.
// Use the smaller of the two to stay in bounds.
ushort effectiveQty = (ushort)Math.Min(qty, wordsInResponse);
if (!ctx.TagMap.TryGetForRange(startAddress, effectiveQty, out var hits))
return;
int dataOffset = 2; // pdu[2..] = register data
foreach (var hit in hits)
{
int offsetWords = hit.OffsetWords;
var tag = hit.Tag;
if (tag.IsThirtyTwoBit)
{
bool lowInRange = offsetWords >= 0 && offsetWords < effectiveQty;
bool highInRange = (offsetWords + 1) >= 0 && (offsetWords + 1) < effectiveQty;
if (!lowInRange || !highInRange)
{
RewriterLogEvents.PartialBcd(ctx.Logger, ctx.PlcName,
tag.Address, startAddress, qty);
ctx.Counters.IncrementPartialBcd();
continue;
}
int lowByteOff = dataOffset + offsetWords * 2;
int highByteOff = dataOffset + (offsetWords + 1) * 2;
if (lowByteOff + 2 > pdu.Length || highByteOff + 2 > pdu.Length)
continue;
// CDAB: Address = low register (low 4 BCD digits), Address+1 = high register
ushort rawLow = (ushort)((pdu[lowByteOff] << 8) | pdu[lowByteOff + 1]);
ushort rawHigh = (ushort)((pdu[highByteOff] << 8) | pdu[highByteOff + 1]);
int decoded;
try
{
decoded = BcdCodec.Decode32(rawLow, rawHigh);
}
catch (FormatException)
{
// Emit invalid_bcd for the low register (first bad word we'd encounter).
ushort badRaw = BcdCodec.HasBadNibble(rawLow) ? rawLow : rawHigh;
ushort badAddr = BcdCodec.HasBadNibble(rawLow) ? tag.Address : tag.HighRegister;
RewriterLogEvents.InvalidBcd(ctx.Logger, ctx.PlcName, badAddr, badRaw, "Read");
ctx.Counters.IncrementInvalidBcd();
continue;
}
// Write decoded binary value back as a 32-bit value in CDAB layout.
// The client receives low 4 digits at Address and high 4 digits at Address+1.
int decodedLow = decoded % 10_000;
int decodedHigh = decoded / 10_000;
pdu[lowByteOff] = (byte)(decodedLow >> 8);
pdu[lowByteOff + 1] = (byte)(decodedLow & 0xFF);
pdu[highByteOff] = (byte)(decodedHigh >> 8);
pdu[highByteOff + 1] = (byte)(decodedHigh & 0xFF);
ctx.Counters.AddRewrittenSlots(2);
}
else
{
// 16-bit tag.
if (offsetWords < 0 || offsetWords >= effectiveQty)
continue;
int byteOff = dataOffset + offsetWords * 2;
if (byteOff + 2 > pdu.Length)
continue;
ushort raw = (ushort)((pdu[byteOff] << 8) | pdu[byteOff + 1]);
int decoded;
try
{
decoded = BcdCodec.Decode16(raw);
}
catch (FormatException)
{
RewriterLogEvents.InvalidBcd(ctx.Logger, ctx.PlcName, tag.Address, raw, "Read");
ctx.Counters.IncrementInvalidBcd();
continue;
}
pdu[byteOff] = (byte)(decoded >> 8);
pdu[byteOff + 1] = (byte)(decoded & 0xFF);
ctx.Counters.AddRewrittenSlots(1);
}
}
}
/// <summary>
/// FC06 response: [fc=06][addrHi][addrLo][valHi][valLo] — echoes the register address
/// and the value the PLC wrote (which is now BCD-encoded if the request was rewritten).
/// Decode the BCD nibbles back to the client's original binary integer so the client
/// sees the value it sent and library validation (e.g. NModbus echo-check) passes.
/// </summary>
private static void ProcessFc06Response(Span<byte> pdu, PerPlcContext ctx)
{
if (pdu.Length < 5)
return;
ushort address = (ushort)((pdu[1] << 8) | pdu[2]);
ushort raw = (ushort)((pdu[3] << 8) | pdu[4]);
if (!ctx.TagMap.TryGet(address, out var tag))
return; // not a BCD address
if (tag.IsThirtyTwoBit)
return; // partial-write echo — pass through (already warned on request)
// 16-bit tag: the PLC echoed back BCD nibbles. Decode them back to binary.
int decoded;
try
{
decoded = BcdCodec.Decode16(raw);
}
catch (FormatException)
{
RewriterLogEvents.InvalidBcd(ctx.Logger, ctx.PlcName, address, raw, "Read");
ctx.Counters.IncrementInvalidBcd();
return;
}
pdu[3] = (byte)(decoded >> 8);
pdu[4] = (byte)(decoded & 0xFF);
// Note: the RewrittenSlots counter is NOT incremented here because the request
// already counted this slot on the way out. Incrementing again would double-count.
}
// Phase 12 (W3 cleanup) — HasBadNibble was previously duplicated here; the canonical
// implementation now lives in BcdCodec.HasBadNibble (internal).
}