Commit d14b736193 for openssl.org
commit d14b7361938d9695f10b0581f5875ca701146961
Author: Matt Caswell <matt@openssl.foundation>
Date: Mon Aug 10 15:52:00 2026 +0100
DTLS 1.3 Wait rather than spin in the blocking accept path
SSL_accept_connection() looped calling ossl_dtls_tick() with nothing in
between, so it only avoided spinning if the application supplied a
blocking network BIO and each tick slept inside BIO_recvmmsg().
A listener cannot require that. It demultiplexes one socket to many
connections and ossl_dgram_demux_pump() holds the demux lock across the
receive, so a thread blocked in accept stalls reads on every established
connection until a datagram for any peer arrives. QUIC takes the
opposite approach, keeping the socket non-blocking and waiting for
readiness instead.
Do the same: set the listener's network BIO non-blocking and wait
between ticks via ossl_dtls_block_until_ready(), which reuses
SSL_poll()'s blocking machinery. It reads nothing out, so the caller
re-tests its own condition and waits again if the wakeup was not for it.
Assisted-by: Claude Code:claude-opus-5
Reviewed-by: Ryan Hooper <ryanh@openssl.foundation>
Reviewed-by: Jakub Zelenka <jakub.zelenka@openssl.foundation>
Merge-date: Mon Aug 17 08:29:51 2026
Merged-from: https://github.com/openssl/openssl/pull/32324
diff --git a/ssl/d1_lib.c b/ssl/d1_lib.c
index 05988b7bc1..0e8f1741f8 100644
--- a/ssl/d1_lib.c
+++ b/ssl/d1_lib.c
@@ -2457,19 +2457,13 @@ SSL *ossl_dtls_accept_connection(SSL *ssl, uint64_t flags)
}
/*
- * Loop calling ossl_dtls_tick() until a verified connection arrives or
- * a fatal error occurs.
+ * Blocking path: tick to make whatever progress is possible now, and if
+ * that did not produce a connection, wait for readiness before ticking
+ * again.
*
- * This blocking accept path requires net_rbio to be a blocking BIO. With
- * a blocking BIO each tick sleeps inside BIO_recvmmsg() until a datagram
- * is received, so the loop waits efficiently and does not spin.
- *
- * If net_rbio were non-blocking, BIO_recvmmsg() would return a transient
- * (non-fatal) error when no datagram is ready; ossl_dtls_tick() would then
- * return >= 0 with the incoming queue still empty and this loop would busy
- * spin. Callers that want non-blocking behaviour must use
- * SSL_ACCEPT_CONNECTION_NO_BLOCK (handled above) instead of a non-blocking
- * BIO on this path.
+ * The wait is what stops this from being a busy loop. The network BIO is
+ * non-blocking, so a tick which finds no datagram returns immediately;
+ * without waiting in between, this loop would spin.
*/
for (;;) {
if (ossl_dtls_tick(dl) < 0) {
@@ -2483,6 +2477,16 @@ SSL *ossl_dtls_accept_connection(SSL *ssl, uint64_t flags)
ossl_crypto_mutex_unlock(dl->mutex);
if (conn != NULL)
break;
+
+ /*
+ * Nothing yet, so wait for the listener to become ready before ticking
+ * again. What that amounts to is decided by the poll translation for a
+ * listener: the network socket becoming readable, or another thread
+ * signalling the notifier because it produced readiness on our behalf.
+ */
+ if (!ossl_dtls_block_until_ready(ssl, SSL_POLL_EVENT_IC,
+ ossl_time_infinite()))
+ break;
}
end:
@@ -2534,6 +2538,16 @@ void ossl_dtls_listener_set0_net_rbio(SSL *s, BIO *bio)
dl = (DTLS_LISTENER *)s;
+ /*
+ * The listener demultiplexes one socket to many connections, so it can
+ * never afford to block inside a read: a read for one connection would
+ * stall every other, and the demux lock is held across it. Blocking
+ * behaviour is provided by waiting for readiness instead, so configure the
+ * BIO for non-blocking operation on the application's behalf, as QUIC does.
+ */
+ if (bio != NULL)
+ BIO_set_nbio(bio, 1); /* best effort autoconfig */
+
ossl_crypto_mutex_lock(dl->mutex);
/*
@@ -2601,6 +2615,10 @@ void ossl_dtls_listener_set0_net_wbio(SSL *s, BIO *bio)
dl = (DTLS_LISTENER *)s;
+ /* See ossl_dtls_listener_set0_net_rbio() as to why. */
+ if (bio != NULL)
+ BIO_set_nbio(bio, 1); /* best effort autoconfig */
+
old_wbio = dl->net_wbio;
/* No change - nothing to do */
diff --git a/ssl/rio/poll_immediate.c b/ssl/rio/poll_immediate.c
index 3b32d679b8..6a622fc16a 100644
--- a/ssl/rio/poll_immediate.c
+++ b/ssl/rio/poll_immediate.c
@@ -196,6 +196,13 @@ static int poll_translate_ssl_dtls_listener(SSL *ssl,
return 0;
}
+ /*
+ * Watch the socket for readability whatever was asked for. Every event a
+ * listener reports is ultimately driven by a datagram arriving, both an
+ * incoming connection and data pending on the listener itself, so there is
+ * no event for which this is the wrong thing to wait on. events is
+ * therefore only consulted for the readiness re-check below.
+ */
if (!ossl_rio_poll_builder_add_fd(rpb, desc.value.fd, /*r=*/1, /*w=*/0))
return 0;
@@ -641,6 +648,37 @@ out:
#endif
return ok;
}
+
+#ifndef OPENSSL_NO_DTLS
+/*
+ * Wait until the given DTLS listener or listener-based connection may have
+ * become ready for one of the given events, or until the deadline expires.
+ *
+ * This is the wait that libssl itself performs when a DTLS object is used in
+ * blocking mode. It is the same wait SSL_poll() performs, and reuses it, so a
+ * blocking call is woken by the same means an application polling the object
+ * would be: readiness of the listener's socket, or the listener's notifier if
+ * another thread produces readiness without the socket becoming readable here.
+ *
+ * No readout is performed. A spurious wakeup is always possible - the socket
+ * becoming readable says nothing about which connection the datagram is for -
+ * so the caller must re-test its own condition and wait again if needed.
+ *
+ * Returns 1 if the wait completed and 0 on error.
+ */
+int ossl_dtls_block_until_ready(SSL *ssl, uint64_t events, OSSL_TIME deadline)
+{
+ SSL_POLL_ITEM item;
+ size_t result_count = 0;
+
+ item.desc.type = BIO_POLL_DESCRIPTOR_TYPE_SSL;
+ item.desc.value.ssl = ssl;
+ item.events = events;
+ item.revents = 0;
+
+ return poll_block(&item, 1, sizeof(item), deadline, &result_count);
+}
+#endif /* OPENSSL_NO_DTLS */
#endif
static int poll_readout(SSL_POLL_ITEM *items,
diff --git a/ssl/ssl_local.h b/ssl/ssl_local.h
index 4a83302c31..3020f44ec5 100644
--- a/ssl/ssl_local.h
+++ b/ssl/ssl_local.h
@@ -3064,6 +3064,7 @@ int ossl_dtls_conn_poll_events(SSL *s, uint64_t events, int do_tick,
uint64_t *revents);
void ossl_dtls_listener_enter_blocking_section(SSL *s);
void ossl_dtls_listener_leave_blocking_section(SSL *s);
+int ossl_dtls_block_until_ready(SSL *ssl, uint64_t events, OSSL_TIME deadline);
int ossl_dtls_tick(DTLS_LISTENER *dl);
/* DTLS Listener internal cookie callbacks */
diff --git a/test/dtls_multithread_test.c b/test/dtls_multithread_test.c
index ac5998ae32..8d312a40cc 100644
--- a/test/dtls_multithread_test.c
+++ b/test/dtls_multithread_test.c
@@ -444,6 +444,124 @@ err:
return testresult;
}
+/*
+ * Per-thread state for the blocking accept
+ */
+struct accept_thread_args {
+ SSL *listener;
+ SSL *conn; /* connection the accept returned */
+ CRYPTO_THREAD *thread;
+ int result; /* 1 = success, 0 = failure */
+};
+
+/*
+ * Thread function: block in SSL_accept_connection() until a connection turns
+ * up. This is the accept path which ticks the listener itself, so no other
+ * thread needs to drive it.
+ */
+static unsigned int blocking_accept_thread(void *arg)
+{
+ struct accept_thread_args *ta = (struct accept_thread_args *)arg;
+
+ ta->conn = SSL_accept_connection(ta->listener, 0);
+ ta->result = (ta->conn != NULL);
+ return 1;
+}
+
+/*
+ * Test that a blocking SSL_accept_connection() waits for a connection and
+ * returns it.
+ *
+ * The listener demultiplexes one socket to many connections, so it cannot
+ * block inside a read: doing so would stall every other connection, and the
+ * demux lock is held across it. Blocking accept therefore has to wait for
+ * readiness rather than for a datagram, which is what this exercises - a
+ * client is only created once the accepting thread is already in the call.
+ *
+ * Note that this cannot distinguish waiting from spinning: the accept returns
+ * the connection either way, and the difference is CPU consumed rather than
+ * anything observable through the API. It is a test that the blocking path
+ * works at all, which was previously only covered for the failure case of
+ * having no BIO set.
+ */
+static int test_dtls_blocking_accept(void)
+{
+ SSL_CTX *sctx = NULL, *cctx = NULL;
+ SSL *listener = NULL, *client = NULL;
+ struct accept_thread_args accept_args;
+ BIO_ADDR *server_addr = NULL;
+ int server_fd = -1, client_fd = -1;
+ int testresult = 0;
+ int i, ret, err;
+
+ memset(&accept_args, 0, sizeof(accept_args));
+
+ if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+ DTLS_client_method(), 0, 0, &sctx, &cctx, cert, privkey)))
+ goto err;
+
+ if (!TEST_true(create_listener(sctx, &listener, &server_addr, &server_fd)))
+ goto err;
+
+ /*
+ * Create the client's socket first, but do not connect with it yet. There
+ * is no way to cancel a blocking SSL_accept_connection(), so a thread
+ * parked in one is released only by a connection arriving - which means
+ * nothing between starting the thread and the exchange completing may bail
+ * out, or the join below would wait for ever. Anything which can fail is
+ * therefore done up front. The accept still has to wait, since no datagram
+ * is sent until SSL_connect() below.
+ */
+ if (!TEST_true(create_client(cctx, server_addr, &client, &client_fd)))
+ goto err;
+
+ accept_args.listener = listener;
+ accept_args.thread = ossl_crypto_thread_native_start(blocking_accept_thread,
+ &accept_args, 1);
+ if (!TEST_ptr(accept_args.thread))
+ goto err;
+
+ /*
+ * Drive the client's side of the cookie exchange. The accepting thread
+ * ticks the listener, so this only has to keep the client moving.
+ */
+ SSL_set_connect_state(client);
+ for (i = 0; i < 200 && accept_args.result == 0; i++) {
+ ret = SSL_connect(client);
+ err = SSL_get_error(client, ret);
+ if (ret <= 0 && err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE) {
+ TEST_error("SSL_connect failed (err %d)", err);
+ goto err;
+ }
+ OSSL_sleep(10);
+ }
+
+ ossl_crypto_thread_native_join(accept_args.thread, NULL);
+ ossl_crypto_thread_native_clean(accept_args.thread);
+ accept_args.thread = NULL;
+
+ if (!TEST_int_eq(accept_args.result, 1) || !TEST_ptr(accept_args.conn))
+ goto err;
+
+ testresult = 1;
+err:
+ if (accept_args.thread != NULL) {
+ ossl_crypto_thread_native_join(accept_args.thread, NULL);
+ ossl_crypto_thread_native_clean(accept_args.thread);
+ }
+ SSL_free(accept_args.conn);
+ SSL_free(client);
+ SSL_free(listener);
+ BIO_ADDR_free(server_addr);
+ if (server_fd >= 0)
+ BIO_closesocket(server_fd);
+ if (client_fd >= 0)
+ BIO_closesocket(client_fd);
+ SSL_CTX_free(sctx);
+ SSL_CTX_free(cctx);
+ return testresult;
+}
+
int setup_tests(void)
{
if (!TEST_ptr(cert = test_get_argument(0))
@@ -451,5 +569,6 @@ int setup_tests(void)
return 0;
ADD_TEST(test_dtls_multithread);
+ ADD_TEST(test_dtls_blocking_accept);
return 1;
}