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
104 changes: 81 additions & 23 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,32 @@ if(NOT DEFINED CPM_SOURCE_CACHE AND NOT DEFINED ENV{CPM_SOURCE_CACHE})
set(CPM_SOURCE_CACHE "${CMAKE_CURRENT_SOURCE_DIR}/.cache/cpm" CACHE PATH
"Where CPM keeps the sources of every fetched dependency")
endif()
include(cmake/CPM.cmake)

# CPM itself is loaded only when something has to be fetched: its bootstrap
# downloads CPM.cmake, so loading it unconditionally would put the network on
# the path of a configure whose every dependency is already installed -- the
# shape a package-manager build (vcpkg, Conan, a distribution) has, where
# reaching the network is not allowed. Each fetch site calls
# morph_use_cpm(<what> [<why>]) before its CPMAddPackage; the first one to
# load CPM names itself in a STATUS line, so a failed bootstrap download says
# which dependency asked for it. A parent project that has loaded CPM already
# is reused, not loaded again.
#
# A macro, not a function, so CPM's own non-cache variables land in the
# caller's scope rather than vanishing with a function's.
set(MORPH_CPM_BOOTSTRAP "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CPM.cmake")
macro(morph_use_cpm _morph_cpm_for)
if(NOT COMMAND CPMAddPackage)
if(${ARGC} GREATER 1)
set(_morph_cpm_why "${ARGV1}")
else()
set(_morph_cpm_why "not found installed (install it and point CMAKE_PREFIX_PATH at it to configure without the network)")
endif()
message(STATUS "morph: loading CPM to fetch ${_morph_cpm_for}: ${_morph_cpm_why}")
unset(_morph_cpm_why)
include("${MORPH_CPM_BOOTSTRAP}")
endif()
endmacro()

# ── glaze ────────────────────────────────────────────────────────────────────
# On Windows vcpkg provides glaze. Elsewhere CPM fetches it, so that CI does
Expand All @@ -165,6 +190,7 @@ include(cmake/CPM.cmake)
set(MORPH_GLAZE_VERSION 7.4)
find_package(glaze ${MORPH_GLAZE_VERSION} CONFIG QUIET)
if(NOT glaze_FOUND)
morph_use_cpm(glaze)
CPMAddPackage(
NAME glaze
GITHUB_REPOSITORY stephenberry/glaze
Expand All @@ -184,40 +210,71 @@ endif()
#
# morph stays a header-only INTERFACE target, but core::base, core::net and
# core::platform are static libraries, so every consumer of morph now builds
# them.
# them -- or links an installed core-cpp's.
#
# Found first, as glaze is, and fetched only when no installed core-cpp
# satisfies the bound. MORPH_CORE_CPP_VERSION is that bound, stated once
# because morphConfig.cmake asks for the same one; core-cpp's package accepts
# only its own minor version while it is 0.x.
#
# Except under a sanitizer: an installed core-cpp was compiled without morph's
# instrumentation, and a ThreadSanitizer that cannot see core::net's side of
# TimeoutScheduler's hand-off reports races that are not there and misses ones
# that are. A sanitizer leg therefore always builds core-cpp in-tree, so the
# block below can instrument it.
#
# morph's install exports morph::morph, which links core-cpp's modules, so an
# install of morph has to install core-cpp too: CMake refuses an export that
# names a target in no export set. CORE_CPP_INSTALL follows MORPH_INSTALL for
# that, and morphConfig.cmake finds the installed core-cpp package in turn.
# install of morph with a fetched core-cpp has to install core-cpp too: CMake
# refuses an export that names a target in no export set. CORE_CPP_INSTALL
# follows MORPH_INSTALL for that, and morphConfig.cmake finds the installed
# core-cpp package in turn. A found core-cpp is already installed, its targets
# are imported, and the export names them without installing anything again.
# MORPH_INSTALL is described with the install rules below. EXCLUDE_FROM_ALL
# is off while morph installs: CMake leaves an excluded subdirectory's install
# rules out of the parent's install, so `cmake --install` would install
# morph's package without the core-cpp package it depends on.
set(MORPH_CORE_CPP_VERSION 0.5)
option(MORPH_INSTALL "Generate morph's install and export rules" ${PROJECT_IS_TOP_LEVEL})
if(MORPH_INSTALL)
set(_morph_core_cpp_exclude_from_all NO)
else()
set(_morph_core_cpp_exclude_from_all YES)
if(NOT DEFINED AF_SANITIZER)
find_package(core-cpp ${MORPH_CORE_CPP_VERSION} CONFIG QUIET)
endif()
if(NOT core-cpp_FOUND)
if(MORPH_INSTALL)
set(_morph_core_cpp_exclude_from_all NO)
else()
set(_morph_core_cpp_exclude_from_all YES)
endif()
if(DEFINED AF_SANITIZER)
morph_use_cpm(core-cpp "AF_SANITIZER builds it in-tree so that it can be instrumented")
else()
morph_use_cpm(core-cpp)
endif()
CPMAddPackage(
NAME core-cpp
GITHUB_REPOSITORY contour-terminal/core-cpp
GIT_TAG v0.5.0
VERSION 0.5.0
SYSTEM YES
EXCLUDE_FROM_ALL ${_morph_core_cpp_exclude_from_all}
OPTIONS "CORE_CPP_TESTING OFF" "CORE_CPP_BUILD_EXAMPLES OFF" "CORE_CPP_WITH_TUI OFF"
"CORE_CPP_WITH_TLS OFF" "CORE_CPP_FETCH_DEPS OFF" "CORE_CPP_INSTALL ${MORPH_INSTALL}")
unset(_morph_core_cpp_exclude_from_all)
endif()
CPMAddPackage(
NAME core-cpp
GITHUB_REPOSITORY contour-terminal/core-cpp
GIT_TAG v0.5.0
VERSION 0.5.0
SYSTEM YES
EXCLUDE_FROM_ALL ${_morph_core_cpp_exclude_from_all}
OPTIONS "CORE_CPP_TESTING OFF" "CORE_CPP_BUILD_EXAMPLES OFF" "CORE_CPP_WITH_TUI OFF"
"CORE_CPP_WITH_TLS OFF" "CORE_CPP_FETCH_DEPS OFF" "CORE_CPP_INSTALL ${MORPH_INSTALL}")
unset(_morph_core_cpp_exclude_from_all)

# A sanitizer leg instruments core-cpp's compiled modules with morph's own
# targets: TimeoutScheduler's loop thread runs inside core::net, and a
# ThreadSanitizer that cannot see one side of a hand-off reports races that
# are not there and misses ones that are. core-cpp lists those targets in the
# CORE_CPP_TARGETS global property for exactly this.
# targets (see above for why). core-cpp lists those targets in the
# CORE_CPP_TARGETS global property for exactly this. The property is set only
# by an in-tree core-cpp; an empty one here means nothing would be
# instrumented while the configure reported success, so it stops instead --
# e.g. a parent project that brought core-cpp in some other way.
if(DEFINED AF_SANITIZER)
get_property(_morph_core_cpp_targets GLOBAL PROPERTY CORE_CPP_TARGETS)
if(NOT _morph_core_cpp_targets)
message(FATAL_ERROR
"AF_SANITIZER=${AF_SANITIZER} but core-cpp lists no targets to instrument "
"(the CORE_CPP_TARGETS global property is empty): core-cpp did not come from an "
"in-tree build, so its compiled modules would run uninstrumented.")
endif()
foreach(_morph_core_cpp_target IN LISTS _morph_core_cpp_targets)
apply_sanitizers(${_morph_core_cpp_target} ${AF_SANITIZER})
endforeach()
Expand Down Expand Up @@ -651,6 +708,7 @@ endif()
if(MORPH_BUILD_TESTS)
find_package(Catch2 CONFIG QUIET)
if(NOT Catch2_FOUND)
morph_use_cpm(Catch2)
CPMAddPackage(
NAME Catch2
GITHUB_REPOSITORY catchorg/Catch2
Expand Down
8 changes: 8 additions & 0 deletions cmake/CPM.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
#
# With CPM_SOURCE_CACHE set (CMakeLists.txt defaults it to .cache/cpm) the
# bootstrap itself lives in the cache, so a warm cache downloads nothing.
#
# morph includes this only through morph_use_cpm() (CMakeLists.txt), when a
# dependency was not found installed and has to be fetched; that call's STATUS
# line, printed just before, names the dependency. The FATAL_ERROR below is
# core-cpp's wording. At morph's top level, its "set CORE_CPP_FETCH_DEPS=OFF"
# does not apply and cmake/FetchTransferBound.cmake does not exist: the way to
# configure without this download is to install the dependency that line names
# and point CMAKE_PREFIX_PATH at it.
set(_coreCppCpmBound "")
if(DEFINED FASTCACHED_FETCH_SILENCE_SECONDS)
set(_coreCppCpmBound INACTIVITY_TIMEOUT "${FASTCACHED_FETCH_SILENCE_SECONDS}")
Expand Down
9 changes: 5 additions & 4 deletions cmake/morphConfig.cmake.in
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ endif()
find_dependency(glaze @MORPH_GLAZE_VERSION@ CONFIG)

# morph::morph links core-cpp's modules (core::base, core::async, core::net and,
# natively, core::platform), which morph's install puts next to it. 0.3 is the
# minor version morph was built against; core-cpp's package accepts only that
# minor version while core-cpp is 0.x.
find_dependency(core-cpp 0.5 CONFIG)
# natively, core::platform): the core-cpp morph was built against, either
# installed by morph's own install next to it or already installed when the
# build found it. The bound is the minor version morph was built against;
# core-cpp's package accepts only that minor version while core-cpp is 0.x.
find_dependency(core-cpp @MORPH_CORE_CPP_VERSION@ CONFIG)

if(NOT WIN32 AND NOT EMSCRIPTEN)
find_dependency(Threads)
Expand Down
5 changes: 3 additions & 2 deletions docs/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ find_package(Doxygen REQUIRED)
message(STATUS "Doxygen found: ${DOXYGEN_EXECUTABLE}")

# DOWNLOAD_ONLY: the repository is a stylesheet, and the one file used is
# `doxygen-awesome.css`; there is nothing to configure. CPM is loaded by the
# root CMakeLists.txt, and its source cache applies here as everywhere.
# `doxygen-awesome.css`; there is nothing to configure. CPM is loaded on demand by
# the root CMakeLists.txt's morph_use_cpm, and its source cache applies here as everywhere.
morph_use_cpm(doxygen-awesome-css)
CPMAddPackage(
NAME doxygen-awesome-css
GITHUB_REPOSITORY jothepro/doxygen-awesome-css
Expand Down
6 changes: 5 additions & 1 deletion docs/spec/core/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,11 @@ would silently change `registerHandler`'s contract. See
[Waiting for a bind — `bindWaitPolicy`](#waiting-for-a-bind--bindwaitpolicy).

`executeVia` fails fast with `"handler not bound"` for a call issued before the
reply arrives; it does not queue.
reply arrives; it does not queue. A caller that wants the call held instead
names that at the call site with `BridgeHandler::executeWhenBound()`, which
chains the dispatch on `whenBound()` ([bridge.md](bridge.md#registration-readiness--isbound--whenbound)).
The default stays fail-fast so that `execute()` means the same thing whichever
backend the handler was built over.

Consequences:

Expand Down
49 changes: 49 additions & 0 deletions docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ that only know action names at runtime.
- [The bridge's own executor](#the-bridges-own-executor)
- [`BridgeHandler<Model>`](#bridgehandlermodel)
- [Registration readiness — `isBound()` / `whenBound()`](#registration-readiness--isbound--whenbound)
- [`executeWhenBound()` — holding a dispatch until the bind lands](#executewhenbound--holding-a-dispatch-until-the-bind-lands)
- [`ActionExecuteRegistry`](#actionexecuteregistry)
- [Why the key carries the sharing policy](#why-the-key-carries-the-sharing-policy)
- [`BRIDGE_REGISTER_ACTION` and `registerActionExecutorOnce`](#bridge_register_action-and-registeractionexecutoronce)
Expand Down Expand Up @@ -756,6 +757,53 @@ it answers and does not:
this binding an id, not that the transport is still up; the socket can drop
the moment after.

### `executeWhenBound()` — holding a dispatch until the bind lands

`execute()` never waits. A view model that constructs a handler and dispatches
its first action in the same breath would otherwise wrap every handler it owns
in the same gate — `whenBound()`, then the dispatch, plus a liveness guard in
case the handler goes first. `BridgeHandler::executeWhenBound(action)` is that
gate, built once:

| Handler state when called | Result |
|---|---|
| Bound | Dispatches exactly as `execute()` does. |
| Registration in flight, then succeeds | Dispatches on the GUI executor once `whenBound()` resolves `true`. |
| Registration in flight, then fails | Rejects with the registration's error; nothing is dispatched. |
| `whenBound()` resolves `false` | Rejects with `"handler not bound"`. |
| Handler destroyed before the dispatch comes due | The held action is dropped: never dispatched, and the returned `Completion` never settles. |
| Bridge retired before the dispatch comes due | Rejects with `"bridge destroyed"`. |

Why each choice was made:

- **A separate method, not a constructor policy.** With a policy, one
`execute()` call would wait or fail depending on how the handler had been
built somewhere else, and a reader of the call site could not tell which.
The name shows the wait where it happens.
- **`NoSharing` only**, enforced with a `static_assert`. A shared handler's
initial binding goes through the attach path, which `whenBound()` does not
track (see the scope limits above), so on a shared handler it would resolve
`false` at once and the method would be fail-fast under another name. A
shared handler's keyed `execute()` already carries its attach.
- **The held dispatch holds the binding weakly and does not capture the
handler.** The binding owns the `whenBound()` waiter, and the waiter owns the
held dispatch; a strong capture of the binding would close that loop and
keep the binding alive after its handler is gone — the dispatch would then
reach the backend on an instance nobody holds. With a weak capture, a
handler destroyed first takes the waiter (and the held action) with it.
- **The bridge gate is read, then released, before the dispatch.** The
dispatch can settle inline, and `BridgeSink`'s settle path takes the same
gate; holding it across the call would lock it recursively. The check makes
the ordinary teardown order — bridge retired while the dispatch sat in the
GUI queue — a rejection instead of a call into a destroyed bridge. It is not
a licence to destroy the bridge concurrently with the dispatch: the bridge
must outlive the dispatch on the same terms as any other call made on the
handler.
- **The result type must be copy-constructible** (a `static_assert`). The
deferred path forwards the value from `executeVia`'s `Completion` into the
one already handed to the caller, and a `Completion`'s value is observed,
never consumed ([completion.md](completion.md)).


## `ActionExecuteRegistry`

Expand Down Expand Up @@ -1142,6 +1190,7 @@ make teardown order-independent.)
| ctor (custom binding) | `BridgeHandler(Bridge&, IExecutor*, shared_ptr<HandlerBinding>)` | Registers pre-built binding. |
| dtor | `~BridgeHandler()` | Deregisters via `Bridge::deregisterHandler`, but only if the bridge's `CallbackToken` is still active; a no-op if the `Bridge` was already destroyed. |
| `execute<Action>` | `Completion<R> execute(Action)` | Typed dispatch through the bridge. For a shared handler, a payload-/result-keyed action's attach or promote step never throws synchronously — a backend refusal (e.g. `LimitPolicy::maxLiveModels`) resolves the returned `Completion` via `.onError(...)`. |
| `executeWhenBound<Action>` | `Completion<R> executeWhenBound(Action)` | `NoSharing` only. Dispatches like `execute()` when bound; otherwise holds the action until `whenBound()` settles, then dispatches it or rejects with the registration's error (or `"handler not bound"`). Dropped if the handler is destroyed first. See [`executeWhenBound()`](#executewhenbound--holding-a-dispatch-until-the-bind-lands). |
| `executeJson` | `Completion<string> executeJson(string_view actionType, string_view bodyJson)` | Type-erased dispatch through `ActionExecuteRegistry`. |
| `subscribe<R>(cb)` | `void subscribe(function<void(R)>)` | Fire `cb` whenever an `R` is produced on the attached instance. |
| `subscribe<R>(scope, cb)` | `void subscribe(CallbackScope const&, function<void(R)>)` | As above, gated on the scope's liveness and stop state ([callback_scope.md](callback_scope.md)). Dead sinks are refused, not pruned. |
Expand Down
1 change: 1 addition & 0 deletions examples/bank/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE)
# Lightweight fetch.
set(_morph_saved_skip_install_rules ${CMAKE_SKIP_INSTALL_RULES})
set(CMAKE_SKIP_INSTALL_RULES ON)
morph_use_cpm(Lightweight)
CPMAddPackage(
NAME Lightweight
GITHUB_REPOSITORY LASTRADA-Software/Lightweight
Expand Down
1 change: 1 addition & 0 deletions examples/common/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ set(LIGHTWEIGHT_BUILD_SHARED OFF CACHE BOOL "" FORCE)
# without touching Lightweight's vendored CMakeLists.txt.
set(_morph_saved_skip_install_rules ${CMAKE_SKIP_INSTALL_RULES})
set(CMAKE_SKIP_INSTALL_RULES ON)
morph_use_cpm(Lightweight)
CPMAddPackage(
NAME Lightweight
GITHUB_REPOSITORY LASTRADA-Software/Lightweight
Expand Down
Loading
Loading