Skip to content

core: add MORPH_CLIENT_ONLY to suppress model-owning registrars - #37

Merged
Yaraslaut merged 5 commits into
masterfrom
feature/27-client-only-registration
Aug 4, 2026
Merged

core: add MORPH_CLIENT_ONLY to suppress model-owning registrars#37
Yaraslaut merged 5 commits into
masterfrom
feature/27-client-only-registration

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Summary

  • BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION emit two registrars (registerModelOnce, registerActionOnce) whose stored closures call the model's constructor and execute() — compiled in regardless of whether the closure is ever invoked at runtime, so a pure remote client still forces the linker to resolve them, along with whatever platform stack they depend on (a browser/WASM build in particular has no link path for those at all).
  • Adds a MORPH_CLIENT_ONLY CMake option that defines the macro on the morph target's INTERFACE (never per-consumer — two TUs disagreeing would violate ODR) and suppresses both registrars' emission. ModelTraits/ActionTraits (type-ids, JSON codecs) are unaffected.
  • The issue's suggested third suppression target needed different treatment than proposed, discovered while implementing. registerActionExecutorOnce (suggested as safe, needing only declarations) actually routes through Bridge::executeVia, which unconditionally compiles an ActionCall::localOp closure calling Model::execute directly — regardless of which backend ends up installed at runtime. Confirmed empirically with a probe (model constructor/execute() declared but never defined anywhere in the link): it failed exactly as expected, referencing both symbols from inside executeVia's instantiation, not from registerActionExecutorOnce. Fixed by gating executeVia's localOp itself on MORPH_CLIENT_ONLY (throws instead of calling Model::execute), so both the typed BridgeHandler::execute<Action>() and the type-erased executeJson path work against a remote backend in a client-only build.
  • NEVER define this for a process that hosts models — it silently registers nothing, failing at runtime with "unknown model type" (or a clear std::logic_error if executeVia's local path is reached anyway) rather than at compile/link time.

Test plan

  • New tests/compile_checks/client_only_no_model_link.cpp + two try_compile() guards in tests/CMakeLists.txt (mirroring the existing MORPH_REQUIRE_VETTED_HMAC guard pattern), proving both directions: links successfully with MORPH_CLIENT_ONLY defined; fails to link (both the constructor and execute() genuinely unresolved) without it.
  • Full suite: ./build/tests/morph_tests — all 811 test cases / 8284 assertions pass, unmodified — confirms zero behavior change for the default (non-client-only) build.

Closes #27

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION emit three registrars per
action. registerModelOnce's factory calls ModelFactory::create<Model>(),
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<M>/
ActionTraits<A> (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<Action>() -> 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<Action>() 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 <yaraslau.tamashevich@gmail.com>
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 <yaraslau.tamashevich@gmail.com>
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 <yaraslau.tamashevich@gmail.com>
@Yaraslaut
Yaraslaut force-pushed the feature/27-client-only-registration branch from 0763db6 to fac7621 Compare August 4, 2026 16:48
…ssion

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 <yaraslau.tamashevich@gmail.com>
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 <yaraslau.tamashevich@gmail.com>
@Yaraslaut
Yaraslaut merged commit 5df7222 into master Aug 4, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Registration macros force clients to link model implementations they never call

1 participant