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 1affedb0546..39d0adcd19c 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,10 @@ public void start(ChannelSession channelSession, Environment environment) throws } public void run() { + commandThread = Thread.currentThread(); int exitStatus = 0; try { - final Session session = sessionFactory.create(in, new PrintStream(out), new PrintStream(err)); + session = sessionFactory.create(in, new PrintStream(out), new PrintStream(err)); for (Map.Entry e : env.getEnv().entrySet()) { session.put(e.getKey(), e.getValue()); } @@ -154,7 +157,12 @@ public void run() { @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 00000000000..a6fe221a2ba --- /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) () -> { + 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); + } +}