mbproxy: strip historical phase/wave/plan references from source comments

Comments described the *history* of how the code arrived (phase numbers,
wave IDs, review IDs, dated TODOs) instead of what it does today. That
scaffolding rotted as the codebase evolved. Cleaned 60 source files +
.gitignore; behaviour unchanged (387/387 tests still pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-14 13:04:30 -04:00
parent b3b8313e9c
commit 1a2856526a
60 changed files with 750 additions and 811 deletions
+2 -1
View File
@@ -1,13 +1,14 @@
# Build output # Build output
bin/ bin/
obj/ obj/
publish-out/
# Visual Studio artifacts # Visual Studio artifacts
.vs/ .vs/
*.user *.user
*.suo *.suo
# Test simulator Python venv (phase 01 onward) # Test simulator Python venv
tests/sim/.venv/ tests/sim/.venv/
# mbproxy runtime logs (default location, see appsettings.json) # mbproxy runtime logs (default location, see appsettings.json)
+15 -15
View File
@@ -25,11 +25,11 @@ namespace Mbproxy.Admin;
/// ///
/// <para>Routes: exactly two — <c>GET /</c> (HTML) and <c>GET /status.json</c> (JSON).</para> /// <para>Routes: exactly two — <c>GET /</c> (HTML) and <c>GET /status.json</c> (JSON).</para>
/// ///
/// <para><b>Phase 12 (W1.5)</b> — was previously also registered as <see cref="IHostedService"/>, /// <para>Registered as a plain singleton (not <see cref="IHostedService"/>) so
/// but the host's automatic stop ordering (reverse of registration) ran admin.StopAsync /// <see cref="Proxy.ProxyWorker"/> can drive its lifecycle explicitly. This is required to
/// BEFORE ProxyWorker.StopAsync, which broke the design's "drain THEN stop admin" guarantee /// honour the design contract that the in-flight drain finishes BEFORE admin stops; an
/// and caused a double-stop with the now-deleted <c>ShutdownCoordinator</c>. Now a plain /// IHostedService registration would let the host stop admin in reverse-registration order
/// singleton with explicit lifecycle calls from ProxyWorker.</para> /// and break that ordering.</para>
/// </summary> /// </summary>
internal sealed partial class AdminEndpointHost : IAsyncDisposable internal sealed partial class AdminEndpointHost : IAsyncDisposable
{ {
@@ -44,11 +44,11 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
// Protects concurrent Start/Stop calls (hot-reload + StopAsync racing). // Protects concurrent Start/Stop calls (hot-reload + StopAsync racing).
private readonly SemaphoreSlim _lock = new(1, 1); private readonly SemaphoreSlim _lock = new(1, 1);
// Phase 12 (W4 / Nm7) — idempotency flag for DisposeAsync. ProxyWorker.StopAsync // Idempotency flag for DisposeAsync. ProxyWorker.StopAsync calls our StopAsync
// calls our StopAsync explicitly; the DI container then disposes the singleton on // explicitly; the DI container then disposes the singleton on host shutdown. Without
// host shutdown. Without this flag the second pass would Dispose `_lock` twice and // this flag the second pass would Dispose `_lock` twice and re-dispose the change
// re-dispose the change registration (both currently safe but symmetry with // registration (both currently safe but symmetry with PlcMultiplexer prevents future
// PlcMultiplexer prevents future regression). // regression).
private volatile bool _disposed; private volatile bool _disposed;
// Current configured port — used to detect changes on hot-reload. // Current configured port — used to detect changes on hot-reload.
@@ -77,11 +77,11 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
// Subscribe to config changes: if AdminPort changes, re-bind. // Subscribe to config changes: if AdminPort changes, re-bind.
_optionsChangeRegistration = _optionsMonitor.OnChange(opts => _optionsChangeRegistration = _optionsMonitor.OnChange(opts =>
{ {
// Phase 12 (W5 / M1) — short-circuit if disposal has already started. The // Short-circuit if disposal has already started. The OnChange callback can
// OnChange callback can fire (and the Task.Run can be queued) AFTER StopAsync // fire (and the Task.Run can be queued) AFTER StopAsync disposed the change
// disposed the change registration but BEFORE DI ran DisposeAsync; without // registration but BEFORE DI ran DisposeAsync; without this guard the lambda
// this guard the lambda would resurrect a fresh Kestrel app on the new port // would resurrect a fresh Kestrel app on the new port after the host already
// after the host already considered admin shut down. // considered admin shut down.
if (_disposed) return; if (_disposed) return;
int newPort = opts.AdminPort; int newPort = opts.AdminPort;
@@ -6,9 +6,9 @@ namespace Mbproxy.Admin;
/// Reads <see cref="AssemblyInformationalVersionAttribute"/> once at startup and caches the /// Reads <see cref="AssemblyInformationalVersionAttribute"/> once at startup and caches the
/// result as a string. Used for the <c>service.version</c> field on the status page. /// result as a string. Used for the <c>service.version</c> field on the status page.
/// ///
/// <para>Note: <see cref="Assembly.Location"/> is unreliable under single-file publish /// <para>Note: <see cref="Assembly.Location"/> is unreliable under single-file publish.
/// (Phase 08). We use <c>Assembly.GetExecutingAssembly().GetCustomAttribute&lt;&gt;()</c> /// We use <c>Assembly.GetExecutingAssembly().GetCustomAttribute&lt;&gt;()</c> which works
/// which works correctly regardless of publish mode.</para> /// correctly regardless of publish mode.</para>
/// </summary> /// </summary>
internal sealed class AssemblyVersionAccessor internal sealed class AssemblyVersionAccessor
{ {
+15 -14
View File
@@ -60,9 +60,9 @@ public sealed record PlcPdusStatus(
long RewrittenSlots, long RewrittenSlots,
long PartialBcdWarnings, long PartialBcdWarnings,
/// <summary> /// <summary>
/// Phase 12 (W2.22) — count of BCD-rewriter slot decisions where the wire value was /// Count of BCD-rewriter slot decisions where the wire value was not a valid BCD
/// not a valid BCD nibble pattern (e.g. <c>0xABCD</c> at a tag address). The slot /// nibble pattern (e.g. <c>0xABCD</c> at a tag address). The slot passes through
/// passes through unrewritten and this counter increments. /// unrewritten and this counter increments.
/// </summary> /// </summary>
long InvalidBcdWarnings); long InvalidBcdWarnings);
@@ -75,15 +75,16 @@ public sealed record FcCounts(
long Other); long Other);
/// <summary> /// <summary>
/// Backend connect, exception, and multiplexer telemetry. Phase 9 added /// Backend connect, exception, and multiplexer telemetry, including the in-flight
/// <c>InFlight</c>, <c>MaxInFlight</c>, <c>TxIdWraps</c>, <c>DisconnectCascades</c>, and /// multiplexer fields (<c>InFlight</c>, <c>MaxInFlight</c>, <c>TxIdWraps</c>,
/// <c>QueueDepth</c>. Phase 10 added the three coalescing counters /// <c>DisconnectCascades</c>, <c>QueueDepth</c>), the read-coalescing counters
/// (<c>CoalescedHitCount</c>, <c>CoalescedMissCount</c>, <c>CoalescedResponseToDeadUpstream</c>); /// (<c>CoalescedHitCount</c>, <c>CoalescedMissCount</c>, <c>CoalescedResponseToDeadUpstream</c>),
/// the dashboard-side derived <c>coalescingRatio</c> is intentionally NOT carried on the wire /// and the response-cache counters (<c>CacheHitCount</c>, <c>CacheMissCount</c>,
/// — consumers compute <c>Hit / (Hit + Miss)</c>. Phase 11 added the five cache counters /// <c>CacheInvalidations</c>, <c>CacheEntryCount</c>, <c>CacheBytes</c>).
/// (<c>CacheHitCount</c>, <c>CacheMissCount</c>, <c>CacheInvalidations</c>, ///
/// <c>CacheEntryCount</c>, <c>CacheBytes</c>); the dashboard-side derived /// <para>The dashboard-side derived ratios <c>coalescingRatio</c> and <c>cacheHitRatio</c>
/// <c>cacheHitRatio</c> is intentionally NOT carried on the wire. /// are intentionally NOT carried on the wire — consumers compute <c>Hit / (Hit + Miss)</c>
/// from the raw counters.</para>
/// </summary> /// </summary>
public sealed record PlcBackendStatus( public sealed record PlcBackendStatus(
long ConnectsSuccess, long ConnectsSuccess,
@@ -111,8 +112,8 @@ public sealed record ExceptionCounts(
long Code03, long Code03,
long Code04, long Code04,
/// <summary> /// <summary>
/// Phase 12 (W2.22) — backend exceptions whose response code is not 0104 (e.g. 0x06 /// Backend exceptions whose response code is not 0104 (e.g. 0x06 Server Device
/// Server Device Busy, 0x0B Gateway Target Failed To Respond, vendor-specific codes). /// Busy, 0x0B Gateway Target Failed To Respond, vendor-specific codes).
/// </summary> /// </summary>
long CodeOther); long CodeOther);
+11 -11
View File
@@ -5,7 +5,7 @@ namespace Mbproxy.Admin;
/// <summary> /// <summary>
/// Renders a <see cref="StatusResponse"/> as a self-contained HTML page. /// Renders a <see cref="StatusResponse"/> as a self-contained HTML page.
/// ///
/// <para>Constraints (from design.md Phase 07):</para> /// <para>Constraints (see <c>docs/design.md</c> status-page section):</para>
/// <list type="bullet"> /// <list type="bullet">
/// <item>No external assets (CSS/JS/fonts/favicons) — firewalled networks only.</item> /// <item>No external assets (CSS/JS/fonts/favicons) — firewalled networks only.</item>
/// <item><c>&lt;meta http-equiv="refresh" content="5"&gt;</c> for auto-refresh.</item> /// <item><c>&lt;meta http-equiv="refresh" content="5"&gt;</c> for auto-refresh.</item>
@@ -77,16 +77,16 @@ internal static class StatusHtmlRenderer
sb.Append("<th>FC06</th><th>FC16</th><th>FC?</th><th>BCD slots</th>"); sb.Append("<th>FC06</th><th>FC16</th><th>FC?</th><th>BCD slots</th>");
sb.Append("<th>Partial BCD</th><th>Invalid BCD</th><th>Ex 01</th><th>Ex 02</th><th>Ex 03</th><th>Ex 04</th><th>Ex ?</th>"); sb.Append("<th>Partial BCD</th><th>Invalid BCD</th><th>Ex 01</th><th>Ex 02</th><th>Ex 03</th><th>Ex 04</th><th>Ex ?</th>");
sb.Append("<th>RTT ms</th><th>Bytes in</th><th>Bytes out</th>"); sb.Append("<th>RTT ms</th><th>Bytes in</th><th>Bytes out</th>");
// Phase 9: multiplexer telemetry columns. // Multiplexer telemetry columns.
sb.Append("<th>In-flight</th><th>Max in-flight</th><th>TxId wraps</th>"); sb.Append("<th>In-flight</th><th>Max in-flight</th><th>TxId wraps</th>");
sb.Append("<th>Cascades</th><th>Queue</th>"); sb.Append("<th>Cascades</th><th>Queue</th>");
// Phase 10: coalescing column. Single cell carries hit / (hit + miss) ratio as // Coalescing column. Single cell carries hit / (hit + miss) ratio as a
// a percentage plus the raw hit count for context. Kept compact (one cell) to // percentage plus the raw hit count for context. Kept compact (one cell) to
// stay under the 50 KB page-weight budget. // stay under the 50 KB page-weight budget.
sb.Append("<th>Coal</th>"); sb.Append("<th>Coal</th>");
// Phase 11: cache column. Single cell carries hit-ratio percent plus raw hit // Cache column. Single cell carries hit-ratio percent plus raw hit count;
// count; an em-dash when no cache-eligible reads have occurred. Page-weight // an em-dash when no cache-eligible reads have occurred. Page-weight budget
// budget assertion stays under 50 KB for the 54-PLC fleet. // assertion stays under 50 KB for the 54-PLC fleet.
sb.Append("<th>Cache</th>"); sb.Append("<th>Cache</th>");
sb.Append("</tr></thead><tbody>"); sb.Append("</tr></thead><tbody>");
@@ -150,14 +150,14 @@ internal static class StatusHtmlRenderer
sb.Append("<td>").Append(plc.Backend.LastRoundTripMs.ToString("F1")).Append("</td>"); sb.Append("<td>").Append(plc.Backend.LastRoundTripMs.ToString("F1")).Append("</td>");
sb.Append("<td>").Append(plc.Bytes.UpstreamIn).Append("</td>"); sb.Append("<td>").Append(plc.Bytes.UpstreamIn).Append("</td>");
sb.Append("<td>").Append(plc.Bytes.UpstreamOut).Append("</td>"); sb.Append("<td>").Append(plc.Bytes.UpstreamOut).Append("</td>");
// Phase 9: multiplexer telemetry cells. // Multiplexer telemetry cells.
sb.Append("<td>").Append(plc.Backend.InFlight).Append("</td>"); sb.Append("<td>").Append(plc.Backend.InFlight).Append("</td>");
sb.Append("<td>").Append(plc.Backend.MaxInFlight).Append("</td>"); sb.Append("<td>").Append(plc.Backend.MaxInFlight).Append("</td>");
sb.Append("<td>").Append(plc.Backend.TxIdWraps).Append("</td>"); sb.Append("<td>").Append(plc.Backend.TxIdWraps).Append("</td>");
sb.Append("<td>").Append(plc.Backend.DisconnectCascades).Append("</td>"); sb.Append("<td>").Append(plc.Backend.DisconnectCascades).Append("</td>");
sb.Append("<td>").Append(plc.Backend.QueueDepth).Append("</td>"); sb.Append("<td>").Append(plc.Backend.QueueDepth).Append("</td>");
// Phase 10: coalescing ratio cell — "<pct>% (<hit>)". When no coalesced reads // Coalescing ratio cell — "<pct>% (<hit>)". When no coalesced reads have
// have been seen, render an em-dash to keep the cell narrow. // been seen, render an em-dash to keep the cell narrow.
long coalHit = plc.Backend.CoalescedHitCount; long coalHit = plc.Backend.CoalescedHitCount;
long coalMiss = plc.Backend.CoalescedMissCount; long coalMiss = plc.Backend.CoalescedMissCount;
sb.Append("<td>"); sb.Append("<td>");
@@ -171,7 +171,7 @@ internal static class StatusHtmlRenderer
sb.Append(pct).Append("% (").Append(coalHit).Append(')'); sb.Append(pct).Append("% (").Append(coalHit).Append(')');
} }
sb.Append("</td>"); sb.Append("</td>");
// Phase 11: cache ratio cell — same pattern as coalescing. // Cache ratio cell — same pattern as coalescing.
long cacheHit = plc.Backend.CacheHitCount; long cacheHit = plc.Backend.CacheHitCount;
long cacheMiss = plc.Backend.CacheMissCount; long cacheMiss = plc.Backend.CacheMissCount;
sb.Append("<td>"); sb.Append("<td>");
@@ -66,10 +66,6 @@ internal sealed class StatusSnapshotBuilder
var activeUpstreams = supervisor?.ActiveUpstreams ?? Array.Empty<UpstreamPipe>(); var activeUpstreams = supervisor?.ActiveUpstreams ?? Array.Empty<UpstreamPipe>();
var clientSnapshots = activeUpstreams var clientSnapshots = activeUpstreams
.Select(p => new ClientSnapshot( .Select(p => new ClientSnapshot(
// Phase 12 (W3 cleanup) — the second `?.Address.ToString()` was
// unreachable: if RemoteEp is non-null the first ?.ToString() returns
// a string; if it's null the second branch's outer `?.` short-circuits
// identically. Simplified to the equivalent two-branch form.
Remote: p.RemoteEp?.ToString() ?? "?", Remote: p.RemoteEp?.ToString() ?? "?",
ConnectedAtUtc: p.ConnectedAtUtc, ConnectedAtUtc: p.ConnectedAtUtc,
PdusForwarded: p.PdusForwardedCount)) PdusForwarded: p.PdusForwardedCount))
@@ -114,7 +110,6 @@ internal sealed class StatusSnapshotBuilder
CacheBytes: 0, CacheBytes: 0,
ResponseDropForFullUpstream: 0); ResponseDropForFullUpstream: 0);
// Phase 08: ConnectsSuccess / ConnectsFailed are now tracked in ProxyCounters.
long connectsSuccess = counters.ConnectsSuccess; long connectsSuccess = counters.ConnectsSuccess;
long connectsFailed = counters.ConnectsFailed; long connectsFailed = counters.ConnectsFailed;
+2 -2
View File
@@ -13,8 +13,8 @@ namespace Mbproxy.Bcd;
/// Example: 12_345_678 → low=0x5678, high=0x1234. /// Example: 12_345_678 → low=0x5678, high=0x1234.
/// ///
/// Bad-nibble policy: Decode16/Decode32 throw <see cref="FormatException"/> /// Bad-nibble policy: Decode16/Decode32 throw <see cref="FormatException"/>
/// (not a sentinel). The Phase 04 rewrite pipeline catches and surfaces the /// (not a sentinel). The rewrite pipeline catches and surfaces the exception as an
/// exception as an mbproxy.rewrite.invalid_bcd warning event. /// mbproxy.rewrite.invalid_bcd warning event.
/// </summary> /// </summary>
internal static class BcdCodec internal static class BcdCodec
{ {
+4 -4
View File
@@ -4,9 +4,9 @@ namespace Mbproxy.Bcd;
/// Immutable description of a single BCD-encoded V-memory tag as seen on the Modbus wire. /// Immutable description of a single BCD-encoded V-memory tag as seen on the Modbus wire.
/// Width is 16 (one register) or 32 (two registers, CDAB low-word-first). /// Width is 16 (one register) or 32 (two registers, CDAB low-word-first).
/// ///
/// <para><b>Phase 11 — <see cref="CacheTtlMs"/></b> is the resolved per-tag response-cache /// <para><b><see cref="CacheTtlMs"/></b> is the resolved per-tag response-cache TTL in
/// TTL in milliseconds. 0 (the default) means caching is disabled for this tag. Positive /// milliseconds. 0 (the default) means caching is disabled for this tag. Positive values
/// values cap upstream staleness; the multi-tag-range read uses <c>min(TTLs)</c> across all /// cap upstream staleness; the multi-tag-range read uses <c>min(TTLs)</c> across all
/// matched tags and treats any 0 in the range as "uncached for the whole read."</para> /// matched tags and treats any 0 in the range as "uncached for the whole read."</para>
/// </summary> /// </summary>
public sealed record BcdTag(ushort Address, byte Width, int CacheTtlMs = 0) public sealed record BcdTag(ushort Address, byte Width, int CacheTtlMs = 0)
@@ -33,7 +33,7 @@ public sealed record BcdTag(ushort Address, byte Width, int CacheTtlMs = 0)
/// <summary>True when this tag occupies two registers (32-bit BCD).</summary> /// <summary>True when this tag occupies two registers (32-bit BCD).</summary>
public bool IsThirtyTwoBit => Width == 32; public bool IsThirtyTwoBit => Width == 32;
/// <summary>True when this tag opts into the Phase-11 response cache.</summary> /// <summary>True when this tag opts into the response cache.</summary>
public bool IsCacheable => CacheTtlMs > 0; public bool IsCacheable => CacheTtlMs > 0;
/// <summary> /// <summary>
+2 -2
View File
@@ -47,7 +47,7 @@ public sealed class BcdTagMap
=> _map.TryGetValue(address, out tag!); => _map.TryGetValue(address, out tag!);
/// <summary> /// <summary>
/// Phase 11 — resolves the effective cache TTL for an FC03/FC04 read over the range /// Resolves the effective cache TTL for an FC03/FC04 read over the range
/// [<paramref name="startAddress"/>, <paramref name="startAddress"/> + <paramref name="qty"/>). /// [<paramref name="startAddress"/>, <paramref name="startAddress"/> + <paramref name="qty"/>).
/// ///
/// <para>Returns 0 (uncached) when:</para> /// <para>Returns 0 (uncached) when:</para>
@@ -135,7 +135,7 @@ public sealed class BcdTagMap
return false; return false;
} }
// Sort ascending by offset so Phase 04 can iterate in wire order. // Sort ascending by offset so the rewrite pipeline can iterate in wire order.
result.Sort(static (a, b) => a.OffsetWords.CompareTo(b.OffsetWords)); result.Sort(static (a, b) => a.OffsetWords.CompareTo(b.OffsetWords));
hits = result; hits = result;
return true; return true;
+11 -13
View File
@@ -34,8 +34,8 @@ public static class BcdTagMapBuilder
=> Build(global, perPlc, perPlcDefaultCacheTtlMs: 0); => Build(global, perPlc, perPlcDefaultCacheTtlMs: 0);
/// <summary> /// <summary>
/// Phase 11 overload resolves the effective BCD tag list for one PLC and validates /// Overload that resolves the effective BCD tag list for one PLC and validates it,
/// it, additionally folding the per-PLC <paramref name="perPlcDefaultCacheTtlMs"/> into /// additionally folding the per-PLC <paramref name="perPlcDefaultCacheTtlMs"/> into
/// any tag whose explicit <see cref="BcdTagOptions.CacheTtlMs"/> is null. /// any tag whose explicit <see cref="BcdTagOptions.CacheTtlMs"/> is null.
/// ///
/// <para>Resolution order per tag:</para> /// <para>Resolution order per tag:</para>
@@ -53,13 +53,11 @@ public static class BcdTagMapBuilder
var errors = new List<BcdError>(); var errors = new List<BcdError>();
var warnings = new List<BcdWarning>(); var warnings = new List<BcdWarning>();
// Phase 12 (W2.11) — duplicate-address detection happens BEFORE the working // Duplicate-address detection happens BEFORE the working dictionary collapses
// dictionary collapses keys. Iterating each list independently catches duplicates // keys. Iterating each list independently catches duplicates that would otherwise
// that would otherwise be silently last-write-wins'd by the dictionary indexer. // be silently last-write-wins'd by the dictionary indexer. Cross-list collisions
// Cross-list collisions (same address in BOTH Global and Add) are the documented // (same address in BOTH Global and Add) are the documented width-override pattern
// width-override pattern and must NOT be flagged — only intra-list duplicates fail. // and must NOT be flagged — only intra-list duplicates fail.
// Without this fix the DuplicateAddress validation error was dead code (the
// post-collapse dict has unique keys by construction).
static void DetectIntraListDuplicates( static void DetectIntraListDuplicates(
IEnumerable<BcdTagOptions> source, string sourceName, List<BcdError> errors) IEnumerable<BcdTagOptions> source, string sourceName, List<BcdError> errors)
{ {
@@ -119,7 +117,7 @@ public static class BcdTagMapBuilder
continue; continue;
} }
// Phase 11 — resolve the effective per-tag cache TTL: // Resolve the effective per-tag cache TTL:
// explicit per-tag (including 0) wins; otherwise fall back to per-PLC default. // explicit per-tag (including 0) wins; otherwise fall back to per-PLC default.
int resolvedTtl = opt.CacheTtlMs ?? perPlcDefaultCacheTtlMs; int resolvedTtl = opt.CacheTtlMs ?? perPlcDefaultCacheTtlMs;
if (resolvedTtl < 0) resolvedTtl = 0; if (resolvedTtl < 0) resolvedTtl = 0;
@@ -128,9 +126,9 @@ public static class BcdTagMapBuilder
} }
// High-register collision check (only meaningful for 32-bit entries). // High-register collision check (only meaningful for 32-bit entries).
// Phase 12 (W2.12) — dedupe symmetric reports. Two 32-bit tags whose pairs collide // Dedupe symmetric reports. Two 32-bit tags whose pairs collide (e.g. (100,W=32)
// (e.g. (100,W=32) and (101,W=32)) would otherwise produce two BcdErrors — one // and (101,W=32)) would otherwise produce two BcdErrors — one from each
// from each direction. Track reported (low,high) pairs so each collision logs once. // direction. Track reported (low,high) pairs so each collision logs once.
var reportedCollisions = new HashSet<(ushort, ushort)>(); var reportedCollisions = new HashSet<(ushort, ushort)>();
foreach (var tag in validated.Values) foreach (var tag in validated.Values)
{ {
@@ -48,17 +48,17 @@ internal sealed partial class ConfigReconciler : IDisposable
private readonly ServiceCounters _serviceCounters; private readonly ServiceCounters _serviceCounters;
// The supervisor dictionary is set by ProxyWorker after initial startup. // The supervisor dictionary is set by ProxyWorker after initial startup.
// Phase 12 (W2.3) — ConcurrentDictionary so the per-PLC Add/Remove/Restart task // ConcurrentDictionary so the per-PLC Add/Remove/Restart task continuations inside
// continuations inside ApplyUnderLockAsync can mutate it concurrently. The outer Apply // ApplyUnderLockAsync can mutate it concurrently. The outer Apply is serialised by
// is serialised by _applySemaphore but the inner Task.WhenAll runs in parallel. // _applySemaphore but the inner Task.WhenAll runs in parallel.
private ConcurrentDictionary<string, PlcListenerSupervisor>? _supervisors; private ConcurrentDictionary<string, PlcListenerSupervisor>? _supervisors;
private MbproxyOptions? _currentOptions; private MbproxyOptions? _currentOptions;
// Phase 12 (W2.1) — live accessor for ReadCoalescingOptions, threaded through Attach // Live accessor for ReadCoalescingOptions, threaded through Attach so PLCs added or
// so PLCs added or restarted via hot-reload honour the current // restarted via hot-reload honour the current
// `Mbproxy.Resilience.ReadCoalescing.{Enabled,MaxParties}` values. Without this, // `Mbproxy.Resilience.ReadCoalescing.{Enabled,MaxParties}` values. Without this,
// reconciler-built supervisors silently used the default `new ReadCoalescingOptions()`, // reconciler-built supervisors would use the default `new ReadCoalescingOptions()`
// and a hot-reload of `Enabled = false` did not propagate to those supervisors. // and a hot-reload of `Enabled = false` would not propagate to them.
private Func<ReadCoalescingOptions>? _coalescingAccessor; private Func<ReadCoalescingOptions>? _coalescingAccessor;
// ── Debounce + serialisation machinery ─────────────────────────────────────────────── // ── Debounce + serialisation machinery ───────────────────────────────────────────────
@@ -111,8 +111,8 @@ internal sealed partial class ConfigReconciler : IDisposable
/// <summary> /// <summary>
/// Provides the reconciler with the supervisor dictionary, the initial options snapshot, /// Provides the reconciler with the supervisor dictionary, the initial options snapshot,
/// and (Phase 12 W2.1) the live <see cref="ReadCoalescingOptions"/> accessor that /// and the live <see cref="ReadCoalescingOptions"/> accessor that add/restart
/// add/restart supervisors must use so a hot-reloaded /// supervisors must use so a hot-reloaded
/// <c>Mbproxy.Resilience.ReadCoalescing.Enabled</c> propagates to them. Must be called /// <c>Mbproxy.Resilience.ReadCoalescing.Enabled</c> propagates to them. Must be called
/// exactly once by <see cref="Proxy.ProxyWorker"/> before any <c>OnChange</c> events /// exactly once by <see cref="Proxy.ProxyWorker"/> before any <c>OnChange</c> events
/// can arrive (i.e. immediately after the supervisors are created). Thread-safe: the /// can arrive (i.e. immediately after the supervisors are created). Thread-safe: the
@@ -287,7 +287,8 @@ internal sealed partial class ConfigReconciler : IDisposable
await old.DisposeAsync().ConfigureAwait(false); await old.DisposeAsync().ConfigureAwait(false);
} }
// Build fresh context. Phase 11: pass DefaultCacheTtlMs. // Build fresh context. Pass DefaultCacheTtlMs so per-PLC default
// caching folds into the resolved tag map.
var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs); var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs);
var newCtx = new PerPlcContext var newCtx = new PerPlcContext
{ {
@@ -345,8 +346,8 @@ internal sealed partial class ConfigReconciler : IDisposable
// Preserve existing counters so operators see real history. // Preserve existing counters so operators see real history.
Counters = supervisor.CurrentCounters, Counters = supervisor.CurrentCounters,
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{name}"), Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{name}"),
// Phase 11: any reseat (tag-map change) constructs a fresh cache. // Any reseat (tag-map change) constructs a fresh cache. The
// The supervisor disposes the old one inside ReplaceContextAsync. // supervisor disposes the old one inside ReplaceContextAsync.
Cache = BuildCacheIfNeeded(newMap, next.Cache), Cache = BuildCacheIfNeeded(newMap, next.Cache),
}; };
@@ -373,7 +374,8 @@ internal sealed partial class ConfigReconciler : IDisposable
{ {
try try
{ {
// Phase 11: pass DefaultCacheTtlMs. // Pass DefaultCacheTtlMs so per-PLC default caching folds into the
// resolved tag map.
var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs); var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs);
var newCtx = new PerPlcContext var newCtx = new PerPlcContext
{ {
@@ -427,9 +429,9 @@ internal sealed partial class ConfigReconciler : IDisposable
// ── Helpers ─────────────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Phase 11 — constructs a <see cref="ResponseCache"/> only when at least one resolved /// Constructs a <see cref="ResponseCache"/> only when at least one resolved tag in
/// tag in <paramref name="map"/> opts in (<see cref="BcdTag.CacheTtlMs"/> &gt; 0). /// <paramref name="map"/> opts in (<see cref="BcdTag.CacheTtlMs"/> &gt; 0). Returns
/// Returns <c>null</c> otherwise so the no-cache path is byte-identical to Phase 10. /// <c>null</c> otherwise so the no-cache path bypasses cache logic entirely.
/// </summary> /// </summary>
private static ResponseCache? BuildCacheIfNeeded(BcdTagMap map, CacheOptions opts) private static ResponseCache? BuildCacheIfNeeded(BcdTagMap map, CacheOptions opts)
{ {
@@ -78,8 +78,8 @@ public sealed record ReloadPlan(
// Tag-map change → reseat (swap context, keep socket). // Tag-map change → reseat (swap context, keep socket).
// We must build both maps to compare them structurally. // We must build both maps to compare them structurally.
// Compute happens after validation so Build should never return errors here. // Compute happens after validation so Build should never return errors here.
// Phase 11: include DefaultCacheTtlMs in the build so a per-PLC default change // Include DefaultCacheTtlMs in the build so a per-PLC default change is
// is detected by TagMapsEqual via the per-tag CacheTtlMs delta. // detected by TagMapsEqual via the per-tag CacheTtlMs delta.
var oldMap = BcdTagMapBuilder.Build(current.BcdTags, plcOld.BcdTags, plcOld.DefaultCacheTtlMs).Map; var oldMap = BcdTagMapBuilder.Build(current.BcdTags, plcOld.BcdTags, plcOld.DefaultCacheTtlMs).Map;
var newMap = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs).Map; var newMap = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs).Map;
@@ -97,8 +97,8 @@ public sealed record ReloadPlan(
/// <summary> /// <summary>
/// Structural equality between two <see cref="BcdTagMap"/> instances: same set of /// Structural equality between two <see cref="BcdTagMap"/> instances: same set of
/// (Address, Width, CacheTtlMs) triples. Order doesn't matter — we compare as sets. /// (Address, Width, CacheTtlMs) triples. Order doesn't matter — we compare as sets.
/// Phase 11 includes <see cref="BcdTag.CacheTtlMs"/> in the comparison so a per-tag /// Includes <see cref="BcdTag.CacheTtlMs"/> in the comparison so a per-tag or per-PLC
/// or per-PLC default TTL change reseats the context (which flushes the cache). /// default TTL change reseats the context (which flushes the cache).
/// </summary> /// </summary>
private static bool TagMapsEqual(BcdTagMap a, BcdTagMap b) private static bool TagMapsEqual(BcdTagMap a, BcdTagMap b)
{ {
@@ -75,10 +75,10 @@ internal static class ReloadValidator
// ── 4. Per-PLC tag-map build ────────────────────────────────────────── // ── 4. Per-PLC tag-map build ──────────────────────────────────────────
// BcdTagMapBuilder.Build is the single source of truth for tag-list // BcdTagMapBuilder.Build is the single source of truth for tag-list
// well-formedness; we must not duplicate its validation logic here. // well-formedness; we must not duplicate its validation logic here.
// Phase 12 (W2.10) — also re-check the RESOLVED per-tag CacheTtlMs against // Also re-check the RESOLVED per-tag CacheTtlMs against AllowLongTtl. The raw-
// AllowLongTtl. The raw-input check at section 5 covers explicit per-tag and // input check at section 5 covers explicit per-tag and per-PLC-default values,
// per-PLC-default values, but defensively re-validating the post-fold values // but defensively re-validating the post-fold values catches any future fold
// catches any future fold logic that produces a value above the gate. // logic that produces a value above the gate.
bool allowLongTtlForResolved = next.Cache.AllowLongTtl; bool allowLongTtlForResolved = next.Cache.AllowLongTtl;
foreach (var plc in next.Plcs) foreach (var plc in next.Plcs)
{ {
@@ -98,7 +98,7 @@ internal static class ReloadValidator
} }
} }
// ── 5. Cache TTL bounds (Phase 11) ──────────────────────────────────── // ── 5. Cache TTL bounds ───────────────────────────────────────────────
// The MbproxyOptionsValidator catches these at schema time too, but ReloadValidator // The MbproxyOptionsValidator catches these at schema time too, but ReloadValidator
// is the gate that the hot-reload path consults directly so re-checking here keeps // is the gate that the hot-reload path consults directly so re-checking here keeps
// both paths internally consistent (and the validator runs against tag-map-resolved // both paths internally consistent (and the validator runs against tag-map-resolved
@@ -129,8 +129,8 @@ internal static class ReloadValidator
if (next.Cache.EvictionIntervalMs < 0) if (next.Cache.EvictionIntervalMs < 0)
errs.Add($"Cache.EvictionIntervalMs must be >= 0; got {next.Cache.EvictionIntervalMs}."); errs.Add($"Cache.EvictionIntervalMs must be >= 0; got {next.Cache.EvictionIntervalMs}.");
// Phase 12 (W2.18) — Connection timeouts must be > 0. A reload that sets any // Connection timeouts must be > 0. A reload that sets any of these to 0 or
// of these to 0 or negative would break the runtime; reject the reload as a whole. // negative would break the runtime; reject the reload as a whole.
if (next.Connection.BackendConnectTimeoutMs <= 0) if (next.Connection.BackendConnectTimeoutMs <= 0)
errs.Add( errs.Add(
$"Connection.BackendConnectTimeoutMs must be > 0; got {next.Connection.BackendConnectTimeoutMs}."); $"Connection.BackendConnectTimeoutMs must be > 0; got {next.Connection.BackendConnectTimeoutMs}.");
@@ -29,9 +29,9 @@ internal sealed class EventLogBridge : ILogEventSink
private const int MaxMessageBytes = 32 * 1024; // 32 KB Event Log limit private const int MaxMessageBytes = 32 * 1024; // 32 KB Event Log limit
private readonly bool _enabled; private readonly bool _enabled;
// Phase 12 (W2.23) — cache the source-exists check at construction so Emit doesn't // Cache the source-exists check at construction so Emit doesn't hit the registry on
// hit the registry on every Error+ log line. A missing source after start requires a // every Error+ log line. A missing source after start requires a service restart to
// service restart to pick up; in practice install.ps1 registers it once at install. // pick up; in practice install.ps1 registers it once at install.
private readonly bool _sourceExists; private readonly bool _sourceExists;
public EventLogBridge(bool enabled) public EventLogBridge(bool enabled)
@@ -50,7 +50,7 @@ internal sealed class EventLogBridge : ILogEventSink
if (!_enabled) return; if (!_enabled) return;
if (logEvent.Level < LogEventLevel.Error) return; if (logEvent.Level < LogEventLevel.Error) return;
// Cached at construction (W2.23) — silently swallow if the source isn't registered. // Cached at construction — silently swallow if the source isn't registered.
// The service account may not be able to create it and we must not crash the logger. // The service account may not be able to create it and we must not crash the logger.
if (!_sourceExists) return; if (!_sourceExists) return;
+12 -14
View File
@@ -10,12 +10,10 @@ internal static class HostingExtensions
{ {
/// <summary> /// <summary>
/// Registers the <c>"Mbproxy"</c> configuration section, binds it to /// Registers the <c>"Mbproxy"</c> configuration section, binds it to
/// <see cref="MbproxyOptions"/> via <c>IOptionsMonitor</c>, and registers /// <see cref="MbproxyOptions"/> via <c>IOptionsMonitor</c>, registers the schema-
/// the schema-level <see cref="MbproxyOptionsValidator"/>. /// level <see cref="MbproxyOptionsValidator"/>, and registers the singleton
/// /// <see cref="ServiceCounters"/> and <see cref="ConfigReconciler"/> so they can be
/// Phase 06: also registers <see cref="ServiceCounters"/> (singleton) and /// injected into <see cref="Proxy.ProxyWorker"/>.
/// <see cref="ConfigReconciler"/> (singleton) so they can be injected into
/// <see cref="Proxy.ProxyWorker"/>.
/// </summary> /// </summary>
public static IHostApplicationBuilder AddMbproxyOptions(this IHostApplicationBuilder builder) public static IHostApplicationBuilder AddMbproxyOptions(this IHostApplicationBuilder builder)
{ {
@@ -28,17 +26,17 @@ internal static class HostingExtensions
Microsoft.Extensions.Options.IValidateOptions<MbproxyOptions>, Microsoft.Extensions.Options.IValidateOptions<MbproxyOptions>,
MbproxyOptionsValidator>(); MbproxyOptionsValidator>();
// Phase 06: service-wide counters (read by Phase 07 status page). // Service-wide counters (read by the status page).
builder.Services.AddSingleton<ServiceCounters>(); builder.Services.AddSingleton<ServiceCounters>();
// Phase 06: hot-reload reconciler (singleton; subscribes to IOptionsMonitor.OnChange). // Hot-reload reconciler (singleton; subscribes to IOptionsMonitor.OnChange).
builder.Services.AddSingleton<ConfigReconciler>(); builder.Services.AddSingleton<ConfigReconciler>();
return builder; return builder;
} }
/// <summary> /// <summary>
/// Registers Phase 07 admin endpoint services: /// Registers the admin endpoint services:
/// <list type="bullet"> /// <list type="bullet">
/// <item><see cref="AssemblyVersionAccessor"/> (singleton — reads version attribute once).</item> /// <item><see cref="AssemblyVersionAccessor"/> (singleton — reads version attribute once).</item>
/// <item><see cref="StatusSnapshotBuilder"/> (singleton — pure orchestration).</item> /// <item><see cref="StatusSnapshotBuilder"/> (singleton — pure orchestration).</item>
@@ -47,8 +45,8 @@ internal static class HostingExtensions
/// Must be called after <see cref="AddMbproxyOptions"/> and after /// Must be called after <see cref="AddMbproxyOptions"/> and after
/// <c>AddHostedService&lt;ProxyWorker&gt;</c> (so ProxyWorker is available via DI). /// <c>AddHostedService&lt;ProxyWorker&gt;</c> (so ProxyWorker is available via DI).
/// ///
/// <para><b>Phase 12 (W1.5)</b> — <see cref="AdminEndpointHost"/> is no longer registered /// <para><see cref="AdminEndpointHost"/> is intentionally NOT registered via
/// via <c>AddHostedService</c>. <see cref="Proxy.ProxyWorker"/> drives its lifecycle /// <c>AddHostedService</c>. <see cref="Proxy.ProxyWorker"/> drives its lifecycle
/// directly so admin start/stop ordering matches the design contract (admin starts /// directly so admin start/stop ordering matches the design contract (admin starts
/// after listeners are up; admin stops AFTER the in-flight drain).</para> /// after listeners are up; admin stops AFTER the in-flight drain).</para>
/// </summary> /// </summary>
@@ -61,10 +59,10 @@ internal static class HostingExtensions
} }
/// <summary> /// <summary>
/// Configures Serilog from the <c>"Serilog"</c> configuration section, /// Configures Serilog from the <c>"Serilog"</c> configuration section, with console
/// with console and rolling-file sinks as defaults. /// and rolling-file sinks as defaults.
/// ///
/// <para>Phase 08: when <paramref name="addEventLogBridge"/> is <c>true</c>, the /// <para>When <paramref name="addEventLogBridge"/> is <c>true</c>, the
/// <see cref="Diagnostics.EventLogBridge"/> is added as a sub-sink for events at /// <see cref="Diagnostics.EventLogBridge"/> is added as a sub-sink for events at
/// <see cref="Serilog.Events.LogEventLevel.Error"/> and above. This flag should only be /// <see cref="Serilog.Events.LogEventLevel.Error"/> and above. This flag should only be
/// set when the service is running as a Windows Service — the bridge silently ignores /// set when the service is running as a Windows Service — the bridge silently ignores
+12 -14
View File
@@ -8,16 +8,15 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>Mbproxy</RootNamespace> <RootNamespace>Mbproxy</RootNamespace>
<AssemblyName>Mbproxy</AssemblyName> <AssemblyName>Mbproxy</AssemblyName>
<!-- Phase 08: Assembly version. CI can override via /p:InformationalVersion=... --> <!-- Assembly version. CI can override via /p:InformationalVersion=... -->
<InformationalVersion>1.0.0</InformationalVersion> <InformationalVersion>1.0.0</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<!-- Phase 08: single-file self-contained publish (Release only; Debug stays normal for fast iteration). <!-- Single-file self-contained publish (Release only; Debug stays normal for fast iteration).
NOTE: the resulting Mbproxy.exe is ~100 MB because the self-contained publish bundles the full The resulting Mbproxy.exe is ~100 MB because the self-contained publish bundles the full
.NET 10 + ASP.NET Core runtime. This exceeds the original 50 MB target in the phase spec; .NET 10 + ASP.NET Core runtime — fixed cost of self-contained deployment on .NET 10 with
the runtime size is a fixed cost of self-contained deployment on .NET 10 with ASP.NET Core. ASP.NET Core. Operators who need a smaller footprint can use a framework-dependent publish
Operators who need a smaller footprint can use a framework-dependent publish (dotnet publish -c Release -r win-x64 -p:SelfContained=false -p:PublishSingleFile=true)
(dotnet publish -c Release -r win-x64 - -self-contained false /p:PublishSingleFile=true)
if the target machine has .NET 10 installed. --> if the target machine has .NET 10 installed. -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'"> <PropertyGroup Condition="'$(Configuration)' == 'Release'">
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>
@@ -27,7 +26,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- ASP.NET Core for the Phase 07 Kestrel-hosted admin endpoint. --> <!-- ASP.NET Core for the Kestrel-hosted admin endpoint. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
@@ -50,12 +49,11 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Phase 12 (W2.21) — link the install template as the published appsettings.json <!-- Link the install template as the published appsettings.json so the binary ships
so the binary ships with a fully-commented, usable example config (one PLC, one with a fully-commented, usable example config (one PLC, one BCD tag, all sections
BCD tag, all sections present) instead of an empty stub. The .NET configuration present) instead of an empty stub. The .NET configuration loader supports JSONC
loader supports JSONC (comments) under the default Host.CreateApplicationBuilder (comments) under the default Host.CreateApplicationBuilder path, so the comments
path, so the comments in the template are valid at runtime. in the template are valid at runtime. -->
A fresh `dotnet run` from src/Mbproxy is no longer a no-op service. -->
<None Remove="appsettings.json" /> <None Remove="appsettings.json" />
<Content Include="..\..\install\mbproxy.config.template.json" <Content Include="..\..\install\mbproxy.config.template.json"
Link="appsettings.json"> Link="appsettings.json">
+4 -4
View File
@@ -6,10 +6,10 @@ public sealed class BcdTagOptions
public byte Width { get; init; } // 16 or 32 public byte Width { get; init; } // 16 or 32
/// <summary> /// <summary>
/// Phase 11 — optional opt-in to the response cache. Null (default) means /// Optional opt-in to the response cache. Null (default) means "unset" and falls
/// "unset" and falls back to the per-PLC <see cref="PlcOptions.DefaultCacheTtlMs"/>; /// back to the per-PLC <see cref="PlcOptions.DefaultCacheTtlMs"/>; 0 explicitly
/// 0 explicitly disables caching for this tag even when the PLC default is non-zero. /// disables caching for this tag even when the PLC default is non-zero. Positive
/// Positive values cap the staleness window in milliseconds. /// values cap the staleness window in milliseconds.
/// </summary> /// </summary>
public int? CacheTtlMs { get; init; } public int? CacheTtlMs { get; init; }
} }
+10 -11
View File
@@ -11,16 +11,16 @@ public sealed class MbproxyOptions
public ResilienceOptions Resilience { get; init; } = new(); public ResilienceOptions Resilience { get; init; } = new();
/// <summary> /// <summary>
/// Phase 11 — service-wide response-cache settings. The cache is opt-in /// Service-wide response-cache settings. The cache is opt-in per-tag
/// per-tag (<see cref="BcdTagOptions.CacheTtlMs"/>); this section configures the /// (<see cref="BcdTagOptions.CacheTtlMs"/>); this section configures the safety
/// safety knobs that gate / bound the cache. /// knobs that gate / bound the cache.
/// </summary> /// </summary>
public CacheOptions Cache { get; init; } = new(); public CacheOptions Cache { get; init; } = new();
} }
/// <summary> /// <summary>
/// Phase 11 — service-wide response-cache knobs. The cache is OFF by default for every /// Service-wide response-cache knobs. The cache is OFF by default for every tag;
/// tag; this section governs the limits when an operator opts a tag in. /// this section governs the limits when an operator opts a tag in.
/// </summary> /// </summary>
public sealed class CacheOptions public sealed class CacheOptions
{ {
@@ -47,8 +47,8 @@ public sealed class CacheOptions
} }
/// <summary> /// <summary>
/// Schema-level validation for <see cref="MbproxyOptions"/>. /// Schema-level validation for <see cref="MbproxyOptions"/>. Business-rule validation
/// Business-rule validation (duplicate addresses, port conflicts) is deferred to phase 06. /// (duplicate addresses, port conflicts) is delegated to <see cref="Configuration.ReloadValidator"/>.
/// </summary> /// </summary>
public sealed class MbproxyOptionsValidator : IValidateOptions<MbproxyOptions> public sealed class MbproxyOptionsValidator : IValidateOptions<MbproxyOptions>
{ {
@@ -68,7 +68,7 @@ public sealed class MbproxyOptionsValidator : IValidateOptions<MbproxyOptions>
{ {
var plc = options.Plcs[i]; var plc = options.Plcs[i];
// Phase 11 — per-PLC default TTL bounds. // Per-PLC default TTL bounds.
if (plc.DefaultCacheTtlMs < 0) if (plc.DefaultCacheTtlMs < 0)
errors.Add($"Plcs[{i}] ({plc.Name}): DefaultCacheTtlMs must be >= 0."); errors.Add($"Plcs[{i}] ({plc.Name}): DefaultCacheTtlMs must be >= 0.");
else if (plc.DefaultCacheTtlMs > 60_000 && !allowLongTtl) else if (plc.DefaultCacheTtlMs > 60_000 && !allowLongTtl)
@@ -94,9 +94,8 @@ public sealed class MbproxyOptionsValidator : IValidateOptions<MbproxyOptions>
if (options.Cache.EvictionIntervalMs < 0) if (options.Cache.EvictionIntervalMs < 0)
errors.Add($"Cache.EvictionIntervalMs must be >= 0; got {options.Cache.EvictionIntervalMs}."); errors.Add($"Cache.EvictionIntervalMs must be >= 0; got {options.Cache.EvictionIntervalMs}.");
// Phase 12 (W2.18) — Connection timeouts must be strictly positive. A 0 or negative // Connection timeouts must be strictly positive. A 0 or negative value produces
// value produces a CancelAfter(0) that fires immediately and breaks every backend // a CancelAfter(0) that fires immediately and breaks every backend connect/request.
// connect/request.
if (options.Connection.BackendConnectTimeoutMs <= 0) if (options.Connection.BackendConnectTimeoutMs <= 0)
errors.Add( errors.Add(
$"Connection.BackendConnectTimeoutMs must be > 0; got {options.Connection.BackendConnectTimeoutMs}."); $"Connection.BackendConnectTimeoutMs must be > 0; got {options.Connection.BackendConnectTimeoutMs}.");
+1 -1
View File
@@ -14,7 +14,7 @@ public sealed class PlcOptions
public PlcBcdOverrides? BcdTags { get; init; } public PlcBcdOverrides? BcdTags { get; init; }
/// <summary> /// <summary>
/// Phase 11 — per-PLC default cache TTL applied to any tag whose explicit /// Per-PLC default cache TTL applied to any tag whose explicit
/// <see cref="BcdTagOptions.CacheTtlMs"/> is unset (null). 0 (the default) means /// <see cref="BcdTagOptions.CacheTtlMs"/> is unset (null). 0 (the default) means
/// "no caching by default at this PLC". Per-tag values always win over the per-PLC /// "no caching by default at this PLC". Per-tag values always win over the per-PLC
/// default when set; an explicit zero on a tag still disables caching for that tag. /// default when set; an explicit zero on a tag still disables caching for that tag.
@@ -10,8 +10,8 @@ public sealed class ResilienceOptions
}; };
/// <summary> /// <summary>
/// Phase 10 — in-flight read coalescing options. Defaults to enabled with a 32-party /// In-flight read coalescing options. Defaults to enabled with a 32-party cap so
/// cap so unconfigured deployments get the de-duplication benefit immediately. /// unconfigured deployments get the de-duplication benefit immediately.
/// </summary> /// </summary>
public ReadCoalescingOptions ReadCoalescing { get; init; } = new(); public ReadCoalescingOptions ReadCoalescing { get; init; } = new();
} }
@@ -29,10 +29,10 @@ public sealed class RecoveryProfile
} }
/// <summary> /// <summary>
/// Phase 10 — knobs for the in-flight read-coalescing feature. The feature attaches /// Knobs for the in-flight read-coalescing feature. The feature attaches late-arriving
/// late-arriving FC03/FC04 reads of identical <c>(unitId, fc, start, qty)</c> tuples to an /// FC03/FC04 reads of identical <c>(unitId, fc, start, qty)</c> tuples to an already-
/// already-in-flight peer, fanning out the single backend response to every attached /// in-flight peer, fanning out the single backend response to every attached upstream
/// upstream client. /// client.
/// ///
/// <para>Zero post-response staleness — coalescing operates entirely within the in-flight /// <para>Zero post-response staleness — coalescing operates entirely within the in-flight
/// window (microseconds to ~10 ms typical). Once the response is delivered, the coalescing /// window (microseconds to ~10 ms typical). Once the response is delivered, the coalescing
@@ -41,10 +41,10 @@ public sealed class RecoveryProfile
public sealed class ReadCoalescingOptions public sealed class ReadCoalescingOptions
{ {
/// <summary> /// <summary>
/// Master switch. When <c>false</c>, every FC03/FC04 request takes the Phase-9 path /// Master switch. When <c>false</c>, every FC03/FC04 request allocates a fresh
/// (allocate a fresh proxy TxId and round-trip to the backend). Hot-reloadable via /// proxy TxId and round-trips to the backend without attempting to coalesce.
/// <c>IOptionsMonitor</c>; flipping to <c>false</c> at runtime does not disturb already- /// Hot-reloadable via <c>IOptionsMonitor</c>; flipping to <c>false</c> at runtime
/// coalesced entries — they drain naturally. /// does not disturb already-coalesced entries — they drain naturally.
/// </summary> /// </summary>
public bool Enabled { get; init; } = true; public bool Enabled { get; init; } = true;
+5 -5
View File
@@ -7,14 +7,14 @@ var builder = Host.CreateApplicationBuilder(args);
// Windows Service support; no-op when running under dotnet run / console. // Windows Service support; no-op when running under dotnet run / console.
builder.Services.AddWindowsService(); builder.Services.AddWindowsService();
// Phase 08: wire EventLogBridge only when actually running as a Windows Service. // Wire EventLogBridge only when actually running as a Windows Service.
bool isWindowsService = WindowsServiceHelpers.IsWindowsService(); bool isWindowsService = WindowsServiceHelpers.IsWindowsService();
// Wire up structured config, Serilog, and typed options. // Wire up structured config, Serilog, and typed options.
builder.AddMbproxySerilog(addEventLogBridge: isWindowsService); builder.AddMbproxySerilog(addEventLogBridge: isWindowsService);
builder.AddMbproxyOptions(); builder.AddMbproxyOptions();
// PDU pipeline: BcdPduPipeline is stateless (Phase 9: per-call correlation flows through // PDU pipeline: BcdPduPipeline is stateless (per-call correlation flows through
// PerPlcContext.CurrentRequest set by the multiplexer); registering as singleton is fine // PerPlcContext.CurrentRequest set by the multiplexer); registering as singleton is fine
// and avoids repeated construction. // and avoids repeated construction.
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>(); builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
@@ -25,9 +25,9 @@ builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
builder.Services.AddSingleton<ProxyWorker>(); builder.Services.AddSingleton<ProxyWorker>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
// Phase 07: admin endpoint (Kestrel read-only status page). // Admin endpoint (Kestrel read-only status page). Not registered as IHostedService —
// Phase 12 (W1.5): no longer registered as IHostedService; ProxyWorker drives its // ProxyWorker drives its lifecycle so admin starts after listeners and stops AFTER the
// lifecycle so admin starts after listeners and stops AFTER the in-flight drain. // in-flight drain.
builder.AddMbproxyAdmin(); builder.AddMbproxyAdmin();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
+21 -23
View File
@@ -4,7 +4,7 @@ namespace Mbproxy.Proxy;
/// <summary> /// <summary>
/// BCD-rewriting PDU pipeline. Registered as the singleton <see cref="IPduPipeline"/> /// BCD-rewriting PDU pipeline. Registered as the singleton <see cref="IPduPipeline"/>
/// in production (replaces <see cref="NoopPduPipeline"/> from Phase 03). /// in production.
/// ///
/// FC scope (per design.md): /// FC scope (per design.md):
/// FC03 / FC04 response — decode covered BCD slots from raw nibbles → binary integer. /// FC03 / FC04 response — decode covered BCD slots from raw nibbles → binary integer.
@@ -15,13 +15,13 @@ namespace Mbproxy.Proxy;
/// MBAP transparency contract: the MBAP length field is NEVER modified. Re-encoded slots /// MBAP transparency contract: the MBAP length field is NEVER modified. Re-encoded slots
/// are the same byte width as the originals (ushort → ushort), so the PDU length is stable. /// are the same byte width as the originals (ushort → ushort), so the PDU length is stable.
/// ///
/// <para><b>Phase 9 — request correlation:</b> FC03/FC04 responses do not carry the /// <para><b>Request correlation:</b> FC03/FC04 responses do not carry the original
/// original start address. The multiplexer builds an <see cref="Multiplexing.InFlightRequest"/> /// start address. The multiplexer builds an <see cref="Multiplexing.InFlightRequest"/>
/// on the request path, stores it in its <see cref="Multiplexing.CorrelationMap"/>, and /// on the request path, stores it in its <see cref="Multiplexing.CorrelationMap"/>, and
/// attaches it to the per-call <see cref="PerPlcContext.CurrentRequest"/> on the response /// attaches it to the per-call <see cref="PerPlcContext.CurrentRequest"/> on the
/// path. The rewriter consumes <c>CurrentRequest</c> instead of a per-pair last-request /// response path. The rewriter consumes <c>CurrentRequest</c>, so concurrent responses
/// slot, so concurrent responses from different upstream clients each decode against /// from different upstream clients each decode against their own request range without
/// their own request range without cross-talk.</para> /// cross-talk.</para>
/// ///
/// <para>This class is stateless. All per-call state arrives via <see cref="PduContext"/> /// <para>This class is stateless. All per-call state arrives via <see cref="PduContext"/>
/// (specifically <see cref="PerPlcContext.CurrentRequest"/> on response). It is safe to /// (specifically <see cref="PerPlcContext.CurrentRequest"/> on response). It is safe to
@@ -157,12 +157,12 @@ internal sealed class BcdPduPipeline : IPduPipeline
ushort startAddress = (ushort)((pdu[1] << 8) | pdu[2]); ushort startAddress = (ushort)((pdu[1] << 8) | pdu[2]);
ushort qty = (ushort)((pdu[3] << 8) | pdu[4]); ushort qty = (ushort)((pdu[3] << 8) | pdu[4]);
// Phase 12 (W2.14) — validate the request is fully sized for `qty` registers // Validate the request is fully sized for `qty` registers (each 2 bytes after
// (each 2 bytes after the byteCount byte). A client claiming qty=10 with only // the byteCount byte). A client claiming qty=10 with only 4 bytes of register
// 4 bytes of register data would otherwise have its BCD slots silently skipped // data would otherwise have its BCD slots silently skipped by the per-slot
// by the per-slot bounds check below — half the request rewritten, half not. // bounds check below — half the request rewritten, half not. Returning here
// Returning here passes the malformed PDU through unchanged so the PLC's own // passes the malformed PDU through unchanged so the PLC's own validator
// validator surfaces the protocol error. // surfaces the protocol error.
if (pdu.Length < 6 + qty * 2) if (pdu.Length < 6 + qty * 2)
return; return;
@@ -210,14 +210,14 @@ internal sealed class BcdPduPipeline : IPduPipeline
ushort clientLow = (ushort)((pdu[lowByteOff] << 8) | pdu[lowByteOff + 1]); ushort clientLow = (ushort)((pdu[lowByteOff] << 8) | pdu[lowByteOff + 1]);
ushort clientHigh = (ushort)((pdu[highByteOff] << 8) | pdu[highByteOff + 1]); ushort clientHigh = (ushort)((pdu[highByteOff] << 8) | pdu[highByteOff + 1]);
// Phase 12 (W2.13) — validate that BOTH input words are within the // Validate that BOTH input words are within the base-10000-digit range
// base-10000-digit range BEFORE reconstructing. Without this guard, a // BEFORE reconstructing. Without this guard, a client writing
// client writing (high=9999, low=9999) silently mutates to (high=9998, // (high=9999, low=9999) silently mutates to (high=9998, low=9999)
// low=9999) because `9999 * 10_000 + 9999 = 99_989_999` is still <= the // because `9999 * 10_000 + 9999 = 99_989_999` is still <= the 32-bit
// 32-bit BCD ceiling, so Encode32 accepts it and rewrites — losing 1 from // BCD ceiling, so Encode32 accepts it and rewrites — losing 1 from the
// the high word. The unconventional wire format ("two base-10000 CDAB // high word. The unconventional wire format ("two base-10000 CDAB
// digits", per design.md:123) means each word independently must be // digits", per design.md) means each word independently must be 0..9999
// 0..9999 to round-trip cleanly. // to round-trip cleanly.
if (clientLow > 9999 || clientHigh > 9999) if (clientLow > 9999 || clientHigh > 9999)
{ {
RewriterLogEvents.InvalidBcd(ctx.Logger, ctx.PlcName, tag.Address, RewriterLogEvents.InvalidBcd(ctx.Logger, ctx.PlcName, tag.Address,
@@ -473,6 +473,4 @@ internal sealed class BcdPduPipeline : IPduPipeline
// already counted this slot on the way out. Incrementing again would double-count. // already counted this slot on the way out. Incrementing again would double-count.
} }
// Phase 12 (W3 cleanup) — HasBadNibble was previously duplicated here; the canonical
// implementation now lives in BcdCodec.HasBadNibble (internal).
} }
+4 -4
View File
@@ -4,10 +4,10 @@ namespace Mbproxy.Proxy.Cache;
/// <summary> /// <summary>
/// Hash key for the per-PLC <see cref="ResponseCache"/>. Structurally identical to /// Hash key for the per-PLC <see cref="ResponseCache"/>. Structurally identical to
/// Phase 10's <see cref="CoalescingKey"/> — both keys discriminate the same dimensions /// the read-coalescing <see cref="CoalescingKey"/> — both keys discriminate the same
/// (UnitId, FunctionCode, StartAddress, Quantity), but the two type aliases live in /// dimensions (UnitId, FunctionCode, StartAddress, Quantity), but the two type aliases
/// different namespaces so the two phases can evolve independently without one shaping /// live in different namespaces so the cache and the coalescer can evolve independently
/// the other's API surface. /// without one shaping the other's API surface.
/// ///
/// <para><b>Equality semantics:</b> record-struct value equality. FC03 and FC04 produce /// <para><b>Equality semantics:</b> record-struct value equality. FC03 and FC04 produce
/// different keys for the same address (different Modbus tables); different /// different keys for the same address (different Modbus tables); different
@@ -1,8 +1,8 @@
namespace Mbproxy.Proxy.Cache; namespace Mbproxy.Proxy.Cache;
/// <summary> /// <summary>
/// Source-generated <see cref="LoggerMessage"/> definitions for the Phase-11 response /// Source-generated <see cref="LoggerMessage"/> definitions for the response cache.
/// cache. Event names are stable — do not rename without updating <c>docs/design.md</c>'s /// Event names are stable — do not rename without updating <c>docs/design.md</c>'s
/// Logging event-name table. /// Logging event-name table.
/// ///
/// <para>Levels are conservative — a busy PLC under steady cache pressure would emit one /// <para>Levels are conservative — a busy PLC under steady cache pressure would emit one
@@ -1,7 +1,7 @@
namespace Mbproxy.Proxy.Cache; namespace Mbproxy.Proxy.Cache;
/// <summary> /// <summary>
/// Per-PLC opt-in response cache for FC03 / FC04 read responses. Phase 11. /// Per-PLC opt-in response cache for FC03 / FC04 read responses.
/// ///
/// <para><b>Lifecycle.</b> One instance per PLC, owned by the per-PLC context. The cache /// <para><b>Lifecycle.</b> One instance per PLC, owned by the per-PLC context. The cache
/// is consulted on every FC03/FC04 request before coalescing; populated by the backend /// is consulted on every FC03/FC04 request before coalescing; populated by the backend
+7 -9
View File
@@ -13,17 +13,15 @@ public enum MbapDirection
} }
/// <summary> /// <summary>
/// Per-pair context carried through each PDU pipeline call. /// Per-pair context carried through each PDU pipeline call. Carries only
/// Phase 03: carries only <see cref="PlcName"/>. /// <see cref="PlcName"/> at the base level; <see cref="PerPlcContext"/> extends it with
/// Phase 04 extends this via <see cref="PerPlcContext"/>, which carries the BcdTagMap, /// the BcdTagMap, counters, logger, and per-call <c>CurrentRequest</c> slot for
/// counters, and logger. Phase 09 added the per-call <c>CurrentRequest</c> slot to /// multiplexer-aware response correlation.
/// <see cref="PerPlcContext"/> for multiplexer-aware response correlation.
/// </summary> /// </summary>
public class PduContext public class PduContext
{ {
/// <summary>The configured PLC name (from <c>MbproxyOptions.Plcs[i].Name</c>).</summary> /// <summary>The configured PLC name (from <c>MbproxyOptions.Plcs[i].Name</c>).</summary>
public string PlcName { get; init; } = ""; public string PlcName { get; init; } = "";
// Phase 04 adds: BcdTagMap, counters, logger
} }
/// <summary> /// <summary>
@@ -31,8 +29,8 @@ public class PduContext
/// Called once per frame in each direction (request and response). /// Called once per frame in each direction (request and response).
/// ///
/// Implementations must be safe to call concurrently from multiple connection pairs. /// Implementations must be safe to call concurrently from multiple connection pairs.
/// In Phase 03 the only implementation is <see cref="NoopPduPipeline"/> (pass-through). /// Production wires <see cref="BcdPduPipeline"/>; <see cref="NoopPduPipeline"/> is a
/// Phase 04 replaces it with a BCD rewriter registered via DI. /// pass-through fallback used by tests.
/// </summary> /// </summary>
public interface IPduPipeline public interface IPduPipeline
{ {
@@ -42,6 +40,6 @@ public interface IPduPipeline
/// <param name="direction">Whether this is a request or a response frame.</param> /// <param name="direction">Whether this is a request or a response frame.</param>
/// <param name="mbapHeader">The 7-byte MBAP header (read-only; includes TxId, UnitId, FC is in pdu[0]).</param> /// <param name="mbapHeader">The 7-byte MBAP header (read-only; includes TxId, UnitId, FC is in pdu[0]).</param>
/// <param name="pdu">The PDU bytes starting at the function code. May be mutated in place.</param> /// <param name="pdu">The PDU bytes starting at the function code. May be mutated in place.</param>
/// <param name="context">Per-pair context (PLC name; extended in phase 04).</param> /// <param name="context">Per-pair context (PLC name; extended via <see cref="PerPlcContext"/>).</param>
void Process(MbapDirection direction, ReadOnlySpan<byte> mbapHeader, Span<byte> pdu, PduContext context); void Process(MbapDirection direction, ReadOnlySpan<byte> mbapHeader, Span<byte> pdu, PduContext context);
} }
@@ -1,14 +1,14 @@
namespace Mbproxy.Proxy.Multiplexing; namespace Mbproxy.Proxy.Multiplexing;
/// <summary> /// <summary>
/// Source-generated <see cref="LoggerMessage"/> definitions for the Phase-10 read-coalescing /// Source-generated <see cref="LoggerMessage"/> definitions for the read-coalescing
/// feature. Event names are stable — do not rename without updating docs/design.md's /// feature. Event names are stable — do not rename without updating docs/design.md's
/// "Logging" event-name table. /// "Logging" event-name table.
/// ///
/// <para>Levels are intentionally conservative — coalescing fires on every overlapping /// <para>Levels are intentionally conservative — coalescing fires on every overlapping
/// read in a busy fleet (HMIs/historians polling the same screen tags), so the steady-state /// read in a busy fleet (HMIs/historians polling the same screen tags), so the
/// log volume would be deafening at Information. The counters surface the same data at /// steady-state log volume would be deafening at Information. The counters surface the
/// far lower cost.</para> /// same data at far lower cost.</para>
/// </summary> /// </summary>
internal static partial class CoalescingLogEvents internal static partial class CoalescingLogEvents
{ {
@@ -8,9 +8,9 @@ namespace Mbproxy.Proxy.Multiplexing;
/// when the matching response arrives. /// when the matching response arrives.
/// ///
/// <para>Backed by <see cref="ConcurrentDictionary{TKey, TValue}"/>. The single-writer / /// <para>Backed by <see cref="ConcurrentDictionary{TKey, TValue}"/>. The single-writer /
/// single-remover pattern in Phase 9 does not strictly require it — but cascade-on- /// single-remover pattern does not strictly require it — but cascade-on-disconnect walks
/// disconnect walks the map from a separate task and Phase 10 adds upstream-side /// the map from a separate task and the coalescing path adds upstream-side cancellation
/// cancellation paths, so the safer primitive is worth the negligible cost.</para> /// paths, so the safer primitive is worth the negligible cost.</para>
/// </summary> /// </summary>
internal sealed class CorrelationMap internal sealed class CorrelationMap
{ {
@@ -1,16 +1,16 @@
namespace Mbproxy.Proxy.Multiplexing; namespace Mbproxy.Proxy.Multiplexing;
/// <summary> /// <summary>
/// Per-PLC "in-flight by key" map that powers <b>Phase 10 read coalescing</b>. Holds the /// Per-PLC "in-flight by key" map that powers read coalescing. Holds the currently-
/// currently-in-flight FC03/FC04 requests keyed by their <see cref="CoalescingKey"/> so a /// in-flight FC03/FC04 requests keyed by their <see cref="CoalescingKey"/> so a
/// late-arriving request with an identical key can attach to the existing in-flight entry /// late-arriving request with an identical key can attach to the existing in-flight entry
/// instead of opening a second backend round-trip. /// instead of opening a second backend round-trip.
/// ///
/// <para><b>Concurrency model.</b> A single <see cref="object"/> lock serialises every /// <para><b>Concurrency model.</b> A single <see cref="object"/> lock serialises every
/// state-touching method. The simpler-lock-over-CAS choice is deliberate (per the phase /// state-touching method. The simpler-lock-over-CAS choice is deliberate the map is
/// doc) — the map is per-PLC and the wire rate per PLC is bounded by the ECOM's internal /// per-PLC and the wire rate per PLC is bounded by the ECOM's internal scan cadence
/// scan cadence (~210 ms per request). The lock-free <c>AddOrUpdate</c> alternative is not /// (~210 ms per request). The lock-free <c>AddOrUpdate</c> alternative is not worth the
/// worth the read-and-prove-it-correct burden.</para> /// read-and-prove-it-correct burden.</para>
/// ///
/// <para><b>Mutable-list seam.</b> Each entry stores a <see cref="List{InterestedParty}"/> /// <para><b>Mutable-list seam.</b> Each entry stores a <see cref="List{InterestedParty}"/>
/// that is also exposed through the parent <see cref="InFlightRequest.InterestedParties"/> /// that is also exposed through the parent <see cref="InFlightRequest.InterestedParties"/>
@@ -55,10 +55,6 @@ internal sealed class InFlightByKeyMap
/// already has <paramref name="maxParties"/> attached parties, the next arrival opens /// already has <paramref name="maxParties"/> attached parties, the next arrival opens
/// a fresh entry (and a fresh backend round-trip). This bounds the response-fanout /// a fresh entry (and a fresh backend round-trip). This bounds the response-fanout
/// cost per entry at O(maxParties).</para> /// cost per entry at O(maxParties).</para>
///
/// <para>Phase 12 (W3 cleanup) — was previously declared as <c>bool TryAttachOrCreate</c>
/// but always returned <c>true</c>. The bool was dead; the result is in the
/// <paramref name="wasNew"/> out parameter.</para>
/// </summary> /// </summary>
public void AttachOrCreate( public void AttachOrCreate(
CoalescingKey key, CoalescingKey key,
@@ -6,10 +6,9 @@ namespace Mbproxy.Proxy.Multiplexing;
/// multiplexer must rewrite the response's MBAP TxId back to <see cref="OriginalTxId"/> /// multiplexer must rewrite the response's MBAP TxId back to <see cref="OriginalTxId"/>
/// before handing the frame to the pipe, so each upstream sees the proxy as transparent. /// before handing the frame to the pipe, so each upstream sees the proxy as transparent.
/// ///
/// <para><b>Phase 9 invariant:</b> exactly one <see cref="InterestedParty"/> per /// <para>Read coalescing fans out a single backend response to multiple upstream parties
/// <see cref="InFlightRequest"/>. <b>Phase 10 (read coalescing)</b> reuses this exact /// via this record. Do not collapse this into a single field on
/// shape to fan-out a single backend response to multiple upstream parties. Do not /// <see cref="InFlightRequest"/>.</para>
/// collapse this into a single field on <see cref="InFlightRequest"/>.</para>
/// </summary> /// </summary>
internal sealed record InterestedParty(UpstreamPipe Pipe, ushort OriginalTxId); internal sealed record InterestedParty(UpstreamPipe Pipe, ushort OriginalTxId);
@@ -22,15 +21,12 @@ internal sealed record InterestedParty(UpstreamPipe Pipe, ushort OriginalTxId);
/// <item><description>Provide the BCD rewriter with the originating request's /// <item><description>Provide the BCD rewriter with the originating request's
/// <c>StartAddress</c> / <c>Qty</c> for FC03/FC04 response decoding — the response /// <c>StartAddress</c> / <c>Qty</c> for FC03/FC04 response decoding — the response
/// PDU itself does not carry the start address.</description></item> /// PDU itself does not carry the start address.</description></item>
/// <item><description>Measure backend round-trip time via <see cref="SentAtUtc"/> /// <item><description>Measure backend round-trip time via <see cref="SentAtUtc"/>.</description></item>
/// (replaces the per-pair stopwatch slot from the 1:1 model).</description></item>
/// </list> /// </list>
/// ///
/// <para><b>Phase 9:</b> <see cref="InterestedParties"/> always has exactly one element. /// <para>The <see cref="InterestedParties"/> list shape is the load-bearing seam that
/// The list shape is the load-bearing seam that <b>Phase 10 — read coalescing</b> hooks /// read coalescing uses to fan out a single PLC response to multiple upstream clients.
/// into to fan out a single PLC response to multiple upstream clients without further /// Reviewer note: do <i>not</i> simplify back to a single <c>UpstreamPipe</c> field.</para>
/// refactor of the multiplexer's data model. Reviewer note: do <i>not</i> simplify back
/// to a single <c>UpstreamPipe</c> field.</para>
/// </summary> /// </summary>
internal sealed record InFlightRequest( internal sealed record InFlightRequest(
byte UnitId, byte UnitId,
@@ -8,9 +8,7 @@ namespace Mbproxy.Proxy.Multiplexing;
internal static partial class MultiplexerLogEvents internal static partial class MultiplexerLogEvents
{ {
/// <summary> /// <summary>
/// Emitted once per upstream client accept. Replaces the per-pair /// Emitted once per upstream client accept.
/// <c>mbproxy.client.connected</c> event from the 1:1 model (same event name,
/// same property shape — operators' log queries are unchanged).
/// </summary> /// </summary>
[LoggerMessage( [LoggerMessage(
EventId = 110, EventId = 110,
@@ -84,9 +82,7 @@ internal static partial class MultiplexerLogEvents
string remoteEp); string remoteEp);
/// <summary> /// <summary>
/// Emitted when the backend connect Polly pipeline fails. Mirrors the existing /// Emitted when the backend connect Polly pipeline fails.
/// <c>mbproxy.backend.failed</c> event from the 1:1 model so operators' alerts keep
/// working unchanged after Phase 9.
/// </summary> /// </summary>
[LoggerMessage( [LoggerMessage(
EventId = 115, EventId = 115,
@@ -48,14 +48,14 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
private readonly ConnectionOptions _connectionOptions; private readonly ConnectionOptions _connectionOptions;
private readonly IPduPipeline _pipeline; private readonly IPduPipeline _pipeline;
// Phase 12 (W1.1) — `_ctx` is volatile so a hot-reload reseat can swap it on the running // `_ctx` is volatile so a hot-reload reseat can swap it on the running
// multiplexer. Each method that uses the context snapshots it into a local at the start // multiplexer. Each method that uses the context snapshots it into a local at the start
// of the operation so a single PDU sees a consistent (TagMap, Cache) pair even if the // of the operation so a single PDU sees a consistent (TagMap, Cache) pair even if the
// swap fires mid-PDU. ReplaceContext is the single mutator. // swap fires mid-PDU. ReplaceContext is the single mutator.
private volatile PerPlcContext _ctx; private volatile PerPlcContext _ctx;
private readonly ILogger<PlcMultiplexer> _logger; private readonly ILogger<PlcMultiplexer> _logger;
private readonly ResiliencePipeline? _backendConnectPipeline; private readonly ResiliencePipeline? _backendConnectPipeline;
// Phase 10: live read-coalescing config accessor. The accessor is read per-PDU on the // Live read-coalescing config accessor. The accessor is read per-PDU on the
// request path so a hot-reload of `Mbproxy.Resilience.ReadCoalescing.Enabled` // request path so a hot-reload of `Mbproxy.Resilience.ReadCoalescing.Enabled`
// propagates immediately. Production wires this to // propagates immediately. Production wires this to
// `() => optionsMonitor.CurrentValue.Resilience.ReadCoalescing`. Tests default to a // `() => optionsMonitor.CurrentValue.Resilience.ReadCoalescing`. Tests default to a
@@ -74,8 +74,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
SingleWriter = false, SingleWriter = false,
}); });
// Attached pipes — Phase 9 needs the list for the status page; Phase 10 will need it for // Attached pipes — used by the status page and by coalescing fan-out.
// coalescing (fan-out). ConcurrentDictionary keyed on UpstreamPipe.Id for O(1) detach. // ConcurrentDictionary keyed on UpstreamPipe.Id for O(1) detach.
private readonly ConcurrentDictionary<Guid, UpstreamPipe> _pipes = new(); private readonly ConcurrentDictionary<Guid, UpstreamPipe> _pipes = new();
// Lifecycle plumbing. Backend tasks share a CTS; cascading disconnect cancels it, // Lifecycle plumbing. Backend tasks share a CTS; cascading disconnect cancels it,
@@ -88,10 +88,10 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
private Task? _backendReaderTask; private Task? _backendReaderTask;
private readonly CancellationTokenSource _disposeCts = new(); private readonly CancellationTokenSource _disposeCts = new();
// Phase 12 (W2.2) — volatile so the disposing thread's write is observed by every // Volatile so the disposing thread's write is observed by every hot-path reader
// hot-path reader (OnUpstreamFrameAsync, ReplaceContext, Attach, etc.) without a // (OnUpstreamFrameAsync, ReplaceContext, Attach, etc.) without a separate fence.
// separate fence. On x86/x64 plain reads happen to give acquire-release semantics, so // On x86/x64 plain reads happen to give acquire-release semantics, so this is
// this is defense for ARM hosts and future portability. // defense for ARM hosts and future portability.
private volatile bool _disposed; private volatile bool _disposed;
private Task? _watchdogTask; private Task? _watchdogTask;
@@ -112,8 +112,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
_backendConnectPipeline = backendConnectPipeline; _backendConnectPipeline = backendConnectPipeline;
_coalescingOptions = coalescingOptions ?? (static () => new ReadCoalescingOptions()); _coalescingOptions = coalescingOptions ?? (static () => new ReadCoalescingOptions());
// Phase 11 — register the per-PLC cache as the live stats source for the snapshot // Register the per-PLC cache as the live stats source for the snapshot path.
// path. Cache may be null when the per-PLC context has not been wired with one // Cache may be null when the per-PLC context has not been wired with one
// (every tag uncached, or unit tests). // (every tag uncached, or unit tests).
if (_ctx.Cache is not null) if (_ctx.Cache is not null)
_ctx.Counters.SetCacheStatsProvider(new CacheStatsAdapter(_ctx.Cache)); _ctx.Counters.SetCacheStatsProvider(new CacheStatsAdapter(_ctx.Cache));
@@ -155,8 +155,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
} }
/// <summary> /// <summary>
/// Phase 12 (W1.1) — atomically swaps the per-PLC context on a running multiplexer. /// Atomically swaps the per-PLC context on a running multiplexer. Called by
/// Called by <see cref="Supervision.PlcListenerSupervisor.ReplaceContextAsync"/> when a /// <see cref="Supervision.PlcListenerSupervisor.ReplaceContextAsync"/> when a
/// hot-reload tag-list change is applied to a PLC whose listener is already bound. /// hot-reload tag-list change is applied to a PLC whose listener is already bound.
/// ///
/// <para>The new context's tag map and (optional) response cache become visible on the /// <para>The new context's tag map and (optional) response cache become visible on the
@@ -174,10 +174,10 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
{ {
if (_disposed) return; if (_disposed) return;
// Phase 12 (W4 / NM2) — provider FIRST, then _ctx. The status page's snapshot // Provider FIRST, then _ctx. The status page's snapshot path reads
// path reads `_cacheStatsProvider` independently of `_ctx`. If we swapped `_ctx` // `_cacheStatsProvider` independently of `_ctx`. If we swapped `_ctx` first, a
// first, a snapshot taken in the gap between the two writes would still hold the // snapshot taken in the gap between the two writes would still hold the OLD
// OLD adapter wrapping the OLD cache — which the supervisor is about to dispose // adapter wrapping the OLD cache — which the supervisor is about to dispose
// (`PlcListenerSupervisor.ReplaceContextAsync` runs `oldCache.Dispose()` after we // (`PlcListenerSupervisor.ReplaceContextAsync` runs `oldCache.Dispose()` after we
// return). Setting the provider first means snapshots in the swap window read // return). Setting the provider first means snapshots in the swap window read
// either (old provider, old ctx) or (new provider, new ctx) — both coherent — // either (old provider, old ctx) or (new provider, new ctx) — both coherent —
@@ -254,9 +254,9 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
} }
_pipes.Clear(); _pipes.Clear();
// Phase 12 (W2.5, W2.6) — guard the CTS dispose against a watchdog tick that // Guard the CTS dispose against a watchdog tick that raced past the WaitAsync
// raced past the WaitAsync above (e.g. a slow Task.Delay completion observing // above (e.g. a slow Task.Delay completion observing cancellation late). Also
// cancellation late). Also dispose the connect-gate semaphore. // dispose the connect-gate semaphore.
try { _disposeCts.Dispose(); } catch (ObjectDisposedException) { /* already disposed */ } try { _disposeCts.Dispose(); } catch (ObjectDisposedException) { /* already disposed */ }
try { _connectGate.Dispose(); } catch (ObjectDisposedException) { /* already disposed */ } try { _connectGate.Dispose(); } catch (ObjectDisposedException) { /* already disposed */ }
} }
@@ -336,30 +336,28 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
private async Task TearDownBackendAsync(string reason, bool cascadeUpstreams) private async Task TearDownBackendAsync(string reason, bool cascadeUpstreams)
{ {
// Phase 12 (W1.4) — serialise tear-down vs connect-up via the connect gate. Without // Serialise tear-down vs connect-up via the connect gate. Without this, a fresh
// this, a fresh EnsureBackendConnectedAsync racing with the channel drain below // EnsureBackendConnectedAsync racing with the channel drain below could see
// could see stranded frames sent on its new socket with old (already-released) TxIds, // stranded frames sent on its new socket with old (already-released) TxIds,
// producing orphaned responses that hang upstream peers via the watchdog. // producing orphaned responses that hang upstream peers via the watchdog.
// //
// Phase 12 (W4 / NM1) — bound the wait. Without a timeout, a long Polly-wrapped // Bounded wait: a long Polly-wrapped EnsureBackendConnectedAsync against an
// EnsureBackendConnectedAsync against an unreachable host can hold the gate for // unreachable host can hold the gate for the full BackendConnectTimeoutMs *
// the full BackendConnectTimeoutMs * MaxAttempts window, blocking DisposeAsync (and // MaxAttempts window, blocking DisposeAsync (and therefore ProxyWorker.StopAsync)
// therefore ProxyWorker.StopAsync) for that duration. A 2 s teardown deadline // for that duration. A 2 s teardown deadline bounds disposal latency; if the gate
// bounds disposal latency; if the gate is unavailable we proceed best-effort // is unavailable we proceed best-effort without it (the worst-case consequence is
// without it (the worst-case consequence is one orphaned in-flight cycle on the // one orphaned in-flight cycle on the dying backend, which the upstream watchdog
// dying backend, which the upstream watchdog will surface as exception 0x0B). // will surface as exception 0x0B).
// //
// Phase 12 (W5 / m1) — KNOWN RACE on the gate-not-held path: a concurrent // KNOWN RACE on the gate-not-held path: a concurrent EnsureBackendConnectedAsync
// EnsureBackendConnectedAsync that DOES hold the gate may TryAllocate a TxId // that DOES hold the gate may TryAllocate a TxId that collides (after wraparound
// that collides (after wraparound in the allocator's forward scan) with a TxId // in the allocator's forward scan) with a TxId we're about to release from the
// we're about to release from the channel-drain step below. The double-release // channel-drain step below. The double-release would mark the new request's slot
// would mark the new request's slot as free even though it's legitimately // as free even though it's legitimately in-flight, allowing the next allocation
// in-flight, allowing the next allocation to reuse the same slot and // to reuse the same slot and CorrelationMap.TryAdd to fail (silent request drop).
// CorrelationMap.TryAdd to fail (silent request drop). Probability is very low // Probability is very low (requires gate timeout + new accept landing during
// (requires gate timeout + new accept landing during cascade + TxId collision in // cascade + TxId collision in a 65,536-slot space); the only consequence is one
// a 65,536-slot space); the only consequence is one dropped request that the // dropped request that the client retries. Accepted as best-effort behaviour.
// client retries. Documented as accepted best-effort behaviour in
// codereviews/2026-05-14/ReReviewAfterRemediation.md (m1).
bool gateHeld = false; bool gateHeld = false;
try try
{ {
@@ -412,8 +410,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
_allocator.Release(kvp.Key); _allocator.Release(kvp.Key);
} }
// Phase 10 — also drain the in-flight-by-key map so a brand-new identical request // Also drain the in-flight-by-key map so a brand-new identical request through
// through the freshly-reconnected backend is treated as a miss (no stale entries // the freshly-reconnected backend is treated as a miss (no stale entries
// outlive the backend they were destined for). // outlive the backend they were destined for).
_inFlightByKey.DrainAll(); _inFlightByKey.DrainAll();
@@ -437,11 +435,11 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
_ctx.Counters.AddDisconnectCascades(upstreamCount); _ctx.Counters.AddDisconnectCascades(upstreamCount);
} }
// Phase 12 (W1.4) — drain any stranded frames left in the outbound channel by // Drain any stranded frames left in the outbound channel by the writer task
// the writer task that just faulted/cancelled. Released their proxy TxIds back // that just faulted/cancelled. Release their proxy TxIds back to the
// to the allocator. By the time we reach this line the writer has stopped // allocator. By the time we reach this line the writer has stopped reading
// reading from the channel (cancelled CTS) and the upstream pipes have been // from the channel (cancelled CTS) and the upstream pipes have been cascaded
// cascaded (no more enqueues), so the channel state is stable. // (no more enqueues), so the channel state is stable.
int strandedDropped = 0; int strandedDropped = 0;
while (_outboundChannel.Reader.TryRead(out byte[]? stranded)) while (_outboundChannel.Reader.TryRead(out byte[]? stranded))
{ {
@@ -464,7 +462,7 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
} }
finally finally
{ {
// Only release if we acquired (W4 / NM1) — best-effort path may have skipped. // Only release if we acquired — best-effort path may have skipped.
if (gateHeld) if (gateHeld)
{ {
try { _connectGate.Release(); } try { _connectGate.Release(); }
@@ -499,10 +497,10 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
} }
catch (Exception ex) catch (Exception ex)
{ {
// Backend failure — cascade. Phase 12 (W4 / NM5) — skip if disposal is // Backend failure — cascade. Skip if disposal is already in progress;
// already in progress; DisposeAsync runs an explicit TearDown and the // DisposeAsync runs an explicit TearDown and the fire-and-forget here would
// fire-and-forget here would race against it, hitting a disposed // race against it, hitting a disposed _connectGate and producing an
// _connectGate and producing an unobserved-task exception. // unobserved-task exception.
if (!_disposeCts.IsCancellationRequested) if (!_disposeCts.IsCancellationRequested)
_ = TearDownBackendAsync($"writer fault: {ex.Message}", cascadeUpstreams: true); _ = TearDownBackendAsync($"writer fault: {ex.Message}", cascadeUpstreams: true);
} }
@@ -554,10 +552,10 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
// Free the allocator slot immediately so it can be reused. // Free the allocator slot immediately so it can be reused.
_allocator.Release(proxyTxId); _allocator.Release(proxyTxId);
// Phase 10 — for FC03/FC04 reads, also clear the coalescing-by-key entry so // For FC03/FC04 reads, also clear the coalescing-by-key entry so a
// a brand-new identical request issued AFTER this response is treated as a // brand-new identical request issued AFTER this response is treated as a
// miss (opens a fresh round-trip). The TryRemove is best-effort: a watchdog // miss (opens a fresh round-trip). The TryRemove is best-effort: a
// timeout or cascade may have already removed it. // watchdog timeout or cascade may have already removed it.
if (inFlight.Fc is 0x03 or 0x04) if (inFlight.Fc is 0x03 or 0x04)
{ {
var coalKey = new CoalescingKey(inFlight.UnitId, inFlight.Fc, var coalKey = new CoalescingKey(inFlight.UnitId, inFlight.Fc,
@@ -580,16 +578,16 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
frame.AsSpan(MbapFrame.HeaderSize, pduBodyLen), frame.AsSpan(MbapFrame.HeaderSize, pduBodyLen),
responseCtx); responseCtx);
// Phase 11 — post-rewriter cache update: // Post-rewriter cache update:
// * FC03/FC04 successful responses are stored when the request was // * FC03/FC04 successful responses are stored when the request was
// cache-eligible (resolvedTtlMs > 0). // cache-eligible (resolvedTtlMs > 0).
// * FC06/FC16 successful responses invalidate every cached entry whose // * FC06/FC16 successful responses invalidate every cached entry whose
// address range overlaps the write. // address range overlaps the write.
// //
// Phase 12 (W2.7) — exception bit comes from the post-rewriter buffer // Exception bit comes from the post-rewriter buffer (the rewriter never
// (the rewriter never touches the FC byte today, but reading from // touches the FC byte today, but reading from inFlight.Fc would lose the
// inFlight.Fc would lose the exception bit). The base FC for routing // exception bit). The base FC for routing decisions uses inFlight.Fc —
// decisions uses inFlight.Fc — the request side knows what was sent. // the request side knows what was sent.
if (_ctx.Cache is { } postCache) if (_ctx.Cache is { } postCache)
{ {
byte fcInResponse = frame[MbapFrame.HeaderSize]; byte fcInResponse = frame[MbapFrame.HeaderSize];
@@ -623,16 +621,16 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
} }
else if (inFlight.Fc is 0x06 or 0x10) else if (inFlight.Fc is 0x06 or 0x10)
{ {
// Phase 12 (W2.9) — the design contract "invalidations during a // The design contract "invalidations during a recovering
// recovering listener state are skipped" (design.md:203) is // listener state are skipped" is upheld IMPLICITLY here:
// upheld IMPLICITLY here: invalidation only fires inside the // invalidation only fires inside the backend reader task when
// backend reader task when a non-exception FC06/FC16 response // a non-exception FC06/FC16 response arrives. A `Recovering`
// arrives. A `Recovering` listener has no backend reader (the // listener has no backend reader (the multiplexer is torn
// multiplexer is torn down between recovery attempts), so no // down between recovery attempts), so no response can land
// response can land here, so no invalidation. The gating is // here, so no invalidation. The gating is structural, not
// structural, not conditional. If a future change ever produces // conditional. If a future change ever produces a write
// a write response off the live backend, an explicit recovering- // response off the live backend, an explicit recovering-state
// state check would need to be added. // check would need to be added.
int invalidated = postCache.Invalidate( int invalidated = postCache.Invalidate(
inFlight.UnitId, inFlight.StartAddress, inFlight.Qty); inFlight.UnitId, inFlight.StartAddress, inFlight.Qty);
if (invalidated > 0) if (invalidated > 0)
@@ -647,23 +645,23 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
} }
// Fan out to each interested party with their original TxId restored. // Fan out to each interested party with their original TxId restored.
// Phase 9: always exactly one party. Phase 10: N parties (read coalescing). // Without coalescing there is exactly one party; with coalescing there
// Note: the InFlightByKey TryRemove above (for FC03/FC04) guarantees no // are N. The InFlightByKey TryRemove above (for FC03/FC04) guarantees no
// further attaches can occur — the parties list is now a stable snapshot. // further attaches can occur — the parties list is now a stable snapshot.
// //
// Phase 12 (W1.3) — non-blocking fan-out via `TrySendResponse`. The // Non-blocking fan-out via `TrySendResponse`. The single backend reader
// single backend reader task must NEVER `await` a per-upstream channel // task must NEVER `await` a per-upstream channel write: a wedged upstream
// write: a wedged upstream (full bounded response channel) would otherwise // (full bounded response channel) would otherwise stall the reader and
// stall the reader and starve every other client on this PLC. A drop here // starve every other client on this PLC. A drop here is recorded via
// is recorded via `responseDropForFullUpstream`; the wedged upstream loses // `responseDropForFullUpstream`; the wedged upstream loses its own
// its own response and will be reaped by its own socket-close path. // response and will be reaped by its own socket-close path.
foreach (var party in inFlight.InterestedParties) foreach (var party in inFlight.InterestedParties)
{ {
if (!party.Pipe.IsAlive) if (!party.Pipe.IsAlive)
{ {
// Phase 10 — record the dead-upstream skip only for FC03/FC04 (the // Record the dead-upstream skip only for FC03/FC04 (the only
// only function codes that take the coalescing path). For non- // function codes that take the coalescing path). For
// coalescing FCs this branch is silent — the Phase-9 behaviour. // non-coalescing FCs this branch is silent.
if (inFlight.Fc is 0x03 or 0x04 if (inFlight.Fc is 0x03 or 0x04
&& inFlight.InterestedParties.Count > 1) && inFlight.InterestedParties.Count > 1)
{ {
@@ -675,10 +673,10 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
continue; continue;
} }
// The frame buffer is private to this iteration; if there are multiple // The frame buffer is private to this iteration; if there are
// parties (Phase 10), each gets its own copy with its own original TxId // multiple coalesced parties, each gets its own copy with its own
// patched in. Phase 9 always has Count == 1, so the single-buffer path // original TxId patched in. The single-party case reuses the buffer
// is the common case; we copy to keep Phase-10 forward compatibility. // directly as the common-case fast path.
byte[] outFrame = inFlight.InterestedParties.Count == 1 byte[] outFrame = inFlight.InterestedParties.Count == 1
? frame ? frame
: (byte[])frame.Clone(); : (byte[])frame.Clone();
@@ -692,17 +690,16 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
} }
else else
{ {
// Phase 12 (W6) — count outbound bytes per delivered party. // Count outbound bytes per delivered party. With coalescing, one
// With coalescing, one backend response fans out to N parties and // backend response fans out to N parties and produces
// produces N × frame.Length bytes leaving the proxy upstream-side. // N × frame.Length bytes leaving the proxy upstream-side.
_ctx.Counters.AddBytes(up: 0, down: outFrame.Length); _ctx.Counters.AddBytes(up: 0, down: outFrame.Length);
} }
} }
} }
// Reader exited cleanly — backend closed by remote. Cascade. // Reader exited cleanly — backend closed by remote. Cascade. Skip if
// Phase 12 (W4 / NM5) — skip if dispose is already in progress (see writer-side // dispose is already in progress (see writer-side comment above).
// comment above for rationale).
if (!_disposeCts.IsCancellationRequested) if (!_disposeCts.IsCancellationRequested)
_ = TearDownBackendAsync("backend reader EOF", cascadeUpstreams: true); _ = TearDownBackendAsync("backend reader EOF", cascadeUpstreams: true);
} }
@@ -730,16 +727,16 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
out ushort originalTxId, out _, out _, out byte unitId)) out ushort originalTxId, out _, out _, out byte unitId))
return; return;
// Phase 12 (W6) — count inbound bytes from the upstream client. Surfaces in // Count inbound bytes from the upstream client. Surfaces in bytes.upstreamIn on
// bytes.upstreamIn on the status page. Counted ONCE per parsed frame regardless // the status page. Counted ONCE per parsed frame regardless of subsequent
// of subsequent routing (cache hit, coalesce, backend round-trip, exception). // routing (cache hit, coalesce, backend round-trip, exception).
_ctx.Counters.AddBytes(up: frame.Length, down: 0); _ctx.Counters.AddBytes(up: frame.Length, down: 0);
// Parse the PDU FC + start/qty. FC03/FC04 reads use start/qty for the coalescing key // Parse the PDU FC + start/qty. FC03/FC04 reads use start/qty for the coalescing
// and (Phase 11) for the cache lookup. FC06 writes carry [addr][value]; we treat qty // key and for the cache lookup. FC06 writes carry [addr][value]; we treat qty as
// as 1 for invalidation. FC16 carries [start][qty][byteCount]...; qty is the write // 1 for invalidation. FC16 carries [start][qty][byteCount]...; qty is the write
// span used for cache invalidation. Phase 11: FC06/FC16 start/qty drive cache // span used for cache invalidation. FC06/FC16 start/qty drive cache invalidation
// invalidation by overlap rather than exact key. // by overlap rather than exact key.
int pduOffset = MbapFrame.HeaderSize; int pduOffset = MbapFrame.HeaderSize;
byte fcByte = frame.Length > pduOffset ? frame[pduOffset] : (byte)0; byte fcByte = frame.Length > pduOffset ? frame[pduOffset] : (byte)0;
ushort startAddr = 0; ushort startAddr = 0;
@@ -763,12 +760,12 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
qty = (ushort)((frame[pduOffset + 3] << 8) | frame[pduOffset + 4]); qty = (ushort)((frame[pduOffset + 3] << 8) | frame[pduOffset + 4]);
} }
// Phase 11 — response-cache path. Cache check happens BEFORE coalescing AND before // Response-cache path. Cache check happens BEFORE coalescing AND before we
// we attempt to bring up the backend connection. A hit short-circuits everything, // attempt to bring up the backend connection. A hit short-circuits everything,
// including the EnsureBackendConnectedAsync call — operators with all reads cached // including the EnsureBackendConnectedAsync call — operators with all reads
// and the backend down still get served (the cache survives backend disconnects per // cached and the backend down still get served (the cache survives backend
// the design contract). The cache only fires for FC03/FC04 and only when the read // disconnects per the design contract). The cache only fires for FC03/FC04 and
// range's resolved TTL > 0. // only when the read range's resolved TTL > 0.
int resolvedCacheTtlMs = 0; int resolvedCacheTtlMs = 0;
if (fcByte is 0x03 or 0x04 && _ctx.Cache is { } responseCache) if (fcByte is 0x03 or 0x04 && _ctx.Cache is { } responseCache)
{ {
@@ -783,7 +780,7 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
byte[] hitFrame = BuildCacheHitFrame(originalTxId, unitId, cached.PduBytes); byte[] hitFrame = BuildCacheHitFrame(originalTxId, unitId, cached.PduBytes);
await pipe.SendResponseAsync(hitFrame, ct).ConfigureAwait(false); await pipe.SendResponseAsync(hitFrame, ct).ConfigureAwait(false);
// Phase 12 (W6) — outbound bytes for cache-hit response. // Outbound bytes for cache-hit response.
_ctx.Counters.AddBytes(up: 0, down: hitFrame.Length); _ctx.Counters.AddBytes(up: 0, down: hitFrame.Length);
return; return;
} }
@@ -800,16 +797,15 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
} }
// Ensure backend is connected. Failure here means we cannot service the request; // Ensure backend is connected. Failure here means we cannot service the request;
// close the upstream pipe (consistent with the 1:1 model's behaviour on connect // close the upstream pipe.
// failure).
if (!await EnsureBackendConnectedAsync(ct).ConfigureAwait(false)) if (!await EnsureBackendConnectedAsync(ct).ConfigureAwait(false))
{ {
try { await pipe.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ } try { await pipe.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ }
return; return;
} }
// Phase 10 — read-coalescing path. Only FC03/FC04 are coalescable; only when the // Read-coalescing path. Only FC03/FC04 are coalescable; only when the feature
// feature is enabled in the live config. If the late-arriving request matches an // is enabled in the live config. If the late-arriving request matches an
// already-in-flight peer, we attach to the existing entry and skip the backend // already-in-flight peer, we attach to the existing entry and skip the backend
// round-trip entirely. The existing entry's response will fan out to both parties. // round-trip entirely. The existing entry's response will fan out to both parties.
var coalescingOpts = _coalescingOptions(); var coalescingOpts = _coalescingOptions();
@@ -818,14 +814,14 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
var key = new CoalescingKey(unitId, fcByte, startAddr, qty); var key = new CoalescingKey(unitId, fcByte, startAddr, qty);
var newParty = new InterestedParty(pipe, originalTxId); var newParty = new InterestedParty(pipe, originalTxId);
// The factory does the Phase-9 work: allocate a proxy TxId, build the // The factory allocates a proxy TxId, builds the InFlightRequest with a
// InFlightRequest with a mutable List<InterestedParty>, add to the correlation // mutable List<InterestedParty>, and adds to the correlation map. We
// map. We deliberately do NOT enqueue to the outbound channel inside the // deliberately do NOT enqueue to the outbound channel inside the factory —
// factory — that's done outside the InFlightByKey lock to keep the lock // that's done outside the InFlightByKey lock to keep the lock scope tight
// scope tight and to avoid holding the lock across an async send. // and to avoid holding the lock across an async send.
// //
// proxyTxIdForSend / inFlightForSend communicate the factory's allocation back // proxyTxIdForSend / inFlightForSend communicate the factory's allocation
// out of the lock so the post-lock code can finish the send. // back out of the lock so the post-lock code can finish the send.
ushort proxyTxIdForSend = 0; ushort proxyTxIdForSend = 0;
InFlightRequest? inFlightForSend = null; InFlightRequest? inFlightForSend = null;
@@ -898,40 +894,38 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
if (inFlightForSend is null) if (inFlightForSend is null)
{ {
// Phase 12 (W1.2) — the factory hit the allocator-saturation path or a // The factory hit the allocator-saturation path or a duplicate-key race
// duplicate-key race and stored a stub `InFlightRequest` under `key`. Late // and stored a stub `InFlightRequest` under `key`. Late attachers may
// attachers may have joined the stub between the factory call and this // have joined the stub between the factory call and this cleanup; we
// cleanup; we must deliver the saturation exception to ALL of them, not just // must deliver the saturation exception to ALL of them, not just the
// the leader, otherwise the late attachers wait forever for a response that // leader, otherwise the late attachers wait forever for a response that
// never comes (the stub has no proxy TxId, so no backend round-trip will // never comes (the stub has no proxy TxId, so no backend round-trip will
// ever fire). // ever fire).
MultiplexerLogEvents.Saturated(_logger, _plc.Name, pipe.RemoteEp?.ToString() ?? "?"); MultiplexerLogEvents.Saturated(_logger, _plc.Name, pipe.RemoteEp?.ToString() ?? "?");
if (_inFlightByKey.TryRemove(key, out var stub)) if (_inFlightByKey.TryRemove(key, out var stub))
{ {
// Phase 12 (W4 / Nm1) — non-blocking delivery via TrySendResponse. // Non-blocking delivery via TrySendResponse — the per-PLC fan-out
// Previously this loop awaited SendResponseAsync per party, which would // path must never await per-pipe writes (a wedged late-attacher's
// serialise on a wedged late-attacher's full bounded channel and stall // full bounded channel would otherwise stall delivery to its peers).
// delivery to its peers. Same doctrine as the W1.3 backend-reader fix:
// the per-PLC fan-out path must never await per-pipe writes.
foreach (var party in stub.InterestedParties) foreach (var party in stub.InterestedParties)
{ {
byte[] excFrame = BuildExceptionFrame(party.OriginalTxId, unitId, fcByte, exceptionCode: 4); byte[] excFrame = BuildExceptionFrame(party.OriginalTxId, unitId, fcByte, exceptionCode: 4);
if (!party.Pipe.TrySendResponse(excFrame)) if (!party.Pipe.TrySendResponse(excFrame))
_ctx.Counters.IncrementResponseDropForFullUpstream(); _ctx.Counters.IncrementResponseDropForFullUpstream();
else else
_ctx.Counters.AddBytes(up: 0, down: excFrame.Length); // W6 _ctx.Counters.AddBytes(up: 0, down: excFrame.Length);
} }
} }
else else
{ {
// The stub was already removed by another path (extremely unlikely, but // The stub was already removed by another path (extremely unlikely,
// defensive). Surface the exception to the original requester. // but defensive). Surface the exception to the original requester.
byte[] excFrame = BuildExceptionFrame(originalTxId, unitId, fcByte, exceptionCode: 4); byte[] excFrame = BuildExceptionFrame(originalTxId, unitId, fcByte, exceptionCode: 4);
if (!pipe.TrySendResponse(excFrame)) if (!pipe.TrySendResponse(excFrame))
_ctx.Counters.IncrementResponseDropForFullUpstream(); _ctx.Counters.IncrementResponseDropForFullUpstream();
else else
_ctx.Counters.AddBytes(up: 0, down: excFrame.Length); // W6 _ctx.Counters.AddBytes(up: 0, down: excFrame.Length);
} }
return; return;
} }
@@ -962,16 +956,16 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
return; return;
} }
// Non-coalescing path (FC06/FC16 writes, FC03/04 with coalescing disabled, or any // Non-coalescing path (FC06/FC16 writes, FC03/04 with coalescing disabled, or
// other FC). This is the Phase-9 path verbatim — every request gets its own proxy // any other FC). Every request gets its own proxy TxId and its own backend
// TxId and its own backend round-trip. // round-trip.
if (!_allocator.TryAllocate(out ushort proxyTxIdFc)) if (!_allocator.TryAllocate(out ushort proxyTxIdFc))
{ {
MultiplexerLogEvents.Saturated(_logger, _plc.Name, pipe.RemoteEp?.ToString() ?? "?"); MultiplexerLogEvents.Saturated(_logger, _plc.Name, pipe.RemoteEp?.ToString() ?? "?");
byte[] excFrame = BuildExceptionFrame(originalTxId, unitId, fcByte, exceptionCode: 4); byte[] excFrame = BuildExceptionFrame(originalTxId, unitId, fcByte, exceptionCode: 4);
await pipe.SendResponseAsync(excFrame, ct).ConfigureAwait(false); await pipe.SendResponseAsync(excFrame, ct).ConfigureAwait(false);
_ctx.Counters.AddBytes(up: 0, down: excFrame.Length); // W6 _ctx.Counters.AddBytes(up: 0, down: excFrame.Length);
return; return;
} }
@@ -993,10 +987,10 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
return; return;
} }
// Phase 10 — even when the coalescing path is bypassed (e.g. coalescing disabled // Even when the coalescing path is bypassed (e.g. coalescing disabled for
// for FC03/04), we still report the request as a Miss so Hit + Miss = total // FC03/04), we still report the request as a Miss so Hit + Miss = total
// FC03/FC04 requests across snapshots. FC06/FC16 are not counted here (they are // FC03/FC04 requests across snapshots. FC06/FC16 are not counted here (they
// not coalescable in any sense). // are not coalescable in any sense).
if (fcByte is 0x03 or 0x04) if (fcByte is 0x03 or 0x04)
_ctx.Counters.IncrementCoalescedMiss(); _ctx.Counters.IncrementCoalescedMiss();
@@ -1037,12 +1031,10 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
/// Modbus exception (code 0x0B / Gateway Target Device Failed To Respond) to each /// Modbus exception (code 0x0B / Gateway Target Device Failed To Respond) to each
/// interested party with the original TxId restored. /// interested party with the original TxId restored.
/// ///
/// <para><b>Why this exists.</b> In the 1:1 connection model, a lost response would /// <para><b>Why this exists.</b> In a multiplexed connection model a single missing
/// fault the dedicated backend socket and the upstream pair would close. The multiplexed /// or mis-routed response would otherwise leak a correlation entry forever and hang
/// model needs an explicit per-request timer because a single missing or mis-routed /// the upstream pipe indefinitely. Real-world causes: PLC drops a response, network
/// response would otherwise leak a correlation entry forever and hang the upstream /// packet loss, backend that mis-echoes MBAP TxIds.</para>
/// pipe indefinitely. Real-world causes: PLC drops a response, network packet loss,
/// backend that mis-echoes MBAP TxIds.</para>
/// </summary> /// </summary>
private async Task RunRequestTimeoutWatchdogAsync(CancellationToken ct) private async Task RunRequestTimeoutWatchdogAsync(CancellationToken ct)
{ {
@@ -1070,10 +1062,10 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
_allocator.Release(proxyTxId); _allocator.Release(proxyTxId);
// Phase 10 — also clear the coalescing-by-key entry. A late attach that // Also clear the coalescing-by-key entry. A late attach that raced
// raced in just before the watchdog claim will still receive the 0x0B // in just before the watchdog claim will still receive the 0x0B
// exception via this entry's InterestedParties list (List<T> mutations // exception via this entry's InterestedParties list (List<T>
// happen before fan-out begins). // mutations happen before fan-out begins).
if (req.Fc is 0x03 or 0x04) if (req.Fc is 0x03 or 0x04)
{ {
var coalKey = new CoalescingKey(req.UnitId, req.Fc, req.StartAddress, req.Qty); var coalKey = new CoalescingKey(req.UnitId, req.Fc, req.StartAddress, req.Qty);
@@ -1097,7 +1089,7 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
try try
{ {
await party.Pipe.SendResponseAsync(excFrame, ct).ConfigureAwait(false); await party.Pipe.SendResponseAsync(excFrame, ct).ConfigureAwait(false);
_ctx.Counters.AddBytes(up: 0, down: excFrame.Length); // W6 _ctx.Counters.AddBytes(up: 0, down: excFrame.Length);
} }
catch catch
{ {
@@ -1150,10 +1142,10 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
} }
/// <summary> /// <summary>
/// Phase 11 — builds an MBAP-framed response from cached PDU bytes for the given /// Builds an MBAP-framed response from cached PDU bytes for the given upstream
/// upstream party. The cache stores POST-rewriter PDU bodies (no MBAP); each hit /// party. The cache stores POST-rewriter PDU bodies (no MBAP); each hit stamps a
/// stamps a fresh MBAP header carrying the requesting party's original TxId so the /// fresh MBAP header carrying the requesting party's original TxId so the response
/// response looks indistinguishable from a fresh backend reply. /// looks indistinguishable from a fresh backend reply.
/// </summary> /// </summary>
private static byte[] BuildCacheHitFrame(ushort originalTxId, byte unitId, byte[] cachedPdu) private static byte[] BuildCacheHitFrame(ushort originalTxId, byte unitId, byte[] cachedPdu)
{ {
@@ -49,12 +49,11 @@ internal sealed partial class UpstreamPipe : IAsyncDisposable
// Internal CTS lets the multiplexer signal "drop this pipe now" without waiting for // Internal CTS lets the multiplexer signal "drop this pipe now" without waiting for
// the upstream socket to close cleanly. // the upstream socket to close cleanly.
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
// Phase 12 (W2.2) — volatile so writes from DisposeAsync are observed by IsAlive / // Volatile so writes from DisposeAsync are observed by IsAlive / TrySendResponse on
// TrySendResponse on other threads without a fence. // other threads without a fence.
private volatile bool _disposed; private volatile bool _disposed;
// Phase 9: per-pipe forwarded-PDU counter (replaces the per-pair counter from the // Per-pipe forwarded-PDU counter. Read by the status page.
// 1:1 model). Read by the status page.
private long _pdusForwardedCount; private long _pdusForwardedCount;
/// <summary>Stable identity for status-page reporting and cascade cleanup.</summary> /// <summary>Stable identity for status-page reporting and cascade cleanup.</summary>
@@ -227,11 +226,11 @@ internal sealed partial class UpstreamPipe : IAsyncDisposable
} }
/// <summary> /// <summary>
/// Phase 12 (W1.3) — non-blocking response enqueue. Returns <c>true</c> when the frame /// Non-blocking response enqueue. Returns <c>true</c> when the frame was queued for
/// was queued for delivery, <c>false</c> when the pipe is dead OR the response channel /// delivery, <c>false</c> when the pipe is dead OR the response channel is full.
/// is full. Used by the per-PLC backend reader's fan-out loop so a single wedged /// Used by the per-PLC backend reader's fan-out loop so a single wedged upstream
/// upstream cannot stall responses to peers sharing the same backend socket — without /// cannot stall responses to peers sharing the same backend socket — without this, a
/// this, a full <c>_responseChannel</c> on one pipe would block the reader task. /// full <c>_responseChannel</c> on one pipe would block the reader task.
/// ///
/// <para>A <c>false</c> return indicates the frame is the multiplexer's responsibility /// <para>A <c>false</c> return indicates the frame is the multiplexer's responsibility
/// to drop and (optionally) account for via a counter. The wedged upstream's socket /// to drop and (optionally) account for via a counter. The wedged upstream's socket
+2 -3
View File
@@ -2,8 +2,8 @@ namespace Mbproxy.Proxy;
/// <summary> /// <summary>
/// No-op PDU pipeline: passes every frame through byte-for-byte without rewriting. /// No-op PDU pipeline: passes every frame through byte-for-byte without rewriting.
/// Registered as the <see cref="IPduPipeline"/> singleton in Phase 03. /// Used by tests and fallback paths; production wires
/// Phase 04 replaces this registration with BcdPduPipeline. /// <see cref="BcdPduPipeline"/> as the <see cref="IPduPipeline"/> singleton.
/// </summary> /// </summary>
internal sealed class NoopPduPipeline : IPduPipeline internal sealed class NoopPduPipeline : IPduPipeline
{ {
@@ -14,6 +14,5 @@ internal sealed class NoopPduPipeline : IPduPipeline
PduContext context) PduContext context)
{ {
// Intentional no-op: bytes forwarded unmodified. // Intentional no-op: bytes forwarded unmodified.
// Phase 04: replace this registration with BcdPduPipeline.
} }
} }
+15 -16
View File
@@ -14,20 +14,20 @@ namespace Mbproxy.Proxy;
/// served by the same <see cref="Multiplexing.PlcMultiplexer"/>; all mutable state is /// served by the same <see cref="Multiplexing.PlcMultiplexer"/>; all mutable state is
/// accessed through <see cref="ProxyCounters"/> which uses Interlocked for thread-safety. /// accessed through <see cref="ProxyCounters"/> which uses Interlocked for thread-safety.
/// ///
/// <para><b>Phase 9 — request correlation:</b> the multiplexer sets <see cref="CurrentRequest"/> /// <para><b>Request correlation:</b> the multiplexer sets <see cref="CurrentRequest"/>
/// before calling the pipeline on each direction. On the request path the pipeline can /// before calling the pipeline on each direction. On the request path the pipeline can
/// peek at the future correlation entry it just enqueued; on the response path the pipeline /// peek at the future correlation entry it just enqueued; on the response path the
/// uses the request's <c>StartAddress</c>/<c>Qty</c> to decode FC03/FC04 BCD slots. Different /// pipeline uses the request's <c>StartAddress</c>/<c>Qty</c> to decode FC03/FC04 BCD
/// in-flight responses use different <see cref="InFlightRequest"/> instances, so there is no /// slots. Different in-flight responses use different <see cref="InFlightRequest"/>
/// cross-talk between concurrent multiplexed requests.</para> /// instances, so there is no cross-talk between concurrent multiplexed requests.</para>
/// ///
/// <para><b>Concurrency:</b> a single <see cref="PerPlcContext"/> instance is shared across /// <para><b>Concurrency:</b> a single <see cref="PerPlcContext"/> instance is shared
/// the per-upstream read tasks (which call the pipeline on the request path) and the /// across the per-upstream read tasks (which call the pipeline on the request path) and
/// single backend reader task (which calls the pipeline on the response path). Because the /// the single backend reader task (which calls the pipeline on the response path).
/// per-call <see cref="CurrentRequest"/> would be racy if mutated on the shared context, /// Because the per-call <see cref="CurrentRequest"/> would be racy if mutated on the
/// the multiplexer constructs a lightweight per-call clone (<see cref="WithCurrentRequest"/>) /// shared context, the multiplexer constructs a lightweight per-call clone
/// for each pipeline invocation. The shared mutable state — the tag map, counters, logger — /// (<see cref="WithCurrentRequest"/>) for each pipeline invocation. The shared mutable
/// is read-only or Interlocked.</para> /// state — the tag map, counters, logger — is read-only or Interlocked.</para>
/// </summary> /// </summary>
internal class PerPlcContext : PduContext internal class PerPlcContext : PduContext
{ {
@@ -46,10 +46,9 @@ internal class PerPlcContext : PduContext
internal InFlightRequest? CurrentRequest { get; init; } internal InFlightRequest? CurrentRequest { get; init; }
/// <summary> /// <summary>
/// Phase 11 — optional per-PLC response cache. <c>null</c> on contexts that opt out /// Optional per-PLC response cache. <c>null</c> on contexts that opt out (every BCD
/// (every BCD tag has <see cref="BcdTag.CacheTtlMs"/> = 0) or in unit tests that don't /// tag has <see cref="BcdTag.CacheTtlMs"/> = 0) or in unit tests that don't exercise
/// exercise the cache. The multiplexer constructs and disposes the cache alongside /// the cache. The multiplexer constructs and disposes the cache alongside itself.
/// itself.
/// </summary> /// </summary>
internal ResponseCache? Cache { get; init; } internal ResponseCache? Cache { get; init; }
+13 -15
View File
@@ -11,15 +11,13 @@ namespace Mbproxy.Proxy;
/// Owns one <see cref="TcpListener"/> bound to a PLC's configured listen port and one /// Owns one <see cref="TcpListener"/> bound to a PLC's configured listen port and one
/// <see cref="PlcMultiplexer"/> that owns the single backend connection to the PLC. /// <see cref="PlcMultiplexer"/> that owns the single backend connection to the PLC.
/// ///
/// <para><b>Phase 9 — TxId multiplexing:</b> the listener no longer pairs each upstream /// <para>Every accepted upstream is wrapped in an <see cref="UpstreamPipe"/> and handed
/// socket with a dedicated backend socket. Instead, every accepted upstream is wrapped /// to the multiplexer, which TxId-multiplexes them onto a single backend socket — this
/// in an <see cref="UpstreamPipe"/> and handed to the multiplexer. The multiplexer holds /// eliminates the H2-ECOM100's 4-concurrent-client cap from the upstream side.</para>
/// at most one TCP connection to the PLC, eliminating the H2-ECOM100's 4-concurrent-client
/// cap from the upstream side.</para>
/// ///
/// <para>The listener's accept loop is otherwise unchanged. <see cref="StartAsync"/> /// <para><see cref="StartAsync"/> binds the socket; <see cref="RunAsync"/> runs until
/// binds the socket; <see cref="RunAsync"/> runs until cancelled or the listener faults; /// cancelled or the listener faults; <see cref="DisposeAsync"/> tears down both the
/// <see cref="DisposeAsync"/> tears down both the listener and the multiplexer.</para> /// listener and the multiplexer.</para>
/// </summary> /// </summary>
internal sealed partial class PlcListener : IAsyncDisposable internal sealed partial class PlcListener : IAsyncDisposable
{ {
@@ -49,9 +47,9 @@ internal sealed partial class PlcListener : IAsyncDisposable
=> _multiplexer?.AttachedPipes ?? Array.Empty<UpstreamPipe>(); => _multiplexer?.AttachedPipes ?? Array.Empty<UpstreamPipe>();
/// <summary> /// <summary>
/// Phase 12 (W1.1) — exposes the running multiplexer so a hot-reload reseat can swap /// Exposes the running multiplexer so a hot-reload reseat can swap the per-PLC
/// the per-PLC context on the live instance. <c>null</c> between StopAsync and a fresh /// context on the live instance. <c>null</c> between StopAsync and a fresh start;
/// start; callers must null-check. /// callers must null-check.
/// </summary> /// </summary>
internal PlcMultiplexer? Multiplexer => _multiplexer; internal PlcMultiplexer? Multiplexer => _multiplexer;
@@ -89,10 +87,10 @@ internal sealed partial class PlcListener : IAsyncDisposable
_listener.Start(); _listener.Start();
LogBound(_listenerLogger, _plc.Name, _plc.ListenPort); LogBound(_listenerLogger, _plc.Name, _plc.ListenPort);
// The multiplexer needs a PerPlcContext to share the BCD tag map and counters with // The multiplexer needs a PerPlcContext to share the BCD tag map and counters
// the pipeline. If the caller (typically a test or pre-Phase-6 startup path) didn't // with the pipeline. If the caller (typically a test) didn't supply one,
// supply one, construct a minimal context that exposes only the PlcName so the // construct a minimal context that exposes only the PlcName so the multiplexer
// multiplexer + a noop/passthrough pipeline still round-trip frames correctly. // + a noop/passthrough pipeline still round-trip frames correctly.
var ctx = _perPlcContext ?? new PerPlcContext var ctx = _perPlcContext ?? new PerPlcContext
{ {
PlcName = _plc.Name, PlcName = _plc.Name,
+67 -66
View File
@@ -1,13 +1,11 @@
namespace Mbproxy.Proxy; namespace Mbproxy.Proxy;
/// <summary> /// <summary>
/// Immutable snapshot of per-PLC counters. Consumed by Phase 07's status page. /// Immutable snapshot of per-PLC counters. Consumed by the status page.
/// All fields are point-in-time reads; no ordering guarantees across fields. /// All fields are point-in-time reads; no ordering guarantees across fields.
/// ///
/// <para><b>Backwards-compat policy (see docs/kpi.md):</b> fields are <i>added</i>, never /// <para><b>Backwards-compat policy (see docs/kpi.md):</b> fields are <i>added</i>, never
/// renamed or removed. Phase 9 appended <c>InFlightCount</c>, <c>MaxInFlight</c>, /// renamed or removed.</para>
/// <c>TxIdWraps</c>, <c>BackendDisconnectCascades</c>, and <c>BackendQueueDepth</c> for
/// the TxId-multiplexer telemetry surface (Tier 1.6 in docs/kpi.md).</para>
/// </summary> /// </summary>
public sealed record CounterSnapshot( public sealed record CounterSnapshot(
long PdusForwarded, long PdusForwarded,
@@ -53,82 +51,85 @@ public sealed record CounterSnapshot(
long ConnectsFailed, long ConnectsFailed,
/// <summary> /// <summary>
/// Number of Modbus requests currently in flight on this PLC's multiplexed backend /// Number of Modbus requests currently in flight on this PLC's multiplexed backend
/// connection (point-in-time snapshot of the correlation map size). Phase 9. /// connection (point-in-time snapshot of the correlation map size).
/// </summary> /// </summary>
long InFlightCount, long InFlightCount,
/// <summary> /// <summary>
/// Peak <see cref="InFlightCount"/> observed since the multiplexer was constructed. /// Peak <see cref="InFlightCount"/> observed since the multiplexer was constructed.
/// Updated via <see cref="Interlocked"/> CAS so concurrent in-flight increments do not /// Updated via <see cref="Interlocked"/> CAS so concurrent in-flight increments do
/// lose the high-water mark. Phase 9. /// not lose the high-water mark.
/// </summary> /// </summary>
long MaxInFlight, long MaxInFlight,
/// <summary> /// <summary>
/// Number of times the per-PLC TxId allocator's rolling cursor has wrapped /// Number of times the per-PLC TxId allocator's rolling cursor has wrapped
/// 0xFFFF → 0x0000. A non-zero value is benign; a sudden burst suggests extreme /// 0xFFFF → 0x0000. A non-zero value is benign; a sudden burst suggests extreme
/// in-flight churn. Phase 9. /// in-flight churn.
/// </summary> /// </summary>
long TxIdWraps, long TxIdWraps,
/// <summary> /// <summary>
/// Cumulative count of upstream pipes closed as a side effect of a backend disconnect. /// Cumulative count of upstream pipes closed as a side effect of a backend
/// Each backend reconnect cycle adds the number of attached upstream clients at the /// disconnect. Each backend reconnect cycle adds the number of attached upstream
/// time of the disconnect. Phase 9. /// clients at the time of the disconnect.
/// </summary> /// </summary>
long BackendDisconnectCascades, long BackendDisconnectCascades,
/// <summary> /// <summary>
/// Current depth of the per-PLC outbound channel feeding the backend writer task /// Current depth of the per-PLC outbound channel feeding the backend writer task
/// (frames queued, not yet on the wire). A sustained non-zero value indicates the /// (frames queued, not yet on the wire). A sustained non-zero value indicates the
/// backend is slower than upstream demand. Phase 9. /// backend is slower than upstream demand.
/// </summary> /// </summary>
long BackendQueueDepth, long BackendQueueDepth,
/// <summary> /// <summary>
/// Phase 10 — cumulative count of FC03/FC04 requests that attached to an already-in-flight /// Cumulative count of FC03/FC04 requests that attached to an already-in-flight
/// peer instead of opening a fresh backend round-trip. <c>CoalescedHitCount + CoalescedMissCount</c> /// peer instead of opening a fresh backend round-trip.
/// equals total FC03/FC04 requests seen by the multiplexer. /// <c>CoalescedHitCount + CoalescedMissCount</c> equals total FC03/FC04 requests
/// seen by the multiplexer.
/// </summary> /// </summary>
long CoalescedHitCount, long CoalescedHitCount,
/// <summary> /// <summary>
/// Phase 10 — cumulative count of FC03/FC04 requests that opened a fresh in-flight entry /// Cumulative count of FC03/FC04 requests that opened a fresh in-flight entry (no
/// (no matching peer was in flight, or the matching peer had reached its <c>MaxParties</c> /// matching peer was in flight, or the matching peer had reached its
/// cap). With <c>ReadCoalescing.Enabled = false</c>, every FC03/FC04 request becomes a miss. /// <c>MaxParties</c> cap). With <c>ReadCoalescing.Enabled = false</c>, every
/// FC03/FC04 request becomes a miss.
/// </summary> /// </summary>
long CoalescedMissCount, long CoalescedMissCount,
/// <summary> /// <summary>
/// Phase 10 — count of coalesced response fan-outs that were skipped because the /// Count of coalesced response fan-outs that were skipped because the attached
/// attached upstream pipe had already disconnected. A spike is a churn indicator; the /// upstream pipe had already disconnected. A spike is a churn indicator; the metric
/// metric itself is informational (Tier 2 in <c>docs/kpi.md</c>). /// itself is informational (Tier 2 in <c>docs/kpi.md</c>).
/// </summary> /// </summary>
long CoalescedResponseToDeadUpstream, long CoalescedResponseToDeadUpstream,
/// <summary> /// <summary>
/// Phase 11 — cumulative count of FC03/FC04 requests served from the response cache. /// Cumulative count of FC03/FC04 requests served from the response cache.
/// <c>CacheHitCount + CacheMissCount</c> equals total FC03/FC04 requests whose resolved /// <c>CacheHitCount + CacheMissCount</c> equals total FC03/FC04 requests whose
/// TTL was &gt; 0 (cache-eligible). Reads against tags with TTL = 0 increment neither. /// resolved TTL was &gt; 0 (cache-eligible). Reads against tags with TTL = 0
/// increment neither.
/// </summary> /// </summary>
long CacheHitCount, long CacheHitCount,
/// <summary> /// <summary>
/// Phase 11 — cumulative count of cache-eligible FC03/FC04 requests that fell through /// Cumulative count of cache-eligible FC03/FC04 requests that fell through to
/// to coalescing / backend (no fresh entry was present or the entry had expired). /// coalescing / backend (no fresh entry was present or the entry had expired).
/// </summary> /// </summary>
long CacheMissCount, long CacheMissCount,
/// <summary> /// <summary>
/// Phase 11 — cumulative count of cache entries invalidated by overlapping FC06/FC16 /// Cumulative count of cache entries invalidated by overlapping FC06/FC16 write
/// write responses. A high rate suggests caching is fighting writes; consider lower /// responses. A high rate suggests caching is fighting writes; consider lower TTLs
/// TTLs on cache-overlapping tags. /// on cache-overlapping tags.
/// </summary> /// </summary>
long CacheInvalidations, long CacheInvalidations,
/// <summary> /// <summary>
/// Phase 11 — point-in-time snapshot of the per-PLC <see cref="Cache.ResponseCache"/> /// Point-in-time snapshot of the per-PLC <see cref="Cache.ResponseCache"/> entry
/// entry count. Read on the snapshot path; 0 when no cache is wired. /// count. Read on the snapshot path; 0 when no cache is wired.
/// </summary> /// </summary>
long CacheEntryCount, long CacheEntryCount,
/// <summary> /// <summary>
/// Phase 11 — point-in-time approximation of cached PDU bytes for this PLC. Sum of /// Point-in-time approximation of cached PDU bytes for this PLC. Sum of
/// <see cref="Cache.CacheEntry.Length"/> across entries. Read on the snapshot path. /// <see cref="Cache.CacheEntry.Length"/> across entries. Read on the snapshot path.
/// </summary> /// </summary>
long CacheBytes, long CacheBytes,
/// <summary> /// <summary>
/// Phase 12 (W1.3) — cumulative count of backend response frames the per-PLC reader /// Cumulative count of backend response frames the per-PLC reader task dropped
/// task dropped because the destination upstream pipe's bounded response channel was /// because the destination upstream pipe's bounded response channel was full. A
/// full. A non-zero value indicates one or more upstream clients are not draining their /// non-zero value indicates one or more upstream clients are not draining their
/// socket fast enough to keep up with the backend; the wedged client loses its own /// socket fast enough to keep up with the backend; the wedged client loses its own
/// responses but its peers on the same PLC continue to receive theirs. /// responses but its peers on the same PLC continue to receive theirs.
/// </summary> /// </summary>
@@ -163,34 +164,34 @@ internal sealed class ProxyCounters
private long _connectsSuccess; private long _connectsSuccess;
private long _connectsFailed; private long _connectsFailed;
// Phase 9 multiplexer telemetry. // Multiplexer telemetry.
private long _maxInFlight; private long _maxInFlight;
private long _backendDisconnectCascades; private long _backendDisconnectCascades;
// Phase 10 — coalescing counters. Hit + Miss = total FC03/FC04 requests. // Coalescing counters. Hit + Miss = total FC03/FC04 requests.
private long _coalescedHitCount; private long _coalescedHitCount;
private long _coalescedMissCount; private long _coalescedMissCount;
private long _coalescedResponseToDeadUpstream; private long _coalescedResponseToDeadUpstream;
// Phase 11 — response-cache counters. Hit + Miss = total cache-eligible FC03/FC04. // Response-cache counters. Hit + Miss = total cache-eligible FC03/FC04.
private long _cacheHitCount; private long _cacheHitCount;
private long _cacheMissCount; private long _cacheMissCount;
private long _cacheInvalidations; private long _cacheInvalidations;
// Phase 12 (W1.3) — backend-reader fan-out drop counter. Increments when the reader // Backend-reader fan-out drop counter. Increments when the reader task tried to
// task tried to enqueue a response to an upstream pipe whose bounded response channel // enqueue a response to an upstream pipe whose bounded response channel was full.
// was full. Without the non-blocking enqueue this would deadlock the reader; with it // Without the non-blocking enqueue this would deadlock the reader; with it we drop
// we drop and account. // and account.
private long _responseDropForFullUpstream; private long _responseDropForFullUpstream;
// Phase 11 — live cache state pulled from a per-PLC ResponseCache on each snapshot. // Live cache state pulled from a per-PLC ResponseCache on each snapshot. The
// The multiplexer registers a single provider via SetCacheStatsProvider so the status // multiplexer registers a single provider via SetCacheStatsProvider so the status
// page sees current entry-count / bytes without a separate poll. // page sees current entry-count / bytes without a separate poll.
private volatile ICacheStatsProvider? _cacheStatsProvider; private volatile ICacheStatsProvider? _cacheStatsProvider;
// Phase 9: live state pulled from the multiplexer's allocator/map/queue on each // Live state pulled from the multiplexer's allocator/map/queue on each snapshot.
// snapshot. The multiplexer registers a single provider via SetMultiplexProvider. // The multiplexer registers a single provider via SetMultiplexProvider. We use a
// We use a volatile reference for lock-free read on the snapshot path. // volatile reference for lock-free read on the snapshot path.
private volatile IMultiplexCountersProvider? _multiplexProvider; private volatile IMultiplexCountersProvider? _multiplexProvider;
// LastBindError is a string (not a long); accessed via volatile field on ProxyCounters // LastBindError is a string (not a long); accessed via volatile field on ProxyCounters
// but actually stored on the supervisor. We expose it here for snapshot parity. // but actually stored on the supervisor. We expose it here for snapshot parity.
@@ -269,61 +270,61 @@ internal sealed class ProxyCounters
=> Interlocked.Increment(ref _connectsFailed); => Interlocked.Increment(ref _connectsFailed);
/// <summary> /// <summary>
/// Records <paramref name="n"/> upstream pipes closed by a backend disconnect cascade. /// Records <paramref name="n"/> upstream pipes closed by a backend disconnect
/// Phase 9. /// cascade.
/// </summary> /// </summary>
public void AddDisconnectCascades(int n) public void AddDisconnectCascades(int n)
=> Interlocked.Add(ref _backendDisconnectCascades, n); => Interlocked.Add(ref _backendDisconnectCascades, n);
/// <summary> /// <summary>
/// Phase 10 — records one FC03/FC04 request that attached to an already-in-flight peer. /// Records one FC03/FC04 request that attached to an already-in-flight peer.
/// </summary> /// </summary>
public void IncrementCoalescedHit() public void IncrementCoalescedHit()
=> Interlocked.Increment(ref _coalescedHitCount); => Interlocked.Increment(ref _coalescedHitCount);
/// <summary> /// <summary>
/// Phase 10 — records one FC03/FC04 request that opened a fresh in-flight entry /// Records one FC03/FC04 request that opened a fresh in-flight entry (no matching
/// (no matching peer was in flight, or the matching peer had reached MaxParties). /// peer was in flight, or the matching peer had reached MaxParties).
/// </summary> /// </summary>
public void IncrementCoalescedMiss() public void IncrementCoalescedMiss()
=> Interlocked.Increment(ref _coalescedMissCount); => Interlocked.Increment(ref _coalescedMissCount);
/// <summary> /// <summary>
/// Phase 10 — records one coalesced response fan-out that was skipped because the /// Records one coalesced response fan-out that was skipped because the attached
/// attached upstream pipe had already disconnected. Informational only. /// upstream pipe had already disconnected. Informational only.
/// </summary> /// </summary>
public void IncrementCoalescedResponseToDeadUpstream() public void IncrementCoalescedResponseToDeadUpstream()
=> Interlocked.Increment(ref _coalescedResponseToDeadUpstream); => Interlocked.Increment(ref _coalescedResponseToDeadUpstream);
/// <summary>Phase 11 — records one FC03/FC04 cache hit.</summary> /// <summary>Records one FC03/FC04 cache hit.</summary>
public void IncrementCacheHit() public void IncrementCacheHit()
=> Interlocked.Increment(ref _cacheHitCount); => Interlocked.Increment(ref _cacheHitCount);
/// <summary>Phase 11 — records one cache-eligible FC03/FC04 read that missed.</summary> /// <summary>Records one cache-eligible FC03/FC04 read that missed.</summary>
public void IncrementCacheMiss() public void IncrementCacheMiss()
=> Interlocked.Increment(ref _cacheMissCount); => Interlocked.Increment(ref _cacheMissCount);
/// <summary>Phase 11 — records <paramref name="n"/> cache entries invalidated by a write.</summary> /// <summary>Records <paramref name="n"/> cache entries invalidated by a write.</summary>
public void AddCacheInvalidations(int n) public void AddCacheInvalidations(int n)
=> Interlocked.Add(ref _cacheInvalidations, n); => Interlocked.Add(ref _cacheInvalidations, n);
/// <summary> /// <summary>
/// Phase 12 (W1.3) — records one backend response frame dropped because the destination /// Records one backend response frame dropped because the destination upstream
/// upstream pipe's response channel was full. /// pipe's response channel was full.
/// </summary> /// </summary>
public void IncrementResponseDropForFullUpstream() public void IncrementResponseDropForFullUpstream()
=> Interlocked.Increment(ref _responseDropForFullUpstream); => Interlocked.Increment(ref _responseDropForFullUpstream);
/// <summary> /// <summary>
/// Phase 11 — wires the per-PLC <see cref="Cache.ResponseCache"/> as the live stats /// Wires the per-PLC <see cref="Cache.ResponseCache"/> as the live stats source for
/// source for the snapshot path. Pass <c>null</c> to detach during disposal. /// the snapshot path. Pass <c>null</c> to detach during disposal.
/// </summary> /// </summary>
internal void SetCacheStatsProvider(ICacheStatsProvider? provider) internal void SetCacheStatsProvider(ICacheStatsProvider? provider)
=> _cacheStatsProvider = provider; => _cacheStatsProvider = provider;
/// <summary> /// <summary>
/// CAS-updates the peak in-flight high-water mark. Called on every successful /// CAS-updates the peak in-flight high-water mark. Called on every successful
/// allocation by the multiplexer. Phase 9. /// allocation by the multiplexer.
/// </summary> /// </summary>
public void ObserveInFlight(int currentInFlight) public void ObserveInFlight(int currentInFlight)
{ {
@@ -341,7 +342,7 @@ internal sealed class ProxyCounters
/// Wires the live multiplexer telemetry source into this counter set. Called by /// Wires the live multiplexer telemetry source into this counter set. Called by
/// <see cref="Mbproxy.Proxy.Multiplexing.PlcMultiplexer"/> at construction time so /// <see cref="Mbproxy.Proxy.Multiplexing.PlcMultiplexer"/> at construction time so
/// the status page's <see cref="Snapshot"/> can include live in-flight / queue-depth /// the status page's <see cref="Snapshot"/> can include live in-flight / queue-depth
/// values without polling the multiplexer separately. Phase 9. /// values without polling the multiplexer separately.
/// </summary> /// </summary>
internal void SetMultiplexProvider(IMultiplexCountersProvider? provider) internal void SetMultiplexProvider(IMultiplexCountersProvider? provider)
=> _multiplexProvider = provider; => _multiplexProvider = provider;
@@ -454,7 +455,7 @@ internal sealed class ProxyCounters
/// and registered with <see cref="ProxyCounters.SetMultiplexProvider"/> so /// and registered with <see cref="ProxyCounters.SetMultiplexProvider"/> so
/// <see cref="ProxyCounters.Snapshot"/> can include live mux telemetry without holding /// <see cref="ProxyCounters.Snapshot"/> can include live mux telemetry without holding
/// a direct reference to the multiplexer (which would couple counter snapshots to the /// a direct reference to the multiplexer (which would couple counter snapshots to the
/// connection layer's lifecycle). Phase 9. /// connection layer's lifecycle).
/// </summary> /// </summary>
internal interface IMultiplexCountersProvider internal interface IMultiplexCountersProvider
{ {
@@ -469,8 +470,8 @@ internal interface IMultiplexCountersProvider
} }
/// <summary> /// <summary>
/// Phase 11 — read-only window into the per-PLC <see cref="Cache.ResponseCache"/>'s live /// Read-only window into the per-PLC <see cref="Cache.ResponseCache"/>'s live state
/// state for the snapshot path. The multiplexer wires this on cache construction so the /// for the snapshot path. The multiplexer wires this on cache construction so the
/// status page sees live counts without holding a direct reference to the cache. /// status page sees live counts without holding a direct reference to the cache.
/// </summary> /// </summary>
internal interface ICacheStatsProvider internal interface ICacheStatsProvider
+52 -59
View File
@@ -24,8 +24,8 @@ namespace Mbproxy.Proxy;
/// log <c>mbproxy.startup.ready</c> with bound/configured counts.</item> /// log <c>mbproxy.startup.ready</c> with bound/configured counts.</item>
/// </list> /// </list>
/// ///
/// Phase 06: passes the supervisor dictionary to <see cref="ConfigReconciler.Attach"/> /// Passes the supervisor dictionary to <see cref="ConfigReconciler.Attach"/> after
/// after initial startup so hot-reload changes are applied by the reconciler. /// initial startup so hot-reload changes are applied by the reconciler.
/// ///
/// Stop: cancels all supervisors in parallel with a 5-second hard deadline. /// Stop: cancels all supervisors in parallel with a 5-second hard deadline.
/// </summary> /// </summary>
@@ -36,30 +36,30 @@ internal sealed partial class ProxyWorker : BackgroundService
private readonly ILogger<ProxyWorker> _logger; private readonly ILogger<ProxyWorker> _logger;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly ConfigReconciler _reconciler; private readonly ConfigReconciler _reconciler;
// Phase 12 (W1.5) — admin endpoint is no longer IHostedService; ProxyWorker drives its // Admin endpoint is not registered as IHostedService; ProxyWorker drives its
// lifecycle directly so the design's "drain THEN stop admin" ordering is honoured. // lifecycle directly so the design's "drain THEN stop admin" ordering is honoured.
// //
// Resolved LAZILY (in ExecuteAsync) rather than in the constructor because the DI graph // Resolved LAZILY (in ExecuteAsync) rather than in the constructor because the DI
// is circular: AdminEndpointHost → StatusSnapshotBuilder → ProxyWorker. A constructor // graph is circular: AdminEndpointHost → StatusSnapshotBuilder → ProxyWorker. A
// GetService<AdminEndpointHost>() during ProxyWorker's own construction returns null // constructor GetService<AdminEndpointHost>() during ProxyWorker's own construction
// silently. Lazy resolution sidesteps the cycle — by the time ExecuteAsync runs the DI // returns null silently. Lazy resolution sidesteps the cycle — by the time
// container is fully built. // ExecuteAsync runs the DI container is fully built.
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
private AdminEndpointHost? _admin; private AdminEndpointHost? _admin;
// Phase 06: supervisors are now managed jointly by ProxyWorker (initial bootstrap) // Supervisors are managed jointly by ProxyWorker (initial bootstrap) and
// and ConfigReconciler (subsequent hot-reload changes). The dictionary is shared // ConfigReconciler (subsequent hot-reload changes). The dictionary is shared via
// via ConfigReconciler.Attach() after initial startup. // ConfigReconciler.Attach() after initial startup.
// //
// Phase 12 (W2.3) — ConcurrentDictionary because ConfigReconciler mutates this from // ConcurrentDictionary because ConfigReconciler mutates this from parallel
// parallel Task.WhenAll continuations (Add/Remove/Restart paths). The outer Apply is // Task.WhenAll continuations (Add/Remove/Restart paths). The outer Apply is
// serialised by a semaphore but the inner per-PLC tasks run concurrently. Status-page // serialised by a semaphore but the inner per-PLC tasks run concurrently.
// reads via IReadOnlyDictionary still work without locking. // Status-page reads via IReadOnlyDictionary still work without locking.
private readonly ConcurrentDictionary<string, PlcListenerSupervisor> _supervisors = private readonly ConcurrentDictionary<string, PlcListenerSupervisor> _supervisors =
new(StringComparer.Ordinal); new(StringComparer.Ordinal);
/// <summary> /// <summary>
/// Read-only view of the live supervisor dictionary. Consumed by Phase 07's /// Read-only view of the live supervisor dictionary. Consumed by
/// <see cref="Admin.StatusSnapshotBuilder"/> to enumerate per-PLC state. /// <see cref="Admin.StatusSnapshotBuilder"/> to enumerate per-PLC state.
/// The caller should read this on the status-page path only (not the hot path). /// The caller should read this on the status-page path only (not the hot path).
/// </summary> /// </summary>
@@ -79,7 +79,7 @@ internal sealed partial class ProxyWorker : BackgroundService
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_reconciler = reconciler; _reconciler = reconciler;
_services = services; _services = services;
// Phase 12 (W1.5) — admin endpoint resolved lazily in ExecuteAsync (see field comment). // Admin endpoint resolved lazily in ExecuteAsync (see field comment).
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -107,11 +107,11 @@ internal sealed partial class ProxyWorker : BackgroundService
continue; continue;
} }
// Phase 11 — construct a per-PLC response cache only when at least one // Construct a per-PLC response cache only when at least one resolved tag
// resolved tag opts in (CacheTtlMs > 0). Skipping cache construction for a // opts in (CacheTtlMs > 0). Skipping cache construction for a PLC with no
// PLC with no cacheable tags keeps the no-cache path free of the eviction // cacheable tags keeps the no-cache path free of the eviction timer and the
// timer and the per-call resolution cost, preserving "default behaviour = // per-call resolution cost, preserving the "no caching" default behaviour
// Phase 10 unchanged" when no operator has opted any tag in. // when no operator has opted any tag in.
var cache = HasAnyCacheableTag(result.Map) var cache = HasAnyCacheableTag(result.Map)
? new ResponseCache(opts.Cache.MaxEntriesPerPlc, opts.Cache.EvictionIntervalMs) ? new ResponseCache(opts.Cache.MaxEntriesPerPlc, opts.Cache.EvictionIntervalMs)
: null; : null;
@@ -144,9 +144,9 @@ internal sealed partial class ProxyWorker : BackgroundService
resilienceOpts.ListenerRecovery, resilienceOpts.ListenerRecovery,
_loggerFactory.CreateLogger($"Mbproxy.Proxy.ListenerRecovery.{plc.Name}")); _loggerFactory.CreateLogger($"Mbproxy.Proxy.ListenerRecovery.{plc.Name}"));
// Phase 10 — give the supervisor a live accessor for ReadCoalescingOptions // Give the supervisor a live accessor for ReadCoalescingOptions so a
// so a hot-reload of `Mbproxy.Resilience.ReadCoalescing.Enabled` propagates // hot-reload of `Mbproxy.Resilience.ReadCoalescing.Enabled` propagates to
// to the multiplexer's per-PDU coalescing decision. // the multiplexer's per-PDU coalescing decision.
Func<ReadCoalescingOptions> coalescingAccessor = Func<ReadCoalescingOptions> coalescingAccessor =
() => _options.CurrentValue.Resilience.ReadCoalescing; () => _options.CurrentValue.Resilience.ReadCoalescing;
@@ -166,13 +166,13 @@ internal sealed partial class ProxyWorker : BackgroundService
_supervisors[plc.Name] = supervisor; _supervisors[plc.Name] = supervisor;
} }
// ── Phase 06: wire reconciler BEFORE starting supervisors ───────────────── // ── Wire reconciler BEFORE starting supervisors ──────────────────────────
// Attach hands the reconciler the authoritative supervisor dictionary and the // Attach hands the reconciler the authoritative supervisor dictionary and the
// initial options snapshot. The reconciler won't process OnChange events until // initial options snapshot. The reconciler won't process OnChange events until
// after this call — the brief window between Attach and first supervisor start // after this call — the brief window between Attach and first supervisor start
// is safe because the channel signal only enqueues; apply runs asynchronously. // is safe because the channel signal only enqueues; apply runs asynchronously.
// Phase 12 (W2.1) — also pass the live coalescing accessor so reconciler-built // Pass the live coalescing accessor so reconciler-built supervisors
// supervisors (add/restart paths) honour hot-reloaded ReadCoalescing values. // (add/restart paths) honour hot-reloaded ReadCoalescing values.
Func<ReadCoalescingOptions> reconcilerCoalescingAccessor = Func<ReadCoalescingOptions> reconcilerCoalescingAccessor =
() => _options.CurrentValue.Resilience.ReadCoalescing; () => _options.CurrentValue.Resilience.ReadCoalescing;
_reconciler.Attach(_supervisors, opts, reconcilerCoalescingAccessor); _reconciler.Attach(_supervisors, opts, reconcilerCoalescingAccessor);
@@ -213,10 +213,10 @@ internal sealed partial class ProxyWorker : BackgroundService
int boundCount = _supervisors.Values.Count(s => s.Snapshot().State == SupervisorState.Bound); int boundCount = _supervisors.Values.Count(s => s.Snapshot().State == SupervisorState.Bound);
LogStartupReady(_logger, boundCount, plcsConfigured); LogStartupReady(_logger, boundCount, plcsConfigured);
// Phase 12 (W1.5) — start the admin endpoint AFTER listeners are bound so the // Start the admin endpoint AFTER listeners are bound so the status page can
// status page can never observe the service in a "no PLCs configured yet" state. // never observe the service in a "no PLCs configured yet" state. The admin
// The admin endpoint is no longer registered as IHostedService (the host's reverse // endpoint is not registered as IHostedService (the host's reverse stop order
// stop order would tear it down BEFORE drain). ProxyWorker drives both ends. // would tear it down BEFORE drain) ProxyWorker drives both ends.
// //
// Resolution happens here, not in the constructor — the DI graph is circular // Resolution happens here, not in the constructor — the DI graph is circular
// (admin → StatusSnapshotBuilder → ProxyWorker) and a constructor-time lookup // (admin → StatusSnapshotBuilder → ProxyWorker) and a constructor-time lookup
@@ -235,10 +235,9 @@ internal sealed partial class ProxyWorker : BackgroundService
} }
else else
{ {
// Phase 12 (W4 / Nm6) — surface the absence. The previous IHostedService // Surface the absence. The lazy lookup returns null silently if
// registration would have hard-errored in DI if AddMbproxyAdmin() was missing // AddMbproxyAdmin() is missing from Program.cs; a single warning makes a
// from Program.cs; the W1.5 lazy lookup returns null silently. A single warning // botched composition observable without blocking startup.
// makes a botched composition observable without blocking startup.
_logger.LogWarning( _logger.LogWarning(
"Admin endpoint not registered (AddMbproxyAdmin() missing from composition). " + "Admin endpoint not registered (AddMbproxyAdmin() missing from composition). " +
"Status page will be unavailable; service continues without it."); "Status page will be unavailable; service continues without it.");
@@ -250,8 +249,7 @@ internal sealed partial class ProxyWorker : BackgroundService
} }
/// <summary> /// <summary>
/// Phase 12 (W1.5) — graceful shutdown sequence (replaces the deleted /// Graceful shutdown sequence:
/// <c>ShutdownCoordinator</c>):
/// <list type="number"> /// <list type="number">
/// <item>Cancel <see cref="ExecuteAsync"/> via <c>base.StopAsync</c>.</item> /// <item>Cancel <see cref="ExecuteAsync"/> via <c>base.StopAsync</c>.</item>
/// <item><b>Snapshot</b> per-PLC in-flight counts BEFORE stopping supervisors — /// <item><b>Snapshot</b> per-PLC in-flight counts BEFORE stopping supervisors —
@@ -263,10 +261,7 @@ internal sealed partial class ProxyWorker : BackgroundService
/// stop is the actual drain — it cancels the listener, which exits its /// stop is the actual drain — it cancels the listener, which exits its
/// accept loop, which disposes the multiplexer, which cascades all attached /// accept loop, which disposes the multiplexer, which cascades all attached
/// pipes. There is no separate "drain in-flight" phase because there is /// pipes. There is no separate "drain in-flight" phase because there is
/// nothing to drain that wouldn't be killed by the supervisor stop itself /// nothing to drain that wouldn't be killed by the supervisor stop itself.</item>
/// (the original Phase-08 ShutdownCoordinator's drain loop had this same
/// shape and was structurally always-zero — call out from
/// codereviews/2026-05-14/ReReviewAfterRemediation.md NC1).</item>
/// <item>Stop the admin endpoint LAST so the status page survives the supervisor /// <item>Stop the admin endpoint LAST so the status page survives the supervisor
/// stop phase and operators can observe the live state right up to shutdown.</item> /// stop phase and operators can observe the live state right up to shutdown.</item>
/// <item>Dispose every supervisor to release sockets, channels, and watchdog timers.</item> /// <item>Dispose every supervisor to release sockets, channels, and watchdog timers.</item>
@@ -277,16 +272,15 @@ internal sealed partial class ProxyWorker : BackgroundService
/// </summary> /// </summary>
public override async Task StopAsync(CancellationToken cancellationToken) public override async Task StopAsync(CancellationToken cancellationToken)
{ {
// Phase 12 (W5 / m2) — snapshot in-flight BEFORE base.StopAsync so the field // Snapshot in-flight BEFORE base.StopAsync so the field matches its name: "the
// matches its name: "the count at the moment the host signalled stop", not "the // count at the moment the host signalled stop", not "the count at the moment we
// count at the moment we got around to computing it." `base.StopAsync` cancels the // got around to computing it." `base.StopAsync` cancels the ExecuteAsync
// ExecuteAsync stoppingToken; in the milliseconds before it returns, in-flight // stoppingToken; in the milliseconds before it returns, in-flight requests
// requests whose responses arrive will be removed from _correlation and the // whose responses arrive will be removed from _correlation and the watchdog can
// watchdog can clear stale entries — the count would otherwise drift downward. // clear stale entries — the count would otherwise drift downward.
// //
// Phase 12 (W4 / NC1) — must run BEFORE supervisor stop too: after // Must run BEFORE supervisor stop too: after supervisor.StopAsync, multiplexers
// supervisor.StopAsync, multiplexers are disposed and CountInFlight returns 0 // are disposed and CountInFlight returns 0 unconditionally.
// unconditionally (the original ShutdownCoordinator had the same defect).
int inFlightAtCancel = CountInFlight(); int inFlightAtCancel = CountInFlight();
// Cancel ExecuteAsync first. // Cancel ExecuteAsync first.
@@ -294,9 +288,9 @@ internal sealed partial class ProxyWorker : BackgroundService
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
// Phase 12 (W2.20) — supervisor stop deadline read from the live config so a // Supervisor stop deadline read from the live config so a hot-reloaded
// hot-reloaded GracefulShutdownTimeoutMs is honoured. Supervisor stop is the // GracefulShutdownTimeoutMs is honoured. Supervisor stop is the drain:
// drain: cancelling the supervisor cancels the listener, which exits accept, which // cancelling the supervisor cancels the listener, which exits accept, which
// disposes the multiplexer, which cascades all attached pipes. // disposes the multiplexer, which cascades all attached pipes.
int gracefulMs = _options.CurrentValue.Connection.GracefulShutdownTimeoutMs; int gracefulMs = _options.CurrentValue.Connection.GracefulShutdownTimeoutMs;
@@ -352,11 +346,11 @@ internal sealed partial class ProxyWorker : BackgroundService
// ── Logging ─────────────────────────────────────────────────────────────────────────── // ── Logging ───────────────────────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Phase 11 — returns <c>true</c> when at least one BcdTag in the resolved map has a /// Returns <c>true</c> when at least one BcdTag in the resolved map has a positive
/// positive <see cref="BcdTag.CacheTtlMs"/>. A PLC with no cacheable tags skips the /// <see cref="BcdTag.CacheTtlMs"/>. A PLC with no cacheable tags skips the
/// <see cref="Mbproxy.Proxy.Cache.ResponseCache"/> entirely (no eviction timer, no /// <see cref="Mbproxy.Proxy.Cache.ResponseCache"/> entirely (no eviction timer, no
/// per-call cache resolution cost), so the default-OFF deployment is byte-identical /// per-call cache resolution cost), so the default-OFF deployment runs the
/// to a Phase-10 deployment. /// no-cache code path.
/// </summary> /// </summary>
private static bool HasAnyCacheableTag(BcdTagMap map) private static bool HasAnyCacheableTag(BcdTagMap map)
{ {
@@ -375,7 +369,6 @@ internal sealed partial class ProxyWorker : BackgroundService
Message = "Failed to bind listener: Plc={Plc} Port={Port} Reason={Reason}")] Message = "Failed to bind listener: Plc={Plc} Port={Port} Reason={Reason}")]
private static partial void LogBindFailed(ILogger logger, string plc, int port, string reason); private static partial void LogBindFailed(ILogger logger, string plc, int port, string reason);
// Phase 12 (W1.5) — moved here from the deleted ShutdownCoordinator.
[LoggerMessage(EventId = 80, EventName = "mbproxy.shutdown.complete", [LoggerMessage(EventId = 80, EventName = "mbproxy.shutdown.complete",
Level = LogLevel.Information, Level = LogLevel.Information,
Message = "Graceful shutdown complete: InFlightAtCancel={InFlightAtCancel} ElapsedMs={ElapsedMs}")] Message = "Graceful shutdown complete: InFlightAtCancel={InFlightAtCancel} ElapsedMs={ElapsedMs}")]
@@ -46,15 +46,15 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
private volatile string? _lastBindError; private volatile string? _lastBindError;
private int _recoveryAttempts; // Interlocked private int _recoveryAttempts; // Interlocked
// Phase 07: current active listener for status-page pair enumeration. // Current active listener for status-page pair enumeration.
private volatile PlcListener? _currentListener; private volatile PlcListener? _currentListener;
// Phase 06: _perPlcContext is now mutable so ReplaceContextAsync can swap it. // _perPlcContext is mutable so ReplaceContextAsync can swap it. Access from the accept
// Access from the accept loop (RunAsync) and from ReplaceContextAsync must be // loop (RunAsync) and from ReplaceContextAsync must be coherent; we use a volatile
// coherent; we use a volatile reference so the accept loop always reads the latest // reference so the accept loop always reads the latest context without locking. The
// context without locking. The PlcListener created on each Polly attempt holds // PlcListener created on each Polly attempt holds its own copy of the context at
// its own copy of the context at construction time; existing in-flight connections // construction time; existing in-flight connections keep their old reference until they
// keep their old reference until they complete. // complete.
private volatile PerPlcContext? _currentContext; private volatile PerPlcContext? _currentContext;
/// <summary> /// <summary>
@@ -67,16 +67,15 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
private bool _disposed; private bool _disposed;
// Phase 12 (W2.15) — completes when the supervisor has transitioned out of Stopped // Completes when the supervisor has transitioned out of Stopped for the first time
// for the first time (reached Bound or Recovering). Replaces the previous busy-poll // (reached Bound or Recovering). Used by WaitForInitialBindAttemptAsync to avoid
// implementation in WaitForInitialBindAttemptAsync, which raced fast Stopped→Bound→ // racing fast Stopped→Bound→Stopped transitions or hanging if the supervisor task
// Stopped transitions and never exited if the supervisor task threw inside Polly. // throws inside Polly.
// //
// Phase 12 (W4 / NM4) — non-readonly so StartAsync can re-arm it for a re-Started // Non-readonly so StartAsync can re-arm it for a re-Started supervisor. Without
// supervisor. Without re-arming, a restart-after-stop scenario would have // re-arming, a restart-after-stop scenario would have WaitForInitialBindAttemptAsync
// WaitForInitialBindAttemptAsync return immediately on the previous run's signal, // return immediately on the previous run's signal, never observing the new run's
// never observing the new run's bind status. No production caller currently re-Starts, // bind status.
// but the supervisor's state machine should be consistent.
private TaskCompletionSource _firstAttemptCompleted = new( private TaskCompletionSource _firstAttemptCompleted = new(
TaskCreationOptions.RunContinuationsAsynchronously); TaskCreationOptions.RunContinuationsAsynchronously);
@@ -104,7 +103,7 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
_multiplexerLogger = multiplexerLogger; _multiplexerLogger = multiplexerLogger;
_pipeLogger = pipeLogger; _pipeLogger = pipeLogger;
_perPlcContext = perPlcContext; _perPlcContext = perPlcContext;
_currentContext = perPlcContext; // Phase 06: live context slot _currentContext = perPlcContext; // live context slot
_recoveryPipeline = recoveryPipeline; _recoveryPipeline = recoveryPipeline;
_logger = logger; _logger = logger;
_backendConnectPipeline = backendConnectPipeline; _backendConnectPipeline = backendConnectPipeline;
@@ -121,7 +120,7 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
/// <summary> /// <summary>
/// Live collection of active <see cref="UpstreamPipe"/> instances attached to this /// Live collection of active <see cref="UpstreamPipe"/> instances attached to this
/// PLC's multiplexer. Returns an empty collection when the listener is not bound. /// PLC's multiplexer. Returns an empty collection when the listener is not bound.
/// Consumed by Phase 07's status page (renamed from <c>ActivePairs</c> in Phase 9). /// Consumed by the status page.
/// </summary> /// </summary>
public IReadOnlyCollection<UpstreamPipe> ActiveUpstreams public IReadOnlyCollection<UpstreamPipe> ActiveUpstreams
=> _currentListener?.ActiveUpstreams ?? Array.Empty<UpstreamPipe>(); => _currentListener?.ActiveUpstreams ?? Array.Empty<UpstreamPipe>();
@@ -137,26 +136,25 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
/// </summary> /// </summary>
public Task StartAsync(CancellationToken ct) public Task StartAsync(CancellationToken ct)
{ {
// Phase 12 (W2.16) — refuse to re-Start an already-running or already-disposed // Refuse to re-Start an already-running or already-disposed supervisor. After
// supervisor. After Stop the state machine returns to Stopped and StartAsync // Stop the state machine returns to Stopped and StartAsync can re-arm; the per-
// can re-arm; W4/NM3+NM4 below ensure the per-Start state (CTS, TCS) is fresh // Start state (CTS, TCS) is refreshed below so no leak or stale signal carries
// each time so no leak or stale signal carries across cycles. // across cycles.
if (_disposed) if (_disposed)
throw new ObjectDisposedException(nameof(PlcListenerSupervisor)); throw new ObjectDisposedException(nameof(PlcListenerSupervisor));
if (_state != SupervisorState.Stopped || !_supervisorTask.IsCompleted) if (_state != SupervisorState.Stopped || !_supervisorTask.IsCompleted)
throw new InvalidOperationException( throw new InvalidOperationException(
$"Supervisor for Plc='{_plc.Name}' has already been started."); $"Supervisor for Plc='{_plc.Name}' has already been started.");
// Phase 12 (W4 / NM3) — dispose the previous CTS before reassigning. The original // Dispose the previous CTS before reassigning so a re-Start cycle does not leak
// code overwrote _supervisorCts unconditionally, leaking the prior CTS on every // the prior CTS (and any registrations linked to it). Idempotent: the
// re-Start cycle (and any registrations linked to it). Idempotent: ObjectDisposed // ObjectDisposed catch covers the very-first-Start case where the field-init CTS
// catch covers the very-first-Start case where the field-init CTS is still fresh. // is still fresh.
try { _supervisorCts.Dispose(); } catch (ObjectDisposedException) { /* fresh */ } try { _supervisorCts.Dispose(); } catch (ObjectDisposedException) { /* fresh */ }
_supervisorCts = CancellationTokenSource.CreateLinkedTokenSource(ct); _supervisorCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Phase 12 (W4 / NM4) — re-arm the first-attempt TCS so a re-Started supervisor // Re-arm the first-attempt TCS so a re-Started supervisor doesn't immediately
// doesn't immediately observe the previous run's signal in // observe the previous run's signal in WaitForInitialBindAttemptAsync.
// WaitForInitialBindAttemptAsync.
_firstAttemptCompleted = new TaskCompletionSource( _firstAttemptCompleted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously); TaskCreationOptions.RunContinuationsAsynchronously);
@@ -170,10 +168,10 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
/// <see cref="SupervisorState.Recovering"/>). /// <see cref="SupervisorState.Recovering"/>).
/// Returns immediately if the supervisor is already past that point. /// Returns immediately if the supervisor is already past that point.
/// ///
/// <para><b>Phase 12 (W2.15)</b> — backed by a <see cref="TaskCompletionSource"/> set /// <para>Backed by a <see cref="TaskCompletionSource"/> set when the supervisor task
/// when the supervisor task first transitions out of <see cref="SupervisorState.Stopped"/>. /// first transitions out of <see cref="SupervisorState.Stopped"/>. This avoids both
/// Replaces the previous 10 ms busy-poll which raced fast bind+stop sequences and could /// racing fast bind+stop sequences and hanging if the supervisor task throws before
/// hang if the supervisor task threw before any state write happened.</para> /// any state write happens.</para>
/// </summary> /// </summary>
public async Task WaitForInitialBindAttemptAsync(CancellationToken ct) public async Task WaitForInitialBindAttemptAsync(CancellationToken ct)
{ {
@@ -184,7 +182,7 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
// Caller cancelled; not a fault — same observable behaviour as the prior poll. // Caller cancelled; not a fault.
} }
} }
@@ -221,8 +219,8 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
/// <summary> /// <summary>
/// Returns a point-in-time snapshot of this supervisor's state. /// Returns a point-in-time snapshot of this supervisor's state.
/// ///
/// <para><b>Phase 12 (W2.17)</b> — reads the three observable fields under a single /// <para>Reads the three observable fields under a single lock so the status page
/// lock so the status page can never report inconsistent triples like /// can never report inconsistent triples like
/// <c>(State=Bound, LastBindError=&lt;previous&gt;, RecoveryAttempts&gt;0)</c>. The /// <c>(State=Bound, LastBindError=&lt;previous&gt;, RecoveryAttempts&gt;0)</c>. The
/// supervisor task uses <see cref="TransitionTo"/> which takes the same lock, so a /// supervisor task uses <see cref="TransitionTo"/> which takes the same lock, so a
/// snapshot reads a transition-consistent view.</para> /// snapshot reads a transition-consistent view.</para>
@@ -241,9 +239,9 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
private readonly object _snapshotLock = new(); private readonly object _snapshotLock = new();
/// <summary> /// <summary>
/// Phase 12 (W2.17) — atomic three-field transition. State, lastBindError, and /// Atomic three-field transition. State, lastBindError, and (optionally) the
/// (optionally) the recoveryAttempts increment all happen under one lock so a /// recoveryAttempts increment all happen under one lock so a concurrent
/// concurrent <see cref="Snapshot"/> never sees a half-applied transition. /// <see cref="Snapshot"/> never sees a half-applied transition.
/// </summary> /// </summary>
private void TransitionTo(SupervisorState newState, string? lastBindError, bool incrementRecoveryAttempt) private void TransitionTo(SupervisorState newState, string? lastBindError, bool incrementRecoveryAttempt)
{ {
@@ -258,15 +256,10 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
/// <summary> /// <summary>
/// Atomically swaps the per-PLC context (tag map + optional response cache) on the /// Atomically swaps the per-PLC context (tag map + optional response cache) on the
/// running listener AND its live multiplexer. /// running listener AND its live multiplexer. The swap propagates into the running
/// /// mux via <see cref="PlcMultiplexer.ReplaceContext"/>, so the very next PDU sees
/// <para><b>Phase 12 (W1.1)</b> — previously this method only updated the supervisor's /// the new tag map / new cache. Counters are preserved (the new context carries the
/// <c>_currentContext</c> slot, which meant the running <see cref="PlcMultiplexer"/> /// same <c>ProxyCounters</c> instance) so operator history is not reset.
/// kept using the OLD context (it captured the reference at construction). A reload
/// only became visible on the next listener fault. Now the swap propagates into the
/// running mux via <see cref="PlcMultiplexer.ReplaceContext"/>, so the very next PDU
/// sees the new tag map / new cache. Counters are preserved (the new context carries
/// the same <c>ProxyCounters</c> instance) so operator history is not reset.</para>
/// ///
/// <para><b>Old cache lifecycle</b>: the supervisor disposes the outgoing context's /// <para><b>Old cache lifecycle</b>: the supervisor disposes the outgoing context's
/// cache AFTER the multiplexer has been swapped to the new context. By that point no /// cache AFTER the multiplexer has been swapped to the new context. By that point no
@@ -281,16 +274,16 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
// subsequent fault recovery) will pick up newCtx through this slot. // subsequent fault recovery) will pick up newCtx through this slot.
_currentContext = newCtx; _currentContext = newCtx;
// Phase 12 (W1.1) — push the swap into the running multiplexer so existing // Push the swap into the running multiplexer so existing connections see the new
// connections see the new tag map / new cache on their next PDU. _currentListener // tag map / new cache on their next PDU. _currentListener may be null between
// may be null between Polly retry attempts; in that case the next listener built // Polly retry attempts; in that case the next listener built inside the Polly loop
// inside the Polly loop will pick up newCtx through _currentContext above. // will pick up newCtx through _currentContext above.
_currentListener?.Multiplexer?.ReplaceContext(newCtx); _currentListener?.Multiplexer?.ReplaceContext(newCtx);
// Phase 12 (W1.1 + W2.8) — drop the outgoing cache AFTER the swap so the running // Drop the outgoing cache AFTER the swap so the running multiplexer can no longer
// multiplexer can no longer reach it. Clear() snapshots the entry count for the // reach it. Clear() snapshots the entry count for the mbproxy.cache.flushed log
// mbproxy.cache.flushed log event before disposing the cache (which stops the // event before disposing the cache (which stops the eviction loop and releases
// eviction loop and releases the timer). // the timer).
if (oldCache is not null && !ReferenceEquals(oldCache, newCtx.Cache)) if (oldCache is not null && !ReferenceEquals(oldCache, newCtx.Cache))
{ {
int dropped = oldCache.Clear(); int dropped = oldCache.Clear();
@@ -318,11 +311,11 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
// A faulted listener's TcpListener socket must be disposed before // A faulted listener's TcpListener socket must be disposed before
// re-binding. We create a new PlcListener on each attempt. // re-binding. We create a new PlcListener on each attempt.
// //
// Phase 06: use _currentContext (volatile) so that a ReplaceContextAsync // Use _currentContext (volatile) so that a ReplaceContextAsync call
// call between Polly retry attempts is picked up here. Each listener // between Polly retry attempts is picked up here. Each listener captures
// captures the context at construction time; existing in-flight pairs // the context at construction time; existing in-flight pairs keep their
// keep their own reference. See ReplaceContextAsync for the transition // own reference. See ReplaceContextAsync for the transition window
// window documentation. // documentation.
var listener = new PlcListener( var listener = new PlcListener(
_plc, _plc,
_connectionOptions, _connectionOptions,
@@ -334,7 +327,7 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
_backendConnectPipeline, _backendConnectPipeline,
_coalescingOptions); _coalescingOptions);
// Phase 07: expose the current listener for status-page pair enumeration. // Expose the current listener for status-page pair enumeration.
_currentListener = listener; _currentListener = listener;
try try
@@ -351,10 +344,10 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
string truncated = Truncate(bindEx.Message, 256); string truncated = Truncate(bindEx.Message, 256);
TransitionTo(SupervisorState.Recovering, truncated, incrementRecoveryAttempt: true); TransitionTo(SupervisorState.Recovering, truncated, incrementRecoveryAttempt: true);
// Phase 12 (W2.15) — signal the first transition out of Stopped. // Signal the first transition out of Stopped.
_firstAttemptCompleted.TrySetResult(); _firstAttemptCompleted.TrySetResult();
// Also update the per-PLC counters if available (Phase 07 reads these). // Also update the per-PLC counters if available (status page reads these).
_currentContext?.Counters.IncrementRecoveryAttempt(truncated); _currentContext?.Counters.IncrementRecoveryAttempt(truncated);
LogBindFailed(_logger, _plc.Name, _plc.ListenPort, truncated); LogBindFailed(_logger, _plc.Name, _plc.ListenPort, truncated);
@@ -379,7 +372,7 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
// Clear the last bind error on a successful bind. // Clear the last bind error on a successful bind.
TransitionTo(SupervisorState.Bound, lastBindError: null, incrementRecoveryAttempt: false); TransitionTo(SupervisorState.Bound, lastBindError: null, incrementRecoveryAttempt: false);
_currentContext?.Counters.ClearLastBindError(); _currentContext?.Counters.ClearLastBindError();
// Phase 12 (W2.15) — signal the first transition out of Stopped. // Signal the first transition out of Stopped.
_firstAttemptCompleted.TrySetResult(); _firstAttemptCompleted.TrySetResult();
// ── Run the accept loop ────────────────────────────────────────── // ── Run the accept loop ──────────────────────────────────────────
@@ -407,9 +400,8 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
string truncated = Truncate(runEx.Message, 256); string truncated = Truncate(runEx.Message, 256);
TransitionTo(SupervisorState.Recovering, truncated, incrementRecoveryAttempt: true); TransitionTo(SupervisorState.Recovering, truncated, incrementRecoveryAttempt: true);
// Phase 12 (W2.15) — also signal first-attempt-completed in case the // Also signal first-attempt-completed in case the very first
// very first listener.RunAsync faulted before the bind-success path // listener.RunAsync faulted before the bind-success path signalled it.
// signalled it.
_firstAttemptCompleted.TrySetResult(); _firstAttemptCompleted.TrySetResult();
// Also update the per-PLC counters if available. // Also update the per-PLC counters if available.
@@ -457,16 +449,16 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
_state = SupervisorState.Stopped; _state = SupervisorState.Stopped;
} }
_currentListener = null; _currentListener = null;
// Phase 12 (W2.15) — defensive: if RunSupervisorAsync exits before any bind // Defensive: if RunSupervisorAsync exits before any bind attempt fired
// attempt fired (e.g. construction-time fault), unblock any awaiting // (e.g. construction-time fault), unblock any awaiting
// WaitForInitialBindAttemptAsync caller so it doesn't hang. // WaitForInitialBindAttemptAsync caller so it doesn't hang.
_firstAttemptCompleted.TrySetResult(); _firstAttemptCompleted.TrySetResult();
} }
} }
/// <summary> /// <summary>
/// Phase 12 (W2 cleanup) — single helper for the truncate-exception-message pattern /// Single helper for the truncate-exception-message pattern shared across the
/// previously copy-pasted across three call sites. /// supervisor's bind/run/end recovery paths.
/// </summary> /// </summary>
private static string Truncate(string s, int max) => s.Length > max ? s[..max] : s; private static string Truncate(string s, int max) => s.Length > max ? s[..max] : s;
@@ -487,8 +479,8 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
// Best-effort cleanup. // Best-effort cleanup.
} }
// Phase 11: dispose the response cache (if any) — its eviction timer would // Dispose the response cache (if any) — its eviction timer would otherwise
// otherwise outlive the supervisor. // outlive the supervisor.
_currentContext?.Cache?.Dispose(); _currentContext?.Cache?.Dispose();
_supervisorCts.Dispose(); _supervisorCts.Dispose();
@@ -26,14 +26,14 @@ public enum SupervisorState
} }
/// <summary> /// <summary>
/// Immutable point-in-time snapshot of a supervisor's state. Consumed by Phase 07's /// Immutable point-in-time snapshot of a supervisor's state. Consumed by the status
/// status page via <see cref="PlcListenerSupervisor.Snapshot"/>. /// page via <see cref="PlcListenerSupervisor.Snapshot"/>.
/// ///
/// <para><b>RecoveryAttempts semantics</b>: this counter <em>accumulates over the lifetime /// <para><b>RecoveryAttempts semantics</b>: this counter <em>accumulates over the lifetime
/// of the supervisor</em> and is never reset. Operators reading the status page should /// of the supervisor</em> and is never reset. Operators reading the status page should
/// interpret it as "how many times has this listener faulted or failed to bind since /// interpret it as "how many times has this listener faulted or failed to bind since
/// the service started" — useful for detecting port-flapping or repeated OS network /// the service started" — useful for detecting port-flapping or repeated OS network
/// resets. Phase 07 surfaces it as-is.</para> /// resets.</para>
/// </summary> /// </summary>
/// <param name="State">Current state of the supervisor.</param> /// <param name="State">Current state of the supervisor.</param>
/// <param name="LastBindError"> /// <param name="LastBindError">
+1 -1
View File
@@ -2,7 +2,7 @@ namespace Mbproxy;
/// <summary> /// <summary>
/// Service-wide counters for the mbproxy host. Tracks reload accept/reject counts and /// Service-wide counters for the mbproxy host. Tracks reload accept/reject counts and
/// timestamps so Phase 07's status page can surface them without coupling to the reconciler. /// timestamps so the status page can surface them without coupling to the reconciler.
/// ///
/// <para>Constructed once at DI startup and shared as a singleton. All writes are via /// <para>Constructed once at DI startup and shared as a singleton. All writes are via
/// dedicated methods that use <see cref="Interlocked"/> so reads from the status page /// dedicated methods that use <see cref="Interlocked"/> so reads from the status page
@@ -330,10 +330,10 @@ public sealed class AdminEndpointTests
System.IO.File.Move(tmp, path, overwrite: true); System.IO.File.Move(tmp, path, overwrite: true);
} }
// ── Phase 12 (W3 test gap) — non-GET methods rejected ────────────────── // ── non-GET methods rejected ─────────────────────────────────────────
/// <summary> /// <summary>
/// W3 — verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE) /// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
/// with HTTP 405 Method Not Allowed. The design intentionally exposes only `GET /` /// with HTTP 405 Method Not Allowed. The design intentionally exposes only `GET /`
/// and `GET /status.json`; this test guards against an accidental MapPost/Map* being /// and `GET /status.json`; this test guards against an accidental MapPost/Map* being
/// added later. /// added later.
@@ -10,8 +10,8 @@ namespace Mbproxy.Tests.Bcd;
/// NOTE on allocation profile: /// NOTE on allocation profile:
/// BcdCodec is a purely static class operating on value types (ushort, int, tuples). /// BcdCodec is a purely static class operating on value types (ushort, int, tuples).
/// It allocates only when constructing exception objects (the error path), never on /// It allocates only when constructing exception objects (the error path), never on
/// the success path. TryGet / hot-path decode callers in Phase 04 will be /// the success path. TryGet / hot-path decode callers are allocation-free for valid
/// allocation-free for valid BCD registers. /// BCD registers.
/// </summary> /// </summary>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
public sealed class BcdCodecTests public sealed class BcdCodecTests
@@ -45,8 +45,8 @@ public sealed class BcdCodecTests
} }
/// <summary> /// <summary>
/// Phase 12 (W3 test gap #11) — locks the boundary contract for the `(uint)value > Max16` /// Locks the boundary contract for the `(uint)value > Max16` range check.
/// range check. `int.MinValue` cast to `uint` becomes `0x80000000`, which is well above /// `int.MinValue` cast to `uint` becomes `0x80000000`, which is well above
/// `Max16` (= 9999), so the throw fires cleanly without arithmetic surprise. Prevents /// `Max16` (= 9999), so the throw fires cleanly without arithmetic surprise. Prevents
/// regressions if the bounds check is ever rewritten with a two-sided int comparison /// regressions if the bounds check is ever rewritten with a two-sided int comparison
/// that would underflow on extreme negatives. /// that would underflow on extreme negatives.
@@ -99,9 +99,9 @@ public sealed class BcdTagMapBuilderTests
} }
/// <summary> /// <summary>
/// Phase 12 (W2.11) — duplicates within Global itself are now detected /// Duplicates within Global itself are detected pre-collapse and produce a
/// pre-collapse and produce a DuplicateAddress error. (Before W2.11 the input /// DuplicateAddress error. (A naive input dictionary would silently collapse
/// dictionary silently collapsed to last-write-wins, leaving the validator dead.) /// to last-write-wins, leaving the validator dead.)
/// </summary> /// </summary>
[Fact] [Fact]
public void Build_DuplicateAddressInGlobal_ReturnsDuplicateAddressError() public void Build_DuplicateAddressInGlobal_ReturnsDuplicateAddressError()
@@ -122,9 +122,9 @@ public sealed class BcdTagMapBuilderTests
} }
/// <summary> /// <summary>
/// Phase 12 (W2.11) — duplicates within the per-PLC Add list itself are now detected /// Duplicates within the per-PLC Add list itself are detected pre-collapse.
/// pre-collapse. (Cross-list collisions Global vs Add remain the legitimate width- /// (Cross-list collisions Global vs Add remain the legitimate width-override
/// override pattern and are NOT errors — see the next test.) /// pattern and are NOT errors — see the next test.)
/// </summary> /// </summary>
[Fact] [Fact]
public void Build_DuplicateAddress_Within_AddList_ReturnsDuplicateAddressError() public void Build_DuplicateAddress_Within_AddList_ReturnsDuplicateAddressError()
@@ -147,9 +147,9 @@ public sealed class BcdTagMapBuilderTests
} }
/// <summary> /// <summary>
/// Phase 12 (W2.11) — same-address entries appearing in BOTH Global AND Add are /// Same-address entries appearing in BOTH Global AND Add are the documented
/// the documented width-override pattern (design.md "Hybrid tag resolution"). They /// width-override pattern (design.md "Hybrid tag resolution"). They must NOT
/// must NOT be flagged as duplicates; Add wins. /// be flagged as duplicates; Add wins.
/// </summary> /// </summary>
[Fact] [Fact]
public void Build_AddOverridesGlobalAtSameAddress_NoDuplicateError_AddWins() public void Build_AddOverridesGlobalAtSameAddress_NoDuplicateError_AddWins()
@@ -282,12 +282,12 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
} }
/// <summary> /// <summary>
/// Phase 12 (W3 test gap #16) — stress-test the W2.3 ConcurrentDictionary fix and the /// Stress-tests the live supervisor dictionary and the coalescing-accessor wiring.
/// W2.1 coalescing-accessor wiring. Many concurrent Apply calls drive add/remove of /// Many concurrent Apply calls drive add/remove of many distinct PLCs; the inner
/// many distinct PLCs; without W2.3's ConcurrentDictionary the inner Task.WhenAll /// Task.WhenAll continuations must not corrupt the dictionary or crash with
/// continuations would corrupt the dictionary and crash with KeyNotFoundException or /// KeyNotFoundException or ArgumentException. The test asserts: all applies
/// ArgumentException. The test asserts: all applies complete, no exceptions are /// complete, no exceptions are thrown, and the reload counter is exactly the
/// thrown, and the reload counter is exactly the apply count. /// apply count.
/// </summary> /// </summary>
[Fact(Timeout = 30_000)] [Fact(Timeout = 30_000)]
public async Task Apply_ManyConcurrentReloads_With_PlcChurn_NoCorruption() public async Task Apply_ManyConcurrentReloads_With_PlcChurn_NoCorruption()
@@ -305,7 +305,7 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
// Build 8 different option snapshots, each a different PLC roster. // Build 8 different option snapshots, each a different PLC roster.
// Each Apply will trigger Add+Remove churn against the live supervisor dict — // Each Apply will trigger Add+Remove churn against the live supervisor dict —
// exactly the path that W2.3's ConcurrentDictionary was needed for. // exactly the path that the ConcurrentDictionary guards against corruption.
const int snapshots = 8; const int snapshots = 8;
const int plcsPerSnapshot = 4; const int plcsPerSnapshot = 4;
var snaps = new MbproxyOptions[snapshots]; var snaps = new MbproxyOptions[snapshots];
@@ -324,8 +324,8 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(25)); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(25));
// Fire 16 concurrent applies cycling through the 8 snapshots so each is // Fire 16 concurrent applies cycling through the 8 snapshots so each is
// submitted twice. Inner per-PLC Task.WhenAll continuations from W2.3 will run // submitted twice. Inner per-PLC Task.WhenAll continuations run in parallel
// in parallel and stress-test the dictionary mutation safety. // and stress-test the dictionary mutation safety.
var tasks = Enumerable.Range(0, 16) var tasks = Enumerable.Range(0, 16)
.Select(i => Task.Run(() => reconciler.ApplyAsync(snaps[i % snapshots], cts.Token), cts.Token)) .Select(i => Task.Run(() => reconciler.ApplyAsync(snaps[i % snapshots], cts.Token), cts.Token))
.ToArray(); .ToArray();
@@ -320,10 +320,10 @@ public sealed class HotReloadE2ETests : IAsyncLifetime
await host.StopAsync(stopCts.Token); await host.StopAsync(stopCts.Token);
} }
// ── Phase 12 (W3 test gap) — cache flush on tag-list reload ───────────────────────── // ── cache flush on tag-list reload ──────────────────────────────────────────────────
/// <summary> /// <summary>
/// W2.8 / W3 — verifies that a tag-list reload for a PLC with a cacheable tag emits /// Verifies that a tag-list reload for a PLC with a cacheable tag emits
/// <c>mbproxy.cache.flushed</c>. The cache count is 0 (no real backend to populate /// <c>mbproxy.cache.flushed</c>. The cache count is 0 (no real backend to populate
/// it), but the event must still fire — it's the operator's signal that the in-memory /// it), but the event must still fire — it's the operator's signal that the in-memory
/// cache state was reset by a config reload. /// cache state was reset by a config reload.
@@ -363,13 +363,13 @@ public sealed class HotReloadE2ETests : IAsyncLifetime
await host.StopAsync(stopCts.Token); await host.StopAsync(stopCts.Token);
} }
// ── Phase 12 (W3 test gap #10) — ReadCoalescing.Enabled hot-reload flip ───────────── // ── ReadCoalescing.Enabled hot-reload flip ──────────────────────────────────────────
/// <summary> /// <summary>
/// W3 — verifies that flipping <c>Mbproxy.Resilience.ReadCoalescing.Enabled</c> at /// Verifies that flipping <c>Mbproxy.Resilience.ReadCoalescing.Enabled</c> at
/// runtime via hot-reload propagates to the live <see cref="IOptionsMonitor{T}"/> /// runtime via hot-reload propagates to the live <see cref="IOptionsMonitor{T}"/>
/// snapshot. The W2.1 fix wires the accessor through to add/restart supervisors; /// snapshot. The accessor is wired through to add/restart supervisors; the
/// the multiplexer reads it per-PDU. Proving the IOptionsMonitor sees the new value /// multiplexer reads it per-PDU. Proving the IOptionsMonitor sees the new value
/// is sufficient — the per-PDU read path is unit-tested at the multiplexer level. /// is sufficient — the per-PDU read path is unit-tested at the multiplexer level.
/// </summary> /// </summary>
[Fact(Timeout = 8_000)] [Fact(Timeout = 8_000)]
@@ -156,10 +156,10 @@ public sealed class ReloadValidatorTests
Assert.Contains(errors, e => e.Contains("non-empty")); Assert.Contains(errors, e => e.Contains("non-empty"));
} }
// ── Phase 12 (W2.10) — Cache.AllowLongTtl gate ────────────────────────────────────── // ── Cache.AllowLongTtl gate ─────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// W2 — per-tag CacheTtlMs > 60_000 without Cache.AllowLongTtl is rejected. /// Per-tag CacheTtlMs > 60_000 without Cache.AllowLongTtl is rejected.
/// </summary> /// </summary>
[Fact] [Fact]
public void Validate_PerTagCacheTtl_Above60s_Without_AllowLongTtl_Fails() public void Validate_PerTagCacheTtl_Above60s_Without_AllowLongTtl_Fails()
@@ -181,7 +181,7 @@ public sealed class ReloadValidatorTests
} }
/// <summary> /// <summary>
/// W2 — same value passes when AllowLongTtl is true (operator opt-in). /// Same value passes when AllowLongTtl is true (operator opt-in).
/// </summary> /// </summary>
[Fact] [Fact]
public void Validate_PerTagCacheTtl_Above60s_With_AllowLongTtl_Passes() public void Validate_PerTagCacheTtl_Above60s_With_AllowLongTtl_Passes()
@@ -203,9 +203,9 @@ public sealed class ReloadValidatorTests
} }
/// <summary> /// <summary>
/// W2 — per-PLC DefaultCacheTtlMs > 60_000 inherited by a tag with null CacheTtlMs is /// Per-PLC DefaultCacheTtlMs > 60_000 inherited by a tag with null CacheTtlMs is
/// caught by the resolved-value check even if the per-PLC default check itself passes /// caught by the resolved-value check even if the per-PLC default check itself
/// (it doesn't, but this validates the defensive resolved re-check from W2.10). /// passes (it doesn't, but this validates the defensive resolved re-check).
/// </summary> /// </summary>
[Fact] [Fact]
public void Validate_ResolvedTtl_FromPerPlcDefault_AboveCap_Fails() public void Validate_ResolvedTtl_FromPerPlcDefault_AboveCap_Fails()
@@ -233,7 +233,7 @@ public sealed class ReloadValidatorTests
Assert.Contains(errors, e => e.Contains("60_000")); Assert.Contains(errors, e => e.Contains("60_000"));
} }
// ── Phase 12 (W2.18) — ConnectionOptions validation ───────────────────────────────── // ── ConnectionOptions validation ────────────────────────────────────────────────────
[Fact] [Fact]
public void Validate_ZeroBackendConnectTimeoutMs_Fails() public void Validate_ZeroBackendConnectTimeoutMs_Fails()
@@ -92,7 +92,7 @@ internal static class TestHostBuilderExtensions
builder.Services.AddSerilog(serilogLogger, dispose: false); builder.Services.AddSerilog(serilogLogger, dispose: false);
builder.AddMbproxyOptions(); builder.AddMbproxyOptions();
// Phase 03: register the no-op pipeline and ProxyWorker (replaces HeartbeatWorker). // Register the no-op pipeline and ProxyWorker.
builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>(); builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
builder.Services.AddHostedService<ProxyWorker>(); builder.Services.AddHostedService<ProxyWorker>();
@@ -1,7 +1,6 @@
<!-- xunit version: v3 (xunit.v3 3.2.2) — chosen because a stable release exists on NuGet as of 2026-05-13 --> <!-- xunit v3 (xunit.v3 3.2.2). NModbus 3.0.83 — small footprint, net10.0 compatibility, and
<!-- NModbus 3.0.83 — chosen for small footprint, net10.0 compatibility, and synchronous/async FC03/FC16 API synchronous/async FC03/FC16 API that maps directly to the Modbus PDU function codes used
that maps directly to the Modbus PDU function codes used in smoke and e2e tests. in smoke and e2e tests. -->
Added in Phase 01 as the Modbus TCP client for all simulator-backed tests. -->
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
@@ -16,14 +15,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<!-- xunit v3: stable as of 2026-05-13 -->
<PackageReference Include="xunit.v3" Version="3.2.2" /> <PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />
<!-- NModbus: Modbus TCP client for simulator smoke tests and e2e tests (Phase 01+) --> <!-- NModbus: Modbus TCP client for simulator smoke tests and e2e tests. -->
<PackageReference Include="NModbus" Version="3.0.83" /> <PackageReference Include="NModbus" Version="3.0.83" />
</ItemGroup> </ItemGroup>
@@ -31,13 +29,12 @@
<ProjectReference Include="..\..\src\Mbproxy\Mbproxy.csproj" /> <ProjectReference Include="..\..\src\Mbproxy\Mbproxy.csproj" />
</ItemGroup> </ItemGroup>
<!-- Phase 12 (W2.21) — the linked appsettings.json from Mbproxy.csproj propagates to <!-- The linked appsettings.json from Mbproxy.csproj propagates to the test bin via the
the test bin via the project reference. Tests build their own in-memory project reference. Tests build their own in-memory configurations and must not pick
configurations and must not pick up the shipped template's example PLCs. The up the shipped template's example PLCs. This target deletes the inherited file from
Target deletes the inherited file from both the build output AND the publish both the build output AND the publish payload so a `dotnet publish` against this
payload (W4 / T3 — adds Publish so a `dotnet publish` against this csproj for a csproj for a packaged self-test would not leak the template's example PLCs into the
packaged self-test would not leak the template's example PLCs into the published published bundle. -->
bundle). -->
<Target Name="RemoveInheritedAppsettings" AfterTargets="Build;Publish"> <Target Name="RemoveInheritedAppsettings" AfterTargets="Build;Publish">
<Delete Files="$(OutputPath)appsettings.json" Condition="Exists('$(OutputPath)appsettings.json')" /> <Delete Files="$(OutputPath)appsettings.json" Condition="Exists('$(OutputPath)appsettings.json')" />
<Delete Files="$(PublishDir)appsettings.json" Condition="'$(PublishDir)' != '' AND Exists('$(PublishDir)appsettings.json')" /> <Delete Files="$(PublishDir)appsettings.json" Condition="'$(PublishDir)' != '' AND Exists('$(PublishDir)appsettings.json')" />
@@ -41,9 +41,8 @@ public sealed class BcdPduPipelineTests
} }
/// <summary> /// <summary>
/// Phase 9: the rewriter consumes <see cref="PerPlcContext.CurrentRequest"/> rather /// The rewriter consumes <see cref="PerPlcContext.CurrentRequest"/>. Tests build a
/// than a per-pair last-request slot. Tests build a synthetic <see cref="InFlightRequest"/> /// synthetic <see cref="InFlightRequest"/> to drive response decoding.
/// to drive response decoding.
/// </summary> /// </summary>
private static InFlightRequest MakeInFlight(byte fc, ushort startAddress, ushort qty) private static InFlightRequest MakeInFlight(byte fc, ushort startAddress, ushort qty)
=> new( => new(
@@ -51,9 +50,8 @@ public sealed class BcdPduPipelineTests
Fc: fc, Fc: fc,
StartAddress: startAddress, StartAddress: startAddress,
Qty: qty, Qty: qty,
// Phase 9: always exactly one party. We don't have a real UpstreamPipe in // We don't have a real UpstreamPipe in pipeline unit tests; the rewriter
// pipeline unit tests; the rewriter never dereferences the party list, so a // never dereferences the party list, so an empty placeholder is safe.
// null-forgiving placeholder is safe.
InterestedParties: Array.Empty<InterestedParty>(), InterestedParties: Array.Empty<InterestedParty>(),
SentAtUtc: DateTimeOffset.UtcNow); SentAtUtc: DateTimeOffset.UtcNow);
@@ -107,9 +105,9 @@ public sealed class BcdPduPipelineTests
} }
/// <summary> /// <summary>
/// Simulate sending an FC03/04 request then reading the response. /// Simulate sending an FC03/04 request then reading the response. Builds an
/// Phase 9: builds an <see cref="InFlightRequest"/> matching the request and attaches /// <see cref="InFlightRequest"/> matching the request and attaches it to the
/// it to the response-call context (replacing the per-pair last-request slot). /// response-call context.
/// </summary> /// </summary>
private void SendRequestThenProcessResponse( private void SendRequestThenProcessResponse(
PerPlcContext ctx, PerPlcContext ctx,
@@ -361,11 +359,11 @@ public sealed class BcdPduPipelineTests
} }
/// <summary> /// <summary>
/// Phase 12 (W2.13) — a client writing a 32-bit BCD value where either word exceeds /// A client writing a 32-bit BCD value where either word exceeds 9999 must NOT be
/// 9999 must NOT be silently mutated by the `high*10000+low` reconstruction. Validation /// silently mutated by the `high*10000+low` reconstruction. Validation rejects the
/// rejects the slot, increments invalidBcdWarnings, and passes the raw bytes through. /// slot, increments invalidBcdWarnings, and passes the raw bytes through. (Otherwise
/// Without W2.13 the codec would accept e.g. (high=9999, low=9999) → 99_989_999 → /// the codec would accept e.g. (high=9999, low=9999) → 99_989_999 → re-encode as
/// re-encode as (high=9998, low=9999), silently losing 1 from the high word. /// (high=9998, low=9999), silently losing 1 from the high word.)
/// </summary> /// </summary>
[Fact] [Fact]
public void FC16_32Bit_ClientHighOrLowAbove9999_PassesThroughRaw_WithInvalidBcdWarning() public void FC16_32Bit_ClientHighOrLowAbove9999_PassesThroughRaw_WithInvalidBcdWarning()
@@ -383,10 +381,10 @@ public sealed class BcdPduPipelineTests
} }
/// <summary> /// <summary>
/// Phase 12 (W2.14) — a malformed FC16 request that claims qty=N but ships fewer than /// A malformed FC16 request that claims qty=N but ships fewer than 6+N*2 bytes must
/// 6+N*2 bytes must NOT be partially rewritten. Without W2.14 each individual slot's /// NOT be partially rewritten. Without the up-front length check, each individual
/// per-slot bounds check would skip the OOB slot, leaving early slots rewritten and late /// slot's per-slot bounds check would skip the OOB slot, leaving early slots rewritten
/// slots untouched (a half-rewritten request reaching the PLC). /// and late slots untouched (a half-rewritten request reaching the PLC).
/// </summary> /// </summary>
[Fact] [Fact]
public void FC16_TruncatedRegisterData_PassesThroughRaw_NoPartialRewrite() public void FC16_TruncatedRegisterData_PassesThroughRaw_NoPartialRewrite()
@@ -405,12 +403,11 @@ public sealed class BcdPduPipelineTests
} }
/// <summary> /// <summary>
/// Phase 12 (W3 test gap #15) — DL205/DL260 caps FC03/FC04 reads at qty=128 (above /// DL205/DL260 caps FC03/FC04 reads at qty=128 (above Modbus spec's 125; documented
/// Modbus spec's 125; documented in DL260/dl205.md). The proxy must NOT truncate the /// in DL260/dl205.md). The proxy must NOT truncate the qty field — a request with
/// qty field — a request with qty &gt; 128 at non-BCD addresses must pass through /// qty &gt; 128 at non-BCD addresses must pass through unchanged so the PLC's own
/// unchanged so the PLC's own validator returns exception 03 to the client. This is /// validator returns exception 03 to the client. This is the transparent-pass-through
/// the transparent-pass-through contract for FCs and addresses the rewriter doesn't /// contract for FCs and addresses the rewriter doesn't own.
/// own.
/// </summary> /// </summary>
[Fact] [Fact]
public void FC03_Request_QtyAbove128_AtNonBcdAddress_PassesThroughUnchanged() public void FC03_Request_QtyAbove128_AtNonBcdAddress_PassesThroughUnchanged()
@@ -426,10 +423,9 @@ public sealed class BcdPduPipelineTests
} }
/// <summary> /// <summary>
/// Phase 12 (W3 test gap) — symmetric inverse of the existing partial-overlap test: /// Symmetric inverse of the existing partial-overlap test: the write range starts ON
/// the write range starts ON the high register of a 32-bit pair (low word is BEFORE /// the high register of a 32-bit pair (low word is BEFORE the write range). Must also
/// the write range). Must also be passed through raw with a partial warning, not /// be passed through raw with a partial warning, not half-rewritten.
/// half-rewritten.
/// </summary> /// </summary>
[Fact] [Fact]
public void FC16_WriteStartsOnHighWord_Of32BitPair_PassesThroughRaw_WithPartialWarning() public void FC16_WriteStartsOnHighWord_Of32BitPair_PassesThroughRaw_WithPartialWarning()
@@ -448,10 +444,9 @@ public sealed class BcdPduPipelineTests
} }
/// <summary> /// <summary>
/// Phase 12 (W3 test gap) — mixed slots in a single FC03 read: a 16-bit BCD tag, a /// Mixed slots in a single FC03 read: a 16-bit BCD tag, a 32-bit BCD pair, and an
/// 32-bit BCD pair, and an unconfigured register. Each slot should be handled /// unconfigured register. Each slot should be handled independently — the 16-bit and
/// independently — the 16-bit and 32-bit rewritten, the unconfigured register passed /// 32-bit rewritten, the unconfigured register passed through unchanged.
/// through unchanged.
/// </summary> /// </summary>
[Fact] [Fact]
public void FC03_Mixed_16Bit_32Bit_AndNonBcd_InOneRead_OnlyConfiguredSlotsRewritten() public void FC03_Mixed_16Bit_32Bit_AndNonBcd_InOneRead_OnlyConfiguredSlotsRewritten()
@@ -488,9 +483,9 @@ public sealed class BcdPduPipelineTests
} }
/// <summary> /// <summary>
/// Phase 12 (W3 test gap) — FC16 response handling. The response carries no register /// FC16 response handling. The response carries no register values (just an echo of
/// values (just an echo of [fc][start][qty]) so the rewriter must pass it through /// [fc][start][qty]) so the rewriter must pass it through unchanged regardless of
/// unchanged regardless of tag-map content. /// tag-map content.
/// </summary> /// </summary>
[Fact] [Fact]
public void FC16_Response_PassesThroughUnchanged_RegardlessOfTagMap() public void FC16_Response_PassesThroughUnchanged_RegardlessOfTagMap()
@@ -15,18 +15,16 @@ using Xunit;
namespace Mbproxy.Tests.Proxy.Cache; namespace Mbproxy.Tests.Proxy.Cache;
/// <summary> /// <summary>
/// End-to-end coverage of the Phase-11 response cache against the pymodbus DL205 /// End-to-end coverage of the response cache against the pymodbus DL205 simulator.
/// simulator.
/// ///
/// <para><b>pymodbus 3.13 simulator quirk.</b> Like Phase 9 and Phase 10, these tests /// <para><b>pymodbus 3.13 simulator quirk.</b> These tests serialise reads in the
/// serialise reads in the simulator-backed cases. The Phase-11 cache's behavioural /// simulator-backed cases. The cache's behavioural guarantee (a TTL-bounded cache hit
/// guarantee (a TTL-bounded cache hit returns the cached value without backend traffic) /// returns the cached value without backend traffic) is independent of the simulator's
/// is independent of the simulator's known concurrent-MBAP-frame bug — sequential reads /// known concurrent-MBAP-frame bug — sequential reads keep the sim in single-PDU mode,
/// keep the sim in single-PDU mode, which is its known-good envelope.</para> /// which is its known-good envelope.</para>
/// ///
/// <para>The headline assertion lives here: 10 reads at 100 ms intervals with a 1 s TTL /// <para>The headline assertion lives here: 10 reads at 100 ms intervals with a 1 s TTL
/// must result in EXACTLY 1 backend round-trip. If this test fails, Phase 11 does not /// must result in EXACTLY 1 backend round-trip.</para>
/// ship — see <c>11-response-cache.md</c>.</para>
/// </summary> /// </summary>
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))] [Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
[Trait("Category", "E2E")] [Trait("Category", "E2E")]
@@ -153,8 +151,8 @@ public sealed class ResponseCacheE2ETests
/// <summary> /// <summary>
/// Mandatory regression. With no cache config anywhere (default deployment shape), /// Mandatory regression. With no cache config anywhere (default deployment shape),
/// behaviour must be byte-identical to Phase 10. Sequential reads through the same /// behaviour must be byte-identical to the non-cached path: sequential reads through
/// client produce one backend round-trip each — no elision. /// the same client produce one backend round-trip each — no elision.
/// </summary> /// </summary>
[Fact(Timeout = 5_000)] [Fact(Timeout = 5_000)]
public async Task Cache_DisabledByDefault_BehaviourIs_ByteIdenticalTo_Phase10() public async Task Cache_DisabledByDefault_BehaviourIs_ByteIdenticalTo_Phase10()
@@ -165,7 +163,7 @@ public sealed class ResponseCacheE2ETests
int adminPort = PickFreePort(); int adminPort = PickFreePort();
var config = MakeBaseConfig(proxyPort); var config = MakeBaseConfig(proxyPort);
config["Mbproxy:AdminPort"] = adminPort.ToString(); config["Mbproxy:AdminPort"] = adminPort.ToString();
// No Cache section, no CacheTtlMs on any tag — pure Phase-10 behaviour. // No Cache section, no CacheTtlMs on any tag — non-cached behaviour.
config["Mbproxy:BcdTags:Global:0:Address"] = "1072"; config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
config["Mbproxy:BcdTags:Global:0:Width"] = "16"; config["Mbproxy:BcdTags:Global:0:Width"] = "16";
@@ -465,7 +465,8 @@ public sealed class ResponseCacheMultiplexerTests
public async Task UncachedReads_BehaveIdentically_ToPhase10() public async Task UncachedReads_BehaveIdentically_ToPhase10()
{ {
// Regression guard: PerPlcContext with Cache = null must behave byte-identically // Regression guard: PerPlcContext with Cache = null must behave byte-identically
// to Phase 10 — every FC03 read produces a backend round-trip (coalescing aside). // to the non-cached path — every FC03 read produces a backend round-trip
// (coalescing aside).
int backendPort = PickFreePort(); int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort); await using var backend = new StubBackend(backendPort);
@@ -49,12 +49,12 @@ public sealed class MultiplexerE2ETests
// ── E2E 1: Five simultaneous upstream clients (connection-cap lift) ────────────── // ── E2E 1: Five simultaneous upstream clients (connection-cap lift) ──────────────
/// <summary> /// <summary>
/// Headline test for Phase 9: prove that the multiplexer accepts the 5th upstream /// Headline test: prove that the multiplexer accepts the 5th upstream client on the
/// client on the same proxy port — pre-Phase-9's 1:1 model would have failed at /// same proxy port — a 1:1 model would have failed at backend connect (H2-ECOM100
/// backend connect (H2-ECOM100 cap = 4). Each client's request is serialised behind /// cap = 4). Each client's request is serialised behind the previous client's response
/// the previous client's response so the pymodbus 3.13 simulator's concurrent-frame /// so the pymodbus 3.13 simulator's concurrent-frame bug never triggers; the
/// bug never triggers; the multiplexer's connection ceiling, not its under-concurrency /// multiplexer's connection ceiling, not its under-concurrency behaviour, is what
/// behaviour, is what this test proves. /// this test proves.
/// </summary> /// </summary>
[Fact(Timeout = 5_000)] [Fact(Timeout = 5_000)]
public async Task E2E_FiveSimultaneousClients_AllReadHR1072_AllGetDecoded_1234() public async Task E2E_FiveSimultaneousClients_AllReadHR1072_AllGetDecoded_1234()
@@ -82,8 +82,9 @@ public sealed class MultiplexerE2ETests
await using var hd = new AsyncHostDispose(host); await using var hd = new AsyncHostDispose(host);
await Task.Delay(200, TestContext.Current.CancellationToken); await Task.Delay(200, TestContext.Current.CancellationToken);
// Open five simultaneous TCP connections to the proxy first (each would have used // Open five simultaneous TCP connections to the proxy first (under a 1:1 model
// a dedicated backend socket pre-Phase-9, blowing through the 4-client cap). // each would have needed a dedicated backend socket, blowing through the
// 4-client cap).
var clients = new TcpClient[5]; var clients = new TcpClient[5];
try try
{ {
@@ -380,8 +380,8 @@ public sealed class PlcMultiplexerTests
try try
{ {
// Both clients use the same upstream TxId 0x0007 — the proxy must hand out // Both clients use the same upstream TxId 0x0007 — the proxy must hand out
// distinct proxy TxIds on the backend wire. Phase 10: reads target DIFFERENT // distinct proxy TxIds on the backend wire. Reads target DIFFERENT addresses
// addresses so coalescing does not fuse them into a single backend request. // so coalescing does not fuse them into a single backend request.
await c1.SendAsync(BuildFc03ReadFrame(0x0007, 0, 1), SocketFlags.None); await c1.SendAsync(BuildFc03ReadFrame(0x0007, 0, 1), SocketFlags.None);
await c2.SendAsync(BuildFc03ReadFrame(0x0007, 10, 1), SocketFlags.None); await c2.SendAsync(BuildFc03ReadFrame(0x0007, 10, 1), SocketFlags.None);
@@ -625,12 +625,12 @@ public sealed class PlcMultiplexerTests
} }
} }
// ── Phase 12 Wave-1 regression tests ────────────────────────────────────── // ── ReplaceContext live-swap regression tests ────────────────────────────────
/// <summary> /// <summary>
/// W1.1 — verifies that <see cref="PlcMultiplexer.ReplaceContext"/> swaps the live /// Verifies that <see cref="PlcMultiplexer.ReplaceContext"/> swaps the live per-PLC
/// per-PLC context on the running multiplexer, so the very next PDU's BCD rewriter /// context on the running multiplexer, so the very next PDU's BCD rewriter uses the
/// uses the new tag map (not the captured-at-construction map). Before W1.1 this /// new tag map (not the captured-at-construction map). Without the live swap this
/// scenario would silently keep using the old map until the listener faulted and the /// scenario would silently keep using the old map until the listener faulted and the
/// supervisor's Polly loop reconstructed everything. /// supervisor's Polly loop reconstructed everything.
/// </summary> /// </summary>
@@ -682,11 +682,11 @@ public sealed class PlcMultiplexerTests
} }
/// <summary> /// <summary>
/// W1.1 — verifies that swapping in a fresh response cache via <see cref="PlcMultiplexer.ReplaceContext"/> /// Verifies that swapping in a fresh response cache via
/// makes the running multiplexer consult the NEW cache for subsequent reads, not the /// <see cref="PlcMultiplexer.ReplaceContext"/> makes the running multiplexer consult
/// old cache that was disposed by the supervisor. Without W1.1 the running mux would /// the NEW cache for subsequent reads, not the old cache that was disposed by the
/// keep its constructor-captured cache reference and either return stale entries or /// supervisor. Without the live swap the running mux would keep its constructor-
/// hit a disposed cache. /// captured cache reference and either return stale entries or hit a disposed cache.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ReplaceContext_NewCache_NextReadGoesToBackend_NotOldCache() public async Task ReplaceContext_NewCache_NextReadGoesToBackend_NotOldCache()
@@ -757,7 +757,7 @@ public sealed class PlcMultiplexerTests
} }
} }
// ── Phase 12 (W3 final-tier race tests) ────────────────────────────────── // ── Final-tier race tests ─────────────────────────────────────────────────
/// <summary> /// <summary>
/// Reflection helper — drains the multiplexer's TxIdAllocator by calling /// Reflection helper — drains the multiplexer's TxIdAllocator by calling
@@ -781,10 +781,10 @@ public sealed class PlcMultiplexerTests
} }
/// <summary> /// <summary>
/// W3 #5 — TxId allocator saturation propagates as a Modbus exception 04 to the /// TxId allocator saturation propagates as a Modbus exception 04 to the upstream
/// upstream client (no hang, no crash). The 16-bit TxId space (65,536 slots) is /// client (no hang, no crash). The 16-bit TxId space (65,536 slots) is pre-saturated
/// pre-saturated via reflection so the next request hits the /// via reflection so the next request hits the <c>!_allocator.TryAllocate</c> branch
/// <c>!_allocator.TryAllocate</c> branch in <c>OnUpstreamFrameAsync</c> immediately. /// in <c>OnUpstreamFrameAsync</c> immediately.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task TxIdAllocator_Saturated_NextRequest_GetsException04_WithOriginalTxId() public async Task TxIdAllocator_Saturated_NextRequest_GetsException04_WithOriginalTxId()
@@ -835,11 +835,11 @@ public sealed class PlcMultiplexerTests
} }
/// <summary> /// <summary>
/// W3 #6 — under TxId saturation, two concurrent identical FC03 reads must BOTH /// Under TxId saturation, two concurrent identical FC03 reads must BOTH receive
/// receive exception 04 (one as the leader directly, the other either via a /// exception 04 (one as the leader directly, the other either via a coalesced
/// coalesced fan-out from the W1.2 cleanup OR via its own independent saturation /// fan-out from the saturation cleanup OR via its own independent saturation path —
/// path — either timing produces the same observable contract). Validates that no /// either timing produces the same observable contract). Validates that no pipe
/// pipe hangs forever waiting for a backend response that would never arrive. /// hangs forever waiting for a backend response that would never arrive.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task TxIdAllocator_Saturated_TwoConcurrentIdenticalReads_BothPipesGetException04() public async Task TxIdAllocator_Saturated_TwoConcurrentIdenticalReads_BothPipesGetException04()
@@ -877,7 +877,7 @@ public sealed class PlcMultiplexerTests
var rspA = await ReadOneFrameAsync(cA, TestContext.Current.CancellationToken); var rspA = await ReadOneFrameAsync(cA, TestContext.Current.CancellationToken);
var rspB = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken); var rspB = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken);
// Both must be exception 04 with the original TxId echoed — the W1.2 contract // Both must be exception 04 with the original TxId echoed — the contract
// is "no late attacher hangs." // is "no late attacher hangs."
foreach (var (rsp, expectedTxId, label) in new[] { foreach (var (rsp, expectedTxId, label) in new[] {
(rspA, txA, "A"), (rspB, txB, "B") }) (rspA, txA, "A"), (rspB, txB, "B") })
@@ -898,14 +898,14 @@ public sealed class PlcMultiplexerTests
} }
/// <summary> /// <summary>
/// W3 #7 — backend-reader head-of-line block. One upstream pipe is wedged by the /// Backend-reader head-of-line block guard. One upstream pipe is wedged by the test
/// test holding its socket-receive side without reading. The W1.3 fix routes the /// holding its socket-receive side without reading. The fan-out is routed through
/// fan-out through <c>TrySendResponse</c> so the per-PLC backend reader cannot be /// <c>TrySendResponse</c> so the per-PLC backend reader cannot be stalled by a
/// stalled by a wedged pipe; responses to a healthy peer must keep flowing and the /// wedged pipe; responses to a healthy peer must keep flowing and the wedged pipe's
/// wedged pipe's <c>responseDropForFullUpstream</c> counter must increment. /// <c>responseDropForFullUpstream</c> counter must increment.
/// ///
/// <para>Pre-W1.3 the synchronous <c>await SendResponseAsync</c> inside the reader /// <para>A synchronous <c>await SendResponseAsync</c> inside the reader would block
/// would block on the wedged pipe's full bounded channel and starve every peer.</para> /// on the wedged pipe's full bounded channel and starve every peer.</para>
/// </summary> /// </summary>
[Fact] [Fact]
public async Task SlowUpstream_DoesNotStallPeerResponses_DropCounterIncrements() public async Task SlowUpstream_DoesNotStallPeerResponses_DropCounterIncrements()
@@ -943,8 +943,8 @@ public sealed class PlcMultiplexerTests
await cB.SendAsync(BuildFc03ReadFrame(txB, 0, 1), SocketFlags.None); await cB.SendAsync(BuildFc03ReadFrame(txB, 0, 1), SocketFlags.None);
// B's response must arrive within a few hundred ms even with A wedged. If // B's response must arrive within a few hundred ms even with A wedged. If
// the W1.3 fix were missing, the reader would be blocked on A's channel and // the non-blocking enqueue path were missing, the reader would be blocked on
// B would time out. // A's channel and B would time out.
var rspB = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken) var rspB = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken)
.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); .WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
ushort echoB = (ushort)((rspB[0] << 8) | rspB[1]); ushort echoB = (ushort)((rspB[0] << 8) | rspB[1]);
@@ -972,7 +972,7 @@ public sealed class PlcMultiplexerTests
} }
/// <summary> /// <summary>
/// W3 #8 — watchdog↔response race. The W1 design uses claim-then-dispatch: /// Watchdog↔response race. The design uses claim-then-dispatch:
/// <c>CorrelationMap.TryRemove</c> is the single source of truth, so exactly ONE /// <c>CorrelationMap.TryRemove</c> is the single source of truth, so exactly ONE
/// of (response delivered, watchdog timeout) wins for any given proxy TxId. This /// of (response delivered, watchdog timeout) wins for any given proxy TxId. This
/// test exercises the race window directly: a stub backend that responds at almost /// test exercises the race window directly: a stub backend that responds at almost
@@ -992,13 +992,13 @@ public sealed class PlcMultiplexerTests
// = 400 the tick is 100 ms. Configure the backend to delay 350-450 ms for each // = 400 the tick is 100 ms. Configure the backend to delay 350-450 ms for each
// request so some land before, some after the timeout. // request so some land before, some after the timeout.
int backendPort = PickFreePort(); int backendPort = PickFreePort();
// Phase 12 (W4 / T2) — deterministic alternation rather than seeded Random. Random // Deterministic alternation rather than seeded Random. Random with a fixed seed is
// with a fixed seed is not stable across .NET major versions (Microsoft has changed // not stable across .NET major versions (Microsoft has changed the implementation,
// the implementation, e.g. legacy → Xoshiro128 in .NET 6), so a runtime upgrade // e.g. legacy → Xoshiro128 in .NET 6), so a runtime upgrade could land all samples
// could land all samples on one side of the watchdog deadline and break the // on one side of the watchdog deadline and break the "both branches must fire"
// "both branches must fire" assertion below. Counter-based alternation guarantees // assertion below. Counter-based alternation guarantees 15 fast (350 ms, beats
// 15 fast (350 ms, beats watchdog) and 15 slow (450 ms, loses to watchdog) responses // watchdog) and 15 slow (450 ms, loses to watchdog) responses across 30 iterations,
// across 30 iterations, regardless of runtime. // regardless of runtime.
int reqCount = 0; int reqCount = 0;
var slowBackend = new SlowResponseBackend(backendPort, () => var slowBackend = new SlowResponseBackend(backendPort, () =>
{ {
@@ -1062,13 +1062,13 @@ public sealed class PlcMultiplexerTests
} }
/// <summary> /// <summary>
/// W3 #9 — cascade racing with new accepts. Stress-test: while the backend is repeatedly /// Cascade racing with new accepts. Stress-test: while the backend is repeatedly
/// killed and resurrected (forcing repeated cascade cycles), new upstream clients /// killed and resurrected (forcing repeated cascade cycles), new upstream clients
/// connect and disconnect concurrently. The contract verified is the /// connect and disconnect concurrently. The contract verified is the
/// no-crash-under-churn property: the multiplexer must survive arbitrary interleavings /// no-crash-under-churn property: the multiplexer must survive arbitrary interleavings
/// of teardown and new-pipe-attach without throwing into the host or leaking sockets. /// of teardown and new-pipe-attach without throwing into the host or leaking sockets.
/// ///
/// <para>The originally-flagged race window — a new pipe added between /// <para>The race window — a new pipe added between
/// <c>_pipes.Values.ToArray()</c> and <c>_pipes.Clear()</c> in <c>TearDownBackendAsync</c> /// <c>_pipes.Values.ToArray()</c> and <c>_pipes.Clear()</c> in <c>TearDownBackendAsync</c>
/// — leaves the new pipe alive but orphaned from <c>_pipes</c>. Its read loop will /// — leaves the new pipe alive but orphaned from <c>_pipes</c>. Its read loop will
/// receive normal traffic until the next cascade or its socket closes. This test /// receive normal traffic until the next cascade or its socket closes. This test
@@ -1165,7 +1165,7 @@ public sealed class PlcMultiplexerTests
/// <summary> /// <summary>
/// Backend stub that delays each response by a caller-supplied amount. Used by the /// Backend stub that delays each response by a caller-supplied amount. Used by the
/// W3 #8 watchdog race test. /// watchdog-vs-response race test.
/// </summary> /// </summary>
private sealed class SlowResponseBackend : IAsyncDisposable private sealed class SlowResponseBackend : IAsyncDisposable
{ {
@@ -10,9 +10,9 @@ namespace Mbproxy.Tests.Proxy.Multiplexing;
/// <summary> /// <summary>
/// Verifies that <see cref="BcdPduPipeline"/> correlates FC03/FC04 responses through /// Verifies that <see cref="BcdPduPipeline"/> correlates FC03/FC04 responses through
/// <see cref="PerPlcContext.CurrentRequest"/> (Phase 9) rather than the pre-Phase-9 /// <see cref="PerPlcContext.CurrentRequest"/>. Concurrent in-flight requests from
/// per-pair last-request slot. Concurrent in-flight requests from different upstream /// different upstream clients must decode against their own request range without
/// clients must decode against their own request range without cross-talk. /// cross-talk.
/// </summary> /// </summary>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
public sealed class RewriterCorrelationTests public sealed class RewriterCorrelationTests
@@ -9,8 +9,8 @@ namespace Mbproxy.Tests.Proxy.Multiplexing;
/// <summary> /// <summary>
/// Unit tests for <see cref="UpstreamPipe"/>'s response-channel contract — particularly /// Unit tests for <see cref="UpstreamPipe"/>'s response-channel contract — particularly
/// the Phase 12 (W1.3) <see cref="UpstreamPipe.TrySendResponse"/> non-blocking enqueue /// the <see cref="UpstreamPipe.TrySendResponse"/> non-blocking enqueue, which exists
/// added so the per-PLC backend reader cannot be stalled by one slow upstream client. /// so the per-PLC backend reader cannot be stalled by one slow upstream client.
/// </summary> /// </summary>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
public sealed class UpstreamPipeTests public sealed class UpstreamPipeTests
@@ -40,7 +40,7 @@ public sealed class UpstreamPipeTests
// ── Tests ───────────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// W1.3 — when no write-loop is draining the response channel, repeated /// When no write-loop is draining the response channel, repeated
/// <see cref="UpstreamPipe.TrySendResponse"/> calls must succeed up to the channel's /// <see cref="UpstreamPipe.TrySendResponse"/> calls must succeed up to the channel's
/// bounded capacity and return <c>false</c> on every subsequent call without blocking. /// bounded capacity and return <c>false</c> on every subsequent call without blocking.
/// This is the non-blocking contract the per-PLC backend reader relies on. /// This is the non-blocking contract the per-PLC backend reader relies on.
@@ -80,7 +80,7 @@ public sealed class UpstreamPipeTests
} }
/// <summary> /// <summary>
/// W1.3 — once the pipe has been disposed, <see cref="UpstreamPipe.TrySendResponse"/> /// Once the pipe has been disposed, <see cref="UpstreamPipe.TrySendResponse"/>
/// returns <c>false</c> regardless of channel state, never throws. /// returns <c>false</c> regardless of channel state, never throws.
/// </summary> /// </summary>
[Fact] [Fact]
@@ -51,13 +51,12 @@ public sealed class ProxyForwardingTests
} }
// ── 2a. FC03 read HR1072 — with BCD configured → decoded 1234 ────────────────────── // ── 2a. FC03 read HR1072 — with BCD configured → decoded 1234 ──────────────────────
// Replaced Phase 03 placeholder: Forward_FC03_HR1072_Returns_RawBCD_0x1234
[Fact(Timeout = 5_000)] [Fact(Timeout = 5_000)]
public async Task Forward_FC03_HR1072_Returns_Decoded_1234() public async Task Forward_FC03_HR1072_Returns_Decoded_1234()
{ {
// Phase 04: BcdPduPipeline is active. When BCD tag 1072 (width=16) is configured, // BcdPduPipeline is active. When BCD tag 1072 (width=16) is configured, the proxy
// the proxy decodes the raw 0x1234 nibbles and the client receives binary 1234. // decodes the raw 0x1234 nibbles and the client receives binary 1234.
if (_sim.SkipReason is not null) if (_sim.SkipReason is not null)
Assert.Skip(_sim.SkipReason); Assert.Skip(_sim.SkipReason);
@@ -230,9 +229,9 @@ public sealed class ProxyForwardingTests
public async Task BackendConnectFailure_ClosesUpstreamCleanly() public async Task BackendConnectFailure_ClosesUpstreamCleanly()
{ {
// Point the proxy at port 1 on loopback — guaranteed unreachable. // Point the proxy at port 1 on loopback — guaranteed unreachable.
// After Phase 9 the multiplexer lazily connects to the backend on the first // The multiplexer lazily connects to the backend on the first upstream PDU, so
// upstream PDU, so we have to actually send a request before the proxy attempts // we have to actually send a request before the proxy attempts the (failing)
// the (failing) backend connect that closes the upstream. // backend connect that closes the upstream.
const int badBackendPort = 1; const int badBackendPort = 1;
const int backendTimeoutMs = 500; // short timeout for test speed const int backendTimeoutMs = 500; // short timeout for test speed
@@ -352,7 +351,7 @@ public sealed class ProxyForwardingTests
new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
dispose: false); dispose: false);
builder.AddMbproxyOptions(); builder.AddMbproxyOptions();
// BCD rewriter pipeline — used by the Phase 04 tests in this file. // BCD rewriter pipeline — used by the BCD-decode tests in this file.
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>(); builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
builder.Services.AddHostedService<ProxyWorker>(); builder.Services.AddHostedService<ProxyWorker>();
return builder.Build(); return builder.Build();
@@ -12,11 +12,10 @@ using Xunit;
namespace Mbproxy.Tests.Proxy.Supervision; namespace Mbproxy.Tests.Proxy.Supervision;
/// <summary> /// <summary>
/// Integration tests for the backend-connect Polly retry path. Phase 9 moved backend /// Integration tests for the backend-connect Polly retry path. Backend connect
/// connect ownership from <c>PlcConnectionPair.CreateAsync</c> into /// ownership lives in <see cref="PlcMultiplexer"/>. These tests exercise the Polly
/// <see cref="PlcMultiplexer"/>. These tests exercise the same Polly pipeline by driving /// pipeline by driving upstream-to-multiplexer frames against a bad/intermittent
/// upstream-to-multiplexer frames against a bad/intermittent backend and observing the /// backend and observing the resulting connect-success/connect-failed counters.
/// resulting connect-success/connect-failed counters.
/// </summary> /// </summary>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
public sealed class BackendConnectRetryTests public sealed class BackendConnectRetryTests
@@ -176,13 +176,13 @@ public sealed class SupervisorTests
// ── Test 4: runtime fault triggers recovery ────────────────────────────────────────── // ── Test 4: runtime fault triggers recovery ──────────────────────────────────────────
/// <summary> /// <summary>
/// Phase 12 (W3 test gap #4) — replaces the previous placeholder. Genuinely faults /// Genuinely faults the running listener mid-life by stopping its underlying
/// the running listener mid-life by stopping its underlying <see cref="TcpListener"/> /// <see cref="TcpListener"/> via reflection (the only externally-observable hook
/// via reflection (the only externally-observable hook to force the accept loop's /// to force the accept loop's <see cref="Socket.AcceptAsync"/> to throw
/// <see cref="Socket.AcceptAsync"/> to throw <see cref="ObjectDisposedException"/>). /// <see cref="ObjectDisposedException"/>). The supervisor must observe the fault,
/// The supervisor must observe the fault, transition to <see cref="SupervisorState.Recovering"/>, /// transition to <see cref="SupervisorState.Recovering"/>, and re-bind on the next
/// and re-bind on the next Polly attempt — emitting one /// Polly attempt — emitting one <c>mbproxy.listener.recovered</c> event and bumping
/// <c>mbproxy.listener.recovered</c> event and bumping <c>RecoveryAttempts</c>. /// <c>RecoveryAttempts</c>.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Supervisor_RuntimeFault_OnRunningListener_RecoversAndRebinds() public async Task Supervisor_RuntimeFault_OnRunningListener_RecoversAndRebinds()
@@ -67,8 +67,8 @@ public sealed class SimulatorSmokeTests
/// <summary> /// <summary>
/// Reads holding register 1072 via FC03 and expects raw BCD value /// Reads holding register 1072 via FC03 and expects raw BCD value
/// <c>0x1234</c> (4660 decimal). This register represents decimal 1234 stored as /// <c>0x1234</c> (4660 decimal). This register represents decimal 1234 stored as
/// BCD nibbles. Phase 04's e2e test will read the same register through the proxy /// BCD nibbles. The end-to-end rewriter test reads the same register through the
/// and assert binary 1234 — proving the proxy rewrote the response. /// proxy and asserts binary 1234 — proving the proxy rewrote the response.
/// </summary> /// </summary>
[Fact(Timeout = 5_000)] [Fact(Timeout = 5_000)]
public async Task Simulator_FC03_ReturnsBCD_RawValueAtHR1072_0x1234() public async Task Simulator_FC03_ReturnsBCD_RawValueAtHR1072_0x1234()