Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
36 changes: 26 additions & 10 deletions docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>`
**`localOp` is compiled — and so needs `Model::execute`'s definition to
link — every time `executeVia<Model, Action>` 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<void>`
into the final `Completion<R>` inside a `try`/`catch`: moving the result out of
the opaque `shared_ptr<void>` can throw (a throwing move/copy on `R`, or a bad
cast), and if that exception escaped the `.then` callback it would be swallowed
Expand Down
92 changes: 88 additions & 4 deletions docs/spec/core/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -500,8 +501,10 @@ BRIDGE_REGISTER_MODEL(AccountModel, "Account")

Expands to:
- `template <> struct morph::model::ModelTraits<M> { 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<M>(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<M>(NAME)`. See
[`MORPH_CLIENT_ONLY`](#morph_client_only--suppressing-model-owning-registrars).

### `BRIDGE_REGISTER_ACTION(M, A, NAME, ...)`

Expand All @@ -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<M, A>(morph::model::ModelTraits<M>::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<M, A>(morph::model::ModelTraits<M>::typeId(), NAME)`.
`detail::registerActionExecutorOnce<M, A>(morph::model::ModelTraits<M>::typeId(), NAME)`
— always emitted, `MORPH_CLIENT_ONLY` or not.

**Hard requirement:** Every translation unit invoking `BRIDGE_REGISTER_ACTION`
must include `<morph/bridge.hpp>` (directly or transitively) because
Expand All @@ -547,6 +553,84 @@ BRIDGE_REGISTER_VALIDATOR(FormAction, [](const FormAction& a) {

Expands to `template <> struct morph::model::ActionValidator<A> { 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<M>` stores a factory (`[] { return ModelFactory::create<M>(); }`)
in the process-level `ModelRegistryFactory`, used by `LocalBackend`/`RemoteServer`
to construct a live instance.
- `registerActionOnce<M, A>` 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<M>`/`ActionTraits<A>` (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<M, A>` routes through
`BridgeHandler<Model>::execute<Action>()` → `Bridge::executeVia<Model, Action>`
— 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<Model>::execute<Action>()` — 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<Action>()` (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
Expand Down
15 changes: 15 additions & 0 deletions include/morph/core/bridge.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,20 @@ class Bridge {
throw ::morph::model::ValidationError{::morph::model::ModelTraits<Model>::typeId(),
::morph::model::ActionTraits<Action>::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<void>(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<Model>();
// Local mode has no client/server split, so this is the same execution
// site `ActionDispatcher::registerAction`'s runner is for remote modes
Expand Down Expand Up @@ -765,6 +779,7 @@ class Bridge {
}
throw;
}
#endif
};
{
std::scoped_lock const lock{_sessionMtx};
Expand Down
64 changes: 52 additions & 12 deletions include/morph/core/registry.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<M>` 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<M> { \
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<M>(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<M, A>(morph::model::ModelTraits<M>::typeId(), NAME); \
}
#endif

/// @brief Registers model type @p M with the string id @p NAME.
///
/// Specialises `morph::model::ModelTraits<M>` 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<M> { \
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.
///
Expand Down Expand Up @@ -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<M, A>(morph::model::ModelTraits<M>::typeId(), NAME); \
[[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_action_exec_reg_, __COUNTER__) = \
morph::model::detail::registerActionExecutorOnce<M, A>(morph::model::ModelTraits<M>::typeId(), NAME); \
}
Expand Down
Loading
Loading