Skip to content

Consider if its at all possible to make context cancellation safe #38

Description

@kammce

Currently we have unsafe_cancel() as an API to async::context. The problem is that when we destroy all of the promises, we do not set their futures the the cancelled state. This is actually fine for all futures except for those that exist within a supervisor. Any function in possession of a free future on its stack will be called a supervisor with the future being supervised by. For example, a coroutine with a proxy is a supervisor. The main function is a supervisor if it creates a context and sets up future. Supervisors are the only types of functions that can encounter an invalid future. A future with its coroutine handle initialized, but the promise has been destroyed. If we could somehow set the state of the top level futures to cancelled, then we make context destruction safe, ensuring users cannot footgun themselves.

Activity

  1. kammce commented on Jan 14, 2026

    @kammce
    MemberAuthor

    Solution

    Wait... If we have the capability to access the promise directly, using the coroutine casting APIs, then we can reach into the promise associated with the top level future, reach in, find its address and update it to cancelled before destroying the promise. That should make context cancel safe.

  2. kammce commented on Jan 14, 2026

    @kammce
    MemberAuthor

    Whoops, looked back at the code and realized that the future state is template against its type. The promise base couldn't access the future* because we don't know which T is for each promise. A few ideas I came up with is using a virtual API for cancellation but now each promise needs its own full vtable which can work but is a lot of ROM overhead for something that is rare.

    Another option is to add a destructor to the promise type and update the future's state to cancelled if its future wasn't finished yet. We can benchmark the cost of adding this destructor check. Simply having the destructor do this for us ensures that no future is left in an invalid state, but at the cost of an additional runtime check on each promise destruction.

  3. kammce commented on Jan 14, 2026

    @kammce
    MemberAuthor

    Some solutions I've come up with are:

    1. Make promise base an abstract base class

    Introduce the abstract API: virtual void cancel() = 0; to promise base. promise<T> will override this based on their template type and set their future_state<T> to the cancelled{}.

    ROM Costs ‼️

    Introduces a vtable for each unique promise type. The ROM cost is (M types * (4 words + N apis + asm_sizeof(cancel_api))). Each cancel API should be small as it will only be a few instructions to set the pointer's state to cancelled.

    Runtime Costs ✅

    No runtime cost for normal operation. This API would only be called by context cancel when necessary.

    RAM Costs 🟡

    An extra word added to each promise type to point to its vtable.

    Complexity ✅

    Very easy, just add a virtual API and a override in the promise bases.


    2. Minimal Singular Virtual Table Entry

    Not sure of a better phrase for this. I got this from Qwen4-coder:30b. The idea is identical to what I've done in strong_ptr.

    template<typename T>
    struct rc
    {
      ref_info m_info;
      T m_object;
    
      // Constructor that forwards arguments to the object
      template<typename... Args>
      constexpr rc(std::pmr::memory_resource* p_alloc, Args&&... args)
        : m_info(p_alloc, &destruct_this_type_and_return_size)
        , m_object(std::forward<Args>(args)...)
      {
      }
    
      constexpr static std::size_t destruct_this_type_and_return_size(
        void const* p_object)
      {
        if (p_object != nullptr) {
          // Cast back into the original rc<T> type and ...
          auto const* obj = static_cast<rc<T> const*>(p_object);
          obj->~rc<T>();
        }
        // Return size for future deallocation
        return sizeof(rc<T>);
      }
    };

    The rc<T> has knowledge of the type and thus it can compose the destruct_this_type_and_return_size static function. There is only 1 per type used with strong_ptr. It knows exactly how to set the future_state to the correct value at the cost of a single pointer.

    ROM Costs 🟡

    Cost of the cancellation function is what is needed to set the variant to cancellation state. The number of these functions is equal to the number of unique T types corresponding to promise types.

    Runtime Costs ✅

    None. Only used by context when needed.

    RAM Costs 🟡

    Each promise base must hold another pointer moving the size from 2-words to 3-words of memory.

    Complexity ✅

    Very easy to implement although more complicated than some of the other solutions.

    3. Add promise<T> destructor that performs cancellation if necessary

    If we make the promise's destructor perform this work, then we can get what we want. But now each promise has a non-trivial destructor.

    ROM Costs 🟡

    N destructors for each promise type.

    Runtime Costs ‼️

    Runtime cost to check each future as a promise is being destroyed.

    RAM Costs ✅

    No additional RAM costs.

    Complexity ✅

    Very easy, just add a destructor.


    4. Define our own variant

    If we have control over the variant ABI, and we know that the first word is the discriminate, and that the cancel object contains no data, we can simply flip the variant over to the cancelled state. This would be the most optimal in terms of RAM and ROM. Our custom variant wouldn't change in size, it would allow any part of the async context to update a future's state without needing to know about the type T for a type. The only concern I have with this is that we must maintain our own variant type. If we find that we need to gain a bit more performance and reduce code size, this is probably the best option.

    ROM Costs ✅

    Shouldn't be any.

    Runtime Costs ✅

    No runtime cost, cancel just has to reach in and change a number. Otherwise nothing else changes.

    RAM Costs ✅

    No additional ram costs because no new information was needed.

    Complexity ‼️

    High. We'd need to reimplement std::variant which is more complicated than it will probably seem.

  4. kammce commented on Jan 14, 2026

    @kammce
    MemberAuthor

    #1 is unacceptable in my view. Context must have a whole vtable because we must ensure that a smart pointer to a type erased context type should know how to destroy the original object. We don't know what contexts devs will make and its likely they will leverage RAII if they need to destroy resources they have claimed. With promise types, we just need a pay to tell them to cancel their futures. So that leaves 2 to 4.

  5. kammce commented on Jan 14, 2026

    @kammce
    MemberAuthor

    2 and 3 have interesting costs. With the function pointer route, the function is as simple as an assignment. No checks are needed. Since this is an internal API we can manage when its called. With the destructor each promise has to perform a check which is unavoidable. For that reason, I think 2 and 4 are the only real contenders.

    So from this point on, its just solution 2 and solution 4.

  6. kammce commented on Jan 14, 2026

    @kammce
    MemberAuthor

    It took me 1 second to look back at solution 4 and go "nah, I'm good." If we choose solution 2, then if we decide to pivot to solution 4 we will need an ABI break. But we'll get a word back and eliminate a bunch of template classes for each promise type.

    So I think we will go with solution 2 for now. I'll create an issue and close this issue.

  7. kammce commented on Jan 14, 2026

    @kammce
    MemberAuthor

    Now that we have a solution in #50, I'll close this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions