This is an automated email from the ASF dual-hosted git repository.
jbonofre pushed a commit to branch karaf-4.4.x
in repository https://gitbox.apache.org/repos/asf/karaf.git
The following commit(s) were added to refs/heads/karaf-4.4.x by this push:
new c9ec515214 [KARAF-2513] Fix log:tail thread leak on SSH disconnect
(backport to 4.4.x) (#2519)
c9ec515214 is described below
commit c9ec5152140d2e92d41baf7a6437bf057ab05acf
Author: JB Onofré <[email protected]>
AuthorDate: Thu Apr 16 09:31:52 2026 +0200
[KARAF-2513] Fix log:tail thread leak on SSH disconnect (backport to 4.4.x)
(#2519)
---
.../org/apache/karaf/shell/ssh/ShellCommand.java | 13 ++-
.../apache/karaf/shell/ssh/ShellCommandTest.java | 109 +++++++++++++++++++++
2 files changed, 120 insertions(+), 2 deletions(-)
diff --git
a/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/ShellCommand.java
b/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/ShellCommand.java
index a591bbcb80..c0d48352b5 100644
--- a/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/ShellCommand.java
+++ b/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/ShellCommand.java
@@ -65,6 +65,8 @@ public class ShellCommand implements Command {
private ServerSession serverSession;
private SessionFactory sessionFactory;
private Environment env;
+ private Session session;
+ private Thread commandThread;
public ShellCommand(SessionFactory sessionFactory, String command) {
this.sessionFactory = sessionFactory;
@@ -95,9 +97,11 @@ public class ShellCommand implements Command {
}
public void run() {
+ commandThread = Thread.currentThread();
int exitStatus = 0;
try {
- final Session session = sessionFactory.create(in, new
PrintStream(out, true), new PrintStream(err, true));
+ this.session = sessionFactory.create(in, new PrintStream(out,
true), new PrintStream(err, true));
+ final Session session = this.session;
for (Map.Entry<String,String> e : env.getEnv().entrySet()) {
session.put(e.getKey(), e.getValue());
}
@@ -154,7 +158,12 @@ public class ShellCommand implements Command {
@Override
public void destroy(ChannelSession channelSession) throws Exception {
-
+ if (session != null) {
+ session.close();
+ }
+ if (commandThread != null && commandThread != Thread.currentThread()) {
+ commandThread.interrupt();
+ }
}
private void executeScript(String names, Session session) {
diff --git
a/shell/ssh/src/test/java/org/apache/karaf/shell/ssh/ShellCommandTest.java
b/shell/ssh/src/test/java/org/apache/karaf/shell/ssh/ShellCommandTest.java
new file mode 100644
index 0000000000..a6fe221a2b
--- /dev/null
+++ b/shell/ssh/src/test/java/org/apache/karaf/shell/ssh/ShellCommandTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.karaf.shell.ssh;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.PrintStream;
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.karaf.shell.api.console.Session;
+import org.apache.karaf.shell.api.console.SessionFactory;
+import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.ExitCallback;
+import org.apache.sshd.server.channel.ChannelSession;
+import org.apache.sshd.server.session.ServerSession;
+import org.easymock.EasyMock;
+import org.easymock.IAnswer;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ShellCommandTest {
+
+ @Test
+ public void testDestroyInterruptsBlockingCommand() throws Exception {
+ // Simulate a blocking command (like log:tail) that waits until
interrupted
+ CountDownLatch commandStarted = new CountDownLatch(1);
+ CountDownLatch commandFinished = new CountDownLatch(1);
+
+ Session session = EasyMock.createMock(Session.class);
+ session.put(EasyMock.anyString(), EasyMock.anyObject());
+ EasyMock.expectLastCall().anyTimes();
+
+ // The execute() call blocks until the thread is interrupted
+
EasyMock.expect(session.execute(EasyMock.anyString())).andAnswer((IAnswer<Object>)
() -> {
+ commandStarted.countDown();
+ try {
+ Thread.sleep(60000);
+ } catch (InterruptedException e) {
+ // Expected when destroy() is called
+ }
+ commandFinished.countDown();
+ return null;
+ });
+ session.close();
+ EasyMock.expectLastCall().once();
+
+ SessionFactory sessionFactory =
EasyMock.createMock(SessionFactory.class);
+ EasyMock.expect(sessionFactory.create(
+ EasyMock.anyObject(InputStream.class),
+ EasyMock.anyObject(PrintStream.class),
+ EasyMock.anyObject(PrintStream.class)))
+ .andReturn(session);
+
+ Environment env = EasyMock.createMock(Environment.class);
+ EasyMock.expect(env.getEnv()).andReturn(Collections.emptyMap());
+
+ ExitCallback exitCallback = EasyMock.createMock(ExitCallback.class);
+ exitCallback.onExit(EasyMock.anyInt());
+ EasyMock.expectLastCall().once();
+
+ ChannelSession channelSession =
EasyMock.createMock(ChannelSession.class);
+ ServerSession serverSession = EasyMock.createMock(ServerSession.class);
+
EasyMock.expect(channelSession.getServerSession()).andReturn(serverSession);
+
EasyMock.expect(serverSession.getAttribute(KarafJaasAuthenticator.SUBJECT_ATTRIBUTE_KEY)).andReturn(null);
+ EasyMock.expect(serverSession.close(false)).andReturn(null);
+
+ EasyMock.replay(session, sessionFactory, env, exitCallback,
channelSession, serverSession);
+
+ ShellCommand shellCommand = new ShellCommand(sessionFactory,
"log:tail");
+ shellCommand.setInputStream(new ByteArrayInputStream(new byte[0]));
+ shellCommand.setOutputStream(new ByteArrayOutputStream());
+ shellCommand.setErrorStream(new ByteArrayOutputStream());
+ shellCommand.setExitCallback(exitCallback);
+
+ // Start the command (runs in a separate thread)
+ shellCommand.start(channelSession, env);
+
+ // Wait for the blocking command to start
+ Assert.assertTrue("Command should have started",
commandStarted.await(5, TimeUnit.SECONDS));
+
+ // Simulate SSH disconnect by calling destroy
+ shellCommand.destroy(channelSession);
+
+ // The command thread should finish within a reasonable time
+ Assert.assertTrue("Command thread should have been interrupted and
finished",
+ commandFinished.await(5, TimeUnit.SECONDS));
+
+ EasyMock.verify(session);
+ }
+}