Motivation
The problem starts with a coroutine which accepts a reference parameter. It is the responsibility of the caller of the coroutine to ensure that the reference argument lives until the coroutine completes (and not just until it returns after the first suspension point).
Reference parameters can unintentionally bind temporaries and local variables of the caller.
For example, a wrapper function, using plain return for coroutine types, makes it easier to blunder and introduce dangling references to locals. The local variable lives only as long as the wrapper function and is destroyed after the return. The coroutine returned by the wrapper would now have dangling references (after its first suspension point).
task<int> coro(const int& a) { co_return a + 1; }
// godbolt.org/z/5Ed5hsETM : stack-use-after-return.
task<int> wrapper(int a) { return coro(a); }
// Ok. 'a' is part of coroutine frame of 'safe_wrapper'.
task<int> safe_wrapper(int a) { co_return co_await coro(a); }
This gets more problematic with template libraries which were previously perfectly safe for synchronous code execution. Now with coroutines, such libraries would become unsafe with the potential introduction of dangling references. std::function
and std::bind
are such examples.
task<int> coro(const int& a) { co_return a + 1; }
int main() {
std::function<task<int>(int)> unsafe = coro;
sync_wait(unsafe(1)); // godbolt.org/z/q557hb35G: stack-use-after-return.
std::function<task<int>(const int&)> safe = coro;
sync_wait(safe(1)); // Ok.
std::function<task<int>(const int&)> unsafe_again = unsafe;
sync_wait(unsafe_again(1)); // godbolt.org/z/znd8Pqn9d: stack-use-after-return.
}
This is because the implementation of std::function
has multiple such wrappers using plain return. See example.
Proposal: Lifetime bound check for parameters of coroutines
Clang already supports sophisticated lifetime bound checks for function parameters annotated with [[clang::lifetimebound]]
. This annotation could be used to annotate function parameters to indicate that the entity referred to by that parameter may also be referred to by the return value of the function.
The reference parameters of a coroutine are, basically, lifetime bound to the coroutine return object.
The key proposals of this document are:
- Extend lifetime-bound analysis to also find lifetime issues in calls to a coroutine.
- Perform this analysis implicitly for calls to a coroutine without needing to explicitly mark coroutine parameters as
[[clang::lifetimebound]]
. - Perform this analysis not just for coroutines but also for plain functions returning a coroutine type.
- Opt-in: Allow coroutine implementations to opt-in for such analysis (should not be default as it can have false positives). An implementation can opt-in by annotating the coroutine result type with
[[clang::coroutine_lifetimebound]]
(new annotation). - Opt-out: It should be possible to disable this analysis for parameters marked explicitly with
[[clang::not_lifetimebound]]
(new annotation).
Details
Firstly, we need to implicitly perform these checks for coroutines instead of relying on explicitly annotated coroutines. Explicit annotations for every parameter in every coroutine declaration take away readability and are also error-prone if we miss applying them.
Secondly, we need to perform these checks not just for coroutines but also for function wrappers. This is because:
- Functions wrapping a coroutine have (mostly) the same lifetime requirements as the wrapped coroutine. In principle, it is possible for a function to not pass its reference parameter to the wrapped coroutine. But it should be fine to be conservative here and consider such params as lifetime bound as well.
- More importantly, it is not possible to distinguish between a function wrapper (returning a coroutine object) and a coroutine by merely looking at the function declaration. This information is only available in a function definition (and a definition might not be available at the callsite).
Thirdly, we would want to allow a coroutine library to opt-in for such lifetime checks. We do not want to enroll all coroutine types since these could give false positives due to function wrappers.
False positives
It is possible for this analysis to produce false warnings in certain scenarios. These include
- A wrapping function accepting a reference parameter but not passing it to the wrapped coroutine.
- A coroutine uses a reference parameter only before its first suspension point.
Proposal: This should be fine to accept in most cases. In order to opt-out, params annotated with [[clang::not_lifetimebound]]
should be skipped from this lifetime-bound analysis.
task<int> coro1() { co_return 1; }
task<int> coro2() { co_return 2; }
// False warning for calls to this wrapper.
task<int> foo(const int& val) { return val > 0 ? coro1() : coro2(); }
task<int> fine([[clang::not_lifetimebound]] const int& val) { return val > 0 ? coro1() : coro2(); }
Future work and improvements
- Reference wrapper: Lifetime bound issues with value types behaving like references are not currently handled. Handling such types properly would be beneficial to memory safety in general and not just to coroutines.
- Function pointers: Current lifetime analysis only works with call expressions associated to a function declaration. This could be extended to perform the proposed implicit analysis for calls involving function pointers.
task<int> coro(const int&);
using FP = task<int>(const int&);
task<int> foo() {
FP* fp = &coro;
return fp(1);
}