Skip to content

HTTP/2 pool: RequestOptions#setConnectTimeout triggers spurious IllegalStateException: Promise already completed when one connection serves multiple queued waiters #6197

Description

@phraktle

Version

5.1.2

Context

With RequestOptions#setConnectTimeout(...) set on HTTP/2 requests, the client logs bursts of unhandled java.lang.IllegalStateException: Promise already completed from SharedHttpClientConnectionGroup. The requests themselves all succeed — the exception is a late, orphaned second completion of the pool acquisition promise, so it's harmless but inflates error rates and is alarming in logs.

Root cause

An HTTP/2 client keeps a single pooled connection (http2MaxPoolSize defaults to 1), so concurrent requests queue as pool waiters while that connection is being established. setConnectTimeout arms a per-waiter wait-queue expiry timer in SharedHttpClientConnectionGroup$Request#onConnect.

When the connection comes up, SimpleConnectionPool$ConnectSuccess#execute serves the whole batch off the one connection. The primary waiter is marked waiter.disposed = true, but the extra waiters polled from the queue are not:

for (int i = 0; i < m; i++) {
  leases[i] = new LeaseImpl<>(slot, pool.waiters.poll().handler); // disposed never set
}

(SimpleConnectionPool$SetConcurrency#execute has the same loop.)

Later, the expiry timer fires for one of those extra waiters and calls SimpleConnectionPool#cancel. Because waiter.disposed was never set, Cancel#execute reports cancelled = true:

if (pool.waiters.remove(waiter)) { cancelled = true; waiter.disposed = true; }
else if (!waiter.disposed)       { waiter.disposed = true; cancelled = true; }  // <-- taken
else                             { cancelled = false; }

so the timer callback calls promise.fail(...) on the acquisition promise that already completed successfully → IllegalStateException: Promise already completed.

Suggested fix

Mark waiters disposed when they are served in the multi-lease loops of ConnectSuccess#execute and SetConcurrency#execute, or have Cancel#execute only report cancelled = true for waiters still actually present in the queue — so a connect-timeout timer that fires after its waiter was already served becomes a no-op instead of failing the completed promise.

Steps to reproduce

import io.vertx.core.Vertx;
import io.vertx.core.http.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class Http2PoolConnectTimeoutDoubleComplete {
  public static void main(String[] args) throws Exception {
    Vertx vertx = Vertx.vertx();

    HttpServer server = vertx.createHttpServer(new HttpServerOptions())   // h2c, answers immediately
        .requestHandler(req -> req.response().end("ok"))
        .listen(0, "localhost").toCompletionStage().toCompletableFuture().get();
    int port = server.actualPort();

    HttpClient client = vertx.createHttpClient(new HttpClientOptions()    // single pooled h2 connection
        .setProtocolVersion(HttpVersion.HTTP_2)
        .setHttp2ClearTextUpgrade(false));

    int n = 20, connectTimeoutMs = 500;
    AtomicInteger ok = new AtomicInteger(), failed = new AtomicInteger();
    CountDownLatch done = new CountDownLatch(n);

    for (int i = 0; i < n; i++) {                                         // fire before the connection is up
      client.request(new RequestOptions()
              .setMethod(HttpMethod.GET).setPort(port).setHost("localhost").setURI("/")
              .setConnectTimeout(connectTimeoutMs))                        // <-- arms the expiry timer
          .compose(req -> req.send().compose(HttpClientResponse::body))
          .onComplete(ar -> { (ar.succeeded() ? ok : failed).incrementAndGet(); done.countDown(); });
    }

    done.await(5, TimeUnit.SECONDS);
    Thread.sleep(connectTimeoutMs + 1000);   // let the now-pointless timers fire on the served waiters

    System.out.printf("requests ok=%d failed=%d  (note the ~%d 'Promise already completed' stack traces above)%n",
        ok.get(), failed.get(), n - 1);
    vertx.close();
  }
}

Expected

No exception. Every request succeeds, and the expiry timer for an already-served waiter should be a no-op.

Actual

All N requests succeed, but ~N-1 unhandled exceptions are logged (one per batch-served waiter; the primary waiter is fine):

requests ok=20 failed=0

with 19 stack traces like:

ERROR io.vertx.core.impl.ContextImpl - Unhandled exception
java.lang.IllegalStateException: Promise already completed
        at io.vertx.core.Promise.fail(Promise.java:103)
        at io.vertx.core.http.impl.SharedHttpClientConnectionGroup$Request.lambda$onConnect$0(SharedHttpClientConnectionGroup.java:125)
        at io.vertx.core.Completable.succeed(Completable.java:31)
        at io.vertx.core.internal.pool.SimpleConnectionPool$Cancel.run(SimpleConnectionPool.java:688)
        at io.vertx.core.internal.pool.Task.runNextTasks(Task.java:43)
        at io.vertx.core.internal.pool.CombinerExecutor.submit(CombinerExecutor.java:91)
        at io.vertx.core.internal.pool.SimpleConnectionPool.execute(SimpleConnectionPool.java:242)
        at io.vertx.core.internal.pool.SimpleConnectionPool.cancel(SimpleConnectionPool.java:650)
        at io.vertx.core.http.impl.SharedHttpClientConnectionGroup$Request.lambda$onConnect$1(SharedHttpClientConnectionGroup.java:123)
        at io.vertx.core.impl.VertxImpl$InternalTimerHandler.handle(VertxImpl.java:1066)
        at io.vertx.core.impl.VertxImpl$InternalTimerHandler.handle(VertxImpl.java:1033)
        at io.vertx.core.impl.ContextBase.emit(ContextBase.java:82)
        at io.vertx.core.internal.ContextInternal.emit(ContextInternal.java:187)
        at io.vertx.core.impl.VertxImpl$InternalTimerHandler.run(VertxImpl.java:1055)
        ...

Do you have a reproducer?

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions