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>
7.7 KiB
Phase 01 — Simulator harness
Wrap the existing pymodbus profile at ../../DL260/dl205.json as a managed lifecycle for xUnit tests. After this phase, any test class that declares [Collection(nameof(DL205SimulatorCollection))] gets a running pymodbus server on a known port, with skip-safe behaviour when Python is unavailable.
Depends on: Phase 00 (test project exists).
Parallel-safe with: Phase 02, Phase 03. (Touches only tests/sim/ and tests/Mbproxy.Tests/Sim/. Disjoint from codec and proxy work.)
Goal
Eliminate "did the simulator start?" as a source of flaky tests. Encode the launch / readiness-probe / shutdown / cleanup contract once, in a fixture, so phases 03 / 04 / 05 / 06 / 07 don't each reinvent it. Tests must be able to declare a dependency on the simulator and get a hot port back, OR get a clean skip if the environment can't provide one.
Outputs
tests/sim/run-dl205-sim.ps1 # idempotent launcher; venv-provisioning
tests/sim/README.md # how to run the simulator standalone
tests/Mbproxy.Tests/Sim/DL205SimulatorFixture.cs
tests/Mbproxy.Tests/Sim/DL205SimulatorCollection.cs
tests/Mbproxy.Tests/Sim/SimulatorSmokeTests.cs # connects, sends FC03, verifies a seeded BCD register
Modifications:
.gitignorealready hastests/sim/.venv/from phase 00 — verify it's present.tests/Mbproxy.Tests/Mbproxy.Tests.csproj— addNModbusPackageReference (chosen for its small footprint and net10.0 compatibility; record the choice as a top-of-csproj comment). This is the Modbus TCP client used by tests against the simulator from this phase forward.
No other files.
Tasks
tests/sim/run-dl205-sim.ps1— pure PowerShell. Parameters:-Profile <path>(default../DL260/dl205.jsonrelative to script),-Port <int>(default 5020). Behaviour:- If
tests/sim/.venvdoesn't exist:python -m venv tests/sim/.venv, thentests/sim/.venv/Scripts/pip.exe install "pymodbus[server]"pinned to a known version (record version in the script + README). - Activate the venv (
& tests/sim/.venv/Scripts/activate.ps1). - Exec
pymodbus.server run --modbus-config-path <Profile> --modbus-server tcp --port <Port>. Output streams to stdout/stderr; on script termination, the child server dies with it. - Exit codes: 0 on clean exit, 1 on venv provisioning failure, 2 on pymodbus launch failure, 3 if the profile file is missing.
- If
DL205SimulatorFixture : IAsyncLifetime—InitializeAsync: pick a free local port (bind/release aTcpListeneronIPEndPoint.Any:0, capture the port, dispose). Spawnpwsh -NoProfile -File <run-dl205-sim.ps1> -Port <picked>viaSystem.Diagnostics.ProcesswithRedirectStandardOutput/Error. Pollnew TcpClient().ConnectAsync("127.0.0.1", port)at 100 ms intervals for up to 10 s. If the simulator never accepts a connection, capture stderr tail, setSkipReason, and dispose the process.DisposeAsync: send Ctrl-C to the process group (Process.Kill(entireProcessTree: true)on Windows is the pragmatic choice — pymodbus handles SIGTERM gracefully but Windows lacks proper signals; document the tradeoff in a comment). Wait up to 5 s for exit.- Public surface:
string Host { get; }(always127.0.0.1),int Port { get; },string? SkipReason { get; },string LogTail { get; }(last ~50 lines of stderr, for diagnosis).
DL205SimulatorCollection—Tests that need the fixture declare[CollectionDefinition(nameof(DL205SimulatorCollection))] public sealed class DL205SimulatorCollection : ICollectionFixture<DL205SimulatorFixture> { }[Collection(nameof(DL205SimulatorCollection))].SimulatorSmokeTests—[Collection(nameof(DL205SimulatorCollection))] [Trait("Category", "E2E")]. Three tests:Simulator_AcceptsTcpConnectionSimulator_FC03_ReturnsSeededValue_AtHR0_0xCAFE— reads register 0, expects0xCAFE(the seeded marker fromdl205.json). Uses NModbus directly. This proves the dl205.json profile is in fact loaded.Simulator_FC03_ReturnsBCD_RawValueAtHR1072_0x1234— reads register 1072, expects raw0x1234(= 4660). This is the BCD register the proxy will rewrite later; phase 04's e2e test will read the SAME register through the proxy and assert 1234 instead.
tests/sim/README.md— a few lines: "Runpwsh ./run-dl205-sim.ps1 -Port 5020to launch the simulator standalone. Used by xUnit tests viaDL205SimulatorFixture. Requires Python 3.10+; the script provisions a venv on first run."
Public surface declared in this phase
namespace Mbproxy.Tests.Sim;
public sealed class DL205SimulatorFixture : IAsyncLifetime {
public string Host { get; }
public int Port { get; }
public string? SkipReason { get; }
public string LogTail { get; }
public Task InitializeAsync();
public Task DisposeAsync();
}
[CollectionDefinition(nameof(DL205SimulatorCollection))]
public sealed class DL205SimulatorCollection : ICollectionFixture<DL205SimulatorFixture> { }
No production code is added in this phase.
Tests required
Unit (Category = Unit)
None in this phase. The fixture itself is a test-infrastructure component; its correctness is verified by the e2e smoke tests below.
E2E (Category = E2E)
Simulator_AcceptsTcpConnection— open a TCP socket tofixture.Host:fixture.Portwithin the fixture lifetime.Simulator_FC03_ReturnsSeededValue_AtHR0_0xCAFE— NModbus FC03, asserts0xCAFE.Simulator_FC03_ReturnsBCD_RawValueAtHR1072_0x1234— NModbus FC03, asserts raw0x1234(4660).
When SkipReason is set, all three skip with Assert.Skip(fixture.SkipReason). The phase gate explicitly verifies that on a machine WITH Python+pymodbus, none of them skip — skips are an environment failure, not a test pass.
Phase gate
pwsh tests/sim/run-dl205-sim.ps1 -Port 5020standalone — script provisions a venv on first run, server logs "Modbus TCP server listening" within 10 s, Ctrl-C exits cleanly.- On second run: venv exists, script skips provisioning, server starts in < 2 s.
- On a machine WITHOUT Python:
SkipReasonis non-null and tests skip rather than fail. - On a machine WITH Python:
SkipReasonis null, all three e2e smoke tests pass. dotnet test --filter Category=E2Eis green on the dev machine.dotnet test --filter Category!=E2Estill green (no regression to phase 00's tests).- Build zero-warnings.
tests/sim/README.mddocuments the manual launch path.
Out of scope
- Multiple simultaneous simulators (one fixture instance is enough for all e2e tests via
ICollectionFixture). - Alternate profiles selected via
MODBUS_SIM_PROFILEenv var — defer until phase 04 actually needs a partial-overlap scenario; add the env-var support then. - A C# pymodbus replacement / in-process Modbus mock. The pymodbus profile is the source of truth for DL-series quirks and we're not duplicating it.
- pip-mirror or offline-install support. CI is expected to have network or a pre-warmed venv; if a customer site needs offline install, that's a deployment concern (phase 08).
Notes for the subagent
- Capture the chosen
pymodbusversion pin in bothrun-dl205-sim.ps1andtests/sim/README.mdso the version isn't lost across re-provisioning. - The free-port-picker pattern (bind on
:0, capture port, dispose, then hand the port to the child process) has an inherent TOCTOU race — another process could grab the port between dispose and pymodbus binding. In practice this is rare; acceptable for tests. Note the trade-off in a comment. - Pymodbus log output is verbose. Pipe it through a line buffer; only the last ~50 lines need to be available via
LogTailfor diagnosis. - Do not commit the
.venv/directory.