The finding
tests/test_strand_race.cpp exists for one purpose: to catch the reintroduction
of StrandExecutor's per-key serialisation race. Its own header comment says so
at length, and it is the test the strand.hpp comments at :81 and :167 point
a future reader at. It does not detect that race's reintroduction.
I reintroduced the exact pre-fix shape the comments describe — the two-step
"flip running under strand->mtx, then erase under _mapMtx" drain — and ran
the suite under ThreadSanitizer. The dedicated test passed every time. What
caught it was an unrelated Bridge test, and only intermittently.
What I ran
Worktree at 7a343e6f plus the #660 change (which does not touch the
locking). The mutation, in scheduleNext's drain block:
// MUTANT: two-step decision, the pre-fix shape.
{
std::scoped_lock const strandLock{strand->mtx};
more = !strand->pending.empty();
if (!more) {
strand->running = false;
}
}
std::scoped_lock const mapLock{_mapMtx};
if (!more) {
auto iter = _strands.find(key);
if (iter != _strands.end() && iter->second == strand) {
_strands.erase(iter);
}
} else {
Built with the clang-tsan preset (clang 22.1.8, -DMORPH_BUILD_NET=ON -DMORPH_BUILD_OFFLINE_SQLITE=ON), TSAN_OPTIONS=suppressions=cmake/tsan.supp.
scripts/check_sanitizer_instrumentation.sh build/clang-tsan tsan reports
6 ctest binaries all carry __tsan_ symbols (0 allowlisted), so this is not an
uninstrumented run.
The dedicated test, ten consecutive runs against the mutant:
$ for i in 1..10; do TSAN_OPTIONS=... ./build/clang-tsan/tests/morph_tests "[race]"; done
All tests passed (41 assertions in 2 test cases)
All tests passed (41 assertions in 2 test cases)
All tests passed (41 assertions in 2 test cases)
All tests passed (41 assertions in 2 test cases)
All tests passed (41 assertions in 2 test cases)
All tests passed (41 assertions in 2 test cases)
All tests passed (41 assertions in 2 test cases)
All tests passed (41 assertions in 2 test cases)
All tests passed (41 assertions in 2 test cases)
All tests passed (41 assertions in 2 test cases)
The whole [strand] tag is no better — 12 test cases, 526 assertions, all pass
against the mutant.
What did catch it, in the full ctest --preset clang-tsan sweep
(1801 tests, one failure):
The following tests FAILED:
1155 - morph::bridge::Bridge: concurrent executeVia under repeated switchBackend resolves every morph::async::Completion (Failed)
and its report names the mechanism exactly — two strand lambdas for one key
running the model concurrently:
WARNING: ThreadSanitizer: data race (pid=490650)
Write of size 4 at 0x72100001407c by thread T4:
#0 (anonymous namespace)::LoadCountModel::execute(...) tests/test_concurrency_invariants.cpp:227:15
...
#11 morph::exec::detail::StrandExecutor::scheduleNext(...)::'lambda'()::operator()() const include/morph/core/strand.hpp:225:17
...
SUMMARY: ThreadSanitizer: data race tests/test_concurrency_invariants.cpp:227:15 in (anonymous namespace)::LoadCountModel::execute(...)
It is intermittent: a single run of that test passed, and a
--repeat until-fail:60 sweep hit it on the 3rd iteration.
Why the dedicated test misses it
Inferred from reading, not measured. test_strand_race.cpp detects overlap with
its own counter — every task bumps an std::atomic in-flight count on entry and
drops it on exit, and the test fails if it ever exceeds 1. Two things follow.
The counter is atomic, so TSan sees no race on it and the sanitizer has nothing
to report however badly the strand misbehaves; and the tasks are empty, so two
concurrently-running tasks have to overlap inside a window of a few instructions
for the count to be observed above 1. The test that does catch it has tasks that
touch ordinary non-atomic model state, which is what gives TSan an object to
report on — detection there does not depend on catching the overlap in the act.
Verification status
- Reproduced: the ten-run result and the TSan report above are real output
from this workstation (x86-64 Linux, clang 22.1.8, glibc), on the revision
named above.
- Not verified: whether the dedicated test would catch it given far more
iterations, a different core count, or a different machine. Ten runs is
evidence that it does not catch it reliably, not proof that it never can.
- Not verified: the
Kanban / ThreadSanitizer CI leg, which needs Qt and the
ladder; only Linux / clang-tsan was reproduced locally.
- Inferred, not measured: the explanation above for why it misses.
Why this matters
strand.hpp's comments treat the drain-and-erase atomicity as protected — the
spec says so too (docs/spec/core/executor.md, "The per-key serialisation
invariant"). Anyone changing that code and running the strand tests will be told
the invariant holds when it does not. The actual guard is a Bridge test that
names neither StrandExecutor nor the invariant, catches it only sometimes, and
could be rewritten by someone with no idea it is load-bearing.
What would change the verdict
- Close as fixed when a mutation of the drain-and-erase shape — the one
above will do — makes a named strand test fail, reliably enough to be worth
relying on, and that is demonstrated by running it.
- Close as invalid if the mutation above turns out not to reintroduce the
original defect at all, in which case the finding is instead that the comments
at strand.hpp:81/:167 describe a shape that was never the broken one.
Found while measuring morph#660; filed rather than folded in, per AGENTS.md.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
The finding
tests/test_strand_race.cppexists for one purpose: to catch the reintroductionof
StrandExecutor's per-key serialisation race. Its own header comment says soat length, and it is the test the
strand.hppcomments at:81and:167pointa future reader at. It does not detect that race's reintroduction.
I reintroduced the exact pre-fix shape the comments describe — the two-step
"flip
runningunderstrand->mtx, then erase under_mapMtx" drain — and ranthe suite under ThreadSanitizer. The dedicated test passed every time. What
caught it was an unrelated
Bridgetest, and only intermittently.What I ran
Worktree at
7a343e6fplus the#660change (which does not touch thelocking). The mutation, in
scheduleNext's drain block:Built with the
clang-tsanpreset (clang 22.1.8,-DMORPH_BUILD_NET=ON -DMORPH_BUILD_OFFLINE_SQLITE=ON),TSAN_OPTIONS=suppressions=cmake/tsan.supp.scripts/check_sanitizer_instrumentation.sh build/clang-tsan tsanreports6 ctest binaries all carry __tsan_ symbols (0 allowlisted), so this is not anuninstrumented run.
The dedicated test, ten consecutive runs against the mutant:
The whole
[strand]tag is no better — 12 test cases, 526 assertions, all passagainst the mutant.
What did catch it, in the full
ctest --preset clang-tsansweep(1801 tests, one failure):
and its report names the mechanism exactly — two strand lambdas for one key
running the model concurrently:
It is intermittent: a single run of that test passed, and a
--repeat until-fail:60sweep hit it on the 3rd iteration.Why the dedicated test misses it
Inferred from reading, not measured.
test_strand_race.cppdetects overlap withits own counter — every task bumps an
std::atomicin-flight count on entry anddrops it on exit, and the test fails if it ever exceeds 1. Two things follow.
The counter is atomic, so TSan sees no race on it and the sanitizer has nothing
to report however badly the strand misbehaves; and the tasks are empty, so two
concurrently-running tasks have to overlap inside a window of a few instructions
for the count to be observed above 1. The test that does catch it has tasks that
touch ordinary non-atomic model state, which is what gives TSan an object to
report on — detection there does not depend on catching the overlap in the act.
Verification status
from this workstation (x86-64 Linux, clang 22.1.8, glibc), on the revision
named above.
iterations, a different core count, or a different machine. Ten runs is
evidence that it does not catch it reliably, not proof that it never can.
Kanban / ThreadSanitizerCI leg, which needs Qt and theladder; only
Linux / clang-tsanwas reproduced locally.Why this matters
strand.hpp's comments treat the drain-and-erase atomicity as protected — thespec says so too (
docs/spec/core/executor.md, "The per-key serialisationinvariant"). Anyone changing that code and running the strand tests will be told
the invariant holds when it does not. The actual guard is a
Bridgetest thatnames neither
StrandExecutornor the invariant, catches it only sometimes, andcould be rewritten by someone with no idea it is load-bearing.
What would change the verdict
above will do — makes a named strand test fail, reliably enough to be worth
relying on, and that is demonstrated by running it.
original defect at all, in which case the finding is instead that the comments
at
strand.hpp:81/:167describe a shape that was never the broken one.Found while measuring morph#660; filed rather than folded in, per AGENTS.md.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW