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

chrisdutz 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 7492d5d5a2 feat(transport-tcp): replace NIO selector with 
per-connection virtual-thread blocking I/O (#2612)
7492d5d5a2 is described below

commit 7492d5d5a2b3027463d9e742704592920cc30fc1
Author: LivingLikeKrillin <[email protected]>
AuthorDate: Thu Jun 25 17:22:33 2026 +0900

    feat(transport-tcp): replace NIO selector with per-connection 
virtual-thread blocking I/O (#2612)
    
    * feat(transport-tcp): replace NIO selector with per-connection 
virtual-thread blocking I/O
    
    Each connection runs a blocking SocketChannel.read() loop on its own 
virtual thread
    instead of a per-connection NIO Selector. On Java 21 blocking-mode 
reads/writes park the
    virtual thread and release the carrier, so the selector (which pins the 
carrier in
    select()) and the OP_WRITE + Thread.sleep(1) write busy-wait are removed. A 
full ring
    buffer applies backpressure (park-and-retry) instead of toggling OP_READ.
    
    Public surface, readLock, RingBuffer, and the AsyncTransportInstance 
callback contract are
    unchanged: the existing TcpTransportInstanceTest (31 tests) passes 
unmodified.
    
    Scaling probe (TcpTransportInstanceScalingTest): 200 idle connections use 2 
carrier
    threads with the blocking model vs 201 with the selector model.
    
    * fix(transport-tcp): emit close log/audit only on successful close
    
    Move the "TCP connection closed" debug line and CLOSE audit event out of
    the finally block and into the success path of close(). Previously they
    ran even when socketChannel.close() threw and the method rethrew, so a
    failed close logged both an ERROR audit event and a misleading CLOSE
    "Closed" event. The readThread.join() stays in finally so the read loop
    is always awaited. Also correct an inaccurate comment in the scaling
    test (the stop-flag holder is a field, not a way to avoid one).
    
    * fix(transport-tcp): drop dead write -1 check and tidy audit/test nits
    
    - write(): blocking SocketChannel.write() never returns -1 (that signals
      read EOF), so the `written == -1` branch was dead code. A broken or closed
      connection already surfaces as IOException/AsynchronousCloseException, 
both
      handled below. Remove the check.
    - constructor: errorMsg already embeds e.getMessage(), so the second ERROR
      audit event duplicated the first. Emit a single event.
    - constructor: start the read-loop virtual thread last (after the INFO log
      and CONNECT audit), so an unchecked throw from logging/audit cannot leak 
an
      already-running read thread and the open SocketChannel — the catch only
      handles IOException and does not stop the read loop.
    - close(): skip readThread.join() when close() runs on the read thread 
itself
      (a disconnect/data listener calling close()), since joining yourself only
      stalls for the timeout and the loop already exits once open is false.
    - scaling test: take one Thread.getAllStackTraces() snapshot so carriers and
      total are counted from the same instant instead of two separate calls.
    - scaling test: exclude ForkJoinPool.commonPool workers from the carrier
      count so unrelated parallel-stream workers cannot inflate it.
---
 .../java/transport/tcp/TcpTransportInstance.java   | 329 ++++++++-------------
 .../tcp/TcpTransportInstanceScalingTest.java       | 127 ++++++++
 2 files changed, 253 insertions(+), 203 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 96e2df38cc..6b67613c05 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
@@ -33,16 +33,22 @@ import java.io.IOException;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.nio.ByteBuffer;
-import java.nio.channels.SelectionKey;
-import java.nio.channels.Selector;
+import java.nio.channels.AsynchronousCloseException;
 import java.nio.channels.SocketChannel;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.LockSupport;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.function.Consumer;
 
 /**
- * Java 21+ optimized version using virtual threads - TCP transport 
implementation using NIO SocketChannel with async support.
- * Implements AsyncTransportInstance for event-driven I/O without polling.
+ * Java 21+ TCP transport using one virtual thread per connection doing 
blocking
+ * {@link SocketChannel} reads/writes. On Java 21 a virtual thread blocked in 
a blocking-mode
+ * channel read/write parks and releases its carrier (the JDK registers the fd 
with the NIO
+ * poller), so there is no NIO {@link java.nio.channels.Selector}, no 
readiness-event loop, and
+ * no busy-wait. The public surface and the {@link AsyncTransportInstance} 
callback contract are
+ * unchanged: the read loop fills the {@link RingBuffer} (under {@code 
readLock}) and invokes the
+ * registered data listener exactly as the previous selector loop did.
  */
 public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConfiguration> implements 
AsyncTransportInstance<TcpTransportConfiguration> {
 
@@ -52,22 +58,21 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
 
     private final SocketChannel socketChannel;
     private final RingBuffer ringBuffer;
-    private final ByteBuffer readBuffer;  // Pre-allocated direct buffer for 
zero-copy I/O
+    private final ByteBuffer readBuffer;  // Reused per-connection direct 
buffer for channel reads (confined to the read thread)
     private final Lock readLock = new ReentrantLock();
     private final Lock writeLock = new ReentrantLock();
-    private volatile boolean open = true;
+    private final AtomicBoolean open = new AtomicBoolean(true);
 
     // Async support
-    private final Selector selector;
     private volatile Runnable dataListener;
     private volatile Consumer<Throwable> disconnectListener;
-    private final Thread selectorThread;
+    private final Thread readThread;
 
     public TcpTransportInstance(InetSocketAddress remoteAddress, 
TcpTransportConfiguration configuration, AuditLog auditLog) throws 
TransportException {
         super(configuration, auditLog);
         LOGGER.debug("TcpTransportInstance");
         this.ringBuffer = new RingBuffer(configuration.receiveBufferSize);
-        this.readBuffer = ByteBuffer.allocateDirect(DEFAULT_BUFFER_SIZE);  // 
Direct buffer for zero-copy
+        this.readBuffer = ByteBuffer.allocateDirect(DEFAULT_BUFFER_SIZE);  // 
Reused direct buffer for channel reads
 
         try {
             // Open socket channel
@@ -90,24 +95,17 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
             if (configuration.receiveBufferSize > 0) {
                 
socketChannel.socket().setReceiveBufferSize(configuration.receiveBufferSize);
             }
-            if (configuration.readTimeout > 0) {
-                socketChannel.socket().setSoTimeout(configuration.readTimeout);
-            }
+            // 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
+            // silent no-op. Read timeouts are enforced at the protocol/driver 
layer (e.g. the S7 driver
+            // bounds responses via CompletableFuture timeouts), not by the 
transport.
 
             // Connect with timeout
             socketChannel.socket().connect(remoteAddress, 
configuration.connectTimeout);
 
-            // Configure non-blocking mode for NIO selector
-            socketChannel.configureBlocking(false);
-
-            // Create a selector for async I/O
-            this.selector = Selector.open();
-            socketChannel.register(selector, SelectionKey.OP_READ);
-
-            // Start selector thread using virtual thread (Java 21+)
-            this.selectorThread = Thread.ofVirtual()
-                .name("TCP-Selector-" + remoteAddress.getHostName() + ":" + 
remoteAddress.getPort())
-                .start(this::runSelectorLoop);
+            // 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);
 
             LOGGER.info("Connected to {}:{} with async support", 
remoteAddress.getHostName(), remoteAddress.getPort());
 
@@ -115,12 +113,19 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
                 "Connected to: %s:%d with local address: %s:%d",
                 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).
+            this.readThread = Thread.ofVirtual()
+                .name("TCP-Read-" + remoteAddress.getHostName() + ":" + 
remoteAddress.getPort())
+                .start(this::runReadLoop);
         } catch (IOException e) {
             String errorMsg = String.format("Failed to connect to %s:%d - %s",
                 remoteAddress.getHostName(), remoteAddress.getPort(), 
e.getMessage());
             LOGGER.error(errorMsg, e);
+            // errorMsg already embeds e.getMessage(); a single audit event 
avoids a duplicate.
             auditLog.write(AuditLogEventType.ERROR, "Error in constructor: " + 
errorMsg);
-            auditLog.write(AuditLogEventType.ERROR, "Error in constructor: " + 
e.getMessage());
             throw new TransportException(errorMsg, e);
         }
     }
@@ -135,7 +140,7 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
 
     @Override
     public boolean isOpen() {
-        return open && socketChannel.isConnected();
+        return open.get() && socketChannel.isConnected();
     }
 
     @Override
@@ -197,10 +202,6 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
             // Read and consume bytes
             byte[] bytes = ringBuffer.read(numBytes);
 
-            // Re-enable read operations if they were disabled due to full 
buffer
-            // Now that we've freed up space, the selector can read more data
-            reEnableReadIfNeeded();
-
             // Log the bytes to the audit log
             if (getAuditLog().isEnabled()) {
                 getAuditLog().write(AuditLogEventType.INCOMING_BYTES, 
StaticHelper.ENCODE_HEX(bytes));
@@ -227,44 +228,28 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
 
             ByteBuffer writeBuffer = ByteBuffer.wrap(bytes);
 
+            // Blocking write parks the virtual thread until the kernel send 
buffer accepts the
+            // bytes — natural backpressure, no OP_WRITE registration or sleep 
loop needed.
+            // (write() never returns -1; a broken/closed connection surfaces 
as
+            // IOException/AsynchronousCloseException, both handled below.)
             while (writeBuffer.hasRemaining()) {
-                int written = socketChannel.write(writeBuffer);
-                if (written == -1) {
-                    open = false;
-                    throw new TransportException("Connection closed while 
writing");
-                }
-
-                // If no bytes were written and buffer is full, register for 
write operations
-                // and wait until the channel becomes writable (prevents CPU 
spinning)
-                if (written == 0 && writeBuffer.hasRemaining()) {
-                    try {
-                        // Temporarily register interest in write operations
-                        SelectionKey key = socketChannel.keyFor(selector);
-                        if (key != null && key.isValid()) {
-                            key.interestOps(key.interestOps() | 
SelectionKey.OP_WRITE);
-                            selector.wakeup();
-
-                            // Wait a short time for the socket to become 
writable
-                            // This prevents tight CPU spinning when the 
socket buffer is full
-                            Thread.sleep(1);
-
-                            // Remove write interest to avoid unnecessary 
wake-ups
-                            key.interestOps(SelectionKey.OP_READ);
-                        }
-                    } catch (InterruptedException e) {
-                        Thread.currentThread().interrupt();
-                        throw new TransportException("Write interrupted", e);
-                    }
-                }
+                socketChannel.write(writeBuffer);
             }
 
-            LOGGER.trace("Wrote {} bytes to {}", bytes.length, 
socketChannel.getRemoteAddress());
+            LOGGER.trace("Wrote {} bytes", bytes.length);
 
             // Log the bytes to the audit log
             if (getAuditLog().isEnabled()) {
                 getAuditLog().write(AuditLogEventType.OUTGOING_BYTES, "Write: 
" + StaticHelper.ENCODE_HEX(bytes));
             }
-        } catch (TransportException | IOException e) {
+        } catch (AsynchronousCloseException e) {
+            // A concurrent close() closed the channel while we were parked in 
write(): normal shutdown.
+            if (!open.get()) {
+                return;
+            }
+            getAuditLog().write(AuditLogEventType.ERROR, "Error in write: " + 
e.getMessage());
+            throw new TransportException("Failed to write data", e);
+        } catch (IOException e) {
             getAuditLog().write(AuditLogEventType.ERROR, "Error in write: " + 
e.getMessage());
             throw new TransportException("Failed to write data", e);
         } finally {
@@ -274,44 +259,34 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
 
     @Override
     public void close() throws TransportException {
-        if (!open) {
+        // CAS so concurrent/repeated close() calls run the shutdown exactly 
once.
+        if (!open.compareAndSet(true, false)) {
             return;
         }
 
-        writeLock.lock();
+        // Intentionally takes NO locks: closing the channel is what unblocks 
a parked read()/write().
+        // Acquiring writeLock first would deadlock against a writer parked in 
a blocking write().
         try {
-            readLock.lock();
-            try {
-                open = false;
-
-                // Wake up selector
-                selector.wakeup();
-
-                // Close socket channel
-                socketChannel.close();
-
-                // Close selector
-                selector.close();
-
-                // Wait for the selector thread to finish
-                if (selectorThread != null) {
-                    try {
-                        selectorThread.join(1000);
-                    } catch (InterruptedException e) {
-                        Thread.currentThread().interrupt();
-                    }
+            socketChannel.close();
+            // Only log/audit a successful close here; on failure the catch 
reports ERROR
+            // and rethrows, so emitting CLOSE in finally would falsely signal 
a clean close.
+            LOGGER.debug("TCP connection closed");
+            getAuditLog().write(AuditLogEventType.CLOSE, "Closed");
+        } catch (IOException e) {
+            getAuditLog().write(AuditLogEventType.ERROR, "Error in close: " + 
e.getMessage());
+            throw new TransportException("Failed to close connection", e);
+        } finally {
+            // Always join the read loop, regardless of whether the channel 
closed cleanly.
+            // Skip the self-join when close() runs on the read thread itself 
(e.g. a
+            // disconnect/data listener calls close()) — joining yourself only 
stalls for the
+            // timeout and the loop already exits once open is false.
+            if (readThread != null && Thread.currentThread() != readThread) {
+                try {
+                    readThread.join(1000);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
                 }
-
-                LOGGER.debug("TCP connection closed");
-                getAuditLog().write(AuditLogEventType.CLOSE, "Closed");
-            } catch (IOException e) {
-                getAuditLog().write(AuditLogEventType.ERROR, "Error in close: 
" + e.getMessage());
-                throw new TransportException("Failed to close connection", e);
-            } finally {
-                readLock.unlock();
             }
-        } finally {
-            writeLock.unlock();
         }
     }
 
@@ -324,30 +299,6 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
         }
     }
 
-    /**
-     * Re-enables read operations on the selector if they were previously 
disabled
-     * due to a full ring buffer. This should be called after reading from the 
ring
-     * buffer to allow new data to be received.
-     */
-    private void reEnableReadIfNeeded() {
-        try {
-            SelectionKey key = socketChannel.keyFor(selector);
-            if (key != null && key.isValid()) {
-                // Check if read interest is currently disabled
-                if ((key.interestOps() & SelectionKey.OP_READ) == 0) {
-                    // Re-enable read operations
-                    key.interestOps(key.interestOps() | SelectionKey.OP_READ);
-                    // Wake up the selector to process the new interest ops
-                    selector.wakeup();
-                    LOGGER.debug("Re-enabled read operations after buffer 
space freed");
-                }
-            }
-        } catch (Exception e) {
-            // Log but don't throw - this is a best-effort operation
-            LOGGER.warn("Failed to re-enable read operations", e);
-        }
-    }
-
     // ========== AsyncTransportInstance Implementation ==========
 
     @Override
@@ -391,104 +342,76 @@ public class TcpTransportInstance extends 
BaseTransportInstance<TcpTransportConf
     }
 
     /**
-     * Selector loop that runs in a virtual thread and notifies listeners when 
data arrives.
-     * This is the core of the async implementation - no polling needed in the 
driver!
+     * Runs the listener on the read thread, guarding against a misbehaving 
listener so a thrown
+     * exception cannot silently kill the read loop (mirrors {@link 
#notifyDisconnect}'s posture).
      */
-    private void runSelectorLoop() {
-        LOGGER.debug("Selector loop started");
-
-        while (open && !Thread.currentThread().isInterrupted()) {
-            try {
-                // Block until events are available (no CPU waste!)
-                int readyChannels = selector.select();
+    private void safeRun(Runnable listener) {
+        if (listener == null) {
+            return;
+        }
+        try {
+            listener.run();
+        } catch (Throwable t) {
+            LOGGER.error("Data listener failed", t);
+        }
+    }
 
-                if (readyChannels == 0) {
+    /**
+     * Per-connection read loop on a virtual thread: blocking read into the 
ring buffer, then
+     * notify the data listener. No selector, no polling.
+     */
+    private void runReadLoop() {
+        LOGGER.debug("Read loop started");
+        try {
+            while (open.get()) {
+                int free = ringBuffer.remainingForWriting();
+                if (free == 0) {
+                    // Backpressure: the ring buffer is full and the consumer 
has not drained yet.
+                    // Park briefly and re-check; never disconnect (only the 
codec knows frame
+                    // boundaries, and cross-thread consumers like COTP can 
legitimately lag).
+                    LockSupport.parkNanos(200_000L);
                     continue;
                 }
 
-                var selectedKeys = selector.selectedKeys();
-                var iterator = selectedKeys.iterator();
-
-                while (iterator.hasNext()) {
-                    SelectionKey key = iterator.next();
-                    iterator.remove();
-
-                    if (!key.isValid()) {
-                        continue;
-                    }
-
-                    if (key.isReadable()) {
-                        // Data available - read it into the ring buffer
-                        boolean notifyListener = false;
-                        boolean connectionClosed = false;
-                        readLock.lock();
-                        try {
-                            // Check available space in ring buffer before 
reading
-                            int availableSpace = 
ringBuffer.remainingForWriting();
-                            if (availableSpace == 0) {
-                                LOGGER.warn("Ring buffer is full, temporarily 
disabling read operations");
-                                key.interestOps(key.interestOps() & 
~SelectionKey.OP_READ);
-                                continue;
-                            }
-
-                            // Limit read buffer to available space in 
ring-buffer to prevent data loss
-                            readBuffer.clear();
-                            readBuffer.limit(Math.min(readBuffer.capacity(), 
availableSpace));
-
-                            int bytesRead = socketChannel.read(readBuffer);
-
-                            if (bytesRead > 0) {
-                                readBuffer.flip();
-
-                                // Write directly from ByteBuffer to ring 
buffer (avoiding intermediate byte array allocation)
-                                int bytesWritten = 
ringBuffer.write(readBuffer);
-                                if (bytesWritten < bytesRead) {
-                                    String message = String.format("Ring 
buffer write incomplete. Expected to write " +
-                                            "%d bytes but only wrote %d bytes. 
This should not happen.",
-                                        bytesRead, bytesWritten);
-                                    LOGGER.error(message);
-                                    
getAuditLog().write(AuditLogEventType.ERROR, message);
-                                }
-
-                                notifyListener = true;
-                            } else if (bytesRead == -1) {
-                                // Connection closed gracefully by remote
-                                LOGGER.info("Connection closed by remote");
-                                open = false;
-                                connectionClosed = true;
-                            }
-                        } finally {
-                            readLock.unlock();
-                        }
-
-                        // Notify listener OUTSIDE the readLock — the data is 
already
-                        // in the ring buffer, so holding the lock during 
response
-                        // processing would unnecessarily block the I/O path 
and cause
-                        // reentrant lock overhead in processIncomingData().
-                        if (notifyListener) {
-                            Runnable listener = dataListener;
-                            if (listener != null) {
-                                listener.run();
-                            }
-                        } else if (connectionClosed) {
-                            notifyDisconnect(null);
-                            break;
-                        }
-                    }
+                // Bound the read to free ring-buffer space so the buffer can 
never overflow.
+                readBuffer.clear();
+                readBuffer.limit(Math.min(readBuffer.capacity(), free));
+
+                int bytesRead = socketChannel.read(readBuffer);  // parks 
vthread; releases carrier (JDK21)
+                if (bytesRead == -1) {
+                    // Connection closed gracefully by remote
+                    LOGGER.info("Connection closed by remote");
+                    open.set(false);
+                    notifyDisconnect(null);
+                    break;
+                }
+                if (bytesRead == 0) {
+                    // A blocking read effectively never returns 0; harmless 
guard.
+                    continue;
                 }
 
-            } catch (IOException e) {
-                getAuditLog().write(AuditLogEventType.ERROR, "Error in 
runSelectorLoop: " + e.getMessage());
-                if (open) {
-                    LOGGER.error("Error in selector loop", e);
-                    open = false;
-                    notifyDisconnect(e);
+                readBuffer.flip();
+                readLock.lock();
+                try {
+                    ringBuffer.write(readBuffer);
+                } finally {
+                    readLock.unlock();
                 }
-                break;
+
+                // Notify OUTSIDE readLock — the data is already in the ring 
buffer.
+                safeRun(dataListener);
+            }
+        } catch (IOException e) {
+            // If open==false, an intentional close() closed the channel 
(AsynchronousCloseException) —
+            // a normal shutdown, not a disconnect. Only a failure while still 
open is a real disconnect.
+            if (open.get()) {
+                getAuditLog().write(AuditLogEventType.ERROR, "Error in 
runReadLoop: " + e.getMessage());
+                LOGGER.error("Error in read loop", e);
+                open.set(false);
+                notifyDisconnect(e);
             }
         }
-
-        LOGGER.debug("Selector loop stopped");
+        LOGGER.debug("Read loop stopped");
     }
 
 }
diff --git 
a/plc4j/transports/tcp/src/test/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstanceScalingTest.java
 
b/plc4j/transports/tcp/src/test/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstanceScalingTest.java
new file mode 100644
index 0000000000..4c994abb5d
--- /dev/null
+++ 
b/plc4j/transports/tcp/src/test/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstanceScalingTest.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.transport.tcp;
+
+import org.apache.plc4x.java.transport.tcp.config.TcpTransportConfiguration;
+import org.apache.plc4x.java.utils.auditlog.api.AuditLog;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import java.net.InetSocketAddress;
+import java.nio.channels.ServerSocketChannel;
+import java.nio.channels.SocketChannel;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Concurrency-model probe: opens many idle connections and reports how many 
carrier (platform)
+ * threads the virtual-thread scheduler is using. With the old selector model, 
each connection's
+ * virtual thread blocks in {@code Selector.select()}, which does not unmount 
the virtual thread,
+ * so the scheduler ties up a carrier per connection (toward maxPoolSize=256) 
— measured at 201
+ * carriers for 200 connections on both JDK 21 and JDK 25, i.e. NOT the 
synchronized-monitor
+ * pinning that JEP 491 (JDK 24) removed. With the blocking-read model, the 
read virtual threads
+ * unmount via the NIO poller, so the carrier pool stays small and flat 
regardless of connection
+ * count (measured 2-3 for 200 connections).
+ *
+ * The test asserts only that all connections stay open (lenient — it is an 
evidence probe, not a
+ * brittle threshold). The before/after signal is the {@code 
CARRIER_COUNT=...} line printed to
+ * stdout and, when run with {@code -Djdk.tracePinnedThreads=full}, the 
presence/absence of
+ * pinned-thread stack traces.
+ */
+class TcpTransportInstanceScalingTest {
+
+    private static final int CONNECTIONS = 200;
+
+    @Test
+    @Disabled("Concurrency-model evidence probe — opens 200 sockets and 
sleeps; run manually, "
+        + "ideally with -Djdk.tracePinnedThreads=full. Not a CI regression 
test.")
+    void manyIdleConnections_carrierThreadUsage() throws Exception {
+        ServerSocketChannel server = ServerSocketChannel.open();
+        server.bind(new InetSocketAddress("localhost", 0), 256);
+        int port = ((InetSocketAddress) server.getLocalAddress()).getPort();
+
+        List<SocketChannel> serverSide = new ArrayList<>();
+        volatileFlag.running = true;
+        Thread acceptThread = new Thread(() -> {
+            try {
+                while (volatileFlag.running) {
+                    SocketChannel s = server.accept();
+                    synchronized (serverSide) {
+                        serverSide.add(s);
+                    }
+                }
+            } catch (Exception ignored) {
+                // server closed
+            }
+        }, "scaling-accept");
+        acceptThread.setDaemon(true);
+        acceptThread.start();
+
+        List<TcpTransportInstance> clients = new ArrayList<>();
+        try {
+            for (int i = 0; i < CONNECTIONS; i++) {
+                TcpTransportConfiguration config = new 
TcpTransportConfiguration();
+                config.receiveBufferSize = 81920;
+                config.connectTimeout = 5000;
+                clients.add(new TcpTransportInstance(
+                    new InetSocketAddress("localhost", port), config, 
AuditLog.builder().build()));
+            }
+
+            // Let every connection's read virtual thread settle into its 
blocking wait.
+            Thread.sleep(3000);
+
+            // One snapshot so carriers and total are derived from the same 
instant.
+            Set<Thread> threads = Thread.getAllStackTraces().keySet();
+            // Count only virtual-thread scheduler carriers, not 
ForkJoinPool.commonPool
+            // workers (parallel streams etc.), which would inflate the 
carrier count.
+            long carriers = threads.stream()
+                .filter(t -> !t.isVirtual())
+                .filter(t -> t.getName().contains("ForkJoinPool"))
+                .filter(t -> !t.getName().contains("commonPool"))
+                .count();
+            long total = threads.size();
+            System.out.println("CARRIER_COUNT=" + carriers
+                + " TOTAL_THREADS=" + total
+                + " CONNECTIONS=" + CONNECTIONS
+                + " CPUS=" + Runtime.getRuntime().availableProcessors());
+
+            long openCount = 
clients.stream().filter(TcpTransportInstance::isOpen).count();
+            assertTrue(openCount >= CONNECTIONS - 5,
+                "expected ~all connections open, got " + openCount + "/" + 
CONNECTIONS);
+        } finally {
+            for (TcpTransportInstance c : clients) {
+                try { c.close(); } catch (Exception ignored) { }
+            }
+            volatileFlag.running = false;
+            synchronized (serverSide) {
+                for (SocketChannel s : serverSide) {
+                    try { s.close(); } catch (Exception ignored) { }
+                }
+            }
+            server.close();
+        }
+    }
+
+    // tiny holder carrying a volatile stop flag the daemon accept loop polls 
to shut down
+    private static final class Flag { volatile boolean running; }
+    private final Flag volatileFlag = new Flag();
+}

Reply via email to