Skip to content

Closed NetClient is never removed from its owner's CloseFuture, leaking every client created with createNetClient() #6311

Description

@phiz71

Version

5.1.6, and every 5.x I tested down to 5.0.12. Not present in 4.5.21 or 4.5.27.

Context

vertx.createNetClient() returns a client that is never released, even after close() has completed. Each call leaves one NetClientImpl reachable for as long as the Vert.x instance lives.

VertxImpl.createNetClient registers the internal client on the close future of its owner:

public NetClient createNetClient(NetClientOptions options) {
  NetClientImpl netClient = new NetClientBuilder(this, options).build();
  CloseFuture fut = resolveCloseFuture();
  fut.add(netClient);
  return new CleanableNetClient(CleanerProvider.INSTANCE.get(), netClient);
}

CloseFuture.add puts it in children, a plain HashMap, and NetClientImpl is not a NestedCloseable, so add never sets an owner and the unregisterFromOwner() path in cascadeClose() never applies to it.
Closing the client reaches channelGroup.shutdown(...), which takes the connections down but leaves the map entry in place. Nothing ever calls CloseFuture.remove(netClient).

4.5.x registered a dedicated CloseFuture per client instead, which took the weakHooks branch of add — a WeakHashMap, plus fut.future().onComplete(ar -> remove(fut)) for auto-removal. Both safety nets went away in 5.x.

There is no workaround through the public API: the object registered is NetClientImpl while
createNetClient returns a CleanableNetClient wrapper, so ContextInternal.removeCloseHook(client)` does not match — the same obstacle described in #6268.

This looks like the NetClient counterpart of #6268, fixed for httpClientBuilder() in 5.1.6. The histogram in that report already showed 10 000 leaked NetClientImpl alongside the HTTP clients. The direct createNetClient path still leaks on 5.1.6.

Steps to reproduce

import io.vertx.core.Vertx;
import io.vertx.core.net.NetClient;

public class Repro {

    public static void main(String[] args) throws Exception {
        Vertx vertx = Vertx.vertx();
        for (int i = 0; i < 10_000; i++) {
            NetClient client = vertx.createNetClient();
            client.close().toCompletionStage().toCompletableFuture().join();
        }
        System.gc();
        Thread.sleep(2_000);
        System.gc();
        System.out.println("pid " + ProcessHandle.current().pid());
        Thread.sleep(120_000);
    }
}
$ jcmd <pid> GC.class_histogram | grep NetClientImpl
   8:         10000         720000  io.vertx.core.net.impl.tcp.NetClientImpl
  11:         10000         640000  io.vertx.core.net.impl.tcp.NetClientImpl$1

The same loop on 4.5.21 and 4.5.27 leaves nothing behind. Connecting the client before closing it changes nothing — the leak is in create + close alone. The loop above runs on the main thread, so the owner is the Vert.x instance's own close future and nothing releases the clients before the JVM exits.

Measured on JDK 21.0.5 (Temurin), macOS 15.

Do you have a reproducer?

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions