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

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


The following commit(s) were added to refs/heads/main by this push:
     new cc2fb8c44e Add timeout option for ssh commands, fixes #9072 (#9073)
cc2fb8c44e is described below

commit cc2fb8c44edfe8c2e7825ac641318421cd9c4f4b
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Oct 22 14:54:37 2026 +0200

    Add timeout option for ssh commands, fixes #9072 (#9073)
---
 .../ROOT/pages/pipeline/transforms/runssh.adoc     |   6 +-
 .../hop/pipeline/transforms/ssh/SessionResult.java |  44 +++++++-
 .../apache/hop/pipeline/transforms/ssh/Ssh.java    |  11 +-
 .../hop/pipeline/transforms/ssh/SshData.java       |   3 +
 .../hop/pipeline/transforms/ssh/SshDialog.java     | 122 +++++++++++++--------
 .../hop/pipeline/transforms/ssh/SshMeta.java       |   5 +
 .../ssh/messages/messages_en_US.properties         |   8 +-
 .../pipeline/transforms/ssh/SessionResultTest.java |  64 +++++++++++
 .../hop/pipeline/transforms/ssh/SshMetaTest.java   |   2 +
 9 files changed, 209 insertions(+), 56 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/runssh.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/runssh.adoc
index c504b73a68..03948871fb 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/runssh.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/runssh.adoc
@@ -38,8 +38,12 @@ You can pass text to stdout or stderr in the commands. This 
information can then
 |Server name / IP address|You can specify the server or IP address of the 
machine on which you want to execute a command.
 |Server port|The TCP/IP port on which the ssh protocol lives on the server.
 The default is 22.
-|Timeout|The timeout in seconds.
+|Connection timeout|The connection timeout in seconds.
 If set to a number larger than zero you can specify a non-default connection 
timeout.
+|Command timeout|The maximum time in seconds to wait for a command to complete.
+The default of 0 waits until the command ends, however long it runs.
+If the command does not complete in time, the transform fails, or sends the 
row to the error hop when error handling is enabled.
+Stopping the pipeline also stops waiting for a running command.
 |Username|The username to log in with.
 |Password|The password to use.
 |Use key|Enable this option if you want to log in using a private key
diff --git 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SessionResult.java
 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SessionResult.java
index 82c3d71085..666bcfbdf5 100644
--- 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SessionResult.java
+++ 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SessionResult.java
@@ -23,14 +23,17 @@ import com.jcraft.jsch.Session;
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
+import java.util.function.BooleanSupplier;
+import java.util.function.LongSupplier;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.i18n.BaseMessages;
 
 public class SessionResult {
+  private static final Class<?> PKG = SshMeta.class;
 
   private static final int CHANNEL_POLL_MS = 50;
-  private static final int CHANNEL_MAX_WAIT_MS = 120_000;
 
   /**
    * After JSch reports the channel closed, {@link InputStream#available()} 
can still briefly return
@@ -44,13 +47,37 @@ public class SessionResult {
   @Getter @Setter private boolean stdErrorType;
 
   public static SessionResult executeCommand(Session session, String command) 
throws HopException {
+    return executeCommand(session, command, 0L, () -> false);
+  }
+
+  /**
+   * Runs a command and waits for it to finish.
+   *
+   * @param commandTimeoutMs maximum time to wait for the command to complete, 
0 or less waits until
+   *     the command ends
+   * @param stopped polled while waiting; when it returns true the channel is 
closed and a {@link
+   *     HopException} is thrown
+   */
+  public static SessionResult executeCommand(
+      Session session, String command, long commandTimeoutMs, BooleanSupplier 
stopped)
+      throws HopException {
+    return executeCommand(session, command, commandTimeoutMs, stopped, 
System::currentTimeMillis);
+  }
+
+  static SessionResult executeCommand(
+      Session session,
+      String command,
+      long commandTimeoutMs,
+      BooleanSupplier stopped,
+      LongSupplier clock)
+      throws HopException {
     ChannelExec channel = null;
     try {
       channel = (ChannelExec) session.openChannel("exec");
       channel.setCommand(command);
       channel.connect();
       SessionResult result = new SessionResult();
-      result.readFromChannel(channel);
+      result.readFromChannel(channel, commandTimeoutMs, stopped, clock);
       return result;
     } catch (JSchException e) {
       throw new HopException(e);
@@ -72,7 +99,9 @@ public class SessionResult {
     return (getStdOut() == null ? "" : getStdOut()) + (getStdErr() == null ? 
"" : getStdErr());
   }
 
-  private void readFromChannel(ChannelExec channel) throws HopException {
+  private void readFromChannel(
+      ChannelExec channel, long commandTimeoutMs, BooleanSupplier stopped, 
LongSupplier clock)
+      throws HopException {
     try {
       InputStream isOut = channel.getInputStream();
       InputStream isErr = channel.getErrStream();
@@ -80,7 +109,7 @@ public class SessionResult {
       StringBuilder stdout = new StringBuilder();
       StringBuilder stderr = new StringBuilder();
 
-      long deadline = System.currentTimeMillis() + CHANNEL_MAX_WAIT_MS;
+      long deadline = commandTimeoutMs > 0 ? clock.getAsLong() + 
commandTimeoutMs : Long.MAX_VALUE;
       int emptyPollsAfterClose = 0;
       while (true) {
         boolean readOut = appendAvailable(isOut, buffer, stdout);
@@ -101,8 +130,11 @@ public class SessionResult {
               break;
             }
           }
-        } else if (System.currentTimeMillis() > deadline) {
-          throw new HopException("Timed out waiting for SSH command to 
complete");
+        } else if (stopped.getAsBoolean()) {
+          throw new HopException(BaseMessages.getString(PKG, 
"SSH.Error.CommandStopped"));
+        } else if (clock.getAsLong() > deadline) {
+          throw new HopException(
+              BaseMessages.getString(PKG, "SSH.Error.CommandTimedOut", 
commandTimeoutMs / 1000));
         } else {
           emptyPollsAfterClose = 0;
         }
diff --git 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/Ssh.java
 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/Ssh.java
index c5218843f1..82d8d9d30a 100644
--- 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/Ssh.java
+++ 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/Ssh.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.pipeline.transforms.ssh;
 
+import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.RowMeta;
@@ -123,7 +124,9 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
       if (isDetailed()) {
         logDetailed(BaseMessages.getString(PKG, "SSH.Log.RunningCommand", 
data.commands));
       }
-      SessionResult sessionresult = SessionResult.executeCommand(data.session, 
data.commands);
+      SessionResult sessionresult =
+          SessionResult.executeCommand(
+              data.session, data.commands, data.commandTimeOutMs, 
this::isStopped);
       if (isDebug()) {
         logDebug(
             BaseMessages.getString(
@@ -154,6 +157,11 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
         logDetailed(BaseMessages.getString(PKG, "SSH.LineNumber", "" + 
getLinesRead()));
       }
     } catch (Exception e) {
+      if (isStopped()) {
+        // The pipeline was stopped while the command was running
+        setOutputDone();
+        return false;
+      }
       String errorMessage = null;
 
       if (getTransformMeta().isDoingErrorHandling()) {
@@ -200,6 +208,7 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
         return false;
       }
       data.stdTypeField = resolve(meta.getStdErrFieldName());
+      data.commandTimeOutMs = Const.toLong(resolve(meta.getCommandTimeOut()), 
0L) * 1000L;
 
       try {
         // Open connection
diff --git 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshData.java
 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshData.java
index 61433b0cfc..b8745b90bd 100644
--- 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshData.java
+++ 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshData.java
@@ -51,6 +51,9 @@ public class SshData extends BaseTransformData implements 
ITransformData {
   public String stdOutField;
   public String stdTypeField;
 
+  /** Maximum time to wait for a command to complete, 0 waits until it ends. */
+  public long commandTimeOutMs;
+
   public IRowMeta outputRowMeta;
 
   public SshData() {
diff --git 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshDialog.java
 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshDialog.java
index 9dfd89e673..1cb7461125 100644
--- 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshDialog.java
+++ 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshDialog.java
@@ -46,9 +46,12 @@ import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CCombo;
 import org.eclipse.swt.custom.CTabFolder;
 import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.custom.ScrolledComposite;
 import org.eclipse.swt.events.ModifyListener;
 import org.eclipse.swt.events.SelectionAdapter;
 import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FillLayout;
 import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
 import org.eclipse.swt.layout.FormLayout;
@@ -65,6 +68,7 @@ public class SshDialog extends BaseTransformDialog {
   private CCombo wCommandField;
 
   private LabelTextVar wTimeOut;
+  private LabelTextVar wCommandTimeOut;
   private final SshMeta input;
 
   private Button wDynamicCommand;
@@ -120,17 +124,7 @@ public class SshDialog extends BaseTransformDialog {
     // ////////////////////////
     // START OF GENERAL TAB ///
     // ////////////////////////
-    CTabItem wGeneralTab = new CTabItem(wTabFolder, SWT.NONE);
-    wGeneralTab.setFont(GuiResource.getInstance().getFontDefault());
-    wGeneralTab.setText(BaseMessages.getString(PKG, "SSHDialog.General.Tab"));
-
-    Composite wGeneralComp = new Composite(wTabFolder, SWT.NONE);
-    PropsUi.setLook(wGeneralComp);
-
-    FormLayout fileLayout = new FormLayout();
-    fileLayout.marginWidth = 3;
-    fileLayout.marginHeight = 3;
-    wGeneralComp.setLayout(fileLayout);
+    Composite wGeneralComp = addTab(wTabFolder, "SSHDialog.General.Tab");
 
     // ///////////////////////////////
     // START OF Settings GROUP //
@@ -156,7 +150,7 @@ public class SshDialog extends BaseTransformDialog {
     wServerName.addModifyListener(lsMod);
     FormData fdServerName = new FormData();
     fdServerName.left = new FormAttachment(0, 0);
-    fdServerName.top = new FormAttachment(wSpacer, margin);
+    fdServerName.top = new FormAttachment(0, margin);
     fdServerName.right = new FormAttachment(100, 0);
     wServerName.setLayoutData(fdServerName);
 
@@ -190,6 +184,21 @@ public class SshDialog extends BaseTransformDialog {
     fdTimeOut.right = new FormAttachment(100, 0);
     wTimeOut.setLayoutData(fdTimeOut);
 
+    // Command TimeOut line
+    wCommandTimeOut =
+        new LabelTextVar(
+            variables,
+            wSettingsGroup,
+            BaseMessages.getString(PKG, "SSHDialog.CommandTimeOut.Label"),
+            BaseMessages.getString(PKG, "SSHDialog.CommandTimeOut.Tooltip"));
+    PropsUi.setLook(wCommandTimeOut);
+    wCommandTimeOut.addModifyListener(lsMod);
+    FormData fdCommandTimeOut = new FormData();
+    fdCommandTimeOut.left = new FormAttachment(0, 0);
+    fdCommandTimeOut.top = new FormAttachment(wTimeOut, margin);
+    fdCommandTimeOut.right = new FormAttachment(100, 0);
+    wCommandTimeOut.setLayoutData(fdCommandTimeOut);
+
     // Usernameline
     wUserName =
         new LabelTextVar(
@@ -201,7 +210,7 @@ public class SshDialog extends BaseTransformDialog {
     wUserName.addModifyListener(lsMod);
     FormData fdUserName = new FormData();
     fdUserName.left = new FormAttachment(0, 0);
-    fdUserName.top = new FormAttachment(wTimeOut, margin);
+    fdUserName.top = new FormAttachment(wCommandTimeOut, margin);
     fdUserName.right = new FormAttachment(100, 0);
     wUserName.setLayoutData(fdUserName);
 
@@ -378,22 +387,14 @@ public class SshDialog extends BaseTransformDialog {
 
     FormData fdSettingsGroup = new FormData();
     fdSettingsGroup.left = new FormAttachment(0, margin);
-    fdSettingsGroup.top = new FormAttachment(wSpacer, margin);
+    fdSettingsGroup.top = new FormAttachment(0, margin);
     fdSettingsGroup.right = new FormAttachment(100, -margin);
     wSettingsGroup.setLayoutData(fdSettingsGroup);
 
     // ///////////////////////////////
     // END OF Settings Fields GROUP //
 
-    FormData fdGeneralComp = new FormData();
-    fdGeneralComp.left = new FormAttachment(0, 0);
-    fdGeneralComp.top = new FormAttachment(0, 0);
-    fdGeneralComp.right = new FormAttachment(100, 0);
-    fdGeneralComp.bottom = new FormAttachment(100, 0);
-    wGeneralComp.setLayoutData(fdGeneralComp);
-
-    wGeneralComp.layout();
-    wGeneralTab.setControl(wGeneralComp);
+    finishTab(wGeneralComp);
 
     // ///////////////////////////////////////////////////////////
     // / END OF GENERAL TAB
@@ -402,17 +403,7 @@ public class SshDialog extends BaseTransformDialog {
     // ////////////////////////
     // START OF Settings TAB///
     // /
-    CTabItem wSettingsTab = new CTabItem(wTabFolder, SWT.NONE);
-    wSettingsTab.setFont(GuiResource.getInstance().getFontDefault());
-    wSettingsTab.setText(BaseMessages.getString(PKG, 
"SSHDialog.Settings.Tab"));
-
-    FormLayout settingsLayout = new FormLayout();
-    settingsLayout.marginWidth = 3;
-    settingsLayout.marginHeight = 3;
-
-    Composite wSettingsComp = new Composite(wTabFolder, SWT.NONE);
-    PropsUi.setLook(wSettingsComp);
-    wSettingsComp.setLayout(settingsLayout);
+    Composite wSettingsComp = addTab(wTabFolder, "SSHDialog.Settings.Tab");
 
     // ///////////////////////////////
     // START OF Output GROUP //
@@ -438,7 +429,7 @@ public class SshDialog extends BaseTransformDialog {
     wResultOutFieldName.addModifyListener(lsMod);
     FormData fdResultOutFieldName = new FormData();
     fdResultOutFieldName.left = new FormAttachment(0, 0);
-    fdResultOutFieldName.top = new FormAttachment(wSpacer, margin);
+    fdResultOutFieldName.top = new FormAttachment(0, margin);
     fdResultOutFieldName.right = new FormAttachment(100, 0);
     wResultOutFieldName.setLayoutData(fdResultOutFieldName);
 
@@ -459,7 +450,7 @@ public class SshDialog extends BaseTransformDialog {
 
     FormData fdOutput = new FormData();
     fdOutput.left = new FormAttachment(0, margin);
-    fdOutput.top = new FormAttachment(wSpacer, margin);
+    fdOutput.top = new FormAttachment(0, margin);
     fdOutput.right = new FormAttachment(100, -margin);
     wOutput.setLayoutData(fdOutput);
 
@@ -485,7 +476,7 @@ public class SshDialog extends BaseTransformDialog {
     PropsUi.setLook(wlDynamicCommand);
     FormData fdlDynamicBase = new FormData();
     fdlDynamicBase.left = new FormAttachment(0, margin);
-    fdlDynamicBase.top = new FormAttachment(wOutput, margin);
+    fdlDynamicBase.top = new FormAttachment(0, margin);
     fdlDynamicBase.right = new FormAttachment(middle, -margin);
     wlDynamicCommand.setLayoutData(fdlDynamicBase);
     wDynamicCommand = new Button(wCommands, SWT.CHECK);
@@ -559,15 +550,7 @@ public class SshDialog extends BaseTransformDialog {
     // / END OF Log SETTINGS GROUP
     // ///////////////////////////////////////////////////////////
 
-    FormData fdSettingsComp = new FormData();
-    fdSettingsComp.left = new FormAttachment(0, 0);
-    fdSettingsComp.top = new FormAttachment(0, 0);
-    fdSettingsComp.right = new FormAttachment(100, 0);
-    fdSettingsComp.bottom = new FormAttachment(100, 0);
-    wSettingsComp.setLayoutData(fdSettingsComp);
-
-    wSettingsComp.layout();
-    wSettingsTab.setControl(wSettingsComp);
+    finishTab(wSettingsComp);
 
     // ///////////////////////////////////////////////////////////
     // / END OF Settings TAB
@@ -626,6 +609,7 @@ public class SshDialog extends BaseTransformDialog {
       wResultErrFieldName.setText(input.getStdErrFieldName());
     }
     wTimeOut.setText(Const.NVL(input.getTimeOut(), "0"));
+    wCommandTimeOut.setText(Const.NVL(input.getCommandTimeOut(), "0"));
     if (input.getProxyHost() != null) {
       wProxyHost.setText(input.getProxyHost());
     }
@@ -640,6 +624,51 @@ public class SshDialog extends BaseTransformDialog {
     }
   }
 
+  /**
+   * A tab whose content scrolls: the options of one tab can outgrow a small 
dialog, and without a
+   * scrolled composite the widgets below the fold are simply unreachable.
+   */
+  private Composite addTab(CTabFolder wTabFolder, String titleKey) {
+    CTabItem tab = new CTabItem(wTabFolder, SWT.NONE);
+    tab.setFont(GuiResource.getInstance().getFontDefault());
+    tab.setText(BaseMessages.getString(PKG, titleKey));
+
+    ScrolledComposite scrolled = new ScrolledComposite(wTabFolder, 
SWT.V_SCROLL | SWT.H_SCROLL);
+    scrolled.setLayout(new FillLayout());
+    PropsUi.setLook(scrolled);
+
+    Composite composite = new Composite(scrolled, SWT.NONE);
+    PropsUi.setLook(composite);
+    FormLayout layout = new FormLayout();
+    layout.marginWidth = PropsUi.getFormMargin();
+    layout.marginHeight = PropsUi.getFormMargin();
+    composite.setLayout(layout);
+
+    tab.setControl(scrolled);
+    return composite;
+  }
+
+  /** Sizes the scrolled content of a tab built by {@link #addTab}. */
+  private void finishTab(Composite composite) {
+    FormData fdComp = new FormData();
+    fdComp.left = new FormAttachment(0, 0);
+    fdComp.top = new FormAttachment(0, 0);
+    fdComp.right = new FormAttachment(100, 0);
+    fdComp.bottom = new FormAttachment(100, 0);
+    composite.setLayoutData(fdComp);
+
+    composite.layout();
+    composite.pack();
+    Rectangle bounds = composite.getBounds();
+
+    ScrolledComposite scrolled = (ScrolledComposite) composite.getParent();
+    scrolled.setContent(composite);
+    scrolled.setExpandHorizontal(true);
+    scrolled.setExpandVertical(true);
+    scrolled.setMinWidth(bounds.width);
+    scrolled.setMinHeight(bounds.height);
+  }
+
   private void cancel() {
     transformName = null;
     input.setChanged(changed);
@@ -660,6 +689,7 @@ public class SshDialog extends BaseTransformDialog {
     in.setStdOutFieldName(wResultOutFieldName.getText());
     in.setStdErrFieldName(wResultErrFieldName.getText());
     in.setTimeOut(wTimeOut.getText());
+    in.setCommandTimeOut(wCommandTimeOut.getText());
     in.setProxyHost(wProxyHost.getText());
     in.setProxyPort(wProxyPort.getText());
     in.setProxyUsername(wProxyUsername.getText());
diff --git 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshMeta.java
 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshMeta.java
index a276bddbc8..de32472f0c 100644
--- 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshMeta.java
+++ 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshMeta.java
@@ -74,6 +74,10 @@ public class SshMeta extends BaseTransformMeta<Ssh, SshData> 
{
   @HopMetadataProperty private String stdOutFieldName;
   @HopMetadataProperty private String stdErrFieldName;
   @HopMetadataProperty private String timeOut;
+
+  /** Maximum time in seconds to wait for a command to complete, 0 waits until 
it ends. */
+  @HopMetadataProperty private String commandTimeOut;
+
   @HopMetadataProperty private String proxyHost;
   @HopMetadataProperty private String proxyPort;
   @HopMetadataProperty private String proxyUsername;
@@ -94,6 +98,7 @@ public class SshMeta extends BaseTransformMeta<Ssh, SshData> {
     stdOutFieldName = "stdOut";
     stdErrFieldName = "stdErr";
     timeOut = "0";
+    commandTimeOut = "0";
     proxyHost = null;
     proxyPort = null;
     proxyUsername = null;
diff --git 
a/plugins/transforms/ssh/src/main/resources/org/apache/hop/pipeline/transforms/ssh/messages/messages_en_US.properties
 
b/plugins/transforms/ssh/src/main/resources/org/apache/hop/pipeline/transforms/ssh/messages/messages_en_US.properties
index 0500b33ffb..6ca636a38e 100644
--- 
a/plugins/transforms/ssh/src/main/resources/org/apache/hop/pipeline/transforms/ssh/messages/messages_en_US.properties
+++ 
b/plugins/transforms/ssh/src/main/resources/org/apache/hop/pipeline/transforms/ssh/messages/messages_en_US.properties
@@ -18,6 +18,8 @@
 SSH.Description=Run SSH commands and returns result.
 SSH.Error.AuthenticationFailed=Authentication failed with username [{0}]!
 SSH.Error.CommandFieldMissing=Commands field name is missing\!
+SSH.Error.CommandStopped=The SSH command was stopped before it completed.
+SSH.Error.CommandTimedOut=The SSH command did not complete within {0} seconds.
 SSH.Error.ErrorConnecting=Error opening connection to [{0}] with username 
[{1}]!
 SSH.Error.MessageEmpty=Message is empty!
 SSH.Error.OpeningConnection=Error opening connection! Error : {0}
@@ -39,6 +41,8 @@ SSH.Name=Run SSH commands
 SSHDialog.Button.PreviewRows=Preview
 SSHDialog.Command.Label=Commands
 SSHDialog.Command.Tooltip=Commands
+SSHDialog.CommandTimeOut.Label=Command timeout
+SSHDialog.CommandTimeOut.Tooltip=Maximum time in seconds to wait for a command 
to complete, 0 means no limit
 SSHDialog.Connected.NOK.ConnectionBad=Can not connect to [{0}] with [{1}]
 SSHDialog.Connected.OK=Connected to [{0}] with [{1}]
 SSHDialog.Connected.Title.Ok=Success
@@ -77,8 +81,8 @@ SSHDialog.Settings.Tab=Settings
 SSHDialog.Shell.Title=Run SSH commands
 SSHDialog.TestConnection.Label=Test connection
 SSHDialog.TestConnection.Tooltip=Test connection
-SSHDialog.TimeOut.Label=Timeout
-SSHDialog.TimeOut.Tooltip=Timeout (seconds)
+SSHDialog.TimeOut.Label=Connection timeout
+SSHDialog.TimeOut.Tooltip=Maximum time in seconds to wait for the connection 
to the server, 0 means no limit
 SSHDialog.TransformName.Label=Transform name
 SSHDialog.UseKey.Label=Use key
 SSHDialog.UseKey.Tooltip=Use key
diff --git 
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SessionResultTest.java
 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SessionResultTest.java
index 8b03971cd5..5fc5625c9b 100644
--- 
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SessionResultTest.java
+++ 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SessionResultTest.java
@@ -33,6 +33,8 @@ import com.jcraft.jsch.Session;
 import java.io.ByteArrayInputStream;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.LongSupplier;
 import org.apache.hop.core.exception.HopException;
 import org.junit.jupiter.api.Test;
 
@@ -77,6 +79,68 @@ class SessionResultTest {
     assertInstanceOf(JSchException.class, ex.getCause());
   }
 
+  @Test
+  void noCommandTimeoutWaitsUntilChannelCloses() throws Exception {
+    Session session = mock(Session.class);
+    ChannelExec channel = mock(ChannelExec.class);
+    when(session.openChannel("exec")).thenReturn(channel);
+    when(channel.getInputStream()).thenReturn(stream("done\n"));
+    when(channel.getErrStream()).thenReturn(stream(""));
+    // Still running for a few polls while the clock jumps 10 minutes per call 
(#9072)
+    when(channel.isClosed()).thenReturn(false, false, false, false, true);
+
+    SessionResult result =
+        SessionResult.executeCommand(
+            session, "sleep 3000; echo done", 0L, () -> false, 
steppingClock(600_000L));
+
+    assertEquals("done\n", result.getStdOut());
+    verify(channel).disconnect();
+  }
+
+  @Test
+  void commandTimeoutFailsWhenChannelStaysOpen() throws Exception {
+    Session session = mock(Session.class);
+    ChannelExec channel = mock(ChannelExec.class);
+    when(session.openChannel("exec")).thenReturn(channel);
+    when(channel.getInputStream()).thenReturn(stream(""));
+    when(channel.getErrStream()).thenReturn(stream(""));
+    when(channel.isClosed()).thenReturn(false);
+
+    HopException ex =
+        assertThrows(
+            HopException.class,
+            () ->
+                SessionResult.executeCommand(
+                    session, "sleep 60", 5_000L, () -> false, 
steppingClock(2_000L)));
+
+    assertTrue(ex.getMessage().contains("5 seconds"), ex.getMessage());
+    verify(channel).disconnect();
+  }
+
+  @Test
+  void stoppedTransformAbandonsRunningCommand() throws Exception {
+    Session session = mock(Session.class);
+    ChannelExec channel = mock(ChannelExec.class);
+    when(session.openChannel("exec")).thenReturn(channel);
+    when(channel.getInputStream()).thenReturn(stream(""));
+    when(channel.getErrStream()).thenReturn(stream(""));
+    when(channel.isClosed()).thenReturn(false);
+    AtomicLong polls = new AtomicLong();
+
+    assertThrows(
+        HopException.class,
+        () ->
+            SessionResult.executeCommand(
+                session, "sleep 60", 0L, () -> polls.incrementAndGet() > 3));
+
+    verify(channel).disconnect();
+  }
+
+  private static LongSupplier steppingClock(long stepMs) {
+    AtomicLong now = new AtomicLong();
+    return () -> now.addAndGet(stepMs);
+  }
+
   private static InputStream stream(String text) {
     return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8));
   }
diff --git 
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshMetaTest.java
 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshMetaTest.java
index 2ea992e085..03658e38c1 100644
--- 
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshMetaTest.java
+++ 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshMetaTest.java
@@ -111,6 +111,7 @@ class SshMetaTest extends SshTestSupport {
             "stdOutFieldName",
             "stdErrFieldName",
             "timeOut",
+            "commandTimeOut",
             "proxyHost",
             "proxyPort",
             "proxyUsername",
@@ -130,6 +131,7 @@ class SshMetaTest extends SshTestSupport {
     assertEquals("22", meta.getPort());
     assertEquals("stdOut", meta.getStdOutFieldName());
     assertEquals("stdErr", meta.getStdErrFieldName());
+    assertEquals("0", meta.getCommandTimeOut());
     assertTrue(meta.isUsePrivateKey());
     assertFalse(meta.isDynamicCommandField());
   }

Reply via email to