Skip to content

Narrow the MSVC symmetric-transfer workaround to _MSC_VER < 1950 #378

Description

@sgerbino

Summary

detail/await_suspend_helper.hpp disables symmetric transfer on all
MSVC versions, substituting h.resume() on the current stack. The
codegen bug it works around is fixed in MSVC 19.50 (VS 2026 / 18.0).
The guard should become a version gate so users on the current
toolset get proper O(1) tail-call transfer instead of O(n) stack
growth.

// current
#if BOOST_CAPY_WORKAROUND(_MSC_VER, >= 1)

// proposed
#if BOOST_CAPY_WORKAROUND(_MSC_VER, < 1950)

Background

MSVC spills the std::coroutine_handle<> returned from
await_suspend into a hidden __$ReturnUdt$ slot located on the
suspending coroutine's frame. If the frame is destroyed inside
await_suspend (when_all_runner / when_any_runner
final_suspend), or another thread resumes and destroys it after an
executor handoff (boundary_trampoline), the read-back for the
tail-call hits freed memory. The workaround trades this for
unconditional suspension with on-stack resumption, giving up bounded
stack usage on MSVC.

Evidence

Verified with a deterministic detector on Compiler Explorer: the
promise's operator delete poisons the frame with 0xDD instead of
freeing it, and await_suspend copies the continuation to the stack
before destroying its own frame. A compiler that round-trips the
return value through the frame resumes a poisoned handle and crashes
with 0xC0000005; a fixed compiler exits 0.

Toolset /O2 /Od
19.34 (VS 17.4) crash crash
19.38 (VS 17.8) crash crash
19.39 (VS 17.9) crash crash
19.40 (VS 17.10) crash crash
19.44 (VS 17.14) crash crash
19.50 (VS 18.0) pass pass
19.50 (VS 18.2) pass
19.51 (VS 18.6) pass

GCC and Clang pass at all tested versions.

Note: Developer Community ticket 10251975
(https://developercommunity.visualstudio.com/t/Using-symmetric-transfer-and-coroutine_h/10251975)
is tagged "Fixed in VS 2022 17.9 Preview 2", but 19.39 crashes on
this repro identically to 19.34. Whatever that fix covered, it was
not this; the gate must not be based on the ticket.

Detector source
#include <coroutine>
#include <cstdio>
#include <cstdlib>
#include <cstring>

bool continuation_ran = false;

struct task
{
    struct promise_type
    {
        void* operator new(std::size_t n)
        {
            return std::malloc(n);
        }

        // Poison instead of free: keeps the read well-defined so the
        // bug manifests as a garbage handle, not silent luck.
        void operator delete(void* p, std::size_t n)
        {
            std::memset(p, 0xDD, n);
        }

        task get_return_object()
        {
            return { std::coroutine_handle<promise_type>::from_promise(*this) };
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::abort(); }
    };
    std::coroutine_handle<promise_type> h;
};

struct destroy_then_transfer
{
    std::coroutine_handle<> next;

    bool await_ready() { return false; }

    std::coroutine_handle<>
    await_suspend(std::coroutine_handle<> self)
    {
        // Copy to the stack first: the awaiter lives on the frame
        // being destroyed. Any remaining frame access after this
        // point is the compiler's doing.
        auto continuation = next;
        self.destroy();
        return continuation;
    }

    void await_resume() {}
};

task continuation_coro()
{
    continuation_ran = true;
    co_return;
}

task victim(std::coroutine_handle<> next)
{
    co_await destroy_then_transfer{ next };
}

int main()
{
    task cont = continuation_coro();
    task v = victim(cont.h);
    v.h.resume();
    if(!continuation_ran)
    {
        std::puts("BUG: continuation never ran");
        return 2;
    }
    cont.h.destroy();
    std::puts("OK: symmetric transfer survived frame destruction");
    return 0;
}

Proposed change

  1. Change the guard in detail/await_suspend_helper.hpp to
    BOOST_CAPY_WORKAROUND(_MSC_VER, < 1950) so 19.50+ takes the
    symmetric-transfer path.
  2. Update the Javadoc on symmetric_transfer to state the affected
    version range and that the workaround self-retires on VS 2026
    toolsets.
  3. Optionally add the detector as a regression test so a future MSVC
    regression (or a wrong gate) is caught by CI once a 19.50+ image
    is in the matrix.

Caveats

The detector exercises the destroy-then-return scenario
deterministically. The cross-thread executor-handoff scenario shares
the same root cause (the return-value spill to the frame) and a pass
implies the spill is gone, but the race itself was not exercised.
Diffing the generated code for await_suspend at 19.44 vs 19.50
before merging would confirm the frame store is absent.

The workaround itself must remain while CI supports MSVC 14.34; this
issue only narrows its version range.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Ready

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions