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>
This commit is contained in:
Joseph Doherty
2026-05-14 06:06:52 -04:00
parent e66b17fe5f
commit 7ead3581ab
16 changed files with 236 additions and 49 deletions
@@ -330,6 +330,40 @@ public sealed class AdminEndpointTests
System.IO.File.Move(tmp, path, overwrite: true);
}
// ── Phase 12 (W3 test gap) — non-GET methods rejected ──────────────────
/// <summary>
/// W3 — verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
/// with HTTP 405 Method Not Allowed. The design intentionally exposes only `GET /`
/// and `GET /status.json`; this test guards against an accidental MapPost/Map* being
/// added later.
/// </summary>
[Theory(Timeout = 5_000)]
[InlineData("POST")]
[InlineData("PUT")]
[InlineData("DELETE")]
[InlineData("PATCH")]
public async Task NonGetMethod_AgainstAdminRoutes_Returns405(string method)
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
proxyPort: proxyPort, bcd16Addresses: []);
await using var _ = new AsyncHostDispose(host);
await host.StartAsync(TestContext.Current.CancellationToken);
await WaitForAdminAsync(adminPort);
foreach (string path in new[] { "/", "/status.json" })
{
using var req = new HttpRequestMessage(new HttpMethod(method),
$"http://127.0.0.1:{adminPort}{path}");
using var resp = await HttpClient.SendAsync(req, TestContext.Current.CancellationToken);
resp.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed,
$"{method} {path} must be rejected (admin endpoint is read-only)");
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static IHost BuildHost(
@@ -320,6 +320,67 @@ public sealed class HotReloadE2ETests : IAsyncLifetime
await host.StopAsync(stopCts.Token);
}
// ── Phase 12 (W3 test gap) — cache flush on tag-list reload ─────────────────────────
/// <summary>
/// W2.8 / W3 — verifies that a tag-list reload for a PLC with a cacheable tag emits
/// <c>mbproxy.cache.flushed</c>. The cache count is 0 (no real backend to populate
/// it), but the event must still fire — it's the operator's signal that the in-memory
/// cache state was reset by a config reload.
/// </summary>
[Fact(Timeout = 8_000)]
public async Task E2E_TagListReload_OnCacheablePlc_EmitsCacheFlushedEvent()
{
int port = PickFreePort();
int adminPort = PickFreePort();
WriteConfigWithCacheableTag(_configPath, port, adminPort, address: 1024, cacheTtlMs: 60_000);
var sink = new HotReloadCapturingSink();
using var host = BuildHost(_configPath, logSink: sink);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await WaitForAsync(() => CanConnect(port), TimeSpan.FromSeconds(5),
"listener should be reachable after startup");
// Mutate the tag list (different address, still cacheable) — this is a Reseat,
// not an Add/Remove, so ReplaceContextAsync runs and the cache flush fires.
WriteConfigWithCacheableTag(_configPath, port, adminPort, address: 1080, cacheTtlMs: 60_000);
// First confirm the reconciler actually applied the reload at all — gives a clearer
// failure mode than a bare timeout if Reseat isn't firing.
await WaitForAsync(
() => sink.Events.Any(e => e.MessageTemplate.Text.Contains("Config reload applied")),
TimeSpan.FromSeconds(5),
"Config reload applied must fire first; verifies reconciler picked up the change");
await WaitForAsync(
() => sink.Events.Any(e => e.MessageTemplate.Text.Contains("Cache flushed")),
TimeSpan.FromSeconds(2),
"expected mbproxy.cache.flushed after tag-list reload on a cacheable PLC");
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await host.StopAsync(stopCts.Token);
}
private static void WriteConfigWithCacheableTag(
string path, int listenPort, int adminPort, int address, int cacheTtlMs)
{
var doc = new
{
Mbproxy = new
{
AdminPort = adminPort,
BcdTags = new { Global = new[] { new { Address = address, Width = 16, CacheTtlMs = cacheTtlMs } } },
Plcs = new[] { new { Name = "PLC-A", ListenPort = listenPort, Host = "127.0.0.1", Port = 502 } },
Connection = new { BackendConnectTimeoutMs = 500, BackendRequestTimeoutMs = 500 },
},
};
string tmp = path + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true }));
File.Move(tmp, path, overwrite: true);
}
// ── Helpers ───────────────────────────────────────────────────────────────────────────
private static bool CanConnect(int port)
@@ -404,6 +404,87 @@ public sealed class BcdPduPipelineTests
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
}
/// <summary>
/// Phase 12 (W3 test gap) — symmetric inverse of the existing partial-overlap test:
/// the write range starts ON the high register of a 32-bit pair (low word is BEFORE
/// the write range). Must also be passed through raw with a partial warning, not
/// half-rewritten.
/// </summary>
[Fact]
public void FC16_WriteStartsOnHighWord_Of32BitPair_PassesThroughRaw_WithPartialWarning()
{
// 32-bit BCD tag at 700/701; write range 701702 (qty=2). Low (700) is OUT of
// range; high (701) is IN range — partial overlap on the high side.
var ctx = MakeContext(BcdTag.Create(700, 32));
var pdu = Fc16Request(701, 0xCCCC, 0xDDDD);
byte[] original = [..pdu];
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
pdu.ShouldBe(original, "high-only partial overlap must pass through raw");
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
}
/// <summary>
/// Phase 12 (W3 test gap) — mixed slots in a single FC03 read: a 16-bit BCD tag, a
/// 32-bit BCD pair, and an unconfigured register. Each slot should be handled
/// independently — the 16-bit and 32-bit rewritten, the unconfigured register passed
/// through unchanged.
/// </summary>
[Fact]
public void FC03_Mixed_16Bit_32Bit_AndNonBcd_InOneRead_OnlyConfiguredSlotsRewritten()
{
// Layout:
// addr 100: 16-bit BCD → wire 0x1234 → decoded 1234 (= 0x04D2 binary)
// addr 101: unconfigured → passes through 0x9999
// addr 102: 32-bit BCD low → wire 0x5678 (BCD digits 5,6,7,8 → 5678)
// addr 103: 32-bit BCD high→ wire 0x1234 (BCD digits 1,2,3,4 → 1234)
// decoded = 1234*10_000 + 5678 = 12_345_678
// emitted as base-10000 binary CDAB:
// low = 12_345_678 % 10_000 = 5678 (binary 0x162E)
// high = 12_345_678 / 10_000 = 1234 (binary 0x04D2)
var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(102, 32));
var inFlight = MakeInFlight(0x03, startAddress: 100, qty: 4);
var responseCtx = ctx.WithCurrentRequest(inFlight);
var pdu = Fc03Response(0x1234, 0x9999, 0x5678, 0x1234);
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), responseCtx);
// pdu[0]=fc, pdu[1]=byteCount, pdu[2..] = register bytes (2 per register).
ushort reg100 = (ushort)((pdu[2] << 8) | pdu[3]);
ushort reg101 = (ushort)((pdu[4] << 8) | pdu[5]);
ushort reg102 = (ushort)((pdu[6] << 8) | pdu[7]);
ushort reg103 = (ushort)((pdu[8] << 8) | pdu[9]);
reg100.ShouldBe((ushort)1234, "16-bit BCD slot must decode to 1234");
reg101.ShouldBe((ushort)0x9999, "unconfigured register must pass through unchanged");
reg102.ShouldBe((ushort)5678, "32-bit pair low must emit decimal 5678 as binary");
reg103.ShouldBe((ushort)1234, "32-bit pair high must emit decimal 1234 as binary");
// Slot count: 1 from 16-bit + 2 from 32-bit pair = 3 rewritten slots.
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(3);
}
/// <summary>
/// Phase 12 (W3 test gap) — FC16 response handling. The response carries no register
/// values (just an echo of [fc][start][qty]) so the rewriter must pass it through
/// unchanged regardless of tag-map content.
/// </summary>
[Fact]
public void FC16_Response_PassesThroughUnchanged_RegardlessOfTagMap()
{
var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(200, 32));
// FC16 response: [fc=10][startHi][startLo][qtyHi][qtyLo] = 5 bytes total.
var pdu = new byte[] { 0x10, 0x00, 0x64, 0x00, 0x05 };
byte[] original = [..pdu];
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
pdu.ShouldBe(original, "FC16 response carries no register data and must pass through");
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
}
[Fact]
public void FC16_WritePartiallyOverlapping32BitPair_PassesThroughRaw_WithPartialWarning()
{
@@ -53,13 +53,12 @@ public sealed class InFlightByKeyMapTests
var party = new InterestedParty(pipe, OriginalTxId: 0x1234);
int factoryCalls = 0;
bool ok = map.TryAttachOrCreate(
map.AttachOrCreate(
key, party,
factory: () => { factoryCalls++; return MakeRequest(party); },
maxParties: 32,
out var req, out bool wasNew);
ok.ShouldBeTrue();
wasNew.ShouldBeTrue("a brand-new key must take the create branch");
factoryCalls.ShouldBe(1, "the factory must be called exactly once");
req.ShouldNotBeNull();
@@ -86,15 +85,14 @@ public sealed class InFlightByKeyMapTests
var partyB = new InterestedParty(pipeB, OriginalTxId: 0x2222);
int factoryCalls = 0;
map.TryAttachOrCreate(key, partyA,
map.AttachOrCreate(key, partyA,
factory: () => { factoryCalls++; return MakeRequest(partyA); },
maxParties: 32, out var first, out bool firstWasNew);
bool ok = map.TryAttachOrCreate(key, partyB,
map.AttachOrCreate(key, partyB,
factory: () => { factoryCalls++; return MakeRequest(partyB); },
maxParties: 32, out var second, out bool secondWasNew);
ok.ShouldBeTrue();
firstWasNew.ShouldBeTrue();
secondWasNew.ShouldBeFalse("the second attach must coalesce onto the first");
factoryCalls.ShouldBe(1, "the factory must only fire on the create branch");
@@ -126,20 +124,19 @@ public sealed class InFlightByKeyMapTests
var partyC = new InterestedParty(pipeC, OriginalTxId: 0xCCCC);
// MaxParties = 2 — first attach creates, second appends, third overflows.
map.TryAttachOrCreate(key, partyA,
map.AttachOrCreate(key, partyA,
factory: () => MakeRequest(partyA), maxParties: 2,
out var first, out _);
map.TryAttachOrCreate(key, partyB,
map.AttachOrCreate(key, partyB,
factory: () => MakeRequest(partyB), maxParties: 2,
out var second, out _);
int factoryCalls = 0;
bool ok = map.TryAttachOrCreate(key, partyC,
map.AttachOrCreate(key, partyC,
factory: () => { factoryCalls++; return MakeRequest(partyC); },
maxParties: 2,
out var third, out bool thirdWasNew);
ok.ShouldBeTrue();
thirdWasNew.ShouldBeTrue("the third attach must overflow into a fresh entry");
factoryCalls.ShouldBe(1, "the factory must fire to create the overflow entry");
third.ShouldNotBeSameAs(first, "the overflow must be a distinct InFlightRequest");
@@ -167,8 +164,8 @@ public sealed class InFlightByKeyMapTests
var partyA = new InterestedParty(pipeA, 1);
var partyB = new InterestedParty(pipeB, 2);
map.TryAttachOrCreate(key, partyA, () => MakeRequest(partyA), 32, out _, out _);
map.TryAttachOrCreate(key, partyB, () => MakeRequest(partyB), 32, out _, out _);
map.AttachOrCreate(key, partyA, () => MakeRequest(partyA), 32, out _, out _);
map.AttachOrCreate(key, partyB, () => MakeRequest(partyB), 32, out _, out _);
bool removed = map.TryRemove(key, out var req);
@@ -228,7 +225,7 @@ public sealed class InFlightByKeyMapTests
{
if (workCt.IsCancellationRequested) return;
var party = new InterestedParty(pipe, (ushort)i);
map.TryAttachOrCreate(
map.AttachOrCreate(
key, party,
factory: () => MakeRequest(party),
maxParties: MaxParties,