mbproxy: Wave 2 fixes from 2026-05-14 code review
Resolves the 21 Major findings catalogued in
codereviews/2026-05-14/RemediationPlan.md (Wave 2). Tests: 370 pass / 0 fail
(baseline 363 + 7 new W2 regression tests).
Multiplexer / concurrency:
W2.1 ConfigReconciler.Attach now threads the live coalescingAccessor through
to add/restart-built supervisors so a hot-reload of
ReadCoalescing.{Enabled,MaxParties} propagates to PLCs added or
restarted via reload.
W2.2 PlcMultiplexer._disposed and UpstreamPipe._disposed are now volatile
for ARM/portability defense.
W2.3 ProxyWorker._supervisors / ConfigReconciler._supervisors switched from
Dictionary to ConcurrentDictionary; reconciler uses TryRemove. The
outer Apply is serialised by a semaphore but the inner Add/Remove/
Restart Task.WhenAll continuations run in parallel.
W2.4 Counter parity for cache miss + coalescing-saturation miss documented
inline (per-design contract; behavior unchanged).
W2.5 _disposeCts.Dispose() and _connectGate.Dispose() guarded against late
watchdog ticks.
W2.6 _connectGate disposed in DisposeAsync.
W2.7 Inline doc clarifying the post-rewriter FC byte read.
Cache / hot-reload:
W2.8 PlcListenerSupervisor.ReplaceContextAsync now calls Clear() to capture
the entry count, emits mbproxy.cache.flushed, then disposes the old
cache. Previously the event was defined but never emitted.
W2.9 Inline doc explaining the implicit "skip cache invalidation while
recovering" gating (no backend reader during recovery → no FC06/FC16
response → no invalidation).
W2.10 ReloadValidator now re-checks resolved per-tag CacheTtlMs against
Cache.AllowLongTtl after BcdTagMapBuilder folds the per-PLC default.
BCD rewriter:
W2.11 Duplicate addresses detected within Global itself and within the per-PLC
Add list itself, BEFORE the working dictionary collapses keys. Cross-list
collisions (Global vs Add) remain the documented width-override pattern.
Previously the DuplicateAddress error was unreachable dead code.
W2.12 OverlappingHighRegister reports each colliding pair exactly once
(canonicalised low/high pair tracked in a HashSet).
W2.13 FC16 32-bit write rejects clientLow > 9999 or clientHigh > 9999 BEFORE
the high*10000+low reconstruction. Without this guard, (high=9999,
low=9999) silently re-encoded as (high=9998, low=9999), losing 1 from
the high word.
W2.14 FC16 validates pdu.Length >= 6 + qty*2 upfront — no half-rewritten
requests when a malformed client claims more registers than it ships.
Supervisor:
W2.15 WaitForInitialBindAttemptAsync now backed by TaskCompletionSource
instead of 10ms busy-poll. Resolves race against fast Stopped→Bound→
Stopped transitions and hangs when the supervisor task throws.
W2.16 StartAsync refuses re-entry on a non-Stopped supervisor (was leaking
the previous _supervisorCts).
W2.17 New TransitionTo helper writes _state, _lastBindError, and (optionally)
_recoveryAttempts under one lock. Snapshot() reads under the same lock
so the status page never reports an inconsistent triple. Truncate
helper extracted (was copy-pasted across three sites).
W2.18 MbproxyOptionsValidator + ReloadValidator reject Connection.{Backend
ConnectTimeoutMs, BackendRequestTimeoutMs, GracefulShutdownTimeoutMs}
<= 0. Misconfigured 0 produces immediate CancelAfter(0) failures.
Hosting / diagnostics:
W2.20 ProxyWorker.StopAsync supervisor-stop deadline now reads from
IOptionsMonitor.CurrentValue.Connection.GracefulShutdownTimeoutMs
(was hard-coded 5s).
W2.21 src/Mbproxy/appsettings.json deleted; the published file is now a Link
to install/mbproxy.config.template.json so the binary ships with a
usable, fully-commented example config instead of an empty stub. Tests
strip the inherited file from their bin via an AfterTargets="Build"
Target so they don't pick up the template's example PLCs.
W2.22 invalidBcdWarnings (PlcPdusStatus) and codeOther (ExceptionCounts)
added to StatusDto, plumbed through StatusSnapshotBuilder, surfaced
in StatusHtmlRenderer table cells.
W2.23 EventLogBridge caches EventLog.SourceExists at construction so Emit
doesn't hit the registry on every Error+ log line.
New regression tests:
ReloadValidatorTests:
Validate_PerTagCacheTtl_Above60s_Without_AllowLongTtl_Fails
Validate_PerTagCacheTtl_Above60s_With_AllowLongTtl_Passes
Validate_ResolvedTtl_FromPerPlcDefault_AboveCap_Fails
Validate_ZeroBackendConnectTimeoutMs_Fails
Validate_NegativeGracefulShutdownTimeoutMs_Fails
BcdPduPipelineTests:
FC16_32Bit_ClientHighOrLowAbove9999_PassesThroughRaw_WithInvalidBcdWarning
FC16_TruncatedRegisterData_PassesThroughRaw_NoPartialRewrite
Reworked tests in BcdTagMapBuilderTests for the W2.11 contract (Global dup,
Add dup, Add-overrides-Global accepted as width override).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,32 @@ public static class BcdTagMapBuilder
|
||||
var errors = new List<BcdError>();
|
||||
var warnings = new List<BcdWarning>();
|
||||
|
||||
// Phase 12 (W2.11) — duplicate-address detection happens BEFORE the working
|
||||
// dictionary collapses keys. Iterating each list independently catches duplicates
|
||||
// that would otherwise be silently last-write-wins'd by the dictionary indexer.
|
||||
// Cross-list collisions (same address in BOTH Global and Add) are the documented
|
||||
// width-override pattern and must NOT be flagged — only intra-list duplicates fail.
|
||||
// Without this fix the DuplicateAddress validation error was dead code (the
|
||||
// post-collapse dict has unique keys by construction).
|
||||
static void DetectIntraListDuplicates(
|
||||
IEnumerable<BcdTagOptions> source, string sourceName, List<BcdError> errors)
|
||||
{
|
||||
var seen = new HashSet<ushort>();
|
||||
foreach (var tag in source)
|
||||
{
|
||||
if (!seen.Add(tag.Address))
|
||||
{
|
||||
errors.Add(new BcdError(BcdValidationError.DuplicateAddress,
|
||||
$"Address {tag.Address} appears more than once in {sourceName}.",
|
||||
tag.Address));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DetectIntraListDuplicates(global.Global, "Global", errors);
|
||||
if (perPlc?.Add is { } addListForDup)
|
||||
DetectIntraListDuplicates(addListForDup, "PerPlc.Add", errors);
|
||||
|
||||
// ── Step 1: collect the working set keyed by address ─────────────────
|
||||
// Dictionary preserves last-write-wins semantics for the Add override.
|
||||
var working = new Dictionary<ushort, BcdTagOptions>(global.Global.Count);
|
||||
@@ -82,7 +108,6 @@ public static class BcdTagMapBuilder
|
||||
// ── Step 4: validate the resolved list ───────────────────────────────
|
||||
// We build a validated-entries list; only clean entries go into the map.
|
||||
var validated = new Dictionary<ushort, BcdTag>(working.Count);
|
||||
var seenAddresses = new HashSet<ushort>(working.Count);
|
||||
|
||||
foreach (var (addr, opt) in working)
|
||||
{
|
||||
@@ -94,14 +119,6 @@ public static class BcdTagMapBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
// Duplicate address check.
|
||||
if (!seenAddresses.Add(addr))
|
||||
{
|
||||
errors.Add(new BcdError(BcdValidationError.DuplicateAddress,
|
||||
$"Address {addr} appears more than once in the resolved tag list.", addr));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 11 — resolve the effective per-tag cache TTL:
|
||||
// explicit per-tag (including 0) wins; otherwise fall back to per-PLC default.
|
||||
int resolvedTtl = opt.CacheTtlMs ?? perPlcDefaultCacheTtlMs;
|
||||
@@ -111,6 +128,10 @@ public static class BcdTagMapBuilder
|
||||
}
|
||||
|
||||
// High-register collision check (only meaningful for 32-bit entries).
|
||||
// Phase 12 (W2.12) — dedupe symmetric reports. Two 32-bit tags whose pairs collide
|
||||
// (e.g. (100,W=32) and (101,W=32)) would otherwise produce two BcdErrors — one
|
||||
// from each direction. Track reported (low,high) pairs so each collision logs once.
|
||||
var reportedCollisions = new HashSet<(ushort, ushort)>();
|
||||
foreach (var tag in validated.Values)
|
||||
{
|
||||
if (!tag.IsThirtyTwoBit)
|
||||
@@ -119,6 +140,13 @@ public static class BcdTagMapBuilder
|
||||
ushort highReg = tag.HighRegister;
|
||||
if (validated.TryGetValue(highReg, out var collision))
|
||||
{
|
||||
// Canonicalise the pair so (a,b) and (b,a) collapse.
|
||||
var pair = tag.Address < collision.Address
|
||||
? (tag.Address, collision.Address)
|
||||
: (collision.Address, tag.Address);
|
||||
if (!reportedCollisions.Add(pair))
|
||||
continue;
|
||||
|
||||
errors.Add(new BcdError(BcdValidationError.OverlappingHighRegister,
|
||||
$"32-bit BCD tag at address {tag.Address} has its high register " +
|
||||
$"({highReg}) colliding with the entry at address {collision.Address}.",
|
||||
|
||||
Reference in New Issue
Block a user