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
5 changes: 2 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ jobs:
strategy:
fail-fast: false
matrix:
preset: [clang-asan, clang-tsan, clang-ubsan, clang-msan, clang-coverage]
preset: [clang-asan, clang-tsan, clang-ubsan, clang-coverage]
steps:
- uses: actions/checkout@v4

Expand All @@ -133,12 +133,11 @@ jobs:
key: apt-sanitizers-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-sanitizers-

- name: Install Clang ${{ env.CLANG_VERSION }} and libc++ from apt.llvm.org
- name: Install Clang ${{ env.CLANG_VERSION }} from apt.llvm.org
run: |
sudo apt-get update -q
sudo apt-get install -y ninja-build catch2
wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }}
sudo apt-get install -y libc++-${{ env.CLANG_VERSION }}-dev libc++abi-${{ env.CLANG_VERSION }}-dev

- name: Cache sccache
uses: actions/cache@v4
Expand Down
7 changes: 0 additions & 7 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,8 @@ endif()

# ── Tests ────────────────────────────────────────────────────────────────────
if(MORPH_BUILD_TESTS)
# Under MSan every library Catch2 touches must also be MSan-instrumented.
# The system apt package is not, so we always build from source in that mode.
set(_catch2_use_fetch OFF)
find_package(Catch2 CONFIG QUIET)
if(NOT Catch2_FOUND)
set(_catch2_use_fetch ON)
endif()

if(_catch2_use_fetch)
include(FetchContent)
FetchContent_Declare(
Catch2
Expand Down
17 changes: 0 additions & 17 deletions CMakePresets.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,16 +153,6 @@
"AF_SANITIZER": "ubsan"
}
},
{
"name": "clang-msan",
"displayName": "MSan (Clang, Linux — requires instrumented libc++)",
"inherits": "base-clang-linux",
"binaryDir": "${sourceDir}/build/clang-msan",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"AF_SANITIZER": "msan"
}
},
{
"name": "clang-coverage",
"displayName": "Coverage (Clang source-based, Linux)",
Expand All @@ -188,7 +178,6 @@
{ "name": "clang-asan", "configurePreset": "clang-asan", "jobs": 0 },
{ "name": "clang-tsan", "configurePreset": "clang-tsan", "jobs": 0 },
{ "name": "clang-ubsan", "configurePreset": "clang-ubsan", "jobs": 0 },
{ "name": "clang-msan", "configurePreset": "clang-msan", "jobs": 0 },
{ "name": "clang-coverage", "configurePreset": "clang-coverage", "jobs": 0 }
],
"testPresets": [
Expand Down Expand Up @@ -246,12 +235,6 @@
"output": { "outputOnFailure": true },
"execution": { "noTestsAction": "error", "stopOnFailure": true }
},
{
"name": "clang-msan",
"configurePreset": "clang-msan",
"output": { "outputOnFailure": true },
"execution": { "noTestsAction": "error", "stopOnFailure": true }
},
{
"name": "clang-coverage",
"configurePreset": "clang-coverage",
Expand Down
12 changes: 0 additions & 12 deletions cmake/compiler_options.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,6 @@ function(apply_sanitizers target mode)
-fsanitize=undefined -fno-omit-frame-pointer -g)
target_link_options(${target} PRIVATE
-fsanitize=undefined)
elseif(mode STREQUAL "msan")
# Instrument only our own targets. Catch2 is built with -stdlib=libc++ (set
# globally for ABI compatibility) but without -fsanitize=memory to avoid its
# known false positives. The ignorelist covers morph:: SSO hash false positives.
target_compile_options(${target} PRIVATE
-fsanitize=memory -fsanitize-memory-track-origins
-fno-omit-frame-pointer -g
-stdlib=libc++
"-fsanitize-ignorelist=${CMAKE_SOURCE_DIR}/cmake/msan.supp")
target_link_options(${target} PRIVATE
-fsanitize=memory
-stdlib=libc++)
endif()
endfunction()

Expand Down
23 changes: 0 additions & 23 deletions cmake/msan.supp

This file was deleted.

4 changes: 4 additions & 0 deletions include/morph/backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,21 @@ struct IBackend {
/// or surface a "backend changed" message — there is no public cancel API on
/// `Completion` itself.
struct BackendChangedError : std::runtime_error {
/// @brief Constructs the error with a canned diagnostic message.
BackendChangedError() : std::runtime_error{"backend changed before completion resolved"} {}
};

/// @brief Thrown to in-flight `Completion`s when `Bridge` is destroyed.
struct BridgeDestroyedError : std::runtime_error {
/// @brief Constructs the error with a canned diagnostic message.
BridgeDestroyedError() : std::runtime_error{"bridge destroyed before completion resolved"} {}
};

/// @brief Thrown to in-flight `Completion`s when a transport drops mid-call (e.g. a
/// Qt WebSocket disconnect). The framework retries the call on reconnect if
/// the backend supports it; otherwise the GUI's `.onError(...)` runs.
struct DisconnectedError : std::runtime_error {
/// @brief Constructs the error with a canned diagnostic message.
DisconnectedError() : std::runtime_error{"transport disconnected before completion resolved"} {}
};

Expand Down Expand Up @@ -201,6 +204,7 @@ class LocalBackend : public detail::IBackend {
}

/// @brief Resolves every still-pending completion this backend produced with @p exc.
/// @param exc Exception delivered to every pending completion's error sink.
void cancelPending(const std::exception_ptr& exc) override {
std::vector<std::weak_ptr<::morph::async::detail::CompletionState<std::shared_ptr<void>>>> snapshot;
{
Expand Down
59 changes: 37 additions & 22 deletions include/morph/bridge.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class Bridge {
/// @param backend Initial backend. Ownership is transferred.
explicit Bridge(std::unique_ptr<::morph::backend::detail::IBackend> backend)
: _backend{std::shared_ptr<::morph::backend::detail::IBackend>(std::move(backend))} {
installReconnectHandler(_backend.load());
installReconnectHandler(_backend);
}

/// @brief Cancels every still-pending completion on the active backend.
Expand All @@ -89,7 +89,7 @@ class Bridge {
/// server replies that arrive after destruction are no-ops because each
/// `CompletionState::setValue`/`setException` is idempotent.
~Bridge() {
if (auto active = _backend.load()) {
if (auto active = loadBackend()) {
active->cancelPending(std::make_exception_ptr(::morph::backend::BridgeDestroyedError{}));
}
}
Expand All @@ -110,7 +110,7 @@ class Bridge {
binding->typeId = std::string{::morph::model::ModelTraits<Model>::typeId()};
binding->modelFactory = [] { return ::morph::model::detail::ModelFactory::create<Model>(); };
std::scoped_lock lock{_mtx};
binding->currentId.store(_backend.load()->registerModel(binding->typeId, binding->modelFactory).v);
binding->currentId.store(loadBackend()->registerModel(binding->typeId, binding->modelFactory).v);
_handlers.push_back(binding);
return binding;
}
Expand All @@ -122,23 +122,10 @@ class Bridge {
/// @param binding Pre-constructed binding. Its `typeId` and `modelFactory` must be set.
void registerHandler(const std::shared_ptr<detail::HandlerBinding>& binding) {
std::scoped_lock lock{_mtx};
binding->currentId.store(_backend.load()->registerModel(binding->typeId, binding->modelFactory).v);
binding->currentId.store(loadBackend()->registerModel(binding->typeId, binding->modelFactory).v);
_handlers.push_back(binding);
}

/// @brief Atomically replaces the active backend with @p newBackend.
///
/// All live bindings are re-registered on the new backend and their
/// `currentId` values are updated. The old backend is released after the
/// swap. Any in-flight `Completion` objects targeting the old backend will
/// fail naturally.
///
/// @note Lock ordering: `Bridge::_mtx` is acquired before
/// `LocalBackend::_regMtx`. `onBackendChanged()` implementations must
/// **not** call `registerHandler()` or `deregisterHandler()` — those
/// also acquire `_mtx` and would deadlock.
///
/// @param newBackend Replacement backend. Ownership is transferred.
/// @brief Installs a default session context applied to every call that does
/// not provide one explicitly via `BridgeHandler::executeWith(...)`.
///
Expand All @@ -153,11 +140,25 @@ class Bridge {
}

/// @brief Returns a copy of the currently installed default session. Thread-safe.
/// @return Snapshot of the default `Context`.
[[nodiscard]] ::morph::session::Context defaultSession() const {
std::scoped_lock lock{_sessionMtx};
return _defaultSession;
}

/// @brief Atomically replaces the active backend with @p newBackend.
///
/// All live bindings are re-registered on the new backend and their
/// `currentId` values are updated. The old backend is released after the
/// swap. Any in-flight `Completion` objects targeting the old backend will
/// fail naturally.
///
/// @note Lock ordering: `Bridge::_mtx` is acquired before
/// `LocalBackend::_regMtx`. `onBackendChanged()` implementations must
/// **not** call `registerHandler()` or `deregisterHandler()` — those
/// also acquire `_mtx` and would deadlock.
///
/// @param newBackend Replacement backend. Ownership is transferred.
void switchBackend(std::unique_ptr<::morph::backend::detail::IBackend> newBackend) {
auto newShared = std::shared_ptr<::morph::backend::detail::IBackend>(std::move(newBackend));
std::shared_ptr<::morph::backend::detail::IBackend> previous;
Expand All @@ -176,7 +177,7 @@ class Bridge {
}
_handlers = std::move(live);

previous = _backend.exchange(newShared);
previous = exchangeBackend(newShared);
newShared->notifyBackendChanged();
}
installReconnectHandler(newShared);
Expand All @@ -200,7 +201,7 @@ class Bridge {
std::scoped_lock lock{_mtx};
uint64_t raw = binding->currentId.load();
if (raw != 0U) {
_backend.load()->deregisterModel(::morph::exec::detail::ModelId{raw});
loadBackend()->deregisterModel(::morph::exec::detail::ModelId{raw});
}
auto iter = std::ranges::find_if(_handlers, [&](auto& weak) {
auto sptr = weak.lock();
Expand Down Expand Up @@ -230,7 +231,7 @@ class Bridge {
::morph::exec::IExecutor* cbExec) {
using R = ::morph::model::ActionTraits<Action>::Result;

auto backend = _backend.load();
auto backend = loadBackend();
uint64_t raw = binding->currentId.load();

auto typedState = std::make_shared<::morph::async::detail::CompletionState<R>>();
Expand Down Expand Up @@ -265,6 +266,19 @@ class Bridge {
}

private:
std::shared_ptr<::morph::backend::detail::IBackend> loadBackend() const {
std::scoped_lock lock{_backendMtx};
return _backend;
}

std::shared_ptr<::morph::backend::detail::IBackend> exchangeBackend(
std::shared_ptr<::morph::backend::detail::IBackend> next) {
std::scoped_lock lock{_backendMtx};
auto previous = std::move(_backend);
_backend = std::move(next);
return previous;
}

void installReconnectHandler(const std::shared_ptr<::morph::backend::detail::IBackend>& backend) {
if (!backend) {
return;
Expand All @@ -276,7 +290,7 @@ class Bridge {
backend->setReconnectHandler([this, weakBackend] {
auto pinned = weakBackend.lock();
std::scoped_lock lock{_mtx};
if (!pinned || pinned != _backend.load()) {
if (!pinned || pinned != loadBackend()) {
return; // We've moved on to a different backend; ignore.
}
for (auto& weak : _handlers) {
Expand All @@ -290,7 +304,8 @@ class Bridge {
});
}

std::atomic<std::shared_ptr<::morph::backend::detail::IBackend>> _backend;
mutable std::mutex _backendMtx;
std::shared_ptr<::morph::backend::detail::IBackend> _backend;
std::mutex _mtx;
std::vector<std::weak_ptr<detail::HandlerBinding>> _handlers;
mutable std::mutex _sessionMtx;
Expand Down
4 changes: 4 additions & 0 deletions include/morph/qt/qt_websocket_backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,13 @@ class QtWebSocketBackend : public ::morph::backend::detail::IBackend {
/// Called by `Bridge::switchBackend()` on the outgoing backend, by `~Bridge`,
/// and internally when the socket disconnects. Late replies arriving for
/// already-cancelled call ids are dropped silently.
///
/// @param exc Exception delivered to every pending completion's error sink.
void cancelPending(const std::exception_ptr& exc) override;

/// @brief Installs the handler `Bridge` uses to re-register handlers after a reconnect.
/// @param handler Callable invoked on the Qt thread after every successful reconnect.
/// Pass `nullptr` to clear.
void setReconnectHandler(std::function<void()> handler) override;

private:
Expand Down
3 changes: 3 additions & 0 deletions include/morph/registry.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ struct ActionValidator {
///
/// Auto-detects a `bool validate() const` member on @p action via the
/// `morph::model::detail::HasValidate` concept. Falls back to `true`.
///
/// @param action Draft action whose readiness is being checked.
/// @return `true` if the action should fire, `false` to keep collecting fields.
static constexpr bool ready(const Action& action) {
if constexpr (detail::HasValidate<Action>) {
return action.validate();
Expand Down
5 changes: 5 additions & 0 deletions include/morph/remote.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ namespace morph::backend {
class RemoteServer : public std::enable_shared_from_this<RemoteServer> {
public:
/// @brief Constructs a server backed by @p workerPool with allow-all authorization.
///
/// @param workerPool Pool used to process messages asynchronously.
/// @param dispatcher Action dispatcher; defaults to the process-level singleton.
/// @param registry Model factory registry; defaults to the process-level singleton.
explicit RemoteServer(
::morph::exec::IExecutor& workerPool,
::morph::model::detail::ActionDispatcher& dispatcher = ::morph::model::detail::defaultDispatcher(),
Expand Down Expand Up @@ -267,6 +271,7 @@ class SimulatedRemoteBackend : public detail::IBackend {
void notifyBackendChanged() override {}

/// @brief Resolves every still-pending completion this backend produced with @p exc.
/// @param exc Exception delivered to every pending completion's error sink.
void cancelPending(const std::exception_ptr& exc) override {
std::vector<std::weak_ptr<::morph::async::detail::CompletionState<std::shared_ptr<void>>>> snapshot;
{
Expand Down
18 changes: 16 additions & 2 deletions include/morph/session.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ struct IAuthorizer {
virtual ~IAuthorizer() = default;

/// @brief Returns `true` if @p ctx is allowed to invoke @p actionType on @p modelType.
///
/// @param ctx Per-call session attached by the client.
/// @param modelType String id of the target model type.
/// @param actionType String id of the action being invoked.
/// @return `true` to allow dispatch, `false` to reject with `err|unauthorized`.
[[nodiscard]] virtual bool authorize(const Context& ctx, std::string_view modelType,
std::string_view actionType) const = 0;
};
Expand All @@ -55,8 +60,14 @@ struct IAuthorizer {
/// Wire it explicitly via `RemoteServer(pool, dispatcher, registry, allowAll)`
/// for documentation, or rely on the server's default (which uses this type).
struct AllowAllAuthorizer : IAuthorizer {
[[nodiscard]] bool authorize(const Context& /*ctx*/, std::string_view /*modelType*/,
std::string_view /*actionType*/) const override {
/// @brief Permits every call.
/// @param ctx Ignored.
/// @param modelType Ignored.
/// @param actionType Ignored.
/// @return Always `true`.
[[nodiscard]] bool authorize([[maybe_unused]] const Context& ctx,
[[maybe_unused]] std::string_view modelType,
[[maybe_unused]] std::string_view actionType) const override {
return true;
}
};
Expand All @@ -81,7 +92,10 @@ inline const Context*& tlsCurrent() {
/// @brief RAII helper that sets the thread-local `Context` for its scope.
class ScopedContext {
public:
/// @brief Installs @p ctx as the thread-local context until the scope exits.
/// @param ctx Context whose address is stored; must outlive this object.
explicit ScopedContext(const Context& ctx) : _prev{tlsCurrent()} { tlsCurrent() = &ctx; }
/// @brief Restores the previously active thread-local context.
~ScopedContext() { tlsCurrent() = _prev; }
ScopedContext(const ScopedContext&) = delete;
ScopedContext& operator=(const ScopedContext&) = delete;
Expand Down
11 changes: 2 additions & 9 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,5 @@ if(AF_COVERAGE)
apply_coverage(morph_tests)
endif()

if(AF_SANITIZER STREQUAL "msan")
# catch_discover_tests runs the binary for enumeration which triggers MSan
# before any suppressions can be applied. Use a single all-up test instead,
# with the suppression file to silence Catch2's known false positive.
add_test(NAME morph_tests COMMAND morph_tests)
else()
include(Catch)
catch_discover_tests(morph_tests DISCOVERY_MODE PRE_TEST)
endif()
include(Catch)
catch_discover_tests(morph_tests DISCOVERY_MODE PRE_TEST)
Loading
Loading