morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
reply_router.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <cstddef>
5#include <cstdint>
6#include <functional>
7#include <glaze/glaze.hpp>
8#include <optional>
9#include <unordered_map>
10#include <utility>
11
12#include "../wire.hpp"
13
14/// @file
15/// @brief The callId-multiplexed `execute` reply router, shared by every
16/// backend that talks to a `RemoteServer` over a real (or simulated)
17/// connection.
18///
19/// Two pieces live here, both extracted out of `net::SocketBackend` because
20/// they are a pure function of a decoded `wire::Envelope` and an in-memory
21/// map — no socket, no thread, no server — and so are worth testing directly
22/// rather than through a bound TCP port:
23///
24/// * `classifyExecuteReply` — the ok/timeout/err triage. It had been
25/// open-coded three times (`net/socket_backend.hpp`,
26/// `core/remote.hpp`'s `SimulatedRemoteBackend`, `src/qt/
27/// qt_websocket_backend.cpp`) and the Qt copy had already drifted to a
28/// hand-typed `"timeout"` literal, which is exactly the drift
29/// `wire::kExecuteTimeoutMessage` exists to prevent.
30/// * `PendingCallTable` — call-id allocation plus the pending-completion
31/// map, owned by the backend's I/O loop.
32///
33/// @par Why `core/detail/` and not `net/detail/`
34/// `core/remote.hpp` is one of the consumers and ships in the base `morph`
35/// target's installed header set, whereas everything under `include/morph/
36/// net/` belongs to the optional `morph_net` target (`MORPH_BUILD_NET`,
37/// default `OFF`). A `core/` header including a `net/` one would therefore
38/// install a `remote.hpp` that cannot find its own include whenever net is
39/// off. Living under
40/// `core/detail/` keeps the dependency pointing the one direction it
41/// already points (`net` → `core`).
42
43namespace morph::backend::detail {
44
45/// @brief How a callId-matched `execute` reply should settle its `Completion`.
46enum class ExecuteReplyKind : std::uint8_t {
47 /// @brief An `ok` reply: deserialize `Envelope::body` and set the value.
48 Value,
49 /// @brief The server's own `LimitPolicy::executeTimeout` reply: fail with
50 /// `backend::TimeoutError` specifically.
51 Timeout,
52 /// @brief Any other reply: fail with a generic error carrying
53 /// `Envelope::message`.
54 Error,
55};
56
57/// @brief Triages one decoded `execute` reply envelope into the three ways it
58/// can settle a pending `Completion`.
59///
60/// Order matters and is part of the contract: `kind == "ok"` is decided
61/// first, so an `ok` reply whose (unused) `message` field happens to read
62/// `"timeout"` still settles as a value, not as a timeout.
63///
64/// The timeout arm matches `wire::kExecuteTimeoutMessage` rather than a
65/// hand-typed literal. As that constant's own documentation is careful to
66/// say, this does not make the classification airtight — `Envelope::message`
67/// is a free-text field that can carry a caught exception's `what()`, so an
68/// application exception whose text is exactly `"timeout"` is
69/// indistinguishable from the server's timeout reply. That ambiguity is
70/// inherent to the documented wire contract and is not something this
71/// function can close; using the shared constant only keeps the one producer
72/// and its consumers from drifting apart by typo.
73///
74/// @param env Decoded reply envelope, already matched to a pending call.
75/// @return Which of the three arms the caller should take.
76[[nodiscard]] inline ExecuteReplyKind classifyExecuteReply(const ::morph::wire::Envelope& env) {
77 if (env.kind == "ok") {
78 return ExecuteReplyKind::Value;
79 }
80 if (env.message == ::morph::wire::kExecuteTimeoutMessage) {
81 return ExecuteReplyKind::Timeout;
82 }
83 return ExecuteReplyKind::Error;
84}
85
86/// @brief Routes a decoded incoming envelope that is a server-initiated
87/// event, before any reply matching.
88///
89/// A client backend calls this first on each envelope it decodes: an `event`
90/// carries `callId` 0, which names no request, so letting it reach reply
91/// matching would count it as an orphaned reply. An event whose body does not
92/// decode is consumed and dropped, not handed on, for the same reason.
93///
94/// @param env Decoded incoming envelope.
95/// @param handler Receives the event; may be empty, in which case the event
96/// is consumed and dropped.
97/// @return `true` if @p env is an event (the caller must not match it to a
98/// call), `false` for any other kind.
99[[nodiscard]] inline bool routeEvent(const ::morph::wire::Envelope& env,
100 const std::function<void(::morph::wire::EventBody)>& handler) {
101 if (env.kind != ::morph::wire::kEventKind) {
102 return false;
103 }
104 if (handler) {
105 ::morph::wire::EventBody body;
106 constexpr glz::opts lenient{.error_on_unknown_keys = false};
107 if (!glz::read<lenient>(body, env.body)) {
108 handler(std::move(body));
109 }
110 }
111 return true;
112}
113
114/// @brief The map of in-flight calls, keyed by call id, owned by one executor.
115///
116/// @tparam PendingT Backend-specific per-call state (the `Completion` state,
117/// the result deserializer, the callback executor). Must be movable.
118///
119/// Owns the call-id counter as well as the map, so the two cannot be
120/// allocated and stored by different owners by accident. Neither is
121/// synchronised: the table belongs to its backend's owner — for
122/// `net::SocketBackend`, the I/O loop — and every call is made in a task that
123/// owner runs. Allocating an id, filing the record, writing the request, and
124/// finding the record again when the reply arrives are all loop tasks, so a
125/// disconnect sweep cannot fall between a connected check and an insert: they
126/// are one task, and the sweep is another.
127template <class PendingT>
128class PendingCallTable {
129public:
130 /// @brief The drained-map type returned by `drain()`.
131 using Map = std::unordered_map<std::uint64_t, PendingT>;
132
133 /// @brief Allocates the next call id.
134 ///
135 /// Starts at 1: a reply with `callId == 0` names no request, so no
136 /// request is ever filed under it.
137 /// @return A call id not previously returned by this table.
138 [[nodiscard]] std::uint64_t nextCallId() noexcept { return ++_nextCallId; }
139
140 /// @brief Files @p pending under @p callId, replacing any existing entry.
141 /// @param callId Id to file @p pending under.
142 /// @param pending Per-call state to store.
143 void insert(std::uint64_t callId, PendingT pending) { _map.insert_or_assign(callId, std::move(pending)); }
144
145 /// @brief Removes the entry for @p callId and returns it.
146 /// @param callId Call id carried by an incoming reply.
147 /// @return The stored state, or `std::nullopt` if no such call is in flight
148 /// (a late or already-cancelled reply).
149 [[nodiscard]] std::optional<PendingT> take(std::uint64_t callId) {
150 auto node = _map.extract(callId);
151 if (node.empty()) {
152 return std::nullopt;
153 }
154 return std::optional<PendingT>{std::move(node.mapped())};
155 }
156
157 /// @brief Removes every entry and returns them, leaving the table empty.
158 ///
159 /// Returns rather than resolves so the caller settles the drained
160 /// completions with the table already empty: settling one may re-enter
161 /// the backend and file a new call.
162 /// @return Every entry that was in flight, keyed by call id.
163 [[nodiscard]] Map drain() { return std::exchange(_map, {}); }
164
165 /// @brief Number of calls currently in flight.
166 /// @return The number of filed entries.
167 [[nodiscard]] std::size_t size() const noexcept { return _map.size(); }
168
169private:
170 std::uint64_t _nextCallId{0};
171 Map _map;
172};
173
174} // namespace morph::backend::detail
AnyHandle env(std::string const &name)
A client environment value, {"env": name}: widthClass, locale, platform, online, backend.
Definition author.hpp:1209