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
- Change the guard in
detail/await_suspend_helper.hpp to
BOOST_CAPY_WORKAROUND(_MSC_VER, < 1950) so 19.50+ takes the
symmetric-transfer path.
- Update the Javadoc on
symmetric_transfer to state the affected
version range and that the workaround self-retires on VS 2026
toolsets.
- 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.
Summary
detail/await_suspend_helper.hppdisables symmetric transfer on allMSVC versions, substituting
h.resume()on the current stack. Thecodegen 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.
Background
MSVC spills the
std::coroutine_handle<>returned fromawait_suspendinto a hidden__$ReturnUdt$slot located on thesuspending coroutine's frame. If the frame is destroyed inside
await_suspend(when_all_runner/when_any_runnerfinal_suspend), or another thread resumes and destroys it after anexecutor handoff (
boundary_trampoline), the read-back for thetail-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 deletepoisons the frame with0xDDinstead offreeing it, and
await_suspendcopies the continuation to the stackbefore 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.
/O2/OdGCC 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
Proposed change
detail/await_suspend_helper.hpptoBOOST_CAPY_WORKAROUND(_MSC_VER, < 1950)so 19.50+ takes thesymmetric-transfer path.
symmetric_transferto state the affectedversion range and that the workaround self-retires on VS 2026
toolsets.
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_suspendat 19.44 vs 19.50before 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.