morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
socket_backend.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <array>
6#include <atomic>
7#include <chrono>
8#include <core/async/StopToken.hpp>
9#include <core/async/Task.hpp>
10#include <core/net/EventLoop.hpp>
11#include <core/net/IConnector.hpp>
12#include <core/net/Sockets.hpp>
13#include <core/net/ThreadedAddressResolver.hpp>
14#include <cstddef>
15#include <cstdint>
16#include <functional>
17#include <future>
18#include <memory>
19#include <morph/core/backend.hpp>
24#include <morph/core/logger.hpp>
26#include <morph/core/wire.hpp>
27#include <optional>
28#include <stdexcept>
29#include <string>
30#include <string_view>
31#include <utility>
32#include <vector>
33
34#include "../attributes.hpp"
36#include "detail/ws_frame.hpp"
37#include "detail/ws_handshake.hpp"
38
39namespace morph::net {
40
41/// @brief Reconnect tuning for `SocketBackend`.
43 /// @brief Whether to attempt automatic reconnect after an unsolicited disconnect.
44 bool reconnectEnabled = true;
45 /// @brief Delay before the first reconnect attempt after a disconnect.
46 std::chrono::milliseconds initialReconnectDelay{500};
47 /// @brief Upper bound on the exponential backoff between reconnect attempts.
48 std::chrono::milliseconds maxReconnectDelay{30000};
49 /// @brief Multiplier applied to the delay after each failed attempt.
50 double backoffMultiplier = 2.0;
51 /// @brief Maximum time for the whole connect: name resolution and every
52 /// resolved address.
53 std::chrono::milliseconds connectTimeout{5000};
54 /// @brief Bound on writing one chunk (up to 64 KiB) of an outgoing frame.
55 ///
56 /// A loop timer armed around each chunk's write closes the connection when
57 /// it runs out, so a peer that stops reading ends in a disconnect (and the
58 /// usual reconnect) rather than in a queue of unsent frames that grows
59 /// forever. Generous on purpose: it bounds a write making no progress, not
60 /// a slow one. Zero disables it.
61 std::chrono::milliseconds sendTimeout{30000};
62 /// @brief Bound on the whole handshake response read that follows a
63 /// successful TCP connect, from its first byte to the header's end.
64 ///
65 /// A peer that accepts the TCP connection and then never completes the
66 /// WebSocket upgrade — a TCP load balancer connecting lazily to its
67 /// backend would — is dropped after this long, and counts as a failed
68 /// connect. Zero disables it.
69 std::chrono::milliseconds handshakeTimeout{10000};
70};
71
72/// @brief `IBackend` implementation that communicates with a `RemoteServer`
73/// over a raw-socket RFC 6455 WebSocket connection — no Qt, no GUI
74/// event loop required.
75///
76/// Mirrors `morph::qt::QtWebSocketBackend`'s observable behavior
77/// (`deregisterModel` fire-and-forget, `execute` and `bindModel` asynchronous
78/// and callId-multiplexed, `DisconnectedError`/reconnect semantics). Its
79/// socket, its pending calls, its session, its handlers and its reconnect
80/// state machine live on an `exec::IoLoop`, which it shares with every other
81/// socket, timer and probe the application builds on that loop.
82///
83/// Cross-thread surface: `execute`, `bindModel`, `promoteModel`, `request`,
84/// `deregisterModel`, `cancelPending`, `setSession`, `setReconnectHandler`,
85/// `setConnectHandler` and `setEventHandler` post to the loop and return, so
86/// calls from one thread take effect in the order they were made;
87/// `waitForConnected` posts a waiter and blocks the
88/// caller, never the loop; the synchronous control verbs (`registerModel`,
89/// `registerModelWithContext`, `assignPrimary`, `listInstances`) issue the same
90/// posted request and wait for its reply, never on the loop's own thread.
91/// Every field is touched only on the loop, except the `connected` flag.
92///
93/// @par TLS
94/// Not supported. `serverUrl` must be `ws://`; `wss://` throws from the
95/// constructor. See `docs/spec/core/backend.md`'s `morph::net` section.
96class SocketBackend : public ::morph::backend::detail::IBackend, public ::morph::backend::IEnvelopeTransport {
97public:
98 /// @brief Alias for the reconnect configuration struct.
100
101 /// @brief Parses @p serverUrl and starts connecting on @p loop.
102 /// @param loop The application's I/O loop. Borrowed: it must outlive
103 /// this backend.
104 /// @param serverUrl `ws://host:port[/path]` URL of the remote `RemoteServer`.
105 /// @param cfg Reconnect tuning. Default: enabled, 500ms initial / 30s cap, 2x backoff.
106 /// @throws std::runtime_error immediately if @p serverUrl is not a
107 /// well-formed `ws://` URL (see `parseWsUrl`).
108 SocketBackend(::morph::exec::IoLoop& loop MORPH_LIFETIMEBOUND, std::string_view serverUrl, Config cfg = {})
109 : _url{::morph::net::detail::parseWsUrl(serverUrl)},
110 _loop{&loop},
111 _core{std::make_shared<Core>(loop, _url, cfg)} {
112 start();
113 }
114
115 /// @brief Parses @p serverUrl and starts connecting on a loop of its own,
116 /// for a caller with no `IoLoop` to share.
117 /// @param serverUrl `ws://host:port[/path]` URL of the remote `RemoteServer`.
118 /// @param cfg Reconnect tuning. Default: enabled, 500ms initial / 30s cap, 2x backoff.
119 /// @throws std::runtime_error immediately, before any loop is started, if
120 /// @p serverUrl is not a well-formed `ws://` URL (see `parseWsUrl`).
121 explicit SocketBackend(std::string_view serverUrl, Config cfg = {})
122 : _url{::morph::net::detail::parseWsUrl(serverUrl)},
123 _ownedLoop{std::make_unique<::morph::exec::IoLoop>()},
124 _loop{_ownedLoop.get()},
125 _core{std::make_shared<Core>(*_ownedLoop, _url, cfg)} {
126 start();
127 }
128
129 SocketBackend(const SocketBackend&) = delete;
130 SocketBackend& operator=(const SocketBackend&) = delete;
131 SocketBackend(SocketBackend&&) = delete;
132 SocketBackend& operator=(SocketBackend&&) = delete;
133
134 /// @brief Closes the connection and rejects every pending call with
135 /// `DisconnectedError`.
136 ///
137 /// The close runs on the loop: inline when this runs on the loop's own
138 /// thread (from a callback), posted and waited for otherwise. It never
139 /// waits for a connect in progress: a dial that completes afterwards finds
140 /// the backend closed and drops its socket.
141 // NOLINTNEXTLINE(bugprone-exception-escape): teardown has no recovery if handing the close to the loop fails, so terminating is the outcome.
142 ~SocketBackend() override {
143 _loop->runAndWait([core = _core] { core->close(); });
144 }
145
146 /// @brief Blocks the calling thread until connected or @p timeout elapses.
147 ///
148 /// Posts a waiter to the loop, which releases it when a connection
149 /// completes. On the loop's own thread it cannot wait for the loop, so it
150 /// answers at once.
151 ///
152 /// @warning The backend must outlive this call. See
153 /// `docs/spec/core/backend.md`'s "Lifetime & ownership" section.
154 /// @param timeout Maximum time to wait.
155 /// @return `true` if connected before the timeout, `false` otherwise.
156 bool waitForConnected(std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) {
157 if (_core->connected.load() || _loop->runningHere()) {
158 return _core->connected.load();
159 }
160 auto waiter = std::make_shared<std::promise<void>>();
161 std::future<void> const released = waiter->get_future();
162 _loop->post([core = _core, waiter] { core->addConnectWaiter(waiter); });
163 static_cast<void>(released.wait_for(timeout));
164 return _core->connected.load();
165 }
166
167 /// @brief Registers a private instance and waits for the reply.
168 ///
169 /// The same request `bindModel` posts, waited for on the calling thread —
170 /// which must not be the I/O loop's, whose reply this would wait on. The
171 /// factory argument is ignored — model construction is delegated to the
172 /// server.
173 /// @param typeId String type-id of the model to register.
174 /// @param factory Ignored — the server constructs via its own registry.
175 /// @return `ModelId` assigned by the server.
176 /// @throws std::runtime_error if the server replies with an error, the
177 /// socket is not connected, or this is the loop's own thread.
178 ::morph::exec::detail::ModelId registerModel(
179 const std::string& typeId,
180 std::function<std::unique_ptr<::morph::model::detail::IModelHolder>()> factory) override {
181 return registerModelWithContext(typeId, std::move(factory), {});
182 }
183
184 /// @brief Registers a private instance carrying @p contextKey and waits for the reply.
185 ///
186 /// The server constructs the holder itself, so `contextKey` is the only
187 /// channel by which the instance's identity reaches it:
188 /// `RemoteServer::attachLogIfConfigured` consults its `LogProvider` only
189 /// for a non-empty one. Same waiting rule as `registerModel`.
190 /// @param typeId String type-id of the model to register.
191 /// @param factory Ignored — the server constructs via its own registry.
192 /// @param contextKey Stable identity of the new instance; empty if none.
193 /// @return `ModelId` assigned by the server.
194 /// @throws std::runtime_error if the server replies with an error, the
195 /// socket is not connected, or this is the loop's own thread.
196 ::morph::exec::detail::ModelId registerModelWithContext(
197 const std::string& typeId, std::function<std::unique_ptr<::morph::model::detail::IModelHolder>()> factory,
198 std::string_view contextKey) override {
199 return awaitReply<::morph::exec::detail::ModelId>("register", [&](::morph::exec::IExecutor& waiter) {
200 return bindModel(::morph::backend::detail::BindRequest{.typeId = typeId,
201 .factory = std::move(factory),
202 .contextKey = std::string{contextKey},
203 .primary = {},
204 .current = {}},
205 waiter);
206 });
207 }
208
209 /// @brief Files a live server-side instance under @p primary and waits for the reply.
210 /// @param mid Live instance to promote.
211 /// @param typeId Model type id.
212 /// @param primary Canonical string encoding of the key to file it under.
213 void assignPrimary(::morph::exec::detail::ModelId mid, const std::string& typeId,
214 std::string_view primary) override {
215 if (primary.empty() || mid.v == 0U) {
216 return;
217 }
218 static_cast<void>(awaitReply<::morph::exec::detail::ModelId>("assign", [&](::morph::exec::IExecutor& waiter) {
219 return promoteModel(
220 ::morph::backend::detail::PromoteRequest{
221 .mid = mid, .typeId = typeId, .primary = std::string{primary}},
222 waiter);
223 }));
224 }
225
226 // ── The structural registration surface ──────────────────────────────
227 //
228 // Overridden natively rather than reached through
229 // `backend::SynchronousBackendAdapter`: this transport already
230 // demultiplexes replies by `callId` on its I/O loop for `execute`, and a
231 // control call is the same shape. See docs/spec/core/backend.md,
232 // "The structural registration surface, natively".
233
234 /// @brief Acquires a model instance without blocking the calling thread.
235 ///
236 /// Sends the control envelope @p request's shape names (see
237 /// `backend::detail::BindRequest`'s table) carrying a non-zero `callId`
238 /// drawn from the same counter `execute` uses, and settles the returned
239 /// `Completion` on the I/O loop when the matching reply arrives. No thread
240 /// is parked anywhere.
241 ///
242 /// An empty `primary` with a live `current` gives that instance up first,
243 /// and an empty `primary` binds a private instance.
244 ///
245 /// A private bind made while the socket is not connected is queued, not
246 /// failed, and sent in FIFO order the next time it connects, the first
247 /// connect included: a single-threaded client binds before the socket is
248 /// up and cannot block waiting for it. `cancelPending` and the backend's
249 /// destruction reject what is still queued. A keyed bind is not queued: it
250 /// may re-point a live instance, and replayed on a later connection it
251 /// would attach from a `current` that connection never issued.
252 ///
253 /// @param request Owning bind request; moved from.
254 /// @param cbExec Executor the continuation is delivered on. Borrowed: it
255 /// must outlive the returned `Completion`.
256 /// @return A `Completion` resolved with the bound `ModelId`; rejected with
257 /// `backend::DisconnectedError` if a keyed bind finds the socket
258 /// down, if the socket drops before the reply, or if a queued bind
259 /// is cancelled; or with a `std::runtime_error` carrying the
260 /// server's own error message.
262 ::morph::exec::IExecutor& cbExec) override {
263 if (request.primary.empty() && request.current.v != 0U) {
264 // An empty primary with a live instance: the instance being given up
265 // is released before the private bind that replaces it.
266 deregisterModel(request.current);
267 }
268 if (request.primary.empty()) {
269 return sendControlAsync(::morph::wire::makeRegister(request.typeId, request.contextKey), "register",
270 std::nullopt, cbExec, BeforeConnect::Queue);
271 }
272 if (request.current.v != 0U) {
273 return sendControlAsync(
274 ::morph::wire::makeAttach(request.typeId, request.primary, request.current.v, request.contextKey),
275 "attach", std::nullopt, cbExec);
276 }
277 return sendControlAsync(::morph::wire::makeRegisterShared(request.typeId, request.primary, request.contextKey),
278 "register", std::nullopt, cbExec);
279 }
280
281 /// @brief Files an already-live server-side instance under a key, without
282 /// blocking the calling thread.
283 ///
284 /// The promote counterpart of `bindModel`, on the same callId-multiplexed
285 /// path. Resolves with `request.mid` echoed back exactly as
286 /// `IBackend::promoteModel` documents — including for the two guards
287 /// `assignPrimary` applies locally (empty `primary`, zero `mid`), which
288 /// resolve without sending anything.
289 ///
290 /// @param request Owning promote request; moved from.
291 /// @param cbExec Executor the continuation is delivered on. Borrowed: it
292 /// must outlive the returned `Completion`.
293 /// @return A `Completion` resolved with `request.mid`, or rejected as
294 /// `bindModel` documents.
296 ::morph::backend::detail::PromoteRequest request, ::morph::exec::IExecutor& cbExec) override {
297 if (request.primary.empty() || request.mid.v == 0U) {
298 auto state = std::make_shared<::morph::async::detail::CompletionState<::morph::exec::detail::ModelId>>();
300 state->setValue(request.mid);
301 return comp;
302 }
303 return sendControlAsync(::morph::wire::makeAssign(request.typeId, request.primary, request.mid.v), "assign",
304 request.mid, cbExec);
305 }
306
307 /// @brief Asks the server for the live shared primary keys of @p typeId
308 /// and waits for the reply, never on the loop's own thread.
309 /// @param typeId String type-id to enumerate.
310 /// @return Canonical key strings of the live shared instances.
311 /// @throws std::runtime_error if the server replies with an error, the
312 /// socket is down, or this is the loop's own thread.
313 std::vector<std::string> listInstances(const std::string& typeId) override {
314 auto body = awaitReply<std::string>("instances", [&](::morph::exec::IExecutor& waiter) {
315 auto state = std::make_shared<::morph::async::detail::CompletionState<std::string>>();
316 ::morph::async::Completion<std::string> comp{state, &waiter};
317 _loop->post([core = _core, env = ::morph::wire::makeInstances(typeId),
318 pending = PendingControl{
319 .state = nullptr, .what = "instances", .echo = std::nullopt, .body = state}]() mutable {
320 core->fileControl(std::move(env), std::move(pending));
321 });
322 return comp;
323 });
324 std::vector<std::string> keys;
325 if (auto errCode = glz::read_json(keys, body)) {
326 throw std::runtime_error("instances decode failed: " + glz::format_error(errCode, body));
327 }
328 return keys;
329 }
330
331 /// @brief Sends one request envelope of a kind the other verbs do not build (`IEnvelopeTransport::request`),
332 /// on the callId-multiplexed control path, and settles with its reply.
333 ///
334 /// Posted to the loop, which assigns the `callId` and sends the envelope with the session it carries. Made while
335 /// the socket is not connected, it is held and sent at the next connect, the first included, as a private bind
336 /// is, so a client can ask before the socket is up. `cancelPending`, a drop and the backend's destruction reject
337 /// what is pending or held.
338 /// @param envelope The request; its `callId` is replaced.
339 /// @param cbExec Executor the answer is delivered on. Borrowed: it must outlive the returned `Completion`.
340 /// @return A `Completion` resolved with the reply envelope, `ok` or `err`; rejected with
341 /// `backend::DisconnectedError` when the connection drops before the reply or the request is cancelled.
343 ::morph::exec::IExecutor& cbExec) override {
344 auto state = std::make_shared<::morph::async::detail::CompletionState<::morph::wire::Envelope>>();
346 std::string what = envelope.kind;
347 _loop->post([core = _core, env = std::move(envelope),
348 pending = PendingControl{.state = nullptr,
349 .what = std::move(what),
350 .echo = std::nullopt,
351 .body = nullptr,
352 .beforeConnect = BeforeConnect::Queue,
353 .reply = std::move(state)}]() mutable {
354 core->fileControl(std::move(env), std::move(pending));
355 });
356 return comp;
357 }
358
359 /// @brief Sends a `"hello"` envelope and classifies the server's reply.
360 ///
361 /// Opt-in, as on the other backends: a backend that never calls it sends
362 /// no `"hello"`. Calling it also learns whether the server honours
363 /// `"cancel"` (`wire::kCapabilityCancel`), which is the only way this
364 /// backend ever sends one; and it re-sends `"hello"` on every reconnect,
365 /// since a new connection may reach a different server.
366 /// @param replyExec Executor the answer is delivered on. Borrowed: it must
367 /// outlive the returned `Completion`.
368 /// @return A `Completion` resolved with `Negotiated` or `LegacyPeer`;
369 /// rejected with a `std::runtime_error` if the server refuses the
370 /// version, and with `backend::DisconnectedError` if the socket is
371 /// down or drops before the reply.
373 ::morph::exec::IExecutor& replyExec MORPH_LIFETIMEBOUND) {
374 using Result = ::morph::wire::ProtocolNegotiationResult;
375 auto const state = std::make_shared<::morph::async::detail::CompletionState<Result>>();
376 ::morph::async::Completion<Result> comp{state, &replyExec};
377 _loop->post([core = _core, state] {
378 core->note("SocketBackend::negotiateProtocolVersion");
379 core->negotiate = true;
380 core->fileHello(state);
381 });
382 return comp;
383 }
384
385 /// @brief Sends a `deregister` message fire-and-forget (does not wait for a reply).
386 ///
387 /// Posted to the loop, which gives it a real, non-zero `callId` drawn from
388 /// the same counter `execute()` uses and stamps the session.
389 ///
390 /// The reply router drops any `callId` that is not pending, and a
391 /// `deregister` files none, so its reply is dropped.
392 ///
393 /// @param mid Id of the model to remove on the server.
394 void deregisterModel(::morph::exec::detail::ModelId mid) override {
395 if (!_core->connected.load()) {
396 return;
397 }
398 _loop->post([core = _core, env = ::morph::wire::makeDeregister(mid.v)]() mutable {
399 core->sendDeregister(std::move(env));
400 });
401 }
402
403 /// @brief Sends an `execute` message and returns a `Completion` resolved on reply.
404 ///
405 /// The action is serialised here, on the calling thread; the loop then
406 /// assigns the call id, files the pending record and writes the frame.
407 /// @param mid Target model id on the server.
408 /// @param call Bundled action; `serializeAction` and `deserializeResult` are used.
409 /// @param cbExec Executor for delivering the completion callbacks.
410 /// @return Completion resolved asynchronously when the server's reply arrives,
411 /// or with `DisconnectedError` if the backend is not connected.
413 ::morph::backend::detail::ActionCall call,
414 ::morph::exec::IExecutor* cbExec) override {
415 auto compState = std::make_shared<::morph::async::detail::CompletionState<std::shared_ptr<void>>>();
417 if (!_core->connected.load()) {
418 // Answered here rather than on the loop, which would answer the
419 // same: the loop re-checks, so a connection lost after this read
420 // is caught there.
421 compState->setException(std::make_exception_ptr(::morph::backend::DisconnectedError{}));
422 return comp;
423 }
424
426 env.kind = "execute";
427 env.modelId = mid.v;
428 env.modelType = call.modelTypeId;
429 env.actionType = call.actionTypeId;
430 env.body = call.serializeBody();
431 env.session = std::move(call.session);
432
433 PendingExecute pending{.state = compState, .deserialize = call.deserializeResult};
434 if (call.stopSource) {
435 // Kept for the cancel a stop sends: the server honours it only
436 // under the principal the execute was admitted with.
437 pending.stop = call.stopSource;
438 pending.session = env.session;
439 }
440 _loop->post([core = _core, env, pending = std::move(pending)] mutable {
441 core->fileExecute(std::move(env), std::move(pending));
442 });
443 return comp;
444 }
445
446 /// @brief No-op — this backend holds no local model objects.
447 void notifyBackendChanged() override {}
448
449 /// @brief Resolves every pending call's `Completion` with @p exc.
450 ///
451 /// Posted to the loop, so it covers every call issued before it — the loop
452 /// runs posts in order. Covers both in-flight tables: the `execute` calls
453 /// and the `bindModel`/`promoteModel` control calls. A bind left out of
454 /// this sweep would hang forever on a disconnect, since its reply can now
455 /// only arrive on a connection that is gone. When the server advertised
456 /// `"cancel"`, each abandoned `execute` is also cancelled on the server,
457 /// from the loop, behind whatever the loop had queued before.
458 /// @param exc Exception delivered to every pending completion's error sink.
459 void cancelPending(const std::exception_ptr& exc) override {
460 _loop->post([core = _core, exc] {
461 ::morph::exec::detail::noteOwner("SocketBackend::cancelPending", core->loop.loop(),
462 core->loop.runningHere());
463 core->cancelAll(exc, CancelOnServer::Yes);
464 });
465 }
466
467 /// @brief Installs the handler posted to @p exec after each *subsequent*
468 /// successful (re)connect.
469 ///
470 /// Stored on the loop. After a reconnect the loop posts the handler to
471 /// @p exec and goes on: whatever the handler does — a `Bridge`
472 /// re-registering its bindings through `bindModel` — never runs on the loop
473 /// whose replies it needs.
474 /// @param handler Callable posted to @p exec. Pass `nullptr` to clear.
475 /// @param exec Executor the handler runs on. Borrowed: it must outlive
476 /// the installation.
477 void setReconnectHandler(std::function<void()> handler, ::morph::exec::IExecutor* exec) override {
478 _loop->post([core = _core, handler = std::move(handler), exec]() mutable {
479 core->note("SocketBackend::setReconnectHandler");
480 core->reconnectHandler = std::move(handler);
481 core->reconnectExec = exec;
482 });
483 }
484
485 /// @brief Installs the handler run after every successful connect, the
486 /// first included.
487 ///
488 /// Stored on the loop, and run there: after the requests held while the
489 /// socket was down have been sent, and before the reconnect handler is
490 /// posted. A handler that throws is logged; the connection carries on.
491 /// @param handler Callable run on the backend's I/O loop. Pass `nullptr`
492 /// to clear.
493 void setConnectHandler(const std::function<void()>& handler) override {
494 _loop->post([core = _core, handler] {
495 core->note("SocketBackend::setConnectHandler");
496 core->connectHandler = handler;
497 });
498 }
499
500 /// @brief Installs the handler each server-initiated event is posted to.
501 /// See `IBackend::setEventHandler`.
502 ///
503 /// Stored on the loop. An `event` envelope is recognised by its kind
504 /// before any reply matching, so whatever its `callId`, it never settles a
505 /// call. Events are posted to @p exec in the order they arrived.
506 /// @param handler Callable posted to @p exec once per event. Pass
507 /// `nullptr` to clear.
508 /// @param exec Executor the handler is posted to. Borrowed: it must
509 /// outlive the installation. May be null only when
510 /// @p handler is.
511 void setEventHandler(std::function<void(::morph::wire::EventBody)> handler,
512 ::morph::exec::IExecutor* exec) override {
513 _loop->post([core = _core, handler = std::move(handler), exec]() mutable {
514 core->note("SocketBackend::setEventHandler");
515 core->eventHandler =
516 handler ? std::make_shared<std::function<void(::morph::wire::EventBody)>>(std::move(handler))
517 : nullptr;
518 core->eventExec = exec;
519 });
520 }
521
522 /// @brief Installs the session stamped onto every control envelope this
523 /// backend subsequently builds (`register`, `registerShared`,
524 /// `attach`, `assign`, `deregister`, `instances`). See
525 /// `IBackend::setSession`.
526 ///
527 /// Stored on the loop, which stamps every control envelope as it files
528 /// it: a verb called after this, from the same thread, carries it.
529 /// @param session Session to stamp; typically pushed by `Bridge::setDefaultSession()`.
530 void setSession(::morph::session::Context session) override {
531 _loop->post([core = _core, session = std::move(session)]() mutable {
532 core->note("SocketBackend::setSession");
533 core->session = std::move(session);
534 });
535 }
536
537private:
538 /// @brief Waits on the calling thread for the reply to the request @p ask
539 /// sends, settled on the loop.
540 ///
541 /// The answer's owner is an executor local to this call, which the
542 /// calling thread pumps while it waits: the attach and the delivery are
543 /// both tasks of it, so the waiting thread is the one place they run.
544 /// @tparam T The answer's type.
545 /// @tparam Ask Callable taking the waiter (`morph::exec::IExecutor&`) and
546 /// returning a `Completion<T>` delivered on it.
547 /// @param what Verb name for the error message.
548 /// @param ask Sends the request, naming the waiter as the answer's owner.
549 /// @return The settled value.
550 /// @throws std::runtime_error on the loop's own thread, when the loop has
551 /// stopped, on a disconnect, or with the server's refusal.
552 template <typename T, typename Ask>
553 T awaitReply(std::string_view what, Ask ask) {
554 if (_loop->runningHere()) {
555 throw std::runtime_error(std::string{what} +
556 " failed: a synchronous call cannot wait on the I/O loop's own thread");
557 }
559 auto completion = ask(static_cast<::morph::exec::IExecutor&>(waiter));
560 std::optional<T> value;
561 std::exception_ptr error;
562 bool done = false;
563 waiter.post([&completion, &value, &error, &done] {
564 completion
565 .then([&value, &done](const T& settled) {
566 value.emplace(settled);
567 done = true;
568 })
569 .onError([&error, &done](const std::exception_ptr& failure) {
570 error = failure;
571 done = true;
572 });
573 });
574 // The request was posted to the loop before this round trip, so a
575 // loop that runs it has filed the request too, and will answer it or
576 // reject it when it closes. A stopped loop drops both unrun.
577 bool alive = false;
578 _loop->runAndWait([&alive] { alive = true; });
579 if (!alive) {
580 throw std::runtime_error(std::string{what} + " failed: the I/O loop has stopped");
581 }
582 // runFor pumps until its deadline even once `done` is set, so it is
583 // only the wait between replies, kept short.
584 while (!done) {
585 if (!waiter.runOnce()) {
586 waiter.runFor(std::chrono::milliseconds{1});
587 }
588 }
589 if (error) {
590 try {
591 std::rethrow_exception(error);
592 } catch (const ::morph::backend::DisconnectedError&) {
593 throw std::runtime_error(std::string{what} + " failed: disconnected");
594 }
595 }
596 return std::move(*value);
597 }
598
599 struct Core;
600
601 /// Whether settling the pending calls also tells the server to stop them.
602 enum class CancelOnServer : std::uint8_t { No, Yes };
603
604 /// The stop callback a stoppable execute registers: hands the cancel to
605 /// the loop, which owns the socket and the pending table. Runs on whichever
606 /// thread requested the stop, so it only posts.
607 struct CancelOnStop {
608 std::weak_ptr<Core> core;
609 ::morph::exec::IoLoop* loop{nullptr};
610 std::uint64_t callId{0};
611 void operator()() const noexcept {
612 try {
613 loop->post([weak = core, target = callId] {
614 if (auto const live = weak.lock()) {
615 live->requestCancel(target);
616 }
617 });
618 } catch (...) { // NOLINT(bugprone-empty-catch): a stop callback must not throw
619 // The loop could not take the task: the server is not told,
620 // and the call runs to its end there, as without a cancel.
621 }
622 }
623 };
624
625 struct PendingExecute {
626 std::shared_ptr<::morph::async::detail::CompletionState<std::shared_ptr<void>>> state;
627 std::shared_ptr<void> (*deserialize)(std::string_view json){nullptr};
628 /// The call's stop source, or null for a call nothing can stop.
629 std::shared_ptr<::core::async::StopSource> stop;
630 /// The execute's session, for the cancel; empty without `stop`.
631 ::morph::session::Context session;
632 /// Registered on `stop` once the call has its id; deregistered when the
633 /// entry goes, so a stop after the reply sends nothing. Shared, not
634 /// unique, because the entry travels in a copyable loop task.
635 std::shared_ptr<::core::async::StopCallback<CancelOnStop>> watch;
636 };
637
638 /// @brief What a control call filed while the socket is not connected does.
639 enum class BeforeConnect : std::uint8_t {
640 Reject, ///< Rejected with `DisconnectedError`.
641 Queue, ///< Held, and sent once the socket connects.
642 };
643
644 /// @brief One in-flight control call issued through `bindModel`/`promoteModel`.
645 ///
646 /// Kept in its own table rather than beside the executes: an execute reply
647 /// settles a `Completion<shared_ptr<void>>` through a deserializer, a
648 /// control reply settles a `Completion<ModelId>` and has none. The two
649 /// tables share one `callId` counter (the execute table's), so an id is
650 /// never ambiguous between them.
651 struct PendingControl {
652 /// @brief State of the `Completion<ModelId>` this call settles, or
653 /// null for a call that settles `body` instead.
654 std::shared_ptr<::morph::async::detail::CompletionState<::morph::exec::detail::ModelId>> state;
655 /// @brief Verb name prefixing the server's error message:
656 /// `"<what> failed: ..."`.
657 std::string what;
658 /// @brief Id to resolve with, for `promoteModel`, which echoes its
659 /// request's `mid`. `nullopt` means "resolve with the reply's".
660 std::optional<::morph::exec::detail::ModelId> echo;
661 /// @brief State of a call answered with the reply's body
662 /// (`instances`), or null.
663 std::shared_ptr<::morph::async::detail::CompletionState<std::string>> body;
664 /// @brief Whether this is a `"hello"`, whose reply says whether the
665 /// server honours `"cancel"`.
666 bool hello = false;
667 /// @brief State of a `negotiateProtocolVersion` call, or null — null
668 /// for the `"hello"` a reconnect re-sends.
669 std::shared_ptr<::morph::async::detail::CompletionState<::morph::wire::ProtocolNegotiationResult>> negotiation;
670 /// @brief What a call filed while the socket is not connected does.
671 BeforeConnect beforeConnect = BeforeConnect::Reject;
672 /// @brief State of a `request`, settled with the whole reply envelope, or null. A request keeps the session
673 /// it carries; every other control call is sent under the backend's.
674 std::shared_ptr<::morph::async::detail::CompletionState<::morph::wire::Envelope>> reply;
675
676 /// @brief Rejects whichever state this call settles.
677 /// @param failure The failure.
678 void reject(const std::exception_ptr& failure) const {
679 if (state) {
680 state->setException(failure);
681 }
682 if (body) {
683 body->setException(failure);
684 }
685 if (negotiation) {
686 negotiation->setException(failure);
687 }
688 if (reply) {
689 reply->setException(failure);
690 }
691 }
692 };
693
694 /// @brief Posts one control envelope to the loop's callId-multiplexed
695 /// path and returns the `Completion` its reply will settle.
696 /// @param env Envelope to send; its `callId` and `session` are filled in
697 /// on the loop.
698 /// @param what Verb name for the error message.
699 /// @param echo Id to resolve with, or `nullopt` to use the reply's `modelId`.
700 /// @param cbExec Executor the continuation is delivered on.
701 /// @param beforeConnect What the call does if the socket is not connected when it is filed.
702 /// @return The `Completion` the reply — or a disconnect — settles.
703 ::morph::async::Completion<::morph::exec::detail::ModelId> sendControlAsync(
704 ::morph::wire::Envelope env, std::string_view what, std::optional<::morph::exec::detail::ModelId> echo,
705 ::morph::exec::IExecutor& cbExec, BeforeConnect beforeConnect = BeforeConnect::Reject) {
706 auto state = std::make_shared<::morph::async::detail::CompletionState<::morph::exec::detail::ModelId>>();
707 ::morph::async::Completion<::morph::exec::detail::ModelId> comp{state, &cbExec};
708 _loop->post([core = _core, env,
709 pending = PendingControl{.state = state,
710 .what = std::string{what},
711 .echo = echo,
712 .body = nullptr,
713 .beforeConnect = beforeConnect}]() mutable {
714 core->fileControl(std::move(env), std::move(pending));
715 });
716 return comp;
717 }
718
719 /// @brief Settles one control call from its matched reply envelope.
720 /// @param pending Entry taken out of the control table.
721 /// @param reply Decoded reply carrying the same `callId`.
722 static void settleControl(const PendingControl& pending, const ::morph::wire::Envelope& reply) {
723 if (pending.hello) {
724 // A legacy peer's refusal is an answer, not a failure: classified,
725 // never prefixed with "hello failed".
726 if (pending.negotiation) {
727 try {
728 pending.negotiation->setValue(::morph::wire::interpretHelloReply(reply));
729 } catch (...) {
730 pending.negotiation->setException(std::current_exception());
731 }
732 }
733 return;
734 }
735 if (pending.reply) {
736 // An `err` is an answer the caller reads, not a transport failure.
737 pending.reply->setValue(reply);
738 return;
739 }
740 if (reply.kind != "ok") {
741 pending.reject(std::make_exception_ptr(std::runtime_error(pending.what + " failed: " + reply.message)));
742 return;
743 }
744 if (pending.state) {
745 pending.state->setValue(pending.echo.value_or(::morph::exec::detail::ModelId{reply.modelId}));
746 }
747 if (pending.body) {
748 pending.body->setValue(reply.body);
749 }
750 }
751
752 void start() {
753 _loop->post([core = _core] { core->startAttempt(); });
754 }
755
756 /// @brief Everything the I/O loop owns, touched only in its tasks.
757 ///
758 /// Held by `shared_ptr` from the backend and from every flow and task on
759 /// the loop, so a flow that resumes after the backend is gone finds it
760 /// closed and ends. It never reaches the backend object itself.
761 struct Core : std::enable_shared_from_this<Core> {
762 Core(::morph::exec::IoLoop& ioLoop, ::morph::net::detail::ParsedWsUrl target, Config config)
763 : loop{ioLoop}, url{std::move(target)}, cfg{config}, reconnectDelay{config.initialReconnectDelay} {}
764
765 /// Owner check for every loop-side body.
766 void note(char const* site) const noexcept {
767 ::morph::exec::detail::noteOwner(site, loop.loop(), loop.runningHere());
768 }
769
770 void startAttempt() {
771 if (!closed) {
772 loop.loop().spawn(attemptFlow(shared_from_this()));
773 }
774 }
775
776 /// Queues @p frame on the live connection.
777 /// @return `false` when there is none, or it is closing.
778 bool send(std::string frame) {
779 note("SocketBackend::send");
780 if (!conn) {
781 return false;
782 }
783 return ::morph::net::detail::enqueueFrame(conn, std::move(frame));
784 }
785
786 /// Assigns @p env a call id, files @p pending under it and writes it.
787 void fileExecute(::morph::wire::Envelope env, PendingExecute pending) {
788 MORPH_ZONE("SocketBackend::fileExecute");
789 MORPH_ZONE_TEXT(env.session.requestId);
790 note("SocketBackend::execute");
791 if (!connected.load() || !conn) {
792 pending.state->setException(std::make_exception_ptr(::morph::backend::DisconnectedError{}));
793 return;
794 }
795 std::uint64_t const callId = executes.nextCallId();
796 env.callId = callId;
797 std::string frame;
798 try {
799 frame = ::morph::net::detail::encodeWsFrame(::morph::net::detail::WsOpcode::kText,
800 ::morph::wire::encode(env), /*mask=*/true);
801 } catch (...) {
802 pending.state->setException(std::current_exception());
803 return;
804 }
805 auto const stop = pending.stop;
806 executes.insert(callId, std::move(pending));
807 // A refused frame is left filed: the connection is closing, and
808 // the disconnect that follows sweeps it with `DisconnectedError`.
809 static_cast<void>(send(std::move(frame)));
810 if (stop) {
811 // After the frame is queued, so a cancel never precedes its
812 // execute on the wire. A stop already requested runs the
813 // callback here, which posts the cancel behind this task.
814 auto watch = std::make_shared<::core::async::StopCallback<CancelOnStop>>(
815 stop->get_token(), CancelOnStop{.core = weak_from_this(), .loop = &loop, .callId = callId});
816 if (auto entry = executes.take(callId)) {
817 entry->watch = std::move(watch);
818 executes.insert(callId, std::move(*entry));
819 }
820 }
821 }
822
823 /// The stop of the call filed under @p callId was requested: tells the
824 /// server, if the call is still waiting for its reply and the server
825 /// advertised `"cancel"`. On the loop.
826 void requestCancel(std::uint64_t callId) {
827 note("SocketBackend::requestCancel");
828 if (!cancelAdvertised) {
829 return;
830 }
831 // Taken and put back: the entry stays pending, its reply still
832 // settles it, and this is the table's one way to read an entry.
833 auto entry = executes.take(callId);
834 if (!entry) {
835 return;
836 }
837 sendCancel(callId, entry->stop ? entry->session : session);
838 executes.insert(callId, std::move(*entry));
839 }
840
841 /// Writes a fire-and-forget `cancel` for @p target. Its reply is
842 /// filed nowhere, so the router drops it. On the loop.
843 void sendCancel(std::uint64_t target, const ::morph::session::Context& callerSession) {
844 auto env = ::morph::wire::makeCancel(target);
845 env.callId = executes.nextCallId();
846 env.session = callerSession;
847 try {
848 static_cast<void>(send(::morph::net::detail::encodeWsFrame(
849 ::morph::net::detail::WsOpcode::kText, ::morph::wire::encode(env), /*mask=*/true)));
850 } catch (const std::exception&) { // NOLINT(bugprone-empty-catch)
851 // Best effort, like a deregister: without the cancel the
852 // server's handler runs to its end, as it did before cancels.
853 }
854 }
855
856 /// Files a `"hello"`, settling @p negotiation (null for a reconnect's
857 /// re-send) from its reply. On the loop.
858 void fileHello(
859 std::shared_ptr<::morph::async::detail::CompletionState<::morph::wire::ProtocolNegotiationResult>>
860 negotiation) {
861 fileControl(::morph::wire::makeHello(), PendingControl{.state = nullptr,
862 .what = "hello",
863 .echo = std::nullopt,
864 .body = nullptr,
865 .hello = true,
866 .negotiation = std::move(negotiation)});
867 }
868
869 /// The control-call counterpart of `fileExecute`.
870 void fileControl(::morph::wire::Envelope env, PendingControl pending) {
871 MORPH_ZONE("SocketBackend::fileControl");
872 note("SocketBackend::bindModel");
873 if (!connected.load() || !conn) {
874 if (pending.beforeConnect == BeforeConnect::Queue && !closed && !attemptsOver) {
875 // The call id is drawn when it is sent, so a queued call holds none.
876 queuedControls.emplace_back(std::move(env), std::move(pending));
877 return;
878 }
879 pending.reject(std::make_exception_ptr(::morph::backend::DisconnectedError{}));
880 return;
881 }
882 std::uint64_t const callId = executes.nextCallId();
883 env.callId = callId;
884 if (!pending.reply) {
885 env.session = session;
886 }
887 std::string frame;
888 try {
889 frame = ::morph::net::detail::encodeWsFrame(::morph::net::detail::WsOpcode::kText,
890 ::morph::wire::encode(env), /*mask=*/true);
891 } catch (const std::exception& exc) {
892 pending.reject(std::make_exception_ptr(std::runtime_error(pending.what + " failed: " + exc.what())));
893 return;
894 }
895 controls.insert(callId, std::move(pending));
896 static_cast<void>(send(std::move(frame)));
897 }
898
899 void sendDeregister(::morph::wire::Envelope env) {
900 note("SocketBackend::deregisterModel");
901 if (!connected.load()) {
902 return;
903 }
904 env.callId = executes.nextCallId();
905 env.session = session;
906 try {
907 static_cast<void>(send(::morph::net::detail::encodeWsFrame(
908 ::morph::net::detail::WsOpcode::kText, ::morph::wire::encode(env), /*mask=*/true)));
909 } catch (const std::exception&) { // NOLINT(bugprone-empty-catch)
910 // Fire-and-forget: same documented trade-off as
911 // QtWebSocketBackend — a failed send just leaks the model on
912 // the server (see docs/spec/core/backend.md's Limitations).
913 }
914 }
915
916 void addConnectWaiter(const std::shared_ptr<std::promise<void>>& waiter) {
917 note("SocketBackend::waitForConnected");
918 if (connected.load()) {
919 waiter->set_value();
920 return;
921 }
922 if (!closed) {
923 connectWaiters.push_back(waiter);
924 }
925 }
926
927 /// Settles every pending call, of both kinds, with @p exc. The tables
928 /// are emptied first, so a settle that re-enters the backend files
929 /// into empty tables. With @p onServer, each abandoned execute is
930 /// also cancelled on the server, when it advertised `"cancel"`.
931 void cancelAll(const std::exception_ptr& exc, CancelOnServer onServer = CancelOnServer::No) {
932 auto drained = executes.drain();
933 auto drainedControl = controls.drain();
934 auto drainedQueue = std::exchange(queuedControls, {});
935 if (onServer == CancelOnServer::Yes && cancelAdvertised) {
936 for (auto const& [callId, pending] : drained) {
937 sendCancel(callId, pending.stop ? pending.session : session);
938 }
939 }
940 for (auto& entry : drained) {
941 if (entry.second.state) {
942 entry.second.state->setException(exc);
943 }
944 }
945 for (auto& entry : drainedControl) {
946 entry.second.reject(exc);
947 }
948 for (auto& entry : drainedQueue) {
949 entry.second.reject(exc);
950 }
951 }
952
953 void onConnected() {
954 bool const isReconnect = everConnected;
955 everConnected = true;
956 connected.store(true);
957 reconnectDelay = cfg.initialReconnectDelay;
958 for (auto const& waiter : std::exchange(connectWaiters, {})) {
959 waiter->set_value();
960 }
961 if (negotiate) {
962 // A new connection may reach another server: learn again
963 // whether it honours `"cancel"`.
964 fileHello(nullptr);
965 }
966 // Before the reconnect handler is posted, so binds made while the
967 // socket was down reach the server ahead of the re-registrations.
968 for (auto& [env, pending] : std::exchange(queuedControls, {})) {
969 fileControl(std::move(env), std::move(pending));
970 }
971 if (connectHandler) {
972 try {
973 connectHandler();
974 } catch (const std::exception& exc) {
975 ::morph::log::logWarn("[SocketBackend] connect handler threw: {}", exc.what());
976 } catch (...) {
977 ::morph::log::logWarn("[SocketBackend] connect handler threw a non-standard exception");
978 }
979 }
980 if (isReconnect && reconnectHandler && reconnectExec != nullptr) {
981 // Posted, never run here: the handler re-registers through
982 // `bindModel`, whose replies this loop delivers.
983 reconnectExec->post(reconnectHandler);
984 }
985 }
986
987 void onDisconnected() {
988 connected.store(false);
989 cancelAdvertised = false;
990 if (conn) {
991 ::morph::net::detail::closeAfterFlush(conn);
992 conn.reset();
993 }
994 cancelAll(std::make_exception_ptr(::morph::backend::DisconnectedError{}));
995 }
996
997 /// Arms the backoff timer for the next attempt, and grows the delay.
998 void scheduleReconnect() {
999 note("SocketBackend::reconnect");
1000 backoffTimer = loop.loop().addTimer(loop.loop().clock().now() + reconnectDelay, &Core::onBackoff, this);
1001 auto const nextDelayMs = static_cast<std::chrono::milliseconds::rep>(
1002 static_cast<double>(reconnectDelay.count()) * cfg.backoffMultiplier);
1003 reconnectDelay = std::min(std::chrono::milliseconds{nextDelayMs}, cfg.maxReconnectDelay);
1004 }
1005
1006 /// The backoff timer's callback. `close()` retires the timer, so the
1007 /// core it points at is alive whenever it runs.
1008 static void onBackoff(void* corePtr) {
1009 auto& self = *static_cast<Core*>(corePtr);
1010 self.backoffTimer = {};
1011 self.startAttempt();
1012 }
1013
1014 /// Ends everything: the connection, the backoff, the waiters, every
1015 /// pending call. Idempotent. After it, no flow touches the backend.
1016 void close() {
1017 note("SocketBackend::close");
1018 if (closed) {
1019 return;
1020 }
1021 closed = true;
1022 connected.store(false);
1023 static_cast<void>(loop.loop().cancelTimer(backoffTimer));
1024 if (conn) {
1025 // After the frames already queued are written, not instead of
1026 // them: the owner's `cancelPending` runs just before this
1027 // close, and the cancels and deregisters it queued are what
1028 // tell the server to stop work nobody will read. The read flow
1029 // sees `closed` and stops dispatching meanwhile; the write is
1030 // still bounded by `sendTimeout`.
1031 ::morph::net::detail::closeAfterFlush(conn);
1032 conn.reset();
1033 }
1034 connectWaiters.clear();
1035 reconnectHandler = nullptr;
1036 connectHandler = nullptr;
1037 eventHandler = nullptr;
1038 cancelAll(std::make_exception_ptr(::morph::backend::DisconnectedError{}));
1039 }
1040
1041 void dispatchIncomingEnvelope(const std::string& payload) {
1042 MORPH_ZONE("SocketBackend::dispatchIncomingEnvelope");
1043 ::morph::wire::Envelope env;
1044 try {
1045 env = ::morph::wire::decode(payload);
1046 } catch (const std::exception&) {
1047 // The callId is unreadable, so this reply cannot be matched to
1048 // the call it belongs to. Every message
1049 // here is required to be one envelope, so an undecodable one
1050 // means the peer's framing is no longer trustworthy: fail the
1051 // pending calls rather than wait on a stream that may never
1052 // produce a matching reply. Mirrors
1053 // QtWebSocketBackend::onTextMessage.
1054 cancelAll(std::make_exception_ptr(
1055 std::runtime_error("protocol error: server sent a message that is not a valid envelope")));
1056 return;
1057 }
1058 note("SocketBackend::reply");
1059 // By kind first: an event's `callId` is not a reply's, so it must
1060 // never reach the tables below.
1061 if (::morph::backend::detail::routeEvent(env, [this](::morph::wire::EventBody event) {
1062 if (eventHandler && eventExec != nullptr) {
1063 eventExec->post([handler = eventHandler, event = std::move(event)]() mutable {
1064 (*handler)(std::move(event));
1065 });
1066 }
1067 })) {
1068 return;
1069 }
1070 auto pending = executes.take(env.callId);
1071 if (!pending) {
1072 // Not an execute: it may be a control reply for a `bindModel`/
1073 // `promoteModel` in flight, which shares this id space.
1074 if (auto control = controls.take(env.callId)) {
1075 if (control->hello) {
1076 cancelAdvertised = ::morph::wire::helloAdvertises(env, ::morph::wire::kCapabilityCancel);
1077 }
1078 settleControl(*control, env);
1079 }
1080 return; // otherwise a late/cancelled reply, dropped silently
1081 }
1082 // Triage shared with SimulatedRemoteBackend and QtWebSocketBackend
1083 // (core/detail/reply_router.hpp), so the three cannot drift. The
1084 // Timeout arm is the server's own `LimitPolicy::executeTimeout`
1085 // reply, not a generic application error -- surfaced as the same
1086 // TimeoutError type the other two backends give callers for this
1087 // case.
1088 switch (::morph::backend::detail::classifyExecuteReply(env)) {
1089 case ::morph::backend::detail::ExecuteReplyKind::Value:
1090 try {
1091 if (pending->deserialize == nullptr) {
1092 throw std::runtime_error{"ActionCall::deserializeResult is null"};
1093 }
1094 pending->state->setValue(pending->deserialize(env.body));
1095 } catch (...) {
1096 pending->state->setException(std::current_exception());
1097 }
1098 break;
1099 case ::morph::backend::detail::ExecuteReplyKind::Timeout:
1100 pending->state->setException(std::make_exception_ptr(::morph::backend::TimeoutError{}));
1101 break;
1102 case ::morph::backend::detail::ExecuteReplyKind::Error:
1103 default:
1104 // `default:` only because the project builds with
1105 // -Wswitch-default; ExecuteReplyKind is a closed enum and
1106 // all three enumerators are handled explicitly.
1107 pending->state->setException(std::make_exception_ptr(std::runtime_error(env.message)));
1108 break;
1109 }
1110 }
1111
1112 /// Handles every complete frame @p reader holds.
1113 /// @return `false` when the connection should stop reading (peer sent
1114 /// Close, a protocol error, or this backend closed meanwhile).
1115 bool drainFrames(const std::shared_ptr<::morph::net::detail::LoopConnection>& connection,
1116 ::morph::net::detail::WsFrameReader& reader) {
1117 MORPH_ZONE("SocketBackend::drainFrames");
1118 using ::morph::net::detail::WsOpcode;
1119 for (;;) {
1120 std::optional<::morph::net::detail::WsFrame> frame;
1121 try {
1122 frame = reader.tryExtractFrame();
1123 } catch (const std::exception&) {
1124 return false;
1125 }
1126 if (!frame) {
1127 return true;
1128 }
1129 if (frame->opcode == WsOpcode::kClose) {
1130 static_cast<void>(::morph::net::detail::enqueueFrame(
1131 connection, ::morph::net::detail::encodeWsFrame(WsOpcode::kClose, frame->payload, true)));
1132 return false;
1133 }
1134 if (frame->opcode == WsOpcode::kPing) {
1135 static_cast<void>(::morph::net::detail::enqueueFrame(
1136 connection, ::morph::net::detail::encodeWsFrame(WsOpcode::kPong, frame->payload, true)));
1137 continue;
1138 }
1139 if (frame->opcode == WsOpcode::kText) {
1140 dispatchIncomingEnvelope(frame->payload);
1141 // A settle may have run user code that destroyed the
1142 // backend, closing this core.
1143 if (closed) {
1144 return false;
1145 }
1146 }
1147 }
1148 }
1149
1150 /// One connection attempt, start to end: dial, handshake, read until
1151 /// the connection ends, then decide whether to try again.
1152 static ::core::async::Task<void> attemptFlow(std::shared_ptr<Core> self) {
1153 bool reachedServer = false;
1154 {
1155 ::core::net::DialOptions options;
1156 options.connectTimeout = self->cfg.connectTimeout;
1157 auto dialed = co_await ::core::net::connect(&self->loop.loop(), self->url.host, self->url.port,
1158 &::core::net::defaultAsyncResolver(), options);
1159 if (self->closed) {
1160 co_return;
1161 }
1162 if (dialed) {
1163 auto connection = std::make_shared<::morph::net::detail::LoopConnection>(
1164 self->loop.loop(), std::move(*dialed), self->cfg.sendTimeout);
1165 self->conn = connection;
1166 std::string const key = ::morph::net::detail::generateClientKey();
1167 static_cast<void>(::morph::net::detail::enqueueFrame(
1168 connection, ::morph::net::detail::buildClientHandshakeRequest(self->url, key)));
1169 auto header =
1170 co_await ::morph::net::detail::readHeaderBlockAsync(connection, self->cfg.handshakeTimeout);
1171 if (self->closed) {
1172 co_return;
1173 }
1174 bool verified = false;
1175 if (header) {
1176 try {
1177 ::morph::net::detail::verifyServerHandshakeResponse(header->header, key);
1178 verified = true;
1179 } catch (const std::exception&) { // NOLINT(bugprone-empty-catch)
1180 // A failed connect, like any other: see below.
1181 }
1182 }
1183 if (verified) {
1184 reachedServer = true;
1185 self->onConnected();
1186 // Client role: RFC 6455 §5.1 forbids a server from
1187 // masking the frames it sends, so this reader must
1188 // reject a masked one.
1189 ::morph::net::detail::WsFrameReader reader{/*expectMasked=*/false};
1190 reader.feed(header->leftover);
1191 std::array<std::byte, 4096> buf{};
1192 while (self->drainFrames(connection, reader) && !connection->closed) {
1193 auto const got = co_await connection->socket->read(buf);
1194 if (self->closed || !got || *got == 0) {
1195 break;
1196 }
1197 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast): the socket yields bytes, the reader takes text; same object representation.
1198 reader.feed(std::string_view{reinterpret_cast<char const*>(buf.data()), *got});
1199 }
1200 }
1201 if (self->closed) {
1202 co_return;
1203 }
1204 }
1205 }
1206 self->onDisconnected();
1207 if (!reachedServer && !self->everConnected) {
1208 // Never reached the server even once — fail fast, no retry
1209 // (mirrors QtWebSocketBackend's "no reconnect for
1210 // never-connected sockets").
1211 self->attemptsOver = true;
1212 co_return;
1213 }
1214 self->attemptsOver = !self->cfg.reconnectEnabled;
1215 if (!self->attemptsOver) {
1216 self->scheduleReconnect();
1217 }
1218 }
1219
1220 ::morph::exec::IoLoop& loop;
1221 ::morph::net::detail::ParsedWsUrl url;
1222 Config cfg;
1223 /// The one field read off the loop: by `execute`'s fast path and
1224 /// `waitForConnected`.
1225 std::atomic<bool> connected{false};
1226 /// Stamped onto every control envelope as it is filed.
1227 ::morph::session::Context session;
1228 /// Posted to `reconnectExec` after every reconnect; cleared by `close()`.
1229 std::function<void()> reconnectHandler;
1230 ::morph::exec::IExecutor* reconnectExec{nullptr};
1231 /// Run on the loop after every connect; cleared by `close()`.
1232 std::function<void()> connectHandler;
1233 /// Posted to `eventExec` for each `event` envelope; cleared by `close()`.
1234 /// Shared, so each posted delivery holds it without copying it.
1235 std::shared_ptr<std::function<void(::morph::wire::EventBody)>> eventHandler;
1236 ::morph::exec::IExecutor* eventExec{nullptr};
1237 bool everConnected{false};
1238 bool closed{false};
1239 /// No further connect will be attempted: a bind made now is rejected,
1240 /// not queued for a connect that cannot come.
1241 bool attemptsOver{false};
1242 /// Set by `negotiateProtocolVersion`: re-send `"hello"` on each connect.
1243 bool negotiate{false};
1244 /// Whether this connection's server advertised `"cancel"`. Cleared on
1245 /// every disconnect, learned again from the next `"hello"` reply.
1246 bool cancelAdvertised{false};
1247 std::chrono::milliseconds reconnectDelay;
1248 ::core::net::TimerId backoffTimer{};
1249 std::shared_ptr<::morph::net::detail::LoopConnection> conn;
1250 ::morph::backend::detail::PendingCallTable<PendingExecute> executes;
1251 // Control calls issued through the structural surface. Draws its ids
1252 // from `executes`' counter rather than owning a second one, so the two
1253 // tables can never claim the same id.
1254 ::morph::backend::detail::PendingCallTable<PendingControl> controls;
1255 // Private binds filed while the socket was not connected, in the order
1256 // they were made; sent on the next connect.
1257 std::vector<std::pair<::morph::wire::Envelope, PendingControl>> queuedControls;
1258 std::vector<std::shared_ptr<std::promise<void>>> connectWaiters;
1259 };
1260
1261 /// Parsed before anything else is built, so a bad URL throws before a
1262 /// loop of the backend's own is started.
1263 ::morph::net::detail::ParsedWsUrl _url;
1264 /// Present only for the owning constructor; destroyed after everything
1265 /// below, once the destructor has closed the core on it.
1266 std::unique_ptr<::morph::exec::IoLoop> _ownedLoop;
1267 ::morph::exec::IoLoop* _loop;
1268 std::shared_ptr<Core> _core;
1269};
1270
1271} // namespace morph::net
AnyHandle env(std::string const &name)
A client environment value, {"env": name}: widthClass, locale, platform, online, backend.
Definition author.hpp:1209
constexpr Self self
The dialog or drawer a command list runs in.
Definition author.hpp:1295
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.
Owns a core::net::PlatformLoop and, for IoLoopDriver::OwnThread natively, the one thread that runs it...
Definition io_loop.hpp:74
void post(F &&task)
Queues task to run on the loop thread, in a later turn.
Definition io_loop.hpp:207
bool runningHere() const noexcept
Whether the calling thread may touch the loop's state directly.
Definition io_loop.hpp:190
Single-thread executor intended for use on the calling (main) thread.
Definition executor.hpp:253
bool runOnce()
Dequeues and runs at most one pending task, without blocking.
Definition executor.hpp:307
void post(std::function< void()> task) override
Enqueues task to be run on the next runFor() call.
Definition executor.hpp:259
void runFor(std::chrono::milliseconds timeout)
Pumps the task queue for up to timeout.
Definition executor.hpp:280
void cancelPending(const std::exception_ptr &exc) override
Resolves every pending call's Completion with exc.
::morph::async::Completion<::morph::exec::detail::ModelId > bindModel(::morph::backend::detail::BindRequest request, ::morph::exec::IExecutor &cbExec) override
Acquires a model instance without blocking the calling thread.
::morph::async::Completion<::morph::exec::detail::ModelId > promoteModel(::morph::backend::detail::PromoteRequest request, ::morph::exec::IExecutor &cbExec) override
Files an already-live server-side instance under a key, without blocking the calling thread.
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 assignPrimary(::morph::exec::detail::ModelId mid, const std::string &typeId, std::string_view primary) override
Files a live server-side instance under primary and waits for the reply.
::morph::exec::detail::ModelId registerModel(const std::string &typeId, std::function< std::unique_ptr<::morph::model::detail::IModelHolder >()> factory) override
Registers a private instance and waits for the reply.
SocketBackendConfig Config
Alias for the reconnect configuration struct.
void setReconnectHandler(std::function< void()> handler, ::morph::exec::IExecutor *exec) override
Installs the handler posted to exec after each subsequent successful (re)connect.
~SocketBackend() override
Closes the connection and rejects every pending call with DisconnectedError.
void setSession(::morph::session::Context session) override
Installs the session stamped onto every control envelope this backend subsequently builds (register,...
bool waitForConnected(std::chrono::milliseconds timeout=std::chrono::milliseconds{5000})
Blocks the calling thread until connected or timeout elapses.
SocketBackend(::morph::exec::IoLoop &loop, std::string_view serverUrl, Config cfg={})
Parses serverUrl and starts connecting on loop.
std::vector< std::string > listInstances(const std::string &typeId) override
Asks the server for the live shared primary keys of typeId and waits for the reply,...
::morph::async::Completion<::morph::wire::ProtocolNegotiationResult > negotiateProtocolVersion(::morph::exec::IExecutor &replyExec)
Sends a "hello" envelope and classifies the server's reply.
SocketBackend(std::string_view serverUrl, Config cfg={})
Parses serverUrl and starts connecting on a loop of its own, for a caller with no IoLoop to share.
void deregisterModel(::morph::exec::detail::ModelId mid) override
Sends a deregister message fire-and-forget (does not wait for a reply).
void setConnectHandler(const std::function< void()> &handler) override
Installs the handler run after every successful connect, the first included.
::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 resolved on reply.
void notifyBackendChanged() override
No-op — this backend holds no local model objects.
::morph::exec::detail::ModelId registerModelWithContext(const std::string &typeId, std::function< std::unique_ptr<::morph::model::detail::IModelHolder >()> factory, std::string_view contextKey) override
Registers a private instance carrying contextKey and waits for the reply.
::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),...
IEnvelopeTransport: a transport that sends a request envelope of a kind its backend verbs do not buil...
The check every loop-owned body makes on entry: "am I on my owner?".
Compile-time profiler instrumentation: Tracy zones, plots, messages and thread names,...
#define MORPH_ZONE_TEXT(text)
Attaches text to the enclosing scope's MORPH_ZONE; an empty text attaches nothing.
Definition profiler.hpp:126
#define MORPH_ZONE(name)
Opens a profiler zone named name that ends with the enclosing scope. At most one per scope.
Definition profiler.hpp:118
The callId-multiplexed execute reply router, shared by every backend that talks to a RemoteServer ove...
Thrown to in-flight Completions when a transport drops mid-call (e.g. a Qt WebSocket disconnect)....
Definition backend.hpp:621
Abstract executor interface.
Definition executor.hpp:70
Reconnect tuning for SocketBackend.
std::chrono::milliseconds maxReconnectDelay
Upper bound on the exponential backoff between reconnect attempts.
std::chrono::milliseconds handshakeTimeout
Bound on the whole handshake response read that follows a successful TCP connect, from its first byte...
double backoffMultiplier
Multiplier applied to the delay after each failed attempt.
std::chrono::milliseconds sendTimeout
Bound on writing one chunk (up to 64 KiB) of an outgoing frame.
std::chrono::milliseconds connectTimeout
Maximum time for the whole connect: name resolution and every resolved address.
std::chrono::milliseconds initialReconnectDelay
Delay before the first reconnect attempt after a disconnect.
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
std::string kind
Discriminator — see class docstring for valid values.
Definition wire.hpp:93
The decoded body of an event envelope.
Definition wire.hpp:621
One WebSocket connection on an I/O loop: its socket, the frames waiting to be written,...