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
83 changes: 74 additions & 9 deletions docs/spec/core/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ and react to backend changes.

- [The dispatch struct — `ActionCall`](#the-dispatch-struct--actioncall)
- [The abstract interface — `IBackend`](#the-abstract-interface--ibackend)
- [Asynchronous registration — `registerModelAsync`](#asynchronous-registration--registermodelasync)
- [Error types](#error-types)
- [`LocalBackend` — in-process execution](#localbackend--in-process-execution)
- [`RemoteServer` — server-side message handler](#remoteserver--server-side-message-handler)
Expand Down Expand Up @@ -68,12 +69,63 @@ holds a `unique_ptr<IBackend>` and delegates all model operations to it.
|---|---|
| `registerModel(typeId, factory)` | Registers a new model instance, returns its opaque `ModelId`. |
| `registerModelWithContext(typeId, factory, contextKey)` | Same as `registerModel`, additionally passes a stable identity (e.g. account id). Default implementation drops `contextKey` and forwards to `registerModel` — correct for `LocalBackend` where the factory closure already captures identity. `SimulatedRemoteBackend` overrides to carry `contextKey` across the wire. |
| `registerModelAsync(typeId, factory, contextKey, onRegistered, onError)` | Optional non-blocking counterpart to `registerModelWithContext`. Returns `false` by default (no async path); `Bridge::registerHandler()` prefers this when it returns `true` and falls back to the synchronous call otherwise. See [Asynchronous registration](#asynchronous-registration--registermodelasync). |
| `deregisterModel(mid)` | Removes the model identified by `mid`. |
| `execute(mid, call, cbExec)` | Dispatches `call` against the model identified by `mid`. Returns a `Completion<std::shared_ptr<void>>`. |
| `notifyBackendChanged()` | Called by `Bridge::switchBackend()` after all handlers are re-registered. |
| `cancelPending(exc)` | Resolves every still-pending completion with `exc`. Called on the outgoing backend during `switchBackend()` and in `Bridge`'s destructor. After this call, any later `setValue`/`setException` on those states is a no-op. |
| `setReconnectHandler(handler)` | Installs a callback invoked when the backend reconnects to its peer. Used by backends with transport (e.g. `QtWebSocketBackend`). Default implementation is a no-op. |

## Asynchronous registration — `registerModelAsync`

`registerModel`/`registerModelWithContext` are synchronous: a backend whose
registration requires a round-trip can only implement that by blocking the
calling thread until the reply arrives — `QtWebSocketBackend` does this via a
nested `QEventLoop` in `sendSync`. On a WASM main thread, Qt refuses to spin a
nested loop at all (`WaitForMoreEvents is not supported on the main thread
without asyncify`), so that blocking call aborts the page — the very first
`registerModel` a WASM client makes.

`IBackend::registerModelAsync(typeId, factory, contextKey, onRegistered,
onError)` is the optional non-blocking counterpart. A backend that offers one
sends the request and returns `true` immediately, then invokes exactly one of
`onRegistered(ModelId)` / `onError(message)` once the reply arrives, on the
backend's own thread — unless the backend is destroyed first, in which case
neither fires. The default implementation returns `false` without calling
either callback.

`Bridge::registerHandler()` (both overloads — the default-factory template and
the pre-built-binding overload) prefers this path: it adds the binding to
`_handlers` and calls `registerModelAsync` *before* acquiring `Bridge::_mtx`
for the callback (a synchronous callback invocation would otherwise
self-deadlock re-acquiring the lock). If it returns `false`, `registerHandler`
falls back to the synchronous `registerModelWithContext`, exactly as before
this feature existed. If it returns `true`, the binding is returned **unbound**
(`currentId == 0`) — `executeVia` fails fast with "handler not bound" for any
call made before `onRegistered` fires, so a caller using the async path must
wait for registration (e.g. gate its UI on it) rather than fire an action
immediately after constructing the handler.

**Staleness guard.** The success callback captures a `weak_ptr<IBackend>`
pinned to the backend the request was issued against, plus the Bridge's
`liveness()` token (the same pattern `installReconnectHandler` uses). Before
applying the received `ModelId`, it checks the liveness token (skip if the
Bridge is gone) and compares the pinned backend against `loadBackend()` (skip
if a `switchBackend()` already moved past this registration — that call's own
re-registration loop already gave the binding a fresh id on the new backend,
which a stale reply must not overwrite).

**Scope.** Only the plain (non-shared) registration path uses this — a
`BridgeHandler`'s initial construction. `registerModelShared`/`attachModel`
(shared/keyed handlers) and the re-registration `switchBackend()`/the
reconnect handler perform after a backend swap remain synchronous; giving
those an async path too is a larger change to `Bridge`'s locking model, left
for a future issue if it proves necessary.

`QtWebSocketBackend` is the one backend that currently overrides this, gated
by `QtWebSocketBackendConfig::asyncRegistrationEnabled` (default `false` — see
its own section below).

## Error types

Four exception types are thrown into in-flight `Completion`s:
Expand Down Expand Up @@ -455,6 +507,11 @@ by `_pendingMtx`, because `cancelPending` can be called from `Bridge` /
unblocks and reports failure instead of freezing the Qt thread forever; when
the parked loop returns with an empty `_pendingReply`, `sendSync` throws
`"disconnected"`. See concurrency_and_lifetimes.md.

**`registerModelAsync` is the non-blocking alternative**, opt-in via
`QtWebSocketBackendConfig::asyncRegistrationEnabled` (default `false`, so
every existing embedder keeps `registerModel`'s synchronous behavior
unchanged). See [Asynchronous registration](#asynchronous-registration--registermodelasync).
- `deregisterModel` — **fire-and-forget**, not synchronous: if `_connected`, it
sends a `deregister` envelope and returns immediately without waiting for the
ack; if disconnected, it does nothing. This deliberately avoids a nested
Expand All @@ -472,16 +529,22 @@ by `_pendingMtx`, because `cancelPending` can be called from `Bridge` /

**Reply framing / callId multiplexing.** `onTextMessage` decodes each incoming
frame and routes it by `callId`:
- A **non-zero `callId`** is an async `execute` reply. The backend pops the
matching `PendingExecute` from `_pending`; `ok` → `deserialize(body)` into the
completion's value (deserialisation exceptions become the completion's error),
any other kind → `std::runtime_error(message)` into the completion's error. A
reply whose `callId` is **not** in `_pending` (e.g. a late reply for an
- A **non-zero `callId`** is checked against `_pending` first (an async
`execute` reply): the backend pops the matching `PendingExecute`; `ok` →
`deserialize(body)` into the completion's value (deserialisation exceptions
become the completion's error), any other kind → `std::runtime_error(message)`
into the completion's error. If not found there, `_pendingRegistrations` is
checked next (an async `registerModelAsync` reply — same `callId`
counter/namespace as `execute`, separate map because the reply shape differs):
`ok` → `onRegistered(ModelId{modelId})`, any other kind → `onError(message)`.
A `callId` matching **neither** map (e.g. a late reply for an
already-cancelled call) is dropped silently.
- A **`callId == 0`** frame is a synchronous control reply (`register`); it is
stored in `_pendingReply` and quits the parked nested `QEventLoop`. A frame
that fails to decode is also routed to the parked sync waiter (as the raw
string) so the blocked `sendSync` unblocks with an error rather than hanging.
- A **`callId == 0`** frame is a synchronous control reply (`register` when
`asyncRegistrationEnabled` is `false`, or `attach`/`assign`/`instances`/
`deregister`); it is stored in `_pendingReply` and quits the parked nested
`QEventLoop`. A frame that fails to decode is also routed to the parked sync
waiter (as the raw string) so the blocked `sendSync` unblocks with an error
rather than hanging.

Because `execute` replies are matched on `callId`, concurrent in-flight execute
calls are supported; `RemoteServer`/`QtWebSocketServer` echo the request `callId`
Expand Down Expand Up @@ -969,12 +1032,14 @@ thread to marshal onto.
| `initialReconnectDelay` | `std::chrono::milliseconds` | `500 ms` |
| `maxReconnectDelay` | `std::chrono::milliseconds` | `30 s` |
| `backoffMultiplier` | `double` | `2.0` |
| `asyncRegistrationEnabled` | `bool` | `false` — opts in to `registerModelAsync` (see [Asynchronous registration](#asynchronous-registration--registermodelasync)); `false` keeps every embedder on `registerModel`'s synchronous behavior. |

### `QtWebSocketBackend` (namespace `morph::qt`)

| 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://`. |
| `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. |
| `registerModel(typeId, factory)` | Synchronous via nested `QEventLoop`; `factory` ignored. Throws on `err` reply. |
Expand Down
11 changes: 8 additions & 3 deletions docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ re-register all live bindings on reconnection.
`ModelFactory::create<Model>()` factory and registers it on the active
backend. Returns the `shared_ptr<HandlerBinding>`. An overload accepts a
pre-built binding (for dependency injection, custom `contextKey`, or custom
factory captures).
factory captures). Both funnel through a shared `registerHandlerImpl`, which
prefers the backend's `IBackend::registerModelAsync` when it offers one (see
`backend.md`, "Asynchronous registration") and falls back to the synchronous
`registerModelWithContext` otherwise — the returned binding may therefore come
back **unbound** (`currentId == 0`) if the backend registered asynchronously
and the reply has not arrived yet.

**`executeVia<Model, Action>(binding, action, cbExec)`** dispatches one
action. Takes a short snapshot of the backend `shared_ptr` under the dedicated
Expand Down Expand Up @@ -468,8 +473,8 @@ make teardown order-independent.)
|---|---|---|
| ctor | `explicit Bridge(unique_ptr<IBackend>)` | Installs reconnect handler on the backend. |
| 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. |
| `registerHandler<Model>` | `shared_ptr<HandlerBinding> registerHandler()` | Default factory. Prefers `IBackend::registerModelAsync`; see `backend.md`. |
| `registerHandler(binding)` | `void registerHandler(const shared_ptr<HandlerBinding>&)` | Pre-built binding. Same async-preferring behavior. |
| `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. |
| `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`. |
Expand Down
51 changes: 51 additions & 0 deletions include/morph/core/backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,57 @@ struct IBackend {
return registerModel(typeId, std::move(factory));
}

/// @brief Optional non-blocking counterpart to `registerModelWithContext`.
///
/// `registerModelWithContext`/`registerModel` are synchronous: a backend
/// whose registration requires a round-trip (a socket backend) can only
/// implement that by blocking the calling thread until the reply arrives —
/// `QtWebSocketBackend` does this via a nested `QEventLoop`. On a WASM main
/// thread, Qt refuses to spin a nested loop at all
/// (`WaitForMoreEvents is not supported on the main thread without asyncify`),
/// so that blocking call aborts the page — the very first `registerModel`
/// a WASM client makes.
///
/// Overriding this lets such a backend register without blocking: send the
/// request and return `true` immediately, then invoke exactly one of
/// @p onRegistered / @p onError once the reply arrives, on the backend's own
/// thread (unless the backend is destroyed first, in which case neither
/// fires). `Bridge::registerHandler()` prefers this path when it is
/// available and falls back to the synchronous `registerModelWithContext`
/// otherwise, so every backend that has not opted in (every backend as of
/// this writing, other than `QtWebSocketBackend`) is unaffected.
///
/// The default implementation offers no async path and returns `false`
/// without calling either callback — the caller (`Bridge::registerHandler`)
/// falls back to `registerModelWithContext` in that case, matching every
/// caller's behavior before this method existed.
///
/// @note Scope: only `Bridge::registerHandler()`'s plain (non-shared)
/// registration path — a `BridgeHandler`'s initial construction —
/// uses this. Shared/keyed registration (`registerModelShared`,
/// `attachModel`) and the re-registration `switchBackend()`/the
/// reconnect handler perform after a backend swap remain
/// synchronous; see docs/spec/core/backend.md.
/// @param typeId String type-id of the model to instantiate.
/// @param factory Callable that constructs the `IModelHolder` (local path only).
/// @param contextKey Stable identity of the new instance; empty if none.
/// @param onRegistered Invoked with the assigned `ModelId` on success.
/// @param onError Invoked with a diagnostic message on failure.
/// @return `true` if this backend accepted the request and will invoke
/// exactly one callback later; `false` if it has no async path
/// (neither callback is invoked in that case).
virtual bool registerModelAsync(
const std::string& typeId, std::function<std::unique_ptr<::morph::model::detail::IModelHolder>()> factory,
std::string_view contextKey, std::function<void(::morph::exec::detail::ModelId)> onRegistered,
std::function<void(const std::string&)> onError) {
(void)typeId;
(void)factory;
(void)contextKey;
(void)onRegistered;
(void)onError;
return false;
}

/// @brief Registers or attaches to the shared instance holding @p primary.
///
/// A *register-or-attach*: if an instance for `(typeId, primary)` is already
Expand Down
Loading
Loading