Repository navigation
fix(engine): run synchronous keyed NotInParallel tests concurrently - #7087
Conversation
The constraint key scheduler invoked each startable test inline from its scheduling loop, so a synchronous test body ran on the loop thread and blocked the loop until it finished. Tests with distinct keys therefore ran one at a time. Waiting tests had the same problem: their start signal ran continuations inline, so a releasing test ran the first unblocked waiter on its own thread before it signalled the rest. Dispatch startable tests to the thread pool and create start signals with RunContinuationsAsynchronously. Fixes #7075
|
Important Review skippedReview was skipped as selected files did not have any reviewable changes. ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
[High impact] The PR appears safe to merge; no new actionable issues remain. SummarySynchronous tests with different constraint keys now run concurrently.
The three earlier, unnumbered findings are addressed by the always-yielding await and trace-controlled gates. thomhurst explicitly leaves the global parallel limit to #7084 because it is a separate, complementary fix. No new actionable issues were found. Tests were not run during this review.
|
Review of #7087I found no blocking issues. Diagnosis. The root cause matches the code. A synchronous test with no yielding hook ran on the scheduling loop's thread. Signalling a plain The fix is small and targeted.
Tests.
Non-blocking suggestions.
Looks good to merge. |
A running test can free a waiter's keys after the loop queues the waiter but before it awaits the start signal. Awaiting the completed signal ran the synchronous test body on the loop thread and stalled later tests. Always yield (ForceYielding on net8+, an equivalent awaitable on netstandard2.0). Tests now pin both race windows deterministically through trace-log hooks: the gate holds its keys until both waiters queue, and a new test frees a waiter's key between queueing and awaiting.
Review of #7087The root-cause analysis is sound, and the fix looks correct and minimal.
Minor, non-blocking points:
I haven't run the tests myself. Otherwise this looks good to merge. |
The released-before-await test let the gate finish on the thread pool unsynchronised, so it could free key-1 before the scheduling loop reached the waiter. The waiter then started through the free-key path and the 'Unblocking waiting test' assertion failed. The gate now waits until the waiter's queue trace fires. Also documents why Task.Run gets CancellationToken.None.
|
Review of #7087 The root-cause analysis is sound and the fix is minimal. Task.Run stops the scheduling loop from running a synchronous test body. RunContinuationsAsynchronously stops the releasing test from running its waiters inline. Key exclusion is still taken under the lock before dispatch, so it is unchanged. Passing CancellationToken.None to Task.Run is correct and well explained, because the finally block must run to release keys. The tests are good: barrier-based, so they fail on a serial run rather than relying on timing. Concerns
Overall this is a good fix. I would simplify or justify the polyfill before merging. |
Co-Authored-By: Claude <noreply@anthropic.com>
|
Responding to the 16:14 review's concerns about
I also merged |
Review of #7087The root-cause analysis is sound and the fix is small and well targeted. I found no blocking issues. What I checked
Suggestions (non-blocking)
Overall: good fix, good regression coverage, and the perf cost (~0.6µs per keyed test) is acceptable. Approving in spirit. |
Fixes #7075
Root cause
ConstraintKeyScheduler.ExecuteTestsWithConstraintsAsyncstarted each test with free keys by callingExecuteTestAndReleaseKeysAsyncdirectly from its scheduling loop. Nothing in the pipeline yields for a synchronous test that has no async hooks, so the test body ran on the loop thread and the loop could not start the next test until it finished. Tests with distinct[NotInParallel]keys ran one after another on a single thread. An async[Before(Test)]hook containingawait Task.Yield()hid the problem, which is why theWithYieldclass in the issue ran in parallel.The release path had the same flaw. Each waiting test's
startSignalwas a plainTaskCompletionSource, soSetResultran the waiter's continuation inline on the releasing test's thread, inside thefinallythat signals waiters. If one release unblocked several synchronous waiters, the first one ran to completion before the others were signalled.Fix
Two changes in
ConstraintKeyScheduler:Task.Run, the same approach as the netstandard2.0 fallback inTestScheduler.ExecuteWithGlobalLimitAsync.startSignalis created withTaskCreationOptions.RunContinuationsAsynchronously.Nothing blocks on async. The scheduling loop still takes keys under the lock before dispatching, so key exclusion is unchanged.
Tests
New
tests/TUnit.UnitTests/ConstraintKeySchedulerTests.csdrives the realConstraintKeySchedulerandTestRunnerwith a coordinator that runs each test body synchronously and returns a completedValueTask. Each blocking test waits on aBarrierwith a 30s timeout, so the tests pass only if the participants run at the same time:SynchronousTests_WithDistinctKeys_RunConcurrently: 4 tests with distinct keys. Before the fix, all 4 timed out (2 minutes, run serially). After the fix it passes in milliseconds.SynchronousWaiters_ReleasedByTheSameTest_RunConcurrently: a gate test holds two keys and two waiters each need one of them. With only theTask.Runchange and the old TCS, this test still fails, so it covers theRunContinuationsAsynchronouslychange on its own.TestRunnerTests.CreateTestandFakeMessageBuswere madeinternalso the new tests can reuse them.I first wrote this as a
TUnit.TestProjectengine test, but it passes without the fix there: TestProject's global async hooks yield before the test body runs. That is why the regression tests are unit tests.Runs:
TUnit.UnitTests(net10.0), full suite: 528 passed, 3 skipped, 0 failed. Builds for all TFMs.TUnit.Engine.Tests(Reflection mode; AOT mode only runs on CI), all passed:ConstraintKeySchedulerConcurrencyTests,KeyedNotInParallelCrossPhaseTests,GlobalNotInParallelDependsOnTests,ParallelismValidationEngineTests,ClearParallelizationTests.Performance
Measured with a throwaway harness, not committed: 20,000 no-op keyed tests over 64 keys, run through the scheduler 7 times per process, Release net10.0. Warm-run medians were about 25ms before and about 38ms after, roughly 0.6µs extra per keyed test for the thread-pool hop. That is negligible next to the real per-test pipeline. In exchange, synchronous keyed tests use all their parallelism instead of running serially.
Overlap with #7084 (#7074)
#7084 also edits
ConstraintKeyScheduler.cs. It adds amaxParallelismconstructor parameter and takes aSemaphoreSlimslot insideExecuteTestAndReleaseKeysAsync. The hunks don't overlap: this PR only changes thestartSignalconstruction and the immediate-start dispatch in the loop. The two fixes are complementary. Under #7084 alone, an uncontendedWaitAsynccompletes synchronously, so synchronous tests would still run inline on the loop thread. Whichever PR merges second must add the new constructor argument toCreateSchedulerinConstraintKeySchedulerTests(e.g.new Lazy<int>(() => 8)).Summary by CodeRabbit