Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 20 additions & 31 deletions include/exec/function.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -574,29 +574,21 @@ namespace experimental::execution
template <class _Factory, class... _Args>
concept __factory_is_nothrow_invocable = __nothrow_invocable<_Factory const &, _Args...>;

//! Satisfied when transform_sender, given a _Sender and an _Env, returns a
//! sender of a different type, i.e. a domain has transformed it.
template <class _Sender, class _Env>
concept __is_transformed_for =
!__same_as<__decay_t<transform_sender_result_t<_Sender, _Env>>, __decay_t<_Sender>>;

//! Satisfied when connecting the sender _Factory returns to the type-erased
//! receiver _Receiver can't throw, short of failing to allocate a frame (see
//! __nothrow_frame_allocation_receiver). If a domain transforms that sender,
//! the transformation and the transformed sender's connect are trusted
//! rather than checked. The domains that can transform it depend only on
//! the sender and on the queries the function type declares: _Receiver's
//! environment provides nothing else but a frame allocator, so the
//! caller's domain can't reach the sender. Those domains are chosen only
//! implicitly (declaring a get_scheduler_t query, for example, brings that
//! scheduler's domain into play) and nothing checks the choice, so
//! declaring the function type noexcept vouches for whatever they do.
//! __nothrow_frame_allocation_receiver). connect passes the sender through
//! transform_sender first, so this covers any domain's transformation of
//! the sender as well as the transformed sender's connect. Checking the
//! transformation here, before any caller exists, is sound because no
//! caller can change it: the domains that can transform the sender depend
//! only on the sender and on the queries the function type declares.
//! _Receiver's environment provides nothing else but a frame allocator, so
//! the caller's domain reaches the sender only through a declared query,
//! and then only as the type that query names.
template <class _Factory, class _Receiver, class... _Args>
concept __factory_sender_connects_without_throwing =
__is_transformed_for<__invoke_result_t<_Factory const &, _Args...>,
env_of_t<__nothrow_frame_allocation_receiver<_Receiver>>>
|| __nothrow_connectable<__invoke_result_t<_Factory const &, _Args...>,
__nothrow_frame_allocation_receiver<_Receiver>>;
__nothrow_connectable<__invoke_result_t<_Factory const &, _Args...>,
__nothrow_frame_allocation_receiver<_Receiver>>;

//! Defines the additional constraints on the sender factory of a noexcept
//! function type, whose noexcept promises that the type-erased path
Expand All @@ -607,8 +599,7 @@ namespace experimental::execution
//! - returns a sender that connects to _Receiver without throwing as
//! defined by __factory_sender_connects_without_throwing.
//! These are the only checks: each is exact, and when one fails, the
//! function type's author can fix it. Anything a domain transformation
//! does is trusted instead.
//! function type's author can fix it.
template <bool _Nothrow, class _Factory, class _Receiver, class... _Args>
concept __meets_noexcept_contract =
(!_Nothrow)
Expand Down Expand Up @@ -832,10 +823,10 @@ namespace experimental::execution
}

//! connect is noexcept exactly when nothing on the path to the operation
//! state can throw: the function type is noexcept and the frame allocator
//! chosen from the receiver's environment allocates without throwing. If
//! a trusted domain transformation or a transformed sender's connect
//! throws anyway, std::terminate is called.
//! state can throw: the function type is noexcept, so the constructor has
//! checked that invoking the factory and connecting the sender it returns
//! don't throw, and the frame allocator chosen from the receiver's
//! environment allocates without throwing.
template <class _Receiver>
static constexpr bool __nothrow_connect = _Nothrow
&& __opstate_t<_Receiver>::__nothrow_frame_allocation;
Expand Down Expand Up @@ -1311,12 +1302,10 @@ namespace experimental::execution
//! accounts for. For the forms with computed completions it also drops
//! set_error(exception_ptr); for the sender_tag form the completion
//! signatures are exactly as given, since they describe only the
//! asynchronous contract. The constructor enforces the promise where it can
//! be checked exactly: it requires the factory to be nothrow-invocable with
//! the curried arguments and, unless a domain transforms the factory's
//! sender, that sender's connect not to throw. A domain transformation and
//! the transformed sender's connect are trusted; if either throws anyway,
//! std::terminate is called.
//! asynchronous contract. The constructor enforces the promise: it requires
//! the factory to be nothrow-invocable with the curried arguments, and the
//! sender it returns to connect without throwing, including any domain's
//! transformation of that sender.
//!
//! Future: support C-style ellipsis arguments in the function signature to
//! permit type-erased arguments as well, like function<int(bar, baz, ...)> (a
Expand Down
26 changes: 24 additions & 2 deletions include/stdexec/__detail/__sequence.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ namespace STDEXEC

template <class _Self>
static constexpr auto transform_sender(set_value_t, _Self &&__self, __ignore) //
-> decltype(auto)
noexcept(__nothrow_transform<_Self>()) -> decltype(auto)
{
constexpr std::size_t __nbr_sndrs = __nbr_children_of<_Self>;
if constexpr (__nbr_sndrs == 0)
Expand All @@ -491,10 +491,32 @@ namespace STDEXEC
}

private:
static constexpr auto __mk_sndr = []<class... _Child>(__ignore, __ignore, _Child &&...__child)
static constexpr auto __mk_sndr =
[]<class... _Child>(__ignore, __ignore, _Child &&...__child) //
noexcept(__nothrow_decay_copyable<_Child...>)
{
return __seq::__sndr<__decay_t<_Child>...>{static_cast<_Child &&>(__child)...};
};

//! whether transform_sender, given a sequence of type _Self, can't throw
template <class _Self>
static consteval auto __nothrow_transform() noexcept -> bool
{
constexpr std::size_t __nbr_sndrs = __nbr_children_of<_Self>;
if constexpr (__nbr_sndrs == 0)
{
return true;
}
else if constexpr (__nbr_sndrs == 1)
{
return noexcept(
static_cast<__tuple_element_t<2, _Self>>(STDEXEC::__get<2>(__declval<_Self>())));
}
else
{
return __nothrow_applicable<decltype(__mk_sndr) const &, _Self>;
}
}
};

inline constexpr __sequence_t __sequence{};
Expand Down
2 changes: 1 addition & 1 deletion include/stdexec/__detail/__transform_sender.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ namespace STDEXEC
}
else if constexpr (__same_as<__result_t, _Sndr>)
{
return __declfn<__result_t, __is_nothrow>();
return __declfn<__result_t, (_Nothrow && __is_nothrow)>();
}
else if constexpr (__same_as<_OpTag, start_t>)
{
Expand Down
1 change: 1 addition & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ set(stdexec_test_sources
stdexec/cpos/test_cpo_receiver.cpp
stdexec/cpos/test_cpo_schedule.cpp
stdexec/cpos/test_cpo_start.cpp
stdexec/cpos/test_cpo_transform_sender.cpp
stdexec/cpos/test_cpo_upon_stopped.cpp
stdexec/detail/test_any.cpp
stdexec/detail/test_common_domain.cpp
Expand Down
49 changes: 34 additions & 15 deletions test/exec/test_function.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -532,22 +532,29 @@ namespace
}
};

using nothrow_connect_sender = connect_sender<true, 1>;
using throwing_connect_sender = connect_sender<false, 2>;
using nothrow_connect_sender = connect_sender<true, 1>;
using throwing_connect_sender = connect_sender<false, 2>;
using other_nothrow_connect_sender = connect_sender<true, 3>;

//! A domain that rewrites a nothrow_connect_sender into a
//! throwing_connect_sender, with a transformation that isn't declared
//! noexcept either
//! A domain that rewrites a nothrow_connect_sender into a Result, with a
//! transformation that is noexcept(NothrowTransform)
template <bool NothrowTransform, class Result>
struct rewriting_domain
{
template <class OpTag, class Env>
auto
transform_sender(OpTag, nothrow_connect_sender &&, Env const &) const -> throwing_connect_sender
auto transform_sender(OpTag, nothrow_connect_sender &&, Env const &) const
noexcept(NothrowTransform) -> Result
{
return {};
}
};

//! A noexcept function type whose implementation's environment answers
//! get_domain with a Domain
template <class Domain>
using nothrow_function_in =
exec::function<int() noexcept, exec::queries<Domain(ex::get_domain_t) noexcept>>;

TEST_CASE("a noexcept function type constrains its sender factory", "[types][function]")
{
auto returns_throwing_connect_sender = []() noexcept
Expand Down Expand Up @@ -654,26 +661,38 @@ namespace
REQUIRE(ret == 42);
}

SECTION("a domain's transformation and the transformed sender's connect are trusted")
SECTION("a domain's transformation and the transformed sender's connect are checked")
{
using fn =
exec::function<int() noexcept, exec::queries<rewriting_domain(ex::get_domain_t) noexcept>>;
using nothrow_transform = rewriting_domain<true, other_nothrow_connect_sender>;
using throwing_transform = rewriting_domain<false, other_nothrow_connect_sender>;
using to_throwing_sender = rewriting_domain<true, throwing_connect_sender>;

auto returns_nothrow_connect_sender = []() noexcept
{
return nothrow_connect_sender();
};

STATIC_REQUIRE(std::constructible_from<fn, decltype(returns_nothrow_connect_sender)>);
using factory_t = decltype(returns_nothrow_connect_sender);

STATIC_REQUIRE(std::constructible_from<nothrow_function_in<nothrow_transform>, factory_t>);

// the transformation may throw, even though the sender it returns
// connects without throwing
STATIC_REQUIRE(!std::constructible_from<nothrow_function_in<throwing_transform>, factory_t>);

fn sndr(returns_nothrow_connect_sender);
// the transformation doesn't throw, but the sender it returns may throw
// when connected
STATIC_REQUIRE(!std::constructible_from<nothrow_function_in<to_throwing_sender>, factory_t>);

nothrow_function_in<nothrow_transform> sndr(returns_nothrow_connect_sender);

auto [ret] = ex::sync_wait(std::move(sndr)
| ex::write_env(ex::prop(ex::get_domain, rewriting_domain())))
| ex::write_env(ex::prop(ex::get_domain, nothrow_transform())))
.value();

// the throwing_connect_sender ran, so the domain did rewrite the sender
REQUIRE(ret == 2);
// the other_nothrow_connect_sender ran, so the domain did rewrite the
// sender
REQUIRE(ret == 3);
}
}

Expand Down
26 changes: 26 additions & 0 deletions test/stdexec/algos/adaptors/test_sequence.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,32 @@ TEST_CASE("sequence with two arguments works", "[sequence]")
}
}

namespace
{
//! A value whose copy constructor may throw, but whose move constructor
//! can't
struct throwing_copy
{
throwing_copy() = default;

throwing_copy(throwing_copy const &) noexcept(false) {}

throwing_copy(throwing_copy &&) noexcept = default;
};
} // namespace

TEST_CASE("lowering a sequence is noexcept unless moving or copying its children can throw",
"[sequence]")
{
auto nothrow = exec::sequence(ex::just(), ex::just(42));
STATIC_REQUIRE(noexcept(ex::transform_sender(std::move(nothrow), ex::env<>{})));
STATIC_REQUIRE(ex::__nothrow_connectable<decltype(nothrow), ex::__receiver_archetype<ex::env<>>>);

auto throwing = exec::sequence(ex::just(), ex::just(throwing_copy{}));
STATIC_REQUIRE(noexcept(ex::transform_sender(std::move(throwing), ex::env<>{})));
STATIC_REQUIRE(!noexcept(ex::transform_sender(throwing, ex::env<>{})));
}

#if !STDEXEC_NO_STDCPP_EXCEPTIONS()
TEST_CASE("sequence with sender with throwing connect", "[sequence]")
{
Expand Down
98 changes: 98 additions & 0 deletions test/stdexec/cpos/test_cpo_transform_sender.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2026 Ian Petersen
* Copyright (c) 2026 NVIDIA Corporation
*
* Licensed under the Apache License Version 2.0 with LLVM Exceptions
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* https://llvm.org/LICENSE.txt
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include <catch2/catch_all.hpp>

#include <stdexec/execution.hpp>

#include <stdexcept>
#include <type_traits>
#include <utility>

namespace ex = STDEXEC;

namespace
{
inline constexpr auto return_zero = []() noexcept
{
return 0;
};

using then_sender_t = decltype(ex::just() | ex::then(return_zero));
using just_sender_t = decltype(ex::just(42));

//! A domain that transforms a then_sender_t into a just_sender_t, which it
//! doesn't transform, so the result is transformed again, by default_domain.
//! The transformation throws unless it's noexcept.
template <bool Nothrow>
struct then_to_just_domain
{
template <class Sndr, class Env>
requires std::same_as<std::remove_cvref_t<Sndr>, then_sender_t>
auto
transform_sender(ex::start_t, Sndr &&, Env const &) const noexcept(Nothrow) -> just_sender_t
{
if constexpr (!Nothrow)
{
throw std::runtime_error("transform_sender");
}
return ex::just(42);
}
};

template <bool Nothrow>
struct receiver
{
using receiver_concept = ex::receiver_tag;

void set_value(int) noexcept {}

auto get_env() const noexcept
{
return ex::prop(ex::get_domain, then_to_just_domain<Nothrow>{});
}
};

TEST_CASE("transform_sender is noexcept only if every transformation is",
"[cpo][cpo_transform_sender]")
{
SECTION("a potentially-throwing transformation whose result is transformed again")
{
auto env = ex::prop(ex::get_domain, then_to_just_domain<false>{});
STATIC_REQUIRE(
std::same_as<decltype(ex::transform_sender(std::declval<then_sender_t>(), env)),
just_sender_t>);
STATIC_REQUIRE(!noexcept(ex::transform_sender(std::declval<then_sender_t>(), env)));
STATIC_REQUIRE(
!noexcept(ex::connect(std::declval<then_sender_t>(), std::declval<receiver<false>>())));
}

SECTION("non-throwing transformations")
{
auto env = ex::prop(ex::get_domain, then_to_just_domain<true>{});
STATIC_REQUIRE(noexcept(ex::transform_sender(std::declval<then_sender_t>(), env)));
STATIC_REQUIRE(
noexcept(ex::connect(std::declval<then_sender_t>(), std::declval<receiver<true>>())));
}
}

TEST_CASE("an exception thrown by a transformation propagates out of connect",
"[cpo][cpo_transform_sender]")
{
auto sndr = ex::just() | ex::then(return_zero);
REQUIRE_THROWS_AS(ex::connect(std::move(sndr), receiver<false>{}), std::runtime_error);
}
} // namespace