3

Is it correct to call ssl::stream::async_shutdown while there are outstanding async_read_some/async_write operations? Should I wait for all async operations to complete before calling ssl::stream::async_shutdown or not?

If I can call ssl::stream::async_shutdown before async_read_some/async_write completes, what happens to the operations in progress?

1 Answer 1

6

SSL is a state-machine. Any stream-level read operation can require protocol-level (socket level, in your case) writes and vice versa.

The implementation of io_op::operator() in boost/asio/ssl/detail/io.hpp (which is used via async_io<Op>) provides some protection against overlapping read/writes by way of the pending_read_ and pending_write_ timers in stream_core. However, it ONLY manages implicit operations, not the user-initiated ones.

So you have to make sure any user-initiated writes or writes do not conflict (it's okay to have a single write and read pending at the same time).

It's not too hard to check the behavior, e.g. with BOOST_ASIO_ENABLE_HANDLER_TRACKING. Say we have the following set of deferred operations:

auto handshake = s.async_handshake(ssl::stream_base::client);
auto hello     = s.async_write_some(asio::buffer("Hello, world!\n"sv));
auto bye       = s.async_write_some(asio::buffer("Bye, world!\n"sv));
auto shutdown  = s.async_shutdown();

A classic, correct way to use them could be:

    handshake([&](auto&&...) {   //
        hello([&](auto&&...) {   //
            bye([&](auto&&...) { //
                shutdown(token);
            });
        });
    });

Equivalently:

    co_spawn(ioc, [&] -> asio::awaitable<void> {
            co_await handshake(asio::deferred);
            co_await hello(asio::deferred);
            co_await bye(asio::deferred);
            co_await shutdown(asio::deferred);
        }, token);

Visualized handlers:

enter image description here

Note that it's pretty tricky to read because the only the lowest_layer operations are showing. So, e.g. shutdown is a write and one ore more reads.

Now, if you go "rogue" instead:

    post(ioc, [&] { handshake(token); }); // 1

    post(ioc, [&] { hello(token); });     // 2
    // post(ioc, [&] { bye(token); });    // 3

    post(ioc, [&] { cancel(); });         // 4
    post(ioc, [&] { shutdown(token); });  // 5

First of all, things don't work as expected. Second of all, even a simple // 1 and // 5 combi shows:

enter image description here

It is at once clear that the two async_receive operations involved are overlapping. That's simply not allowed.

Notes

Listing

Live On Coliru

#define BOOST_ASIO_ENABLE_HANDLER_TRACKING
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>

using namespace std::literals;
namespace asio = boost::asio;
namespace ssl  = asio::ssl;
using asio::ip::tcp;

int main() {
    asio::io_context         ioc;
    ssl::context             ctx(ssl::context::sslv23);
    ssl::stream<tcp::socket> s(ioc, ctx);
    s.lowest_layer().connect({{}, 8989});

    asio::cancellation_signal signal;
    asio::cancellation_slot   slot   = signal.slot();
    auto                      token  = bind_cancellation_slot(slot, asio::detached);
    auto                      cancel = [&] { signal.emit(asio::cancellation_type::all); };
    // auto                   cancel = [&] { s.lowest_layer().cancel(); };

    // deferred ops
    auto handshake = s.async_handshake(ssl::stream_base::client);
    auto hello     = s.async_write_some(asio::buffer("Hello, world!\n"sv));
    auto bye       = s.async_write_some(asio::buffer("Bye, world!\n"sv));
    auto shutdown  = s.async_shutdown();

    if (0) {
        // properly serialize operations
#if 1
        handshake([&](auto&&...) {   //
            hello([&](auto&&...) {   //
                bye([&](auto&&...) { //
                    shutdown(token);
                });
            });
        });
#else
        co_spawn(ioc, [&] -> asio::awaitable<void> {
                co_await handshake(asio::deferred);
                co_await hello(asio::deferred);
                co_await bye(asio::deferred);
                co_await shutdown(asio::deferred);
            }, token);
#endif
    } else {
        // "rogue"
        post(ioc, [&] { handshake(token); }); // 1

        //post(ioc, [&] { hello(token); });     // 2
        //// post(ioc, [&] { bye(token); });    // 3

        //post(ioc, [&] { cancel(); });         // 4
        post(ioc, [&] { shutdown(token); });  // 5
    }

    ioc.run();
}

BONUS: Cancellation

From the comments, and for posterity: it is possible to use cancellation to give async_shutdown priority. You MUST still await completion of the cancelled operation(s):

Live On Coliru

#define BOOST_ASIO_ENABLE_HANDLER_TRACKING
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <iostream>

using namespace std::literals;
namespace asio = boost::asio;
namespace ssl  = asio::ssl;
using asio::ip::tcp;
using boost::system::error_code;

int main() {
    std::cout << "Boost version " << BOOST_VERSION << std::endl;
    asio::thread_pool        ioc(1);
    ssl::context             ctx(ssl::context::sslv23);
    ssl::stream<tcp::socket> s(ioc, ctx);
    s.lowest_layer().connect({{}, 8989});

    asio::cancellation_signal signal;
    asio::cancellation_slot   slot   = signal.slot();
    auto                      token  = bind_cancellation_slot(slot, asio::detached);
    auto                      cancel = [&] { signal.emit(asio::cancellation_type::all); };
    // auto                   cancel = [&] { s.lowest_layer().cancel(); };

    // deferred ops
    auto handshake = s.async_handshake(ssl::stream_base::client, asio::deferred);
    auto hello     = s.async_write_some(asio::buffer("Hello, world!\n"sv), asio::deferred);
    auto bye       = s.async_write_some(asio::buffer("Bye, world!\n"sv), asio::deferred);
    auto shutdown  = s.async_shutdown(asio::deferred);

    if (0) {
        // properly serialize operations
#if 1
        handshake([&](auto&&...) {   //
            hello([&](auto&&...) {   //
                bye([&](auto&&...) { //
                    shutdown(token);
                });
            });
        });
#else
        co_spawn(ioc, [&] -> asio::awaitable<void> {
                co_await handshake(asio::deferred);
                co_await hello(asio::deferred);
                co_await bye(asio::deferred);
                co_await shutdown(asio::deferred);
            }, token);
#endif
    } else {
        handshake(asio::use_future).get(); // 1, simply blocking

        auto writes = hello(asio::deferred([&](error_code ec, size_t) {
            return !ec ? bye : throw boost::system::system_error(ec); // 2, 3
        }));

        auto timer          = asio::steady_timer(ioc, 100ms);
        auto delayed_writes = timer.async_wait(asio::deferred([&](error_code ec) { //
            return !ec ? writes : throw boost::system::system_error(ec);
        }));

        auto f = std::move(delayed_writes) //
            (bind_cancellation_slot(slot, asio::use_future));

        std::this_thread::sleep_for(150ms);
        post(ioc, [&] { cancel(); }); // 4

        // Crucially, wait for the cancellation to complete
        f.wait(); // 2, 3

#ifndef NDEBUG
        try { f.get(); throw boost::system::system_error({}); }
        catch (boost::system::system_error const& e) { std::cout << "Writes result: " << e.code().message() << std::endl; }
#endif

        post(ioc, [&] { shutdown(token); }); // 5
    }

    ioc.join();
}

Note that it's possible to vary the sleep_for. Also watch what happens when you replace std::move(delayed_writes) with std::move(writes) removing all delays. (In my experience, the writes always succeed in that case).

The live demo could be hard to read, so here is side by side:

Cancellation
Late Early
sleep_for(150ms) sleep_for(50ms)
Console output: Console output:
Boost version 108800
Writes result: Operation canceled
Boost version 108800
Writes result: Success
enter image description here enter image description here
Sign up to request clarification or add additional context in comments.

17 Comments

Thank you so much! Your code doesn't compile for me. async_handshake, async_write_some, async_write_some, async_shutdown fail to compile with "no matching overloaded function found" error. Also two points are unclear: 1) if I have unfinished async_write/async_read_some, is it correct to call cancel() first, and then async_shutdown()? 2) "you should consider queueing the operations" do I understand correctly that this queue should take into account not only write operations, but also read operations? If I called cancel() before async_shutdown() do I still need the queue?
0) recent boost made deferred the default token, so compare: coliru.stacked-crooked.com/a/058eafc6d7bdc488 1) no, the cancel doesn't help - I tried! That's why it's in the code listing, so you too can try 2) yes.
Ok, thank you! Can you recommend some pattern for calling async_shutdown given that I always have an active async_read_some operation so that the session stays "alive" until the connection is closed (or until I decide to destroy the session myself).
Can you explain why cancel doesn't work? It cancels all active async operations. After canceling, there are no active async operations, so it's safe to call async_shutdown, but it doesn't work. How can this be explained?
Timing. It IS possible to wait for the cancellations to have completed (... yes, that sounds funny, but you can wait for the operation_aborted error codes to be received). It's hard to see what guarantees there are regarding the validity of the SSL stream after cancellation, but something tells me that if you actually manage to cancel the io_op (using bound cancellation slot) it looks like it /should/ be fine since the state machine should just be able to continue on the next operation.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.