tests: a budget for the strand-race case sized from what it costs, not the blanket 120 s it was never measured against (fixes #760) - #779
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… costs, instead of the blanket 120 s it was never measured against (fixes #760) `catch_discover_tests(morph_tests ... PROPERTIES TIMEOUT 120)` applied one ceiling to all 2966 cases in the binary. The longest, most load-sensitive case in it was governed by the same number as the cheapest, and that number was chosen against "every test is sub-second in practice" -- true of the other 2965. The case is not slow because it computes anything. It serialises 3200 tasks per iteration through one strand, and every handoff is a thread wakeup that has to wait its turn on the run queue, so its cost is set by how contended the host is. Measured on this tree, 12 cores, clang 22.1.8 Release, load generated with plain spin loops, whole-case wall clock, one run each: run queue 1 (idle) -> 0.136 s run queue 14 -> 23.8 s run queue 27 -> 170.5 s run queue 38 -> 396.4 s (load average 31-38 at measurement) Steeply superlinear -- 12 spin loops to 24 costs 7.2x, 24 to 36 another 2.3x -- and **every one of those runs passed**: 40 assertions, `inFlight 1, maxInFlight 1` throughout. The invariant the case exists for never wavered at any load. The 120 s ceiling was killing a passing test, which is a false red and, on shared hardware, eventually a false red on somebody else's PR. The same measurement with a 120 s cap, for the control: load36 run 1: rc=124 wall=120.014 s last watchdog: iteration 5, phase 'draining (~StrandExecutor)', 10 s into the iteration, completed 1975/3200, inFlight 1, maxInFlight 1 load36 run 2: rc=124 wall=120.018 s (reached iteration 6) load36 run 3: rc=124 wall=120.021 s (reached iteration 6) morph#760 reported four iterations in at 120 s under load average 29-48; six here at 26-38. Same shape. ## What was not done `kIterations` is untouched at 20. It is this case's detection power for a rare interleaving, not a duration knob, and cutting it to fit a ceiling would make the case cheaper and worse at the only thing it is for. The budget moved instead. Both constants now say so in the source. ## The budget `[slow]` is excluded from the blanket and registered again at `TIMEOUT 900` -- the mechanism #589/#590 already established here, because `DISCOVERY_MODE PRE_TEST` defers discovery to ctest invocation time and `set_tests_properties()` has nothing to name at configure time. 900 s is ~2.3x the measured 396 s. Extrapolating the curve to a run queue of ~50 -- the top of morph#760's reported load average -- gives roughly 730 s, so it is ~1.2x that too. It is also the number this same file already uses for its other load-sensitive case, `forms_schema_generation_is_not_route_count_ sensitive`, chosen there for the same reason. It is a ceiling, not immunity: past roughly 4.5x oversubscription this will exceed 900 s as well. What it still catches is a *deadlock*, which is unbounded rather than merely large -- in 15 minutes instead of 2, for one test out of 3044. ## Verified The tag is load-bearing, not decorative. `ctest --show-only=json-v1`: with [slow]: 3044 tests, {120.0: 2966, 900.0: 2, 300.0: 2, 60.0: 2}, strand case TIMEOUT 900.0 without [slow]: 3044 tests, {120.0: 2967, 900.0: 1, 300.0: 2, 60.0: 2}, strand case TIMEOUT 120.0 Same total either way and zero duplicate names, so splitting the discovery call in two neither drops a test nor registers one twice. Its neighbour in the same file, `StrandExecutor keeps one strand per key when a post races the drain`, stays at 120. Catch2 tags are never translated into ctest labels anywhere in this repository (no `catch_discover_tests` call passes `ADD_TAGS_AS_LABELS`; ci.yml says so at the TSan leg), so `[slow]` cannot change what any `-L`/`-LE` filter selects. ## The budget proved where it has to hold Full `ctest -j 12` with 24 spin loops competing for the same 12 cores, in four foreground ranges, load average 17 -> 37 across them: -I 1,1000 CTEST_EXIT=0 60 s 100% tests passed out of 1000 -I 1001,1800 CTEST_EXIT=0 274 s 100% tests passed out of 800 -I 1801,2600 CTEST_EXIT=0 233 s 100% tests passed out of 800 -I 2601,3044 CTEST_EXIT=0 30 s 100% tests passed out of 444 and in the range that holds it: 799/800 Test #1651: StrandExecutor never runs two tasks for one key concurrently under contention ... Passed 188.43 sec 188.43 s is a hard timeout at 120 and passes comfortably at 900 -- the defect and the fix in one line, under the load the issue asks the case to survive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Yaraslaut
force-pushed
the
strand-race-timeout
branch
from
September 23, 2026 14:42
2c1594c to
53960ec
Compare
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 #760 — the strand-race case was being killed while passing, by a ceiling that was never chosen with it in mind.
The defect is the ceiling, not the test
One blanket 120 s over all 2966 Catch2 cases in
morph_tests, justified in the comment above it by "every test is sub-second in practice". That is true of 2965 of them. The longest, most load-sensitive case in the binary was governed by the same number as the cheapest.What the case actually costs, measured
It is not slow because it computes anything.
StrandExecutor never runs two tasks for one key concurrently under contentionserialises8 × 400 = 3200tasks per iteration through one strand, and every handoff is a thread wakeup that has to wait its turn on the run queue. Its cost is therefore set by how contended the host is, not by the work.12 cores, clang 22.1.8 Release (
linux-everything), load generated with plainwhile :; do :; donespin loops, whole-case wall clock, one run each:Steeply superlinear: 12→24 spin loops costs 7.2×, 24→36 another 2.3×. The load average at the 36-spinner measurement was 31–38, against morph#760's reported 29–48.
Every one of those runs passed —
All tests passed (40 assertions in 1 test case), withinFlight 1, maxInFlight 1in every watchdog line. The invariant the case exists to check never wavered at any load. What the 120 s cap was killing was a passing test.The control, at the old ceiling
Same load, capped at 120 s:
Catch2 reports
SIGTERM - Termination request signal, not a failed assertion; 12 of 12 assertions evaluated up to that point had passed. morph#760 saw four iterations in at 120 s under a heavier load average; six here. Same shape.What this does not do
kIterationsis untouched at 20.That is the cheap fix and it is the wrong one. Twenty iterations is this case's detection power for a rare drain-and-re-arm interleaving; a strand-race detector with too few iterations approaches "would this still pass if the feature did nothing?", which
AGENTS.mdnames first. The budget moved instead, and both constants now say so in the source rather than leaving the next reader to rediscover it.The budget
[slow]is excluded from the blanket and registered again with its ownTIMEOUT 900— the mechanism #589/#590 already established in this file, becauseDISCOVERY_MODE PRE_TESTdefers discovery to ctest invocation time andset_tests_properties()has nothing to name at configure time.Why 900:
forms_schema_generation_is_not_route_count_sensitive, chosen there for the same reason ("a slow shared runner can be several times that").What it is not: immunity. Past roughly 4.5× oversubscription this will exceed 900 s as well. A fixed ceiling cannot bound an unbounded load, and the comment says so. What it still catches is a deadlock — unbounded, not merely large — in 15 minutes instead of 2, for one test out of 3044.
The tag is load-bearing, and that was checked rather than assumed
ctest --show-only=json-v1, same tree, tag added and removed:catch_discover_testscall into~[slow]and[slow]neither drops a test nor registers one twice. That is the failure mode this mechanism has: aTEST_SPECthat silently selects nothing, or two that overlap.StrandExecutor keeps one strand per key when a post races the drain, stays at 120 — the widening is one case, not one file.catch_discover_testscall passesADD_TAGS_AS_LABELS;ci.ymlstates this at the TSan leg), so[slow]cannot change what any-L/-LEfilter selects.Proved where it has to hold
morph#760's close condition is that the case survive a full
ctest -j $(nproc)under load comparable to the reported run. Full suite at-j 12with 24 spin loops competing for the same 12 cores, in four foreground ranges, load average 17 → 37 across them:and in the range that holds it:
188.43 s — a hard timeout at 120, comfortably inside 900. That one line is the defect and the fix together, under the load the issue asks the case to survive.
Gates
linux-everything(clang 22.1.8, Release, every optional feature on), after the final edit, exit codes captured directly and never through a pipe.clang-format --dry-run -Werror, whole treefiles: 796/FORMAT_EXIT=0clang-tidy-diff.pyovergit diff -U0 origin/master...HEADTIDY_EXIT=0--target doc,WARN_AS_ERROR=FAIL_ON_WARNINGSDOC_EXIT=0ctest -j 12, all 3044, under 24 competing spin loopsctest --show-only=json-v1, tag present vs absentThe tidy gate is not vacuous. It is run against
origin/master...HEAD(the merge base), notHEAD— see morph#776 for why that distinction cost a red CI cycle on morph#768 this week — and it was checked by injection on this branch:Findings left for their own issues, not folded in
StrandExecutordefect if the drain rate degrades beyond what the scheduler explains" clause points straight at it, and three data points on one machine are not enough to answer it — that wants aperf schedsplit, not a timeout change. Filed with the measurements, the alternatives not ruled out, and the experiment that would settle it. morph#760 is closed by this PR, so it could not stay there.forms_schema_generation_is_not_route_count_sensitive,bench: RemoteServer dispatch throughput and latency). Neither is inmorph_tests' blanket — the first already carriesTIMEOUT 900 RUN_SERIAL TRUE— so neither is fixed or affected here.Base
Branched from
c4e21233.origin/masterhas since moved toce1f6193(morph#773), whose delta istests/net/**only — disjoint from the two files here. No conflict, no rebase.🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW