Skip to content

core: give Bridge an executor for late registration replies, and re-measure what a local dispatch allocates (fixes #588, refs #572) - #661

Merged
Yaraslaut merged 5 commits into
masterfrom
laneCORE3-batch-588-572
Sep 21, 2026
Merged

Yaraslaut merged 5 commits into
masterfrom
laneCORE3-batch-588-572

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Sep 21, 2026

Copy link
Copy Markdown
Member

Two tickets on the core dispatch tree, one commit each. One lands, one is handed
back with the measurement that says it should be.

Filed while here: #660 (StrandExecutor rebuilds a model's Strand on every
serial dispatch — 4 allocations, 752 of the 1995 bytes per local call).


#588 — the bridge's own executor

Bridge issues registrations on its own behalf and 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.

The constructor now takes an optional IExecutor* bridgeExec, and
detail::deliverLate routes a late reply to it. Null — the default — runs it
inline, 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 are
not, and that is the one deliberate deviation. Naming a posting executor on the
bindModel/promoteModel call breaks two things:

  1. 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.
  2. A kCallerMayBlock backend deadlocks when the dispatching thread is the
    executor's thread: awaitHandoff waits 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 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 (which BridgeHandler::execute's onResult relies
on 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 affinity
concurrency_and_lifetimes.md §"teardown is order-independent, on any thread"
spent two sections arguing against — is why the executor is supplied, not
owned: Bridge still has no thread, spawns none (it could not, in an Emscripten
build 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 parkIfInFrame and published
by 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 ~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. The three specs now say
that, rather than an unqualified "the guarantee is structural".

Review reasoning, inline (no /code-review, per the lane's bounds)

  • Can the posted task outlive the bridge? Yes, and it always could — that is
    what the gates are for. What changed is that _bridgeExec is read once at
    dispatch
    and captured by value, never from inside the callback: reading
    this->_bridgeExec in a callback that can run after ~Bridge would be a read
    of destroyed memory ahead of the gate that exists to prevent one. The member
    comment says so.
  • Does the executor widen the active()-then-touch window? For a null
    executor, 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. _attachMtx is released before the
    dispatch (it was already), so publishing the parked outcome after
    claimHandoff takes the same lock the callback would have taken, on the same
    thread, at the same point in the frame. Behaviour is unchanged for every
    backend that settles inline; attachHandler + bindingPrimary() is asserted
    in the new tests.
  • Allocation shape. deliverLate is a template with a forwarding reference,
    not a std::function parameter, so the null path type-erases nothing and
    allocates nothing. The first version took a std::function and broke
    morph#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 primaryCopy is moved rather than copied into the deferred body.
    onDone cannot be moved the same way (captured from a const& parameter, so
    the capture is const) — clang-tidy's performance-move-const-arg caught the
    attempt.

Verification

  • morph_tests: 1561 cases, 22972 assertions, all pass (one deliberate
    expected failure, [ERROR] default-sink-coverage). GCC 16.2.1, Release.
  • Mutation-checked, per AGENTS.md. Making deliverLate ignore its executor
    and always run inline fails the late-reply case:
    tests/test_async_registration.cpp:2483: FAILED:
      CHECK( binding->currentId.load() == 0U )
    with expansion:  100 == 0
    tests/test_async_registration.cpp:2484: FAILED:
      REQUIRE( bridgeExec.queued() == 1 )
    with expansion:  0 == 1
    
    Restored, re-run, green. The three cases pin the three halves: a late reply
    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.
  • Gates run locally, all green: 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 -Werror over exactly the touched files.
  • clang-tidy 22.1.8 against a clang-configured compile database: no finding
    on any changed line
    . Three findings remain in bridge.hpp on lines this
    branch does not touch (performance-unnecessary-value-param ×3), which
    clang-tidy-diff does not report and which are present on master.
  • No gate repoint was needed: no check_mutation_survivors.py or
    branch_partial_allowlist.json citation moved.

#572 — re-measured, handed back

The ticket's scope is a number taken on 4017228d; #639, #649 and #654 rewrote
the dispatch path afterwards. The commit adds
tests/bench/bench_dispatch_allocations.cpp (morph_bench_alloc, under the
existing 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} through LocalBackend, 50 warm-up calls excluded, 200
counted:

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

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:

  • "two strings" → one, on a workload whose ids straddle libstdc++'s 15-byte
    SSO threshold; 0–2 in general. The SSO caveat the ticket applies to Part C
    applies to Part A too.
  • "3 std::functions" → two. deserializeResult is captureless and fits the
    small buffer; it does not appear in the census.
  • "two CompletionStates" → confirmed, both still there.
  • Unrecorded by the ticket, and the largest single cluster: four allocations
    and 752 of the 1995 bytes are StrandExecutor rebuilding the model's Strand
    every 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.
  • Consequence: Part A is worth 2–4 and Part B ~6, so A+B land at 11–13 per
    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_alloc is an instrument, not a control: not registered with ctest,
asserts nothing unless --budget=<n> is passed, and both arms of that budget
were exercised (--budget=5 exits 1 and names the overrun, --budget=40 exits
0). It is a separate binary from morph_bench because it replaces the global
operator new/delete, which is process-wide and would perturb the latency
benchmark sharing the binary. Its one warning suppression
(-Wno-mismatched-new-delete, GCC pairing an inlined new with the std::free
inside the replaced operator delete) is scoped to that one target.


Not verified

  • CI has not run. Reporting immediately per the lane's hand-off rule; no
    check results are included below the line.
  • Qt, net, ladder and examples were not built locally (MORPH_BUILD_TESTS +
    LOAD_TESTS only). The Bridge constructor change is source-compatible — a
    defaulted trailing parameter — but that is an inference from the signature, not
    a measurement of those builds.
  • The new bench was run on one toolchain and one platform. libc++ and MSVC have
    different SSO thresholds and std::function buffer sizes, so the census will
    differ there. That is exactly why it has no default ceiling.
  • Windows/macOS behaviour of a global operator new replacement in that target
    is 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 legs
were things that build never ran. Rebased onto 7ab4c7a9 (#657) first; the
branch 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. da9deb52 still says refs #572, not fixes: that ticket stays
open on its re-measurement.

Red leg Cause Commit
Build the ladder's WASM clients four -Wshadow init-captures b64c1e9a
clang-tidy-diff attachHandlerAsync cognitive complexity 28 > 25 194c2d12
Linux / clang-coverage two stale branch-allowlist line hints 7b7e052a

1. -Wshadow — and why the local clang build was also clean

Renamed the four init-captures to registered/bound/failed. The fifth
(bridge.hpp:1208) is left alone: its onFailed is a plain local, not a
structured 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 enclosing x. Checked with a tracked
type 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:

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

This was never a GCC/clang split. A full clang 22.1.8 -Weverything -Werror
build 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, which compiler_options.cmake suppresses tree-wide;
emsdk 3.1.56's clang files the identical construct under plain -Wshadow, which
nothing suppresses. Both legs pass the same -Wno-shadow-uncaptured-local. The
WASM 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:

before: shadows a local variable: 18   shadows a structured binding: 4
after:  shadows a local variable: 18

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 attachHandlerAsync shares with
ensureBoundAsync
rather than lifting one body: both run the same four steps
on an out-of-frame reply (Bridge alive? binding alive? backend still active?
report through onDone), differing only in what a success publishes. That is
now publishLateBindReply's Publish parameter, and two ~30-line bodies — two
copies of the stale-backend reasoning and of its error string — became one.

assignHandlerPrimary is deliberately not routed through it: having no
onDone, it drops a stale reply silently rather than reporting it, so folding
it in would mean a second mode for a helper whose contract is "report exactly
once".

Publish is a template parameter, not a std::function, so the late path
type-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 --version is 22.1.8 and CLANG_VERSION: "22"):

before after
attachHandlerAsync 28 13
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 exact command that reported the 28 now reports nothing on
bridge.hpp, and clang-tidy-diff against the base reports nothing on any
changed line.

Behaviour is unchanged, with one narrow exception stated rather than
glossed:
the shared 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 that handler is unreachable there. Lock
scope, lock ordering and "onDone outside the lock" are identical at both sites.

3. Linux / clang-coverage — what the log actually said

Not 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/core 94.90% against a 94% floor). The entire failure was the
allowlist audit in scripts/check_branch_coverage.py:

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 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: 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 outright — "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, reachable guard.
  2. Read directly: line 1856 is that catch block.
  3. On master, where the gate is green, hint 1679 is an exact match on the first
    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)

  • Does the helper widen any lock scope? No. It takes _attachMtx at the
    same point both inlined bodies did, and releases it before onDone on every
    path that calls onDone at all. The only paths that skip onDone are the two
    that skipped it before (Bridge gone, binding gone).
  • Can publish run after ~Bridge? Same as before: the liveness token is
    checked first and the whole body runs on whatever thread deliverLate chose.
    The helper changed where the code lives, not which thread runs it.
  • Does taking Publish&& and std::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.
  • Do the renamed captures still move rather than copy? Yes — verified above,
    and primaryCopy is still moved into the deferred body, so morph#108's
    OOM-injection case still sees the same allocation shape.

What was measured this pass, and on what

All on 7b7e052a unless stated, x86-64 Linux, clang 22.1.8:

  • clang -Weverything -Werror (the project's own Clang branch) build of
    morph_tests and morph_bench_alloc: zero diagnostics. The bench had
    never been compiled by clang before — the previous pass used GCC and its
    target needs MORPH_BUILD_LOAD_TESTS=ON to exist at all.
  • Suite green: 1561 cases, 22971 assertions, one deliberate expected failure
    ([ERROR] default-sink-coverage).
  • clang-tidy 22.1.8 against a clang compile database: nothing on bridge.hpp;
    clang-tidy-diff against the base finds nothing on any changed line of
    bridge.hpp or the two changed test sources.
  • clang-format --dry-run -Werror over exactly the touched files: clean.
  • Doxygen WARN_AS_ERROR=FAIL_ON_WARNINGS: clean.
  • Gates: 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.
  • The complexity and shadow fixes were each mutation-checked: reverting the
    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

  • No Emscripten toolchain is available here, so the WASM leg itself was
    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.
  • No coverage build was run. The allowlist citations were verified against
    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.
  • Qt, net and the ladder were not built locally.
  • CI has not been waited on; the check counts reported with this push are
    incomplete by construction.
  • ladder: the ledger scenario corpus fails intermittently with "database is locked" — observed once, not reproduced #658 (SQLite database is locked in the ledger scenario corpus) is a known
    flake: an Application ladder failure with that signature is not this branch.

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification

The 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:

  • "two strings" → one here, 0–2 in general (the ids straddle libstdc++'s 15-byte SSO threshold — the caveat the ticket applies to Part C applies to Part A too)
  • "3 std::functions" → two (deserializeResult is captureless)
  • and the largest single cluster is unrecorded: 4 allocations / 752 of 1995 bytes, 38%, from StrandExecutor rebuilding a model's Strand every call.

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 dbe10202 says refs #572, not fixes; #572 is still OPEN; the measurement is on the issue; #660 is filed for the StrandExecutor cluster with no triage: label. That is the right shape for "the ticket is not what it thought it was".

Committing the instrument is the part that compounds. tests/bench/bench_dispatch_allocations.cpp under the existing MORPH_BUILD_LOAD_TESTS, asserting nothing unless --budget=<n> is passed, with both arms exercised (--budget=5 → exit 1, --budget=40 → exit 0). The next person re-measures with one command instead of rebuilding the apparatus — and a bench that asserts nothing by default cannot become a flaky gate.

On #588's deliberate deviation

Verified rather than taken on trust: 16 occurrences of inlineExecutor() remain in bridge.hpp, so the five bindModel/promoteModel sites were genuinely left alone. The stated reason holds — replacing them would make registration asynchronous for every inline-binding backend and deadlock a kCallerMayBlock backend whose dispatching thread is the executor's thread. A ticket's text asking for a change that would deadlock is the case AGENTS.md is about, and departing from it with the reason written down is right.

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 MORPH_LIFETIMEBOUND puts the borrow in the type rather than only in prose.

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 ~Bridge; an executor on an unrelated thread satisfies the type and closes nothing. Saying that in the spec, rather than "the guarantee is now structural", is the difference between a spec that holds and one that reads well.

The finding I most want kept

taking std::function in deliverLate put an allocation on the default (null-executor) path, which broke tests/test_async_registration.cpp's morph#108 OOM-injection case — the injected failure caught the new capture copy instead of the target allocation.

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 primaryCopy rather than copying) makes the default path allocate nothing, is worth more than the feature. It should not be weakened when it next gets in someone's way — and it will.

Not verified by me: the allocation measurements themselves (they need the bench built), the mutation check on deliverLate, and the Qt/net/ladder/WASM builds the lane did not run — source compatibility of the defaulted parameter is by inspection there, not measurement.

Not merged: CI incomplete at hand-off. Worktree pruned.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner, sweep 10:41 — three failures, three different causes

gh pr checks 661: 36 pass, 3 fail, 2 pending. They are not one problem.

1. Build the ladder's WASM clients — four -Wshadow errors, mechanical

include/morph/core/bridge.hpp:2112:38: error: declaration shadows a structured binding [-Werror,-Wshadow]
include/morph/core/bridge.hpp:2118:50: error: declaration shadows a structured binding [-Werror,-Wshadow]
include/morph/core/bridge.hpp:2297:50: error: declaration shadows a structured binding [-Werror,-Wshadow]
include/morph/core/bridge.hpp:2303:50: error: declaration shadows a structured binding [-Werror,-Wshadow]

Exactly four, all the new init-captures — [onRegistered = std::move(onRegistered), newId] and [onFailed = std::move(onFailed), failure] — shadowing the structured binding they are initialised from. The WASM leg compiles with -Weverything -Werror; GCC does not diagnose this, which is why a GCC-only local build reported clean.

2. clang-tidy-diff — a threshold this branch crossed, not a style nit

From the run's artifact (the run itself is stalled, so the log is unreachable):

include/morph/core/bridge.hpp:776:10: error: function 'attachHandlerAsync' has
    cognitive complexity of 28 (threshold 25)
    [readability-function-cognitive-complexity,-warnings-as-errors]

This is not fixable by renaming anything. attachHandlerAsync was under the threshold before and the executor branch pushed it over. #615 hit the identical wall in this file and the answer there was extraction — rebindThroughSurface / stageRebinds / rollbackStaged / commitRebinds — so there is a precedent in the same function family.

3. Linux / clang-coveragecause unknown, and I am not guessing

It failed after 40m57s, which means it got a long way in, so it is unlikely to be the compile errors above. The run holding it has been in_progress with no update for ~50 minutes, its log is unreachable, and it published no coverage-report artifact — only clang-tidy-report and clang-format-report. So I have nothing to read.

The leading hypothesis, stated as a hypothesis: this branch adds tests/bench/bench_dispatch_allocations.cpp and a new executor branch in bridge.hpp, and the lane's own notes say it ran no coverage build. A new partial line or a moved allowlist citation would fail this leg without touching any other. That is a guess. It must be checked against the log, not assumed.

What I am not doing

I am not pushing the four renames now. Two of the three failures are diagnosed and one is not, and ci.yml sets cancel-in-progress: true — a push cancels the stalled run and purges whatever the coverage leg would have told us. That trade cost this session nothing on #654 and gained the actual diagnosis; the same reasoning applies.

Dispatching a lane whose whole batch is this PR, with all three findings and the instruction to get the coverage log before touching it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

Yaraslaut and others added 5 commits September 21, 2026 10:43
…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
@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification — and my dispatch was wrong about the cause

I 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. cmake/compiler_options.cmake:

161:  -Wno-shadow-uncaptured-local    # lambda param shadowing an uncaptured local
233:  -Wshadow

The project enables -Wshadow and suppresses -Wshadow-uncaptured-local tree-wide. clang 22 files this construct under the suppressed flag; emsdk 3.1.56's older clang files the identical construct under plain -Wshadow. So a full local clang -Weverything -Werror build is also silent, and building with clang — which is what I told the lane would have caught it — would not have.

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:

#588 commit content identical after rebase (f0f4be90 vs 69a697db): True
#572 commit content identical after rebase (da9deb52 vs dbe10202): True

and da9deb52 still says refs #572, with #572 still OPEN — the hand-back is intact, which was the thing most at risk in a five-commit rebase.

On the coverage log — my hypothesis was also wrong

I guessed new partial lines from the bench file or the executor branch. It was neither: 2926 tests passed, no floor breached (include/morph/core 94.90% against a 94% floor), and the entire failure was the allowlist audit — two stale line hints in bridge.hpp, one of them ambiguous across three occurrences. I labelled that a guess when I wrote it, which is the only reason it did no harm.

Two things the lane did there are worth keeping:

  • It resolved the ambiguous citation by three independent checks that agree — the entry's own reason naming the catch block and explicitly disclaiming the other two candidates; the line at 1856 being that catch; and hint 1679 matching the first occurrence on master where the gate is green. That is how a deliberately-ambiguous entry is supposed to be resolved.
  • It then stated the limit of its own evidence: "the resolver accepts any hint matching any occurrence, so 'all 22 resolve cleanly' is not evidence the right one was picked." A green gate that cannot distinguish right from plausible is worth saying out loud.

On the complexity fix

Extraction, not a NOLINT and not a threshold change — and the split was chosen to cover ensureBoundAsync (20, five under the gate and next in line) as well as attachHandlerAsync (28 → 13). Excluding assignHandlerPrimary with a stated reason (no onDone, drops stale replies silently, would need a second mode) is the right kind of restraint.

The behaviour note is the part I would have wanted flagged and was: ensureBoundAsync's atomic currentId store now sits inside the helper's try/catch, and an integral store is noexcept, so that handler is unreachable there. Stated rather than glossed.

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 -Wshadow fix rests on a local proxy with the suppression removed (before: 18 local + 4 structured-binding; after: 18 + 0).

Not merged: CI incomplete at hand-off. Worktree pruned.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner, 11:09 — the four new red legs look like an upstream outage, not this branch

After the fix push: 18 pass, 4 fail, 19 pending. The four are Build documentation, Linux / gcc-release, Valgrind memcheck, find_package(morph CONFIG) consumability — all of them different from the three that were red before, and all failing at ~5 minutes, which is the shape of a setup step rather than a build or a test.

Build documentation lives in its own workflow, so its log is readable while the ci.yml run is still going, and it says:

E: Failed to fetch https://ppa.launchpadcontent.net/ubuntu-toolchain-r/test/ubuntu/pool/main/g/gcc-16/libgcc-s1_16-…_amd64.deb   503  Service Unavailable
E: Failed to fetch …/gcc-15/cpp-15-x86-64-linux-gnu_15.2.0-…_amd64.deb                                                          503  Service Unavailable
E: Failed to fetch …/gcc-16/libubsan1_16-…_amd64.deb                                                                            503  Service Unavailable
##[error]Process completed with exit code 100

The ubuntu-toolchain-r/test PPA is returning 503. Nothing to do with the code.

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 SQLite database is locked. Neither is a defect in any branch, and both cost a diagnosis cycle on an unrelated PR. If a third appears it is worth asking whether the ladder and toolchain legs should distinguish "the environment failed" from "the change failed" in their exit status, rather than every consumer re-deriving it from a log.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@codecov

codecov Bot commented Sep 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.50000% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
include/morph/core/bridge.hpp 92.50% 4 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut merged commit 7a343e6 into master Sep 21, 2026
78 of 82 checks passed
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…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
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant