Skip to content

Your First Verified QUIC Stream in C++

Send hello QUIC over a real QUIC connection, with the server's identity checked before application data is sent. Use one bidirectional stream and its FIN markers to frame one request and one reply.

Outcome: Verify localhost, exchange an eleven-byte message on QUIC stream 0, and require the reply's FIN rather than assuming a successful read completes the reply.

Before you start

Complete QUIC Streams and Toolchain. Run commands on Linux or your Pi from the examples repository's network/ directory. You need C++20, CMake 3.28+, and OpenSSL 3.5+ development files with QUIC enabled.

The C++ programs link only OpenSSL SSL/Crypto in addition to the standard platform libraries. The independent tests additionally use Python 3.11+ and the pinned aioquic package in a virtual environment. CMake downloads nothing automatically.

One bounded loopback session

Both UDP sockets bind only to 127.0.0.1. The server accepts one connection and the first client bidirectional stream, buffers at most 64 application bytes, and exits. Server certificate verification is not client authentication. This is not a public listener, HTTP/3 endpoint or general-purpose QUIC service.

Follow the QUIC stream exchange

sequenceDiagram accTitle: Stream FIN is not connection closure accDescr: Client and server complete the QUIC handshake and negotiate the private echo protocol. After verifying localhost, the client sends eleven request bytes and a stream FIN. The server gathers the bytes through FIN, returns them and its own FIN. Both programs then service QUIC connection shutdown within their original deadlines. participant C as C++ client participant S as C++ server C->>S: QUIC handshake over UDP S->>C: Certificate and ALPN wpi-echo/1 Note over C: Verify CA trust and localhost C->>S: Stream 0 request bytes C->>S: FIN for client sending direction Note over S: Request complete at FIN, at most 64 bytes S->>C: Stream 0 echo bytes S->>C: FIN for server sending direction Note over C: Compare all bytes and require FIN C->>S: QUIC connection shutdown Note over C,S: Service pending data and closure before freeing objects

This shows application-visible steps, not one UDP packet per arrow. QUIC can combine stream frames into packets, retransmit data, and deliver reads with different boundaries from writes. The client writes hello QUIC\n — 11 bytes, including the newline. The server echoes arbitrary binary data up to 64 bytes; this client expects only that fixed message.

The custom ALPN identifier wpi-echo/1 names this teaching protocol. It is not h3. Echoing text over QUIC is not HTTP/3.

Build and run the client/server pair

Create a test-only Python environment, then configure the optional chapter:

1
2
3
4
5
6
quic_tools_env=$(mktemp -d /tmp/network-quic-python.XXXXXX)
python3 -m venv "$quic_tools_env/venv"
"$quic_tools_env/venv/bin/python" -m pip install -r tests/requirements-quic.txt
cmake --preset native-debug -DNETWORK_BUILD_QUIC=ON \
    -DNETWORK_QUIC_PYTHON="$quic_tools_env/venv/bin/python"
cmake --build --preset native-debug --target quic_echo_client quic_echo_server --parallel 2

If you only want to compile/run the C++ pair, use -DBUILD_TESTING=OFF instead of installing the Python test dependency. Re-enable tests before using ctest. An absent independent peer must not be reported as a successful skipped check.

Prepare terminal B's client command before starting the server. In terminal A:

1
2
3
4
5
quic_practice=$(mktemp -d /tmp/network-quic-practice.XXXXXX)
python3 fixtures/tls_echo.py certs "$quic_practice/certs"
printf '%s\n' "$quic_practice/certs"
./build/native/debug/quic_echo_server 9448 \
    "$quic_practice/certs/server.pem" "$quic_practice/certs/server.key"

After LISTEN 9448, run in terminal B. Replace the placeholder with the complete certificate-directory path printed in terminal A; shell variables are not shared across terminals:

quic_certs="/paste/the/printed/certificate-directory"
./build/native/debug/quic_echo_client 9448 "$quic_certs/ca.pem"

Expected client output:

verified=localhost alpn=wpi-echo/1
stream=0 echo_bytes=11 fin=received

Expected final server line:

stream=0 echo_bytes=11 request_fin=received

Restart the one-session server for every attempt. Its default whole-session budget is 10000 ms, including waiting for a client. The client defaults to 3000 ms. Server arguments are port server.pem server.key [session_ms]; client arguments are port ca.pem [hostname [session_ms]]. Explicit budgets accept 1–10000 ms. Server port 0 selects and prints a temporary port; the client requires a concrete port.

The two-day practice certificate is trusted only by this client process. Generate a new directory when it expires. Do not commit private keys or install the practice CA in system trust.

Read the code in three layers

UDP socket and object ownership

The socket(), bind() and close() calls stay visible in each source. Both descriptors are nonblocking. The client sets a QUIC destination address using SSL_set1_initial_peer_addr(); it does not perform a TCP-style connection on the UDP socket.

BIO_new_dgram(fd, BIO_NOCLOSE) connects the descriptor to OpenSSL's datagram interface. After SSL_set_bio(), SSL owns the BIO, but not the descriptor. Cleanup frees stream objects first, then connection/listener/context objects, then closes the descriptor once.

The server's accepted connection shares the listener's network resources: do not attach a new fd or BIO to that accepted connection. The listener is created with flags 0, retaining OpenSSL's address-validation behavior. Neither this setting nor a loopback bind makes the program a production service. See listener and connection ownership.

Handshake, trust and application protocol

The client enables SSL_VERIFY_PEER, loads its explicit CA file, sets both the expected hostname and SNI, and offers only wpi-echo/1. No application stream is opened until the handshake, trust/identity checks and selected ALPN are acceptable.

The server loads its certificate/key and selects only that ALPN. It does not require a client certificate. The handshake uses QUIC's integrated TLS 1.3 support, not DTLS or TLS records around stream bytes.

Both programs disable the convenience default stream. The client creates a separate stream object with SSL_new_stream(); the server obtains the corresponding object with SSL_accept_stream(). Stream 0 is the first client-initiated bidirectional stream in this reduced session. See stream creation.

Stream completion, deadlines and shutdown

The example uses one request per stream, framed by the sender's FIN. Reads accumulate bytes through that FIN, using a 65-byte buffer to detect data beyond the 64-byte application limit. Exactly matching bytes without FIN do not count as a complete reply. A stream reset or connection closure is not substituted for normal FIN.

SSL_stream_conclude() ends only that stream's sending direction. It does not prevent receiving the reply on the same bidirectional stream and does not close the entire connection. For several messages on one stream, introduce explicit message framing rather than using a FIN after every write. See stream conclusion.

The wait_events() helper handles socket readiness and OpenSSL's next protocol timer, capped by the unchanged session deadline. Network progress never starts a new budget. UDP writability is watched only when OpenSSL needs it, avoiding an always-writable busy loop. Retries of a pending write keep the same buffer and length; they are not new application requests. See event timers.

An empty connection or stream accept queue returns a null pointer; the server checks the error queue and waits. It does not feed that ordinary empty-queue result into the read/write retry helper. Actual I/O calls clear stale errors and immediately obtain SSL_get_error() when they fail.

After the stream exchange, both sides continue calling SSL_shutdown() and processing events until connection shutdown completes or the same deadline expires. Freeing a QUIC connection immediately after a successful write can abandon buffered or unacknowledged data. QUIC shutdown is not the earlier TLS close_notify exchange, and stream receipt does not prove an application committed a durable transaction. See QUIC-specific shutdown.

Full standalone sources

Each file can also be compiled independently, without a course-specific header:

1
2
3
4
5
6
7
quic_direct=$(mktemp -d /tmp/network-quic-direct.XXXXXX)
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
    app/quic-echo/client.cpp $(pkg-config --cflags --libs openssl) \
    -o "$quic_direct/quic_echo_client"
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
    app/quic-echo/server.cpp $(pkg-config --cflags --libs openssl) \
    -o "$quic_direct/quic_echo_server"

These commands need pkg-config and the same OpenSSL 3.5+ development setup. Use the resulting executables in place of the CMake paths above.

Full standalone client: app/quic-echo/client.cpp
#include <openssl/err.h>
#include <openssl/quic.h>
#include <openssl/ssl.h>

#include <arpa/inet.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>

#include <algorithm>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>

using Clock = std::chrono::steady_clock;
constexpr unsigned char protocols[] = "\x0awpi-echo/1";

void require(bool condition, const char* message) {
    if (!condition) {
        throw std::runtime_error(message);
    }
}

int number(std::string_view text, int minimum, int maximum) {
    int value = 0;
    auto [end, error] =
        std::from_chars(text.data(), text.data() + text.size(), value);
    require(error == std::errc{} && end == text.data() + text.size() &&
                value >= minimum && value <= maximum,
            "invalid port or session_ms");
    return value;
}

void check_deadline(Clock::time_point deadline) {
    require(Clock::now() < deadline, "session deadline expired");
}

// QUIC protocol timers and our fixed session deadline are different clocks.
void wait_events(SSL* connection, int fd, Clock::time_point deadline) {
    check_deadline(deadline);
    timeval timer{};
    int infinite = 0;
    require(SSL_get_event_timeout(connection, &timer, &infinite) == 1,
            "event timer failed");
    const auto remaining =
        std::chrono::ceil<std::chrono::milliseconds>(deadline - Clock::now())
            .count();
    require(remaining > 0, "session deadline expired");
    long long milliseconds = std::min<long long>(remaining, 1000);
    if (!infinite) {
        milliseconds =
            std::min(milliseconds,
                     timer.tv_sec * 1000LL + (timer.tv_usec + 999LL) / 1000);
    }
    pollfd descriptor{fd, POLLIN, 0};
    if (SSL_net_write_desired(connection)) {
        descriptor.events |= POLLOUT;
    }
    const int result = poll(&descriptor, 1, static_cast<int>(milliseconds));
    require(result >= 0 || errno == EINTR, "poll failed");
    require(!(descriptor.revents & (POLLERR | POLLNVAL)), "UDP socket error");
    require(SSL_handle_events(connection) == 1, "event processing failed");
}

void retry(int error) {
    if (error != SSL_ERROR_WANT_READ && error != SSL_ERROR_WANT_WRITE) {
        throw std::runtime_error("QUIC operation failed (SSL error " +
                                 std::to_string(error) + ")");
    }
}

int main(int argc, char** argv) {
    if (argc < 3 || argc > 5) {
        std::cerr
            << "usage: quic_echo_client port ca.pem [hostname [session_ms]]\n";
        return 2;
    }
    SSL_CTX* context = nullptr;
    SSL* connection = nullptr;
    SSL* stream = nullptr;
    int fd = -1;
    int status = 1;
    try {
        const int port = number(argv[1], 1, 65535);
        const char* hostname = argc >= 4 ? argv[3] : "localhost";
        const int budget = argc == 5 ? number(argv[4], 1, 10000) : 3000;
        require(*hostname != '\0', "hostname must not be empty");
        context = SSL_CTX_new(OSSL_QUIC_client_method());
        require(context, "client context failed");
        SSL_CTX_set_verify(context, SSL_VERIFY_PEER, nullptr);
        require(SSL_CTX_load_verify_locations(context, argv[2], nullptr) == 1,
                "CA file failed");
        connection = SSL_new(context);
        require(connection, "connection allocation failed");
        require(SSL_set1_host(connection, hostname) == 1 &&
                    SSL_set_tlsext_host_name(connection, hostname) == 1 &&
                    SSL_set_alpn_protos(connection, protocols,
                                        sizeof(protocols) - 1) == 0 &&
                    SSL_set_default_stream_mode(
                        connection, SSL_DEFAULT_STREAM_MODE_NONE) == 1 &&
                    SSL_set_incoming_stream_policy(
                        connection, SSL_INCOMING_STREAM_POLICY_REJECT, 1) == 1,
                "client configuration failed");

        fd = socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
        require(fd >= 0, "socket failed");
        sockaddr_in local{};
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        require(bind(fd, reinterpret_cast<sockaddr*>(&local), sizeof(local)) ==
                    0,
                "client bind failed");
        BIO* datagram = BIO_new_dgram(fd, BIO_NOCLOSE);
        require(datagram, "datagram BIO failed");
        SSL_set_bio(connection, datagram, datagram); // SSL now owns the BIO.
        BIO_ADDR* peer = BIO_ADDR_new();
        require(peer, "peer address allocation failed");
        const in_addr loopback{htonl(INADDR_LOOPBACK)};
        const bool address_ready =
            BIO_ADDR_rawmake(peer, AF_INET, &loopback, sizeof(loopback),
                             htons(static_cast<unsigned short>(port))) == 1 &&
            SSL_set1_initial_peer_addr(connection, peer) == 1;
        BIO_ADDR_free(peer);
        require(address_ready && SSL_set_blocking_mode(connection, 0) == 1,
                "peer or nonblocking setup failed");

        const auto deadline = Clock::now() + std::chrono::milliseconds(budget);
        for (;;) {
            check_deadline(deadline);
            ERR_clear_error();
            const int result = SSL_connect(connection);
            if (result == 1) {
                break;
            }
            const int error = SSL_get_error(connection, result);
            retry(error);
            wait_events(connection, fd, deadline);
        }
        const unsigned char* selected = nullptr;
        unsigned int selected_size = 0;
        SSL_get0_alpn_selected(connection, &selected, &selected_size);
        require(SSL_get_verify_result(connection) == X509_V_OK && selected &&
                    selected_size == 10 &&
                    std::string_view(reinterpret_cast<const char*>(selected),
                                     selected_size) == "wpi-echo/1",
                "identity or ALPN check failed");
        std::cout << "verified=" << hostname << " alpn=wpi-echo/1\n";

        stream = SSL_new_stream(connection, SSL_STREAM_FLAG_NO_BLOCK);
        require(stream && SSL_set_blocking_mode(stream, 0) == 1,
                "stream creation failed");
        constexpr std::string_view message = "hello QUIC\n";
        std::size_t sent = 0;
        while (sent < message.size()) {
            check_deadline(deadline);
            std::size_t count = 0;
            ERR_clear_error();
            const int result = SSL_write_ex(stream, message.data() + sent,
                                            message.size() - sent, &count);
            if (result == 1) {
                require(count > 0, "write made no progress");
                sent += count;
            } else {
                const int error = SSL_get_error(stream, result);
                retry(error);
                // Keep the same buffer and length on a WANT retry.
                wait_events(connection, fd, deadline);
            }
        }
        require(SSL_stream_conclude(stream, 0) == 1, "request FIN failed");

        std::array<char, 65> buffer{};
        std::size_t received = 0;
        for (;;) {
            check_deadline(deadline);
            std::size_t count = 0;
            ERR_clear_error();
            const int result = SSL_read_ex(stream, buffer.data() + received,
                                           buffer.size() - received, &count);
            if (result == 1) {
                received += count;
                require(count > 0 && received <= 64, "reply exceeds 64 bytes");
            } else {
                const int error = SSL_get_error(stream, result);
                if (error == SSL_ERROR_ZERO_RETURN) {
                    require(SSL_get_stream_read_state(stream) ==
                                SSL_STREAM_STATE_FINISHED,
                            "connection closed without stream FIN");
                    break;
                }
                retry(error);
                wait_events(connection, fd, deadline);
            }
        }
        require(std::string_view(buffer.data(), received) == message,
                "echo does not match");
        // Flush pending stream data and service normal QUIC connection closure.
        for (;;) {
            check_deadline(deadline);
            ERR_clear_error();
            const int result = SSL_shutdown(connection);
            if (result == 1) {
                break;
            }
            if (result < 0) {
                const int error = SSL_get_error(connection, result);
                retry(error);
            }
            wait_events(connection, fd, deadline);
        }
        std::cout << "stream=" << SSL_get_stream_id(stream)
                  << " echo_bytes=" << received << " fin=received\n";
        status = 0;
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        ERR_print_errors_fp(stderr);
    }
    SSL_free(stream);
    SSL_free(connection);
    SSL_CTX_free(context);
    if (fd >= 0) {
        close(fd); // BIO_NOCLOSE leaves descriptor ownership here.
    }
    return status;
}
Full standalone server: app/quic-echo/server.cpp
#include <openssl/err.h>
#include <openssl/quic.h>
#include <openssl/ssl.h>

#include <arpa/inet.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>

#include <algorithm>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>

using Clock = std::chrono::steady_clock;
constexpr unsigned char protocols[] = "\x0awpi-echo/1";

void require(bool condition, const char* message) {
    if (!condition) {
        throw std::runtime_error(message);
    }
}

int number(std::string_view text, int minimum, int maximum) {
    int value = 0;
    auto [end, error] =
        std::from_chars(text.data(), text.data() + text.size(), value);
    require(error == std::errc{} && end == text.data() + text.size() &&
                value >= minimum && value <= maximum,
            "invalid port or session_ms");
    return value;
}

void check_deadline(Clock::time_point deadline) {
    require(Clock::now() < deadline, "session deadline expired");
}

// QUIC protocol timers and our fixed session deadline are different clocks.
void wait_events(SSL* connection, int fd, Clock::time_point deadline) {
    check_deadline(deadline);
    timeval timer{};
    int infinite = 0;
    require(SSL_get_event_timeout(connection, &timer, &infinite) == 1,
            "event timer failed");
    const auto remaining =
        std::chrono::ceil<std::chrono::milliseconds>(deadline - Clock::now())
            .count();
    require(remaining > 0, "session deadline expired");
    long long milliseconds = std::min<long long>(remaining, 1000);
    if (!infinite) {
        milliseconds =
            std::min(milliseconds,
                     timer.tv_sec * 1000LL + (timer.tv_usec + 999LL) / 1000);
    }
    pollfd descriptor{fd, POLLIN, 0};
    if (SSL_net_write_desired(connection)) {
        descriptor.events |= POLLOUT;
    }
    const int result = poll(&descriptor, 1, static_cast<int>(milliseconds));
    require(result >= 0 || errno == EINTR, "poll failed");
    require(!(descriptor.revents & (POLLERR | POLLNVAL)), "UDP socket error");
    require(SSL_handle_events(connection) == 1, "event processing failed");
}

void retry(int error) {
    if (error != SSL_ERROR_WANT_READ && error != SSL_ERROR_WANT_WRITE) {
        throw std::runtime_error("QUIC operation failed (SSL error " +
                                 std::to_string(error) + ")");
    }
}

int select_protocol(SSL*, const unsigned char** output, unsigned char* length,
                    const unsigned char* offered, unsigned int size, void*) {
    unsigned char* selected = nullptr;
    if (SSL_select_next_proto(&selected, length, protocols,
                              sizeof(protocols) - 1, offered,
                              size) != OPENSSL_NPN_NEGOTIATED) {
        return SSL_TLSEXT_ERR_ALERT_FATAL;
    }
    *output = selected;
    return SSL_TLSEXT_ERR_OK;
}

int main(int argc, char** argv) {
    if (argc < 4 || argc > 5) {
        std::cerr << "usage: quic_echo_server port server.pem server.key "
                     "[session_ms]\n";
        return 2;
    }
    SSL_CTX* context = nullptr;
    SSL* listener = nullptr;
    SSL* connection = nullptr;
    SSL* stream = nullptr;
    int fd = -1;
    int status = 1;
    try {
        const int port = number(argv[1], 0, 65535);
        const int budget = argc == 5 ? number(argv[4], 1, 10000) : 10000;
        context = SSL_CTX_new(OSSL_QUIC_server_method());
        require(context, "server context failed");
        require(SSL_CTX_use_certificate_chain_file(context, argv[2]) == 1 &&
                    SSL_CTX_use_PrivateKey_file(context, argv[3],
                                                SSL_FILETYPE_PEM) == 1 &&
                    SSL_CTX_check_private_key(context) == 1,
                "certificate or key failed");
        // Authenticate the server to the client; no client certificate is
        // required.
        SSL_CTX_set_alpn_select_cb(context, select_protocol, nullptr);
        listener =
            SSL_new_listener(context, 0); // Keep address validation enabled.
        require(listener, "listener allocation failed");

        fd = socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
        require(fd >= 0, "socket failed");
        sockaddr_in local{};
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        local.sin_port = htons(static_cast<unsigned short>(port));
        require(bind(fd, reinterpret_cast<sockaddr*>(&local), sizeof(local)) ==
                    0,
                "server bind failed");
        socklen_t length = sizeof(local);
        require(getsockname(fd, reinterpret_cast<sockaddr*>(&local), &length) ==
                    0,
                "getsockname failed");
        BIO* datagram = BIO_new_dgram(fd, BIO_NOCLOSE);
        require(datagram, "datagram BIO failed");
        SSL_set_bio(listener, datagram, datagram);
        require(SSL_set_blocking_mode(listener, 0) == 1 &&
                    SSL_listen(listener) == 1,
                "listener setup failed");
        const auto deadline = Clock::now() + std::chrono::milliseconds(budget);
        std::cout << "LISTEN " << ntohs(local.sin_port) << std::endl;

        while (!connection) {
            check_deadline(deadline);
            ERR_clear_error();
            connection =
                SSL_accept_connection(listener, SSL_ACCEPT_CONNECTION_NO_BLOCK);
            if (!connection) {
                // An empty listener queue returns nullptr without an ERR entry.
                // Do not classify it as a failed SSL_read/SSL_write operation.
                require(ERR_peek_error() == 0, "connection acceptance failed");
                wait_events(listener, fd, deadline);
            }
        }
        // The accepted connection shares the listener's socket/BIO.
        // Do not set a separate fd or BIO on it.
        require(SSL_set_blocking_mode(connection, 0) == 1 &&
                    SSL_set_default_stream_mode(
                        connection, SSL_DEFAULT_STREAM_MODE_NONE) == 1 &&
                    SSL_set_incoming_stream_policy(
                        connection, SSL_INCOMING_STREAM_POLICY_ACCEPT, 0) == 1,
                "connection configuration failed");
        for (;;) {
            check_deadline(deadline);
            ERR_clear_error();
            const int result = SSL_accept(connection);
            if (result == 1) {
                break;
            }
            const int error = SSL_get_error(connection, result);
            retry(error);
            wait_events(connection, fd, deadline);
        }
        while (!stream) {
            check_deadline(deadline);
            ERR_clear_error();
            stream = SSL_accept_stream(connection, SSL_ACCEPT_STREAM_NO_BLOCK);
            if (!stream) {
                require(ERR_peek_error() == 0, "stream acceptance failed");
                require(!(SSL_get_shutdown(connection) & SSL_RECEIVED_SHUTDOWN),
                        "connection closed before a stream arrived");
                wait_events(connection, fd, deadline);
            }
        }
        require(SSL_set_blocking_mode(stream, 0) == 1 &&
                    SSL_get_stream_type(stream) == SSL_STREAM_TYPE_BIDI &&
                    SSL_get_stream_id(stream) == 0,
                "expected the first client bidirectional stream");

        std::array<char, 65> buffer{};
        std::size_t received = 0;
        for (;;) {
            check_deadline(deadline);
            std::size_t count = 0;
            ERR_clear_error();
            const int result = SSL_read_ex(stream, buffer.data() + received,
                                           buffer.size() - received, &count);
            if (result == 1) {
                received += count;
                require(count > 0 && received <= 64,
                        "request exceeds 64 bytes");
            } else {
                const int error = SSL_get_error(stream, result);
                if (error == SSL_ERROR_ZERO_RETURN) {
                    require(SSL_get_stream_read_state(stream) ==
                                SSL_STREAM_STATE_FINISHED,
                            "connection closed without stream FIN");
                    break;
                }
                retry(error);
                wait_events(connection, fd, deadline);
            }
        }
        std::size_t sent = 0;
        while (sent < received) {
            check_deadline(deadline);
            std::size_t count = 0;
            ERR_clear_error();
            const int result = SSL_write_ex(stream, buffer.data() + sent,
                                            received - sent, &count);
            if (result == 1) {
                require(count > 0, "write made no progress");
                sent += count;
            } else {
                const int error = SSL_get_error(stream, result);
                retry(error);
                wait_events(connection, fd, deadline);
            }
        }
        require(SSL_stream_conclude(stream, 0) == 1, "response FIN failed");
        for (;;) {
            check_deadline(deadline);
            ERR_clear_error();
            const int result = SSL_shutdown(connection);
            if (result == 1) {
                break;
            }
            if (result < 0) {
                const int error = SSL_get_error(connection, result);
                retry(error);
            }
            wait_events(connection, fd, deadline);
        }
        std::cout << "stream=" << SSL_get_stream_id(stream)
                  << " echo_bytes=" << received << " request_fin=received\n";
        status = 0;
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        ERR_print_errors_fp(stderr);
    }
    SSL_free(stream);
    SSL_free(connection);
    SSL_free(listener);
    SSL_CTX_free(context);
    if (fd >= 0) {
        close(fd);
    }
    return status;
}

Check failure cases safely

Run the automated checks without a manual server:

ctest --preset native-debug -R '^network\.quic_echo\.' --output-on-failure

The pinned aioquic peer is independent of OpenSSL's QUIC implementation. It checks both C++ roles, split reads, empty/binary/exact-limit requests, oversized input, mismatching replies, absent FIN, stream reset, certificate/hostname failures, absent/wrong ALPN, bind/key errors and fixed deadlines. A local relay drops the first outgoing handshake datagram to exercise transport recovery; no firewall or system networking changes are made.

Observation Meaning and recovery
CA file failed The explicit CA file is missing/invalid. Supply the fresh practice CA; do not disable verification
Certificate/hostname failure Trust and identity must match. The client has not sent its request; restart the server for a corrected attempt
server bind failed Another local process owns the UDP port. Pick a free port or inspect the owner; do not kill unrelated processes
session deadline expired Handshake, stream FIN or shutdown did not finish within the original budget. Check which stage completed before retrying
reply exceeds 64 bytes / echo does not match The peer violated this application's reduced profile. Do not accept an arbitrary prefix as success
Matching bytes followed by reset/closure Not a normal complete reply. This exercise requires FIN on the reply direction

Loss recovery remains bounded by failure and deadlines; one recovered handshake drop is not a general loss, performance or congestion-control benchmark. We do not exercise connection migration, resumption, 0-RTT, unreliable QUIC DATAGRAM frames, public load or every possible backpressure state here.

A failure after a possible request send does not prove the server received nothing or rolled back work. This echo has no external side effect. For real commands, define application confirmation and retry semantics as in the operation-ID lesson, even though QUIC already supplies transport retransmission.

Check your understanding

  1. Why can the server still send after receiving the client's FIN?
  2. Why is it unsafe to free the QUIC connection as soon as a write succeeds?
  3. Why would changing the ALPN string to h3 not turn this echo into HTTP/3?
Answers
  1. A bidirectional stream has separate sending directions. Client FIN ends only the request direction.
  2. The library can still have data awaiting transmission or acknowledgement. Service events and the connection shutdown within the deadline.
  3. HTTP/3 needs its own framing, control streams and QPACK. The echo application implements none of those.

Continue the course

Continue with Multiple QUIC Streams to complete one request while another stays open on the same connection. Return to the course overview for the full path.

Donate