Skip to content

Make Darling's analysis budget mean something, and stop a wedged server going silent (#2430) - #2438

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/2430-darling-wedged-server
Aug 21, 2026
Merged

erikdarlingdata merged 4 commits into
devfrom
fix/2430-darling-wedged-server

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 21, 2026 •

Copy link
Copy Markdown
Owner

Closes #2430.

Darling had Lite's #2412 defect in the same shape. The per-server pass raced AnalyzeAsync against a Task.Delay, AnalyzeAsync received only the stopping token, and the in-flight marker was released only when the pass truly finished. So the timeout abandoned the wait without cancelling any work, and a pass that never finished left that server's marker set for the life of the service — skipped by the guard on every later cycle, in silence, with one warning from the cycle that first timed out and then nothing ever again.

It stayed invisible here for the opposite reason it was visible in Lite. DuckDB implements no async execution, so Lite's pass ran inline and a wedged read took the whole collection loop down with it. Npgsql is genuinely async and hands the thread straight back, so a wedged pass on Darling never broke anything you could see. It just stopped analysing one server.

Why porting the Lite fix would have made things worse

Arming the pass token is one line. But DarlingAnalysisService classifies its abandon arm from that token, and the question it asked was "are we shutting down?". Link the token from stoppingToken and arm it with CancelAfter, and the answer becomes yes on every ordinary overrun of a perfectly healthy service — so each one reports itself as "abandoned at shutdown", at Information, on exactly the signal a person would use to decide the budget needs raising. Same class of mistake review caught on #2419's first round, and a wrong answer that reads as a calm one is worse than a loud wrong one.

So the classifier moved first. IsShutdownAbandon becomes IsExpectedAbandon at the sixteen component catch sites, and the rename is the point rather than tidying: at that level the reason does not matter, only that nobody should see nine ERRORs for work we ourselves called off — and the token those sites are handed is now a budget. Deciding which abandonment is a new Classify, asked once, where both tokens are in scope. That is why AnalysisContext now carries the stopping token separately from the token the reads observe: the old CancellationToken kept its name and lost its meaning, and something had to still know what a stop is.

Classify's timeout arm is deliberately narrower than its shutdown arm. A budget expiring produces exactly one shape — the token observed properly. A disposed data source or a 57P0x means the store went away, which a timeout on a running service does not cause, so those fall through and keep the ERROR #2299 gave them. Widening the arm would have quietly relabelled that bug's only evidence for the whole window after any pass overruns, which is a thing this fix could easily have done by accident.

AnalysisShutdownResidueTests moves with them, exactly as the issue predicted: it pins the call sites' argument lists, so classifier, sites and test are one change.

The token, and how far it actually reaches

The worker links a CTS from stoppingToken, arms it with the 120s budget, and hands the pass both tokens. The sweep then waits the budget plus the existing five-second unwind grace, so losing that race carries information — the pass was asked to stop and did not — rather than being a coin flip against its own timer. There is no Task.Run: Lite needed one because its pass ran inline, and Darling's does not.

That genuinely fixes the permanent skip for most of it. A cancelled pass throws at the next checkpoint or tokened read, the task completes, the continuation clears the marker, and the server is back in the rotation.

It does not fix all of it, and the measurement runs the other way from the issue's guess. Npgsql honours cancellation natively, but the call sites still have to hand it one, and most do not:

async store calls pass a token
PgFactCollector.* (the phase that dominates a pass) 93 0
PgDrillDownCollector.* 53 0
PgAnomalyDetector 33 33
PgFindingStore 20 2
everything else 10 7
total 209 42

So threading the token in is 167 call sites across thirteen files — more than the 138 Lite deferred, not fewer. It is mechanical, and it belongs in its own change rather than sitting on top of a classifier rewrite.

Which is why the reporting is what closes the symptom

A pass wedged inside one of those 167 reads is still wedged and the marker still holds. What changes is that the skip stops being silent. The in-flight marker now carries when the pass started and how often it has been mentioned, and a server past three times its budget is reported on a doubling backoff with how long it has been stuck and how many cycles it has cost — capped so the shift cannot run away on a service that stays up for months. A permanently-skipped server that is loudly skipped is a far smaller bug than one silently skipped: the first costs findings and says so, the second is indistinguishable from a server with nothing wrong with it.

Two smaller things came with it. A pass that ended early no longer reads as a clean all-clear: the scheduler reads DarlingAnalysisService.EndedEarlyAs — what the pass's own catch classified — and turns it into the terminal state analyze_now reports, adding no second log line of its own. That shape came out of review: inferring it here from "no findings and the budget token has fired" produced a duplicate Warning for every ordinary timeout, and, worse, said the same thing about a genuine fault that landed after the budget expired — burying its ERROR under a sentence claiming the pass merely ran out of time. A fault now reports Error, not TimedOut.

And the catch releases the marker only for a pass that was never launched; it used to clear unconditionally, which got away with it only because every path reaching it after launch had already completed the task.

Verification

Darling.Tests targets net10.0-windows and cannot run on macOS, but PerformanceMonitor.Darling.Analysis is net10.0 — so the classification is testable here for real rather than pinned. Both behavioural tests run offline in milliseconds against a data source pointed at a closed port: the token is observed before any socket work, so nothing ever dials it.

The whole test file was compiled into a throwaway net10.0 harness with an xUnit shim and run against both branches, with the source pins reading each branch's own DarlingWorker.cs.

dev this branch
DarlingAnalysisBudgetTests + DarlingWedgedServerReportingTests (+ residue tests) 1 passed, 8 failed 16 passed, 0 failed

dev's line for the failing case is the whole issue in one string:

Information: [DarlingAnalysisService] Analysis abandoned at shutdown for four-args
 — this pass's findings are lost by design

That is an ordinary budget cancellation on a service that is not stopping.

The updated AnalysisShutdownResidueTests cannot compile against dev at all, which is not a gap in the comparison — it is the same fact from the other side, and the reason the issue said classifier, call site and test move together. (dev needed two compile-only shims for the behavioural file: an inert AnalysisContext.ShutdownToken and an ignored fifth AnalyzeAsync parameter. Neither is read by dev, which is precisely the state under test.)

dotnet build clean on the full solution with the documented macOS EnableWindowsTargeting ritual; global.json restored and not committed. The eight xUnit2031 warnings in DarlingFirewallCheckTests.cs are already on dev and are not from this branch.

Deliberately not in scope

  • Threading the token into the 167 untokened store reads, measured above. Until that lands, abandonment happens between stages rather than inside a read, and the reporting covers the difference.
  • analyze_now shares this pass, so it now really is cancelled at 120s instead of reporting a timeout while the work continued behind it and persisted findings anyway. The command's own answer was already TimedOut; it is true now.

erikdarlingdata and others added 3 commits August 21, 2026 14:43
…er going silent

Darling had Lite's #2412 defect in the same shape. The per-server pass raced
AnalyzeAsync against a Task.Delay, AnalyzeAsync received only the stopping
token, and the in-flight marker was released only when the pass truly
finished. So the timeout abandoned the wait without cancelling any work, and a
pass that never finished left that server's marker set for the life of the
service -- skipped by the guard on every later cycle, in silence, with one
warning from the cycle that first timed out and then nothing ever again.

It stayed invisible here for the opposite reason it was visible in Lite.
DuckDB implements no async execution, so Lite's pass ran inline and a wedged
read took the whole collection loop down with it. Npgsql is genuinely async
and hands the thread straight back, so a wedged pass on Darling never broke
anything you could see. It just stopped analysing one server.

## Why porting the Lite fix would have made things worse

Arming the pass token is one line. But DarlingAnalysisService classifies its
abandon arm from that token, and the question it asked was "are we shutting
down?". Link the token from stoppingToken and arm it with CancelAfter, and the
answer becomes yes on every ordinary overrun of a perfectly healthy service --
so each one reports itself as "abandoned at shutdown", at Information, on
exactly the signal a person would use to decide the budget needs raising. That
is the same class of mistake review caught on #2419's first round, and a wrong
answer that reads as a calm one is worse than a loud wrong one.

So the classifier moves first. IsShutdownAbandon becomes IsExpectedAbandon at
the sixteen component catch sites, and the rename is the point rather than
tidying: at that level the REASON does not matter, only that nobody should see
nine ERRORs for work we ourselves called off, and the token those sites are
handed is now a budget. Deciding WHICH abandonment is a new Classify, asked
once, where both tokens are in scope -- which is why AnalysisContext now
carries the stopping token separately from the token the reads observe. The
old CancellationToken kept its name and lost its meaning; something had to
still know what a stop is.

Classify's timeout arm is deliberately narrower than its shutdown arm. A
budget expiring produces exactly one shape, the token observed properly. A
disposed data source or a 57P0x means the store went away, which a timeout on
a running service does not cause -- so those fall through and keep the ERROR
#2299 gave them. Widening the arm would have quietly relabelled that bug's
only evidence for the whole window after any pass overruns, which is a thing
this fix could easily have done by accident and nobody would have noticed for
months.

AnalysisShutdownResidueTests moves with them, as expected: it pins the call
sites' argument lists, so classifier, sites and test are one change.

## The token, and how far it actually reaches

The worker now links a CTS from stoppingToken, arms it with the 120s budget,
and hands the pass both tokens. The sweep waits the budget plus the existing
five-second unwind grace, so losing that race carries information -- the pass
was asked to stop and did not -- rather than being a coin flip against its own
timer. There is no Task.Run: Lite needed one because its pass ran inline, and
Darling's does not.

That genuinely fixes the permanent skip for most of it, because a cancelled
pass throws at the next checkpoint or tokened read, the task completes, the
continuation clears the marker and the server is back in the rotation.

It does not fix all of it, and the measurement is worth stating plainly since
the issue guessed the other way. Npgsql honours cancellation natively, but the
call sites still have to hand it one, and most do not: across the analysis
pipeline there are 209 async store calls and only 42 pass a token. The whole
fact-collection phase -- 93 of them, and the phase that dominates a pass --
takes none. So this is 167 call sites across thirteen files, MORE than the 138
Lite deferred, not fewer. It is mechanical and it belongs in its own change,
where its diff is not sitting on top of a classifier rewrite.

## Which is why the reporting is the part that closes the symptom

A pass wedged inside one of those 167 reads is still wedged, and the marker
still holds. What changes is that the skip stops being silent. The in-flight
marker carries when the pass started and how often it has been mentioned, and
a server past three times its budget is reported on a doubling backoff with
how long it has been stuck and how many cycles it has cost -- loud enough to
be noticed, quiet enough to stay noticed, and capped so the shift cannot run
away on a service that stays up for months. A permanently-skipped server that
is loudly skipped is a far smaller bug than one silently skipped: the first
costs findings and says so, the second is indistinguishable from a server with
nothing wrong with it.

Two smaller things came with it. A pass that unwound at its budget and came
back empty no longer reads as a clean all-clear -- it says it was cancelled.
And the catch now releases the marker only for a pass that was never launched;
it used to clear unconditionally, which got away with it only because every
path that could reach it after launch had already completed the task.

## Verification

Darling.Tests targets net10.0-windows and cannot run on macOS, but
PerformanceMonitor.Darling.Analysis is net10.0, so the classification is
testable here for real rather than pinned. Both new behavioural tests run
offline in milliseconds against a data source pointed at a closed port: the
token is observed before any socket work, so nothing dials it.

The whole test file was compiled into a throwaway net10.0 harness with an
xUnit shim and run against both branches, with the source pins reading each
branch's own DarlingWorker.cs.

    dev            1 passed, 8 failed
    this branch   16 passed, 0 failed

dev's one line for the failing case is the whole issue in one string:

    Information: [DarlingAnalysisService] Analysis abandoned at shutdown for
    four-args -- this pass's findings are lost by design

That is an ordinary budget cancellation on a service that is not stopping.

The updated AnalysisShutdownResidueTests cannot compile against dev at all,
which is not a gap in the comparison -- it is the same fact from the other
side, and the reason the issue said classifier, call site and test move
together.

dotnet build clean, 0 warnings, on the full solution with the documented macOS
EnableWindowsTargeting ritual; global.json restored and not committed.

## Deliberately not in scope

Threading the token into the 167 untokened store reads, measured above. Until
that lands, abandonment happens between stages rather than inside a read, and
the reporting is what covers the difference.

analyze_now shares this pass, so it now really is cancelled at 120s instead of
reporting a timeout while the work continued behind it and persisted findings
anyway. The command's own answer was already TimedOut; it is true now.

Fixes #2430

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DocCommentHygieneTests caught this, which is what it is for: inserting
AnalysisAbandonKind directly above `public static class AnalysisShutdown` left
the class's summary sitting on top of the enum's, so the file documented the
enum twice and the class not at all. XML docs take the LAST block, so tooling
would have looked perfectly correct and only someone reading the file would
have been misled.

The enum moves above the class's doc block rather than the doc block being
deleted -- the text belongs to the class and is the record of why #2299 exists.
Its opening paragraph is split out while it is being touched, because the
"before this" it opened with had quietly come to mean #2430 rather than #2299
once the first sentence gained a clause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +3194 to +3200
if (findings.Count == 0 && cts.IsCancellationRequested)
{
_logger.LogWarning(
"[{Server}] Analysis exceeded {Timeout}s and was cancelled — no findings this cycle; the next cycle recomputes them",
displayName, (int)s_analysisTimeout.TotalSeconds);
return new AnalysisPassResult(
AnalysisPassStatus.TimedOut, 0, $"analysis exceeded {(int)s_analysisTimeout.TotalSeconds}s");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: on an ordinary budget timeout this block duplicates (and can mislabel) the classification DarlingAnalysisService.AnalyzeAsync already did.

AnalyzeAsync's catch (DarlingAnalysisService.cs:290-313) has already run AnalysisShutdown.Classify and, for the Timeout case, logged a Warning ("...was cancelled at its per-pass budget and unwound as asked...") before returning []. This block then sees findings.Count == 0 && cts.IsCancellationRequested and logs a second, differently-worded Warning ("Analysis exceeded {Timeout}s and was cancelled..."). Every scheduled per-pass-budget timeout now produces two near-duplicate WARNING lines instead of one.

Compare with the Lite twin (CollectionBackgroundService.cs): AnalysisService's inner catch logs at Info, and the outer loop's "exceeded Ns and was cancelled" is the one Warning — a deliberate two-tier severity. Darling's inner catch was upgraded to Warning for the Timeout case (DarlingAnalysisService.cs:308-311), so the outer message here is now Warning-on-Warning, which is noisier than Lite's equivalent path for the same event.

There's also an edge case this widens: Classify returns None (a genuine fault, not Timeout/Shutdown) whenever the exception isn't shaped like a cancellation even though the pass token had already fired at the time the fault landed — e.g. a query that ignored the cancellation and later threw an unrelated error after the budget expired. AnalyzeAsync correctly logs that as an ERROR with the real message and returns [], but cts.IsCancellationRequested is still true, so this block will additionally log "...exceeded Ns and was cancelled" and return AnalysisPassStatus.TimedOut — burying the real ERROR's cause behind a Warning that implies "just a timeout."

Worth considering: drop this block's own log line (and just check cts.IsCancellationRequested for the returned AnalysisPassStatus), or have AnalyzeAsync/Classify surface enough back to the caller (e.g. via AnalysisAbandonKind) that this decision isn't made twice from two different vantage points.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on all three points and fixed in a550675.

The double Warning is real, and the severity comparison is the right one: Lite's inner catch stays at Info so its loop's line is the single Warning, while Darling's inner catch is now correctly a Warning for the timeout case — which made mine Warning-on-Warning for one event.

The None case is the worse half and it is the one that decided the shape of the fix. A fault landing after the budget expired classifies as None, and the pass logs it as an ERROR with the real message, but findings.Count == 0 && cts.IsCancellationRequested is true anyway — so the sweep would have added "exceeded 120s and was cancelled" on top and reported TimedOut to analyze_now. That buries a genuine fault under a sentence saying the pass merely ran out of time, which is the same species of mistake as calling a timeout a shutdown, one layer further out.

Taking your second option rather than the first, because dropping only the log line would have left the status still inferred from the same two conditions. DarlingAnalysisService.EndedEarlyAs is null when the pass ran through and otherwise holds what its own catch classified, with None meaning a fault — the catch ran and the classifier said it was not an abandonment. The sweep maps that to the terminal state and adds no line of its own: Shutdown to Skipped, Timeout to TimedOut, and a fault to Error instead of TimedOut.

Pinned three ways: both behavioural tests now assert EndedEarlyAs as well as the wording, and TheSchedulerReadsThePassesOwnEnding_RatherThanInferringOne requires the read and forbids the inference it replaces.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed the diff (all C#, no T-SQL touched). This is a well-scoped, well-tested port of Lite's #2412 in-flight-analysis-marker fix to Darling, adapted for the fact that Classify now has to distinguish a budget timeout from a real shutdown (something Lite's synchronous DuckDB pass never had to do). The reasoning in the PR description and code comments checks out against the implementation:

  • AnalysisShutdown.Classify correctly orders shutdown-wins-ties over budget timeout, and the timeout arm's narrower shape (OperationCanceledException only) is consistent with the existing IsShutdownResidue discipline IsExpectedAbandon already relied on.
  • AnalysisContext.ShutdownToken vs CancellationToken split is correctly wired end-to-end: DarlingWorker passes the raw stoppingToken as ShutdownToken and the linked/armed cts.Token as CancellationToken; on-demand callers (MCP analyze_server) correctly get neither, so their cancellations read as timeouts, matching the doc comments.
  • The in-flight-marker lifecycle (passStarted, continuation-owns-cleanup-after-launch, catch-owns-cleanup-only-if-never-launched) is correct and avoids the two failure modes it explicitly guards against (double-clearing a live marker vs. leaking a marker on ctor failure).
  • ReportStuckAnalysis's backoff math matches the Lite twin, and per-server exclusivity (both _analysisInFlight and the outer InFlightSweep/gate guard) rules out the mutation race I checked for on AnalysisPassState.
  • Lite is intentionally not touched with the Shutdown/Timeout split — the PR body explains why a straight port would have been wrong for Darling's classifier, and Lite's own scheduler-level Warning already covers the operator-facing signal Lite needs. I don't see unjustified Lite/Darling drift here.

One inline note on DarlingWorker.cs around the new findings.Count == 0 && cts.IsCancellationRequested block: it duplicates (and in one edge case can mislabel) the classification DarlingAnalysisService.AnalyzeAsync already logged, producing two Warning-level lines per ordinary budget timeout where Lite's equivalent path produces one Info + one Warning. Left as a comment there, not a blocker.

No correctness, security, or missing-index-style findings beyond that.

…d one

Review caught this and every part of it checks out. The pass classifies its
own ending in one place and logs the single line for it. My sweep then looked
at "no findings and the budget token has fired" and logged a second, differently
worded Warning for the same event -- so an ordinary timeout produced two
near-duplicate WARNINGs where the Lite twin produces one, since Lite's inner
catch stays at Info and Darling's now correctly does not.

The worse half is the case those two conditions cannot tell apart. A fault that
lands AFTER the budget expired classifies as None, and the pass logs it as an
ERROR with the real message -- but the token has fired and the result is empty,
so the sweep would have added "exceeded 120s and was cancelled" on top and
reported it to analyze_now as TimedOut. That buries a genuine fault under a
sentence saying the pass merely ran out of time, which is the same species of
mistake as classifying a timeout as a shutdown, one layer further out.

So the ending is carried out of the pass rather than re-derived. EndedEarlyAs
is null when the pass ran through and otherwise holds what its own catch
classified, where None means a fault -- the catch ran and the classifier said
it was not an abandonment. The sweep now maps that to the terminal state
analyze_now reports and adds no line of its own: shutdown to Skipped, budget to
TimedOut, and a fault to Error rather than to TimedOut.

The two behavioural tests assert the property as well as the wording, and a new
source pin requires the sweep to read EndedEarlyAs and forbids the inference it
replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed. This PR is C#/.NET (Darling worker + analysis service), so the T-SQL style rules in CONTRIBUTING.md don't apply — no .sql files are touched.

I traced through the core logic in detail rather than just skimming:

  • AnalysisShutdown.Classify (shutdown-token-wins-ties, narrower timeout arm that only matches bare OperationCanceledException rather than the full IsShutdownResidue shape set): checked against both the linked-token and standalone-budget cases. The narrower timeout arm mirrors the same non-unwrapped ex is OperationCanceledException check IsShutdownResidue already used pre-Darling's analysis pass has the same wedged-server defect Lite just fixed, but its shutdown classifier blocks the same fix #2430, so it's not introducing a new wrapped-exception blind spot — just carrying forward the existing one symmetrically.
  • RunAnalysisPassAsync: verified the CancellationTokenSource is armed before the task exists (no dispose-race), the marker/cts are only manually cleaned up on the !passStarted path (ctor-throw only — AnalyzeAsync itself can't throw synchronously since it's async), and the continuation is the sole owner of cleanup once passStarted = true. No double-dispose or leak paths found.
  • EndedEarlyAs is AnalysisAbandonKind ending: this pattern also matches AnalysisAbandonKind.None (not just Shutdown/Timeout), which is handled correctly by the switch's _ => arm mapping to Error. Worth calling out because it's easy to misread as only covering the two "expected" cases — it's actually also fixing a latent gap where a genuine mid-pass fault swallowed by AnalyzeAsync's catch-all used to fall through to notifyFindings/WriteAnalysisStateAsync as if it were a clean empty-findings run. Good catch, and the test suite (TheSchedulerReadsThePassesOwnEnding_RatherThanInferringOne) pins the mechanism, if not this exact edge.
  • ReportStuckAnalysis/backoff math: TimeSpan * int * double behaves as expected, the Math.Min cap bounds the doubling, and the benign TryGetValue-then-mutate-after-removal race (pass finishes between the read and the increment) is harmless — no double-report or lost-update issue that matters.
  • Lite parity: Lite/Analysis/AnalysisService.cs's only change is a comment reflecting the IsShutdownAbandon → IsExpectedAbandon rename (Lite doesn't call into Darling.Analysis, so this was never a compile dependency). No stale references to the old name remain anywhere in the repo. The PR body's reasoning for not porting Lite's shape 1:1 (sync DuckDB vs async Npgsql, and the classifier-arm ordering that Make the analysis timeout mean something, and stop a wedged server going silent #2419 already caught once) checks out against the actual code — this is a deliberate, justified asymmetry rather than drift.
  • Confirmed the 5-arg AnalyzeAsync overload is backward compatible (shutdownToken defaults to None) for the existing on-demand callers (DarlingMcpTools.AnalyzeServer), which correctly leaves shutdown undistinguished for those.

Nothing to flag on security (no new input handling, no query construction, no secrets/process/network surface changed beyond the existing Npgsql calls) or performance (the extra CancellationTokenSource per pass and dictionary value upgrade from byte to a small class are negligible next to a 4-hour-window Postgres analysis pass).

One very minor, pre-existing (not introduced here) rough edge for awareness rather than action: analyze_now's AnalysisPassStatus.Skipped → CommandOutcome(false, "analysis already running", ...) mapping uses a fixed label even though Skipped now covers both "already running" and "service is stopping" (including the new EndedEarlyAs.Shutdown path) — the JSON body's result.Message does carry the accurate reason, so this is cosmetic at most, and it's the same ambiguity the pre-existing shutdown path already had.

@erikdarlingdata
erikdarlingdata merged commit 0ba2854 into dev Aug 21, 2026
7 checks passed
MisterZeus pushed a commit to MisterZeus/PerformanceMonitor that referenced this pull request Aug 24, 2026
146 store calls across the fact collector and the drill-down collector took the
no-argument overload, which is why erikdarlingdata#2438's budget could abandon a pass between
stages but never inside a read. A wedged query held its connection until it
finished on its own, and the service that had already given up on that server sat
there holding a session it was no longer waiting for.

Threading the reads alone would have accomplished almost nothing, and that is the
part worth reading twice. The fact collector's per-query catches are bare
`catch { }` on purpose -- a missing table degrades to "no facts", never an
exception -- so a fired token would have been caught thirty times over and the
pass would have carried on to the next collector each time, dialling the store
again to be told again. The measured proof is in the behavioural test: reverted,
it fails with an NpgsqlException instead of an OperationCanceledException, which
is the collector connecting to the store while holding a token that had already
been cancelled before the call.

So all 27 of those catches now carry the classification the rest of the pass has
used since erikdarlingdata#2299 -- `when (!AnalysisShutdown.IsExpectedAbandon(ex, ...))` -- and
an abandonment propagates to the single line the pass logs for it instead of being
swallowed per-metric. The catch bodies and their comments are unchanged; the only
thing that stops being caught is the one thing we asked for. Four collect methods
have no catch at all and never did: their reads propagate straight up, which is
the same outcome by a shorter route.

AppendDmvSnapshotRowsAsync takes a required token rather than a defaulted one for
the reason the plan fetcher did: three callers share it, two are on the pass, and
a default would have let either keep passing nothing silently. The viewer is the
third and it genuinely has no pass to abandon -- it says so at its own call site
rather than in the shared method, because the exception belongs to the caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata deleted the fix/2430-darling-wedged-server branch September 12, 2026 20:29
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