Files
wwtools/mbproxy/docs/plan/00-bootstrap.md
T
Joseph Doherty 56eee3c563 mbproxy: initial commit through Phase 9 (TxId multiplexing)
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>
2026-05-14 01:49:35 -04:00

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 via Microsoft.Extensions.Hosting.WindowsServices, and also run as a console under dotnet run for local dev.
  • Load appsettings.json with reloadOnChange: true, bind the "Mbproxy" section to typed POCOs, and expose them via IOptionsMonitor<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

  1. Create Mbproxy.slnx referencing the two csprojs.
  2. 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.WindowsServices
    • Serilog.Extensions.Hosting
    • Serilog.Settings.Configuration
    • Serilog.Sinks.Console
    • Serilog.Sinks.File
    • Polly (referenced now so phase 04/05 don't have to touch this csproj for the package; usage is deferred)
  3. Options/MbproxyOptions.cs and siblings — typed POCOs that mirror the appsettings schema in ../design.md → Configuration. Keep them plain DTOs (public sealed class with init-only properties). Use IValidateOptions<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).
  4. HostingExtensions.cs — extension methods on IHostApplicationBuilder named AddMbproxyOptions(IConfiguration) and AddMbproxySerilog(IConfiguration). Keep Program.cs thin: read config, call the two extensions, register HeartbeatWorker, run.
  5. Program.cs — Generic Host with .UseWindowsService(). await Host.CreateApplicationBuilder(args)...Build().RunAsync(). Honour --console as a no-op flag for documentation symmetry with the design (the worker SDK + UseWindowsService combo already runs in console mode under dotnet run).
  6. Workers/HeartbeatWorker.csBackgroundService that logs mbproxy.startup.ready once after Task.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.
  7. appsettings.json — minimal, valid against the POCOs, with Plcs: []. Include the full key shape (BcdTags.Global, AdminPort, Connection, Resilience) so future phases just fill in values.
  8. tests/Mbproxy.Tests/Mbproxy.Tests.csproj — Microsoft.NET.Sdk, TargetFramework=net10.0, same Nullable/TreatWarningsAsErrors. ProjectReference to src/Mbproxy/Mbproxy.csproj. PackageReferences:
    • Microsoft.NET.Test.Sdk
    • xunit (v3 if a stable release exists; v2 otherwise — record the decision in the csproj comment)
    • xunit.runner.visualstudio
    • Shouldly
  9. HostSmokeTests.cs — build the host with Host.CreateApplicationBuilder against a synthetic config, start it on a CancellationTokenSource with a short deadline, assert it logged mbproxy.startup.ready and shut down without unhandled exceptions.
  10. MbproxyOptionsBindingTests.cs — bind a hand-written Dictionary<string,string> config source into MbproxyOptions, assert all fields populate correctly (including a Plcs entry with BcdTags.Add and BcdTags.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)

  1. MbproxyOptionsBinding_BindsGlobalBcdTags_From_appsettings
  2. MbproxyOptionsBinding_BindsPerPlcAddAndRemove
  3. MbproxyOptionsBinding_DefaultsAreApplied_WhenSectionMissing (AdminPort=8080, Resilience defaults)
  4. MbproxyOptionsBinding_RejectsInvalidWidth — IValidateOptions returns Fail for Width != 16 && Width != 32. Schema-level only; address-overlap validation is phase 06.
  5. HostSmoke_StartsAndStops_Cleanly_AndLogs_StartupReady — uses a Serilog sink that captures events to memory; asserts the mbproxy.startup.ready event fired at Information.
  6. HostSmoke_ShutdownIsOrdered — host responds to StopAsync within 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, logs mbproxy.startup.ready to console within 5 s, exits cleanly on Ctrl-C.
  • appsettings.json is a valid JSON document and parses into a populated MbproxyOptions instance via the test harness.
  • ../design.md is unchanged (this phase introduces no new design decisions).
  • Resource index entry for docs/plan/00-bootstrap.md is 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.md for the tool root yet — that's a phase 08 deliverable when there's something installable to document.
  • If the xunit v3 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. Set EventId.Name = "mbproxy.startup.ready".