Files
scadaproj/code-reviews/Overview/findings.md
T
Joseph Doherty c25acd6093 fix(overview): one bad health response no longer stops the dashboard host (scadaproj#29)
ZbHealthReportClient caught only HttpRequestException/OperationCanceledException,
so an unknown Content-Type charset (InvalidOperationException from
ReadAsStringAsync) escaped the probe, faulted Task.WhenAll and the poller
BackgroundService, and StopHost took the dashboard down.

- client: final catch-all returns an Unreachable result for that instance
- poller: per-target Guarded probe + catch-and-log per sweep in ExecuteAsync
- tests: unknown charset (both tiers), arbitrary handler exception, sweep with
  one bad instance, and the BackgroundService survival loop
- code-reviews: Overview-001 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:42:23 -05:00

302 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Code Review — Overview
| Field | Value |
|-------|-------|
| Library | `ZB.MOM.WW.Overview/` (hosted application, not a packed library) |
| Packages | none (app; `IsPackable=false`) |
| Component spec | `docs/plans/2026-07-22-overview-dashboard-design.md` (+ `docs/plans/2026-07-22-overview-dashboard-impl-plan.md`) |
| Shared contract | Consumes the `ZB.MOM.WW.Health` `ZbHealthWriter` JSON body (`/health/ready`, `/health/active`); mirrored in `ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/ZbHealthReport.cs` |
| Status | Reviewed |
| Last reviewed | 2026-11-09 |
| Reviewer | Claude |
| Commit reviewed | `91fb2b4` |
| Open findings | 4 |
## Summary
The dashboard meets its core requirements:
- **No authentication, no credentials, no mutating endpoint.** The only interaction is a per-circuit
pause toggle.
- **No SSRF surface.** Probe URLs come only from validated configuration (absolute http/https).
- **Health-check JSON cannot inject markup.** The one `MarkupString` (cluster evidence line) is built
from `WebUtility.HtmlEncode`-escaped values. Everything else renders through Razor encoding.
- **Blazor state is marshalled and cleaned up.** Store-change notifications go through `InvokeAsync`,
and the page unsubscribes and disposes its timer.
- **Sweeps cannot overlap.** There is a single writer and an immutable snapshot swap.
- **DI is covered.** `BootTests` boots the real host through `WebApplicationFactory`, so the DI
wiring has container-level coverage.
The suite passes: 188/188 tests.
The one serious defect is error handling at the probe boundary, Overview-001. `ZbHealthReportClient`
catches only `HttpRequestException` and `OperationCanceledException`. Any other exception from one
instance's response faults the poller `BackgroundService`, and the default `StopHost` policy then
takes down the whole dashboard. This was verified end-to-end: a single registered endpoint answering
with an unknown `charset` stopped the host within one sweep.
The other findings cover:
- leader-chip mis-attribution when two cluster groups of one application share hosts, which is the
QA ScadaBridge layout (Overview-002);
- a status-only active probe that reads any 200, including a followed login redirect, as Active
(Overview-003);
- one slow instance gating the whole sweep (Overview-004);
- stale docs (Overview-005).
Known open item, not a code finding: scadaproj #14 (re-run the two OtOpcUa-blocked Phase 4
live-acceptance checks).
## Checklist coverage
| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | ☑ | Status derivation, damping and staleness are correct and table-tested. See Overview-002 (leader resolution scans the whole application) and Overview-003 (any 200 = Active). |
| 2 | Public API surface & compatibility | ☑ | App, not a library. The `ZbHealthReport` mirror is tolerant (case-insensitive, open `data` as `JsonElement`). No issues found. |
| 3 | Concurrency & thread safety | ☑ | Single-writer poller. `PollTarget.Last` is written only after `Task.WhenAll`. `Volatile` snapshot swap. Blazor updates go through `InvokeAsync`. The `_paused` read off the render thread is benign. No issues found. |
| 4 | Error handling & resilience | ☑ | See Overview-001 (one bad response kills the host) and Overview-004 (one slow instance stales every card). |
| 5 | Security & secret handling | ☑ | Anonymous and read-only by design. No credentials in registry or code. `MarkupString` input is HTML-encoded. Management links and probe URLs come from config only and are scheme-validated. No issues found. |
| 6 | Performance & resource management | ☑ | One pooled `IHttpClientFactory` client per sweep. Per-request linked CTS disposed. Page timer and event handler released in `Dispose`. No issues found beyond Overview-004. |
| 7 | Spec & shared-contract adherence | ☑ | Matches design §2 (anonymous, cache-first) and the Health 0.3.0 `activeNode`/`leader` data keys. See Overview-005: the design and options docs say `/health/active` is probed only when `HasActiveRole`, but the code probes it unconditionally. |
| 8 | Packaging, dependencies & project layout | ☑ | Inline pins (family app convention). No Auth/Audit/Secrets references, as required. Package source mapping restricts the Gitea feed to `ZB.MOM.WW.*`. No issues found. |
| 9 | Testing coverage | ☑ | Strong: poller, client, derivation, leader resolver, page and card via bUnit, boot. Gaps: no test for a non-`HttpRequestException` failure from a probe (Overview-001), no cross-group same-host leader test (Overview-002), no redirect test (Overview-003). |
| 10 | Documentation & XML docs | ☑ | See Overview-005. The `deployment.md` §7 claim that QA's registry resolves leaders correctly is undermined by Overview-002. |
## Findings
### Overview-001 — Any non-`HttpRequestException` from one instance faults the poller and stops the whole dashboard
| | |
|--|--|
| Severity | High |
| Priority | P1 |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/ZbHealthReportClient.cs:99` (catch list `:122-134`; unguarded loop `ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/OverviewPollerService.cs:86-95`) |
**Description**
The method promises it "never throws for an unreachable endpoint". It catches only
`OperationCanceledException`, `HttpRequestException` and, around deserialization, `JsonException`.
Other exceptions reachable from a remote response escape `ProbeAsync`. The confirmed case is
`response.Content.ReadAsStringAsync` (`:99`), which throws **`InvalidOperationException`** when the
`Content-Type` charset is not a known encoding.
From there the exception propagates up through each layer, none of which catches it:
1. The probe's `ProbeAsync` task faults.
2. `Task.WhenAll` in `SweepAsync` rethrows it.
3. `ExecuteAsync` catches only `OperationCanceledException` when stopping.
4. The `BackgroundService` faults.
5. The host applies the default `HostOptions.BackgroundServiceExceptionBehavior = StopHost`.
A malformed answer from any *one* registered instance therefore terminates the entire dashboard. This
contradicts design §5, which classes a non-JSON or failed answer as an **Unreachable** instance,
not a fatal error.
**Failure scenario**
A registered `BaseUrl` points at something that is not a healthy family app: a port reused by another
service, a misconfigured reverse proxy, or an app that emits `Content-Type: application/json;
charset=<unsupported>`. Verified end-to-end with `WebApplicationFactory<Program>` and a local
`HttpListener` answering `charset=bogus-xyz` for one of two instances. Within the first sweep the log
showed:
- `[ERR] BackgroundService failed — System.InvalidOperationException: The character set provided in
ContentType is invalid`
- `[FTL] ... BackgroundServiceExceptionBehavior is configured to StopHost ... the IHost instance is
stopping`
`ApplicationStopping` fired within 256 ms. On wonder, NSSM restarts the service, and it dies again on
its first sweep: a crash loop. Operators lose the fleet view because of a single bad entry.
**Recommendation**
- In `ZbHealthReportClient.SendAsync`, after the `OperationCanceledException` filters, add a final
`catch (Exception ex)` that returns `Failed(startedAt, statusCode?, ex.Message)`.
- Alternatively, read the body as bytes and decode as UTF-8 yourself, which removes the charset
dependency.
- Defence in depth: wrap each per-target `ProbeAsync` in `SweepAsync` so a throwing probe becomes an
`Unreachable` result for that instance only, and catch-and-log per sweep in `ExecuteAsync` so one
bad sweep cannot end the loop.
- Add a test with a handler returning an invalid charset, and one whose handler throws an arbitrary
exception.
**Resolution**
Resolved 2026-11-09 in the commit tagged `(scadaproj#29)` — `ZbHealthReportClient.SendAsync` now ends with a catch-all that returns an Unreachable result (keeping the status code when one was received), so an unknown `charset` or any other non-shutdown exception costs only that instance. Defence in depth: `OverviewPollerService` wraps each per-target probe (`Guarded`) and catches-and-logs per sweep in `ExecuteAsync`, so neither a throwing probe nor a throwing sweep can fault the `BackgroundService`. Regression tests: `ZbHealthReportClientTests.Probe_UnknownCharset_IsUnreachableNotThrown` (both tiers), `Probe_ArbitraryHandlerException_IsUnreachableNotThrown`, `OverviewPollerServiceTests.Sweep_OneInstanceWithAnUnreadableBody_DoesNotFaultTheSweep` and `ExecuteLoop_SurvivesAnInstanceWithAnUnreadableBody` — all five fail without the fix (the loop test with the same `InvalidOperationException` from the finding); the poller tests also pass with the client fix reverted, proving the per-target guard independently.
### Overview-002 — Leader address is resolved against every instance of the application, not the group, so co-located groups steal each other's chip
| | |
|--|--|
| Severity | Medium |
| Priority | P2 |
| Category | Correctness & logic bugs |
| Status | Open |
| Location | `ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/LeaderResolver.cs:85` (scan at `:150-166`) |
**Description**
`ResolveGroup` passes `allInstances` (every instance in the application) to
`ResolveToInstanceName`. That method returns the **first** instance in registry order whose
`BaseUrl` host equals the Akka address host.
A group's leader is by definition one of that group's members, but the members are never searched
first. Whenever two groups of the same application share hosts, the group listed later gets the
earlier group's instance name on its "Leader ·" chip. No card in the right group then gets the Leader
badge: `IsLeader` compares within the group, and the resolved name belongs to the other group.
`ZB.MOM.WW.Overview/CLAUDE.md` and `deployment.md` §7 describe this as a single-box problem (worked
around on VD03 with `127.0.0.1` BaseUrls) and state that "QA's registry can use real FQDNs" because
the hosts are distinct. On QA, however, ScadaBridge **central-a and site-a both run on vq03, and
central-b and site-b on vq04** (`deployment.md:52-53`), all inside one `ScadaBridge` application.
**Failure scenario**
On the QA dashboard (`vq03:5320` / `vq04:5320`), ScadaBridge's central group is listed first and the
site cluster's leader is on vq03. A scratch test of that exact layout gave:
- `central: leaderInstance=central-a`
- `site: leaderInstance=central-a`
So the site group header reads "Leader · central-a" (a node in a different Akka cluster), and neither
site card shows the Leader chip. This is the only environment with a real HA pair, which is where
operators read leadership during failover. This is inferred from the documented QA layout; the QA
overlay is deployed-only and was not read.
**Recommendation**
In `ResolveGroup`, resolve against `members` first and fall back to `allInstances` only if no member
matches. Add a `LeaderResolverTests` case with two co-located groups. Then revisit the CLAUDE.md /
`deployment.md` guidance: with the fix, the VD03 `127.0.0.1` workaround also becomes unnecessary.
**Resolution**
_Unresolved._
### Overview-003 — Status-only active probe treats any 200, including a followed redirect to a login page, as Active
| | |
|--|--|
| Severity | Low |
| Priority | P3 |
| Category | Correctness & logic bugs |
| Status | Open |
| Location | `ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/ZbHealthReportClient.cs:112` (client config `ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/PollingServiceCollectionExtensions.cs:25`) |
**Description**
`ProbeStatusAsync` (`bodyRequired: false`) returns `Reachable = true` with the HTTP status even when
the body is not health JSON. `StatusDerivation.ObserveActive` then maps 200 to `Active`.
The named `HttpClient` keeps the default `AllowAutoRedirect = true`. If `/health/active` 302s to a
login page, which is how ASP.NET cookie auth answers an unauthenticated request, the client follows
the redirect and the final 200 HTML page is reported as **Active**.
The ready tier is protected, because its body must parse. The active tier is not.
**Failure scenario**
A family app is redeployed with a fallback authorization policy that accidentally covers
`/health/active`. This shape has happened before: OtOpcUa's LocalDb sync POST was once answered by
the login 302. Both nodes of the pair now show the **Active** chip, and the raw-signals footer reads
`active 200`, while their true role is unknown.
Scratch test: a handler returning `200 text/html <html>login</html>` gives
`ObserveActive(true, probe) == Active`.
**Recommendation**
Configure the probe handler with `AllowAutoRedirect = false`
(`ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false })`)
so a 3xx maps to `Unknown`. Optionally, for a non-empty body that is not health JSON, treat 200 as
`Unknown`: the comment's legitimate case is an *empty* body.
**Resolution**
_Unresolved._
### Overview-004 — One slow instance gates the single sweep, so every other instance goes Stale
| | |
|--|--|
| Severity | Low |
| Priority | P3 |
| Category | Error handling & resilience |
| Status | Open |
| Location | `ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Polling/OverviewPollerService.cs:120` (sequential ready + active at `:155-171`; validator `ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Registry/OverviewOptionsValidator.cs:69-76`) |
**Description**
All due targets are probed in one `Task.WhenAll`, and the next sweep cannot start until the slowest
target finishes. `PeriodicTimer` coalesces missed ticks. Each target makes up to two sequential
requests (ready, then active), so a sweep can last up to `2 × max(effective Timeout)`.
The validator checks `StaleAfter > PollInterval` per instance. It does not check that any instance's
`StaleAfter` exceeds the worst-case sweep duration imposed by *other* instances' timeouts. Per-instance
`TimeoutSeconds` overrides are explicitly supported.
**Failure scenario**
An operator gives a slow WAN site a `TimeoutSeconds: 30` override. That site then hangs, accepting
TCP but not answering. Every sweep now takes about 60 s. Every other instance, with the default
`StaleAfterSeconds: 45`, is re-polled only every ~60 s, so the whole dashboard flips to **Stale**
between polls even though everything else is healthy.
Scratch test: with one instance whose handler delays 4 s, `SweepAsync` took **8.0 s** while the other
instance answered instantly.
**Recommendation**
Decouple targets: run each target on its own loop or task, or let the sweep publish as results
arrive and not wait for laggards past their own interval. At minimum, extend the validator: reject a
configuration where any instance's effective `StaleAfter` is ≤ `2 × max effective Timeout` across the
registry, naming the offending override.
**Resolution**
_Unresolved._
### Overview-005 — Docs say `/health/active` is probed only when `HasActiveRole`; the code probes it for every reachable instance
| | |
|--|--|
| Severity | Low |
| Priority | P3 |
| Category | Documentation & XML docs |
| Status | Open |
| Location | `ZB.MOM.WW.Overview/src/ZB.MOM.WW.Overview/Registry/OverviewOptions.cs:9` (also `:76-80`) |
**Description**
Two places say the active tier is gated on the role:
- The `OverviewOptions` remarks (`:8-10`) say "GET `{BaseUrl}/health/ready`, plus
`{BaseUrl}/health/active` when `HasActiveRole`".
- `InstanceEntry.HasActiveRole` (`:76-80`) says single-instance gateways "are never probed for
`/health/active`".
Design doc §3 (line 81) and §5 ("only when `HasActiveRole`") say the same. `OverviewPollerService.ProbeAsync` (`:162-171`) deliberately
probes the active tier **unconditionally** whenever ready answered. Only the *interpretation* is gated
on `HasActiveRole`, so that MxAccessGateway's session counts on the active tier are visible.
**Failure scenario**
An operator or reviewer relies on the documented behaviour, for example when sizing the probe load,
or when deciding that a gateway's active-tier auth or rate limit is irrelevant because "Overview never
calls it". They are surprised by twice the requests per gateway, or by gateway access-log noise. A
future maintainer may also "fix" the code to match the doc and silently remove the MXAccess session
row.
**Recommendation**
Update the `OverviewOptions` remarks, the `HasActiveRole` XML doc and design §3: the active tier is
probed whenever ready answers, and `HasActiveRole` controls only whether an Active/Standby badge is
shown.
**Resolution**
_Unresolved._