From f6f4aeb03192b87deab1cb61353e2aaf47380f1f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 18:13:56 -0400 Subject: [PATCH] =?UTF-8?q?mbproxy:=20fix=20Multiplexing=20N6=20=E2=80=94?= =?UTF-8?q?=20cache-hit=20replay=20preserves=20observation=20age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Mbproxy/Proxy/Multiplexing/PlcMultiplexer.cs | 13 +++++++------ mbproxy/src/Mbproxy/Proxy/TagValueCapture.cs | 12 ++++++++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/mbproxy/src/Mbproxy/Proxy/Multiplexing/PlcMultiplexer.cs b/mbproxy/src/Mbproxy/Proxy/Multiplexing/PlcMultiplexer.cs index d216eed..99e0761 100644 --- a/mbproxy/src/Mbproxy/Proxy/Multiplexing/PlcMultiplexer.cs +++ b/mbproxy/src/Mbproxy/Proxy/Multiplexing/PlcMultiplexer.cs @@ -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); diff --git a/mbproxy/src/Mbproxy/Proxy/TagValueCapture.cs b/mbproxy/src/Mbproxy/Proxy/TagValueCapture.cs index c1b97a0..77c125d 100644 --- a/mbproxy/src/Mbproxy/Proxy/TagValueCapture.cs +++ b/mbproxy/src/Mbproxy/Proxy/TagValueCapture.cs @@ -126,7 +126,15 @@ internal sealed class TagValueCapture /// BCD-encoded low word as it sits on the PLC wire. /// BCD-encoded high word (0 for a 16-bit tag). /// Decoded binary integer the client reads/wrote. - public void Record(ushort address, ushort rawLow, ushort rawHigh, int decoded, CaptureDirection direction) + /// + /// 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. + /// + 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