diff --git a/Darling/Darling.Tests/CapturingTestLogger.cs b/Darling/Darling.Tests/CapturingTestLogger.cs
index 559f4574ee..9dffce0062 100644
--- a/Darling/Darling.Tests/CapturingTestLogger.cs
+++ b/Darling/Darling.Tests/CapturingTestLogger.cs
@@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using Microsoft.Extensions.Logging;
namespace Darling.Tests;
@@ -35,6 +36,16 @@ public string Joined
}
}
+ /// How many lines were logged at exactly this level — #4276's tests count Warning/Error lines
+ /// rather than parsing .
+ public int CountAtLevel(LogLevel level)
+ {
+ lock (_lines)
+ {
+ return _lines.Count(line => line.StartsWith(level.ToString() + ":", StringComparison.Ordinal));
+ }
+ }
+
public IDisposable? BeginScope(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
diff --git a/Darling/Darling.Tests/DarlingWebFailureHandlingTests.cs b/Darling/Darling.Tests/DarlingWebFailureHandlingTests.cs
new file mode 100644
index 0000000000..d5d1880445
--- /dev/null
+++ b/Darling/Darling.Tests/DarlingWebFailureHandlingTests.cs
@@ -0,0 +1,349 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.IO;
+using System.Net;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+using PerformanceMonitor.Darling.Analysis;
+using PerformanceMonitor.Darling.Service.Hosting;
+using PerformanceMonitor.Darling.Service.Mcp;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4276: a web read that timed out came back as an empty HTTP 500 with no trace in the service log, because
+/// /api/ag and /api/fleet had no try/catch of their own — their exception reached ASP.NET Core's
+/// own error handling, which writes into the providers ConfigurePipeline clears on purpose (see its
+/// ClearProviders comment). This class covers the fix in two layers:
+/// itself (the classifier + the one log line + the body, unit-tested directly), and the WIRED pipeline (the
+/// #4128 TestServer pattern established) so the ordering
+/// and end-to-end behavior are proven against the real ConfigurePipeline, not a hand-copied second one.
+///
+public sealed class DarlingWebFailureHandlingTests
+{
+ /* ═══════════════════════════ DarlingWebFailureLog: the shared classifier ═══════════════════════════ */
+
+ [Fact]
+ public void IsStatementTimeout_PostgresException57014_IsTrue()
+ {
+ var ex = new PostgresException("canceling statement due to statement timeout", "ERROR", "ERROR", "57014");
+ Assert.True(DarlingWebFailureLog.IsStatementTimeout(ex));
+ }
+
+ [Fact]
+ public void IsStatementTimeout_NpgsqlExceptionWrappingTimeoutException_IsTrue()
+ {
+ var ex = new NpgsqlException("Exception while reading from stream", new TimeoutException());
+ Assert.True(DarlingWebFailureLog.IsStatementTimeout(ex));
+ }
+
+ [Fact]
+ public void IsStatementTimeout_OtherPostgresException_IsFalse()
+ {
+ var ex = new PostgresException("relation \"x\" does not exist", "ERROR", "ERROR", "42P01");
+ Assert.False(DarlingWebFailureLog.IsStatementTimeout(ex));
+ }
+
+ [Fact]
+ public void IsStatementTimeout_NpgsqlExceptionWithoutTimeoutInner_IsFalse()
+ {
+ var ex = new NpgsqlException("Exception while reading from stream", new IOException("connection reset"));
+ Assert.False(DarlingWebFailureLog.IsStatementTimeout(ex));
+ }
+
+ [Fact]
+ public void IsStatementTimeout_PlainException_IsFalse()
+ {
+ Assert.False(DarlingWebFailureLog.IsStatementTimeout(new InvalidOperationException("boom")));
+ }
+
+ [Fact]
+ public void StatusCode_Timeout_Is503_GenericFailure_Is500()
+ {
+ var timeout = new PostgresException("cancelled", "ERROR", "ERROR", "57014");
+ var other = new InvalidOperationException("boom");
+
+ Assert.Equal(StatusCodes.Status503ServiceUnavailable, DarlingWebFailureLog.StatusCode(timeout));
+ Assert.Equal(StatusCodes.Status500InternalServerError, DarlingWebFailureLog.StatusCode(other));
+ }
+
+ /// Ruled #4276: any other failure returns a generic message, never the exception text — a
+ /// stack-shaped string on the wire is an information leak with no reader who benefits from it.
+ [Fact]
+ public void Body_GenericFailure_NeverCarriesTheExceptionText()
+ {
+ var ex = new InvalidOperationException("super secret internal connection string detail");
+ var body = DarlingWebFailureLog.Body(ex);
+ var wire = body.ToJsonString();
+
+ Assert.DoesNotContain("super secret internal connection string detail", wire, StringComparison.Ordinal);
+ Assert.Equal(DarlingWebFailureLog.GenericMessage, body["error"]!.GetValue());
+ }
+
+ [Fact]
+ public void Body_Timeout_NamesTheRuledMessage()
+ {
+ var ex = new PostgresException("cancelled", "ERROR", "ERROR", "57014");
+ Assert.Equal(DarlingWebFailureLog.TimeoutMessage, DarlingWebFailureLog.Body(ex)["error"]!.GetValue());
+ }
+
+ [Fact]
+ public void Report_Timeout_WritesExactlyOneWarning_NamingRouteElapsedKindTypeAndSqlState()
+ {
+ var logger = new CapturingTestLogger();
+ var ex = new PostgresException("canceling statement due to statement timeout", "ERROR", "ERROR", "57014");
+
+ DarlingWebFailureLog.Report(logger, "/api/ag", 15650, ex);
+
+ Assert.Equal(1, logger.CountAtLevel(LogLevel.Warning));
+ Assert.Equal(0, logger.CountAtLevel(LogLevel.Error));
+ Assert.Contains("/api/ag", logger.Joined, StringComparison.Ordinal);
+ Assert.Contains("15650", logger.Joined, StringComparison.Ordinal);
+ Assert.Contains("timeout", logger.Joined, StringComparison.Ordinal);
+ Assert.Contains(nameof(PostgresException), logger.Joined, StringComparison.Ordinal);
+ Assert.Contains("57014", logger.Joined, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Report_GenericFailure_WritesExactlyOneError_NamingRouteElapsedKindAndType()
+ {
+ var logger = new CapturingTestLogger();
+ var ex = new InvalidOperationException("boom");
+
+ DarlingWebFailureLog.Report(logger, "/api/fleet", 42, ex);
+
+ Assert.Equal(1, logger.CountAtLevel(LogLevel.Error));
+ Assert.Equal(0, logger.CountAtLevel(LogLevel.Warning));
+ Assert.Contains("/api/fleet", logger.Joined, StringComparison.Ordinal);
+ Assert.Contains("42", logger.Joined, StringComparison.Ordinal);
+ Assert.Contains(nameof(InvalidOperationException), logger.Joined, StringComparison.Ordinal);
+ }
+
+ /// #4281: route is request-supplied (the backstop passes context.Request.Path.Value
+ /// straight off the wire), and Kestrel decodes a percent-encoded CR/LF in a path into the real characters
+ /// — so an unsanitized route could forge a second log line. must
+ /// sanitize it the same way does a Host header: CR/LF become
+ /// '.', so the forged text lands inertly inside the one real entry instead of starting a line of its own.
+ [Fact]
+ public void Report_RouteCarriesCrLf_SanitizesSoNoForgedLineReachesTheLog()
+ {
+ var logger = new CapturingTestLogger();
+ var ex = new InvalidOperationException("boom");
+
+ DarlingWebFailureLog.Report(logger, "/api/ag\r\nForged: line", 5, ex);
+
+ Assert.Equal(1, logger.CountAtLevel(LogLevel.Error));
+ Assert.Equal(0, logger.CountAtLevel(LogLevel.Warning));
+ Assert.DoesNotContain("\r", logger.Joined, StringComparison.Ordinal);
+ Assert.DoesNotContain("\n", logger.Joined, StringComparison.Ordinal);
+ Assert.Contains("/api/ag..Forged: line", logger.Joined, StringComparison.Ordinal);
+ }
+
+ /* ═══════════════════════════ the wired pipeline: the top-of-pipeline backstop ═══════════════════════════ */
+
+ /// Adapts (a plain ) to the generic
+ /// 's constructor requires.
+ private sealed class CapturingLogger : ILogger
+ {
+ public readonly CapturingTestLogger Inner = new();
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => Inner.BeginScope(state);
+
+ public bool IsEnabled(LogLevel logLevel) => Inner.IsEnabled(logLevel);
+
+ public void Log(
+ LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ => Inner.Log(logLevel, eventId, state, exception, formatter);
+ }
+
+ ///
+ /// Builds a over the REAL ConfigureResponseCompression + ConfigurePipeline
+ /// (the #4128 pattern established), plus three test-only
+ /// throwing routes registered AFTER ConfigurePipeline returns. Registering them after is legal and
+ /// still covered by the #4276 backstop: Map* only adds to the endpoint data source the implicit
+ /// UseRouting/UseEndpoints pair reads at request time, and
+ /// already proves a route registered from INSIDE ConfigurePipeline (via MapAll) is wrapped by
+ /// middleware registered textually before it — the same nesting these test-only routes rely on. No live
+ /// Postgres needed: every route here throws before ever opening the (unopened) pool.
+ ///
+ private static async Task<(TestServer Server, CapturingTestLogger Logger)> BuildServer()
+ {
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ ContentRootPath = Path.Combine(RepoFile.Root, "Darling", "PerformanceMonitor.Darling.Service"),
+ WebRootPath = "wwwroot",
+ });
+ builder.WebHost.UseTestServer();
+ builder.Logging.ClearProviders();
+
+ var postgres = NpgsqlDataSource.Create("Host=localhost;Database=postgres;Username=darling");
+ builder.Services.AddSingleton(postgres);
+
+ DarlingWebHostService.ConfigureResponseCompression(builder.Services);
+
+ var app = builder.Build();
+
+ var capturing = new CapturingLogger();
+ var host = new DarlingWebHostService(
+ capturing,
+ new WebRuntimeState(),
+ new CollectorRuntimeState(),
+ new WebTlsCertificateState(),
+ new BaselineCache());
+
+ host.ConfigurePipeline(
+ app,
+ postgres,
+ networkMode: false,
+ networkListenIp: null,
+ allowedCidr: IPNetwork.Parse("127.0.0.1/32"),
+ accessToken: "unused-in-loopback-mode",
+ oidcClient: null);
+
+ app.MapGet("/api/__test/timeout", (HttpContext _) =>
+ throw new PostgresException("canceling statement due to statement timeout", "ERROR", "ERROR", "57014"));
+
+ app.MapGet("/api/__test/generic", (HttpContext _) =>
+ throw new InvalidOperationException("super secret internal connection string detail"));
+
+ app.MapGet("/api/__test/abort", (HttpContext context) =>
+ {
+ /* The shape a real aborted read throws: OperationCanceledException carrying the SAME token
+ RequestAborted resolves to, already cancelled — exactly what the #4276 handler's
+ `when (context.RequestAborted.IsCancellationRequested)` guard tests. */
+ context.RequestAborted.ThrowIfCancellationRequested();
+ return Task.CompletedTask;
+ });
+
+ await app.StartAsync();
+ return (app.GetTestServer(), capturing.Inner);
+ }
+
+ private static Task Send(TestServer server, string path, CancellationToken? requestAborted = null)
+ {
+ return server.SendAsync(ctx =>
+ {
+ ctx.Request.Method = "GET";
+ ctx.Request.Path = path;
+ ctx.Request.Headers.Host = "localhost";
+ ctx.Connection.RemoteIpAddress = IPAddress.Loopback;
+ if (requestAborted is { } token)
+ {
+ ctx.RequestAborted = token;
+ }
+ });
+ }
+
+ private static async Task ReadJsonBody(HttpContext ctx)
+ {
+ using var reader = new StreamReader(ctx.Response.Body);
+ return JsonDocument.Parse(await reader.ReadToEndAsync());
+ }
+
+ [Fact]
+ public async Task StatementTimeout_Returns503_WithTheRuledJsonBody_AndWritesOneWarning()
+ {
+ var (server, logger) = await BuildServer();
+ using var _ = server;
+
+ var ctx = await Send(server, "/api/__test/timeout");
+
+ Assert.Equal(StatusCodes.Status503ServiceUnavailable, ctx.Response.StatusCode);
+ Assert.Equal("application/json; charset=utf-8", ctx.Response.ContentType);
+
+ using var json = await ReadJsonBody(ctx);
+ Assert.Equal(DarlingWebFailureLog.TimeoutMessage, json.RootElement.GetProperty("error").GetString());
+
+ Assert.Equal(1, logger.CountAtLevel(LogLevel.Warning));
+ Assert.Equal(0, logger.CountAtLevel(LogLevel.Error));
+ Assert.Contains("/api/__test/timeout", logger.Joined, StringComparison.Ordinal);
+ Assert.Contains("57014", logger.Joined, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task GenericException_Returns500_WithNoExceptionText_AndWritesOneError()
+ {
+ var (server, logger) = await BuildServer();
+ using var _ = server;
+
+ var ctx = await Send(server, "/api/__test/generic");
+
+ Assert.Equal(StatusCodes.Status500InternalServerError, ctx.Response.StatusCode);
+
+ using var json = await ReadJsonBody(ctx);
+ var message = json.RootElement.GetProperty("error").GetString();
+ Assert.Equal(DarlingWebFailureLog.GenericMessage, message);
+ Assert.DoesNotContain("super secret internal connection string detail", message, StringComparison.Ordinal);
+
+ Assert.Equal(1, logger.CountAtLevel(LogLevel.Error));
+ Assert.Equal(0, logger.CountAtLevel(LogLevel.Warning));
+ /* The log line is where the real detail belongs — unlike the wire body, checked above. */
+ Assert.Contains("InvalidOperationException", logger.Joined, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task BrowserAbort_WritesNoLogLine_AndNoBody()
+ {
+ var (server, logger) = await BuildServer();
+ using var _ = server;
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ var ctx = await Send(server, "/api/__test/abort", cts.Token);
+
+ Assert.Equal(0, logger.CountAtLevel(LogLevel.Warning));
+ Assert.Equal(0, logger.CountAtLevel(LogLevel.Error));
+ Assert.Equal("(no log lines captured)", logger.Joined);
+
+ using var reader = new StreamReader(ctx.Response.Body);
+ Assert.Equal(string.Empty, await reader.ReadToEndAsync());
+ }
+
+ /* ═══════════════════════════ source pin: registered ahead of every /api/* route ═══════════════════════════ */
+
+ ///
+ /// ConfigurePipeline maps every /api/* route (the read dispatch, Custom Views, alerts, mute
+ /// rules, fleet sweep, triage) through the ONE DarlingWebEndpoints.MapAll call — it has no
+ /// Map* of its own. So "the backstop covers every route" reduces to "the backstop is registered
+ /// (app.Use) textually ahead of that one call" — middleware registered earlier wraps everything a
+ /// later Map* adds, exactly as already proves for
+ /// the no-store stamp. A backstop registered AFTER would compile and pass every OTHER test in this class
+ /// (they all call ConfigurePipeline directly) while leaving production's real routes uncovered.
+ ///
+ [Fact]
+ public void ExceptionBackstop_IsRegistered_AheadOfMapAll_SoEveryApiRouteIsCovered()
+ {
+ var code = CSharpSourceWalker.StripCommentsAndStrings(
+ RepoFile.ReadRepoFile("Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs"));
+
+ var handler = code.IndexOf("DarlingWebFailureLog.Report(_logger", StringComparison.Ordinal);
+ Assert.True(handler >= 0,
+ "ConfigurePipeline no longer calls DarlingWebFailureLog.Report; this pin is reading nothing.");
+
+ var mapAll = code.IndexOf("DarlingWebEndpoints.MapAll(app", StringComparison.Ordinal);
+ Assert.True(mapAll >= 0,
+ "ConfigurePipeline no longer calls DarlingWebEndpoints.MapAll; this pin is reading nothing.");
+
+ Assert.True(
+ handler < mapAll,
+ "The #4276 exception backstop must be registered (app.Use) AHEAD of DarlingWebEndpoints.MapAll, so "
+ + "it wraps every /api/* route MapAll adds. It is currently registered AFTER, which leaves those "
+ + "routes reaching ASP.NET Core's own error handling again — the empty, untraced 500 #4276 reports.");
+ }
+}
diff --git a/Darling/Darling.Tests/TsqlConventionGuardTests.cs b/Darling/Darling.Tests/TsqlConventionGuardTests.cs
index adba751559..2f0878356a 100644
--- a/Darling/Darling.Tests/TsqlConventionGuardTests.cs
+++ b/Darling/Darling.Tests/TsqlConventionGuardTests.cs
@@ -1433,6 +1433,10 @@ where the bound has nothing to compare against. */
"PerformanceMonitor.Common/SystemHealthParser.cs GbFromKb",
"PerformanceMonitor.Notifications/WebhookAlertService.cs DeriveResourceDatabase",
"Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs IsCommandTimeout",
+ /* #4276: the same expression-bodied classifier shape as IsCommandTimeout just above — it strands
+ only its own SqlState literal ("57014"), not T-SQL or a tempdb label, so no census reads a site
+ of that kind here. */
+ "Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingWebFailureLog.cs IsStatementTimeout",
"Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs ToSettings",
"Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs IsConfigured",
"Darling/PerformanceMonitor.Darling.Service/HypotheticalIndexRequest.cs IsComplete",
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index d15f85e8b9..51d460be3e 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -302,16 +303,27 @@ its own empty state and the nav gate can read the count off the same response. *
{
app.MapGet("/api/read/" + name, async (HttpContext context) =>
{
+ var stopwatch = Stopwatch.StartNew();
string result;
try
{
result = await handler(context, postgres, analysis);
}
+ catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
+ {
+ /* #4276: the browser left. Not a failure — no log line, and rethrown so the #4276
+ top-of-pipeline backstop (which already owns this exact classification) sees the same
+ exception rather than this catch turning it into a written body for a caller who is gone. */
+ throw;
+ }
catch (Exception ex)
{
/* The tools swallow their own exceptions into McpHelpers.FormatError's envelope; this is only a
backstop for a binding-layer throw, built by the same helper so it maps the same way
- (-> HTTP 500) and no bare "Error during ..." sentence is produced anywhere any more. */
+ (-> HTTP 500) and no bare "Error during ..." sentence is produced anywhere any more.
+ #4276: also the one log line this surface was missing — through the same helper the
+ top-of-pipeline backstop uses, so the wording and the timeout/error split cannot drift. */
+ DarlingWebFailureLog.Report(logger, "/api/read/" + name, stopwatch.ElapsedMilliseconds, ex);
result = McpHelpers.FormatError(name, ex);
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingWebFailureLog.cs b/Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingWebFailureLog.cs
new file mode 100644
index 0000000000..92a948fafb
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingWebFailureLog.cs
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Text.Json.Nodes;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+
+namespace PerformanceMonitor.Darling.Service.Hosting;
+
+///
+/// #4276: what an unhandled exception from a web-dashboard route becomes — one log line through the
+/// service's own logger, and a JSON body shaped like DarlingWebEndpoints.ErrorResult — shared by the
+/// top-of-pipeline backstop () and the
+/// /api/read/* dispatcher () so neither the wording nor the
+/// timeout/error split can drift between the two call sites.
+///
+/// Why this needed its own logger, not app.Logger. ConfigurePipeline clears the
+/// web host's log providers on purpose (request noise has no seat in the service log), which leaves
+/// app.Logger a logger with nowhere to write — an endpoint that logged through it would degrade with
+/// no trace, which is the exact gap #4276 reports for /api/ag and /api/fleet: both had no
+/// try/catch, so their exception reached ASP.NET Core's own (silenced) error logging and the browser got an
+/// empty 500. Both call sites here pass the service's real instead.
+///
+internal static class DarlingWebFailureLog
+{
+ /// The body's message for a statement timeout — deliberately silent on WHERE it timed out or
+ /// why; that detail is in the service log, not on the wire to whoever is holding the browser.
+ internal const string TimeoutMessage = "The store took too long to answer this read. Try again in a moment.";
+
+ /// The body's message for anything else. Never the exception text (ruled #4276): a stack-shaped
+ /// string on the wire is an information leak for no reader's benefit — the service log carries the real
+ /// exception for whoever can act on it.
+ internal const string GenericMessage = "Something went wrong answering this request. The service log names what failed.";
+
+ ///
+ /// A statement timeout, either side of the connection: the server cancelling us at its own
+ /// statement_timeout (, SQLSTATE 57014), or Npgsql's own
+ /// client-side CommandTimeout elapsing first (an wrapping a
+ /// — the shape the driver actually throws; a bare
+ /// is not one Npgsql produces, but is accepted too so a hand-built test
+ /// exception classifies the same as the real one).
+ ///
+ internal static bool IsStatementTimeout(Exception exception) =>
+ exception is PostgresException { SqlState: "57014" }
+ || exception is TimeoutException
+ || (exception is NpgsqlException && exception.InnerException is TimeoutException);
+
+ /// The SQLSTATE named in the log line, or "(none)" for anything that isn't a
+ /// — a client-side timeout and a plain bug both carry none.
+ private static string SqlState(Exception exception) =>
+ (exception as PostgresException)?.SqlState ?? "(none)";
+
+ ///
+ /// The ONE log line for a failed request (#4276): a Warning for a timeout, an Error for anything else,
+ /// naming the route, the elapsed milliseconds, the kind, the exception type and the SQLSTATE. No
+ /// throttle — unlike DarlingHttpRefusalLog's refusals, a failed READ on an operator's own LAN
+ /// dashboard is not adversary-shaped traffic, so there is no flood to bound. is
+ /// request-supplied (the backstop passes context.Request.Path.Value straight off the wire), so it
+ /// goes through the same way that log sanitizes a Host
+ /// header, before either branch below writes it.
+ ///
+ internal static void Report(ILogger logger, string route, long elapsedMs, Exception exception)
+ {
+ var safeRoute = DarlingHttpRefusalLog.Sanitize(route, 256);
+
+ if (IsStatementTimeout(exception))
+ {
+ logger.LogWarning(
+ exception,
+ "Web dashboard read {Route} timed out after {ElapsedMs} ms ({Kind}): {ExceptionType}, SQLSTATE {SqlState}",
+ safeRoute, elapsedMs, "timeout", exception.GetType().Name, SqlState(exception));
+ return;
+ }
+
+ logger.LogError(
+ exception,
+ "Web dashboard read {Route} failed after {ElapsedMs} ms ({Kind}): {ExceptionType}, SQLSTATE {SqlState}",
+ safeRoute, elapsedMs, "error", exception.GetType().Name, SqlState(exception));
+ }
+
+ /// The HTTP status a failure answers with: 503 for a timeout (tell-apart-from-a-bug, per the
+ /// issue), 500 for anything else.
+ internal static int StatusCode(Exception exception) =>
+ IsStatementTimeout(exception) ? StatusCodes.Status503ServiceUnavailable : StatusCodes.Status500InternalServerError;
+
+ /// The response body: {"error": "…"}, the same shape
+ /// DarlingWebEndpoints.ErrorResult already writes, so the viewer's existing error display handles
+ /// it without a new code path.
+ internal static JsonObject Body(Exception exception) =>
+ new() { ["error"] = IsStatementTimeout(exception) ? TimeoutMessage : GenericMessage };
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
index 3703ba7961..1ce3892d93 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
@@ -7,6 +7,7 @@
*/
using System;
+using System.Diagnostics;
using System.Globalization;
using System.IO.Compression;
using System.Linq;
@@ -997,9 +998,12 @@ started server so a rebind starts with a clean budget. */
/* Pipeline order: response compression (#4188) runs FIRST of all, ahead of every gate below — it only
transforms an OUTGOING body (Content-Encoding), never a routing or auth decision, so it costs
nothing to wrap the gates' own refusal bodies and the login page in it too. Then the Host-allowlist
- middleware runs on EVERY request (both modes) as the DNS-rebinding guard, then (network mode only)
- the auth middleware, then the no-store stamp on /api/* responses, then DarlingWebEndpoints.MapAll ->
- UseDefaultFiles -> UseStaticFiles. WebApplication auto-inserts UseRouting at the head and
+ middleware runs on EVERY request (both modes) as the DNS-rebinding guard — it must stay FIRST after
+ compression, ahead of the #4276 backstop too (see HostHeaderGuardTests, #1648): that guard is the
+ fix for a previously-exploited hole, and a handler ahead of it would itself be new unauthenticated
+ surface on the tokenless loopback bind. Then (network mode only) the auth middleware, then the
+ #4276 failure backstop, then the no-store stamp on /api/* responses, then DarlingWebEndpoints.MapAll
+ -> UseDefaultFiles -> UseStaticFiles. WebApplication auto-inserts UseRouting at the head and
UseEndpoints at the tail, so the static-file middleware sits behind these gates and serves the SPA
for non-API paths. */
app.UseResponseCompression();
@@ -1183,6 +1187,43 @@ a warning. */
});
}
+ /* #4276: one backstop exception handler, ahead of every route below (DarlingWebEndpoints.MapAll has no
+ Map* of its own outside that one call, so this covers all of them — see
+ DarlingWebFailureHandlingTests' source pin), so a route with no try/catch of its own (the issue's
+ own examples, /api/ag and /api/fleet, plus any future one) cannot reach ASP.NET Core's own error
+ handling — which writes into the providers ClearProviders silenced above, so the browser got an
+ empty 500 with no trace anywhere. AFTER the Host-allowlist guard and the auth gate on purpose (see
+ the pipeline-order comment above app.UseResponseCompression): both already handle their own
+ exceptions (HandleAuthFlowAsync's try/catch above), so this only ever fires for an UNANTICIPATED
+ throw from a gate, or an uncaught one from a route MapAll wires. A client that closed the page is
+ not a failure — DarlingWebFailureLog never sees it, and nothing is written to a caller who is
+ gone. */
+ app.Use(async (context, next) =>
+ {
+ var route = context.Request.Path.Value ?? "/";
+ var stopwatch = Stopwatch.StartNew();
+ try
+ {
+ await next(context);
+ }
+ catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
+ {
+ }
+ catch (Exception ex)
+ {
+ DarlingWebFailureLog.Report(_logger, route, stopwatch.ElapsedMilliseconds, ex);
+
+ /* Only log, per the ruling, once the response has already started — there is no header or
+ body left to change at that point. */
+ if (!context.Response.HasStarted)
+ {
+ context.Response.StatusCode = DarlingWebFailureLog.StatusCode(ex);
+ context.Response.ContentType = "application/json; charset=utf-8";
+ await context.Response.WriteAsync(DarlingWebFailureLog.Body(ex).ToJsonString());
+ }
+ }
+ });
+
/* API responses never cache (#4188): the store mutates continuously, so a stale GET is a stale
dashboard. no-store rather than no-cache/must-revalidate — these bodies carry no ETag, so "cache but
always revalidate" would have nothing to revalidate against and a client that honored only the