Closes findings from the third focused re-review pass on the post-W4-followup state (recorded in codereviews/2026-05-14/ReReviewAfterRemediation.md). W5/M1 — AdminEndpointHost OnChange callback can resurrect Kestrel after StopAsync The hot-reload OnChange handler at AdminEndpointHost.StartAsync did fire-and-forget `_ = Task.Run(...)` with no _disposed check. If AdminPort was hot-reloaded during shutdown, the queued Task could land between StopAsync's registration-dispose and DisposeAsync's _lock-dispose, take the lock, and bind a fresh Kestrel WebApplication on the new port — resurrecting admin AFTER the host considered it shut down. Worse, if DisposeAsync had already run _lock.Dispose, the queued Task throws ObjectDisposedException as an unobserved Task exception. Fix: _disposed guard at the top of the OnChange lambda AND inside the queued Task.Run, plus try/catch (ObjectDisposedException) around _lock.WaitAsync and _lock.Release. W5/m2 — inFlightAtCancel computed AFTER base.StopAsync The W4/NC1 fix correctly snapshotted inFlight BEFORE supervisor.StopAsync (so the multiplexers' counter providers were still wired), but it computed the snapshot AFTER base.StopAsync(cancellationToken). Between those two lines, in-flight requests whose responses arrive get removed from _correlation, and the watchdog can clear stale entries. The reported count therefore drifted downward from "in-flight at signal time" to "in-flight at compute time." Fix: snapshot at the very top of StopAsync before any cancellation is propagated. W5/m1 — Cascade gate-not-held path race (accepted as documented best-effort) When TearDownBackendAsync's _connectGate.WaitAsync(2s) times out, the body runs unprotected. A concurrent EnsureBackendConnectedAsync that DOES hold the gate may TryAllocate a TxId that collides (after wraparound in the allocator's forward scan) with one being released by the channel drain. The double-release would mark the new request's slot as free even though it's legitimately in-flight, allowing the next allocation to reuse the same slot and CorrelationMap.TryAdd to fail (silent request drop). Probability is very low (gate timeout AND new accept landing AND TxId collision in 65,536-slot space); the only consequence is one dropped request the client retries. Documented inline at PlcMultiplexer.cs near the gateHeld declaration as accepted best-effort behaviour. W5/m3 — CountInFlight allocates a CounterSnapshot record per supervisor Trivial (~5 KB on a 54-PLC fleet, called once per shutdown). Skipped per re-review verdict. Tests: 387 pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mbproxy
A .NET 10 Windows Service that sits inline as a Modbus TCP proxy in front of a fleet of AutomationDirect DirectLOGIC DL205/DL260 controllers, rewriting BCD-encoded registers bidirectionally so upstream clients can read and write them as plain integers. The proxy also offers an opt-in per-tag response cache (default OFF) for FC03/FC04 reads with bounded operator-configured staleness — see docs/Architecture/ResponseCache.md before enabling it.
⚠ 32-bit BCD wire format is "two base-10000 digits in CDAB", not standard CDAB binary Int32. A 32-bit BCD tag at address
Adecodes asdecimal = high * 10_000 + lowwherelowis the register atAandhighis the register atA+1. Each word independently must be 0–9999. Standard Modbus clients (NModbus, FluentModbus, Wonderware DAServer) that interpret CDAB as straight binary Int32 will silently corrupt any value > 9999 on writes and read garbage on reads. Configure your client to send/receive each register as a separate base-10000 BCD digit pair, not as a single binary Int32. Full details indocs/Features/BcdRewriting.md.
Hard constraints / prerequisites
- Windows 10 / Server 2019 or later, 64-bit. No Linux or Docker support — the service uses
Microsoft.Extensions.Hosting.WindowsServicesand the Windows Event Log. - Modbus TCP backends reachable from the proxy host on port 502 (or the port configured per PLC). The H2-ECOM100 module caps simultaneous connections at 4 per PLC — a fifth upstream client will fail to connect.
- Admin rights to install the service (
install.ps1requires elevation). - No COM dependency — this is a pure .NET 10 socket-level proxy (unlike the
.NET Framework 4.8 / x86siblings in this repo). - Python 3.10+ on the test machine to run the pymodbus-backed E2E simulator (not needed to run the service in production).
Layout
src/Mbproxy/ Main C# project (net10.0, Microsoft.NET.Sdk.Worker)
tests/Mbproxy.Tests/ xUnit v3 test project (314 unit + 48 E2E tests)
install/ PowerShell install/uninstall scripts and config template
docs/ Architecture, features, operations, reference, and testing docs
DL260/ DL205/DL260 reference material and pymodbus simulator profile
Resource index
| Task | Go to |
|---|---|
| End-to-end architectural design (entry point — routes into focused docs below) | docs/design.md |
| Phase-by-phase implementation plan and history | docs/plan/README.md |
| Install, upgrade, uninstall, log file locations, first-install smoke checklist | docs/operations.md |
| Dashboard KPI catalog | docs/kpi.md |
| DL205/DL260 Modbus quirks (BCD, CDAB, octal V-memory, FC limits) | DL260/dl205.md |
| pymodbus simulator profile (register seeds for E2E tests) | DL260/dl205.json |
| Agent-oriented coding guide (architecture bullets, device quirks, phase context) | CLAUDE.md |
Detailed documentation
The docs/ tree is organized by topic. Start with docs/design.md for the canonical end-to-end design; jump to the focused pages below when you need depth on one area.
Architecture
Architecture/Overview.md— Listener topology, request flow, per-PLC isolation.Architecture/ConnectionModel.md— Single backend connection per PLC, TxId multiplexing, request-timeout watchdog, disconnect cascade.Architecture/ReadCoalescing.md— In-flight FC03/FC04 deduplication viaInFlightByKeyMap.Architecture/ResponseCache.md— Opt-in per-tag response cache with bounded operator-configured staleness.
Features
Features/BcdRewriting.md— BCD codec, CDAB word order, FC03/04/06/16 scope, partial-overlap policy.Features/HotReload.md—IOptionsMonitor-driven config reload with per-change-kind reconcile rules.
Operations
Operations/Configuration.md— Fullappsettings.jsonreference: everyMbproxy:*key, default, and validation rule.Operations/StatusPage.md— Admin endpoint surface (/,/status.json) with every JSON field documented.Operations/Troubleshooting.md— Diagnosis playbook keyed to log events and status counters.
Reference
Reference/LogEvents.md— Stablembproxy.*event catalog (28 events across 7 categories).
Testing
Testing/Simulator.md— pymodbus DL205 fixture, skip policy, and the load-bearing pymodbus 3.13 framer quirk.
Build and run
Build (Debug, multi-file — fast for iteration):
dotnet build Mbproxy.slnx -c Debug
Publish (Release, single-file self-contained, win-x64):
dotnet publish src/Mbproxy/Mbproxy.csproj -c Release -r win-x64 --self-contained true -o C:\build\mbproxy-publish
The published output is a single Mbproxy.exe (~100 MB). The self-contained publish bundles the full .NET 10 + ASP.NET Core runtime. No .NET installation is required on the target machine.
Run tests:
dotnet test Mbproxy.slnx -c Debug # all tests
dotnet test Mbproxy.slnx -c Debug --filter Category=Unit # unit tests only (no Python required)
dotnet test Mbproxy.slnx -c Debug --filter Category=E2E # E2E tests (require Python + pymodbus)
Run interactively (without installing as a service):
cd src/Mbproxy
dotnet run --configuration Debug
Edit src/Mbproxy/appsettings.json to configure PLCs before running. The admin status page will be at http://localhost:8080/ by default.
Install
Full detail is in docs/operations.md. Quick path:
# 1. Publish
dotnet publish src/Mbproxy/Mbproxy.csproj -c Release -r win-x64 --self-contained true -o C:\build\mbproxy-publish
# 2. Install (elevated PowerShell)
.\install\install.ps1 -PublishOutput C:\build\mbproxy-publish -Start
# 3. Edit the config that was placed at %ProgramData%\mbproxy\appsettings.json
# 4. Verify
Invoke-WebRequest http://localhost:8080/ -UseBasicParsing
Maintenance
Documentation doctrine for this repo: ../DOCS-GUIDE.md.
- This README routes to deep docs — it does not duplicate them.
- Design decisions:
docs/design.mdis the source of truth. - When the service's public surface or task→tool mapping changes, update this README and the root
../CLAUDE.mdindex row.