core: recycle the strand's map node instead of rebuilding it every serial dispatch, with no deregistration hook (fixes #670) - #689
Conversation
Runner verification — and my framing of this ticket was wrongI narrowed the solution space incorrectly. My triage of #670 said it "needs a Verified structurally — the drain's lock shape is unchanged: Same guard, same moment, same 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 The mutation table is the best part, and M4 is why
M4 needed a new third M3 is reported as passing rather than dressed up. The #687 deserves attention beyond this PR
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 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 🤖 Generated with Claude Code |
Runner: five legs red, and this is not the infrastructure signatureFailing step per leg — the fastest discriminator, and it points the wrong way for an environment problem: Four legs fail in What I have ruled out so far, on the branch with local GCC 16.2.1: 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 Two hypotheses worth holding, neither confirmed:
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 This PR also now needs a rebase independently: master has moved twice (#684 🤖 Generated with Claude Code |
200dcbe to
c02346a
Compare
Fixed in
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Runner: the one red leg is pre-existing on master — not this branch
That job — added by #683 — has never passed, on master or anywhere. Its shape here is identical to master's: Qt installs, Configure succeeds, Build succeeds, then the instrumentation sweep reports 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 I am not merging itThe 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 |
…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>
…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
Runner: rebased onto
|
c02346a to
1c60267
Compare
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_mapnode and themake_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
StrandExecutorderegistration 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.mdsaid 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 callsextractinstead oferaseand parks the detached node in a single-slot_spare; the nextpost()that misses re-keys that node and inserts it back. The map is bounded by the removal exactly as before,_spareholds at most one node, and it is freed with the executor.So the
{_mapMtx, strand->mtx}atomicity recorded atstrand.hpp:81/:167is not renegotiated. That was the whole risk the ticket was about, and it is untouched.The one claim the branch's safety rests on
The second-order claim, that a parked node's
Strandmay be reused, stands on sole ownership:use_count() == 1means 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 itsshared_ptr, the guard fails and a freshStrandis built exactly as before — only the node is recycled. To make the guard usually hold, the lambda releases itsshared_ptrright 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 binary7d4ca453and the branch binary run alternately, six pairs:18.90 → 16.95 allocations/call, 1394.8 → 1244.6 bytes/call.
--attributeon one steady-state call names the two that went — a32and a120disappear from the middle of the list:32 bytes is the map node; 120 is
make_shared<Strand>(16 of control block over a 104-byteStrand). 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:find(B)still misses.tests/test_strand_race.cppgains 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-tsanwithTSAN_OPTIONS=suppressions=cmake/tsan.supp, 10 runs each, per-run limit 120 s (CI's ctestTIMEOUT):[race]resulttest_strand_race.cpp:208before hanging_spare.key() = key;(the new step)use_count() != 1guardAn 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:
What is not covered by a detector
M3 passes 10/10. Deleting the
use_count() != 1guard is not something the race suite can see. That is honest rather than alarming: recycling aStrandthat a finishing lambda still references appears safe by inspection too — after the drain block that lambda's only remaining work is--_inFlightunder_mapMtx, and it never touches*strandagain. 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
clang-tsanctest: 1583/1583, andclang-asanctest: 1583/1583, both with CI's own-E "OomInjector|morph#108"exclusion (ci.yml;OomInjectorreplaces globaloperator new, which ASan/TSan already interpose). Without the exclusion those six fail on both legs, as CI documents.clang-tidy-diff.py -path build/clang-debugover this branch's-U0diff against7d4ca453, clang-tidy 22.1.8 — 0 findings. Verified non-vacuous: a planted C-style cast and anew intininstallStrandproduced five findings on those exact lines, so the gate does reach this header.clang-format --dry-run -Werrorover 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.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.lcovfromscripts/coverage.sh, which was not run. What was checked is the part this change could break:check_branch_coverage.resolve_allowlist_source_lineresolves the repointed strand citation to line 323 with no failures.installStrandwas 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
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).200dcbec— gate repoint only:scripts/branch_partial_allowlist.json's strand entry moves 259 → 323, and itsreasonprose is corrected where it citedpost()'s now-deletedif (!slot) { slot = make_shared<Strand>(); }and called the removal anerase.Filed, not folded
morph_bench_alloc's per-call figure moved 20% between two runs of the same source (15.06 once, 18.9 on 23 other runs). Weak evidence, observed once, not reproduced; filed because three tickets and a spec section now cite this instrument by number.TSAN_OPTIONSsuppressions path makes ctest's Catch2 discovery exit 66 with empty output, and nothing documents the absolute form or theOomInjectorexclusion CI uses.🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW