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

gnodet pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new d9ef4a9679ae CAMEL-24273: Fix SshClient thread leak in 
MinaSftpOperations.disconnect()
d9ef4a9679ae is described below

commit d9ef4a9679ae41be4fdc406fa7f8e0efb561cd38
Author: Guillaume Nodet <[email protected]>
AuthorDate: Tue Jul 28 18:42:33 2026 +0200

    CAMEL-24273: Fix SshClient thread leak in MinaSftpOperations.disconnect()
    
    Stop the SshClient in disconnect() to release NIO2 and timer daemon threads
    that previously leaked on every SFTP connect/disconnect cycle.
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 components/camel-mina-sftp/pom.xml                 |   6 +
 .../file/remote/mina/MinaSftpOperations.java       |  13 ++
 .../mina/MinaSftpOperationsDisconnectTest.java     | 136 +++++++++++++++++++++
 .../mina/sftp/SftpDisconnectThreadLeakIT.java      |  90 ++++++++++++++
 4 files changed, 245 insertions(+)

diff --git a/components/camel-mina-sftp/pom.xml 
b/components/camel-mina-sftp/pom.xml
index 4c14f2f46a27..4823ef249454 100644
--- a/components/camel-mina-sftp/pom.xml
+++ b/components/camel-mina-sftp/pom.xml
@@ -95,6 +95,12 @@
             <version>${mockito-version}</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.awaitility</groupId>
+            <artifactId>awaitility</artifactId>
+            <version>${awaitility-version}</version>
+            <scope>test</scope>
+        </dependency>
 
         <!-- test infra -->
         <dependency>
diff --git 
a/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
 
b/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
index 8b6613c3e9e7..412b114be7ad 100644
--- 
a/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
+++ 
b/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
@@ -830,7 +830,20 @@ public class MinaSftpOperations implements 
RemoteFileOperations<SftpRemoteFile>
                     LOG.debug("Error closing session: {}", e.getMessage(), e);
                 }
             }
+            // Stop the SSH client to release NIO2 and timer daemon threads 
(CAMEL-24273).
+            // Without this, each connect/disconnect cycle leaks threads that 
accumulate
+            // until the system's thread limit is exhausted.
+            if (sshClient != null) {
+                try {
+                    sshClient.stop();
+                } catch (Exception e) {
+                    LOG.debug("Error stopping SSH client: {}", e.getMessage(), 
e);
+                }
+            }
         } finally {
+            sftpClient = null;
+            session = null;
+            sshClient = null;
             lock.unlock();
         }
     }
diff --git 
a/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/MinaSftpOperationsDisconnectTest.java
 
b/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/MinaSftpOperationsDisconnectTest.java
new file mode 100644
index 000000000000..f89078116f95
--- /dev/null
+++ 
b/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/MinaSftpOperationsDisconnectTest.java
@@ -0,0 +1,136 @@
+/*
+ * 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.camel.component.file.remote.mina;
+
+import java.lang.reflect.Field;
+
+import org.apache.sshd.client.SshClient;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit test that verifies disconnect() stops the SshClient and releases its 
daemon threads (CAMEL-24273).
+ * <p/>
+ * Before the fix, disconnect() only closed the SFTP client and session but 
left the SshClient running, which leaked
+ * NIO2 and timer daemon threads on every connect/disconnect cycle.
+ */
+class MinaSftpOperationsDisconnectTest {
+
+    /**
+     * Verifies that disconnect() stops the SshClient and nulls the field so 
its internal thread pool is shut down.
+     * <p/>
+     * This is the core fix for CAMEL-24273: without sshClient.stop(), the 
NIO2/timer daemon threads from
+     * SshClient.start() are never released, accumulating with each 
connect/disconnect cycle.
+     */
+    @Test
+    void testDisconnectStopsSshClient() throws Exception {
+        MinaSftpOperations operations = new MinaSftpOperations();
+
+        // Create and start an SshClient
+        SshClient sshClient = SshClient.setUpDefaultClient();
+        sshClient.start();
+
+        // Verify the client is running
+        assertFalse(sshClient.isClosed(), "SshClient should be open after 
start()");
+        assertFalse(sshClient.isClosing(), "SshClient should not be closing 
after start()");
+
+        // Inject the sshClient into operations
+        setField(operations, "sshClient", sshClient);
+
+        // Call disconnect — this should stop the sshClient
+        operations.disconnect();
+
+        // Verify the SshClient is stopped
+        assertTrue(sshClient.isClosed() || sshClient.isClosing(),
+                "SshClient should be stopped after disconnect()");
+
+        // Verify the field is nulled so a fresh client is created on next 
connect
+        assertNull(getField(operations, "sshClient"),
+                "sshClient field should be null after disconnect()");
+    }
+
+    /**
+     * Verifies that disconnect() nulls all connection-related fields, 
ensuring a clean state for reconnection.
+     */
+    @Test
+    void testDisconnectNullsAllConnectionFields() throws Exception {
+        MinaSftpOperations operations = new MinaSftpOperations();
+
+        // Create and start an SshClient
+        SshClient sshClient = SshClient.setUpDefaultClient();
+        sshClient.start();
+
+        // Inject fields
+        setField(operations, "sshClient", sshClient);
+
+        // Call disconnect
+        operations.disconnect();
+
+        // Verify all connection fields are nulled
+        assertNull(getField(operations, "sshClient"), "sshClient should be 
null after disconnect()");
+        assertNull(getField(operations, "session"), "session should be null 
after disconnect()");
+        assertNull(getField(operations, "sftpClient"), "sftpClient should be 
null after disconnect()");
+    }
+
+    /**
+     * Verifies that calling disconnect() multiple times does not throw 
exceptions (idempotent behavior).
+     */
+    @Test
+    void testDisconnectIsIdempotent() throws Exception {
+        MinaSftpOperations operations = new MinaSftpOperations();
+
+        // Create and start an SshClient
+        SshClient sshClient = SshClient.setUpDefaultClient();
+        sshClient.start();
+        setField(operations, "sshClient", sshClient);
+
+        // First disconnect
+        operations.disconnect();
+
+        // Second disconnect should not throw
+        operations.disconnect();
+
+        // Verify still clean
+        assertNull(getField(operations, "sshClient"), "sshClient should be 
null after double disconnect()");
+    }
+
+    /**
+     * Verifies that disconnect() on a never-connected MinaSftpOperations does 
not throw.
+     */
+    @Test
+    void testDisconnectWhenNeverConnected() {
+        MinaSftpOperations operations = new MinaSftpOperations();
+
+        // Should not throw when no connection was ever established
+        operations.disconnect();
+    }
+
+    private static void setField(Object target, String fieldName, Object 
value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private static Object getField(Object target, String fieldName) throws 
Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        return field.get(target);
+    }
+}
diff --git 
a/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/sftp/SftpDisconnectThreadLeakIT.java
 
b/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/sftp/SftpDisconnectThreadLeakIT.java
new file mode 100644
index 000000000000..4bfe4e4dbfd5
--- /dev/null
+++ 
b/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/sftp/SftpDisconnectThreadLeakIT.java
@@ -0,0 +1,90 @@
+/*
+ * 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.camel.component.file.remote.mina.sftp;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.apache.camel.Exchange;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIf;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test that verifies SshClient daemon threads are cleaned up after disconnect 
(CAMEL-24273).
+ * <p/>
+ * Before the fix, each SFTP connect/disconnect cycle left behind NIO2 and 
timer daemon threads from the SshClient,
+ * causing the thread count to grow continuously until the system's thread 
limit was exhausted.
+ */
+@EnabledIf(value = 
"org.apache.camel.test.infra.ftp.services.embedded.SftpUtil#hasRequiredAlgorithms('src/test/resources/sftp/hostkey.pem')")
+class SftpDisconnectThreadLeakIT extends SftpServerTestSupport {
+
+    @Test
+    void testDisconnectCleansUpSshClientThreads() throws Exception {
+        // Capture the set of SshClient thread names before SFTP operations
+        Set<String> threadsBefore = getSshClientThreadNames();
+
+        // Perform multiple SFTP transfers — each creates a new SshClient with 
NIO2/timer threads
+        int transfers = 5;
+        for (int i = 0; i < transfers; i++) {
+            template.sendBodyAndHeader(
+                    
"mina-sftp://localhost:{{ftp.server.port}}/{{ftp.root.dir}}";
+                                       + 
"?username=admin&password=admin&disconnect=true&knownHostsFile="
+                                       + service.getKnownHostsFile(),
+                    "Hello World " + i, Exchange.FILE_NAME, "hello" + i + 
".txt");
+
+            Path file = ftpFile("hello" + i + ".txt");
+            assertTrue(Files.exists(file), "File should exist: " + file);
+        }
+
+        // Verify files were written correctly
+        for (int i = 0; i < transfers; i++) {
+            Path file = ftpFile("hello" + i + ".txt");
+            assertTrue(Files.exists(file), "File should exist: " + file);
+            assertEquals("Hello World " + i, Files.readString(file));
+        }
+
+        // After all transfers with disconnect=true, no SshClient threads 
should remain.
+        await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> {
+            Set<String> threadsAfter = getSshClientThreadNames();
+            Set<String> leakedThreads = threadsAfter.stream()
+                    .filter(name -> !threadsBefore.contains(name))
+                    .collect(Collectors.toSet());
+
+            assertTrue(leakedThreads.isEmpty(),
+                    "SshClient daemon threads should be cleaned up after 
disconnect, but found leaked threads: "
+                                                + leakedThreads);
+        });
+    }
+
+    /**
+     * Returns the names of all alive threads whose name contains "SshClient".
+     */
+    private Set<String> getSshClientThreadNames() {
+        return Thread.getAllStackTraces().keySet().stream()
+                .filter(Thread::isAlive)
+                .map(Thread::getName)
+                .filter(name -> name.contains("SshClient"))
+                .collect(Collectors.toSet());
+    }
+}

Reply via email to