Skip to content

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:

1
2
3
4
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
  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
#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:

1
2
3
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

  1. Must every Pong correspond to a Ping we sent?
  2. Should an unrelated Pong trigger another Pong?
  3. Can a valid text echo arrive before the matching Pong?
  4. Why does this client handle Ping in its Close-wait loop?
  5. 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.

Donate