Closes the latent + minor + test-discipline items left after Wave 4. Updates
the re-review doc with a final resolution table — every actionable finding
now marked Resolved or Accepted with rationale.
NM3 — _supervisorCts leaks on re-Start
StartAsync now disposes the previous CTS before reassigning. Idempotent:
a try/catch (ObjectDisposedException) covers the very-first-Start case
where the field-init CTS is still fresh.
NM4 — W2.15 TCS is single-shot
_firstAttemptCompleted is no longer readonly; StartAsync re-creates it
after the W2.16 guard so a re-Started supervisor's
WaitForInitialBindAttemptAsync doesn't observe the previous run's signal.
Nm6 — _admin GetService<> returns null silently
ProxyWorker.ExecuteAsync now logs a Warning when admin isn't registered.
Preserves the loud-failure intent from the original IHostedService
registration without forcing test hosts to wire admin.
Nm7 — AdminEndpointHost.DisposeAsync no double-dispose guard
Added a volatile bool _disposed flag with an early-return at the top of
DisposeAsync. Symmetry with PlcMultiplexer; protects against
ProxyWorker.StopAsync explicitly disposing then DI disposing the singleton
again on host shutdown.
T3 — RemoveInheritedAppsettings only fires on Build
AfterTargets="Build;Publish" + a second Delete against $(PublishDir)
so a `dotnet publish` against the test csproj doesn't ship the example
PLCs from the linked install template.
T4 — Stale TryAttachOrCreate_*_ReturnsTrue_* test method names
Renamed to AttachOrCreate_*_WasNew{True,False} after W3 dropped the bool
return.
Accepted (with rationale documented in ReReviewAfterRemediation.md):
Nm2 — CoalescedHit semantic is per-design
Nm4 — _lastBindError preservation on clean exit is intentional forensics
Nm5 — EventLogBridge has no injectable logger
Nm8 — Cosmetic log noise
T1 — Reflection on private fields documented as maintenance trap
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.