fix: settle pending async sleeps on close - #892
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 28db2acd5e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (synchronized(lock) { pending.remove(future) }) { | ||
| future.complete(null) |
There was a problem hiding this comment.
Keep the future tracked until it is completed
When the timer fires concurrently with close(), this removes the future from pending and releases the lock before calling future.complete(null). During that gap, close() can acquire the lock, find no pending future, and return while the future is still incomplete, so shutdown does not deterministically settle every outstanding sleep as intended. Complete the future within the same critical section, or otherwise keep it tracked until completion has occurred.
Useful? React with 👍 / 👎.
jbeckwith-oai
left a comment
There was a problem hiding this comment.
Thanks for putting this together — this is a thoughtful, tightly scoped fix for a real lifecycle bug, and the focused tests are a good start.
I confirmed the underlying problem is worth fixing. DefaultSleeper.sleepAsync() has only one normal completion path: its scheduled TimerTask. Timer.cancel() discards queued tasks without running them, so the old close() could permanently orphan their CompletableFutures. RetryingHttpClient.executeAsync() composes the next attempt from that sleep future, and ClientOptions.close() closes the same sleeper. Closing a client during async retry backoff can therefore leave the externally returned request future incomplete indefinitely.
I am requesting one correctness change before merge:
In TimerTask.run(), the new implementation removes future from pending while holding lock, releases the lock, and only then calls future.complete(null). A legal interleaving is:
- The timer thread removes the future and is descheduled before completing it.
close()acquires the lock, sees no pending future, cancels the timer, and returns.- The timer thread resumes and completes the future, potentially starting a retry after the client has closed.
That means close() can still return while an outstanding sleep future is incomplete, contrary to this PR's deterministic-settlement guarantee.
Please keep the future tracked until its completion state has been set, so close() can only miss futures that are already done. One small approach is to check that the future is still tracked without removing it, call complete, and let the existing whenComplete callback perform the removal. Please also add deterministic coverage for the timer-fire-versus-close handoff if it can be done without production-only hooks.
A lightweight RetryingHttpClient regression test would also be valuable: enter a long async backoff with the real DefaultSleeper, close the retry client, assert the returned request future settles promptly, and verify that no second request is issued. I view that as useful cross-layer evidence rather than a separate blocker.
Once the remove-before-complete race is addressed, the rest of the lifecycle handling looks sound: scheduling and close are serialized, repeated close is safe, externally settled futures are untracked, and post-close calls return settled futures.
Summary
Make
DefaultSleeper.close()settle all pendingsleepAsync()futures instead of abandoning them when its backingTimeris cancelled.Fixes #889.
Problem
DefaultSleeper.sleepAsync()completes its returned future from a scheduledTimerTask, whileclose()currently only calls:Timer.cancel()discards scheduled tasks that have not run. Their futures remain incomplete forever.In the SDK this can strand an async retry chain if the client is closed while
RetryingHttpClientis waiting onsleeper.sleepAsync(backoffDuration).A second edge case is
sleepAsync()after close:Timer.schedule()throws synchronously because the timer has already been cancelled.Fix
DefaultSleepernow:sleepAsync()calls instead of throwing synchronously.The synchronous
sleep()path is unchanged.Regression coverage
Added tests verifying:
sleepAsync()after close returns a cancelled future without throwing from the method call.Validation
mainatcf942a40074291290634321ad9fe21e514030b4c;DefaultSleeperand focused lifecycle tests;Full repository validation is left to GitHub Actions.
Risk
Low. The behavioral change is limited to sleeper shutdown: pending async waits now terminate deterministically instead of becoming permanently incomplete.