Skip to content

fix(client): destroy pool clients that fail to close - #3437

Open
GiHoon1123 wants to merge 3 commits into
redis:masterfrom
GiHoon1123:fix-pool-close-swallowed-error
Open

fix(client): destroy pool clients that fail to close#3437
GiHoon1123 wants to merge 3 commits into
redis:masterfrom
GiHoon1123:fix-pool-close-swallowed-error

Conversation

@GiHoon1123

@GiHoon1123 GiHoon1123 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

close() awaited Promise.all() over each idle client's close() inside a try/catch with an empty catch block. One client failing to close skipped everything after it: #idleClients and #clientsInUse were never reset, and the rejection never reached the caller.

Switched to Promise.allSettled and moved cleanup into a finally block, so every client gets processed and the pool resets regardless of individual failures. A client whose close() rejects is destroyed, and close() rethrows the first rejection instead of discarding it.

Two follow-ups from review: a concurrent close() call while one was already in progress used to resolve immediately regardless of the outcome, so close() now shares its in-flight promise with concurrent callers. And a cache cleanup failure in the finally block could replace a pending client close error (a throw in finally overrides the exception it's handling), so cache cleanup is now wrapped in its own try/catch.

Tests: one client already closed makes close() reject while the pool still resets; a second client's close() is delayed to check it still gets waited on; concurrent close() calls observe the same outcome; a failing cache cleanup doesn't hide the real close error.


Note

Medium Risk
Changes lifecycle teardown for pooled connections (close/drain/cache), which can affect shutdown ordering and error propagation in apps using credentials providers or client-side cache.

Overview
pool.close() no longer swallows per-client close failures or leaves the pool half-torn-down. Client shutdown uses Promise.allSettled so every idle client is closed (or cleaned up) before the pool resets; a rejecting close() triggers destroy() on that client and the promise rejects with the first close error instead of resolving after an empty catch.

Concurrent close() calls now share one in-flight promise (via #closePromise, replacing a separate #isClosing flag) so later callers get the same resolve/reject outcome. Pool teardown (idle/in-use lists, isOpen, client-side cache onPoolClose) runs in a finally block so it always runs; cache cleanup errors are caught so they do not mask the underlying client close error.

docs/pool.md documents graceful shutdown, ClientClosedError for new work while closing, and handling close rejections. pool.spec.ts adds coverage for partial failures, concurrent close, credential disposal errors, cache cleanup, and reentrant execute during close.

Reviewed by Cursor Bugbot for commit 2a8f5a8. Bugbot is set up for automated code reviews on this repo. Configure here.

@GiHoon1123
GiHoon1123 force-pushed the fix-pool-close-swallowed-error branch from 2732fa9 to e10da39 Compare September 5, 2026 03:19
close() awaited Promise.all() over each idle client's close() inside a
try/catch with an empty catch block. One client failing to close
skipped everything after it: #idleClients and #clientsInUse were never
reset, and the rejection never reached the caller.

Switched to Promise.allSettled and moved cleanup into a finally block,
so every client gets processed and the pool resets regardless of
individual failures. A client whose close() rejects is destroyed, and
close() rethrows the first rejection instead of discarding it.

Also documents this in docs/pool.md.
@GiHoon1123
GiHoon1123 force-pushed the fix-pool-close-swallowed-error branch from e10da39 to 97ed810 Compare September 5, 2026 03:21

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 97ed810868

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/client/lib/client/pool.ts Outdated
this._self.#drainResolve = undefined;
this._self.#isClosing = false;
this._self.#isOpen = false;
this._self.#clientSideCache?.onPoolClose();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the client close error through cache cleanup

When a pooled client's close() rejects and a custom PooledClientSideCacheProvider.onPoolClose() also throws, this call in the finally block replaces the original client-close rejection. That contradicts the documented behavior that pool.close() rejects with the client close error and defeats the nearby attempt to preserve that error when forced destruction fails; retain the first close failure while still attempting cache cleanup.

Useful? React with 👍 / 👎.

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.

Real — fixed in 51a84e5. onPoolClose() is now wrapped in its own try/catch so a cleanup failure can't replace the client close error, same as the destroy() fallback right above it.

Comment on lines +650 to +651
for (const result of results) {
if (result.status === 'rejected') throw result.reason;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return the active close result to concurrent callers

When two callers invoke pool.close() while shutdown is in progress, only the first caller can receive the newly propagated client-close error here; the second hits the existing #isClosing early return and immediately receives a fulfilled promise, potentially before shutdown finishes. This makes identical concurrent shutdown requests report contradictory outcomes, so callers should share the active close promise (including its rejection) rather than treating an in-progress close as successful.

Useful? React with 👍 / 👎.

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.

Real — fixed in 4ea2a0e. close() now stores its in-flight promise and returns it to a concurrent caller instead of resolving early.

A second caller invoking close() while the first was still in progress
hit the #isClosing guard and resolved immediately, independent of how
the first call's close() turned out. Once close() could reject, this
meant concurrent callers could see contradictory outcomes for the same
shutdown: one rejected, the other resolved before the pool had
actually finished closing.

close() now stores its in-flight promise and returns that to a
concurrent caller instead of resolving early.

@nkaradzhov nkaradzhov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hi @GiHoon1123, this looks great, thanks!

one small nit:
#isClosing and #closePromise now change together: both are set in close(), both cleared in the finally. They can never disagree, so the flag is redundant. The isClosing getter can just return #closePromise !== undefined, and the field can go (the four internal reads route through the getter)

@GiHoon1123
GiHoon1123 force-pushed the fix-pool-close-swallowed-error branch from 51a84e5 to 11b19c6 Compare September 5, 2026 08:58

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 11b19c605e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/client/lib/client/pool.ts Outdated

this._self.#isClosing = true;
clearTimeout(this._self.cleanupTimeout);
this._self.#closePromise = this._self.#doClose();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Publish the close promise before shutdown hooks run

When an idle client's synchronous shutdown hook re-enters the pool—for example, an end listener emitted by client.close() calls pool.execute()—the RHS starts #doClose() before this assignment stores the promise. During that hook, isClosing is therefore false while #isOpen remains true, so the new task is accepted even though shutdown has started; #doClose() has already passed its drain check and can reset the client lists and settle while that task remains active. The fresh evidence in the current code is this pre-assignment execution window, so the closing state needs to be published before any client-close hooks run.

Useful? React with 👍 / 👎.

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.

Fixed in 2a8f5a8. close() publishes the in-flight promise before #doClose() can run client shutdown hooks, and the regression test covers re-entrant work from a close hook.

@GiHoon1123
GiHoon1123 force-pushed the fix-pool-close-swallowed-error branch from 11b19c6 to 6d3e3d6 Compare September 5, 2026 09:21

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d3e3d6028

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/client/lib/client/pool.ts Outdated
Comment on lines +664 to +665
this._self.#isOpen = false;
this._self.#closePromise = undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the pool closing while running the cache hook

When a custom PooledClientSideCacheProvider.onPoolClose() synchronously calls pool.connect(), these assignments have already made both isOpen and isClosing false, so connect() accepts the request and starts new clients before the original close() has settled. Since no state reset occurs after the hook, close() can return with the pool open again; run the hook before clearing the closing state and only mark the pool closed afterward.

Useful? React with 👍 / 👎.

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.

Fixed in 2a8f5a8. onPoolClose() now runs while the pool is still closing; the pool state is reset only after the hook returns. Added a regression test for a hook that calls connect().

@GiHoon1123
GiHoon1123 force-pushed the fix-pool-close-swallowed-error branch from 6d3e3d6 to 5d50fcd Compare September 5, 2026 09:27
onPoolClose() ran directly inside the finally block. A throw from a custom PooledClientSideCacheProvider there replaced any client close error already in flight, since a throw inside finally overrides the exception it was handling. close() would then reject with the cache cleanup error instead of the real client close failure.\n\nonPoolClose() is now wrapped in its own try/catch, matching the existing destroy() fallback above it: attempt cleanup, but never let its failure hide the original close error. The pool stays in its closing state until the hook returns, so a synchronous hook cannot reopen it with connect().
@GiHoon1123
GiHoon1123 force-pushed the fix-pool-close-swallowed-error branch from 5d50fcd to 2a8f5a8 Compare September 5, 2026 09:27
@GiHoon1123

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review. I’ve addressed the review feedback and the maintainer’s suggestion: the close promise is published before shutdown hooks run, the pool remains in its closing state during cache cleanup, and the redundant #isClosing field has been removed. I also added regression coverage for the re-entrant close and connect paths.

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