Files
wwtools/mbproxy/tests/Mbproxy.Tests/Configuration/ConfigReconcilerTests.cs
T
Joseph Doherty e66b17fe5f 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>
2026-05-14 05:48:44 -04:00

318 lines
12 KiB
C#

using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using Mbproxy;
using Mbproxy.Configuration;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Supervision;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Polly;
using Xunit;
namespace Mbproxy.Tests.Configuration;
/// <summary>
/// Unit tests for <see cref="ConfigReconciler.ApplyAsync"/> using a fake
/// <see cref="IOptionsMonitor{T}"/> and real (but fast-recovery) supervisors.
/// Tests operate at the Apply level — no file I/O, no real config reload chain.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ConfigReconcilerTests : IAsyncDisposable
{
// ── Helpers ───────────────────────────────────────────────────────────────────────────
private static int PickFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
int port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
private static PlcOptions MakePlc(string name, int listenPort, string host = "127.0.0.1")
=> new() { Name = name, ListenPort = listenPort, Host = host, Port = 502 };
private static MbproxyOptions MakeOptions(PlcOptions[] plcs, BcdTagListOptions? global = null)
=> new()
{
Plcs = plcs,
BcdTags = global ?? new BcdTagListOptions(),
AdminPort = 8080,
};
private static ResiliencePipeline FastRecovery()
{
var profile = new RecoveryProfile { InitialBackoffMs = [50, 50], SteadyStateMs = 50 };
return PolicyFactory.BuildListenerRecovery(profile, NullLogger.Instance);
}
private PlcListenerSupervisor BuildSupervisor(PlcOptions plc)
{
ILoggerFactory lf = NullLoggerFactory.Instance;
return new PlcListenerSupervisor(
plc,
new ConnectionOptions(),
new NoopPduPipeline(),
lf.CreateLogger<PlcListener>(),
lf.CreateLogger<Mbproxy.Proxy.Multiplexing.PlcMultiplexer>(),
lf.CreateLogger($"Mbproxy.Proxy.UpstreamPipe.{plc.Name}"),
perPlcContext: null,
FastRecovery(),
lf.CreateLogger<PlcListenerSupervisor>(),
backendConnectPipeline: null);
}
private ConfigReconciler BuildReconciler(
IOptionsMonitor<MbproxyOptions> monitor,
ServiceCounters? counters = null)
{
return new ConfigReconciler(
monitor,
NullLoggerFactory.Instance,
counters ?? new ServiceCounters());
}
// The reconciler and supervisors tracked for cleanup.
private readonly List<ConfigReconciler> _reconcilers = [];
private readonly List<PlcListenerSupervisor> _supervisors = [];
public async ValueTask DisposeAsync()
{
foreach (var r in _reconcilers) r.Dispose();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
foreach (var s in _supervisors)
{
try { await s.StopAsync(cts.Token); } catch { /* best effort */ }
await s.DisposeAsync();
}
}
// ── Test 1: Happy path ────────────────────────────────────────────────────────────────
[Fact]
public async Task Apply_HappyPath_StartsAndStopsSupervisors_PerPlan()
{
int portA = PickFreePort();
int portB = PickFreePort();
var plcA = MakePlc("A", portA);
var initial = MakeOptions([plcA]);
var next = MakeOptions([plcA, MakePlc("B", portB)]);
// Build initial supervisor for A.
var supA = BuildSupervisor(plcA);
_supervisors.Add(supA);
await supA.StartAsync(CancellationToken.None);
var supervisors = new System.Collections.Concurrent.ConcurrentDictionary<string, PlcListenerSupervisor>(StringComparer.Ordinal)
{
["A"] = supA,
};
var monitor = new FakeOptionsMonitor(initial);
var reconciler = BuildReconciler(monitor);
_reconcilers.Add(reconciler);
reconciler.Attach(supervisors, initial);
// Apply a config that adds PLC-B.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
bool applied = await reconciler.ApplyAsync(next, cts.Token);
Assert.True(applied, "Apply should succeed for a valid config");
// The supervisor dictionary must now contain both A and B.
Assert.True(supervisors.ContainsKey("A"), "Supervisor A should still exist");
Assert.True(supervisors.ContainsKey("B"), "Supervisor B should have been added");
_supervisors.Add(supervisors["B"]);
}
// ── Test 2: Validation fails → no mutation ────────────────────────────────────────────
[Fact]
public async Task Apply_ValidationFails_NoMutationOccurs_AndLogsRejected()
{
int portA = PickFreePort();
var plcA = MakePlc("A", portA);
var initial = MakeOptions([plcA]);
// Invalid next: duplicate listen port.
var invalid = MakeOptions([plcA, MakePlc("B", portA)]); // port conflict
var supA = BuildSupervisor(plcA);
_supervisors.Add(supA);
await supA.StartAsync(CancellationToken.None);
var supervisors = new System.Collections.Concurrent.ConcurrentDictionary<string, PlcListenerSupervisor>(StringComparer.Ordinal)
{
["A"] = supA,
};
var counters = new ServiceCounters();
var monitor = new FakeOptionsMonitor(initial);
var reconciler = BuildReconciler(monitor, counters);
_reconcilers.Add(reconciler);
reconciler.Attach(supervisors, initial);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
bool applied = await reconciler.ApplyAsync(invalid, cts.Token);
Assert.False(applied, "Apply should return false for invalid config");
// State must NOT have mutated: B must not have been added.
Assert.False(supervisors.ContainsKey("B"), "B must not have been added after rejection");
Assert.Single((IEnumerable<KeyValuePair<string, PlcListenerSupervisor>>)supervisors);
// Rejected counter must have been bumped.
Assert.Equal(1, counters.ReloadRejectedCount);
Assert.Equal(0, counters.ReloadAppliedCount);
}
// ── Test 3: Reseat does NOT restart the supervisor ────────────────────────────────────
[Fact]
public async Task Apply_ReseatTagMap_DoesNotRestartSupervisor()
{
int portA = PickFreePort();
var plcA = MakePlc("A", portA);
var globalBefore = new BcdTagListOptions
{
Global = [new BcdTagOptions { Address = 1072, Width = 16 }],
};
var globalAfter = new BcdTagListOptions
{
Global =
[
new BcdTagOptions { Address = 1072, Width = 16 },
new BcdTagOptions { Address = 1080, Width = 16 },
],
};
var initial = MakeOptions([plcA], global: globalBefore);
var next = MakeOptions([plcA], global: globalAfter);
var supA = BuildSupervisor(plcA);
_supervisors.Add(supA);
await supA.StartAsync(CancellationToken.None);
// Wait until bound.
using var waitCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await supA.WaitForInitialBindAttemptAsync(waitCts.Token);
Assert.Equal(SupervisorState.Bound, supA.Snapshot().State);
var supervisors = new System.Collections.Concurrent.ConcurrentDictionary<string, PlcListenerSupervisor>(StringComparer.Ordinal)
{
["A"] = supA,
};
var monitor = new FakeOptionsMonitor(initial);
var reconciler = BuildReconciler(monitor);
_reconcilers.Add(reconciler);
reconciler.Attach(supervisors, initial);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
bool applied = await reconciler.ApplyAsync(next, cts.Token);
Assert.True(applied);
// The supervisor instance must be the SAME object — no restart.
Assert.Same(supA, supervisors["A"]);
// Supervisor must still be Bound — it was NOT stopped and restarted.
Assert.Equal(SupervisorState.Bound, supA.Snapshot().State);
}
// ── Test 4: Concurrent reloads are serialised ─────────────────────────────────────────
[Fact]
public async Task Apply_ConcurrentReloads_Are_Serialised()
{
// Start with an empty config (no PLCs) so Apply is fast but still real.
var initial = MakeOptions([]);
var monitor = new FakeOptionsMonitor(initial);
// We'll count how many concurrent executions happen simultaneously.
int concurrentPeak = 0;
int inProgress = 0;
var counters = new ServiceCounters();
var reconciler = BuildReconciler(monitor, counters);
_reconcilers.Add(reconciler);
reconciler.Attach(new System.Collections.Concurrent.ConcurrentDictionary<string, PlcListenerSupervisor>(StringComparer.Ordinal), initial);
// Fire 5 concurrent Apply calls — they must execute one-at-a-time.
var opts = MakeOptions([]);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
// Wrap ApplyAsync in a task that measures concurrency.
// We use a short Task.Delay inside to make concurrent calls more visible.
var tasks = Enumerable.Range(0, 5).Select(_ => Task.Run(async () =>
{
// Increment in-progress and capture peak.
int current = Interlocked.Increment(ref inProgress);
Interlocked.Exchange(ref concurrentPeak,
Math.Max(Interlocked.CompareExchange(ref concurrentPeak, 0, 0), current));
await Task.Delay(5, cts.Token); // tiny delay to increase collision chance
bool result = await reconciler.ApplyAsync(opts, cts.Token);
Interlocked.Decrement(ref inProgress);
return result;
}, cts.Token)).ToArray();
var results = await Task.WhenAll(tasks);
// All 5 should have been applied (empty config is always valid).
Assert.All(results, r => Assert.True(r));
// The serialisation check: while the above measurement isn't perfect
// (the Interlocked peak is set before the semaphore wait, not inside),
// the key invariant we verify is that all 5 completed successfully
// without deadlock or exception — proving the semaphore doesn't deadlock
// under concurrent load.
Assert.Equal(5, counters.ReloadAppliedCount);
}
}
/// <summary>
/// Minimal fake <see cref="IOptionsMonitor{T}"/> backed by a fixed value.
/// </summary>
internal sealed class FakeOptionsMonitor : IOptionsMonitor<MbproxyOptions>
{
private MbproxyOptions _value;
private readonly List<Action<MbproxyOptions, string?>> _callbacks = [];
public FakeOptionsMonitor(MbproxyOptions value) => _value = value;
public MbproxyOptions CurrentValue => _value;
public MbproxyOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<MbproxyOptions, string?> listener)
{
_callbacks.Add(listener);
return new DisposableAction(() => _callbacks.Remove(listener));
}
/// <summary>Simulates an appsettings file change notification.</summary>
public void TriggerChange(MbproxyOptions newValue)
{
_value = newValue;
foreach (var cb in _callbacks)
cb(newValue, null);
}
private sealed class DisposableAction(Action action) : IDisposable
{
public void Dispose() => action();
}
}