Multiple QUIC Streams in C++
Keep one request unfinished and complete another request on the same QUIC connection. This small experiment makes stream independence visible without a benchmark, artificial packet loss or extra threads.
Outcome: Receive stream 4's complete echo before sending stream 0's request FIN, then finish stream 0 on the original connection.
Before you start
Complete Your First Verified QUIC Stream. Reuse its Linux/Pi C++20, OpenSSL 3.5+, CMake and independent-test Python environment. Run commands from network/ in the examples checkout.
The socket ownership, CA/hostname checks, fixed deadlines and normal QUIC shutdown are unchanged. This page changes application scheduling and per-stream state, not cryptography or the UDP socket API.
One stream can finish while another is open
sequenceDiagram
accTitle: Two streams, separate completion
accDescr: After one verified QUIC handshake, the client sends slow on stream zero without FIN. It sends fast and FIN on stream four. The server processes both streams, returns the stream four echo and FIN, and leaves stream zero pending. Only after that reply completes does the client finish stream zero and receive its echo.
participant C as C++ client
participant S as C++ server
Note over C,S: One verified QUIC connection, ALPN wpi-streams/1
C->>S: Stream 0 bytes slow, no FIN
Note over S: Stream 0 request is incomplete
C->>S: Stream 4 bytes fast and FIN
S->>C: Stream 4 echo and FIN
Note over C: COMPLETE stream 4, stream 0 request still open
C->>S: Stream 0 FIN
S->>C: Stream 0 echo and FIN
Note over C: COMPLETE stream 0
C->>S: Connection shutdown
slow and fast are labels, not speed measurements. The first request is held open by the program; there is no claim that the network delayed it. Each arrow can involve several packets, and read boundaries need not match writes.
Both streams are bidirectional byte streams. IDs 0 and 4 are the first two client-initiated bidirectional streams: the lower two ID bits encode initiator and direction. IDs 1–3 are not missing messages. See stream types and identifiers.
Run the two-stream example
With the optional chapter and its Python environment configured as in the previous lesson:
| cmake --build --preset native-debug --target quic_streams_client quic_streams_server --parallel 2
|
In terminal A, generate fresh practice certificates and print their directory. Prepare terminal B's command before launching the server:
| quic_streams_practice=$(mktemp -d /tmp/network-quic-streams.XXXXXX)
python3 fixtures/tls_echo.py certs "$quic_streams_practice/certs"
printf '%s\n' "$quic_streams_practice/certs"
./build/native/debug/quic_streams_server 9449 \
"$quic_streams_practice/certs/server.pem" "$quic_streams_practice/certs/server.key"
|
After LISTEN 9449, run in terminal B. Replace the placeholder with the complete printed certificate-directory path:
| quic_streams_certs="/paste/the/printed/certificate-directory"
./build/native/debug/quic_streams_client 9449 "$quic_streams_certs/ca.pem"
|
Expected client output, in this order:
| verified=localhost alpn=wpi-streams/1
COMPLETE stream=4 slow_request_fin=not_sent
COMPLETE stream=0
connection_shutdown=complete
|
The server reports:
| FIN_QUEUED stream=4 bytes=5
FIN_QUEUED stream=0 bytes=5
connection_shutdown=complete
|
Each payload includes a newline: slow\n and fast\n are five bytes each. The server's FIN_QUEUED line means a reply and its FIN were queued locally. The client's corresponding COMPLETE line means it received matching bytes and the peer FIN. These observations are different.
Restart the server for each attempt. CLI argument forms match the previous pair; defaults remain 10000 ms for the server and 3000 ms for the client. The same fixed budget covers both streams and shutdown. The private ALPN is now wpi-streams/1; these programs deliberately do not negotiate with the single-stream wpi-echo/1 application or claim HTTP/3 support.
Why a blocking application loop would defeat this
Imagine a server that waits for stream 0's FIN before trying stream 4. This client's rules then create a deadlock:
- The server waits for stream 0 to finish.
- The client waits for stream 4's reply before finishing stream 0.
QUIC cannot fix an application that refuses to inspect a ready stream. The server therefore keeps two separate StreamState records and tries each without blocking. A WANT_READ or WANT_WRITE on one record does not stop the scan of the other. Only after a scan makes no progress does it wait for connection-level readiness or the next QUIC timer.
Each record has its own receive buffer, received/sent byte counts, request-FIN state and reply-FIN state. Bytes from different streams are never concatenated. Stream identity chooses the record, so the server does not assume arrival order equals stream-ID order. The independent peer also exercises stream 4 before it sends stream 0's data.
After a request FIN, that record's buffer stops changing. A pending SSL_write_ex() retry can therefore keep the same bytes and length while the server continues inspecting the other stream. Successful writes advance only that record's send count. The underlying connection and UDP descriptor are still shared. See OpenSSL's multi-stream model.
The client is deliberately simpler: write both requests, finish/read stream 4, then finish/read stream 0. It is an experiment coordinator, not a general concurrent client dispatcher. The server's round-robin scan is the piece that must avoid waiting inside one stream's read loop.
What stream independence does not promise
Ordering is per stream, not a global ordering across streams. Streams still share network capacity, congestion control and connection-level flow control. A blocked or failed connection affects them all, and application dependencies can introduce their own waiting. This experiment does not measure recovery from loss or claim that QUIC is always faster than TCP.
A normal FIN ends one sending direction. A reset is an abnormal stream end; a connection close is broader. This reduced application aborts the session on any reset or invalid stream, rather than implementing a recovery/cancellation policy. That policy is a teaching simplification, not a QUIC requirement that one reset must terminate every stream.
Limits are explicit: one loopback connection, client bidirectional streams 0 and 4 only, at most 64 received application bytes per stream, two state records, one deadline. An unexpected accepted stream or oversized message fails. The client requires the peer to permit creating two streams; it fails stream creation rather than implementing a stream-credit waiting queue. No dynamic unbounded stream manager is hidden in a course helper.
Full standalone sources
Each source contains its socket setup and all its own helper functions. No net.hpp or shared course C++ header is required. For direct builds:
| quic_streams_direct=$(mktemp -d /tmp/network-quic-streams-direct.XXXXXX)
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
app/quic-streams/client.cpp $(pkg-config --cflags --libs openssl) \
-o "$quic_streams_direct/quic_streams_client"
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
app/quic-streams/server.cpp $(pkg-config --cflags --libs openssl) \
-o "$quic_streams_direct/quic_streams_server"
|
Full standalone client: app/quic-streams/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[] = "\x0dwpi-streams/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) + ")");
}
}
void write_stream(SSL* stream, SSL* connection, int fd,
Clock::time_point deadline, std::string_view message) {
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);
wait_events(connection, fd, deadline);
}
}
}
void read_stream(SSL* stream, SSL* connection, int fd,
Clock::time_point deadline, std::string_view expected) {
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) == expected,
"echo does not match");
}
int main(int argc, char** argv) {
if (argc < 3 || argc > 5) {
std::cerr << "usage: quic_streams_client port ca.pem [hostname "
"[session_ms]]\n";
return 2;
}
SSL_CTX* context = nullptr;
SSL* connection = nullptr;
SSL* slow = nullptr;
SSL* fast = 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 == 13 &&
std::string_view(reinterpret_cast<const char*>(selected),
selected_size) == "wpi-streams/1",
"identity or ALPN check failed");
std::cout << "verified=" << hostname << " alpn=wpi-streams/1\n";
slow = SSL_new_stream(connection, SSL_STREAM_FLAG_NO_BLOCK);
fast = SSL_new_stream(connection, SSL_STREAM_FLAG_NO_BLOCK);
require(slow && fast && SSL_get_stream_id(slow) == 0 &&
SSL_get_stream_id(fast) == 4 &&
SSL_set_blocking_mode(slow, 0) == 1 &&
SSL_set_blocking_mode(fast, 0) == 1,
"two client bidirectional streams are required");
write_stream(slow, connection, fd, deadline, "slow\n");
// Deliberately leave stream 0's request direction open.
write_stream(fast, connection, fd, deadline, "fast\n");
require(SSL_stream_conclude(fast, 0) == 1, "fast request FIN failed");
read_stream(fast, connection, fd, deadline, "fast\n");
std::cout << "COMPLETE stream=4 slow_request_fin=not_sent" << std::endl;
// Only after stream 4 completes do we finish stream 0's request.
require(SSL_stream_conclude(slow, 0) == 1, "slow request FIN failed");
read_stream(slow, connection, fd, deadline, "slow\n");
std::cout << "COMPLETE stream=0\n";
// 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 << "connection_shutdown=complete\n";
status = 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
ERR_print_errors_fp(stderr);
}
SSL_free(fast);
SSL_free(slow);
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-streams/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[] = "\x0dwpi-streams/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;
}
struct StreamState {
SSL* ssl = nullptr;
std::array<char, 65> bytes{};
std::size_t received = 0;
std::size_t sent = 0;
bool request_fin = false;
bool response_fin = false;
};
int main(int argc, char** argv) {
if (argc < 4 || argc > 5) {
std::cerr << "usage: quic_streams_server port server.pem server.key "
"[session_ms]\n";
return 2;
}
SSL_CTX* context = nullptr;
SSL* listener = nullptr;
SSL* connection = nullptr;
std::array<StreamState, 2> streams{};
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);
}
std::size_t completed = 0;
while (completed < streams.size()) {
check_deadline(deadline);
bool progress = false;
ERR_clear_error();
SSL* incoming =
SSL_accept_stream(connection, SSL_ACCEPT_STREAM_NO_BLOCK);
if (incoming) {
const auto id = SSL_get_stream_id(incoming);
if ((id != 0 && id != 4) ||
SSL_get_stream_type(incoming) != SSL_STREAM_TYPE_BIDI ||
streams[id / 4].ssl) {
SSL_free(incoming);
throw std::runtime_error("only client bidirectional "
"streams 0 and 4 are accepted");
}
streams[id / 4].ssl = incoming;
require(SSL_set_blocking_mode(incoming, 0) == 1,
"stream nonblocking setup failed");
progress = true;
} else {
require(ERR_peek_error() == 0, "stream acceptance failed");
}
// A WANT on one stream must not prevent trying the other stream.
for (auto& stream : streams) {
if (!stream.ssl || stream.response_fin) {
continue;
}
if (!stream.request_fin) {
std::size_t count = 0;
ERR_clear_error();
const int result = SSL_read_ex(
stream.ssl, stream.bytes.data() + stream.received,
stream.bytes.size() - stream.received, &count);
if (result == 1) {
stream.received += count;
require(count > 0 && stream.received <= 64,
"request exceeds 64 bytes");
progress = true;
} else {
const int error = SSL_get_error(stream.ssl, result);
if (error == SSL_ERROR_ZERO_RETURN) {
require(SSL_get_stream_read_state(stream.ssl) ==
SSL_STREAM_STATE_FINISHED,
"connection closed without stream FIN");
stream.request_fin = true;
progress = true;
} else {
retry(error);
}
}
}
if (!stream.request_fin) {
continue;
}
// No more reads modify this buffer while a write is pending.
if (stream.sent < stream.received) {
std::size_t count = 0;
ERR_clear_error();
const int result = SSL_write_ex(
stream.ssl, stream.bytes.data() + stream.sent,
stream.received - stream.sent, &count);
if (result == 1) {
require(count > 0, "write made no progress");
stream.sent += count;
progress = true;
} else {
const int error = SSL_get_error(stream.ssl, result);
retry(error);
}
}
if (stream.sent == stream.received) {
require(SSL_stream_conclude(stream.ssl, 0) == 1,
"response FIN failed");
stream.response_fin = true;
++completed;
progress = true;
// Queued locally, not proof that the client consumed it.
std::cout
<< "FIN_QUEUED stream=" << SSL_get_stream_id(stream.ssl)
<< " bytes=" << stream.received << std::endl;
}
}
if (!progress) {
require(!(SSL_get_shutdown(connection) & SSL_RECEIVED_SHUTDOWN),
"connection closed before both streams completed");
wait_events(connection, fd, deadline);
}
}
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 << "connection_shutdown=complete\n";
status = 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
ERR_print_errors_fp(stderr);
}
for (auto& stream : streams) {
SSL_free(stream.ssl);
}
SSL_free(connection);
SSL_free(listener);
SSL_CTX_free(context);
if (fd >= 0) {
close(fd);
}
return status;
}
|
Test the dependency, not just two successful echoes
| ctest --preset native-debug -R '^network\.quic_streams\.' --output-on-failure
|
The native pair checks the printed completion order. More importantly, an independent aioquic server checks that stream 0's request FIN does not arrive before it sends the stream 4 response. An independent client talks to the C++ server, receives stream 4's echo while withholding stream 0's FIN, then completes stream 0.
Further cases exercise empty/binary/exact-limit requests, split data, reverse stream arrival, extra or unidirectional streams, oversized input/replies, missing FIN, resets, early connection closure, trust/hostname/ALPN failures, key/port/argument errors and fixed deadlines. Missing stream 0 FIN leaves its state incomplete even after stream 4 has finished. The tests do not replace reset with successful FIN or silently skip absent independent-test dependencies.
| Symptom |
What to inspect |
| Nothing completes until the deadline |
Check for an application loop waiting on stream 0 before trying stream 4 |
| Stream 4 completes, then the deadline expires |
Check stream 0's own request/reply FIN; stream 4's FIN cannot complete it |
echo does not match |
Keep buffers and expectations keyed by stream, not arrival order |
two client bidirectional streams are required |
Check the peer's permitted stream count and the negotiated application protocol |
only client bidirectional streams 0 and 4 are accepted |
This peer exceeded the two-stream teaching profile; a general stream manager is out of scope |
Errors after a possible request send do not prove the peer received nothing or rolled back an action. This demonstration echoes bytes and has no external effect.
Check your understanding
- Why can stream 4 complete when stream 0 has not received its request FIN?
- Would opening two QUIC streams help a server that blocks forever reading only the first one?
- Does a FIN on stream 4 close stream 0 or the connection?
Answers
- The requests occupy separate streams with separate completion states. The server services both and can respond to the finished request.
- Not by itself. The application must inspect and progress other ready streams rather than withholding them behind its own blocking loop.
- Neither. It ends that stream's sending direction. Other streams and connection shutdown have separate state.
Continue the course
Continue with Your First HTTP/3 Request: QUIC streams plus HTTP framing, control streams and QPACK. Return to the course overview for the full path.