Skip to content

heap-use-after-free error when cancelling spawned awaitable  #194

Description

@Pele44

In the code that is based on cobalt, crashes occur. When this code is run under address sanitizer, it detects a "heap-use-after-free" error. I managed to prepare a simplified scenario (maybe not the simplest possible) that leads to this memory violation.

#include <cassert>
#include <thread>

#include <boost/asio.hpp>
#include <boost/cobalt.hpp>

using namespace boost;

void runContext(asio::io_context& context)
{
  cobalt::this_thread::set_executor(context.get_executor());
  context.run();
}

class SingleThreadedWithContext
{
public:
  SingleThreadedWithContext()
    : executorWorkGuard(boost::asio::make_work_guard(ioContext)),
      contextThread(std::jthread{runContext, std::ref(ioContext)})
  {
  }

  ~SingleThreadedWithContext() { executorWorkGuard.reset(); }

  auto getExecutor() { return ioContext.get_executor(); }

private:
  asio::io_context ioContext;
  asio::executor_work_guard<decltype(ioContext)::executor_type> executorWorkGuard;
  std::jthread contextThread;
};

cobalt::task<void> emptyTask()
{
  co_return;
}

cobalt::promise<void> promisThatSpawnsEmptyTaskToAnotherThread(auto executorToSpawn)
{
  co_await cobalt::spawn(executorToSpawn, emptyTask(), cobalt::use_op);
}

cobalt::task<void> makePromiseThatSpawnsToAnotherThreadAndCancelIt(auto executorToSpawn)
{
  auto prom = promisThatSpawnsEmptyTaskToAnotherThread(executorToSpawn);
  assert(prom);
  prom.cancel();
  co_await prom;
}

int main(int /*argc*/, const char** /*argv*/)
{
  SingleThreadedWithContext threadA;
  SingleThreadedWithContext threadB;

  cobalt::spawn(threadA.getExecutor(),
                makePromiseThatSpawnsToAnotherThreadAndCancelIt(threadB.getExecutor()),
                asio::use_future)
    .get();

  return 0;
}

My analysis led me to the conclusion that when we call the spawn method, one of the variables (recs) is allocated as a shared_ptr, but on a memory block that is probably saved on the coroutine frame (cobalt/include/boost/cobalt/detail/spawn.hpp:111 -> auto recs = std::allocate_shared<detail::task_receiver<void>>(alloc, std::move(a.receiver_));)
When the coroutine ends, the frame is freed, along with the memory pointed to by the aforementioned shared_ptr. The problem is that access to this shared_ptr is done by another thread and can occur after the frame has been removed. From the point of view of shared_ptr, everything seems to be ok, because the reference count is positive, but because we allocated the object itself (with the counter) on a "local" piece of memory, the whole thing can lead to access to the freed memory.

    if (sl.is_connected())
      sl.assign(
          [ex = exec, recs](asio::cancellation_type ct)
          {
            asio::dispatch(ex, [recs, ct] {recs->cancel(ct); // crash here -> recs point to freed memory });
          });

e.g. changing allocation to auto recs = std::make_shared<detail::task_receiver<void>>(std::move(a.receiver_)); resolves a problem

gcc 14.1, example build with address sanitizer

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions