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