# Phase 03 — Proxy plumbing The minimum-viable proxy: one `TcpListener` per configured PLC, 1:1 upstream-client ↔ backend-socket, byte-for-byte forwarding both directions, transparent MBAP TxId / unit ID. No BCD rewriting yet — that's phase 04. No supervisor / auto-recovery — that's phase 05. **Depends on:** Phase 00 (host, options). **Parallel-safe with:** Phase 02 (BCD codec lives under `src/Mbproxy/Bcd/`; this phase lives under `src/Mbproxy/Proxy/`). ## Goal Stand up the listener-and-forwarder pair so an e2e test can: 1. Configure the proxy with `Plcs: [{ Host: "127.0.0.1", Port: , ListenPort: }]`. 2. Start the host. 3. Drive NModbus against `127.0.0.1:` and see the SAME bytes the simulator would return on a direct connection. The proxy is transparent in this phase. The BCD rewrite hook point is reserved but not wired. ## Outputs ``` src/Mbproxy/Proxy/PlcListener.cs # owns one TcpListener; accepts loop src/Mbproxy/Proxy/PlcConnectionPair.cs # one upstream socket + one backend socket; forwarder src/Mbproxy/Proxy/IPduPipeline.cs # the rewrite hook contract (no-op impl in this phase) src/Mbproxy/Proxy/NoopPduPipeline.cs # the no-op impl src/Mbproxy/Proxy/ProxyWorker.cs # BackgroundService that owns all PlcListeners src/Mbproxy/Proxy/MbapFrame.cs # MBAP header parse helpers (length, txid, unit) tests/Mbproxy.Tests/Proxy/ProxyForwardingTests.cs # e2e against the simulator tests/Mbproxy.Tests/Proxy/MbapFrameTests.cs # unit tests for the MBAP parser ``` Modifications: - `src/Mbproxy/Program.cs` — register `ProxyWorker` as a hosted service. The `HeartbeatWorker` from phase 00 is DELETED in this phase (its job is replaced by ProxyWorker logging `mbproxy.startup.ready` after all listeners are bound). - `src/Mbproxy/Workers/HeartbeatWorker.cs` — DELETED. ## Tasks 1. **`MbapFrame.cs`** — pure helpers, no allocations. Static methods: - `static bool TryParseHeader(ReadOnlySpan buffer, out ushort txId, out ushort protocolId, out ushort length, out byte unitId)` — returns false if buffer.Length < 7. - `static int TotalFrameLength(ushort lengthField)` — `lengthField + 6` (7 header bytes minus the 1-byte unit ID which is counted in the length field). 2. **`IPduPipeline.cs`** — the rewrite hook. Single method: ```csharp void Process(MbapDirection direction, ReadOnlySpan mbapHeader, Span pdu, PduContext context); ``` `MbapDirection` is `RequestToBackend` or `ResponseToClient`. `PduContext` carries the per-pair state (counters, PLC name, configured tag map). In phase 03, the only implementation is `NoopPduPipeline` which does nothing. 3. **`NoopPduPipeline.cs`** — empty `Process` method. Registered as the default `IPduPipeline` in DI for this phase. Phase 04 replaces it with the real rewriter. 4. **`PlcConnectionPair.cs`** — owns the upstream `Socket` (or `TcpClient`) handed to it by `PlcListener.Accept`, opens a fresh backend socket to the configured PLC, and runs two `Task`s: - **Upstream → backend**: read one full MBAP frame at a time (header → length → rest), call `pipeline.Process(RequestToBackend, header, pdu, ctx)`, write the frame to the backend. - **Backend → upstream**: same shape, with `ResponseToClient`. Either task ending (socket closed, exception, cancellation) tears down both sides cleanly. No retry loop; that's phase 05. Backend connect is wrapped in a `try`/`catch` with the configured `BackendConnectTimeoutMs`. Connect failures close the upstream socket immediately and log `mbproxy.backend.failed`. Polly bounded retries on backend connect are **deferred to phase 05** to keep this phase scope tight — note the deferral in code with `// Phase 05: wrap in Polly pipeline`. 5. **`PlcListener.cs`** — owns one `TcpListener` for one PLC. `StartAsync` binds; on bind failure, throws (caller logs `mbproxy.startup.bind.failed` and decides what to do — phase 05 will introduce the supervisor that turns this into a recoverable state). On each accept, hands the socket to a fresh `PlcConnectionPair` and runs it on the thread-pool. 6. **`ProxyWorker.cs`** — `BackgroundService`. On start: enumerates `MbproxyOptions.Plcs`, instantiates one `PlcListener` per entry, starts them all. Each bind that succeeds logs `mbproxy.startup.bind`; each that fails logs `mbproxy.startup.bind.failed` and continues to the next PLC (matching the design's "eager, continue on per-port failure" posture). After all bind attempts, logs `mbproxy.startup.ready` with `{ ListenersBound, PlcsConfigured }`. On stop: cancels and disposes all listeners and their open pairs. 7. **`Program.cs`** — remove the HeartbeatWorker registration; register `ProxyWorker`. Also register `IPduPipeline` as a singleton `NoopPduPipeline` in DI. ## Public surface declared in this phase All `internal sealed class` — the proxy types are not consumed outside this assembly. The only public-shaped surfaces are the `IPduPipeline` interface and the `MbapDirection` enum (so phase 04 can implement its own pipeline cleanly). ```csharp namespace Mbproxy.Proxy; public interface IPduPipeline { void Process(MbapDirection direction, ReadOnlySpan mbapHeader, Span pdu, PduContext context); } public enum MbapDirection { RequestToBackend, ResponseToClient } public sealed class PduContext { public string PlcName { get; init; } = ""; // Phase 04 adds: BcdTagMap, counters, logger } internal sealed class NoopPduPipeline : IPduPipeline { /* no-op */ } internal sealed class MbapFrame { /* static helpers */ } internal sealed class PlcListener : IAsyncDisposable { /* ... */ } internal sealed class PlcConnectionPair : IAsyncDisposable { /* ... */ } internal sealed class ProxyWorker : BackgroundService { /* ... */ } ``` ## Tests required ### Unit (`Category = Unit`) `MbapFrameTests` (≥ 8 tests): 1. `TryParseHeader_TooShort_ReturnsFalse` 2. `TryParseHeader_ValidFrame_ParsesAllFields` 3. `TryParseHeader_ProtocolId_NotZero_StillParses` — we don't reject non-zero protocol IDs; that's the PLC's job. 4. `TotalFrameLength_LengthField7_Returns13` 5. `TotalFrameLength_LengthFieldMax_Returns_LengthFieldPlus6` 6. Round-trip: parse a known good FC03 frame and assert each field. 7. Round-trip: parse a known good FC16 write-multiple frame. 8. Negative: a frame with `length < 2` returns the parsed value but is callers' responsibility to reject. Document in a test. ### E2E (`Category = E2E`) `ProxyForwardingTests` (≥ 5 tests, `[Collection(nameof(DL205SimulatorCollection))]`): 1. `Forward_FC03_HR0_Returns_SimulatorRawValue_0xCAFE` — proxy is transparent; client sees the raw simulator value. 2. `Forward_FC03_HR1072_Returns_RawBCD_0x1234` — the BCD register is NOT rewritten in phase 03 (NoopPduPipeline). This test will be REPLACED in phase 04 with one that asserts `1234` instead. Document the planned replacement in a comment so phase 04's agent knows what to update. 3. `Forward_FC06_WriteHR200_ThenReadBack_RoundTrips` — proves the write path forwards correctly. 4. `Forward_FC16_WriteMultipleHR201_203_ThenReadBack_RoundTrips`. 5. `MbapTxId_IsPreservedEndToEnd` — issue 20 back-to-back FC03 reads with monotonically increasing TxIds; assert every response carries the matching TxId. 6. `BackendConnectFailure_ClosesUpstreamCleanly` — point the proxy at an unreachable backend (`127.0.0.1:1`), assert the client's socket is closed within `BackendConnectTimeoutMs + 200ms`. ## Phase gate - [ ] Zero-warnings build. - [ ] All phase 00, 02 tests still green. - [ ] All new unit tests green (≥ 8 in MbapFrameTests). - [ ] All new e2e tests green when the simulator is available; skip cleanly when it isn't. - [ ] `dotnet run --project src/Mbproxy` with an appsettings.json pointing at the simulator: NModbus can read/write through the proxy and gets the simulator's raw values. - [ ] On startup with one bad and one good PLC config, the good one binds and the bad one logs `mbproxy.startup.bind.failed`, and the service does NOT abort. (Hand the supervisor work to phase 05; this phase only proves the "continue on per-port failure" posture.) - [ ] `mbproxy.startup.ready` is now logged by `ProxyWorker`, not by a heartbeat worker. The heartbeat worker file is deleted. ## Out of scope - BCD rewriting (phase 04 replaces `NoopPduPipeline`). - Polly retries on backend connect (phase 05 supervisor wraps this). - Auto-recovery for failed listener binds (phase 05). - Counter tracking / per-PLC telemetry (phase 04 starts adding counters via `PduContext`). - Half-MBAP-frame handling (split TCP packets): rely on `NetworkStream.ReadAsync` returning short reads; loop to fill the header (7 bytes) and then loop to fill the body (`length - 1` more bytes). Test 5 above verifies this stays correct over 20 back-to-back requests. ## Notes for the subagent - `Socket` vs `TcpClient`: prefer `Socket` directly so framing reads can use `ReadOnlyMemory` without `NetworkStream` allocation overhead. The performance difference is small but the byte-precise API matches what the rewriter in phase 04 will need. - Frame reads use a per-pair pooled buffer of 260 bytes (MBAP header 7 + max PDU 253). Don't allocate per-frame. - The "Phase 04 will replace test 2" pattern is intentional. Leave breadcrumbs so the next phase's agent knows exactly which test to update; do NOT silently make the test pass against a future rewriter. - Both forwarder tasks run with the same `CancellationTokenSource`. Cancellation propagates from listener stop → pair stop → both task ends → socket dispose.