Skip to content

tests/ci: a race regression test that detects its race, and a Catch2 version that is checked rather than asserted (fixes #668, fixes #666, refs #667) - #673

Merged
Yaraslaut merged 3 commits into
masterfrom
laneRACE-batch-668-666-667
Sep 21, 2026
Merged

Yaraslaut merged 3 commits into
masterfrom
laneRACE-batch-668-666-667

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Three tickets about controls that report success while measuring nothing. Two
commits. One of the three turns out to be false, and is not "fixed" here
see the #667 section, which is the part worth reading first if you only read
one.


#668 — commit 1

Reproduced the miss before touching anything, as the ticket asks

Two-step drain restored in scheduleNext (flip running under strand->mtx,
release it, then erase under _mapMtx in a separate critical section), base
7a343e6f, x86-64 Linux, clang 22.1.8, clang-tsan preset,
TSAN_OPTIONS=suppressions=cmake/tsan.supp:

run 1..10: PASS   All tests passed (41 assertions in 2 test cases)
=== [race] : 10 pass / 0 fail out of 10 ===

Not a blind run: the binary carries 173 __tsan_ symbols.
(scripts/check_sanitizer_instrumentation.sh refuses a build directory holding
one binary — only examined 1 binaries ... too few for this check to mean anything — so the symbol count is read directly, since only morph_tests was
built.)

The finding is not stale. It reproduces exactly as filed.

The miss is the load shape

The erase only fires when a drain finds the pending queue empty, and eight
threads posting 3200 tasks back-to-back onto one key keep it non-empty almost
throughout. The header comment's "very short tasks maximise the drain/re-arm
interleaving that triggered the bug" is false in the direction that matters:
they maximise re-arm, and never produce the quiet moment a drain needs. That
sentence is corrected in place, with the measurement beside it.

The new case manufactures the rendezvous

A pilot task is posted alone on the key and publishes the round number as its
last act. Four chaser threads spin on that publication and post the instant it
flips — i.e. while the drain block following the pilot's body is deciding
keep-running vs. erase — each with a per-thread stagger that walks its post
across the handful of instructions that decision spans. A round ends only once
every one of its tasks has run, so the strand genuinely empties before the next
pilot. The gap is the point.

Three detectors, because the defect and its symptom are not the same event:

detector what it sees needs a sanitizer?
maxInFlight (kept from the old case) the symptom: two tasks for one key at the same wall-clock moment no
plain, non-atomic state touched by every task the defect: two strands for one key leave those accesses unordered by any happens-before edge — TSan reports it whether or not the tasks overlap; lost updates to it are visible anyway no (both ways)
per-producer FIFO an orphaned strand runs one producer's later task before its earlier one, with no overlap required no

Mutation-tested, with the parameters as committed

clang-tsan,  mutant, 10 runs        :  0 pass / 10 fail
                                       2-3 TSan data races per run
                                       outOfOrder 65-114, maxInFlight 2
clang-debug, mutant, 15 runs        :  0 pass / 15 fail
                                       outOfOrder 1-9, maxInFlight 2
clang-tsan,  mutant, taskset 4 CPUs :  0 pass / 10 fail
clang-tsan,  mutant, taskset 2 CPUs :  0 pass / 10 fail
the OLD case, same mutant binary    : 10 pass /  0 fail

The TSan report names the mechanism:

WARNING: ThreadSanitizer: data race (pid=520470)
  Write of size 4 at 0x721800008400 by thread T1:
    #0 ...::$_2::operator()(int, int) const tests/test_strand_race.cpp:210:22
    #1 ...::$_0::operator()() const::'lambda'()::operator()() const tests/test_strand_race.cpp:245:71
    #6 morph::exec::detail::StrandExecutor::scheduleNext(...)::'lambda'()::operator()() const strand.hpp:158:17
  Previous write of size 4 at 0x721800008400 by thread T2:
    ... the same two frames, the other pool thread

Two strand lambdas for one key writing the same plain int.

And it does not flake on the fixed tree

[race] passes 20/20 under clang-tsan and 20/20 under clang-debug; the
full morph_tests binary passes (1562 cases, 23007 assertions, the one
failed as expected). Cost: 1.0 s under TSan on 4 CPUs, 1.3 s on 2,
0.098 s without a sanitizer, against the suite's TIMEOUT 120.

include/morph/core/strand.hpp is untouched (PR #671 holds it). The mutant
was applied to the file and reverted from a byte-identical backup; md5sum
checked against the pristine copy after each cycle, and git diff --stat for
the branch names no header.


#666 + #667 — commit 2

Establishing the version first, as instructed — and it settles #667 the other way

apt-cache policy catch2 is not available here (Arch workstation), so two
independent sources:

Launchpad, ubuntu/+archive/primary for noble:

catch2 3.4.0-1build1 in noble | Release  | Published  | 2024-04-22
catch2 3.4.0-1build1 in noble | Proposed | Deleted    | 2024-04-22
catch2 3.4.0-1       in noble | Release  | Superseded | 2023-11-19
catch2 2.13.10-1     in noble | Release  | Superseded | 2023-10-23

No -updates or -security publication. 3.4.0-1build1.

The CI artifact both tickets read as 3.5.x, checked against the upstream
tarballs:

v3.4.0  catch_test_registry.hpp:121
  INTERNAL_CATCH_TESTCASE2( INTERNAL_CATCH_UNIQUE_NAME( dummyFunction ) )
v3.5.3  catch_test_registry.hpp:121
  INTERNAL_CATCH_TESTCASE2( INTERNAL_CATCH_UNIQUE_NAME( dummyFunction ), __VA_ARGS__ )

The artifact prints it without , __VA_ARGS__ — v3.4.0's text exactly, and
the one line #667 itself flags as not matching 3.5.3. catch_test_macros.hpp:142
and catch_unique_name.hpp:12/13/15 match v3.4.0 too; they are simply
unchanged between the two releases, so they never distinguished them.

The premise both tickets rest on is false. #667: "dummyFunction exists
only inside #ifdef CATCH_CONFIG_EXPERIMENTAL_STATIC_ANALYSIS_SUPPORT, which
Catch2 did not have in 3.4.0 — it arrives in 3.5.0."
v3.4.0 has it, at
catch_test_registry.hpp:77/91/124, and #cmakedefines it in
catch_user_config.hpp.in:172. So the nine copies were right all along, and
#667 meets its own invalidation condition verbatim — "the naming mechanism is
not what anyone thinks"
.

Two smaller corrections to #667's text while we are here: the sentence is in
nine files, and tests/.clang-tidy (which the title names) is not one
of them — it is the eight examples/*/tests/.clang-tidy plus
examples/common/testkit/.clang-tidy.

What actually needed fixing

#666's divergence is real and its measurement stands: which Catch2 is on the
include path decides whether a readability-function-cognitive-complexity
finding on a TEST_CASE body reaches the job. What was missing is that nothing
recorded or checked the version, and apt-get install -y catch2 pins nothing.

scripts/check_catch2_pin.sh, modelled on scripts/check_ci_clang_pin.sh
(#457) down to the rule-B rewording trap and the anti-vacuity failure:

  • A. Textual. Every CI pins catch2 <V> must name ci.yml's new
    CATCH2_VERSION; any other line naming a Catch2 version beside a CI
    reference is rejected as a phrasing the gate cannot check; zero canonical
    assertions is a failure, not a pass.
  • B. Behavioural. It reads the version out of the Catch2 headers actually
    on the include path. Under --strict — how the clang-tidy job runs it, right
    after the apt install and before the configure — a moved runner package, or
    no Catch2 at all, fails the job before it measures anything. Without
    --strict, a workstation run prints the divergence and says in as many words
    that its clang-tidy-diff result is not the measurement CI makes:
WARNING: this machine's Catch2 is 3.16.0 (/usr/include); CI pins catch2 3.4.0.
         A local clang-tidy-diff run is therefore NOT the measurement the
         clang-tidy-diff job makes. ...
         A green local run is not evidence for those checks. It is still
         evidence for every check whose finding lands on a line you wrote.

scripts/test_check_catch2_pin.sh drives both halves from the wrong side as
well as the right one — 13 cases, each drift reintroduced one at a time into a
scratch git tree or a synthetic include directory and required to fail for the
stated reason
, plus four false-positive mirrors. All 13 pass; the two that
matter most:

ok: caught: --strict against a runner whose Catch2 package has moved
ok: caught: --strict with no Catch2 installed at all

The nine copies are reworded, not corrected: same fact, in the canonical
phrasing the gate reads, so the number is now checked rather than asserted —
which is what #667's triage actually wanted from nine hand-written copies of a
version.

What this deliberately does not do

It does not make a local clang-tidy-diff agree with CI's. Only the job not
depending on the runner's Catch2 does that (#666's first closing condition):
have the clang-tidy configure use the Catch2 CMakeLists.txt already pins for
FetchContent instead of apt's. That needs examples/common/CMakeLists.txt,
which PR #665 holds, and it is filed separately rather than folded in.

Nothing here tells a contributor to run clang-tidy differently — the local half
reports a measurement, the CI half fails a job.


The one claim this branch rests on

tests/test_strand_race.cpp now fails against the two-step drain-and-erase
mutant, and its new case's CHECKs cannot pass while two Strand objects
serve one key — measured 10/10 under clang-tsan and 15/15 without a
sanitizer, at 12, 4 and 2 CPUs, against 10/10 passes for the case it sits
beside.

Everything else on the branch is a gate over prose and a package version.

Review notes, done inline

  • Could the new case flake on the fixed tree? Its three CHECKs are all
    invariants the spec states unconditionally (at most one task in flight per
    key, per-producer FIFO, no lost updates to state the strand is the only
    synchronisation for). None depends on a deadline or a task count reached in
    time: the round barrier is a synchronisation point, and ~StrandExecutor is
    the final drain, exactly as the neighbouring case's comment argues. 40 clean
    runs across two build configurations found none.
  • Does the spin-wait starve a small runner? Four chasers spin while four
    pool threads work. The spin yields once per 4096 iterations as a starvation
    guard; pinned to 2 CPUs the case takes 1.3 s rather than 1.0 s and still
    catches the mutant 10/10, so the guard costs the rendezvous nothing.
  • Is the plain state actually data-race-free on the fixed tree? Every
    handoff between two tasks for one key passes through _mapMtx/strand->mtx
    and the pool's own queue mutex, so each task's writes happen-before the next
    task's reads. 20 TSan runs report zero races; the mutant reports 2-3 per run.
  • Could the new gate's rule B fire on existing prose? Surveyed before
    writing it: exactly one line in the tree names a Catch2 version by the gate's
    regex (docs/superpowers/specs/2026-09-15-opt-in-logging-design.md:119), and
    it carries no CI reference, so it is out of scope by construction. The
    self-test pins that as an expect_accepted case.

Gates run here

check_mutation_survivors.py, check_nolint_directives.sh,
check_bidi_controls.py, check_rung_filters.sh (58 checks, including all
nine reworded .clang-tidy files), check_spec_citations.sh,
check_ci_clang_pin.sh, check_workflow_job_banners.py,
check_workflow_option_coverage.py, check_catch_test_names.sh,
check_test_type_names.sh, check_ctest_name_collisions.sh build/clang-debug,
test_check_catch2_pin.sh, and clang-format --dry-run -Werror over
tests/test_strand_race.cpp. All pass.

Not verified: the --strict half has never run on an actual ubuntu-24.04
runner — it is exercised here against synthetic include directories, and this
PR's own clang-tidy leg is its first real execution. If it fails there, the
interesting outcome is what version it prints. Also not verified: the
Kanban / ThreadSanitizer leg and every non-Linux leg of the new test case.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification

#668's fix matches the mechanism, not just the symptom. My triage argued the old case missed because 8 threads × 400 posts is sustained saturation while the race needs a strand to reach empty. The new case is built on exactly that:

"A pilot task is posted alone on the key. Its last act is to publish the round number… kChasers threads spin on that publication and post the instant it flips — that is, while the drain block following the pilot's body is deciding 'keep running vs. erase'… The round ends only once every one of its tasks has run, so the strand really does empty before the next pilot. The gap is the point."

And the mutation table is the part that makes it a control rather than a test: 0 pass / 10 fail against the mutant under clang-tsan, 0/15 without a sanitizer, unchanged at 4 and 2 CPUs — against 10/10 passes for the old case on the same mutant binary. Three independent detectors (maxInFlight, plain non-atomic state, per-producer FIFO), of which the last breaks on an orphaned strand with no overlap at all, which is the case maxInFlight structurally cannot see.

include/morph/core/strand.hpp is untouched — confirmed, empty diff against base. This is a test fix, not a source fix, which is what the ticket asked for.

The new gate works: test_check_catch2_pin.sh passes, and check_catch2_pin.sh reports all 9 catch2-pin assertion(s) agree with .github/workflows/ci.yml, exit 0.

#667 was mine to get wrong, and this branch is right

I triaged #667 valid. It is invalid, and I have relabelled it and recorded the correction there. One command settles it:

v3.4.0 catch_test_registry.hpp:
   77:#if !defined(CATCH_CONFIG_EXPERIMENTAL_STATIC_ANALYSIS_SUPPORT)
  121:        INTERNAL_CATCH_TESTCASE2( INTERNAL_CATCH_UNIQUE_NAME( dummyFunction ) )
v3.5.3 same file:
  121:        INTERNAL_CATCH_TESTCASE2( INTERNAL_CATCH_UNIQUE_NAME( dummyFunction ), __VA_ARGS__ )

v3.4.0 has the macro, and the artifact line has no , __VA_ARGS__ — so it is 3.4.0, and the nine .clang-tidy copies were right all along. Rejecting the ticket rather than implementing it is the outcome AGENTS.md asks for, and the three independent lines of evidence are why it is convincing.

Rewording the nine into a phrasing the gate reads, instead of editing a number, is the better fix — it converts prose into something checkable, which is the same move #659 made for AUTOMOC pairing.

Two things before this can land

  1. It needs a rebase. Base is 7a343e6f; master is 563502ab since bank/gui + ci: clear the eleven bank-GUI sources' clang-tidy debt, and gate the Q_OBJECT header split that no build catches early (fixes #656, fixes #659) #665 merged, and that added scripts/check_qobject_moc_pairing.py plus a drift-guard.yml job — a gate this branch has never been judged by. Same verdict I applied to core/cmake: stop a serial dispatch rebuilding a std::deque it never fills, and say what -Wno-shadow-uncaptured-local really suppresses (fixes #660, refs #662) #671 an hour ago.
  2. --strict has never run on a real runner, as the lane says. Its first real execution is this PR's own clang-tidy leg, and if it fails there the interesting output is which version it prints — that is the direct measurement neither ticket had.

Filing the check_sanitizer_instrumentation.sh finding the lane offered rather than leaving it in a hand-back: a guard that refuses a single-target build tree makes the script unusable for exactly the narrow verification it is most needed for.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner: rebased onto 563502ab and fixed the one finding — 00ff98cc

Rebase, on the gate limb. #665 landed scripts/check_qobject_moc_pairing.py and its drift-guard.yml job — a gate this branch had never been judged by. Same verdict I applied to #671. It passes on the rebased tree, along with the branch's own new gate:

moc-pairing OK (the new gate from #665)
ok: all 9 catch2-pin assertion(s) agree with .github/workflows/ci.yml
nolint OK   bidi OK

clang-tidy-diff had one finding, read from the run's artifact since the run was still in progress:

tests/test_strand_race.cpp:289:40: error: use a ranges version of this algorithm
    [modernize-use-ranges,-warnings-as-errors]

Fixed: std::minmax_element(cells.begin(), cells.end())std::ranges::minmax_element(cells). <algorithm> was already included. The result is an aggregate of two iterators rather than a pair, so the structured binding and both dereferences are unchanged — I compiled that shape standalone under clang 22.1.8 -std=c++23 -Werror rather than assuming it, since "structured binding still decomposes" is exactly the kind of thing that reads as obvious and is not.

Worth noting what did not fail: the new --strict Catch2 check. The lane flagged that its first real execution on a runner would be this PR's own clang-tidy leg, and that the interesting output would be which version it prints. It did not fail, which is the first real evidence the pin matches — though the printed version is in the leg's log, and that log is still unavailable while the run finishes. Worth reading next sweep, because it is the direct apt-cache policy measurement neither #666 nor #667 ever had.

A correction about my own monitoring

I armed a watcher on #671 that reported "settled" while every check was still pending. It exited immediately because I started it seconds after a push, before the new run's checks existed — so pending == 0 was true for the wrong reason. That is the same "control that reports success while measuring nothing" failure this repository files issues about, in my own tooling rather than the repo's. Re-armed with a total-count floor, which is the same remedy check_bidi_controls.py and check_nolint_directives.sh already use: a scan that examined nothing must not read as clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@codecov

codecov Bot commented Sep 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner: the red legs are environmental, re-run requested

Both runs are complete, so I read every failing job rather than inferring. Failing step, per leg:


Not one of them is a compile, a test or a lint finding. They are package installs, a vcpkg checkout, and a dependency-cache configure — the same class as #672, which now has four distinct upstreams recorded in one day.

The two signatures seen here:

  • Install sccache / Install GCC 15 / Setup vcpkggzip: stdin: not in gzip format (a download that returned something other than a tarball, piped to tar unchecked) and pathspec 'D:\a\morph\morph\vcpkg' did not match any file(s) known to git.

  • Configure — inside the vendored Lightweight ORM's own CMakeLists in the dep cache:

    CMake Error at ~/.cache/morph-dep-cache/Lightweight_bbb972a78e1962b9/CMakeLists.txt:98 (CPMAddPackage):
    -- reflection-cpp not found, downloading...
      Unknown CMake command "CPMAddPackage".
    

    That is a cached third-party tree missing CPM, not anything either branch changed.

Why I am confident this is not the branches: master's own 14:27Z run was success, and PR #671 settled 54/54 green at 18:12 — passing the very Configure step that failed here at ~15:21. The failure window is bounded and shared, which is what an environment looks like and not what a defect looks like.

Re-run requested. If a leg comes back red with a compile or test error, that is a real finding and I will treat it as one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

Yaraslaut and others added 3 commits September 21, 2026 18:29
…e load test never reaches the boundary (fixes #668)

`tests/test_strand_race.cpp` is the named regression test for "a strand never
runs two tasks for one key concurrently" -- the invariant most of
`docs/spec/concurrency_and_lifetimes.md` rests on. It did not detect that
invariant's regression.

Reproduced before changing anything. With the pre-fix two-step drain restored
in `scheduleNext` (flip `running` under `strand->mtx`, release it, then erase
under `_mapMtx` in a separate critical section), on this branch's base
`7a343e6f`, x86-64 Linux, clang 22.1.8, `clang-tsan` preset,
`TSAN_OPTIONS=suppressions=cmake/tsan.supp`, binary carrying 173 `__tsan_`
symbols:

    run 1..10: PASS   All tests passed (41 assertions in 2 test cases)
    === [race] : 10 pass / 0 fail out of 10 ===

The miss is the load *shape*, not the load volume. The erase only fires when a
drain finds the pending queue empty, and eight threads posting 3200 tasks
back-to-back onto one key keep it non-empty almost throughout. The header
comment's claim that "very short tasks maximise the drain/re-arm interleaving"
is false in the direction that matters: they maximise re-arm, and never produce
the quiet moment a drain needs. That claim is corrected in place.

The new case manufactures the rendezvous instead of hoping for it. A pilot task
is posted alone on the key and publishes the round number as its last act; four
chaser threads spin on that publication and post the instant it flips, i.e.
while the drain block following the pilot's body is deciding keep-running vs.
erase, each with a per-thread stagger that walks the post across that window. A
round ends only once every one of its tasks has run, so the strand really does
empty before the next pilot.

Three detectors, because the defect and its symptom are not the same event:
`maxInFlight` (kept -- the symptom); plain, non-atomic state touched by every
task (the defect: two strands for one key leave those accesses unordered by any
happens-before edge, which TSan reports whether or not the tasks overlap, and
whose lost updates are visible with no sanitizer at all); and per-producer
FIFO, which an orphaned strand breaks without any two tasks overlapping.

Mutation-tested, with the parameters as committed:

    clang-tsan, mutant, 10 runs:      0 pass / 10 fail
      2-3 TSan data races per run; outOfOrder 65-114; maxInFlight 2
    clang-debug (no sanitizer), 15 runs: 0 pass / 15 fail
      outOfOrder 1-9; maxInFlight 2
    clang-tsan, mutant, pinned to 4 CPUs:  0 pass / 10 fail
    clang-tsan, mutant, pinned to 2 CPUs:  0 pass / 10 fail
    the OLD case, same mutant binary, 10 runs: 10 pass / 0 fail

And it does not flake on the fixed tree: `[race]` passes 20/20 under
`clang-tsan` and 20/20 under `clang-debug`, plus the full `morph_tests` binary
(1562 cases, 23007 assertions). Cost: 1.0 s under TSan on 4 CPUs, 1.3 s on 2,
0.098 s without a sanitizer -- against the suite's `TIMEOUT 120`.

`include/morph/core/strand.hpp` is untouched; the mutant was applied to a
scratch copy and reverted (md5 verified against the pristine file).

Not verified: the `Kanban / ThreadSanitizer` CI leg, which needs Qt and the
ladder, and every non-Linux leg. The 2-CPU run is the closest stand-in here for
a four-core runner under load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ixes #666, refs #667)

`apt-get install -y catch2` pins nothing, and which Catch2 is on the include
path decides whether a `readability-function-cognitive-complexity` finding on a
`TEST_CASE` body reaches the clang-tidy-diff job at all. So a workstation can
run the same clang-tidy over the same diff with the same job flags and exit 0
where CI exits 1 -- silently, reporting nothing rather than reporting less
(#666). Nothing in the tree stated which Catch2 the job had, except nine
hand-written copies of a sentence that nothing checked.

`scripts/check_catch2_pin.sh` has two halves, and only the second one is new in
kind:

  A. Textual, modelled on `scripts/check_ci_clang_pin.sh` (#457) down to the
     rule-B rewording trap and the anti-vacuity failure: every `CI pins catch2
     <V>` in the tree must name `CATCH2_VERSION` from ci.yml, and any other
     line naming a Catch2 version beside a CI reference is rejected as a
     phrasing the gate cannot check.
  B. Behavioural: the Catch2 headers actually present are read and compared
     against that pin. Under `--strict`, which is how the clang-tidy job runs
     it, a moved runner package -- or no Catch2 at all -- fails the job before
     it measures anything. Without `--strict`, a workstation run prints the
     divergence and says in as many words that its clang-tidy-diff result is
     not the measurement CI makes.

`scripts/test_check_catch2_pin.sh` drives both halves from the wrong side as
well as the right one: 13 cases, every drift the gate claims to catch
reintroduced one at a time into a scratch git tree or a synthetic include
directory, each required to fail *for the stated reason*, plus four
false-positive mirrors. Run here:

    ok: the unmodified tree passes against the pinned Catch2
    ok: caught: a doc asserting CI pins catch2 3.5.3 while ci.yml pins 3.4.0
    ok: caught: ci.yml bumped to 3.5.3 while the nine copies still say 3.4.0
    ok: caught: a CI Catch2 claim in an unrecognised phrasing, even with the right version
    ok: caught: a CI Catch2 claim in an unrecognised phrasing with the wrong version
    ok: caught: every canonical assertion removed from the tree
    ok: caught: ci.yml with no CATCH2_VERSION to read
    ok: accepted: a Catch2 version named with no CI reference on the line
    ok: accepted: a historical record carrying the documented marker
    ok: caught: --strict against a runner whose Catch2 package has moved
    ok: caught: --strict with no Catch2 installed at all
    ok: accepted: a workstation whose Catch2 differs is warned, not failed
    ok: accepted: a workstation whose Catch2 matches the pin is not warned
    ok: all catch2-pin checker self-tests passed

The version is 3.4.0, which is what the nine `.clang-tidy` copies already said
-- #667 is wrong and is not "fixed" here. ubuntu-24.04's package is
3.4.0-1build1 (Launchpad, noble Release pocket, no -updates publication), and
the artifact line both tickets read as 3.5.x is v3.4.0's text exactly:

    v3.4.0 catch_test_registry.hpp:121
      INTERNAL_CATCH_TESTCASE2( INTERNAL_CATCH_UNIQUE_NAME( dummyFunction ) )
    v3.5.3 catch_test_registry.hpp:121
      INTERNAL_CATCH_TESTCASE2( INTERNAL_CATCH_UNIQUE_NAME( dummyFunction ), __VA_ARGS__ )

The artifact has no `, __VA_ARGS__`. Both tickets rest on "3.4.0 has no
`CATCH_CONFIG_EXPERIMENTAL_STATIC_ANALYSIS_SUPPORT`, so it cannot produce
`dummyFunctionNN`"; v3.4.0 has it, at `catch_test_registry.hpp:77/91/124`. The
nine copies are therefore reworded rather than corrected: same fact, in the
canonical phrasing the gate reads, so the number is checked instead of
asserted.

What this does not do, because it matters that the line is drawn: it does not
make a local clang-tidy-diff agree with CI's. Only the job not depending on the
runner's Catch2 does that -- #666's first closing condition, which needs a
CMake change in a file this branch does not own, and is filed separately rather
than folded in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
`clang-tidy-diff` reported one finding on the new race case:

  tests/test_strand_race.cpp:289:40: error: use a ranges version of this
      algorithm [modernize-use-ranges,-warnings-as-errors]

`std::minmax_element(cells.begin(), cells.end())` becomes
`std::ranges::minmax_element(cells)`. `<algorithm>` was already included, and
the result is an aggregate of two iterators rather than a pair, so the
structured binding and both dereferences are unchanged -- verified by
compiling the same shape standalone under clang 22.1.8 `-std=c++23 -Werror`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
@Yaraslaut
Yaraslaut force-pushed the laneRACE-batch-668-666-667 branch from 00ff98c to f619158 Compare September 21, 2026 16:29
@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner: rebased onto 5fc5e788 — and this one is not routine

include/morph/core/strand.hpp changed on master (#671's PendingQueue), and tests/test_strand_race.cpp includes it directly. Second staleness limb: the combination was never tested.

Why it matters more than the usual rebase. This PR's entire contribution is a race test, and its evidence is a mutation result — 0 pass / 10 fail against the two-step-drain mutant, versus 10/10 passes for the old case on the same binary. That was measured against the old strand, whose queue was a std::deque rebuilt per dispatch. #671 replaced it with PendingQueue, which holds the head task inline.

So the open question is not whether the test still compiles or passes — CI answers that — but whether it still detects the race it was written to detect. A green run cannot distinguish "the test works" from "the test no longer provokes the window". That is precisely the failure #668 exists to fix, and merging on a green tick alone would reintroduce it one layer up.

Rebased cleanly, three commits replayed, pushed as f6191588. Cheap gates on the rebased tree:

ok: all 9 catch2-pin assertion(s) agree with .github/workflows/ci.yml
moc-pairing OK
ok: 15 structured citation(s) in scripts/mutation_survivors.json resolve to the line they name.
bidi-control lint OK: 1249 text file(s) scanned, 12 codepoint(s) searched for, 0 raw occurrence(s).

Before this merges, the mutation needs re-running against PendingQueue — restore the two-step drain in the new strand and confirm the new case still fails. I am not treating a green CI run as a substitute for that.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut
Yaraslaut merged commit 95aa568 into master Sep 21, 2026
13 checks passed
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…ownload that blamed tar, and a sanitizer matrix the bank example was never in (fixes #675, fixes #679, refs #672) (#683)

* ci: let the sanitizer-instrumentation check answer "is this one binary instrumented?", without lowering the floor that makes its sweep mean something (fixes #675)

`scripts/check_sanitizer_instrumentation.sh` conflated two questions behind one
floor. "Did this check examine a representative set?" needs the floor, and that
is the CI invocation. "Is this one binary instrumented?" is a yes/no about a
single file and needs no floor at all -- but the script refused it:

    only examined 1 binaries -- too few for this check to mean anything

so a developer who had built one target under a sanitizer preset answered it by
hand with `nm | grep __tsan_`, re-deriving the per-mode symbol table and the
SIGPIPE trap the script already encodes. The #673 lane did exactly that.

`--binary <file> <mode>` answers the second question against the same symbol
table and skips the floor. The constraint that matters is that it must not be
usable where the floor was meant to apply, and that is enforced structurally
rather than by convention: the mode refuses outright, exit 2, when
GITHUB_ACTIONS is set, so no step of any workflow in this repository can reach
it. The sweep is unchanged -- same floor, same message, same exit codes.

Both halves are pinned by a new self-test, following the repository's
scripts/test_check_*.sh convention and running in drift-guard.yml's
sanitizer-can-fail job. It needs no compiler beyond `cc` and no sanitizer
runtime: the gate's whole measurement is `nm | grep -c __<mode>_`, so a fixture
that merely defines a function of that name exercises the gate rather than
clang, and a hand-written CTestTestfile.cmake is all `ctest --show-only` needs.

Measured, not asserted. The self-test's two load-bearing cases were confirmed
to fail against a mutated checker on this revision:

  - guard replaced by `if false` ->
      error: --binary ran under GITHUB_ACTIONS -- the floor can now be bypassed
      from a workflow step:
      1 self-test check(s) failed

  - floor lowered from 2 to 1 ->
      error: the sweep accepted a one-binary tree -- the floor has been
      lowered:
      1 self-test check(s) failed

and all twelve cases pass on the unmutated script.

Not verified: the narrow mode against a real sanitizer-instrumented binary --
the fixtures carry the symbol name, not a sanitizer runtime. That is deliberate
(it keeps the self-test in a dependency-free job), and the sweep path, which
shares the same `count_symbols` helper, is exercised against real instrumented
binaries by the three CI legs that already run it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

* ci: make a failed download say it failed, instead of letting tar report it as "not in gzip format" (refs #672)

On 2026-09-21 both GCC legs of PR #671 died in the `Install sccache` step with

    gzip: stdin: not in gzip format
    tar: Child returned status 1
    tar: Error is not recoverable: exiting now
    ##[error]Process completed with exit code 2

Nothing compiled, and nothing in the log named the download. The step piped
`curl -sSL <url>` straight into `tar -xz`, and plain curl treats an HTTP 4xx or
5xx as a *successful* transfer of whatever body came back -- so an error page
went down the pipe and the decompressor was the only thing that complained.
`set -o pipefail` would not have helped: GitHub runs `run:` under `bash -e`
without it, so the pipeline's status is tar's regardless.

Reproduced locally against a server that returns 503, the old shape and the new
one, both under `bash -e`:

    =========== OLD SHAPE, 503 (bash -e, no pipefail) ===========

    gzip: stdin: not in gzip format
    tar: Child returned status 1
    tar: Error is not recoverable: exiting now
    exit=2
    =========== NEW SHAPE, 503 ===========
    curl: (22) The requested URL returned error: 503
    exit=22
    =========== NEW SHAPE, 200 ===========
    exit=0
    total 4
    -rwxr-xr-x 1 yaraslau yaraslau 28 Sep 21 18:36 sccache

-- the third block being the success path over a tarball with the real
release's member layout, so `--strip-components=1` still lands the binary in
place.

Applied to all nine copies of the step in ci.yml (the first carries the full
reasoning, the other eight point at it), and to the two other downloads in this
repository with the identical defect and the identical one-flag fix:
docs.yml's Doxygen tarball and mutation.yml's Mull .deb. Those two are the same
finding, not a separate one -- the Mull case is measurably worse, since a 503
puts 64 bytes of HTML in `${asset}` and `dpkg-deb` is left to object to the
archive.

Deliberately NOT in scope, and #672 stays open for them:

  - a retry policy for dependency installation;
  - a CI-wide marker that distinguishes "the environment failed" from "the
    change failed" in the check list.

Both are decisions rather than implementations, and neither is needed for the
one case where the log actively misled. The three apt/PPA outages that make up
the rest of #672 are untouched by this.

Found while doing it, filed rather than folded: `wget -qO- https://apt.llvm.org/
llvm.sh | sudo bash` (nine sites in ci.yml, one in mutation.yml) fails the other
way. Measured against the same 503 server: `wget -qO-` exits 8 and writes zero
bytes, `bash` reads empty input and exits 0, and the pipeline exits 0 -- a
clang-installation step that reports success having installed nothing. Filed
separately because it is a different failure shape needing a different fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

* ci/bank: put the bank example under a sanitizer, and instrument the targets that were never going to carry one (fixes #679)

Measured on 5fc5e78: `linux-sanitizers`, `kanban-tsan`, `ladder-sanitizers` and
`valgrind` set zero `MORPH_BUILD_BANK_*` flags, against eight places elsewhere
in ci.yml that set `MORPH_BUILD_BANK_EXAMPLE=ON`. Bank is not a rung, so
`MORPH_BUILD_LADDER=ON` does not reach it either. Nothing was written down
about excluding it; it was an option nobody turned on.

Two halves, and the second was not in the ticket.

**1. bank's targets never called `apply_sanitizers()`.** `ladder_bank_server`
was the only one that did. So flipping the CI flag alone would not have
instrumented anything -- it would have built `bank_lib`, `bank_cli` and all
three test binaries blind. Measured, on a `clang-ubsan` configure with bank on
and the blocks absent:

    ::error::check_sanitizer_instrumentation: 3 of 9 ctest binaries are not
    ubsan-instrumented

naming bank_tests, bank_gui_tests and bank_gui_qml_tests. With the blocks in
place the same sweep reports 9 of 9. The blocks are also required *together*:
removing bank_tests' alone, with bank_lib's kept, does not merely leave the
binary unchecked, it fails to link with `undefined reference to
__ubsan_handle_type_mismatch_v1_abort`.

**2. a `bank-sanitizers` job (clang-ubsan, Qt + bank + GUI)**, rather than a
flag on `linux-sanitizers`' clang-ubsan leg, which is what the ticket proposed.
Two measured reasons. That leg builds no Qt and its own comment reserves the
matrix against GUI stacks, while bank's GUI is where the UB was. And, cold and
cacheless on 12 cores with clang 22.1.8:

    leg's current shape (core + net + offline_sqlite, no Qt)
        configure 36s   build 144s   144 ninja edges
    this job's shape (core + Qt + bank + bank GUI)
        configure 64s   build 391s   287 ninja edges

Folding one into the other roughly triples the slowest leg of a three-leg
matrix, whose duration is then the matrix's. Split out, **the three existing
legs' flags are not changed at all, so the wall-clock delta on them is zero**,
and this runs beside them. Same precedent and same argument as kanban-tsan.
Bank's isolated build cost -- the number the ticket asked for -- is the
difference between this job and the same configure without bank
(51s / 151s / 150 edges): **+13s configure, +240s build, +137 edges**.

`ubsan` rather than `asan`: UBSan diagnoses this class, and ASan over a Qt GUI
needs the `detect_leaks=0` and suppression story ladder-sanitizers carries,
which this job would have to acquire before it could be believed. Bank under
ASan is not closed by this.

**The first bill is zero.** Per the morph#646 (84) and morph#656 (97)
precedents, measured before landing: bank_tests (145 assertions in 21 cases),
bank_gui_tests (19 in 5) and bank_gui_qml_tests (32 in 2) all pass clean under
`-fsanitize=undefined -fno-sanitize-recover=undefined`, and so does the whole
1696-test suite of this configure (212s serial, 100% passed, zero `runtime
error` lines). Nothing is suppressed and no allowlist entry was added.

**A finding that cuts against the ticket's framing, stated rather than shipped
around.** The ticket says this gap is why morph#663 survived to be found by
reading code. Half true. Rebuilding Format.hpp as it stood before the fix, in
this job's exact configuration, bank_gui_tests exits 1 with

    examples/bank/gui/controllers/Format.hpp:76:38: runtime error: 9.2e+19 is
    outside the range of representable values of type 'long'

-- but only because morph#663's fix also added the test that calls parseMinor
with such a value. With the pre-morph#663 header *and* the pre-morph#663 test
set, this leg is green: no pre-existing bank test drove that path. The
sanitizer gap was real and is what this closes; it was not on its own what let
morph#663 through, and this job's reach is bounded by how much of bank the
suites actually drive. That is written into the job's banner, not just here.

Also here, because the job needs it: a `bank` ctest label on the three suites,
so the leg runs bank's 28 tests (6s) rather than re-running the 1696 (212s)
that linux-sanitizers' clang-ubsan leg already runs under identical
instrumentation. A label filter that matched nothing would be this
repository's named failure mode; CMakePresets.json's `base-test` already sets
`noTestsAction: error`, confirmed to exit 8 with `No tests were found!!!`
against a tree built without bank.

Not verified: bank under ASan or TSan; whether the Lightweight ORM is clean
under anything other than UBSan; and the job's real duration on a hosted
runner, which adds an apt install and a Qt install this local measurement does
not model.

Found while measuring, filed rather than folded: bank's 21 ctest cases share
one SQLite file and cannot run concurrently -- `ctest -j 12` fails 21 of them
with `[SQLite]disk I/O error (10)` where the same binaries pass serially. CI
runs ctest serially (no test preset sets a parallel level), so it is latent
rather than active.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

* ci: pin install-qt-action to v4.3.1, so a moving major tag cannot break Qt

`Bank example / UBSan` failed with the Qt install step rejected before any
build ran:

  The template is not valid. jurplel/install-qt-action/v4/action.yml
  (Line: 160, Col: 11): Expected format {org}/{repo}[/path]@ref.
  Actual '$/action'

Upstream's action.yml now contains `uses: $/action` -- GitHub's
self-repository syntax, added in their 2026-07-30 changelog. Some runner
images resolve it and some do not. In the same workflow run, `Linux / Qt6
WebSockets` started 17:13:34Z with a byte-identical invocation and passed;
this job started 17:23:29Z and did not. Ten minutes and a different runner
apart, with no change on our side.

`@v4` is a major alias that upstream moves on every release, so this repository
has no say in when that syntax arrives. `v4.3.1` is the last release whose
action.yml does not use it (v4.4.0 and v4.4.1 both do), and it declares every
input used here -- arch, cache, dir, host, modules, target, version. All 11
call sites across ci.yml, wasm-demo.yml and wasm-ladder.yml are pinned
together, because a partial pin leaves the same lottery running on whichever
job was missed.

Verified: no floating `@v4` remains; all seven workflows parse; banner-lint,
option-coverage and catch2-pin pass.

This does not fix the class -- a pinned tag is still a tag, and the durable
answer is a commit SHA. That trade (immutability against a version nobody can
read) is recorded in morph#672 rather than decided here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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