Fixes every finding from the codereviews/2026-05-16 multi-agent review (2 Critical, 20 Major, 38 Minor) and adds that review to the repo. Highlights: dashboard XSS escape; response cache invalidated on the write request (not just the response); ReloadValidator now runs at startup so port collisions / duplicate names / malformed Resilience profiles fail fast; AdminPort 0 genuinely disables the admin endpoint; PlcListener accept-loop faults propagate to the supervisor's faulted path; reconciler Restart builds before removing; Resilience pipelines are restart-only from a frozen snapshot; multiplexer connect-race leak, watchdog party-list snapshot, backend-response and FC16 framing validation; frontend reconnect retry and util.js load guard; plus the log-event/doc drift sweep and test-port hygiene. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
31 KiB
Configuration Reference
mbproxy binds its runtime configuration from appsettings.json under the Mbproxy section. This document is the full reference for every supported key, its type, default, range, and validation rules.
File Location
The configuration loader resolves appsettings.json relative to the executable.
- Development run (
dotnet run):src/Mbproxy/appsettings.jsonnext to the build output. - Single-file publish (
dotnet publish -c Release -r <rid>):appsettings.jsonnext to the published binary. Awin-x64publish shipsinstall/mbproxy.config.template.json; alinux-x64publish shipsinstall/mbproxy.linux.config.template.json(same keys, Unix log path) — each linked into the bundle asappsettings.json. - Installed as a Windows Service:
%ProgramData%\mbproxy\appsettings.json, seeded byinstall.ps1frommbproxy.config.template.json. - Installed as a systemd unit:
/etc/mbproxy/appsettings.json(the unit'sWorkingDirectory), seeded byinstall.shfrom the Linux template.
In both installed cases the install script copies the template only when no config already exists — an existing file is preserved across reinstalls.
The file is loaded with reloadOnChange: true. All consumers read through IOptionsMonitor<MbproxyOptions>, so a save propagates without restarting the service. See ../Features/HotReload.md for per-key propagation semantics.
The .NET configuration provider accepts // and /* */ comments (JSONC) in appsettings.json when loaded through Host.CreateApplicationBuilder. The install template ships with comments.
Environment variables and command-line arguments are also accepted by the host. Either form can override any Mbproxy:* key; for example, Mbproxy__AdminPort=9090 (double-underscore segment separator) overrides the JSON. Environment overrides are useful for ephemeral diagnostic switches but should not replace the file as the source of truth — ReloadValidator runs against the merged configuration on every reload.
Top-Level Schema
Every supported key under Mbproxy:*, populated to a representative default:
{
"Mbproxy": {
// Global BCD tag list — applies to every PLC unless overridden per-PLC.
"BcdTags": {
"Global": [
{ "Address": 1024, "Width": 16 }, // 16-bit BCD register
{ "Address": 1056, "Width": 32 }, // 32-bit BCD pair (CDAB)
{ "Address": 1088, "Width": 16, "CacheTtlMs": 1000 } // opt-in cache, 1 s TTL
]
},
// One entry per PLC. Each maps an upstream proxy port to a backend Modbus TCP endpoint.
"Plcs": [
{
"Name": "Line1-Mixer",
"ListenPort": 5020,
"Host": "10.0.1.1",
"Port": 502,
"DefaultCacheTtlMs": 0,
"BcdTags": {
"Add": [ { "Address": 1200, "Width": 32 } ],
"Remove": [ 1056 ]
}
}
],
// Read-only HTTP status page. Set to 0 to disable.
"AdminPort": 8080,
// Backend connection / request / shutdown timeouts and keepalive.
"Connection": {
"BackendConnectTimeoutMs": 3000,
"BackendRequestTimeoutMs": 3000,
"GracefulShutdownTimeoutMs": 10000,
"Keepalive": {
"Enabled": true,
"TcpIdleTimeMs": 30000,
"TcpProbeIntervalMs": 5000,
"TcpProbeCount": 4,
"BackendHeartbeatIdleMs": 30000,
"BackendHeartbeatProbeAddress": 0
}
},
// Polly resilience policies.
"Resilience": {
"BackendConnect": {
"MaxAttempts": 3,
"BackoffMs": [ 100, 500, 2000 ]
},
"ListenerRecovery": {
"InitialBackoffMs": [ 1000, 2000, 5000, 15000, 30000 ],
"SteadyStateMs": 30000
},
"ReadCoalescing": {
"Enabled": true,
"MaxParties": 32
}
},
// Response-cache safety knobs. The cache is off by default per tag.
"Cache": {
"AllowLongTtl": false,
"MaxEntriesPerPlc": 1000,
"EvictionIntervalMs": 5000
}
}
}
Serilog configuration is documented in ./Troubleshooting.md and lives outside the Mbproxy section.
The Windows Event Log sink is not the standard
Serilog.Sinks.EventLogpackage. It is a customEventLogBridge(src/Mbproxy/Diagnostics/EventLogBridge.cs) that writes Error+ events to thembproxysource underApplicationonly when the service runs under the SCM. Event Log source registration is intentionally NOT attempted at runtime (the service account may not be admin);install.ps1registers the source at install time. Don't addSerilog.Sinks.EventLog— the bridge would duplicate every event. The bridge caches the source-exists check at construction (Phase 12 / W2.23), so a missing source produces no per-event registry traffic.
Mbproxy.AdminPort
Port for the read-only HTTP status server. Binds to all interfaces on startup.
| Field | Type | Default | Range |
|---|---|---|---|
AdminPort |
int | 8080 |
[1, 65535] |
ReloadValidator rejects values outside [1, 65535] and rejects collisions with any Plcs[i].ListenPort. Source: MbproxyOptions.AdminPort.
The server exposes the SignalR-backed web dashboard (GET /, GET /plc/{name}, GET /assets/{path}, /hub/status) and the JSON twin GET /status.json. See ./StatusPage.md for the endpoint surface and schema.
Authentication is assumed at the network layer (trusted internal segment). The endpoint is read-only — no admin actions are exposed — so the risk surface is limited to status disclosure. Place the admin port behind a firewall rule that allows only operator workstations.
Mbproxy.AdminPushIntervalMs
Server-push cadence (milliseconds) for the admin dashboard's SignalR feed. Every interval StatusBroadcaster builds a status snapshot and pushes it to connected dashboard / detail-page clients.
| Field | Type | Default | Range |
|---|---|---|---|
AdminPushIntervalMs |
int | 1000 |
1–60000 |
MbproxyOptionsValidator and ReloadValidator both reject values outside 1–60000 ms — the upper bound is a soft guard against a typo (e.g. a seconds value pasted as milliseconds) that would make the "live" feed effectively non-live. The broadcaster additionally floors the effective interval at 100 ms. Source: MbproxyOptions.AdminPushIntervalMs.
Mbproxy.Plcs[]
One entry per PLC. The array drives the listener supervisor; on reload, entries added here cause new listeners to bind and entries removed here cause listeners to stop. Source: PlcOptions.cs.
| Field | Type | Default | Required | Notes |
|---|---|---|---|---|
Name |
string | "" |
yes | Non-empty, unique across the array. Shown on the status page and in structured logs as plc. |
ListenPort |
int | 0 |
yes | Port the proxy listens on. [1, 65535]. Unique across the array. Cannot collide with AdminPort. |
Host |
string | "" |
yes | PLC IP address or hostname. |
Port |
int | 502 |
no | Backend Modbus TCP port on the PLC. |
BcdTags |
object | null |
no | Per-PLC overrides on top of Mbproxy.BcdTags.Global. See below. |
DefaultCacheTtlMs |
int | 0 |
no | Fallback TTL in milliseconds for any tag on this PLC whose explicit CacheTtlMs is unset (null). 0 disables caching by default. |
Plcs[i].BcdTags
Per-PLC override block. Resolution: the effective tag list for a PLC is Global ∪ Add − Remove, with Add winning on width when an address appears in both Global and Add. Source: BcdTagListOptions.PlcBcdOverrides.
| Field | Type | Default | Notes |
|---|---|---|---|
Add |
BcdTagOptions[] |
[] |
Tags to append for this PLC. Can override a Global entry's Width by repeating the address. Each entry follows the BcdTagOptions shape (see next section). |
Remove |
ushort[] |
[] |
Addresses to drop from this PLC's effective list. Matches by address. |
The full tag-list resolution algorithm — Add width override semantics, overlap detection, and per-PLC tag-map flushing on reload — is documented in ../Features/BcdRewriting.md.
A subtle case worth pinning down: when an address appears in both Mbproxy.BcdTags.Global[] and Plcs[i].BcdTags.Add[], the per-PLC Add entry wins on Width and CacheTtlMs. This is how a per-PLC width override is expressed (for example, a 16-bit tag globally, promoted to 32-bit on the one PLC that uses the wider format). To strip a global tag from a PLC entirely, use Remove; do not add a same-address entry with Width = 0.
Mbproxy.BcdTags.Global[]
The fleet-wide BCD tag list. Every PLC starts with this set, then applies its per-PLC Add / Remove overrides. Source: BcdTagListOptions.Global, entries of type BcdTagOptions.
| Field | Type | Default | Range | Notes |
|---|---|---|---|---|
Address |
ushort | 0 |
[0, 65535] |
Modbus PDU address (decimal). Address 0 is valid on DL205/DL260 — do not skip it. Octal V-memory addresses must be converted: V2000 octal = decimal 1024. |
Width |
byte | 0 |
{ 16, 32 } |
Bit width. 16 is one register holding 4 BCD digits (0–9999). 32 is a CDAB-ordered register pair at Address (low word) and Address+1 (high word). |
CacheTtlMs |
int? | null |
>= 0, <= 60000 unless Cache.AllowLongTtl = true |
Optional per-tag opt-in to the response cache. null falls back to the PLC's DefaultCacheTtlMs. 0 explicitly disables caching for this tag even when the PLC default is non-zero. |
Name |
string? | null |
free-form | Optional human-friendly label (e.g. "Left AirSP"). Shown on the connection-detail debug view as the row heading. No effect on Modbus rewriting — purely a display aid. |
MbproxyOptionsValidator rejects any entry whose Width is not 16 or 32. See ../Features/BcdRewriting.md for the wire encoding rules and the multi-tag-overlap validation that runs in BcdTagMapBuilder.
Address conversion examples for operators coming from DirectLOGIC ladder:
| V-memory (octal) | Modbus PDU (decimal) |
|---|---|
V2000 |
1024 |
V2040 |
1056 |
V2100 |
1088 |
V2200 |
1152 |
The proxy expects PDU-decimal addresses. Do not use octal V-memory addresses and do not use 1-based 4xxxx Modbus references — both will resolve to the wrong register.
Mbproxy.Connection
Backend connection and shutdown timeouts. Source: ConnectionOptions.cs.
| Field | Type | Default | Notes |
|---|---|---|---|
BackendConnectTimeoutMs |
int | 3000 |
Max time in milliseconds to wait for one TCP connect to the backend PLC. Each Polly retry attempt is bounded by its own copy of this timeout — total worst-case connect time is MaxAttempts * BackendConnectTimeoutMs plus the configured backoffs. |
BackendRequestTimeoutMs |
int | 3000 |
Max time in milliseconds to wait for the PLC to respond to a forwarded PDU. On timeout the upstream client is disconnected. FC06 / FC16 writes are not retried because they are non-idempotent on BCD tags; FC03 / FC04 reads are also not retried mid-request (a fresh upstream request takes the full pipeline again). |
GracefulShutdownTimeoutMs |
int | 10000 |
Max time in milliseconds the shutdown coordinator waits for in-flight PDUs to drain after a stop signal (sc.exe stop or Windows Service stop). After this deadline, remaining work is cancelled. Keep at or below the Service Control Manager wait hint (30 s). |
On hot reload, BackendConnectTimeoutMs and BackendRequestTimeoutMs apply to the next backend connect or request — in-flight operations keep their already-applied timeout. GracefulShutdownTimeoutMs is sampled only at shutdown.
Operational sizing notes:
- The default 3 s connect timeout is appropriate for a local Ethernet segment to a healthy ECOM100. On WAN paths or for devices behind switches with slow MAC-table aging, raise to 5–10 s.
- A 3 s request timeout is generous compared with typical DL205/DL260 scan times (a few ms to tens of ms for FC03 of 100 registers). The slack absorbs PLC scan-overlap jitter without faulting the upstream client.
GracefulShutdownTimeoutMsshould be less than the Service Control Manager's stop deadline. The default 10 s suits a fleet of 54 PLCs; on a much larger fleet, raise both the SCM wait hint and this value in lockstep.
Mbproxy.Connection.Keepalive
TCP keepalive and backend heartbeat settings. Source: KeepaliveOptions.cs. Enabled by default — the DL205/DL260 ECOM never emits TCP keepalives, so an idle socket is otherwise dropped by middleboxes after 2–5 minutes. See ../Architecture/Keepalive.md for the full design.
| Field | Type | Default | Notes |
|---|---|---|---|
Enabled |
bool | true |
Master switch. When false, neither SO_KEEPALIVE nor the backend heartbeat is applied and the proxy behaves exactly as a pre-keepalive build. |
TcpIdleTimeMs |
int | 30000 |
SO_KEEPALIVE idle time before the OS sends its first probe. Applied to the backend socket and accepted upstream sockets. |
TcpProbeIntervalMs |
int | 5000 |
SO_KEEPALIVE interval between probes once idle. |
TcpProbeCount |
int | 4 |
SO_KEEPALIVE unanswered probes before the OS declares the socket dead. |
BackendHeartbeatIdleMs |
int | 30000 |
After this much backend idle, the proxy issues a synthetic FC03 qty=1 read to keep the path warm and prove the ECOM still answers Modbus. Must be greater than BackendRequestTimeoutMs. |
BackendHeartbeatProbeAddress |
int | 0 |
Modbus PDU address the heartbeat FC03 probe reads. Address 0 (V0) is valid on DL205/DL260 in factory absolute mode. Range [0, 65535]. |
On hot reload, the heartbeat interval and probe address are re-read on every loop tick. The Tcp* socket options are applied at connect/accept time, so a reload affects only sockets opened after the change. A reload where BackendHeartbeatIdleMs <= BackendRequestTimeoutMs is rejected — a heartbeat interval at or below the request timeout would fire continuously.
Mbproxy.Resilience
Polly retry pipelines for backend connect, listener bind, and the in-flight read coalescer. Source: ResilienceOptions.cs.
Mbproxy.Resilience.BackendConnect
Bounded retries on the backend TCP connect path. Mid-request failures (during a forwarded PDU) are never retried.
| Field | Type | Default | Notes |
|---|---|---|---|
MaxAttempts |
int | 3 |
Total connect tries, including the first. 1 disables retries. |
BackoffMs |
int[] | [100, 500, 2000] |
Delay in milliseconds between attempts. Must have MaxAttempts - 1 entries. |
Mbproxy.Resilience.ListenerRecovery
Unbounded retries on the listener bind path. If a PLC's ListenPort cannot be bound (port in use, bad interface, transient OS error), the supervisor cycles through InitialBackoffMs once, then repeats SteadyStateMs forever. The same recovery code path also reacts to a listener that faults at runtime (for example, the underlying socket dies) and to listeners that come online from a hot-reload that adds a new PLC.
| Field | Type | Default | Notes |
|---|---|---|---|
InitialBackoffMs |
int[] | [1000, 2000, 5000, 15000, 30000] |
Backoff schedule for the first N retries after a fault. |
SteadyStateMs |
int | 30000 |
Backoff for every retry after the initial schedule is exhausted. Runs indefinitely. |
Mbproxy.Resilience.ReadCoalescing
In-flight de-duplication of identical FC03 / FC04 reads. When multiple upstream clients issue the same (unitId, fc, startAddress, qty) tuple while a matching backend round-trip is already in flight, the late arrivals attach to the existing entry and the single response is fanned out to every party. See ../Architecture/ReadCoalescing.md.
| Field | Type | Default | Notes |
|---|---|---|---|
Enabled |
bool | true |
Master switch. Hot-reloadable; flipping to false lets already-coalesced entries drain naturally. |
MaxParties |
int | 32 |
Per-entry cap on attached parties. Past this cap, the next identical request opens a fresh in-flight entry. |
Writes (FC06 / FC16) are never coalesced. FC03 and FC04 never share an entry. Different unitId bytes never share an entry.
Total FC03 + FC04 request accounting is preserved across the coalescing path: coalescedHitCount + coalescedMissCount equals the total reads observed by the multiplexer since startup. coalescedHitCount stays at 0 while Enabled = false, but every read still increments coalescedMissCount. See ./StatusPage.md for the full counter catalogue.
Mbproxy.Cache
Service-wide safety knobs for the opt-in response cache. The cache is off by default per tag — this section only governs the limits when an operator opts a tag in via CacheTtlMs or DefaultCacheTtlMs. Source: CacheOptions in MbproxyOptions.cs.
| Field | Type | Default | Notes |
|---|---|---|---|
AllowLongTtl |
bool | false |
Gate for any CacheTtlMs > 60_000. When false, ReloadValidator rejects any tag or PLC default that exceeds 60 s. Set to true to opt in explicitly. |
MaxEntriesPerPlc |
int | 1000 |
LRU cap on the number of entries per PLC. When full, the next insert evicts the least-recently-used entry. Must be >= 0. 0 is accepted but means "evict every insert immediately" — effectively the cache is disabled even for tags with non-zero TTL. |
EvictionIntervalMs |
int | 5000 |
Background eviction loop tick in milliseconds. Each tick scans the per-PLC caches and removes entries past their ExpiresAtUtc. Must be >= 0; values below 100 ms are clamped at 100 ms internally to avoid pathologically tight loops. |
On hot reload, AllowLongTtl is enforced by the next reload validation. MaxEntriesPerPlc applies to subsequent inserts (existing entries are not pruned). EvictionIntervalMs is read by each fresh eviction loop iteration.
Any tag-list change for a given PLC drops that PLC's entire cache on reload — per-tag flush granularity is intentionally not implemented. New entries re-populate on demand under the new TTL. Process restart wipes every cache; there is no persistence and no last-known-good snapshot.
See ../Architecture/ResponseCache.md for the cache contract (lookup order, write-invalidation by address-range overlap, post-rewriter byte storage).
Per-Tag CacheTtlMs
Per-tag opt-in to the cache. The same field appears on every BcdTagOptions entry — both Mbproxy.BcdTags.Global[] and Mbproxy.Plcs[i].BcdTags.Add[].
| Value | Meaning |
|---|---|
null (omitted) |
Unset. Falls back to Plcs[i].DefaultCacheTtlMs. |
0 |
Caching explicitly disabled for this tag, even if the PLC default is non-zero. |
1..60000 |
Cache enabled with this TTL in milliseconds. |
> 60000 |
Rejected at reload unless Cache.AllowLongTtl = true. |
TTL resolution order for any single tag: explicit per-tag value → per-PLC DefaultCacheTtlMs → 0 (off).
For multi-tag read ranges, the effective TTL is min(TTLs) across all configured tags inside the read range. If any tag in the range has CacheTtlMs = 0, the entire read is uncached.
The cache itself is described in detail in ../Architecture/ResponseCache.md. The properties most relevant to operators setting TTLs:
- Lookup order is cache → coalesce → backend. A cache hit short-circuits the read coalescer entirely.
- Writes invalidate by address-range overlap. A successful FC06 / FC16 response invalidates every cached FC03 / FC04 entry whose read range overlaps the write range — not just exact-key matches. Exception responses do not invalidate (the write did not take effect on the PLC).
- Cache stores post-rewriter bytes. Hits never re-invoke the BCD rewriter. Tag-list reloads flush the affected PLC's whole cache so a rewriter-relevant change cannot serve stale post-rewriter bytes from before the change.
- Different
unitIdbytes never invalidate each other. Invalidation is scoped to(unitId, FC ∈ {3, 4}).
Validation Rules
ReloadValidator.Validate runs on every config load (startup and hot reload) and rejects the entire snapshot if any rule fails. On rejection at startup, the service logs mbproxy.startup.config.rejected at Error and exits non-zero. On rejection at runtime, the current in-memory config stays in effect and mbproxy.config.reload.rejected is logged at Error.
Rules (in order):
- PLC names: every
Plcs[i].Nameis non-empty and unique (ordinal comparison). - ListenPort / Host / Port: every
Plcs[i].ListenPortis in[1, 65535]and unique across the array; everyHostis non-empty; every backendPortis in[1, 65535]. - AdminPort: in
[1, 65535], or0to disable the admin endpoint; a non-zero value does not collide with anyListenPort. - BCD tag map per PLC, delegated to
BcdTagMapBuilder.Build:- duplicate addresses within a single PLC's resolved tag list
- 32-bit entries whose high register (
Address + 1) overlaps a separate 16-bit entry at that address
- Cache TTL bounds:
- any
CacheTtlMsorDefaultCacheTtlMsless than 0 is rejected - any
CacheTtlMsorDefaultCacheTtlMsgreater than60_000is rejected unlessCache.AllowLongTtl = true
- any
- Cache size knobs:
Cache.MaxEntriesPerPlcin[0, 100000],Cache.EvictionIntervalMs >= 0. - AdminPushIntervalMs / timeouts / keepalive / Resilience:
AdminPushIntervalMsin[1, 60000]; connection timeouts> 0; the keepalive cross-field rule (BackendHeartbeatIdleMs > BackendRequestTimeoutMs); and well-formedResilienceprofiles (BackendConnect.MaxAttempts >= 1with>= MaxAttempts - 1non-negativeBackoffMsentries,ListenerRecovery.SteadyStateMs > 0,ReadCoalescing.MaxParties >= 1). - Width: every
BcdTagOptions.Widthis16or32(also enforced byMbproxyOptionsValidatorat schema time).
Sample rejection messages (logged at Error with the structured property errors carrying the full list):
Plc 'Line1-Mixer': Duplicate ListenPort 5020 (already used by 'Line1-Conveyor').
AdminPort 5020 collides with ListenPort of PLC 'Line1-Mixer'.
Plc 'Line1-Mixer': BCD tag map error (DuplicateAddress): address 1024 appears twice.
BcdTags.Global Address 1024: CacheTtlMs=120000 exceeds 60_000 ms without Cache.AllowLongTtl=true.
Plcs[2] (Line2-Press): DefaultCacheTtlMs must be >= 0.
Warning case (not a rejection):
Plcs[i].BcdTags.Remove[]entries that do not match any global tag address are logged as warnings — probably stale config, but the reload proceeds.
Two additional rejection categories handled earlier in the pipeline:
- Type-mismatched / malformed JSON. The .NET configuration binder rejects values whose type does not match the bound property (for example, a string in
BackendConnectTimeoutMs). At startup this aborts the host; on hot reload the binder retains the previous snapshot and the reload never reachesReloadValidator. - Width invalid.
MbproxyOptionsValidatorrejects anyBcdTagOptions.Widththat is not16or32. This runs as part of options validation beforeReloadValidatorand surfaces the same way as schema errors.
See ../Features/HotReload.md for the full reload-acceptance flow, including the log event names emitted on acceptance (mbproxy.config.reload.applied) and rejection (mbproxy.config.reload.rejected).
Two Concrete Examples
The minimal and production examples below are both complete appsettings.json snippets — paste either one and the service will start without further edits beyond the addresses and ports.
Minimal
One PLC, no BCD tags, no cache. The proxy is pure pass-through.
{
"Mbproxy": {
"BcdTags": { "Global": [] },
"Plcs": [
{
"Name": "Line1-Mixer",
"ListenPort": 5020,
"Host": "10.0.1.1"
}
],
"AdminPort": 8080
}
}
Everything else picks up defaults: Port = 502, Connection.BackendConnectTimeoutMs = 3000, Connection.BackendRequestTimeoutMs = 3000, Connection.GracefulShutdownTimeoutMs = 10000, Resilience.BackendConnect.MaxAttempts = 3, Resilience.ReadCoalescing.Enabled = true, Cache.AllowLongTtl = false, Cache.MaxEntriesPerPlc = 1000, Cache.EvictionIntervalMs = 5000, and so on.
Behaviour in this snapshot: every byte passes through unchanged in both directions, FC03 / FC04 reads are still subject to in-flight coalescing (the feature is on by default), and no responses are cached.
Production
Three PLCs, a global BCD tag list, one PLC with overrides, cache enabled on hot reads.
{
"Mbproxy": {
"BcdTags": {
"Global": [
{ "Address": 1024, "Width": 16 }, // V2000 — 16-bit BCD counter
{ "Address": 1056, "Width": 32 }, // V2040 — 32-bit BCD total
{ "Address": 1088, "Width": 16, "CacheTtlMs": 1000 } // V2100 — setpoint, 1 s cache
]
},
"Plcs": [
{
"Name": "Line1-Mixer",
"ListenPort": 5020,
"Host": "10.0.1.1",
"Port": 502,
"DefaultCacheTtlMs": 0,
"BcdTags": {
"Add": [ { "Address": 1200, "Width": 32 } ],
"Remove": [ 1056 ]
}
},
{
"Name": "Line1-Conveyor",
"ListenPort": 5021,
"Host": "10.0.1.2"
},
{
"Name": "Line2-Press",
"ListenPort": 5022,
"Host": "10.0.2.1",
"DefaultCacheTtlMs": 500
}
],
"AdminPort": 8080,
"Connection": {
"BackendConnectTimeoutMs": 3000,
"BackendRequestTimeoutMs": 3000,
"GracefulShutdownTimeoutMs": 10000
},
"Resilience": {
"BackendConnect": { "MaxAttempts": 3, "BackoffMs": [ 100, 500, 2000 ] },
"ListenerRecovery": { "InitialBackoffMs": [ 1000, 2000, 5000, 15000, 30000 ], "SteadyStateMs": 30000 },
"ReadCoalescing": { "Enabled": true, "MaxParties": 32 }
},
"Cache": {
"AllowLongTtl": false,
"MaxEntriesPerPlc": 1000,
"EvictionIntervalMs": 5000
}
}
}
In this snapshot, Line1-Mixer adds a 32-bit tag at 1200 and removes the global 32-bit tag at 1056. Line2-Press opts every tag in (whose CacheTtlMs is null) into a 500 ms cache via its DefaultCacheTtlMs. The setpoint at 1088 already has an explicit per-tag TTL and that value wins.
The effective tag map per PLC after resolution:
| PLC | Effective tag list |
|---|---|
Line1-Mixer |
1024 (16-bit), 1088 (16-bit, CacheTtlMs = 1000), 1200 (32-bit). 1056 is removed. |
Line1-Conveyor |
1024 (16-bit), 1056 (32-bit), 1088 (16-bit, CacheTtlMs = 1000). |
Line2-Press |
1024 (16-bit, effective CacheTtlMs = 500 via PLC default), 1056 (32-bit, effective CacheTtlMs = 500), 1088 (16-bit, effective CacheTtlMs = 1000 from explicit per-tag value). |
Any FC03 / FC04 read whose register range overlaps Line2-Press's tag 1088 resolves to the per-tag 1 s TTL. A read that spans tags with different TTLs takes min(TTLs) across the range; a read that includes a tag with CacheTtlMs = 0 is uncached even if every other tag in the range is opted in.
Hot-Reload Propagation Summary
A reduced view of ../Features/HotReload.md, restricted to the keys documented here. Every accepted reload emits mbproxy.config.reload.applied at Information with a summary of which PLCs were added or removed and the size of the tag-list delta.
| Change | Propagation |
|---|---|
BcdTags.Global add / remove / width |
Rewriter dereferences IOptionsMonitor per PDU. Next PDU sees the new map; in-flight PDUs are not retroactively touched. |
Plcs[i].BcdTags.{Add,Remove} |
Same per-PDU resolution as above, scoped to the affected PLC. |
New Plcs[i] entry |
Listener supervisor binds the new port under ListenerRecovery. |
Plcs[i] removed |
Supervisor stops the listener and closes all upstream connections for that PLC. |
Plcs[i].ListenPort or Host changed |
Equivalent to remove + add. |
Connection.Backend*TimeoutMs |
Next backend connect or request uses the new value. |
Connection.Keepalive heartbeat fields |
Re-read on every heartbeat loop tick. Tcp* socket options apply to backend/upstream sockets opened after the change. |
AdminPort |
Requires a service restart — the Kestrel admin host is built once at startup. |
Resilience.ReadCoalescing.Enabled |
Hot-reloadable; in-flight coalesced entries drain naturally. |
BcdTags.*.CacheTtlMs, Plcs[i].DefaultCacheTtlMs |
Tag-map reseat for the affected PLC drops that PLC's entire cache. |
Cache.AllowLongTtl / MaxEntriesPerPlc / EvictionIntervalMs |
Enforced on next reload validation / next insert / next eviction tick respectively. |
Where Options Live in Code
| Section | File | Binding class |
|---|---|---|
| Root | src/Mbproxy/Options/MbproxyOptions.cs |
MbproxyOptions |
Plcs[] |
src/Mbproxy/Options/PlcOptions.cs |
PlcOptions |
BcdTags.Global[] entry shape |
src/Mbproxy/Options/BcdTagOptions.cs |
BcdTagOptions |
BcdTags.Global / Plcs[i].BcdTags |
src/Mbproxy/Options/BcdTagListOptions.cs |
BcdTagListOptions, PlcBcdOverrides |
Connection |
src/Mbproxy/Options/ConnectionOptions.cs |
ConnectionOptions |
Resilience |
src/Mbproxy/Options/ResilienceOptions.cs |
ResilienceOptions, RetryProfile, RecoveryProfile, ReadCoalescingOptions |
Cache |
src/Mbproxy/Options/MbproxyOptions.cs |
CacheOptions (declared alongside MbproxyOptions in the same file) |
| Schema validation | src/Mbproxy/Options/MbproxyOptions.cs |
MbproxyOptionsValidator |
| Reload validation | src/Mbproxy/Configuration/ReloadValidator.cs |
ReloadValidator |
| Tag-map resolution | src/Mbproxy/Bcd/BcdTagMapBuilder.cs |
BcdTagMapBuilder |
| Reload reconciliation | src/Mbproxy/Configuration/ConfigReconciler.cs |
ConfigReconciler, ReloadPlan |
All option classes are registered through services.Configure<T>(...) against the Mbproxy:* section in Program.cs. IOptionsMonitor<T> is the runtime read path; direct IOptions<T> injection is not used because it does not propagate reloads.
Related Documentation
../Features/HotReload.md— reload acceptance flow and per-key propagation semantics../Features/BcdRewriting.md— tag-list resolution, wire encoding, multi-tag overlap rules../Architecture/ResponseCache.md— cache contract, lookup order, write invalidation./StatusPage.md— schema served byAdminPort./Troubleshooting.md— Serilog block and common config rejection diagnostics../../README.md— install and operational entry point