Skip to content

Log and answer web-viewer read failures instead of an empty 500 (#4276) - #4281

Merged
erikdarlingdata merged 5 commits into
devfrom
fix/4276-web-read-errors
Sep 25, 2026
Merged

erikdarlingdata merged 5 commits into
devfrom
fix/4276-web-read-errors

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Closes #4276.

Why

A web-viewer read that ran past the viewer role's 15-second statement_timeout came back as an empty HTTP 500, and nothing went into the service log. /api/ag, /api/fleet and other routes had no try/catch of their own. Their exception reached ASP.NET Core's own error handling. That handling writes to the log providers that DarlingWebHostService clears on purpose, to keep request noise out of the service log. The only trace was in PostgreSQL's own log.

What changes

  • A new shared helper, DarlingWebFailureLog (Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingWebFailureLog.cs), tells a statement timeout apart from any other failure. A timeout is a PostgresException with SQLSTATE 57014, or an NpgsqlException that wraps a TimeoutException.
  • The helper writes one log line per failure: a Warning for a timeout, an Error for anything else. The line names the route, the elapsed milliseconds, the kind of failure, the exception type and the SQLSTATE.
  • The route comes from the request. So the helper cleans it with DarlingHttpRefusalLog.Sanitize before it logs it, the same way the refusal log cleans a Host header. A percent-encoded line break in a path cannot start a fake log line. (Commit db1c618.)
  • The helper builds the {"error": "..."} body, the same shape DarlingWebEndpoints.ErrorResult already writes.
  • One backstop exception handler sits in DarlingWebHostService.ConfigurePipeline, ahead of DarlingWebEndpoints.MapAll. MapAll is the only place /api/* routes are mapped, so the handler covers all of them.
    • A timeout answers 503 with this message: "The store took too long to answer this read. Try again in a moment."
    • Anything else answers 500 with a generic message. The response never carries the exception text.
    • A browser that left the page logs nothing and gets nothing written. That case is an OperationCanceledException while RequestAborted is cancelled.
    • If the response has already started, the handler only logs.
  • The /api/read/* dispatcher (DarlingWebEndpoints.cs) now logs through the same helper when its own catch fires. It no longer turns a browser abort into a written body. It rethrows, and the backstop handles the abort.
  • The viewer needs no change. classifyResponse in wwwroot/js/util.js already reads body.error for any response outside 2xx, and errorStrip shows it in the panel. apiGet and every panel loader go through it: pages/ag.js, pages/fleet.js, the Custom View loader in panels.js, and the server-tab composites.
  • Lite has no web host, so there is nothing to mirror there (ruled).

Where the handler sits

The issue sketches the handler at the top of the web pipeline. It sits after UseResponseCompression, which stays first, and after the Host-header guard (the DNS-rebinding guard) and the network-mode auth gate. HostHeaderGuardTests pins that guard as the first app.Use middleware because of #1648, an exploited hole on the loopback bind. A handler ahead of the guard breaks that pin. It also puts new code ahead of a security gate for no gain, since both gates handle their own exceptions. The issue's real need still holds: the handler sits ahead of MapAll, so it covers every /api/* route.

Test plan

  • Darling/Darling.Tests/DarlingWebFailureHandlingTests.cs is new. Its unit tests cover the classifier: both timeout shapes, the status code, the body and the log line.
  • Four live TestServer tests run the real ConfigurePipeline, the pattern DarlingWebResponseCompressionTests set up in Darling: a live HTTP test for both hosts' gates, including /core #4128:
    • A route that throws a 57014 PostgresException returns 503 with the JSON body and writes exactly one Warning.
    • A generic exception returns 500 with no exception text and writes exactly one Error.
    • A browser abort writes no log line and no body.
    • A source pin checks that the handler is registered ahead of DarlingWebEndpoints.MapAll.
  • Revert proof: with the wiring reverted (a scoped git stash), all four live tests fail. With it restored, they pass.
  • Report_RouteCarriesCrLf_SanitizesSoNoForgedLineReachesTheLog passes a route that holds a CR/LF. It checks for exactly one log entry, with no line break in it. With the sanitize call removed, the test fails.
  • After the sanitize fix and a merge of origin/dev: DarlingWebFailureHandlingTests 15/15 and HostHeaderGuardTests 46/46.
  • PerformanceMonitor.Darling.Service and Darling.Tests build with 0 warnings.
  • Targeted classes after the fixes, 86/86 pass: HostHeaderGuardTests, DarlingWebFailureHandlingTests, DarlingWebEditorAttributionTests, DarlingWebResponseCompressionTests, DarlingWebHostGateLiveTests.
  • Full Darling.Tests suite, once, on a fresh darlingtest on a UTC rig after merging origin/dev: 13,975 total, 3 failed.
    • HostHeaderGuardTests.EachHost_InstallsTheHostHeaderGuard_AsItsFirstMiddleware failed because of this PR's first placement. The handler moved behind the guard, as described above, and the class passes.
    • TsqlConventionGuardTests.TheMemberScan_ReadsEveryDeclarationWhole failed because of this PR. DarlingWebFailureLog.IsStatementTimeout is an expression-bodied member, and the member-range walker reads its range short. PgBaselineProvider.IsCommandTimeout has the same harmless shape and is already on the allow list. DarlingWebFailureLog.cs IsStatementTimeout is now in KnownTruncatedRanges, as the test's failure message directs. The class passes alone, 14/14.
    • PgTargetAnomalyTests.TheAuroraWaitProfile_OneHotCollectionStaysQuiet_ASustainedShiftFires_AgainstDevPostgres is the known flake CI flake: PgTarget anomaly/blocking worst-tile assertions fail on first attempts unrelated to the change #4274 and touches none of this PR's files. It passed alone on the same rig, 44/44.
    • The full suite did not run again after those two fixes. CI runs it.
  • Installer.Tests did not run, because the lane rules exclude them. No Lite test ran, since Lite has nothing to change.

CHANGELOG entry

SECTION: Fixed
ENTRY:

🤖 Generated with Claude Code

https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ

erikdarlingdata and others added 5 commits September 25, 2026 07:50
A read that timed out past the viewer role's statement_timeout reached ASP.NET
Core's own error handling, which writes into the log providers the web host
clears on purpose - so the browser got an empty 500 and the service log had
no trace. Adds one backstop exception handler ahead of every /api/* route:
a statement timeout (SQLSTATE 57014, or a client-side Npgsql command
timeout) answers 503 with a message the viewer already knows how to show;
anything else answers 500 with a generic message, never the exception text;
a browser that left the page logs and writes nothing. One shared helper,
DarlingWebFailureLog, so the /api/read/* dispatcher's own catch gets the
same log line it was missing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…s pin

The backstop landed ahead of the DNS-rebinding Host-header guard, which
HostHeaderGuardTests pins as the first middleware for a real reason (#1648).
Moves it to run after that guard and the auth gate instead, still ahead of
every route. Also adds DarlingWebFailureLog.IsStatementTimeout to
TsqlConventionGuardTests' KnownTruncatedRanges: it is the same
expression-bodied classifier shape as the existing IsCommandTimeout entry
right above it, stranding only its own SqlState literal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
context.Request.Path.Value is request-supplied, and Kestrel decodes a
percent-encoded CR/LF in a path into real characters, so an unsanitized
route could forge a second log line. Report now sanitizes once, the same
way DarlingHttpRefusalLog.Sanitize already handles a Host header, with a
256-char cap so a legitimate long API path isn't cut.

Adds a CapturingTestLogger unit test proving a CR/LF-carrying route lands
as one inert entry instead of a forged line. Reverting the sanitize call
makes the new test fail (Assert.DoesNotContain: sub-string found); restoring
it passes again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Pushed db1c618a to this branch, fixing the log-forging gap: DarlingWebFailureLog.Report wrote route into the log line unsanitized. The top-of-pipeline backstop passes context.Request.Path.Value straight off the wire. Kestrel decodes a percent-encoded CR/LF in a path into a real CR/LF. A crafted request path forged a second log line.

What changed

  • DarlingWebFailureLog.Report now sanitizes once at the top: var safeRoute = DarlingHttpRefusalLog.Sanitize(route, 256);, and both the LogWarning (timeout) and LogError (generic failure) branches log safeRoute instead of route. 256, not the default 64, so a legitimate long API path is not truncated. Sanitizing inside Report covers both call sites: the backstop and the /api/read/* dispatcher.
  • Updated Report's doc comment to say the route is request-supplied and sanitized the same way DarlingHttpRefusalLog.Sanitize handles a Host header.

Test

Added Report_RouteCarriesCrLf_SanitizesSoNoForgedLineReachesTheLog next to Report_GenericFailure_... in DarlingWebFailureHandlingTests.cs, using CapturingTestLogger:

DarlingWebFailureLog.Report(logger, "/api/ag\r\nForged: line", 5, new InvalidOperationException());

It asserts exactly one Error entry logged (CountAtLevel(Error) == 1, CountAtLevel(Warning) == 0). The joined log text contains neither \r nor \n, and does contain /api/ag..Forged: line (CR and LF each become a .).

Revert-proof: temporarily reverted Report to log the raw route (removed the sanitize call), rebuilt, and ran the new test alone. It failed as expected:

Report_RouteCarriesCrLf_SanitizesSoNoForgedLineReachesTheLog [FAIL]
  Assert.DoesNotContain() Failure: Sub-string found
Total: 1, Errors: 0, Failed: 1

Restored the sanitize call from a backup copy, confirmed the diff matched the original fix exactly, rebuilt clean (0 Warning(s), 0 Error(s)).

Test totals

  • dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Debug: Build succeeded, 0 Warning(s), 0 Error(s).
  • DarlingWebFailureHandlingTests: 15 total, 0 failed.
  • HostHeaderGuardTests: 46 total, 0 failed.
  • Both classes together: 61 total, 0 failed, 0 errors.

Other notes

  • Branch was 1 commit behind origin/dev (MCP budget: default get_query_store_regressions under the response budget (#4198) #4264, query-store-regressions budget work). Merged it in with no conflicts (DarlingWebEndpoints.cs auto-merged) before pushing, then rebuilt and re-ran both classes clean.
  • Touched only the two files in scope: Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingWebFailureLog.cs and Darling/Darling.Tests/DarlingWebFailureHandlingTests.cs. Did not edit the PR body or CHANGELOG.md, per the brief.
  • Did not run the full suite or a live-Postgres TestServer rig. Neither is needed for this change: it touches no store or database, and the brief scoped verification to the two named classes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 25, 2026 12:32
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 25, 2026 12:32
@erikdarlingdata
erikdarlingdata merged commit baaa4e0 into dev Sep 25, 2026
17 of 18 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4276-web-read-errors branch September 25, 2026 12:38
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Round-1 security review (post-merge) at baaa4e0

Scope: the backstop in DarlingWebHostService.ConfigurePipeline, the /api/read/* dispatcher catch in DarlingWebEndpoints.cs, and the new Hosting/DarlingWebFailureLog.cs. I read the code at the squash commit. I also ran a standalone repro against a local Kestrel. Finding 1 describes it.

Result: 1 Medium and 4 Low. Nothing in the change weakens the Host-header guard order, the auth gate, or the tokenless loopback bind.

Answers to the four questions

  1. Can the Host guard or the auth gate throw past their own handling? I found no path today by which crafted input makes either gate throw. The Host guard uses IPAddress.TryParse and string compares. The auth gate parses the cookie expiry with long.TryParse and catches the FormatException from the base64 decode. It hashes both tokens before it compares them. DarlingHttpRefusalLog.Observe runs under a lock. The only throw source left is the logging call itself. The file provider catches all of its own errors, and I found no realistic throw in the other providers. But the backstop comment is wrong, and finding 4 explains why.
  2. Does a 500 or 503 carry exception text? The backstop does not. Its body is one of two constant strings, and it sets no header from the exception. The /api/read/* path does, and finding 2 names it.
  3. Is the route sanitized at both call sites? Yes. Report sanitizes in one place (DarlingWebFailureLog.cs:71), and the /api/read/* site passes a constant route anyway. But the 256-character cut can split a surrogate pair (finding 1). Also, exception.Message reaches the file log with no sanitizing (finding 5).
  4. Does anything weaken the gates? No. At baaa4e0 the order is compression (1009), the Host guard (1021) and the auth gate (1049, network mode only). The backstop (1201), the no-store stamp (1233) and the routes (1243) come after them. In network mode the backstop sees only requests that the auth gate allowed, after the read-only seat check. On the loopback bind it sees the same requests that the routes already see. Backstop answers still carry Cache-Control: no-store, because the stamp sets the header before it calls next.

1. Medium: a split surrogate pair drops a whole batch of the service log

Where: Hosting/DarlingHttpRefusalLog.cs:294-300 makes the cut. Hosting/DarlingWebFailureLog.cs:71 calls it. The failure happens at DarlingFileLoggerProvider.cs:164.

What fails: Sanitize(route, 256) cuts at 256 UTF-16 code units. Kestrel decodes a percent-encoded character outside the Basic Multilingual Plane into a surrogate pair. If the pair sits at index 255 and 256, the cut keeps only the high surrogate. Flush then calls File.AppendAllText with its default UTF-8 encoding, which throws EncoderFallbackException on a lone surrogate.

The catch at line 167 then drops the lines that Flush already dequeued. The comment there says so: "The dequeued lines are already gone." The batch holds lines from every part of the service, not only the web host. The failure latch reports only the first loss, through the Event Log, and only if the event source exists. Every later loss is silent.

Who can do it: the path must match a route, and the request must then throw. At baaa4e0 the only routes with an unconstrained string path parameter are the three /api/mute-rules/{id} routes. ReadBodyAsync (DarlingWebEndpoints.cs:883) reads the body outside the catch in the mute-rule cores. So one request is enough: PATCH /api/mute-rules/ plus 239 a characters plus %F0%9F%98%80, with Content-Type: application/json, Content-Length: 40000000 and no body.

In loopback mode any local process can send it without a token. In network mode it needs a seat that can edit, because the read-only seat check refuses PATCH. One such request in each 5-second flush window loses the lines of that window.

Repro: a standalone program, not the shipped binary. It copies Sanitize and the Flush write line for line. It runs a Kestrel app with the same backstop shape and a route shaped like the mute-rules PATCH.

The request got a 500 (the same request without the backstop got 413 Payload Too Large). The backstop caught BadHttpRequestException. The decoded path was 257 characters, with a high surrogate at index 255. The sanitized line held a lone high surrogate. File.AppendAllText threw EncoderFallbackException and wrote 0 bytes, so an unrelated line in the same batch was lost too. With new UTF8Encoding(false), the same batch wrote 495 bytes.

Fix:

  • In DarlingFileLoggerProvider.Flush, pass an encoding that does not throw: File.AppendAllText(CurrentLogFile(), sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)). A bad character then becomes U+FFFD, whatever its source, and one line can no longer cost a batch.
  • In DarlingHttpRefusalLog.Sanitize, do not end on a high surrogate (if (take < value.Length && char.IsHighSurrogate(value[take - 1])) take--;). Also map any lone surrogate to ., as the method already does for control characters.
  • Test: add a 255-character route plus one non-BMP character to the CR/LF test, and flush it through a real DarlingFileLoggerProvider in a temp directory.

2. Low: the /api/read/* 500 body carries the exception text

Where: DarlingWebEndpoints.cs:327 (the dispatcher catch) and DarlingWebEndpoints.cs:2948 (the ServerError arm of ToHttpResult), through McpHelpers.ErrorSentence (PerformanceMonitor.Common/Mcp/McpHelpers.cs:635).

What fails: the body is {"error":"Error during <tool>: <ex.Message>"} with HTTP 500. The tools' own catches take the same path, and those are the common case. A PostgresException message starts with the SQLSTATE, then the server's message text, which can name schema objects and roles. An Npgsql connection failure names the endpoint of the store (host and port).

So a statement timeout inside a tool answers 500 with 57014: canceling statement due to statement timeout. It does not get the 503 and the generic message that the backstop gives. The class comment at DarlingWebFailureLog.cs:17-22 says that the wording and the timeout/error split cannot drift between the two call sites. On the wire they do, because the dispatcher shares only Report.

Who sees it: every web seat, including read-only seats, and any local process on the loopback bind. I rate it Low because it needs a session or local access, and I found no credentials in these messages. It is older code, but the #4276 ruling ("never the exception text") applies here too.

Fix: in the dispatcher catch, answer with the same helpers as the backstop:

return Results.Json(DarlingWebFailureLog.Body(ex), statusCode: DarlingWebFailureLog.StatusCode(ex));

The tools' own catches need the same change in the ServerError arm of ToHttpResult. That arm must log the real text before it drops it, unless the tool already logs its catch. The MCP wire keeps FormatError as it is.

The same family exists in older code outside this diff. /api/compose/run answers 500 with Error running query: {ex.Message} (DarlingWebEndpoints.cs:1112) and 400 with the Postgres message text (:1108). The triage page puts ex.Message into its notes with a 200 (DarlingTriageEndpoint.cs:608 and :663).

3. Low: client protocol errors now answer 500 and write an Error line with no limit

Where: DarlingWebHostService.cs:1212-1223, and the "No throttle" reasoning at DarlingWebFailureLog.cs:62-64.

What fails: Kestrel throws BadHttpRequestException into the app for a bad request body. Examples are a body over the size limit (413), bad chunked encoding (400), or a body that arrives too slowly (408). Before this change, Kestrel answered with that status and logged nothing. Now the backstop answers 500 and writes one Error line ("Web dashboard read ... failed") for each request, at a rate that the client chooses. The "not adversary-shaped traffic" reasoning does not hold for these errors, because a client can cause them on purpose at almost no cost.

Who can do it: /api/compose/run catches only JsonException around its body read (DarlingWebEndpoints.cs:514-522). It is the one POST that DarlingWebSeat.IsRequestAllowed (Hosting/DarlingWebSeat.cs:122) lets a read-only seat send. So the least-privileged seat can do it, and so can any local process on the loopback bind. The file log has a 14-day age sweep but no size cap. For a managed store, the log directory and the store's data directory are both under %ProgramData%\PerformanceMonitorDarling, so they share a volume.

Fix: in the backstop, catch BadHttpRequestException before the generic catch. Answer its own status code, and do not log it at Error. If you want a trace, log one throttled Warning through a fold like the one in DarlingHttpRefusalLog.

catch (BadHttpRequestException bad)
{
    if (!context.Response.HasStarted)
    {
        context.Response.StatusCode = bad.StatusCode;
    }
}

4. Low: the backstop comment says that it catches gate throws, but it cannot

Where: DarlingWebHostService.cs:1195-1198.

What fails: the comment says that both gates "already handle their own exceptions", and that the backstop fires for "an UNANTICIPATED throw from a gate". The backstop is registered after both gates, so a gate throw never enters its try. The Host guard has no try/catch. The auth gate has one only around HandleAuthFlowAsync (1107-1124), and the body of that catch has no guard.

If a gate throws, Kestrel answers 500 with an empty body, or resets the connection if the response started. Nothing is logged, because the Kestrel error log goes to the providers that ClearProviders (772) removed. That is the #4276 symptom, on the surface that has no authentication.

There is one more case. The WebApplicationOptions at 703-707 do not set EnvironmentName. If the service environment sets ASPNETCORE_ENVIRONMENT or DOTNET_ENVIRONMENT to Development, WebApplication adds the developer exception page. It adds that page first in the pipeline, ahead of the Host guard. A caller with no credentials then gets the exception message and the stack trace for any throw that escapes.

No shipped config sets that variable, and I found no gate throw path today. But a developer can set that variable for the whole machine on a test box.

Fix: correct the comment. It must say that the backstop covers the routes only, and that a gate throw is not logged. Set EnvironmentName = Environments.Production in the WebApplicationOptions of the web host. If you want gate throws logged, add a log-only try/catch inside each gate body. The middleware order stays the same.

5. Low: exception.Message reaches the file log with no sanitizing

Where: DarlingWebFailureLog.cs:75-85 passes the exception object. DarlingFileLoggerProvider.cs:288 appends | {exception.GetType().Name}: {exception.Message} with no change.

What fails: the third commit sanitized the route, because CR/LF in the route can forge a log line. The same line also carries exception.Message, with no sanitizing and no length cap. An exception message can repeat request text. For example, PostgreSQL repeats a bad input value in a cast error, and KeyNotFoundException includes the key.

I found no route today where a caller's CR/LF reaches an exception that gets to Report. The tools and /api/compose/run catch their Postgres errors, and the BadHttpRequestException messages do not repeat input. So the risk is latent. But this change is what sends exceptions that a request triggers into this logger.

Fix: sanitize in FileLogger.Log, for both the formatted message and exception.Message. Map control characters to . and cap the length, as Sanitize does. That change covers every call site in the service.

Note (not a finding)

IsStatementTimeout (DarlingWebFailureLog.cs:50-53) also matches the Npgsql errors for pool exhaustion and connect timeouts. Npgsql reports both as an NpgsqlException that wraps a TimeoutException. So when the store cannot hand out a connection, the backstop answers 503 "took too long" and logs a Warning that says "timed out", not an Error.

erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
…review)

File.AppendAllText(path, text) with no encoding uses a strict UTF-8 encoder that
throws EncoderFallbackException on a lone surrogate and writes zero bytes,
dropping every line in that batch. #4286 fixed Darling's service logger
(DarlingFileLoggerProvider.cs); this applies the same
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false) third argument to the
other seven batch loggers: Darling's ViewerLogger, Lite's AppLogger,
MethodProfiler and QueryLogger, and their deprecated Dashboard counterparts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
 review)

AppLogger.Flush gains an internal FlushTo(logDirectory) seam -- the write half
of Flush with the directory as a parameter -- so a test can drive a real batch
write without AppLogger.Initialize, which repoints the whole process's static
logging and starts a 5s timer (the hazard AppLoggerRetentionTests already
documents). Mirrors the CleanOldLogs(directory) and DrainBufferedLines seams
added for the same reason.

New tests pin both loggers against the old strict-encoder shape: a batch with
a lone high surrogate plus a normal line must still write the normal line.
Both were reverted to the old File.AppendAllText(path, text) call and
confirmed to fail (empty file, EncoderFallbackException swallowed by the
catch) before the fix was restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
…ks, log sink, sweep GETs (#4281 review)

Fixes six Low findings and one review note from the #4286 round-1 security review:
pin the MCP host's environment to Production like the web host, take IOException the
same as OperationCanceledException on a client abort, treat the C1 controls and
U+2028/U+2029 as control characters in the three places that checked ASCII only, clean
the whole log line at the file sink instead of only the exception message, answer the
two sweep GETs through the dispatcher's failure pattern instead of ex.Message with no
log line, replace the environment test with a source pin on both hosts, and guard
Sanitize against maxLength 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
…requests with their own status (#4286)

* Web failure log: keep the log batch on a split character, answer bad requests with their own status

Follow-up to #4281's round-1 review (#4276). Fixes the Medium and four
Lows the post-merge security review found.

- A 256-char route sanitize cut could split a surrogate pair, and
  File.AppendAllText's strict UTF-8 encoder throws on a lone
  surrogate and writes zero bytes -- dropping the whole 5-second log
  batch, not just the bad line. Sanitize never cuts mid-pair (or
  leaves any other lone surrogate); Flush uses a permissive,
  BOM-less UTF-8 encoding as a second line of defense.
- The backstop now catches BadHttpRequestException before the
  generic catch: answers its own status code, writes no body beyond
  what Kestrel would, and logs at Debug (never Error), so a client
  triggering a 413/400/408 on purpose no longer costs a 500 and an
  Error line per request.
- Corrected the backstop's comment: it covers the routes only, since
  it is registered after both gates and a gate throw never enters
  its try. Pinned EnvironmentName to Production in the web host's
  WebApplicationOptions so an ambient ASPNETCORE_ENVIRONMENT=
  Development can never add the developer exception page ahead of
  the Host guard.
- exception.Message now reaches the file log sanitized (control
  characters mapped to '.', no length cap); the formatted message is
  left alone because DarlingWorker's "Store host profile" line
  embeds '\n' on purpose.
- The /api/read/* dispatcher's own catch (a binding-layer throw, not
  a tool's own swallowed exception) now answers the same ruled body
  and status the top-of-pipeline backstop gives, instead of
  McpHelpers.FormatError's exception text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4286 parity: permissive UTF-8 in the other seven batch loggers (#4281 review)

File.AppendAllText(path, text) with no encoding uses a strict UTF-8 encoder that
throws EncoderFallbackException on a lone surrogate and writes zero bytes,
dropping every line in that batch. #4286 fixed Darling's service logger
(DarlingFileLoggerProvider.cs); this applies the same
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false) third argument to the
other seven batch loggers: Darling's ViewerLogger, Lite's AppLogger,
MethodProfiler and QueryLogger, and their deprecated Dashboard counterparts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4286 parity: pin the surrogate fix in AppLogger and ViewerLogger (#4281 review)

AppLogger.Flush gains an internal FlushTo(logDirectory) seam -- the write half
of Flush with the directory as a parameter -- so a test can drive a real batch
write without AppLogger.Initialize, which repoints the whole process's static
logging and starts a 5s timer (the hazard AppLoggerRetentionTests already
documents). Mirrors the CleanOldLogs(directory) and DrainBufferedLines seams
added for the same reason.

New tests pin both loggers against the old strict-encoder shape: a batch with
a lone high surrogate plus a normal line must still write the normal line.
Both were reverted to the old File.AppendAllText(path, text) call and
confirmed to fail (empty file, EncoderFallbackException swallowed by the
catch) before the fix was restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4286 round-1 fixes: environment pin, abort filter, Unicode line breaks, log sink, sweep GETs (#4281 review)

Fixes six Low findings and one review note from the #4286 round-1 security review:
pin the MCP host's environment to Production like the web host, take IOException the
same as OperationCanceledException on a client abort, treat the C1 controls and
U+2028/U+2029 as control characters in the three places that checked ASCII only, clean
the whole log line at the file sink instead of only the exception message, answer the
two sweep GETs through the dispatcher's failure pattern instead of ex.Message with no
log line, replace the environment test with a source pin on both hosts, and guard
Sanitize against maxLength 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
…on text

DarlingFleetSweepEndpoints.cs and DarlingWebEndpoints.cs both changed the
same catches #4286 touched. Kept this PR's side throughout: the two sweep
GETs (/api/sweeps/latest, /api/sweeps/{id}) get no local catch, matching
every other route #4283 already strips one from, and the #4281
top-of-pipeline backstop (Mcp/DarlingWebHostService.cs) answers a store
fault the same ruled way -- DarlingWebFailureLog.Report, then Body and
StatusCode, confirmed unchanged in the merged backstop. Dropped the
now-unused System.Diagnostics and PerformanceMonitor.Darling.Service.Hosting
usings #4286 had added to DarlingFleetSweepEndpoints.cs for its local
catch's Stopwatch and DarlingWebFailureLog.

The /api/read/* dispatcher catch in DarlingWebEndpoints.cs only conflicted
on its comment; both sides already answer through the same
Report/Body/StatusCode shape, so kept this PR's wording, which is the more
accurate one post-merge (dev's comment called the ServerError arm's own fix
"pending", which this same PR resolves elsewhere in the file).

Rewrote FleetSweepWebFeedTests' TheSweepReadCatches_AnswerTheRuledBodyAndStatus_NotExMessage
(#4286) as TheSweepReads_HaveNoLocalCatch_NotExMessage, pinning the new
shape: neither sweep GET has a local catch, and neither puts ex.Message on
the wire.

Fixed DarlingWebFailureHandlingTests' ReadDispatchCatch_AnswersTheRuledBodyAndStatus_NotFormatError
(#4286), which located the catch block's end by searching for the literal
"return ToHttpResult(result);" -- this PR's own ToHttpResult already takes
four arguments (result, route, logger, elapsedMs) for its own logging, so
that exact literal no longer appears. Matched on the call's start instead
of its whole signature.

WebExceptionTextCensusTests (#4283) was missing [Collection("live-postgres")]
even though its live test opens DARLING_TEST_PG directly -- caught by
LivePostgresCollectionHygieneTests once the full suite ran clean of the
merge conflicts. Added the attribute so it serializes against the rest of
the live-postgres collection like every other class touching the shared
store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
…ng paths (#4283) (#4293)

* Web viewer: stop sending exception text to the browser on the remaining paths (#4283)

Round-1 review of #4281 found three older paths that still answered a failed web
request with the caught exception's own text; this lane's own grep of the web host
found two more. All eight now answer a fixed message and log the real text once
through DarlingWebFailureLog, reusing #4281's classifier and messages rather than a
second copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* Route mute-rule ServerError through ServerErrorResult (#4283 H1)

MuteRuleToolResult wrapped a tool's caught-exception text straight onto
the wire under ErrorResult, never through ServerErrorResult's logged,
fixed-message answer the read surface already uses. Classify the
result first and route ServerError through ServerErrorResult, with a
Stopwatch per mute-rule route matching the /api/read/* loop's timing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* Classify the resolver's own registry-read fault as a ServerError (#4283 H2)

The resolver's store-fault sentence ("Could not read the servers
registry...") fell through ClassifyToolResponse as a bare ClientError,
so ToHttpResult, MuteRuleToolResult and the triage page's note/card all
answered it as a 400 with ex.Message on the wire, instead of the
generic/timeout 500/503 every other caught exception gets.

Factor the sentence's prefix into a constant on DarlingServerResolver,
byte-identical to the old inline literal, and recognize it in
ClassifyToolResponse alongside the existing "Error during " bare
sentence. MCP callers see no change: they read the resolver's sentence
back unchanged, never through ClassifyToolResponse.

The triage note block gets its own matching branch, since it inspects
the resolver's error string directly rather than going through
ClassifyToolResponse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* Anchor the sentence timeout token to a known prefix (#4283 L1)

s_sentenceTimeoutToken matched \b57014\b anywhere in a tool-caught
sentence, so a real SQLSTATE elsewhere in the tail (a quoted value, a
port number) could false-positive a 500 into a 503. Anchor the match
to the start of the sentence, requiring 57014 immediately after one of
the three known prefixes: ErrorSentence's "Error during {op}: ", the
compose route's "Error running query: ", and H2's resolver registry-
read-fault prefix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4283 review round 1 (M1): route a non-actionable compose/run PostgresException through the web backstop

RunComposedPanelAsync's PostgresException catch put every SQLSTATE's real MessageText on the wire at
400, including auth failures (28P01), admin shutdowns (57P01) and other STORE faults a Custom Views
panel author cannot act on. Ruling: keep the FIRST design from the review handoff (ComposeRunOutcome
gets a Fault field; run_custom_view_panel's MCP answer stays byte-for-byte the same either way).

IsComposeRunAuthorActionable classifies 57014 / class 22 / class 42 (except 42501) as author-actionable
- those keep "Query failed: {MessageText}" verbatim at 400. Everything else rides back on
ComposeRunOutcome.Fault, and the web endpoint mapping (factored into a standalone
ComposeRunFailureResult, mirroring ToHttpResult/MuteRuleToolResult so it is directly testable without a
live Postgres round trip) answers it through the same fixed-body backstop #4276 gives an uncaught
exception: 500, one log line, no role/host text on the wire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4283 review round 1 (L2): widen WebExceptionTextCensusTests' census past a fixed ex/exception name

The census only matched (ex|exception).Message(Text)?, so a differently-named caught exception
(pgEx.MessageText) or a bare interpolated {e}/{ex} with no property access at all could reach a web
response unseen. Widened the pattern to any leading identifier before .Message/.MessageText/
.InnerException/.Detail/.Hint/.Where (LINQ .Where( excluded) or an ex-shaped .ToString()/{ex}
interpolation, matched line-by-line instead of a +-60-char window that could spill a neighboring
statement's allow-listed snippet onto an unrelated line.

Added DarlingServerResolver.cs and DarlingWebFailureLog.cs to the roster (both now carry exception-text
code after H1/H2/M1), with the five new allow-list entries the widened pattern surfaces for real:
CustomViewResult/CustomAlertRuleResult's own Conflict.Message/Invalid.Message business text,
CollectorRuntimeState's own snapshot.Detail, DarlingWebFailureLog's own
exception.InnerException type-pattern check, and DarlingServerResolver's registry-read fault sentence
(reclassified through ToHttpResult before the web surface, MCP unchanged, the same shape as
ComposeRunOutcome.ServerError's existing allow-list entry).

Fixed one bug in the review handoff's own proposed regex along the way: its .ToString() alternative
was folded into the unrestricted property-name group, so it matched every unrelated .ToString() in the
roster (value.ToString(), m.Archetype.ToString(), builder.ToString()) instead of only an exception-
shaped receiver - caught by the handoff's own required negative test case for "count.ToString()", which
its literal regex did not actually satisfy. Gave .ToString() its own alternation gated on an ex-shaped
identifier to match the handoff's stated design intent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4293 round 2 (R2-L1, R2-L2): a FATAL/PANIC store fault is no longer treated as author-actionable, and a panel's own query error is logged once

A FATAL 22023/42704 from a startup parameter the server rejects shared IsComposeRunAuthorActionable's SQLSTATE
buckets with a plain ERROR, so it answered 400 with the configured setting's name and value instead of routing
through the Fault backstop. Pull the compose runner's PostgresException decision into a testable
FromPostgresException helper: author-actionable now also requires ERROR severity, so FATAL/PANIC always carries
Fault regardless of SQLSTATE class.

ComposeRunOutcome gains a trailing AuthorSqlState member and an AuthorQueryError factory so
ComposeRunFailureResult can log a single Warning when it answers a panel's own query error at 400 - store drift
(42P01/42703 after an unfinished migration) now reaches the service log, not only one author's browser. The MCP
run_custom_view_panel path is unaffected: it reads only Error/IsServerError, unchanged on every arm.

Adds FromPostgresException coverage for both arms, a source pin on the catch body, and
ComposeRunFailureResult logging coverage for AuthorQueryError vs a plain BadRequest. Updates the stale
revert-proof doc comment on the neighboring M1 test to describe what fails now. Darling.Tests: 0 warnings, 0
errors; WebExceptionTextCensusTests 33 (1 skip, no DARLING_TEST_PG), DarlingComposeTests 280, DocCommentHygieneTests
77, all green. Confirmed each revert-proof by hand: dropping the InvariantSeverity clause fails the FATAL cases,
reverting the catch to the old ternary fails the source pin, and removing the log call fails the logger test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4293 round 2 (R2-L3): an allow-list snippet now vouches only for the match it contains, not the whole line

NoWebEndpoint_BuildsAnAnswerFromExMessage_ExceptTheNamedAllowList used line.Contains(snippet) to decide a match
was accounted for - true the moment the snippet appeared ANYWHERE on the offending line, even for a second,
unrelated exception-text access sharing that line. Add SnippetCovers(line, snippet, column, length), which scans
every occurrence of the snippet on the line and accepts only when the match's own span sits inside one of them.

Pull the per-file loop out of the [Fact] into ComputeUnaccounted(relativePath, code) so a test can run the same
check against one fabricated line instead of the whole roster. Add SnippetCovers unit coverage (covered,
uncovered, a second occurrence), a test running ComputeUnaccounted against the real allow-list on the synthetic
line "Query failed: {ex.MessageText} {ex.Detail}" (one unaccounted match, ex.Detail), and a revert-proof pinning
that the retired line.Contains check finds the snippet anywhere on that line.

Darling.Tests: 0 warnings, 0 errors; WebExceptionTextCensusTests 36 (1 skip, no DARLING_TEST_PG),
DocCommentHygieneTests 77, both green. Confirmed by hand: reverting ComputeUnaccounted's SnippetCovers call back
to line.Contains(snippet) fails SnippetCovers_RealAllowList_OnASyntheticLine_LeavesExDetailUnaccounted (empty
collection instead of the one expected ex.Detail entry).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4293 round 2 (R2-L4): widen s_exMessagePattern to the shapes it still misses, and correct the snapshot.Detail allow-list reason

Four widenings to the exception-text census pattern:
- A ?, ! or closing ) may now sit before the dot on the generic-receiver alternation: ex?.Message, (ex as
  PostgresException)?.MessageText, ex.GetBaseException().Message.
- A second property list (StackTrace, InternalQuery, TableName, SchemaName, ColumnName, ConstraintName,
  Routine) gated on an exception-shaped receiver only (ex/exception/e/pgEx/fault/Fault, bare or at the end of a
  dotted path like outcome.Fault) - TableName/ColumnName are common, harmless names on other receivers, so this
  list cannot reuse the generic-receiver alternation.
- The .ToString()/{...} arms' shared name fragment (now pulled into a private const, ExOrFaultName) also
  accepts Fault/fault, bare or dotted.
- A new alternation catches a string literal concatenated onto an exception-shaped name ("Query failed: " +
  ex), gated by a negative lookahead so it defers to the other alternations when the name is further accessed.

Also corrects the snapshot.Detail allow-list's reason: it is the raw ex.Message of a startup failure today
(round-1 H4, tracked in #4316, fixed by PR #4326), not never PostgresException.Detail as it previously read;
the entry is to be removed once #4326 lands.

Adds every new shape to the must-match list, two non-exception-receiver negatives (widget.TableName,
row.ColumnName) proving the new property list's gating, and a revert-proof pinning that the round-1 pattern
misses every round-2 shape. Checked every roster file by hand for "fault"/"Fault"/the new property names before
widening: DarlingServerResolver.cs has bare fault/Fault tuple variables, none followed by a dot, so none newly
match; other hits are inside comments, which StripComments removes.

Darling.Tests: 0 warnings, 0 errors; WebExceptionTextCensusTests 37 (1 skip, no DARLING_TEST_PG) -
NoWebEndpoint_BuildsAnAnswerFromExMessage_ExceptTheNamedAllowList still reports 0 unaccounted on the real
roster files under the widened pattern; DocCommentHygieneTests 77. Both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4293ci: drop the stale 400 theory row for the resolver's registry-read fault

The round-1 H2 fix made ClassifyToolResponse answer ServerError for any
sentence starting with DarlingServerResolver.RegistryReadFaultPrefix. An
older #3776 theory row still expected 400 for that exact sentence, which
CI caught as a live regression against the new behavior.

Remove the stale InlineData row, update the theory's summary to describe
what still reaches the 400 arm, and add a fact pinning the fault's 500.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant