mbproxy: fix Multiplexing N6 — cache-hit replay preserves observation age

The response-cache hit path replayed captured tag observations into the
debug-view capture stamped 'now', making a cache-served read look freshly
read when its value is actually up to CacheTtlMs old. Record() now takes
an optional observedAtUtc; the cache-hit replay passes each observation's
original timestamp so the debug view shows the true age.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-16 18:13:56 -04:00
parent b222362ce0
commit f6f4aeb031
2 changed files with 17 additions and 8 deletions
@@ -913,17 +913,18 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
// A cache hit bypasses the BCD pipeline, so the debug-view capture
// would otherwise never see cache-served reads. Replay the
// observations captured when this entry was stored — re-stamped now,
// since the client receives this value right now — so the detail
// page reflects what the client actually gets. Entries stored while
// no viewer was armed carry no observations; those tags self-heal on
// the next cache miss.
// observations captured when this entry was stored, preserving each
// observation's ORIGINAL timestamp — the value the client receives is
// cache-aged (up to CacheTtlMs old), so the debug view must show its
// true age, not a misleading "just now". Entries stored while no
// viewer was armed carry no observations; those tags self-heal on the
// next cache miss.
if (_ctx.Capture is { IsArmed: true } capture &&
cached.CapturedTags is { Count: > 0 } cachedTags)
{
foreach (var obs in cachedTags)
capture.Record(obs.Address, obs.RawLow, obs.RawHigh,
obs.DecodedValue, CaptureDirection.Read);
obs.DecodedValue, CaptureDirection.Read, obs.UpdatedAtUtc);
}
byte[] hitFrame = BuildCacheHitFrame(originalTxId, unitId, cached.PduBytes);
+10 -2
View File
@@ -126,7 +126,15 @@ internal sealed class TagValueCapture
/// <param name="rawLow">BCD-encoded low word as it sits on the PLC wire.</param>
/// <param name="rawHigh">BCD-encoded high word (0 for a 16-bit tag).</param>
/// <param name="decoded">Decoded binary integer the client reads/wrote.</param>
public void Record(ushort address, ushort rawLow, ushort rawHigh, int decoded, CaptureDirection direction)
/// <param name="observedAtUtc">
/// When the value was actually observed from the PLC. Defaults to "now" for a live
/// pipeline observation. The response-cache hit path passes the cached observation's
/// original timestamp so a cache-served read shows its true age in the debug view
/// rather than appearing freshly read.
/// </param>
public void Record(
ushort address, ushort rawLow, ushort rawHigh, int decoded, CaptureDirection direction,
DateTimeOffset? observedAtUtc = null)
{
if (!_armed)
return;
@@ -137,7 +145,7 @@ internal sealed class TagValueCapture
ref _slots[idx],
new TagValueObservation(
_addresses[idx], _widths[idx], _names[idx], rawLow, rawHigh, decoded, direction,
DateTimeOffset.UtcNow));
observedAtUtc ?? DateTimeOffset.UtcNow));
// A concurrent Disarm() may have flipped _armed (and cleared the slots) between
// the _armed check above and the write just made — which would strand a stale