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
This commit is contained in:
Joseph Doherty
2026-11-09 14:42:23 -05:00
parent 477245f5db
commit c25acd6093
6 changed files with 172 additions and 17 deletions
@@ -85,7 +85,17 @@ public sealed class OverviewPollerService : BackgroundService
{ {
do do
{ {
await SweepAsync(stoppingToken).ConfigureAwait(false); try
{
await SweepAsync(stoppingToken).ConfigureAwait(false);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
// One bad sweep must not end the loop: a faulted BackgroundService stops the
// whole host under the default StopHost policy (Overview-001). The heartbeat is
// not stamped, so the dashboard's own health check still notices the gap.
_logger.LogError(ex, "Overview poll sweep failed; retrying on the next tick.");
}
} }
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)); while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
} }
@@ -152,7 +162,7 @@ public sealed class OverviewPollerService : BackgroundService
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var timeout = target.Timings.Timeout; var timeout = target.Timings.Timeout;
var ready = await probe.ProbeAsync(target.ReadyUrl, timeout, cancellationToken).ConfigureAwait(false); var ready = await Guarded(probe.ProbeAsync(target.ReadyUrl, timeout, cancellationToken), cancellationToken).ConfigureAwait(false);
// Skip the active probe when nothing answered the ready probe: it would spend a second full // Skip the active probe when nothing answered the ready probe: it would spend a second full
// timeout to learn what we already know, and ObserveActive maps a missing probe to Unknown // timeout to learn what we already know, and ObserveActive maps a missing probe to Unknown
@@ -168,7 +178,7 @@ public sealed class OverviewPollerService : BackgroundService
// not exist. So probe unconditionally and let ObserveActive stay role-gated below. // not exist. So probe unconditionally and let ObserveActive stay role-gated below.
HealthProbeResult? active = null; HealthProbeResult? active = null;
if (ready.Reachable) if (ready.Reachable)
active = await probe.ProbeStatusAsync(target.ActiveUrl, timeout, cancellationToken).ConfigureAwait(false); active = await Guarded(probe.ProbeStatusAsync(target.ActiveUrl, timeout, cancellationToken), cancellationToken).ConfigureAwait(false);
var observed = StatusDerivation.Observe(ready); var observed = StatusDerivation.Observe(ready);
var (status, failures) = StatusDerivation.Damp( var (status, failures) = StatusDerivation.Damp(
@@ -190,6 +200,23 @@ public sealed class OverviewPollerService : BackgroundService
}; };
} }
/// <summary>
/// Defence in depth behind <see cref="ZbHealthReportClient"/>'s own catch-all: a probe that
/// throws anything but a shutdown cancellation becomes an unreachable result for THIS instance
/// only, instead of faulting the sweep's <c>Task.WhenAll</c> (Overview-001).
/// </summary>
private static async Task<HealthProbeResult> Guarded(Task<HealthProbeResult> probe, CancellationToken cancellationToken)
{
try
{
return await probe.ConfigureAwait(false);
}
catch (Exception ex) when (!(ex is OperationCanceledException && cancellationToken.IsCancellationRequested))
{
return new HealthProbeResult(false, null, null, TimeSpan.Zero, $"probe failed: {ex.GetType().Name}: {ex.Message}");
}
}
private OverviewSnapshot BuildSnapshot(DateTimeOffset now) private OverviewSnapshot BuildSnapshot(DateTimeOffset now)
{ {
var applications = _targets var applications = _targets
@@ -48,7 +48,10 @@ public sealed class ZbHealthReportClient
/// <param name="url">Absolute URL of a health endpoint.</param> /// <param name="url">Absolute URL of a health endpoint.</param>
/// <param name="timeout">Per-request timeout.</param> /// <param name="timeout">Per-request timeout.</param>
/// <param name="cancellationToken">Token cancelled when the host is shutting down.</param> /// <param name="cancellationToken">Token cancelled when the host is shutting down.</param>
/// <returns>The probe result; this never throws for an unreachable endpoint.</returns> /// <returns>
/// The probe result. This never throws for anything the remote end does — only for a
/// cancellation of <paramref name="cancellationToken"/> (host shutdown).
/// </returns>
public Task<HealthProbeResult> ProbeAsync( public Task<HealthProbeResult> ProbeAsync(
string url, string url,
TimeSpan timeout, TimeSpan timeout,
@@ -62,7 +65,10 @@ public sealed class ZbHealthReportClient
/// <param name="url">Absolute URL of a health endpoint.</param> /// <param name="url">Absolute URL of a health endpoint.</param>
/// <param name="timeout">Per-request timeout.</param> /// <param name="timeout">Per-request timeout.</param>
/// <param name="cancellationToken">Token cancelled when the host is shutting down.</param> /// <param name="cancellationToken">Token cancelled when the host is shutting down.</param>
/// <returns>The probe result; this never throws for an unreachable endpoint.</returns> /// <returns>
/// The probe result. This never throws for anything the remote end does — only for a
/// cancellation of <paramref name="cancellationToken"/> (host shutdown).
/// </returns>
/// <remarks> /// <remarks>
/// For the active tier the status code IS the answer — 200 means active, 503 means standby — /// For the active tier the status code IS the answer — 200 means active, 503 means standby —
/// and the body carries nothing the dashboard uses. Demanding a parseable body here would /// and the body carries nothing the dashboard uses. Demanding a parseable body here would
@@ -89,6 +95,7 @@ public sealed class ZbHealthReportClient
// timed-out instance. // timed-out instance.
using var timeoutSource = new CancellationTokenSource(timeout); using var timeoutSource = new CancellationTokenSource(timeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutSource.Token); using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutSource.Token);
int? statusCode = null;
try try
{ {
@@ -96,8 +103,8 @@ public sealed class ZbHealthReportClient
.GetAsync(url, HttpCompletionOption.ResponseContentRead, linked.Token) .GetAsync(url, HttpCompletionOption.ResponseContentRead, linked.Token)
.ConfigureAwait(false); .ConfigureAwait(false);
statusCode = (int)response.StatusCode;
var body = await response.Content.ReadAsStringAsync(linked.Token).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(linked.Token).ConfigureAwait(false);
var statusCode = (int)response.StatusCode;
ZbHealthReport? report; ZbHealthReport? report;
try try
@@ -132,6 +139,14 @@ public sealed class ZbHealthReportClient
{ {
return Failed(startedAt, null, ex.Message); return Failed(startedAt, null, ex.Message);
} }
catch (Exception ex)
{
// Anything else a remote answer can provoke — e.g. InvalidOperationException from
// ReadAsStringAsync when the Content-Type names an unknown charset — is still a bad
// answer from ONE instance. Letting it escape faulted the poller BackgroundService and
// the host's StopHost policy took the whole dashboard down (Overview-001).
return Failed(startedAt, statusCode, $"unreadable response: {ex.GetType().Name}: {ex.Message}");
}
} }
private static HealthProbeResult Failed(long startedAt, int? statusCode, string error) => private static HealthProbeResult Failed(long startedAt, int? statusCode, string error) =>
@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.Overview.Observability; using ZB.MOM.WW.Overview.Observability;
@@ -434,6 +435,76 @@ public class OverviewPollerServiceTests
Assert.True(polled.IsStale(Start.AddSeconds(46))); Assert.True(polled.IsStale(Start.AddSeconds(46)));
} }
[Fact]
public async Task Sweep_OneInstanceWithAnUnreadableBody_DoesNotFaultTheSweep()
{
// Overview-001 (scadaproj#29): one instance answering with an unknown charset faulted the
// Task.WhenAll, the BackgroundService and then the host. It must cost that instance only.
var routes = new RoutingHandler()
.Json("http://good:8080/health/ready", HttpStatusCode.OK, HealthBody.Ready("Healthy"))
.Status("http://good:8080/health/active", HttpStatusCode.OK);
var handler = new BadCharsetForHost("bad", routes);
var (poller, _) = Build(
Registry(App("App", Node("good", "http://good:8080"), Node("bad", "http://bad:8080"))),
handler,
new FakeTimeProvider(Start));
var snapshot = await poller.SweepAsync();
var good = snapshot.AllInstances.Single(i => i.Instance == "good");
var bad = snapshot.AllInstances.Single(i => i.Instance == "bad");
Assert.Equal(InstanceStatus.Up, good.Status);
Assert.Equal(InstanceStatus.Unreachable, bad.Status);
Assert.NotNull(bad.Error);
}
[Fact]
public async Task ExecuteLoop_SurvivesAnInstanceWithAnUnreadableBody()
{
// The end-to-end shape of the finding: the BackgroundService must keep running.
var routes = new RoutingHandler();
var handler = new BadCharsetForHost("bad", routes);
var (poller, _) = Build(
Registry(App("App", Node("bad", "http://bad:8080"))),
handler,
new FakeTimeProvider(Start));
await poller.StartAsync(CancellationToken.None);
try
{
for (var i = 0; i < 100 && handler.Calls == 0; i++)
await Task.Delay(20);
await Task.Delay(100);
Assert.True(handler.Calls > 0);
Assert.False(poller.ExecuteTask!.IsFaulted, poller.ExecuteTask.Exception?.ToString());
}
finally
{
await poller.StopAsync(CancellationToken.None);
}
}
/// <summary>Answers every request to <c>host</c> with a 200 carrying an unknown charset.</summary>
private sealed class BadCharsetForHost(string host, RoutingHandler inner) : HttpMessageHandler
{
private int _calls;
public int Calls => Volatile.Read(ref _calls);
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (!string.Equals(request.RequestUri!.Host, host, StringComparison.OrdinalIgnoreCase))
return inner.HandleAsync(request);
Interlocked.Increment(ref _calls);
var content = new ByteArrayContent(Encoding.UTF8.GetBytes(HealthBody.Ready("Healthy")));
content.Headers.TryAddWithoutValidation("Content-Type", "application/json; charset=bogus-xyz");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content });
}
}
[Fact] [Fact]
public async Task Sweep_HostShutdown_AbandonsTheSweepInsteadOfRecordingFailures() public async Task Sweep_HostShutdown_AbandonsTheSweepInsteadOfRecordingFailures()
{ {
@@ -210,6 +210,38 @@ public class ZbHealthReportClientTests
Assert.Null(result.StatusCode); Assert.Null(result.StatusCode);
} }
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Probe_UnknownCharset_IsUnreachableNotThrown(bool bodyRequired)
{
// Overview-001 (scadaproj#29): ReadAsStringAsync throws InvalidOperationException for a
// Content-Type charset it cannot map to an Encoding. That escaped ProbeAsync, faulted the
// poller BackgroundService and the default StopHost policy took the whole dashboard down.
var client = new ZbHealthReportClient(new HttpClient(new BogusCharsetHandler()));
var result = bodyRequired
? await client.ProbeAsync("http://node/health/ready", ProbeTimeout)
: await client.ProbeStatusAsync("http://node/health/active", ProbeTimeout);
Assert.False(result.Reachable);
Assert.Equal(200, result.StatusCode);
Assert.NotNull(result.Error);
}
[Fact]
public async Task Probe_ArbitraryHandlerException_IsUnreachableNotThrown()
{
var client = new ZbHealthReportClient(
new HttpClient(new ThrowingHandler(new InvalidOperationException("boom"))));
var result = await client.ProbeAsync("http://node/health/ready", ProbeTimeout);
Assert.False(result.Reachable);
Assert.Null(result.StatusCode);
Assert.Contains("boom", result.Error, StringComparison.Ordinal);
}
[Fact] [Fact]
public async Task Probe_ReadyTierStillDemandsAParseableBody() public async Task Probe_ReadyTierStillDemandsAParseableBody()
{ {
@@ -233,6 +265,18 @@ public class ZbHealthReportClientTests
}); });
} }
/// <summary>A 200 whose Content-Type names a charset no Encoding maps to.</summary>
internal sealed class BogusCharsetHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var content = new ByteArrayContent(Encoding.UTF8.GetBytes("""{"status":"Healthy","entries":{}}"""));
content.Headers.TryAddWithoutValidation("Content-Type", "application/json; charset=bogus-xyz");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content });
}
}
private sealed class ThrowingHandler(Exception exception) : HttpMessageHandler private sealed class ThrowingHandler(Exception exception) : HttpMessageHandler
{ {
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
+3 -3
View File
@@ -10,7 +10,7 @@
| Last reviewed | 2026-11-09 | | Last reviewed | 2026-11-09 |
| Reviewer | Claude | | Reviewer | Claude |
| Commit reviewed | `91fb2b4` | | Commit reviewed | `91fb2b4` |
| Open findings | 5 | | Open findings | 4 |
## Summary ## Summary
@@ -71,7 +71,7 @@ live-acceptance checks).
| Severity | High | | Severity | High |
| Priority | P1 | | Priority | P1 |
| Category | Error handling & resilience | | Category | Error handling & resilience |
| Status | Open | | 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`) | | 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** **Description**
@@ -125,7 +125,7 @@ its first sweep: a crash loop. Operators lose the fleet view because of a single
**Resolution** **Resolution**
_Unresolved._ 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 ### Overview-002 — Leader address is resolved against every instance of the application, not the group, so co-located groups steal each other's chip
+6 -8
View File
@@ -40,7 +40,7 @@ code-reviews/
## Summary ## Summary
10 of 10 libraries reviewed. 49 pending findings across all libraries. 10 of 10 libraries reviewed. 48 pending findings across all libraries.
Coverage: Secrets, LocalDb, GalaxyRepository and Overview first reviewed, and Auth, Health, Coverage: Secrets, LocalDb, GalaxyRepository and Overview first reviewed, and Auth, Health,
Theme, Audit, Configuration and Telemetry re-reviewed, on 2026-11-09 (#15). Each finding also Theme, Audit, Configuration and Telemetry re-reviewed, on 2026-11-09 (#15). Each finding also
@@ -49,10 +49,10 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low).
| Severity | Open findings | | Severity | Open findings |
|----------|---------------| |----------|---------------|
| Critical | 0 | | Critical | 0 |
| High | 1 | | High | 0 |
| Medium | 15 | | Medium | 15 |
| Low | 33 | | Low | 33 |
| **Total** | **49** | | **Total** | **48** |
## Library Status ## Library Status
@@ -64,7 +64,7 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low).
| [GalaxyRepository](GalaxyRepository/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/2/6 | 8 | 8 | | [GalaxyRepository](GalaxyRepository/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/2/6 | 8 | 8 |
| [Health](Health/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/1 | 1 | 7 | | [Health](Health/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/1 | 1 | 7 |
| [LocalDb](LocalDb/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/4/3 | 7 | 7 | | [LocalDb](LocalDb/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/4/3 | 7 | 7 |
| [Overview](Overview/findings.md) | 2026-11-09 | `91fb2b4` | 0/1/1/3 | 5 | 5 | | [Overview](Overview/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/1/3 | 4 | 5 |
| [Secrets](Secrets/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/6/13 | 19 | 19 | | [Secrets](Secrets/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/6/13 | 19 | 19 |
| [Telemetry](Telemetry/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/3 | 3 | 11 | | [Telemetry](Telemetry/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/3 | 3 | 11 |
| [Theme](Theme/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/1/1 | 2 | 8 | | [Theme](Theme/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/1/1 | 2 | 8 |
@@ -80,11 +80,9 @@ description, location, recommendation — lives in the library's `findings.md`.
_None open._ _None open._
### High (1) ### High (0)
| ID | Library | Title | _None open._
|----|---------|-------|
| Overview-001 | [Overview](Overview/findings.md) | Any non-`HttpRequestException` from one instance faults the poller and stops the whole dashboard |
### Medium (15) ### Medium (15)