diff --git a/include/exec/timed_scheduler.hpp b/include/exec/timed_scheduler.hpp index 4ef4e5534..d56217d0b 100644 --- a/include/exec/timed_scheduler.hpp +++ b/include/exec/timed_scheduler.hpp @@ -213,17 +213,22 @@ namespace experimental::execution operator()(_Scheduler &&__sched, const duration_of_t<_Scheduler> &__duration) const noexcept { using __sched_t = __decay_t<_Scheduler>; + // The let_value sender below invokes the native CPO with the captured + // scheduler as a `const __sched_t&`. Probe the native sender type the + // same way, so instead-of-inventory such as the completion-scheduler + // claim is computed from the overload the deferred call resolves to. using __native_sender_t = - __call_result_t<__schedule_at_base_t, _Scheduler, time_point_of_t<_Scheduler> const &>; + __call_result_t<__schedule_at_base_t, __sched_t const &, time_point_of_t<_Scheduler>>; return __sexpr{ __timed_scheduler_fallback::__tag(), __timed_scheduler_fallback::__data<__sched_t, __native_sender_t>{__sched}, - let_value(just(), - [__sched, __duration]() noexcept( - __nothrow_callable> - &&__nothrow_callable) - { return schedule_at(__sched, now(__sched) + __duration); })}; + let_value( + just(), + [__sched, __duration]() noexcept( + __nothrow_callable> + &&__nothrow_callable) + { return schedule_at(__sched, now(__sched) + __duration); })}; } }; } // namespace __schedule_after @@ -279,8 +284,10 @@ namespace experimental::execution noexcept(noexcept(schedule_after(__sched, __time_point - now(__sched)))) { using __sched_t = __decay_t<_Scheduler>; + // Same as above: probe through the const-qualified lvalue the + // deferred lambda captures, not the call site's value category. using __native_sender_t = - __call_result_t<__schedule_after_base_t, _Scheduler, duration_of_t<_Scheduler> const &>; + __call_result_t<__schedule_after_base_t, __sched_t const &, duration_of_t<_Scheduler>>; return __sexpr{__timed_scheduler_fallback::__tag(), __timed_scheduler_fallback::__data<__sched_t, __native_sender_t>{__sched}, diff --git a/test/exec/CMakeLists.txt b/test/exec/CMakeLists.txt index 9489a47df..a63885178 100644 --- a/test/exec/CMakeLists.txt +++ b/test/exec/CMakeLists.txt @@ -21,6 +21,7 @@ set(exec_test_sources test_any_sender.cpp test_task.cpp test_timed_thread_scheduler.cpp + test_timed_scheduler_fallback.cpp test_thread_pool_base.cpp test_variant_sender.cpp test_type_async_scope.cpp diff --git a/test/exec/test_timed_scheduler_fallback.cpp b/test/exec/test_timed_scheduler_fallback.cpp new file mode 100644 index 000000000..2cf54359f --- /dev/null +++ b/test/exec/test_timed_scheduler_fallback.cpp @@ -0,0 +1,179 @@ +/* + * 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. + */ + +// Regression tests for #2165: the `schedule_after`/`schedule_at` fallbacks must +// probe the native sender through the same const-qualification the deferred +// invocation uses. The fallback's let_value lambda captures the scheduler by +// copy and is not mutable, so it invokes the native CPO with a `const S&`. The +// probe in the fallback deduced the native sender type through a non-const +// lvalue instead, so a scheduler whose const-lvalue and non-const-lvalue +// overloads return differing senders advertises a completion scheduler that +// its deferred invocation never resolves to. + +#include + +#include + +#include +#include + +namespace +{ + // A scheduler with no `schedule_after` member and lvalue-ref-qualified + // `schedule_at` members. `schedule_after` on it goes through the fallback + // (into `schedule_at`), and the two lvalue ref-qualified `schedule_at` + // overloads return senders that differ in what their attributes advertise. + struct ref_qualified_scheduler + { + using scheduler_concept = STDEXEC::scheduler_tag; + using time_point = std::chrono::steady_clock::time_point; + using duration = time_point::duration; + + struct sender; + + struct const_lvalue_sender; + + constexpr auto operator==(ref_qualified_scheduler const &) const noexcept -> bool = default; + + [[nodiscard]] + auto now() const noexcept -> time_point + { + return {}; + } + + [[nodiscard]] + auto schedule() const noexcept -> sender; + + // The overload the probe reaches: a non-const lvalue. Its sender does not + // advertise a completion scheduler. + [[nodiscard]] + auto schedule_at(time_point) & noexcept -> sender; + + // The overload the fallback's deferred invocation reaches: the lambda + // holds the scheduler as a `const S&`. Its sender advertises this + // scheduler as the completion scheduler. + [[nodiscard]] + auto schedule_at(time_point) const & noexcept -> const_lvalue_sender; + }; + + // The native sender resolved through the non-const-lvalue overload. Its + // attributes do not advertise a completion scheduler. + struct ref_qualified_scheduler::sender + { + using sender_concept = STDEXEC::sender_tag; + using completion_signatures = STDEXEC::completion_signatures; + + template + struct operation + { + void start() & noexcept + { + STDEXEC::set_value(static_cast(receiver_)); + } + + Receiver receiver_; + }; + + template + auto connect(Receiver receiver) const -> operation + { + return {static_cast(receiver)}; + } + }; + + // The native sender resolved through the const-lvalue overload. Its + // attributes advertise this scheduler as the completion scheduler. + struct ref_qualified_scheduler::const_lvalue_sender + { + using sender_concept = STDEXEC::sender_tag; + using completion_signatures = STDEXEC::completion_signatures; + + struct attrs + { + [[nodiscard]] + auto query(STDEXEC::get_completion_scheduler_t) const noexcept + -> ref_qualified_scheduler + { + return {}; + } + }; + + template + struct operation + { + void start() & noexcept + { + STDEXEC::set_value(static_cast(receiver_)); + } + + Receiver receiver_; + }; + + template + auto connect(Receiver receiver) const -> operation + { + return {static_cast(receiver)}; + } + + [[nodiscard]] + constexpr auto get_env() const noexcept -> attrs + { + return {}; + } + }; + + auto ref_qualified_scheduler::schedule() const noexcept -> sender + { + return {}; + } + + auto ref_qualified_scheduler::schedule_at(time_point) & noexcept -> sender + { + return {}; + } + + auto ref_qualified_scheduler::schedule_at(time_point) const & noexcept -> const_lvalue_sender + { + return {}; + } + + static_assert(exec::timed_scheduler); +} // namespace + +TEST_CASE("timed scheduler fallback probes through const-lvalue scheduler", + "[timed_scheduler][completion_scheduler]") +{ + using completion_scheduler_query = STDEXEC::get_completion_scheduler_t; + + // Passing the scheduler as a prvalue: the fallback must probe the native + // sender the same way its deferred lambda will invoke it, i.e. through a + // `const S&`. + auto after = exec::schedule_after(ref_qualified_scheduler{}, std::chrono::milliseconds(10)); + using after_t = decltype(after); + static_assert(STDEXEC::__callable>); + + // Same behavior regardless of the value category at the call site. + ref_qualified_scheduler sched; + auto after_from_lvalue = exec::schedule_after(sched, std::chrono::milliseconds(10)); + using after_from_lvalue_t = decltype(after_from_lvalue); + static_assert( + STDEXEC::__callable>); + + CHECK(STDEXEC::get_completion_scheduler(STDEXEC::get_env(after)) + == ref_qualified_scheduler{}); + CHECK(STDEXEC::get_completion_scheduler(STDEXEC::get_env(after_from_lvalue)) + == sched); +}