From f18d6bb22c653f9f41e4f9c6ca7ac84056446dbb Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 20:46:32 +0300 Subject: [PATCH 1/5] core: add MORPH_CLIENT_ONLY to suppress model-owning registrars BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION emit three registrars per action. registerModelOnce's factory calls ModelFactory::create(), and registerActionOnce's runner calls Model::execute(...) on a live holder -- both are ordinary functions the compiler must fully compile into their stored closures regardless of whether those closures are ever invoked at runtime, so even a pure client that dispatches every action to a remote peer and never constructs a model locally still forces the linker to resolve the model's constructor and execute() bodies. Those routinely depend on a platform stack the client target doesn't have (a database driver, a native UI framework, an OS-specific API) -- for a browser/WASM build they don't exist at all, so the link cannot be satisfied. Add a MORPH_CLIENT_ONLY CMake option that, when ON, defines MORPH_CLIENT_ONLY on the morph target's INTERFACE (never per-consumer, since two TUs disagreeing would violate ODR) and suppresses registerModelOnce/ registerActionOnce's emission from the two macros. ModelTraits/ ActionTraits (type-ids, JSON codecs) are still specialised exactly as before -- only the two registrar bodies disappear. The suggested third suppression target, registerActionExecutorOnce, turned out to need different treatment than expected: it routes through BridgeHandler::execute() -> Bridge::executeVia, and executeVia unconditionally constructs an ActionCall::localOp closure that calls Model::execute directly, regardless of which backend ends up installed at runtime (only LocalBackend::execute ever invokes it; every remote backend ignores it). That closure is what actually needs the model's execute() body -- confirmed empirically by building a probe with the model's constructor/execute declared but never defined anywhere in the link: it failed exactly as expected without the guard, referencing both symbols from inside executeVia's instantiation, not from registerActionExecutorOnce. Gating executeVia's localOp on MORPH_CLIENT_ONLY (throwing instead of calling Model::execute) closes this properly, so both the typed BridgeHandler::execute() and the type-erased executeJson path work against a remote backend in a client-only build; LocalBackend must not be used in one. Verified in both directions via a new try_compile() guard (tests/CMakeLists.txt) against tests/compile_checks/client_only_no_model_link.cpp: links successfully with MORPH_CLIENT_ONLY defined, fails to link (both symbols genuinely unresolved) without it. NEVER define MORPH_CLIENT_ONLY for a process that hosts models (a server, or any Bridge running LocalBackend) -- it silently registers nothing, and the model fails at runtime with "unknown model type" (or, if LocalBackend's executeVia path is reached anyway, a clear std::logic_error) rather than at compile/link time. Closes #27 Signed-off-by: Yaraslau Tamashevich --- CMakeLists.txt | 27 ++++++ docs/spec/core/bridge.md | 36 ++++++-- docs/spec/core/registry.md | 92 ++++++++++++++++++- include/morph/core/bridge.hpp | 15 +++ include/morph/core/registry.hpp | 64 ++++++++++--- tests/CMakeLists.txt | 49 ++++++++++ .../client_only_no_model_link.cpp | 30 ++++++ 7 files changed, 287 insertions(+), 26 deletions(-) create mode 100644 tests/compile_checks/client_only_no_model_link.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7662953e..958f841c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,6 +56,30 @@ option(MORPH_REQUIRE_VETTED_HMAC OFF ) +# Opt-in linker-footprint guard for a pure client -- one that dispatches every +# action to a remote peer and never constructs a model locally. Registering a +# model normally emits two registrars (registerModelOnce, registerActionOnce) +# whose bodies call the model's constructor and Model::execute -- pulling in +# implementations (a database driver, a native UI framework, an OS-specific +# API) a client target may not even have a link path for, and definitely never +# calls. The third registrar (registerActionExecutorOnce) routes through +# Bridge::executeVia -> IBackend::execute (fully abstract) and needs only the +# action's JSON codecs plus Model::execute's *declaration* -- never its body +# -- so it stays emitted regardless. See docs/spec/core/registry.md, +# "MORPH_CLIENT_ONLY". +# +# On the INTERFACE of the morph target, not per-consumer: the macro changes +# which registrars a model header emits, so two translation units disagreeing +# about it would violate ODR. Off by default: the standard build is unaffected. +# +# NEVER enable this for a process that hosts models (a server, or any Bridge +# running LocalBackend) -- it would silently register nothing, failing at +# runtime with "unknown model type" instead of at compile/link time. +option(MORPH_CLIENT_ONLY + "Suppress the model-owning registrars so a pure remote client need not link model constructor/execute bodies. NEVER enable for a process that hosts models." + OFF +) + # Both Qt consumers (the WebSocket backend and the forms QML renderer) need # Qt6 auto-detected on Windows before their find_package(Qt6 ...) calls run. if(WIN32 AND (MORPH_BUILD_QT OR MORPH_BUILD_FORMS_QML)) @@ -117,6 +141,9 @@ endif() if(MORPH_REQUIRE_VETTED_HMAC) target_compile_definitions(morph INTERFACE MORPH_REQUIRE_VETTED_HMAC) endif() +if(MORPH_CLIENT_ONLY) + target_compile_definitions(morph INTERFACE MORPH_CLIENT_ONLY) +endif() set_target_properties(morph PROPERTIES VERIFY_INTERFACE_HEADER_SETS ON diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index 40ebc9b5..921c2590 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -109,16 +109,32 @@ with; actions with no validator are unaffected (`ready()` defaults to `true`). No JSON is involved on this path, so there is no declared-precision reconciliation step here (that only applies to decoded wire payloads); the `Quantity` fields carry whatever precision the caller constructed them with. -`Model::execute(*action)` itself is wrapped in a `try`/`catch -(const std::exception&)`: on success it records a journal `LogEntry` with -`outcome = Outcome::Succeeded` for loggable actions; on a throw it records -`outcome = Outcome::Failed` (`error = exc.what()`, `result` empty) for the -same actions and rethrows unchanged, so the exception still resolves the -`Completion` through `onError` exactly as before — the journal entry is a -side effect of the attempt, not a change to error propagation. Mirrors -`ActionDispatcher::registerAction`'s runner (`registry.md`) for remote -topologies. See [journal.md, "Outcome"](../journal/journal.md#logentry--one-recorded-action-execution) -for the full field/replay semantics. The typed result is unwrapped from `std::shared_ptr` +**`localOp` is compiled — and so needs `Model::execute`'s definition to +link — every time `executeVia` is instantiated, regardless of +which backend ends up installed at runtime.** Only `LocalBackend::execute` +ever actually calls `call.localOp`; every remote backend ignores it entirely. +But the closure itself is still compiled into the instantiation, so a build +that only ever installs a remote backend still forces the linker to resolve +`Model::execute` — see `registry.md`, "`MORPH_CLIENT_ONLY`". When +`MORPH_CLIENT_ONLY` is defined, `localOp`'s body is replaced with a +`std::logic_error` throw instead of the block described below, so nothing in +the compiled program references `Model::execute`'s definition. Reaching that +throw at runtime means `LocalBackend` was used in a build that promised never +to — a configuration error, not a normal failure mode. + +Otherwise (the default, non-`MORPH_CLIENT_ONLY` build), `Model::execute(*action)` +itself is wrapped in a `try`/`catch (const std::exception&)`: on success it +records a journal `LogEntry` with `outcome = Outcome::Succeeded` for loggable +actions; on a throw it records `outcome = Outcome::Failed` (`error = +exc.what()`, `result` empty) for the same actions and rethrows unchanged, so +the exception still resolves the `Completion` through `onError` exactly as +before — the journal entry is a side effect of the attempt, not a change to +error propagation. Mirrors `ActionDispatcher::registerAction`'s runner +(`registry.md`) for remote topologies. See [journal.md, +"Outcome"](../journal/journal.md#logentry--one-recorded-action-execution) for +the full field/replay semantics. + +The typed result is unwrapped from `std::shared_ptr` into the final `Completion` inside a `try`/`catch`: moving the result out of the opaque `shared_ptr` can throw (a throwing move/copy on `R`, or a bad cast), and if that exception escaped the `.then` callback it would be swallowed diff --git a/docs/spec/core/registry.md b/docs/spec/core/registry.md index 64a45508..551eb7f1 100644 --- a/docs/spec/core/registry.md +++ b/docs/spec/core/registry.md @@ -26,6 +26,7 @@ without knowing their concrete types. - [ModelRegistryFactory](#modelregistryfactory) - [ActionExecuteRegistry](#actionexecuteregistry) - [Registration macros](#registration-macros) +- [`MORPH_CLIENT_ONLY` — suppressing model-owning registrars](#morph_client_only--suppressing-model-owning-registrars) - [BRIDGE_REGISTER_MODEL](#bridge_register_model) - [BRIDGE_REGISTER_ACTION](#bridge_register_action) - [BRIDGE_REGISTER_VALIDATOR](#bridge_register_validator) @@ -500,8 +501,10 @@ BRIDGE_REGISTER_MODEL(AccountModel, "Account") Expands to: - `template <> struct morph::model::ModelTraits { static constexpr std::string_view typeId() noexcept { return NAME; } };` -- A `[[maybe_unused]] const bool` in an anonymous namespace (internal linkage, - no explicit `static`) that calls `detail::registerModelOnce(NAME)`. +- Unless `MORPH_CLIENT_ONLY` is defined: a `[[maybe_unused]] const bool` in an + anonymous namespace (internal linkage, no explicit `static`) that calls + `detail::registerModelOnce(NAME)`. See + [`MORPH_CLIENT_ONLY`](#morph_client_only--suppressing-model-owning-registrars). ### `BRIDGE_REGISTER_ACTION(M, A, NAME, ...)` @@ -524,11 +527,14 @@ Expands to: forward-compatibility convention `wire::decode` uses (see wire.md, "Action-evolution policy") — so an older-compiled action struct silently ignores an additive field a newer peer sent. -- A `[[maybe_unused]] const bool` in an anonymous namespace calling +- Unless `MORPH_CLIENT_ONLY` is defined: a `[[maybe_unused]] const bool` in an + anonymous namespace calling `detail::registerActionOnce(morph::model::ModelTraits::typeId(), NAME)` (the model-id argument is the model's registered `typeId()`, not a raw string). + See [`MORPH_CLIENT_ONLY`](#morph_client_only--suppressing-model-owning-registrars). - A `[[maybe_unused]] const bool` in an anonymous namespace calling - `detail::registerActionExecutorOnce(morph::model::ModelTraits::typeId(), NAME)`. + `detail::registerActionExecutorOnce(morph::model::ModelTraits::typeId(), NAME)` + — always emitted, `MORPH_CLIENT_ONLY` or not. **Hard requirement:** Every translation unit invoking `BRIDGE_REGISTER_ACTION` must include `` (directly or transitively) because @@ -547,6 +553,84 @@ BRIDGE_REGISTER_VALIDATOR(FormAction, [](const FormAction& a) { Expands to `template <> struct morph::model::ActionValidator { static bool ready(const A& action) { return (FN)(action); } };`. +## `MORPH_CLIENT_ONLY` — suppressing model-owning registrars + +A pure client — one that dispatches every action to a remote peer and never +constructs a model locally — has no use for two of the three registrars +`BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` normally emit: + +- `registerModelOnce` stores a factory (`[] { return ModelFactory::create(); }`) + in the process-level `ModelRegistryFactory`, used by `LocalBackend`/`RemoteServer` + to construct a live instance. +- `registerActionOnce` stores a runner in the process-level + `ActionDispatcher` that calls `Model::execute(...)` directly on a live + holder — the server-side dispatch path `RemoteServer` uses. + +Both are ordinary functions the compiler must fully compile into the stored +closure regardless of whether that closure is ever *invoked* at runtime — +so even a build that never constructs a model locally still forces the +linker to resolve the model's constructor and `execute()` bodies, pulling in +whatever those depend on (a database driver, a native UI framework, an +OS-specific API) — dependencies a client target may have no link path for at +all (a browser/WASM build in particular), and will never call regardless. + +Defining `MORPH_CLIENT_ONLY` (via the CMake option of the same name, which +adds it to the `morph` target's `INTERFACE` compile definitions) suppresses +both. `BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` still specialise +`ModelTraits`/`ActionTraits` (type-ids, JSON codecs) exactly as before — +only the two registrar bodies above disappear. + +**The third registrar needed the same treatment, contrary to first +appearances.** `registerActionExecutorOnce` routes through +`BridgeHandler::execute()` → `Bridge::executeVia` +— and `executeVia` unconditionally constructs an `ActionCall::localOp` closure +that calls `Model::execute(...)` directly, *regardless of which backend ends +up installed at runtime* (only `LocalBackend::execute` ever actually invokes +`call.localOp`; every remote backend ignores it). That closure is compiled +into `executeVia`'s instantiation the moment any code calls +`BridgeHandler::execute()` — which the type-erased +`ActionExecuteRegistry` executor `registerActionExecutorOnce` installs +*also* does, internally, to serve `executeJson`. So merely suppressing the +first two registrars is not sufficient to make a client-only build link if it +uses `BridgeHandler::execute()` (the typed API) or `executeJson` (the +type-erased API) at all — both routes reach the same `model.execute(...)` +call inside `executeVia`. + +`Bridge::executeVia`'s `localOp` closure is therefore itself gated on +`MORPH_CLIENT_ONLY` (`bridge.hpp`): under the macro, the closure throws +`std::logic_error` instead of calling `Model::execute`, so nothing in the +compiled program ever references its definition. This is *not* a +per-registration-site choice like the two macros above — it lives inside +`executeVia` itself, compiled once per `(Model, Action)` instantiation, +consistently for the whole link (exactly the "carried on the interface, not +per-consumer" requirement below). + +**Confirmed empirically** (see `tests/compile_checks/client_only_no_model_link.cpp` +and the `try_compile()` probes in `tests/CMakeLists.txt`): a model whose +constructor and `execute()` are declared but never defined anywhere in the +link succeeds when built with `MORPH_CLIENT_ONLY` defined, and fails to link +(both symbols genuinely referenced) when built without it. + +**Must be carried on the `morph` target's `INTERFACE`, never per-consumer.** +The macro changes which registrars a model header emits; two translation +units disagreeing about it — one linking `registerModelOnce`'s closure, the +other not — would be an ODR violation (the closures wouldn't even have the +same instantiated members). The `MORPH_CLIENT_ONLY` CMake option sets it via +`target_compile_definitions(morph INTERFACE MORPH_CLIENT_ONLY)`, so every +consumer of the `morph::morph` target sees the identical definition. + +**Must never be defined for a process that hosts models.** A server, or any +`Bridge` running `LocalBackend`, would silently register nothing: +`ModelRegistryFactory::create`/`ActionDispatcher::dispatch` would fail at +*runtime* with `"unknown model type"` — far from the actual cause — rather +than failing to compile or link. `Bridge::executeVia`'s `localOp` closure +throwing `std::logic_error` if ever reached is a second line of defence for +exactly this mistake: a `MORPH_CLIENT_ONLY` build that somehow still ends up +running `LocalBackend` gets a clear, immediate diagnostic instead of a +"model not found" red herring. + +Off by default: the standard build (and every existing consumer) is unaffected. + ## API reference ### Traits and policies diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 9f942fc6..9f8331a7 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -719,6 +719,20 @@ class Bridge { throw ::morph::model::ValidationError{::morph::model::ModelTraits::typeId(), ::morph::model::ActionTraits::typeId()}; } +#ifdef MORPH_CLIENT_ONLY + // A MORPH_CLIENT_ONLY build never links Model::execute's definition + // (see docs/spec/core/registry.md, "MORPH_CLIENT_ONLY") -- this + // #ifdef, not just the registration macros, is what actually makes + // that true: ActionCall::localOp is constructed unconditionally + // here regardless of which backend ends up installed, so the + // `model.execute(...)` call below would otherwise still force the + // linker to resolve it even for a build that only ever installs a + // remote backend. LocalBackend must not be used in such a build; + // reaching this point means it was anyway. + static_cast(holder); + throw std::logic_error( + "Bridge::executeVia: localOp invoked in a MORPH_CLIENT_ONLY build -- LocalBackend must not be used"); +#else auto& model = holder.template into(); // Local mode has no client/server split, so this is the same execution // site `ActionDispatcher::registerAction`'s runner is for remote modes @@ -765,6 +779,7 @@ class Bridge { } throw; } +#endif }; { std::scoped_lock const lock{_sessionMtx}; diff --git a/include/morph/core/registry.hpp b/include/morph/core/registry.hpp index 0831f22f..a530b15f 100644 --- a/include/morph/core/registry.hpp +++ b/include/morph/core/registry.hpp @@ -428,22 +428,63 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio #define BRIDGE_DETAIL_CAT_(a, b) a##b #define BRIDGE_DETAIL_CAT(a, b) BRIDGE_DETAIL_CAT_(a, b) -/// @brief Registers model type @p M with the string id @p NAME. +/// @brief Suppresses the two registrars whose bodies reference a model's +/// constructor/`execute()` definitions (`registerModelOnce`, +/// `registerActionOnce`), for a pure client that dispatches every +/// action to a remote peer and never constructs a model locally. /// -/// Specialises `morph::model::ModelTraits` and registers a factory with the -/// process-level `ModelRegistryFactory` at static-init time. +/// A model's constructor and `Model::execute` are ordinary functions the +/// compiler emits a call to inside these registrars' lambda bodies, whether +/// or not that lambda is ever invoked at runtime — so a plain client-only +/// build still forces the linker to resolve them, pulling in implementations +/// (a database driver, a native UI framework, an OS-specific API) the client +/// target may have no link path for at all, and will never call regardless. +/// The third registrar (`registerActionExecutorOnce`, `bridge.hpp`) routes +/// through `Bridge::executeVia` -> `IBackend::execute` (fully abstract) and +/// needs only the action's JSON codecs plus `Model::execute`'s *declaration* +/// (for `ActionTraits::Result`, via `decltype`) — never its definition — so +/// it is unaffected and always emitted. /// -/// @param M Concrete model type. -/// @param NAME String literal used as the type-id. -#define BRIDGE_REGISTER_MODEL(M, NAME) \ - template <> \ - struct morph::model::ModelTraits { \ - static constexpr std::string_view typeId() noexcept { return NAME; } \ - }; \ +/// Defined on the `morph` target's INTERFACE via the `MORPH_CLIENT_ONLY` +/// CMake option, never per translation unit: two TUs disagreeing on whether a +/// model registers itself would violate ODR. See docs/spec/core/registry.md, +/// "MORPH_CLIENT_ONLY". +/// +/// @warning NEVER define this for a process that hosts models (a server, or +/// any `Bridge` running `LocalBackend`) — it silently registers nothing, and +/// the model fails at runtime with "unknown model type" rather than at +/// compile/link time. +#ifdef MORPH_CLIENT_ONLY +#define MORPH_DETAIL_REGISTER_MODEL_LOCAL(M, NAME) +#define MORPH_DETAIL_REGISTER_ACTION_LOCAL(M, A, NAME) +#else +#define MORPH_DETAIL_REGISTER_MODEL_LOCAL(M, NAME) \ namespace { \ [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_model_reg_, __COUNTER__) = \ morph::model::detail::registerModelOnce(NAME); \ } +#define MORPH_DETAIL_REGISTER_ACTION_LOCAL(M, A, NAME) \ + namespace { \ + [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_action_reg_, __COUNTER__) = \ + morph::model::detail::registerActionOnce(morph::model::ModelTraits::typeId(), NAME); \ + } +#endif + +/// @brief Registers model type @p M with the string id @p NAME. +/// +/// Specialises `morph::model::ModelTraits` and registers a factory with the +/// process-level `ModelRegistryFactory` at static-init time — unless +/// `MORPH_CLIENT_ONLY` is defined, in which case that registrar is suppressed +/// (see `MORPH_DETAIL_REGISTER_MODEL_LOCAL`'s doc comment above). +/// +/// @param M Concrete model type. +/// @param NAME String literal used as the type-id. +#define BRIDGE_REGISTER_MODEL(M, NAME) \ + template <> \ + struct morph::model::ModelTraits { \ + static constexpr std::string_view typeId() noexcept { return NAME; } \ + }; \ + MORPH_DETAIL_REGISTER_MODEL_LOCAL(M, NAME) /// @brief Registers action type @p A (for model @p M) with the string id @p NAME. /// @@ -522,9 +563,8 @@ 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_reg_, __COUNTER__) = \ - morph::model::detail::registerActionOnce(morph::model::ModelTraits::typeId(), NAME); \ [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_action_exec_reg_, __COUNTER__) = \ morph::model::detail::registerActionExecutorOnce(morph::model::ModelTraits::typeId(), NAME); \ } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7fe1ac70..1df147ee 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -212,3 +212,52 @@ if(NOT MORPH_VETTED_HMAC_GUARD_DEFAULT_WORKS_UNGATED) "without MORPH_REQUIRE_VETTED_HMAC defined (regression: the default " "hmacSha256 argument must keep working when the option is off).") endif() + +# ── MORPH_CLIENT_ONLY guard check ──────────────────────────────────────────── +# Configure-time proof that MORPH_CLIENT_ONLY actually suppresses the two +# model-owning registrars (registerModelOnce, registerActionOnce) -- see +# docs/spec/core/registry.md, "MORPH_CLIENT_ONLY". Reuses +# MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS (morph + glaze include dirs; see that +# guard's own comments above for why try_compile needs them forwarded this +# way rather than via LINK_LIBRARIES). +# +# compile_checks/client_only_no_model_link.cpp declares (never defines) +# ClientOnlyModel's constructor and execute(). Two probes, in opposite +# directions, prove the guard does something real rather than the probe being +# vacuously satisfied either way: +# - WITH MORPH_CLIENT_ONLY defined: must LINK (the model-owning registrars +# are macroed away, so nothing in the program ever references either +# undefined symbol). +# - WITHOUT it (today's default): must FAIL TO LINK (the registrars' lambda +# bodies do reference them, and the linker cannot resolve either symbol). +unset(MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE CACHE) +try_compile(MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE + SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/client_only_no_model_link.cpp" + CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" + CXX_STANDARD 23 + COMPILE_DEFINITIONS "-DMORPH_CLIENT_ONLY" +) +if(NOT MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE) + message(FATAL_ERROR + "MORPH_CLIENT_ONLY guard check failed: " + "compile_checks/client_only_no_model_link.cpp failed to link with " + "MORPH_CLIENT_ONLY defined, even though ClientOnlyModel's constructor " + "and execute() are never called anywhere in that program (the " + "model-owning registrars must be fully suppressed).") +endif() + +unset(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE CACHE) +try_compile(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE + SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/client_only_no_model_link.cpp" + CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" + CXX_STANDARD 23 +) +if(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE) + message(FATAL_ERROR + "MORPH_CLIENT_ONLY guard check failed: " + "compile_checks/client_only_no_model_link.cpp linked successfully " + "WITHOUT MORPH_CLIENT_ONLY defined (expected a link failure -- the " + "probe's undefined constructor/execute() should be referenced by " + "the default (non-client-only) registrars; if it links anyway, this " + "probe is not actually proving anything about the guard).") +endif() diff --git a/tests/compile_checks/client_only_no_model_link.cpp b/tests/compile_checks/client_only_no_model_link.cpp new file mode 100644 index 00000000..9fa1d9c4 --- /dev/null +++ b/tests/compile_checks/client_only_no_model_link.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Compile/link-check fixture for the MORPH_CLIENT_ONLY guard (see the +// try_compile() block at the end of tests/CMakeLists.txt). ClientOnlyModel's +// constructor and execute() are declared but never defined anywhere in this +// link. If MORPH_CLIENT_ONLY successfully suppresses the two model-owning +// registrars (registerModelOnce, registerActionOnce -- see +// docs/spec/core/registry.md, "MORPH_CLIENT_ONLY"), nothing in this program +// ever references either symbol and it links; without the guard, both +// registrars' lambda bodies call them, and the link fails with an unresolved +// external symbol. + +#include +#include + +struct ClientOnlyAction { + int x = 0; +}; + +struct ClientOnlyModel { + ClientOnlyModel(); // declared, deliberately never defined + int execute(const ClientOnlyAction& action); // declared, deliberately never defined +}; + +BRIDGE_REGISTER_MODEL(ClientOnlyModel, "ClientOnlyModel") +BRIDGE_REGISTER_ACTION(ClientOnlyModel, ClientOnlyAction, "ClientOnlyAction") + +int main() { + return 0; +} From 9095025012f5e9cb781b4cde03f86491bd1cdda5 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 21:59:41 +0300 Subject: [PATCH 2/5] tests: surface compiler/linker output on MORPH_CLIENT_ONLY guard failure The client_only_no_model_link.cpp try_compile() checks currently swallow the underlying compiler/linker diagnostics on failure -- CI only shows the FATAL_ERROR summary, not why the probe actually failed. Capture OUTPUT_VARIABLE and print it so a failure (e.g. the Windows/cl-debug and Windows/cl-release failures on this PR) is self-diagnosing. Signed-off-by: Yaraslau Tamashevich --- tests/CMakeLists.txt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1df147ee..2dee41a1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -236,6 +236,7 @@ try_compile(MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" CXX_STANDARD 23 COMPILE_DEFINITIONS "-DMORPH_CLIENT_ONLY" + OUTPUT_VARIABLE MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE_OUTPUT ) if(NOT MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE) message(FATAL_ERROR @@ -243,7 +244,9 @@ if(NOT MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE) "compile_checks/client_only_no_model_link.cpp failed to link with " "MORPH_CLIENT_ONLY defined, even though ClientOnlyModel's constructor " "and execute() are never called anywhere in that program (the " - "model-owning registrars must be fully suppressed).") + "model-owning registrars must be fully suppressed).\n" + "--- compiler/linker output ---\n" + "${MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE_OUTPUT}") endif() unset(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE CACHE) @@ -251,6 +254,7 @@ try_compile(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/client_only_no_model_link.cpp" CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" CXX_STANDARD 23 + OUTPUT_VARIABLE MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE_OUTPUT ) if(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE) message(FATAL_ERROR @@ -259,5 +263,7 @@ if(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE) "WITHOUT MORPH_CLIENT_ONLY defined (expected a link failure -- the " "probe's undefined constructor/execute() should be referenced by " "the default (non-client-only) registrars; if it links anyway, this " - "probe is not actually proving anything about the guard).") + "probe is not actually proving anything about the guard).\n" + "--- compiler/linker output ---\n" + "${MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE_OUTPUT}") endif() From fac7621ceb1de9923437c6039423cf25d38b081d Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 22:18:12 +0300 Subject: [PATCH 3/5] tests: avoid PICK-macro variadic dispatch in MORPH_CLIENT_ONLY probe MSVC's cl.exe failed the MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE try_compile with hard compile errors (C2143/C4430/C2059) attributed to the BRIDGE_REGISTER_ACTION(...) call. The preceding warning names BRIDGE_REGISTER_ACTION_PICK directly ("not enough arguments for function-like macro invocation"), a known MSVC quirk with the variadic-count-dispatch idiom that's otherwise benign everywhere else in the suite -- but combined with MORPH_CLIENT_ONLY's empty MORPH_DETAIL_REGISTER_ACTION_LOCAL expansion, it produced a hard failure on cl.exe specifically (Clang/GCC/clang-cl were unaffected). Call BRIDGE_REGISTER_ACTION_4 directly, bypassing the PICK dispatch, since the guard only needs to exercise the registrar-suppression path, not the public macro's arity-dispatch mechanism. Signed-off-by: Yaraslau Tamashevich --- tests/compile_checks/client_only_no_model_link.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/compile_checks/client_only_no_model_link.cpp b/tests/compile_checks/client_only_no_model_link.cpp index 9fa1d9c4..f52c3e77 100644 --- a/tests/compile_checks/client_only_no_model_link.cpp +++ b/tests/compile_checks/client_only_no_model_link.cpp @@ -9,6 +9,16 @@ // ever references either symbol and it links; without the guard, both // registrars' lambda bodies call them, and the link fails with an unresolved // external symbol. +// +// Uses BRIDGE_REGISTER_ACTION_4 directly rather than the public +// BRIDGE_REGISTER_ACTION(...) variadic-dispatch macro: MSVC's preprocessor +// emits "not enough arguments for function-like macro invocation +// BRIDGE_REGISTER_ACTION_PICK" (C4003) for the 3-arg form, which is normally +// benign, but combined with MORPH_CLIENT_ONLY's empty +// MORPH_DETAIL_REGISTER_ACTION_LOCAL expansion produced hard compile errors +// in this probe on cl.exe specifically (not reproducible on Clang/GCC). +// Calling _4 directly sidesteps the variadic dispatch while still exercising +// the exact registrar-suppression path this guard exists to prove. #include #include @@ -23,7 +33,7 @@ struct ClientOnlyModel { }; BRIDGE_REGISTER_MODEL(ClientOnlyModel, "ClientOnlyModel") -BRIDGE_REGISTER_ACTION(ClientOnlyModel, ClientOnlyAction, "ClientOnlyAction") +BRIDGE_REGISTER_ACTION_4(ClientOnlyModel, ClientOnlyAction, "ClientOnlyAction", ::morph::model::Loggable::Yes) int main() { return 0; From c50056ee5d43d15993356e5f2e7ff64177a07605 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 4 Aug 2026 20:07:57 +0300 Subject: [PATCH 4/5] tests: verify MORPH_CLIENT_ONLY's runtime throw, not just link suppression Flagged during code review: the std::logic_error thrown by Bridge::executeVia's localOp under MORPH_CLIENT_ONLY had no runtime test, only the two try_compile() link-suppression guards (which prove registerModelOnce/registerActionOnce get suppressed, not that the throw itself fires correctly). Add compile_checks/client_only_runtime_throw.cpp: a fully-defined model+action registered normally, executed against LocalBackend (the misuse MORPH_DETAIL_REGISTER_MODEL_LOCAL's @warning names) under MORPH_CLIENT_ONLY, asserting the completion's onError delivers a std::logic_error mentioning "MORPH_CLIENT_ONLY". Wired via try_run() (compiles AND executes, unlike try_compile()) since this needs to observe runtime behavior. Verified the probe actually catches a regression: temporarily broke the thrown message and confirmed the configure step fails with a clear diagnostic; restored, reconfirmed clean, then ran the full suite (818 cases) to confirm no side effects on the non-MORPH_CLIENT_ONLY build. Signed-off-by: Yaraslau Tamashevich --- tests/CMakeLists.txt | 37 ++++++++ .../client_only_runtime_throw.cpp | 89 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/compile_checks/client_only_runtime_throw.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2dee41a1..a903fe5b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -267,3 +267,40 @@ if(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE) "--- compiler/linker output ---\n" "${MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE_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" + LINK_LIBRARIES Threads::Threads + 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() diff --git a/tests/compile_checks/client_only_runtime_throw.cpp b/tests/compile_checks/client_only_runtime_throw.cpp new file mode 100644 index 00000000..35652a1a --- /dev/null +++ b/tests/compile_checks/client_only_runtime_throw.cpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Runtime-check fixture for the MORPH_CLIENT_ONLY guard's other half: proves +// that Bridge::executeVia's localOp actually throws std::logic_error under +// MORPH_CLIENT_ONLY when misused against LocalBackend, rather than silently +// calling Model::execute (see MORPH_DETAIL_REGISTER_MODEL_LOCAL's @warning in +// registry.hpp -- this is exactly the misuse scenario it warns against). Run +// via try_run() in tests/CMakeLists.txt (compiles AND executes, unlike +// try_compile()), since this needs to observe runtime behavior, not just +// link success. +// +// Unlike client_only_no_model_link.cpp, ClientOnlyRuntimeModel is fully +// defined here -- the localOp guard is a runtime throw independent of +// whether Model::execute has a definition; the point of this probe is to +// prove that throw actually fires, with the process's exit code as the +// observable result try_run() checks. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct InlineExecutor : morph::exec::IExecutor { + void post(std::function fn) override { fn(); } +}; + +} // namespace + +struct ClientOnlyRuntimeAction { + int x = 0; +}; + +struct ClientOnlyRuntimeModel { + int execute(const ClientOnlyRuntimeAction& action) { return action.x * 2; } +}; + +BRIDGE_REGISTER_MODEL(ClientOnlyRuntimeModel, "ClientOnlyRuntimeModel") +BRIDGE_REGISTER_ACTION_4(ClientOnlyRuntimeModel, ClientOnlyRuntimeAction, "ClientOnlyRuntimeAction", + ::morph::model::Loggable::Yes) + +int main() { + morph::exec::ThreadPoolExecutor pool{2}; + InlineExecutor cbExec; + // Deliberate misuse: MORPH_DETAIL_REGISTER_MODEL_LOCAL's @warning says + // MORPH_CLIENT_ONLY must never be paired with LocalBackend. Exercising it + // anyway is exactly how this probe proves the throw fires instead of + // silently calling execute. + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &cbExec}; + + std::atomic gotExpectedError{false}; + std::atomic completed{false}; + handler.execute(ClientOnlyRuntimeAction{21}) + .then([&](int) { completed.store(true); }) + .onError([&](const std::exception_ptr& eptr) { + try { + std::rethrow_exception(eptr); + } catch (const std::logic_error& exc) { + std::string const what{exc.what()}; + gotExpectedError.store(what.find("MORPH_CLIENT_ONLY") != std::string::npos); + } catch (...) { + } + completed.store(true); + }); + + for (int idx = 0; idx < 200 && !completed.load(); ++idx) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + if (!completed.load()) { + std::fputs("client_only_runtime_throw: timed out waiting for completion\n", stderr); + return 1; + } + if (!gotExpectedError.load()) { + std::fputs("client_only_runtime_throw: did not observe the expected std::logic_error\n", stderr); + return 1; + } + return 0; +} From 2e73cb92e519856acb752c0d49c697fc60e7c333 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 4 Aug 2026 20:20:26 +0300 Subject: [PATCH 5/5] tests: fix Windows CI failure in the new MORPH_CLIENT_ONLY runtime probe The try_run() added for the runtime-throw probe passed LINK_LIBRARIES Threads::Threads, mirroring how the QT_NO_SSL guard passes Qt6::Core/Network/WebSockets. Those are exported find_package() targets; Threads::Threads (from CMake's bundled FindThreads module) 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." Use CMAKE_THREAD_LIBS_INIT (the raw linker-flag string find_package (Threads REQUIRED) already sets at CMakeLists.txt:102, e.g. "-lpthread" on Linux, empty where nothing extra is needed) instead of the target. Verified locally: configure + full suite (818 cases) still pass. Signed-off-by: Yaraslau Tamashevich --- tests/CMakeLists.txt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a903fe5b..47124053 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -283,7 +283,17 @@ try_run(MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE MORPH_CLIENT_ONLY_RUNTIME_THROW CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" CXX_STANDARD 23 COMPILE_DEFINITIONS "-DMORPH_CLIENT_ONLY" - LINK_LIBRARIES Threads::Threads + # 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 )