Skip to content

core: recycle the strand's map node instead of rebuilding it every serial dispatch, with no deregistration hook (fixes #670) - #689

Merged
Yaraslaut merged 3 commits into
masterfrom
laneSTRAND2-670
Sep 22, 2026
Merged

Yaraslaut merged 3 commits into
masterfrom
laneSTRAND2-670

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Closes #670.

morph#660 took the container's share of what a serial dispatch spent on its strand. What it left was 2 allocations and 152 bytes per call: the unordered_map node and the make_shared<Strand>, rebuilt every dispatch because the drain destroys the entry as soon as the queue empties.

The ticket's framing, and why this does not follow it

#670 and its triage comment both treat that residual as inherent to the erase, removable only through a StrandExecutor deregistration hook — keeping the map slot alive across the drain, and so trading this churn for a per-model entry nothing reclaims. docs/spec/core/executor.md said the same in as many words.

No hook is added here, and the slot does not stay alive. The entry leaves the map at exactly the same program point, under exactly the same {_mapMtx, strand->mtx} pair, with exactly the same precondition. The drain calls extract instead of erase and parks the detached node in a single-slot _spare; the next post() that misses re-keys that node and inserts it back. The map is bounded by the removal exactly as before, _spare holds at most one node, and it is freed with the executor.

So the {_mapMtx, strand->mtx} atomicity recorded at strand.hpp:81/:167 is not renegotiated. That was the whole risk the ticket was about, and it is untouched.

The one claim the branch's safety rests on

extract detaches the element and transfers ownership of the node; it does not destroy the element, invalidate other elements, or move the mapped object. Every argument the erase supported — the per-key serialisation invariant, "the drain never removes a strand whose pending is non-empty", "a strand that becomes running in post() is still the map entry" — is an argument about when the entry leaves the map, and that moment is unchanged.

The second-order claim, that a parked node's Strand may be reused, stands on sole ownership: use_count() == 1 means the recycled node holds the only reference, so nothing else can reach the object and reusing it is indistinguishable from constructing a new one. When a strand lambda is still finishing and holds its shared_ptr, the guard fails and a fresh Strand is built exactly as before — only the node is recycled. To make the guard usually hold, the lambda releases its shared_ptr right after the drain block instead of at its own destruction; nothing after that point touches the strand. That timing decides whether the object is recycled, never whether the recycling is safe.

Re-measured, not inherited

tests/bench/bench_dispatch_allocations.cpp (morph_bench_alloc, MORPH_BUILD_LOAD_TESTS=ON), Release, x86-64 Linux, clang 22.1.8 / libstdc++ 16.2.1, base binary 7d4ca453 and the branch binary run alternately, six pairs:

base (18.90 (1394.7      new  (16.96 (1245.0
base (18.93 (1395.6      new  (16.95 (1244.8
base (18.91 (1395.6      new  (16.94 (1244.2
base (18.90 (1395.1      new  (16.86 (1241.9
base (18.95 (1395.6      new  (16.98 (1245.6
base (18.96 (1396.8      new  (16.84 (1241.5

18.90 → 16.95 allocations/call, 1394.8 → 1244.6 bytes/call. --attribute on one steady-state call names the two that went — a 32 and a 120 disappear from the middle of the list:

base  160 17 24 16 16 176 344 32 120 32 136 32 64 32 32 32 24 40 40   (19)
new   160 17 24 16 16 176 344 32     32 136 32 64 32 32 32 24 40 40   (17)

32 bytes is the map node; 120 is make_shared<Strand> (16 of control block over a 104-byte Strand). 152 bytes — the figure the ticket names, recovered in full. Magnitude is libstdc++-specific, as it was for morph#660. The ticket's own 18.9/1396 baseline reproduced exactly.

The detector, and a mutation for the new step

Neither existing [race] case can be wrong about re-keying. An entry re-inserted under the previous key still serialises every task that reaches it, still runs them in order and still completes them all; what it corrupts is which key the map answers for, and that only becomes a serialisation failure two posts later:

  1. Key A drains, parking a node still keyed A.
  2. Key B misses and takes it — unkeyed, it goes back into the map under A. B's first task runs on a strand the map calls A, so find(B) still misses.
  3. B's next post misses too, and installs a second strand for B while the first is still running.

tests/test_strand_race.cpp gains a third case that manufactures that: several keys each posting a short burst (so the burst's second post arrives while the first task runs) and then going quiet (so the drain that parks a node happens), several keys out of phase so a parked node crosses keys. Three detectors per key — in-flight counter, plain non-atomic per-key state, FIFO sequence.

All under clang-tsan with TSAN_OPTIONS=suppressions=cmake/tsan.supp, 10 runs each, per-run limit 120 s (CI's ctest TIMEOUT):

build [race] result
this branch pass 10/10
M1 — pre-fix two-step drain restored on top of this change fail 10/10 (TSan data race)
M2 — park the node even when work is still queued 5/5 exceed 120 s; 3/5 also emit TSan data races at test_strand_race.cpp:208 before hanging
M4 — delete _spare.key() = key; (the new step) fail 10/10, caught by the new case
M3 — delete the use_count() != 1 guard pass 10/10

An earlier draft of the new case that drained between every single post, with no burst, passed 10/10 against M4 — which is why the burst is in the case and why that is written into its comment. Shape, not volume, again.

Instrumentation confirmed rather than assumed, per #683's new narrow mode:

$ bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-tsan/tests/morph_tests tsan
check_sanitizer_instrumentation: morph_tests carries 173 __tsan_ symbols -- tsan-instrumented.

What is not covered by a detector

M3 passes 10/10. Deleting the use_count() != 1 guard is not something the race suite can see. That is honest rather than alarming: recycling a Strand that a finishing lambda still references appears safe by inspection too — after the drain block that lambda's only remaining work is --_inFlight under _mapMtx, and it never touches *strand again. The guard is kept anyway, so the reuse stands on a one-line ownership argument instead of a multi-step access-ordering one; it is defence in depth, and this table is where that is recorded rather than left to look like coverage.

Other verification

  • Release ctest: 1568/1568.
  • clang-tsan ctest: 1583/1583, and clang-asan ctest: 1583/1583, both with CI's own -E "OomInjector|morph#108" exclusion (ci.yml; OomInjector replaces global operator new, which ASan/TSan already interpose). Without the exclusion those six fail on both legs, as CI documents.
  • clang-tidy: via clang-tidy-diff.py -path build/clang-debug over this branch's -U0 diff against 7d4ca453, clang-tidy 22.1.8 — 0 findings. Verified non-vacuous: a planted C-style cast and a new int in installStrand produced five findings on those exact lines, so the gate does reach this header.
  • clang-format --dry-run -Werror over both touched sources: clean.
  • check_mutation_survivors.py, check_nolint_directives.sh, check_bidi_controls.py, check_spec_citations.sh, check_spec_sync.sh (with the real path list), check_catch_test_names.sh, check_ctest_name_collisions.sh, check_test_type_names.sh: all OK.
  • Doxygen with WARN_AS_ERROR = FAIL_ON_WARNINGS (MORPH_BUILD_DOCUMENTATION=ON): builds clean.

Not verified: the branch-coverage gate end to end — it needs build/clang-coverage/coverage.lcov from scripts/coverage.sh, which was not run. What was checked is the part this change could break: check_branch_coverage.resolve_allowlist_source_line resolves the repointed strand citation to line 323 with no failures. installStrand was deliberately written without the defensive branches an earlier draft had (a null check, an idle re-check, an insert-failure path) precisely because none of their arms can be taken and each would have needed its own allowlist entry; the preconditions are written down in the comment instead.

Commits

  1. 9aafc140 — the change: strand.hpp, the third [race] case, and the two spec files (docs/spec/core/executor.md's "Lifetime & ownership" paragraph said this residual was inherent; it now says what replaced it, with the measurement).
  2. 200dcbec — gate repoint only: scripts/branch_partial_allowlist.json's strand entry moves 259 → 323, and its reason prose is corrected where it cited post()'s now-deleted if (!slot) { slot = make_shared<Strand>(); } and called the removal an erase.

Filed, not folded

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification — and my framing of this ticket was wrong

I narrowed the solution space incorrectly. My triage of #670 said it "needs a StrandExecutor deregistration hook, i.e. changing when the map slot dies", and I dispatched it with "not worth the risk" pre-authorised as an outcome. That framing was avoidable, and this branch avoids it.

Verified structurally — the drain's lock shape is unchanged:

master:                                     branch:
  scoped_lock{strand->mtx}                    scoped_lock{strand->mtx}
  more = !strand->pending.empty()             more = !strand->pending.empty()
  if (!more) { running = false;               if (!more) { running = false;
    if (iter != end && iter->second == strand)   if (iter != end && iter->second == strand)
      _strands.erase(iter);                        _spare = _strands.extract(iter);

Same guard, same moment, same {_mapMtx, strand->mtx} pair. The entry leaves the map at exactly the program point it always did — so the atomicity a previous defect forced into that shape is never renegotiated, which was the entire risk. Taking the 152 bytes without touching lifetime rules is a strictly better answer than the one I framed, and better than the handback I authorised.

The measurement reproduces the ticket and then moves it: base 18.90 alloc / 1394.8 B (against the ticket's 18.9/1396), new 16.95 / 1244.6 — −1.95 allocations, −150.2 bytes, with --attribute showing exactly the 32 map node and the 120 control block leaving the list. The full 152.

The mutation table is the best part, and M4 is why

mutant result
M1 — pre-fix two-step drain on top of the change fail 10/10
M2 — park the node even with work queued 5/5 over 120 s, 3/5 with TSan races
M4 — delete the new _spare.key() = key; fail 10/10
M3 — delete the use_count() != 1 guard pass 10/10

M4 needed a new third [race] case, and the reason is the finding: neither existing case can be wrong about re-keying, because a mis-keyed entry still serialises and still completes everything — it only becomes a serialisation break two posts later. And an earlier draft of that case, which drained between every post, passed 10/10 against M4. The burst is what makes it detect, and that is written into the comment rather than left as a tuning accident. That is the #668 lesson applied prospectively by the person introducing the code, which is the first time that has happened today.

M3 is reported as passing rather than dressed up. The use_count() guard is not detector-provable; keeping it as defence in depth so the reuse rests on an ownership argument, and saying the detector cannot prove it, is the honest handling — a table with one green row nobody hid.

#687 deserves attention beyond this PR

morph_bench_alloc's per-call figure moved 20% between runs of identical source — one process at 15.06/1287.7 against 23 runs at 18.9/1395. Observed once, correctly labelled weak.

It matters disproportionately because three tickets and a spec section now cite that instrument by number: #660's saving, #670's residual, this PR's −150.2 B, and docs/spec/core/executor.md. If the bench is bimodal, every one of those figures inherits the uncertainty. Filing it rather than absorbing it was right.

Not verified by me: the allocation runs, the TSan and ASan ctest sweeps, and the coverage gate (which needs an lcov the lane did not produce). Writing installStrand without three defensive branches whose arms cannot be taken — each of which would have needed its own allowlist entry — is the kind of restraint that does not show up in a diff, and is worth noting.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner: five legs red, and this is not the infrastructure signature

Failing step per leg — the fastest discriminator, and it points the wrong way for an environment problem:

  Bank example / UBSan       Every ctest binary is instrumented
  Linux / Qt6 WebSockets     Build
  Linux / gcc-debug          Build
  Linux / gcc-release        Build
  Valgrind memcheck          Build

Four legs fail in Build. Today's five infrastructure signatures all died in package installation, vcpkg setup or a dependency-cache configure, at 30 s–5 min and never in a compile. This is a genuine build failure. (The Bank UBSan one is almost certainly downstream: nothing built, so ctest listed nothing, so the #675 floor refused — the same correct behaviour it showed earlier today.)

What I have ruled out so far, on the branch with local GCC 16.2.1:

strand.hpp (synthetic TU)          ok
tests/test_strand_race.cpp         ok
tests/test_strand.cpp              ok
tests/test_strand_extra.cpp        ok
examples/common/testkit/test_strand_interleaver.cpp   ok

So it is not a syntax error in the header or in the obvious consumers. Three other includers could not be checked that way (they need glaze include paths my ad-hoc command did not supply) — that is a limitation of my check, not a finding about them.

What I have not established, and am not going to guess at: the actual compiler error. The run still has legs in flight, so gh run view --log refuses, and the check-run annotations carry only Process completed with exit code 1 with no diagnostic. Today has repeatedly punished guessing before the log exists.

Two hypotheses worth holding, neither confirmed:

  1. A gcc-15 vs gcc-16 difference. CI installs gcc-15; my local GCC is 16.2.1, and the branch's new code uses std::map::extract / insert(node_handle) / node_handle::key(). A version-specific rejection would pass here and fail there.
  2. A TU I could not syntax-check. Four legs including Qt6 WebSockets suggests something broader than the strand tests.

A full GCC build of the branch is running locally now; that settles it either way without waiting on CI.

Nothing about the verification I did earlier changes. The extract-not-erase design, the unchanged {_mapMtx, strand->mtx} critical section, and the mutation table all stand on their own evidence — a build failure elsewhere does not retract them.

This PR also now needs a rebase independently: master has moved twice (#684 74158aba, #686 24a470c4) since this branched from 7d4ca453.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Fixed in c02346ac — reproduced locally, and it is GCC-only

tests/test_strand_race.cpp:374:60: error: useless cast to type 'uint64_t'
    {aka 'long unsigned int'} [-Werror=useless-cast]
ninja: build stopped: subcommand failed.

slot is std::size_t, so 100 + slot is already uint64_t on LP64 and the static_cast is an identity conversion. -Wuseless-cast is GCC-only — clang has no such warning. That is exactly why a clang-verified branch passed every clang leg and failed all four GCC ones.

This is the mirror image of #661, where the WASM leg's older clang was the only thing in CI that could see a -Wshadow the project suppresses elsewhere. Same lesson, opposite compiler: the branch was verified thoroughly, on one toolchain.

Verified both directions with the warning that fired:

fixed form -> g++ -std=c++23 -Werror=useless-cast ...   exit 0
old form   -> same command, same 1 diagnostic
full gcc-debug build of morph_tests -> links, 0 errors  (previously stopped at this file)

Dropping the cast rather than suppressing it: brace-init of ModelId from std::size_t is non-narrowing where size_t is 64-bit and a widening conversion where it is 32-bit, so the removal is portable and the intent is unchanged.

Also rebased onto 24a470c4 — master moved twice (#684, #686) while this was open.

A correction to my own earlier note on this PR

I wrote that four Build failures were "not the infrastructure signature" and therefore a genuine defect. That was right. But I also said my local syntax checks had "ruled out" the header and the obvious consumers — they had not ruled out anything, because -fsyntax-only without the project's warning flags cannot see a -Werror=useless-cast. The check I ran could not have found this bug. A full build with the project's own flags found it in one pass.

That is the same mistake in miniature that this PR's own failure is about: verifying with a weaker configuration than the one that judges you. I have been telling lanes that all day.

The Bank UBSan failure was downstream as predicted — nothing built, so ctest listed nothing, so the #675 floor refused. It should clear with the build.

Nothing about the earlier verification changes: the extract-not-erase design, the unchanged {_mapMtx, strand->mtx} critical section, and the mutation table stand on their own evidence.

🤖 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 one red leg is pre-existing on master — not this branch

gh pr checks 689: 54 pass, 1 fail, 0 pending. The failure is Bank example / UBSan, and it is not yours:

Bank example / UBSan on master:
  run 35643825635: failure
  run 35639767288: failure

That job — added by #683has never passed, on master or anywhere. master is red on it right now. Filed as #690 with the evidence.

Its shape here is identical to master's: Qt installs, Configure succeeds, Build succeeds, then the instrumentation sweep reports ctest listed no tests in build/clang-ubsan. The guard is behaving correctly — it refuses to pass having examined nothing, which is what #675 added it for. It simply has nothing to examine, for a reason nobody has established, and scripts/check_sanitizer_instrumentation.sh:129 discards ctest's stderr (2>/dev/null) so the diagnostic that would say why is thrown away.

The GCC fix landed and worked. Every leg that was red for a reason belonging to this branch is now green — 54 of 55, including all four Build legs and Linux / clang-tsan.

I am not merging it

The standing authorisation is for PRs whose CI is green, and this is 54/55. A failure being demonstrably somebody else's does not make the PR green, and I would rather leave that judgement visible than quietly redefine the bar — especially on a branch that changes concurrency-critical code.

The unblocking move is #690, not a merge. Once that job passes on master, this should go green with no further work.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

Yaraslaut added a commit that referenced this pull request Sep 22, 2026
…ites headless, and the checker was hiding why (fixes #690, refs #691) (#692)

* scripts: print why ctest listed no tests instead of discarding it (refs #690)

`check_sanitizer_instrumentation.sh` enumerated the build tree with

    ctest --test-dir "${build_dir}" --show-only=json-v1 2>/dev/null

which made the two ways that list can come back empty indistinguishable:
ctest enumerated a tree that registers no tests, and ctest failed before
printing any JSON at all. Both arrive at the same "listed no tests" error,
and the sentence that tells them apart was being thrown away one pipe
away from the message that needed it.

That cost three CI runs and two local sessions on the bank-ubsan leg
(#690): the cause was already being printed on every failing run.

ctest's stderr now goes to a file rather than /dev/null and its exit
status is kept. On the empty-list path the checker reports the status,
the stdout byte count, and either the captured stderr or an explicit
statement that there was none -- which is the distinction itself, since
a listing that merely found nothing is silent and one that failed is
not. The stream still stays off stdout: it is not JSON and jq would
choke on it.

Measured on the real bank-ubsan tree at 24a470c, headless, which is
what the runner is:

    ::error::check_sanitizer_instrumentation: ctest listed no tests in
    build/clang-ubsan -- this check would pass having examined nothing
    check_sanitizer_instrumentation: `ctest --show-only=json-v1` exited 8
    and wrote 0 bytes of stdout.
    check_sanitizer_instrumentation: its stderr follows -- a non-empty
    stderr here means the listing *failed*, not that the tree registers
    no tests:
        | CMake Error at /usr/lib/cmake/Catch2/CatchAddTests.cmake:307 (message):
        |   Error listing tests from executable
        |   '.../build/clang-ubsan/examples/bank/bank_gui_qml_tests':
        |
        |     Result: Subprocess aborted

Two self-test cases hold it. Case 3 now also asserts that a genuinely
empty tree is *named* as empty rather than reading like a failure, and
case 3b drives a CTestTestfile.cmake that fails while being read --
ctest exits nonzero with an empty stdout, the shape #690 had -- and
asserts the fixture's own marker string reaches the caller. Asserting on
the fixture's marker rather than on ctest's wording is what makes it a
test of the pass-through and not of ctest.

Verified by mutation: with this commit's script change reverted and the
self-test left in place, both cases fail --

    error: the failed listing was rejected, but ctest's own reason was
    discarded -- the caller is left with 'listed no tests' and no cause,
    which is morph#690
    2 self-test check(s) failed

and all 13 pass with it. The self-test is run in CI by drift-guard.yml.

This is the diagnostic half of #690 and stands on its own: it does not
make the bank-ubsan leg green. The next commit does that.

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

* ci: the bank-ubsan sweep enumerates ctest headless, so it needs the offscreen platform (fixes #690)

`Bank example / UBSan` has failed on every run since it landed -- twice
on master, once on PR #689 -- at the step before its tests:

    ::error::check_sanitizer_instrumentation: ctest listed no tests in
    build/clang-ubsan -- this check would pass having examined nothing

The guard was right and is untouched. What it could not examine, and
why, is the whole of the defect.

The sweep's first act is `ctest --show-only=json-v1`, and ctest is
exactly where bank's discovery runs. Bank's three suites are the only
Qt-linked targets in this repository registered with `DISCOVERY_MODE
PRE_TEST`; every other Qt suite uses POST_BUILD, where the Build step's
`QT_QPA_PLATFORM=offscreen` already covers the enumeration run. PRE_TEST
moves that run to ctest time, so *listing* the tests executes
`bank_gui_qml_tests --list-tests`, whose main constructs a
QGuiApplication (examples/common/testkit/testkit_main.cpp) before Catch2
parses the flag. With no display and no QT_QPA_PLATFORM it aborts,
Catch2's CatchAddTests.cmake turns a nonzero discovery into
`message(FATAL_ERROR ...)`, and ctest exits 8 having printed no JSON at
all -- not bank's entries missing, the entire listing, every other suite
with it. Nine binaries became zero.

The step now declares the same `QT_QPA_PLATFORM: offscreen` the Test
step below it already declares, which is the principle rather than a
patch: this sweep's subject is the binaries that step will run, so it
has to enumerate them in that step's environment.

Verification status: **reproduced locally and fixed locally**, on this
configure at 24a470c (clang 22.1.8, Catch2 3.16.0, Qt 6.11.2; the
runner's versions differ, the code path does not). The runner's one
distinguishing property is that it is headless, so that is what was
emulated. Same tree, same build, one environment variable apart:

    $ env -u DISPLAY -u WAYLAND_DISPLAY \
          bash scripts/check_sanitizer_instrumentation.sh build/clang-ubsan ubsan
    ::error::check_sanitizer_instrumentation: ctest listed no tests in
    build/clang-ubsan -- this check would pass having examined nothing
    `ctest --show-only=json-v1` exited 8 and wrote 0 bytes of stdout.
      | CMake Error at .../CatchAddTests.cmake:307 (message):
      |   Error listing tests from executable
      |   '.../examples/bank/bank_gui_qml_tests':
      |     Result: Subprocess aborted

    $ env -u DISPLAY -u WAYLAND_DISPLAY QT_QPA_PLATFORM=offscreen \
          bash scripts/check_sanitizer_instrumentation.sh build/clang-ubsan ubsan
    check_sanitizer_instrumentation: 9 ctest binaries all carry __ubsan_
    symbols (0 allowlisted).

and the binary itself, directly:

    $ env -u DISPLAY -u WAYLAND_DISPLAY ./bank_gui_qml_tests --list-tests
    exit 134 (SIGABRT), no output
    $ env -u DISPLAY -u WAYLAND_DISPLAY QT_QPA_PLATFORM=offscreen \
          ./bank_gui_qml_tests --list-tests
    2 test cases

The step after it was then run under the same conditions -- headless,
offscreen, UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 -- and
`ctest -L bank` reports 29/29 in 1.81s, so the leg has somewhere to go
once the sweep lets it through.

This also explains why two earlier sessions could not reproduce the
failure and reported "9 ctest binaries all carry __ubsan_ symbols": a
workstation has a display, so the enumeration succeeds there whether or
not the variable is set. It is the one difference between the runner and
a workstation that this step was sensitive to.

Two comments are corrected alongside it, both of which state the fact
that was missed:

- The Build step's note claimed catch_discover_tests runs "each Qt-linked
  test binary" at build time. In this configure that is morph_qt_tests
  and not bank's suites, which is precisely the gap.
- examples/bank/CMakeLists.txt's `PROPERTIES ENVIRONMENT
  "QT_QPA_PLATFORM=offscreen"` reads as though it makes the suite
  headless-safe. It does not: PROPERTIES are set on the tests Catch2
  registers, and the run that finds out what those tests are happens
  first. CatchAddTests.cmake's execute_process() forwards only DL_PATHS
  and DL_FRAMEWORK_PATHS into it, never ENVIRONMENT.

What would change the verdict: if the leg still fails after this, the
first commit's diagnostic now prints ctest's own reason, so the next
failure arrives named rather than opaque.

Fixes #690

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>
Yaraslaut and others added 3 commits September 22, 2026 02:41
…rial dispatch (fixes #670)

morph#660 took the container's share of what a serial dispatch spent on its
strand. What it left was 2 allocations and 152 bytes per call: the
`unordered_map` node and the `make_shared<Strand>`, rebuilt on every dispatch
because the drain destroys the entry as soon as the queue empties.

The ticket framed that residual as inherent to the erase, removable only by a
`StrandExecutor` deregistration hook -- keeping the map slot alive across the
drain, and so trading this churn for a per-model entry nothing reclaims. That
framing turns out to be avoidable, and no hook is added here. The entry still
leaves the map at exactly the same program point, under exactly the same
`{_mapMtx, strand->mtx}` pair, with exactly the same precondition; the drain
just calls `extract` instead of `erase` and parks the detached node in a
single-slot `_spare`, and the next `post()` that misses re-keys that node and
inserts it back. The map stays bounded by the removal, `_spare` holds at most
one node, and it is freed with the executor.

Reusing the parked node's `Strand` as well is guarded by sole ownership:
`use_count() == 1` means the recycled node holds the only reference, so
nothing else can reach the object and reusing it is indistinguishable from
constructing a new one. When a strand lambda is still finishing and holds its
`shared_ptr`, the guard fails and a fresh `Strand` is constructed exactly as
before -- only the node is recycled. To make the guard usually hold, the
lambda releases its `shared_ptr` right after the drain block rather than at
its own destruction; nothing after that point touches the strand. That timing
decides *whether* the object is recycled, never whether the recycling is safe.

Measured on 7d4ca45 with `tests/bench/bench_dispatch_allocations.cpp`
(`morph_bench_alloc`, `MORPH_BUILD_LOAD_TESTS=ON`), Release, x86-64 Linux,
clang 22.1.8 / libstdc++ 16.2.1, six alternating runs of each binary:

    base  18.90 18.93 18.91 18.90 18.95 18.96 allocations/call
          1394.7 1395.6 1395.6 1395.1 1395.6 1396.8 bytes/call
    new   16.96 16.95 16.94 16.86 16.98 16.84 allocations/call
          1245.0 1244.8 1244.2 1241.9 1245.6 1241.5 bytes/call

`--attribute` names the two that went: a 32-byte allocation (the map node) and
a 120-byte one (`make_shared<Strand>`: 16 bytes of control block over a
104-byte `Strand`). 152 bytes, which is the figure the ticket names. The
magnitude is libstdc++-specific, as it was for morph#660.

`tests/test_strand_race.cpp` gains a third case, because neither of the first
two can be wrong about the new step. An entry re-inserted under the *previous*
key still serialises every task that reaches it, still runs them in order and
still completes them all; what it corrupts is which key the map answers for,
and that only becomes a serialisation failure two posts later. The new case
manufactures that: several keys each posting a short burst and then going
quiet, so a node parked by one key's drain is taken by another key's miss, and
the burst's second post arrives while the first task is still running.

Verification, all on this revision, x86-64 Linux / clang 22.1.8:

  * `[race]` under `clang-tsan` with `cmake/tsan.supp`: 10/10 green.
    Instrumentation confirmed rather than assumed --
    `scripts/check_sanitizer_instrumentation.sh --binary
    build/clang-tsan/tests/morph_tests tsan` reports 173 `__tsan_` symbols.
  * Full ctest: 1568/1568 Release; 1583/1583 under `clang-tsan` and 1583/1583
    under `clang-asan`, both with CI's own `-E "OomInjector|morph#108"`
    exclusion.
  * Mutation, because a green suite is not evidence on its own. The pre-fix
    two-step drain restored on top of this change fails 10/10. Deleting
    `_spare.key() = key;` -- the new step -- fails 10/10, and the new case is
    what catches it: an earlier draft of that case which drained between every
    single post, with no burst, passed 10/10 against the same mutant.

What is *not* covered by a detector, stated plainly: deleting the
`use_count() != 1` guard passes 10/10. That guard is a conservative
precondition, not a repair -- recycling a `Strand` a finishing lambda still
references appears safe by inspection too, since that lambda makes no further
access to it. The guard is kept so the reuse stands on an ownership argument
rather than on an access-ordering one, and the mutation result is recorded
here rather than left to look like coverage.

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

`scripts/branch_partial_allowlist.json` keys its strand entry on the source
text `if (iter != _strands.end() && iter->second == strand) {`, which the
previous commit moved from line 259 to 323. Verified by calling
`check_branch_coverage.resolve_allowlist_source_line` directly against this
tree: it returns 323 with no failures, where against 259 it reported "has
moved to line 323".

The `reason` prose is repointed with the line. It cited `post()`'s
`if (!slot) { slot = make_shared<Strand>(); }`, which no longer exists -- the
insert-if-absent is now `installStrand` -- and called the removal an `erase`,
which is now an `extract`. Both mutation sites and the argument built on them
are unchanged; only their spelling is. One clause is added, because the
disposition now depends on it: a node parked in `_spare` is out of the map,
so `find` cannot return it, which is what keeps "`find(key)` yields this exact
strand" true in the presence of the recycling.

Not verified: the branch-coverage gate end to end. It needs
`build/clang-coverage/coverage.lcov` from `scripts/coverage.sh`, which was not
run here; what is checked is the citation resolution that the previous
commit's line motion could break.

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

  tests/test_strand_race.cpp:374:60: error: useless cast to type 'uint64_t'
      {aka 'long unsigned int'} [-Werror=useless-cast]

`slot` is `std::size_t`, so `100 + slot` is already `uint64_t` on LP64 and the
`static_cast` is an identity conversion. `-Wuseless-cast` is **GCC-only** --
clang has no such warning -- which is why a clang-verified branch passed every
clang leg and failed every GCC one. The mirror image of morph#661, where the
WASM leg's older clang was the only thing that could see a `-Wshadow` the
project suppresses elsewhere.

Dropping the cast rather than silencing the warning: brace-init of `ModelId`
from `std::size_t` is non-narrowing where `size_t` is 64-bit and a widening
conversion where it is 32-bit, so the removal is portable and the intent --
"a distinct non-zero id per slot" -- is unchanged.

Verified both directions with the warning that fired:
  fixed form -> g++ -Werror=useless-cast ... exit 0
  old form   -> the same command reports the same 1 diagnostic
and the full `gcc-debug` build of `morph_tests` now links, 0 errors, where it
previously stopped at this file.

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

Copy link
Copy Markdown
Member Author

Runner: rebased onto 38dcba18 — the blocker is fixed on master

#692 landed, closing #690. Its change touched two gates this branch had never been judged by:

.github/workflows/ci.yml                        <-- QT_QPA_PLATFORM on the sweep step
scripts/check_sanitizer_instrumentation.sh      <-- stderr no longer discarded
scripts/test_check_sanitizer_instrumentation.sh
examples/bank/CMakeLists.txt

First staleness limb — its CI was judged by rules that no longer exist — so rebase, not merge. Replayed cleanly, pushed as 1c60267e.

Cheap gates on the rebased tree, including the one #692 rewrote:

ok: 15 structured citation(s) in scripts/mutation_survivors.json resolve to the line they name.
NOLINT directive lint OK: 177 NOLINTNEXTLINE directive(s), all annotating code.
bidi-control lint OK: 1251 text file(s) scanned, 12 codepoint(s) searched for, 0 raw occurrence(s).
check_sanitizer_instrumentation self-test: all checks passed

This should now go green on its own merits. Its previous 54/55 had exactly one failure — Bank example / UBSan — which was master's, not this branch's, and is the thing #692 fixed. If that leg is red again after this run, it is a new fact and not the one we have been chasing.

Nothing about the branch's own verification changes: the extract-not-erase design, the unchanged {_mapMtx, strand->mtx} critical section, the mutation table, and the GCC -Wuseless-cast fix all stand.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut
Yaraslaut merged commit a9cb564 into master Sep 22, 2026
55 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant