morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
qt_websocket_server.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <QHostAddress>
5#include <QObject>
6#include <QPointer>
7#ifndef QT_NO_SSL
8#include <QSslSocket>
9#endif
10#ifndef QT_NO_SSL
11#include <QSslConfiguration>
12#endif
13#include <QTimer>
14#include <QWebSocket>
15#include <QWebSocketServer>
16#include <atomic>
17#include <chrono>
18#include <cstddef>
19#include <memory>
20#include <morph/core/remote.hpp>
21#include <morph/core/wire.hpp>
23#include <optional>
24#include <string>
25#include <unordered_map>
26
27namespace morph::qt {
28
29/// @brief Per-connection resource limits and bind/exposure policy enforced by
30/// `QtWebSocketServer`.
31///
32/// Declared outside `QtWebSocketServer` so its default member initialisers are
33/// fully parsed before any constructor default argument that names `Config{}`
34/// is evaluated (same rationale as `morph::qt::QtWebSocketBackendConfig` and
35/// `morph::offline::NetworkMonitorConfig`).
37 /// @brief Max simultaneous live client connections. `0` = unbounded.
38 ///
39 /// A new connection beyond this count is closed immediately in `onNewConnection`,
40 /// before any message exchange and before it is tracked internally.
41 std::size_t maxConnections = 0;
42
43 /// @brief Per-frame size cap enforced before a message reaches `RemoteServer::handle()`.
44 ///
45 /// Defaults to `morph::wire::kMaxEnvelopeBytes`, the wire layer's own bound, so an
46 /// unconfigured server is capped only there. Set lower to reject oversized frames
47 /// earlier, before the cost of a pool round-trip and JSON decode.
48 std::size_t maxMessageBytes = ::morph::wire::kMaxEnvelopeBytes;
49
50 /// @brief Per-connection token-bucket rate limit, in messages per second. `0` = unbounded.
51 ///
52 /// The bucket capacity equals `messagesPerSecond` (an immediate one-second
53 /// burst is allowed right after connecting), refilling continuously at that
54 /// rate. A frame that arrives with an empty bucket is **refused, not
55 /// queued**: it never reaches `RemoteServer`, and the sender is answered
56 /// with an `err "rate limited"` addressed to that frame's own `callId`, so
57 /// a caller's `Completion` fails rather than waiting forever. The
58 /// connection stays open — throttling slows a client down, it does not
59 /// evict one. See `docs/spec/core/backend.md`.
60 std::size_t messagesPerSecond = 0;
61
62 /// @brief Time allowed for a newly-accepted connection to send its first text
63 /// frame before it is closed. `0` = disabled.
64 ///
65 /// `QWebSocketServer::newConnection()` fires only after the WebSocket (and, in
66 /// `SecureMode`, TLS) opening handshake has already completed, so in practice
67 /// this bounds time-to-first-frame after that point, not the handshake itself
68 /// — Qt exposes no earlier hook at this layer.
69 std::chrono::milliseconds handshakeTimeout{0};
70
71 /// @brief Time a connection may go without sending any frame before it is
72 /// closed. `0` = disabled.
73 ///
74 /// Checked by a periodic housekeeping sweep (roughly once per second), so the
75 /// actual close can lag the configured value by up to that sweep interval.
76 std::chrono::milliseconds idleTimeout{0};
77
78 /// @brief Time a frame handed to a connection may stay unwritten before
79 /// the connection is closed. `0` = disabled.
80 ///
81 /// A peer that stops reading leaves the oldest frame queued on its socket;
82 /// once that frame is older than this, the socket is aborted, so the
83 /// connection is retired — its models reclaimed, its events no longer
84 /// written — instead of holding frames it will never read. The same bound,
85 /// with the same default, as `morph::net::SocketServerConfig::sendTimeout`.
86 std::chrono::milliseconds sendTimeout{30000};
87
88 /// @brief Address `listen()` binds to. Default `QHostAddress::LocalHost`.
89 QHostAddress bindAddress = QHostAddress::LocalHost;
90
91 /// @brief Deliberate opt-out of the exposure guard: set `true` only to
92 /// knowingly serve plaintext (no TLS) on a non-loopback `bindAddress`.
94};
95
96/// @brief Qt WebSocket server that bridges incoming connections to a `RemoteServer`.
97///
98/// Listens for WebSocket clients, receives their text messages, and forwards each
99/// one to `RemoteServer::handle()`. Replies are sent back to the originating client.
100///
101/// @par TLS
102/// Pass a `QSslConfiguration` to enable `wss://`. The configuration should be built
103/// from a certificate file, a Qt resource, or a byte array before being passed in.
104///
105/// @par SSL-less Qt builds (`QT_NO_SSL`, including the standard Qt-for-WebAssembly build)
106/// The constructor's `tls` parameter does not exist at all when Qt itself was
107/// configured without SSL — `QSslConfiguration` isn't a type Qt provides in that
108/// configuration. Such a server always constructs in `NonSecureMode`; see
109/// `QtWebSocketBackend`'s equivalent doc comment for the client-side rationale.
110///
111/// @par Resource limits
112/// Pass a `QtWebSocketServerConfig` to bound connection count, per-frame size,
113/// per-connection message rate, handshake/idle time, and how long a frame may
114/// stay unwritten. All fields default to unbounded, except `maxMessageBytes`,
115/// which defaults to the wire-layer cap, and `sendTimeout`, which defaults to
116/// 30 s.
117///
118/// @par Bind address & plaintext-exposure guard
119/// `listen()` refuses — returns `false` and logs at `morph::log::LogLevel::error`
120/// — when `cfg.bindAddress` is not loopback, no TLS configuration was passed to
121/// the constructor, and `cfg.allowPlaintextExposure` is `false`. See
122/// `QtWebSocketServerConfig` and security.md's "Transport security" section.
123///
124/// @par Server-initiated events
125/// Each connection is handed to `RemoteServer::setConnectionWriter` as it is
126/// accepted, so `RemoteServer::post` reaches it. Every frame the server writes
127/// to a connection, replies and events alike, counts toward that connection's
128/// backlog by the size of its encoded envelope until the socket has written
129/// it; when the backlog falls below `backend::kEventBacklogLow`, the server is
130/// told through `RemoteServer::connectionDrained`. A connection whose oldest
131/// unwritten frame is older than `QtWebSocketServerConfig::sendTimeout` is
132/// aborted.
133///
134/// @par Usage
135/// Call `listen()` to start accepting connections, and `port()` to discover the
136/// bound port (useful when @p port was 0 — let the OS assign a free port).
137/// Call `close()` or destroy the object to stop the server.
138class QtWebSocketServer : public QObject {
139 Q_OBJECT
140public:
141 /// @brief Alias for the per-connection limits configuration struct.
143
144 /// @brief Constructs the server and prepares it to listen on @p port.
145 ///
146 /// The server does not start accepting connections until `listen()` is called.
147 ///
148 /// @param server `RemoteServer` instance that processes incoming messages.
149 /// @param port TCP port to listen on. Pass 0 to let the OS pick a free port.
150 /// @param tls If non-null, enables TLS (`wss://`) with this configuration. Not
151 /// declared at all on an SSL-less Qt build (`QT_NO_SSL`) — see the
152 /// class doc comment's "SSL-less Qt builds" section.
153 /// @param cfg Per-connection resource limits. Default: everything unbounded
154 /// except `maxMessageBytes`, which defaults to the wire-layer cap.
155 /// @param parent Optional Qt parent object.
157#ifndef QT_NO_SSL
158 std::optional<QSslConfiguration> tls = std::nullopt,
159#endif
160 QtWebSocketServerConfig cfg = QtWebSocketServerConfig{}, QObject* parent = nullptr);
161
162 /// @brief Closes the server and disconnects all clients.
164
165 // Neither copyable nor movable: a QObject with signal/slot connections bound
166 // to this address, holding per-client state keyed by QWebSocket pointer.
167 QtWebSocketServer(const QtWebSocketServer&) = delete;
168 QtWebSocketServer& operator=(const QtWebSocketServer&) = delete;
170 QtWebSocketServer& operator=(QtWebSocketServer&&) = delete;
171
172 /// @brief Starts listening for incoming WebSocket connections.
173 ///
174 /// Refuses — returns `false` without binding, and logs at
175 /// `morph::log::LogLevel::error` — when the configured `bindAddress` is not
176 /// loopback, no TLS configuration was passed to the constructor, and
177 /// `allowPlaintextExposure` is `false`. See `QtWebSocketServerConfig`.
178 ///
179 /// @return `true` if the server successfully bound to the requested port.
180 bool listen();
181
182 /// @brief Returns the port the server is currently bound to.
183 ///
184 /// When constructed with port 0, this returns the OS-assigned port after
185 /// a successful `listen()` call.
186 /// @return Bound TCP port number.
187 [[nodiscard]] quint16 port() const;
188
189 /// @brief Stops accepting new connections and closes the server socket.
190 void close();
191
192 /// @brief Gracefully stops the server: refuse new work, let in-flight
193 /// replies deliver, close each client with a proper WebSocket
194 /// close frame, then hard-stop anything still connected.
195 ///
196 /// Sequence: `QWebSocketServer::pauseAccepting()` (no new connections),
197 /// `RemoteServer::beginShutdown()` (new `register`/`execute` now fail
198 /// fast with `"server shutting down"`), waiting up to @p deadline for
199 /// `RemoteServer::drainedWithin()` to report every in-flight execute has
200 /// replied, sending each still-connected client a real close frame
201 /// (`CloseCodeGoingAway`, reason `"server shutting down"`) instead of an
202 /// abort, and finally running the `close()` hard stop for whatever
203 /// @p deadline did not leave time to finish gracefully.
204 ///
205 /// Pumps the Qt event loop internally while it waits, so it is safe to
206 /// call from the Qt thread — which is also the thread that must run
207 /// `sendTextMessage` for replies and close frames to actually reach
208 /// clients.
209 ///
210 /// @param deadline Total time budget for the whole sequence, measured
211 /// from the moment this call starts. Whatever the drain
212 /// and graceful-close steps have not finished within it
213 /// is hard-stopped exactly as `close()` does.
214 /// @return `true` if every in-flight execute drained before @p deadline
215 /// elapsed; `false` if the hard stop had to reclaim stragglers.
216 bool closeGracefully(std::chrono::milliseconds deadline);
217
218 /// @brief Qt slot called when a new client connects.
219 Q_SLOT void onNewConnection();
220
221 /// @brief Qt slot called when a connected client sends a text message.
222 /// @param message Raw text frame received from the client.
223 Q_SLOT void onTextMessage(const QString& message);
224
225 /// @brief Qt slot called when a client disconnects.
226 Q_SLOT void onDisconnected();
227
228 /// @brief Qt slot called when a client's socket has written @p bytes.
229 ///
230 /// Releases from the connection's backlog every frame those bytes
231 /// complete, and re-arms its send timeout for the oldest frame left.
232 /// @param bytes Bytes the socket just wrote.
233 Q_SLOT void onBytesWritten(qint64 bytes);
234
235private:
236 /// @brief Per-connection state tracked between accept and disconnect.
237 struct ClientState {
238 /// @brief The connection this state belongs to (for symmetry with the map key; unused for lookup).
239 QWebSocket* socket = nullptr;
240
241 /// @brief Current token-bucket balance for `messagesPerSecond`.
242 double tokens = 0.0;
243
244 /// @brief Last time `tokens` was refilled; the next frame's refill is computed from it.
245 std::chrono::steady_clock::time_point lastRefill;
246
247 /// @brief Last time any frame was received on this connection (drives `idleTimeout`).
248 std::chrono::steady_clock::time_point lastActivity;
249
250 /// @brief One-shot timer enforcing `handshakeTimeout`; `nullptr` once cancelled by the
251 /// first frame, or if `handshakeTimeout == 0`.
252 QTimer* handshakeTimer = nullptr;
253
254 /// @brief Connection scope `RemoteServer::openConnection()` assigned this
255 /// client at accept time, so its disconnect can reclaim every model
256 /// it registered (see `RemoteServer::closeConnection`).
258
259 /// @brief Bytes of encoded envelopes handed to this connection and not
260 /// yet written: the backlog `RemoteServer` reads.
261 ///
262 /// Added to on whichever thread hands over a frame, before the frame is
263 /// queued to the Qt thread, so the count covers frames still on their
264 /// way to the socket. Held by `shared_ptr` so the writer, kept on the
265 /// server strand, can read it without reaching this state.
266 std::shared_ptr<std::atomic<std::size_t>> backlog = std::make_shared<std::atomic<std::size_t>>(0);
267
268 /// @brief Frames on the socket and not yet written, released as
269 /// `QWebSocket::bytesWritten` reports them out.
270 detail::OutboundLedger unwritten;
271
272#ifndef QT_NO_SSL
273 /// @brief The TLS socket under `socket`, on a `SecureMode` server.
274 ///
275 /// `QWebSocket` reports encrypted bytes written but holds back only
276 /// the plaintext it has not yet encrypted; what is encrypted and still
277 /// unsent is on this socket, so a frame's position must count it.
278 QPointer<QSslSocket> tls;
279#endif
280
281 /// @brief One-shot timer enforcing `sendTimeout` on the oldest
282 /// unwritten frame; `nullptr` if `sendTimeout == 0`.
283 QTimer* sendTimer = nullptr;
284 };
285
286 /// @brief Counts @p envelope against the connection's backlog, then
287 /// queues it to the Qt thread to be written to @p socket.
288 ///
289 /// Callable from any thread: replies arrive on the server's pool, events
290 /// on its strand. Dropped, its count released, if the server or the
291 /// socket is gone by the time it reaches the Qt thread.
292 /// @param server This server, held weakly.
293 /// @param socket The connection's socket, held weakly.
294 /// @param backlog The connection's backlog counter.
295 /// @param envelope The encoded envelope.
296 /// @return `false` if there is no Qt application to queue to; the
297 /// envelope is dropped and its count released.
298 static bool queueEnvelope(QPointer<QtWebSocketServer> const& server, QPointer<QWebSocket> const& socket,
299 std::shared_ptr<std::atomic<std::size_t>> const& backlog, std::string envelope);
300
301 /// @brief Writes @p envelope, already counted, to @p socket and tracks it
302 /// until the socket has written it. On the Qt thread.
303 /// @param socket A tracked socket.
304 /// @param state @p socket's state.
305 /// @param envelope The encoded envelope.
306 void writeCounted(QWebSocket* socket, ClientState& state, std::string const& envelope);
307
308 /// @brief Counts @p envelope against @p socket's backlog and writes it.
309 /// On the Qt thread.
310 /// @param socket A tracked socket.
311 /// @param state @p socket's state.
312 /// @param envelope The encoded envelope.
313 void writeNow(QWebSocket* socket, ClientState& state, std::string const& envelope);
314
315 /// @brief Takes @p bytes off @p state's backlog, and tells the server if
316 /// that takes it below `backend::kEventBacklogLow`.
317 /// @param state The connection.
318 /// @param bytes What a frame added when it was handed over.
319 void releaseBacklog(ClientState const& state, std::size_t bytes);
320
321 /// @brief Arms @p state's send timer for its oldest unwritten frame, or
322 /// stops it when none is left.
323 /// @param state The connection.
324 void armSendTimer(ClientState& state) const;
325
326 /// @brief Aborts @p socket if its oldest unwritten frame has outlived
327 /// `sendTimeout`; otherwise re-arms the timer.
328 /// @param socket The connection's socket.
329 void onSendTimer(QWebSocket* socket);
330
331 /// @brief Bytes @p state's socket stack still holds unsent: the
332 /// `QWebSocket`'s own, plus, over TLS, the ciphertext not yet sent.
333 /// @param state The connection.
334 /// @return The bytes held.
335 static qint64 pendingBytes(ClientState const& state);
336
337#ifndef QT_NO_SSL
338 /// @brief Finds the `QSslSocket` a `SecureMode` server accepted
339 /// @p socket over.
340 /// @param socket The upgraded connection.
341 /// @return The TLS socket, or null when there is none or it cannot be found.
342 [[nodiscard]] QSslSocket* findTlsSocket(QWebSocket const* socket) const;
343#endif
344
345 /// @brief Releases every frame @p state still holds and drops its timers.
346 /// Called when the connection goes away.
347 /// @param state The connection.
348 static void forgetConnection(ClientState& state);
349
350 /// @brief Refills @p state's token bucket for elapsed time, then consumes one
351 /// token if available.
352 /// @param state Per-connection state to update.
353 /// @return `true` if a token was available (the frame is admitted), `false` if
354 /// the bucket was empty (the frame must be dropped).
355 bool consumeToken(ClientState& state) const;
356
357 /// @brief Qt slot: periodic sweep that closes any connection idle past `idleTimeout`.
358 Q_SLOT void onHousekeepingTick();
359
361 quint16 _requestedPort;
363 QWebSocketServer _wsServer;
364 std::unordered_map<QWebSocket*, ClientState> _clients;
365 QTimer _housekeepingTimer;
366};
367
368} // namespace morph::qt
Server-side message handler that owns model instances and dispatches actions.
Definition remote.hpp:365
Q_SLOT void onBytesWritten(qint64 bytes)
Qt slot called when a client's socket has written bytes.
Q_SLOT void onDisconnected()
Qt slot called when a client disconnects.
Q_SLOT void onTextMessage(const QString &message)
Qt slot called when a connected client sends a text message.
bool closeGracefully(std::chrono::milliseconds deadline)
Gracefully stops the server: refuse new work, let in-flight replies deliver, close each client with a...
QtWebSocketServer(::morph::backend::RemoteServer &server, quint16 port=0, std::optional< QSslConfiguration > tls=std::nullopt, QtWebSocketServerConfig cfg=QtWebSocketServerConfig{}, QObject *parent=nullptr)
Constructs the server and prepares it to listen on port.
bool listen()
Starts listening for incoming WebSocket connections.
Q_SLOT void onNewConnection()
Qt slot called when a new client connects.
void close()
Stops accepting new connections and closes the server socket.
quint16 port() const
Returns the port the server is currently bound to.
~QtWebSocketServer() override
Closes the server and disconnects all clients.
QtWebSocketServerConfig Config
Alias for the per-connection limits configuration struct.
std::uint64_t ConnectionId
Opaque id a transport uses to scope model registrations to one connection so RemoteServer::closeConne...
Which frames handed to a socket it has written, told from the socket's own byte counts.
Per-connection resource limits and bind/exposure policy enforced by QtWebSocketServer.
std::chrono::milliseconds idleTimeout
Time a connection may go without sending any frame before it is closed. 0 = disabled.
std::chrono::milliseconds sendTimeout
Time a frame handed to a connection may stay unwritten before the connection is closed....
std::size_t maxMessageBytes
Per-frame size cap enforced before a message reaches RemoteServer::handle().
QHostAddress bindAddress
Address listen() binds to. Default QHostAddress::LocalHost.
std::size_t messagesPerSecond
Per-connection token-bucket rate limit, in messages per second. 0 = unbounded.
std::chrono::milliseconds handshakeTimeout
Time allowed for a newly-accepted connection to send its first text frame before it is closed....
bool allowPlaintextExposure
Deliberate opt-out of the exposure guard: set true only to knowingly serve plaintext (no TLS) on a no...
std::size_t maxConnections
Max simultaneous live client connections. 0 = unbounded.