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
2 changes: 2 additions & 0 deletions docs/spec/core/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,8 @@ thread to marshal onto.
| Method | Notes |
|---|---|
| `QtWebSocketBackend(serverUrl, dispatcher = defaultDispatcher(), registry = defaultRegistry(), tls = nullopt, cfg = Config{})` | Opens the socket to `serverUrl` in the constructor. `dispatcher`/`registry` params are accepted but unused (models live on the server). `tls` non-null → `wss://`. `tls` is not declared at all when Qt is built with `QT_NO_SSL` (see above). |
| `QtWebSocketBackend(serverUrl, tls, cfg = Config{})` | Overload that skips the unused `dispatcher`/`registry` pair (issue #55): a caller who only needs `tls`/`cfg` reaches them directly, without naming `morph::model::detail::defaultDispatcher()`/`defaultRegistry()` explicitly. Delegates to the main constructor with both defaulted. Not declared on a `QT_NO_SSL` build (no `tls` parameter to distinguish it from the `(serverUrl, cfg)` overload below). |
| `QtWebSocketBackend(serverUrl, cfg)` | Overload that skips `dispatcher`/`registry` and `tls` together — the common case for a caller that only wants to set a `Config` field (e.g. `asyncRegistrationEnabled`) over a plaintext `ws://` connection. Delegates to the main constructor with `dispatcher`/`registry` defaulted and (on an SSL-enabled build) `tls = std::nullopt`. |
| `registerModelAsync(typeId, factory, contextKey, onRegistered, onError)` | Returns `false` immediately unless `cfg.asyncRegistrationEnabled` is `true`. Otherwise: assigns a fresh `callId` (the same counter `execute` uses), records the callbacks in `_pendingRegistrations[callId]`, sends `register` with that `callId`, and returns `true`. `onRegistered`/`onError` fire later from `onTextMessage` (or from `cancelPending` on a disconnect) — never synchronously from this call. |
| `waitForConnected(timeoutMs = 5000)` | Pumps the Qt loop until connected or timeout; returns `_connected`. |
| `negotiateProtocolVersion()` | Opt-in: sends `hello` synchronously (same nested-`QEventLoop` path as `registerModel`), classifies the reply via `wire::interpretHelloReply`. Throws on an explicit version rejection or a `sendSync` failure. |
Expand Down
55 changes: 54 additions & 1 deletion docs/spec/core/completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ than vanishing (see [Failure modes](#failure-modes)).
- [Shared state — `CompletionState<T>`](#shared-state--completionstatet)
- [Orphan detection](#orphan-detection)
- [Move-only handle — `Completion<T>`](#move-only-handle--completiont)
- [Settleable promise seam — `Completion<T>::Promise`](#settleable-promise-seam--completiontpromise)
- [Thread safety](#thread-safety)
- [Failure modes](#failure-modes)
- [Client-side execute deadline](#client-side-execute-deadline)
Expand Down Expand Up @@ -155,6 +156,42 @@ completion
.onError([](std::exception_ptr e) { /* ... */ });
```

## Settleable promise seam — `Completion<T>::Promise`

`Completion<T>::makeSettleable(execPtr)` is a static factory returning a
`std::pair<Completion<T>, Completion<T>::Promise>` that share one freshly
allocated `CompletionState<T>`. It is the public counterpart to hand-building a
`Completion<T>` from a `detail::CompletionState<T>` the way `Bridge` and the
backends do internally (see [Shared state](#shared-state--completionstatet)) —
useful for test code (or any caller outside the framework's own producer code)
that needs a `Completion<T>` it can resolve or reject on demand, without a full
`Bridge`/`IBackend` round trip and without ever naming
`morph::async::detail::CompletionState<T>` (issue #55).

```cpp
auto [completion, promise] = morph::async::Completion<int>::makeSettleable(&exec);
completion.then([](int val) { /* ... */ });
// ... later, from producer code:
promise.resolve(42); // or promise.reject(someExceptionPtr);
```

`Promise` is move-only, mirroring `Completion<T>`, and exposes exactly two
methods:

- `resolve(T val)` — calls the shared state's `setValue(std::move(val))`.
- `reject(std::exception_ptr exc)` — calls the shared state's `setException(exc)`.

Both are no-ops if the state is already settled (first-result-wins, same as
`CompletionState<T>::setValue`/`setException`) or if this `Promise` was itself
moved from (mirroring `Completion<T>::then()`/`onError()`'s null-state no-op —
see [Empty state](#empty-state)). Both are safe to call from any thread, since
they forward directly to the mutex-guarded `CompletionState<T>` methods.

`Promise` never exposes `CompletionState<T>` in its own interface — its
constructor is private, reachable only via the `friend`ed `makeSettleable()` —
so a caller can settle a `Completion<T>` on demand without the `detail::`
namespace ever appearing in their code.

## Thread safety

- `then()` and `onError()` may be called from any thread — the mutex guards
Expand Down Expand Up @@ -328,6 +365,18 @@ that will never signal.
| `then(handler)` | `Completion& then(std::function<void(T)>)` | Registers success callback; returns `*this` for chaining. |
| `onError(handler)` | `Completion& onError(std::function<void(std::exception_ptr)>)` | Registers error callback; returns `*this` for chaining. |
| `state()` | `shared_ptr<CompletionState<T>> state() const` | Returns the underlying shared state (advanced / internal use). |
| `makeSettleable(execPtr)` | `static std::pair<Completion<T>, Promise> makeSettleable(IExecutor*)` | Public settleable-promise factory (see [Settleable promise seam](#settleable-promise-seam--completiontpromise)). |

### `Completion<T>::Promise` (namespace `morph::async`)

| Member | Signature | Notes |
|---|---|---|
| move ctor | `Promise(Promise&&) noexcept = default` | Transfers state ownership. |
| move assign | `Promise& operator=(Promise&&) noexcept = default` | Transfers state ownership. |
| copy ctor | `Promise(Promise const&) = delete` | Move-only handle. |
| copy assign | `Promise& operator=(Promise const&) = delete` | Move-only handle. |
| `resolve(val)` | `void resolve(T)` | Settles the paired `Completion<T>` with a value; no-op if already settled or moved-from. |
| `reject(exc)` | `void reject(std::exception_ptr)` | Settles the paired `Completion<T>` with an error; no-op if already settled or moved-from. |

### `CompletionState<T>` (namespace `morph::async::detail`)

Expand All @@ -353,6 +402,7 @@ that will never signal.
| Value copy on fire-now | **`attachThen` copies `*value`; `setValue` moves it only into the last handler's invocation** | The set-after-attach path copies the value into every handler but the last (moving only into the final call), so no earlier handler observes a moved-from value and the value is still consumed exactly once overall. The attach-after-ready path must copy so `value` stays intact and a repeated `then()` on a settled state can still fire with the result. |
| Handler fan-out | **`onOk`/`onErr` are `std::vector`s, appended to on each attach** | Fixes issue #59: a second `onError()` (or `then()`) on the same still-pending `Completion` used to silently replace the first handler in a single-slot field. Composing (invoking every attached handler, in order) matches the mental model of an observer list and is what most call sites composing behavior via repeated attach actually expect. |
| Per-handler exception isolation | **Each composed handler invocation is wrapped in its own `try`/`catch (...)`, logged via `logError` and swallowed** | Fan-out means every attached handler should get its turn regardless of what an earlier one does. Without per-handler isolation, one throwing handler would unwind the whole posted closure and silently skip every handler attached after it — turning a single misbehaving consumer into an outage for unrelated ones sharing the same `Completion`. |
| Public settleable-promise seam | **`Completion<T>::Promise`, reachable only via `makeSettleable()`** | Fixes issue #55: test code needing a `Completion<T>` it can resolve/reject on demand had no seam except reaching into `morph::async::detail::CompletionState<T>` directly. `Promise`'s constructor is private and `friend`ed only to `Completion<T>`, so `detail::CompletionState<T>` never has to appear in a caller's own code. |

## Limitations

Expand Down Expand Up @@ -414,4 +464,7 @@ state; the log is emitted only when the state itself is finally destroyed with a
story; the orphan-logging contract detailed in this file is summarised there
alongside the executor and backend error paths.
- [`bridge.md`](bridge.md) — `BridgeHandler<M>` produces `Completion<T>` from
`execute()` and posts callbacks on the GUI executor.
`execute()` and posts callbacks on the GUI executor.
- [Settleable promise seam](#settleable-promise-seam--completiontpromise) —
`Completion<T>::makeSettleable()`, the public seam test code uses in place of
a `Bridge`/`IBackend` round trip.
14 changes: 14 additions & 0 deletions docs/spec/core/executor.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,20 @@ bookkeeping: the next queued task for that key still runs.
The destructor waits for all in-flight tasks to complete (`_inFlight == 0`)
before destroying the strand map.

**Testing per-model ordering without naming `StrandExecutor`/`ModelId` (issue #55).**
`RemoteServer` (see `backend.md`) owns a `StrandExecutor` internally, but every
task it ever dispatches — the top-level `handle()` post and the internal
per-model strand dispatch alike — funnels through the single `IExecutor` the
server was constructed with. A caller that wants a deterministic, hand-stepped
interleaving harness against `RemoteServer`'s real per-model ordering does not
need to touch `morph::exec::detail::StrandExecutor` or
`morph::exec::detail::ModelId` at all: constructing the server against a
single-step, test-controlled `IExecutor` (see `tests/test_support.hpp`'s
`morph::testing::StepExecutor`) and driving it one task at a time is enough —
`RemoteServer`'s own wire replies carry the model id as a plain `uint64_t`
(`wire::Envelope::modelId`), so a test never needs the `ModelId` vocabulary
either.

## Lifetime & ownership

`StrandExecutor` stores a raw pointer to the base `IExecutor` (`_base`, copied
Expand Down
78 changes: 78 additions & 0 deletions include/morph/core/completion.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <vector>

#include "executor.hpp"
Expand Down Expand Up @@ -246,6 +247,83 @@ class Completion {
/// @return Shared pointer to the completion state, or `nullptr` for empty completions.
[[nodiscard]] std::shared_ptr<detail::CompletionState<T>> state() const { return _state; }

/// @brief Producer-side handle paired with a `Completion<T>` by `makeSettleable()`.
///
/// `Promise<T>` is the public settling counterpart to `Completion<T>`: it
/// exposes exactly `resolve()`/`reject()` against the same shared state a
/// paired `Completion<T>` observes via `then()`/`onError()`, without ever
/// naming `morph::async::detail::CompletionState<T>`. Intended for test code
/// that needs to construct a `Completion<T>` it can settle on demand — e.g.
/// standing in for a `Bridge`/`IBackend` round trip — instead of reaching
/// into `detail::CompletionState<T>` directly (see docs/spec/core/completion.md,
/// "Settleable promise seam").
///
/// Move-only, mirroring `Completion<T>`: exactly one producer settles a
/// given operation.
class Promise {
public:
/// @brief Move constructor — transfers ownership of the shared state.
Promise(Promise&&) noexcept = default;
/// @brief Move assignment — transfers ownership of the shared state.
/// @return `*this`.
Promise& operator=(Promise&&) noexcept = default;
Promise(const Promise&) = delete;
Promise& operator=(const Promise&) = delete;
~Promise() = default;

/// @brief Resolves the paired `Completion<T>` with @p val.
///
/// No-op if the state is already settled (first result wins — see
/// `detail::CompletionState<T>::setValue`), or if this `Promise` was
/// moved from (mirrors `Completion<T>::then()`'s null-state no-op).
/// Safe to call from any thread.
/// @param val Success value delivered to every attached `then()` handler.
void resolve(T val) {
if (_state != nullptr) {
_state->setValue(std::move(val));
}
}

/// @brief Rejects the paired `Completion<T>` with @p exc.
///
/// No-op if the state is already settled (first result wins — see
/// `detail::CompletionState<T>::setException`), or if this `Promise` was
/// moved from (mirrors `Completion<T>::onError()`'s null-state no-op).
/// Safe to call from any thread.
/// @param exc Error delivered to every attached `onError()` handler, or
/// logged as an orphan if none is ever attached.
void reject(std::exception_ptr exc) {
if (_state != nullptr) {
_state->setException(exc);
}
}

private:
friend class Completion<T>;
explicit Promise(std::shared_ptr<detail::CompletionState<T>> state) : _state{std::move(state)} {}

std::shared_ptr<detail::CompletionState<T>> _state;
};

/// @brief Constructs a `Completion<T>`/`Promise<T>` pair sharing one settleable state.
///
/// The public "settleable promise" seam (issue #55): lets a caller — typically
/// test code — construct a `Completion<T>` it can resolve or reject on demand,
/// without a full `Bridge`/`IBackend` round trip and without reaching into
/// `morph::async::detail::CompletionState<T>`. Everything `Completion(state,
/// executor)` already provided by hand is available through this factory
/// instead: the returned `Completion<T>` is exactly what `then()`/`onError()`
/// observe; the returned `Promise` is exactly what settles it.
/// @param execPtr Executor callbacks are posted on; `nullptr` for a
/// write-only completion (see the two-argument constructor).
/// @return A `{Completion<T>, Promise}` pair sharing one `CompletionState<T>`.
[[nodiscard]] static std::pair<Completion<T>, Promise> makeSettleable(::morph::exec::IExecutor* execPtr) {
auto state = std::make_shared<detail::CompletionState<T>>();
Completion<T> completion{state, execPtr};
Promise promise{state};
return {std::move(completion), std::move(promise)};
}

private:
std::shared_ptr<detail::CompletionState<T>> _state;
};
Expand Down
39 changes: 39 additions & 0 deletions include/morph/qt/qt_websocket_backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,45 @@ class QtWebSocketBackend : public ::morph::backend::detail::IBackend {
#endif
Config cfg = Config{});

/// @brief Constructs the backend without naming the dispatcher/registry pair.
///
/// `QtWebSocketBackend` never actually uses its `dispatcher`/`registry`
/// constructor parameters (model construction is delegated to the server —
/// see `registerModel()`'s doc comment); the main constructor still accepts
/// them, positioned before `tls`/`cfg`, purely for API-shape parity with
/// other backends. That forces a caller who only wants to set `cfg` (e.g.
/// `Config::asyncRegistrationEnabled`) to spell out
/// `morph::model::detail::defaultDispatcher()`/`defaultRegistry()` explicitly
/// to reach the parameters after them — reaching into a `detail::` namespace
/// for no functional reason. This overload skips straight to `tls`/`cfg`.
/// @param serverUrl `ws://` or `wss://` URL of the remote `RemoteServer`.
/// @param tls If non-null, enables TLS and applies this configuration. Not
/// declared at all on an SSL-less Qt build (`QT_NO_SSL`) — see
/// the class doc comment's "SSL-less Qt builds" section.
/// @param cfg Reconnect tuning. Default: enabled, 500ms initial / 30s cap, 2x backoff.
#ifndef QT_NO_SSL
explicit QtWebSocketBackend(QUrl serverUrl, std::optional<QSslConfiguration> tls, Config cfg = Config{})
: QtWebSocketBackend(std::move(serverUrl), ::morph::model::detail::defaultDispatcher(),
::morph::model::detail::defaultRegistry(), std::move(tls), cfg) {}
#endif

/// @brief Constructs the backend without naming the dispatcher/registry pair or TLS.
///
/// See the `(serverUrl, tls, cfg)` overload's doc comment for why this
/// exists. Equivalent to that overload with `tls = std::nullopt` on an
/// SSL-enabled Qt build, or to the main constructor's defaults on an
/// SSL-less (`QT_NO_SSL`) build, where there is no `tls` parameter to skip.
/// @param serverUrl `ws://` or `wss://` URL of the remote `RemoteServer`.
/// @param cfg Reconnect tuning. Default: enabled, 500ms initial / 30s cap, 2x backoff.
explicit QtWebSocketBackend(QUrl serverUrl, Config cfg)
: QtWebSocketBackend(std::move(serverUrl), ::morph::model::detail::defaultDispatcher(),
::morph::model::detail::defaultRegistry(),
#ifndef QT_NO_SSL
std::nullopt,
#endif
cfg) {
}

/// @brief Closes the socket and cleans up pending operations.
~QtWebSocketBackend() override;

Expand Down
3 changes: 3 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ add_executable(morph_tests
test_completion.cpp
test_completion_extra.cpp
test_completion_multi_handler.cpp
test_completion_promise.cpp
test_model.cpp
test_logger.cpp
test_observability.cpp
Expand All @@ -26,8 +27,10 @@ add_executable(morph_tests
test_bridge_remote.cpp
test_bridge_pending_calls.cpp
test_bridge_execute_json.cpp
test_bridge_pending_calls.cpp
test_remote_extra.cpp
test_remote_connection_scope.cpp
test_remote_step_interleaving.cpp
test_action_validation.cpp
test_security_fixes.cpp
test_bridge_lifetime.cpp
Expand Down
Loading
Loading