0308490aef
Resolves the remaining Minor items from the 2026-05-15 review so the web-UI dashboard work has no open follow-ups: a real-HubConnection end-to-end test for the SignalR feed, stable mbproxy.admin.broadcast.* log-event names, keyboard/aria accessibility on the fleet table, frontend JS hardening (URL-decode guard, NaN guards, shared util.js), reconciler<->capture-registry coverage, throwing-sink and embedded-asset tests, broadcaster polish, and a soft upper bound on AdminPushIntervalMs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
433 lines
17 KiB
C#
433 lines
17 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,
|
|
Mbproxy.Proxy.TagCaptureRegistry? captureRegistry = null)
|
|
{
|
|
return new ConfigReconciler(
|
|
monitor,
|
|
NullLoggerFactory.Instance,
|
|
counters ?? new ServiceCounters(),
|
|
captureRegistry ?? new Mbproxy.Proxy.TagCaptureRegistry());
|
|
}
|
|
|
|
// 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>
|
|
/// Stress-tests the live supervisor dictionary and the coalescing-accessor wiring.
|
|
/// Many concurrent Apply calls drive add/remove of many distinct PLCs; the inner
|
|
/// Task.WhenAll continuations must not corrupt the dictionary or crash with
|
|
/// KeyNotFoundException or ArgumentException. The test asserts: all applies
|
|
/// complete, no exceptions are thrown, and the reload counter is exactly the
|
|
/// apply count.
|
|
/// </summary>
|
|
[Fact(Timeout = 30_000)]
|
|
public async Task Apply_ManyConcurrentReloads_With_PlcChurn_NoCorruption()
|
|
{
|
|
// Empty initial — first Apply will Add all PLCs.
|
|
var initial = MakeOptions([]);
|
|
var monitor = new FakeOptionsMonitor(initial);
|
|
|
|
var supervisors = new System.Collections.Concurrent.ConcurrentDictionary<string, PlcListenerSupervisor>(StringComparer.Ordinal);
|
|
|
|
var counters = new ServiceCounters();
|
|
var reconciler = BuildReconciler(monitor, counters);
|
|
_reconcilers.Add(reconciler);
|
|
reconciler.Attach(supervisors, initial);
|
|
|
|
// Build 8 different option snapshots, each a different PLC roster.
|
|
// Each Apply will trigger Add+Remove churn against the live supervisor dict —
|
|
// exactly the path that the ConcurrentDictionary guards against corruption.
|
|
const int snapshots = 8;
|
|
const int plcsPerSnapshot = 4;
|
|
var snaps = new MbproxyOptions[snapshots];
|
|
var allPlcs = new List<PlcOptions>();
|
|
for (int s = 0; s < snapshots; s++)
|
|
{
|
|
var plcsForSnap = new PlcOptions[plcsPerSnapshot];
|
|
for (int p = 0; p < plcsPerSnapshot; p++)
|
|
{
|
|
plcsForSnap[p] = MakePlc($"PLC-{s}-{p}", PickFreePort());
|
|
allPlcs.Add(plcsForSnap[p]);
|
|
}
|
|
snaps[s] = MakeOptions(plcsForSnap);
|
|
}
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(25));
|
|
|
|
// Fire 16 concurrent applies cycling through the 8 snapshots so each is
|
|
// submitted twice. Inner per-PLC Task.WhenAll continuations run in parallel
|
|
// and stress-test the dictionary mutation safety.
|
|
var tasks = Enumerable.Range(0, 16)
|
|
.Select(i => Task.Run(() => reconciler.ApplyAsync(snaps[i % snapshots], cts.Token), cts.Token))
|
|
.ToArray();
|
|
|
|
var results = await Task.WhenAll(tasks);
|
|
|
|
Assert.All(results, r => Assert.True(r, "every Apply must succeed"));
|
|
Assert.Equal(16, counters.ReloadAppliedCount);
|
|
|
|
// Final dictionary state: all keys present must come from the last-applied snapshot.
|
|
// The "last-applied snapshot" depends on scheduling so we just verify NO orphan
|
|
// entries — every supervisor in the dict must correspond to some snapshot's PLCs.
|
|
var validNames = new HashSet<string>(allPlcs.Select(p => p.Name));
|
|
foreach (var name in supervisors.Keys)
|
|
Assert.Contains(name, validNames);
|
|
|
|
// Track supervisors for cleanup.
|
|
foreach (var s in supervisors.Values)
|
|
_supervisors.Add(s);
|
|
}
|
|
|
|
// ── Test 6: Reconciler ↔ TagCaptureRegistry wiring ────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// The reconciler owns the tag-capture lifecycle for hot-reload: a PLC added by a
|
|
/// reload must get a capture entry (<c>GetOrCreate</c>), and a PLC removed by a
|
|
/// reload must have its capture entry dropped (<c>Remove</c>). Holds a real
|
|
/// <see cref="Mbproxy.Proxy.TagCaptureRegistry"/> and asserts <c>TryGet</c> tracks
|
|
/// the roster across an add reload and a remove reload.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Apply_AddThenRemovePlc_TagCaptureRegistryTracksRoster()
|
|
{
|
|
int portA = PickFreePort();
|
|
int portB = PickFreePort();
|
|
|
|
var plcA = MakePlc("A", portA);
|
|
var plcB = MakePlc("B", portB);
|
|
var initial = MakeOptions([plcA]);
|
|
var withB = MakeOptions([plcA, plcB]);
|
|
|
|
var supA = BuildSupervisor(plcA);
|
|
_supervisors.Add(supA);
|
|
await supA.StartAsync(CancellationToken.None);
|
|
|
|
var supervisors = new ConcurrentDictionary<string, PlcListenerSupervisor>(StringComparer.Ordinal)
|
|
{
|
|
["A"] = supA,
|
|
};
|
|
|
|
var registry = new Mbproxy.Proxy.TagCaptureRegistry();
|
|
var monitor = new FakeOptionsMonitor(initial);
|
|
var reconciler = BuildReconciler(monitor, captureRegistry: registry);
|
|
_reconcilers.Add(reconciler);
|
|
reconciler.Attach(supervisors, initial);
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
|
|
// Reload that adds PLC-B → the registry must gain a capture for B.
|
|
Assert.True(await reconciler.ApplyAsync(withB, cts.Token));
|
|
Assert.True(registry.TryGet("B", out _), "adding PLC-B must create its tag-value capture");
|
|
_supervisors.Add(supervisors["B"]);
|
|
|
|
// Reload that removes PLC-B → the registry must drop B's capture.
|
|
Assert.True(await reconciler.ApplyAsync(initial, cts.Token));
|
|
Assert.False(registry.TryGet("B", out _), "removing PLC-B must drop its tag-value capture");
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
}
|
|
}
|