mbproxy: Wave 6 — wire ProxyCounters.AddBytes (bytes counters were always 0)

The 10-min stress test (1.46 M PDUs through the proxy) revealed that
status.json's bytes.upstreamIn / bytes.upstreamOut counters always read 0
because ProxyCounters.AddBytes was defined but never called from anywhere.
Same shape as the original review's W2.22 finding (counter wired in DTO +
HTML but no increment site), missed for the bytes counters specifically.

Wired five increment sites in PlcMultiplexer:

  OnUpstreamFrameAsync (request side, parsed frame)
    AddBytes(up: frame.Length, down: 0) — counted ONCE per parsed frame
    regardless of subsequent routing (cache hit, coalesce, backend
    round-trip, exception).

  RunBackendReaderAsync fan-out (response side, after TrySendResponse=true)
    AddBytes(up: 0, down: outFrame.Length) per delivered party. With
    coalescing, one backend response fans out to N parties and produces
    N × frame.Length bytes leaving the proxy upstream-side. Drops
    (TrySendResponse=false) increment ResponseDropForFullUpstream
    instead.

  Cache hit path
    AddBytes(up: 0, down: hitFrame.Length) for the BuildCacheHitFrame
    response (no backend round-trip but still bytes leaving the proxy).

  Saturation cleanup (W1.2 path, both branches)
    AddBytes(up: 0, down: excFrame.Length) per delivered exception 0x04.

  Non-coalescing-path saturation
    AddBytes(up: 0, down: excFrame.Length) for the single exception 0x04.

  Watchdog timeout exception delivery
    AddBytes(up: 0, down: excFrame.Length) per delivered exception 0x0B.

Backend-side bytes (proxy ↔ PLC) are NOT counted by these counters — the
field name is `BytesUpstreamIn/Out` which is upstream-only by contract.

Tests: 387 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-14 09:30:48 -04:00
parent 59d0b5deb9
commit b3b8313e9c
@@ -690,6 +690,13 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
{
_ctx.Counters.IncrementResponseDropForFullUpstream();
}
else
{
// Phase 12 (W6) — count outbound bytes per delivered party.
// With coalescing, one backend response fans out to N parties and
// produces N × frame.Length bytes leaving the proxy upstream-side.
_ctx.Counters.AddBytes(up: 0, down: outFrame.Length);
}
}
}
@@ -723,6 +730,11 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
out ushort originalTxId, out _, out _, out byte unitId))
return;
// Phase 12 (W6) — count inbound bytes from the upstream client. Surfaces in
// bytes.upstreamIn on the status page. Counted ONCE per parsed frame regardless
// of subsequent routing (cache hit, coalesce, backend round-trip, exception).
_ctx.Counters.AddBytes(up: frame.Length, down: 0);
// Parse the PDU FC + start/qty. FC03/FC04 reads use start/qty for the coalescing key
// and (Phase 11) for the cache lookup. FC06 writes carry [addr][value]; we treat qty
// as 1 for invalidation. FC16 carries [start][qty][byteCount]...; qty is the write
@@ -771,6 +783,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
byte[] hitFrame = BuildCacheHitFrame(originalTxId, unitId, cached.PduBytes);
await pipe.SendResponseAsync(hitFrame, ct).ConfigureAwait(false);
// Phase 12 (W6) — outbound bytes for cache-hit response.
_ctx.Counters.AddBytes(up: 0, down: hitFrame.Length);
return;
}
@@ -905,6 +919,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
byte[] excFrame = BuildExceptionFrame(party.OriginalTxId, unitId, fcByte, exceptionCode: 4);
if (!party.Pipe.TrySendResponse(excFrame))
_ctx.Counters.IncrementResponseDropForFullUpstream();
else
_ctx.Counters.AddBytes(up: 0, down: excFrame.Length); // W6
}
}
else
@@ -914,6 +930,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
byte[] excFrame = BuildExceptionFrame(originalTxId, unitId, fcByte, exceptionCode: 4);
if (!pipe.TrySendResponse(excFrame))
_ctx.Counters.IncrementResponseDropForFullUpstream();
else
_ctx.Counters.AddBytes(up: 0, down: excFrame.Length); // W6
}
return;
}
@@ -953,6 +971,7 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
MultiplexerLogEvents.Saturated(_logger, _plc.Name, pipe.RemoteEp?.ToString() ?? "?");
byte[] excFrame = BuildExceptionFrame(originalTxId, unitId, fcByte, exceptionCode: 4);
await pipe.SendResponseAsync(excFrame, ct).ConfigureAwait(false);
_ctx.Counters.AddBytes(up: 0, down: excFrame.Length); // W6
return;
}
@@ -1078,6 +1097,7 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
try
{
await party.Pipe.SendResponseAsync(excFrame, ct).ConfigureAwait(false);
_ctx.Counters.AddBytes(up: 0, down: excFrame.Length); // W6
}
catch
{