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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user