core/ladder: retire IBackend's four async twins and move the examples' prose onto the structural surface (fixes #570, fixes #571) - #649
Conversation
Runner verification — the conclusion holds, the stated inventory does notThe claim this branch rests on: "Since #568, no backend in the tree overrode any of the four twins … the only overriders left were test doubles in The inventory is incomplete. A shipped type overrides all three twins:
But the conclusion survives, because the override is a pass-through: bool registerModelAsync(…) override {
return _inner->registerModelAsync(typeId, std::move(factory), contextKey,
std::move(onRegistered), std::move(onError));
}It returns whatever the wrapped backend returned, so it cannot make the twin branch run unless something beneath it returns #586/#587 and #585 had already moved both onto the structural surface. So every twin call in production reached the base default and returned Worth restating precisely, because the difference will matter to the next reader: the correct statement is not "nothing overrides the twins" but "nothing implements them — the one production override forwards, and no forwardee implements them either." A forwarder in the inventory is not a counterexample, but leaving it out makes the claim look falsifiable by a one-line grep that anybody will run. The Accepting both judgement calls. Keeping the removed verbs named in Not merged: CI incomplete at hand-off. The legs to watch are the two the lane could not run locally — 🤖 Generated with Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Runner, landing sweep 02:41: this PR is red on |
Runner: diagnosed — the red is not in this PR's codeThe run has been stalled for over an hour ( Every finding is a The two That TU deliberately omits Why it appeared now: this PR edits all six of those files, and every edit is prose. For example: -// Bridge::registerHandler(binding) (registerModelWithContext has no async
-// path for LocalBackend -- see IBackend::registerModelAsync's doc comment),
+// Bridge::registerHandler(binding) (LocalBackend does not override bindModel,
+// so IBackend's default runs registerModelWithContext inline -- see
+// IBackend::bindModel's doc comment), so the factory must succeedA changed comment line is a changed line, so This is the third instance of one structural problem, and it is worth naming as such rather than fixing three times: #624 (the two AUTOMOC self-including sources), #650 ( Also one real finding, unrelated to the above and not to be lost in it: Dispatching a lane whose whole batch is this PR, with this artifact. It also needs a rebase independently: #647 landed and changed this leg, which is a gate delta. 🤖 Generated with Claude Code |
…ed async twins (fixes #570) The ticket is scoped to "move the example GUIs and the WASM spike onto the structural registration surface". They were already on it: nothing under `examples/` ever called `registerModelAsync`, `registerModelSharedAsync`, `attachModelAsync` or `assignPrimaryAsync` — application code reaches a backend only through `Bridge`, and morph#568 moved `Bridge`'s four dispatch sites. Verified rather than assumed: every one of the 25 hits under `examples/` on `c55ea5b7` was a `//` comment, a `///` doc comment, a CMake comment or a line of Markdown. So what this commit moves is the prose, which named verbs that morph#571 deletes and that nothing had implemented since morph#568. - The GUI clients and `AppContext` now name `QtWebSocketBackend::bindModel()` as what queues a private bind issued before the socket connects. The behaviour they describe is unchanged and still true: `bindModel` queues a request with an empty `primary` and sends it on the next `connected`, and rejects a keyed one with `"disconnected"` (`src/qt/qt_websocket_backend.cpp`, the `!_connected` arms of `bindModel`). - `AppContext`'s readiness contract gains the half it was missing: with `asyncRegistrationEnabled` the backend answers `BindWait::kCallerMustNotBlock`, so `registerHandler` hands back an *unbound* handler and a caller must gate on `whenBound()`/`isBound()`. That was true before this commit too; the doc comment only described the queue. - `examples/polls/README.md`'s struck `assignPrimaryAsync` claim keeps its correction but states it against `IBackend::promoteModel`, which no backend can decline — a stronger version of the same rebuttal. - `LADDER.md`, `TESTING.md` and `polls/README.md` keep their "shipped" history: each says what shipped then and that morph#567–morph#571 replaced it, rather than deleting the record. `examples/common/testkit/test_wasm_registration_path_native.cpp` still pins the single-threaded registration path through `Bridge` — only its comment changed, and its citation of `tests/qt/test_qt_websocket.cpp` now names that file's current test ("bindModel called before the socket connects queues and retries once connected fires"). Verification: `cmake --build build` green with `-DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_QT=ON -DMORPH_BUILD_NET=ON`; `examples/common/ladder_common_tests` 587 assertions in 153 cases, all passing. **Not verified: anything WASM.** No Emscripten toolchain was available, so neither `morph_ladder_wasm_spike` nor any `*_gui_wasm` target was configured, let alone built or run — those targets exist only in an Emscripten configure. The acceptance criterion "the WASM ladder clients build, and the WASM demo deploys" is therefore unmet by local measurement and rests entirely on the `wasm-ladder` and `wasm-demo` CI legs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ding contract (fixes #571) `registerModelAsync`, `registerModelSharedAsync`, `attachModelAsync` and `assignPrimaryAsync` are gone from `IBackend` and from `SynchronousBackendAdapter`, and `Bridge`'s four "offer the twin first, fall back to `bindModel`" branches are now one unconditional dispatch each. $ grep -rn 'registerModelAsync\|registerModelSharedAsync\|attachModelAsync\|assignPrimaryAsync' include src tests examples $ echo $? 1 **`IBackend`'s virtual count, and #522's prediction.** Measured on `origin/master` (`c55ea5b7`) and on this commit, excluding the destructor: | | virtuals | pure | defaulted | |---|---|---|---| | before | 21 | 5 | 16 | | after | 17 | 5 | 12 | #522 says "18 virtuals with 14 defaulted" and predicts the interface "roughly halves". **The prediction was wrong on both counts.** The count it quotes was already stale when it was written (there were 21, not 18, once `bindModel`/`promoteModel`/`bindWaitPolicy` landed), and removing four of twenty-one is a 19% reduction, not a halving. What the set actually delivered is the thing #522 is about — there is now exactly one acquire surface and one promote surface, and no call site carries a second path — but the arithmetic in the ticket should not be quoted as if it came true. **What the twins carried that the surface does not.** One thing, named rather than left to be found. A `bool` twin handed `Bridge` two raw `std::function`s, so a backend that violated "exactly one callback" by firing twice reached `detail::parkIfInFrame`'s own double-claim guard. A `Completion` cannot be settled twice — `CompletionState` drops the second settle before any `Bridge` code sees it — so `DoubleFiringBackend` in `tests/test_async_registration.cpp` now pins the observable contract ("exactly one `onDone`") while that guard is no longer reachable *from a backend*. The guard is kept because `parkIfInFrame` is also called from the dispatching frame. This is recorded in the double's own comment and in `docs/spec/core/backend.md`. **The prose threading contract is relocated, not deleted.** The `@note` block shared by the four twins asked every backend author to deliver `onRegistered`/`onError` from a thread on which `~Bridge` could not run concurrently — morph#486's use-after-free. That reasoning is still true and now lives in `docs/spec/core/backend.md`, "What was wrong with the old shape" (what the contract was, and why `registerHandlerImpl` was the one site that did not depend on it) and "How the threading contract becomes structural" (what replaced it), plus `docs/spec/concurrency_and_lifetimes.md`'s bind/promote-continuation bullet. Per the runner decision recorded on #571, the claim is stated narrowly in all three places: the guarantee is structural **for backends**, and `Bridge`'s four dispatch sites still name `exec::detail::inlineExecutor()`, which reproduces the old delivery thread exactly. **The morph#486 window is unchanged, not closed.** Closing it means giving `Bridge` an executor of its own, which is morph#588 and is not in this commit. **The ticket's "Also in scope" list was stale, and is not acted on.** It asks for `LocalBackend`, `SimulatedRemoteBackend` and "the 11 test doubles" across `tests/test_switch_backend.cpp`, `tests/test_bridge_lifetime.cpp` and `tests/test_client_execute_deadline.cpp` to be migrated, "candidates for `SynchronousBackendAdapter` rather than hand-editing". Checked: none of those three files contains a single reference to any of the four verbs, and neither does `LocalBackend` or `SimulatedRemoteBackend`. They need no migration — `IBackend`'s default `bindModel` runs `bindModelBlocking`, which dispatches to exactly the synchronous verb each request shape names, so they behave identically through the surface. Wrapping them in `SynchronousBackendAdapter` would be a *regression*: the adapter answers `kCallerMustNotBlock`, which would turn every `registerHandler` against a `LocalBackend` into an unbound handler. The only doubles that did override the twins are the ones in `tests/test_async_registration.cpp`, and they are migrated here. **Migrated doubles** (`tests/test_async_registration.cpp`): `AsyncRegisterBackend` now overrides `bindModel` (one verb for all three acquire shapes, deferred into the same `completeNext()`/`failNext()` queue) and answers `BindWait::kCallerMustNotBlock`, which is what reproduces "dispatch and return without waiting" — the observable behaviour the `true` return used to produce. `InlineCompletingBackend`, `ThrowingDispatchBackend`, `DoubleFiringBackend` and `AsyncBackendShim` follow; `AsyncAssignPrimaryBackend` and `SelfFiringAssignPrimaryBackend` move to `promoteModel`. `ThrowingDispatchBackend` tells its two arms apart by `request.primary` rather than `request.current`: `attachHandlerAsync`'s *first* attach carries a zero `current`, so `current` would have put both tests on the same arm — a real detail of the surface the twins hid, found by the test failing. `tests/test_backend_registration_surface.cpp`'s `RecordingBackend` drops the four twins and the four forwarding assertions with them; the adapter's forwarding test still pins every synchronous verb. **What this does and does not establish.** The suite passes, and for a deletion that proves only that nothing referenced the deleted thing. Beyond compilation: every test that previously drove the twin branch now drives the `bindModel` branch with the same double and asserts the same outcomes, including the inline settle, the inline failure, the synchronously-throwing dispatch, the double-settle, the three staleness guards and the two ~Bridge/~binding teardown races — so the migration is checked by tests that were written against the twins' behaviour, not by new ones written against the replacement. The one case where that is *not* true is `parkIfInFrame`'s double-claim guard, named above. No behavioural claim beyond that is made. Verification, on this commit, Linux/GCC 16.2.1, Debug: tests/morph_tests 22931 assertions, 1556 cases (1 failed as expected) tests/qt/morph_qt_tests 578 assertions, 79 cases tests/net/morph_net_tests 1112 assertions, 191 cases tests/net_qt_interop/..._tests 9 assertions, 2 cases examples/common/ladder_common_tests 587 assertions, 153 cases $ bash scripts/check_spec_citations.sh Prose lint OK: every pinned fact is still cited; no banned terminology found; ... $ bash scripts/test_check_spec_citations.sh scripts/check_spec_citations.sh detects every section-citation and forms-vocabulary drift it claims to. Not verified: any WASM configuration (no Emscripten toolchain), clang-tidy, and any sanitizer build. `docs/spec/core/backend.md`'s "Asynchronous registration" section is replaced by "Why registration needs a non-blocking path", which keeps the live content (the nested-`QEventLoop`/WASM rationale, the `asyncRegistrationEnabled` gate, the pre-connect queue, and what an unbound handler means) and hands the history to "What was wrong with the old shape". Every citation of the old heading is repointed — `locality.md`, `bridge.md`, `shared_instances.md`, `include/morph/core/bridge.hpp` (three sites, to `bridge.md`'s "Registration readiness" or `backend.md`'s "Waiting for a bind"), and three `examples/` files. `scripts/test_check_spec_citations.sh`'s rename fixture is repointed to the new heading, and its self-test still catches all sixteen drifts it claims to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…moved
Mechanical follow-up to the two commits above, kept separate so that neither
ticket's diff contains a number nobody read the code for. No behaviour change.
`scripts/mutation_survivors.json` — three `backend.hpp` citations. The gate
reported one moved line and two "appears 2 times … not decidable", exactly as
those two entries' own `reason` text predicted it would:
include/morph/core/backend.hpp:1412 has moved to line 1138. ...
include/morph/core/backend.hpp:1305 is allowlisted by a source line that appears 2 times (lines [1031, 1050]), and none of them is 1305, so which one is meant is not decidable. ...
include/morph/core/backend.hpp:1469 is allowlisted by a source line that appears 2 times (lines [1195, 1240]), and none of them is 1469, so which one is meant is not decidable. ...
Each ambiguity was resolved by reading both candidates, not by taking the
first:
- `registerCount` → **1031**, inside `LocalBackend::registerModel`. The other
candidate, 1050, is the `registerModelShared` arm, which the entry's own
reason names as the one it is *not*.
- `executeInFlight` → **1195**, the statement immediately after
`inFlightCounter->fetch_add(...)`. The other candidate, 1240, follows
`fetch_sub` inside the posted task; the entry's reason says "the increment
side of an execute".
- `aware.reserve(...)` → 1138, unambiguous.
`scripts/branch_partial_allowlist.json` — three entries, same shape:
`backend.hpp:1414 → 1140`, `bridge.hpp:1551 → 1540`, and
`bridge.hpp:1673 → 1662`. The last is the ambiguous one: three textually
identical `if (deadlineHandle && schedulerRef) {` guards exist, now at 1662,
1686 and 1775. 1662 is the one in `executeVia`'s `catch (...)` block, which is
what that entry's reason describes ("specifically the exception-path use of
the guard"); the other two are the `.then()`/`.onError()` continuations the
same reason explicitly excludes, and its parenthetical naming their old lines
is updated with them.
One further hit was **not** edited: `mutation_survivors.json`'s
`classification_2026_09_09` sample citing `bridge.hpp:584` and
`started = backend->attachModelAsync(...)`. That is a dated record of what a
sampling run measured on that revision, not a description of the current tree,
and rewriting it to match today's code would destroy the measurement. A `note`
saying so is added beside the verdict instead. It carries no verbatim `source`
and so is not audited by the gate either way.
Verification:
$ python3 scripts/check_mutation_survivors.py
ok: 15 structured citation(s) in scripts/mutation_survivors.json resolve to the line they name.
note: 20 further citation(s) in this file are free text inside prose
strings, which carry no verbatim `source` and so are NOT audited here (morph#613).
`scripts/check_branch_coverage.py` and `scripts/check_error_path_coverage.py`
both need `build/clang-coverage/coverage.lcov`, which needs a full
`scripts/coverage.sh` run and was not produced here. Their line-hint half was
audited directly instead, by calling
`check_branch_coverage.resolve_allowlist_source_line` over every entry of both
allowlists: 0 failures for each. **Their coverage half — whether each
allowlisted arm is still partial — is therefore not verified locally and rests
on the CI coverage leg.**
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…xception out of its destructor
`~SelfFiringAssignPrimaryBackend` resolves its one still-pending
`ModelCompletion` as a last act of teardown, which is the only way to reach
`assignHandlerPrimary`'s `!pinned` arm. Settling is not a non-throwing
operation, and a destructor is implicitly `noexcept`, so an escape there is
`std::terminate` with no attribution rather than a failed assertion.
Reproduced, not inferred -- clang-tidy 22.1.8 (CI's pinned major) driven by
`clang-tidy-diff.py` with this job's own configure and flags, on the branch
before this commit:
tests/test_async_registration.cpp:582:5: error: an exception may be thrown
in function '~SelfFiringAssignPrimaryBackend' which should not throw
exceptions [bugprone-exception-escape,-warnings-as-errors]
note: frame #0: unhandled exception of type 'bad_weak_ptr' may be thrown
in function '__throw_bad_weak_ptr' here
note: frame #5: function 'setValue' calls function 'shared_from_this'
here include/morph/core/completion.hpp:108
note: frame #6: function 'resolve' calls function 'setValue' here
note: frame #7: function '~SelfFiringAssignPrimaryBackend' calls
function 'resolve' here
The named `bad_weak_ptr` is a static over-approximation: the `Promise` holds a
`shared_ptr` to the state, so `shared_from_this()` cannot fail here. The
destructor's obligation is not, because `setValue` also runs the continuation
`Bridge::assignHandlerPrimary` attached, inline, on this thread -- that is
ordinary caller code and nothing makes it non-throwing.
So the body is wrapped in `try`/`catch (...)`, and the catch *records* into an
`std::exception_ptr` the test owns and checks after the backend is gone. A
`NOLINT` would have kept the terminate; a bare `catch (...) {}` would have
turned it into silence. The new `CHECK` is what keeps the arm from being a
swallow.
Measured after: the same clang-tidy run reports nothing in this file's changed
lines, and re-introducing the unguarded destructor makes it report the finding
again at the moved line (609). `morph_tests`: 22932 assertions in 1556 cases,
1 failed as expected -- the branch's baseline plus this commit's one `CHECK`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…er builds Third instance of one structural problem, after #624 (a build-time generated header) and #650 (tests/lint/ text fixtures): `clang-tidy-diff.py` analyses every changed C/C++ line, a changed *comment* line is a changed line, and clang tooling does not skip a file it has no compile command for -- it interpolates a neighbouring entry's command. What comes back is a `clang-diagnostic-error` about this job's configure, not a finding about the diff, and `WarningsAsErrors: "*"` makes it a failed job. Reproduced on this branch with the `clang-tidy` job's own configure flags and clang-tidy 22.1.8: **21 `clang-diagnostic-error`s, all from five changed sources absent from `compile_commands.json`**, none of them naming a changed line's defect: examples/bookmarks/gui_wasm/main_wasm.cpp:58:10: error: 'QGuiApplication' file not found examples/common/wasm_spike/main_wasm.cpp:47:29: error: expected ')' examples/pastebin/gui_wasm/main_wasm.cpp:50:10: error: 'QGuiApplication' file not found examples/polls/gui_wasm/main_wasm.cpp:178:10: error: 'emscripten/emscripten.h' file not found include/morph/core/bridge.hpp:1610:43: error: no matching member function for call to 'into' include/morph/core/bridge.hpp:2667:37: error: no matching member function for call to 'executeVia' (+ 15 more in registry.hpp/model.hpp, all attributed upward from the same TU) The four WASM mains need an Emscripten/Qt-WASM toolchain this job does not configure. The `bridge.hpp`/`registry.hpp`/`model.hpp` errors are not those headers': the note chain resolves every one to `tests/compile_checks/client_only_facade_no_model_header.cpp`, which is built by a configure-time `try_run()` with `-DMORPH_CLIENT_ONLY` and *deliberately* omits `model.hpp`, so its model type is incomplete by design. Reverting the comment edits is not a general answer, and here was not even a local one. Measured: the four WASM comments cite a `docs/spec/core/backend.md` section that the same branch renames, so leaving them at their old text turns the spec-citation gate red instead -- ::error file=examples/bookmarks/gui_wasm/main_wasm.cpp,line=88::dangling section citation: docs/spec/core/backend.md has no section "Asynchronous registration" ::error file=examples/pastebin/gui_wasm/main_wasm.cpp,line=78::dangling section citation: ... ::error file=examples/polls/gui_wasm/main_wasm.cpp,line=254::dangling section citation: ... (x2) So the diff is filtered before `clang-tidy-diff.py` sees it. A changed *source* with no entry in `compile_commands.json` is dropped and named in the log with a `::warning::`; a changed *header* is never dropped, because a header is never a translation unit and discarding headers is what `-only-check-in-db` does -- #479's own defect one directory over. Sections are kept or dropped whole, never hunk by hunk: `clang-tidy-diff.py` attributes `@@` lines to the last `+++` it saw. The filter is only as honest as the database it consults, so it refuses to run unless that database is still the wide one the Configure step builds: at least 600 in-workspace sources and at least 200 under `examples/`. Counting only paths that resolve *inside* the workspace means a resolution mismatch trips the floor too, rather than silently skipping everything. Measured here: 703 entries naming 695 distinct in-workspace sources, 270 under `examples/`; CI measured 690/276 at #481's revision. The regression the floors exist to catch -- `MORPH_BUILD_LADDER` back to its OFF default -- takes `examples/` to 16. Verified, not asserted: * with the filter, `clang-tidy-diff.py` over this branch's diff exits 0 and reports nothing; 26 of 31 changed file sections analysed, 5 skipped and each named; * the filtered gate can still fail: re-introducing the unguarded `~SelfFiringAssignPrimaryBackend` makes it exit 1 with `bugprone-exception-escape` on the changed line; * the Python embedded in the workflow was extracted back out of the parsed YAML and produces a byte-identical filtered diff to the version tested standalone; * `check_workflow_job_banners.py`, `check_ci_clang_pin.sh` and `check_workflow_option_coverage.py` all pass on the edited file. Not verified: no Emscripten toolchain was available, so nothing was built for the four WASM mains -- the claim is only that this job cannot analyse them, which is what their absence from `compile_commands.json` shows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
50a8569 to
3c6dd24
Compare
Runner verification — the gate change, checked by driving it, not by reading itThis PR now changes It keeps what it must keep. A diff touching three files, against a database clearing both floors: The header is kept although nothing in the database names it, which is the property that separates this from The floor trips in both directions, and the second floor is not redundant: That second case is the one I most wanted to see fail: a configure that still looks big while Accepting the forced choice on its evidence. The cheap option — revert the comment edits — was not available, and the lane established that rather than asserting it: the four WASM comments cite a The residual, stated plainly because a filter's cost is what it stops seeing: a source that drops out of the database without breaching either floor is skipped with only a warning. #651 quantifies that at 60 of 470 tracked C/C++ sources, and usefully splits it by cause — 11 only because this job does not pass On the real finding: keeping Not merged: CI incomplete at hand-off. Next sweep counts the checks — and the check to watch is 🤖 Generated with Claude Code |
…uding TUs, and drop a deleted workflow from a docstring (fixes #646, fixes #643) (#653) * testkit/qt: clear the 84 clang-tidy findings the two AUTOMOC self-including TUs now report (fixes #646) #647 made the clang-tidy job build `ladder_common_tests_autogen` and `morph_forms_qml_tests_autogen`, so `examples/common/testkit/test_qml_surface.cpp` and `src/qt/forms/tests/tst_main.cpp` parse for the first time and are analysed. Nothing was broken -- clang-tidy-diff reports only changed lines -- but the first PR to touch one of those lines would have inherited findings that were not its own. Re-measured on 0067b5b with the clang-tidy job's own configure flags and clang-tidy 22.1.8 (CI's pinned major), both AUTOMOC targets built first: 81 findings in test_qml_surface.cpp (73 misc-const-correctness, 3 readability-convert-member-functions-to-static, 2 readability-inconsistent-declaration-parameter-name, 2 readability-identifier-length, 1 bugprone-easily-swappable-parameters) and 3 in tst_main.cpp. That reproduces the #647 lane's figure at c55ea5b exactly. 77 of the 84 are fixed rather than suppressed: * 73 `misc-const-correctness` -- local `QTemporaryDir` and fixture-bridge declarations that are never mutated. Applied with clang-tidy --fix, then rewritten to the west-const spelling the rest of the file uses. * 2 `readability-identifier-length` -- `id` -> `rowId`, `ok` -> `okay`. Safe: QmlSurfaceAudit reads `QMetaMethod::name()` and `parameterCount()` and never a parameter name, and the QML fixture text is unchanged. * 1 `misc-use-internal-linkage` -- `MorphFormsQmlTestSetup` moves into an anonymous namespace; QUICK_TEST_MAIN_WITH_SETUP expands in the same TU. * 1 `readability-redundant-access-specifiers` -- the explicitly defaulted default constructor and its `public:` are removed, which also removes the redundancy, since Q_OBJECT ends in `private:`. The remaining 7 get individually reasoned NOLINTNEXTLINEs, reason above the directive (#631/#627's rule) -- no NOLINT sweep and no new `.clang-tidy` entry, which is what #632 was about: * 4 `readability-convert-member-functions-to-static` on Q_PROPERTY readers, a Q_INVOKABLE and a Qt Quick Test setup slot. The reason is shape, not legality: the static form was measured to compile and moc registers the same property, but no bridge these fixtures stand in for has a static property reader, and a Qt slot is a member function by definition. * 2 `readability-inconsistent-declaration-parameter-name` on the two signals. moc's generated definitions name the parameters `_t1`/`_t2`, so no edit to the declarations can remove the mismatch; the finding reaches these files only because the classes are declared in a .cpp. * 1 `bugprone-easily-swappable-parameters` on the file-local `writeQml` helper's two adjacent `const QString&`. Verified, not asserted: * Both files are in this configure's compile_commands.json (703 entries, 695 in-workspace, 270 under examples/ -- above #649's 600/200 floors). * clang-tidy exits 0 on both files afterwards, and with every NOLINTNEXTLINE line stripped it exits 1 reporting exactly the 6 + 1 suppressed findings again. Each directive is load-bearing and the TUs are really analysed, rather than clean because nothing looked at them. * `ladder_common_tests "[qml-surface]"`: 182 assertions in 36 test cases, all passing. * `morph_forms_qml_tests -input src/qt/forms/tests`: 292 passed, 0 failed, the two corpus-reading suites among them -- which is what proves the setup slot still runs after the anonymous-namespace move. * clang-format 22.1.8 clean; check_nolint_directives.sh, check_bidi_controls.py, check_tidy_suppression_scope.sh, check_automoc_includes.sh, check_catch_test_names.sh all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW * ci: drop the deleted suppression-guard.yml from the banner gate's docstring (fixes #643) #635 deleted .github/workflows/suppression-guard.yml; the checker's "## Scope" paragraph still named it as one of the single-job workflows the gate skips. It was the last reference to that file in the tree: $ git grep -n "suppression-guard" -- . scripts/check_workflow_job_banners.py:50:suppression-guard.yml and the two wasm workflows are single-job files that have Gate behaviour was never affected and is not affected now -- the skip is derived per file, not read from that list. Measured on 0067b5b, over the five workflows with zero banners: ok: .github/workflows/docs.yml: no section banners, not in the banner style ok: .github/workflows/mutation.yml: no section banners, not in the banner style ok: .github/workflows/spec-sync.yml: no section banners, not in the banner style ok: .github/workflows/wasm-demo.yml: no section banners, not in the banner style ok: .github/workflows/wasm-ladder.yml: no section banners, not in the banner style ok: all 24 section banner(s) introduce the job they describe So the name is deleted rather than swapped for another: with it gone the sentence enumerates exactly the five files a run reports as skipped, and there is no sixth current example to put in its place. A second paragraph says so explicitly -- the list is an illustration with a shelf life, nothing reads it, and the run's own output is the current list -- so the next workflow deletion dates one sentence instead of producing a third round of this. Verified: `python3 scripts/check_workflow_job_banners.py .` and `bash scripts/test_check_workflow_job_banners.sh` both pass; the five skipped files and their single-job counts were enumerated from the tree with the checker's own BANNER_RE/JOB_KEY_RE rather than read off the docstring. 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>
The tail of the #522 registration-surface chain: #570 then #571, in that order, because the second cannot land until the first has moved the last consumers. Three commits — one per ticket, plus a separate, clearly-labelled one for the two line-hint gates.
Closes #570. Closes #571. Closes #522 (see the caveat under "What this does not claim").
1 — #570: the examples were already on the surface; the prose was not
6cdff61dThe ticket reads as a code migration. It is not one, and that is worth stating plainly rather than quietly delivering a documentation change against a ticket that asked for something else.
Nothing under
examples/has ever calledregisterModelAsync,registerModelSharedAsync,attachModelAsyncorassignPrimaryAsync. Application code reaches a backend only throughBridge, and #568 movedBridge's four dispatch sites ontobindModel/promoteModel. Checked rather than assumed: all 25 hits underexamples/onc55ea5b7were comments —//,///, a CMake#, or Markdown.So this commit moves prose that named verbs nothing had implemented since #568 and that commit 2 deletes:
AppContextnow nameQtWebSocketBackend::bindModel()as what queues a private bind issued before the socket connects. The described behaviour is unchanged and still true (src/qt/qt_websocket_backend.cpp, the!_connectedarms: a request with an emptyprimaryis queued and sent on the nextconnected; one with a non-emptyprimaryis rejected with"disconnected").AppContext's readiness contract gains the half it was missing. WithasyncRegistrationEnabledthe backend answerskCallerMustNotBlock, soregisterHandlerhands back an unbound handler and the caller must gate onwhenBound()/isBound(). That was already true; the doc comment described only the queue.polls/README.md's struckassignPrimaryAsyncclaim keeps its correction but states it againstIBackend::promoteModel, which no backend can decline — a stronger version of the same rebuttal.LADDER.md,TESTING.mdandpolls/README.mdkeep their "shipped" history: each says what shipped then and that core: give IBackend a registration surface whose threading contract is structural, not prose #567–core: remove IBackend's four async twins and retire the prose threading contract #571 replaced it, rather than deleting the record.examples/common/testkit/test_wasm_registration_path_native.cppstill pins the single-threaded registration path throughBridge— only its comment moved, and its citation oftests/qt/test_qt_websocket.cppnow names that file's current test name.2 — #571: the four twins, and the prose contract they carried
085b401bRemoved from
IBackendand fromSynchronousBackendAdapter's forwarding block;Bridge's four "offer the twin, fall back tobindModel" branches are now one unconditional dispatch each.Virtual count — and #522's prediction, which was wrong
struct IBackend, excluding the destructor:c55ea5b7)085b401b)#522 says "18 virtuals with 14 defaulted" and predicts the interface "roughly halves". The baseline was already stale (there were 21 once
bindModel/promoteModel/bindWaitPolicylanded), and 21 → 17 is a 19% reduction, not a halving. It could never have been one: the four twins were the only things this set removes, and the synchronous verbs they sat beside all remain, becausebindModelBlockingdispatches to them by request shape — which is what lets an un-migrated backend keep working. Full before/after name lists are on #522.What the twins carried that the surface does not
One thing, named rather than left to be found. A
booltwin handedBridgetwo rawstd::functions, so a backend firing twice reacheddetail::parkIfInFrame's double-claim guard. ACompletioncannot be settled twice —CompletionStatedrops the second settle before anyBridgecode sees it — soDoubleFiringBackendnow pins the observable contract ("exactly oneonDone") while that guard is no longer reachable from a backend. Filed as #648, with the smaller sibling case (thecatch (...)around the dispatch, now reachable in-tree only from a test double).The prose threading contract is relocated, not deleted
The
@noteshared by the four twins asked every backend author to deliver from a thread on which~Bridgecould not run concurrently — #486's use-after-free. That reasoning now lives indocs/spec/core/backend.mdunder "What was wrong with the old shape" (what the contract was, and whyregisterHandlerImplwas the one site that did not depend on it) and "How the threading contract becomes structural" (what replaced it), plusdocs/spec/concurrency_and_lifetimes.md's continuation bullet.Per the runner decision recorded on #571, the claim is stated narrowly in all three places and in
bridge.hpp's call-site comments: the guarantee is structural for backends;Bridge's four dispatch sites still nameexec::detail::inlineExecutor(), which reproduces the old delivery thread exactly, so #486's window is unchanged, not closed. Closing it is #588 and is not here.The ticket's "Also in scope" list is stale, and is not acted on
It asks for
LocalBackend,SimulatedRemoteBackendand "the 11 test doubles" intest_switch_backend.cpp,test_bridge_lifetime.cppandtest_client_execute_deadline.cppto be wrapped inSynchronousBackendAdapter. Measured onc55ea5b7:None of them — nor
LocalBackend, norSimulatedRemoteBackend— overrides a twin, so none needs migrating:bindModel's default already routes them. Wrapping them would be a regression: the adapter answerskCallerMustNotBlockunconditionally, which would makeregisterHandlerreturn an unbound handler to every in-process embedder — the exact regression #593 undid forSocketBackend. Full reasoning on #571.Doubles that were migrated
tests/test_async_registration.cpp.AsyncRegisterBackendoverridesbindModel(one verb, all three acquire shapes, deferred into the samecompleteNext()/failNext()queue) and answerskCallerMustNotBlock, which is what reproduces "dispatch and return without waiting".InlineCompletingBackend,ThrowingDispatchBackend,DoubleFiringBackendandAsyncBackendShimfollow;AsyncAssignPrimaryBackendandSelfFiringAssignPrimaryBackendmove topromoteModel.One detail the twins hid, found by a test failing rather than by reading:
ThrowingDispatchBackendmust tell its two arms apart byrequest.primary, notrequest.current—attachHandlerAsync's first attach carries a zerocurrent, so both tests landed on the same arm.tests/test_backend_registration_surface.cpp'sRecordingBackenddrops the twins and their four forwarding assertions; the adapter's forwarding test still pins every synchronous verb.Spec section rename
backend.md's "Asynchronous registration —registerModelAsync" becomes "Why registration needs a non-blocking path", keeping the live content (the nested-QEventLoop/WASM rationale, theasyncRegistrationEnabledgate, the pre-connect queue, what an unbound handler means) and handing the history to "What was wrong with the old shape". Every citation is repointed:locality.md,bridge.md,shared_instances.md, three sites ininclude/morph/core/bridge.hpp, and threeexamples/files.scripts/test_check_spec_citations.sh's rename fixture is repointed too, and its self-test still catches all sixteen drifts it claims to.Two places still name the removed verbs, deliberately:
backend.md's one paragraph saying what was removed, andshared_instances.md's one-line pointer to it. A spec that cannot name what it removed cannot explain the removal. The grep acceptance criterion is scoped toinclude src tests examples, and that is clean.3 — gates
50a8569d, separate so no ticket's diff carries a number nobody read the code for.scripts/mutation_survivors.json: threebackend.hppcitations, two of them reported as "appears 2 times … not decidable" exactly as those entries' ownreasontext predicted. Resolved by reading both candidates, not by taking the first —registerCount→ 1031 (LocalBackend::registerModel, not 1050'sregisterModelSharedarm, which the reason names as the one it is not);executeInFlight→ 1195 (immediately afterfetch_add, not 1240'sfetch_sub);reserve→ 1138, unambiguous.scripts/branch_partial_allowlist.json:backend.hpp:1414 → 1140,bridge.hpp:1551 → 1540,bridge.hpp:1673 → 1662. The last is ambiguous three ways; 1662 is thecatch (...)block inexecuteVia, which is what that entry's reason describes, and the parenthetical naming the other two (1697, 1786) is updated to1686, 1775.One hit not edited:
mutation_survivors.json'sclassification_2026_09_09sample citingbridge.hpp:584andstarted = backend->attachModelAsync(...). That is a dated record of a measurement, not a description of the tree; rewriting it would destroy the record. Anotesaying so is added beside the verdict.Review reasoning (done inline — no
/code-review, no/simplify)falseand every dispatch site already took thebindModelbranch. Verified by grep onc55ea5b7— the only overriders left were test doubles. Deleting a branch that never ran therefore changes no production path.SynchronousBackendAdapter's four forwards, removed with them. Nothing else: the full-tree grep is empty and the build is clean with-Werror.try/catchinattachHandlerAsync/ensureBoundAsyncis kept, thoughbindModel's default rejects rather than throws. Removing it would be a behaviour change for an out-of-tree override, outside this ticket. Its comment now says what it actually guards. Recorded in core: parkIfInFrame's double-claim guard is unreachable from a backend now that every dispatch goes through one Completion #648.bindWaitPolicyforwarding inAsyncBackendShimis not cosmetic: without it the shim would answer the defaultkCallerMayBlockwhile its target defers forever, andswitchBackend's phase 1 would park. Called out in the code.What this does not claim
bindModelbranch with the same double and asserts the same outcomes — inline settle, inline failure, synchronously-throwing dispatch, double-settle, the three staleness guards, the two teardown races. So the migration is checked by tests written against the twins' behaviour, not by new ones written against the replacement. The single exception isparkIfInFrame's guard (core: parkIfInFrame's double-claim guard is unreachable from a backend now that every dispatch goes through one Completion #648). No behavioural claim beyond that is made.Bridge-mediated calls it is still carried by the delivery thread the backend chose — core: give Bridge an executor bound to its own thread, so the threading guarantee is structural for Bridge-mediated calls too #588.morph_ladder_wasm_spikeand every*_gui_wasmtarget were never configured, let alone built. ladder: move the example GUIs and the WASM spike onto the structural registration surface #570's "the WASM ladder clients build, and the WASM demo deploys" is unmet by local measurement and rests entirely on thewasm-ladderandwasm-demoCI legs.scripts/coverage.sh(so the branch-coverage and error-path gates' coverage halves are unverified; their line-hint halves were audited directly — see below).Verification actually performed
Linux, GCC 16.2.1, Debug,
-DMORPH_BUILD_TESTS=ON -DMORPH_BUILD_EXAMPLES=ON -DMORPH_BUILD_QT=ON -DMORPH_BUILD_NET=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all, build clean.Doxygen with
WARN_AS_ERROR = FAIL_ON_WARNINGSbuilds clean (-DMORPH_BUILD_DOCUMENTATION=ON, targetdoc).clang-format --dry-run -Werrorclean over every changed C++ file — with the caveat that the local binary is 22.1.8 and CI pins 20; I checked that v22 reports the unmodifiedtests/test_async_registration.cppfromorigin/masteras already clean, so the two agree at least on that file.The branch-coverage and error-path allowlists' line hints were audited by calling
check_branch_coverage.resolve_allowlist_source_lineover every entry of both files: 0 failures each. Their coverage halves needbuild/clang-coverage/coverage.lcov, which was not produced.Issues filed
parkIfInFrame's double-claim guard is unreachable from a backend now that every dispatch goes through oneCompletion. Reasoned from the code, not from a coverage run; says so._controltask still runsinner->bindModelBlocking(...)andcancelPendingis untouched.4 — rebase onto
f7c231df, and the two things that madeclang-tidy-diffredRebased from
c55ea5b7ontof7c231dfbefore anything else: #647 landed andchanged the very leg this section fixes — the
clang-tidyjob now builds two*_autogentargets so the AUTOMOC self-including sources parse, and assertsevery self-included
.mocexists. A CI result judged against the old basewould not have meant anything. Rebase was clean; no conflicts.
The failed run (
35544723152) stalled withLinux / all optional features (clang)never starting, so its log is unreachable. Theclang-tidy-reportartifact is the evidence, and it is reproduced below locally rather than taken
on trust.
4a —
bugprone-exception-escape: a real finding, fixed on its meritsa7f8aba9This double settles its one still-pending
ModelCompletionfrom inside itsown destructor — the only way to reach
assignHandlerPrimary's!pinnedarm.A destructor is implicitly
noexcept, so an escape isstd::terminatewithno attribution, not a failed assertion.
The named
bad_weak_ptris a static over-approximation: thePromiseholds ashared_ptrto the state, soshared_from_this()cannot fail here. Theobligation is not an over-approximation, because
setValuealso runs thecontinuation
Bridge::assignHandlerPrimaryattached, inline, on this thread,and that is ordinary caller code.
So:
try/catch (...), and the catch records into anstd::exception_ptrthe test owns and
CHECKs after the backend is gone. ANOLINTwould havekept the
terminate; a barecatch (...) {}would have turned it intosilence. The new
CHECKis what stops the arm being a swallow.Not bundled with 4b's suppression, deliberately — that is how a real defect
gets hidden behind an environmental one.
4b — the five TUs this configure cannot build: the gate is wrong, not the diff
3c6dd24f,.github/workflows/ci.ymlonly.The other 21 findings were all
clang-diagnostic-error, from five changedsources this branch edits in comments only. A changed comment line is a
changed line, so
clang-tidy-diffanalysed five TUs nothing had ever analysed:four WASM mains needing an Emscripten/Qt-WASM toolchain the job does not
configure, and
tests/compile_checks/client_only_facade_no_model_header.cpp, which is builtby a configure-time
try_run()with-DMORPH_CLIENT_ONLYand deliberatelyomits
model.hpp— the artifact's note chain resolves everybridge.hpp,registry.hppandmodel.hpperror to that one TU.Reverting the comment edits was checked and is not available. Those four
WASM comments cite a
docs/spec/core/backend.mdsection this branch renames,so leaving them at their old text turns the spec-citation gate red instead —
measured, by actually reverting them and running the script:
This is the third instance of one structural problem (#624 a generated
header, #650
tests/lint/fixtures, this), so the fix generalises rather thanspecial-casing five paths. The diff is filtered before
clang-tidy-diff.pysees it:
compile_commands.jsonis dropped andnamed in the log with a
::warning::;unit, and discarding headers is exactly
-only-check-in-db's behaviour andclang-tidy-diff CI gate cannot fail: its exit status is tee's, and -extra-arg=-warnings-as-errors=* errors on every file #479's defect one directory over;
clang-tidy-diff.pyattributes@@lines to the last+++it saw.What keeps the filter from being a control that measures nothing. It
refuses to run unless the database is still the wide one the Configure step
builds: ≥600 in-workspace sources, ≥200 under
examples/. Counting only pathsthat resolve inside the workspace means a resolution mismatch trips the
floor too, instead of silently skipping everything. Measured here: 703 entries
naming 695 distinct in-workspace sources, 270 under
examples/; CI measured690/276 at #481's revision. The regression the floors exist to catch —
MORPH_BUILD_LADDERback to its OFF default — takesexamples/to 16, not to199.
No path list is hard-coded anywhere; the exclusion is derived from the tree on
every run.
Verification for section 4 — measured, with the flag set that matters
Everything below ran the gate itself, not a bare
clang-tidy file.cpp -- -std=c++23(which enables none of the warnings CI builds with and reportsclean on broken code). That means: this job's exact configure
(
clang-debug+ the nineMORPH_BUILD_*flags), the two*_autogentargets#647 added,
clang-tidy-diff.py, clang-tidy 22.1.8 (CI's pinned major),-p1 -extra-arg=-std=c++23 -extra-arg=-Wno-missing-include-dirs -quiet.Findings are recognisable by ending
[check-name,-warnings-as-errors].clang-diagnostic-errorfrom the 5 unbuilt TUs + 1bugprone-exception-escape26 of 31 changed file section(s) analysed, 5 source(s) skipped as unbuilt herebugprone-exception-escapeat the moved line 609bash -non the edited stepcheck_workflow_job_banners.pyok: all 24 section banner(s) introduce the job they describecheck_ci_clang_pin.shok: all 7 CI-clang-pin assertion(s) agree with .github/workflows/ci.ymlcheck_workflow_option_coverage.py16 MORPH_BUILD_* option(s) declared, all accounted forcheck_spec_citations.shProse lint OK: ...clang-format --dry-run -Werroron the changed testtests/morph_tests(clang 22 Debug)22932 assertions in 1556 cases— the branch's 22931 plus this commit's oneCHECK—1 failed as expectedNot verified, plainly: no Emscripten toolchain here, so nothing was built
for the four WASM mains — the only claim made about them is that this job
cannot analyse them, which is what their absence from
compile_commands.jsonshows. The workflow step was validated as its parts (
bash -n, the extractedPython, the pipeline) rather than executed as one script. Everything section
3's "What this does not claim" already said still stands.
Issues filed for section 4
11 of them (
examples/bank/gui,examples/bank/tests) only because thejob does not pass
-DMORPH_BUILD_BANK_GUI=ON, whichlinux-all-featuresalready does. Split by cause, with what is cheap and what is not.
tests/lint/fixtures arenow filtered out and named, so no new fixture directory costs a red round.
The documentation half of that issue is untouched.
bugprone-exception-escapeover thethree compiled production sources reports nothing, so 4a's shape does not
occur outside the test double.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW