mbproxy: Wave 2 fixes from 2026-05-14 code review

Resolves the 21 Major findings catalogued in
codereviews/2026-05-14/RemediationPlan.md (Wave 2). Tests: 370 pass / 0 fail
(baseline 363 + 7 new W2 regression tests).

Multiplexer / concurrency:
  W2.1  ConfigReconciler.Attach now threads the live coalescingAccessor through
        to add/restart-built supervisors so a hot-reload of
        ReadCoalescing.{Enabled,MaxParties} propagates to PLCs added or
        restarted via reload.
  W2.2  PlcMultiplexer._disposed and UpstreamPipe._disposed are now volatile
        for ARM/portability defense.
  W2.3  ProxyWorker._supervisors / ConfigReconciler._supervisors switched from
        Dictionary to ConcurrentDictionary; reconciler uses TryRemove. The
        outer Apply is serialised by a semaphore but the inner Add/Remove/
        Restart Task.WhenAll continuations run in parallel.
  W2.4  Counter parity for cache miss + coalescing-saturation miss documented
        inline (per-design contract; behavior unchanged).
  W2.5  _disposeCts.Dispose() and _connectGate.Dispose() guarded against late
        watchdog ticks.
  W2.6  _connectGate disposed in DisposeAsync.
  W2.7  Inline doc clarifying the post-rewriter FC byte read.

Cache / hot-reload:
  W2.8  PlcListenerSupervisor.ReplaceContextAsync now calls Clear() to capture
        the entry count, emits mbproxy.cache.flushed, then disposes the old
        cache. Previously the event was defined but never emitted.
  W2.9  Inline doc explaining the implicit "skip cache invalidation while
        recovering" gating (no backend reader during recovery → no FC06/FC16
        response → no invalidation).
  W2.10 ReloadValidator now re-checks resolved per-tag CacheTtlMs against
        Cache.AllowLongTtl after BcdTagMapBuilder folds the per-PLC default.

BCD rewriter:
  W2.11 Duplicate addresses detected within Global itself and within the per-PLC
        Add list itself, BEFORE the working dictionary collapses keys. Cross-list
        collisions (Global vs Add) remain the documented width-override pattern.
        Previously the DuplicateAddress error was unreachable dead code.
  W2.12 OverlappingHighRegister reports each colliding pair exactly once
        (canonicalised low/high pair tracked in a HashSet).
  W2.13 FC16 32-bit write rejects clientLow > 9999 or clientHigh > 9999 BEFORE
        the high*10000+low reconstruction. Without this guard, (high=9999,
        low=9999) silently re-encoded as (high=9998, low=9999), losing 1 from
        the high word.
  W2.14 FC16 validates pdu.Length >= 6 + qty*2 upfront — no half-rewritten
        requests when a malformed client claims more registers than it ships.

Supervisor:
  W2.15 WaitForInitialBindAttemptAsync now backed by TaskCompletionSource
        instead of 10ms busy-poll. Resolves race against fast Stopped→Bound→
        Stopped transitions and hangs when the supervisor task throws.
  W2.16 StartAsync refuses re-entry on a non-Stopped supervisor (was leaking
        the previous _supervisorCts).
  W2.17 New TransitionTo helper writes _state, _lastBindError, and (optionally)
        _recoveryAttempts under one lock. Snapshot() reads under the same lock
        so the status page never reports an inconsistent triple. Truncate
        helper extracted (was copy-pasted across three sites).
  W2.18 MbproxyOptionsValidator + ReloadValidator reject Connection.{Backend
        ConnectTimeoutMs, BackendRequestTimeoutMs, GracefulShutdownTimeoutMs}
        <= 0. Misconfigured 0 produces immediate CancelAfter(0) failures.

Hosting / diagnostics:
  W2.20 ProxyWorker.StopAsync supervisor-stop deadline now reads from
        IOptionsMonitor.CurrentValue.Connection.GracefulShutdownTimeoutMs
        (was hard-coded 5s).
  W2.21 src/Mbproxy/appsettings.json deleted; the published file is now a Link
        to install/mbproxy.config.template.json so the binary ships with a
        usable, fully-commented example config instead of an empty stub. Tests
        strip the inherited file from their bin via an AfterTargets="Build"
        Target so they don't pick up the template's example PLCs.
  W2.22 invalidBcdWarnings (PlcPdusStatus) and codeOther (ExceptionCounts)
        added to StatusDto, plumbed through StatusSnapshotBuilder, surfaced
        in StatusHtmlRenderer table cells.
  W2.23 EventLogBridge caches EventLog.SourceExists at construction so Emit
        doesn't hit the registry on every Error+ log line.

New regression tests:
  ReloadValidatorTests:
    Validate_PerTagCacheTtl_Above60s_Without_AllowLongTtl_Fails
    Validate_PerTagCacheTtl_Above60s_With_AllowLongTtl_Passes
    Validate_ResolvedTtl_FromPerPlcDefault_AboveCap_Fails
    Validate_ZeroBackendConnectTimeoutMs_Fails
    Validate_NegativeGracefulShutdownTimeoutMs_Fails
  BcdPduPipelineTests:
    FC16_32Bit_ClientHighOrLowAbove9999_PassesThroughRaw_WithInvalidBcdWarning
    FC16_TruncatedRegisterData_PassesThroughRaw_NoPartialRewrite

Reworked tests in BcdTagMapBuilderTests for the W2.11 contract (Global dup,
Add dup, Add-overrides-Global accepted as width override).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-14 05:48:44 -04:00
parent ce32c5cee8
commit e66b17fe5f
21 changed files with 545 additions and 163 deletions
+25 -1
View File
@@ -156,7 +156,15 @@ internal sealed class BcdPduPipeline : IPduPipeline
ushort startAddress = (ushort)((pdu[1] << 8) | pdu[2]);
ushort qty = (ushort)((pdu[3] << 8) | pdu[4]);
// byte byteCount = pdu[5]; (qty * 2, not used directly)
// Phase 12 (W2.14) — validate the request is fully sized for `qty` registers
// (each 2 bytes after the byteCount byte). A client claiming qty=10 with only
// 4 bytes of register data would otherwise have its BCD slots silently skipped
// by the per-slot bounds check below — half the request rewritten, half not.
// Returning here passes the malformed PDU through unchanged so the PLC's own
// validator surfaces the protocol error.
if (pdu.Length < 6 + qty * 2)
return;
if (!ctx.TagMap.TryGetForRange(startAddress, qty, out var hits))
return; // no BCD tags in this range
@@ -202,6 +210,22 @@ internal sealed class BcdPduPipeline : IPduPipeline
ushort clientLow = (ushort)((pdu[lowByteOff] << 8) | pdu[lowByteOff + 1]);
ushort clientHigh = (ushort)((pdu[highByteOff] << 8) | pdu[highByteOff + 1]);
// Phase 12 (W2.13) — validate that BOTH input words are within the
// base-10000-digit range BEFORE reconstructing. Without this guard, a
// client writing (high=9999, low=9999) silently mutates to (high=9998,
// low=9999) because `9999 * 10_000 + 9999 = 99_989_999` is still <= the
// 32-bit BCD ceiling, so Encode32 accepts it and rewrites — losing 1 from
// the high word. The unconventional wire format ("two base-10000 CDAB
// digits", per design.md:123) means each word independently must be
// 0..9999 to round-trip cleanly.
if (clientLow > 9999 || clientHigh > 9999)
{
RewriterLogEvents.InvalidBcd(ctx.Logger, ctx.PlcName, tag.Address,
clientLow, "Write");
ctx.Counters.IncrementInvalidBcd();
continue;
}
// Reconstruct the 32-bit binary value (CDAB: low-word = low digits).
int binaryValue = clientHigh * 10_000 + clientLow;
@@ -88,7 +88,11 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
private Task? _backendReaderTask;
private readonly CancellationTokenSource _disposeCts = new();
private bool _disposed;
// Phase 12 (W2.2) — volatile so the disposing thread's write is observed by every
// hot-path reader (OnUpstreamFrameAsync, ReplaceContext, Attach, etc.) without a
// separate fence. On x86/x64 plain reads happen to give acquire-release semantics, so
// this is defense for ARM hosts and future portability.
private volatile bool _disposed;
private Task? _watchdogTask;
public PlcMultiplexer(
@@ -240,7 +244,11 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
}
_pipes.Clear();
_disposeCts.Dispose();
// Phase 12 (W2.5, W2.6) — guard the CTS dispose against a watchdog tick that
// raced past the WaitAsync above (e.g. a slow Task.Delay completion observing
// cancellation late). Also dispose the connect-gate semaphore.
try { _disposeCts.Dispose(); } catch (ObjectDisposedException) { /* already disposed */ }
try { _connectGate.Dispose(); } catch (ObjectDisposedException) { /* already disposed */ }
}
// ── Backend connect / teardown ────────────────────────────────────────────
@@ -522,9 +530,14 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
// cache-eligible (resolvedTtlMs > 0).
// * FC06/FC16 successful responses invalidate every cached entry whose
// address range overlaps the write.
//
// Phase 12 (W2.7) — exception bit comes from the post-rewriter buffer
// (the rewriter never touches the FC byte today, but reading from
// inFlight.Fc would lose the exception bit). The base FC for routing
// decisions uses inFlight.Fc — the request side knows what was sent.
if (_ctx.Cache is { } postCache)
{
byte fcInResponse = frame[MbapFrame.HeaderSize]; // post-rewriter, but the FC byte is never rewritten
byte fcInResponse = frame[MbapFrame.HeaderSize];
bool isException = (fcInResponse & 0x80) != 0;
if (!isException)
@@ -555,6 +568,16 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
}
else if (inFlight.Fc is 0x06 or 0x10)
{
// Phase 12 (W2.9) — the design contract "invalidations during a
// recovering listener state are skipped" (design.md:203) is
// upheld IMPLICITLY here: invalidation only fires inside the
// backend reader task when a non-exception FC06/FC16 response
// arrives. A `Recovering` listener has no backend reader (the
// multiplexer is torn down between recovery attempts), so no
// response can land here, so no invalidation. The gating is
// structural, not conditional. If a future change ever produces
// a write response off the live backend, an explicit recovering-
// state check would need to be added.
int invalidated = postCache.Invalidate(
inFlight.UnitId, inFlight.StartAddress, inFlight.Qty);
if (invalidated > 0)
@@ -692,6 +715,12 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
return;
}
// Per design contract: "miss" = "fell through to coalescing/backend".
// When two upstream peers issue the same cache-eligible read, both increment
// CacheMiss; only one then opens a backend round-trip (the second coalesces
// onto the first via the InFlightByKey path below). So `CacheMiss` does NOT
// equal "produced a backend round-trip" — it equals "did not find a fresh
// cache entry". The identity `Hit + Miss = cache-eligible requests` holds.
_ctx.Counters.IncrementCacheMiss();
CacheLogEvents.Miss(_logger, _plc.Name, unitId, fcByte, startAddr, qty);
}
@@ -786,7 +815,11 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
return;
}
// Coalesce miss: we just opened a fresh in-flight entry.
// Coalesce miss: this request did not attach to an in-flight peer. Per the
// design contract `coalescedHitCount + coalescedMissCount = total FC03/FC04`,
// so even saturation-failure paths (factory below returns null inFlightForSend)
// count as a miss — every FC03/FC04 entered the coalescing path exactly once.
// "Miss" here means "did not coalesce", NOT "produced a backend round-trip".
_ctx.Counters.IncrementCoalescedMiss();
CoalescingLogEvents.Miss(_logger, _plc.Name, unitId, fcByte, startAddr, qty);
@@ -49,7 +49,9 @@ internal sealed partial class UpstreamPipe : IAsyncDisposable
// Internal CTS lets the multiplexer signal "drop this pipe now" without waiting for
// the upstream socket to close cleanly.
private readonly CancellationTokenSource _cts = new();
private bool _disposed;
// Phase 12 (W2.2) — volatile so writes from DisposeAsync are observed by IsAlive /
// TrySendResponse on other threads without a fence.
private volatile bool _disposed;
// Phase 9: per-pipe forwarded-PDU counter (replaces the per-pair counter from the
// 1:1 model). Read by the status page.
+20 -6
View File
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Mbproxy.Admin;
using Mbproxy.Bcd;
@@ -49,7 +50,13 @@ internal sealed partial class ProxyWorker : BackgroundService
// Phase 06: supervisors are now managed jointly by ProxyWorker (initial bootstrap)
// and ConfigReconciler (subsequent hot-reload changes). The dictionary is shared
// via ConfigReconciler.Attach() after initial startup.
private readonly Dictionary<string, PlcListenerSupervisor> _supervisors = new(StringComparer.Ordinal);
//
// Phase 12 (W2.3) — ConcurrentDictionary because ConfigReconciler mutates this from
// parallel 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
// reads via IReadOnlyDictionary still work without locking.
private readonly ConcurrentDictionary<string, PlcListenerSupervisor> _supervisors =
new(StringComparer.Ordinal);
/// <summary>
/// Read-only view of the live supervisor dictionary. Consumed by Phase 07's
@@ -164,7 +171,11 @@ internal sealed partial class ProxyWorker : BackgroundService
// initial options snapshot. The reconciler won't process OnChange events until
// after this call — the brief window between Attach and first supervisor start
// is safe because the channel signal only enqueues; apply runs asynchronously.
_reconciler.Attach(_supervisors, opts);
// Phase 12 (W2.1) — also pass the live coalescing accessor so reconciler-built
// supervisors (add/restart paths) honour hot-reloaded ReadCoalescing values.
Func<ReadCoalescingOptions> reconcilerCoalescingAccessor =
() => _options.CurrentValue.Resilience.ReadCoalescing;
_reconciler.Attach(_supervisors, opts, reconcilerCoalescingAccessor);
if (_supervisors.Count == 0)
{
@@ -252,9 +263,13 @@ internal sealed partial class ProxyWorker : BackgroundService
await base.StopAsync(cancellationToken).ConfigureAwait(false);
var sw = Stopwatch.StartNew();
// Phase 12 (W2.20) — supervisor stop deadline read from the live config so a
// hot-reloaded GracefulShutdownTimeoutMs is honoured. Previously hard-coded 5 s.
// The supervisor stop budget is bounded by the same total-shutdown budget.
int gracefulMs = _options.CurrentValue.Connection.GracefulShutdownTimeoutMs;
// ── 1. Stop accepting new connections ─────────────────────────────────────────
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var stopCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(gracefulMs));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
stopCts.Token, cancellationToken);
@@ -272,9 +287,8 @@ internal sealed partial class ProxyWorker : BackgroundService
}
// ── 2. Drain in-flight PDUs ───────────────────────────────────────────────────
// Reads the current configured deadline so a hot-reloaded
// GracefulShutdownTimeoutMs is honoured at stop time, not frozen at process start.
int drainDeadlineMs = _options.CurrentValue.Connection.GracefulShutdownTimeoutMs;
// Same `gracefulMs` budget the supervisor-stop step used.
int drainDeadlineMs = gracefulMs;
int inFlightAtCancel = 0;
if (drainDeadlineMs > 0)
@@ -1,4 +1,5 @@
using Mbproxy.Options;
using Mbproxy.Proxy.Cache;
using Mbproxy.Proxy.Multiplexing;
using Polly;
@@ -66,6 +67,13 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
private bool _disposed;
// Phase 12 (W2.15) — completes when the supervisor has transitioned out of Stopped
// for the first time (reached Bound or Recovering). Replaces the previous busy-poll
// implementation in WaitForInitialBindAttemptAsync, which raced fast Stopped→Bound→
// Stopped transitions and never exited if the supervisor task threw inside Polly.
private readonly TaskCompletionSource _firstAttemptCompleted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
// ── Public surface ────────────────────────────────────────────────────────────────────
public string PlcName => _plc.Name;
@@ -123,6 +131,16 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
/// </summary>
public Task StartAsync(CancellationToken ct)
{
// Phase 12 (W2.16) — refuse to re-Start an already-running or already-disposed
// supervisor. The original code reassigned _supervisorCts unconditionally, which
// leaked the previous CTS and could leave a zombie task running against an
// unobserved token. The supervisor's state machine has exactly one Start.
if (_disposed)
throw new ObjectDisposedException(nameof(PlcListenerSupervisor));
if (_state != SupervisorState.Stopped || !_supervisorTask.IsCompleted)
throw new InvalidOperationException(
$"Supervisor for Plc='{_plc.Name}' has already been started.");
_supervisorCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_supervisorTask = Task.Run(() => RunSupervisorAsync(_supervisorCts.Token), CancellationToken.None);
return Task.CompletedTask;
@@ -133,13 +151,22 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
/// (transitioned to <see cref="SupervisorState.Bound"/> or
/// <see cref="SupervisorState.Recovering"/>).
/// Returns immediately if the supervisor is already past that point.
///
/// <para><b>Phase 12 (W2.15)</b> — backed by a <see cref="TaskCompletionSource"/> set
/// when the supervisor task first transitions out of <see cref="SupervisorState.Stopped"/>.
/// Replaces the previous 10 ms busy-poll which raced fast bind+stop sequences and could
/// hang if the supervisor task threw before any state write happened.</para>
/// </summary>
public async Task WaitForInitialBindAttemptAsync(CancellationToken ct)
{
while (_state == SupervisorState.Stopped && !ct.IsCancellationRequested
&& !_supervisorTask.IsCompleted)
if (_firstAttemptCompleted.Task.IsCompleted) return;
try
{
await Task.Delay(10, ct).ConfigureAwait(false);
await _firstAttemptCompleted.Task.WaitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Caller cancelled; not a fault — same observable behaviour as the prior poll.
}
}
@@ -173,11 +200,43 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
}
}
/// <summary>Returns a point-in-time snapshot of this supervisor's state.</summary>
public SupervisorSnapshot Snapshot() => new(
State: _state,
LastBindError: _lastBindError,
RecoveryAttempts: Interlocked.CompareExchange(ref _recoveryAttempts, 0, 0));
/// <summary>
/// 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
/// lock so the status page can never report inconsistent triples like
/// <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
/// snapshot reads a transition-consistent view.</para>
/// </summary>
public SupervisorSnapshot Snapshot()
{
lock (_snapshotLock)
{
return new SupervisorSnapshot(
State: _state,
LastBindError: _lastBindError,
RecoveryAttempts: _recoveryAttempts);
}
}
private readonly object _snapshotLock = new();
/// <summary>
/// Phase 12 (W2.17) — atomic three-field transition. State, lastBindError, and
/// (optionally) the recoveryAttempts increment all happen under one lock so a
/// concurrent <see cref="Snapshot"/> never sees a half-applied transition.
/// </summary>
private void TransitionTo(SupervisorState newState, string? lastBindError, bool incrementRecoveryAttempt)
{
lock (_snapshotLock)
{
_state = newState;
_lastBindError = lastBindError;
if (incrementRecoveryAttempt)
_recoveryAttempts++;
}
}
/// <summary>
/// Atomically swaps the per-PLC context (tag map + optional response cache) on the
@@ -210,12 +269,16 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
// inside the Polly loop will pick up newCtx through _currentContext above.
_currentListener?.Multiplexer?.ReplaceContext(newCtx);
// Phase 12 (W1.1 + W2.8 prereq) — drop the outgoing cache AFTER the swap so the
// running multiplexer can no longer reach it. Dispose stops the eviction loop and
// releases the timer. (The cache.flushed log event is W2.8 work; this Wave-1 fix
// is the "no longer in use, safe to drop" piece.)
// Phase 12 (W1.1 + W2.8) — drop the outgoing cache AFTER the swap so the running
// multiplexer can no longer reach it. Clear() snapshots the entry count for the
// mbproxy.cache.flushed log event before disposing the cache (which stops the
// eviction loop and releases the timer).
if (oldCache is not null && !ReferenceEquals(oldCache, newCtx.Cache))
{
int dropped = oldCache.Clear();
CacheLogEvents.Flushed(_logger, _plc.Name, "tag-list-reload", dropped);
oldCache.Dispose();
}
return Task.CompletedTask;
}
@@ -268,11 +331,10 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
_currentListener = null;
await listener.DisposeAsync().ConfigureAwait(false);
Interlocked.Increment(ref _recoveryAttempts);
string reason = bindEx.Message;
string truncated = reason.Length > 256 ? reason[..256] : reason;
_lastBindError = truncated;
_state = SupervisorState.Recovering;
string truncated = Truncate(bindEx.Message, 256);
TransitionTo(SupervisorState.Recovering, truncated, incrementRecoveryAttempt: true);
// Phase 12 (W2.15) — signal the first transition out of Stopped.
_firstAttemptCompleted.TrySetResult();
// Also update the per-PLC counters if available (Phase 07 reads these).
_currentContext?.Counters.IncrementRecoveryAttempt(truncated);
@@ -297,9 +359,10 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
}
// Clear the last bind error on a successful bind.
_lastBindError = null;
TransitionTo(SupervisorState.Bound, lastBindError: null, incrementRecoveryAttempt: false);
_currentContext?.Counters.ClearLastBindError();
_state = SupervisorState.Bound;
// Phase 12 (W2.15) — signal the first transition out of Stopped.
_firstAttemptCompleted.TrySetResult();
// ── Run the accept loop ──────────────────────────────────────────
// RunAsync returns when: (a) token is cancelled (normal shutdown),
@@ -324,10 +387,12 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
_currentListener = null;
await listener.DisposeAsync().ConfigureAwait(false);
Interlocked.Increment(ref _recoveryAttempts);
string truncated = runEx.Message.Length > 256 ? runEx.Message[..256] : runEx.Message;
_lastBindError = truncated;
_state = SupervisorState.Recovering;
string truncated = Truncate(runEx.Message, 256);
TransitionTo(SupervisorState.Recovering, truncated, incrementRecoveryAttempt: true);
// Phase 12 (W2.15) — also signal first-attempt-completed in case the
// very first listener.RunAsync faulted before the bind-success path
// signalled it.
_firstAttemptCompleted.TrySetResult();
// Also update the per-PLC counters if available.
_currentContext?.Counters.IncrementRecoveryAttempt(truncated);
@@ -346,10 +411,8 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
// Otherwise (listener closed without cancellation — e.g., OS event),
// treat as a fault and re-enter recovery.
Interlocked.Increment(ref _recoveryAttempts);
const string unexpectedEnd = "Listener accept loop ended unexpectedly";
_lastBindError = unexpectedEnd;
_state = SupervisorState.Recovering;
TransitionTo(SupervisorState.Recovering, unexpectedEnd, incrementRecoveryAttempt: true);
_currentContext?.Counters.IncrementRecoveryAttempt(unexpectedEnd);
LogListenerEnded(_logger, _plc.Name, _plc.ListenPort);
throw new InvalidOperationException(unexpectedEnd);
@@ -369,11 +432,26 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
}
finally
{
_state = SupervisorState.Stopped;
// Snapshot consistency: state goes back to Stopped without changing the last
// bind error so operators can still see WHY the supervisor exited.
lock (_snapshotLock)
{
_state = SupervisorState.Stopped;
}
_currentListener = null;
// Phase 12 (W2.15) — defensive: if RunSupervisorAsync exits before any bind
// attempt fired (e.g. construction-time fault), unblock any awaiting
// WaitForInitialBindAttemptAsync caller so it doesn't hang.
_firstAttemptCompleted.TrySetResult();
}
}
/// <summary>
/// Phase 12 (W2 cleanup) — single helper for the truncate-exception-message pattern
/// previously copy-pasted across three call sites.
/// </summary>
private static string Truncate(string s, int max) => s.Length > max ? s[..max] : s;
// ── IAsyncDisposable ─────────────────────────────────────────────────────────────────
public async ValueTask DisposeAsync()