Skip to content

WebSocket Text Frames and Clean Closure in C++

The connection is upgraded; now send actual WebSocket bytes. Build a C++ program on Raspberry Pi that sends one masked text frame, reads its echo and exchanges Close frames before closing TCP.

Course examples on GitHub

Standalone C++ client · Echo fixture · Build instructions.

Before you start

Complete Opening Handshake. Run commands on the Pi from the examples repository's network/ directory, using the same OpenSSL development dependency.

Outcome: complete a short text echo with client masking, then verify Close code 1000 and the server's TCP EOF.

A deliberately reduced session

This lesson supports one final text frame containing 0–125 printable ASCII bytes, followed by Close 1000 with no reason. Other valid WebSocket features, including Ping/Pong, fragmentation, binary messages and longer payloads, are not implemented yet. Unexpected input aborts TCP rather than implementing every protocol-error Close response. This is not a general or production WebSocket client.

The connection is plaintext ws:// on loopback. Masking is not encryption; wss:// still requires verified TLS.

Follow the complete short exchange

sequenceDiagram accTitle: Text echo and WebSocket Close happen before TCP ends accDescr: After the verified HTTP Upgrade, the client sends a masked final text frame. The server replies with an unmasked final text frame. The client sends masked Close 1000, receives unmasked Close 1000, waits for server TCP EOF and closes its descriptor. participant C as C++ client participant S as Local fixture C->>S: HTTP Upgrade request S->>C: Validated 101 response C->>S: Final text, MASK=1 S->>C: Echo text, MASK=0 C->>S: Close 1000, MASK=1 S->>C: Close 1000, MASK=0 S-->>C: TCP EOF Note over C: Close descriptor and report success

The first text frame has FIN=1, but that does not end the connection. Here FIN marks the final frame of the message; the later Close opcode starts WebSocket shutdown. Neither is the TCP FIN flag.

Build and run

Build:

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

Terminal A:

timeout 35s python3 fixtures/ws_text.py 9005

It prints LISTEN 9005. In terminal B:

./build/native/debug/ws_text_client 9005

Expected client output:

1
2
3
echo_bytes=16
Hello WebSocket!
close=1000 tcp_eof=yes

The server reports close=1000 and exits after one connection. This fixture exchanges frames; it is not the previous handshake-only fixture. Restart it before each attempt.

Try another message, then an empty message in separate runs:

./build/native/debug/ws_text_client 9005 "Pi reading: 42"
./build/native/debug/ws_text_client 9005 ""

For the empty message, expect echo_bytes=0, a blank line and the same successful Close result. A zero-length WebSocket frame is still a frame, not TCP EOF.

Inspect a short masked frame

flowchart LR accTitle: Short client text frame in wire order accDescr: Byte zero combines FIN and the text opcode into hexadecimal 81. Byte one combines the mask bit with payload length N. Four random mask bytes precede N XOR-transformed payload bytes. The length excludes the mask. A["Byte 0: 81"] --> B["Byte 1: 80 + N"] B --> C["Mask: 4 bytes"] C --> D["Payload: N bytes"]

The displayed byte values are hexadecimal; N is the payload byte count, at most 125 in this lesson.

Field Client text Server text
FIN / opcode 81: final text frame. 81 for the final echo.
MASK / length 80 + N, then a four-byte key. N alone; no mask key.
Payload Each byte XORed with the corresponding key byte. Plain payload bytes inside this plaintext TCP stream.

The transformation is wire[i] = payload[i] XOR mask[i % 4]. The receiver applies the same XOR to recover the original byte. The payload length excludes the key's four bytes.

For example, with illustration-only key 12 34 56 78, ASCII Hi (48 69) becomes 5a 5d:

81 82 | 12 34 56 78 | 5a 5d
header     mask       payload

Never reuse that fixed key in the implementation. The client calls RAND_bytes() separately for each outgoing frame, including Close. Even an empty payload still carries the client mask bit and its four-byte key.

Keep reads independent from frames

A single TCP read might contain half a frame, one frame, or bytes from several frames. The program keeps a pending buffer:

  1. Start with bytes already read after the HTTP header terminator.
  2. Accumulate enough bytes for the two-byte frame header.
  3. Check flags and the short length before waiting for the payload.
  4. Accumulate exactly enough for this frame, extract its payload and retain any following bytes.

This parser accepts only final, RSV-clear, unmasked server text/Close frames using the short length form. An incoming MASK bit or length marker 126/127 is rejected immediately rather than trusted as a length to allocate.

The pending buffer is capped at 256 bytes. The frame payload cap is 125; the two limits serve different purposes. The ASCII echo comparison also ensures that the returned text matches the valid ASCII we sent. There is no general UTF-8 validator or message reassembler here.

Read the standalone C++ client

The first part repeats the previous handshake so the source remains independently compilable. Concentrate on send_frame, read_more, read_frame, and the final text/Close sequence.

These local helpers still show the actual send(), recv(), poll(), buffer offsets and explicit descriptor cleanup. There are no project-specific includes, socket classes or hidden WebSocket APIs.

Full standalone client: app/ws-text/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 <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_text_client port [message [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");
        }
        const std::string_view message =
            argc >= 3 ? argv[2] : "Hello WebSocket!";
        if (message.size() > 125 ||
            !std::all_of(message.begin(), message.end(),
                         [](unsigned char c) { return c >= 32 && c < 127; })) {
            throw std::invalid_argument(
                "message must contain 0..125 printable ASCII bytes");
        }
        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) {
            if (payload.size() > 125) {
                throw std::runtime_error(
                    "outgoing frame exceeds short-frame 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>(0x80 | opcode));
            frame.push_back(static_cast<char>(0x80 | payload.size()));
            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() > 256) {
                    throw std::runtime_error("frame buffer exceeds 256 bytes");
                }
                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]);
            if (first != 0x81 && first != 0x88) {
                throw std::runtime_error(
                    "expected final text or Close with RSV bits clear");
            }
            if ((second & 0x80) != 0 || second > 125) {
                throw std::runtime_error(
                    "expected an unmasked short server frame");
            }
            const std::size_t size = second;
            while (pending.size() < 2 + size) {
                read_more();
            }
            std::string payload = pending.substr(2, size);
            pending.erase(0, 2 + size);
            return std::pair{static_cast<unsigned char>(first & 15), payload};
        };
        send_frame(0x1, message);
        const auto [echo_opcode, echo] = read_frame();
        if (echo_opcode != 0x1 || echo != message) {
            throw std::runtime_error("expected the exact ASCII text echo");
        }
        // Close code 1000 is two bytes in network order, not the text "1000".
        const std::string normal_close{"\x03\xe8", 2};
        send_frame(0x8, normal_close);
        const auto [close_opcode, close_payload] = read_frame();
        if (close_opcode != 0x8 || close_payload != normal_close) {
            throw std::runtime_error(
                "expected Close with code 1000 and no reason");
        }
        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=" << echo.size() << '\n'
                  << echo << "\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;
}

Or compile directly:

1
2
3
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
    app/ws-text/client.cpp -o /tmp/ws_text_client \
    $(pkg-config --cflags --libs libcrypto)

Finish WebSocket before closing TCP

The client's Close payload is the two-byte network-order value 03 e8, meaning 1000, not the four ASCII characters 1000.

Client: 88 82 | four fresh mask bytes | two transformed payload bytes
Server: 88 02 | 03 e8

After sending Close, this program sends no more application data. It requires the server's Close to contain exactly code 1000 without a reason, then waits for TCP EOF within the session budget.

That final TCP wait is an explicit sample policy: if the Close exchange succeeds but the server does not end TCP in time, the command still fails. Do not confuse that with failing to receive a Close frame.

The default 3000 ms deadline covers TCP connect, Upgrade, text echo, Close exchange and TCP cleanup after local nonce/hash setup. An optional last argument accepts 1–10000 ms. Progress never renews it. Successful output is withheld until the complete sequence finishes.

Check the actual wire behavior

Without starting a manual fixture, run:

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

The independent Python peers inspect client header bits and mask keys, decode the exact payload by XOR, and require a masked Close containing 03 e8. Checks cover empty/125-byte messages, split input, echo/Close arriving together, text arriving with Upgrade headers, malformed or unsupported frames, incomplete replies, missing Close, extra data and fixed deadlines.

A four-session check guards against constant keys/masks. It does not require every random four-byte mask to be unique, and is not an entropy audit. Tests also ensure a rejected handshake sends no text frame.

Troubleshooting

  • TCP ended before a complete frame: compare announced length with received bytes; TCP EOF cannot complete a missing frame body.
  • Expected an unmasked short server frame: distinguish a protocol error (server masking) from a valid feature outside this lesson (extended lengths).
  • Expected final text or Close: Ping/Pong, binary and fragmented messages are outside this reduced parser. That does not make those WebSocket features invalid.
  • Expected exact ASCII text echo: use this fixture, not a server sending an unrelated greeting or a binary response.
  • Expected Close 1000: the server may have closed TCP directly or used another Close code/reason. The sample intentionally accepts only its documented path.
  • Session deadline after Close: inspect whether the server replied with Close but kept TCP open.
  • Message rejected before connecting: use 0–125 printable ASCII bytes. WebSocket generally supports UTF-8 text; this exercise intentionally starts with an ASCII subset.

Check your understanding

  1. Does FIN=1 on a text frame close the connection?
  2. Does a length of five include the four-byte mask?
  3. Is an empty masked frame the same as TCP EOF?
  4. Why should frame parsing consume the pending Upgrade prefix first?
  5. What is the difference between receiving Close and observing TCP EOF?
Answers

FIN ends the message's frame sequence, not the connection. The length counts payload only. An empty frame still has its header and client mask. Ignoring already-read prefix bytes would lose part of the stream. Close is a WebSocket protocol message; EOF is a later transport observation. This client verifies both before reporting success.

Continue the course

Previous: Opening Handshake · Next: Ping and Pong

References: WebSocket frame layout and masking, WebSocket closing handshake.

Donate