Skip to content

WebSocket Fragmentation in C++

A WebSocket message can span several frames. This lesson sends one text message as three frames, reassembles the reply, and answers a Ping before the peer sends the final continuation.

Before you start

Complete Extended Payload Lengths. Run commands on the Pi from the examples repository's network/ directory. The new source repeats the direct Linux socket calls, verified HTTP Upgrade and OpenSSL Crypto operations; it needs no shared socket header or new dependency.

Outcome: send a fragmented text, assemble its exact echo only after the final data frame, and reply to an interleaved binary Ping without adding control bytes to the message.

The profile remains one printable-ASCII echo over plaintext loopback, with one correlated client Ping and Close 1000. General UTF-8, binary messages, negotiated extensions, arbitrary Close handling and full protocol-error Close responses are not implemented. This is not wss:// or a production endpoint.

WebSocket fragmentation is not TCP segmentation

TCP reads have no knowledge of WebSocket messages. A read may contain part of a frame or several frames. The previous lesson's pending buffer solves that frame-reading problem.

Fragmentation adds a separate message-assembly problem: concatenate the payloads of successive data frames in order. Their individual lengths are not the message's total length.

Frame in a text message Opcode FIN First byte, with RSV clear
Unfragmented text 1 1 81
First fragment 1 0 01
More fragments 0, continuation 0 00
Last fragment 0, continuation 1 80

An empty continuation is allowed; FIN still decides whether the message ends. Ping/Pong/Close retain their own opcodes and final, short-frame format. They are not text fragments. See RFC 6455 fragmentation.

Follow one message, not three replies

The default client text is 256 bytes. It sends payloads of 85, 85 and 86 bytes. The fixture deliberately echoes a different split: 128, zero and 128 bytes.

sequenceDiagram accTitle: A control frame does not finish a fragmented message accDescr: After Upgrade the client sends its token Ping and three masked data frames. The server replies with a nonfinal text fragment and binary Ping, and waits for Pong before sending the remaining continuations. The matching token Pong does not end the message. Only the final continuation completes the echo, followed by Close and TCP EOF. participant C as C++ client participant S as Local fixture Note over C,S: HTTP Upgrade validated C->>S: Masked Ping with token C->>S: Text, FIN 0, 85 bytes C->>S: Continuation, FIN 0, 85 bytes C->>S: Continuation, FIN 1, 86 bytes S->>C: Text, FIN 0, 128 bytes Note over C: Keep partial message S->>C: Ping, FIN 1, binary payload C->>S: Masked Pong with identical bytes S->>C: Pong matching our token Note over C: Still waiting for final data frame S->>C: Continuation, FIN 0, zero bytes S->>C: Continuation, FIN 1, 128 bytes Note over C: Compare assembled 256-byte echo C->>S: Masked Close 1000 S->>C: Close 1000, then TCP EOF

The fixture waits for the client's Pong before releasing the final continuation. A client that postpones all control work until the message completes would stall here.

The sender's and receiver's fragment boundaries need not match. This client also accepts an unfragmented echo; it checks message content, not a required number of echo frames.

Run the fragmented exchange

Build the new target:

cmake --preset native-debug
cmake --build --preset native-debug --target ws_fragments_client --parallel 2

Terminal A:

timeout 35s python3 fixtures/ws_fragments.py 9008

After LISTEN 9008, terminal B:

./build/native/debug/ws_fragments_client 9008

Expected output:

1
2
3
4
echo_bytes=256
sent_frames=3 received_frames=3
matched_pong=yes pings_replied=1 pongs_ignored=0
close=1000 tcp_eof=yes

The frame counts include data frames only, not Ping, Pong or Close. This fixture always returns three data frames, but another compatible peer can choose different boundaries.

Restart the one-connection fixture for every attempt. The optional second argument is a generated ASCII payload size, 0–65536 bytes, default 256:

1
2
3
4
# Run each command against a freshly started fixture.
./build/native/debug/ws_fragments_client 9008 0
./build/native/debug/ws_fragments_client 9008 1
./build/native/debug/ws_fragments_client 9008 65536

The client always sends three data frames, even for an empty message. Each frame gets a fresh mask, with its XOR index starting at that frame's first payload byte. A final optional session_ms accepts 1–10000 (default 3000).

Use ws_fragments.py, not a previous fixture that expects a single final text frame.

Keep message state separate from control state

The frame reader now returns opcode, final and payload. It checks RSV bits, the supported opcode set, masking, canonical lengths and the existing frame-size policy. Controls must still have FIN set and fit in 125 bytes.

The assembly loop then applies a second set of rules:

Current message state Data frame Action
No message open Text, opcode 1 Append its payload. FIN 0 opens assembly; FIN 1 completes the message.
Message open Continuation, opcode 0 Append. FIN 0 keeps assembly open; FIN 1 completes it.
No message open Continuation Reject: there is nothing to continue.
Message open Another text start Reject: it cannot start a second message inside this one.

Dispatch Ping and Pong before this table. Their FIN bits must not change assembling, and their payloads must not enter assembled. Receiving a matching Pong only updates pong_received.

Even if the accumulated bytes already equal the expected message, FIN 0 means more data frames are still possible. Only a final data frame triggers the exact content comparison and sets echo_received. The session needs both that flag and the correlated Pong before sending Close.

A Close before the expected message completes aborts this reduced exercise and produces no partial-success output. That is a sample limitation, not a claim that a server's early Close is inherently malformed.

Read the standalone C++ implementation

Start with the three outgoing send_frame calls, then assembling, data_frames and the data-frame branch. The full file includes raw socket, connect, poll, send, recv and explicit descriptor cleanup.

Full standalone client: app/ws-fragments/client.cpp
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
#include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <map>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <tuple>
#include <unistd.h>
#include <utility>

int main(int argc, char** argv) {
    int fd = -1;
    int status = 0;
    try {
        if (argc < 2 || argc > 4) {
            throw std::invalid_argument(
                "usage: ws_fragments_client port [payload_bytes [session_ms]]");
        }
        unsigned int port = 0;
        const std::string_view text = argv[1];
        const auto [end, invalid] =
            std::from_chars(text.data(), text.data() + text.size(), port);
        if (invalid != std::errc{} || end != text.data() + text.size() ||
            port == 0 || port > 65535) {
            throw std::invalid_argument("port must be 1..65535");
        }
        constexpr std::uint64_t payload_limit = 65536;
        unsigned int payload_bytes = 256;
        if (argc >= 3) {
            const std::string_view count = argv[2];
            const auto [last, error] = std::from_chars(
                count.data(), count.data() + count.size(), payload_bytes);
            if (error != std::errc{} || last != count.data() + count.size() ||
                payload_bytes > payload_limit) {
                throw std::invalid_argument("payload_bytes must be 0..65536");
            }
        }
        // Predictable printable text, without a huge command-line argument.
        std::string message(payload_bytes, 'x');
        for (std::size_t i = 0; i < message.size(); ++i) {
            message[i] = static_cast<char>('a' + i % 26);
        }
        int session_ms = 3000;
        if (argc == 4) {
            const std::string_view budget = argv[3];
            const auto [last, error] = std::from_chars(
                budget.data(), budget.data() + budget.size(), session_ms);
            if (error != std::errc{} || last != budget.data() + budget.size() ||
                session_ms < 1 || session_ms > 10000) {
                throw std::invalid_argument("session_ms must be 1..10000");
            }
        }
        std::array<unsigned char, 16> nonce{};
        std::array<unsigned char, 25> encoded_key{};
        if (RAND_bytes(nonce.data(), nonce.size()) != 1 ||
            EVP_EncodeBlock(encoded_key.data(), nonce.data(), nonce.size()) !=
                24) {
            throw std::runtime_error("cannot generate WebSocket key");
        }
        const std::string key(reinterpret_cast<const char*>(encoded_key.data()),
                              24);
        const std::string challenge =
            key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
        std::array<unsigned char, EVP_MAX_MD_SIZE> digest{};
        std::size_t digest_size = 0;
        std::array<unsigned char, 29> encoded_accept{};
        if (EVP_Q_digest(nullptr, "SHA1", nullptr, challenge.data(),
                         challenge.size(), digest.data(), &digest_size) != 1 ||
            digest_size != 20 ||
            EVP_EncodeBlock(encoded_accept.data(), digest.data(), 20) != 28) {
            throw std::runtime_error("cannot compute WebSocket accept value");
        }
        const std::string expected_accept(
            reinterpret_cast<const char*>(encoded_accept.data()), 28);
        fd = ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
        if (fd < 0) {
            throw std::system_error(errno, std::generic_category(), "socket");
        }
        const auto deadline = std::chrono::steady_clock::now() +
                              std::chrono::milliseconds(session_ms);
        const auto check_deadline = [&] {
            if (std::chrono::steady_clock::now() >= deadline) {
                throw std::runtime_error("session deadline expired");
            }
        };
        const auto wait_for = [&](short events) {
            for (;;) {
                check_deadline();
                const auto remaining =
                    std::chrono::ceil<std::chrono::milliseconds>(
                        deadline - std::chrono::steady_clock::now())
                        .count();
                if (remaining <= 0) {
                    throw std::runtime_error("session deadline expired");
                }
                pollfd ready{fd, events, 0};
                const int result =
                    ::poll(&ready, 1, static_cast<int>(remaining));
                if (result < 0 && errno == EINTR) {
                    continue;
                }
                if (result < 0) {
                    throw std::system_error(errno, std::generic_category(),
                                            "poll");
                }
                check_deadline();
                if (result > 0) {
                    return; // The next socket operation inspects EOF/errors.
                }
            }
        };
        const auto send_bytes = [&](std::string_view bytes) {
            std::size_t offset = 0;
            while (offset < bytes.size()) {
                check_deadline();
                const auto sent = ::send(fd, bytes.data() + offset,
                                         bytes.size() - offset, MSG_NOSIGNAL);
                if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK ||
                                 errno == EINTR)) {
                    wait_for(POLLOUT);
                    continue;
                }
                if (sent <= 0) {
                    throw std::runtime_error("send failed");
                }
                offset += static_cast<std::size_t>(sent);
            }
        };
        sockaddr_in server{};
        server.sin_family = AF_INET;
        server.sin_port = htons(static_cast<std::uint16_t>(port));
        server.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        if (::connect(fd, reinterpret_cast<const sockaddr*>(&server),
                      sizeof(server)) < 0) {
            if (errno != EINPROGRESS) {
                throw std::system_error(errno, std::generic_category(),
                                        "connect");
            }
            wait_for(POLLOUT);
            int socket_error = 0;
            socklen_t size = sizeof(socket_error);
            if (::getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &size) <
                0) {
                throw std::system_error(errno, std::generic_category(),
                                        "getsockopt");
            }
            if (socket_error != 0) {
                throw std::system_error(socket_error, std::generic_category(),
                                        "connect");
            }
        }
        const std::string request =
            "GET /ws HTTP/1.1\r\nHost: localhost:" + std::to_string(port) +
            "\r\nUpgrade: websocket\r\nConnection: "
            "Upgrade\r\nSec-WebSocket-Key: " +
            key + "\r\nSec-WebSocket-Version: 13\r\n\r\n";
        send_bytes(request);
        // Do not shutdown(SHUT_WR): this same connection would carry frames.
        std::string response;
        std::array<char, 64> buffer{};
        std::size_t boundary = std::string::npos;
        while (boundary == std::string::npos) {
            check_deadline();
            const auto count = ::recv(fd, buffer.data(), buffer.size(), 0);
            if (count < 0 &&
                (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) {
                wait_for(POLLIN);
                continue;
            }
            if (count <= 0) {
                throw std::runtime_error(
                    "handshake ended before complete headers");
            }
            response.append(buffer.data(), static_cast<std::size_t>(count));
            boundary = response.find("\r\n\r\n");
            if ((boundary == std::string::npos && response.size() > 4096) ||
                (boundary != std::string::npos && boundary + 4 > 4096)) {
                throw std::runtime_error("handshake headers exceed 4096 bytes");
            }
        }
        const auto trim = [](std::string_view value) {
            while (!value.empty() &&
                   (value.front() == ' ' || value.front() == '\t')) {
                value.remove_prefix(1);
            }
            while (!value.empty() &&
                   (value.back() == ' ' || value.back() == '\t')) {
                value.remove_suffix(1);
            }
            return value;
        };
        const auto lower = [](std::string value) {
            for (char& c : value) {
                if (c >= 'A' && c <= 'Z') {
                    c += 'a' - 'A';
                }
            }
            return value;
        };
        const auto token_char = [](unsigned char c) {
            return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
                   (c >= '0' && c <= '9') ||
                   std::string_view("!#$%&'*+-.^_`|~").find(c) !=
                       std::string_view::npos;
        };
        const auto first_end = response.find("\r\n");
        const std::string_view status_line(response.data(), first_end);
        if (status_line.size() < 13 ||
            !status_line.starts_with("HTTP/1.1 101 ") ||
            !std::all_of(status_line.begin(), status_line.end(),
                         [](unsigned char c) {
                             return c == '\t' || (c >= 32 && c < 127);
                         })) {
            throw std::runtime_error("expected HTTP/1.1 status 101");
        }
        std::map<std::string, std::string> fields;
        std::size_t cursor = first_end + 2;
        while (cursor < boundary) {
            const auto line_end = response.find("\r\n", cursor);
            const std::string_view line(response.data() + cursor,
                                        line_end - cursor);
            const auto colon = line.find(':');
            if (colon == std::string_view::npos || colon == 0 ||
                !std::all_of(line.begin(), line.begin() + colon, token_char)) {
                throw std::runtime_error("invalid handshake header name");
            }
            const std::string name = lower(std::string(line.substr(0, colon)));
            const auto raw_value = line.substr(colon + 1);
            if (!std::all_of(raw_value.begin(), raw_value.end(),
                             [](unsigned char c) {
                                 return c == '\t' || (c >= 32 && c < 127);
                             })) {
                throw std::runtime_error("invalid handshake header value");
            }
            const auto value = trim(raw_value);
            if (name == "sec-websocket-protocol" ||
                name == "sec-websocket-extensions" ||
                name == "content-length" || name == "transfer-encoding") {
                throw std::runtime_error(
                    "unexpected negotiation or HTTP body framing");
            }
            if (name == "upgrade" || name == "connection" ||
                name == "sec-websocket-accept") {
                if (!fields.emplace(name, value).second) {
                    throw std::runtime_error("duplicate handshake field");
                }
            }
            cursor = line_end + 2;
        }
        if (!fields.contains("upgrade") || !fields.contains("connection") ||
            !fields.contains("sec-websocket-accept") ||
            lower(fields.at("upgrade")) != "websocket" ||
            fields.at("sec-websocket-accept") != expected_accept) {
            throw std::runtime_error(
                "invalid WebSocket upgrade or accept value");
        }
        bool upgrade_token = false;
        std::string_view options = fields.at("connection");
        for (;;) {
            const auto comma = options.find(',');
            const auto option = trim(options.substr(0, comma));
            if (option.empty() ||
                !std::all_of(option.begin(), option.end(), token_char)) {
                throw std::runtime_error("invalid Connection token list");
            }
            upgrade_token |= lower(std::string(option)) == "upgrade";
            if (comma == std::string_view::npos) {
                break;
            }
            options.remove_prefix(comma + 1);
        }
        if (!upgrade_token) {
            throw std::runtime_error("Connection lacks Upgrade token");
        }
        check_deadline();
        // Start with bytes received alongside the HTTP header terminator.
        std::string pending = response.substr(boundary + 4);
        const auto send_frame = [&](unsigned char opcode,
                                    std::string_view payload,
                                    bool final = true) {
            if (payload.size() > payload_limit ||
                (opcode >= 0x8 && (payload.size() > 125 || !final))) {
                throw std::runtime_error("outgoing frame exceeds profile");
            }
            std::array<unsigned char, 4> mask{};
            if (RAND_bytes(mask.data(), mask.size()) != 1) {
                throw std::runtime_error("cannot generate frame mask");
            }
            std::string frame;
            frame.push_back(static_cast<char>((final ? 0x80 : 0) | opcode));
            const std::uint64_t size = payload.size();
            if (size <= 125) {
                frame.push_back(static_cast<char>(0x80 | size));
            } else {
                const int bytes = size <= 65535 ? 2 : 8;
                frame.push_back(
                    static_cast<char>(0x80 | (bytes == 2 ? 126 : 127)));
                for (int shift = (bytes - 1) * 8; shift >= 0; shift -= 8) {
                    frame.push_back(static_cast<char>((size >> shift) & 0xff));
                }
            }
            frame.append(reinterpret_cast<const char*>(mask.data()),
                         mask.size());
            for (std::size_t index = 0; index < payload.size(); ++index) {
                frame.push_back(static_cast<char>(
                    static_cast<unsigned char>(payload[index]) ^
                    mask[index % 4]));
            }
            send_bytes(frame);
        };
        const auto read_more = [&] {
            for (;;) {
                check_deadline();
                const auto count = ::recv(fd, buffer.data(), buffer.size(), 0);
                if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK ||
                                  errno == EINTR)) {
                    wait_for(POLLIN);
                    continue;
                }
                if (count <= 0) {
                    throw std::runtime_error(
                        "TCP ended before a complete frame");
                }
                pending.append(buffer.data(), static_cast<std::size_t>(count));
                if (pending.size() > payload_limit + 10 + buffer.size()) {
                    throw std::runtime_error(
                        "frame buffer exceeds bounded read-ahead");
                }
                return;
            }
        };
        const auto read_frame = [&] {
            while (pending.size() < 2) {
                read_more();
            }
            const auto first = static_cast<unsigned char>(pending[0]);
            const auto second = static_cast<unsigned char>(pending[1]);
            const unsigned char opcode = first & 15;
            const bool final = (first & 0x80) != 0;
            if ((first & 0x70) != 0 ||
                (opcode != 0 && opcode != 1 && opcode != 8 && opcode != 9 &&
                 opcode != 10)) {
                throw std::runtime_error("unsupported opcode or reserved bits");
            }
            if (opcode >= 8 && !final) {
                throw std::runtime_error("control frames cannot be fragmented");
            }
            const unsigned char marker = second & 0x7f;
            if ((second & 0x80) != 0) {
                throw std::runtime_error("server frames must not be masked");
            }
            if ((first & 0x08) != 0 && marker > 125) {
                throw std::runtime_error(
                    "control payload must be at most 125 bytes");
            }
            const std::size_t extended = marker == 126   ? 2
                                         : marker == 127 ? 8
                                                         : 0;
            const std::size_t header = 2 + extended;
            while (pending.size() < header) {
                read_more();
            }
            std::uint64_t advertised = marker;
            if (extended != 0) {
                if (extended == 8 &&
                    (static_cast<unsigned char>(pending[2]) & 0x80) != 0) {
                    throw std::runtime_error(
                        "64-bit length has its high bit set");
                }
                advertised = 0;
                for (std::size_t i = 2; i < header; ++i) {
                    advertised = (advertised << 8) |
                                 static_cast<unsigned char>(pending[i]);
                }
                if ((extended == 2 && advertised < 126) ||
                    (extended == 8 && advertised < 65536)) {
                    throw std::runtime_error("nonminimal payload length");
                }
            }
            // Validate before narrowing, adding, allocating or waiting for
            // body.
            if (advertised > payload_limit) {
                throw std::runtime_error("payload exceeds 65536-byte policy");
            }
            const auto size = static_cast<std::size_t>(advertised);
            while (pending.size() < header + size) {
                read_more();
            }
            std::string payload = pending.substr(header, size);
            pending.erase(0, header + size);
            return std::tuple{opcode, final, payload};
        };
        std::array<unsigned char, 8> token_bytes{};
        if (RAND_bytes(token_bytes.data(), token_bytes.size()) != 1) {
            throw std::runtime_error("cannot generate Ping token");
        }
        const std::string ping_token(
            reinterpret_cast<const char*>(token_bytes.data()),
            token_bytes.size());
        bool pong_received = false;
        unsigned int ping_pong_count = 0;
        unsigned int pings_replied = 0;
        unsigned int pongs_ignored = 0;
        const auto handle_control = [&](unsigned char opcode,
                                        const std::string& payload) {
            if (opcode != 0x9 && opcode != 0xa) {
                return false;
            }
            if (++ping_pong_count > 32) {
                throw std::runtime_error("Ping/Pong frame budget exceeded");
            }
            if (opcode == 0x9) {
                // Preserve arbitrary bytes, including NUL, and mask our reply.
                send_frame(0xa, payload);
                ++pings_replied;
            } else if (!pong_received && payload == ping_token) {
                pong_received = true;
            } else {
                // A Pong can be unsolicited. Never reply to it with another
                // Pong.
                ++pongs_ignored;
            }
            return true;
        };
        send_frame(0x9, ping_token);
        // One text message, three independently masked frames.
        const std::string_view outgoing = message;
        const auto third = outgoing.size() / 3;
        send_frame(0x1, outgoing.substr(0, third), false);
        send_frame(0x0, outgoing.substr(third, third), false);
        send_frame(0x0, outgoing.substr(third * 2), true);
        bool echo_received = false;
        bool assembling = false;
        unsigned int data_frames = 0;
        std::string assembled;
        while (!echo_received || !pong_received) {
            const auto [opcode, final, payload] = read_frame();
            if (handle_control(opcode, payload)) {
                continue; // Control FIN does not finish a data message.
            }
            if (opcode >= 8 || echo_received) {
                throw std::runtime_error(
                    "expected one text message before Close");
            }
            if ((opcode == 0 && !assembling) || (opcode == 1 && assembling)) {
                throw std::runtime_error("invalid continuation state");
            }
            if (++data_frames > 64) {
                throw std::runtime_error("data-frame budget exceeded");
            }
            if (payload.size() > payload_limit - assembled.size()) {
                throw std::runtime_error("message exceeds 65536-byte policy");
            }
            assembled.append(payload); // Append data, never control payloads.
            assembling = !final;
            if (final) {
                if (assembled != message) {
                    throw std::runtime_error(
                        "expected exact ASCII message echo");
                }
                echo_received = true;
            }
        }
        const std::string normal_close{"\x03\xe8", 2};
        send_frame(0x8, normal_close);
        for (;;) {
            const auto [opcode, final, payload] = read_frame();
            // We sent Close but have not received it: Ping still needs Pong.
            if (handle_control(opcode, payload)) {
                continue;
            }
            if (opcode != 0x8 || !final || payload != normal_close) {
                throw std::runtime_error(
                    "expected Close with code 1000 and no reason");
            }
            break;
        }
        if (!pending.empty()) {
            throw std::runtime_error("data after Close");
        }
        // This teaching client additionally waits for the server's TCP EOF.
        for (;;) {
            check_deadline();
            char extra = 0;
            const auto count = ::recv(fd, &extra, 1, 0);
            if (count < 0 &&
                (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) {
                wait_for(POLLIN);
                continue;
            }
            if (count < 0) {
                throw std::system_error(errno, std::generic_category(),
                                        "recv after Close");
            }
            if (count > 0) {
                throw std::runtime_error("data after Close");
            }
            break;
        }
        std::cout << "echo_bytes=" << assembled.size() << '\n'
                  << "sent_frames=3 received_frames=" << data_frames << '\n'
                  << "matched_pong=yes pings_replied=" << pings_replied
                  << " pongs_ignored=" << pongs_ignored
                  << "\nclose=1000 tcp_eof=yes\n";
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        status = 1;
    }
    if (fd >= 0) {
        ::close(fd);
    }
    return status;
}

Compile the source directly

From the same network/ directory:

1
2
3
4
mkdir -p build/direct-ws-fragments
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
    app/ws-fragments/client.cpp -o build/direct-ws-fragments/ws_fragments_client \
    $(pkg-config --cflags --libs libcrypto)

Run ./build/direct-ws-fragments/ws_fragments_client 9008 against a fresh fixture. OpenSSL supplies randomness and the Upgrade hash; the socket itself is still plaintext.

Bound a message as well as each frame

Resource This sample's policy
One incoming data frame At most 65,536 payload bytes
Assembled message At most 65,536 payload bytes in total
Incoming data frames At most 64, including empty fragments
Incoming Ping/Pong frames At most 32 across echo and Close waits
Pending frame bytes Logical cap of 65,610 including header/read-ahead
Whole socket session One fixed deadline, default 3000 ms

A frame-size check alone is insufficient: two individually acceptable fragments can form an oversized message. Before appending, check payload.size() > payload_limit - assembled.size(). Subtraction avoids first forming a potentially overflowing sum, given that the accumulated size is already bounded.

This aggregate check occurs after the bounded frame has been read but before appending it. The earlier advertised-frame-size check still happens before waiting for that frame's body. Neither check is a claim about total process memory: outgoing data, pending storage, payload copies and library allocations also exist.

Empty nonfinal continuations consume no payload budget but still consume frame-count and time budgets. The 64-frame cap is a local policy, not a protocol limit. New bytes, fragments and successful Pong replies never renew the original deadline.

Fragmentation does not automatically save memory here: the example deliberately stores the complete message for an exact comparison. A streaming application would need a different delivery contract. This finite send-then-receive exercise is not a general concurrent WebSocket event loop.

Verify continuation state and interleaved Ping

No manual fixture is needed for the automated checks:

ctest --preset native-debug -R network.ws_fragments --output-on-failure

Independent Python peers require the three outgoing masked data frames and their exact patterned payloads. They return single, two-part, three-part and 64-part messages; include empty first/middle/last fragments; split frame bytes; combine frames; retain controls alongside Upgrade; and require Pong before releasing the final continuation.

Checks also cover an exact 64 KiB message, a real eight-byte incoming frame length, aggregate overflow across individually bounded frames, the 65th data frame, orphan continuations, a new text start while assembling, fragmented controls, unsupported flags/opcodes, truncated frames, Close/EOF mid-message, wrong content/order and duplicate messages.

Separate waits leave all expected bytes in a nonfinal message or send slow empty continuations: neither may be mistaken for completion or reset the deadline. These checks exercise real loopback sockets, not induced packet loss, throughput or full protocol conformance.

Verification scope

Verified on 23 October 2026 on a Raspberry Pi 4B running 64-bit Raspberry Pi OS (Debian 13), plus a Linux x86-64 host. All 484 course checks passed in Debug and Release on both machines; 61 exercise this lesson. Twenty-five representative direct-build cases also passed on both. The Pi reused isolated official OpenSSL headers and existing runtime libraries without system package changes. Testing covered loopback behavior, not LAN performance.

Troubleshooting

  • invalid continuation state: start a text message with opcode 1; subsequent fragments use opcode 0. Do not start another message before FIN.
  • Full text arrived but the client waits: inspect FIN on the data frames. Matching bytes without a final fragment are not yet a complete message.
  • Ping causes a stall: dispatch it while assembly is open. Waiting until the final continuation can deadlock an exchange like this fixture.
  • message exceeds 65536-byte policy: inspect the combined payload, not only each frame's length.
  • data-frame budget exceeded: empty fragments count too. Raising the time budget does not change this policy.
  • control frames cannot be fragmented: Ping/Pong/Close must remain final frames.
  • Refusal or incompatible server: restart ws_fragments.py on the same port for each run.

Check your understanding

  1. Is a continuation a second text message?
  2. Does a Ping's FIN 1 end the text being assembled?
  3. Can a complete text message end with a zero-byte continuation?
  4. Must the echo preserve the client's fragment boundaries?
  5. Why bound the aggregate message and count empty fragments?
  6. Is receiving all expected bytes with FIN 0 sufficient to report success?
Answers

A continuation belongs to the currently open message. A control frame's FIN is independent of that message. An empty final continuation can end it. Echo fragment boundaries may differ; message bytes must match. Aggregate size prevents many bounded frames from growing the message indefinitely, while frame count bounds zero-byte work. FIN 0 does not finish the message, even when its current bytes happen to match.

Continue the course

Previous: Extended Payload Lengths · Course overview

Next: WebSocket over Verified TLS — carry the same frame/message distinction over an authenticated encrypted connection.

References: Fragmentation rules, Receiving data, Control frames.

Donate