DTLS Message IDs and Explicit Retries in C++
If a reply is lost, retry the same logical request with the same ID and contents. The server can return its cached result without applying the operation again—but exhausted retries still mean unconfirmed, not necessarily unexecuted.
Before you start
Complete Your First Verified DTLS Exchange. Run commands on the Pi from the examples repository's network/ directory, using the same C++20/OpenSSL development setup.
Outcome: request an in-memory increment, recover from a lost reply by repeating its ID, observe one server execution, and distinguish an unrelated response from a matching result.
This is a toy application protocol, not a standard DTLS message format. The operation adds 7 to a process-local counter; it does not touch hardware, files or a real actuator. One DTLS association supports one logical operation and one cached result.
The verified DTLS-1.2-only transport and first-peer loopback server retain the preceding lesson's limitations. No public listener, client certificate authentication, durable cache or restart/reconnect guarantee is added.
Follow a lost reply without repeating the work
sequenceDiagram
accTitle: A second attempt need not be a second execution
accDescr: After verified DTLS, the client sends request 42 to add 7. The server applies it once and caches the result, but the reply is deliberately dropped. After its reply window expires, the client sends the same request in a new DTLS record. The server returns the cached value and execution count without incrementing again.
participant C as C++ client
participant S as C++ server
Note over C,S: DTLS handshake and server identity verified
C->>S: Request ID 42, add 7
Note over S: Value becomes 7, executions becomes 1
Note over S: Cache request bytes and reply
S--xC: First result dropped by test relay
Note over C: Reply window expires
C->>S: Same ID 42 and amount 7, new DTLS record
Note over S: Reuse cached result, do not apply again
S->>C: ID 42, value 7, executions 1
Note over C: Logical request confirmed on attempt 2
C->>S: DTLS close_notify
Note over S: End this bounded session
The ID identifies the operation, not its transmission attempt. Changing the ID on every retry would turn each attempt into different work. A delayed response from attempt 1 still confirms the current operation if its ID and result match; it is not obsolete merely because attempt 2 has started.
Run the one-operation example
Build:
| cmake --preset native-debug
cmake --build --preset native-debug --target dtls_ids_client dtls_ids_server --parallel 2
|
In terminal A, create fresh private practice certificates and print their directory:
| dtls_ids_practice=$(mktemp -d /tmp/network-dtls-ids-practice.XXXXXX)
python3 fixtures/tls_echo.py certs "$dtls_ids_practice/certs"
printf '%s\n' "$dtls_ids_practice/certs"
|
Prepare terminal B by replacing this placeholder with the complete printed certificate-directory path:
| dtls_ids_certs="/paste/the/printed/certificate-directory"
|
Then start terminal A's server with a 10000 ms whole-session budget, and promptly run the client in terminal B:
| # Terminal A
./build/native/debug/dtls_ids_server 9447 \
"$dtls_ids_practice/certs/server.pem" "$dtls_ids_practice/certs/server.key" 10000
|
| # Terminal B, after LISTEN 9447
./build/native/debug/dtls_ids_client 9447 "$dtls_ids_certs/ca.pem"
|
Normal client output:
| verified=localhost protocol=DTLSv1.2
id=42 value=7 executions=1 attempts=1 ignored=0
|
Normal server output after its LISTEN line:
| APPLIED id=42 value=7 executions=1
requests=1 executions=1 value=7 close_notify=received
|
Restart the server for each new session. Without an explicit budget it defaults to 5000 ms, including first-peer waiting. The client accepts port ca.pem [hostname [session_ms]], with localhost and 3000 ms defaults. Both budget arguments accept 1–10000 ms.
The ID 42 is deliberately fixed for this single-operation association. It is not a globally unique ID generator. Certificates expire after two days; create new ones in a fresh directory as needed. Do not install the practice CA globally or commit secret keys.
Observe an actual retry and cached result
No manual server is needed:
| ctest --preset native-debug -V -R '^network.dtls_ids.drop_first_reply$'
|
The test prints the captured endpoint/relay results, including:
| verified=localhost protocol=DTLSv1.2
id=42 value=7 executions=1 attempts=2 ignored=0
APPLIED id=42 value=7 executions=1
CACHED id=42 requests=2
requests=2 executions=1 value=7 close_notify=received
relay_client_app_records=2
|
The two requests are separate application sends in newly generated DTLS records. The single APPLIED line and cached reply demonstrate application-level duplicate suppression, rather than assuming DTLS itself knows the operation's meaning.
Encode the application packet explicitly
All multi-byte fields use unsigned 32-bit big-endian values. Do not send a native C++ struct: padding and host byte order are not a wire format.
| Byte offset |
Request: 10 bytes |
Reply: 14 bytes |
| 0 |
Version 1 |
Version 1 |
| 1 |
Kind 1: increment |
Kind 2: result |
| 2–5 |
Operation ID |
Echoed operation ID |
| 6–9 |
Amount, 1–100 |
Resulting counter value |
| 10–13 |
Not present |
Execution count |
The client sends version=1, kind=1, id=42, amount=7. Its matching reply must contain value=7, executions=1. The small put_u32/get_u32 loops make byte order visible. Exact record lengths, version and kind are checked before interpreting fields.
The server compares the complete cached request, not just its ID:
| Request after the first operation |
Server action |
| Same ID and same bytes |
Return the cached response. |
| Same ID but changed amount |
Reject; never silently reuse the ID for different work. |
| Different ID |
Reject as outside this one-operation session profile. |
Supporting a second logical operation would need a larger cache and an explicit lifetime/eviction policy. It is not implemented by clearing this slot whenever a new ID arrives.
A correctly formatted reply with another ID cannot confirm this request. Count and ignore it, leaving both the attempt deadline and whole-session deadline unchanged. A matching ID with contradictory result fields fails rather than being treated as a successful acknowledgement.
The independent reply_stale_then_valid test peer injects a scripted ID-41 result, waits for a retry, then returns ID 42. This exercises correlation; it is not an observed delayed result from an earlier real operation in this one-slot server.
Compare the three identities:
- DTLS record sequence: protects against replaying the same protected record.
- Application operation ID: groups new transmissions of the same logical work.
- Attempt number: reports how many application sends this client made.
An application retry calls SSL_write_ex() again; OpenSSL creates a new protected record. Its record replay checks cannot replace the application's result cache. The tests contrast a duplicated ciphertext record with a newly encrypted request retry. See DTLS record anti-replay.
Read the standalone C++ client
Focus on the fixed request bytes, per-attempt reply deadline, ID matching and operation_confirmed. The existing raw UDP socket, connected datagram BIO, verified handshake and DTLS timer handling stay in this file.
Full standalone client: app/dtls-ids/client.cpp
| #include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <csignal>
#include <cstdint>
#include <iostream>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <unistd.h>
int main(int argc, char** argv) {
int fd = -1;
SSL_CTX* context = nullptr;
SSL* tls = nullptr;
int status = 0;
bool request_may_have_run = false;
bool operation_confirmed = false;
try {
if (argc < 3 || argc > 5) {
throw std::invalid_argument(
"usage: dtls_ids_client port ca.pem [hostname [session_ms]]");
}
unsigned int port = 0;
const std::string_view text = argv[1];
const auto [end, error] =
std::from_chars(text.data(), text.data() + text.size(), port);
if (error != std::errc{} || end != text.data() + text.size() ||
port == 0 || port > 65535) {
throw std::invalid_argument("port must be 1..65535");
}
const std::string hostname = argc >= 4 ? argv[3] : "localhost";
if (hostname.empty() || hostname.size() > 253 ||
hostname.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLM"
"NOPQRSTUVWXYZ0123456789.-") !=
std::string::npos) {
throw std::invalid_argument("hostname must be an ASCII DNS name");
}
int session_ms = 3000;
if (argc == 5) {
const std::string_view budget = argv[4];
const auto [last, invalid] = std::from_chars(
budget.data(), budget.data() + budget.size(), session_ms);
if (invalid != std::errc{} ||
last != budget.data() + budget.size() || session_ms < 1 ||
session_ms > 10000) {
throw std::invalid_argument("session_ms must be 1..10000");
}
}
// OpenSSL writes through the socket BIO, not send(MSG_NOSIGNAL).
if (std::signal(SIGPIPE, SIG_IGN) == SIG_ERR) {
throw std::runtime_error("cannot ignore SIGPIPE");
}
context = SSL_CTX_new(DTLS_client_method());
if (!context ||
SSL_CTX_set_min_proto_version(context, DTLS1_2_VERSION) != 1 ||
SSL_CTX_set_max_proto_version(context, DTLS1_2_VERSION) != 1 ||
SSL_CTX_load_verify_locations(context, argv[2], nullptr) != 1) {
throw std::runtime_error("cannot configure TLS or load CA file");
}
SSL_CTX_set_verify(context, SSL_VERIFY_PEER, nullptr);
tls = SSL_new(context);
if (!tls || SSL_set1_host(tls, hostname.c_str()) != 1 ||
SSL_set_tlsext_host_name(tls, hostname.c_str()) != 1) {
throw std::runtime_error("cannot configure server identity");
}
fd = ::socket(AF_INET, SOCK_DGRAM | 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");
}
};
auto reply_deadline = deadline;
const auto wait_for = [&](short events) {
for (;;) {
check_deadline();
const auto remaining =
std::chrono::ceil<std::chrono::milliseconds>(
std::min(deadline, reply_deadline) -
std::chrono::steady_clock::now())
.count();
if (remaining <= 0) {
check_deadline();
return false; // Only the per-attempt reply wait expired.
}
auto wait_ms = remaining;
timeval timer{};
if (DTLSv1_get_timeout(tls, &timer) == 1) {
const auto due =
std::chrono::ceil<std::chrono::milliseconds>(
std::chrono::seconds(timer.tv_sec) +
std::chrono::microseconds(timer.tv_usec))
.count();
wait_ms = std::min(wait_ms, due);
}
pollfd ready{fd, events, 0};
const int result = ::poll(&ready, 1, static_cast<int>(wait_ms));
if (result < 0 && errno == EINTR) {
continue;
}
if (result < 0) {
throw std::system_error(errno, std::generic_category(),
"poll");
}
check_deadline();
if (std::chrono::steady_clock::now() >= reply_deadline) {
return false;
}
if (result > 0) {
return true;
}
// A protocol retransmission timer never renews our deadline.
ERR_clear_error();
if (DTLSv1_handle_timeout(tls) < 0) {
throw std::runtime_error("DTLS timer handling failed");
}
}
};
const auto retry_tls = [&](int result) {
// No intervening OpenSSL call: result belongs to the last TLS call.
const int error_code = SSL_get_error(tls, result);
if (error_code == SSL_ERROR_WANT_READ) {
wait_for(POLLIN);
} else if (error_code == SSL_ERROR_WANT_WRITE) {
wait_for(POLLOUT);
} else {
char detail[256]{};
ERR_error_string_n(ERR_get_error(), detail, sizeof(detail));
throw std::runtime_error("TLS operation failed (SSL error " +
std::to_string(error_code) +
"): " + detail);
}
};
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);
// UDP connect selects a peer; it does not contact or authenticate it.
if (::connect(fd, reinterpret_cast<const sockaddr*>(&server),
sizeof(server)) < 0) {
throw std::system_error(errno, std::generic_category(),
"UDP connect");
}
BIO* datagram = BIO_new_dgram(fd, BIO_NOCLOSE);
if (!datagram) {
throw std::runtime_error("cannot create datagram BIO");
}
if (BIO_ctrl(datagram, BIO_CTRL_DGRAM_SET_CONNECTED, 0, &server) != 1) {
BIO_free(datagram);
throw std::runtime_error("cannot configure connected datagram BIO");
}
SSL_set_bio(tls, datagram,
datagram); // SSL owns BIO, not the descriptor.
SSL_set_options(tls, SSL_OP_NO_QUERY_MTU);
if (DTLS_set_link_mtu(tls, 1200) <= 0) {
throw std::runtime_error("cannot set DTLS link MTU");
}
for (;;) {
check_deadline();
ERR_clear_error();
const int result = SSL_connect(tls);
if (result == 1) {
break;
}
retry_tls(result);
}
if (SSL_get_verify_result(tls) != X509_V_OK) {
throw std::runtime_error("server certificate verification failed");
}
const auto put_u32 = [](unsigned char* out, std::uint32_t value) {
for (int i = 3; i >= 0; --i) {
out[3 - i] =
static_cast<unsigned char>((value >> (i * 8)) & 0xff);
}
};
const auto get_u32 = [](const unsigned char* in) {
std::uint32_t value = 0;
for (int i = 0; i < 4; ++i) {
value = (value << 8) | in[i];
}
return value;
};
// Lab ID is scoped to this one fresh DTLS association, not global.
constexpr std::uint32_t request_id = 42;
constexpr std::uint32_t amount = 7;
std::array<unsigned char, 10> request{1, 1};
put_u32(request.data() + 2, request_id);
put_u32(request.data() + 6, amount);
if (DTLS_get_data_mtu(tls) < request.size()) {
throw std::runtime_error("request exceeds data MTU");
}
std::array<unsigned char, 15> reply{};
unsigned int attempts = 0;
unsigned int ignored = 0;
for (unsigned int attempt = 1; attempt <= 3 && !operation_confirmed;
++attempt) {
reply_deadline =
deadline; // A pending SSL write must finish unchanged.
for (;;) {
check_deadline();
std::size_t written = 0;
ERR_clear_error();
request_may_have_run = true;
const int result =
SSL_write_ex(tls, request.data(), request.size(), &written);
if (result == 1) {
if (written != request.size()) {
throw std::runtime_error("incomplete request datagram");
}
break;
}
retry_tls(
result); // Same write operation, not a new app attempt.
}
attempts = attempt;
reply_deadline = std::min(
deadline,
std::chrono::steady_clock::now() +
std::chrono::milliseconds(250 * (1 << (attempt - 1))));
for (;;) {
check_deadline();
if (std::chrono::steady_clock::now() >= reply_deadline) {
break;
}
std::size_t received = 0;
ERR_clear_error();
const int result =
SSL_read_ex(tls, reply.data(), reply.size(), &received);
if (result != 1) {
const int error = SSL_get_error(tls, result);
if (error != SSL_ERROR_WANT_READ &&
error != SSL_ERROR_WANT_WRITE) {
throw std::runtime_error("DTLS reply read failed");
}
if (!wait_for(error == SSL_ERROR_WANT_READ ? POLLIN
: POLLOUT)) {
if (error == SSL_ERROR_WANT_WRITE) {
throw std::runtime_error(
"reply window expired during required protocol "
"write");
}
break;
}
continue;
}
if (received != 14 || reply[0] != 1 || reply[1] != 2) {
throw std::runtime_error("invalid reply format");
}
if (get_u32(reply.data() + 2) != request_id) {
if (++ignored > 16) {
throw std::runtime_error(
"unrelated-reply budget exceeded");
}
continue; // Do not restart either deadline.
}
if (get_u32(reply.data() + 6) != amount ||
get_u32(reply.data() + 10) != 1) {
throw std::runtime_error(
"matching reply has unexpected result");
}
operation_confirmed = true;
break;
}
}
if (!operation_confirmed) {
throw std::runtime_error("no matching reply after 3 attempts");
}
reply_deadline = deadline;
// Send our notification, without claiming a reliably delivered alert.
for (;;) {
check_deadline();
ERR_clear_error();
const int result = SSL_shutdown(tls);
if (result >= 0) {
break;
}
retry_tls(result);
}
std::cout << "verified=" << hostname
<< " protocol=" << SSL_get_version(tls) << '\n'
<< "id=" << request_id << " value=" << amount
<< " executions=1 attempts=" << attempts
<< " ignored=" << ignored << '\n';
} catch (const std::exception& error) {
std::cerr << error.what();
if (request_may_have_run) {
std::cerr << (operation_confirmed ? "; operation was confirmed"
: "; operation outcome unknown");
}
std::cerr << '\n';
status = 1;
}
SSL_free(tls);
SSL_CTX_free(context);
if (fd >= 0) {
::close(fd);
}
return status;
}
|
Read the single-slot C++ server
Focus on cached_request, the one cached response, and the branch that prints APPLIED versus CACHED. The simulated state change and cache live only in this process and DTLS association.
Full standalone server: app/dtls-ids/server.cpp
| #include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <unistd.h>
int main(int argc, char** argv) {
int fd = -1;
SSL_CTX* context = nullptr;
SSL* tls = nullptr;
int status = 0;
try {
if (argc < 4 || argc > 5) {
throw std::invalid_argument("usage: dtls_ids_server port "
"server.pem server.key [session_ms]");
}
unsigned int port = 0;
const std::string_view text = argv[1];
const auto [end, error] =
std::from_chars(text.data(), text.data() + text.size(), port);
if (error != std::errc{} || end != text.data() + text.size() ||
port > 65535) {
throw std::invalid_argument("port must be 0..65535");
}
int session_ms = 5000;
if (argc == 5) {
const std::string_view budget = argv[4];
const auto [last, invalid] = std::from_chars(
budget.data(), budget.data() + budget.size(), session_ms);
if (invalid != std::errc{} ||
last != budget.data() + budget.size() || session_ms < 1 ||
session_ms > 10000) {
throw std::invalid_argument("session_ms must be 1..10000");
}
}
context = SSL_CTX_new(DTLS_server_method());
if (!context ||
SSL_CTX_set_min_proto_version(context, DTLS1_2_VERSION) != 1 ||
SSL_CTX_set_max_proto_version(context, DTLS1_2_VERSION) != 1 ||
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) {
throw std::runtime_error("cannot load DTLS server certificate/key");
}
tls = SSL_new(context);
if (!tls) {
throw std::runtime_error("SSL_new failed");
}
fd = ::socket(AF_INET, SOCK_DGRAM | 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");
}
};
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(static_cast<std::uint16_t>(port));
local.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::bind(fd, reinterpret_cast<const sockaddr*>(&local),
sizeof(local)) < 0) {
throw std::system_error(errno, std::generic_category(), "bind");
}
socklen_t local_size = sizeof(local);
if (::getsockname(fd, reinterpret_cast<sockaddr*>(&local),
&local_size) < 0) {
throw std::system_error(errno, std::generic_category(),
"getsockname");
}
std::cout << "LISTEN " << ntohs(local.sin_port) << std::endl;
// One loopback peer only. This is NOT a public cookie-protected
// listener.
sockaddr_in peer{};
for (;;) {
check_deadline();
char first = 0;
socklen_t peer_size = sizeof(peer);
const auto count =
::recvfrom(fd, &first, 1, MSG_PEEK,
reinterpret_cast<sockaddr*>(&peer), &peer_size);
if (count >= 0) {
break; // Leave the entire first datagram queued for OpenSSL.
}
if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
throw std::system_error(errno, std::generic_category(),
"recvfrom");
}
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, POLLIN, 0};
if (::poll(&ready, 1, static_cast<int>(remaining)) < 0 &&
errno != EINTR) {
throw std::system_error(errno, std::generic_category(),
"poll first peer");
}
}
if (::connect(fd, reinterpret_cast<const sockaddr*>(&peer),
sizeof(peer)) < 0) {
throw std::system_error(errno, std::generic_category(),
"UDP connect");
}
BIO* datagram = BIO_new_dgram(fd, BIO_NOCLOSE);
if (!datagram) {
throw std::runtime_error("cannot create datagram BIO");
}
if (BIO_ctrl(datagram, BIO_CTRL_DGRAM_SET_CONNECTED, 0, &peer) != 1) {
BIO_free(datagram);
throw std::runtime_error("cannot configure connected datagram BIO");
}
SSL_set_bio(tls, datagram, datagram);
SSL_set_options(tls, SSL_OP_NO_QUERY_MTU);
if (DTLS_set_link_mtu(tls, 1200) <= 0) {
throw std::runtime_error("cannot set DTLS link MTU");
}
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");
}
auto wait_ms = remaining;
timeval timer{};
if (DTLSv1_get_timeout(tls, &timer) == 1) {
const auto due =
std::chrono::ceil<std::chrono::milliseconds>(
std::chrono::seconds(timer.tv_sec) +
std::chrono::microseconds(timer.tv_usec))
.count();
wait_ms = std::min(wait_ms, due);
}
pollfd ready{fd, events, 0};
const int result = ::poll(&ready, 1, static_cast<int>(wait_ms));
if (result < 0 && errno == EINTR) {
continue;
}
if (result < 0) {
throw std::system_error(errno, std::generic_category(),
"poll");
}
check_deadline();
if (result > 0) {
return;
}
// A protocol retransmission timer never renews our deadline.
ERR_clear_error();
if (DTLSv1_handle_timeout(tls) < 0) {
throw std::runtime_error("DTLS timer handling failed");
}
}
};
const auto retry_tls = [&](int result) {
// No intervening OpenSSL call: result belongs to the last TLS call.
const int error_code = SSL_get_error(tls, result);
if (error_code == SSL_ERROR_WANT_READ) {
wait_for(POLLIN);
} else if (error_code == SSL_ERROR_WANT_WRITE) {
wait_for(POLLOUT);
} else {
char detail[256]{};
ERR_error_string_n(ERR_get_error(), detail, sizeof(detail));
throw std::runtime_error("TLS operation failed (SSL error " +
std::to_string(error_code) +
"): " + detail);
}
};
for (;;) {
check_deadline();
ERR_clear_error();
const int result = SSL_accept(tls);
if (result == 1) {
break;
}
retry_tls(result);
}
const auto put_u32 = [](unsigned char* out, std::uint32_t value) {
for (int i = 3; i >= 0; --i) {
out[3 - i] =
static_cast<unsigned char>((value >> (i * 8)) & 0xff);
}
};
const auto get_u32 = [](const unsigned char* in) {
std::uint32_t value = 0;
for (int i = 0; i < 4; ++i) {
value = (value << 8) | in[i];
}
return value;
};
std::array<unsigned char, 11> request{};
std::array<unsigned char, 10> cached_request{};
std::array<unsigned char, 14> response{1, 2};
bool cached = false;
unsigned int requests = 0;
unsigned int executions = 0;
std::uint32_t value = 0;
for (;;) {
std::size_t received = 0;
for (;;) {
check_deadline();
ERR_clear_error();
const int result =
SSL_read_ex(tls, request.data(), request.size(), &received);
if (result == 1) {
break;
}
const int error = SSL_get_error(tls, result);
if (error == SSL_ERROR_ZERO_RETURN) {
received = 0; // Client's DTLS notification ends this lab.
break;
}
if (error == SSL_ERROR_WANT_READ ||
error == SSL_ERROR_WANT_WRITE) {
wait_for(error == SSL_ERROR_WANT_READ ? POLLIN : POLLOUT);
} else {
throw std::runtime_error("DTLS request read failed");
}
}
if (received == 0) {
break;
}
if (received != 10 || request[0] != 1 || request[1] != 1) {
throw std::runtime_error("invalid request format");
}
if (++requests > 16) {
throw std::runtime_error("request-count budget exceeded");
}
const auto id = get_u32(request.data() + 2);
const auto amount = get_u32(request.data() + 6);
if (amount < 1 || amount > 100) {
throw std::runtime_error("amount must be 1..100");
}
if (!cached) {
std::copy_n(request.begin(), 10, cached_request.begin());
value +=
amount; // In-memory demonstration, not a device command.
++executions;
put_u32(response.data() + 2, id);
put_u32(response.data() + 6, value);
put_u32(response.data() + 10, executions);
cached = true; // Cache and effect exist only in this
// process/session.
std::cout << "APPLIED id=" << id << " value=" << value
<< " executions=" << executions << std::endl;
} else {
if (id != get_u32(cached_request.data() + 2)) {
throw std::runtime_error(
"only one request ID is supported in this session");
}
if (!std::equal(cached_request.begin(), cached_request.end(),
request.begin())) {
throw std::runtime_error(
"same ID with different request bytes");
}
std::cout << "CACHED id=" << id << " requests=" << requests
<< std::endl;
}
if (response.size() > DTLS_get_data_mtu(tls)) {
throw std::runtime_error("reply exceeds data MTU");
}
for (;;) {
check_deadline();
std::size_t written = 0;
ERR_clear_error();
const int result = SSL_write_ex(tls, response.data(),
response.size(), &written);
if (result == 1) {
if (written != response.size()) {
throw std::runtime_error("incomplete reply datagram");
}
break;
}
retry_tls(result);
}
}
if (!cached) {
throw std::runtime_error("session ended without an operation");
}
for (;;) {
check_deadline();
ERR_clear_error();
const int result = SSL_shutdown(tls);
if (result >= 0) {
break;
}
retry_tls(result);
}
std::cout << "requests=" << requests << " executions=" << executions
<< " value=" << value << " close_notify=received\n";
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
status = 1;
}
SSL_free(tls);
SSL_CTX_free(context);
if (fd >= 0) {
::close(fd);
}
return status;
}
|
Bound attempts, records and three clocks
| Limit or timer |
Policy in this example |
| Application attempts |
At most 3: initial send plus 2 retries |
| Reply windows |
250, 500 and 1000 ms after each completed send |
| Whole client session |
Fixed default 3000 ms including DTLS handshake |
| DTLS protocol timer |
Still handled by OpenSSL, independently |
| Unrelated replies |
At most 16 across attempts |
| Server requests |
At most 16 including duplicates |
| Cached operations |
Exactly one slot per association |
The reply windows are capped by the original session deadline; a late handshake may leave less time. Ignore traffic and retries never renew that overall budget. While waiting, poll() considers readiness, the DTLS timer and the current reply window.
A pending TLS write is retried with unchanged arguments until that operation completes or the session fails. That is different from starting a new application attempt. If a receive operation needs a protocol write but its reply window expires before progress, this reduced client fails rather than replacing that pending work with another request.
These short backoff windows make a loopback exercise observable. They are not an Internet congestion-control algorithm, RTT estimator or general retry recommendation. Production UDP protocols need appropriate congestion control and recovery design; see the UDP usage guidelines.
A missing result can leave the operation outcome unknown
Run the two failure fixtures:
| ctest --preset native-debug -V -R '^network.dtls_ids.drop_all_requests$'
ctest --preset native-debug -V -R '^network.dtls_ids.drop_all_replies$'
|
Both clients exhaust three attempts and report operation outcome unknown. In the first fixture, no request reached the server. In the second, the server applied the operation once and reused the result, but no reply reached the client.
A timeout cannot distinguish those situations. It is not evidence of rollback. Do not automatically create a new ID after an uncertain response to a real side-effecting command.
This cache is not durable: process failure, session replacement or eviction would remove its protection. It does not provide global exactly-once execution, transactional side effects, persistence or cross-reconnect deduplication. A real design must specify those lifetimes and failure boundaries.
On success, the client sends a local DTLS shutdown notification. The server waits for that notification to finish the lab and print its final summary. Losing it can leave the server waiting until its unchanged deadline even after a confirmed operation; that does not undo the operation.
Compile separately and run all checks
From network/:
| mkdir -p build/direct-dtls-ids
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
app/dtls-ids/client.cpp -o build/direct-dtls-ids/dtls_ids_client \
$(pkg-config --cflags --libs openssl)
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
app/dtls-ids/server.cpp -o build/direct-dtls-ids/dtls_ids_server \
$(pkg-config --cflags --libs openssl)
ctest --preset native-debug -R network.dtls_ids --output-on-failure
|
The checks use C++ peers, independent OpenSSL CLI peers and a bounded UDP relay. They cover lost requests/replies, delayed replies from an earlier attempt, repeated encrypted records versus new application sends, matching IDs with changed payloads, malformed packets, cache/work limits, invalid trust/name checks and fixed deadlines.
The application-ID oracle uses Python's network-order struct.pack, not the C++ serialization helper. This is controlled loopback verification, not a real-network loss-rate measurement, a complete replay-window audit or a production command protocol.
Verification scope
Verified on 23 October 2026 on a Raspberry Pi 4B running 64-bit Raspberry Pi OS (Debian 13), plus a Linux x86-64 host. All 629 course checks passed in Debug and Release on both machines; 40 exercise this lesson. The same 40 cases passed against directly compiled pairs on both. The Pi reused isolated official OpenSSL headers and existing runtime libraries without system package or trust changes. The effect is an in-memory counter, not a hardware command or durable transaction.
Troubleshooting
- Attempts rise but executions stays 1: this is expected for reply loss. Distinguish transmissions from work.
- Client reports outcome unknown: inspect server-side evidence, but do not infer that nothing ran. The client has no confirmed result.
- Same ID with different request bytes: retries must preserve the operation's contents, not just its ID.
- Only one request ID is supported: this server intentionally implements one logical operation, not a general cache.
- Unrelated reply ignored: a valid shape or authenticated sender does not make another ID's result yours.
- Server waits after client success: the ending DTLS notification can be lost. Its absence does not invalidate an already received result.
- Server expired before use: prepare both terminals first and use the documented server budget. Keep the loopback bind and restart for each session.
Check your understanding
- Should a retry get a new operation ID?
- Can a response from attempt 1 confirm the operation during attempt 2?
- Why compare payload bytes as well as the ID?
- Why does DTLS record replay protection not prevent every duplicate operation?
- Does exhausting retries mean the operation was not applied?
- Does a one-slot in-memory cache survive a reconnect or crash?
Answers
Keep the same ID and contents for the same operation. A delayed matching response is still useful. Changed content under the same ID must not become different work. A fresh DTLS record can carry the same application operation, so record replay filtering and application caching solve different problems. Missing confirmation leaves the outcome unknown. This cache is limited to the current process/association and is not durable.
Continue the course
Previous: Your First Verified DTLS Exchange · Course overview
This completes the first bounded DTLS application path. Continue with QUIC Streams and Toolchain to distinguish transport streams from HTTP/3 and check your development files before sending packets.