core: give Bridge an executor for late registration replies, and re-measure what a local dispatch allocates (fixes #588, refs #572) - #661
Conversation
Runner verificationThe measurement-first condition on #572 did its job, and the result is more useful than either outcome I expected. I dispatched it expecting the number to have gone stale after #639, #649 and #654 rewrote the dispatch path. It has not: 20.8 allocations / 1995 bytes per call against the ticket's 19.2 / 1983 — the total held. What is wrong is the attribution, in three separate ways, and the attribution is what scopes the fix:
So Part A + Part B land at 11–13 per call, not the 2–3 the ticket targets. Building them as scoped would have been real work measured against an unreachable target — which is precisely the waste Step 6's re-measure rule exists to prevent, arriving by a route I did not anticipate. Handed back correctly. Commit Committing the instrument is the part that compounds. On #588's deliberate deviationVerified rather than taken on trust: 16 occurrences of The no-op claim checks out: explicit Bridge(std::unique_ptr<IBackend> backend,
::morph::exec::IExecutor* bridgeExec MORPH_LIFETIMEBOUND = nullptr)A defaulted trailing parameter, so existing callers are unaffected, and The narrower secondary claim is the honest one: morph#486's window closes only for an embedder whose executor runs on a thread that cannot run The finding I most want kept
An OOM-injection test that fails because a new, unrelated allocation appeared upstream of the injection point is doing something most tests cannot: detecting a change in allocation behaviour nobody asserted. That it caught a defect in the first draft, and that the fix (a forwarding-reference template, moving Not verified by me: the allocation measurements themselves (they need the bench built), the mutation check on Not merged: CI incomplete at hand-off. Worktree pruned. 🤖 Generated with Claude Code |
Runner, sweep 10:41 — three failures, three different causes
1.
|
…ve their dispatch frame (fixes #588) `Bridge` issues registrations on its own behalf, and until now it was the only caller in the framework that could not say where their completions are delivered: its five dispatch sites name `exec::detail::inlineExecutor()`, so a reply is published on whichever thread the backend settled it on. That is the thread morph#486's use-after-free is about -- each of those callbacks asks "is the `Bridge` still alive" and then touches it. `Bridge`'s constructor now takes an optional `IExecutor* bridgeExec`, and `detail::deliverLate` routes a reply to it. Null -- the default -- runs the reply inline, which is byte for byte the previous behaviour, so nothing that does not ask for the new argument changes at all. The ticket asked for the five `inlineExecutor()` arguments to be replaced. They are deliberately **not**, and that is the one place this deviates from what #588 says. Naming a posting executor on the `bindModel`/`promoteModel` call breaks two things that are not negotiable: * `registerHandler()` stops being synchronous for every backend that binds inline. The settle becomes a queued task, `claimHandoff` finds nothing parked, and the caller gets an unbound handler from a call that has always returned a bound one. * a `kCallerMayBlock` backend deadlocks outright when the dispatching thread is the executor's thread -- `awaitHandoff` waits for a task only that same thread could run. A GUI embedder passing its GUI executor is exactly that case. An inline settle has to reach `parkIfInFrame` inside the dispatch frame, and the dispatching frame then publishes it on the dispatching thread. Only the *late* reply has a delivery thread left to choose, and that is the one the new executor gets. `assignHandlerPrimary` grew an `AsyncDispatchHandoff` for that reason: it had no way to tell the two cases apart, and posting both would have made an inline promote asynchronous. What this buys, stated as narrowly as it is true: the morph#486 window is closed for an embedder whose executor runs on a thread that cannot run `~Bridge` concurrently -- the callback and the destructor are then two tasks on one thread. An executor on an unrelated thread satisfies the type and closes nothing; it makes nothing worse either, since the existing `CallbackToken`/`BridgeLifetime` gates are untouched. That, rather than an unqualified "the guarantee is now structural", is what the specs now say. Rejected alternatives, both from #588's own triage thread: giving `Bridge` a thread of its own contradicts `concurrency_and_lifetimes.md`'s "teardown is order-independent, on any thread" and cannot exist in an Emscripten build with no pthreads; capturing the constructing thread names a thread nobody promised would be the destroying one. Verified: `morph_tests` 1561 cases, 22972 assertions, all pass (one deliberate expected failure). The three new cases in `tests/test_async_registration.cpp` were mutation-checked -- making `deliverLate` ignore its executor and always run inline fails the late-reply case (`binding->currentId` 100 != 0, `queued()` 0 != 1), which is the check AGENTS.md asks for. `deliverLate` is a template rather than a `std::function` parameter so the null path type-erases nothing and allocates nothing: taking a `std::function` put an allocation on the default path and made morph#108's OOM-injection case catch the wrong allocation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
… the instrument that measured it (refs #572) morph#572's scope is a number -- "19 allocations, two CompletionStates, two strings per dispatch", taken on master @ 4017228. morph#639, morph#649 and morph#654 then rewrote the dispatch path, so the first thing the ticket needs is not a fix but a re-measurement. This commit is that, plus the program that produced it, so the next person re-checks the figure in one command instead of rebuilding it from a description. Measured on f24e225 (this branch's base), x86-64 Linux, GCC 16.2.1 / libstdc++, -O2 -DNDEBUG, `Ping{int} -> Pong{int}` through `LocalBackend` on a one-thread pool, 50 warm-up calls excluded, 200 counted round trips: local execute round-trips : 200 heap allocations total : 4165 (20.82 per call) bytes allocated total : 398912 (1994.6 per call) allocations in one steady-state call: 21 The total held: 20.8 against 19.2, and 1995 bytes against 1983. The attribution in the ticket did not, and the fix it proposes is scoped by the attribution rather than by the total -- which is why this lands as a measurement and not as a fix. The per-line breakdown (backtrace-attributed at -O2 -g, symbolised with addr2line) and what it does to the ticket's "2-3 allocations per call" target are on morph#572; the ticket is handed back rather than built against a number this commit disproves in detail. `morph_bench_alloc` is an instrument, not a control. It is not registered with ctest and asserts nothing unless `--budget=<n>` is passed: an allocation count is standard-library and allocator specific, so a ceiling that is right on libstdc++ is wrong on libc++, and a gate nobody can satisfy everywhere is worse than no gate. Both arms of the optional budget were exercised -- `--budget=5` exits 1 and prints the overrun, `--budget=40` exits 0 -- so the one assertion it can make is known to be able to fail. It is a separate binary from `morph_bench` deliberately: it replaces the global `operator new`/`delete`, which is process-wide and would perturb every other measurement in a shared binary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…g they move from
The WASM leg compiles `-Weverything -Werror` and rejected morph#588's four new
init-captures:
include/morph/core/bridge.hpp:2112:38: error: declaration shadows a
structured binding [-Werror,-Wshadow]
(and 2118, 2297, 2303). All four are `[onX = std::move(onX), ...]` inside a
lambda that captured `onX` from `auto [onRegistered, onFailed] =
makeBindCallbacks(...)`. Renamed to `registered`/`bound`/`failed`.
## The rename cannot change which object is moved from
An init-capture's initializer is looked up in the *enclosing* scope, not in the
capture being declared ([expr.prim.lambda.capture]/6), so `[x = std::move(x)]`
and `[y = std::move(x)]` both move from the enclosing `x`. Confirmed rather
than asserted, with a tracked type on clang 22.1.8 -- the two forms print
byte-identical traces:
A: shadowing form [f = std::move(f)] B: renamed form [g = std::move(f)]
copy of OUTER(gen1) copy of OUTER(gen1)
move -> gen1 move -> gen1
after move, enclosing f.tag=<moved-from> after move, enclosing f.tag=<moved-from>
call tag=OUTER gen=1 call tag=OUTER gen=1
So no use-after-move is introduced: in both forms the inner callable holds the
live value and the outer lambda's copy is left moved-from, unused thereafter.
The fifth `[onFailed = std::move(onFailed), ...]` (line 1208, in
`assignHandlerPrimary`) is deliberately left alone: its `onFailed` is a plain
local, not a structured binding, and neither toolchain diagnoses it.
## Why the GCC-only local build was clean, and why a clang build is too
This is not a GCC/clang split. Local clang 22.1.8 files this diagnostic under
`-Wshadow-uncaptured-local`, which `cmake/compiler_options.cmake`'s Clang
branch suppresses tree-wide; emsdk 3.1.56's older clang files the identical
construct under plain `-Wshadow`, which nothing suppresses. Same flag list on
both legs -- both pass `-Wno-shadow-uncaptured-local` -- so the WASM leg is the
only one in CI that enforces this diagnostic class. Filed separately rather
than folded in.
Verified by dropping that one suppression from the project's own flags for a TU
that includes bridge.hpp (clang 22.1.8, everything else identical):
before: shadows a local variable: 18 shadows a structured binding: 4
after: shadows a local variable: 18
The same 18 pre-existing local-variable shadows (backend.hpp, completion.hpp,
registry.hpp, callback_scope.hpp) before and after, all four structured-binding
shadows gone, none introduced.
Not verified: no Emscripten toolchain is available here, so the WASM leg itself
was not run. The above is a local proxy for the same diagnostic on a different
clang, not a build of that leg.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ng attachHandlerAsync's complexity gate
clang-tidy rejected morph#588's executor branch:
include/morph/core/bridge.hpp:776:10: error: function 'attachHandlerAsync'
has cognitive complexity of 28 (threshold 25)
[readability-function-cognitive-complexity,-warnings-as-errors]
Extraction, following morph#615's remedy in this same file rather than raising
the threshold or adding a NOLINT -- the number is a gate, and widening a gate
to fit the change is the failure mode AGENTS.md names.
## Why this split rather than a narrower one
The cheapest fix would lift `attachHandlerAsync`'s deferred body alone. What is
extracted instead is the shape it *shares* with `ensureBoundAsync`, which is
morph#615's own move (three continuation pairs -> one `makeBindCallbacks`).
Both functions dispatch through `IBackend::bindModel`, both park an in-frame
reply, and when the reply instead lands out of frame both run the same four
steps: is the `Bridge` alive, is the binding alive, is the answering backend
still the active one, and report through `onDone` either way. They differed
only in what a successful reply publishes. That is now `publishLateBindReply`'s
`Publish` parameter, and two ~30-line bodies -- including two copies of the
stale-backend reasoning and of its error string -- became one.
`assignHandlerPrimary`'s continuation is deliberately *not* routed through it.
It has no `onDone`: a stale reply there is dropped silently because no caller
is waiting on it. Folding it in would mean giving the helper a second mode, and
the two behaviours differ on purpose.
`Publish` is a template parameter, not a `std::function`, so the late path
type-erases and allocates nothing -- the same constraint morph#108's
OOM-injection case already imposed on `deliverLate`.
## Measured, on this revision, with clang-tidy 22.1.8 (CI's pinned major)
before after
attachHandlerAsync 28 13 (threshold 25)
ensureBoundAsync 20 9
publishLateBindReply - 7
assignHandlerPrimary 19 19 (untouched)
`ensureBoundAsync` at 20 was five under the gate and next in line; this takes
both off it. The command that reported the 28 reports nothing on bridge.hpp
now, and `clang-tidy-diff` against the base finds nothing on any changed line.
## Behaviour
Unchanged on both paths, with one narrow exception worth stating: the helper
wraps the publish in try/catch for `attachHandlerAsync`'s two `std::string`
assignments, so `ensureBoundAsync` -- which previously had none -- now stores
its atomic `currentId` inside one. An integral `store` is `noexcept`, so the
handler is unreachable there. Lock scope, lock ordering and the "onDone outside
the lock" contract are the same at both sites.
Verified: clang 22.1.8 `-Weverything -Werror` build of `morph_tests` and
`morph_bench_alloc` clean; suite green (1561 cases, 22971 assertions, 1
deliberate expected failure); Doxygen `WARN_AS_ERROR=FAIL_ON_WARNINGS` clean;
`clang-format --dry-run -Werror` clean on both touched files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ranch moved
The `Linux / clang-coverage` leg was red, and not for the reason its 40m57s
runtime suggested. All 2926 tests passed and no floor was breached
(`include/morph/core` 94.90% against a 94% floor). The whole failure was
`scripts/check_branch_coverage.py`'s allowlist audit:
error: include/morph/core/bridge.hpp:1557 has moved to line 1709. The text
still matches, so nothing is wrong with the disposition -- update the
`line` hint.
error: include/morph/core/bridge.hpp:1679 is allowlisted by a source line
that appears 3 times (lines [1832, 1856, 1945]), and none of them is
1679, so which one is meant is not decidable. Make the entry unambiguous.
Both dispositions are still correct; only the `line` hints had drifted, because
this branch inserted lines above them. Repointed against this branch's final
tree (after the two commits before this one, which moved them again): 1557 ->
1733, 1679 -> 1856.
## Resolving the ambiguous one by reading, not by taking the first match
`if (deadlineHandle && schedulerRef) {` occurs three times, all in
`executeVia`: the `catch` block that undoes `_pendingCalls` before rethrowing,
the `.then()` disarm, and the `.onError()` disarm. Three independent lines of
evidence agree on the first:
1. The entry's own reason says so in as many words -- "This entry is
specifically the exception-path use of the guard (the `catch` block that
undoes `_pendingCalls` and cancels the deadline before rethrowing)" -- and
then explicitly disclaims the other two as "a different guard on a
different, reachable arm".
2. Read directly: line 1856 is that catch block.
3. On master (7ab4c7a), where the gate is green, hint 1679 is an exact match
on the first of the three occurrences, and the other two sit at the offsets
the reason names.
Note the resolver accepts any hint that matches one of the occurrences, so
"all 22 entries resolve" is *not* evidence that the right one was picked --
hence the three checks above. The gate's other direction (the resolved line
must still be partial in the LCOV report) would catch a wrong pick, and that
half needs a coverage run this change did not make.
Two bare line numbers inside the reason prose (`bridge.hpp:1557`, and "lines
1703, 1792" for the other two occurrences) were already stale and are removed
rather than re-stated: the file's own header comment says a comment citing a
bare line number is the defect this repository has found three times, and the
structural naming beside them ("the entry above", "the `.then()`/`.onError()`
continuations") identifies them unambiguously without rotting.
Verified: `check_branch_coverage.py`'s own `resolve_allowlist_source_line`
resolves all 22 entries cleanly against this tree. Not verified: no local
coverage build was run, so the partial-line half of the audit, and the
subsystem floors on this branch's code, are unmeasured here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
dbe1020 to
7b7e052
Compare
Runner verification — and my dispatch was wrong about the causeI told this lane "GCC does not diagnose this, which is why the GCC-only local build was clean." That explanation is false, and the lane checked rather than accepting it. The project enables The consequence is the real finding, and #662 records it: the WASM leg is currently the only leg in CI enforcing this diagnostic class, by accident of which clang version files it under which flag. That is a gate whose coverage nobody chose, and it is exactly the class of thing this repository files issues about. Rebase integrity verified rather than trusted: and On the coverage log — my hypothesis was also wrongI guessed new partial lines from the bench file or the executor branch. It was neither: 2926 tests passed, no floor breached ( Two things the lane did there are worth keeping:
On the complexity fixExtraction, not a NOLINT and not a threshold change — and the split was chosen to cover The behaviour note is the part I would have wanted flagged and was: Residual risk, in the lane's words and mine: no coverage build was run, so the partial-line half of the allowlist audit is unmeasured — and that half is precisely what would catch a wrong pick among the three candidates. No Emscripten either, so the leg whose failure started this was not re-run locally; the Not merged: CI incomplete at hand-off. Worktree pruned. 🤖 Generated with Claude Code |
Runner, 11:09 — the four new red legs look like an upstream outage, not this branchAfter the fix push: 18 pass, 4 fail, 19 pending. The four are
The The other three are inference, not measurement, and I am saying so rather than rolling them up: their logs are unreachable while the run has 19 checks outstanding. What supports the guess is that all four install the GCC toolchain from that same PPA and all four died at the same ~5-minute mark. What would refute it is any of them showing a compile error instead once the run completes. No action taken. Re-running now would hit the same outage, and pushing would cancel the 19 in-flight checks — including the legs that were the actual subject of this fix. The next sweep reads the completed logs and re-runs the affected legs if the PPA has recovered. Worth noting for the record: this is the second infrastructure-shaped failure this session, after #658's 🤖 Generated with Claude Code |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…ixes #660) `StrandExecutor` erases a model's `Strand` as soon as it drains, so a workload that dispatches one action at a time against a model rebuilds the whole strand on every call. Re-measured on 7a343e6 with tests/bench/bench_dispatch_allocations.cpp (the instrument #661 landed, which the original figure predates), x86-64 Linux, GCC 16.2.1 / libstdc++, -O2, per-line attribution from a `backtrace()` in the counting hook symbolised through `dladdr` + `addr2line`: #7 32 bytes strand.hpp:104 _strands map node #8 152 bytes strand.hpp:106 make_shared<Strand> #9 64 bytes <stl_deque> the Strand's std::queue deque map #10 512 bytes <stl_deque> the Strand's std::queue first buffer heap allocations total : 4159 (20.80 per call) bytes allocated total : 398072 (1990.4 per call) 760 of 1990 bytes, 38%. But 576 of those 760 are not the strand's lifetime at all: they are libstdc++'s `std::deque` allocating a node map and a 512-byte first buffer *in its default constructor*, for a queue that in this workload never holds more than one task. So this takes the third of the ticket's three directions -- shrink what is rebuilt -- and leaves the lifetime policy alone. `PendingQueue` holds the head task inside the `Strand` and constructs a `std::deque` behind it only when a second task is genuinely queued. Nothing about the locking changes: the erase still fires when `empty()` becomes true, still under the `{_mapMtx, strand->mtx}` pair whose atomicity the comments at :81 and :167 record a previous defect forcing into shape. heap allocations total : 3786 (18.93 per call) bytes allocated total : 279208 (1396.0 per call) #7 32 bytes _strands map node #8 120 bytes make_shared<Strand> 2 allocations and 608 bytes per local dispatch, 30% of the total. The two remaining strand allocations are inherent to the erase, and keeping the slot alive to remove them trades this churn for a per-model entry nothing reclaims -- `StrandExecutor` has no deregistration hook. That trade is not made here and is not smuggled in. Verification. Reproduced, not inferred: both the before and after figures above are runs on this tree, and the four backtraces are real output, not a reading of the code. The overflow path the change adds is covered -- `_overflow->push_back` -> `push_front` fails 3 test cases including the 50-task FIFO assertion, and disabling the refill-from- overflow arm fails 8. Full gcc-release suite: 1561 cases pass. The race, not the suite, is the hazard, so: full ctest under the clang-tsan preset (clang 22.1.8, MORPH_BUILD_NET=ON, MORPH_BUILD_OFFLINE_SQLITE=ON, cmake/tsan.supp, the `Linux / clang-tsan` leg's own flags) -- 1801/1801 pass, all six binaries confirmed instrumented by check_sanitizer_instrumentation.sh. And that configuration was shown to be able to see this specific hazard: restoring the pre-fix two-step drain-and-erase makes TSan report a data race whose two stacks both run through `scheduleNext`'s lambda into `LoadCountModel::execute` -- two strands for one key, which is exactly the defect the combined lock exists to prevent. Not verified: the time cost -- nothing here claims a latency change. Not verified on libc++, MSVC or the WASM toolchain; the byte figures are libstdc++'s and the `std::deque` behaviour they turn on is an implementation choice, so the saving may differ elsewhere. The `Kanban / ThreadSanitizer` leg (Qt + ladder) was not run locally; the `Linux / clang-tsan` leg was, and it is the one that covers strand.hpp's own tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ills, and say what -Wno-shadow-uncaptured-local really suppresses (fixes #660, refs #662) (#671) * core: stop a serial dispatch paying for a std::deque it never fills (fixes #660) `StrandExecutor` erases a model's `Strand` as soon as it drains, so a workload that dispatches one action at a time against a model rebuilds the whole strand on every call. Re-measured on 7a343e6 with tests/bench/bench_dispatch_allocations.cpp (the instrument #661 landed, which the original figure predates), x86-64 Linux, GCC 16.2.1 / libstdc++, -O2, per-line attribution from a `backtrace()` in the counting hook symbolised through `dladdr` + `addr2line`: #7 32 bytes strand.hpp:104 _strands map node #8 152 bytes strand.hpp:106 make_shared<Strand> #9 64 bytes <stl_deque> the Strand's std::queue deque map #10 512 bytes <stl_deque> the Strand's std::queue first buffer heap allocations total : 4159 (20.80 per call) bytes allocated total : 398072 (1990.4 per call) 760 of 1990 bytes, 38%. But 576 of those 760 are not the strand's lifetime at all: they are libstdc++'s `std::deque` allocating a node map and a 512-byte first buffer *in its default constructor*, for a queue that in this workload never holds more than one task. So this takes the third of the ticket's three directions -- shrink what is rebuilt -- and leaves the lifetime policy alone. `PendingQueue` holds the head task inside the `Strand` and constructs a `std::deque` behind it only when a second task is genuinely queued. Nothing about the locking changes: the erase still fires when `empty()` becomes true, still under the `{_mapMtx, strand->mtx}` pair whose atomicity the comments at :81 and :167 record a previous defect forcing into shape. heap allocations total : 3786 (18.93 per call) bytes allocated total : 279208 (1396.0 per call) #7 32 bytes _strands map node #8 120 bytes make_shared<Strand> 2 allocations and 608 bytes per local dispatch, 30% of the total. The two remaining strand allocations are inherent to the erase, and keeping the slot alive to remove them trades this churn for a per-model entry nothing reclaims -- `StrandExecutor` has no deregistration hook. That trade is not made here and is not smuggled in. Verification. Reproduced, not inferred: both the before and after figures above are runs on this tree, and the four backtraces are real output, not a reading of the code. The overflow path the change adds is covered -- `_overflow->push_back` -> `push_front` fails 3 test cases including the 50-task FIFO assertion, and disabling the refill-from- overflow arm fails 8. Full gcc-release suite: 1561 cases pass. The race, not the suite, is the hazard, so: full ctest under the clang-tsan preset (clang 22.1.8, MORPH_BUILD_NET=ON, MORPH_BUILD_OFFLINE_SQLITE=ON, cmake/tsan.supp, the `Linux / clang-tsan` leg's own flags) -- 1801/1801 pass, all six binaries confirmed instrumented by check_sanitizer_instrumentation.sh. And that configuration was shown to be able to see this specific hazard: restoring the pre-fix two-step drain-and-erase makes TSan report a data race whose two stacks both run through `scheduleNext`'s lambda into `LoadCountModel::execute` -- two strands for one key, which is exactly the defect the combined lock exists to prevent. Not verified: the time cost -- nothing here claims a latency change. Not verified on libc++, MSVC or the WASM toolchain; the byte figures are libstdc++'s and the `std::deque` behaviour they turn on is an implementation choice, so the saving may differ elsewhere. The `Kanban / ThreadSanitizer` leg (Qt + ladder) was not run locally; the `Linux / clang-tsan` leg was, and it is the one that covers strand.hpp's own tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW * gate: repoint the strand.hpp branch-allowlist line hint after PendingQueue (refs #660) Mechanical follow-up to the previous commit, kept separate because it changes no behaviour and no argument. `PendingQueue` inserts 67 lines above the allowlisted `if (iter != _strands.end() && iter->second == strand) {`, so the ST1 entry's `line: 192` hint drifted to 259. The `source` text is unchanged and matches exactly one line in the file, so the disposition itself is untouched -- this is the "text still matches, update the hint" case check_branch_coverage.py's resolve_allowlist_source_line() names, not a re-reading of the invariant. Verified by re-running that resolver's own rule over every entry in branch_partial_allowlist.json, error_path_allowlist.json and mutation_survivors.json against this tree: one drifted entry before, zero after. Not verified: check_branch_coverage.py itself, which needs a coverage profile this lane did not produce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW * cmake: say what -Wno-shadow-uncaptured-local actually suppresses (refs #662) The comment on this suppression said "lambda param shadowing an uncaptured local". That is one of four constructs the flag owns. Measured on clang 22.1.8, `clang++ -std=c++23 -fsyntax-only -Weverything`, one construct per function: probe.cpp:9:22: declaration shadows a local variable [-Wshadow-uncaptured-local] probe.cpp:16:24: declaration shadows a local variable [-Wshadow-uncaptured-local] probe.cpp:23:16: declaration shadows a local variable [-Wshadow-uncaptured-local] probe.cpp:30:16: declaration shadows a structured binding [-Wshadow-uncaptured-local] probe.cpp:37:11: declaration shadows a local variable [-Wshadow] probe.cpp:44:48: declaration shadows a local variable [-Wshadow] -- lambda parameter, ordinary local inside a lambda, init-capture over a local, init-capture over a structured binding; and, still enforced, a plain nested block and a shadow of a variable the lambda *does* capture. So the group is "any declaration inside a lambda with no capture-default that shadows an uncaptured enclosing local or structured binding", which includes the `[x = std::move(x)]` idiom this codebase uses throughout. A suppression whose stated reason is narrower than its effect is a defect on its own, independent of any cleanup, so the comment is corrected here and nothing else changes. It is corrected rather than narrowed because narrowing to the one named construct is not expressible: clang has no finer flag than -Wshadow-uncaptured-local, so the only alternatives are all-or-nothing plus per-site suppressions. And all-or-nothing is not a one-line diff. Measured on this tree, clang 22.1.8, clang-debug with NET/QT/FORMS_QML/OFFLINE_SQLITE/LOAD_TESTS/HMAC_EXAMPLES/LADDER/ BANK_EXAMPLE configured and -Werror off, with the flag removed: 4856 diagnostics, 41 distinct sites, 16 first-party files 35 of the 41 are in headers, which is why the emission count is two orders of magnitude larger. Eight each in core/backend.hpp and core/remote.hpp, four each in core/bridge.hpp and core/completion.hpp. No dependency is affected; they arrive via -isystem. **This does not close #662.** That issue's own condition is "a diagnostic in this class fails a leg that a developer can run locally, demonstrated by mutating the code and watching that leg go red", and nothing here changes which leg enforces what. It stays open, the 41-site cleanup it needs is filed separately, and folding either into this commit is what AGENTS.md says not to do. Two things this measurement says that #662 does not. First, the split is not "structured binding versus local variable": the flag routes on whether the shadowing declaration sits in an uncapturing lambda, not on what kind of entity is shadowed -- rows 3 and 4 above differ only in the shadowed entity and land in the same group. Second, the tree now has **zero** structured-binding shadows (morph#661 fixed the only four), so the WASM leg's exclusive enforcement of that row currently enforces nothing in practice; it is a trap for the next one written, not a live gate. Verified: the six-row table and the 4856/41/16 figures are real output from this workstation, re-derived from the build log rather than transcribed. `cmake --preset clang-debug` reconfigures clean afterwards with the flag still on all 355 compile commands. Not verified: anything about emsdk -- no emsdk toolchain is available here, so the WASM claim in the comment is cited to the CI log it came from and labelled as such. Not verified: which clang release reclassified the structured-binding case; only clang 22.1.8 was available locally. 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>
Two tickets on the core dispatch tree, one commit each. One lands, one is handed
back with the measurement that says it should be.
Bridgegets an executor for the registration replies that outlivetheir dispatch frame. Landed (
fixes #588).The instrument is committed; the numbers and the re-scope are
on the issue.
No
fixestrailer: the ticket stays open.Filed while here: #660 (
StrandExecutorrebuilds a model'sStrandon everyserial dispatch — 4 allocations, 752 of the 1995 bytes per local call).
#588 — the bridge's own executor
Bridgeissues registrations on its own behalf and was the only caller in theframework that could not say where their completions are delivered: its five
dispatch sites name
exec::detail::inlineExecutor(), so a reply is published onwhichever thread the backend settled it on. That is the thread morph#486's
use-after-free is about — each of those callbacks asks "is the
Bridgestillalive" and then touches it.
The constructor now takes an optional
IExecutor* bridgeExec, anddetail::deliverLateroutes a late reply to it. Null — the default — runs itinline, byte for byte what happened before, so nothing that does not ask for the
new argument changes.
Where this deviates from the ticket, and why
#588 asks for the five
inlineExecutor()arguments to be replaced. They arenot, and that is the one deliberate deviation. Naming a posting executor on the
bindModel/promoteModelcall breaks two things:registerHandler()stops being synchronous for every backend that bindsinline. The settle becomes a queued task,
claimHandofffinds nothing parked,and the caller gets an unbound handler from a call that has always returned a
bound one.
kCallerMayBlockbackend deadlocks when the dispatching thread is theexecutor's thread:
awaitHandoffwaits for a task only that thread could run.A GUI embedder passing its GUI executor is exactly that case.
An inline settle has to reach
parkIfInFrameinside the dispatch frame, and thedispatching frame then publishes it on the dispatching thread. Only the late
reply has a delivery thread left to choose, and that is the one the new executor
gets.
assignHandlerPrimarygrew anAsyncDispatchHandofffor that reason — ithad no way to tell the two cases apart, and posting both would have made an
inline promote asynchronous (which
BridgeHandler::execute'sonResultrelieson not being).
Both triage comments on #588 were read. The first one's objection — that "an
executor bound to the thread that runs
~Bridge" asks for an affinityconcurrency_and_lifetimes.md§"teardown is order-independent, on any thread"spent two sections arguing against — is why the executor is supplied, not
owned:
Bridgestill has no thread, spawns none (it could not, in an Emscriptenbuild with no pthreads), and imposes no affinity. Capturing the constructing
thread was rejected for the same reason the first triage gives: it names a thread
nobody promised would be the destroying one.
The one claim the safety rests on
Only the late-delivery path changed; the in-frame path is untouched. A reply
that settles inside the dispatch call is parked by
parkIfInFrameand publishedby the dispatching frame exactly as before, so every synchronous registration
contract in the tree holds unchanged. If that claim is wrong,
registerHandler()silently stops binding — which is what the second new test case exists to catch.
What it buys, stated as narrowly as it is true
The morph#486 window is closed for an embedder whose executor runs on a thread
that cannot run
~Bridgeconcurrently: the callback and the destructor are thentwo tasks on one thread. An executor on an unrelated thread satisfies the type
and closes nothing; it makes nothing worse either, since the existing
CallbackToken/BridgeLifetimegates are untouched. The three specs now saythat, rather than an unqualified "the guarantee is structural".
Review reasoning, inline (no
/code-review, per the lane's bounds)what the gates are for. What changed is that
_bridgeExecis read once atdispatch and captured by value, never from inside the callback: reading
this->_bridgeExecin a callback that can run after~Bridgewould be a readof destroyed memory ahead of the gate that exists to prevent one. The member
comment says so.
active()-then-touch window? For a nullexecutor, not at all. For a non-null one it defers the whole body to the
executor's thread, which is the point; the gates run inside the deferred body.
assignHandlerPrimary's new handoff._attachMtxis released before thedispatch (it was already), so publishing the parked outcome after
claimHandofftakes the same lock the callback would have taken, on the samethread, at the same point in the frame. Behaviour is unchanged for every
backend that settles inline;
attachHandler+bindingPrimary()is assertedin the new tests.
deliverLateis a template with a forwarding reference,not a
std::functionparameter, so the null path type-erases nothing andallocates nothing. The first version took a
std::functionand brokemorph#108's OOM-injection case — the injected failure hit the new 256-byte
capture copy instead of the target allocation. That is now a comment in the
code, and
primaryCopyis moved rather than copied into the deferred body.onDonecannot be moved the same way (captured from aconst¶meter, sothe capture is const) — clang-tidy's
performance-move-const-argcaught theattempt.
Verification
morph_tests: 1561 cases, 22972 assertions, all pass (one deliberateexpected failure,
[ERROR] default-sink-coverage). GCC 16.2.1, Release.deliverLateignore its executorand always run inline fails the late-reply case:
goes through the executor, an in-frame bind and a keyed attach do not (with an
executor that never drains), and no executor means the old behaviour.
check_mutation_survivors.py,check_nolint_directives.sh,check_bidi_controls.py,check_spec_citations.sh,check_spec_sync.sh(real path list),clang-format --dry-run -Werrorover exactly the touched files.clang-tidy22.1.8 against a clang-configured compile database: no findingon any changed line. Three findings remain in
bridge.hppon lines thisbranch does not touch (
performance-unnecessary-value-param×3), whichclang-tidy-diffdoes not report and which are present onmaster.check_mutation_survivors.pyorbranch_partial_allowlist.jsoncitation moved.#572 — re-measured, handed back
The ticket's scope is a number taken on
4017228d; #639, #649 and #654 rewrotethe dispatch path afterwards. The commit adds
tests/bench/bench_dispatch_allocations.cpp(morph_bench_alloc, under theexisting
MORPH_BUILD_LOAD_TESTS) so the number is re-checkable in one command,and records what it says.
On
f24e225a, x86-64 Linux, GCC 16.2.1 / libstdc++,-O2 -DNDEBUG,Ping{int} -> Pong{int}throughLocalBackend, 50 warm-up calls excluded, 200counted:
Against the ticket's 19.2 / 1983: 20.8 and 1995. The total holds — the path
did not get cheaper. The attribution does not, and the attribution is what
scopes the fix:
SSO threshold; 0–2 in general. The SSO caveat the ticket applies to Part C
applies to Part A too.
std::functions" → two.deserializeResultis captureless and fits thesmall buffer; it does not appear in the census.
CompletionStates" → confirmed, both still there.and 752 of the 1995 bytes are
StrandExecutorrebuilding the model'sStrandevery call. Filed as core: StrandExecutor rebuilds a model's Strand on every serial dispatch -- 4 allocations and 752 of the 1995 bytes per local call #660, not folded in.
call, not the "2-3" the ticket targets. Building them as scoped would be work
measured against a target it cannot reach.
Per the dispatch's own instruction — if the figure has materially changed, stop
and report; do not build a fix against a number you have just disproved — the
fix is not attempted here. The full per-line table, the re-scope proposal and the
"not verified" list (Part C was not re-measured; one toolchain only) are in
the issue comment.
morph_bench_allocis an instrument, not a control: not registered with ctest,asserts nothing unless
--budget=<n>is passed, and both arms of that budgetwere exercised (
--budget=5exits 1 and names the overrun,--budget=40exits0). It is a separate binary from
morph_benchbecause it replaces the globaloperator new/delete, which is process-wide and would perturb the latencybenchmark sharing the binary. Its one warning suppression
(
-Wno-mismatched-new-delete, GCC pairing an inlinednewwith thestd::freeinside the replaced
operator delete) is scoped to that one target.Not verified
check results are included below the line.
MORPH_BUILD_TESTS+LOAD_TESTSonly). TheBridgeconstructor change is source-compatible — adefaulted trailing parameter — but that is an inference from the signature, not
a measurement of those builds.
different SSO thresholds and
std::functionbuffer sizes, so the census willdiffer there. That is exactly why it has no default ceiling.
operator newreplacement in that targetis untested; it compiles nowhere but where CI builds load tests.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
CI repair pass (3 commits on top, rebased onto
7ab4c7a9)The first push was verified with GCC and
morph_tests, and all three red legswere things that build never ran. Rebased onto
7ab4c7a9(#657) first; thebranch touched none of what #657 moved and the rebase was clean.
One commit per cause, on top of the original two — neither of which was
rewritten.
da9deb52still saysrefs #572, notfixes: that ticket staysopen on its re-measurement.
-Wshadowinit-capturesb64c1e9aattachHandlerAsynccognitive complexity 28 > 25194c2d12linehints7b7e052a1.
-Wshadow— and why the local clang build was also cleanRenamed the four init-captures to
registered/bound/failed. The fifth(
bridge.hpp:1208) is left alone: itsonFailedis a plain local, not astructured binding, and neither toolchain diagnoses it.
The rename cannot change which object is moved from. An init-capture's
initializer is looked up in the enclosing scope, so
[x = std::move(x)]and[y = std::move(x)]both move from the enclosingx. Checked with a trackedtype on clang 22.1.8 rather than argued from the standard — the two forms
produce byte-identical traces, enclosing copy left moved-from, inner callable
holding the live value:
This was never a GCC/clang split. A full clang 22.1.8
-Weverything -Werrorbuild is also silent here, so "build with clang" would not have caught it
either. Local clang 22.1.8 files this diagnostic under
-Wshadow-uncaptured-local, whichcompiler_options.cmakesuppresses tree-wide;emsdk 3.1.56's clang files the identical construct under plain
-Wshadow, whichnothing suppresses. Both legs pass the same
-Wno-shadow-uncaptured-local. TheWASM leg is therefore the only leg in CI enforcing this diagnostic class, and no
local build on morph's usual toolchain can reproduce it. Filed as #662, not
folded in.
Verified by dropping that one suppression from the project's own flags for a TU
that includes
bridge.hpp, everything else identical:Same 18 pre-existing local-variable shadows either side; all four
structured-binding shadows gone; none introduced.
2. Cognitive complexity — extraction, not a wider gate
Neither the threshold nor a NOLINT was touched. Following #615's remedy in this
same file, but extracting the shape
attachHandlerAsyncshares withensureBoundAsyncrather than lifting one body: both run the same four stepson an out-of-frame reply (Bridge alive? binding alive? backend still active?
report through
onDone), differing only in what a success publishes. That isnow
publishLateBindReply'sPublishparameter, and two ~30-line bodies — twocopies of the stale-backend reasoning and of its error string — became one.
assignHandlerPrimaryis deliberately not routed through it: having noonDone, it drops a stale reply silently rather than reporting it, so foldingit in would mean a second mode for a helper whose contract is "report exactly
once".
Publishis a template parameter, not astd::function, so the late pathtype-erases and allocates nothing — the constraint morph#108's OOM-injection
case already put on
deliverLate.Measured with clang-tidy 22.1.8 (CI's pinned major — there is no version
skew; local
clang-tidy --versionis 22.1.8 andCLANG_VERSION: "22"):attachHandlerAsyncensureBoundAsyncpublishLateBindReplyassignHandlerPrimaryensureBoundAsyncat 20 was five under the gate and next in line; this takesboth off it. The exact command that reported the 28 now reports nothing on
bridge.hpp, andclang-tidy-diffagainst the base reports nothing on anychanged line.
Behaviour is unchanged, with one narrow exception stated rather than
glossed: the shared helper wraps the publish in try/catch for
attachHandlerAsync's twostd::stringassignments, soensureBoundAsync—which previously had none — now stores its atomic
currentIdinside one. Anintegral
storeisnoexcept, so that handler is unreachable there. Lockscope, lock ordering and "onDone outside the lock" are identical at both sites.
3.
Linux / clang-coverage— what the log actually saidNot the compile errors, and not the hypothesis that a new partial line or a
breached floor did it. All 2926 tests passed and no floor was breached
(
include/morph/core94.90% against a 94% floor). The entire failure was theallowlist audit in
scripts/check_branch_coverage.py:Both dispositions are still correct; only the hints drifted, because this branch
inserted lines above them. Repointed against the branch's final tree (the
two commits before it moved them again): 1557 → 1733, 1679 → 1856.
The ambiguous one was resolved by reading all three candidates, not by taking
the first match. All three are in
executeVia: thecatchblock that undoes_pendingCallsbefore rethrowing, the.then()disarm, and the.onError()disarm. Three independent lines of evidence agree on the first:
exception-path use of the guard (the
catchblock that undoes_pendingCallsand cancels the deadline before rethrowing)" — and thenexplicitly disclaims the other two as a different, reachable guard.
of the three, and the other two sit at the offsets the reason names.
Worth being explicit: the resolver accepts any hint matching any occurrence,
so "all 22 entries resolve cleanly" is not evidence the right one was
picked — hence the three checks above. The audit's other direction (the resolved
line must still be partial in the LCOV report) would catch a wrong pick, and
that half needs a coverage run this pass did not make.
Two bare line numbers inside the reason prose were already stale and are removed
rather than restated — the file's own header calls a comment citing a bare line
number the defect this repository has found three times, and the structural
naming beside them identifies the sites without rotting.
Review reasoning, inline (no
/code-review, per the lane's bounds)_attachMtxat thesame point both inlined bodies did, and releases it before
onDoneon everypath that calls
onDoneat all. The only paths that skiponDoneare the twothat skipped it before (Bridge gone, binding gone).
publishrun after~Bridge? Same as before: the liveness token ischecked first and the whole body runs on whatever thread
deliverLatechose.The helper changed where the code lives, not which thread runs it.
Publish&&andstd::forwarding it once risk a double-move?It is invoked at most once, on the success arm only, and the two call sites
pass lambdas that own nothing needing a move.
and
primaryCopyis still moved into the deferred body, so morph#108'sOOM-injection case still sees the same allocation shape.
What was measured this pass, and on what
All on
7b7e052aunless stated, x86-64 Linux, clang 22.1.8:-Weverything -Werror(the project's own Clang branch) build ofmorph_testsandmorph_bench_alloc: zero diagnostics. The bench hadnever been compiled by clang before — the previous pass used GCC and its
target needs
MORPH_BUILD_LOAD_TESTS=ONto exist at all.(
[ERROR] default-sink-coverage).bridge.hpp;clang-tidy-diffagainst the base finds nothing on any changed line ofbridge.hppor the two changed test sources.clang-format --dry-run -Werrorover exactly the touched files: clean.WARN_AS_ERROR=FAIL_ON_WARNINGS: clean.check_bidi_controls.py,check_mutation_survivors.py,check_spec_citations.sh,check_spec_sync.sh(real path list),check_nolint_directives.sh,check_rung_filters.sh(58 checks, incl.ci/testkit: three gates that did not cover what they appeared to (fixes #651, fixes #652, fixes #655) #657's new check-5). All green.
rename restores all four structured-binding diagnostics; the command that
reported complexity 28 reported it again on the pre-fix tree and reports
nothing after.
Still not verified
not built. Its fix rests on a local proxy — the same diagnostic, on a
different clang, with one suppression dropped — not on running that leg.
check_branch_coverage.py's own resolver; the partial-line half of the audit,and this branch's effect on the subsystem floors, are unmeasured.
incomplete by construction.
SQLite database is lockedin the ledger scenario corpus) is a knownflake: an
Application ladderfailure with that signature is not this branch.