FastMM documentation
FastMM C++ API 0.6.0
Public headers, tier 1 and tier 2
Loading...
Searching...
No Matches
ws_client.hpp
Go to the documentation of this file.
1#pragma once
2// RFC 6455 WebSocket client over any ByteStream, driven by a Reactor.
3//
4// Lifecycle: start() registers the fd and drives Stream::handshake() (TCP connect, TLS),
5// then sends the HTTP upgrade, verifies Sec-WebSocket-Accept, and switches to framing.
6// Receive path: one read() into the RecvBuffer, then WsMessageAssembler delivers complete
7// messages in place (no copies for unfragmented frames; text payloads keep kPadding bytes
8// behind them for simdjson). Send path: frames are encoded + masked into a WireBuffer and
9// written immediately; leftovers are flushed on EPOLLOUT.
10//
11// Extensions: none are negotiated. permessage-deflate would be requested in
12// detail::ws_build_upgrade_request and accepted in ws_check_upgrade_response; frames with
13// RSV1 set are rejected until then (see WsMessageAssembler).
14#include "fastmm/net/byte_stream.hpp"
15#include "fastmm/net/http_message.hpp"
17#include "fastmm/net/recv_buffer.hpp"
18#include "fastmm/net/wire_buffer.hpp"
19#include "fastmm/net/ws_frame.hpp"
20
21#include <array>
22#include <concepts>
23#include <cstddef>
24#include <cstdint>
25#include <span>
26#include <string>
27#include <string_view>
28#include <utility>
29
30namespace fastmm::net {
31
32// Milestones before the upgrade completes, so owners can report fine-grained states.
33enum class WsProgress : std::uint8_t {
34 TcpConnected, // TCP established (TLS handshake starts next for TLS streams)
35 UpgradeSent, // transport ready, HTTP upgrade request written
36};
37
38template <class H>
39concept WsClientHandler = requires(H& h,
40 std::string_view s,
41 std::span<const std::byte> b,
42 std::int64_t ts,
43 std::uint16_t code,
44 NetError e,
45 WsProgress p) {
46 h.on_ws_progress(p);
47 h.on_ws_open();
48 h.on_ws_text(s, ts);
49 h.on_ws_binary(b, ts);
50 h.on_ws_ping(b);
51 h.on_ws_pong(b);
52 h.on_ws_close(code, s); // peer sent a close frame (or replied to ours)
53 h.on_ws_error(e, s); // transport/protocol failure; the stream is closed
54};
55
56struct WsClientConfig {
57 std::size_t recv_capacity =
58 std::size_t{4} * 1024 * 1024; // RecvBuffer; bounds the largest message
59 std::size_t send_capacity = std::size_t{1024} * 1024; // WireBuffer for outgoing frames
60 std::size_t max_message_bytes = 0; // 0 = recv_capacity - kWsMaxHeaderSize
61 bool validate_utf8 = true; // reject text messages that are not UTF-8 (close code 1007)
62};
64struct WsStats {
65 std::uint64_t frames_rx = 0;
66 std::uint64_t frames_tx = 0;
67 std::uint64_t bytes_rx = 0;
68 std::uint64_t bytes_tx = 0;
69 std::uint64_t drops = 0; // sends refused because the send buffer was full
70};
72enum class WsState : std::uint8_t { Idle, Connecting, Upgrading, Open, Closing, Closed };
73
74namespace detail {
75
76inline constexpr std::size_t kWsKeyLen = 24; // base64 of 16 random bytes
77inline constexpr std::size_t kWsAcceptLen = 28; // base64 of SHA-1
78
79// base64(SHA1(client_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")).
80void ws_compute_accept_key(std::string_view client_key, std::span<char, kWsAcceptLen> out) noexcept;
81void ws_generate_client_key(std::span<char, kWsKeyLen> out) noexcept;
82
83// Appends the upgrade request. `extra_headers` is a pre-formatted "Name: value\r\n" block
84// (venue-specific headers such as API keys). Returns false if the buffer is too small.
85bool ws_build_upgrade_request(WireBuffer& out,
86 std::string_view host,
87 std::uint16_t port,
88 bool tls,
89 std::string_view target,
90 std::string_view key,
91 std::string_view extra_headers) noexcept;
92
93// nullptr when the response is a valid upgrade, else a static description of the failure.
94const char* ws_check_upgrade_response(const HttpResponseHead& head,
95 std::string_view expected_accept) noexcept;
96
97// xorshift64* for per-frame masks: RFC 6455 wants unpredictability against proxies, not
98// cryptographic strength; seeded from RAND_bytes at construction.
99class MaskGenerator {
100 public:
101 MaskGenerator() noexcept;
102 void next(std::uint8_t out[4]) noexcept {
103 state_ ^= state_ >> 12;
104 state_ ^= state_ << 25;
105 state_ ^= state_ >> 27;
106 const std::uint64_t v = state_ * 0x2545F4914F6CDD1DULL;
107 const auto w = static_cast<std::uint32_t>(v >> 32);
108 out[0] = static_cast<std::uint8_t>(w);
109 out[1] = static_cast<std::uint8_t>(w >> 8);
110 out[2] = static_cast<std::uint8_t>(w >> 16);
111 out[3] = static_cast<std::uint8_t>(w >> 24);
112 }
113
114 private:
115 std::uint64_t state_;
116};
117
118} // namespace detail
119
120template <ByteStream Stream, WsClientHandler Handler>
121class WsClient final : public IoHandler {
122 public:
123 WsClient(Reactor& reactor, Stream&& stream, Handler& handler, const WsClientConfig& cfg = {})
124 : reactor_(reactor),
125 stream_(std::move(stream)),
126 handler_(handler),
127 rx_(cfg.recv_capacity),
128 tx_(cfg.send_capacity),
129 assembler_(rx_,
130 cfg.max_message_bytes != 0 ? cfg.max_message_bytes
131 : cfg.recv_capacity - kWsMaxHeaderSize,
132 /*require_masked=*/false,
133 cfg.validate_utf8) {}
134
135 ~WsClient() override { detach(); }
136 WsClient(const WsClient&) = delete;
137 WsClient& operator=(const WsClient&) = delete;
139 // Registers with the reactor and begins the connect/TLS/upgrade sequence. The stream must
140 // already hold a socket that is connected or connecting.
141 bool start(std::string_view host,
142 std::uint16_t port,
143 bool tls,
144 std::string_view target,
145 std::string_view extra_headers = {}) {
146 if (state_ != WsState::Idle) return false;
147 host_ = std::string(host);
148 port_ = port;
149 tls_ = tls;
150 target_ = std::string(target);
151 extra_headers_ = std::string(extra_headers);
152 if (!reactor_.add(stream_.fd(), *this, IoEvent::ReadWrite)) return false;
153 registered_ = true;
154 state_ = WsState::Connecting;
155 drive_connect();
156 return true;
157 }
158
159 // --- sending (client frames are always masked) ---------------------------------------
160 // False means the frame did not go out: the client is not open, the frame did not fit, or the
161 // write failed and closed the stream (on_ws_error already fired). A frame the kernel did not
162 // take yet is queued for writability and counts as sent. Between cork() and uncork() a data
163 // frame is only encoded, so uncork() reports its write instead.
164 bool send_text(std::string_view text) noexcept {
165 return send_frame(
166 WsOpcode::Text,
167 std::span<const std::byte>(reinterpret_cast<const std::byte*>(text.data()), text.size()));
168 }
169 bool send_binary(std::span<const std::byte> data) noexcept {
170 return send_frame(WsOpcode::Binary, data);
171 }
172 bool send_ping(std::span<const std::byte> data = {}) noexcept {
173 return send_frame(WsOpcode::Ping, data);
174 }
175 bool send_pong(std::span<const std::byte> data = {}) noexcept {
176 return send_frame(WsOpcode::Pong, data);
177 }
178
179 // Between cork() and uncork() data frames are only encoded into the send buffer; uncork()
180 // writes them together (one TLS write and one write(2) for the lot when the kernel takes it).
181 // uncork() may invoke on_ws_error, like send_text(); it returns false when the client is not
182 // open afterwards.
183 void cork() noexcept { corked_ = true; }
184 bool uncork() noexcept {
185 corked_ = false;
186 if (state_ != WsState::Open) return false;
187 flush();
188 return state_ == WsState::Open;
189 }
190
191 // Starts the closing handshake; on_ws_close fires when the peer echoes the close frame.
192 bool send_close(WsCloseCode code = WsCloseCode::Normal, std::string_view reason = {}) noexcept {
193 if (state_ != WsState::Open) return false;
194 std::array<std::byte, kWsMaxControlPayload> payload{};
195 const std::size_t n =
196 ws_encode_close_payload(payload, static_cast<std::uint16_t>(code), reason);
197 if (!encode_frame(WsOpcode::Close, std::span<const std::byte>(payload.data(), n))) return false;
198 state_ = WsState::Closing; // before flush(): a write failure may report on_ws_error
199 flush();
200 return true;
201 }
202
203 // Immediate teardown, no callbacks.
204 void close() noexcept {
205 detach();
206 stream_.close();
207 state_ = WsState::Closed;
208 }
209
210 WsState state() const noexcept { return state_; }
211 bool is_open() const noexcept { return state_ == WsState::Open; }
212 const WsStats& stats() const noexcept { return stats_; }
213 std::size_t pending_send_bytes() const noexcept { return tx_.size(); }
214 Stream& stream() noexcept { return stream_; }
215 int fd() const noexcept { return stream_.fd(); }
217 // --- IoHandler -------------------------------------------------------------------------
218 void on_readable() override {
219 switch (state_) {
221 drive_connect();
222 break;
224 read_upgrade_response();
225 break;
226 case WsState::Open:
227 case WsState::Closing:
228 read_frames();
229 break;
230 case WsState::Idle:
231 case WsState::Closed:
232 break;
233 }
234 }
235 void on_writable() override {
236 if (state_ == WsState::Connecting) {
237 drive_connect();
238 } else if (state_ != WsState::Idle && state_ != WsState::Closed) {
239 flush();
240 }
241 }
242 void on_error(int err) override {
243 fail(NetError::Syscall, err != 0 ? "socket error" : "epoll error");
244 }
245
246 private:
247 void detach() noexcept {
248 if (registered_) {
249 reactor_.remove(stream_.fd());
250 registered_ = false;
251 }
252 }
253
254 void drive_connect() {
255 const IoResult r = stream_.handshake();
256 if (r.failed()) {
257 fail(r.error, r.error == NetError::Tls ? "tls handshake failed" : "connect failed");
258 return;
259 }
260 if (!tcp_reported_ && (!r.would_block() || tcp_connected())) {
261 tcp_reported_ = true;
262 handler_.on_ws_progress(WsProgress::TcpConnected);
263 if (state_ != WsState::Connecting) return; // handler closed us
264 }
265 if (r.would_block()) return;
266 // Transport ready: send the upgrade request.
267 detail::ws_generate_client_key(std::span<char, detail::kWsKeyLen>(key_.data(), key_.size()));
268 detail::ws_compute_accept_key(
269 std::string_view(key_.data(), key_.size()),
270 std::span<char, detail::kWsAcceptLen>(expected_accept_.data(), expected_accept_.size()));
271 if (!detail::ws_build_upgrade_request(tx_,
272 host_,
273 port_,
274 tls_,
275 target_,
276 std::string_view(key_.data(), key_.size()),
277 extra_headers_)) {
278 fail(NetError::Overflow, "upgrade request exceeds send buffer");
279 return;
280 }
281 state_ = WsState::Upgrading;
282 flush();
283 if (state_ == WsState::Upgrading) handler_.on_ws_progress(WsProgress::UpgradeSent);
284 // The server may already have answered (loopback); readable events will follow anyway.
285 }
286
287 // TLS streams expose whether the underlying TCP connect finished; plain streams are
288 // connected exactly when their handshake() succeeds.
289 bool tcp_connected() const noexcept {
290 if constexpr (requires(const Stream& s) { s.transport_ready(); }) {
291 return stream_.transport_ready();
292 } else {
293 return false;
294 }
295 }
296
297 void read_upgrade_response() {
298 for (;;) {
299 const std::span<std::byte> w = rx_.writable();
300 if (w.empty()) {
301 fail(NetError::Overflow, "upgrade response exceeds receive buffer");
302 return;
303 }
304 const IoResult r = stream_.read(w);
305 if (r.bytes > 0) {
306 rx_.commit(r.bytes);
307 stats_.bytes_rx += r.bytes;
308 HttpResponseHead head;
309 const HttpParseStatus st = parse_response_head(rx_.readable_view(), head);
310 if (st == HttpParseStatus::Invalid || st == HttpParseStatus::TooManyHeaders) {
311 fail(NetError::Protocol, "malformed upgrade response");
312 return;
313 }
314 if (st == HttpParseStatus::Ok) {
315 if (const char* why = detail::ws_check_upgrade_response(
316 head, std::string_view(expected_accept_.data(), expected_accept_.size()))) {
317 fail(NetError::Protocol, why);
318 return;
319 }
320 rx_.consume(head.head_len);
321 state_ = WsState::Open;
322 handler_.on_ws_open();
323 if (state_ != WsState::Open && state_ != WsState::Closing) return; // handler closed us
324 rx_ts_ = Reactor::now_ns();
325 if (!process_frames()) return;
326 read_frames(); // drain anything else already queued (edge-triggered)
327 return;
328 }
329 }
330 if (r.closed) {
331 fail(NetError::Closed, "eof during upgrade");
332 return;
333 }
334 if (r.failed()) {
335 fail(r.error, "read failed during upgrade");
336 return;
337 }
338 if (r.would_block()) return;
339 }
340 }
341
342 // Edge-triggered drain: read until the stream wants more.
343 void read_frames() {
344 for (;;) {
345 const std::span<std::byte> w = rx_.writable();
346 if (w.empty()) {
347 fail_close(WsCloseCode::MessageTooBig, "message larger than receive buffer");
348 return;
349 }
350 const IoResult r = stream_.read(w);
351 if (r.bytes > 0) {
352 rx_.commit(r.bytes);
353 stats_.bytes_rx += r.bytes;
354 rx_ts_ = Reactor::now_ns();
355 if (!process_frames()) return;
356 if (state_ == WsState::Closed) return;
357 if (input_drained()) return; // skip the read that would return EAGAIN
358 }
359 if (r.closed) {
360 fail(NetError::Closed, "eof");
361 return;
362 }
363 if (r.failed()) {
364 fail(r.error, "read failed");
365 return;
366 }
367 if (r.would_block()) return;
368 }
369 }
370
371 // The socket had nothing more after the last read and the event reported no hang-up: data that
372 // arrives later comes with a new readable event (edge-triggered contract).
373 bool input_drained() const noexcept {
374 if constexpr (requires(const Stream& s) {
375 { s.input_drained() } -> std::same_as<bool>;
376 }) {
377 return !reactor_.event_hangup() && stream_.input_drained();
378 } else {
379 return false;
380 }
381 }
382
383 // Returns false if the connection was torn down.
384 bool process_frames() {
385 const detail::WsAssembleError err = assembler_.process(*this);
386 if (err) {
387 fail_close(err.code, err.detail);
388 return false;
389 }
390 return state_ != WsState::Closed;
391 }
392
393 // WsMessageAssembler sink.
394 friend class detail::WsMessageAssembler;
395
396 public:
397 void on_message(WsOpcode op, std::span<std::byte> payload) noexcept {
398 if (state_ == WsState::Closed) return; // handler closed us mid-batch
399 ++stats_.frames_rx;
400 if (op == WsOpcode::Text) {
401 handler_.on_ws_text(
402 std::string_view(reinterpret_cast<const char*>(payload.data()), payload.size()), rx_ts_);
403 } else {
404 handler_.on_ws_binary(payload, rx_ts_);
405 }
406 }
407 void on_control(WsOpcode op, std::span<std::byte> payload) noexcept {
408 if (state_ == WsState::Closed) return;
409 ++stats_.frames_rx;
410 switch (op) {
411 case WsOpcode::Ping:
412 handler_.on_ws_ping(payload);
413 if (state_ == WsState::Open) send_pong(payload);
414 break;
415 case WsOpcode::Pong:
416 handler_.on_ws_pong(payload);
417 break;
418 case WsOpcode::Close: {
419 const std::uint16_t code = ws_decode_close_code(payload);
420 const std::string_view reason =
421 payload.size() > 2 ? std::string_view(reinterpret_cast<const char*>(payload.data()) + 2,
422 payload.size() - 2)
423 : std::string_view{};
424 if (state_ == WsState::Open) {
425 // Peer-initiated close: echo the code (ยง5.5.1) then tear down.
426 std::array<std::byte, kWsMaxControlPayload> echo{};
427 const std::size_t n = ws_encode_close_payload(
428 echo, code == static_cast<std::uint16_t>(WsCloseCode::NoStatus) ? 1000 : code, {});
429 send_frame(WsOpcode::Close, std::span<const std::byte>(echo.data(), n));
430 if (state_ == WsState::Closed) break; // the echo failed: on_ws_error already fired
431 }
432 close();
433 handler_.on_ws_close(code, reason);
434 break;
435 }
436 default:
437 break;
438 }
439 }
440
441 private:
442 bool encode_frame(WsOpcode op, std::span<const std::byte> payload) noexcept {
443 std::uint8_t mask[4];
444 masks_.next(mask);
445 if (!ws_encode_frame(tx_, op, /*fin=*/true, payload, mask)) {
446 ++stats_.drops;
447 return false;
448 }
449 ++stats_.frames_tx;
450 return true;
451 }
452
453 // Encodes + writes one masked frame. May invoke on_ws_error (via flush) before returning;
454 // callers must not touch members afterwards unless they re-check state_.
455 bool send_frame(WsOpcode op, std::span<const std::byte> payload) noexcept {
456 if (state_ != WsState::Open) return false;
457 if (corked_ && op != WsOpcode::Text && op != WsOpcode::Binary) corked_ = false;
458 if (corked_ && tx_.free_space() < payload.size() + kWsMaxHeaderSize) {
459 flush(); // full while corked: write what is queued first
460 if (state_ != WsState::Open) return false;
461 }
462 if (!encode_frame(op, payload)) return false;
463 if (corked_) return true; // queued; uncork() reports the write
464 flush();
465 return state_ == WsState::Open; // a failed write closed the stream: the frame never left
466 }
467
468 void flush() noexcept {
469 if constexpr (requires(Stream& s) { s.flush(); }) {
470 const IoResult fl = stream_.flush(); // TLS: push buffered ciphertext first
471 if (fl.failed()) {
472 fail(fl.error, "flush failed");
473 return;
474 }
475 if (fl.want_write) return;
476 }
477 while (!tx_.empty()) {
478 const IoResult w = stream_.write(tx_.pending());
479 tx_.consumed(w.bytes);
480 stats_.bytes_tx += w.bytes;
481 if (w.failed()) {
482 fail(w.error, "write failed");
483 return;
484 }
485 if (w.would_block()) return;
486 }
487 }
488
489 // Protocol violation: send a close frame with `code`, then tear down and report.
490 void fail_close(WsCloseCode code, const char* detail) noexcept {
491 if (state_ == WsState::Open) {
492 std::array<std::byte, kWsMaxControlPayload> payload{};
493 const std::size_t n = ws_encode_close_payload(payload, static_cast<std::uint16_t>(code), {});
494 send_frame(WsOpcode::Close, std::span<const std::byte>(payload.data(), n));
495 }
496 fail(code == WsCloseCode::MessageTooBig ? NetError::Overflow : NetError::Protocol, detail);
497 }
498
499 void fail(NetError err, const char* detail) noexcept {
500 if (state_ == WsState::Closed) return;
501 close();
502 handler_.on_ws_error(err, detail);
503 }
504
505 Reactor& reactor_;
506 Stream stream_;
507 Handler& handler_;
508 RecvBuffer rx_;
509 WireBuffer tx_;
510 detail::WsMessageAssembler assembler_;
511 detail::MaskGenerator masks_;
512 WsStats stats_;
513 WsState state_ = WsState::Idle;
514 bool registered_ = false;
515 bool corked_ = false;
516 bool tcp_reported_ = false;
517 bool tls_ = false;
518 std::uint16_t port_ = 0;
519 std::int64_t rx_ts_ = 0;
520 std::array<char, detail::kWsKeyLen> key_{};
521 std::array<char, detail::kWsAcceptLen> expected_accept_{};
522 std::string host_;
523 std::string target_;
524 std::string extra_headers_;
525};
526
527} // namespace fastmm::net
Definition reactor.hpp:72
bool event_hangup() const noexcept
True while a handler runs for an event that also reported a hang-up or an error (RDHUP/HUP/ERR).
Definition reactor.hpp:146
bool add(int fd, IoHandler &handler, IoEvent events) noexcept
fd registration (reactor thread only).
static std::int64_t now_ns() noexcept
CLOCK_MONOTONIC.
bool remove(int fd) noexcept
Definition ws_client.hpp:123
void close() noexcept
Immediate teardown, no callbacks.
Definition ws_client.hpp:205
void on_writable() override
Definition ws_client.hpp:235
WsClient & operator=(const WsClient &)=delete
void on_message(WsOpcode op, std::span< std::byte > payload) noexcept
Definition ws_client.hpp:397
std::size_t pending_send_bytes() const noexcept
Definition ws_client.hpp:214
WsState state() const noexcept
Definition ws_client.hpp:211
bool send_ping(std::span< const std::byte > data={}) noexcept
Definition ws_client.hpp:173
void cork() noexcept
Between cork() and uncork() data frames are only encoded into the send buffer; uncork() writes them t...
Definition ws_client.hpp:184
bool send_text(std::string_view text) noexcept
False means the frame did not go out: the client is not open, the frame did not fit,...
Definition ws_client.hpp:165
void on_error(int err) override
POLLERR (or a poll request the kernel refused).
Definition ws_client.hpp:242
bool send_binary(std::span< const std::byte > data) noexcept
Definition ws_client.hpp:170
void on_readable() override
Definition ws_client.hpp:218
bool send_close(WsCloseCode code=WsCloseCode::Normal, std::string_view reason={}) noexcept
Starts the closing handshake; on_ws_close fires when the peer echoes the close frame.
Definition ws_client.hpp:193
bool is_open() const noexcept
Definition ws_client.hpp:212
void on_control(WsOpcode op, std::span< std::byte > payload) noexcept
Definition ws_client.hpp:407
int fd() const noexcept
Definition ws_client.hpp:216
bool start(std::string_view host, std::uint16_t port, bool tls, std::string_view target, std::string_view extra_headers={})
Registers with the reactor and begins the connect/TLS/upgrade sequence.
Definition ws_client.hpp:143
Stream & stream() noexcept
Definition ws_client.hpp:215
bool uncork() noexcept
Definition ws_client.hpp:185
const WsStats & stats() const noexcept
Definition ws_client.hpp:213
~WsClient() override
Definition ws_client.hpp:137
bool send_pong(std::span< const std::byte > data={}) noexcept
Definition ws_client.hpp:176
Definition reactor.hpp:37
WsProgress
Milestones before the upgrade completes, so owners can report fine-grained states.
Definition ws_client.hpp:35
@ TcpConnected
TCP established (TLS handshake starts next for TLS streams)
@ UpgradeSent
transport ready, HTTP upgrade request written
WsState
Definition ws_client.hpp:74
Single-threaded event loop with a timer min-heap and a thread-safe mailbox (post/wake via eventfd),...
Definition ws_client.hpp:58
bool validate_utf8
reject text messages that are not UTF-8 (close code 1007)
Definition ws_client.hpp:63
std::size_t send_capacity
WireBuffer for outgoing frames.
Definition ws_client.hpp:61
std::size_t max_message_bytes
0 = recv_capacity - kWsMaxHeaderSize
Definition ws_client.hpp:62
std::size_t recv_capacity
RecvBuffer; bounds the largest message.
Definition ws_client.hpp:59
Definition ws_client.hpp:66
std::uint64_t drops
sends refused because the send buffer was full
Definition ws_client.hpp:71
std::uint64_t frames_tx
Definition ws_client.hpp:68
std::uint64_t frames_rx
Definition ws_client.hpp:67
std::uint64_t bytes_tx
Definition ws_client.hpp:70
std::uint64_t bytes_rx
Definition ws_client.hpp:69