ci+mutation: the campaign could not report a failure at all -- three compounding defects, a pipefail gate, and TSan's second stack (fixes #730, #731, #732, #736) - #749
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…ow (fixes #730) The mutation campaign step ran bash scripts/mutation.sh "<scope>" | tee "mutation-<scope>.log" under `bash -e {0}` -- `-e` but not `-o pipefail` -- so the step's status was `tee`'s. scripts/mutation.sh takes deliberate care to exit 1 when mull-runner writes no report; that 1 was discarded, the step went green, and the failure surfaced one step later as check_mutation_regression.py failing to *open* build/mutation-core-forms/mutation-core-forms.txt. Two weeks of scheduled runs measured nothing and CI said so only as a missing file. Verified by driving the step's own shell fragment the way GitHub does, against a stub mutation.sh that fails the way run 35592789912 did: BEFORE: campaign step exit = 0 AFTER: campaign step exit = 1 AFTER (a campaign that prints nothing and exits 0): exit = 1 The third line is the `test -s` on the log: pipefail cannot see a script that prints nothing and exits 0, and an empty log is the same claim of success over nothing. ## The sweep, and why this closes the class rather than the instance #730 asked for an audit of every other `run:` block. Parsing all 169 of them across the seven workflow files found 13 pipelines, 6 of them unguarded: * mutation.yml:119 `scripts/mutation.sh | tee` -- this defect * ci.yml:1435,1898 `echo "$changed" | grep -qE` -- `echo` cannot fail * ci.yml:2627 `tr -cd '\0' < … | wc -c` -- the `-lt 100` guard already catches the vacuous case * ci.yml:2965 `find A B … | head -1` -- deliberate: the left side is *expected* to fail * wasm-ladder.yml:209 `find … | sort` -- prints nothing and exits 0 whether the tree is empty or absent Five are now guarded; the sixth carries `# pipefail-ok:` with its reason, which is the one morph#479's own comment already explains at length. scripts/check_workflow_pipefail.py makes that state the rule. Every pipeline in a bash `run:` block must be covered by a pipefail shell, a `set … pipefail` earlier in the block, or a `# pipefail-ok: <reason>` marker -- and a marker that excuses nothing is an error, because it sits inert on a line ready to excuse whatever is written there next. Between morph#479 and morph#730 the same trap was guarded against by hand in three steps, and ci.yml:286 carries a comment about this exact hazard. The guard was known and applied inconsistently, which is what a gate is for. The gate is not vacuous on arrival: it found all six. Its self-test drives 13 cases, including the two that decide whether it measures anything -- a tree whose `run:` blocks it can no longer parse, and one whose pipelines it can no longer recognise, both of which it would otherwise pass while reading nothing. ok: the unmodified tree passes ok: caught: morph#730: the mutation campaign's pipefail removed ok: caught: morph#479: the clang-tidy step's pipefail removed ok: caught: a newly added unguarded pipeline ok: caught: the run-block syntax moved out from under the parser ok: caught: every pipeline rewritten out of the shape the gate reads ok: caught: an exemption with no reason ok: caught: an exemption left behind after its pipeline went away ok: accepted: a disjunction is not a pipeline ok: accepted: a quoted pipe character is not a pipeline ok: accepted: a GitHub expression containing || ok: accepted: a pipeline under an explicit shell: bash ok: accepted: a pipeline in a pwsh step The self-test's `edit()` helper checks three things the ten sibling self-tests' copy does not -- a `sed` that failed, a `sed` that emptied the file, and a `sed` that matched nothing. All three fired while this file was being written, and the first two produced passing cases over an empty workflow. Filed as morph#746 rather than fixed across the other ten here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…fixes #731) The "Open an issue on failure" step built a fixed title per scope and skipped when any issue carrying that title was open: title="Mutation campaign failed or regressed (scope: ${scope})" existing="$(gh issue list --state open --search "in:title \"${title}\"" …)" if [ "$existing" != "0" ]; then echo "An open issue already names this failure; not filing a duplicate." exit 0 fi The title carries the scope and nothing about the failure, so "already names this failure" was false whenever the failure was a different one. morph#517 was filed on 2026-09-14 for a survivor regression; the 2026-09-21 run, in which the campaign never ran at all (morph#732), printed that line and left no record outside its own run log. The dedup was right and is kept -- one thread per scope. What was missing is the other half: a suppressed run now lands as a comment on the issue that suppressed it, with the cause it was classified as and the tail of each log the run produced. The step's body moves to scripts/report_mutation_failure.sh, and that is the fix rather than a tidy-up. morph#731's acceptance condition is "force two different failures in one scope and confirm both are recorded", and the thing to confirm is an *absence* of a record -- no issue, no comment, no label, only a run log. There is nothing to inspect after the fact, so it has to be driven, and a dozen lines inside a `run:` block cannot be driven at all. scripts/test_report_mutation_failure.sh drives it against a stub `gh` that keeps issues in a directory and records every create/comment. The two failures are the two runs morph#731 cites, verbatim: ok: two different failures in one scope: the first opens the issue, the second comments on it ok: the comment carries the second failure's own log, not the first's ok: the comment names the cause it classified ok: the comment names the run it came from ok: the opening issue quotes the regression rather than paraphrasing it ok: an issue whose title merely contains the phrase is not commented on ok: a failure in a different scope opens its own thread ok: an unrecognised failure is reported as unclassified, with its log ok: a log the run never wrote is named as missing, and the report still lands ok: a failing search is fatal, and the failure still reaches the run log Shown failing on the old behaviour: reinstating the `exit 0` branch in a scratch copy gives error: both failures must be recorded. Transcript was: create 900 expected: create 900 comment 900 error: the comment does not quote the warm-up timeout it was reporting: error: the comment does not name the classified cause: error: the comment does not name run 2 4 case(s) failed. Three things that are not incidental: * **The title match is exact.** `--search 'in:title "…"'` is a text search, and morph#731 recorded its behaviour as unverified. The reply is filtered in shell, not in `--jq`, so the near-miss case is testable -- a filter inside jq is one only the real `gh` can run. * **A failing search is fatal**, not "no issue open". Reading an API error as "nothing is open" files a duplicate every week; the digest goes to the run log on the way out so the failure is not lost with it. * **The regression check now tees its verdict** to regression-<scope>.log, so the report can quote it rather than paraphrase it. That pipeline is guarded, which is #730. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…he runner (fixes #732) `--timeout 60000` was arithmetic done once -- 2.7x the 22s this suite takes on a 12-core workstation -- and the same number bounds mull's **warm-up** run of the *unmutated* suite. When the baseline goes over the cap mull does not slow down, it aborts before mutating anything: [info] Warm up run (threads: 1) [################################] 1/1. Finished in 1m0.0s [error] Original test failed (warmup run) status: Timedout ## The hosted-runner baseline, which nobody had #732 asked for it before changing the number. It is in the campaign's own logs: | run | date | warm-up (1 thread) | mutant phase | build | |--------------|------------|--------------------|----------------|--------| | 34349442137 | 2026-09-09 | completed | completed | -- | | 34836153375 | 2026-09-14 | **24.21s** | 121m13.7s /784 | ~14min | | 35592789912 | 2026-09-21 | **> 60s** (killed) | never started | ~29min | $ gh run view 34836153375 --log | grep -i "warm up" -A 2 [info] Warm up run (threads: 1) [################################] 1/1. Finished in 24.21s **This corrects the issue's premise, which was that a hosted runner is materially slower.** It is not: 24.21s against 22s is about 10%. What it is, is *variable*. The same job's instrumented build took 14 minutes on 09-14 and 29 on 09-21, and the baseline moved with it -- from 2.5x under the cap to over it. Two of the three hosted runs completed the campaign at the old cap, so "the campaign cannot complete on a hosted runner" is too strong; it completes on the machine you usually get and not on the one you sometimes get. That is why this derives rather than re-pins. A second constant chosen from 24.21s would have the same shape as the first and a cliff in a new place. ## What changed scripts/mutation.sh runs the unmutated suite once, single-threaded, exactly as mull's warm-up will, and sets `--timeout` to 2.7x what it measured -- floored at the old 60000 ms, which is what 22s x 2.7 comes to, so the machine the constant came from keeps the behaviour it had. `mull.yml`'s `timeout:` is appended after the measurement rather than written before the build: the frontend has read the file by then and does not consume `timeout`, and the runner -- which does -- has not started, so both halves see one number. Verified by driving the derivation lines as they appear in the script: baseline 1s -> --timeout 60000 ms (60.0x) <- the floor baseline 22s -> --timeout 60000 ms (2.7x) <- today's workstation, unchanged baseline 24s -> --timeout 64800 ms (2.7x) <- the measured hosted baseline baseline 60s -> --timeout 162000 ms (2.7x) <- the run that aborted would have proceeded baseline 150s -> --timeout 405000 ms (2.7x) baseline 150s with MULL_TIMEOUT_MS=99000 -> --timeout 99000 ms The measurement costs one suite run (~24s against a two-hour campaign) and is also the only place the unmutated suite's own output is ever seen -- mull reports a failing baseline as `Original test failed (warmup run)` with `stdout: ''`, `stderr: ''`. Against a stub binary that fails: refusal exit = 1 scripts/mutation.sh: the unmutated suite exited 1 after 0s. Every mutant is scored against this run, so a failing baseline makes the whole campaign meaningless. Mull reports this as 'Original test failed (warmup run)' with the suite's own output stripped out; here it is: test_outbox.cpp:412: FAILED: REQUIRE( queue.size() == 1 ) **Not verified:** no campaign was run. This box has no Mull install, and the campaign is ~76 minutes at best. What is measured is the derivation, the refusal, and the baseline figures above -- which come from CI's own logs, not from an estimate. The next scheduled run prints its own baseline into the log the workflow uploads, so the number stops being something anybody has to dig for. Consequence worth stating: **no mutation score since 2026-09-09.** This unblocks morph#517, which cannot be closed without a campaign run to triage its four survivors. And a runner as slow as 09-21's will now reach the mutant phase and is likely to hit `timeout-minutes: 240` instead -- ~242 min of mutants plus a ~29 min build, extrapolated from 09-14. Filed as morph#747 rather than folded in: it is a different constant and a different decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…736) Both TSan sites in ci.yml, and CMakePresets.json's `clang-tsan` test preset, carried `suppressions=` and nothing else. Without `second_deadlock_stack=1` a lock-order inversion is reported with the cycle and one stack per acquisition, and the stacks that would name the *participants* are replaced by advice: Hint: use TSAN_OPTIONS=second_deadlock_stack=1 to get more informative warning message -- which nobody can take after the fact. morph#578 and morph#717 are both intermittent and neither has been reproduced; the run that fires is the only evidence that will ever exist, and the job cannot be re-run into the same interleaving. ## Measured, not assumed A two-mutex inversion, clang 22.1.8, `-fsanitize=thread -g -O1`: $ TSAN_OPTIONS="" ./deadlock; wc -l 36 $ TSAN_OPTIONS="second_deadlock_stack=1" ./deadlock; wc -l 55 $ grep -c "previously acquired by the same thread here" plain.txt 0 $ grep -c "previously acquired by the same thread here" sds.txt 2 The 19 extra lines are the two stacks, and they name the function and source line that took each already-held mutex: Mutex M0 previously acquired by the same thread here: #0 pthread_mutex_lock … #4 take_a_then_b() deadlock.cpp:11:33 #5 main deadlock.cpp:21:5 Mutex M1 previously acquired by the same thread here: … #4 take_b_then_a() deadlock.cpp:16:33 (morph#738's lane measured 47 -> 71 on a larger fixture; same two stacks, and that lane also disproved its own hypothesis that this was why morph#578's report was uninformative -- that was morph#578's own `grep -A 60 | head -70`.) ## The triage's own closing condition, measured > `invalid` if the option turns out to cost meaningful runtime on a green run -- > I have not measured that, and neither did the filing. 2M lock acquisitions across 4 threads over 16 mutexes under TSan, which is the worst case for an option that retains an acquisition stack per mutex: without: 0.275 0.275 0.242 with second_deadlock_stack=1: 0.263 0.273 0.278 No measurable difference. **Proxy, not the suite:** this is a lock-heavy microbenchmark, not morph_tests under the CI preset, which I did not run. ## Two things the change gets right rather than nearly right The option goes *into* the existing value, colon-separated. A second `TSAN_OPTIONS:` key would silently replace the first and drop the suppressions file -- morph#688's failure one spelling over: >>> yaml.safe_load("env:\n TSAN_OPTIONS: suppressions=/x/cmake/tsan.supp\n TSAN_OPTIONS: second_deadlock_stack=1\n") {'env': {'TSAN_OPTIONS': 'second_deadlock_stack=1'}} And `CMakePresets.json`'s `clang-tsan` test preset gets it too. That preset exists (morph#688) so a local run matches CI; CI gaining the option alone would make a local reproduction *less* informative than the run being reproduced, which is the asymmetry morph#688 was filed to remove. `cmake/tsan.supp` is untouched -- it is held by an open PR, and nothing here needs it. Three places now have to agree on one string and nothing checks that they do. Filed as morph#748 rather than folded in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Yaraslaut
force-pushed
the
lane-ci-batch-730-731-732-736
branch
from
September 23, 2026 04:42
6975635 to
ef30b6a
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.
Four of six tickets, one commit each. The first three are
mutation.ymlandthey compound: on
masterthe campaign is unable to report a failure atall — the step goes green, and anything that does surface is swallowed by an
unrelated open issue. Fixed in that order, because #730 has to land first for
the other two to be checkable.
Two tickets are handed back with evidence rather than implemented; both are
boundary problems, not design problems, and both are recorded on their issues.
set -o pipefailon the campaign step, the other four unguarded pipes in the tree, andscripts/check_workflow_pipefail.py+ self-test + apipefail-lintjob so the class stays closedscripts/report_mutation_failure.sh, which comments on the scope's open issue instead of skipping — self-tested against a stubghsecond_deadlock_stack=1in bothci.ymlsites andCMakePresets.json'sclang-tsanpresettests/**#730 — a failed campaign reported success
mutation.ymlranbash scripts/mutation.sh … | tee …underbash -e {0},which is
-ebut not-o pipefail, so the step's status wastee's.scripts/mutation.shtakes deliberate care toexit 1when mull-runner writesno report; that 1 was discarded and the failure surfaced one step later as
check_mutation_regression.pyfailing to open a missing file.The deliberate breakage, driving the step's own shell fragment the way
GitHub does against a stub
mutation.shthat fails the way run 35592789912 did:The third line is a
test -son the log: pipefail cannot see a script thatprints nothing and exits 0.
The unguarded-pipe sweep
Parsing all 169
run:blocks across the seven workflow files found 13pipelines, 6 unguarded:
mutation.yml:119scripts/mutation.sh | teeci.yml:1435,:1898echo "$changed" | grep -qEechocannot fail) — guarded anywayci.yml:2627tr -cd '\0' < … | wc -c-lt 100check below already refuses a vacuous run — guarded anywayci.yml:2965find A B … | head -1# pipefail-ok:with the reason morph#479's own comment already giveswasm-ladder.yml:209find … | sortSo one live defect, one deliberate exemption, four hardened.
ci.yml:286already carried a comment about this exact hazard and
pipefailwas set by handin three steps — the guard was known and applied inconsistently, which is what
the gate is for.
scripts/check_workflow_pipefail.pymakes it the rule: a pipeline must becovered by a pipefail shell, a
set … pipefailearlier in the block, or a# pipefail-ok: <reason>marker — and a marker that excuses nothing is anerror, since it sits inert ready to excuse whatever is written on that line
next. Two anti-vacuity floors, on run blocks parsed and pipelines found, because
a scanner that stops matching would otherwise report a clean tree it never read.
The gate shown failing on broken input (13 cases, all green on this branch):
Note the last-but-two:
shell: bashisbash --noprofile --norc -eo pipefail {0}and does carry the guard. Only the absent key does not, and that asymmetryis most of why this recurs.
#731 — the report was silenced by an unrelated open issue
The old step built a fixed title per scope and skipped when any issue with that
title was open. The title carries the scope and nothing about the failure, so
the first failure silenced every later, different one — which is how #732 stayed
invisible behind #517.
The body moves to
scripts/report_mutation_failure.sh, which comments on theopen issue instead, with the cause it classified and the tail of each log the
run produced. The dedup is kept: one thread per scope, and the thread is the
campaign's history.
It moves to a script because the acceptance condition is "force two different
failures in one scope and confirm both are recorded", and what has to be
confirmed is the absence of a record — no issue, no comment, no label. There
is nothing to inspect afterwards, so it has to be driven.
scripts/test_report_mutation_failure.shdrives it against a stubgh, usingthe two runs #731 cites verbatim:
Shown failing on the old behaviour — reinstating the
exit 0branch in ascratch copy:
Two details that are not incidental: the title match is exact, and the
filter is in shell rather than in
--jq, because a filter inside jq is one onlythe real
ghcan run and the near-miss case could not be tested at all. And afailing
gh issue listis fatal rather than "no issue open", which wouldfile a duplicate every week.
#732 — the campaign could not complete, and the baseline nobody had
The hosted-runner baseline, measured — it was in the campaign's own logs:
This corrects the ticket's premise. A hosted runner is not materially
slower: 24.21 s against the workstation's 22 s is about 10%, and two of the
three hosted runs completed the campaign at the old cap. What it is, is
variable — the same job's instrumented build took 14 minutes on 09-14 and 29
on 09-21, and the baseline moved with it, from 2.5× under the cap to over it.
So a second constant derived from 24.21 s would have the same shape as the first
and a cliff in a new place.
scripts/mutation.shnow runs the unmutated suite once, single-threaded,exactly as mull's warm-up will, and sets
--timeoutto 2.7× what it measured,floored at the old 60000 ms. Driving the derivation lines as they appear in the
script:
The measurement is also the only place the unmutated suite's own output is ever
seen — mull reports a failing baseline as
Original test failed (warmup run)with
stdout: ''. Against a stub binary that fails:Not verified: no campaign was run — no Mull on this box, and the campaign is
~76 minutes at best. What is measured is the derivation, the refusal, and the
three runs' figures, which come from CI's logs rather than an estimate.
Consequence: no mutation score since 2026-09-09. This unblocks #517,
which cannot be closed without a campaign run to triage its four survivors —
that was left alone here, as instructed.
#736 — TSan now names where the already-held mutex was taken
Both
ci.ymlsites andCMakePresets.json'sclang-tsantest preset. Measuredon a two-mutex inversion, clang 22.1.8:
The 19 extra lines are two stacks naming the acquiring function and source line
(
take_a_then_b() deadlock.cpp:11:33). Without them TSan prints only the hintto set the option — advice nobody can take after the fact, since #578 and #717
are intermittent and the run that fires is the only evidence there will be.
The triage's own closing condition ("
invalidif the option costs meaningfulruntime") measured: 2M lock acquisitions across 4 threads over 16 mutexes under
TSan, which is the worst case for an option that retains a stack per
acquisition —
No measurable difference. Proxy, not the suite — a lock-heavy microbenchmark,
not
morph_testsunder the CI preset.The option is colon-separated into the existing value; a second
TSAN_OPTIONS:key silently replaces the first and drops the suppressions file:cmake/tsan.suppis untouched — it is held by an open PR and nothing here needsit.
Handed back
#674 — third hand-back, same boundary, one new measurement: the half this
lane does own (
ci.yml'sCATCH2_VERSIONand 22catch2apt lines) is thehalf that must not ship first.
CMakeLists.txt:555-562's fallback fetchesv3.8.1 without
SYSTEM, so dropping the apt installs alone makes everyCatch2 macro expansion user code — the #684 lane measured 1 finding → 45 on a
single TU. Recorded on the issue, with the corollary that any contributor
without the distro package is already in that configuration today.
#650 — the
rescopeverdict stands and needs one correction: the couplingis four checkers, not three, and they point in opposite directions. Three
prune
tests/lintfrom a tree-wide scan;check_bidi_controls.pynames itsfixture root to scan it deliberately. So the unwritten convention is two rules,
not one, which strengthens the documentation case and weakens the gate case.
The remaining work is
tests/lint/README.mdplus a two-way correspondencecheck; the README is in
tests/**, held by another PR this sweep, and thecheck is worthless without it. Recorded on the issue.
Filed
edit()helper shared by tentest_check_*.shself-testscannot tell an applied mutation from one that silently truncated the file.
Reproduced here: five consecutive
ok: acceptedlines over adocs.ymlthathad been emptied by a
sedsyntax error. The repaired helper is in thisbranch's new self-test; the other ten are other lanes' files.
mutation.yml'stimeout-minutes: 240has a 1.6× margin over theonly completed hosted run (2h16m35s), against 2× observed runner variance.
Surfaced by The mutation campaign cannot complete on a hosted runner: mull's warm-up run of the unmutated suite times out at 60 s #732: a slow runner now reaches the mutant phase instead of
aborting in the warm-up. Extrapolated, not observed.
TSAN_OPTIONSis now spelled out in three places that must agree,with nothing comparing them. That divergence is what ci: five test suites are never sanitizer-instrumented, including the one a job was widened to cover #542 and A relative TSAN_OPTIONS suppressions path makes ctest's Catch2 discovery exit 66 with no message, and no doc says to use an absolute one #688 each were.
Nothing staled a citation in either allowlist JSON.
scripts/mutation_survivors.jsonmentions
--timeout 60000in three places; all three are records of specificpast runs on the 12-core workstation, where the derived value is still 60000, so
they remain accurate and were not touched.
Gates
All run after the final edit, exit codes captured directly from the command
rather than through a pipe:
Plus
check_spec_citations.sh,check_tidy_suppression_scope.shandcheck_catch2_pin.sh, allexit=0.🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW