This is an automated email from the ASF dual-hosted git repository.
hansva 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 b9929f4e5e Harden explorer DnD against null drop targets, fixes #7933
(#7934)
b9929f4e5e is described below
commit b9929f4e5e72c56090de624448fd745c19a67185
Author: Matt Casters <[email protected]>
AuthorDate: Thu Aug 13 15:47:57 2026 +0200
Harden explorer DnD against null drop targets, fixes #7933 (#7934)
* Harden explorer DnD against null drop targets, fixes #7933
Guard ExplorerPerspective drop/dragOver so a null RAP drop target
or bad payload cannot NPE and kill the Hop Web session.
* Harden SSH transform tests against empty stdout under CI load
SessionResult treated channel-closed + available()==0 as EOF too early,
which can drop late stdout packets on busy GitHub runners. Drain with a
short post-close grace when empty, and run the embedded SSHD command
handler asynchronously so onExit is not racy with the JSch client.
---
.../hop/pipeline/transforms/ssh/SessionResult.java | 52 +++++++---
.../pipeline/transforms/ssh/SshTestSupport.java | 60 ++++++++----
.../perspective/explorer/ExplorerPerspective.java | 105 +++++++++++++--------
3 files changed, 151 insertions(+), 66 deletions(-)
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 d24f50618e..82c3d71085 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
@@ -32,6 +32,13 @@ public class SessionResult {
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
+ * 0 while stdout/stderr packets are in flight (common under CI load). Keep
polling a few times
+ * before treating empty streams as complete so we do not drop command
output.
+ */
+ private static final int POST_CLOSE_EMPTY_POLLS = 10;
+
@Getter @Setter private String stdOut;
@Getter private String stdErr;
@Getter @Setter private boolean stdErrorType;
@@ -74,22 +81,37 @@ public class SessionResult {
StringBuilder stderr = new StringBuilder();
long deadline = System.currentTimeMillis() + CHANNEL_MAX_WAIT_MS;
+ int emptyPollsAfterClose = 0;
while (true) {
- appendAvailable(isOut, buffer, stdout);
- appendAvailable(isErr, buffer, stderr);
+ boolean readOut = appendAvailable(isOut, buffer, stdout);
+ boolean readErr = appendAvailable(isErr, buffer, stderr);
+ boolean readSomething = readOut || readErr;
+
if (channel.isClosed()) {
- if (isStreamDrained(isOut) && isStreamDrained(isErr)) {
- break;
+ if (readSomething) {
+ emptyPollsAfterClose = 0;
+ } else if (isStreamDrained(isOut) && isStreamDrained(isErr)) {
+ // Already captured output: channel close is enough. Empty output
needs a short grace
+ // window because available()==0 right after close is not a
reliable EOF under load.
+ if (stdout.length() > 0 || stderr.length() > 0) {
+ break;
+ }
+ emptyPollsAfterClose++;
+ if (emptyPollsAfterClose >= POST_CLOSE_EMPTY_POLLS) {
+ break;
+ }
}
} else if (System.currentTimeMillis() > deadline) {
throw new HopException("Timed out waiting for SSH command to
complete");
} else {
- try {
- Thread.sleep(CHANNEL_POLL_MS);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new HopException(e);
- }
+ emptyPollsAfterClose = 0;
+ }
+
+ try {
+ Thread.sleep(CHANNEL_POLL_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new HopException(e);
}
}
@@ -100,18 +122,24 @@ public class SessionResult {
}
}
- private static void appendAvailable(InputStream in, byte[] buffer,
StringBuilder target)
+ /**
+ * @return true if at least one byte was read
+ */
+ private static boolean appendAvailable(InputStream in, byte[] buffer,
StringBuilder target)
throws IOException {
if (in == null) {
- return;
+ return false;
}
+ boolean readSomething = false;
while (in.available() > 0) {
int read = in.read(buffer, 0, buffer.length);
if (read < 0) {
break;
}
target.append(new String(buffer, 0, read, StandardCharsets.UTF_8));
+ readSomething = true;
}
+ return readSomething;
}
private boolean isStreamDrained(InputStream in) throws IOException {
diff --git
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshTestSupport.java
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshTestSupport.java
index 224c6cd2a6..27a9c54c32 100644
---
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshTestSupport.java
+++
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshTestSupport.java
@@ -17,7 +17,6 @@
package org.apache.hop.pipeline.transforms.ssh;
-import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -25,6 +24,10 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.PublicKey;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
import lombok.extern.slf4j.Slf4j;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.variables.Variables;
@@ -46,9 +49,28 @@ class SshTestSupport {
/** Passphrase used when generating test keys; must match {@link
#privateKeyMeta}. */
static final String TEST_KEY_PASSPHRASE = "hello";
+ /**
+ * Lightweight pool for embedded command handlers. SSHD expects {@link
Command#start} not to block
+ * the I/O thread for the whole command lifetime.
+ */
+ private static final ExecutorService COMMAND_EXECUTOR =
+ Executors.newCachedThreadPool(
+ new ThreadFactory() {
+ private final AtomicInteger seq = new AtomicInteger();
+
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r, "ssh-test-command-" +
seq.incrementAndGet());
+ t.setDaemon(true);
+ return t;
+ }
+ });
+
static SshServer startPasswordSshServer(PublicKey authorizedPublicKey)
throws IOException {
SshServer sshd = SshServer.setUpDefaultServer();
sshd.setPort(0);
+ // Keep the embedded server lean for constrained CI runners.
+ sshd.setNioWorkers(2);
sshd.setKeyPairProvider(new SimpleGeneratorHostKeyProvider());
sshd.setPasswordAuthenticator(
(username, password, session) -> SSH_USER.equals(username) &&
SSH_PASS.equals(password));
@@ -180,33 +202,39 @@ class SshTestSupport {
}
@Override
- public void start(ChannelSession channel, Environment env) throws
IOException {
+ public void start(ChannelSession channel, Environment env) {
+ // Run off the SSHD I/O thread. Synchronous start+onExit can close the
channel before the
+ // client has attached readers, which shows up as empty stdout under CI
load.
+ COMMAND_EXECUTOR.execute(this::runCommand);
+ }
+
+ private void runCommand() {
try {
if (command != null && command.startsWith("echo ")) {
- String payload = command.substring(5) + "\n";
- stdout.write(payload.getBytes(StandardCharsets.UTF_8));
- stdout.flush();
+ writeAndFlush(stdout, command.substring(5) + "\n");
exitCallback.onExit(0);
} else if (command != null && command.startsWith("stderr ")) {
- String payload = command.substring(7) + "\n";
- stderr.write(payload.getBytes(StandardCharsets.UTF_8));
- stderr.flush();
+ writeAndFlush(stderr, command.substring(7) + "\n");
exitCallback.onExit(0);
} else {
- stderr.write(("unsupported command: " + command +
"\n").getBytes(StandardCharsets.UTF_8));
- stderr.flush();
+ writeAndFlush(stderr, "unsupported command: " + command + "\n");
exitCallback.onExit(1);
}
- } finally {
- closeQuietly(stdout);
- closeQuietly(stderr);
+ } catch (IOException e) {
+ log.warn("Embedded SSH test command failed: {}", command, e);
+ if (exitCallback != null) {
+ exitCallback.onExit(1, e.getMessage());
+ }
}
+ // Do not close stdout/stderr here: SSHD owns channel stream lifecycle
after onExit.
}
- private static void closeQuietly(OutputStream stream) throws IOException {
- if (stream instanceof Closeable closeable) {
- closeable.close();
+ private static void writeAndFlush(OutputStream stream, String payload)
throws IOException {
+ if (stream == null) {
+ return;
}
+ stream.write(payload.getBytes(StandardCharsets.UTF_8));
+ stream.flush();
}
@Override
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index 81788c7050..1c2e3e2ae8 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -174,7 +174,6 @@ import org.eclipse.swt.widgets.ToolBar;
import org.eclipse.swt.widgets.ToolItem;
import org.eclipse.swt.widgets.Tree;
import org.eclipse.swt.widgets.TreeItem;
-import org.eclipse.swt.widgets.Widget;
@HopPerspectivePlugin(
id = "100-HopExplorerPerspective",
@@ -967,7 +966,11 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable, IFileD
@Override
public void dragOver(final DropTargetEvent event) {
+ // No tree item under the cursor (empty area / between items).
Reject so RAP/Hop Web
+ // does not attempt an invalid drop that can NPE and kill the
session (#7933).
if (event.item == null) {
+ event.detail = DND.DROP_NONE;
+ event.feedback = DND.FEEDBACK_NONE;
return;
}
@@ -984,10 +987,11 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable, IFileD
// For internal drag check hierarchies
if (dragFile != null) {
FileObject sourceFile = HopVfs.getFileObject(dragFile);
+ FileObject sourceParent = sourceFile.getParent();
- // Avoid copy or move to itself, it's parent or for folder
it's descendant
+ // Avoid copy or move to itself, its parent or for folder
its descendant
if (sourceFile.equals(targetFile)
- || sourceFile.getParent().equals(targetFile)
+ || (sourceParent != null &&
sourceParent.equals(targetFile))
||
sourceFile.getName().isDescendent(targetFile.getName())) {
event.detail = DND.DROP_NONE;
}
@@ -999,52 +1003,77 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable, IFileD
event.detail = DND.DROP_NONE;
event.feedback = DND.FEEDBACK_NONE;
}
+ } else {
+ event.detail = DND.DROP_NONE;
+ event.feedback = DND.FEEDBACK_NONE;
}
}
@Override
public void drop(final DropTargetEvent event) {
- if
(FileTransfer.getInstance().isSupportedType(event.currentDataType)) {
- Widget item = event.item;
- if (item.getData() instanceof TreeItemFolder targetItem) {
- List<String> errors = new ArrayList<>();
-
- for (String path : (String[]) event.data) {
- try {
- FileObject sourceFile = HopVfs.getFileObject(path);
- FileObject targetFile =
- HopVfs.getFileObject(
- targetItem.path
- + Const.FILE_SEPARATOR
- + sourceFile.getName().getBaseName());
-
- if (event.detail == DND.DROP_COPY) {
- // Copy file and folder and all its descendants
- // No need to update tab item handler because all files
are new
- targetFile.copyFrom(sourceFile, Selectors.SELECT_ALL);
- } else if (event.detail == DND.DROP_MOVE) {
- // Move file or folder and all its descendants and
update tab item handlers
- moveFile(sourceFile, targetFile);
- }
- } catch (Exception e) {
- errors.add(path);
- }
- }
+ // Guard every precondition: uncaught exceptions in RAP DND
listeners can tear down
+ // the Hop Web UI session (see #7933).
+ try {
+ if
(!FileTransfer.getInstance().isSupportedType(event.currentDataType)
+ || event.item == null
+ || event.data == null
+ || !(event.data instanceof String[] paths)) {
+ event.detail = DND.DROP_NONE;
+ return;
+ }
- // Report errors
- if (!errors.isEmpty()) {
+ Object itemData = event.item.getData();
+ if (!(itemData instanceof TreeItemFolder targetItem) ||
!targetItem.folder) {
+ event.detail = DND.DROP_NONE;
+ return;
+ }
- String paths = String.join("\n", errors);
+ List<String> errors = new ArrayList<>();
- MessageBox messageBox =
- new MessageBox(HopGui.getInstance().getShell(),
SWT.ICON_ERROR | SWT.OK);
- messageBox.setText("Drag and drop");
- messageBox.setMessage("Unable to copy/move file(s):\n\n" +
paths);
- messageBox.open();
+ for (String path : paths) {
+ if (Utils.isEmpty(path)) {
+ continue;
}
+ try {
+ FileObject sourceFile = HopVfs.getFileObject(path);
+ FileObject targetFile =
+ HopVfs.getFileObject(
+ targetItem.path
+ + Const.FILE_SEPARATOR
+ + sourceFile.getName().getBaseName());
+
+ if (event.detail == DND.DROP_COPY) {
+ // Copy file and folder and all its descendants
+ // No need to update tab item handler because all files
are new
+ targetFile.copyFrom(sourceFile, Selectors.SELECT_ALL);
+ } else if (event.detail == DND.DROP_MOVE) {
+ // Move file or folder and all its descendants and update
tab item handlers
+ moveFile(sourceFile, targetFile);
+ }
+ } catch (Exception e) {
+ errors.add(path);
+ }
+ }
+
+ // Report errors
+ if (!errors.isEmpty()) {
+ String errorPaths = String.join("\n", errors);
- refresh();
+ MessageBox messageBox =
+ new MessageBox(HopGui.getInstance().getShell(),
SWT.ICON_ERROR | SWT.OK);
+ messageBox.setText("Drag and drop");
+ messageBox.setMessage("Unable to copy/move file(s):\n\n" +
errorPaths);
+ messageBox.open();
}
+
+ refresh();
+ } catch (Exception e) {
+ event.detail = DND.DROP_NONE;
+ new ErrorDialog(
+ HopGui.getInstance().getShell(),
+ "Drag and drop",
+ "Unexpected error during file drag and drop",
+ e);
}
}
});