Commit 2d2363ee97 for openssl.org

commit 2d2363ee97be027fec500eb9652de67bd3be8c81
Author: Jakub Zelenka <jakub.zelenka@openssl.foundation>
Date:   Fri Jul 10 20:53:16 2026 +0200

    quic-radix: add thread-assisted idle keepalive test

    Add a radix vignette covering thread-assisted mode: with the client's
    per-op ticking disabled, only its assist thread can act on the
    connection, so skipping the virtual time past the idle timeout keeps the
    server up only if the assist thread keeps sending keepalives. This
    migrates the thread-assisted idle coverage away from the
    QUIC_TSERVER-based test.

    The client is created with OSSL_QUIC_client_thread_method() so the
    method itself stays covered as well. The client and listener are linked
    with an in-memory datagram BIO pair instead of real UDP sockets, so
    keepalive delivery cannot race the virtual clock in the OS UDP path.

    New ops: hf_set_tick_active (enable/disable per-op ticking),
    hf_skip_time_wait (skip virtual time, wake the assist thread and wait
    for it to catch up), hf_expect_connected, and hf_link_dgram_pair.

    Assisted-by: Claude:claude-opus-4-8

    Reviewed-by: Saša NedvÄ›dický <sashan@openssl.org>
    Reviewed-by: Andrew Dinh <andrewd@openssl.org>
    MergeDate: Mon Aug  3 02:12:44 2026
    (Merged from https://github.com/openssl/openssl/pull/31918)

diff --git a/test/radix/quic_bindings.c b/test/radix/quic_bindings.c
index de066deb46..f31eb103a7 100644
--- a/test/radix/quic_bindings.c
+++ b/test/radix/quic_bindings.c
@@ -13,6 +13,7 @@
 #include "internal/quic_channel.h"
 #include "internal/quic_ssl.h"
 #include "internal/quic_error.h"
+#include "internal/quic_thread_assist.h"

 /*
  * RADIX 6D QUIC Test Framework
diff --git a/test/radix/quic_ops.c b/test/radix/quic_ops.c
index f7f11bd3b9..25bcd797bf 100644
--- a/test/radix/quic_ops.c
+++ b/test/radix/quic_ops.c
@@ -138,6 +138,18 @@ err:
     return ok;
 }

+/* Attaches bio as both rbio and wbio, consuming the caller's reference. */
+static int ssl_attach_bio(SSL *ssl, BIO *bio)
+{
+    SSL_set0_rbio(ssl, bio);
+    if (!TEST_true(BIO_up_ref(bio)))
+        return 0;
+
+    SSL_set0_wbio(ssl, bio);
+
+    return 1;
+}
+
 static int ssl_attach_bio_dgram(SSL *ssl,
     uint16_t local_port, uint16_t *actual_port)
 {
@@ -152,13 +164,7 @@ static int ssl_attach_bio_dgram(SSL *ssl,
         return 0;
     }

-    SSL_set0_rbio(ssl, bio);
-    if (!TEST_true(BIO_up_ref(bio)))
-        return 0;
-
-    SSL_set0_wbio(ssl, bio);
-
-    return 1;
+    return ssl_attach_bio(ssl, bio);
 }

 DEF_FUNC(hf_new_ssl)
@@ -169,22 +175,51 @@ DEF_FUNC(hf_new_ssl)
     const SSL_METHOD *method;
     SSL *ssl;
     uint64_t flags;
-    int is_server, is_domain;
+    int is_server, is_domain, is_ta, is_no_bio;

     F_POP2(name, flags);

     is_domain = ((flags & 2) != 0);
     is_server = ((flags & 1) != 0);
+    is_ta = ((flags & 4) != 0);
+    is_no_bio = ((flags & 8) != 0);
+
+    if (is_server)
+        method = OSSL_QUIC_server_method();
+    else if (is_ta)
+        method = OSSL_QUIC_client_thread_method();
+    else
+        method = OSSL_QUIC_client_method();

-    method = is_server ? OSSL_QUIC_server_method() : OSSL_QUIC_client_method();
     if (!TEST_ptr(ctx = SSL_CTX_new(method)))
         goto err;

+#if defined(OPENSSL_NO_QUIC_THREAD_ASSIST) || !defined(OPENSSL_THREADS)
+    if (is_ta) {
+        TEST_skip("thread assisted mode not available");
+        F_SKIP_REST();
+    }
+#endif
+
 #if defined(OPENSSL_THREADS)
-    if (!TEST_true(SSL_CTX_set_domain_flags(ctx,
-            SSL_DOMAIN_FLAG_MULTI_THREAD
-                | SSL_DOMAIN_FLAG_BLOCKING)))
+    if (is_ta) {
+        uint64_t domain_flags = 0;
+
+        /*
+         * Rely on the OSSL_QUIC_client_thread_method() domain flag defaults
+         * rather than setting them so the method's defaulting stays covered.
+         */
+        if (!TEST_true(SSL_CTX_get_domain_flags(ctx, &domain_flags))
+            || !TEST_uint64_t_eq(domain_flags,
+                SSL_DOMAIN_FLAG_MULTI_THREAD
+                    | SSL_DOMAIN_FLAG_THREAD_ASSISTED
+                    | SSL_DOMAIN_FLAG_BLOCKING))
+            goto err;
+    } else if (!TEST_true(SSL_CTX_set_domain_flags(ctx,
+                   SSL_DOMAIN_FLAG_MULTI_THREAD
+                       | SSL_DOMAIN_FLAG_BLOCKING))) {
         goto err;
+    }
 #endif

     if (!TEST_true(ssl_ctx_configure(ctx, is_server)))
@@ -202,7 +237,8 @@ DEF_FUNC(hf_new_ssl)
             goto err;
     }

-    if (!is_domain && !TEST_true(ssl_attach_bio_dgram(ssl, 0, NULL)))
+    if (!is_domain && !is_no_bio
+        && !TEST_true(ssl_attach_bio_dgram(ssl, 0, NULL)))
         goto err;

     if (!TEST_true(ossl_quic_set_override_now_cb(ssl, get_time, NULL))) {
@@ -911,6 +947,62 @@ err:
     return ok;
 }

+/*
+ * Link a client and a listener with an in-memory datagram BIO pair. Fake-time
+ * tests need this: a datagram sitting in the OS UDP path while fake time skips
+ * ahead could arrive only after a deadline it preceded in fake time.
+ */
+DEF_FUNC(hf_link_dgram_pair)
+{
+    int ok = 0;
+    SSL *c_ssl, *l_ssl;
+    BIO *c_bio = NULL, *l_bio = NULL;
+    BIO_ADDR *addr = NULL;
+    struct in_addr ina;
+
+    REQUIRE_SSL_2(c_ssl, l_ssl);
+
+    if (!TEST_true(BIO_new_bio_dgram_pair(&c_bio, 0, &l_bio, 0)))
+        goto err;
+
+    if (!TEST_true(BIO_dgram_set_caps(c_bio, BIO_DGRAM_CAP_HANDLES_DST_ADDR))
+        || !TEST_true(BIO_dgram_set_caps(l_bio,
+            BIO_DGRAM_CAP_HANDLES_DST_ADDR)))
+        goto err;
+
+    ina.s_addr = htonl(INADDR_LOOPBACK);
+    if (!TEST_ptr(addr = BIO_ADDR_new())
+        || !TEST_true(BIO_ADDR_rawmake(addr, AF_INET, &ina, sizeof(ina), 0)))
+        goto err;
+
+    /* There are no real ports; a stable dummy address is all that is needed. */
+    if (!TEST_true(SSL_set1_initial_peer_addr(c_ssl, addr)))
+        goto err;
+
+    if (!TEST_true(BIO_dgram_set0_local_addr(c_bio, addr)))
+        goto err;
+    addr = NULL;
+
+    if (!ssl_attach_bio(c_ssl, c_bio)) {
+        c_bio = NULL;
+        goto err;
+    }
+    c_bio = NULL;
+
+    if (!ssl_attach_bio(l_ssl, l_bio)) {
+        l_bio = NULL;
+        goto err;
+    }
+    l_bio = NULL;
+
+    ok = 1;
+err:
+    BIO_free(c_bio);
+    BIO_free(l_bio);
+    BIO_ADDR_free(addr);
+    return ok;
+}
+
 DEF_FUNC(hf_set_peer_addr_from)
 {
     int ok = 0;
@@ -966,6 +1058,80 @@ err:
     return ok;
 }

+DEF_FUNC(hf_set_tick_active)
+{
+    int ok = 0;
+    uint64_t active;
+    const char *name;
+    RADIX_OBJ *obj;
+
+    F_POP2(name, active);
+    if (!TEST_ptr(obj = RADIX_PROCESS_get_obj(RP(), name)))
+        goto err;
+
+    obj->active = (active != 0);
+    ok = 1;
+err:
+    return ok;
+}
+
+/*
+ * Skip fake time and wait for the assist thread to catch up. It waits on real
+ * time internally, so wake it and spin until the event timeout is back in the
+ * future, meaning everything due up to now (any keepalive) has been serviced.
+ */
+DEF_FUNC(hf_skip_time_wait)
+{
+    int ok = 0;
+    uint64_t ms;
+    SSL *ssl;
+    struct timeval tv;
+    int is_infinite;
+
+    REQUIRE_SSL(ssl);
+    F_POP(ms);
+
+    if (RT()->scratch0 == 0) {
+        /* Skip only once; spin re-entries pass through here. */
+        radix_skip_time(ossl_ms2time(ms));
+        RT()->scratch0 = 1;
+    }
+
+    ossl_quic_conn_force_assist_thread_wake(ssl);
+
+    if (!TEST_true(SSL_get_event_timeout(ssl, &tv, &is_infinite)))
+        goto err;
+
+    /* {0,0} (subtract saturates) means an event is still pending. */
+    if (!is_infinite && tv.tv_sec == 0 && tv.tv_usec == 0) {
+        OSSL_sleep(1); /* Yield so the assist thread can run. */
+        F_SPIN_AGAIN();
+    }
+
+    RT()->scratch0 = 0; /* done; not reset at err, as spins pass through it */
+    ok = 1;
+err:
+    return ok;
+}
+
+DEF_FUNC(hf_expect_connected)
+{
+    int ok = 0;
+    SSL *ssl;
+    QUIC_CHANNEL *ch;
+
+    REQUIRE_SSL(ssl);
+    if (!TEST_ptr(ch = ossl_quic_conn_get_channel(ssl)))
+        goto err;
+
+    if (!TEST_true(ossl_quic_channel_is_active(ch)))
+        goto err;
+
+    ok = 1;
+err:
+    return ok;
+}
+
 DEF_FUNC(hf_override_key_update)
 {
     int ok = 0;
@@ -1526,6 +1692,42 @@ err:
     (OP_PUSH_U64(ms), \
         OP_FUNC(hf_sleep))

+/* Thread-assisted client, no socket (link a BIO pair instead). */
+#define OP_NEW_SSL_C_TA_MEM(name) \
+    (OP_PUSH_PZ(#name),           \
+        OP_PUSH_U64(4 | 8),       \
+        OP_FUNC(hf_new_ssl))
+
+/* Listener, no socket (link a BIO pair instead). */
+#define OP_NEW_SSL_L_MEM(name) \
+    (OP_PUSH_PZ(#name),        \
+        OP_PUSH_U64(1 | 8),    \
+        OP_FUNC(hf_new_ssl))
+
+#define OP_LINK_DGRAM_PAIR(client_name, listener_name) \
+    (OP_SELECT_SSL(0, client_name),                    \
+        OP_SELECT_SSL(1, listener_name),               \
+        OP_FUNC(hf_link_dgram_pair))
+
+#define OP_TICK_DISABLE(name) \
+    (OP_PUSH_PZ(#name),       \
+        OP_PUSH_U64(0),       \
+        OP_FUNC(hf_set_tick_active))
+
+#define OP_TICK_ENABLE(name) \
+    (OP_PUSH_PZ(#name),      \
+        OP_PUSH_U64(1),      \
+        OP_FUNC(hf_set_tick_active))
+
+#define OP_SKIP_TIME_WAIT(name, ms) \
+    (OP_SELECT_SSL(0, name),        \
+        OP_PUSH_U64(ms),            \
+        OP_FUNC(hf_skip_time_wait))
+
+#define OP_EXPECT_CONNECTED(name) \
+    (OP_SELECT_SSL(0, name),      \
+        OP_FUNC(hf_expect_connected))
+
 #define OP_OVERRIDE_KEY_UPDATE(name, threshold) \
     (OP_SELECT_SSL(0, name),                    \
         OP_PUSH_U64(threshold),                 \
diff --git a/test/radix/quic_tests.c b/test/radix/quic_tests.c
index fa96845ea2..1945480e98 100644
--- a/test/radix/quic_tests.c
+++ b/test/radix/quic_tests.c
@@ -946,6 +946,39 @@ DEF_SCRIPT(check_ctx_cbks, "Check new_pending and client_hello callbacks")
     OP_FUNC(check_pending);
 }

+/*
+ * With client ticking disabled only its assist thread can act, so skipping fake
+ * time past the 30s idle timeout keeps the server up only if the assist thread
+ * keeps sending keepalives.
+ */
+DEF_SCRIPT(check_thread_assisted_idle,
+    "thread-assisted mode keeps an idle connection alive")
+{
+    size_t i;
+
+    OP_NEW_SSL_L_MEM(L);
+    OP_NEW_SSL_C_TA_MEM(C);
+    OP_LINK_DGRAM_PAIR(C, L);
+    OP_LISTEN(L);
+    OP_CONNECT_WAIT(C);
+
+    OP_ACCEPT_CONN_WAIT(L, Sa, 0);
+    OP_ACCEPT_CONN_NONE(L);
+
+    OP_WRITE_B(C, "apple");
+    OP_READ_EXPECT_B(Sa, "apple");
+
+    OP_TICK_DISABLE(C);
+
+    /* Step well below the keepalive interval so due PINGs can be serviced. */
+    for (i = 0; i < 40; ++i) {
+        OP_SKIP_TIME_WAIT(C, 1000);
+        OP_EXPECT_CONNECTED(Sa);
+    }
+
+    OP_TICK_ENABLE(C);
+}
+
 DEF_FUNC(check_stream_reset_5)
 {
     int ok = 0;
@@ -2437,6 +2470,7 @@ static SCRIPT_INFO *const scripts[] = {
     USE(check_cwm),
     USE(check_pc_flood),
     USE(check_ctx_cbks),
+    USE(check_thread_assisted_idle),
     USE(script_5),
     USE(script_6),
     USE(script_7),