morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
ws_connection.hpp
Go to the documentation of this file.
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/Task.hpp>
9#include <core/net/EventLoop.hpp>
10#include <core/net/ISocket.hpp>
11#include <cstddef>
12#include <deque>
13#include <functional>
14#include <memory>
15#include <optional>
16#include <span>
17#include <string>
18#include <string_view>
19#include <utility>
20
23#include "ws_handshake.hpp"
24
25/// @file
26/// @brief One WebSocket connection on an I/O loop: its socket, the frames
27/// waiting to be written, and the two flows that move bytes.
28///
29/// Shared by `SocketBackend` (client role) and `SocketServer` (server role).
30/// Everything here runs on the loop's thread and is touched nowhere else; the
31/// `LoopConnection` itself is held by `shared_ptr` from each flow that uses it,
32/// so a flow that resumes after its owner has let go of the connection still
33/// finds the socket object alive. The one exception is the backlog counter,
34/// `pending`, which other threads add to and read.
35
36namespace morph::net::detail {
37
38/// @brief One whole frame waiting for the writer.
39struct OutboundFrame {
40 /// @brief The encoded WebSocket frame.
41 std::string bytes;
42 /// @brief What the frame added to `LoopConnection::pending`: the size of
43 /// the envelope it carries, or `0` for a frame that is not counted.
44 std::size_t counted{0};
45};
46
47/// @brief A connected socket on the loop, plus its outgoing frame queue.
48struct LoopConnection {
49 /// @param eventLoop The loop the socket is registered with.
50 /// @param connected The socket; owned from here on.
51 /// @param writeTimeout Bound on writing one chunk of a frame; zero disables it.
52 LoopConnection(::core::net::EventLoop& eventLoop, std::unique_ptr<::core::net::ISocket> connected,
53 std::chrono::milliseconds writeTimeout)
54 : loop{eventLoop}, socket{std::move(connected)}, sendTimeout{writeTimeout} {}
55
56 LoopConnection(const LoopConnection&) = delete;
57 LoopConnection& operator=(const LoopConnection&) = delete;
58 LoopConnection(LoopConnection&&) = delete;
59 LoopConnection& operator=(LoopConnection&&) = delete;
60 ~LoopConnection() = default;
61
62 /// @brief The loop the socket belongs to.
63 ::core::net::EventLoop& loop;
64 /// @brief The socket. Closed, never replaced, for the connection's life.
65 std::unique_ptr<::core::net::ISocket> socket;
66 /// @brief Bound on one chunk's write; see `writerFlow`.
67 std::chrono::milliseconds sendTimeout;
68 /// @brief Whole frames waiting for the writer, oldest first.
69 std::deque<OutboundFrame> outbox;
70 /// @brief Bytes of encoded envelopes handed to this connection and not yet
71 /// written: its backlog, as `RemoteServer` reads it.
72 ///
73 /// Added to by whoever hands over a frame, on any thread, before the frame
74 /// reaches the loop, so the count covers frames still on their way to the
75 /// outbox. Subtracted on the loop by the same amount once the frame's last
76 /// chunk is written, or once the frame is dropped. Counting envelopes, not
77 /// framed bytes, makes the add and the subtract the same number, so the
78 /// count cannot drift. Held by `shared_ptr` so a thread off the loop can
79 /// keep the counter without keeping the connection, whose socket must be
80 /// released on the loop.
81 std::shared_ptr<std::atomic<std::size_t>> const pending{std::make_shared<std::atomic<std::size_t>>(0)};
82 /// @brief Called on the loop when a written frame takes `pending` from at
83 /// least `backend::kEventBacklogLow` to below it. Not called once
84 /// the connection is closed or closing. Empty means nobody is told.
85 std::function<void()> onDrained;
86 /// @brief Whether a writer flow is running.
87 bool writing{false};
88 /// @brief Whether the socket has been closed.
89 bool closed{false};
90 /// @brief Close once the outbox has drained (a Close echo, say).
91 bool closeWhenFlushed{false};
92};
93
94/// @brief Takes @p bytes off @p conn's backlog, and calls `onDrained` if
95/// that takes it below `backend::kEventBacklogLow`.
96///
97/// The crossing is judged from the value this subtraction replaced, so of
98/// several releases that pass the bound together exactly one reports it.
99/// Frames handed over meanwhile on other threads may lift the count again;
100/// the server's drain handling is idempotent, so a second report is harmless.
101/// @param conn The connection.
102/// @param bytes What the frame added when it was handed over.
103inline void releaseBacklog(LoopConnection& conn, std::size_t bytes) {
104 if (bytes == 0) {
105 return;
106 }
107 std::size_t const before = conn.pending->fetch_sub(bytes);
108 bool const crossed =
109 before >= ::morph::backend::kEventBacklogLow && before - bytes < ::morph::backend::kEventBacklogLow;
110 if (crossed && !conn.closed && !conn.closeWhenFlushed && conn.onDrained) {
111 conn.onDrained();
112 }
113}
114
115/// @brief Closes @p conn's socket now, dropping any unwritten frames.
116///
117/// A read or write parked on it resumes with `NetErrorCode::Cancelled` in a
118/// later drain step of the loop, which is how the flows learn of it. The
119/// dropped frames leave the backlog; a frame the writer holds leaves it when
120/// the writer lets go of it.
121/// @param conn The connection; idempotent.
122inline void closeConnection(LoopConnection& conn) noexcept {
123 if (conn.closed) {
124 return;
125 }
126 conn.closed = true;
127 std::size_t dropped = 0;
128 for (auto const& frame : conn.outbox) {
129 dropped += frame.counted;
130 }
131 conn.outbox.clear();
132 conn.pending->fetch_sub(dropped);
133 conn.socket->close();
134}
135
136/// @brief The largest single write the writer issues, so that `sendTimeout`
137/// bounds a write making no progress rather than a large frame on a
138/// slow link.
139inline constexpr std::size_t kWriteChunk = std::size_t{64} * 1024;
140
141/// @brief Timer callback: a write, or a handshake read, overran its bound.
142/// @param connPtr The `LoopConnection`.
143inline void closeOnDeadline(void* connPtr) { closeConnection(*static_cast<LoopConnection*>(connPtr)); }
144
145/// @brief Writes every queued frame, in order, one chunk at a time.
146///
147/// Each chunk's write is bounded by `sendTimeout`: a loop timer that closes
148/// the socket if the write has not finished, so a peer that stops reading
149/// retires the connection instead of growing its queue forever. A frame
150/// leaves the backlog once its last chunk is written, or once the connection
151/// closes under it.
152/// @param conn The connection; the flow's own share keeps its socket alive.
153/// @return The flow.
154inline ::core::async::Task<void> writerFlow(std::shared_ptr<LoopConnection> conn) {
155 while (!conn->closed && !conn->outbox.empty()) {
156 OutboundFrame const next = std::move(conn->outbox.front());
157 conn->outbox.pop_front();
158 std::string const& frame = next.bytes;
159 std::size_t offset = 0;
160 while (offset < frame.size() && !conn->closed) {
161 std::size_t const length = std::min(kWriteChunk, frame.size() - offset);
162 ::core::net::TimerId timer{};
163 if (conn->sendTimeout.count() > 0) {
164 timer =
165 conn->loop.addTimer(conn->loop.clock().now() + conn->sendTimeout, &closeOnDeadline, conn.get());
166 }
167 auto const written =
168 co_await conn->socket->write(std::as_bytes(std::span<char const>{frame}.subspan(offset, length)));
169 static_cast<void>(conn->loop.cancelTimer(timer));
170 if (!written || *written == 0) {
171 closeConnection(*conn);
172 break;
173 }
174 offset += *written;
175 }
176 releaseBacklog(*conn, next.counted);
177 }
178 conn->writing = false;
179 if (conn->closeWhenFlushed) {
180 closeConnection(*conn);
181 }
182}
183
184/// @brief Queues @p frame on @p conn and starts the writer if it is idle.
185/// @param conn The connection.
186/// @param frame One complete, encoded WebSocket frame.
187/// @param counted What the caller added to `conn->pending` for this frame;
188/// `0` for a frame that is not counted.
189/// @return `false` if the connection is closed and the frame was dropped, its
190/// count released with it.
191inline bool enqueueFrame(std::shared_ptr<LoopConnection> const& conn, std::string frame, std::size_t counted = 0) {
192 // The send side's zone is here and not in `writerFlow`: a zone must not
193 // stay open across a `co_await`, where the loop runs other work on the
194 // same thread before the writer resumes.
195 MORPH_ZONE("ws::enqueueFrame");
196 if (conn->closed || conn->closeWhenFlushed) {
197 releaseBacklog(*conn, counted);
198 return false;
199 }
200 conn->outbox.push_back(OutboundFrame{.bytes = std::move(frame), .counted = counted});
201 if (!conn->writing) {
202 conn->writing = true;
203 conn->loop.spawn(writerFlow(conn));
204 }
205 return true;
206}
207
208/// @brief Closes @p conn once every queued frame is written, or now if none is.
209/// @param conn The connection.
210inline void closeAfterFlush(std::shared_ptr<LoopConnection> const& conn) {
211 if (conn->writing) {
212 conn->closeWhenFlushed = true;
213 return;
214 }
215 closeConnection(*conn);
216}
217
218/// @brief Reads an HTTP header block — up to and including `\r\n\r\n` — off
219/// @p conn, as the WebSocket handshake needs.
220///
221/// @p timeout bounds the whole read, from the first byte to the terminator:
222/// a loop timer closes the socket when it runs out, so a peer that dribbles
223/// one byte at a time cannot stretch the handshake past it. The header is
224/// capped at 64 KiB, checked before each read, like `readHttpHeaderBlock`.
225/// @param conn The connection; must have no other read outstanding.
226/// @param timeout Bound on the whole read; zero disables it.
227/// @return The header and any bytes read past it, or `std::nullopt` if the
228/// peer closed, the read failed, the cap was reached or the time ran out.
229inline ::core::async::Task<std::optional<HandshakeReadResult>> readHeaderBlockAsync(
230 std::shared_ptr<LoopConnection> conn, std::chrono::milliseconds timeout) {
231 ::core::net::TimerId timer{};
232 if (timeout.count() > 0) {
233 timer = conn->loop.addTimer(conn->loop.clock().now() + timeout, &closeOnDeadline, conn.get());
234 }
235 std::string buf;
236 std::array<std::byte, 4096> chunk{};
237 std::optional<HandshakeReadResult> result;
238 for (;;) {
239 if (auto const pos = buf.find("\r\n\r\n"); pos != std::string::npos) {
240 result = HandshakeReadResult{.header = buf.substr(0, pos), .leftover = buf.substr(pos + 4)};
241 break;
242 }
243 if (buf.size() > std::size_t{64} * 1024 || conn->closed) {
244 break;
245 }
246 auto const got = co_await conn->socket->read(chunk);
247 if (!got || *got == 0) {
248 break;
249 }
250 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast): the socket yields bytes, the buffer holds text; same object representation.
251 buf.append(reinterpret_cast<char const*>(chunk.data()), *got);
252 }
253 static_cast<void>(conn->loop.cancelTimer(timer));
254 co_return result;
255}
256
257} // namespace morph::net::detail
What a transport hands RemoteServer to write to one connection, and the backlog bounds both sides app...
Compile-time profiler instrumentation: Tracy zones, plots, messages and thread names,...
#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