From 365b51aaeb181d9850dc9c5222a36aa1e90adc03 Mon Sep 17 00:00:00 2001 From: romintomasetti Date: Tue, 6 Oct 2026 06:49:40 +0000 Subject: [PATCH 1/4] stdexec(then): noexcept-ness Signed-off-by: romintomasetti --- include/stdexec/__detail/__then.hpp | 1 + test/stdexec/algos/adaptors/test_then.cpp | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/include/stdexec/__detail/__then.hpp b/include/stdexec/__detail/__then.hpp index 1a86eece8..bbf6a3ddc 100644 --- a/include/stdexec/__detail/__then.hpp +++ b/include/stdexec/__detail/__then.hpp @@ -240,6 +240,7 @@ namespace STDEXEC template <__movable_value _Fun> STDEXEC_ATTRIBUTE(always_inline, host, device) constexpr auto operator()(_Fun __fun) const + noexcept(__nothrow_decay_copyable<_Fun>) { return __closure(*this, static_cast<_Fun&&>(__fun)); } diff --git a/test/stdexec/algos/adaptors/test_then.cpp b/test/stdexec/algos/adaptors/test_then.cpp index 88e85e0c3..1292d522d 100644 --- a/test/stdexec/algos/adaptors/test_then.cpp +++ b/test/stdexec/algos/adaptors/test_then.cpp @@ -179,6 +179,17 @@ namespace | ex::then([] { return std::string{"hello"}; })); } + TEST_CASE("then noexceptness", "[adaptors][then]") + { + auto func = [](){}; + + STATIC_REQUIRE(noexcept(ex::then(func))); + + STATIC_REQUIRE(noexcept(STDEXEC::just() | STDEXEC::then(func))); + + STATIC_REQUIRE(noexcept(STDEXEC::then(STDEXEC::just(), func))); + } + TEST_CASE("then keeps error_types from input sender", "[adaptors][then]") { inline_scheduler sched1{}; From ffdc3907a7da30e9cc154f40c0a0a26d8cda10c7 Mon Sep 17 00:00:00 2001 From: romintomasetti Date: Mon, 5 Oct 2026 17:45:28 +0000 Subject: [PATCH 2/4] exec(fork_join): coalesce empty and unary calls Similar to https://github.com/NVIDIA/stdexec/pull/2124. See P4269R1. Signed-off-by: romintomasetti --- include/exec/fork_join.hpp | 24 ++++++++++++++++++++++-- test/exec/test_fork_join.cpp | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/include/exec/fork_join.hpp b/include/exec/fork_join.hpp index 921c84d86..c306dbd66 100644 --- a/include/exec/fork_join.hpp +++ b/include/exec/fork_join.hpp @@ -281,9 +281,28 @@ namespace experimental::execution struct fork_join_t { - template + /// No closure given. + template STDEXEC_ATTRIBUTE(host, device) - constexpr auto operator()(Sndr&& sndr, Closures&&... closures) const // + constexpr auto operator()(Sndr&& sndr) const noexcept(STDEXEC::__nothrow_decay_copyable) + { + return static_cast(sndr); + } + + /// Unary closure. + template requires (!STDEXEC::sender) + STDEXEC_ATTRIBUTE(host, device) + constexpr auto operator()(Sndr&& sndr, Closure&& clsr) const + noexcept(STDEXEC::__nothrow_callable) + { + return static_cast(clsr)(static_cast(sndr)); + } + + /// One sender and multiple closures. + template requires (sizeof...(Closures) > 1) + STDEXEC_ATTRIBUTE(host, device) + constexpr auto operator()(Sndr&& sndr, Closures&&... closures) const + noexcept(STDEXEC::__nothrow_decay_copyable) -> STDEXEC::__well_formed_sender auto { return STDEXEC::__sexpr{fork_join_t(), @@ -291,6 +310,7 @@ namespace experimental::execution static_cast(sndr)}; } + /// One or more closures. template requires((!STDEXEC::sender) && ...) STDEXEC_ATTRIBUTE(host, device) diff --git a/test/exec/test_fork_join.cpp b/test/exec/test_fork_join.cpp index a2d6d41b7..05006230d 100644 --- a/test/exec/test_fork_join.cpp +++ b/test/exec/test_fork_join.cpp @@ -43,6 +43,30 @@ namespace completion_signatures>); } + TEST_CASE("fork_join coalesces empty and unary calls", "[adaptors][fork_join]") + { + /// Empty (no closure given). + STDEXEC::sender auto empty = exec::fork_join(STDEXEC::just()); + using empty_t = decltype(empty); + STATIC_REQUIRE(std::same_as); + STATIC_REQUIRE(!exec::sender_for); + STATIC_REQUIRE(noexcept(exec::fork_join(STDEXEC::just()))); + + auto then = STDEXEC::then([]() noexcept {}); + + /// Unary closure. + STDEXEC::sender auto unary = exec::fork_join(STDEXEC::just(), then); + using unary_t = decltype(unary); + STATIC_REQUIRE(std::same_as); + STATIC_REQUIRE(!exec::sender_for); + STATIC_REQUIRE(noexcept(exec::fork_join(STDEXEC::just(), then))); + + /// Multiple closures. + STDEXEC::sender auto multiple = STDEXEC::just() | exec::fork_join(then, then); + STATIC_REQUIRE(exec::sender_for); + STATIC_REQUIRE(noexcept(exec::fork_join(STDEXEC::just(), then, then))); + } + struct ForwardingThen { template From fadea16d29e6c8d72a7f6e31dcf673e4e9ce8c7d Mon Sep 17 00:00:00 2001 From: Eric Niebler Date: Fri, 9 Oct 2026 23:25:22 +0000 Subject: [PATCH 3/4] clang-format Signed-off-by: romintomasetti --- include/exec/fork_join.hpp | 8 +++++--- include/stdexec/__detail/__then.hpp | 3 +-- test/exec/test_fork_join.cpp | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/include/exec/fork_join.hpp b/include/exec/fork_join.hpp index c306dbd66..18096845d 100644 --- a/include/exec/fork_join.hpp +++ b/include/exec/fork_join.hpp @@ -290,7 +290,8 @@ namespace experimental::execution } /// Unary closure. - template requires (!STDEXEC::sender) + template + requires(!STDEXEC::sender) STDEXEC_ATTRIBUTE(host, device) constexpr auto operator()(Sndr&& sndr, Closure&& clsr) const noexcept(STDEXEC::__nothrow_callable) @@ -299,11 +300,12 @@ namespace experimental::execution } /// One sender and multiple closures. - template requires (sizeof...(Closures) > 1) + template + requires(sizeof...(Closures) > 1) STDEXEC_ATTRIBUTE(host, device) constexpr auto operator()(Sndr&& sndr, Closures&&... closures) const noexcept(STDEXEC::__nothrow_decay_copyable) - -> STDEXEC::__well_formed_sender auto + -> STDEXEC::__well_formed_sender auto { return STDEXEC::__sexpr{fork_join_t(), STDEXEC::__tuple{static_cast(closures)...}, diff --git a/include/stdexec/__detail/__then.hpp b/include/stdexec/__detail/__then.hpp index bbf6a3ddc..9a7ddbff1 100644 --- a/include/stdexec/__detail/__then.hpp +++ b/include/stdexec/__detail/__then.hpp @@ -239,8 +239,7 @@ namespace STDEXEC //! sender `then(sndr, std::move(__fun))`. template <__movable_value _Fun> STDEXEC_ATTRIBUTE(always_inline, host, device) - constexpr auto operator()(_Fun __fun) const - noexcept(__nothrow_decay_copyable<_Fun>) + constexpr auto operator()(_Fun __fun) const noexcept(__nothrow_decay_copyable<_Fun>) { return __closure(*this, static_cast<_Fun&&>(__fun)); } diff --git a/test/exec/test_fork_join.cpp b/test/exec/test_fork_join.cpp index 05006230d..622ad68cf 100644 --- a/test/exec/test_fork_join.cpp +++ b/test/exec/test_fork_join.cpp @@ -47,7 +47,7 @@ namespace { /// Empty (no closure given). STDEXEC::sender auto empty = exec::fork_join(STDEXEC::just()); - using empty_t = decltype(empty); + using empty_t = decltype(empty); STATIC_REQUIRE(std::same_as); STATIC_REQUIRE(!exec::sender_for); STATIC_REQUIRE(noexcept(exec::fork_join(STDEXEC::just()))); @@ -56,7 +56,7 @@ namespace /// Unary closure. STDEXEC::sender auto unary = exec::fork_join(STDEXEC::just(), then); - using unary_t = decltype(unary); + using unary_t = decltype(unary); STATIC_REQUIRE(std::same_as); STATIC_REQUIRE(!exec::sender_for); STATIC_REQUIRE(noexcept(exec::fork_join(STDEXEC::just(), then))); From 0a604fcc43895af189618cc3d4978cd00f0f0127 Mon Sep 17 00:00:00 2001 From: romintomasetti Date: Sat, 10 Oct 2026 12:41:40 +0000 Subject: [PATCH 4/4] fix(fork_join): account for throwing copies in cache variant Signed-off-by: romintomasetti --- include/exec/fork_join.hpp | 18 ++++++++++- test/exec/test_fork_join.cpp | 62 +++++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/include/exec/fork_join.hpp b/include/exec/fork_join.hpp index 18096845d..c23a5c8cc 100644 --- a/include/exec/fork_join.hpp +++ b/include/exec/fork_join.hpp @@ -39,8 +39,24 @@ namespace experimental::execution } }; + template ::value> + struct _cache_completions + { + using type = Completions; + }; + + template + struct _cache_completions + { + using type = + STDEXEC::__concat_completion_signatures_t; + }; + + // Account that decay-copying into the cache may throw. template - using _variant_t = STDEXEC::__mapply_q; + using _variant_t = STDEXEC::__mapply_q::type>; template struct _env_t diff --git a/test/exec/test_fork_join.cpp b/test/exec/test_fork_join.cpp index 622ad68cf..b167c8828 100644 --- a/test/exec/test_fork_join.cpp +++ b/test/exec/test_fork_join.cpp @@ -93,6 +93,35 @@ namespace }; #endif + //! Count how many copies and moves are performed. + struct counter + { + inline static std::atomic copy_constructions{0}; + inline static std::atomic copy_assignments{0}; + inline static std::atomic move_constructions{0}; + inline static std::atomic move_assignments{0}; + + counter() = default; + counter(counter const &) noexcept + { + copy_constructions.fetch_add(1, std::memory_order_relaxed); + } + counter &operator=(counter const &) noexcept + { + copy_assignments.fetch_add(1, std::memory_order_relaxed); + return *this; + } + counter(counter &&) noexcept + { + move_constructions.fetch_add(1, std::memory_order_relaxed); + } + counter &operator=(counter &&) noexcept + { + move_assignments.fetch_add(1, std::memory_order_relaxed); + return *this; + } + }; + template struct identifiable_domain : public STDEXEC::default_domain {}; @@ -164,18 +193,49 @@ namespace #if !STDEXEC_NO_STDCPP_EXCEPTIONS() TEST_CASE("fork_join reports failures while caching results", "[adaptors][fork_join]") { + std::atomic witness{0}; + auto sndr = exec::fork_join(exec::just_from( [](auto sink) noexcept { static throwing_copy value; return sink(value); }), - then([](throwing_copy const &) noexcept {})); + then([&witness](throwing_copy const &) noexcept { ++witness; }), + then([&witness](throwing_copy const &) noexcept { ++witness; })); CHECK_THROWS_AS(sync_wait(std::move(sndr)), int); + + CHECK(witness == 0); } #endif + TEST_CASE("fork_join caches without copying when results are movable and replays by reference to " + "children", + "[adaptors][fork_join]") + { + std::atomic witness{0}; + + auto sndr = exec::fork_join(exec::just_from([](auto sink) noexcept { return sink(counter{}); }), + STDEXEC::then([&witness](counter const &) noexcept { ++witness; }), + STDEXEC::then([&witness](counter const &) noexcept { ++witness; })); + + STDEXEC::sync_wait(std::move(sndr)); + + CHECK(counter::copy_constructions == 0); + CHECK(counter::copy_assignments == 0); +#if STDEXEC_MSVC() + // TODO: MSVC drops the initialization entirely, even though there is an observable side effect (atomic increment). + // See https://github.com/NVIDIA/stdexec/pull/2302#issuecomment-6110273094. + CHECK(counter::move_constructions == 0); +#else + CHECK(counter::move_constructions == 1); +#endif + CHECK(counter::move_assignments == 0); + + CHECK(witness == 2); + } + TEST_CASE("fork_join can be nested", "[adaptors][fork_join]") { std::atomic witness = 0;