From f72c75f01285c355e03714cad0b3ce822fe146bc Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 21 Feb 2024 10:47:00 +0100 Subject: [PATCH] TCP socket send buffer limit (#4237) (#4329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * TCP non-blocking send (#4237) * Refs #20119: Check send buffer queue before sending new data Signed-off-by: Jesus Perez * Refs #20119: Add non_blocking_send attribute to tcp Signed-off-by: Jesus Perez * Refs #20119: Add test Signed-off-by: Jesus Perez * Refs #20119: Add non-blocking send to secure socket Signed-off-by: Jesus Perez * Refs #20119: Update versions.md Signed-off-by: Jesus Perez * Refs #20119: Uncrustify Signed-off-by: Jesus Perez * Refs #20119: Apply suggestions Signed-off-by: Jesus Perez * Refs #20119: non_blocking_send moved to properties Signed-off-by: Jesus Perez * Refs #20119: Apply suggestions Signed-off-by: Jesus Perez * Refs #20119: Fix Windows&Mac build warnings Signed-off-by: Jesus Perez --------- Signed-off-by: Jesus Perez (cherry picked from commit b5f8c8adcd06c42e25d2f8baeb5f00572588b16e) # Conflicts: # versions.md * Fix conflicts Signed-off-by: Miguel Company --------- Signed-off-by: Miguel Company Co-authored-by: Jesús Pérez <78275223+jepemi@users.noreply.github.com> Co-authored-by: Miguel Company --- .../attributes/RTPSParticipantAttributes.cpp | 2 + src/cpp/rtps/transport/TCPChannelResource.cpp | 24 ++ src/cpp/rtps/transport/TCPChannelResource.h | 4 + .../transport/TCPChannelResourceBasic.cpp | 7 + .../transport/TCPChannelResourceSecure.cpp | 7 + .../rtps/transport/TCPTransportInterface.cpp | 11 +- .../rtps/transport/TCPTransportInterface.h | 19 ++ test/unittest/transport/TCPv4Tests.cpp | 205 ++++++++++++++++++ test/unittest/transport/TCPv6Tests.cpp | 85 ++++++++ .../transport/mock/MockTCPv4Transport.h | 6 + .../transport/mock/MockTCPv6Transport.h | 6 + versions.md | 2 + 12 files changed, 377 insertions(+), 1 deletion(-) diff --git a/src/cpp/rtps/attributes/RTPSParticipantAttributes.cpp b/src/cpp/rtps/attributes/RTPSParticipantAttributes.cpp index f68aba617f6..23c68767733 100644 --- a/src/cpp/rtps/attributes/RTPSParticipantAttributes.cpp +++ b/src/cpp/rtps/attributes/RTPSParticipantAttributes.cpp @@ -198,6 +198,7 @@ static void setup_transports_large_data( auto tcp_transport = create_tcpv4_transport(att); att.userTransports.push_back(tcp_transport); + att.properties.properties().emplace_back("fastdds.tcp_transport.non_blocking_send", "true"); Locator_t tcp_loc; tcp_loc.kind = LOCATOR_KIND_TCPv4; @@ -234,6 +235,7 @@ static void setup_transports_large_datav6( auto tcp_transport = create_tcpv6_transport(att); att.userTransports.push_back(tcp_transport); + att.properties.properties().emplace_back("fastdds.tcp_transport.non_blocking_send", "true"); Locator_t tcp_loc; tcp_loc.kind = LOCATOR_KIND_TCPv6; diff --git a/src/cpp/rtps/transport/TCPChannelResource.cpp b/src/cpp/rtps/transport/TCPChannelResource.cpp index 81a62d77952..7cd4c018be9 100644 --- a/src/cpp/rtps/transport/TCPChannelResource.cpp +++ b/src/cpp/rtps/transport/TCPChannelResource.cpp @@ -295,6 +295,30 @@ bool TCPChannelResource::remove_logical_port( return true; } +bool TCPChannelResource::check_socket_send_buffer( + const size_t& msg_size, + const asio::ip::tcp::socket::native_handle_type& socket_native_handle) +{ + int bytesInSendQueue = 0; + +#ifndef _WIN32 + if (ioctl(socket_native_handle, TIOCOUTQ, &bytesInSendQueue) == -1) + { + bytesInSendQueue = 0; + } +#else // ifdef _WIN32 + static_cast(socket_native_handle); +#endif // ifndef _WIN32 + + + size_t future_queue_size = size_t(bytesInSendQueue) + msg_size; + if (future_queue_size > size_t(parent_->configuration()->sendBufferSize)) + { + return false; + } + return true; +} + } // namespace rtps } // namespace fastrtps } // namespace eprosima diff --git a/src/cpp/rtps/transport/TCPChannelResource.h b/src/cpp/rtps/transport/TCPChannelResource.h index e45fb7b3b58..4bceb696bf2 100644 --- a/src/cpp/rtps/transport/TCPChannelResource.h +++ b/src/cpp/rtps/transport/TCPChannelResource.h @@ -190,6 +190,10 @@ class TCPChannelResource : public ChannelResource const std::vector& availablePorts, RTCPMessageManager* rtcp_manager); + bool check_socket_send_buffer( + const size_t& msg_size, + const asio::ip::tcp::socket::native_handle_type& socket_native_handle); + TCPConnectionType tcp_connection_type_; friend class TCPTransportInterface; diff --git a/src/cpp/rtps/transport/TCPChannelResourceBasic.cpp b/src/cpp/rtps/transport/TCPChannelResourceBasic.cpp index 39a96bc9364..bb4aac5702f 100644 --- a/src/cpp/rtps/transport/TCPChannelResourceBasic.cpp +++ b/src/cpp/rtps/transport/TCPChannelResourceBasic.cpp @@ -151,6 +151,13 @@ size_t TCPChannelResourceBasic::send( if (eConnecting < connection_status_) { std::lock_guard send_guard(send_mutex_); + + if (parent_->get_non_blocking_send() && + !check_socket_send_buffer(header_size + size, socket_->native_handle())) + { + return 0; + } + if (header_size > 0) { std::array buffers; diff --git a/src/cpp/rtps/transport/TCPChannelResourceSecure.cpp b/src/cpp/rtps/transport/TCPChannelResourceSecure.cpp index 92497e7e662..ff12d3c26cc 100644 --- a/src/cpp/rtps/transport/TCPChannelResourceSecure.cpp +++ b/src/cpp/rtps/transport/TCPChannelResourceSecure.cpp @@ -209,6 +209,13 @@ size_t TCPChannelResourceSecure::send( if (eConnecting < connection_status_) { + if (parent_->get_non_blocking_send() && + !check_socket_send_buffer(header_size + size, + secure_socket_->lowest_layer().native_handle())) + { + return 0; + } + std::vector buffers; if (header_size > 0) { diff --git a/src/cpp/rtps/transport/TCPTransportInterface.cpp b/src/cpp/rtps/transport/TCPTransportInterface.cpp index 611de341248..9d34ad8c964 100644 --- a/src/cpp/rtps/transport/TCPTransportInterface.cpp +++ b/src/cpp/rtps/transport/TCPTransportInterface.cpp @@ -142,6 +142,7 @@ TCPTransportInterface::TCPTransportInterface( int32_t transport_kind) : TransportInterface(transport_kind) , alive_(true) + , non_blocking_send_(false) #if TLS_FOUND , ssl_context_(asio::ssl::context::sslv23) #endif // if TLS_FOUND @@ -364,7 +365,7 @@ bool TCPTransportInterface::DoInputLocatorsMatch( } bool TCPTransportInterface::init( - const fastrtps::rtps::PropertyPolicy*) + const fastrtps::rtps::PropertyPolicy* properties) { if (!apply_tls_config()) { @@ -389,6 +390,14 @@ bool TCPTransportInterface::init( ip::tcp::endpoint local_endpoint = initial_peer_local_locator_socket_->local_endpoint(); initial_peer_local_locator_port_ = local_endpoint.port(); + // Get non_blocking_send property + if (properties) + { + auto s_non_blocking_send = eprosima::fastrtps::rtps::PropertyPolicyHelper::find_property(*properties, + "fastdds.tcp_transport.non_blocking_send"); + non_blocking_send_ = s_non_blocking_send && *s_non_blocking_send == "true"? true : false; + } + // Check system buffer sizes. if (configuration()->sendBufferSize == 0) { diff --git a/src/cpp/rtps/transport/TCPTransportInterface.h b/src/cpp/rtps/transport/TCPTransportInterface.h index 5ae4acc66c8..f454e829425 100644 --- a/src/cpp/rtps/transport/TCPTransportInterface.h +++ b/src/cpp/rtps/transport/TCPTransportInterface.h @@ -78,6 +78,19 @@ class TCPTransportInterface : public TransportInterface asio::io_service io_service_timers_; std::unique_ptr initial_peer_local_locator_socket_; uint16_t initial_peer_local_locator_port_; + /** + * Whether to use non-blocking calls to send(). + * + * When set to true, calls to send() will return immediately if the send buffer is full. + * This may happen when receive buffer on reader's side is full. No error will be returned + * to the upper layer. This means that the application will behave + * as if the datagram is sent but lost (i.e. throughput may be reduced). This value is + * specially useful on high-frequency writers. + * + * When set to false, calls to send() will block until the send buffer has space for the + * datagram. This may cause application lock. + */ + bool non_blocking_send_; #if TLS_FOUND asio::ssl::context ssl_context_; @@ -437,6 +450,12 @@ class TCPTransportInterface : public TransportInterface */ void fill_local_physical_port( Locator& locator) const; + + bool get_non_blocking_send() const + { + return non_blocking_send_; + } + }; } // namespace rtps diff --git a/test/unittest/transport/TCPv4Tests.cpp b/test/unittest/transport/TCPv4Tests.cpp index ddd9ccee870..af50eebd139 100644 --- a/test/unittest/transport/TCPv4Tests.cpp +++ b/test/unittest/transport/TCPv4Tests.cpp @@ -21,6 +21,7 @@ #include "mock/MockTCPChannelResource.h" #include "mock/MockTCPv4Transport.h" #include +#include #include #include #include @@ -1262,6 +1263,126 @@ TEST_F(TCPv4Tests, send_and_receive_between_both_secure_ports_with_sni) } } +#ifndef _WIN32 +// The primary purpose of this test is to check the non-blocking behavior of a secure socket sending data to a +// destination that does not read or does it so slowly. +TEST_F(TCPv4Tests, secure_non_blocking_send) +{ + uint16_t port = g_default_port; + uint32_t msg_size = eprosima::fastdds::rtps::s_minimumSocketBuffer; + // Create a TCP Server transport + using TLSOptions = TCPTransportDescriptor::TLSConfig::TLSOptions; + using TLSVerifyMode = TCPTransportDescriptor::TLSConfig::TLSVerifyMode; + using TLSHSRole = TCPTransportDescriptor::TLSConfig::TLSHandShakeRole; + TCPv4TransportDescriptor senderDescriptor; + senderDescriptor.add_listener_port(port); + senderDescriptor.sendBufferSize = msg_size; + senderDescriptor.tls_config.handshake_role = TLSHSRole::CLIENT; + senderDescriptor.tls_config.verify_file = "ca.crt"; + senderDescriptor.tls_config.verify_mode = TLSVerifyMode::VERIFY_PEER; + senderDescriptor.tls_config.add_option(TLSOptions::DEFAULT_WORKAROUNDS); + senderDescriptor.tls_config.add_option(TLSOptions::SINGLE_DH_USE); + senderDescriptor.tls_config.add_option(TLSOptions::NO_SSLV2); + senderDescriptor.tls_config.add_option(TLSOptions::NO_COMPRESSION); + MockTCPv4Transport senderTransportUnderTest(senderDescriptor); + eprosima::fastrtps::rtps::RTPSParticipantAttributes att; + att.properties.properties().emplace_back("fastdds.tcp_transport.non_blocking_send", "true"); + senderTransportUnderTest.init(&att.properties); + + // Create a TCP Client socket. + // The creation of a reception transport for testing this functionality is not + // feasible. For the saturation of the sending socket, it's necessary first to + // saturate the reception socket of the datareader. This saturation requires + // preventing the datareader from reading from the socket, what inevitably + // happens continuously if instantiating and connecting the receiver transport. + // Hence, a raw socket is opened and connected to the server. There won't be read + // calls on that socket. + Locator_t serverLoc; + serverLoc.kind = LOCATOR_KIND_TCPv4; + IPLocator::setIPv4(serverLoc, 127, 0, 0, 1); + serverLoc.port = port; + IPLocator::setLogicalPort(serverLoc, 7410); + + // Socket TLS config + asio::ssl::context ssl_context(asio::ssl::context::sslv23); + ssl_context.set_verify_callback([](bool preverified, asio::ssl::verify_context&) + { + return preverified; + }); + ssl_context.set_password_callback([](std::size_t, asio::ssl::context_base::password_purpose) + { + return "fastddspwd"; + }); + ssl_context.use_certificate_chain_file("fastdds.crt"); + ssl_context.use_private_key_file("fastdds.key", asio::ssl::context::pem); + ssl_context.use_tmp_dh_file("dh_params.pem"); + + uint32_t options = 0; + options |= asio::ssl::context::default_workarounds; + options |= asio::ssl::context::single_dh_use; + options |= asio::ssl::context::no_sslv2; + options |= asio::ssl::context::no_compression; + ssl_context.set_options(options); + + // TCPChannelResourceSecure::connect() like connection + asio::io_service io_service; + asio::ip::tcp::resolver resolver(io_service); + auto endpoints = resolver.resolve( + IPLocator::ip_to_string(serverLoc), + std::to_string(IPLocator::getPhysicalPort(serverLoc))); + + auto secure_socket = std::make_shared>(io_service, ssl_context); + asio::ssl::verify_mode vm = 0x00; + vm |= asio::ssl::verify_peer; + secure_socket->set_verify_mode(vm); + + asio::async_connect(secure_socket->lowest_layer(), endpoints, + [secure_socket](const std::error_code& ec +#if ASIO_VERSION >= 101200 + , asio::ip::tcp::endpoint +#else + , const tcp::resolver::iterator& /*endpoint*/ +#endif // if ASIO_VERSION >= 101200 + ) + { + ASSERT_TRUE(!ec); + asio::ssl::stream_base::handshake_type role = asio::ssl::stream_base::server; + secure_socket->async_handshake(role, + [](const std::error_code& ec) + { + ASSERT_TRUE(!ec); + }); + }); + + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + /* + Get server's accepted channel. This is retrieved from the unbound_channel_resources_, + which is a vector where client channels are pushed immediately after the server accepts + a connection. This channel will not be present in the server's channel_resources_ map + as communication lacks most of the discovery messages using a raw socket as participant. + */ + auto sender_unbound_channel_resources = senderTransportUnderTest.get_unbound_channel_resources(); + ASSERT_TRUE(sender_unbound_channel_resources.size() == 1); + auto sender_channel_resource = + std::static_pointer_cast(sender_unbound_channel_resources[0]); + + // Prepare the message + asio::error_code ec; + std::vector message(msg_size, 0); + const octet* data = message.data(); + size_t size = message.size(); + + // Send the message with no header + for (int i = 0; i < 5; i++) + { + sender_channel_resource->send(nullptr, 0, data, size, ec); + } + + secure_socket->lowest_layer().close(ec); +} +#endif // ifndef _WIN32 + #endif //TLS_FOUND TEST_F(TCPv4Tests, send_and_receive_between_allowed_localhost_interfaces_ports) @@ -1693,6 +1814,90 @@ TEST_F(TCPv4Tests, client_announced_local_port_uniqueness) ASSERT_EQ(receiveTransportUnderTest.get_channel_resources().size(), 2); } +#ifndef _WIN32 +// The primary purpose of this test is to check the non-blocking behavior of a secure socket sending data to a +// destination that does not read or does it so slowly. +TEST_F(TCPv4Tests, non_blocking_send) +{ + uint16_t port = g_default_port; + uint32_t msg_size = eprosima::fastdds::rtps::s_minimumSocketBuffer; + // Create a TCP Server transport + TCPv4TransportDescriptor senderDescriptor; + senderDescriptor.add_listener_port(port); + senderDescriptor.sendBufferSize = msg_size; + MockTCPv4Transport senderTransportUnderTest(senderDescriptor); + eprosima::fastrtps::rtps::RTPSParticipantAttributes att; + att.properties.properties().emplace_back("fastdds.tcp_transport.non_blocking_send", "true"); + senderTransportUnderTest.init(&att.properties); + + // Create a TCP Client socket. + // The creation of a reception transport for testing this functionality is not + // feasible. For the saturation of the sending socket, it's necessary first to + // saturate the reception socket of the datareader. This saturation requires + // preventing the datareader from reading from the socket, what inevitably + // happens continuously if instantiating and connecting the receiver transport. + // Hence, a raw socket is opened and connected to the server. There won't be read + // calls on that socket. + Locator_t serverLoc; + serverLoc.kind = LOCATOR_KIND_TCPv4; + IPLocator::setIPv4(serverLoc, 127, 0, 0, 1); + serverLoc.port = port; + IPLocator::setLogicalPort(serverLoc, 7410); + + // TCPChannelResourceBasic::connect() like connection + asio::io_service io_service; + asio::ip::tcp::resolver resolver(io_service); + auto endpoints = resolver.resolve( + IPLocator::ip_to_string(serverLoc), + std::to_string(IPLocator::getPhysicalPort(serverLoc))); + + asio::ip::tcp::socket socket = asio::ip::tcp::socket (io_service); + asio::async_connect( + socket, + endpoints, + [](std::error_code ec +#if ASIO_VERSION >= 101200 + , asio::ip::tcp::endpoint +#else + , asio::ip::tcp::resolver::iterator +#endif // if ASIO_VERSION >= 101200 + ) + { + ASSERT_TRUE(!ec); + } + ); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + /* + Get server's accepted channel. This is retrieved from the unbound_channel_resources_, + which is a vector where client channels are pushed immediately after the server accepts + a connection. This channel will not be present in the server's channel_resources_ map + as communication lacks most of the discovery messages using a raw socket as participant. + */ + auto sender_unbound_channel_resources = senderTransportUnderTest.get_unbound_channel_resources(); + ASSERT_TRUE(sender_unbound_channel_resources.size() == 1); + auto sender_channel_resource = + std::static_pointer_cast(sender_unbound_channel_resources[0]); + + // Prepare the message + asio::error_code ec; + std::vector message(msg_size, 0); + const octet* data = message.data(); + size_t size = message.size(); + + // Send the message with no header + for (int i = 0; i < 5; i++) + { + sender_channel_resource->send(nullptr, 0, data, size, ec); + } + + socket.shutdown(asio::ip::tcp::socket::shutdown_both); + socket.cancel(); + socket.close(); +} +#endif // ifndef _WIN32 + void TCPv4Tests::HELPER_SetDescriptorDefaults() { descriptor.add_listener_port(g_default_port); diff --git a/test/unittest/transport/TCPv6Tests.cpp b/test/unittest/transport/TCPv6Tests.cpp index b7a10f32606..d0c77c98277 100644 --- a/test/unittest/transport/TCPv6Tests.cpp +++ b/test/unittest/transport/TCPv6Tests.cpp @@ -243,6 +243,91 @@ TEST_F(TCPv6Tests, client_announced_local_port_uniqueness) ASSERT_EQ(receiveTransportUnderTest.get_channel_resources().size(), 2); } + +#ifndef _WIN32 +// The primary purpose of this test is to check the non-blocking behavior of a secure socket sending data to a +// destination that does not read or does it so slowly. +TEST_F(TCPv6Tests, non_blocking_send) +{ + uint16_t port = g_default_port; + uint32_t msg_size = eprosima::fastdds::rtps::s_minimumSocketBuffer; + // Create a TCP Server transport + TCPv6TransportDescriptor senderDescriptor; + senderDescriptor.add_listener_port(port); + senderDescriptor.sendBufferSize = msg_size; + MockTCPv6Transport senderTransportUnderTest(senderDescriptor); + eprosima::fastrtps::rtps::RTPSParticipantAttributes att; + att.properties.properties().emplace_back("fastdds.tcp_transport.non_blocking_send", "true"); + senderTransportUnderTest.init(&att.properties); + + // Create a TCP Client socket. + // The creation of a reception transport for testing this functionality is not + // feasible. For the saturation of the sending socket, it's necessary first to + // saturate the reception socket of the datareader. This saturation requires + // preventing the datareader from reading from the socket, what inevitably + // happens continuously if instantiating and connecting the receiver transport. + // Hence, a raw socket is opened and connected to the server. There won't be read + // calls on that socket. + Locator_t serverLoc; + serverLoc.kind = LOCATOR_KIND_TCPv6; + IPLocator::setIPv6(serverLoc, "::1"); + serverLoc.port = g_default_port; + IPLocator::setLogicalPort(serverLoc, 7610); + + // TCPChannelResourceBasic::connect() like connection + asio::io_service io_service; + asio::ip::tcp::resolver resolver(io_service); + auto endpoints = resolver.resolve( + IPLocator::ip_to_string(serverLoc), + std::to_string(IPLocator::getPhysicalPort(serverLoc))); + + asio::ip::tcp::socket socket = asio::ip::tcp::socket (io_service); + asio::async_connect( + socket, + endpoints, + [](std::error_code ec +#if ASIO_VERSION >= 101200 + , asio::ip::tcp::endpoint +#else + , asio::ip::tcp::resolver::iterator +#endif // if ASIO_VERSION >= 101200 + ) + { + ASSERT_TRUE(!ec); + } + ); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + /* + Get server's accepted channel. This is retrieved from the unbound_channel_resources_, + which is a vector where client channels are pushed immediately after the server accepts + a connection. This channel will not be present in the server's channel_resources_ map + as communication lacks most of the discovery messages using a raw socket as participant. + */ + auto sender_unbound_channel_resources = senderTransportUnderTest.get_unbound_channel_resources(); + ASSERT_TRUE(sender_unbound_channel_resources.size() == 1); + auto sender_channel_resource = + std::static_pointer_cast(sender_unbound_channel_resources[0]); + + // Prepare the message + asio::error_code ec; + std::vector message(msg_size, 0); + const octet* data = message.data(); + size_t size = message.size(); + + // Send the message with no header + for (int i = 0; i < 5; i++) + { + sender_channel_resource->send(nullptr, 0, data, size, ec); + } + + socket.shutdown(asio::ip::tcp::socket::shutdown_both); + socket.cancel(); + socket.close(); +} +#endif // ifndef _WIN32 + /* TEST_F(TCPv6Tests, send_and_receive_between_both_secure_ports) { diff --git a/test/unittest/transport/mock/MockTCPv4Transport.h b/test/unittest/transport/mock/MockTCPv4Transport.h index a561e473b4c..6a1e7300b86 100644 --- a/test/unittest/transport/mock/MockTCPv4Transport.h +++ b/test/unittest/transport/mock/MockTCPv4Transport.h @@ -24,6 +24,7 @@ namespace rtps { using TCPv4Transport = eprosima::fastdds::rtps::TCPv4Transport; using TCPChannelResource = eprosima::fastdds::rtps::TCPChannelResource; +using TCPChannelResourceBasic = eprosima::fastdds::rtps::TCPChannelResourceBasic; class MockTCPv4Transport : public TCPv4Transport { @@ -40,6 +41,11 @@ class MockTCPv4Transport : public TCPv4Transport return channel_resources_; } + const std::vector> get_unbound_channel_resources() const + { + return unbound_channel_resources_; + } + }; } // namespace rtps diff --git a/test/unittest/transport/mock/MockTCPv6Transport.h b/test/unittest/transport/mock/MockTCPv6Transport.h index d84347cbce7..37b8d7c02d3 100644 --- a/test/unittest/transport/mock/MockTCPv6Transport.h +++ b/test/unittest/transport/mock/MockTCPv6Transport.h @@ -24,6 +24,7 @@ namespace rtps { using TCPv6Transport = eprosima::fastdds::rtps::TCPv6Transport; using TCPChannelResource = eprosima::fastdds::rtps::TCPChannelResource; +using TCPChannelResourceBasic = eprosima::fastdds::rtps::TCPChannelResourceBasic; class MockTCPv6Transport : public TCPv6Transport { @@ -40,6 +41,11 @@ class MockTCPv6Transport : public TCPv6Transport return channel_resources_; } + const std::vector>& get_unbound_channel_resources() const + { + return unbound_channel_resources_; + } + }; } // namespace rtps diff --git a/versions.md b/versions.md index d832d44a610..cb1e706d562 100644 --- a/versions.md +++ b/versions.md @@ -1,6 +1,8 @@ Forthcoming ----------- +* Added `non_blocking_send` to TCP Transport. + Version 2.10.3 --------------