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:
| 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:
| # 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
| #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:
| 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
- Is a continuation a second text message?
- Does a Ping's FIN 1 end the text being assembled?
- Can a complete text message end with a zero-byte continuation?
- Must the echo preserve the client's fragment boundaries?
- Why bound the aggregate message and count empty fragments?
- 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.