Files
wwtools/mbproxy
Joseph Doherty 53f842a655 mbproxy: close all 5 race-hard W3 test gaps from 2026-05-14 review
Closes the 5 deterministically-race-hard test gaps that were previously
documented as known omissions (#5–9 in codereviews/2026-05-14/RemediationPlan.md).
Tests: 387 pass / 0 fail (baseline 382 + 5 new race tests). Three back-to-back
runs in isolation all green — no observable flakes.

Each test reaches the relevant code path deterministically by either:
  - reaching into the multiplexer's private state via reflection (only used
    for pre-saturating the TxIdAllocator — the test path that's externally
    impossible to hit otherwise without spawning 65,536 real connections),
  - constructing a backend stub that exercises the timing window directly, or
  - asserting only the externally-observable contract that holds across all
    valid interleavings (no-double-delivery, no-hang) rather than asserting
    a specific ordering that flakes.

W3 #5 — TxIdAllocator_Saturated_NextRequest_GetsException04_WithOriginalTxId
  Pre-saturates the multiplexer's _allocator via reflection (TryAllocate
  ×65536), then sends one FC06 write. The next request hits the
  !_allocator.TryAllocate branch immediately and the test verifies exception
  04 with the original TxId echoed.

W3 #6 — TxIdAllocator_Saturated_TwoConcurrentIdenticalReads_BothPipesGetException04
  Pre-saturates the allocator, then fires two concurrent identical FC03 reads
  from two pipes. Both pipes must receive exception 04 — regardless of whether
  pipe B coalesces onto pipe A's stub (W1.2's deliver-to-late-attachers path)
  OR opens its own factory failure path. The contract verified is "no late
  attacher hangs" — the externally-observable invariant from the W1.2 fix.

W3 #7 — SlowUpstream_DoesNotStallPeerResponses_DropCounterIncrements
  Wedges upstream A by leaving its socket-receive side undrained, pumps 500
  FC03 requests through A so the bounded response channel + kernel buffer
  fill, then sends one request from a healthy upstream B. B's response must
  arrive within seconds (would block forever pre-W1.3) and A's
  ResponseDropForFullUpstream counter must increment — proving the W1.3
  TrySendResponse non-blocking fan-out works as designed.

W3 #8 — WatchdogVsResponse_Race_AlwaysExactlyOneOutcome_PerRequest
  Custom SlowResponseBackend stub responds at a randomized 350–450 ms delay
  while BackendRequestTimeoutMs=400. Across 30 iterations, the request races
  the watchdog's per-tick scan. The contract asserts: every request gets
  exactly ONE response (normal or exception 0x0B), the original TxId is
  always echoed, and BOTH branches are exercised (proving the race window is
  real). The W1 claim-then-dispatch design (CorrelationMap.TryRemove as the
  single source of truth) makes this contract hold across all interleavings.

W3 #9 — CascadeVsNewAccept_StressChurn_NoCrash_NoHang
  Stress-test: 3 cascade cycles, 8 concurrent connect+request tasks per
  cycle. Backend is killed mid-cascade-storm to force teardown to race the
  in-flight connect attempts. After all churn the multiplexer must still
  serve a normal request. The originally-flagged race (a pipe added between
  _pipes.Values.ToArray() and _pipes.Clear() in TearDownBackendAsync) is
  microseconds wide and not deterministically reproducible without test
  seams; this stress test instead proves the no-crash-under-churn property
  that operators care about.

Helpers added:
  DrainAllocator(PlcMultiplexer) — reflection-based saturation primitive,
    only used by tests #5 and #6.
  SlowResponseBackend — backend stub with caller-supplied per-request delay
    via a Func<int>, only used by test #8.

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