Files
wwtools/mbproxy
Joseph Doherty 59d0b5deb9 mbproxy: Wave 5 — fixes from third re-review pass
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>
2026-05-14 07:13:47 -04:00
..

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 A decodes as decimal = high * 10_000 + low where low is the register at A and high is the register at A+1. Each word independently must be 09999. 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 in docs/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.WindowsServices and 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.ps1 requires elevation).
  • No COM dependency — this is a pure .NET 10 socket-level proxy (unlike the .NET Framework 4.8 / x86 siblings 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

Features

Operations

Reference

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.md is the source of truth.
  • When the service's public surface or task→tool mapping changes, update this README and the root ../CLAUDE.md index row.