Adds the mbproxy service end-to-end. Phases 00-08 implement the production-ready single-listener / 1:1-backend transparent Modbus TCP proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260 fleet. Phase 9 replaces the connection layer with a single backend socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's 4-concurrent-client cap as an operational ceiling. Phase 9 additions of note: - PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap - InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing for Phase 10 read coalescing — do not collapse to a single field) - Per-request watchdog: surfaces Modbus exception 0x0B to upstream on BackendRequestTimeoutMs, defending against lost responses, dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed- request bug (its ServerRequestHandler.last_pdu state race) - Status DTO + HTML gain inFlight / maxInFlight / txIdWraps / disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md) Tests: 263 unit + 38 E2E. Multiplexer correctness under truly concurrent backend traffic is proved against a stub backend in PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus 3.13's single-PDU framer stays in known-good mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9.7 KiB
Phase 06 — Configuration hot-reload
Subscribe to IOptionsMonitor<MbproxyOptions>.OnChange and reconcile the running supervisors + per-PLC tag maps + connection settings against the new config — without restarting the host.
Depends on: Phase 05 (supervisor lifecycle). Parallel-safe with: nothing (touches the widest cross-cut: supervisors + tag maps + counters + DI options).
Goal
A appsettings.json save propagates per the design's reconcile table:
| Change | Action |
|---|---|
BcdTags.Global add/remove/width |
Rebuild every PLC's BcdTagMap, swap atomically. Next PDU sees it. |
Plcs[i].BcdTags.{Add,Remove} |
Rebuild that PLC's BcdTagMap only. |
New Plcs[i] |
Create supervisor + context, start it. |
Removed Plcs[i] |
Stop supervisor, close all client connections to it. |
Changed ListenPort / Host |
Stop + start the supervisor (remove + add semantics). |
Connection.Backend*TimeoutMs |
Take effect on the next backend connect / request. |
| Invalid reload | Reject as a whole; keep current state; log mbproxy.config.reload.rejected. |
Validation runs FIRST. A reload that would produce duplicate ListenPort values, or a BcdTagMapBuilder.Build error for any PLC, is rejected atomically before any state mutates.
Outputs
src/Mbproxy/Configuration/ConfigReconciler.cs # OnChange handler; orchestrates the apply
src/Mbproxy/Configuration/ReloadValidator.cs # cross-PLC validation (duplicate ports, etc.)
src/Mbproxy/Configuration/ReloadPlan.cs # immutable diff record between current and new
tests/Mbproxy.Tests/Configuration/ReloadValidatorTests.cs
tests/Mbproxy.Tests/Configuration/ConfigReconcilerTests.cs
tests/Mbproxy.Tests/Configuration/HotReloadE2ETests.cs # real appsettings.json mutation, real host
Modifications:
src/Mbproxy/Proxy/ProxyWorker.cs— accept aConfigReconcilerand forwardIOptionsMonitor.OnChangeto it; on startup, also seed the reconciler with the initial snapshot.src/Mbproxy/Proxy/Supervision/PlcListenerSupervisor.cs— expose aTask ReplaceContextAsync(PerPlcContext newCtx, CancellationToken ct)that atomically swaps the BCD tag map and counters without restarting the listener. Old in-flight connections finish on the old map; new connections use the new map. (Document the brief transition window in comments.)- Add
mbproxy.config.reload.appliedandmbproxy.config.reload.rejected[LoggerMessage]events. src/Mbproxy/Options/MbproxyOptions.cs— wireIValidateOptions<MbproxyOptions>to call the schema-level validator only. Cross-PLC validation (duplicate ports, etc.) is handled byReloadValidatorbecause it requires inspecting multiplePlcs[i]together, whichIValidateOptionsdoesn't naturally express.
Tasks
ReloadPlan.cs— immutable record describing the diff:Computed by a pure functionpublic sealed record ReloadPlan( IReadOnlyList<PlcOptions> ToAdd, IReadOnlyList<string> ToRemove, // PLC names IReadOnlyList<(string Name, PlcOptions New)> ToRestart, // port or host changed IReadOnlyList<(string Name, BcdTagMap NewMap)> ToReseat, // tag map changed ConnectionOptions Connection);ReloadPlan.Compute(MbproxyOptions current, MbproxyOptions next); PLC identity is keyed onName(NOT onListenPort, which is mutable).ReloadValidator.cs— single static methodValidate(MbproxyOptions next, out IReadOnlyList<string> errors):- PLC names are unique and non-empty.
ListenPortvalues are unique.- For each PLC,
BcdTagMapBuilder.Build(global, perPlc).Errorsis empty. AdminPortdoesn't collide with anyPlcs[i].ListenPort.- All ports are in
[1, 65535].
ConfigReconciler.cs— subscribes via constructor-injectedIOptionsMonitor<MbproxyOptions>.OnChange. On change:- Snapshot the new options.
- Run
ReloadValidator.Validate. On failure: logmbproxy.config.reload.rejectedwith the error list; do nothing else. - Compute
ReloadPlanagainst the current snapshot. - Apply the plan in order:
- Stop supervisors in
ToRemove(concurrently). - Stop+restart supervisors in
ToRestart(concurrently). - Build new
PerPlcContextfor eachToReseatentry and callsupervisor.ReplaceContextAsync(newCtx). - Build supervisors for
ToAdd, start them.
- Stop supervisors in
- On success: log
mbproxy.config.reload.appliedwith summary (PlcsAdded,PlcsRemoved,PlcsReseated,TagListDelta). RecordlastReloadUtcand bumpreloadCounton a service-wide counter (consumed by phase 07). - On any step throwing: best-effort log the partial-apply state at Error, then continue. The host stays up. (The validator should have caught most failure modes; a runtime failure here is a true bug.)
ProxyWorker.csupdates — register the reconciler with the host and wire startup to use it for the initial snapshot.
Public surface declared in this phase
namespace Mbproxy.Configuration;
internal sealed class ConfigReconciler : IDisposable {
public ConfigReconciler(IOptionsMonitor<MbproxyOptions> monitor, /* dependencies */);
public Task ApplyAsync(MbproxyOptions next, CancellationToken ct); // exposed for tests
public void Dispose();
}
public sealed record ReloadPlan(
IReadOnlyList<PlcOptions> ToAdd,
IReadOnlyList<string> ToRemove,
IReadOnlyList<(string Name, PlcOptions New)> ToRestart,
IReadOnlyList<(string Name, BcdTagMap NewMap)> ToReseat,
ConnectionOptions Connection) {
public static ReloadPlan Compute(MbproxyOptions current, MbproxyOptions next);
}
internal static class ReloadValidator {
public static bool Validate(MbproxyOptions next, out IReadOnlyList<string> errors);
}
Tests required
Unit (Category = Unit)
ReloadValidatorTests (≥ 6 tests):
Validate_DuplicatePlcName_FailsValidate_DuplicateListenPort_FailsValidate_AdminPortCollidesWith_PlcListenPort_FailsValidate_PerPlc_BcdMapBuildError_FailsValidate_PortOutOfRange_FailsValidate_HappyPath_Passes
ReloadPlanTests (≥ 5 tests):
Compute_AddOnePlc_OnlyToAddPopulatedCompute_RemoveOnePlc_OnlyToRemovePopulatedCompute_ChangePort_GoesToToRestart_NotToReseatCompute_ChangePerPlcTagOverride_GoesToToReseatCompute_ChangeGlobalTagList_AllPlcsReseat_NoRestart
ConfigReconcilerTests (≥ 4 tests, using a fake IOptionsMonitor + fake supervisor factory):
Apply_HappyPath_StartsAndStopsSupervisors_PerPlanApply_ValidationFails_NoMutationOccurs_AndLogsRejectedApply_ReseatTagMap_DoesNotRestartSupervisorApply_ConcurrentReloads_Are_Serialised— two rapid changes get processed in order, no interleaving.
E2E (Category = E2E)
HotReloadE2ETests (≥ 4 tests, using a real Host.CreateApplicationBuilder + temp appsettings.json file):
E2E_AddPlcAtRuntime_NewListenerBinds_AndIsReachable— start the host with one PLC, write a new appsettings adding a second PLC pointing at the simulator on a fresh listen port, drive NModbus against the new proxy port within 2 s.E2E_RemovePlcAtRuntime_ClosesUpstreamConnections— start with two PLCs and a connected client, write appsettings removing one; client's socket closes within 1 s.E2E_ChangeGlobalBcdTagList_RewriteReflectsImmediately— start with addr 1072 NOT in BCD list, read raw 0x1234. Write appsettings adding it. Read again, get decoded 1234.E2E_InvalidReload_DoesNotMutateRunningState— start happy, write a broken appsettings (duplicate ListenPort), assert the host keeps running with the OLD config andmbproxy.config.reload.rejectedis logged.
Phase gate
- Zero-warnings build.
- All phase 00–05 tests still green.
- All new unit tests green.
- All e2e hot-reload tests green when the simulator is available.
mbproxy.config.reload.applied/.rejectedevents match the design's properties list.- A misconfigured reload (duplicate ports) is rejected atomically — the assertion in test E2E_4 verifies no partial mutation.
- The reconciler serializes concurrent
OnChangenotifications (SemaphoreSlimor equivalent) so two file saves in quick succession don't race. - Counters
service.config.reloadCountandservice.config.reloadRejectedCountare bumped correctly.
Out of scope
- Watching for files OTHER than
appsettings.json(env files, dotnet user-secrets, etc.). The default config source set established in phase 00 is the contract. - Reloading Serilog log levels at runtime. Possible but not in this phase.
- A reload audit log file. The accept/reject events are sufficient.
- Online schema migrations (e.g., renaming a key in an older config to a new one). Reject-the-whole-thing is the simpler contract.
Notes for the subagent
IOptionsMonitor.OnChangecan fire MULTIPLE times for a single file save on some platforms (text editors saving via rename-and-replace can trigger 2-3 events). Debounce inside the reconciler — a 250 ms quiescent window after the lastOnChangebefore computing the plan. Document the choice in code.- The reconciler must NOT block the
OnChangecallback thread for I/O (StopAsyncetc.). UseChannel<ReloadRequest>or aTask.Run-style hand-off so the callback returns immediately. - When a supervisor restart is in progress (e.g., port changed), reject further reloads briefly with a queued "retry after current applies" — OR just serialise everything via a single semaphore and accept that a backed-up reload queue gets all changes eventually. Pick the simpler option (semaphore); document it.
BcdTagMapBuilder.Buildis the validator for tag-list well-formedness; do not duplicate that validation inReloadValidator. The validator just callsBuildand checks theErrorslist.