From 0f76c89b6b714778b24bd1fec66e5cda05cff58a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 20 Jan 2026 19:03:37 -0800 Subject: [PATCH] TLS shutdown tests and fixes --- src/openssl/src/openssl_stream.cpp | 6 +- src/wolfssl/src/wolfssl_stream.cpp | 17 ++- test/unit/tls/openssl_stream.cpp | 32 +++++ test/unit/tls/test_utils.hpp | 213 +++++++++++++++++++++++++++++ test/unit/tls/wolfssl_stream.cpp | 32 +++++ 5 files changed, 297 insertions(+), 3 deletions(-) diff --git a/src/openssl/src/openssl_stream.cpp b/src/openssl/src/openssl_stream.cpp index 362ea31..df7bbee 100644 --- a/src/openssl/src/openssl_stream.cpp +++ b/src/openssl/src/openssl_stream.cpp @@ -357,9 +357,11 @@ struct openssl_stream_impl_ { if(ec == make_error_code(capy::error::eof)) { - // Check if we got a proper shutdown + // Check if we got a proper TLS shutdown if(SSL_get_shutdown(ssl_) & SSL_RECEIVED_SHUTDOWN) ec = make_error_code(capy::error::eof); + else + ec = make_error_code(capy::error::stream_truncated); } goto done; } @@ -373,7 +375,7 @@ struct openssl_stream_impl_ { unsigned long ssl_err = ERR_get_error(); if(ssl_err == 0) - ec = make_error_code(capy::error::eof); + ec = make_error_code(capy::error::stream_truncated); else ec = system::error_code( static_cast(ssl_err), system::system_category()); diff --git a/src/wolfssl/src/wolfssl_stream.cpp b/src/wolfssl/src/wolfssl_stream.cpp index 7e0feca..0835765 100644 --- a/src/wolfssl/src/wolfssl_stream.cpp +++ b/src/wolfssl/src/wolfssl_stream.cpp @@ -421,7 +421,22 @@ struct wolfssl_stream_impl_ if(read_in_pos_ == read_in_len_) { read_in_pos_ = 0; read_in_len_ = 0; } capy::mutable_buffer buf(read_in_buf_.data() + read_in_len_, read_in_buf_.size() - read_in_len_); auto [rec, rn] = co_await do_underlying_read(buf); - if(rec) { ec = rec; goto done; } + if(rec) + { + if(rec == make_error_code(capy::error::eof)) + { + // Check if we got a proper TLS shutdown + if(wolfSSL_get_shutdown(ssl_) & SSL_RECEIVED_SHUTDOWN) + ec = make_error_code(capy::error::eof); + else + ec = make_error_code(capy::error::stream_truncated); + } + else + { + ec = rec; + } + goto done; + } read_in_len_ += rn; } else if(err == WOLFSSL_ERROR_WANT_WRITE) diff --git a/test/unit/tls/openssl_stream.cpp b/test/unit/tls/openssl_stream.cpp index bd574c0..c02adb1 100644 --- a/test/unit/tls/openssl_stream.cpp +++ b/test/unit/tls/openssl_stream.cpp @@ -73,6 +73,36 @@ struct openssl_stream_test ioc.restart(); } } + + void + testTlsShutdown() + { + using namespace tls::test; + + for( auto mode : { context_mode::shared_cert, + context_mode::separate_cert } ) + { + io_context ioc; + auto [client_ctx, server_ctx] = make_contexts( mode ); + run_tls_shutdown_test( ioc, client_ctx, server_ctx, + make_stream, make_stream ); + } + } + + void + testStreamTruncated() + { + using namespace tls::test; + + for( auto mode : { context_mode::shared_cert, + context_mode::separate_cert } ) + { + io_context ioc; + auto [client_ctx, server_ctx] = make_contexts( mode ); + run_tls_truncation_test( ioc, client_ctx, server_ctx, + make_stream, make_stream ); + } + } #endif void @@ -80,6 +110,8 @@ struct openssl_stream_test { #ifdef BOOST_COROSIO_HAS_OPENSSL testSuccessCases(); + testTlsShutdown(); + testStreamTruncated(); // Failure tests disabled: socket cancellation doesn't propagate to // TLS handshake operations, causing hangs when one side fails. // testFailureCases(); diff --git a/test/unit/tls/test_utils.hpp b/test/unit/tls/test_utils.hpp index d507f99..42bf487 100644 --- a/test/unit/tls/test_utils.hpp +++ b/test/unit/tls/test_utils.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -446,6 +447,218 @@ run_tls_test_fail( s2.close(); } +/** Run a TLS shutdown test with graceful close_notify. + + Tests that one side can initiate TLS shutdown (sends close_notify) + and the other side receives EOF. Uses unidirectional shutdown to + avoid deadlock in single-threaded io_context. + + Note: TLS shutdown in a single-threaded context can deadlock when both + sides wait for each other. We use a timeout to detect and recover from + potential deadlocks. + + @param ioc The io_context to use + @param client_ctx TLS context for the client + @param server_ctx TLS context for the server + @param make_client Factory: (io_stream&, context) -> TLS stream + @param make_server Factory: (io_stream&, context) -> TLS stream +*/ +template +void +run_tls_shutdown_test( + io_context& ioc, + context client_ctx, + context server_ctx, + ClientStreamFactory make_client, + ServerStreamFactory make_server ) +{ + auto [s1, s2] = corosio::test::make_socket_pair( ioc ); + + auto client = make_client( s1, client_ctx ); + auto server = make_server( s2, server_ctx ); + + // Handshake phase + auto client_hs = [&client]() -> capy::task<> + { + auto [ec] = co_await client.handshake( tls_stream::client ); + BOOST_TEST( !ec ); + }; + + auto server_hs = [&server]() -> capy::task<> + { + auto [ec] = co_await server.handshake( tls_stream::server ); + BOOST_TEST( !ec ); + }; + + capy::run_async( ioc.get_executor() )( client_hs() ); + capy::run_async( ioc.get_executor() )( server_hs() ); + + ioc.run(); + ioc.restart(); + + // Data transfer phase + auto transfer_task = [&client, &server]() -> capy::task<> + { + co_await test_stream( client, server ); + }; + capy::run_async( ioc.get_executor() )( transfer_task() ); + + ioc.run(); + ioc.restart(); + + // Shutdown phase with timeout protection + bool shutdown_done = false; + bool read_done = false; + + auto client_shutdown = [&client, &shutdown_done]() -> capy::task<> + { + auto [ec] = co_await client.shutdown(); + shutdown_done = true; + // Shutdown may return success, canceled, or stream_truncated + BOOST_TEST( !ec || ec == capy::cond::stream_truncated || + ec == capy::cond::canceled ); + }; + + auto server_read_eof = [&server, &read_done]() -> capy::task<> + { + char buf[32]; + auto [ec, n] = co_await server.read_some( + capy::mutable_buffer( buf, sizeof( buf ) ) ); + read_done = true; + // Should get EOF, stream_truncated, or canceled + BOOST_TEST( ec == capy::cond::eof || ec == capy::cond::stream_truncated || + ec == capy::cond::canceled ); + }; + + // Timeout to prevent deadlock + timer timeout( ioc ); + timeout.expires_after( std::chrono::milliseconds( 500 ) ); + auto timeout_task = [&timeout, &s1, &s2, &shutdown_done, &read_done]() -> capy::task<> + { + (void)shutdown_done; + (void)read_done; + auto [ec] = co_await timeout.wait(); + if( !ec ) + { + // Timer expired - cancel pending operations (check if still open) + if( s1.is_open() ) { s1.cancel(); s1.close(); } + if( s2.is_open() ) { s2.cancel(); s2.close(); } + } + }; + + capy::run_async( ioc.get_executor() )( client_shutdown() ); + capy::run_async( ioc.get_executor() )( server_read_eof() ); + capy::run_async( ioc.get_executor() )( timeout_task() ); + + ioc.run(); + + timeout.cancel(); + if( s1.is_open() ) s1.close(); + if( s2.is_open() ) s2.close(); +} + +/** Run a test for stream truncation (socket close without TLS shutdown). + + Tests that when one side closes the underlying socket without + performing TLS shutdown, the other side receives stream_truncated. + + @param ioc The io_context to use + @param client_ctx TLS context for the client + @param server_ctx TLS context for the server + @param make_client Factory: (io_stream&, context) -> TLS stream + @param make_server Factory: (io_stream&, context) -> TLS stream +*/ +template +void +run_tls_truncation_test( + io_context& ioc, + context client_ctx, + context server_ctx, + ClientStreamFactory make_client, + ServerStreamFactory make_server ) +{ + auto [s1, s2] = corosio::test::make_socket_pair( ioc ); + + auto client = make_client( s1, client_ctx ); + auto server = make_server( s2, server_ctx ); + + // Handshake phase + auto client_hs = [&client]() -> capy::task<> + { + auto [ec] = co_await client.handshake( tls_stream::client ); + BOOST_TEST( !ec ); + }; + + auto server_hs = [&server]() -> capy::task<> + { + auto [ec] = co_await server.handshake( tls_stream::server ); + BOOST_TEST( !ec ); + }; + + capy::run_async( ioc.get_executor() )( client_hs() ); + capy::run_async( ioc.get_executor() )( server_hs() ); + + ioc.run(); + ioc.restart(); + + // Data transfer phase + auto transfer_task = [&client, &server]() -> capy::task<> + { + co_await test_stream( client, server ); + }; + capy::run_async( ioc.get_executor() )( transfer_task() ); + + ioc.run(); + ioc.restart(); + + // Truncation test with timeout protection + bool read_done = false; + + auto client_close = [&s1]() -> capy::task<> + { + // Close underlying socket without TLS shutdown + s1.close(); + co_return; + }; + + auto server_read_truncated = [&server, &read_done]() -> capy::task<> + { + char buf[32]; + auto [ec, n] = co_await server.read_some( + capy::mutable_buffer( buf, sizeof( buf ) ) ); + read_done = true; + // Should get stream_truncated, eof, or canceled + BOOST_TEST( ec == capy::cond::stream_truncated || + ec == capy::cond::eof || + ec == capy::cond::canceled ); + }; + + // Timeout to prevent deadlock + timer timeout( ioc ); + timeout.expires_after( std::chrono::milliseconds( 500 ) ); + auto timeout_task = [&timeout, &s1, &s2, &read_done]() -> capy::task<> + { + (void)read_done; + auto [ec] = co_await timeout.wait(); + if( !ec ) + { + // Timer expired - cancel pending operations (check if still open) + if( s1.is_open() ) { s1.cancel(); s1.close(); } + if( s2.is_open() ) { s2.cancel(); s2.close(); } + } + }; + + capy::run_async( ioc.get_executor() )( client_close() ); + capy::run_async( ioc.get_executor() )( server_read_truncated() ); + capy::run_async( ioc.get_executor() )( timeout_task() ); + + ioc.run(); + + timeout.cancel(); + if( s1.is_open() ) s1.close(); + if( s2.is_open() ) s2.close(); +} + } // namespace test } // namespace tls } // namespace corosio diff --git a/test/unit/tls/wolfssl_stream.cpp b/test/unit/tls/wolfssl_stream.cpp index c12ff00..c7e6157 100644 --- a/test/unit/tls/wolfssl_stream.cpp +++ b/test/unit/tls/wolfssl_stream.cpp @@ -76,6 +76,36 @@ struct wolfssl_stream_test ioc.restart(); } } + + void + testTlsShutdown() + { + using namespace tls::test; + + for( auto mode : { context_mode::shared_cert, + context_mode::separate_cert } ) + { + io_context ioc; + auto [client_ctx, server_ctx] = make_contexts( mode ); + run_tls_shutdown_test( ioc, client_ctx, server_ctx, + make_stream, make_stream ); + } + } + + void + testStreamTruncated() + { + using namespace tls::test; + + for( auto mode : { context_mode::shared_cert, + context_mode::separate_cert } ) + { + io_context ioc; + auto [client_ctx, server_ctx] = make_contexts( mode ); + run_tls_truncation_test( ioc, client_ctx, server_ctx, + make_stream, make_stream ); + } + } #endif void @@ -83,6 +113,8 @@ struct wolfssl_stream_test { #ifdef BOOST_COROSIO_HAS_WOLFSSL testSuccessCases(); + testTlsShutdown(); + testStreamTruncated(); // Failure tests disabled: socket cancellation doesn't propagate to // TLS handshake operations, causing hangs when one side fails. // testFailureCases();