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());
+ }
+}