morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
qt_websocket_backend.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#ifndef QT_NO_SSL
5#include <QSslConfiguration>
6#endif
7#include <QTimer>
8#include <QUrl>
9#include <QWebSocket>
10#include <atomic>
11#include <chrono>
12#include <core/async/StopToken.hpp>
13#include <cstdint>
14#include <functional>
15#include <memory>
16#include <morph/attributes.hpp>
17#include <morph/core/backend.hpp>
19#include <morph/core/registry.hpp>
20#include <morph/core/wire.hpp>
21#include <mutex>
22#include <optional>
23#include <string>
24#include <unordered_map>
25#include <unordered_set>
26
27namespace morph::qt {
28
29/// @brief Reconnect tuning for `QtWebSocketBackend`.
30///
31/// Declared outside `QtWebSocketBackend` so its default initialisers are fully
32/// parsed before any constructor default argument that names `Config{}` is
33/// evaluated (same rationale as `morph::offline::NetworkMonitorConfig`).
35 /// @brief Whether to attempt automatic reconnect after an unsolicited disconnect.
36 bool reconnectEnabled = true;
37
38 /// @brief Delay before the first reconnect attempt after a disconnect.
39 std::chrono::milliseconds initialReconnectDelay = std::chrono::milliseconds{500};
40
41 /// @brief Upper bound on the exponential backoff between reconnect attempts.
42 std::chrono::milliseconds maxReconnectDelay = std::chrono::seconds{30};
43
44 /// @brief Multiplier applied to the delay after each failed attempt.
45 double backoffMultiplier = 2.0;
46};
47
48/// @brief `IBackend` implementation that communicates with a `RemoteServer` over WebSocket.
49///
50/// Every request is asynchronous and nothing blocks the Qt thread: a request
51/// carries a fresh non-zero `callId`, its pending entry is filed under it, and
52/// the matching reply settles that entry from the `textMessageReceived` slot.
53/// The caller's `Completion` is delivered on the executor the caller names.
54/// The request verbs are `execute()`, `bindModel()`/`promoteModel()` (the
55/// structural registration surface, `docs/spec/core/backend.md`),
56/// `instances()` and `negotiateProtocolVersion()`. A bind issued before the
57/// socket is up leaves its handler unbound; a call made through it is held by
58/// the `Bridge` and dispatched when the bind settles.
59///
60/// The synchronous `IBackend` verbs that would have to wait for a reply —
61/// `registerModel()`, `assignPrimary()`, `listInstances()` — throw
62/// `std::logic_error` naming their completion form: waiting would mean
63/// spinning a nested event loop on the Qt thread, which a WASM main thread
64/// cannot do at all.
65///
66/// Everything this backend keeps — the socket, the pending tables — belongs to
67/// the thread the socket lives on, as Qt's own objects do: every verb is called
68/// there (the `Bridge`'s owner is that thread) and every reply slot runs there,
69/// so none of it takes a lock. A debug build asserts it at each site.
70/// `deregisterModel()` is fire-and-forget: it sends the message and files only
71/// its `callId`, so the reply can be recognised and dropped.
72///
73/// @par TLS
74/// Pass a `QSslConfiguration` to enable `wss://`. Build it with `tlsVerifyingConfig()`
75/// (CA-verified — the recommended production default) or `tlsPinnedConfig()`
76/// (pinned-certificate — the correct choice for a self-signed deployment), both in
77/// `qt_tls.hpp`. `tlsInsecureNoVerify()` disables peer verification entirely and is
78/// for local development and tests only — see security.md's "Transport security" section.
79///
80/// @par SSL-less Qt builds (`QT_NO_SSL`, including the standard Qt-for-WebAssembly build)
81/// The constructor's `tls` parameter (and the `_tls` member) does not exist at all
82/// when Qt itself was configured without SSL — `QSslConfiguration` isn't a type
83/// Qt provides in that configuration, so there is no value to accept or ignore.
84/// `wss://` still works on such a build regardless: in a WASM/browser
85/// deployment the browser terminates TLS before Qt's `QWebSocket` ever sees
86/// the connection, so the only thing genuinely unavailable is the ability to
87/// *configure* TLS from C++ (client certificates, pinning, etc.) — plain
88/// `wss://` and `ws://` both still connect normally.
89///
90/// @par Threading
91/// Must be used from the Qt event loop thread. Every verb and the internal
92/// message handler are called on that thread.
93class QtWebSocketBackend : public ::morph::backend::detail::IBackend, public ::morph::backend::IEnvelopeTransport {
94public:
95 /// @brief Alias for the reconnect configuration struct.
97
98 /// @brief Constructs the backend and opens a WebSocket connection to @p serverUrl.
99 ///
100 /// @param serverUrl `ws://` or `wss://` URL of the remote `RemoteServer`.
101 /// @param dispatcher Action dispatcher (defaults to the process-level singleton).
102 /// @param registry Model registry (defaults to the process-level singleton).
103#ifndef QT_NO_SSL
104 /// @param tls If non-null, enables TLS and applies this configuration. Not
105 /// declared at all on an SSL-less Qt build (`QT_NO_SSL`) — see
106 /// the class doc comment's "SSL-less Qt builds" section.
107#endif
108 /// @param cfg Reconnect tuning. Default: enabled, 500ms initial / 30s cap, 2x backoff.
110 QUrl serverUrl,
111 ::morph::model::detail::ActionDispatcher& dispatcher = ::morph::model::detail::defaultDispatcher(),
112 ::morph::model::detail::ModelRegistryFactory& registry = ::morph::model::detail::defaultRegistry(),
113#ifndef QT_NO_SSL
114 std::optional<QSslConfiguration> tls = std::nullopt,
115#endif
116 Config cfg = Config{});
117
118 /// @brief Constructs the backend without naming the dispatcher/registry pair.
119 ///
120 /// `QtWebSocketBackend` never actually uses its `dispatcher`/`registry`
121 /// constructor parameters (model construction is delegated to the server —
122 /// see `registerModel()`'s doc comment); the main constructor still accepts
123 /// them, positioned before `tls`/`cfg`, purely for API-shape parity with
124 /// other backends. That forces a caller who only wants to set `cfg` (e.g.
125 /// `Config::reconnectEnabled`) to spell out
126 /// `morph::model::detail::defaultDispatcher()`/`defaultRegistry()` explicitly
127 /// to reach the parameters after them — reaching into a `detail::` namespace
128 /// for no functional reason. This overload skips straight to `tls`/`cfg`.
129 /// @param serverUrl `ws://` or `wss://` URL of the remote `RemoteServer`.
130 /// @param tls If non-null, enables TLS and applies this configuration. Not
131 /// declared at all on an SSL-less Qt build (`QT_NO_SSL`) — see
132 /// the class doc comment's "SSL-less Qt builds" section.
133 /// @param cfg Reconnect tuning. Default: enabled, 500ms initial / 30s cap, 2x backoff.
134#ifndef QT_NO_SSL
135 explicit QtWebSocketBackend(QUrl serverUrl, std::optional<QSslConfiguration> tls, Config cfg = Config{})
136 : QtWebSocketBackend(std::move(serverUrl), ::morph::model::detail::defaultDispatcher(),
137 ::morph::model::detail::defaultRegistry(), std::move(tls), cfg) {}
138#endif
139
140 /// @brief Constructs the backend without naming the dispatcher/registry pair or TLS.
141 ///
142 /// See the `(serverUrl, tls, cfg)` overload's doc comment for why this
143 /// exists. Equivalent to that overload with `tls = std::nullopt` on an
144 /// SSL-enabled Qt build, or to the main constructor's defaults on an
145 /// SSL-less (`QT_NO_SSL`) build, where there is no `tls` parameter to skip.
146 /// @param serverUrl `ws://` or `wss://` URL of the remote `RemoteServer`.
147 /// @param cfg Reconnect tuning. Default: enabled, 500ms initial / 30s cap, 2x backoff.
148 explicit QtWebSocketBackend(QUrl serverUrl, Config cfg)
149 : QtWebSocketBackend(std::move(serverUrl), ::morph::model::detail::defaultDispatcher(),
150 ::morph::model::detail::defaultRegistry(),
151#ifndef QT_NO_SSL
152 std::nullopt,
153#endif
154 cfg) {
155 }
156
157 /// @brief Closes the socket and cleans up pending operations.
159
160 // Neither copyable nor movable: the backend owns a QWebSocket bound to this
161 // object's address through Qt's signal/slot connections, and its pending-call
162 // maps are keyed to callbacks that capture `this`.
163 QtWebSocketBackend(const QtWebSocketBackend&) = delete;
164 QtWebSocketBackend& operator=(const QtWebSocketBackend&) = delete;
166 QtWebSocketBackend& operator=(QtWebSocketBackend&&) = delete;
167
168 /// @brief Pumps the Qt event loop until the socket is connected or @p timeoutMs elapses.
169 ///
170 /// Must be called on the Qt event loop thread after construction. It spins
171 /// a local `QEventLoop`, which a WASM main thread cannot do: a WASM client
172 /// binds without waiting (a bind issued before the socket is up is queued)
173 /// or reacts to `setConnectHandler`.
174 ///
175 /// @param timeoutMs Maximum time to wait in milliseconds.
176 /// @return `true` if connected before the timeout, `false` otherwise.
177 bool waitForConnected(int timeoutMs = 5000);
178
179 /// @brief Sends a `"hello"` envelope to the server and classifies its reply.
180 ///
181 /// Intended to be sent once, after the socket connects and before any
182 /// `bindModel`/`execute`; nothing enforces that ordering. The reply also
183 /// says whether the server honours `"cancel"`, which is the only way this
184 /// backend ever sends one; once called, `"hello"` is re-sent on every
185 /// reconnect, since a new connection may reach a different server.
186 ///
187 /// @param replyExec Executor the answer is delivered on. Borrowed: it must
188 /// outlive the returned `Completion`.
189 /// @return A `Completion` resolved with `Negotiated` if the server accepted
190 /// `kProtocolVersion`, or `LegacyPeer` if it does not understand
191 /// `"hello"`; rejected with a `std::runtime_error` if the server
192 /// rejects the version or the socket is not connected, and with
193 /// `backend::DisconnectedError` if it drops before the reply.
195 ::morph::exec::IExecutor& replyExec MORPH_LIFETIMEBOUND);
196
197 /// @brief Refuses: this backend registers only through `bindModel`.
198 ///
199 /// A registration is a round trip, and answering it synchronously would
200 /// mean blocking the Qt thread until the reply. `bindModel` with an empty
201 /// `primary` sends the same `register` envelope and settles a `Completion`.
202 /// The model is constructed by the server from its own registry, so this
203 /// backend never uses a factory.
204 /// @param typeId Unused.
205 /// @param factory Unused.
206 /// @return Never returns.
207 /// @throws std::logic_error always.
208 ::morph::exec::detail::ModelId registerModel(
209 const std::string& typeId,
210 std::function<std::unique_ptr<::morph::model::detail::IModelHolder>()> factory) override;
211
212 /// @brief Acquires a model instance over the wire, settled by the server's reply.
213 ///
214 /// The structural registration surface (`IBackend::bindModel`). The
215 /// request's shape selects the envelope:
216 ///
217 /// | `primary` | `current` | Envelope sent |
218 /// |---|---|---|
219 /// | empty | `0` | `register` (private) |
220 /// | empty | non-zero | `deregister` of `current`, then `register` |
221 /// | non-empty | `0` | `register` with `shared` (register-or-attach) |
222 /// | non-empty | non-zero | `attach`, naming `current` |
223 ///
224 /// Each carries a fresh `callId` from the counter `execute()` uses and the
225 /// installed session; `request.contextKey` rides on every shape, since the
226 /// server constructs the holder itself and the key is the only channel by
227 /// which the instance's identity (and so its action log) reaches it.
228 ///
229 /// A **private** registration issued before the socket has finished
230 /// connecting is queued rather than failed: exactly the ordering a
231 /// single-threaded WASM client must use, since it can never wait for the
232 /// connection to settle. The queued request is sent — with a call-id
233 /// assigned at that point — the moment `connected` fires next, first
234 /// connect included, in FIFO order. If the backend is torn down first,
235 /// `cancelPending` rejects each queued entry exactly once. A keyed bind on
236 /// a disconnected socket rejects immediately instead: it may re-point a
237 /// live instance, and replaying that against a later connection would
238 /// attach from a `current` that connection never issued.
239 ///
240 /// @param request Owning bind request; moved from. `request.factory` is
241 /// unused — the server instantiates the model from
242 /// `request.typeId`.
243 /// @param cbExec Executor the continuation is delivered on. Borrowed: it
244 /// must outlive the returned `Completion`.
245 /// @return A `Completion` resolved with the bound `ModelId`, or rejected
246 /// with the failure.
248 ::morph::exec::IExecutor& cbExec) override;
249
250 /// @brief Refuses: this backend promotes only through `promoteModel`.
251 ///
252 /// Same reason as `registerModel`: an `assign` is a round trip.
253 /// @param mid Unused.
254 /// @param typeId Unused.
255 /// @param primary Unused.
256 /// @throws std::logic_error always.
257 void assignPrimary(::morph::exec::detail::ModelId mid, const std::string& typeId,
258 std::string_view primary) override;
259
260 /// @brief Files a live server-side instance under a key, settled by the server's reply.
261 ///
262 /// The structural counterpart of `assignPrimary` (`IBackend::promoteModel`):
263 /// sends the `assign` envelope with a fresh `callId` and returns an
264 /// unsettled `Completion`. The documented no-op cases (empty `primary`,
265 /// zero `mid`) resolve with @p request's `mid` without sending anything,
266 /// matching `IBackend::promoteModel`. A disconnected socket rejects.
267 ///
268 /// @param request Owning promote request; moved from.
269 /// @param cbExec Executor the continuation is delivered on. Borrowed: it
270 /// must outlive the returned `Completion`.
271 /// @return A `Completion` resolved with the promoted `ModelId`, or rejected
272 /// with the failure.
274 ::morph::backend::detail::PromoteRequest request, ::morph::exec::IExecutor& cbExec) override;
275
276 /// @brief Refuses: this backend lists instances only through `instances`.
277 ///
278 /// Same reason as `registerModel`: an `instances` request is a round trip.
279 /// @param typeId Unused.
280 /// @return Never returns.
281 /// @throws std::logic_error always.
282 std::vector<std::string> listInstances(const std::string& typeId) override;
283
284 /// @brief Asks the server for the live shared primary keys of @p typeId.
285 ///
286 /// Sends an `instances` envelope with a fresh `callId`; the reply settles
287 /// the returned `Completion`. A disconnected socket rejects.
288 /// @param typeId String type-id to enumerate.
289 /// @param cbExec Executor the answer is delivered on. Borrowed: it must
290 /// outlive the returned `Completion`.
291 /// @return A `Completion` resolved with the canonical key strings of the
292 /// live shared instances, or rejected with the server's error, a
293 /// decode failure, or `backend::DisconnectedError`.
295 ::morph::exec::IExecutor& cbExec) override;
296
297 /// @brief Sends one request envelope of a kind the other verbs do not build (`IEnvelopeTransport::request`)
298 /// and settles with its reply.
299 ///
300 /// The envelope gets a fresh `callId` and keeps the session it carries. Made while the socket is not connected,
301 /// it is held and sent at the next connect, the first included, as a private bind is; `cancelPending`, a drop
302 /// and the destructor reject what is pending or held. On the socket's thread.
303 /// @param envelope The request; its `callId` is replaced.
304 /// @param cbExec Executor the answer is delivered on. Borrowed: it must outlive the returned `Completion`.
305 /// @return A `Completion` resolved with the reply envelope, `ok` or `err`; rejected with
306 /// `backend::DisconnectedError` when the connection drops before the reply or the request is cancelled.
308 ::morph::exec::IExecutor& cbExec) override;
309
310 /// @brief Sends a `deregister` message fire-and-forget (does not wait for a reply).
311 ///
312 /// Nothing waits for the acknowledgement, so it is safe from a destructor.
313 /// The server performs no connection-scoped cleanup: an undelivered or lost
314 /// `deregister` leaves the model registered on the server indefinitely.
315 ///
316 /// The request carries a real, non-zero `callId` from the counter every
317 /// other request uses, filed in `_pendingDeregisters` only so its reply is
318 /// recognised and dropped.
319 ///
320 /// @param mid Id of the model to remove on the server.
321 void deregisterModel(::morph::exec::detail::ModelId mid) override;
322
323 /// @brief Sends an `execute` message and returns a `Completion` that resolves on reply.
324 ///
325 /// Assigns a monotonically increasing call-id so that concurrent calls can be
326 /// matched to their replies. The `Completion` callbacks are posted via @p cbExec.
327 ///
328 /// @param mid Target model id on the server.
329 /// @param call Bundled action; `serializeAction` and `deserializeResult` are used.
330 /// @param cbExec Executor for delivering the completion callbacks.
331 /// @return Completion resolved asynchronously when the server reply arrives.
333 ::morph::backend::detail::ActionCall call,
334 ::morph::exec::IExecutor* cbExec) override;
335
336 /// @brief No-op — this backend holds no local model objects.
337 void notifyBackendChanged() override {}
338
339 /// @brief Rejects every in-flight request's `Completion` with @p exc —
340 /// executes, binds (queued ones included), promotes, instance
341 /// listings and the `hello` alike.
342 ///
343 /// Called by `Bridge::switchBackend()` on the outgoing backend, by `~Bridge`,
344 /// and internally when the socket disconnects. Late replies arriving for
345 /// already-cancelled call ids are dropped silently.
346 ///
347 /// @param exc Exception delivered to every pending completion's error sink.
348 void cancelPending(const std::exception_ptr& exc) override;
349
350 /// @brief Installs the handler `Bridge` uses to re-register handlers after a reconnect.
351 /// @param handler Callable posted to @p exec after every successful
352 /// reconnect. Pass `nullptr` to clear.
353 /// @param exec Executor the handler runs on. Borrowed: it must outlive
354 /// the installation.
355 void setReconnectHandler(std::function<void()> handler, ::morph::exec::IExecutor* exec) override;
356
357 /// @brief Installs a handler invoked on every successful connect, including the first.
358 /// @param handler Callable invoked on the Qt thread after every successful connect
359 /// (first and subsequent). Pass `nullptr` to clear.
360 void setConnectHandler(const std::function<void()>& handler) override;
361
362 /// @brief Installs a handler invoked whenever the socket drops.
363 ///
364 /// Fires before reconnect scheduling, so an observer sees the disconnected
365 /// state even when a retry follows immediately.
366 /// @param handler Callable invoked on the Qt thread whenever the connection
367 /// drops. Pass `nullptr` to clear.
368 void setDisconnectHandler(const std::function<void()>& handler) override;
369
370 /// @brief Installs the handler each server-initiated event is posted to.
371 /// See `IBackend::setEventHandler`.
372 ///
373 /// An `event` envelope is recognised by its kind before any reply
374 /// matching, so whatever its `callId`, it never settles a call. Events are
375 /// posted to @p exec in the order they arrived. Callable from any thread:
376 /// the handler is held under a mutex, and an event already being delivered
377 /// keeps the handler it was read with.
378 /// @param handler Callable posted to @p exec once per event. Pass
379 /// `nullptr` to clear.
380 /// @param exec Executor the handler is posted to. Borrowed: it must
381 /// outlive the installation. May be null only when
382 /// @p handler is.
383 void setEventHandler(std::function<void(::morph::wire::EventBody)> handler,
384 ::morph::exec::IExecutor* exec) override;
385
386 /// @brief Installs the session stamped onto every control envelope this
387 /// backend subsequently builds (`register`, `registerShared`,
388 /// `attach`, `assign`, `deregister`). See `IBackend::setSession`.
389 /// @param session Session to stamp; typically pushed by `Bridge::setDefaultSession()`.
390 void setSession(::morph::session::Context session) override;
391
392private:
393 /// @brief A request waiting for its reply: what the reply settles, and
394 /// what a cancel rejects. Exactly one of the two is called.
395 struct PendingControl {
396 /// Settles the caller's `Completion` from the matching reply.
397 std::function<void(const ::morph::wire::Envelope&)> reply;
398 /// Rejects it, when the request is cancelled or never sent.
399 std::function<void(const std::exception_ptr&)> fail;
400 /// Whether the envelope keeps the session it carries (`request`) instead of the backend's.
401 bool keepSession = false;
402 };
403
404 /// @brief A `PendingControl` whose reply is a bare `modelId`
405 /// (`register`, `registerShared`, `attach`, `assign`).
406 /// @param promise The caller's promise.
407 /// @return The pending entry that settles @p promise.
408 static PendingControl modelIdReply(::morph::async::Completion<::morph::exec::detail::ModelId>::Promise promise);
409
410 /// @brief Slot called by `QWebSocket` when a text frame arrives.
411 void onTextMessage(const QString& message);
412
413 /// @brief Dispatches a reply to whichever pending table holds its `callId`,
414 /// and drops it when none does.
415 /// @param env Decoded reply envelope.
416 void routeReply(const ::morph::wire::Envelope& env);
417
418 /// @brief Settles the execute filed under `env.callId`, if one is pending.
419 /// @param env Decoded reply envelope.
420 /// @return `true` if an execute was found and settled.
421 bool tryRouteExecuteReply(const ::morph::wire::Envelope& env);
422
423 /// @brief Settles the request filed under `env.callId` in `_pendingControl`, if any.
424 /// @param env Decoded reply envelope.
425 /// @return `true` if a pending request was found and settled.
426 bool tryRouteControlReply(const ::morph::wire::Envelope& env);
427
428 /// @brief Drops the reply to a fire-and-forget deregister.
429 /// @param env Decoded reply envelope.
430 /// @return `true` if the id belonged to a pending deregister.
431 bool tryRouteDeregisterReply(const ::morph::wire::Envelope& env);
432
433 /// @brief Schedules a reconnect attempt with exponential backoff.
434 void scheduleReconnect();
435
436 /// @brief Attempts to reopen the socket using the saved URL/TLS config.
437 void attemptReconnect();
438
439 /// @brief Assigns a call-id, files @p pending under it, and sends @p env.
440 ///
441 /// The one send path every request but `execute` takes. It encodes before
442 /// filing: a throwing `wire::encode()` after filing would leave an entry
443 /// waiting for a reply to a message that was never sent, so a throw
444 /// rejects through @p pending instead.
445 ///
446 /// @param env Request envelope to send; its `callId`/`session` are
447 /// stamped here.
448 /// @param pending Settled when the matching reply arrives, or rejected by
449 /// `cancelPending`.
450 void sendControl(::morph::wire::Envelope env, PendingControl pending);
451
452 /// @brief Sends every private bind, then every request, queued while the
453 /// socket was not yet connected, each in FIFO order. Called from the
454 /// `connected` slot, before the reconnect handler fires.
455 void flushQueuedRegistrations();
456
457 /// @brief Sends a `cancel` for every pending execute whose stop was
458 /// requested since the last drain, if the server advertised
459 /// `"cancel"`. On the socket's thread, from `_cancelWake`.
460 void drainCancels();
461
462 /// @brief Sends a fire-and-forget `cancel` for @p target; its reply is
463 /// filed nowhere and dropped.
464 /// @param target Id of the execute to stop.
465 /// @param callerSession Session the execute carried.
466 void sendCancel(uint64_t target, const ::morph::session::Context& callerSession);
467
468 /// @brief Re-sends `"hello"` after a reconnect, recording only whether the
469 /// server advertises `"cancel"`.
470 void resendHello();
471
472 /// @brief Asserts, in a debug build, that @p site runs on the socket's
473 /// thread — where every table this backend keeps belongs.
474 /// @param site Name of the calling body.
475 void checkThread(char const* site) const noexcept;
476
477 QUrl _serverUrl;
478#ifndef QT_NO_SSL
479 std::optional<QSslConfiguration> _tls;
480#endif
481 Config _cfg;
482 QWebSocket _socket;
483 QTimer _reconnectTimer;
484 /// Zero-interval single shot whose `timeout` runs `drainCancels` on the
485 /// socket's thread. A stop callback, on any thread, starts it through a
486 /// queued `QMetaObject::invokeMethod(&_cancelWake, "start")`.
487 QTimer _cancelWake;
488 std::chrono::milliseconds _currentReconnectDelay;
489 bool _connected{false};
490 bool _everConnected{false};
491 bool _shuttingDown{false};
492 std::function<void()> _reconnectHandler;
493 ::morph::exec::IExecutor* _reconnectExec{nullptr};
494 std::function<void()> _connectHandler;
495 std::function<void()> _disconnectHandler;
496 /// Guards `_eventHandler` and `_eventExec`, which `setEventHandler` may
497 /// write from any thread while the socket's thread reads them.
498 std::mutex _eventMutex;
499 std::shared_ptr<std::function<void(::morph::wire::EventBody)>> _eventHandler;
500 ::morph::exec::IExecutor* _eventExec{nullptr};
501 ::morph::session::Context _session;
502 /// Set by `negotiateProtocolVersion`: re-send `"hello"` on each connect.
503 bool _negotiate{false};
504 /// Whether this connection's server advertised `"cancel"`. Cleared on
505 /// every disconnect, learned again from the next `"hello"` reply.
506 bool _cancelAdvertised{false};
507
508 /// The stop callback a stoppable execute registers. Runs on whichever
509 /// thread requested the stop, so it touches no table: it marks its call
510 /// and queues a start of `_cancelWake`, whose drain runs on the socket's
511 /// thread. A start queued for a destroyed timer is dropped with it.
512 struct CancelOnStop {
513 std::shared_ptr<std::atomic<bool>> requested;
514 QTimer* wake{nullptr};
515 void operator()() const noexcept;
516 };
517
518 struct PendingExecute {
519 std::shared_ptr<::morph::async::detail::CompletionState<std::shared_ptr<void>>> state;
520 std::function<std::shared_ptr<void>(std::string_view)> deserialize;
521 ::morph::exec::IExecutor* cbExec{nullptr};
522 /// The call's stop source, or null for a call nothing can stop.
523 std::shared_ptr<::core::async::StopSource> stop;
524 /// The execute's session, for the cancel; empty without `stop`.
525 ::morph::session::Context session;
526 /// Set by the stop callback, cleared by the drain that sends the
527 /// cancel; null without `stop`.
528 std::shared_ptr<std::atomic<bool>> stopRequested;
529 /// Registered on `stop`; deregistered when the entry goes, so a stop
530 /// after the reply sends nothing.
531 std::shared_ptr<::core::async::StopCallback<CancelOnStop>> watch;
532 };
533 uint64_t _nextCallId{0};
534 // Every table below is touched only on the socket's thread: by the verbs,
535 // called there, and by the reply slot, which runs there. All of them share
536 // the one `callId` counter, so an id names one request across them.
537 std::unordered_map<uint64_t, PendingExecute> _pending;
538
539 /// @brief In-flight requests other than `execute` — `register`,
540 /// `registerShared`, `attach`, `assign`, `instances`, `hello` —
541 /// keyed by `callId`.
542 ///
543 /// Apart from `_pending` because an execute reply is deserialised into the
544 /// action's result, while each of these settles its own value type.
545 std::unordered_map<uint64_t, PendingControl> _pendingControl;
546
547 /// @brief One private `bindModel` issued before the socket had finished
548 /// connecting. No call-id is assigned until the request is actually
549 /// sent (from `flushQueuedRegistrations`), so a queued entry that
550 /// never gets to fire (backend destroyed first) is drained by
551 /// `cancelPending` from here rather than by call-id.
552 struct QueuedRegistration {
553 std::string typeId;
554 std::string contextKey;
555 ::morph::async::Completion<::morph::exec::detail::ModelId>::Promise promise;
556 };
557 std::vector<QueuedRegistration> _queuedRegistrations;
558
559 /// @brief One `request` made while the socket was not connected, sent at the next connect; drained by
560 /// `cancelPending` like a queued registration.
561 struct QueuedRequest {
562 ::morph::wire::Envelope envelope;
563 PendingControl pending;
564 };
565 std::vector<QueuedRequest> _queuedRequests;
566
567 /// @brief Call-ids of `deregister` envelopes still awaiting their (unused)
568 /// reply, so the reply is recognised and dropped. A late reply for
569 /// an id no longer here (the backend was cancelled) is dropped too.
570 std::unordered_set<uint64_t> _pendingDeregisters;
571};
572
573} // namespace morph::qt
AnyHandle session(std::string const &name)
A session attribute, {"session": name}.
Definition author.hpp:1214
Producer-side handle paired with a Completion<T> by makeSettleable().
Move-only handle representing the eventual result of an asynchronous operation.
What a wire transport offers beside IBackend: one request, one reply, matched by callId.
IBackend implementation that communicates with a RemoteServer over WebSocket.
QtWebSocketBackend(QUrl serverUrl, ::morph::model::detail::ActionDispatcher &dispatcher=::morph::model::detail::defaultDispatcher(), ::morph::model::detail::ModelRegistryFactory &registry=::morph::model::detail::defaultRegistry(), std::optional< QSslConfiguration > tls=std::nullopt, Config cfg=Config{})
Constructs the backend and opens a WebSocket connection to serverUrl.
::morph::async::Completion<::morph::wire::Envelope > request(::morph::wire::Envelope envelope, ::morph::exec::IExecutor &cbExec) override
Sends one request envelope of a kind the other verbs do not build (IEnvelopeTransport::request) and s...
::morph::async::Completion<::morph::wire::ProtocolNegotiationResult > negotiateProtocolVersion(::morph::exec::IExecutor &replyExec)
Sends a "hello" envelope to the server and classifies its reply.
void assignPrimary(::morph::exec::detail::ModelId mid, const std::string &typeId, std::string_view primary) override
Refuses: this backend promotes only through promoteModel.
QtWebSocketBackend(QUrl serverUrl, std::optional< QSslConfiguration > tls, Config cfg=Config{})
Constructs the backend without naming the dispatcher/registry pair.
::morph::async::Completion< std::shared_ptr< void > > execute(::morph::exec::detail::ModelId mid, ::morph::backend::detail::ActionCall call, ::morph::exec::IExecutor *cbExec) override
Sends an execute message and returns a Completion that resolves on reply.
QtWebSocketBackend(QUrl serverUrl, Config cfg)
Constructs the backend without naming the dispatcher/registry pair or TLS.
::morph::async::Completion< std::vector< std::string > > instances(const std::string &typeId, ::morph::exec::IExecutor &cbExec) override
Asks the server for the live shared primary keys of typeId.
::morph::exec::detail::ModelId registerModel(const std::string &typeId, std::function< std::unique_ptr<::morph::model::detail::IModelHolder >()> factory) override
Refuses: this backend registers only through bindModel.
std::vector< std::string > listInstances(const std::string &typeId) override
Refuses: this backend lists instances only through instances.
bool waitForConnected(int timeoutMs=5000)
Pumps the Qt event loop until the socket is connected or timeoutMs elapses.
void setDisconnectHandler(const std::function< void()> &handler) override
Installs a handler invoked whenever the socket drops.
void notifyBackendChanged() override
No-op — this backend holds no local model objects.
void setSession(::morph::session::Context session) override
Installs the session stamped onto every control envelope this backend subsequently builds (register,...
void setEventHandler(std::function< void(::morph::wire::EventBody)> handler, ::morph::exec::IExecutor *exec) override
Installs the handler each server-initiated event is posted to. See IBackend::setEventHandler.
void setReconnectHandler(std::function< void()> handler, ::morph::exec::IExecutor *exec) override
Installs the handler Bridge uses to re-register handlers after a reconnect.
void deregisterModel(::morph::exec::detail::ModelId mid) override
Sends a deregister message fire-and-forget (does not wait for a reply).
void cancelPending(const std::exception_ptr &exc) override
Rejects every in-flight request's Completion with exc — executes, binds (queued ones included),...
void setConnectHandler(const std::function< void()> &handler) override
Installs a handler invoked on every successful connect, including the first.
QtWebSocketBackendConfig Config
Alias for the reconnect configuration struct.
::morph::async::Completion<::morph::exec::detail::ModelId > promoteModel(::morph::backend::detail::PromoteRequest request, ::morph::exec::IExecutor &cbExec) override
Files a live server-side instance under a key, settled by the server's reply.
~QtWebSocketBackend() override
Closes the socket and cleans up pending operations.
::morph::async::Completion<::morph::exec::detail::ModelId > bindModel(::morph::backend::detail::BindRequest request, ::morph::exec::IExecutor &cbExec) override
Acquires a model instance over the wire, settled by the server's reply.
IEnvelopeTransport: a transport that sends a request envelope of a kind its backend verbs do not buil...
Abstract executor interface.
Definition executor.hpp:70
Reconnect tuning for QtWebSocketBackend.
std::chrono::milliseconds maxReconnectDelay
Upper bound on the exponential backoff between reconnect attempts.
std::chrono::milliseconds initialReconnectDelay
Delay before the first reconnect attempt after a disconnect.
double backoffMultiplier
Multiplier applied to the delay after each failed attempt.
bool reconnectEnabled
Whether to attempt automatic reconnect after an unsolicited disconnect.
Per-call session context carried through the bridge to the model.
Definition session.hpp:26
JSON wire envelope used between any client and RemoteServer.
Definition wire.hpp:91
The decoded body of an event envelope.
Definition wire.hpp:621