This is an automated email from the ASF dual-hosted git repository.

sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git


The following commit(s) were added to refs/heads/develop by this push:
     new 130d77d37b fix(plc4j/transports-tcp): close SocketChannel on failed 
bind/socket-option setup (#2645)
130d77d37b is described below

commit 130d77d37b647d8e6426463de7a61198d9b33471
Author: Martin Kribs <[email protected]>
AuthorDate: Thu Jul 23 13:59:10 2026 +0200

    fix(plc4j/transports-tcp): close SocketChannel on failed bind/socket-option 
setup (#2645)
    
    * fix(plc4j/transports-tcp): close SocketChannel on failed 
bind/socket-option setup
    
    TcpTransportInstance's constructor only assigned the opened SocketChannel
    to the socketChannel field after bind(), socket options and connect() all
    succeeded. If bind() or a socket-option call threw (e.g. a pinned local
    port still held by a previous failed attempt), the already-open channel
    was never closed - each such failure leaked one fd. connect() failures
    (timeout/refused) are unaffected since Socket.connect() already closes
    itself internally on failure.
    
    * fix(plc4j/transports-tcp): tighten fd-leak test assertion per review
    
    grown < attempts still passed if the leak only fired on some (but not all) 
iterations. Assert grown <= 1 instead, so any per-attempt leak is caught, not 
just a leak on every single attempt.
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
    
    * fix(plc4j/transports-tcp): address Copilot review feedback on fd-leak test
    
    * fix(plc4j/transports-tcp): close channel on any constructor failure, not 
just IOException
    
    ---------
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 .../java/transport/tcp/TcpTransportInstance.java   | 36 ++++++++++------
 .../transport/tcp/TcpTransportInstanceTest.java    | 49 ++++++++++++++++++++++
 2 files changed, 73 insertions(+), 12 deletions(-)

diff --git 
a/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstance.java
 
b/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstance.java
index 6b67613c05..f6325c50ca 100644
--- 
a/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstance.java
+++ 
b/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstance.java
@@ -74,26 +74,29 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
         this.ringBuffer = new RingBuffer(configuration.receiveBufferSize);
         this.readBuffer = ByteBuffer.allocateDirect(DEFAULT_BUFFER_SIZE);  // 
Reused direct buffer for channel reads
 
+        // Opened into a local first, only promoted to the final 
`socketChannel` field once every
+        // setup step (bind, socket options, connect) has succeeded.
+        SocketChannel channel = null;
         try {
             // Open socket channel
-            this.socketChannel = SocketChannel.open();
+            channel = SocketChannel.open();
 
             // Bind to a local address if specified
             if (configuration.localAddress != null && 
!configuration.localAddress.isEmpty()) {
                 SocketAddress localAddr = new 
InetSocketAddress(configuration.localAddress, configuration.localPort);
-                socketChannel.bind(localAddr);
+                channel.bind(localAddr);
                 LOGGER.debug("Bound to local address {}:{}", 
configuration.localAddress, configuration.localPort);
             }
 
             // Configure socket options before connecting
-            socketChannel.socket().setTcpNoDelay(configuration.tcpNoDelay);
-            socketChannel.socket().setKeepAlive(configuration.keepAlive);
+            channel.socket().setTcpNoDelay(configuration.tcpNoDelay);
+            channel.socket().setKeepAlive(configuration.keepAlive);
 
             if (configuration.sendBufferSize > 0) {
-                
socketChannel.socket().setSendBufferSize(configuration.sendBufferSize);
+                
channel.socket().setSendBufferSize(configuration.sendBufferSize);
             }
             if (configuration.receiveBufferSize > 0) {
-                
socketChannel.socket().setReceiveBufferSize(configuration.receiveBufferSize);
+                
channel.socket().setReceiveBufferSize(configuration.receiveBufferSize);
             }
             // Note: configuration.readTimeout is intentionally NOT mapped to 
Socket.setSoTimeout here.
             // SO_TIMEOUT has no effect on blocking SocketChannel reads, so 
the previous call was a
@@ -101,11 +104,13 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
             // bounds responses via CompletableFuture timeouts), not by the 
transport.
 
             // Connect with timeout
-            socketChannel.socket().connect(remoteAddress, 
configuration.connectTimeout);
+            channel.socket().connect(remoteAddress, 
configuration.connectTimeout);
 
             // Blocking mode: on Java 21 a virtual thread blocked in 
read()/write() parks and
             // releases its carrier, so no selector is needed.
-            socketChannel.configureBlocking(true);
+            channel.configureBlocking(true);
+
+            this.socketChannel = channel;
 
             LOGGER.info("Connected to {}:{} with async support", 
remoteAddress.getHostName(), remoteAddress.getPort());
 
@@ -114,13 +119,20 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
                 remoteAddress.getHostName(), remoteAddress.getPort(),
                 getLocalAddress().getHostName(), getLocalAddress().getPort()));
 
-            // Start the per-connection read loop on a virtual thread (Java 
21+) LAST, so an
-            // unchecked throw from the logging/audit above cannot leak an 
already-running thread
-            // (the catch only handles IOException and does not stop the read 
loop).
+            // Start the per-connection read loop on a virtual thread (Java 
21+) LAST, so a
+            // throw from the logging/audit above cannot leak an 
already-running thread.
             this.readThread = Thread.ofVirtual()
                 .name("TCP-Read-" + remoteAddress.getHostName() + ":" + 
remoteAddress.getPort())
                 .start(this::runReadLoop);
-        } catch (IOException e) {
+        } catch (Exception e) {
+            // Close before any other side effect (logging/audit), so a 
failure there can't skip cleanup.
+            if (channel != null) {
+                try {
+                    channel.close();
+                } catch (IOException closeException) {
+                    e.addSuppressed(closeException);
+                }
+            }
             String errorMsg = String.format("Failed to connect to %s:%d - %s",
                 remoteAddress.getHostName(), remoteAddress.getPort(), 
e.getMessage());
             LOGGER.error(errorMsg, e);
diff --git 
a/plc4j/transports/tcp/src/test/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstanceTest.java
 
b/plc4j/transports/tcp/src/test/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstanceTest.java
index 57c9ea76bd..0781cd7689 100644
--- 
a/plc4j/transports/tcp/src/test/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstanceTest.java
+++ 
b/plc4j/transports/tcp/src/test/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstanceTest.java
@@ -290,6 +290,55 @@ class TcpTransportInstanceTest {
         assertTrue(doneLatch.await(10, TimeUnit.SECONDS));
     }*/
 
+    @Test
+    void testConstructor_failedBind_doesNotLeakFileDescriptors() throws 
Exception {
+        // getOpenFileDescriptorCount() is only exposed on Unix-flavoured JVMs
+        java.lang.management.OperatingSystemMXBean rawOsBean =
+            java.lang.management.ManagementFactory.getOperatingSystemMXBean();
+        org.junit.jupiter.api.Assumptions.assumeTrue(
+            rawOsBean instanceof com.sun.management.UnixOperatingSystemMXBean,
+            "Open file descriptor count not available on this platform");
+        com.sun.management.UnixOperatingSystemMXBean osBean =
+            (com.sun.management.UnixOperatingSystemMXBean) rawOsBean;
+
+        // Occupy a specific local port so binding our own channel to it 
deterministically fails
+        // with BindException
+        try (ServerSocketChannel occupier = ServerSocketChannel.open()) {
+            occupier.bind(new InetSocketAddress("127.0.0.1", 0));
+            int occupiedPort = ((InetSocketAddress) 
occupier.getLocalAddress()).getPort();
+
+            TcpTransportConfiguration config = new TcpTransportConfiguration();
+            config.receiveBufferSize = 81920;
+            config.localAddress = "127.0.0.1";
+            config.localPort = occupiedPort;
+            // Never actually reached - bind() fails first - but must be a 
well-formed address.
+            InetSocketAddress remoteAddress = new 
InetSocketAddress("127.0.0.1", occupiedPort);
+
+            // Sample the fd count after every attempt rather than just 
before/after
+            int attempts = 20;
+            long previous = osBean.getOpenFileDescriptorCount();
+            int increases = 0;
+            for (int i = 0; i < attempts; i++) {
+                assertThrows(TransportException.class, () ->
+                    new TcpTransportInstance(remoteAddress, config, 
AuditLog.builder().build())
+                );
+                long current = osBean.getOpenFileDescriptorCount();
+                if (current > previous) {
+                    increases++;
+                }
+                previous = current;
+            }
+
+            // Without the fix each failed constructor call leaks the 
SocketChannel it opened before
+            // bind() failed, so the fd count grows on essentially every 
attempt. With the fix it doesn't.
+            double leakThreshold = 0.75;
+            assertTrue(increases < attempts * leakThreshold,
+                "Open file descriptor count increased on " + increases + "/" + 
attempts
+                    + " failed bind attempts; expected only incidental growth, 
not a consistent"
+                    + " per-attempt increase (possible SocketChannel leak)");
+        }
+    }
+
     @Test
     void testClose_idempotent() throws TransportException {
         transportInstance.close();

Reply via email to