56eee3c563
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.6 KiB
9.6 KiB
Phase 00 — Bootstrap
Scaffold the .NET 10 Worker Service project and the test project. Wire up Generic Host, Serilog, Windows-Service registration, and MbproxyOptions POCOs bound via IOptionsMonitor. No proxy logic yet — the service starts, logs "ready", and stops cleanly.
Depends on: nothing. Must run alone.
Parallel-safe with: nothing. Phase 00 owns the initial .csproj and solution; subsequent phases append.
Goal
Produce a minimal but production-shaped host that all subsequent phases plug into. The host must:
- Target
.NET 10(net10.0), be registered as a Windows Service viaMicrosoft.Extensions.Hosting.WindowsServices, and also run as a console underdotnet runfor local dev. - Load
appsettings.jsonwithreloadOnChange: true, bind the"Mbproxy"section to typed POCOs, and expose them viaIOptionsMonitor<MbproxyOptions>. - Use Serilog with console + rolling-file sinks under
%ProgramData%\mbproxy\logs\(configurable, but default that location). - Set
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>and<Nullable>enable</Nullable>in the csproj. These stay set forever.
Outputs (files created in this phase)
Mbproxy.slnx
src/Mbproxy/Mbproxy.csproj
src/Mbproxy/Program.cs
src/Mbproxy/HostingExtensions.cs # AddMbproxyOptions, AddMbproxySerilog
src/Mbproxy/Options/MbproxyOptions.cs
src/Mbproxy/Options/BcdTagOptions.cs
src/Mbproxy/Options/PlcOptions.cs
src/Mbproxy/Options/ConnectionOptions.cs
src/Mbproxy/Options/ResilienceOptions.cs
src/Mbproxy/Options/BcdTagListOptions.cs # the Global + per-PLC Add/Remove DTOs
src/Mbproxy/Workers/HeartbeatWorker.cs # one-line "service alive" worker; deleted by phase 03
src/Mbproxy/appsettings.json # minimal default with empty Plcs array
tests/Mbproxy.Tests/Mbproxy.Tests.csproj
tests/Mbproxy.Tests/HostSmokeTests.cs
tests/Mbproxy.Tests/Options/MbproxyOptionsBindingTests.cs
.gitignore # add bin/, obj/, .vs/, *.user, tests/sim/.venv/, %ProgramData%\mbproxy\
No other files. Phase 00 does NOT create:
- BCD codec types (phase 02)
- Proxy types (phase 03)
- Listener supervisor (phase 05)
- Status page (phase 07)
Tasks
- Create
Mbproxy.slnxreferencing the two csprojs. src/Mbproxy/Mbproxy.csproj—<Project Sdk="Microsoft.NET.Sdk.Worker">,TargetFramework=net10.0,OutputType=Exe,Nullable=enable,TreatWarningsAsErrors=true,ImplicitUsings=enable. PackageReferences:Microsoft.Extensions.Hosting(latest stable for .NET 10)Microsoft.Extensions.Hosting.WindowsServicesSerilog.Extensions.HostingSerilog.Settings.ConfigurationSerilog.Sinks.ConsoleSerilog.Sinks.FilePolly(referenced now so phase 04/05 don't have to touch this csproj for the package; usage is deferred)
Options/MbproxyOptions.csand siblings — typed POCOs that mirror the appsettings schema in../design.md→ Configuration. Keep them plain DTOs (public sealed classwith init-only properties). UseIValidateOptions<MbproxyOptions>for cross-field checks at the schema level only (no business rules like "duplicate addresses" — those move to phase 06 along with hot-reload).HostingExtensions.cs— extension methods onIHostApplicationBuildernamedAddMbproxyOptions(IConfiguration)andAddMbproxySerilog(IConfiguration). KeepProgram.csthin: read config, call the two extensions, registerHeartbeatWorker, run.Program.cs— Generic Host with.UseWindowsService().await Host.CreateApplicationBuilder(args)...Build().RunAsync(). Honour--consoleas a no-op flag for documentation symmetry with the design (the worker SDK + UseWindowsService combo already runs in console mode underdotnet run).Workers/HeartbeatWorker.cs—BackgroundServicethat logsmbproxy.startup.readyonce afterTask.Delay(100)(so Serilog has flushed) and then idles. This worker is deleted in phase 03 when the real listener supervisor takes over; it exists so phase 00's smoke test has something to assert.appsettings.json— minimal, valid against the POCOs, withPlcs: []. Include the full key shape (BcdTags.Global,AdminPort,Connection,Resilience) so future phases just fill in values.tests/Mbproxy.Tests/Mbproxy.Tests.csproj— Microsoft.NET.Sdk,TargetFramework=net10.0, sameNullable/TreatWarningsAsErrors. ProjectReference tosrc/Mbproxy/Mbproxy.csproj. PackageReferences:Microsoft.NET.Test.Sdkxunit(v3 if a stable release exists; v2 otherwise — record the decision in the csproj comment)xunit.runner.visualstudioShouldly
HostSmokeTests.cs— build the host withHost.CreateApplicationBuilderagainst a synthetic config, start it on aCancellationTokenSourcewith a short deadline, assert it loggedmbproxy.startup.readyand shut down without unhandled exceptions.MbproxyOptionsBindingTests.cs— bind a hand-writtenDictionary<string,string>config source intoMbproxyOptions, assert all fields populate correctly (including aPlcsentry withBcdTags.AddandBcdTags.Remove).
Public surface declared in this phase
namespace Mbproxy.Options;
public sealed class MbproxyOptions {
public BcdTagListOptions BcdTags { get; init; } = new();
public IReadOnlyList<PlcOptions> Plcs { get; init; } = [];
public int AdminPort { get; init; } = 8080;
public ConnectionOptions Connection { get; init; } = new();
public ResilienceOptions Resilience { get; init; } = new();
}
public sealed class BcdTagListOptions {
public IReadOnlyList<BcdTagOptions> Global { get; init; } = [];
}
public sealed class BcdTagOptions {
public ushort Address { get; init; }
public byte Width { get; init; } // 16 or 32
}
public sealed class PlcOptions {
public string Name { get; init; } = "";
public int ListenPort { get; init; }
public string Host { get; init; } = "";
public PlcBcdOverrides? BcdTags { get; init; }
}
public sealed class PlcBcdOverrides {
public IReadOnlyList<BcdTagOptions> Add { get; init; } = [];
public IReadOnlyList<ushort> Remove { get; init; } = [];
}
public sealed class ConnectionOptions {
public int BackendConnectTimeoutMs { get; init; } = 3000;
public int BackendRequestTimeoutMs { get; init; } = 3000;
}
public sealed class ResilienceOptions {
public RetryProfile BackendConnect { get; init; } = new() { MaxAttempts = 3, BackoffMs = [100, 500, 2000] };
public RecoveryProfile ListenerRecovery { get; init; } = new() {
InitialBackoffMs = [1000, 2000, 5000, 15000, 30000],
SteadyStateMs = 30000,
};
}
public sealed class RetryProfile {
public int MaxAttempts { get; init; }
public IReadOnlyList<int> BackoffMs { get; init; } = [];
}
public sealed class RecoveryProfile {
public IReadOnlyList<int> InitialBackoffMs { get; init; } = [];
public int SteadyStateMs { get; init; }
}
namespace Mbproxy;
internal static class HostingExtensions {
public static IHostApplicationBuilder AddMbproxyOptions(this IHostApplicationBuilder b);
public static IHostApplicationBuilder AddMbproxySerilog(this IHostApplicationBuilder b);
}
namespace Mbproxy.Workers;
internal sealed class HeartbeatWorker : BackgroundService { /* logs mbproxy.startup.ready */ }
No other public types in this phase.
Tests required
Unit (Category = Unit, default)
MbproxyOptionsBinding_BindsGlobalBcdTags_From_appsettingsMbproxyOptionsBinding_BindsPerPlcAddAndRemoveMbproxyOptionsBinding_DefaultsAreApplied_WhenSectionMissing(AdminPort=8080, Resilience defaults)MbproxyOptionsBinding_RejectsInvalidWidth— IValidateOptions returns Fail forWidth != 16 && Width != 32. Schema-level only; address-overlap validation is phase 06.HostSmoke_StartsAndStops_Cleanly_AndLogs_StartupReady— uses a Serilog sink that captures events to memory; asserts thembproxy.startup.readyevent fired at Information.HostSmoke_ShutdownIsOrdered— host responds toStopAsyncwithin 2 s.
E2E (Category = E2E)
None in this phase. The simulator harness is phase 01.
Phase gate
dotnet build Mbproxy.slnx -c Debug— zero warnings.dotnet test --filter Category!=E2E— all green, ≥6 tests.dotnet run --project src/Mbproxy— service starts, logsmbproxy.startup.readyto console within 5 s, exits cleanly on Ctrl-C.appsettings.jsonis a valid JSON document and parses into a populatedMbproxyOptionsinstance via the test harness.../design.mdis unchanged (this phase introduces no new design decisions).- Resource index entry for
docs/plan/00-bootstrap.mdis not needed (the plan README routes there).
Out of scope
- BCD encode/decode logic (phase 02).
- TcpListener / Modbus framing / byte forwarding (phase 03).
- Polly retry pipelines (referenced as a NuGet, used starting in phase 04/05).
- Address-overlap / duplicate-port validation (phase 06).
- AdminPort HTTP endpoint (phase 07).
- Service install / uninstall scripts (phase 08).
Notes for the subagent
- Do not create
README.mdfor the tool root yet — that's a phase 08 deliverable when there's something installable to document. - If the
xunitv3 vs v2 question is unclear at implementation time, prefer v3 if available on NuGet — record the choice in a single-line comment at the top of the test csproj. Future phases must not silently switch. - Use
LoggerMessage-source-generated logging ([LoggerMessage]) for the heartbeat event so phases that add more log events can follow the same pattern. SetEventId.Name = "mbproxy.startup.ready".