a2dba4bd07
When two or more upstream clients send the same FC03/FC04 read while a matching request is already in flight on the same PLC's multiplexed backend socket, attach the late arrivals to the existing InFlightRequest .InterestedParties list instead of opening a second backend round-trip. The single backend response fans out to every attached party with each party's original MBAP TxId restored individually. Zero post-response staleness — coalescing operates entirely within the in-flight window (microseconds to ~10 ms typical); the proxy is NOT a cache layer. Headline mechanism: - New record struct CoalescingKey(UnitId, Fc, StartAddress, Qty) keys the per-PLC InFlightByKeyMap. FC03 and FC04 are separate Modbus tables and never share a key; different unit IDs never coalesce; writes (FC06/FC16) bypass the coalescing path entirely. - InFlightByKeyMap uses a simple lock around a Dictionary; atomic TryAttachOrCreate either appends a new party to the in-flight request's mutable List<InterestedParty> or invokes a factory to build a fresh entry. Per-entry MaxParties cap (default 32) bounds fan-out cost; past the cap, the next arrival opens a new entry. - PlcMultiplexer.OnUpstreamFrameAsync takes the coalescing path for FC03/FC04 when Mbproxy.Resilience.ReadCoalescing.Enabled. The factory closure does the Phase-9 work (allocate TxId, add to CorrelationMap); the channel send happens AFTER returning from TryAttachOrCreate so the map lock is not held across the async send. - Response fan-out in RunBackendReaderAsync removes the entry from InFlightByKeyMap before iterating InterestedParties, ensuring no concurrent attach can mutate the list during iteration. - Cascade + watchdog paths also drain the key map so a stale entry cannot outlive its backend round-trip. Counter accounting balance (per snapshot): CoalescedHitCount + CoalescedMissCount equals total FC03 + FC04 requests since startup. Even with coalescing disabled, every read still bumps Miss so dashboard math stays balanced. New surface (additive only): - src/Mbproxy/Proxy/Multiplexing/CoalescingKey.cs - src/Mbproxy/Proxy/Multiplexing/InFlightByKeyMap.cs - src/Mbproxy/Proxy/Multiplexing/CoalescingLogEvents.cs - ReadCoalescingOptions on ResilienceOptions - CoalescedHitCount / CoalescedMissCount / CoalescedResponseToDeadUpstream counters surfaced on /status.json per PLC and as a compact "Coal" cell on the HTML status page. Phase 9 test patch: TwoUpstreams_ProxyTxIds_AreDistinct_OnTheWire previously read the same register from both clients (which now coalesces). Patched to read two different addresses so the test still proves distinct backend TxIds without violating the coalescing contract. Tests added: 24 new (19 unit + 5 E2E): - CoalescingKeyTests (5) - InFlightByKeyMapTests (6, includes concurrent stress) - ReadCoalescingTests (8, stub-backend with deterministic delay) - ReadCoalescingE2ETests (5, pymodbus simulator; coalescing-active during overlap is proven against the stub, not the sim, due to pymodbus 3.13's known concurrent-frame bug) Total: 325 tests passing (282 unit + 43 E2E). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
3.9 KiB
Markdown
91 lines
3.9 KiB
Markdown
# 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.
|
|
|
|
## 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 (282 unit + 43 E2E tests)
|
|
install/ PowerShell install/uninstall scripts and config template
|
|
docs/ Design document, phase plans, and operations runbook
|
|
DL260/ DL205/DL260 reference material and pymodbus simulator profile
|
|
```
|
|
|
|
## Resource index
|
|
|
|
| Task | Go to |
|
|
|---|---|
|
|
| Full architecture, schema, log events, status counters, test strategy | [`docs/design.md`](docs/design.md) |
|
|
| Phase-by-phase implementation plan | [`docs/plan/README.md`](docs/plan/README.md) |
|
|
| Install, upgrade, config, logs, troubleshooting | [`docs/operations.md`](docs/operations.md) |
|
|
| DL205/DL260 Modbus quirks (BCD, CDAB, octal V-memory, FC limits) | [`DL260/dl205.md`](DL260/dl205.md) |
|
|
| pymodbus simulator profile (register seeds for E2E tests) | [`DL260/dl205.json`](DL260/dl205.json) |
|
|
| Agent-oriented coding guide (architecture bullets, device quirks, phase context) | [`CLAUDE.md`](CLAUDE.md) |
|
|
|
|
## Build and run
|
|
|
|
**Build (Debug, multi-file — fast for iteration):**
|
|
|
|
```powershell
|
|
dotnet build Mbproxy.slnx -c Debug
|
|
```
|
|
|
|
**Publish (Release, single-file self-contained, win-x64):**
|
|
|
|
```powershell
|
|
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:**
|
|
|
|
```powershell
|
|
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):**
|
|
|
|
```powershell
|
|
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`](docs/operations.md). Quick path:
|
|
|
|
```powershell
|
|
# 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`](../DOCS-GUIDE.md).
|
|
|
|
- This README routes to deep docs — it does not duplicate them.
|
|
- Design decisions: [`docs/design.md`](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`](../CLAUDE.md) index row.
|