Files
wwtools/mbproxy
Joseph Doherty ce32c5cee8 mbproxy: Wave 1 fixes from 2026-05-14 code review
Resolves the four critical correctness defects + the ShutdownCoordinator
double-stop ordering bug called out in codereviews/2026-05-14/Overview.md.
Tests: 362 pass / 0 fail (baseline 358 + 4 new W1 regression tests).

W1.1 — Context swap on running multiplexer.
  PlcMultiplexer._ctx becomes volatile with a new ReplaceContext() method
  that re-registers the cache stats provider on the (preserved) counters.
  PlcListener exposes its multiplexer; PlcListenerSupervisor.ReplaceContextAsync
  swaps the running mux first, then disposes the old cache. Hot-reload
  tag-list changes and the cache-flush-on-reload contract now actually take
  effect on the next PDU instead of waiting for the next listener fault.

W1.2 — Coalescing factory leak.
  When the InFlightByKey factory soft-fails (allocator saturation or duplicate
  TxId), the cleanup path now TryRemoves the stub and walks every party on it
  (including late attachers) to deliver Modbus exception 0x04. Previously
  only the leader got the exception; late attachers waited forever for a
  response that no backend round-trip would ever fire.

W1.3 — Backend-reader head-of-line block.
  UpstreamPipe gains TrySendResponse for non-blocking enqueue. The per-PLC
  backend reader's fan-out loop uses it instead of awaiting SendResponseAsync,
  so a wedged upstream's full bounded response channel can no longer stall
  the single backend reader and starve every other client on that PLC. New
  responseDropForFullUpstream counter on ProxyCounters / CounterSnapshot
  records the drops.

W1.4 — Stranded outbound frames after cascade.
  TearDownBackendAsync acquires _connectGate and drains any frames left in
  _outboundChannel after the writer task faulted/cancelled, releasing their
  proxy TxIds back to the allocator. Without this, a fresh
  EnsureBackendConnectedAsync racing the cascade would send stranded frames
  with old TxIds onto the new backend socket; the responses would arrive
  with no correlation entry and the upstream peers would hang on the
  watchdog until BackendRequestTimeoutMs.

W1.5 — Delete ShutdownCoordinator (Option B).
  Drain logic moved into ProxyWorker.StopAsync. AdminEndpointHost is no
  longer registered as IHostedService; ProxyWorker drives its lifecycle
  directly so admin starts after listeners are bound and stops AFTER the
  in-flight drain (the design's documented contract). Admin is resolved
  lazily in ExecuteAsync to break the circular DI graph
  (Admin -> StatusSnapshotBuilder -> ProxyWorker). GracefulShutdownTimeoutMs
  is now read fresh from IOptionsMonitor.CurrentValue at stop time, so a
  hot-reloaded value is honoured. Removes ShutdownCoordinator + tests.

New tests:
  PlcMultiplexerTests.ReplaceContext_NewTagMap_VisibleOnNextPdu
  PlcMultiplexerTests.ReplaceContext_NewCache_NextReadGoesToBackend_NotOldCache
  UpstreamPipeTests.TrySendResponse_WhenChannelFull_ReturnsFalse_WithoutBlocking
  UpstreamPipeTests.TrySendResponse_AfterDispose_ReturnsFalse

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 05:16:13 -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.

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.