Handle WebSocket Ping and Pong in C++
A Ping can arrive while you are waiting for a text reply. Respond to it without mistaking a control frame for the application's answer, and check that a Pong actually matches your own Ping.
Before you start
Complete Text Frames and Clean Closure. Run commands on the Pi from the examples repository's network/ directory. This lesson adds control handling to the same short-frame, plaintext loopback profile; it does not add TLS or another dependency.
Outcome: match an eight-byte Ping token, reply to binary server Pings, ignore unrelated Pongs and complete the text/Close exchange without extending its deadline.
The text is still restricted to 0–125 printable ASCII bytes. Ping/Pong payloads are arbitrary bytes, not necessarily text. Fragmentation, extended data lengths, general UTF-8, other Close payloads and full protocol-error handling remain outside this reduced client.
Follow Ping, Pong and text on one connection
sequenceDiagram
accTitle: A Pong is not the text echo
accDescr: After Upgrade, the client sends a token Ping and text. It ignores an unsolicited Pong, responds to a server Ping, then receives its matching Pong and text echo. A second server Ping is already in flight when the client sends Close, so it is answered in the closing loop before receiving server 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 random token
C->>S: Masked text
S->>C: Unsolicited Pong
Note over C: Ignore, do not send another Pong
S->>C: Ping with binary payload
C->>S: Masked Pong with identical payload
S->>C: Pong with our original token
S->>C: Text echo
S-->>C: Second Ping already in flight
Note over C: Its bytes can wait in the pending buffer
C->>S: Masked Close 1000
C->>S: Masked Pong for second Ping
S->>C: Close 1000, then TCP EOF
The two directions have independent Ping payloads. Reply to the peer's Ping with its bytes; match the Pong for our Ping against our own saved token.
Run the control-aware client
Build:
| cmake --preset native-debug
cmake --build --preset native-debug --target ws_control_client --parallel 2
|
Terminal A:
| timeout 35s python3 fixtures/ws_control.py 9006
|
It prints LISTEN 9006. In terminal B:
| ./build/native/debug/ws_control_client 9006
|
Expected output against this fixture:
| echo_bytes=16
Hello WebSocket!
matched_pong=yes pings_replied=2 pongs_ignored=1
close=1000 tcp_eof=yes
|
The two replies answer the server's Pings. The ignored Pong contains notice, not our outstanding token. These counts describe the fixture's scripted exchange, not guaranteed counts for every server.
Use the new fixture, not the text-only server: the previous lesson's server does not implement this Ping-first exchange. Restart the one-connection fixture for each attempt. An optional message and final session budget have the same syntax as the preceding client.
WebSocket Ping/Pong rules and local policy
| Received frame |
What this program does |
| Ping, opcode 9 |
Send a freshly masked Pong containing exactly those bytes. |
| Pong, opcode A |
Mark our Ping answered only if the full payload matches the outstanding token. Otherwise count and ignore it. |
| Text, opcode 1 |
Require one exact echo, independently of Pong arrival order. |
| Close, opcode 8 |
Accept the restricted Close 1000 reply in the closing phase. |
A Pong can be unsolicited. Never answer a Pong with another Pong: that would create needless feedback. A matching Pong is evidence that the peer processed this control exchange; it does not prove the health of an application, sensor or future connection.
The eight-byte token is generated with OpenSSL RAND_bytes(). Compare its complete binary value, including any zero bytes. There is only one outstanding client Ping here, so no token-to-request table is needed.
Keep binary payloads intact
The server's first Ping includes NUL and a non-UTF-8 byte. The payload remains a length-aware std::string; it is not passed through a C-string terminator, text decoder or case conversion.
The client reuses send_frame(0xa, payload), including a new four-byte random mask. Client Pong frames need masking just as client text and Close frames do. The mask does not encrypt those bytes; this remains ws://, not wss://.
Dispatch before deciding whether the wait is finished
flowchart TD
accTitle: One frame reader, separate control and application decisions
accDescr: Complete frames are dispatched by opcode. Ping sends Pong, Pong updates correlation or is ignored, and text validates the echo. These paths continue the current wait. Close validates the closing phase and leads to transport cleanup.
F[Read a complete frame] --> K{Opcode}
K -->|9 Ping| P[Send identical Pong]
K -->|A Pong| O[Match token or ignore]
K -->|1 Text| T[Validate one echo]
K -->|8 Close| C[Validate closing state]
P --> W[Continue current wait]
O --> W
T --> W
C --> D[Finish TCP cleanup]
The first loop finishes only when both echo_received and pong_received are true. The echo may arrive before or after the matching Pong. Receiving a Ping, an unrelated Pong or only the echo does not satisfy both conditions.
The same control handler is used while waiting for the peer's Close. This avoids adding a second parser or assuming that the next received frame must be the expected application response.
Read the standalone C++ implementation
The Upgrade validation, mask encoding, pending buffer and raw socket calls repeat the earlier source so this file remains independently compilable.
Focus on handle_control, the two independent completion flags, and the Close-wait loop. The C++ file uses no project-specific headers or socket wrappers.
Full standalone client: app/ws-control/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_control_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 && first != 0x89 &&
first != 0x8a) {
throw std::runtime_error(
"expected final text/Close/Ping/Pong 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};
};
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);
send_frame(0x1, message);
bool echo_received = false;
std::string echo;
while (!echo_received || !pong_received) {
const auto [opcode, payload] = read_frame();
if (handle_control(opcode, payload)) {
continue;
}
if (opcode != 0x1 || echo_received || payload != message) {
throw std::runtime_error(
"expected one exact ASCII text echo before Close");
}
echo = payload;
echo_received = true;
}
const std::string normal_close{"\x03\xe8", 2};
send_frame(0x8, normal_close);
for (;;) {
const auto [opcode, payload] = read_frame();
// We sent Close but have not received it: Ping still needs Pong.
if (handle_control(opcode, payload)) {
continue;
}
if (opcode != 0x8 || 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=" << echo.size() << '\n'
<< echo
<< "\nmatched_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 directly if preferred:
| c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
app/ws-control/client.cpp -o /tmp/ws_control_client \
$(pkg-config --cflags --libs libcrypto)
|
Handle a Ping crossing our Close
Sending Close prohibits further data frames. It does not mean the peer's Close has already been received.
In the fixture, the second server Ping is sent before the server reads our Close. Its bytes can already be buffered when our application moves into the closing loop. The client answers it, then accepts the peer's Close. Once that Close is received, the program stops frame handling and finishes its bounded TCP cleanup.
This is a crossing-control example, not a recommendation that a server delay its Close response by starting new work. Earlier-than-expected Close and other Close payloads still fall outside this deliberately restricted application path.
Bound time and work separately
One 3000 ms default session deadline covers connect, Upgrade, Ping/text, Close and server TCP EOF after local nonce/hash setup. The final optional argument accepts 1–10000 ms. Successful traffic does not renew it.
A second local limit permits at most 32 received Ping/Pong frames across both waiting phases. It counts matching and ignored Pongs as well as Pings, but not the Close frame. A 33rd valid control frame exceeds this sample resource policy; it is not automatically invalid WebSocket.
Other limits remain: 125-byte short payloads, 256-byte pending storage and one ASCII text echo. Invalid or unsupported inputs abort TCP without implementing every protocol-error Close response. This is not a general endpoint, periodic heartbeat service, timeout/retry scheduler or performance benchmark.
Verify control behavior, not just stdout
Without a manual server, run:
| ctest --preset native-debug -R network.ws_control --output-on-failure
|
The independent Python peers decode the client's mask and require byte-for-byte Pong payloads. They test empty, binary and 125-byte Pings; both echo/Pong orders; unsolicited and duplicate Pongs; split/coalesced input; controls alongside Upgrade and crossing Close; exact/exceeded frame budgets; malformed controls; wrong or absent matching Pongs; and deadline progress that never completes the required exchange.
A wrong Pong has the same length as the token but different bytes. It must not complete our Ping. Repeated unrelated Pongs, or correctly answered Pings, must not keep the command alive beyond its original deadline.
Troubleshooting
- Echo received but command times out: the matching Pong may still be absent. A text echo does not answer the Ping.
- Ping/Pong frame budget exceeded: inspect traffic and the chosen resource limit. Merely raising the deadline does not change the count limit.
- Binary Ping reply differs: preserve the exact byte length, including NUL, and XOR with a fresh mask.
- Unexpected opcode/flags: controls must fit the supported final, RSV-clear, unmasked short server-frame format. Fragmentation/extended data remain later lessons.
- Timeout after Close: distinguish waiting for the peer's Close from waiting for TCP EOF after both Close frames.
- Connection refused or incompatible fixture: use
ws_control.py on the same port, and restart it for each run.
Check your understanding
- Must every Pong correspond to a Ping we sent?
- Should an unrelated Pong trigger another Pong?
- Can a valid text echo arrive before the matching Pong?
- Why does this client handle Ping in its Close-wait loop?
- Does processing control frames extend the session deadline?
Answers
No: unsolicited Pongs are allowed. No reply to Pong is required. Echo and control replies can arrive in either order, so completion flags are separate. A Ping can already be in flight before the peer sees our Close, and our handler has not received the peer's Close yet. Neither control processing nor byte arrival renews the deadline.
Continue the course
Previous: Text Frames and Clean Closure · Course overview
Next: Extended Payload Lengths — decode larger text frames without trusting advertised sizes or losing control handling.
References: Ping, Pong, Close semantics.