let_async_scopeDate: 16th July 2026
Author: Anthony Williams anthony@justsoftwaresolutions.co.uk
Audience: LWG
This is intended to address concerns raised in LEWG about ensuring
that a counting_scope is joined: the scope provided by
let_async_scope is always joined, irrespective of how the
nested work completes, and whether or not the provided function throws
an exception after spawning work.
Code with explicit counting_scope:
some_data_type scoped_data = make_scoped_data();
counting_scope scope;
spawn(on(exec, [&] {
spawn(on(exec, [&] {
if (need_more_work(scoped_data)) {
spawn(on(exec, [&] { do_more_work(scoped_data); }), scope.get_token());
spawn(on(exec, [&] { do_more_other_work(scoped_data); }), scope.get_token());
}
}),scope.get_token());
spawn(on(exec, [&] { do_something_else_with(scoped_data); }), scope.get_token());
}),scope.get_token());
maybe_throw();
this_thread::sync_wait(scope.join());Here, if maybe_throw throws an exception, then the scope
is not joined, and the nested tasks can continue executing
asynchronously, potentially accessing both the scope and
scoped_data objects out of lifetime.
Using let_async_scope addresses this by encapsulating
the scope object and the result of the previous sender. The returned
sender does not complete until all tasks nested on the scope complete,
even if the function passed to let_async_scope exits via an
exception:
auto scope_sender = just(make_scoped_data()) | let_async_scope([](auto scope_token,
auto& scoped_data) {
spawn(on(exec, [scope_token, &scoped_data] {
spawn(on(exec, [scope_token, &scoped_data] {
if (need_more_work(scoped_data)) {
spawn(on(exec, [&scoped_data] { do_more_work(scoped_data); }),scope_token);
spawn(on(exec, [&scoped_data] { do_more_other_work(scoped_data); }),scope_token);
}
}),scope_token);
spawn(on(exec, [&scoped_data] { do_something_else_with(scoped_data); }),scope_token);
}),scope_token);
maybe_throw();
});
this_thread::sync_wait(scope_sender);Here, even if maybe_throw throws an exception, then
scope_sender doesn’t complete until all the nested tasks
have completed. This prevents out-of-lifetime access to the
scoped_data or the scope itself, unless references to the
data or scope_token are stored outside the sender tree.
Stop requests are propagated to all senders nested in the async scope, but does not prevent those senders adding additional work to the scope. This allows senders to respond to stop requests by scheduling additional work to perform the necessary cleanup for cancellation.
If either the function passed to let_async_scope throws
an exception, or any of the senders associated with the async scope
complete with an error, then that exception or error completion is used
as the completion of the sender returned from
let_async_scope. This applies even if the function passed
to let_async_scope returns normally; in that case the
return value is discarded in favour of the error return. If multiple
errors are raised, one of them is chosen to be used as the completion of
the sender returned from let_async_scope; all other errors
are discarded.
Given:
auto scope_sender = just(make_scoped_data()) | let_async_scope([](auto scope_token,
auto& scoped_data) {
spawn(just_error(foo{}),scope_token);
spawn(just_error(bar{}),scope_token);
});
this_thread::sync_wait(scope_sender);Then the sync_wait will throw either an exception of
type foo or an exception of type bar, but it
is not specified which.
In order to allow the error propagation, all senders associated with
the scope must have a compatible error signature. The default error
signature is set_error_t(std::exception_ptr), and all
raised errors are wrapped with AS-EXCEPT-PTR (See
[exec.general/8). An explicit error signature can be specified by
calling let_async_scope_with_error, in which case errors
are not converted unless std::exception_ptr is the
only permitted error type.
“Noexcept” error signature scopes:
auto scope_sender = just(make_scoped_data()) | let_async_scope_with_error<>([](auto scope_token,
auto& scoped_data) noexcept {
spawn(just_error(foo{}),scope_token); // error, sender may fail in "noexcept" scope
});
this_thread::sync_wait(scope_sender);“Default” error signature scopes:
auto scope_sender = just(make_scoped_data()) | let_async_scope([](auto scope_token,
auto& scoped_data) noexcept(false) {
spawn(just_error(foo{}),scope_token); // Coerced with AS-EXCEPT-PTR
});
this_thread::sync_wait(scope_sender); // throws foo{}“Explicit error signature” scopes:
auto scope_sender = just(make_scoped_data()) |
let_async_scope_with_error<foo,bar>(
[](auto scope_token, auto& scoped_data) noexcept {
spawn(just_error(foo{}),scope_token); // OK
spawn(just_error(bar{}),scope_token); // OK
spawn(just_error(baz{}),scope_token); // error
});
this_thread::sync_wait(scope_sender | upon_error([](auto e){
static_assert(is_same<decltype(e),foo> || is_same<decltype(e),bar>,
"Error must be foo or bar");
}));let_async_scope provides a means of creating an async
scope, which is associated with a set of tasks, and ensuring that they
are all complete before the async scope sender completes.The previous
sender’s result is passed to a user-specified invocable, along with an
async scope token, which returns a new sender that is connected and
started.
The sender returned by let_async_scope completes with
the result of the completion of the sender returned from the supplied
invocable. It does not complete until all tasks nested on the
scope_token passed to the invocable have completed.
Additional tasks may be nested on copies of the
scope_token, even if the initial sender returned from the
invocable has completed. The returned scope_sender will not
complete while there are any nested tasks that have not completed.
If the callable supplied to let_async_scope does not
return a sender, it must return void. The sender returned
from let_async_scope will then have a void
value completion.
Stop requests are propagated to all senders nested in the async scope.
The environment from the receiver connected to the sender returned
from let_async_scope is propagated via the internal
receiver to all senders spawned using the supplied scope token.
If the callable passed to let_async_scope throws an
exception, or any of the senders associated with the scope complete with
an error, then a stop request is propagated to all outstanding senders
spawned using the scope token.
If either the function passed to let_async_scope throws
an exception, or any of the senders associated with the async scope
complete with an error, then that exception or error completion is used
as the completion of the sender returned from
let_async_scope. This applies even if the function passed
to let_async_scope returns normally; in that case the
return value is discarded in favour of the error return. If multiple
errors are raised, one of them is chosen to be used as the completion of
the sender returned from let_async_scope; all other errors
are discarded.
If the error list specified for
let_async_scope_with_error does not specify
std::exception_ptr, then the function supplied must be
declared noexcept, otherwise the program is ill-formed.
If the possible error completions of senders passed to
spawn with a token from
let_async_scope_with_error are not compatible with the
error signatures then the program is ill-formed.
<execution> synopsis
[execution.syn]Please add the following to 33.4 [execution.syn] inside
namespace std::execution, in the section marked
// 33.9.12, sender adaptors
template<class ...Errors>
struct let_async_scope_with_error_t {};
inline constexpr let_async_scope_with_error_t<exception_ptr> let_async_scope{};
template<class ...Errors>
inline constexpr let_async_scope_with_error_t<Errors...> let_async_scope_with_error{};[exec.adapt]Please add the following as a new section under 33.9.12
[exec.adapt], following 33.9.12.10
[exec.let]
execution::let_async_scopelet_async_scope transforms a sender’s value
completions into a new child asynchronous operation associated with an
async scope, by passing the sender’s result datums to a user-specified
callable, which returns a new sender that is connected and
started.
For a subexpression sndr, let
let-async-scope-env(sndr) be expression-equivalent to the
first well-formed expression below:
SCHED-ENV(get_completion_scheduler<set_value_t>(get_env(sndr),FWD-ENV(env)))MAKE-ENV(get_domain, get_completion_domain<set_value_t>(get_env(sndr),FWD-ENV(env)))(void(sndr), env<>{})The expression let_async_scope(sndr, f) is
expression-equivalent to:
let_async_scope_with_error<exception_ptr>(sndr, f);The name let_async_scope_with_error<Errors...>
denotes a pipeable sender adaptor object. For subexpressions
sndr and f, let F be the decayed
type of f. If decltype((sndr)) does not
satisfy sender or if decltype((f)) does not
satisfy movable-value , the expression
let_async_scope_with_error<Errors...>(sndr, f) is
ill-formed. Otherwise, the expression
let_async_scope_with_error<Errors...>(sndr, f) is
expression-equivalent to:
make-sender(let_async_scope_with_error<Errors...>, f, sndr);Where Errors is the list of possible error completion
types.
The exposition-only class template impls-for
([exec.snd.general]) is specialized for let_async_scope as
follows:
namespace std::execution {
template<class Errors, class State, class Rcvr, class... Args>
void let-async-scope-bind(State& state, Rcvr& rcvr, Args&&... args); // exposition only
template<class ... Errors>
using error-variant-type = see below; // exposition only
template<typename ... Errors>
struct impls-for<let_async_scope_with_error_t<Errors...>> : default-impls {
static constexpr auto get-state = see below;
static constexpr auto complete = see below;
template<class Sndr,class... Env>
static consteval void check-types();
};
}error-variant-type<Errors...> is
monostate if Errors... is an empty pack;
otherwise it is a variant<Errors...>, with duplicate
types removed.
Let receiver2 denote the following exposition-only
class template:
namespace std::execution {
template<class Rcvr, class Env,class ... Errors>
struct receiver2 {
using receiver_concept = receiver_tag;
explicit receiver2(Rcvr& rcvr, Env env)
: rcvr(rcvr), env(std::move(env)) {}
auto get_env() const noexcept {
return see below;
}
void set_value(Args&& ... args) && noexcept{
bool has_error{false};
if constexpr(!is_same_v<error-variant-type<Errors...>,monostate>){
if(error && error->has_value()){
error->value().visit([&rcvr](auto& the_error){
execution::set_error(std::move(rcvr),std::move(the_error));
});
has_error=true;
}
}
if(!has_error){
execution::set_value(std::move(rcvr),std::forward<Args>(args)...);
}
}
Rcvr& rcvr; // exposition only
optional<error-variant-type<Errors...>>*error; // exposition only
Env env; // exposition only
};
}Invocation of the function receiver2 ::get_env returns
an object e such that
decltype(e) models queryable andq and a pack of subexpressions
args, the expression e.query(q, args...) is
expression-equivalent to env .query(q, args...) if that
expression is valid; otherwise, if the type of q satisfies
forwarding-query , e.query(q, args...) is
expression-equivalent to get_env(rcvr ).query(q, args...);
otherwise, e.query(q, args...) is ill-formed.template<class Sndr, class... Env>
static consteval void check-types ();Effects: Equivalent to:
using ScopedFn = remove_cvref_t<data-type <Sndr>>;
auto cs = get_completion_signatures<child-type <Sndr>, FWD-ENV-T (Env)...>();
auto fn = []<class... Ts>(set_value_t(*)(Ts...)) {
if constexpr (!is-valid-scoped-sender )
// see below
throw unspecified-exception ();
};
cs.for-each (overload-set (fn, [](auto){}));where is-valid-scoped-sender is true if and only if all
of the following are true:
(constructible_from<decay_t<Ts>, Ts> &&...)invocable<ScopedFn, decay_t<Ts>&...>sender<invoke_result_t<ScopedFn, decay_t<Ts>&...>>sizeof...(Env) == 0 || sender_in<invoke_result_t<ScopedFn, decay_t<Ts>&...>, env-t...>where env-t is the pack
decltype(JOIN-ENV (let-env (declval<child-type <Sndr>>(), declval<Env>()), FWD-ENV (declval<Env>()))).
impls-for<let_async_scope_with_error_t<Errors...>>::get-state
is initialized with a callable object equivalent to the following:
[]<class Sndr, class Rcvr>(Sndr&& sndr, Rcvr& rcvr) requires see below {
auto&& [tag, data, child] = std::forward<Sndr>(sndr);
return [&]<class Fn, class Env>(Fn fn, Env env) {
using args-variant-type = see below;
using ops2-variant-type = see below;
struct state-type { // exposition only
Fn fn;
Env env;
counting_scope scope;
args-variant-type args;
ops2-variant-type ops2;
optional<error-variant-type<Errors...>> error;
};
return state-type{std::move(fn), std::move(env), {}, {}};
}(std::forward_like<Sndr>(data), let-async-scope-env(child));
}scope-token-type is the type of the
async-scope-token associated with this invocation of
let_async_scope_with_error. It is a type that satisfies the
scope_token concept, and wraps an instance of the
counting_scope::token associated with the internal
state-type::scope instance, such that calls to the
wrap, try_associate and
disassociate member functions are forwarded to the wrapped
counting_scope::token instance.
[Note: The operation logic means that close is never
called on the scope member of instances of
state-type — end note]
Let Sigs be a pack of the arguments to the
completion_signatures specialization named by
completion_signatures_of_t<child-type<Sndr>, decltype(FWD-ENV(get_env(declval<Rcvr&>())))>.
Let LetSigs be a pack of those types in Sigs
with a return type of set_value_t. Let
as-tuple be an alias template such that
as-tuple<Tag(Args...)> denotes the type
decayed-tuple<Args...>. Then
args-variant-type denotes the type
variant<monostate, as-tuple<LetSigs>...>
with duplicate types removed.
Let as-sndr2 be an alias template such that
as-sndr2<Tag(Args...)> denotes the type
call-result-t<Fn, scope-token-type, decay_t<Args>&...>.
Then ops2-variant-type denotes the type
variant<monostate, connect_result_t<as-sndr2<LetSigs>, receiver2<Rcvr, Env, Errors...>>...>
with duplicate types removed.
The requires-clause constraining the above lambda is
satisfied if and only if the types args-variant-type and
ops2-variant-type are well-formed.
scope-token-type shall be a unique type, such that
invoking spawn(snd, token, env) or
spawn(snd, token) where token is an instance
of scope-token-type invokes a distinct overload of
spawn. Such an invocation is ill-formed if the completion
signatures of snd include error completions that are not
compatible with the Errors... list of the
let_async_scope_with_error<Errors...> invocation. If
the error list is compatible, then such an invocation of
spawn(snd,token,env) is equivalent to
spawn(snd | upon_error(
[&state](auto&& error){
{
internal-synchronization-lock guard(state);
state.error.emplace(TRANSFORM-ERROR(error));
}
state.scope.request_stop();
}), state.scope.get_token(), env);and an invocation of spawn(snd,token) is equivalent
to
spawn(snd | upon_error(
[&state](auto&& error){
{
internal-synchronization-lock guard(state);
state.error.emplace(TRANSFORM-ERROR(error));
}
state.scope.request_stop();
}), state.scope.get_token(), state.env);Where TRANSFORM-ERROR is
AS-EXCEPT-PTR(error) if Errors... is a list
consisting of the single element exception_ptr, and
std::forward<decltype(error)>(error) otherwise,
and internal-synchronization-lock guard(state); is an
exposition only placeholder for appropriate synchronization to ensure
that the calls to state.error.emplace synchronize with each
other. [Note: All these accesses happen-before the use in
receiver2::set_value above, due to the use of
state.scope.join() to ensure all senders have completed. —
end note][Note: This could be achieved by storing a mutex
in the state and using lock_guard, but other
implementation strategies may be more optimal. — end note]
The exposition-only function template
let-async-scope-bind is equal to:
auto& args = state.args.emplace<decayed-tuple<scope-token-type, Args...>>(
create-scope-token(), std::forward<Args>(args)...);
try {
auto sndr2 = nest(apply(std::move(state.fn), args), state.scope);
auto join_sender = state.scope.join();
auto result_sender = when_all_with_variant(std::move(sndr2), std::move(join_sender)) |
then([](auto& result, auto&) { return result; });
auto rcvr2 = receiver2{&state.error, std::move(rcvr), std::move(state.env)};
auto mkop2 = [&] { return connect(std::move(result_sender), std::move(rcvr2)); };
auto& op2 = state.ops2.emplace<decltype(mkop2())>(emplace-from{mkop2});
start(op2);
} catch (...) {
state.scope.request_stop();
auto result_sender = when_all(just_error(std::current_exception()), state.scope.join());
auto rcvr2 = receiver2{&state.error, std::move(rcvr), std::move(state.env)};
auto mkop2 = [&] { return connect(std::move(result_sender), std::move(rcvr2)); };
auto& op2 = state.ops2.emplace<decltype(mkop2())>(emplace-from{mkop2});
start(op2);
}where create-scope-token() creates an instance of the
scope-token-type associated with the state for
this invocation of let_async_scope_with_error.
impls-for<let_async_scope_with_error_t<Errors...>>::complete
is is initialized with a callable object equivalent to the
following:
[]<class Tag, class... Args>
(auto, auto& state, auto& rcvr, Tag, Args&&... args) noexcept -> void {
if constexpr (same_as<Tag, set_value_t>) {
TRY-EVAL(std::move(rcvr), let-async-scope-bind(state, rcvr, std::forward<Args>(args)...));
} else {
Tag()(std::move(rcvr), std::forward<Args>(args)...);
}
}Let sndr and env be subexpressions, and
let Sndr be decltype((sndr)). If
sender-for<Sndr, let_async_scope_with_error_t<Errors...>>
is false, then the expression
let_async_scope_with_error<Errors...>.transform_env(sndr, env)
is ill-formed. Otherwise, it is equal to
JOIN-ENV(let-env(sndr), FWD-ENV(env)).
Let the subexpression out_sndr denote the result of
the invocation
let_async_scope_with_error<Errors...>(sndr, f) or an
object copied or moved from such, and let the subexpression
rcvr denote a receiver such that the expression
connect(out_sndr, rcvr) is well-formed. The expression
connect(out_sndr, rcvr) has undefined behavior unless it
creates an asynchronous operation ([async.ops]) that, when started:
invokes f when set_value is called with
sndr’s result datums,
makes its completion dependent on the completion of a sender
returned by f, and
propagates the other completion operations sent by
sndr.
spawn is
expected.spawn: with/without explicit
environmentasync_scope_token concept to
scope_token insteadset_value_t directly rather than
DECAY_OF(set_value)let-async-scope-env to match
let_valueusing declaration for
error-variant-typeclosed state for
the scope to a notestate.errorstate.error in
let-async-scope-bindget-domain-earlyFWD-ENV to SCHED-ENV to match
let_value<execution> synopsisdecayed-type-ofstd:: qualification from
exception_ptr in wordingvisitErrors... is an empty packreceiver2 stores a reference to Rcvr
rather than movingcounting_scope directly rather than the unspecified
scope-typelet_async_scope_with_errors is pipeablecheck-typesget_env wording changed to match that of
let_valueThanks to Ian Petersen, Lewis Baker, Inbal Levi, Kirk Shoop, Eric Niebler, Ruslan Arutyunyan, Maikel Nadolski, Lucian Radu Teodorescu, Robert Leahy, Dmitry Prokoptsev, Dietmar Kuehl, Ville Voutilainen and everyone else who contributed to discussions leading to this paper, and commented on early drafts.