morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
socket_server.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <array>
5#include <atomic>
6#include <cerrno>
7#include <chrono>
8#include <core/async/Task.hpp>
9#include <core/net/EventLoop.hpp>
10#include <core/net/IListener.hpp>
11#include <core/net/NetError.hpp>
12#include <core/net/Sockets.hpp>
13#include <cstddef>
14#include <cstdint>
15#include <exception>
16#include <memory>
20#include <morph/core/remote.hpp>
21#include <optional>
22#include <stdexcept>
23#include <string>
24#include <string_view>
25#include <utility>
26#include <vector>
27
28#include "../attributes.hpp"
29#include "detail/tcp_socket.hpp"
31#include "detail/ws_frame.hpp"
32#include "detail/ws_handshake.hpp"
33
34namespace morph::net {
35
36namespace detail {
37
38/// @brief Whether a failed `accept()` says the listening socket itself is
39/// unusable, so every later attempt fails the same way and retrying
40/// cannot help.
41///
42/// A socket that is no longer listening answers `EINVAL`; a closed or foreign
43/// descriptor `EBADF`/`ENOTSOCK`; a non-stream socket `EOPNOTSUPP`. core-cpp
44/// classifies these as `BadHandle` and `Unsupported`; a failure it leaves
45/// unclassified arrives as `SystemError` with the `errno` in `systemCode`, so
46/// that spelling is recognised too. Exhaustion (`ResourceExhausted`: `EMFILE`,
47/// `ENFILE`, `ENOBUFS`, `ENOMEM`) is not in the list: it clears once
48/// descriptors or memory are freed, so it is retried.
49/// @param error The error `IListener::accept()` reported.
50/// @return `true` when the listener can never accept again.
51[[nodiscard]] inline bool acceptListenerIsDead(::core::net::NetError const& error) noexcept {
52 using ::core::net::NetErrorCode;
53 if (error.code == NetErrorCode::BadHandle || error.code == NetErrorCode::Unsupported) {
54 return true;
55 }
56 if (error.code != NetErrorCode::SystemError) {
57 return false;
58 }
59 int const code = error.systemCode;
60 return code == EINVAL || code == EBADF || code == ENOTSOCK || code == EOPNOTSUPP;
61}
62
63} // namespace detail
64
65/// @brief Configuration for `SocketServer`.
67 /// @brief Pending-connection backlog passed to the listening socket.
68 int backlog = 64;
69
70 /// @brief Bound on completing the RFC 6455 Upgrade handshake, from
71 /// accept to the request's terminating `\r\n\r\n`, as a single deadline
72 /// across the whole read. Zero disables it.
73 std::chrono::milliseconds handshakeTimeout{10000};
74
75 /// @brief Bound on writing one chunk (up to 64 KiB) of a reply frame.
76 ///
77 /// A loop timer armed around each chunk's write closes the connection when
78 /// it runs out, so a peer that stops reading is retired — its models
79 /// reclaimed, its requests no longer dispatched — instead of accumulating
80 /// replies it will never read. Zero disables it.
81 std::chrono::milliseconds sendTimeout{30000};
82};
83
84/// @brief Raw-socket WebSocket server front for `morph::backend::RemoteServer`.
85///
86/// Mirrors `morph::qt::QtWebSocketServer`'s behavior without Qt: for each
87/// accepted connection it performs the RFC 6455 handshake, then reads framed
88/// text messages and forwards each to `RemoteServer::handle()`; the reply is
89/// written back to the originating socket, or dropped if the connection is
90/// gone by the time the reply is ready.
91///
92/// @par Threading
93/// Owns no thread. The listener, every accepted connection and every reply
94/// write live on an `exec::IoLoop` — the application's one loop, shared with
95/// every other socket and timer built on it. `RemoteServer::handle()` replies
96/// arrive on the server's worker pool and are posted to the loop, which queues
97/// them on the connection's writer.
98///
99/// Cross-thread surface: `listen()` and `close()`, which run on the loop and
100/// wait for it (inline when already there), and `port()`, an atomic read.
101///
102/// @par Lifetime
103/// Holds `RemoteServer& _server` by reference, exactly like
104/// `QtWebSocketServer` — the server's owning `shared_ptr` must outlive this
105/// object (see `docs/spec/core/backend.md`'s Lifetime & ownership section).
107public:
108 /// @brief Alias for the config struct.
110
111 /// @brief Constructs the server on @p loop; does not start listening.
112 /// @param loop The application's I/O loop. Borrowed: it must outlive this
113 /// server.
114 /// @param server `RemoteServer` instance that processes incoming messages.
115 /// Borrowed, not owned: it must outlive this server (see
116 /// `docs/spec/concurrency_and_lifetimes.md`, "Destruction
117 /// ordering").
118 /// @param port TCP port to listen on. Pass 0 to let the OS pick a free port.
119 /// @param cfg Backlog and timeout tuning. Default: 64-connection
120 /// backlog, 10s handshake timeout, 30s send timeout.
121 SocketServer(::morph::exec::IoLoop& loop MORPH_LIFETIMEBOUND,
122 ::morph::backend::RemoteServer& server MORPH_LIFETIMEBOUND, std::uint16_t port, Config cfg = {})
123 : _loop{&loop}, _core{std::make_shared<Core>(loop, server, cfg)}, _requestedPort{port} {}
124
125 /// @brief Constructs the server on a loop of its own, created by the first
126 /// `listen()`; does not start listening.
127 /// @param server `RemoteServer` instance that processes incoming messages.
128 /// Borrowed, not owned: it must outlive this server.
129 /// @param port TCP port to listen on. Pass 0 to let the OS pick a free port.
130 /// @param cfg Backlog and timeout tuning.
131 SocketServer(::morph::backend::RemoteServer& server MORPH_LIFETIMEBOUND, std::uint16_t port, Config cfg = {})
132 : _server{&server}, _requestedPort{port}, _cfg{cfg} {}
133
134 SocketServer(const SocketServer&) = delete;
135 SocketServer& operator=(const SocketServer&) = delete;
136 SocketServer(SocketServer&&) = delete;
137 SocketServer& operator=(SocketServer&&) = delete;
138
139 /// @brief Stops accepting and closes every client connection.
140 ///
141 /// Closes on the loop and waits for it (inline when already there), then
142 /// cuts the loop's tie to `RemoteServer`, so a connection's flow that
143 /// resumes later touches neither this object nor the server.
144 // NOLINTNEXTLINE(bugprone-exception-escape): teardown has no recovery if handing the close to the loop fails, so terminating is the outcome.
146 if (_core) {
147 _loop->runAndWait([core = _core] {
148 core->close();
149 core->server = nullptr;
150 });
151 }
152 }
153
154 /// @brief Starts listening for incoming WebSocket connections.
155 ///
156 /// Binds `127.0.0.1:port` and starts the accept flow, on the loop. With no
157 /// loop of its own yet, creates one first; a process out of descriptors
158 /// cannot, and gets `false`.
159 /// @return `true` if the server bound the requested port and is accepting.
160 bool listen() {
161 if (!_core) {
162 try {
163 _ownedLoop = std::make_unique<::morph::exec::IoLoop>();
164 } catch (const std::exception&) {
165 return false;
166 }
167 _loop = _ownedLoop.get();
168 _core = std::make_shared<Core>(*_ownedLoop, *_server, _cfg);
169 }
170 bool listening = false;
171 _loop->runAndWait([core = _core, port = _requestedPort, &listening] { listening = core->listen(port); });
172 return listening;
173 }
174
175 /// @brief Returns the port the server is currently bound to.
176 /// @return Bound TCP port (OS-assigned when constructed with port 0), or
177 /// `0` when not listening.
178 [[nodiscard]] std::uint16_t port() const { return _core ? _core->port.load() : std::uint16_t{0}; }
179
180 /// @brief Stops accepting new connections and closes every client connection.
181 ///
182 /// Releases the listening socket, so `port()` reads `0` afterwards and the
183 /// bound port is free to rebind — the same post-close observation
184 /// `QtWebSocketServer` makes. Reclaims every connection's models
185 /// (`RemoteServer::closeConnection`) before it returns.
186 ///
187 /// Runs on the loop and waits for it, inline when already there.
188 /// Idempotent, and safe to call from several threads at once on a live
189 /// object: each call is one loop task, and the loop runs them one at a
190 /// time. Racing a call against the *destructor* is still the caller's
191 /// problem (see `docs/spec/concurrency_and_lifetimes.md`, "Destruction
192 /// ordering").
193 void close() {
194 if (_core) {
195 _loop->runAndWait([core = _core] { core->close(); });
196 }
197 }
198
199private:
200 /// One accepted connection.
201 struct Client {
202 std::shared_ptr<::morph::net::detail::LoopConnection> conn;
203 /// Scope every `register` on this connection belongs to, so dropping
204 /// the connection reclaims its models. See `RemoteServer::openConnection`.
206 /// Set once its models are reclaimed; the flow touches nothing after.
207 bool done{false};
208 };
209
210 /// @brief Everything the loop owns, touched only in its tasks.
211 ///
212 /// Held by `shared_ptr` from the server and from every flow, so a flow
213 /// that resumes after the server is gone finds it closed and ends.
214 struct Core : std::enable_shared_from_this<Core> {
215 Core(::morph::exec::IoLoop& ioLoop, ::morph::backend::RemoteServer& remote, Config config)
216 : loop{ioLoop}, server{&remote}, cfg{config} {}
217
218 void note(char const* site) const noexcept {
219 ::morph::exec::detail::noteOwner(site, loop.loop(), loop.runningHere());
220 }
221
222 bool listen(std::uint16_t requestedPort) {
223 note("SocketServer::listen");
224 if (listener || server == nullptr) {
225 return false;
226 }
227 ::morph::net::detail::TcpSocket bound;
228 try {
229 bound = ::morph::net::detail::TcpSocket::listen(requestedPort, cfg.backlog);
230 } catch (const std::exception&) {
231 return false;
232 }
233 if (!bound.setNonBlocking()) {
234 return false;
235 }
236 auto adopted = ::core::net::adoptListener(loop.loop(), bound.nativeHandle());
237 if (!adopted) {
238 return false; // `bound` still owns the descriptor and closes it
239 }
240 static_cast<void>(bound.release());
241 listener = std::move(*adopted);
242 port.store(listener->boundPort());
243 ++generation;
244 loop.loop().spawn(acceptFlow(shared_from_this(), listener.get(), generation));
245 return true;
246 }
247
248 void close() {
249 note("SocketServer::close");
250 ++generation;
251 if (listener) {
252 // The accept flow parked on it resumes, in a later turn, with
253 // `Cancelled`, sees the generation moved on, and ends without
254 // touching the listener it no longer owns.
255 listener->close();
256 listener.reset();
257 }
258 port.store(0);
259 for (auto const& client : std::exchange(clients, {})) {
260 finish(*client, /*flush=*/false);
261 }
262 }
263
264 /// Reclaims @p client's models and closes its socket. Idempotent.
265 void finish(Client& client, bool flush) const {
266 MORPH_ZONE("SocketServer::finish");
267 if (client.done) {
268 return;
269 }
270 client.done = true;
271 if (server != nullptr) {
272 server->closeConnection(client.cid);
273 }
274 if (flush) {
275 ::morph::net::detail::closeAfterFlush(client.conn);
276 } else {
277 ::morph::net::detail::closeConnection(*client.conn);
278 }
279 }
280
281 void forget(Client const& client) {
282 MORPH_ZONE("SocketServer::forget");
283 std::erase_if(clients, [&client](std::shared_ptr<Client> const& entry) { return entry.get() == &client; });
284 MORPH_PLOT("SocketServer::connections", clients.size());
285 }
286
287 void accepted(std::unique_ptr<::core::net::ISocket> socket) {
288 MORPH_ZONE("SocketServer::accept");
289 note("SocketServer::accept");
290 if (server == nullptr) {
291 return;
292 }
293 auto const cid = server->openConnection();
294 auto conn = std::make_shared<::morph::net::detail::LoopConnection>(loop.loop(), std::move(socket),
295 cfg.sendTimeout);
296 // Weak: the connection belongs to this core, and is told nothing
297 // once `~SocketServer` has cut the core's tie to the server.
298 conn->onDrained = [weak = weak_from_this(), cid] {
299 if (auto const self = weak.lock(); self && self->server != nullptr) {
300 self->server->connectionDrained(cid);
301 }
302 };
303 auto client = std::make_shared<Client>(Client{.conn = std::move(conn), .cid = cid, .done = false});
304 clients.push_back(client);
305 MORPH_PLOT("SocketServer::connections", clients.size());
306 loop.loop().spawn(clientFlow(shared_from_this(), client));
307 }
308
309 /// The writer `RemoteServer::post` reaches @p client through, and its
310 /// backlog read. Neither keeps the connection: the server may hold
311 /// them until it learns the connection is gone.
312 [[nodiscard]] ::morph::backend::ConnectionWriter writerFor(std::shared_ptr<Client> const& client) const {
313 std::weak_ptr<Client> const weak = client;
314 auto const counter = client->conn->pending;
315 return {.write =
316 [loopHandle = loop.weak(), weak, counter](std::string const& envelope) {
317 if (!sendEnvelope(loopHandle, weak, counter, envelope)) {
318 throw std::runtime_error("the connection's I/O loop is gone");
319 }
320 },
321 .backlog = [counter] { return counter->load(); }};
322 }
323
324 /// Counts @p envelope against the connection's backlog, then posts
325 /// its frame to the loop, to be queued on the connection if it is
326 /// still open. Callable from any thread: replies arrive on the
327 /// server's pool, events on its strand. Encoded on the calling thread,
328 /// so the loop only queues it.
329 /// @return `false` if the loop is gone, the frame dropped and its
330 /// count released.
331 static bool sendEnvelope(::morph::exec::IoLoop::Weak const& loopHandle, std::weak_ptr<Client> const& weak,
332 std::shared_ptr<std::atomic<std::size_t>> const& counter,
333 std::string const& envelope) {
334 MORPH_ZONE("SocketServer::encodeReply");
335 std::string frame =
336 ::morph::net::detail::encodeWsFrame(::morph::net::detail::WsOpcode::kText, envelope, /*mask=*/false);
337 std::size_t const counted = envelope.size();
338 // Before the post, so a backlog read on the server strand already
339 // covers a frame still on its way to the loop.
340 counter->fetch_add(counted);
341 bool const posted = loopHandle.post([weak, counter, counted, frame = std::move(frame)]() mutable {
342 MORPH_ZONE("SocketServer::queueReply");
343 auto const target = weak.lock();
344 if (!target) {
345 counter->fetch_sub(counted);
346 return;
347 }
348 ::morph::exec::detail::noteOwner("SocketServer::reply", target->conn->loop,
349 ::morph::exec::runningOn(target->conn->loop));
350 // A finished client's connection is closed or closing, so this
351 // drops the frame and releases its count.
352 static_cast<void>(::morph::net::detail::enqueueFrame(target->conn, std::move(frame), counted));
353 });
354 if (!posted) {
355 counter->fetch_sub(counted);
356 }
357 return posted;
358 }
359
360 /// Drops a listener that can no longer accept, so `port()` reads `0`
361 /// and a later `listen()` can bind again. Connections already accepted
362 /// keep running: only the listening socket is gone.
363 ///
364 /// Only the accept flow calls it, and only while its generation is
365 /// current, so `listener` is still the one it was started with: the
366 /// one place that resets it, `close()`, moves the generation first.
367 void listenerLost() {
368 note("SocketServer::listenerLost");
369 listener->close();
370 listener.reset();
371 port.store(0);
372 }
373
374 /// Accepts until the listener it was started with is closed or stops
375 /// being able to accept.
376 static ::core::async::Task<void> acceptFlow(std::shared_ptr<Core> self, ::core::net::IListener* accepting,
377 std::uint64_t startedAt) {
378 for (;;) {
379 auto next = co_await accepting->accept();
380 if (self->generation != startedAt) {
381 co_return;
382 }
383 if (!next) {
384 if (next.error().code == ::core::net::NetErrorCode::Cancelled) {
385 co_return;
386 }
387 if (::morph::net::detail::acceptListenerIsDead(next.error())) {
388 self->listenerLost();
389 co_return;
390 }
391 // Out of descriptors, or another failure the next attempt
392 // may not repeat: wait a little rather than spin on a
393 // listener that stays readable.
394 co_await self->loop.loop().delay(std::chrono::milliseconds{50});
395 if (self->generation != startedAt) {
396 co_return;
397 }
398 continue;
399 }
400 self->accepted(std::move(*next));
401 }
402 }
403
404 /// One connection: handshake, then read frames until it ends.
405 static ::core::async::Task<void> clientFlow(std::shared_ptr<Core> self, std::shared_ptr<Client> client) {
406 auto const connection = client->conn;
407 auto header = co_await ::morph::net::detail::readHeaderBlockAsync(connection, self->cfg.handshakeTimeout);
408 if (client->done) {
409 co_return;
410 }
411 std::optional<std::string> leftover;
412 if (header) {
413 // A zone only in this block: a Tracy zone must not span the
414 // coroutine's suspension points around it.
415 MORPH_ZONE("SocketServer::handshake");
416 try {
417 auto const request = ::morph::net::detail::parseClientHandshakeRequest(header->header);
418 static_cast<void>(::morph::net::detail::enqueueFrame(
419 connection, ::morph::net::detail::buildServerHandshakeResponse(request.key)));
420 leftover = std::move(header->leftover);
421 // Only now: a frame the server posted earlier would be
422 // queued ahead of the 101 and break the handshake. Until
423 // here the server has no writer, and drops what it posts.
424 if (self->server != nullptr) {
425 self->server->setConnectionWriter(client->cid, self->writerFor(client));
426 }
427 } catch (const std::exception&) { // NOLINT(bugprone-empty-catch)
428 // A malformed Upgrade request: the connection ends below.
429 }
430 }
431 bool flush = false;
432 if (leftover) {
433 // Server role: RFC 6455 §5.1 requires a client to mask every
434 // frame it sends, so this reader must reject an unmasked one.
435 ::morph::net::detail::WsFrameReader reader{/*expectMasked=*/true};
436 reader.feed(*leftover);
437 std::array<std::byte, 4096> buf{};
438 for (;;) {
439 auto const outcome = self->drainFrames(client, reader);
440 if (outcome != Drain::More) {
441 flush = outcome == Drain::Closed;
442 break;
443 }
444 auto const got = co_await connection->socket->read(buf);
445 if (client->done || !got || *got == 0) {
446 break;
447 }
448 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast): the socket yields bytes, the reader takes text; same object representation.
449 reader.feed(std::string_view{reinterpret_cast<char const*>(buf.data()), *got});
450 }
451 }
452 if (client->done) {
453 co_return;
454 }
455 self->finish(*client, flush);
456 self->forget(*client);
457 }
458
459 enum class Drain : std::uint8_t {
460 More, ///< Read on.
461 Closed, ///< The peer sent Close; the echo is queued.
462 Failed, ///< A protocol error, or the connection is done.
463 };
464
465 Drain drainFrames(std::shared_ptr<Client> const& client, ::morph::net::detail::WsFrameReader& reader) const {
466 MORPH_ZONE("SocketServer::drainFrames");
467 using ::morph::net::detail::WsOpcode;
468 for (;;) {
469 std::optional<::morph::net::detail::WsFrame> frame;
470 try {
471 frame = reader.tryExtractFrame();
472 } catch (const std::exception&) {
473 return Drain::Failed;
474 }
475 if (!frame) {
476 return Drain::More;
477 }
478 if (frame->opcode == WsOpcode::kClose) {
479 static_cast<void>(::morph::net::detail::enqueueFrame(
480 client->conn, ::morph::net::detail::encodeWsFrame(WsOpcode::kClose, frame->payload, false)));
481 return Drain::Closed;
482 }
483 if (frame->opcode == WsOpcode::kPing) {
484 static_cast<void>(::morph::net::detail::enqueueFrame(
485 client->conn, ::morph::net::detail::encodeWsFrame(WsOpcode::kPong, frame->payload, false)));
486 continue;
487 }
488 if (frame->opcode == WsOpcode::kText && server != nullptr) {
489 dispatch(client, frame->payload);
490 if (client->done) {
491 return Drain::Failed;
492 }
493 }
494 }
495 }
496
497 /// Hands one request to the server. Its reply comes back on whichever
498 /// thread produces it — the server's pool, usually — and is posted
499 /// here, to be queued on this connection if it is still open. It
500 /// counts against the connection's backlog like an event.
501 void dispatch(std::shared_ptr<Client> const& client, std::string const& payload) const {
502 std::weak_ptr<Client> const weak = client;
503 server->handle(
504 payload,
505 [loopHandle = loop.weak(), weak, counter = client->conn->pending](const std::string& reply) {
506 static_cast<void>(sendEnvelope(loopHandle, weak, counter, reply));
507 },
508 client->cid);
509 }
510
511 ::morph::exec::IoLoop& loop;
512 /// The server, until `~SocketServer`.
513 ::morph::backend::RemoteServer* server;
514 Config cfg;
515 std::unique_ptr<::core::net::IListener> listener;
516 std::vector<std::shared_ptr<Client>> clients;
517 /// Moves on with every `listen()` and `close()`, so an accept flow can
518 /// tell that the listener it was started with is gone.
519 std::uint64_t generation{0};
520 /// Read by `port()` from any thread.
521 std::atomic<std::uint16_t> port{0};
522 };
523
524 /// Present only for the owning constructor, from the first `listen()`;
525 /// destroyed after the core has been closed on it.
526 std::unique_ptr<::morph::exec::IoLoop> _ownedLoop;
527 ::morph::exec::IoLoop* _loop{nullptr};
528 std::shared_ptr<Core> _core;
529 ::morph::backend::RemoteServer* _server{nullptr};
530 std::uint16_t _requestedPort;
531 Config _cfg{};
532};
533
534} // namespace morph::net
constexpr Self self
The dialog or drawer a command list runs in.
Definition author.hpp:1295
Server-side message handler that owns model instances and dispatches actions.
Definition remote.hpp:365
bool post(F &&task) const
Posts task if the loop still exists.
Definition io_loop.hpp:98
Owns a core::net::PlatformLoop and, for IoLoopDriver::OwnThread natively, the one thread that runs it...
Definition io_loop.hpp:74
~SocketServer()
Stops accepting and closes every client connection.
SocketServer(::morph::exec::IoLoop &loop, ::morph::backend::RemoteServer &server, std::uint16_t port, Config cfg={})
Constructs the server on loop; does not start listening.
bool listen()
Starts listening for incoming WebSocket connections.
SocketServer(::morph::backend::RemoteServer &server, std::uint16_t port, Config cfg={})
Constructs the server on a loop of its own, created by the first listen(); does not start listening.
std::uint16_t port() const
Returns the port the server is currently bound to.
SocketServerConfig Config
Alias for the config struct.
void close()
Stops accepting new connections and closes every client connection.
std::uint64_t ConnectionId
Opaque id a transport uses to scope model registrations to one connection so RemoteServer::closeConne...
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_PLOT(name, value)
Records value on the plot named name.
Definition profiler.hpp:132
#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
void request(Store< ViewState, Msg > &store, bridge::BridgeHandler< Model, Sharing > &handler, async::CallbackScope const &scope, Action action, ToMsg toMsg, ToFailMsg toFailMsg)
Executes action and sends its result, or its failure, to store as one message.
Definition store.hpp:202
Configuration for SocketServer.
std::chrono::milliseconds sendTimeout
Bound on writing one chunk (up to 64 KiB) of a reply frame.
std::chrono::milliseconds handshakeTimeout
Bound on completing the RFC 6455 Upgrade handshake, from accept to the request's terminating \r\n\r\n...
int backlog
Pending-connection backlog passed to the listening socket.
One WebSocket connection on an I/O loop: its socket, the frames waiting to be written,...