Skip to content

refactor: add internal Promise/Future classes for future use#521

Merged
beekld merged 20 commits intomainfrom
beeklimt/promises
Apr 15, 2026
Merged

refactor: add internal Promise/Future classes for future use#521
beekld merged 20 commits intomainfrom
beeklimt/promises

Conversation

@beekld
Copy link
Copy Markdown
Contributor

@beekld beekld commented Apr 13, 2026

This PR adds internal-only Promise and Future classes. These have some advantages over using callbacks everywhere:

  • They are more easily composable. That means utility functions like WhenAll and WhenAny only have to be written once, instead of having to re-invent the wheel every time we need those concepts.
  • This allows C++ code to be structured more like the code in other languages with proper promise types.
  • Unlike std::future, this supports a Then method, which enables things like WhenAny without occupying an extra thread per future.
  • Unlike std::function, the Continuation class used in this implementation supports move-only functions.

Some points to consider:

  • Would other people like using these, or am I the only one?
  • Naming: I chose to mimic the C++ names of std::promise, std::future, and then() (experimental).
  • Executors: I chose to require passing an executor into Then, because there is no natural default:
    • C++ doesn't have a "main" thread exactly, or at least, it's not used the same way as on other platforms.
    • Immediate executors are confusing to reason about, and result in calling work on unpredictable threads.
    • We shouldn't assume asio::post, as this class may be used for other things.
    • I didn't want to add the complexity of storing execution contexts in a thread-local.
  • Error handling: I chose not to put error handling directly in the Future, and encourage users to use Future<std::expected<>> if they care about it, to keep things simpler.
  • Cancellation: I chose not to implement cancellation. We can add it in the future. But cancellation can be treated as mostly orthogonal to futures, and we can't cancel http requests currently anyway, so 🤷‍♀️ .
  • By default, GetResult is non-blocking, as this prevents unintended blocking. There is a blocking version called WaitForResult with a timeout if needed.
  • The continuations in Then are passed the result of the Future instead of the Future itself. 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.
  • The type T in Promise<T>/Future<T> has to be copyable. This is an inherent limitation of the need to be able to have Future<Future<T>> unwrappable to Future<T>, while also letting a Future have multiple continuations.
  • The executor has to be copyable, for simplicity. ASIO has the same requirement on executors, and it seems fine.

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-only Continuation, and WhenAll/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.hpp implementing Promise<T>/Future<T> with executor-based Then chaining (including flattening when a continuation returns another Future) and a move-only Continuation wrapper to support move-only captures.

Introduces aggregation helpers WhenAll and WhenAny, a blocking WaitForResult (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.

@beekld beekld requested a review from a team as a code owner April 13, 2026 20:48
@beekld
Copy link
Copy Markdown
Contributor Author

beekld commented Apr 13, 2026

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.

Comment thread libs/internal/include/launchdarkly/async/promise.hpp
@beekld
Copy link
Copy Markdown
Contributor Author

beekld commented Apr 13, 2026

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.

Comment thread libs/internal/include/launchdarkly/async/promise.hpp Outdated
Comment thread libs/internal/include/launchdarkly/async/promise.hpp
Comment thread libs/internal/include/launchdarkly/async/promise.hpp
Comment thread libs/internal/include/launchdarkly/async/promise.hpp Outdated
// 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_);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;                                                                                             
  }   

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread libs/internal/include/launchdarkly/async/promise.hpp Outdated
Comment thread libs/internal/include/launchdarkly/async/promise.hpp
class Promise {
public:
Promise() : internal_(std::make_shared<PromiseInternal<T>>()) {}
~Promise() = default;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need any Future handling for an abandoned unresolved promise?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread libs/internal/include/launchdarkly/async/promise.hpp
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need some more thread-intense test scenarios?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went ahead and added some tests for those cases. Doesn't hurt to be thorough when it comes to thread safety. 🫡

@beekld beekld requested a review from kinyoklion April 15, 2026 17:29
Comment thread libs/internal/include/launchdarkly/async/promise.hpp
Copy link
Copy Markdown
Member

@kinyoklion kinyoklion left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remaining comments are all optional.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread libs/internal/tests/promise_test.cpp Outdated
@beekld beekld merged commit 4b40bb4 into main Apr 15, 2026
46 checks passed
@beekld beekld deleted the beeklimt/promises branch April 15, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants