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