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 d3eab57849 Issue #8047 : Isolate Hop Web GUI state across RAP sessions 
(#8108)
d3eab57849 is described below

commit d3eab57849258f9021b9df390ad0441b23ba8b50
Author: Matt Casters <[email protected]>
AuthorDate: Wed Aug 26 22:28:20 2026 +0200

    Issue #8047 : Isolate Hop Web GUI state across RAP sessions (#8108)
    
    * Issue #8047 : Isolate Hop Web GUI state across RAP sessions
    
    Stop sharing SWT widgets, images, and session-owned GUI objects between
    Hop Web UISessions so concurrent users no longer hit Invalid thread
    access or disposed widget errors.
    
    * Issue #8047 : Fix DrillDownGuiPlugin javadoc after sniffer map rename
    
    * Issue #8047 : Do not construct HopGui from perspective getInstance()
    
    Calling HopGui.getInstance() from Explorer/Metadata/Git helpers started a
    full GUI during hop-ui-rcp tests, flooding SWT and the session display.
    peekInstance() returns an existing GUI only. Guard MetaSelectionLine
    getEditIcon when the managed class is unset (widget-disable harness).
---
 .../ROOT/pages/hopweb/hopweb-antipatterns.adoc     |  12 ++
 .../org/apache/hop/git/GitCommitPerspective.java   |  15 +-
 .../main/java/org/apache/hop/git/GitGuiPlugin.java |  18 +-
 .../java/org/apache/hop/git/GitPerspective.java    |  15 +-
 .../main/java/org/apache/hop/git/GitResource.java  |  19 +-
 .../java/org/apache/hop/git/TextDiffDialog.java    |   2 +-
 .../apache/hop/git/info/DiffStyledTextComp.java    |  28 +--
 .../hop/git/GitGuiPluginSessionIsolationTest.java  |  55 ++++++
 .../HopGuiImpl.java => core/PropsUiImpl.java}      |   7 +-
 .../java/org/apache/hop/ui/hopgui/HopGuiImpl.java  |  13 ++
 .../{HopGuiImpl.java => HopGuiKeyHandlerImpl.java} |   4 +-
 .../org/apache/hop/ui/hopgui/HopWebEntryPoint.java |   2 +
 .../HopGuiImpl.java => core/PropsUiImpl.java}      |  10 +-
 .../java/org/apache/hop/ui/hopgui/HopGuiImpl.java  |   5 +
 .../{HopGuiImpl.java => HopGuiKeyHandlerImpl.java} |   6 +-
 .../hop/ui/hopgui/HopGuiPeekInstanceTest.java      |  42 +++++
 .../main/java/org/apache/hop/ui/core/PropsUi.java  |  49 ++++-
 .../org/apache/hop/ui/core/gui/HopNamespace.java   |  26 ++-
 .../apache/hop/ui/core/vfs/HopVfsFileDialog.java   |  15 +-
 .../hop/ui/core/widget/MetaSelectionLine.java      |  10 +-
 .../apache/hop/ui/core/widget/StyledTextVar.java   |   3 +-
 .../org/apache/hop/ui/core/widget/TreeMemory.java  |  18 +-
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |  57 ++++++
 .../org/apache/hop/ui/hopgui/HopGuiKeyHandler.java |  64 +++++--
 .../apache/hop/ui/hopgui/ISingletonProvider.java   |   8 +
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |   6 +-
 .../ui/hopgui/file/shared/DrillDownGuiPlugin.java  | 210 +++++++++++++++++----
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  |   3 +-
 .../configuration/ConfigurationPerspective.java    |  16 +-
 .../tabs/ConfigGeneralOptionsTab.java              |   4 +-
 .../configuration/tabs/ConfigGuiOptionsTab.java    |  24 +--
 .../tabs/ConfigKeyboardShortcutsTab.java           |   4 +-
 .../configuration/tabs/ConfigPluginOptionsTab.java |  29 ++-
 .../execution/ExecutionPerspective.java            |  14 +-
 .../perspective/explorer/ExplorerPerspective.java  |  14 +-
 .../perspective/metadata/MetadataPerspective.java  |  15 +-
 .../hop/ui/hopgui/shared/SashFormMemory.java       |  43 ++++-
 .../org/apache/hop/ui/hopgui/shared/SwtGc.java     |  61 +++---
 .../apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java |  14 ++
 .../ui/hopgui/StaticSwtSessionResourceTest.java    | 110 +++++++++++
 .../hopgui/file/shared/DrillDownGuiPluginTest.java |  72 +++++++
 41 files changed, 977 insertions(+), 165 deletions(-)

diff --git 
a/docs/hop-dev-manual/modules/ROOT/pages/hopweb/hopweb-antipatterns.adoc 
b/docs/hop-dev-manual/modules/ROOT/pages/hopweb/hopweb-antipatterns.adoc
index c30b5be8be..a6cbf4b418 100644
--- a/docs/hop-dev-manual/modules/ROOT/pages/hopweb/hopweb-antipatterns.adoc
+++ b/docs/hop-dev-manual/modules/ROOT/pages/hopweb/hopweb-antipatterns.adoc
@@ -162,6 +162,18 @@ public class HadoopClusterViewTreeExtension implements 
ExtensionPointInterface {
 }
 ----
 
+== Hop Web session isolation
+
+Hop Web creates one RAP `UISession` (and one SWT `Display`) per browser 
session. `HopGui`, `GuiResource` and `GuiContextUtil` are already 
session-scoped via `ISingletonProvider` and RAP `SingletonUtil`. Anything else 
that holds SWT widgets or session GUI identity must follow one of these 
patterns:
+
+* **ui-module classes:** `ISingletonProvider` plus `*Impl` in `rcp` (process 
singleton) and `rap` (`SingletonUtil.getSessionInstance`).
+* **plugin classes:** 
`HopGui.getInstance().getSessionSingleton(TheClass.class, TheClass::new)`. 
Plugin classloaders cannot load RAP `*Impl` classes through 
`ImplementationLoader`.
+* **Perspectives:** `getInstance()` must prefer 
`HopGui.findSessionPerspective(...)`. A static `instance` field is only a 
fallback for tests and disabled perspectives.
+* **Caches of SWT `Image`s:** key by `Device`/`Display` and dispose on *that* 
device. Never one process-wide dispose hook.
+* **Engine-thread maps (drill-down):** stamp `hopGui.getId()` on the engine in 
the GUI thread; do not call `HopGui.getInstance()` from pipeline worker threads 
(no RAP `UISession`).
+
+Do not store `Control`, `Shell`, `SashForm`, `Image`, `Color`, `Font` or `GC` 
in `static` fields. When session A ends RAP disposes those objects; session B 
then hits `Invalid thread access` or `Widget is disposed`.
+
 == Avoid use of unimplemented SWT APIs
 
 === Example: drawing on an Image
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
index 14d4cdb2b4..a2fbb6ecc3 100644
--- 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
+++ 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
@@ -23,7 +23,6 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import lombok.Getter;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.exception.HopException;
@@ -116,7 +115,7 @@ public class GitCommitPerspective implements 
IHopPerspective {
   private static final String STAGED_LABEL = 
"GitCommitPerspective.Status.Staged.Label";
   private static final String UNSTAGED_LABEL = 
"GitCommitPerspective.Status.Unstaged.Label";
   private static final String UNTRACKED_LABEL = 
"GitCommitPerspective.Status.Untracked.Label";
-  @Getter private static GitCommitPerspective instance;
+  private static GitCommitPerspective instance;
 
   private HopGui hopGui;
   private SashForm wSashForm;
@@ -137,6 +136,18 @@ public class GitCommitPerspective implements 
IHopPerspective {
     instance = this;
   }
 
+  public static GitCommitPerspective getInstance() {
+    try {
+      GitCommitPerspective fromGui = 
HopGui.findSessionPerspective(GitCommitPerspective.class);
+      if (fromGui != null) {
+        return fromGui;
+      }
+    } catch (Throwable e) {
+      // No HopGuiImpl in unit tests
+    }
+    return instance;
+  }
+
   @Override
   public String getId() {
     return "GitCommitPerspective";
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java
index ff9626580e..ee5a81f618 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java
@@ -123,19 +123,25 @@ public class GitGuiPlugin
   public static final String CONST_GIT = "git: ";
   public static final String CONST_S_S_S = "%s (%s -> %s)";
 
-  private static GitGuiPlugin instance;
-
-  private static UIGit git;
+  private UIGit git;
 
   @Getter private Map<String, UIFile> changedFiles;
 
   @Getter private Map<String, String> ignoredFiles;
 
+  private static GitGuiPlugin fallback;
+
   public static GitGuiPlugin getInstance() {
-    if (instance == null) {
-      instance = new GitGuiPlugin();
+    HopGui hopGui = HopGui.peekInstance();
+    if (hopGui != null) {
+      return hopGui.getSessionSingleton(GitGuiPlugin.class, GitGuiPlugin::new);
+    }
+    synchronized (GitGuiPlugin.class) {
+      if (fallback == null) {
+        fallback = new GitGuiPlugin();
+      }
+      return fallback;
     }
-    return instance;
   }
 
   public GitGuiPlugin() {
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
index 3ae7005c9d..6a0e58af45 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
@@ -25,7 +25,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.Timer;
 import java.util.TimerTask;
-import lombok.Getter;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.config.HopConfig;
@@ -195,7 +194,7 @@ public class GitPerspective implements IHopPerspective {
 
   public static final String OPTION_SHOW_ALL_REF = "Git.ShowAllRef";
 
-  @Getter private static GitPerspective instance;
+  private static GitPerspective instance;
 
   private HopGui hopGui;
   private SashForm wSashForm;
@@ -229,6 +228,18 @@ public class GitPerspective implements IHopPerspective {
     instance = this;
   }
 
+  public static GitPerspective getInstance() {
+    try {
+      GitPerspective fromGui = 
HopGui.findSessionPerspective(GitPerspective.class);
+      if (fromGui != null) {
+        return fromGui;
+      }
+    } catch (Throwable e) {
+      // No HopGuiImpl in unit tests
+    }
+    return instance;
+  }
+
   @Override
   public String getId() {
     return "GitPerspective";
diff --git a/plugins/misc/git/src/main/java/org/apache/hop/git/GitResource.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitResource.java
index c9befab5c9..d536611040 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/GitResource.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/GitResource.java
@@ -22,6 +22,7 @@ import lombok.Getter;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.hopgui.HopGui;
 import org.eclipse.swt.graphics.Color;
 import org.eclipse.swt.graphics.Image;
 
@@ -64,10 +65,8 @@ public class GitResource {
   @Getter private final Image resetImage;
   @Getter private final Image tagImage;
 
-  private static GitResource instance;
-
   /** Utility class */
-  private GitResource() {
+  GitResource() {
     GuiResource resource = GuiResource.getInstance();
 
     textInsertForegroundColor = resource.getColor(0, 255, 0);
@@ -118,11 +117,19 @@ public class GitResource {
     tagImage = getImage("tag.svg");
   }
 
+  private static GitResource fallback;
+
   public static GitResource getInstance() {
-    if (instance == null) {
-      instance = new GitResource();
+    HopGui hopGui = HopGui.peekInstance();
+    if (hopGui != null) {
+      return hopGui.getSessionSingleton(GitResource.class, GitResource::new);
+    }
+    synchronized (GitResource.class) {
+      if (fallback == null) {
+        fallback = new GitResource();
+      }
+      return fallback;
     }
-    return instance;
   }
 
   public Image getImage(String location) {
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/TextDiffDialog.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/TextDiffDialog.java
index 12c4a23bf8..30d5deef73 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/TextDiffDialog.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/TextDiffDialog.java
@@ -387,7 +387,7 @@ public class TextDiffDialog extends Dialog {
       path.dispose();
     }
 
-    
gc.setForeground(Display.getDefault().getSystemColor(SWT.COLOR_WIDGET_BORDER));
+    
gc.setForeground(Display.getCurrent().getSystemColor(SWT.COLOR_WIDGET_BORDER));
     gc.drawLine(0, 0, 0, area.height);
     gc.drawLine(area.width - 1, 0, area.width - 1, area.height);
   }
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/info/DiffStyledTextComp.java
 
b/plugins/misc/git/src/main/java/org/apache/hop/git/info/DiffStyledTextComp.java
index 4e28b98134..5d89401c2a 100644
--- 
a/plugins/misc/git/src/main/java/org/apache/hop/git/info/DiffStyledTextComp.java
+++ 
b/plugins/misc/git/src/main/java/org/apache/hop/git/info/DiffStyledTextComp.java
@@ -35,17 +35,20 @@ import org.eclipse.swt.widgets.Display;
  */
 public class DiffStyledTextComp extends StyledTextVar {
 
-  private static final Color COLOR_ADDITION = new Color(Display.getDefault(), 
0, 128, 0); // Green
-  private static final Color COLOR_DELETION = new Color(Display.getDefault(), 
255, 0, 0); // Red
-  private static final Color COLOR_FILE_HEADER =
-      new Color(Display.getDefault(), 0, 128, 128); // Cyan
-  private static final Color COLOR_HUNK_HEADER =
-      new Color(Display.getDefault(), 153, 102, 0); // Brown/Orange
+  private final Color colorAddition;
+  private final Color colorDeletion;
+  private final Color colorFileHeader;
+  private final Color colorHunkHeader;
 
   public DiffStyledTextComp(IVariables variables, Composite parent, int style) 
{
     super(variables, parent, style, false, false, true, STYLE_TYPE_DIFF); // 
No variable support
     // Set read-only and disable editing
     getTextWidget().setEditable(false);
+    Display display = parent.getDisplay();
+    colorAddition = new Color(display, 0, 128, 0);
+    colorDeletion = new Color(display, 255, 0, 0);
+    colorFileHeader = new Color(display, 0, 128, 128);
+    colorHunkHeader = new Color(display, 153, 102, 0);
   }
 
   /**
@@ -80,10 +83,10 @@ public class DiffStyledTextComp extends StyledTextVar {
 
       if (line.startsWith("+")) {
         // Addition line (green)
-        styleRange = new StyleRange(offset, lineLength, COLOR_ADDITION, null);
+        styleRange = new StyleRange(offset, lineLength, colorAddition, null);
       } else if (line.startsWith("-")) {
         // Deletion line (red)
-        styleRange = new StyleRange(offset, lineLength, COLOR_DELETION, null);
+        styleRange = new StyleRange(offset, lineLength, colorDeletion, null);
       } else if (line.startsWith("diff --git")
           || line.startsWith("+++")
           || line.startsWith("---")
@@ -94,13 +97,13 @@ public class DiffStyledTextComp extends StyledTextVar {
           || line.startsWith("rename from")
           || line.startsWith("rename to")) {
         // File header (cyan)
-        styleRange = new StyleRange(offset, lineLength, COLOR_FILE_HEADER, 
null);
+        styleRange = new StyleRange(offset, lineLength, colorFileHeader, null);
         if (line.startsWith("+++") || line.startsWith("---")) {
           styleRange.fontStyle = SWT.BOLD;
         }
       } else if (line.startsWith("@@")) {
         // Hunk header (brown/orange)
-        styleRange = new StyleRange(offset, lineLength, COLOR_HUNK_HEADER, 
null);
+        styleRange = new StyleRange(offset, lineLength, colorHunkHeader, null);
         styleRange.fontStyle = SWT.BOLD;
       }
 
@@ -115,7 +118,10 @@ public class DiffStyledTextComp extends StyledTextVar {
 
   @Override
   public void dispose() {
-    // Colors are shared and managed by Display, no need to dispose them 
explicitly
+    colorAddition.dispose();
+    colorDeletion.dispose();
+    colorFileHeader.dispose();
+    colorHunkHeader.dispose();
     super.dispose();
   }
 }
diff --git 
a/plugins/misc/git/src/test/java/org/apache/hop/git/GitGuiPluginSessionIsolationTest.java
 
b/plugins/misc/git/src/test/java/org/apache/hop/git/GitGuiPluginSessionIsolationTest.java
new file mode 100644
index 0000000000..a59f66edc3
--- /dev/null
+++ 
b/plugins/misc/git/src/test/java/org/apache/hop/git/GitGuiPluginSessionIsolationTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.hop.git;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import org.junit.jupiter.api.Test;
+
+class GitGuiPluginSessionIsolationTest {
+
+  @Test
+  void gitHandleIsNotAProcessWideStatic() throws Exception {
+    Field git = GitGuiPlugin.class.getDeclaredField("git");
+    assertFalse(
+        Modifier.isStatic(git.getModifiers()),
+        "UIGit must be per HopGui / RAP UISession, not a JVM static");
+  }
+
+  @Test
+  void gitPluginIsNotAProcessWideStaticSingletonField() {
+    for (Field field : GitGuiPlugin.class.getDeclaredFields()) {
+      if ("instance".equals(field.getName())) {
+        assertFalse(
+            Modifier.isStatic(field.getModifiers()),
+            "GitGuiPlugin must not keep a static instance field");
+      }
+    }
+  }
+
+  @Test
+  void gitResourceIsNotAProcessWideStaticSingletonField() {
+    for (Field field : GitResource.class.getDeclaredFields()) {
+      if ("instance".equals(field.getName())) {
+        assertFalse(Modifier.isStatic(field.getModifiers()));
+      }
+    }
+  }
+}
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java 
b/rap/src/main/java/org/apache/hop/ui/core/PropsUiImpl.java
similarity index 81%
copy from rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
copy to rap/src/main/java/org/apache/hop/ui/core/PropsUiImpl.java
index 5a6a6660e3..fb09e7be6a 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/core/PropsUiImpl.java
@@ -15,13 +15,14 @@
  * limitations under the License.
  */
 
-package org.apache.hop.ui.hopgui;
+package org.apache.hop.ui.core;
 
+import org.apache.hop.ui.hopgui.ISingletonProvider;
 import org.eclipse.rap.rwt.SingletonUtil;
 
-public class HopGuiImpl implements ISingletonProvider {
+public class PropsUiImpl implements ISingletonProvider {
   @Override
   public Object getInstanceInternal() {
-    return SingletonUtil.getSessionInstance(HopGui.class);
+    return SingletonUtil.getSessionInstance(PropsUi.class);
   }
 }
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
index 5a6a6660e3..277c05fde6 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.ui.hopgui;
 
+import org.eclipse.rap.rwt.RWT;
 import org.eclipse.rap.rwt.SingletonUtil;
 
 public class HopGuiImpl implements ISingletonProvider {
@@ -24,4 +25,16 @@ public class HopGuiImpl implements ISingletonProvider {
   public Object getInstanceInternal() {
     return SingletonUtil.getSessionInstance(HopGui.class);
   }
+
+  @Override
+  public Object peekInstanceInternal() {
+    try {
+      if (RWT.getUISession() == null) {
+        return null;
+      }
+      return SingletonUtil.getSessionInstance(HopGui.class);
+    } catch (Exception e) {
+      return null;
+    }
+  }
 }
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerImpl.java
similarity index 87%
copy from rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
copy to rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerImpl.java
index 5a6a6660e3..cfed51f0e5 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerImpl.java
@@ -19,9 +19,9 @@ package org.apache.hop.ui.hopgui;
 
 import org.eclipse.rap.rwt.SingletonUtil;
 
-public class HopGuiImpl implements ISingletonProvider {
+public class HopGuiKeyHandlerImpl implements ISingletonProvider {
   @Override
   public Object getInstanceInternal() {
-    return SingletonUtil.getSessionInstance(HopGui.class);
+    return SingletonUtil.getSessionInstance(HopGuiKeyHandler.class);
   }
 }
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
index 59ae0cb0a9..ce87d5e55c 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
@@ -36,6 +36,7 @@ import org.apache.hop.history.AuditManager;
 import org.apache.hop.history.AuditState;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.hopgui.canvas.CanvasGraphRegistry;
+import org.apache.hop.ui.hopgui.file.shared.DrillDownGuiPlugin;
 import org.eclipse.rap.rwt.RWT;
 import org.eclipse.rap.rwt.application.AbstractEntryPoint;
 import org.eclipse.rap.rwt.client.service.JavaScriptExecutor;
@@ -269,6 +270,7 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
                     return;
                   }
                   hopGui.auditDelegate.writeLastOpenFiles();
+                  DrillDownGuiPlugin.cleanupSession(hopGui.getId());
                 } catch (SWTException e) {
                   if (e.code != SWT.ERROR_WIDGET_DISPOSED) {
                     LogChannel.UI.logError("Error persisting open files on 
session end", e);
diff --git a/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/core/PropsUiImpl.java
similarity index 81%
copy from rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
copy to rcp/src/main/java/org/apache/hop/ui/core/PropsUiImpl.java
index 927d1aca6e..0d40ac2f9e 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
+++ b/rcp/src/main/java/org/apache/hop/ui/core/PropsUiImpl.java
@@ -15,15 +15,17 @@
  * limitations under the License.
  */
 
-package org.apache.hop.ui.hopgui;
+package org.apache.hop.ui.core;
 
-public class HopGuiImpl implements ISingletonProvider {
-  private static HopGui instance;
+import org.apache.hop.ui.hopgui.ISingletonProvider;
+
+public class PropsUiImpl implements ISingletonProvider {
+  private static PropsUi instance;
 
   @Override
   public Object getInstanceInternal() {
     if (instance == null) {
-      instance = new HopGui();
+      instance = new PropsUi();
     }
     return instance;
   }
diff --git a/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
index 927d1aca6e..3fb5b5889a 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
@@ -27,4 +27,9 @@ public class HopGuiImpl implements ISingletonProvider {
     }
     return instance;
   }
+
+  @Override
+  public Object peekInstanceInternal() {
+    return instance;
+  }
 }
diff --git a/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerImpl.java
similarity index 86%
copy from rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
copy to rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerImpl.java
index 927d1aca6e..7ff4872b26 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerImpl.java
@@ -17,13 +17,13 @@
 
 package org.apache.hop.ui.hopgui;
 
-public class HopGuiImpl implements ISingletonProvider {
-  private static HopGui instance;
+public class HopGuiKeyHandlerImpl implements ISingletonProvider {
+  private static HopGuiKeyHandler instance;
 
   @Override
   public Object getInstanceInternal() {
     if (instance == null) {
-      instance = new HopGui();
+      instance = new HopGuiKeyHandler();
     }
     return instance;
   }
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/HopGuiPeekInstanceTest.java 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/HopGuiPeekInstanceTest.java
new file mode 100644
index 0000000000..29201f8438
--- /dev/null
+++ b/rcp/src/test/java/org/apache/hop/ui/hopgui/HopGuiPeekInstanceTest.java
@@ -0,0 +1,42 @@
+/*
+ * 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.hop.ui.hopgui;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import 
org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspective;
+import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.apache.hop.ui.hopgui.perspective.metadata.MetadataPerspective;
+import org.junit.jupiter.api.Test;
+
+class HopGuiPeekInstanceTest {
+
+  @Test
+  void perspectiveGetInstanceDoesNotConstructHopGui() {
+    HopGui before = HopGui.peekInstance();
+    ExplorerPerspective.getInstance();
+    MetadataPerspective.getInstance();
+    ExecutionPerspective.getInstance();
+    ConfigurationPerspective.getInstance();
+    assertSame(
+        before,
+        HopGui.peekInstance(),
+        "Perspective getInstance() must not start a HopGui (that floods SWT 
tests and the display)");
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java 
b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
index 95aa7e6a71..9c86b941e9 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
@@ -35,6 +35,8 @@ import org.apache.hop.ui.core.gui.WindowProperty;
 import org.apache.hop.ui.core.widget.OsHelper;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
+import org.apache.hop.ui.hopgui.ISingletonProvider;
+import org.apache.hop.ui.hopgui.ImplementationLoader;
 import org.apache.hop.ui.hopgui.TextSizeUtilFacade;
 import org.apache.hop.ui.util.EnvironmentUtils;
 import org.eclipse.swt.SWT;
@@ -64,7 +66,7 @@ import org.eclipse.swt.widgets.Widget;
 public class PropsUi extends Props {
   private static final String OS = System.getProperty("os.name").toLowerCase();
 
-  private static double nativeZoomFactor;
+  private double nativeZoomFactor;
   private static final String STRING_SHOW_COPY_OR_DISTRIBUTE_WARNING =
       "ShowCopyOrDistributeWarning";
   private static final String SHOW_TOOL_TIPS = "ShowToolTips";
@@ -117,7 +119,32 @@ public class PropsUi extends Props {
 
   public static final int DEFAULT_MAX_EXECUTION_LOGGING_TEXT_SIZE = 2000000;
   private Map<RGB, RGB> contrastingColors;
-  private static PropsUi instance;
+
+  /**
+   * Hop Web session override for dark mode so one user on /ui-dark does not 
rewrite hop-config for
+   * every other session.
+   */
+  private Boolean darkModeOverride;
+
+  private static PropsUi fallback;
+
+  private static final ISingletonProvider PROVIDER = loadProvider();
+
+  private static ISingletonProvider loadProvider() {
+    try {
+      return (ISingletonProvider) 
ImplementationLoader.newInstance(PropsUi.class);
+    } catch (Throwable e) {
+      // hop-ui unit tests have no rcp/rap *Impl on the classpath.
+      return () -> {
+        synchronized (PropsUi.class) {
+          if (fallback == null) {
+            fallback = new PropsUi();
+          }
+          return fallback;
+        }
+      };
+    }
+  }
 
   /**
    * Session-only window position storage for dialogs. This map is kept in 
memory only and is
@@ -127,13 +154,10 @@ public class PropsUi extends Props {
   private final Map<String, WindowProperty> sessionWindowProperties = new 
HashMap<>();
 
   public static PropsUi getInstance() {
-    if (instance == null) {
-      instance = new PropsUi();
-    }
-    return instance;
+    return (PropsUi) PROVIDER.getInstanceInternal();
   }
 
-  private PropsUi() {
+  public PropsUi() {
     super();
 
     // If the zoom factor is set with variable HOP_GUI_ZOOM_FACTOR we set this 
first.
@@ -1150,10 +1174,17 @@ public class PropsUi extends Props {
   }
 
   public boolean isDarkMode() {
+    if (darkModeOverride != null) {
+      return darkModeOverride;
+    }
     return YES.equalsIgnoreCase(getProperty(DARK_MODE, NO));
   }
 
   public void setDarkMode(boolean darkMode) {
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      darkModeOverride = darkMode;
+      return;
+    }
     setProperty(DARK_MODE, darkMode ? YES : NO);
   }
 
@@ -1366,14 +1397,14 @@ public class PropsUi extends Props {
    * @return value of nativeZoomFactor
    */
   public static double getNativeZoomFactor() {
-    return nativeZoomFactor;
+    return getInstance().nativeZoomFactor;
   }
 
   /**
    * @param nativeZoomFactor The nativeZoomFactor to set
    */
   public static void setNativeZoomFactor(double nativeZoomFactor) {
-    PropsUi.nativeZoomFactor = nativeZoomFactor;
+    getInstance().nativeZoomFactor = nativeZoomFactor;
   }
 
   private void populateContrastingColors() {
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/HopNamespace.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/HopNamespace.java
index 5936eb0cb3..a507eace48 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/HopNamespace.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/HopNamespace.java
@@ -17,8 +17,12 @@
 
 package org.apache.hop.ui.core.gui;
 
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 import org.apache.hop.core.exception.HopRuntimeException;
 import org.apache.hop.core.util.Utils;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Display;
 
 /**
  * This keeps track of the currently active namespace for all the current. It 
makes it easy to see
@@ -31,6 +35,12 @@ public class HopNamespace {
 
   private String namespace;
 
+  /**
+   * Hop Web has one Display per UISession. Keying by Display keeps 
project/namespace isolation
+   * without calling HopGui.getInstance() from this class (which would 
construct a GUI).
+   */
+  private static final Map<Display, String> NAMESPACE_BY_DISPLAY = new 
ConcurrentHashMap<>();
+
   private HopNamespace() {}
 
   public static final HopNamespace getInstance() {
@@ -46,11 +56,18 @@ public class HopNamespace {
    * @return value of namespace
    */
   public static final String getNamespace() {
+    Display display = Display.getCurrent();
+    if (display != null && !display.isDisposed()) {
+      String sessionNamespace = NAMESPACE_BY_DISPLAY.get(display);
+      if (!Utils.isEmpty(sessionNamespace)) {
+        return sessionNamespace;
+      }
+    }
     String namespace = getInstance().namespace;
     if (Utils.isEmpty(namespace)) {
       throw new HopRuntimeException("Please set a namespace before using one");
     }
-    return getInstance().namespace;
+    return namespace;
   }
 
   /**
@@ -58,5 +75,12 @@ public class HopNamespace {
    */
   public static final void setNamespace(String namespace) {
     getInstance().namespace = namespace;
+    Display display = Display.getCurrent();
+    if (display != null && !display.isDisposed()) {
+      if (!NAMESPACE_BY_DISPLAY.containsKey(display)) {
+        display.addListener(SWT.Dispose, e -> 
NAMESPACE_BY_DISPLAY.remove(display));
+      }
+      NAMESPACE_BY_DISPLAY.put(display, namespace);
+    }
   }
 }
diff --git a/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
index 862e896672..7aa2d4bc09 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
@@ -185,7 +185,7 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
   private Image folderImage;
   private Image fileImage;
 
-  @Getter private static HopVfsFileDialog instance;
+  private static HopVfsFileDialog instance;
 
   private java.util.List<String> navigationHistory;
   private int navigationIndex;
@@ -303,6 +303,7 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
           }
         });
     instance = this;
+    HopGui.getInstance().setOpenVfsFileDialog(this);
 
     FormLayout formLayout = new FormLayout();
     formLayout.marginWidth = PropsUi.getFormMargin();
@@ -1375,8 +1376,20 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
     }
   }
 
+  public static HopVfsFileDialog getInstance() {
+    HopGui hopGui = HopGui.peekInstance();
+    if (hopGui != null && hopGui.getOpenVfsFileDialog() != null) {
+      return hopGui.getOpenVfsFileDialog();
+    }
+    return instance;
+  }
+
   private void dispose() {
     instance = null;
+    HopGui hopGui = HopGui.peekInstance();
+    if (hopGui != null && hopGui.getOpenVfsFileDialog() == this) {
+      hopGui.setOpenVfsFileDialog(null);
+    }
     try {
       // Save the navigation history
       //
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/MetaSelectionLine.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/MetaSelectionLine.java
index 5010739331..8521ba7d9c 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/MetaSelectionLine.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/MetaSelectionLine.java
@@ -263,8 +263,14 @@ public class MetaSelectionLine<T extends IHopMetadata> 
extends Composite {
   }
 
   public static String getEditIcon(Object guiPluginObject) {
-    MetaSelectionLine<?> line = (MetaSelectionLine<?>) guiPluginObject;
-    return line.getManagedClass().getAnnotation(HopMetadata.class).image();
+    if (!(guiPluginObject instanceof MetaSelectionLine<?> line) || 
line.getManagedClass() == null) {
+      return "ui/images/metadata.svg";
+    }
+    HopMetadata annotation = 
line.getManagedClass().getAnnotation(HopMetadata.class);
+    if (annotation == null || Utils.isEmpty(annotation.image())) {
+      return "ui/images/metadata.svg";
+    }
+    return annotation.image();
   }
 
   @GuiToolbarElement(
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
index 95ff36842c..c9a49d70e9 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
@@ -331,7 +331,8 @@ public class StyledTextVar extends TextComposite {
       getToolbar().setEnabled(enabled);
     }
     // StyledText component does not get the "disabled" look, so it needs to 
be applied explicitly
-    if (Display.getDefault() != null) {
+    Display display = wText.getDisplay();
+    if (display != null && !display.isDisposed()) {
       wText.setBackground(
           enabled
               ? GuiResource.getInstance().getColorWhite()
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java
index 7698b814cd..d762498a40 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java
@@ -19,9 +19,12 @@ package org.apache.hop.ui.core.widget;
 
 import java.util.Hashtable;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 import org.apache.hop.ui.core.ConstUi;
+import org.eclipse.swt.SWT;
 import org.eclipse.swt.events.TreeEvent;
 import org.eclipse.swt.events.TreeListener;
+import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Tree;
 import org.eclipse.swt.widgets.TreeItem;
 
@@ -32,19 +35,28 @@ import org.eclipse.swt.widgets.TreeItem;
 public class TreeMemory {
   private static TreeMemory treeMemory;
 
+  private static final Map<Display, TreeMemory> BY_DISPLAY = new 
ConcurrentHashMap<>();
+
   private Map<TreeMemoryEntry, Boolean> map;
 
   public static final TreeMemory getInstance() {
+    Display display = Display.getCurrent();
+    if (display != null && !display.isDisposed()) {
+      return BY_DISPLAY.computeIfAbsent(
+          display,
+          d -> {
+            d.addListener(SWT.Dispose, e -> BY_DISPLAY.remove(d));
+            return new TreeMemory();
+          });
+    }
     if (treeMemory != null) {
       return treeMemory;
     }
-
     treeMemory = new TreeMemory();
-
     return treeMemory;
   }
 
-  private TreeMemory() {
+  TreeMemory() {
     map = new Hashtable<>(100);
   }
 
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index 4d37882f2e..3e76385473 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -29,9 +29,12 @@ import java.util.Arrays;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.Consumer;
+import java.util.function.Supplier;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.commons.io.output.TeeOutputStream;
@@ -350,6 +353,21 @@ public class HopGui
    */
   @Setter private HopSecurityContext securityContext;
 
+  /**
+   * Per-HopGui (per RAP UISession) plugin/UI singletons that cannot use RAP 
{@code SingletonUtil}
+   * because they live in plugin classloaders.
+   */
+  private final Map<Class<?>, Object> sessionSingletons = new 
ConcurrentHashMap<>();
+
+  /**
+   * The file dialog currently open in this session, so toolbar plugins can 
navigate it without a
+   * process-wide static.
+   */
+  @Getter @Setter private org.apache.hop.ui.core.vfs.HopVfsFileDialog 
openVfsFileDialog;
+
+  /** Active namespace for this GUI session (project id, or {@link 
#DEFAULT_HOP_GUI_NAMESPACE}). */
+  @Getter @Setter private String activeNamespace = DEFAULT_HOP_GUI_NAMESPACE;
+
   protected HopGui() {
     this(Display.getCurrent());
   }
@@ -382,6 +400,7 @@ public class HopGui
 
     updateMetadataManagers();
 
+    this.activeNamespace = DEFAULT_HOP_GUI_NAMESPACE;
     HopNamespace.setNamespace(DEFAULT_HOP_GUI_NAMESPACE);
     shell = new Shell(display, SWT.DIALOG_TRIM | SWT.RESIZE | SWT.MIN | 
SWT.MAX);
   }
@@ -405,6 +424,44 @@ public class HopGui
     return (HopGui) PROVIDER.getInstanceInternal();
   }
 
+  /**
+   * The HopGui of this process/session if it already exists. Does not 
construct a GUI. Use this
+   * from {@code getInstance()} helpers that must stay inert in unit tests.
+   */
+  public static HopGui peekInstance() {
+    try {
+      return (HopGui) PROVIDER.peekInstanceInternal();
+    } catch (Throwable e) {
+      return null;
+    }
+  }
+
+  /**
+   * Returns a singleton owned by this HopGui / RAP UISession. Desktop has one 
HopGui, so this is
+   * equivalent to a process-wide singleton there. Plugins cannot use RAP 
{@code SingletonUtil}
+   * through {@link ImplementationLoader} because they load from a different 
classloader.
+   */
+  @SuppressWarnings("unchecked")
+  public <T> T getSessionSingleton(Class<T> type, Supplier<T> factory) {
+    return (T) sessionSingletons.computeIfAbsent(type, key -> factory.get());
+  }
+
+  /**
+   * Looks up a perspective on the current session's HopGui. Returns null when 
the GUI or its
+   * perspective manager is not ready (tests, disabled perspectives).
+   */
+  public static <T extends IHopPerspective> T findSessionPerspective(Class<T> 
type) {
+    try {
+      HopGui hopGui = peekInstance();
+      if (hopGui == null || hopGui.getPerspectiveManager() == null) {
+        return null;
+      }
+      return hopGui.getPerspectiveManager().findPerspective(type);
+    } catch (Throwable e) {
+      return null;
+    }
+  }
+
   public void setWebThemeRedirectCallback(Consumer<Boolean> callback) {
     this.webThemeRedirectCallback = callback;
   }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
index f3e8bd8cae..2d8dd4b7f9 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
@@ -58,7 +58,25 @@ public class HopGuiKeyHandler extends KeyAdapter {
   /** Widget classes that pass their key listeners on to a widget inside them. 
*/
   private static final Map<Class<?>, Boolean> DELEGATING_KEY_LISTENERS = new 
ConcurrentHashMap<>();
 
-  private static HopGuiKeyHandler singleton;
+  private static HopGuiKeyHandler fallback;
+
+  private static final ISingletonProvider PROVIDER = loadProvider();
+
+  private static ISingletonProvider loadProvider() {
+    try {
+      return (ISingletonProvider) 
ImplementationLoader.newInstance(HopGuiKeyHandler.class);
+    } catch (Throwable e) {
+      // hop-ui unit tests have no rcp/rap *Impl on the classpath.
+      return () -> {
+        synchronized (HopGuiKeyHandler.class) {
+          if (fallback == null) {
+            fallback = new HopGuiKeyHandler();
+          }
+          return fallback;
+        }
+      };
+    }
+  }
 
   public Set<Object> parentObjects;
 
@@ -69,20 +87,21 @@ public class HopGuiKeyHandler extends KeyAdapter {
   private final Set<Shell> handledShells = new HashSet<>();
 
   /**
-   * Displays with a focus filter. This handler is a singleton for the whole 
process while Hop Web
-   * has a display per session, so the filter is installed once per display.
+   * Displays with a focus filter. Hop Web has one handler (and one display) 
per RAP UISession; the
+   * desktop handler covers the single process display.
    */
   private final Set<Display> filteredDisplays = new HashSet<>();
 
-  private HopGuiKeyHandler() {
+  /**
+   * Public no-arg constructor so RAP {@code SingletonUtil.getSessionInstance} 
can create a handler
+   * per UISession. Call {@link #getInstance()} rather than constructing this 
yourself.
+   */
+  public HopGuiKeyHandler() {
     this.parentObjects = new HashSet<>();
   }
 
   public static HopGuiKeyHandler getInstance() {
-    if (singleton == null) {
-      singleton = new HopGuiKeyHandler();
-    }
-    return singleton;
+    return (HopGuiKeyHandler) PROVIDER.getInstanceInternal();
   }
 
   public void addParentObjectToHandle(Object parentObject) {
@@ -214,7 +233,7 @@ public class HopGuiKeyHandler extends KeyAdapter {
       return;
     }
 
-    List<Object> orderedParents = getParentObjectsInContextOrder(event.widget);
+    List<Object> orderedParents = getParentObjectsInContextOrder(event);
     for (Object parentObject : orderedParents) {
       List<KeyboardShortcut> shortcuts =
           
GuiRegistry.getInstance().getKeyboardShortcuts(parentObject.getClass().getName());
@@ -230,11 +249,15 @@ public class HopGuiKeyHandler extends KeyAdapter {
   }
 
   /** Order: parents whose window has focus (closest first), then active 
perspectives, then rest. */
-  private List<Object> getParentObjectsInContextOrder(Object focusedWidget) {
+  private List<Object> getParentObjectsInContextOrder(KeyEvent event) {
+    Object focusedWidget = event.widget;
     List<Object> inFocus = new ArrayList<>();
     List<Object> fallback = new ArrayList<>();
     for (Object parent : parentObjects) {
       Control control = parent instanceof Control c ? c : 
parentToControl.get(parent);
+      if (control != null && !belongsToEventDisplay(control, event)) {
+        continue;
+      }
       if (control != null && isWidgetInControlHierarchy(focusedWidget, 
control)) {
         inFocus.add(parent);
       } else {
@@ -296,6 +319,9 @@ public class HopGuiKeyHandler extends KeyAdapter {
       Object parentObject, KeyEvent event, KeyboardShortcut shortcut) {
     if (parentObject instanceof Control control) {
       try {
+        if (!belongsToEventDisplay(control, event)) {
+          return false;
+        }
         if (!control.isVisible()) {
           return shortcut.isGlobal();
         }
@@ -379,8 +405,24 @@ public class HopGuiKeyHandler extends KeyAdapter {
     return false;
   }
 
+  /**
+   * RAP forbids touching a widget whose Display belongs to another UISession. 
Skip those parents
+   * instead of walking their widget tree.
+   */
+  private boolean belongsToEventDisplay(Control control, KeyEvent event) {
+    if (control == null || control.isDisposed() || event == null) {
+      return false;
+    }
+    try {
+      Display controlDisplay = control.getDisplay();
+      return event.display != null && event.display.equals(controlDisplay);
+    } catch (SWTException e) {
+      return false;
+    }
+  }
+
   private boolean isWidgetInControlHierarchy(Object widget, Control control) {
-    if (!(widget instanceof Control)) {
+    if (!(widget instanceof Control) || control == null || 
control.isDisposed()) {
       return false;
     }
 
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/ISingletonProvider.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/ISingletonProvider.java
index 6d9c9d8c84..66e5a084f5 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/ISingletonProvider.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/ISingletonProvider.java
@@ -19,4 +19,12 @@ package org.apache.hop.ui.hopgui;
 
 public interface ISingletonProvider {
   Object getInstanceInternal();
+
+  /**
+   * Existing instance, or {@code null} if one has not been created. Must not 
construct a GUI.
+   * Default is {@code null} so providers that only create on demand stay 
inert during tests.
+   */
+  default Object peekInstanceInternal() {
+    return null;
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index a8a00c0030..1f0e5e5bf2 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -5690,7 +5690,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
           // Also make sure to clear the log entries in the central log store 
& registry
           //
           if (pipeline != null) {
-            DrillDownGuiPlugin.cleanupOnRunStart();
+            DrillDownGuiPlugin.cleanupOnRunStart(hopGui.getId());
             HopLogStore.discardLines(pipeline.getLogChannelId(), true);
           }
 
@@ -5714,6 +5714,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
                   variables.resolve(pipelineRunConfigurationName),
                   hopGui.getMetadataProvider(),
                   pipelineMeta);
+          DrillDownGuiPlugin.bindToHopGui(pipeline, hopGui.getId());
 
           // Set the variables from the execution configuration
           // These are values set by the user in the execution dialog
@@ -5895,7 +5896,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
         // Do we have a previous execution to clean up in the logging registry?
         //
         if (pipeline != null) {
-          DrillDownGuiPlugin.cleanupOnRunStart();
+          DrillDownGuiPlugin.cleanupOnRunStart(hopGui.getId());
           HopLogStore.discardLines(pipeline.getLogChannelId(), false);
           
LoggingRegistry.getInstance().removeIncludingChildren(pipeline.getLogChannelId());
         }
@@ -5904,6 +5905,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
         //
         pipelineMeta.clearCaches();
         pipeline = new LocalPipelineEngine(pipelineMeta, variables, 
hopGui.getLoggingObject());
+        DrillDownGuiPlugin.bindToHopGui(pipeline, hopGui.getId());
         pipeline.setPreview(true);
         pipeline.setVariable(IPipelineEngine.PIPELINE_IN_PREVIEW_MODE, "Y");
         pipeline.setMetadataProvider(hopGui.getMetadataProvider());
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/DrillDownGuiPlugin.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/DrillDownGuiPlugin.java
index 35b4abb877..b9496f42f3 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/DrillDownGuiPlugin.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/DrillDownGuiPlugin.java
@@ -22,6 +22,7 @@ import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
+import org.apache.hop.core.IExtensionData;
 import org.apache.hop.core.action.GuiContextAction;
 import org.apache.hop.core.action.GuiContextActionFilter;
 import org.apache.hop.core.exception.HopException;
@@ -59,68 +60,162 @@ public class DrillDownGuiPlugin {
 
   private static final Class<?> PKG = DrillDownGuiPlugin.class;
 
-  // Global registries to track running instances
-  private static final Map<String, IPipelineEngine<PipelineMeta>> 
runningPipelines =
-      new ConcurrentHashMap<>();
-  private static final Map<String, IWorkflowEngine<WorkflowMeta>> 
runningWorkflows =
-      new ConcurrentHashMap<>();
+  /**
+   * Extension-data key stamped onto engines started from the GUI so 
registration on engine threads
+   * (no RAP UISession) still belongs to one Hop Web session.
+   */
+  public static final String HOP_GUI_ID = "HOP_GUI_ID";
+
+  // Per HopGui-id registries so one user's run does not clear or expose 
another's engines.
+  private static final Map<String, Map<String, IPipelineEngine<PipelineMeta>>>
+      runningPipelinesByHopGui = new ConcurrentHashMap<>();
+  private static final Map<String, Map<String, IWorkflowEngine<WorkflowMeta>>>
+      runningWorkflowsByHopGui = new ConcurrentHashMap<>();
   public static final String DRILL_DOWN_ERROR_OPENING_EXECUTION =
       "DrillDown.Error.OpeningExecution";
   public static final String DRILL_DOWN_ERROR_TITLE = "DrillDown.Error.Title";
 
+  public static void bindToHopGui(IExtensionData engine, String hopGuiId) {
+    if (engine == null || hopGuiId == null || engine.getExtensionDataMap() == 
null) {
+      return;
+    }
+    engine.getExtensionDataMap().put(HOP_GUI_ID, hopGuiId);
+  }
+
+  public static String hopGuiIdOf(Object engine) {
+    Object current = engine;
+    while (current != null) {
+      if (current instanceof IExtensionData ext && ext.getExtensionDataMap() 
!= null) {
+        Object id = ext.getExtensionDataMap().get(HOP_GUI_ID);
+        if (id instanceof String hopGuiId && !hopGuiId.isEmpty()) {
+          return hopGuiId;
+        }
+      }
+      if (current instanceof ILoggingObject logging) {
+        current = logging.getParent();
+      } else {
+        break;
+      }
+    }
+    return null;
+  }
+
   public static void registerRunningPipeline(
       String logChannelId, IPipelineEngine<PipelineMeta> pipeline) {
-    runningPipelines.put(logChannelId, pipeline);
+    String hopGuiId = ensureHopGuiId(pipeline);
+    if (hopGuiId == null || logChannelId == null || pipeline == null) {
+      return;
+    }
+    runningPipelinesByHopGui
+        .computeIfAbsent(hopGuiId, k -> new ConcurrentHashMap<>())
+        .put(logChannelId, pipeline);
   }
 
   public static void registerRunningWorkflow(
       String logChannelId, IWorkflowEngine<WorkflowMeta> workflow) {
-    runningWorkflows.put(logChannelId, workflow);
+    String hopGuiId = ensureHopGuiId(workflow);
+    if (hopGuiId == null || logChannelId == null || workflow == null) {
+      return;
+    }
+    runningWorkflowsByHopGui
+        .computeIfAbsent(hopGuiId, k -> new ConcurrentHashMap<>())
+        .put(logChannelId, workflow);
+  }
+
+  private static String ensureHopGuiId(IExtensionData engine) {
+    String hopGuiId = hopGuiIdOf(engine);
+    if (hopGuiId != null) {
+      return hopGuiId;
+    }
+    return null;
   }
 
   /**
-   * Clears all drill-down and sample-row state. Call when starting a new run 
to get a clean slate
-   * and avoid leaking memory.
+   * Clears drill-down and sample-row state for this HopGui session. Call when 
starting a new run.
    */
   public static void cleanupOnRunStart() {
-    runningPipelines.clear();
-    runningWorkflows.clear();
-    dataSnifferBuffersByLogChannelId.clear();
-    dataSnifferHopBuffersByLogChannelId.clear();
+    HopGui hopGui = HopGui.peekInstance();
+    if (hopGui != null) {
+      cleanupOnRunStart(hopGui.getId());
+    }
+  }
+
+  public static void cleanupOnRunStart(String hopGuiId) {
+    if (hopGuiId == null) {
+      return;
+    }
+    Map<String, IPipelineEngine<PipelineMeta>> pipelines = 
runningPipelinesByHopGui.get(hopGuiId);
+    if (pipelines != null) {
+      pipelines.clear();
+    }
+    Map<String, IWorkflowEngine<WorkflowMeta>> workflows = 
runningWorkflowsByHopGui.get(hopGuiId);
+    if (workflows != null) {
+      workflows.clear();
+    }
+    Map<String, Map<String, RowBuffer>> sniffers = 
dataSnifferBuffersByHopGui.get(hopGuiId);
+    if (sniffers != null) {
+      sniffers.clear();
+    }
+    Map<String, Map<String, RowBuffer>> hopSniffers = 
dataSnifferHopBuffersByHopGui.get(hopGuiId);
+    if (hopSniffers != null) {
+      hopSniffers.clear();
+    }
+  }
+
+  /** Visible for tests. */
+  static IPipelineEngine<PipelineMeta> runningPipeline(String hopGuiId, String 
logChannelId) {
+    Map<String, IPipelineEngine<PipelineMeta>> pipelines = 
runningPipelinesByHopGui.get(hopGuiId);
+    return pipelines == null ? null : pipelines.get(logChannelId);
+  }
+
+  /** Drops every drill-down map for a UI session that is ending. */
+  public static void cleanupSession(String hopGuiId) {
+    if (hopGuiId == null) {
+      return;
+    }
+    runningPipelinesByHopGui.remove(hopGuiId);
+    runningWorkflowsByHopGui.remove(hopGuiId);
+    dataSnifferBuffersByHopGui.remove(hopGuiId);
+    dataSnifferHopBuffersByHopGui.remove(hopGuiId);
   }
 
   /**
-   * Per-run data sniffer buffers (logChannelId -> transform name -> 
RowBuffer). Filled when
-   * pipelines start so we have row data even if the pipeline tab is never 
opened. Only the latest
-   * run's data is kept per execution (each run has its own logChannelId).
+   * Per-session, per-run data sniffer buffers (hopGuiId -> logChannelId -> 
transform name ->
+   * RowBuffer).
    */
-  private static final Map<String, Map<String, RowBuffer>> 
dataSnifferBuffersByLogChannelId =
+  private static final Map<String, Map<String, Map<String, RowBuffer>>> 
dataSnifferBuffersByHopGui =
       new ConcurrentHashMap<>();
 
   /**
-   * Per-run hop-level sniffer buffers (logChannelId -> hop key -> RowBuffer) 
for target hops
-   * ({@code putRowTo}).
+   * Per-session hop-level sniffer buffers (hopGuiId -> logChannelId -> hop 
key -> RowBuffer) for
+   * target hops ({@code putRowTo}).
    */
-  private static final Map<String, Map<String, RowBuffer>> 
dataSnifferHopBuffersByLogChannelId =
-      new ConcurrentHashMap<>();
+  private static final Map<String, Map<String, Map<String, RowBuffer>>>
+      dataSnifferHopBuffersByHopGui = new ConcurrentHashMap<>();
 
   /**
    * Attach row listeners to a pipeline at start so we capture output rows for 
debugging. Uses the
    * pipeline's run configuration (sample type and size in GUI) when 
available; otherwise no
-   * sniffers are attached. Data is stored in {@link 
#dataSnifferBuffersByLogChannelId} so it is
-   * available when the user later opens the pipeline tab. Only applies to 
local pipeline engine.
+   * sniffers are attached. Data is stored in the per-session sniffer buffers 
so it is available
+   * when the user later opens the pipeline tab. Only applies to local 
pipeline engine.
    */
   public static void 
attachDataSniffersToPipeline(IPipelineEngine<PipelineMeta> pipeline) {
     if (!(pipeline instanceof LocalPipelineEngine)) {
       return;
     }
+    String hopGuiId = ensureHopGuiId(pipeline);
     String logChannelId = pipeline.getLogChannelId();
+    if (hopGuiId == null || logChannelId == null) {
+      return;
+    }
     Map<String, RowBuffer> buffers =
-        dataSnifferBuffersByLogChannelId.computeIfAbsent(
-            logChannelId, k -> new ConcurrentHashMap<>());
+        dataSnifferBuffersByHopGui
+            .computeIfAbsent(hopGuiId, k -> new ConcurrentHashMap<>())
+            .computeIfAbsent(logChannelId, k -> new ConcurrentHashMap<>());
     Map<String, RowBuffer> hopBuffers =
-        dataSnifferHopBuffersByLogChannelId.computeIfAbsent(
-            logChannelId, k -> new ConcurrentHashMap<>());
+        dataSnifferHopBuffersByHopGui
+            .computeIfAbsent(hopGuiId, k -> new ConcurrentHashMap<>())
+            .computeIfAbsent(logChannelId, k -> new ConcurrentHashMap<>());
     PipelineRowSamplerHelper.addRowSamplersToPipeline(pipeline, buffers, 
hopBuffers);
   }
 
@@ -129,7 +224,7 @@ public class DrillDownGuiPlugin {
    * attaching to a running/finished pipeline so the UI can show the rows that 
flowed through.
    */
   public static Map<String, RowBuffer> getDataSnifferBuffersForPipeline(String 
logChannelId) {
-    return dataSnifferBuffersByLogChannelId.get(logChannelId);
+    return findSnifferBuffers(dataSnifferBuffersByHopGui, logChannelId);
   }
 
   /**
@@ -139,7 +234,39 @@ public class DrillDownGuiPlugin {
    * @return hop key → RowBuffer, or null
    */
   public static Map<String, RowBuffer> 
getDataSnifferHopBuffersForPipeline(String logChannelId) {
-    return dataSnifferHopBuffersByLogChannelId.get(logChannelId);
+    return findSnifferBuffers(dataSnifferHopBuffersByHopGui, logChannelId);
+  }
+
+  private static Map<String, RowBuffer> findSnifferBuffers(
+      Map<String, Map<String, Map<String, RowBuffer>>> byHopGui, String 
logChannelId) {
+    if (logChannelId == null) {
+      return null;
+    }
+    for (Map<String, Map<String, RowBuffer>> sessionBuffers : 
byHopGui.values()) {
+      Map<String, RowBuffer> buffers = sessionBuffers.get(logChannelId);
+      if (buffers != null) {
+        return buffers;
+      }
+    }
+    return null;
+  }
+
+  private static Map<String, IPipelineEngine<PipelineMeta>> 
pipelinesFor(Object parentEngine) {
+    String hopGuiId = hopGuiIdOf(parentEngine);
+    if (hopGuiId == null) {
+      return Map.of();
+    }
+    Map<String, IPipelineEngine<PipelineMeta>> pipelines = 
runningPipelinesByHopGui.get(hopGuiId);
+    return pipelines != null ? pipelines : Map.of();
+  }
+
+  private static Map<String, IWorkflowEngine<WorkflowMeta>> 
workflowsFor(Object parentEngine) {
+    String hopGuiId = hopGuiIdOf(parentEngine);
+    if (hopGuiId == null) {
+      return Map.of();
+    }
+    Map<String, IWorkflowEngine<WorkflowMeta>> workflows = 
runningWorkflowsByHopGui.get(hopGuiId);
+    return workflows != null ? workflows : Map.of();
   }
 
   // ==================== TRANSFORM CONTEXT ====================
@@ -253,22 +380,22 @@ public class DrillDownGuiPlugin {
                     if (child.pipeline != null) {
                       PipelineMeta meta = child.pipeline.getPipelineMeta();
                       if (meta != null) {
-                        display.asyncExec(
-                            () -> openTabAndAttachPipeline(hopGui, meta, 
child.pipeline));
+                        runOnDisplay(
+                            display, () -> openTabAndAttachPipeline(hopGui, 
meta, child.pipeline));
                         return;
                       }
                     } else {
                       WorkflowMeta meta = child.workflow.getWorkflowMeta();
                       if (meta != null) {
-                        display.asyncExec(
-                            () -> openTabAndAttachWorkflow(hopGui, meta, 
child.workflow));
+                        runOnDisplay(
+                            display, () -> openTabAndAttachWorkflow(hopGui, 
meta, child.workflow));
                         return;
                       }
                     }
                   }
                   Thread.sleep(100);
                 }
-                display.asyncExec(() -> showNoRunningExecution(hopGui));
+                runOnDisplay(display, () -> showNoRunningExecution(hopGui));
               } catch (InterruptedException e) {
                 Thread.currentThread().interrupt();
               } catch (Exception e) {
@@ -359,6 +486,19 @@ public class DrillDownGuiPlugin {
    * Single scan over parent's children: looks up each child in both pipeline 
and workflow maps,
    * returns the one matching run (pipeline preferred over workflow, most 
recent by start time).
    */
+  private static void runOnDisplay(Display display, Runnable runnable) {
+    if (display == null || display.isDisposed()) {
+      return;
+    }
+    display.asyncExec(
+        () -> {
+          if (display.isDisposed()) {
+            return;
+          }
+          runnable.run();
+        });
+  }
+
   private RunningChild findRunningChild(Object parentEngine, String name) {
     String parentLogChannelId;
     if (parentEngine instanceof IPipelineEngine) {
@@ -372,6 +512,8 @@ public class DrillDownGuiPlugin {
     LoggingRegistry registry = LoggingRegistry.getInstance();
     List<String> childLogChannelIds = 
registry.getLogChannelChildren(parentLogChannelId);
 
+    Map<String, IPipelineEngine<PipelineMeta>> runningPipelines = 
pipelinesFor(parentEngine);
+    Map<String, IWorkflowEngine<WorkflowMeta>> runningWorkflows = 
workflowsFor(parentEngine);
     List<IPipelineEngine<PipelineMeta>> pipelines = new ArrayList<>();
     List<IWorkflowEngine<WorkflowMeta>> workflows = new ArrayList<>();
     for (String logChannelId : childLogChannelIds) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index d276a92fb9..47441b3e0e 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -5196,7 +5196,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
           // store & registry
           //
           if (workflow != null) {
-            DrillDownGuiPlugin.cleanupOnRunStart();
+            DrillDownGuiPlugin.cleanupOnRunStart(hopGui.getId());
             HopLogStore.discardLines(workflow.getLogChannelId(), true);
           }
 
@@ -5258,6 +5258,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
           // Pass specific extension points...
           //
           
workflow.getExtensionDataMap().putAll(executionConfiguration.getExtensionOptions());
+          DrillDownGuiPlugin.bindToHopGui(workflow, hopGui.getId());
 
           // Add action listeners
           //
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
index 4f19a70ac5..4d2f9286db 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
@@ -23,7 +23,6 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import lombok.Getter;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.gui.plugin.GuiPlugin;
 import org.apache.hop.core.gui.plugin.GuiRegistry;
@@ -111,12 +110,25 @@ public class ConfigurationPerspective implements 
IHopPerspective {
   private SearchMatcher searchMatcher = new SearchMatcher("", false, false, 
true);
   private Color highlightColor; // Custom neutral highlight color
   private Text searchBox;
-  @Getter private static ConfigurationPerspective instance;
+  private static ConfigurationPerspective instance;
 
   public ConfigurationPerspective() {
     instance = this;
   }
 
+  public static ConfigurationPerspective getInstance() {
+    try {
+      ConfigurationPerspective fromGui =
+          HopGui.findSessionPerspective(ConfigurationPerspective.class);
+      if (fromGui != null) {
+        return fromGui;
+      }
+    } catch (Throwable e) {
+      // No HopGuiImpl in unit tests
+    }
+    return instance;
+  }
+
   @Override
   public List<IGuiContextHandler> getContextHandlers() {
     return new ArrayList<>();
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGeneralOptionsTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGeneralOptionsTab.java
index 0beedb8d99..6400eb0b01 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGeneralOptionsTab.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGeneralOptionsTab.java
@@ -520,7 +520,7 @@ public class ConfigGeneralOptionsTab {
     expandBar.addListener(
         SWT.Expand,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wGeneralComp.isDisposed() && 
!sGeneralComp.isDisposed()) {
@@ -532,7 +532,7 @@ public class ConfigGeneralOptionsTab {
     expandBar.addListener(
         SWT.Collapse,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wGeneralComp.isDisposed() && 
!sGeneralComp.isDisposed()) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
index 4ab32ada19..a69c6f5483 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
@@ -513,7 +513,7 @@ public class ConfigGuiOptionsTab {
     appearanceExpandBar.addListener(
         SWT.Expand,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -524,7 +524,7 @@ public class ConfigGuiOptionsTab {
     appearanceExpandBar.addListener(
         SWT.Collapse,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -596,7 +596,7 @@ public class ConfigGuiOptionsTab {
     fontsExpandBar.addListener(
         SWT.Expand,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -607,7 +607,7 @@ public class ConfigGuiOptionsTab {
     fontsExpandBar.addListener(
         SWT.Collapse,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -750,7 +750,7 @@ public class ConfigGuiOptionsTab {
     canvasExpandBar.addListener(
         SWT.Expand,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -761,7 +761,7 @@ public class ConfigGuiOptionsTab {
     canvasExpandBar.addListener(
         SWT.Collapse,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -876,7 +876,7 @@ public class ConfigGuiOptionsTab {
     autoLayoutExpandBar.addListener(
         SWT.Expand,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -887,7 +887,7 @@ public class ConfigGuiOptionsTab {
     autoLayoutExpandBar.addListener(
         SWT.Collapse,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -974,7 +974,7 @@ public class ConfigGuiOptionsTab {
     tablesExpandBar.addListener(
         SWT.Expand,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -985,7 +985,7 @@ public class ConfigGuiOptionsTab {
     tablesExpandBar.addListener(
         SWT.Collapse,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -1128,7 +1128,7 @@ public class ConfigGuiOptionsTab {
     metricsPanelExpandBar.addListener(
         SWT.Expand,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
@@ -1139,7 +1139,7 @@ public class ConfigGuiOptionsTab {
     metricsPanelExpandBar.addListener(
         SWT.Collapse,
         e ->
-            Display.getDefault()
+            Display.getCurrent()
                 .asyncExec(
                     () -> {
                       if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigKeyboardShortcutsTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigKeyboardShortcutsTab.java
index 2ea93f1457..aca4bf5788 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigKeyboardShortcutsTab.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigKeyboardShortcutsTab.java
@@ -182,7 +182,7 @@ public class ConfigKeyboardShortcutsTab {
       expandBar.addListener(
           SWT.Expand,
           e ->
-              Display.getDefault()
+              Display.getCurrent()
                   .asyncExec(
                       () -> {
                         if (!contentComposite.isDisposed() && 
!scrolledComposite.isDisposed()) {
@@ -194,7 +194,7 @@ public class ConfigKeyboardShortcutsTab {
       expandBar.addListener(
           SWT.Collapse,
           e ->
-              Display.getDefault()
+              Display.getCurrent()
                   .asyncExec(
                       () -> {
                         if (!contentComposite.isDisposed() && 
!scrolledComposite.isDisposed()) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigPluginOptionsTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigPluginOptionsTab.java
index 93991041b4..f6f3018886 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigPluginOptionsTab.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigPluginOptionsTab.java
@@ -54,7 +54,6 @@ public class ConfigPluginOptionsTab {
 
   public static final String GUI_WIDGETS_PARENT_ID = 
"EnterOptionsDialog-GuiWidgetsParent";
 
-  private static Map<String, Object> pluginDataMap = new HashMap<>();
   private Composite wPluginComposite;
 
   public ConfigPluginOptionsTab() {
@@ -96,7 +95,7 @@ public class ConfigPluginOptionsTab {
 
     // Load all configuration plugins and store their data
     //
-    pluginDataMap.clear();
+    pluginDataMap().clear();
     PluginRegistry pluginRegistry = PluginRegistry.getInstance();
     List<String> disabledIds = GuiRegistry.getDisabledGuiElements();
     java.util.List<IPlugin> configPlugins = 
pluginRegistry.getPlugins(ConfigPluginType.class);
@@ -120,7 +119,7 @@ public class ConfigPluginOptionsTab {
               Const.NVL(
                   TranslateUtil.translate(annotation.description(), 
emptySourceData.getClass()),
                   "");
-          pluginDataMap.put(name, sourceData);
+          pluginDataMap().put(name, sourceData);
         }
       } catch (Exception e) {
         new ErrorDialog(
@@ -150,7 +149,7 @@ public class ConfigPluginOptionsTab {
    * ConfigurationPerspective.
    */
   public static void showConfigPluginSettings(String pluginName, Composite 
targetComposite) {
-    Object pluginSourceData = pluginDataMap.get(pluginName);
+    Object pluginSourceData = pluginDataMap().get(pluginName);
     if (pluginSourceData == null) {
       return;
     }
@@ -204,7 +203,27 @@ public class ConfigPluginOptionsTab {
 
   /** Get all available plugin names for tree population */
   public static java.util.Set<String> getPluginNames() {
-    return pluginDataMap.keySet();
+    return pluginDataMap().keySet();
+  }
+
+  private static PluginOptionsData fallbackData;
+
+  private static Map<String, Object> pluginDataMap() {
+    HopGui hopGui = HopGui.peekInstance();
+    if (hopGui != null) {
+      return hopGui.getSessionSingleton(PluginOptionsData.class, 
PluginOptionsData::new).map;
+    }
+    synchronized (ConfigPluginOptionsTab.class) {
+      if (fallbackData == null) {
+        fallbackData = new PluginOptionsData();
+      }
+      return fallbackData.map;
+    }
+  }
+
+  /** Per-session store of config-plugin GUI objects. */
+  static final class PluginOptionsData {
+    final Map<String, Object> map = new HashMap<>();
   }
 
   /** Show instruction message when no plugin is selected */
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java
index 2be21509b4..0feb1b3a69 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java
@@ -181,7 +181,7 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
   private static final String TAB_KEY_DELIMITER = "\t";
   public static final String SNAP_ID_EIL_REFRESH = "EILRefresh";
 
-  @Getter private static ExecutionPerspective instance;
+  private static ExecutionPerspective instance;
 
   private boolean onlyShowingParents = true;
   private boolean onlyShowingFailed;
@@ -227,6 +227,18 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
     instance = this;
   }
 
+  public static ExecutionPerspective getInstance() {
+    try {
+      ExecutionPerspective fromGui = 
HopGui.findSessionPerspective(ExecutionPerspective.class);
+      if (fromGui != null) {
+        return fromGui;
+      }
+    } catch (Throwable e) {
+      // No HopGuiImpl in unit tests
+    }
+    return instance;
+  }
+
   /**
    * When this perspective is disabled (an exclusion in 
disabledGuiElements.xml) HopGui skips it
    * while loading the perspectives, so {@link #initialize(HopGui, Composite)} 
never runs. The
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 ae1696b0df..deafce40fb 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
@@ -352,13 +352,25 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   }
 
   public static ExplorerPerspective getInstance() {
-    // There can be only one
+    ExplorerPerspective fromGui = sessionPerspectiveOrNull();
+    if (fromGui != null) {
+      return fromGui;
+    }
+    // Fallback for tests and the disabled-perspective case (constructed, 
never initialized).
     if (instance == null) {
       new ExplorerPerspective();
     }
     return instance;
   }
 
+  private static ExplorerPerspective sessionPerspectiveOrNull() {
+    try {
+      return HopGui.findSessionPerspective(ExplorerPerspective.class);
+    } catch (Throwable e) {
+      return null;
+    }
+  }
+
   @Override
   public String getId() {
     return "explorer-perspective";
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
index fcb3580bf0..bcdc58f1c7 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
@@ -28,7 +28,6 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import lombok.Getter;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.Props;
@@ -230,7 +229,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
 
   private static final int FILTER_DEBOUNCE_MS = 250;
 
-  @Getter private static MetadataPerspective instance;
+  private static MetadataPerspective instance;
 
   private HopGui hopGui;
   private SashForm sash;
@@ -277,6 +276,18 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     this.metadataFileType = new MetadataFileType();
   }
 
+  public static MetadataPerspective getInstance() {
+    try {
+      MetadataPerspective fromGui = 
HopGui.findSessionPerspective(MetadataPerspective.class);
+      if (fromGui != null) {
+        return fromGui;
+      }
+    } catch (Throwable e) {
+      // No HopGuiImpl in unit tests
+    }
+    return instance;
+  }
+
   /**
    * When this perspective is disabled (an exclusion in 
disabledGuiElements.xml) HopGui skips it
    * while loading the perspectives, so initialize() never runs. The singleton 
still exists because
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SashFormMemory.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SashFormMemory.java
index 4c4b60fa3a..502f6ad4aa 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SashFormMemory.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SashFormMemory.java
@@ -18,9 +18,11 @@
 package org.apache.hop.ui.hopgui.shared;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.history.AuditList;
 import org.apache.hop.history.AuditManager;
@@ -52,8 +54,12 @@ public final class SashFormMemory {
    */
   private static final int[] DEFAULT_WEIGHTS = {20, 80};
 
-  /** Every tracked sash form, keyed by its audit key, so {@link #resetAll()} 
can reach them all. */
-  private static final Map<String, Tracked> TRACKED = new LinkedHashMap<>();
+  /**
+   * Tracked sash forms of one Display, keyed by audit key. Hop Web has a 
Display per UISession; a
+   * process-wide map would let {@link #resetAll()} touch another session's 
widgets.
+   */
+  private static final Map<Display, Map<String, Tracked>> TRACKED_BY_DISPLAY =
+      new ConcurrentHashMap<>();
 
   private record Tracked(SashForm sashForm, int[] defaultWeights) {}
 
@@ -76,7 +82,7 @@ public final class SashFormMemory {
     }
     int[] defaults = defaultsOrFallback(defaultWeights);
     restore(sashForm, key, defaults);
-    TRACKED.put(key, new Tracked(sashForm, defaults));
+    trackedFor(sashForm.getDisplay()).put(key, new Tracked(sashForm, 
defaults));
 
     Display display = sashForm.getDisplay();
     Runnable saver = () -> save(sashForm, key);
@@ -149,10 +155,18 @@ public final class SashFormMemory {
    * startup).
    */
   public static void resetAll() {
-    for (Map.Entry<String, Tracked> entry : TRACKED.entrySet()) {
-      Tracked tracked = entry.getValue();
-      SashForm sashForm = tracked.sashForm();
-      int[] defaults = tracked.defaultWeights();
+    Display display = Display.getCurrent();
+    if (display == null || display.isDisposed()) {
+      return;
+    }
+    Map<String, Tracked> tracked = TRACKED_BY_DISPLAY.get(display);
+    if (tracked == null) {
+      return;
+    }
+    for (Map.Entry<String, Tracked> entry : tracked.entrySet()) {
+      Tracked item = entry.getValue();
+      SashForm sashForm = item.sashForm();
+      int[] defaults = item.defaultWeights();
       if (sashForm != null && !sashForm.isDisposed() && defaults != null && 
defaults.length > 0) {
         sashForm.setWeights(defaults);
       }
@@ -160,6 +174,21 @@ public final class SashFormMemory {
     }
   }
 
+  private static Map<String, Tracked> trackedFor(Display display) {
+    return TRACKED_BY_DISPLAY.computeIfAbsent(
+        display,
+        d -> {
+          d.addListener(SWT.Dispose, e -> TRACKED_BY_DISPLAY.remove(d));
+          return Collections.synchronizedMap(new LinkedHashMap<>());
+        });
+  }
+
+  /** Visible for tests: how many sash forms this Display currently tracks. */
+  static int trackedCount(Display display) {
+    Map<String, Tracked> tracked = TRACKED_BY_DISPLAY.get(display);
+    return tracked == null ? 0 : tracked.size();
+  }
+
   /** The caller's defaults, or the shared {@link #DEFAULT_WEIGHTS} when none 
were supplied. */
   private static int[] defaultsOrFallback(int[] defaultWeights) {
     return (defaultWeights != null && defaultWeights.length > 0) ? 
defaultWeights : DEFAULT_WEIGHTS;
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SwtGc.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SwtGc.java
index 253fb3e598..4ec4264b9a 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SwtGc.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SwtGc.java
@@ -55,12 +55,14 @@ public class SwtGc implements IGc {
    * its SWT {@link Image} bitmaps) that is never disposed — a severe handle 
leak on large graphs
    * (e.g. Data Vault models with many table icons redrawn while dragging).
    *
-   * <p>Keyed by SVG filename and dark-mode flag so theme changes get a 
correct rendering.
+   * <p>Keyed by Device (one Display per Hop Web session), then SVG filename 
and dark-mode flag. A
+   * process-wide cache would share (and dispose) session-unique SWT images.
    */
-  private static final Map<String, SwtUniversalImage> SVG_IMAGE_CACHE = new 
ConcurrentHashMap<>();
+  private static final Map<Device, Map<String, SwtUniversalImage>> 
SVG_IMAGE_CACHE_BY_DEVICE =
+      new ConcurrentHashMap<>();
 
-  private static final Object SVG_CACHE_HOOK_LOCK = new Object();
-  private static volatile boolean svgCacheDisposeHookRegistered;
+  private static final java.util.Set<Device> SVG_CACHE_DISPOSE_HOOKS =
+      ConcurrentHashMap.newKeySet();
 
   protected Color background;
 
@@ -565,34 +567,41 @@ public class SwtGc implements IGc {
     SvgCacheEntry cacheEntry = SvgCache.loadSvg(svgFile);
     boolean darkMode = PropsUi.getInstance().isDarkMode();
     String cacheKey = svgFile.getFilename() + (darkMode ? "|dark" : "|light");
-    ensureSvgImageCacheDisposeHook(gc.getDevice());
-    return SVG_IMAGE_CACHE.computeIfAbsent(
-        cacheKey,
-        key -> new SwtUniversalImageSvg(new 
SvgImage(cacheEntry.getSvgDocument()), false));
+    Device device = gc.getDevice();
+    ensureSvgImageCacheDisposeHook(device);
+    return SVG_IMAGE_CACHE_BY_DEVICE
+        .computeIfAbsent(device, d -> new ConcurrentHashMap<>())
+        .computeIfAbsent(
+            cacheKey,
+            key -> new SwtUniversalImageSvg(new 
SvgImage(cacheEntry.getSvgDocument()), false));
   }
 
   private static void ensureSvgImageCacheDisposeHook(Device device) {
-    if (svgCacheDisposeHookRegistered || !(device instanceof Display display)) 
{
+    if (!(device instanceof Display display) || 
!SVG_CACHE_DISPOSE_HOOKS.add(device)) {
       return;
     }
-    synchronized (SVG_CACHE_HOOK_LOCK) {
-      if (svgCacheDisposeHookRegistered) {
-        return;
-      }
-      display.addListener(
-          SWT.Dispose,
-          event -> {
-            for (SwtUniversalImage image : SVG_IMAGE_CACHE.values()) {
-              try {
-                image.dispose();
-              } catch (Exception ignored) {
-                // best-effort cleanup at display shutdown
-              }
+    display.addListener(
+        SWT.Dispose,
+        event -> {
+          SVG_CACHE_DISPOSE_HOOKS.remove(device);
+          Map<String, SwtUniversalImage> cache = 
SVG_IMAGE_CACHE_BY_DEVICE.remove(device);
+          if (cache == null) {
+            return;
+          }
+          for (SwtUniversalImage image : cache.values()) {
+            try {
+              image.dispose();
+            } catch (Exception ignored) {
+              // best-effort cleanup at display shutdown
             }
-            SVG_IMAGE_CACHE.clear();
-          });
-      svgCacheDisposeHookRegistered = true;
-    }
+          }
+        });
+  }
+
+  /** Visible for tests: number of cached SVG images for a Device. */
+  static int cachedSvgCount(Device device) {
+    Map<String, SwtUniversalImage> cache = 
SVG_IMAGE_CACHE_BY_DEVICE.get(device);
+    return cache == null ? 0 : cache.size();
   }
 
   @Override
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
index 536003292f..7cab8c20df 100644
--- a/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
@@ -226,6 +226,20 @@ class HopGuiKeyHandlerTest {
     verify(terminalWidget, never()).addKeyListener(keyHandler);
   }
 
+  @Test
+  void separateHandlersDoNotShareParentObjects() {
+    HopGuiKeyHandler first = new HopGuiKeyHandler();
+    HopGuiKeyHandler second = new HopGuiKeyHandler();
+    Object parent = new Object();
+
+    first.addParentObjectToHandle(parent);
+
+    assertTrue(first.parentObjects.contains(parent));
+    assertFalse(
+        second.parentObjects.contains(parent),
+        "Each RAP UISession must have its own key handler parent set");
+  }
+
   @Test
   void pipelineGraphBindsArrowKeys() {
     assertGraphBindsArrowKeys(HopGuiPipelineGraph.class);
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/StaticSwtSessionResourceTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/StaticSwtSessionResourceTest.java
new file mode 100644
index 0000000000..0d28f27ff2
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/StaticSwtSessionResourceTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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.hop.ui.hopgui;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.apache.hop.ui.hopgui.perspective.execution.ExecutionLogPanel;
+import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.graphics.Color;
+import org.eclipse.swt.graphics.Font;
+import org.eclipse.swt.graphics.GC;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Shell;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Static SWT resources are RAP-session-unique. Caching them in {@code static} 
fields lets one Hop
+ * Web session dispose images/widgets still used by another.
+ */
+class StaticSwtSessionResourceTest {
+
+  private static final Set<Class<?>> SWT_RESOURCE_TYPES =
+      Set.of(
+          Image.class,
+          Color.class,
+          Font.class,
+          Control.class,
+          Shell.class,
+          SashForm.class,
+          GC.class);
+
+  @Test
+  void uiClassesDoNotKeepSwtResourcesInStaticFields() throws Exception {
+    List<String> violations = new ArrayList<>();
+    Path root =
+        Path.of(
+            
ExecutionLogPanel.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+    ClassLoader classLoader = 
StaticSwtSessionResourceTest.class.getClassLoader();
+
+    try (Stream<Path> files = Files.walk(root)) {
+      for (Path file :
+          (Iterable<Path>) 
files.filter(StaticSwtSessionResourceTest::isClassFile)::iterator) {
+        String className =
+            root.relativize(file)
+                .toString()
+                .replace(java.io.File.separatorChar, '.')
+                .replaceAll("\\.class$", "");
+        if (!className.startsWith("org.apache.hop.ui")) {
+          continue;
+        }
+        Class<?> clazz;
+        try {
+          clazz = Class.forName(className, false, classLoader);
+        } catch (Throwable e) {
+          continue;
+        }
+        for (Field field : clazz.getDeclaredFields()) {
+          if (!Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) 
{
+            continue;
+          }
+          if (isSwtResource(field.getType())) {
+            violations.add(
+                clazz.getName() + "." + field.getName() + " : " + 
field.getType().getName());
+          }
+        }
+      }
+    }
+
+    assertTrue(
+        violations.isEmpty(), "Static SWT resource fields leak RAP UISession 
state: " + violations);
+  }
+
+  private static boolean isSwtResource(Class<?> type) {
+    for (Class<?> swtType : SWT_RESOURCE_TYPES) {
+      if (swtType.isAssignableFrom(type)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static boolean isClassFile(Path path) {
+    String name = path.getFileName().toString();
+    return name.endsWith(".class") && !name.contains("$");
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/file/shared/DrillDownGuiPluginTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/file/shared/DrillDownGuiPluginTest.java
new file mode 100644
index 0000000000..cfef9a38bf
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/file/shared/DrillDownGuiPluginTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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.hop.ui.hopgui.file.shared;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engine.IPipelineEngine;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+class DrillDownGuiPluginTest {
+
+  @AfterEach
+  void tearDown() {
+    DrillDownGuiPlugin.cleanupSession("gui-a");
+    DrillDownGuiPlugin.cleanupSession("gui-b");
+    DrillDownGuiPlugin.cleanupSession("session-1");
+  }
+
+  @Test
+  void cleanupOnRunStartDoesNotClearAnotherSession() {
+    IPipelineEngine<PipelineMeta> engineA = engine("gui-a", "log-a");
+    IPipelineEngine<PipelineMeta> engineB = engine("gui-b", "log-b");
+
+    DrillDownGuiPlugin.registerRunningPipeline("log-a", engineA);
+    DrillDownGuiPlugin.registerRunningPipeline("log-b", engineB);
+
+    DrillDownGuiPlugin.cleanupOnRunStart("gui-a");
+
+    assertNull(DrillDownGuiPlugin.runningPipeline("gui-a", "log-a"));
+    assertSame(engineB, DrillDownGuiPlugin.runningPipeline("gui-b", "log-b"));
+  }
+
+  @Test
+  void hopGuiIdWalksExtensionData() {
+    IPipelineEngine<PipelineMeta> engine = engine("session-1", "log-1");
+    assertEquals("session-1", DrillDownGuiPlugin.hopGuiIdOf(engine));
+  }
+
+  @SuppressWarnings("unchecked")
+  private static IPipelineEngine<PipelineMeta> engine(String hopGuiId, String 
logChannelId) {
+    IPipelineEngine<PipelineMeta> pipeline = mock(IPipelineEngine.class);
+    Map<String, Object> data = new HashMap<>();
+    data.put(DrillDownGuiPlugin.HOP_GUI_ID, hopGuiId);
+    when(pipeline.getExtensionDataMap()).thenReturn(data);
+    when(pipeline.getLogChannelId()).thenReturn(logChannelId);
+    when(pipeline.getParent()).thenReturn(null);
+    return pipeline;
+  }
+}

Reply via email to