mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the production-ready single-listener / 1:1-backend transparent Modbus TCP proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260 fleet. Phase 9 replaces the connection layer with a single backend socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's 4-concurrent-client cap as an operational ceiling. Phase 9 additions of note: - PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap - InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing for Phase 10 read coalescing — do not collapse to a single field) - Per-request watchdog: surfaces Modbus exception 0x0B to upstream on BackendRequestTimeoutMs, defending against lost responses, dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed- request bug (its ServerRequestHandler.last_pdu state race) - Status DTO + HTML gain inFlight / maxInFlight / txIdWraps / disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md) Tests: 263 unit + 38 E2E. Multiplexer correctness under truly concurrent backend traffic is proved against a stub backend in PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus 3.13's single-PDU framer stays in known-good mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
using Mbproxy.Options;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="MbproxyOptions"/> binds correctly from
|
||||
/// <see cref="IConfiguration"/> and that schema-level validation fires.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MbproxyOptionsBindingTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Helper: build MbproxyOptions directly from an in-memory configuration.
|
||||
// We configure the DI container with IConfiguration so BindConfiguration works.
|
||||
// -------------------------------------------------------------------------
|
||||
private static MbproxyOptions BindOptions(Dictionary<string, string?> values)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
// Register IConfiguration so BindConfiguration("Mbproxy") can resolve it.
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
services
|
||||
.AddOptions<MbproxyOptions>()
|
||||
.BindConfiguration("Mbproxy");
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
return provider.GetRequiredService<IOptionsMonitor<MbproxyOptions>>().CurrentValue;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 1 — global BCD tags bind correctly
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void MbproxyOptionsBinding_BindsGlobalBcdTags_From_appsettings()
|
||||
{
|
||||
var options = BindOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
|
||||
["Mbproxy:BcdTags:Global:0:Width"] = "16",
|
||||
["Mbproxy:BcdTags:Global:1:Address"] = "1080",
|
||||
["Mbproxy:BcdTags:Global:1:Width"] = "32",
|
||||
});
|
||||
|
||||
options.BcdTags.Global.Count.ShouldBe(2);
|
||||
options.BcdTags.Global[0].Address.ShouldBe((ushort)1072);
|
||||
options.BcdTags.Global[0].Width.ShouldBe((byte)16);
|
||||
options.BcdTags.Global[1].Address.ShouldBe((ushort)1080);
|
||||
options.BcdTags.Global[1].Width.ShouldBe((byte)32);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 2 — per-PLC Add and Remove override lists bind correctly
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void MbproxyOptionsBinding_BindsPerPlcAddAndRemove()
|
||||
{
|
||||
var options = BindOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:Plcs:0:Name"] = "Line1-Mixer",
|
||||
["Mbproxy:Plcs:0:ListenPort"] = "5020",
|
||||
["Mbproxy:Plcs:0:Host"] = "10.0.1.1",
|
||||
["Mbproxy:Plcs:0:BcdTags:Add:0:Address"] = "1200",
|
||||
["Mbproxy:Plcs:0:BcdTags:Add:0:Width"] = "32",
|
||||
["Mbproxy:Plcs:0:BcdTags:Remove:0"] = "1080",
|
||||
});
|
||||
|
||||
options.Plcs.Count.ShouldBe(1);
|
||||
var plc = options.Plcs[0];
|
||||
plc.Name.ShouldBe("Line1-Mixer");
|
||||
plc.ListenPort.ShouldBe(5020);
|
||||
plc.Host.ShouldBe("10.0.1.1");
|
||||
plc.BcdTags.ShouldNotBeNull();
|
||||
plc.BcdTags!.Add.Count.ShouldBe(1);
|
||||
plc.BcdTags.Add[0].Address.ShouldBe((ushort)1200);
|
||||
plc.BcdTags.Add[0].Width.ShouldBe((byte)32);
|
||||
plc.BcdTags.Remove.Count.ShouldBe(1);
|
||||
plc.BcdTags.Remove[0].ShouldBe((ushort)1080);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 3 — defaults apply when the "Mbproxy" section is absent
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void MbproxyOptionsBinding_DefaultsAreApplied_WhenSectionMissing()
|
||||
{
|
||||
var options = BindOptions(new Dictionary<string, string?>());
|
||||
|
||||
options.AdminPort.ShouldBe(8080);
|
||||
options.Connection.BackendConnectTimeoutMs.ShouldBe(3000);
|
||||
options.Connection.BackendRequestTimeoutMs.ShouldBe(3000);
|
||||
options.Resilience.BackendConnect.MaxAttempts.ShouldBe(3);
|
||||
options.Resilience.BackendConnect.BackoffMs.ShouldBe([100, 500, 2000]);
|
||||
options.Resilience.ListenerRecovery.SteadyStateMs.ShouldBe(30000);
|
||||
options.Resilience.ListenerRecovery.InitialBackoffMs.ShouldBe([1000, 2000, 5000, 15000, 30000]);
|
||||
options.Plcs.ShouldBeEmpty();
|
||||
options.BcdTags.Global.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 4 — validator rejects Width != 16 && != 32 (schema-level only)
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void MbproxyOptionsBinding_RejectsInvalidWidth()
|
||||
{
|
||||
// Build options with an invalid Width=8.
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
|
||||
["Mbproxy:BcdTags:Global:0:Width"] = "8", // invalid: not 16 or 32
|
||||
})
|
||||
.Build();
|
||||
|
||||
// Get<T> creates a new instance and populates it — works with init-only properties.
|
||||
var options = config.GetSection("Mbproxy").Get<MbproxyOptions>() ?? new MbproxyOptions();
|
||||
|
||||
// Call the validator directly to check schema-level rejection.
|
||||
var validator = new MbproxyOptionsValidator();
|
||||
var result = validator.Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeTrue("Width=8 should fail schema validation");
|
||||
result.Failures.ShouldNotBeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user