Repository navigation
The cloud probe budget test no longer fails when the thread pool is starved (#4741) - #4754
Merged
Merged
Conversation
…tarved (#4741) The test asserted that the whole call finished in under 2000 ms. The budget's timer callback, and every continuation after it, run on a thread-pool thread, so when other tests hold the pool's threads the budget fires late. Measured with 4 processors and the pool's threads blocked: 2 of 28 runs took 2289 and 2492 ms, the cancellation itself landing that late. CI hit 2035 and 2084 ms with the probe unchanged. The test now asserts what the probe controls: it returns None, the request it left hanging was cancelled by its own budget rather than abandoned, and the budget did not fire early. A 30 s hang guard is the only upper bound. A new test pins the 200 ms budget value, which the old clock check covered only loosely.
erikdarlingdata
marked this pull request as ready for review
September 29, 2026 08:19
…bound (#4741) The budget test dropped its wall-clock upper bound because a starved thread pool fires the budget late. That bound also caught an untokened wait inside the probe, and the test no longer did. Two bars return, each timing only steps that need no pool thread: the handler's first request must start within 1000 ms of the call (the path to it is synchronous), and ProbeAsync must return within 1000 ms of that request's end (the cancellation's continuations run inline). Only the first request's start and end are recorded, so a later attempt cannot overwrite either value and move it past a wait.
This was referenced Sep 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4741
Why
ProbeAsync_NeitherCloudResponds_ReturnsNone_WithinItsOwnBudgetasserted that the whole probe call finished in under 2000 ms against a 200 ms budget. It failed on CI at 2035 ms and 2084 ms with the probe unchanged since #4330.The cause is thread-pool starvation.
CancelAfter's timer callback, and every continuation after it, need a free pool thread. When the other tests in a shard hold the pool's threads, the pool adds about one thread per 500 ms, so the budget itself fires late. The clock cannot tell a slow probe from a starved pool, and the probe has no say in the second.Diagnosis, measured
Harness: queue N work items that each wait on one event, leave
SetMinThreadsalone, then time the test's ownProbeAsynccall with its fake handler. One process per run, so the pool starts fresh. The harness recorded three times per run: the moment the budget token was cancelled ("cancel-fired"), the moment the fake handler's own continuation ran, and the test's total elapsed time.4 processors (
DOTNET_PROCESSOR_COUNT=4, the shape of CI's Windows runner), blocked withManualResetEventSlim:A wider sweep at 4 processors (N = 4, 5, 6, 8, 12, 24, 60, two blocking styles, two runs each, 28 runs) ranged from 541 ms to 2492 ms. Two of the 28 runs went past 2 s (2492 ms and 2289 ms), which is the CI symptom.
32 processors (this machine's natural count):
In every slow run the cancel-fired time was within a few milliseconds of the total elapsed time (for example 4573 ms of 4578 ms). The delay is the budget firing late, not slow work after the cancellation. In these tests
SynchronizationContext.Currentis null, so the continuations go through the pool too; there is no separate queue to blame.Is there a wait the budget token does not bound? No.
I read
ProbeAsync,TryEc2Async,TryAzureAsyncandReadCappedValueAsyncinDarlingCloudIdentityProbe.cs:CancelAfter(ProbeBudget).HttpClient.Timeoutis infinite, so there is no second timeout.budget.Token. Lines 173, 185, 199 (SendAsync) and 218, 225 (ReadAsStreamAsync,ReadAsync) all pass it through.Task.Delay, no.Result,.Wait(orGetAwaiter. The disposals (using,await usingat 219) do not wait.So there is no product change and no CHANGELOG entry.
What changes
Test only, in
Darling/Darling.Tests/DarlingCloudIdentityProbeTests.cs:ProbeAsync_NeitherCloudResponds_ReturnsNone_WithinItsOwnBudgetno longer has a wall-clock upper bound on the budget itself. The fake handler records when its first request started and when that request's ownTask.Delayended. The test asserts: the probe returnedNone; the handler's first request started within 1000 ms of the call; the handler's request was ended before the probe returned (so the budget cancelled it rather than the probe walking away from a pending request); that end was not earlier than half the budget (a one-sided bound, since starvation can only make it later); andProbeAsyncreturned within 1000 ms of that end. A 30 sWaitAsynchang guard turns "the probe never cancels its handler" into a failure instead of a hang until the job timeout.ProbeBudget_IsTwoHundredMillisecondspins the 200 ms value without a clock. The old check also failed a budget that grew to seconds; this keeps that.CreateHandler_ArmsExactlyOneBudget_NotAPerRequestTimeoutalready pins that only one budget is armed.Two bars that still catch a wait the budget does not bound
The old
< 2000 msbar also caught extra waiting inside the probe that the budget does not bound (an untokenedTask.Delay, say). Two bars keep that. Each one times only steps that need no thread-pool thread, so a starved pool cannot trip them:Task.Delay(ProbeAsync,TryEc2Async,HttpClient.SendAsync, the fake handler) is synchronous. A wait before the first request shows here.ProbeAsyncreturns within 1000 ms of the first request's end. The cancellation's continuations run inline on the thread that fires the budget, so the probe returns within a few ms of the cancel (4573 ms of 4578 ms in the slow runs above). A wait after the cancel shows here.Both bars read only the first request's start and end (
Interlocked.CompareExchangeagainst -1), so a later attempt cannot overwrite either value and move it past a wait.When the budget cancels the hanging EC2 request,
TryEc2Asyncthrows and the probe's catch block returnsNone, so the Azure attempt is never made in this test. The code that runs after the cancel is the catch block; the test plan shows where the return bar was proved.Why not "cancelled within the budget plus slack"
I did not assert the handler's cancel moment against the budget plus a slack (for example 1500 ms). The data above shows the cancellation itself lands at 2.3 to 4.6 s under a blocked pool, so any slack small enough to mean something still flakes, and one large enough not to flake is the hang guard.
Why not a non-parallel collection
It would shrink the starvation window, not remove it (the pool's injection cadence is the same after any earlier test leaves threads blocked), it would serialize part of the suite, and the assertion would still be a clock that can lose to the machine.
Load harness is not committed
The harness clamps the process-wide pool with
ThreadPool.SetMaxThreads(min, ...)and blocks every allowed thread, so it starves every test running in parallel with it. It cannot run inside this suite. Setup, for anyone repeating it: run withDOTNET_PROCESSOR_COUNT=4, clamp the max worker threads to the min, queue min-many items that wait on an event, release the event from a dedicated thread after the hold time, then restore the maximum.Other tests elsewhere in the project use a wall-clock upper bound too (
ClipboardTextRetryBudgetTests,PgLogEventsPipelineTests). They are not part of this failure and I left them.Test plan
CancelAfterargument changed to 5 ms: the test fails ("the handler's request ended after 11 ms, well before the 200 ms budget: something other than the probe's budget ended it"). Changing theProbeBudgetconstant itself is caught byProbeBudget_IsTwoHundredMilliseconds, since the budget test reads the constant.CancelAfterargument changed to infinite: the test fails after 30.3 s with the hang-guard message ("ProbeAsync was still waiting after 30 s: nothing cancelled the handler's request, so the probe's own 200 ms budget is not what bounds it"). Both mutations were reverted; the diff is the test file only.await Task.Delay(3000);inProbeAsyncjust before theTryEc2Asynccall. The start bar fails: "the handler's first request started at 3027 ms (-1 means it never started), not within 1000 ms of the call: the probe waited before sending it, and nothing bounds that wait". Reverted.TryEc2AsyncandTryAzureAsynccalls. The test stays green (0.39 s), because the budget's cancel throws out ofTryEc2Asyncand the catch block returnsNone, so that line is never reached in this test. Reverted.return CloudIdentity.None;. The return bar fails: "ProbeAsync returned 3010 ms after the handler's request ended (at 207 ms), not within 1000 ms: the probe waited after the budget cancelled the request, and nothing bounds that wait". Reverted.DOTNET_PROCESSOR_COUNT=4, the pool's max worker threads clamped to its min (4), four items blocked and released after 2600 ms from a dedicated thread, the test method called directly. All five passed. The call took 2617, 2614, 2614, 2623 and 2620 ms, so the budget fired about 2.4 s late each time, which the old< 2000 msbar would have failed. The harness stayed uncommitted.Darling.Testsbuilds with 0 warnings and 0 errors.DarlingCloudIdentityProbeTests,DarlingCloudIdentityProbeSourcePinTestsandDocCommentHygieneTests: 97 passed, 0 failed.Darling.Testssuite once, withoutDARLING_TEST_PG: Total 17085, Errors 0, Failed 0, Skipped 1111 (the live tests), Not Run 1 (as the runner printed it; I did not chase that one), 120 s.