Skip to content

test_strand_race.cpp does not detect the race it is the regression test for -- 10/10 passes under TSan against a build with the two-step drain-and-erase restored #668

Description

@Yaraslaut

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreSubsystem: corebugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions