Skip to content

Commit 3d7d7a1

Browse files
committed
fix(pooling): fix race conditions, deadlock, and connection leak in PoolEntry
Fix a relocated ABBA deadlock: `PoolEntry.connect()`/`internalConnect()` no longer hold the entry monitor across `client.connect()`, which would deadlock with the Netty close-callback path on the shared `ConnectionImpl` monitor during a close/reconnect overlap. Fix `onConnectionClosed` accounting: the `PoolEntry` shutdown idempotency flag is now reset per connection generation, so a KILL/reconnect cycle emits one close event per generation instead of suppressing all closes after the first. `IProtoClientPoolImpl.forEach()` snapshots clients under the pool lock and invokes the action outside it, avoiding `ConcurrentModificationException` under concurrent `setGroups()` without holding the lock across a user callback.
1 parent 6ba4d21 commit 3d7d7a1

3 files changed

Lines changed: 31 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
### Pooling
2424

2525
- Fix race conditions, ABBA deadlock between `PoolEntry` and `ConnectionImpl` monitors, NPE on inline connect failure, and connection leak after a KILL/reconnect cycle in `PoolEntry` and `IProtoClientPoolImpl`.
26-
- Synchronize `IProtoClientPoolImpl.forEach()` on the pool lock to avoid `ConcurrentModificationException` under concurrent `setGroups()`.
2726

2827
### Dependencies
2928
- Updated dependencies:

tarantool-pooling/src/main/java/io/tarantool/pool/IProtoClientPoolImpl.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,13 +524,15 @@ public void setReconnectAfter(long reconnectAfter) throws IllegalArgumentExcepti
524524

525525
@Override
526526
public void forEach(Consumer<IProtoClient> action) {
527+
List<IProtoClient> clients = new ArrayList<>();
527528
synchronized (connectionPoolLock) {
528529
for (List<PoolEntry> group : entries.values()) {
529530
for (PoolEntry entry : group) {
530-
action.accept(entry.getClient());
531+
clients.add(entry.getClient());
531532
}
532533
}
533534
}
535+
clients.forEach(action);
534536
}
535537

536538
/**

tarantool-pooling/src/main/java/io/tarantool/pool/PoolEntry.java

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,13 @@ final class PoolEntry {
167167
private volatile boolean isLocked;
168168

169169
/**
170-
* Idempotency flag for {@link #shutdown()}.
170+
* Idempotency flag for {@link #shutdown()}, scoped to a single connection generation.
171171
*
172-
* <p>Guarantees that the close listener is invoked only once even if both {@link #close()} and
173-
* {@link #shutdown()} are called, or shutdown is invoked multiple times due to overlapping
174-
* connection error events.
172+
* <p>Guarantees that the close listener is invoked only once per generation even if both {@link
173+
* #close()} and {@link #shutdown()} are called, or shutdown is invoked multiple times due to
174+
* overlapping connection error events. It is reset in {@link #internalConnect()} when a new
175+
* connection generation begins, so a KILL/reconnect cycle still emits one {@code
176+
* onConnectionClosed} per generation.
175177
*/
176178
private final AtomicBoolean isShutdown = new AtomicBoolean(false);
177179

@@ -379,10 +381,7 @@ public void shutdown() {
379381
*
380382
* @return {@link java.util.concurrent.CompletableFuture} with client
381383
*/
382-
public synchronized CompletableFuture<IProtoClient> connect() {
383-
if (connectFuture != null) {
384-
return connectFuture;
385-
}
384+
public CompletableFuture<IProtoClient> connect() {
386385
return internalConnect();
387386
}
388387

@@ -432,20 +431,21 @@ public void stopHeartbeat() {
432431
/**
433432
* Internal method used by reconnect task and public connect.
434433
*
435-
* <p>Returns the local chain {@code cf} rather than the {@link #connectFuture} field. If the
436-
* underlying {@code client.connect()} fails inline (e.g. connection refused), the {@code
437-
* whenComplete} callback fires reentrantly on the calling thread, which causes {@link
438-
* #handleConnectError(Object, Throwable)} to clear the field for reconnect purposes. The local
439-
* variable still references the (failed) future, so callers always receive a non-null result and
440-
* observe the failure via {@code ExecutionException} from {@code .get()}, while the field being
441-
* null guarantees that the next reconnect attempt actually reconnects instead of returning a
442-
* stale failed future.
434+
* <p>The {@code client.connect()} chain is built without holding the entry monitor: holding it
435+
* across {@code client.connect()} (which takes the {@code ConnectionImpl} monitor) would create
436+
* an ABBA deadlock with the Netty close-callback path, which takes the {@code ConnectionImpl}
437+
* monitor first and then re-enters {@link #handleConnectError(Object, Throwable)} on the entry.
438+
* Concurrent callers are de-duplicated via the {@link #connectFuture} field; if two threads race
439+
* past the first check, the second {@code client.connect()} is a no-op because {@code
440+
* ConnectionImpl.connect()} only opens a channel on a {@code CLOSED -> CONNECTING} transition.
443441
*
444442
* @return {@link java.util.concurrent.CompletableFuture} with client
445443
*/
446-
private synchronized CompletableFuture<IProtoClient> internalConnect() {
447-
if (connectFuture != null) {
448-
return connectFuture;
444+
private CompletableFuture<IProtoClient> internalConnect() {
445+
synchronized (this) {
446+
if (connectFuture != null) {
447+
return connectFuture;
448+
}
449449
}
450450
log.info("connect {}/{}", tag, index);
451451
LongTaskTimer.Sample timer = startTimer(connectTime);
@@ -463,7 +463,15 @@ private synchronized CompletableFuture<IProtoClient> internalConnect() {
463463
return client.ping(firstPingOpts);
464464
})
465465
.thenApply(r -> client);
466-
connectFuture = cf;
466+
synchronized (this) {
467+
if (connectFuture != null) {
468+
// ponytail: lost the race; ConnectionImpl's CLOSED->CONNECTING CAS already de-duped the
469+
// channel, so our `cf` wraps the same promise and is safely discarded.
470+
return connectFuture;
471+
}
472+
connectFuture = cf;
473+
isShutdown.set(false);
474+
}
467475
cf.whenComplete(this::onConnectComplete);
468476
return cf;
469477
}

0 commit comments

Comments
 (0)