Skip to content
Open
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
22 changes: 19 additions & 3 deletions docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,24 @@ only the two bridge-touching side effects are skipped when the token has
expired.

**`switchBackend(newBackend)`** replaces the active backend atomically: the
switch either fully succeeds or leaves everything exactly as it was. It runs
in two phases under `_mtx`:
switch either fully succeeds or leaves everything exactly as it was. It has
two overloads:

- `switchBackend(shared_ptr<IBackend>)` — the caller keeps its own reference,
so the same backend instance can be re-installed later (e.g. switching back
to a long-lived remote backend, with its live socket and reconnect state,
after a temporary fallback to a local one) instead of reconstructing it.
- `switchBackend(unique_ptr<Backend>)` — transfers ownership, as before.
Templated on the concrete `Backend` type (rather than taking
`unique_ptr<IBackend>` directly) so that a call like
`switchBackend(std::make_unique<LocalBackend>(...))` is an *exact* match
and is preferred over the `shared_ptr<IBackend>` overload; a non-template
`unique_ptr<IBackend>` overload would tie with it (both are one
equally-ranked user-defined conversion from `unique_ptr<Backend>`), making
every existing call site ambiguous. It converts to a `shared_ptr` and
delegates to the overload above.

Both run the same two phases under `_mtx`:

- **Phase 1 — stage, do not mutate.** Every live binding is registered on the
new backend and the resulting `(binding, newId)` pairs are collected into a
Expand Down Expand Up @@ -470,7 +486,7 @@ make teardown order-independent.)
| dtor | `~Bridge()` | Clears the active backend's reconnect handler, then cancels all pending completions with `BridgeDestroyedError`. |
| `registerHandler<Model>` | `shared_ptr<HandlerBinding> registerHandler()` | Default factory. |
| `registerHandler(binding)` | `void registerHandler(const shared_ptr<HandlerBinding>&)` | Pre-built binding. |
| `switchBackend` | `void switchBackend(unique_ptr<IBackend>)` | Atomic: stages all re-registrations on the new backend, commits (publishes new ids + swaps) only if all succeed, else rolls back and rethrows leaving old backend + `currentId`s intact. Cancels old backend's pending ops with `BackendChangedError`. Holds both `_mtx` and `_attachMtx` for its duration. |
| `switchBackend` | `void switchBackend(unique_ptr<IBackend>)` / `void switchBackend(shared_ptr<IBackend>)` | Atomic: stages all re-registrations on the new backend, commits (publishes new ids + swaps) only if all succeed, else rolls back and rethrows leaving old backend + `currentId`s intact. Cancels old backend's pending ops with `BackendChangedError`. Holds both `_mtx` and `_attachMtx` for its duration. The `unique_ptr` overload is a template on the concrete backend type and delegates to the `shared_ptr` one — see below. |
| `deregisterHandler` | `void deregisterHandler(const shared_ptr<HandlerBinding>&)` | Deregisters from active backend (if bound), resets `currentId` to 0, removes from tracking. |
| `executeVia<Model, Action>` | `Completion<R> executeVia(const shared_ptr<HandlerBinding>&, Action, IExecutor*)` | Lock-free dispatch. Attaches default session. On `LocalBackend`, rejects an action whose `ActionValidator::ready` returns `false` with `morph::model::ValidationError` via `onError`, before `Model::execute` runs. Records journal for loggable actions. Value-forwarding into the typed `Completion` is `try`/`catch`-guarded — a throwing result move/copy resolves the completion via `onError` instead of hanging or terminating. The bridge-touching side effects (`onResult`, `hasSubscribers()`/`publishResult`) are gated on the `_liveness` token, checked before either runs, so a completion resolving after `~Bridge()` skips them instead of touching the dangling `Bridge`. |
| `setDefaultSession` | `void setDefaultSession(session::Context)` | Installs default session context. |
Expand Down
27 changes: 25 additions & 2 deletions include/morph/core/bridge.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <algorithm>
#include <any>
#include <atomic>
#include <concepts>
#include <functional>
#include <memory>
#include <mutex>
Expand Down Expand Up @@ -514,9 +515,31 @@ class Bridge {
/// switch caller's still-held lock. It is also strand-serialised against
/// `execute` on the same model, so it needs no locking of model state.
///
/// @note Templated on the concrete @p Backend (rather than taking
/// `unique_ptr<IBackend>` directly) so it is an exact match for a
/// `unique_ptr<Concrete>` argument (e.g. `std::make_unique<LocalBackend>(...)`)
/// and is preferred over the `shared_ptr<IBackend>` overload during overload
/// resolution — both would otherwise require an equally-ranked user-defined
/// conversion (unique_ptr<Concrete> -> unique_ptr<IBackend> vs. unique_ptr<Concrete>
/// -> shared_ptr<IBackend>), making every existing call site ambiguous.
/// @tparam Backend Concrete backend type; must derive from `IBackend`.
/// @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));
template <typename Backend>
requires std::derived_from<Backend, ::morph::backend::detail::IBackend>
void switchBackend(std::unique_ptr<Backend> newBackend) {
switchBackend(std::shared_ptr<::morph::backend::detail::IBackend>{std::move(newBackend)});
}

/// @brief Atomically replaces the active backend with @p newBackend.
///
/// Identical to the `unique_ptr` overload, except the caller keeps shared
/// ownership of @p newBackend — the backend can be re-installed later
/// (e.g. switching back to a long-lived remote backend after a temporary
/// fallback to a local one) without reconstructing it.
///
/// @param newBackend Replacement backend, shared with the caller.
void switchBackend(std::shared_ptr<::morph::backend::detail::IBackend> newBackend) {
auto newShared = std::move(newBackend);
std::shared_ptr<::morph::backend::detail::IBackend> previous;
{
// Both mutexes: this phase reads/writes every live binding's
Expand Down
55 changes: 55 additions & 0 deletions tests/test_switch_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,61 @@ TEST_CASE("morph::bridge::Bridge::switchBackend - multiple live handlers all r
REQUIRE(res2.load() == 20);
}

TEST_CASE("morph::bridge::Bridge::switchBackend(shared_ptr) - caller-owned instance can be re-installed",
"[bridge][switch][shared_ptr]") {
morph::exec::ThreadPoolExecutor poolInitial{2};
SyncExec cbExec;
morph::bridge::Bridge bridge{std::make_unique<morph::backend::LocalBackend>(poolInitial)};
morph::bridge::BridgeHandler<CountModel> handler{bridge, &cbExec};

morph::exec::ThreadPoolExecutor poolA{2};
morph::exec::ThreadPoolExecutor poolB{2};
auto backendA = std::make_shared<morph::backend::LocalBackend>(poolA);
auto backendB = std::make_shared<morph::backend::LocalBackend>(poolB);

// Switch to a caller-owned shared_ptr backend -- the crux of the API this
// overload adds: the caller keeps its own reference (use_count > 1) rather
// than transferring ownership away, as the unique_ptr overload requires.
bridge.switchBackend(backendA);
REQUIRE(backendA.use_count() > 1);

// Switch away to a second backend, then back to the *same* backendA
// instance -- this is exactly what a unique_ptr signature cannot express,
// since the first switchBackend call would have consumed it.
bridge.switchBackend(backendB);
REQUIRE_NOTHROW(bridge.switchBackend(backendA));

std::atomic<int> res{-1};
handler.execute(CountAction{9})
.then([&](int val) { res.store(val); })
.onError([](const std::exception_ptr&) {});
for (int i = 0; i < 50 && res.load() == -1; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
REQUIRE(res.load() == 9);
}

TEST_CASE("morph::bridge::Bridge::switchBackend(unique_ptr) still transfers ownership (delegates to shared_ptr "
"overload)",
"[bridge][switch][shared_ptr]") {
morph::exec::ThreadPoolExecutor pool{2};
SyncExec cbExec;
morph::bridge::Bridge bridge{std::make_unique<morph::backend::LocalBackend>(pool)};
morph::bridge::BridgeHandler<CountModel> handler{bridge, &cbExec};

morph::exec::ThreadPoolExecutor pool2{2};
REQUIRE_NOTHROW(bridge.switchBackend(std::make_unique<morph::backend::LocalBackend>(pool2)));

std::atomic<int> res{-1};
handler.execute(CountAction{3})
.then([&](int val) { res.store(val); })
.onError([](const std::exception_ptr&) {});
for (int i = 0; i < 50 && res.load() == -1; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
REQUIRE(res.load() == 3);
}

// ── Deep onBackendChanged count verification ──────────────────────────────────

TEST_CASE(
Expand Down
Loading