refactor: add internal Promise/Future classes for future use#521
refactor: add internal Promise/Future classes for future use#521
Conversation
|
The Windows CI seems to be broken because it couldn't download OpenSSL? I don't think that has anything to do with my change. |
|
FWIW, I wrote the bulk of this code by hand. Claude helped fill out the comments, and also helped me work through some of the trickier template syntax. |
| // Call continuations outside the lock so that continuations which | ||
| // re-enter this future (e.g. via GetResult or Then) don't deadlock. | ||
| for (auto& continuation : to_call) { | ||
| continuation(*result_); |
There was a problem hiding this comment.
Should we copy the result to a local under the lock?
bool Resolve(T result) {
...
T resolved_value; // local copy
{
...
resolved_value = *result_; // copy under the lock
...
}
for (auto& continuation : to_call) {
continuation(resolved_value); // use local copy
}
return true;
}
There was a problem hiding this comment.
This was intentional, to avoid an unnecessary copy. It's safe to access result_ outside the lock here because once result_ is set (which is checked above in the lock), it can never be changed. Due to the nature of promises, I can't imagine that invariant ever changing.
Would you rather I add a comment to that a effect, or make the extra copy to be defensive?
There was a problem hiding this comment.
I am just a little concerned with what the fences may look like here, and a copy inside the lock removes that concern. But I cannot really imagine a re-ordering situation where it would be a problem.
There was a problem hiding this comment.
If the value were a known type, I'd probably just copy it to be on the safe side. But since it's not, I'll leave this for now. Happy to revisit in the future.
| class Promise { | ||
| public: | ||
| Promise() : internal_(std::make_shared<PromiseInternal<T>>()) {} | ||
| ~Promise() = default; |
There was a problem hiding this comment.
Do we need any Future handling for an abandoned unresolved promise?
There was a problem hiding this comment.
That's a really interesting question. If we had a standardized way to signal failure, I would try signaling all futures with that failure. If this were Java, I would fail them with a RuntimeException... But since the C++ standard is std::expected<T, E> with no constraint on E, I'm not really sure what we can do. I don't really think it's worth being more opinionated about error handling here.
TBH, I'm not even sure it's always a failure, since the caller could be racing multiple futures and not care if one of them was abandoned. 🤷♀️
I'm open to discussing it more, if you have ideas.
There was a problem hiding this comment.
I think its only a problem if someone was waiting it? Because it would never resolve? We could handle it "application" level and just have some assert?
Like, you always have to signal, and that means that your types have to be something that can report failure?
There was a problem hiding this comment.
Yeah, it's only a problem if someone is waiting, and even then WaitForResult has a timeout. I dunno, I just think if we want to be opinionated about error handling in this class, we should give it some thought. I'll go with this for now, and we can revisit it later if it turns out to be a problem.
FWIW, it looks like this SDK uses expected with both launchdarkly::Error and launchdarkly::JsonError quite a bit. If we were standardized on one of those, I'd probably be less resistant to using it directly. It might be worth standardizing on a single error enum across the codebase at some point in the future.
| } | ||
|
|
||
| // Move result into storage if possible; otherwise copy. | ||
| if constexpr (std::is_move_assignable_v<T>) { |
There was a problem hiding this comment.
This was intentional, to avoid copying the result if its a moveable value. Normally I wouldn't bother with this kind of optimization, but since it's already in a class full of generic template magic, I figured it fit in. Before changing this to require copyable types, this was necessary, but now it's just a minor optimization.
But I don't feel strongly about it, if you'd rather we just always copy.
There was a problem hiding this comment.
I was thinking always just std::move? Because it should be safe to attempt to move an unmovable type, because it would be equivalent to a cast to an rvalue and just copy when it wasn't movable?
There was a problem hiding this comment.
I tried that first, but it turns out it doesn't compile, because of the way std::optional is written. :-/
I actually have a test that catches it.
There was a problem hiding this comment.
Do we need some more thread-intense test scenarios?
There was a problem hiding this comment.
If you've got any suggestions, I'm happy to take them. I added a test that spawns more threads. Not really sure what other scenarios to try.
FWIW, I tried to run the tests with tsan, but this build is configured with asan, and they can't both be used together. I can look into that more, if you think it's useful. Might need to add a second build configuration or something.
There was a problem hiding this comment.
It could be useful, unfortunately we have lots of builds. But enforcing as much correctness as we can is good.
I'll look at the tests again to see if I have any better thread test suggestions.
There was a problem hiding this comment.
Not really threading, but do we have a test for the timeout path when waiting for a result?
I am not sure if the thread scenarios would really cover more ground.
Creating continuations on multiple threads, racing resolutions on multiple threads, using a multi-threaded asio executor.
They are also probably situations we won't be encountering.
There was a problem hiding this comment.
I went ahead and added some tests for those cases. Doesn't hurt to be thorough when it comes to thread safety. 🫡
kinyoklion
left a comment
There was a problem hiding this comment.
Remaining comments are all optional.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c33f9f3. Configure here.

This PR adds internal-only
PromiseandFutureclasses. These have some advantages over using callbacks everywhere:WhenAllandWhenAnyonly have to be written once, instead of having to re-invent the wheel every time we need those concepts.std::future, this supports aThenmethod, which enables things likeWhenAnywithout occupying an extra thread per future.std::function, theContinuationclass used in this implementation supports move-only functions.Some points to consider:
std::promise,std::future, andthen()(experimental).Then, because there is no natural default:asio::post, as this class may be used for other things.Future, and encourage users to useFuture<std::expected<>>if they care about it, to keep things simpler.GetResultis non-blocking, as this prevents unintended blocking. There is a blocking version calledWaitForResultwith a timeout if needed.Thenare passed the result of theFutureinstead of theFutureitself. This is simpler, but it means we can't add more metadata in the future without a breaking change. This is an internal class, so I think it's better to keep it simple.TinPromise<T>/Future<T>has to be copyable. This is an inherent limitation of the need to be able to haveFuture<Future<T>>unwrappable toFuture<T>, while also letting aFuturehave multiple continuations.I added lots of tests. Some of them double as documentation for humans and LLMs.
Note
Medium Risk
Introduces new concurrency primitives (
Promise/Future, move-onlyContinuation, andWhenAll/WhenAny) with mutex/atomic coordination and executor-dispatched continuations, so correctness depends on subtle threading and lifetime behavior. Changes are currently additive/internal but could impact future async flows once adopted.Overview
Adds a new internal async module
launchdarkly/async/promise.hppimplementingPromise<T>/Future<T>with executor-basedThenchaining (including flattening when a continuation returns anotherFuture) and a move-onlyContinuationwrapper to support move-only captures.Introduces aggregation helpers
WhenAllandWhenAny, a blockingWaitForResult(timeout-based) for test/support scenarios, and wires the new async headers into the internal library build. Adds extensive unit tests covering chaining, move/copy behavior, concurrent resolution, and ASIO executor usage.Reviewed by Cursor Bugbot for commit 0dfce88. Bugbot is set up for automated code reviews on this repo. Configure here.