# Tag-Value Capture Review

Scope: commit `e719dd5` ("replace status page with a live SignalR web dashboard"), restricted to the on-demand tag-value capture feature:
`src/Mbproxy/Proxy/TagValueCapture.cs`, `src/Mbproxy/Proxy/TagCaptureRegistry.cs`, the `PerPlcContext.cs` / `BcdPduPipeline.cs` / `ProxyWorker.cs` / `ConfigReconciler.cs` / `HostingExtensions.cs` deltas. Cross-checked against `mbproxy/CLAUDE.md` (design intent: capture armed only while a detail page is open; disarmed hot-path cost = one nullable-deref + one volatile read; torn-read safety via immutable records swapped with `Volatile.Write`) and the surrounding admin layer (`StatusHub`, `PlcSubscriptionTracker`, `StatusBroadcaster`, `AdminEndpointHost`, `StatusSnapshotBuilder`, `PlcMultiplexer`).

## Summary

- The disarmed hot path is genuinely near-zero: one `?.` null check plus one volatile-bool read. No allocations, no dictionary lookup, no lock when disarmed — the design contract holds.
- Torn-read safety is correct: `TagValueObservation` is an immutable `record`, slots are reference-typed and only ever swapped via `Volatile.Write` / read via `Volatile.Read`. `FrozenDictionary` is built once in the constructor and never mutated. No defect here.
- The single material correctness gap is **feature, not crash**: with the Phase-11 response cache enabled, an FC03/FC04 **cache hit bypasses the pipeline entirely**, so `Record` never fires for cached reads — the debug view silently freezes for any cacheable tag while caching is on.
- One real lifecycle leak: a `TagCaptureRegistry` reseat/restart on a PLC that is **not** currently being viewed still rebuilds the capture, and `GetOrCreate`'s armed-flag preservation has a benign-but-real race against `Arm`/`Disarm`.
- `ProcessFc06Response` does not `Record`, which is defensible but leaves the write path slightly asymmetric — noted as Minor.

## Critical Findings

### C1. Response-cache hits never reach `Record` — the debug view freezes for cached tags
`PlcMultiplexer.cs:817-828` vs `BcdPduPipeline.cs:408,437`. The capture `Record` calls live exclusively inside `BcdPduPipeline.ProcessResponse`, which only runs when a backend response is rewritten (`PlcMultiplexer.cs:618`). The Phase-11 cache-hit path at `PlcMultiplexer.cs:823-828` builds the upstream frame straight from `cached.PduBytes` via `BuildCacheHitFrame` and returns — **the pipeline is never invoked, so `ctx.Capture?.Record(...)` never fires**. Consequence: for any BCD tag with `CacheTtlMs > 0`, once the cache is warm the connection-detail debug view shows a value that is frozen at the last *cache-miss* observation and ages indefinitely (`AgeSeconds` keeps climbing, `UpdatedAtUtc` never advances), even though clients are reading the tag every poll cycle. An operator using the debug view to confirm "is this tag live?" is actively misled — the tag *is* live, the proxy just isn't recording the cache-served reads.

This is a feature-correctness defect, not a crash, but it directly defeats the stated purpose of the debug view ("real-time debug view of raw PLC-side BCD vs. decoded client-side values"). Note caching is OFF by default, so the default deployment is unaffected — hence Critical-for-the-feature rather than Critical-for-the-service.

Fix: record on the cache-hit path too. The cleanest option is, inside the `responseCache.TryGet` hit branch, to decode the cached PDU against the request range and call `ctx.Capture?.Record(...)` — but the cache stores **post-rewrite** bytes (binary, already decoded), so the raw BCD nibbles are no longer available there. Better: store the `TagValueObservation`(s) alongside the cache entry at `Set` time (line 658) and re-publish them into the capture on a hit; or have the cache entry retain the pre-rewrite raw words. At minimum, document in `docs/Architecture/ResponseCache.md` and the debug-view docs that the debug view does not reflect cache-served reads when caching is enabled, and surface "served from cache" in the UI so the stale age is not mistaken for a dead tag.

## Major Findings

### M1. `GetOrCreate` rebuilds (and re-arms) the capture for PLCs nobody is viewing
`TagCaptureRegistry.cs:29-39`, `ConfigReconciler.cs:308,364,400`. On every Reseat/Restart/Add, `ConfigReconciler` calls `GetOrCreate`, whose `AddOrUpdate` update-delegate **always constructs a brand-new `TagValueCapture`** and copies `existing.IsArmed` onto it. For the overwhelmingly common case — a PLC with no open detail page — this allocates a fresh capture (arrays + `FrozenDictionary`) on every hot-reload for every reconciled PLC, throwing away an identically-shaped object. `FrozenDictionary` construction is not free; doing 54 of them on a tag-list reload is wasteful churn. Functionally harmless, but it contradicts the "on-demand, cheap when no viewer" spirit. Fix: when `!existing.IsArmed` **and** the resolved tag set is unchanged, return `existing` unchanged. A cheap tag-set equality check (ordered address+width sequence) avoids the rebuild for the no-op-reload case entirely.

### M2. Armed-flag preservation in `GetOrCreate` races `Arm`/`Disarm`
`TagCaptureRegistry.cs:33-38`. The update delegate reads `existing.IsArmed`, builds `rebuilt`, conditionally `rebuilt.Arm()`s, and returns it. `ConcurrentDictionary.AddOrUpdate` may invoke this delegate **more than once** under contention, and — more importantly — there is no synchronization between this delegate and a concurrent `StatusHub.SubscribePlc` → `Registry.Arm` / `OnDisconnectedAsync` → `Registry.Disarm`. Interleavings that lose an arm/disarm:
- A viewer opens the detail page (`Arm`) *after* the delegate reads `existing.IsArmed == false` but *before* `AddOrUpdate` swaps `rebuilt` in. The `Arm` lands on `existing`, which is then discarded — the new `rebuilt` is published **disarmed**. The detail page stays open but capture is silently off until the next subscribe event (there is none — subscription already completed).
- Symmetrically, a `Disarm` racing the rebuild can be lost, leaving a capture armed with no viewer (a slow leak — see M3).
This is a genuine lost-update race. The window is small (config reload concurrent with a detail-page open) but real. Fix: serialize capture arm-state transitions — e.g. funnel `Arm`/`Disarm`/`GetOrCreate` through the `PlcSubscriptionTracker`'s authoritative subscriber count: after a reseat, `GetOrCreate` should set the rebuilt capture's armed state from `tracker.ActivePlcs()` (the source of truth) rather than from the soon-to-be-discarded `existing` object. That makes the tracker — not a transient capture instance — the single owner of arm state.

### M3. A capture can stay armed forever if the last viewer's disconnect cleanup is lost
`StatusHub.cs:60-66`, `PlcSubscriptionTracker.cs:50-73`, `TagCaptureRegistry.cs:56-60`. Disarm happens only on three paths: `OnDisconnectedAsync`, `StatusBroadcaster.StopAsync` → `DisarmAll`, and (indirectly) a hot-reload `Remove`. `OnDisconnectedAsync` is best-effort in SignalR — for an abruptly-killed browser tab it fires on the **server transport-timeout** (default ~30 s for WebSockets, longer for long-polling), which is acceptable. But two narrower holes remain:
- If `Remove` is called for a hot-reload-removed PLC (`ConfigReconciler.cs:259`) while a detail page is still open, the capture object is dropped from the registry but the `PlcSubscriptionTracker` still holds the connection→PLC subscription. The eventual `OnDisconnectedAsync` calls `Registry.Disarm(plcName)` which is now a no-op (PLC unknown) — fine — but the subscriber count for the removed PLC is never reconciled, and if that PLC name is later re-added by another hot-reload, `GetOrCreate` creates a *disarmed* capture even though a stale subscription still nominally exists. Minor in practice.
- Combined with M2: a `Disarm` lost to the rebuild race leaves a capture armed with no viewer until the next `DisarmAll` (admin port hot-reload or shutdown). That capture keeps doing the full `Record` work (volatile write + `FrozenDictionary` lookup + record allocation) on the proxy hot path for every BCD PDU, indefinitely. Low-frequency trigger, unbounded duration. The fix for M2 closes this.

There is no periodic reconciliation of "armed captures vs. tracker subscriber counts." Consider a guard: `StatusBroadcaster.PushOnceAsync` already enumerates `tracker.ActivePlcs()` every tick — it could cheaply assert/repair that exactly those PLCs (and no others) are armed, turning any lost arm/disarm into a self-healing condition within one push interval.

## Minor Findings

- **`BcdPduPipeline.cs:448-479` — `ProcessFc06Response` does not `Record`.** FC06 write is captured on the *request* path (`ProcessFc06Request`, line 144) with the client's binary `value` and the `encoded` BCD it sent to the PLC. The FC06 *response* decodes the PLC's BCD echo back to binary but does not record. This is defensible (the request already captured the write, and recording the echo would just re-stamp `UpdatedAtUtc`), and the inline comment correctly notes the counter is intentionally not double-incremented. But it is asymmetric with FC03/FC04, which record on the response. Leave as-is, or add a one-line comment in `ProcessFc06Response` stating capture is intentionally request-side only for FC06 — otherwise a future reader will "fix" the perceived omission.

- **`BcdPduPipeline.cs:251` — FC16 32-bit `Record` passes `binaryValue` reconstructed as `clientHigh * 10_000 + clientLow`.** This is the base-10000 CDAB reconstruction used for encoding, consistent with the FC03/FC04 read path (`Decode32`) and with `DebugDto.ToTagDto`'s `0x{RawHigh:X4}{RawLow:X4}` rendering. Correct — but note the `DecodedValue` shown for a 32-bit tag is the base-10000 composed integer, not a true binary 32-bit value. That matches the rest of the proxy's 32-bit BCD model; just confirm the UI labels it consistently. No code change needed.

- **`TagValueCapture.cs:136-146` — `Snapshot()` allocates a fresh `TagValueObservation` for every empty slot on every call.** `StatusBroadcaster` calls `BuildDebug` → `Snapshot` once per push interval per *viewed* PLC, so this is bounded and cheap (only viewed PLCs, low cadence). Not worth fixing, but the placeholder records for never-seen tags could be cached once at construction since they are constant. Optional micro-optimization.

- **`TagValueCapture.cs:68-74` — constructor de-dups tags by `Address` via `GroupBy(...).Select(g => g.First())`.** If the resolved `BcdTagMap` ever contains two tags at the same address with different `Width` (it should not — the map resolver should reject that), the capture silently keeps the first and the debug view width could disagree with what the rewriter actually does. Low risk given upstream validation, but a defensive assert or a comment that the map is already de-duplicated would help.

- **`TagCaptureRegistry.cs:45-46` — `TryGet`'s `out` uses `capture!` to suppress nullability.** Fine, but callers (`StatusSnapshotBuilder.BuildDebug:46`) correctly branch on the bool first. No issue; noted only for completeness.

- **`PerPlcContext.cs` clone — `WithCurrentRequest` copies `Capture` by reference.** Correct: the capture is per-PLC and shared across all per-call context clones, which is exactly what's wanted (all concurrent responses for one PLC record into the same capture). Confirmed not a bug.

## What Looks Good

- **Disarmed hot-path cost meets the contract.** `Record` is `if (!_armed) return;` — `_armed` is a `volatile bool`, so it is one volatile read; reaching `Record` at all is one `?.` null check on `ctx.Capture`. No allocation, no dictionary lookup, no lock on the disarmed path. The CLAUDE.md claim ("one nullable-deref + one volatile read when disarmed") is accurate.
- **Torn-read safety is correctly implemented.** `TagValueObservation` is a `sealed record` with init-only positional members — genuinely immutable. Slots are `TagValueObservation?[]`, mutated only via `Volatile.Write` and read only via `Volatile.Read`. Reference assignment is atomic on all .NET-supported architectures, and the record being immutable means a reader either sees the old reference or the fully-constructed new one — never a torn object. `Disarm` clears slots with `Volatile.Write(..., null)`. No lock needed and none taken.
- **`FrozenDictionary` usage is correct.** Built once in the constructor from a plain `Dictionary`, never mutated afterward, only read on the hot path — exactly the intended `FrozenDictionary` use case (build-once, read-many, allocation-free lookup).
- **`Snapshot()` always returns one row per configured tag**, substituting a placeholder (`UpdatedAtUtc = null`, zero values) for never-seen slots, so the debug view renders a stable row set — good UX decision, and `DebugDto.ToTagDto` honours it (`HasValue`, `RawHex = "—"`).
- **`PlcSubscriptionTracker` is clean.** Single-lock, low-frequency, reference-counted; `Add` returns "first subscriber" and `RemoveConnection` returns "PLCs whose count hit 0" — exactly the arm/disarm edges. The lock is appropriate for the low churn.
- **`StatusBroadcaster.StopAsync` calls `DisarmAll`** explicitly, and `AdminEndpointHost.StopCurrentAppAsync` disposes the broadcaster *before* stopping Kestrel — so an AdminPort hot-reload that tears down the SignalR host without firing per-connection `OnDisconnectedAsync` still disarms every capture. This is the deliberate backstop for the "browser tab killed / host torn down" case and it is wired correctly.
- **`ConfigReconciler.Remove` is called on the PLC-removed path** (`ConfigReconciler.cs:259`) before the supervisor is stopped, so a hot-reload-removed PLC does not leak its capture in the registry.
- **DI wiring is correct.** `TagCaptureRegistry` is a singleton in the outer container (`HostingExtensions.cs`), and `AdminEndpointHost.StartAppAsync:197` re-registers the *same instance* into the inner Kestrel container so `StatusHub` shares it — not a second copy. Verified.

## Open Questions

1. C1: is the debug view *expected* to reflect cache-served reads? If the product decision is "debug view shows wire traffic only," then C1 downgrades to a documentation gap — but the UI must then clearly distinguish "cached, no recent wire read" from "tag dead." If the decision is "debug view shows what the client sees," C1 is a real defect and the cache path must record.
2. M2/M3: should arm state be owned by `PlcSubscriptionTracker` (the authoritative subscriber count) rather than carried on the transient `TagValueCapture` instance and copied across rebuilds? That single change removes the lost-update race and makes `GetOrCreate` stateless w.r.t. arm state.
3. Is there value in `StatusBroadcaster` self-healing arm state each tick from `ActivePlcs()`? It already enumerates that set; the reconcile is nearly free and converts any lost arm/disarm into a one-interval transient.
