From a30b1a406bc047e75e797e5a660440de25668b1a Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 26 Sep 2026 20:50:25 +0200 Subject: [PATCH 1/2] core: move BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION's registrar out of the header BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION's registrar initialiser is an ordinary function call written where the macro is expanded, so its body -- and everything it drags in (the action's glaze codecs, the dispatcher's runner closure, forms::schemaJson's schema generator) -- is instantiated in every translation unit that includes a model header carrying it. Measured on examples/kanban/include/kanban/models/board_model.hpp (morph#791): 26.93 CPU-seconds of -O3 codegen per including TU, for codecs the overwhelming majority of those TUs never call. BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION decompose the existing macros into a header-safe half (the ModelTraits/ActionTraits specialisation alone, free to repeat per TU) and BRIDGE_REGISTER_MODEL_SOURCE/ BRIDGE_REGISTER_ACTION_SOURCE carry the registration itself, called once in the .cpp that should own it. The combined BRIDGE_REGISTER_MODEL/ BRIDGE_REGISTER_ACTION macros are unchanged and remain the right choice when the split isn't worth it. Splitting declaration from registration introduces a mistake that couldn't previously happen: a model declared but never registered anywhere. Closed with a link-time canary -- BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION call a function template behind `extern template`, and only the SOURCE macros explicitly instantiate it -- so a missing registration fails to *link*, instead of compiling clean and surfacing only as a runtime "unknown model type" far from the cause. Proven both ways by tests/compile_checks/declare_only_no_source_link.cpp/ declare_only_source_provided.cpp via a new try_compile() pair in tests/CMakeLists.txt. Retrofits examples/kanban's board_model.hpp/.cpp -- the exact header morph#791 measured -- as the proof this reaches the measured cost. Re-measured (AppleClang 17, not isolated from concurrent system load so reported as a lower bound): 20.81 CPU-s before, 7.81 CPU-s after, a 13.00 CPU-s reduction per translation unit; the compiled stub's object file shrinks from 3,331,704 to 3,232 bytes (7155 to 24 symbols), with zero glaze symbols left in the post-fix object. docs/spec/core/registry.md gains "Moving a registrar out of the header", documenting the mechanism, the canary, and its one real gap (a `dlopen`ed plugin module, which must keep using the combined macros). Closes #791 Signed-off-by: Yaraslau Tamashevich Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018fEUahMFF32wQLiWjbsfkc --- docs/spec/core/bridge.md | 11 + docs/spec/core/registry.md | 197 ++++++++++++ .../include/kanban/models/board_model.hpp | 43 +-- examples/kanban/src/models/board_model.cpp | 24 ++ include/morph/core/registry.hpp | 289 ++++++++++++++++-- tests/CMakeLists.txt | 103 +++++++ .../declare_only_no_source_link.cpp | 27 ++ .../declare_only_source_provided.cpp | 13 + .../declare_source_canary_shared.hpp | 34 +++ 9 files changed, 697 insertions(+), 44 deletions(-) create mode 100644 tests/compile_checks/declare_only_no_source_link.cpp create mode 100644 tests/compile_checks/declare_only_source_provided.cpp create mode 100644 tests/compile_checks/declare_source_canary_shared.hpp diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index 3b4439410..1ef6cb10e 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -869,6 +869,17 @@ static-init time. The `inline` keyword lets the definition in this header be included and instantiated across many translation units without an ODR/link violation; the registration itself runs from the macro's static initializer. +`BRIDGE_REGISTER_ACTION_SOURCE(M, A)` — the `.cpp`-side half of +`BRIDGE_REGISTER_ACTION` decomposed into a header-only declaration +(`BRIDGE_DECLARE_ACTION`) plus a registration call, for a model header whose +per-TU registration cost is worth moving out (see +[registry.md, "Moving a registrar out of the +header"](registry.md#moving-a-registrar-out-of-the-header)) — emits +exactly the same two initializers as (b) above, so it carries the identical +`#include ` requirement: the translation unit calling +`BRIDGE_REGISTER_ACTION_SOURCE` must include this header, or the link fails +on the same unresolved `registerActionExecutorOnce` symbol. + ## `MemberPointerTraits` Declared as `morph::bridge::detail::MemberPointerTraits`. diff --git a/docs/spec/core/registry.md b/docs/spec/core/registry.md index ee8c3c28b..01f1c804a 100644 --- a/docs/spec/core/registry.md +++ b/docs/spec/core/registry.md @@ -28,6 +28,7 @@ without knowing their concrete types. - [ModelRegistryFactory](#modelregistryfactory) - [ActionExecuteRegistry](#actionexecuteregistry) - [Registration macros](#registration-macros) + - [Moving a registrar out of the header](#moving-a-registrar-out-of-the-header) - [`MORPH_CLIENT_ONLY` — suppressing model-owning registrars](#morph_client_only--suppressing-model-owning-registrars) - [`BRIDGE_REGISTER_ACTION_FOR_CLIENT` — a header seam for `MORPH_CLIENT_ONLY`](#bridge_register_action_for_client--a-header-seam-for-morph_client_only) - [BRIDGE_REGISTER_MODEL](#bridge_register_model) @@ -959,6 +960,187 @@ A client-side alternative, `BRIDGE_REGISTER_ACTION_FOR_CLIENT`, avoids the `MORPH_CLIENT_ONLY`](#bridge_register_action_for_client--a-header-seam-for-morph_client_only) below. +### Moving a registrar out of the header + +`BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION`'s registrar initialiser is +not free to repeat per translation unit. Its right-hand side -- +`registerModelOnce(...)` / `registerActionOnce(...)` -- is an +ordinary (non-template) function call written where the macro is expanded, +so its body is compiled there: `registerActionOnce`'s call into +`ActionDispatcher::registerAction` instantiates the runner +closure, which odr-uses `ActionTraits::toJson`/`fromJson`/`resultToJson`/ +`resultFromJson` (each a glaze codec over `A`) and files a `describe` thunk +over `buildActionDescription` (`forms::schemaJson`, another glaze +codec). None of that is triggered by the `ActionTraits` *specialisation* +by itself -- a class's inline member functions are only compiled when +odr-used, and nothing odr-uses them until the registrar's initialiser calls +them. So a model header that many translation units `#include` pays for this +instantiation-and-optimisation work once per including TU, for codecs the +overwhelming majority of those TUs never call. + +**A prior measurement** (clang 22.1.8, gcc 16.2.1, Linux, 12 +cores, compiler cache off, best of two): stripping only +`examples/kanban/include/kanban/models/board_model.hpp`'s 17 +`BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` lines cost **26.93 CPU-s** at +`-O3 -c` and **2.63 CPU-s** at `-fsyntax-only`, on the rung's own real compile +command -- the `-fsyntax-only`/`-O3` gap showing the cost is +instantiate-and-optimise work, not parsing. + +**Re-measured for this fix** (AppleClang 17.0.0, macOS, `-O3 -c`, a stub +translation unit that only `#include`s the header, best of two; **not** +isolated -- another build was running concurrently on this machine, so +CPU-seconds is reported rather than wall-clock, and the numbers below are a +lower bound, not a ceiling): the current header, using +`BRIDGE_DECLARE_MODEL`/`BRIDGE_DECLARE_ACTION`, costs **7.81 CPU-s**; +restoring the pre-fix `BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` content +(`git show origin/master:.../board_model.hpp`) costs **20.81 CPU-s** -- a +**13.00 CPU-s** reduction per translation unit, on a different compiler and +machine than the original measurement but the same mechanism and the same +header. `-fsyntax-only` drops from 7.55 to 4.68 CPU-s (2.87 CPU-s), the same +proportionally-smaller gap the original measurement found. The compiled +object file corroborates it directly: the pre-fix stub's `.o` is +**3,331,704 bytes** (7155 symbols, 1,348,458 `__TEXT` bytes); the post-fix +stub's is **3,232 bytes** (24 symbols, 172 `__TEXT` bytes) -- the registrar's +codecs, dispatch closures, and schema generator are simply absent from the +object file once the registration is deferred to `board_model.cpp`. + +`BRIDGE_DECLARE_MODEL(M, NAME)` and `BRIDGE_DECLARE_ACTION(M, A, NAME, ...)` +emit **only** the trait specialisation half of what +`BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` emit -- `ModelTraits` / +`ActionTraits` -- which stays free to repeat per TU exactly as it always +was (see ["Header placement is legal"](#header-placement-is-legal) above). +`BRIDGE_REGISTER_MODEL_SOURCE(M)` and `BRIDGE_REGISTER_ACTION_SOURCE(M, A)` +emit the registrar half -- the same `registerModelOnce`/`registerActionOnce`/ +`registerActionExecutorOnce` calls `BRIDGE_REGISTER_MODEL`/ +`BRIDGE_REGISTER_ACTION` emit directly -- for a `.cpp` to call exactly once. +Neither `NAME` is repeated at the `.cpp` call site: both `SOURCE` macros read +it back off the trait specialisation `DECLARE` already installed +(`ModelTraits::typeId()` / `ActionTraits::typeId()`), so a `DECLARE`/ +`SOURCE` pair cannot register under a different string than the one the +type's own traits report. + +```cpp +// board_model.hpp -- every translation unit that includes this pays only for +// the trait specialisation, not for the registrar's codec instantiation. +BRIDGE_DECLARE_MODEL(BoardModel, "BoardModel") +BRIDGE_DECLARE_ACTION(BoardModel, CreateSwimlane, "CreateSwimlane") + +// board_model.cpp -- the one translation unit that pays the registration +// cost, once, for the whole program. +BRIDGE_REGISTER_MODEL_SOURCE(BoardModel) +BRIDGE_REGISTER_ACTION_SOURCE(BoardModel, CreateSwimlane) +``` + +`BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` are unchanged and remain the +right choice for a model whose registration cost is not worth splitting +across two macro calls in two files -- this is a decomposition of the +existing macros' expansion, not a replacement for them, and both shapes are +usable in the same program (see [Failure modes](#failure-modes)'s +last-write-wins entry if the same type is accidentally registered by both). + +#### The link-time canary: closing `BRIDGE_DECLARE_*`'s own new hazard + +Splitting declaration from registration introduces a mistake that could not +previously happen: `BRIDGE_DECLARE_MODEL`/`BRIDGE_DECLARE_ACTION` in the +header with no matching `BRIDGE_REGISTER_MODEL_SOURCE`/ +`BRIDGE_REGISTER_ACTION_SOURCE` anywhere in the program -- an omitted `.cpp`, +or a `.cpp` that exists but was never added to the target. Left unguarded, +this would reproduce exactly the failure mode `MORPH_CLIENT_ONLY`'s own doc +comment names: the program compiles and links cleanly, and the first symptom +is `ModelRegistryFactory::create`'s or `ActionDispatcher::dispatch`'s runtime +`"unknown model type"` / `"unknown action"`, far from the missing `.cpp`. + +`BRIDGE_DECLARE_MODEL`/`BRIDGE_DECLARE_ACTION` close this at **link time** +instead. Each also emits a call to a function template -- +`detail::modelSourceRegistrationRequired()` / +`detail::actionSourceRegistrationRequired()` -- behind an `extern +template` declaration. `extern template` is what makes this work: it +suppresses this translation unit's own implicit instantiation of the +function (which has a trivial, always-visible definition) and instead +requires an *explicit* instantiation to exist somewhere else in the link. +`BRIDGE_REGISTER_MODEL_SOURCE`/`BRIDGE_REGISTER_ACTION_SOURCE` are the only +macros that provide one. So: + +- **Declared and registered** (the normal case): the `.cpp`'s explicit + instantiation satisfies every header's `extern template` reference. Link + succeeds, exactly as before. +- **Declared, never registered**: no explicit instantiation exists anywhere + in the link. Every translation unit that included the header carries an + unresolved reference to `modelSourceRegistrationRequired` / + `actionSourceRegistrationRequired`, and the final link fails with an + unresolved external symbol naming the missing model/action by type -- + before the program ever runs, let alone dispatches anything. + +The function bodies do nothing; only whether an explicit instantiation +exists anywhere the linker looks is being tested. This is a stricter relative +of the "declare here, must be provided from over there" idiom +`registerActionExecutorOnce` already relies on (declared in `registry.hpp`, +*generically* defined in `bridge.hpp` -- see the "Hard requirement" note +above): that idiom only proves "this translation unit transitively included +`bridge.hpp`", since any TU that does gets a visible, callable generic +definition for *any* `(Model, Action)`. The canary needs a stronger +guarantee -- "some translation unit in this exact link explicitly registered +*this* `(Model, Action)`, not merely code that theoretically could" -- so it +pairs `extern template` (suppressing implicit instantiation from the header's +own, deliberately trivial, generic definition) with an explicit instantiation +only `BRIDGE_REGISTER_MODEL_SOURCE`/`BRIDGE_REGISTER_ACTION_SOURCE` emit, +rather than reusing `registerActionExecutorOnce`'s plain +declare-in-one-header/define-in-another shape verbatim. + +**Confirmed empirically** +(`tests/compile_checks/declare_only_no_source_link.cpp` and +`declare_only_source_provided.cpp`, run via the `try_compile()` block in +`tests/CMakeLists.txt`): a program that declares a real, fully-defined model +and action via `BRIDGE_DECLARE_MODEL`/`BRIDGE_DECLARE_ACTION` but never calls +either `SOURCE` macro fails to link with an unresolved symbol naming the +canary function; adding a second translation unit that does call both +`SOURCE` macros for the same model/action makes the same program link +cleanly (`try_compile()`, not `try_run()` -- link success is what the canary +claims, and is all this check exercises). Verified on Clang and GCC (both +link the missing-registration probe as "undefined reference"/"symbol(s) not +found"); not verified on MSVC, though +`extern template` is standard since C++11 and MSVC has supported it since +Visual Studio 2013. + +**Suppressed under `MORPH_CLIENT_ONLY`**, exactly like the two registrars +`BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` emit directly: a +`MORPH_CLIENT_ONLY` build legitimately never registers a model at all (see +[`MORPH_CLIENT_ONLY`](#morph_client_only--suppressing-model-owning-registrars) +below), so requiring an explicit instantiation there would turn every such +build's intended behaviour into a link failure. + +**What this does not close: `dlopen`/`LoadLibrary` plugins.** The canary +requires the `SOURCE` macro's explicit instantiation to be part of the *same +link* as the header's `extern template` reference -- exactly the population +["Static initialisation only fires in linked translation +units"](#static-initialisation-only-fires-in-linked-translation-units)'s +`dlopen` case describes as running *after* `main` starts, i.e. never part of +the host executable's own link at all. A model meant to be registered from a +dynamically loaded module must therefore keep using the combined +`BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` inside that module -- not +`BRIDGE_DECLARE_MODEL`/`BRIDGE_DECLARE_ACTION` with the registration deferred +to the host -- or the host executable simply fails to link at all, with +nothing in its own build able to satisfy the canary. + +**What the static-library case does -- untested, stated as inferred, not +measured.** A registration-only `.cpp` built into a static-library member the +linker never pulls in (the *other* entry in that same section) previously +registered nothing and surfaced only as a runtime `"unknown model type"`. The +canary's explicit instantiation lives in that same member, so every +`BRIDGE_DECLARE_MODEL`/`BRIDGE_DECLARE_ACTION` site now carries an `extern +template` reference into it too -- but which of two outcomes that produces +depends on link order, and this has not been built and checked either way: +a linker that resolves a static archive by repeatedly rescanning it until no +more members are pulled in (the common case when the registering `.cpp` sits +in the *same* archive as its callers) would plausibly pull the member in on +the strength of the canary reference alone, which would fix registration +outright rather than merely fail loudly; one that scans archives once, +left to right, without `--start-group`, would instead leave the reference +unresolved and fail to *link*. Either outcome is better than the +pre-canary silent runtime failure. The +`--whole-archive`/`-force_load`/`/WHOLEARCHIVE` guidance in that section +remains the guaranteed fix regardless of which applies. + ### `BRIDGE_REGISTER_VALIDATOR(A, FN)` Specialises `ActionValidator` with a custom predicate. @@ -1190,6 +1372,10 @@ correctly under `MORPH_CLIENT_ONLY`. | `BRIDGE_REGISTER_MODEL` | `(M, NAME)` | `ModelTraits` specialisation + static-init factory registration. | | `BRIDGE_REGISTER_ACTION` | `(M, A, NAME, ...)` | `ActionTraits` specialisation + static-init dispatcher and executor registration. Optional 4th arg: `Loggable`. `Result` deduced from `decltype(M::execute(A))`, requiring `M` complete. | | `BRIDGE_REGISTER_ACTION_FOR_CLIENT` | `(M, A, RESULT, NAME, ...)` | Same as `BRIDGE_REGISTER_ACTION`, except `Result` is the explicitly-named `RESULT` argument — `M` may be forward-declared only. See ["a header seam for `MORPH_CLIENT_ONLY`"](#bridge_register_action_for_client--a-header-seam-for-morph_client_only). | +| `BRIDGE_DECLARE_MODEL` | `(M, NAME)` | `ModelTraits` specialisation + a link-time canary requiring `BRIDGE_REGISTER_MODEL_SOURCE(M)` somewhere in the link. No registration. See ["Moving a registrar out of the header"](#moving-a-registrar-out-of-the-header). | +| `BRIDGE_REGISTER_MODEL_SOURCE` | `(M)` | Static-init factory registration for a `M` already declared via `BRIDGE_DECLARE_MODEL`. Reads `NAME` back off `ModelTraits::typeId()`. | +| `BRIDGE_DECLARE_ACTION` | `(M, A, NAME, ...)` | `ActionTraits` specialisation + a link-time canary requiring `BRIDGE_REGISTER_ACTION_SOURCE(M, A)` somewhere in the link. Optional 4th arg: `Loggable`. No registration. | +| `BRIDGE_REGISTER_ACTION_SOURCE` | `(M, A)` | Static-init dispatcher and executor registration for an `A` already declared via `BRIDGE_DECLARE_ACTION`. Reads `NAME` back off `ActionTraits::typeId()`. | | `BRIDGE_REGISTER_VALIDATOR` | `(A, FN)` | `ActionValidator` specialisation + custom predicate. | ### Detail helpers @@ -1205,6 +1391,8 @@ correctly under `MORPH_CLIENT_ONLY`. | `registerModelOnce(id)` | Static-init helper; returns `true`. | | `registerActionOnce(modelId, actionId)` | Static-init helper; returns `true`. | | `registerActionExecutorOnce(modelId, actionId)` | Static-init helper; only declared in `registry.hpp`, defined in `bridge.hpp`. | +| `modelSourceRegistrationRequired()` | Link-time canary: `BRIDGE_DECLARE_MODEL` calls it behind `extern template`; `BRIDGE_REGISTER_MODEL_SOURCE` is the only macro that explicitly instantiates it. See ["The link-time canary"](#the-link-time-canary-closing-bridge_declare_s-own-new-hazard). | +| `actionSourceRegistrationRequired()` | Same canary, for `BRIDGE_DECLARE_ACTION`/`BRIDGE_REGISTER_ACTION_SOURCE`. | | `registrationPhaseFlag()` | The process-wide registration-phase `std::atomic`. See ["The registration-phase latch"](#the-registration-phase-latch). | | `noteRegistryRead(isProcessRegistry)` | Closes the latch on the first read of a process-level registry. Debug builds only; empty under `NDEBUG`. | | `reopenRegistrationPhaseForTesting()` | Re-opens the latch. Test-only. | @@ -1317,6 +1505,7 @@ and separately `noteRegistryRead` emptied). | `coalesce` for an unknown pair | Does **not** throw — defaults to `false` (every entry kept). | `ActionDispatcher::coalesce` | | Allocation failure inside a `register*Once` helper during static init | `registerModelOnce` / `registerActionOnce` (and `registerActionExecutorOnce`) are declared `noexcept` yet allocate (they build `std::string` keys and grow the map). An OOM there raises an exception through a `noexcept` boundary, which calls `std::terminate` — the process aborts during static init. This is the intended outcome rather than an accepted defect: the same OOM without `noexcept` terminates anyway, because the caller is the dynamic initialiser of a non-local variable. See ["Why all three are `noexcept` while allocating"](#why-all-three-are-noexcept-while-allocating). | `registry.hpp` | | A `register*Once` call after the registration phase closes (e.g. a `dlopen`ed module registering once dispatch has begun) | **Debug build:** the `assert` fires and the process aborts with a message naming this hazard. **Release build:** unchanged — undefined behaviour, racing the map's internals against concurrent `find`s, with no diagnostic. The latch detects the violation; it does not make it safe. | `registry.hpp`, `bridge.hpp` — see ["The registration-phase latch"](#the-registration-phase-latch) | +| `BRIDGE_DECLARE_MODEL`/`BRIDGE_DECLARE_ACTION` used with no matching `BRIDGE_REGISTER_MODEL_SOURCE`/`BRIDGE_REGISTER_ACTION_SOURCE` anywhere in the same link | **Fails to link** — an unresolved external symbol naming `modelSourceRegistrationRequired` / `actionSourceRegistrationRequired`, before the program ever runs. Contrast the row above: this is the one registration mistake in this table that is *not* deferred to runtime. See ["The link-time canary"](#the-link-time-canary-closing-bridge_declare_s-own-new-hazard). | `registry.hpp` | Note the asymmetry the design accepts intentionally: the **typed local path** (`BridgeHandler::execute()`, `Model::execute(action)`) is checked by the @@ -1342,6 +1531,14 @@ testing obligation, not a compile-time guarantee. compile-time guarantee that every remotely executed pair was actually registered** (registration is a static-init side effect that can be silently dropped; see [Registration rules and invariants](#registration-rules-and-invariants)). + A model/action registered via `BRIDGE_DECLARE_MODEL`/`BRIDGE_DECLARE_ACTION` + plus `BRIDGE_REGISTER_MODEL_SOURCE`/`BRIDGE_REGISTER_ACTION_SOURCE` gets a + **link-time**, not compile-time, version of this guarantee instead — see + ["The link-time canary"](#the-link-time-canary-closing-bridge_declare_s-own-new-hazard) + — but only for pairs that opt into the split macros; the combined + `BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` carry no such check, because + for them declaration and registration are the same macro call and cannot + drift apart. - **Global mutable singletons with no teardown or reset.** `defaultDispatcher()` and `defaultRegistry()` (and `ActionExecuteRegistry::instance()`) are function-local `static`s that live for the whole process and expose no clear / diff --git a/examples/kanban/include/kanban/models/board_model.hpp b/examples/kanban/include/kanban/models/board_model.hpp index 36ae53418..200fd98d8 100644 --- a/examples/kanban/include/kanban/models/board_model.hpp +++ b/examples/kanban/include/kanban/models/board_model.hpp @@ -543,23 +543,32 @@ class BoardModel { } // namespace kanban -BRIDGE_REGISTER_MODEL(kanban::BoardModel, "BoardModel") -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::OpenBoard, "OpenBoard", ::morph::model::Loggable::No) -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::GetBoardState, "GetBoardState", ::morph::model::Loggable::No) -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::CreateColumn, "CreateColumn") -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::CreateSwimlane, "CreateSwimlane") -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::CreateTask, "CreateTask") -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::AddComment, "AddComment") -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::MoveTaskPosition, "MoveTaskPosition") -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::GetEventsSince, "GetEventsSince", ::morph::model::Loggable::No) -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::GetActivity, "GetActivity", ::morph::model::Loggable::No) -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::CreateRule, "CreateRule") -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::GetRules, "GetRules", ::morph::model::Loggable::No) -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::DeleteRule, "DeleteRule") -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::ApplyTagMutation, "ApplyTagMutation") -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::AddAttachment, "AddAttachment") -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::GetAttachments, "GetAttachments", ::morph::model::Loggable::No) -BRIDGE_REGISTER_ACTION(kanban::BoardModel, kanban::RemoveAttachment, "RemoveAttachment") +// Declared, not registered: this header is reached by ~20 translation units +// (test files, the QML/GUI bridge, app.cpp), and BRIDGE_REGISTER_MODEL/ +// BRIDGE_REGISTER_ACTION's registrar initialiser would instantiate this +// model's action codecs, buildActionDescription, and forms::schemaJson +// again in every one of them -- a measured double-digit CPU-second cost per +// TU, for codecs the overwhelming majority of those TUs never call (see +// docs/spec/core/registry.md, "Moving a registrar out of the header"). The +// actual registration happens once, in board_model.cpp, via +// BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE. +BRIDGE_DECLARE_MODEL(kanban::BoardModel, "BoardModel") +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::OpenBoard, "OpenBoard", ::morph::model::Loggable::No) +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::GetBoardState, "GetBoardState", ::morph::model::Loggable::No) +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::CreateColumn, "CreateColumn") +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::CreateSwimlane, "CreateSwimlane") +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::CreateTask, "CreateTask") +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::AddComment, "AddComment") +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::MoveTaskPosition, "MoveTaskPosition") +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::GetEventsSince, "GetEventsSince", ::morph::model::Loggable::No) +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::GetActivity, "GetActivity", ::morph::model::Loggable::No) +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::CreateRule, "CreateRule") +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::GetRules, "GetRules", ::morph::model::Loggable::No) +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::DeleteRule, "DeleteRule") +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::ApplyTagMutation, "ApplyTagMutation") +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::AddAttachment, "AddAttachment") +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::GetAttachments, "GetAttachments", ::morph::model::Loggable::No) +BRIDGE_DECLARE_ACTION(kanban::BoardModel, kanban::RemoveAttachment, "RemoveAttachment") // `BoardModel` is keyed on the project the board belongs to, and `OpenBoard` // is the action that names it. `BRIDGE_MODEL_KEY` deduces the key *type* from diff --git a/examples/kanban/src/models/board_model.cpp b/examples/kanban/src/models/board_model.cpp index 18ec8c61c..0dac8da15 100644 --- a/examples/kanban/src/models/board_model.cpp +++ b/examples/kanban/src/models/board_model.cpp @@ -1474,3 +1474,27 @@ GetActivityResult BoardModel::execute(const GetActivity& /*action*/) { } } // namespace kanban + +// The actual registration board_model.hpp's BRIDGE_DECLARE_MODEL/ +// BRIDGE_DECLARE_ACTION calls defer to this one translation unit -- see the +// comment above those calls, and docs/spec/core/registry.md, "Moving a +// registrar out of the header". A model header reached by many translation +// units (kanban's tests, the QML/GUI bridge, app.cpp) pays for this once, +// here, instead of once per including TU. +BRIDGE_REGISTER_MODEL_SOURCE(kanban::BoardModel) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::OpenBoard) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::GetBoardState) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::CreateColumn) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::CreateSwimlane) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::CreateTask) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::AddComment) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::MoveTaskPosition) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::GetEventsSince) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::GetActivity) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::CreateRule) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::GetRules) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::DeleteRule) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::ApplyTagMutation) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::AddAttachment) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::GetAttachments) +BRIDGE_REGISTER_ACTION_SOURCE(kanban::BoardModel, kanban::RemoveAttachment) diff --git a/include/morph/core/registry.hpp b/include/morph/core/registry.hpp index ed9c779c7..c237dfb87 100644 --- a/include/morph/core/registry.hpp +++ b/include/morph/core/registry.hpp @@ -1358,11 +1358,50 @@ inline bool registerActionOnce(std::string_view modelId, std::string_view action template bool registerActionExecutorOnce(std::string_view modelId, std::string_view actionId) noexcept; +/// @brief Link-time canary for `BRIDGE_DECLARE_MODEL` / `BRIDGE_REGISTER_MODEL_SOURCE`. +/// +/// `BRIDGE_DECLARE_MODEL(M, NAME)` calls this specialisation behind an +/// `extern template` declaration, which suppresses implicit instantiation of +/// this always-visible, trivial definition and instead demands an *explicit* +/// instantiation elsewhere in the link. `BRIDGE_REGISTER_MODEL_SOURCE(M)` is +/// the only macro that provides one. A model declared via +/// `BRIDGE_DECLARE_MODEL` but never registered via +/// `BRIDGE_REGISTER_MODEL_SOURCE` anywhere in the same binary therefore fails +/// to *link* with an unresolved symbol naming this function, instead of +/// compiling clean and surfacing only as `ModelRegistryFactory::create`'s +/// runtime `"unknown model type"`, far from the cause. The body does nothing; +/// only whether an explicit instantiation for `Model` exists anywhere the +/// linker looks is being tested. +/// @tparam Model Concrete model type a `BRIDGE_DECLARE_MODEL` call named. +template +void modelSourceRegistrationRequired() {} + +/// @brief Link-time canary for `BRIDGE_DECLARE_ACTION` / `BRIDGE_REGISTER_ACTION_SOURCE`. +/// +/// Same mechanism as `modelSourceRegistrationRequired` above, keyed on the +/// `(Model, Action)` pair `BRIDGE_DECLARE_ACTION` and +/// `BRIDGE_REGISTER_ACTION_SOURCE` were each invoked with. +/// @tparam Model Model type the action belongs to. +/// @tparam Action Concrete action type a `BRIDGE_DECLARE_ACTION` call named. +template +void actionSourceRegistrationRequired() {} + } // namespace detail } // namespace morph::model // NOLINTBEGIN(bugprone-macro-parentheses) +// NOLINTBEGIN(cppcoreguidelines-macro-usage) — registration macros are the intended public API +// The registrars below are namespace-scope `const bool`s whose initialisers run +// before main, inside an unnamed namespace so each translation unit gets its own +// copy — and `BRIDGE_DECLARE_*` places that in a header by design. clang-tidy +// attributes these to every expansion site, so they are suppressed here, once: +// * bugprone-throwing-static-initialization / cert-err58-cpp -- registration +// cannot be wrapped in a try block; a throw aborts at start-up, which is the +// right outcome for a type that cannot be registered. +// * misc-anonymous-namespace-in-header / cert-dcl59-cpp -- the per-TU copy is +// the point: it is what ODR-uses the link canary in each including TU. +// NOLINTBEGIN(bugprone-throwing-static-initialization,cert-err58-cpp,misc-anonymous-namespace-in-header,cert-dcl59-cpp) // Keying the generated registrar names on `__COUNTER__` (rather than on the type spelling) // keeps them valid identifiers regardless of how `M`/`A` are written. A namespace-qualified @@ -1405,9 +1444,25 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio /// any `Bridge` running `LocalBackend`) — it silently registers nothing, and /// the model fails at runtime with "unknown model type" rather than at /// compile/link time. +/// @brief Emits the `extern template` link requirement `BRIDGE_DECLARE_MODEL`/ +/// `BRIDGE_DECLARE_ACTION` place in the header (`MORPH_DETAIL_REQUIRE_SOURCE_LOCAL`), +/// and the matching explicit-instantiation definition +/// `BRIDGE_REGISTER_MODEL_SOURCE`/`BRIDGE_REGISTER_ACTION_SOURCE` place +/// in the `.cpp` (`MORPH_DETAIL_DEFINE_SOURCE_LOCAL`). +/// +/// One pair of macros for both the model and action canaries: @p ... +/// carries the whole call expression (`modelSourceRegistrationRequired()` +/// or `actionSourceRegistrationRequired()`), passed through the +/// variadic parameter rather than a fixed one so the template argument +/// list's own comma isn't mistaken for a macro-argument separator. @p PREFIX +/// (`MORPH_DETAIL_REQUIRE_SOURCE_LOCAL` only) names the generated variable, +/// same role as the fixed prefixes `MORPH_DETAIL_REGISTER_MODEL_LOCAL`/ +/// `MORPH_DETAIL_REGISTER_ACTION_LOCAL` above use directly. #ifdef MORPH_CLIENT_ONLY #define MORPH_DETAIL_REGISTER_MODEL_LOCAL(M, NAME) #define MORPH_DETAIL_REGISTER_ACTION_LOCAL(M, A, NAME) +#define MORPH_DETAIL_REQUIRE_SOURCE_LOCAL(PREFIX, ...) +#define MORPH_DETAIL_DEFINE_SOURCE_LOCAL(...) #else // clang-format off // Hand-aligned: clang-format pulls the short registerModelOnce() call up onto the @@ -1424,9 +1479,29 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_action_reg_, __COUNTER__) = \ morph::model::detail::registerActionOnce(morph::model::ModelTraits::typeId(), NAME); \ } +#define MORPH_DETAIL_REQUIRE_SOURCE_LOCAL(PREFIX, ...) \ + extern template void __VA_ARGS__; \ + namespace { \ + [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(PREFIX, __COUNTER__) = (__VA_ARGS__, true); \ + } +#define MORPH_DETAIL_DEFINE_SOURCE_LOCAL(...) template void __VA_ARGS__; // clang-format on #endif +/// @brief `ModelTraits`'s specialisation alone -- the type-only half +/// `BRIDGE_REGISTER_MODEL` and `BRIDGE_DECLARE_MODEL` both build on, so +/// the two cannot drift apart the way two independently hand-written +/// copies could. +/// @param M Concrete model type. +/// @param NAME String literal used as the type-id. +// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". +#define BRIDGE_DETAIL_MODEL_TRAITS_ONLY_BODY(M, NAME) \ + template <> \ + struct morph::model::ModelTraits { \ + static constexpr std::string_view typeId() noexcept { return NAME; } \ + }; +// clang-format on + /// @brief Registers model type @p M with the string id @p NAME. /// /// Specialises `morph::model::ModelTraits` and registers a factory with the @@ -1437,14 +1512,57 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio /// @param M Concrete model type. /// @param NAME String literal used as the type-id. // clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". -#define BRIDGE_REGISTER_MODEL(M, NAME) \ - template <> \ - struct morph::model::ModelTraits { \ - static constexpr std::string_view typeId() noexcept { return NAME; } \ - }; \ +#define BRIDGE_REGISTER_MODEL(M, NAME) \ + BRIDGE_DETAIL_MODEL_TRAITS_ONLY_BODY(M, NAME) \ MORPH_DETAIL_REGISTER_MODEL_LOCAL(M, NAME) // clang-format on +/// @brief Declares model type @p M's `ModelTraits` specialisation without +/// registering it -- the header-safe half `BRIDGE_REGISTER_MODEL` +/// decomposes into. +/// +/// `BRIDGE_REGISTER_MODEL`'s registrar initialiser instantiates +/// `registerModelOnce` in every translation unit that includes the header +/// it sits in; `BRIDGE_DECLARE_MODEL` emits only the trait specialisation +/// (essentially free to repeat per TU) and defers the actual registration to +/// `BRIDGE_REGISTER_MODEL_SOURCE(M)`, called once in the one `.cpp` that +/// should own it. See docs/spec/core/registry.md, "Moving a registrar out of +/// the header", for when this is worth doing and for the link-time canary +/// this macro emits in @p M's place: a `BRIDGE_DECLARE_MODEL` with no +/// matching `BRIDGE_REGISTER_MODEL_SOURCE` anywhere in the link fails to +/// *link* with an unresolved symbol, rather than compiling clean and only +/// surfacing as `ModelRegistryFactory::create`'s runtime +/// `"unknown model type"`. +/// +/// @param M Concrete model type. +/// @param NAME String literal used as the type-id. +// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". +#define BRIDGE_DECLARE_MODEL(M, NAME) \ + BRIDGE_DETAIL_MODEL_TRAITS_ONLY_BODY(M, NAME) \ + MORPH_DETAIL_REQUIRE_SOURCE_LOCAL(bridge_model_src_req_, \ + morph::model::detail::modelSourceRegistrationRequired()) +// clang-format on + +/// @brief Registers model type @p M -- previously declared via +/// `BRIDGE_DECLARE_MODEL(M, NAME)` -- with the process-level +/// `ModelRegistryFactory` at static-init time. +/// +/// Call exactly once, in the one `.cpp` that owns @p M's registration -- +/// typically the translation unit that defines @p M's out-of-line members. +/// Requires `morph::model::ModelTraits` already visible (from +/// `BRIDGE_DECLARE_MODEL` or `BRIDGE_REGISTER_MODEL` in an included header) +/// and reads its `typeId()` rather than taking a `NAME` argument again, so the +/// two cannot register under a different string than the one @p M's traits +/// actually report. Suppressed under `MORPH_CLIENT_ONLY`, exactly like the +/// registrar `BRIDGE_REGISTER_MODEL` emits directly. +/// +/// @param M Concrete model type, already declared via `BRIDGE_DECLARE_MODEL`. +// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". +#define BRIDGE_REGISTER_MODEL_SOURCE(M) \ + MORPH_DETAIL_DEFINE_SOURCE_LOCAL(morph::model::detail::modelSourceRegistrationRequired()) \ + MORPH_DETAIL_REGISTER_MODEL_LOCAL(M, morph::model::ModelTraits::typeId()) +// clang-format on + /// @brief Registers action type @p A (for model @p M) with the string id @p NAME. /// /// Specialises `morph::model::ActionTraits` with JSON codec functions and @@ -1471,7 +1589,6 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio /// @param A Concrete action type. /// @param NAME String literal used as the action type-id. /// @param ... Optional: a `morph::model::Loggable` value (defaults to `Loggable::Yes`). -// NOLINTBEGIN(cppcoreguidelines-macro-usage) — registration macros are the intended public API // clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". #define BRIDGE_REGISTER_ACTION(...) \ BRIDGE_REGISTER_ACTION_PICK(__VA_ARGS__, BRIDGE_REGISTER_ACTION_4, BRIDGE_REGISTER_ACTION_3) \ @@ -1542,17 +1659,12 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio BRIDGE_REGISTER_ACTION_FOR_CLIENT_5(M, A, RESULT, NAME, ::morph::model::Loggable::Yes) // clang-format on -/// @brief Shared body for `ActionTraits`'s specialisation, its local-execute -/// registrar, and its dispatch-registry registrar — everything -/// `BRIDGE_REGISTER_ACTION_4` and `BRIDGE_REGISTER_ACTION_FOR_CLIENT_5` -/// expand to alike, parameterised only on how `Result` is spelled. -/// -/// `BRIDGE_REGISTER_ACTION_4` deduces `Result` from `M::execute(A)`, which -/// requires `M` complete at the call site; `BRIDGE_REGISTER_ACTION_FOR_CLIENT_5` -/// takes `RESULT_ALIAS` as an explicit type instead, so `M` may stay -/// declaration-only (see `BRIDGE_REGISTER_ACTION_FOR_CLIENT`'s doc comment). -/// That one line is the only difference between the two public macros; every -/// other member here is byte-identical between them. +/// @brief `ActionTraits`'s specialisation alone -- the type-only half +/// `BRIDGE_DETAIL_ACTION_TRAITS_BODY` decomposes into, reused directly +/// by `BRIDGE_DECLARE_ACTION` for the case that wants the trait +/// specialisation in a header without registering anything there. +/// Parameterised only on how `Result` is spelled, exactly like +/// `BRIDGE_DETAIL_ACTION_TRAITS_BODY` below. /// @param M Model type (see each public macro's own doc comment for /// the completeness requirement `RESULT_ALIAS` differs on). /// @param A Concrete action type. @@ -1562,7 +1674,7 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio /// @param NAME String literal used as the action type-id. /// @param LOGGABLE A `morph::model::Loggable` value. // clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". -#define BRIDGE_DETAIL_ACTION_TRAITS_BODY(M, A, RESULT_ALIAS, NAME, LOGGABLE) \ +#define BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY(M, A, RESULT_ALIAS, NAME, LOGGABLE) \ template <> \ struct morph::model::ActionTraits { \ using Result = RESULT_ALIAS; \ @@ -1625,14 +1737,61 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio } \ return result; \ } \ - }; \ - MORPH_DETAIL_REGISTER_ACTION_LOCAL(M, A, NAME) \ - namespace { \ - [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_action_exec_reg_, __COUNTER__) = \ - morph::model::detail::registerActionExecutorOnce(morph::model::ModelTraits::typeId(), NAME); \ + }; +// clang-format on + +/// @brief The two registrar blocks `BRIDGE_DETAIL_ACTION_TRAITS_BODY` appends +/// after `BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY`'s trait +/// specialisation, factored out so `BRIDGE_REGISTER_ACTION_SOURCE` can +/// emit exactly the same two registrars from a `.cpp`, without +/// repeating the trait specialisation `BRIDGE_DECLARE_ACTION` already +/// placed in the header. +/// +/// Reads `morph::model::ActionTraits::typeId()` rather than taking a +/// `NAME` parameter, so a `BRIDGE_DECLARE_ACTION`/`BRIDGE_REGISTER_ACTION_SOURCE` +/// pair cannot register under a different string than the one @p A's traits +/// specialisation actually reports -- there is only one place `NAME` is +/// spelled, in whichever macro declared the traits. +/// @param M Model type that handles the action. +/// @param A Concrete action type; `morph::model::ActionTraits` must already +/// be visible. +// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". +#define BRIDGE_DETAIL_ACTION_REGISTRARS(M, A) \ + MORPH_DETAIL_REGISTER_ACTION_LOCAL(M, A, morph::model::ActionTraits::typeId()) \ + namespace { \ + [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_action_exec_reg_, __COUNTER__) = \ + morph::model::detail::registerActionExecutorOnce(morph::model::ModelTraits::typeId(), \ + morph::model::ActionTraits::typeId()); \ } // clang-format on +/// @brief Shared body for `ActionTraits`'s specialisation plus its two +/// registrars -- everything `BRIDGE_REGISTER_ACTION_4` and +/// `BRIDGE_REGISTER_ACTION_FOR_CLIENT_5` expand to alike, parameterised +/// only on how `Result` is spelled. Composes +/// `BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY` (the type) with +/// `BRIDGE_DETAIL_ACTION_REGISTRARS` (the two registrars) -- the same +/// two pieces `BRIDGE_DECLARE_ACTION`/`BRIDGE_REGISTER_ACTION_SOURCE` +/// emit separately, in a header and a `.cpp` respectively. +/// +/// `BRIDGE_REGISTER_ACTION_4` deduces `Result` from `M::execute(A)`, which +/// requires `M` complete at the call site; `BRIDGE_REGISTER_ACTION_FOR_CLIENT_5` +/// takes `RESULT_ALIAS` as an explicit type instead, so `M` may stay +/// declaration-only (see `BRIDGE_REGISTER_ACTION_FOR_CLIENT`'s doc comment). +/// That one line is the only difference between the two public macros; every +/// other member here is byte-identical between them. +/// @param M Model type (see each public macro's own doc comment for +/// the completeness requirement `RESULT_ALIAS` differs on). +/// @param A Concrete action type. +/// @param RESULT_ALIAS Expression naming the action's result type -- either +/// `RESULT` (named explicitly) or the `decltype(...)` +/// deduction from `M::execute(A)`. +/// @param NAME String literal used as the action type-id. +/// @param LOGGABLE A `morph::model::Loggable` value. +#define BRIDGE_DETAIL_ACTION_TRAITS_BODY(M, A, RESULT_ALIAS, NAME, LOGGABLE) \ + BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY(M, A, RESULT_ALIAS, NAME, LOGGABLE) \ + BRIDGE_DETAIL_ACTION_REGISTRARS(M, A) + // clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". #define BRIDGE_REGISTER_ACTION_FOR_CLIENT_5(M, A, RESULT, NAME, LOGGABLE) \ BRIDGE_DETAIL_ACTION_TRAITS_BODY(M, A, RESULT, NAME, LOGGABLE) @@ -1644,14 +1803,89 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio #define BRIDGE_REGISTER_ACTION_3(M, A, NAME) BRIDGE_REGISTER_ACTION_4(M, A, NAME, ::morph::model::Loggable::Yes) +/// `Result` deduction shared by `BRIDGE_REGISTER_ACTION_4` and +/// `BRIDGE_DECLARE_ACTION_4` -- both need `M` complete with `execute(A)` +/// declared for the same reason, spelled once so the two cannot drift. +/// Wrapped in `HandlerResultT` so a `core::async::Task`-returning handler +/// resolves to `R` on both paths alike; without that, a declare-only header +/// and its out-of-line registration would disagree on the action's result +/// type for every coroutine handler. +#define BRIDGE_DETAIL_DEDUCED_ACTION_RESULT(M, A) \ + ::morph::model::HandlerResultT().execute(std::declval()))> + // clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". -#define BRIDGE_REGISTER_ACTION_4(M, A, NAME, LOGGABLE) \ - BRIDGE_DETAIL_ACTION_TRAITS_BODY( \ - M, A, ::morph::model::HandlerResultT().execute(std::declval()))>, NAME, \ - LOGGABLE) +#define BRIDGE_REGISTER_ACTION_4(M, A, NAME, LOGGABLE) \ + BRIDGE_DETAIL_ACTION_TRAITS_BODY(M, A, BRIDGE_DETAIL_DEDUCED_ACTION_RESULT(M, A), NAME, LOGGABLE) // clang-format on /// @endcond +/// @brief Declares action type @p A's `ActionTraits` specialisation +/// without registering it -- the header-safe half `BRIDGE_REGISTER_ACTION` +/// decomposes into. +/// +/// Same 3-or-4-argument shape as `BRIDGE_REGISTER_ACTION` (an optional +/// trailing `Loggable`) and the same `Result`-deduction requirement -- `M` +/// must be complete with `execute(A)` *declared* at this point, though (unlike +/// `BRIDGE_REGISTER_ACTION_SOURCE` below) not necessarily *defined*, since +/// nothing this macro emits calls it. Pair with +/// `BRIDGE_REGISTER_ACTION_SOURCE(M, A)` in the one `.cpp` that should +/// actually register @p A -- see `BRIDGE_DECLARE_MODEL`'s doc comment for why, +/// and docs/spec/core/registry.md, "Moving a registrar out of the header", for +/// the measured cost this exists to move out of the header. +/// +/// @param M Concrete model type that handles the action. +/// @param A Concrete action type. +/// @param NAME String literal used as the action type-id. +/// @param ... Optional: a `morph::model::Loggable` value (defaults to `Loggable::Yes`). +// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". +#define BRIDGE_DECLARE_ACTION(...) \ + BRIDGE_DECLARE_ACTION_PICK(__VA_ARGS__, BRIDGE_DECLARE_ACTION_4, BRIDGE_DECLARE_ACTION_3) \ + (__VA_ARGS__) +// clang-format on + +/// @cond detail +#define BRIDGE_DECLARE_ACTION_PICK(_1, _2, _3, _4, NAME, ...) NAME + +#define BRIDGE_DECLARE_ACTION_3(M, A, NAME) BRIDGE_DECLARE_ACTION_4(M, A, NAME, ::morph::model::Loggable::Yes) + +// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". +#define BRIDGE_DECLARE_ACTION_4(M, A, NAME, LOGGABLE) \ + BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY(M, A, BRIDGE_DETAIL_DEDUCED_ACTION_RESULT(M, A), NAME, LOGGABLE) \ + MORPH_DETAIL_REQUIRE_SOURCE_LOCAL(bridge_action_src_req_, \ + morph::model::detail::actionSourceRegistrationRequired()) +// clang-format on +/// @endcond + +/// @brief Registers action type @p A (for model @p M) -- previously declared +/// via `BRIDGE_DECLARE_ACTION(M, A, ...)` -- with the process-level +/// `ActionDispatcher` and `ActionExecuteRegistry` at static-init time. +/// +/// Call exactly once, in the one `.cpp` that owns @p M's registration. +/// Requires `morph::model::ActionTraits` already visible (from +/// `BRIDGE_DECLARE_ACTION` or `BRIDGE_REGISTER_ACTION` in an included header) +/// and, unlike `BRIDGE_DECLARE_ACTION`, requires @p M complete with +/// `execute(A)` *defined* -- the registered runner calls it. Reads +/// `ActionTraits::typeId()` rather than taking a `NAME` argument again, so +/// the two cannot register under a different string than the one @p A's +/// traits actually report. Suppressed under `MORPH_CLIENT_ONLY`, exactly like +/// the two registrars `BRIDGE_REGISTER_ACTION` emits directly. +/// +/// HARD REQUIREMENT: this macro's expansion unconditionally calls +/// `morph::model::detail::registerActionExecutorOnce`, exactly as +/// `BRIDGE_REGISTER_ACTION` does -- the translation unit calling it MUST +/// include `` (directly or transitively), or the build +/// fails to link with an unresolved external symbol for +/// `registerActionExecutorOnce`. +/// +/// @param M Concrete model type that handles the action, already declared via +/// `BRIDGE_DECLARE_ACTION`. +/// @param A Concrete action type, already declared via `BRIDGE_DECLARE_ACTION`. +// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". +#define BRIDGE_REGISTER_ACTION_SOURCE(M, A) \ + MORPH_DETAIL_DEFINE_SOURCE_LOCAL(morph::model::detail::actionSourceRegistrationRequired()) \ + BRIDGE_DETAIL_ACTION_REGISTRARS(M, A) +// clang-format on + /// @brief Registers a readiness predicate for action @p A. /// /// Specialises `morph::model::ActionValidator` so that @p FN gates @p A on every @@ -1667,5 +1901,6 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio static bool ready(const A& action) { return (FN)(action); } \ }; // clang-format on +// NOLINTEND(bugprone-throwing-static-initialization,cert-err58-cpp,misc-anonymous-namespace-in-header,cert-dcl59-cpp) // NOLINTEND(cppcoreguidelines-macro-usage) // NOLINTEND(bugprone-macro-parentheses) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e2aeef265..af3f65ff9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -510,6 +510,109 @@ target_link_libraries(morph_client_only_runtime_throw PRIVATE morph_guard_probe_ morph_suppress_test_dialogs(morph_client_only_runtime_throw) add_test(NAME morph_client_only_runtime_throw COMMAND morph_client_only_runtime_throw) set_tests_properties(morph_client_only_runtime_throw PROPERTIES TIMEOUT 60) +# ── BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION link-time canary ───────────── +# Configure-time proof that a model/action declared via BRIDGE_DECLARE_MODEL/ +# BRIDGE_DECLARE_ACTION but never registered via BRIDGE_REGISTER_MODEL_SOURCE/ +# BRIDGE_REGISTER_ACTION_SOURCE anywhere in the link fails to LINK -- see +# docs/spec/core/registry.md, "Moving a registrar out of the header". Reuses +# MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS, as the MORPH_CLIENT_ONLY guard above +# does. +# +# Two probes, in opposite directions, prove the canary does something real: +# - declare_only_no_source_link.cpp ALONE: must FAIL TO LINK. It declares a +# real, fully-defined model/action and never calls +# BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE for either. +# - declare_only_no_source_link.cpp PLUS declare_only_source_provided.cpp +# (which does call both SOURCE macros for the same model/action): must +# LINK. If this direction also failed, the first probe would be proving +# nothing about the canary specifically. +unset(MORPH_DECLARE_SOURCE_CANARY_MISSING_FAILS CACHE) +try_compile(MORPH_DECLARE_SOURCE_CANARY_MISSING_FAILS + SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/declare_only_no_source_link.cpp" + CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" + CXX_STANDARD 23 + OUTPUT_VARIABLE MORPH_DECLARE_SOURCE_CANARY_MISSING_OUTPUT +) +if(MORPH_DECLARE_SOURCE_CANARY_MISSING_FAILS) + message(FATAL_ERROR + "BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION canary check failed: " + "compile_checks/declare_only_no_source_link.cpp LINKED successfully " + "even though it never calls BRIDGE_REGISTER_MODEL_SOURCE/" + "BRIDGE_REGISTER_ACTION_SOURCE. The whole point of BRIDGE_DECLARE_* " + "is that a forgotten .cpp-side registration call fails to link " + "instead of silently compiling into a runtime \"unknown model " + "type\"/\"unknown action\" -- see docs/spec/core/registry.md, " + "\"Moving a registrar out of the header\".\n" + "--- compiler/linker output ---\n" + "${MORPH_DECLARE_SOURCE_CANARY_MISSING_OUTPUT}") +endif() + +unset(MORPH_DECLARE_SOURCE_CANARY_PROVIDED_LINKS CACHE) +try_compile(MORPH_DECLARE_SOURCE_CANARY_PROVIDED_LINKS + SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/declare_only_no_source_link.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/declare_only_source_provided.cpp" + CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" + CXX_STANDARD 23 + OUTPUT_VARIABLE MORPH_DECLARE_SOURCE_CANARY_PROVIDED_OUTPUT +) +if(NOT MORPH_DECLARE_SOURCE_CANARY_PROVIDED_LINKS) + message(FATAL_ERROR + "BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION canary check failed: " + "declare_only_no_source_link.cpp + declare_only_source_provided.cpp " + "failed to link, even though the latter calls " + "BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE for " + "exactly the model/action the former declares (so the canary check " + "above is not proving anything if this direction also fails).\n" + "--- compiler/linker output ---\n" + "${MORPH_DECLARE_SOURCE_CANARY_PROVIDED_OUTPUT}") +endif() + +# The two try_compile() checks above only prove the *registration-suppression* +# half of the guard (static-init-time link resolution). The other half -- +# that Bridge::executeVia's localOp actually throws std::logic_error at +# *runtime* under MORPH_CLIENT_ONLY, instead of silently calling +# Model::execute -- needs the probe to actually run, not just link. try_run() +# compiles AND executes compile_checks/client_only_runtime_throw.cpp, +# capturing its exit code (0 = the expected std::logic_error was caught). +unset(MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILED CACHE) +unset(MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE CACHE) +try_run(MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILED + "${CMAKE_CURRENT_BINARY_DIR}/client_only_runtime_throw_check" + "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/client_only_runtime_throw.cpp" + CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" + CXX_STANDARD 23 + COMPILE_DEFINITIONS "-DMORPH_CLIENT_ONLY" + # CMAKE_THREAD_LIBS_INIT (a raw linker-flag string set by the outer + # project's find_package(Threads REQUIRED) at CMakeLists.txt:102, e.g. + # "-lpthread" on Linux, empty on platforms needing nothing extra) rather + # than the Threads::Threads *target*: unlike Qt6::Core/Network/WebSockets + # (an exported find_package(Qt6) target, used the same way by the + # MORPH_QT_NO_SSL_GUARD_COMPILES check above), the CMake-bundled + # FindThreads module's imported target does not reliably resolve inside + # try_run()'s isolated scratch project on every platform -- confirmed by + # a real CI failure on Windows/MSVC ("Target ... links to: Threads::Threads + # ... but the target was not found"). + LINK_LIBRARIES "${CMAKE_THREAD_LIBS_INIT}" + COMPILE_OUTPUT_VARIABLE MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILE_OUTPUT + RUN_OUTPUT_VARIABLE MORPH_CLIENT_ONLY_RUNTIME_THROW_RUN_OUTPUT +) +if(NOT MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILED) + message(FATAL_ERROR + "MORPH_CLIENT_ONLY guard check failed: " + "compile_checks/client_only_runtime_throw.cpp failed to compile with " + "MORPH_CLIENT_ONLY defined.\n" + "--- compiler output ---\n" + "${MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILE_OUTPUT}") +endif() +if(NOT MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE EQUAL 0) + message(FATAL_ERROR + "MORPH_CLIENT_ONLY guard check failed: " + "compile_checks/client_only_runtime_throw.cpp did not observe the " + "expected std::logic_error from Bridge::executeVia's localOp under " + "MORPH_CLIENT_ONLY (exit code ${MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE}).\n" + "--- program output ---\n" + "${MORPH_CLIENT_ONLY_RUNTIME_THROW_RUN_OUTPUT}") +endif() # BRIDGE_REGISTER_ACTION_FOR_CLIENT lets a MORPH_CLIENT_ONLY client # register an action's ActionTraits (JSON codecs + an explicitly-named Result diff --git a/tests/compile_checks/declare_only_no_source_link.cpp b/tests/compile_checks/declare_only_no_source_link.cpp new file mode 100644 index 000000000..a2599699e --- /dev/null +++ b/tests/compile_checks/declare_only_no_source_link.cpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Compile/link-check fixture proving the BRIDGE_DECLARE_MODEL/ +// BRIDGE_DECLARE_ACTION link-time canary (see the try_compile() block in +// tests/CMakeLists.txt, and docs/spec/core/registry.md, "Moving a registrar +// out of the header"). This translation unit declares DeclareOnlyModel/ +// DeclareOnlyAction via declare_source_canary_shared.hpp but never calls +// BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE for them -- +// the exact mistake the canary exists to catch: a rung's model header moved +// its registrar call to a .cpp, and that .cpp was never written (or never +// linked into this target). +// +// Compiled alone, this must FAIL TO LINK with an unresolved external symbol +// naming morph::model::detail::modelSourceRegistrationRequired +// / actionSourceRegistrationRequired -- +// not compile clean and only fail at *runtime* the first time something +// dispatches "DeclareOnlyModel"/"DeclareOnlyAction" with +// ModelRegistryFactory::create's "unknown model type" (or +// ActionDispatcher::dispatch's "unknown action"), far from the actual cause. +// +// Compiled together with declare_only_source_provided.cpp (which does call +// both SOURCE macros for exactly this model/action), the same program must +// link and run cleanly -- the opposite-direction probe proving the canary +// is not vacuously unsatisfiable. +#include "declare_source_canary_shared.hpp" + +int main() { return 0; } diff --git a/tests/compile_checks/declare_only_source_provided.cpp b/tests/compile_checks/declare_only_source_provided.cpp new file mode 100644 index 000000000..d4dd4d322 --- /dev/null +++ b/tests/compile_checks/declare_only_source_provided.cpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Opposite-direction probe for declare_only_no_source_link.cpp (see that +// file's own comment, and tests/CMakeLists.txt's try_compile() block). Calls +// BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE for exactly the +// model/action declare_source_canary_shared.hpp declares, so a program built +// from this file plus declare_only_no_source_link.cpp must link and run +// cleanly -- proving the canary check is satisfiable, not merely never +// triggered. +#include "declare_source_canary_shared.hpp" + +BRIDGE_REGISTER_MODEL_SOURCE(DeclareOnlyModel) +BRIDGE_REGISTER_ACTION_SOURCE(DeclareOnlyModel, DeclareOnlyAction) diff --git a/tests/compile_checks/declare_source_canary_shared.hpp b/tests/compile_checks/declare_source_canary_shared.hpp new file mode 100644 index 000000000..da3730ddc --- /dev/null +++ b/tests/compile_checks/declare_source_canary_shared.hpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Shared fixture for the BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION link-time +// canary check (see the try_compile() block in tests/CMakeLists.txt). +// DeclareOnlyModel/DeclareOnlyAction are a real, fully-defined model and +// action -- unlike client_only_no_model_link.cpp's probe, nothing here is +// deliberately left undefined -- so the only thing that can make either of +// the two probes below fail to link is whether +// BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE was ever called +// for them anywhere in that probe's link. +// +// Uses BRIDGE_DECLARE_ACTION_4 directly rather than the public +// BRIDGE_DECLARE_ACTION(...) variadic-dispatch macro: MSVC's preprocessor +// emits "not enough arguments for function-like macro invocation +// BRIDGE_DECLARE_ACTION_PICK" (C4003) for the 3-arg form, the same MSVC +// quirk client_only_no_model_link.cpp's own comment documents for +// BRIDGE_REGISTER_ACTION -- confirmed here too (CI's Windows/cl-debug job). +// Calling _4 directly sidesteps the variadic dispatch while still exercising +// the exact declare/source split this probe exists to prove. +#pragma once + +#include +#include + +struct DeclareOnlyAction { + int x = 0; +}; + +struct DeclareOnlyModel { + int execute(const DeclareOnlyAction& action) { return action.x; } +}; + +BRIDGE_DECLARE_MODEL(DeclareOnlyModel, "DeclareOnlyModel") +BRIDGE_DECLARE_ACTION_4(DeclareOnlyModel, DeclareOnlyAction, "DeclareOnlyAction", ::morph::model::Loggable::Yes) From 95d2b49b7ffe6f544afd3f7a9cbf080aedc22492 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 26 Sep 2026 21:58:32 +0200 Subject: [PATCH 2/2] tests: link the declare/source canary's positive probe instead of try_compile A try_compile() scratch project cannot link core-cpp's compiled modules (established just above, for the MORPH_CLIENT_ONLY guard, when morph moved onto core-cpp v0.5.0). BRIDGE_REGISTER_ACTION_SOURCE routes through registerActionExecutorOnce's definition in bridge.hpp, which pulls in TimeoutScheduler and, through it, core::net -- so the "must link" probe needs the same real, linked-executable treatment as that guard's own positive probes, via morph_guard_probe_deps. The "must fail to link" probe stays a try_compile(): it never reaches Bridge's registration machinery, so it needs nothing beyond MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS. Also drops a stale try_run()-based client_only_runtime_throw check that had survived, unchanged, as merge-conflict context from before core-cpp v0.5.0 -- duplicating the add_executable()-based check already added above. --- scripts/check_coverage_objects.sh | 3 + tests/CMakeLists.txt | 92 +++++++------------------------ 2 files changed, 22 insertions(+), 73 deletions(-) diff --git a/scripts/check_coverage_objects.sh b/scripts/check_coverage_objects.sh index 7d948bd62..d6b1edae1 100755 --- a/scripts/check_coverage_objects.sh +++ b/scripts/check_coverage_objects.sh @@ -76,6 +76,9 @@ coverage_exclusion_reason() { morph_client_only_guard_links|morph_client_only_runtime_throw|morph_client_only_facade) echo "the MORPH_CLIENT_ONLY guard probes (tests/CMakeLists.txt), which exit 0 when the guard holds: configure-time try_compile()/try_run() checks until core-cpp's compiled modules made them build-time targets, and never measured then either; they compile morph's headers under MORPH_CLIENT_ONLY, so instrumenting them would score that configuration against the library's" ;; + morph_declare_source_canary) + echo "the BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION link canary's positive probe (tests/CMakeLists.txt): what it asserts is that the program links at all, and the fixture model it registers exists nowhere else in the tree, so instrumenting it would only score probe-unique template instantiations against the library" + ;; fuzz_wire_decode|fuzz_dispatch_execute) echo "libFuzzer harnesses (MORPH_BUILD_FUZZERS=ON only, which the coverage leg does not set); apply_fuzzer() builds them at -O1 under -fsanitize=fuzzer,address, a different instrumentation from apply_coverage()'s" ;; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index af3f65ff9..cbe7e0020 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -514,18 +514,23 @@ set_tests_properties(morph_client_only_runtime_throw PROPERTIES TIMEOUT 60) # Configure-time proof that a model/action declared via BRIDGE_DECLARE_MODEL/ # BRIDGE_DECLARE_ACTION but never registered via BRIDGE_REGISTER_MODEL_SOURCE/ # BRIDGE_REGISTER_ACTION_SOURCE anywhere in the link fails to LINK -- see -# docs/spec/core/registry.md, "Moving a registrar out of the header". Reuses -# MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS, as the MORPH_CLIENT_ONLY guard above -# does. +# docs/spec/core/registry.md, "Moving a registrar out of the header". # # Two probes, in opposite directions, prove the canary does something real: # - declare_only_no_source_link.cpp ALONE: must FAIL TO LINK. It declares a # real, fully-defined model/action and never calls -# BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE for either. +# BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE for either, +# so nothing in it reaches Bridge's registration machinery -- it stays a +# configure-time try_compile() against MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS, +# same as the MORPH_CLIENT_ONLY guard's own negative probe above. # - declare_only_no_source_link.cpp PLUS declare_only_source_provided.cpp # (which does call both SOURCE macros for the same model/action): must -# LINK. If this direction also failed, the first probe would be proving -# nothing about the canary specifically. +# LINK. This direction calls BRIDGE_REGISTER_ACTION_SOURCE, whose +# registrar needs registerActionExecutorOnce's definition in bridge.hpp +# and, through it, TimeoutScheduler's core-cpp compiled net module -- a +# try_compile() scratch project cannot link that (same reasoning as the +# MORPH_CLIENT_ONLY guard's positive probe above), so this direction is a +# real linked executable via morph_guard_probe_deps instead. unset(MORPH_DECLARE_SOURCE_CANARY_MISSING_FAILS CACHE) try_compile(MORPH_DECLARE_SOURCE_CANARY_MISSING_FAILS SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/declare_only_no_source_link.cpp" @@ -547,72 +552,13 @@ if(MORPH_DECLARE_SOURCE_CANARY_MISSING_FAILS) "${MORPH_DECLARE_SOURCE_CANARY_MISSING_OUTPUT}") endif() -unset(MORPH_DECLARE_SOURCE_CANARY_PROVIDED_LINKS CACHE) -try_compile(MORPH_DECLARE_SOURCE_CANARY_PROVIDED_LINKS - SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/declare_only_no_source_link.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/declare_only_source_provided.cpp" - CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" - CXX_STANDARD 23 - OUTPUT_VARIABLE MORPH_DECLARE_SOURCE_CANARY_PROVIDED_OUTPUT -) -if(NOT MORPH_DECLARE_SOURCE_CANARY_PROVIDED_LINKS) - message(FATAL_ERROR - "BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION canary check failed: " - "declare_only_no_source_link.cpp + declare_only_source_provided.cpp " - "failed to link, even though the latter calls " - "BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE for " - "exactly the model/action the former declares (so the canary check " - "above is not proving anything if this direction also fails).\n" - "--- compiler/linker output ---\n" - "${MORPH_DECLARE_SOURCE_CANARY_PROVIDED_OUTPUT}") -endif() - -# The two try_compile() checks above only prove the *registration-suppression* -# half of the guard (static-init-time link resolution). The other half -- -# that Bridge::executeVia's localOp actually throws std::logic_error at -# *runtime* under MORPH_CLIENT_ONLY, instead of silently calling -# Model::execute -- needs the probe to actually run, not just link. try_run() -# compiles AND executes compile_checks/client_only_runtime_throw.cpp, -# capturing its exit code (0 = the expected std::logic_error was caught). -unset(MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILED CACHE) -unset(MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE CACHE) -try_run(MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILED - "${CMAKE_CURRENT_BINARY_DIR}/client_only_runtime_throw_check" - "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/client_only_runtime_throw.cpp" - CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" - CXX_STANDARD 23 - COMPILE_DEFINITIONS "-DMORPH_CLIENT_ONLY" - # CMAKE_THREAD_LIBS_INIT (a raw linker-flag string set by the outer - # project's find_package(Threads REQUIRED) at CMakeLists.txt:102, e.g. - # "-lpthread" on Linux, empty on platforms needing nothing extra) rather - # than the Threads::Threads *target*: unlike Qt6::Core/Network/WebSockets - # (an exported find_package(Qt6) target, used the same way by the - # MORPH_QT_NO_SSL_GUARD_COMPILES check above), the CMake-bundled - # FindThreads module's imported target does not reliably resolve inside - # try_run()'s isolated scratch project on every platform -- confirmed by - # a real CI failure on Windows/MSVC ("Target ... links to: Threads::Threads - # ... but the target was not found"). - LINK_LIBRARIES "${CMAKE_THREAD_LIBS_INIT}" - COMPILE_OUTPUT_VARIABLE MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILE_OUTPUT - RUN_OUTPUT_VARIABLE MORPH_CLIENT_ONLY_RUNTIME_THROW_RUN_OUTPUT -) -if(NOT MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILED) - message(FATAL_ERROR - "MORPH_CLIENT_ONLY guard check failed: " - "compile_checks/client_only_runtime_throw.cpp failed to compile with " - "MORPH_CLIENT_ONLY defined.\n" - "--- compiler output ---\n" - "${MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILE_OUTPUT}") -endif() -if(NOT MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE EQUAL 0) - message(FATAL_ERROR - "MORPH_CLIENT_ONLY guard check failed: " - "compile_checks/client_only_runtime_throw.cpp did not observe the " - "expected std::logic_error from Bridge::executeVia's localOp under " - "MORPH_CLIENT_ONLY (exit code ${MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE}).\n" - "--- program output ---\n" - "${MORPH_CLIENT_ONLY_RUNTIME_THROW_RUN_OUTPUT}") -endif() +add_executable(morph_declare_source_canary + compile_checks/declare_only_no_source_link.cpp + compile_checks/declare_only_source_provided.cpp) +target_link_libraries(morph_declare_source_canary PRIVATE morph_guard_probe_deps) +morph_suppress_test_dialogs(morph_declare_source_canary) +add_test(NAME morph_declare_source_canary COMMAND morph_declare_source_canary) +set_tests_properties(morph_declare_source_canary PROPERTIES TIMEOUT 60) # BRIDGE_REGISTER_ACTION_FOR_CLIENT lets a MORPH_CLIENT_ONLY client # register an action's ActionTraits (JSON codecs + an explicitly-named Result @@ -633,7 +579,7 @@ set_tests_properties(morph_client_only_facade PROPERTIES TIMEOUT 60) # program linking them fails with the sanitizer runtime undefined. if(DEFINED AF_SANITIZER) foreach(_morph_guard_probe IN ITEMS morph_client_only_guard_links morph_client_only_runtime_throw - morph_client_only_facade) + morph_client_only_facade morph_declare_source_canary) apply_sanitizers(${_morph_guard_probe} ${AF_SANITIZER}) endforeach() unset(_morph_guard_probe)