This is an automated email from the ASF dual-hosted git repository.
bamaer 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 d0b3d5d42f Issue #9136 : Keep a failed SQL statement from crashing Hop
Web (#9141)
d0b3d5d42f is described below
commit d0b3d5d42ffa0da3cbacd3642532cf52592a59b6
Author: Matt Casters <[email protected]>
AuthorDate: Fri Oct 30 10:35:29 2026 +0100
Issue #9136 : Keep a failed SQL statement from crashing Hop Web (#9141)
---
.../org/apache/hop/core/SwtUniversalImage.java | 7 +-
.../org/apache/hop/ui/core/gui/GuiResource.java | 23 ++++++
.../perspective/database/DatabaseOperation.java | 6 ++
.../perspective/database/DatabasePerspective.java | 4 +-
.../perspective/database/DatabaseSqlEditorTab.java | 89 ++++++++++++++++++++--
.../perspective/database/DatabaseSqlTabMemory.java | 21 ++++-
.../perspective/database/DatabaseTableInfoTab.java | 10 ++-
.../perspective/database/DatabaseWorkbench.java | 45 +++++++++--
.../database/HopGuiDatabaseWorkbenchHost.java | 4 +-
.../org/apache/hop/core/SwtUniversalImageTest.java | 34 +++++++++
.../database/DatabaseOperationsPanelTest.java | 18 +++++
.../database/DatabaseSqlEditorTabTest.java | 33 ++++++++
12 files changed, 273 insertions(+), 21 deletions(-)
diff --git a/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
b/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
index d1b679bba5..1b07815d31 100644
--- a/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
+++ b/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
@@ -63,8 +63,13 @@ public abstract class SwtUniversalImage {
cache = null;
}
+ /** True after {@link #dispose()}, including when the display has already
released this image. */
+ public synchronized boolean isDisposed() {
+ return cache == null;
+ }
+
private void checkDisposed() {
- if (cache == null) {
+ if (isDisposed()) {
throw new HopRuntimeException("Already disposed");
}
}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
index 1c4edb5bb0..8ca82a44bf 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
@@ -76,6 +76,13 @@ public class GuiResource {
private final Display display;
+ /**
+ * Set at the start of {@link #dispose()}. Display shutdown fires {@code
SWT.Dispose} and releases
+ * these images before it disposes shells, so callers can still see a live
widget whose icon is
+ * already gone.
+ */
+ private boolean disposed;
+
private double zoomFactor;
// Colors
@@ -442,7 +449,16 @@ public class GuiResource {
// Transform, action, database and value type icons are loaded when they
are first asked for
}
+ /** True once display shutdown has released the fonts and images held here.
*/
+ public boolean isDisposed() {
+ return disposed;
+ }
+
private void dispose() {
+ if (disposed) {
+ return;
+ }
+ disposed = true;
// display shutdown, clean up the fonts, images, colors, and so on.
//
// Fonts
@@ -1259,6 +1275,13 @@ public class GuiResource {
private Image getZoomedImaged(
SwtUniversalImage universalImage, Device device, int width, int height) {
+ if (disposed
+ || universalImage == null
+ || universalImage.isDisposed()
+ || device == null
+ || device.isDisposed()) {
+ return null;
+ }
return universalImage.getAsBitmapForSize(
device,
ConstUi.zoomedIconSize(width, zoomFactor),
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java
index 9ce401ba12..bfbc4b7639 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java
@@ -86,6 +86,12 @@ public class DatabaseOperation {
}
public void complete() {
+ // A statement failure marks the operation failed and returns. Completing
afterwards must not
+ // turn that into success.
+ if (status != Status.RUNNING) {
+ detachDatabase();
+ return;
+ }
status = cancelled ? Status.CANCELLED : Status.DONE;
endTime = System.currentTimeMillis();
detachDatabase();
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabasePerspective.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabasePerspective.java
index b3001c1ca8..ec0e7a92c8 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabasePerspective.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabasePerspective.java
@@ -293,10 +293,10 @@ public class DatabasePerspective implements
IHopPerspective, TabClosable, IDatab
}
display.asyncExec(
() -> {
- if (workbench == null || workbench.isDisposed()) {
+ if (display.isDisposed() || workbench == null ||
workbench.isDisposed()) {
return;
}
- runnable.run();
+ DatabaseWorkbench.runSafely(runnable);
});
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java
index cdf523cf2d..7aa1efc809 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java
@@ -63,6 +63,8 @@ import org.apache.hop.ui.hopgui.shared.SashFormMemory;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.CTabItem;
import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.graphics.Font;
+import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.layout.FormLayout;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Control;
@@ -467,6 +469,9 @@ public class DatabaseSqlEditorTab implements
IHopFileTypeHandler {
nr++;
if (sql.isQuery()) {
List<Object[]> rows = db.getRows(sql.getStatement(), rowLimit);
+ if (rows == null) {
+ rows = List.of();
+ }
IRowMeta rowMeta = db.getReturnRowMeta();
queryResults.add(new DatabaseResultsPanel.QueryResult(nr,
rowMeta, rows));
messages.append(queryResultMessage(nr, rows.size(),
rowLimit)).append(Const.CR);
@@ -480,19 +485,23 @@ public class DatabaseSqlEditorTab implements
IHopFileTypeHandler {
}
}
} catch (Exception e) {
+ // A bad statement is a result, not a GUI failure. Rethrowing it
makes runOperation log
+ // the stack, and on Hop Web that takes the session down (issue
#9136).
if (!messages.isEmpty() && messages.charAt(messages.length() - 1)
!= '\n') {
messages.append(Const.CR);
}
+ String failure = sqlFailureText(e);
messages
.append(BaseMessages.getString(PKG,
"DatabasePerspective.SqlTab.Failed"))
.append(": ")
- .append(Const.NVL(e.getMessage(), e.toString()));
- throw e;
+ .append(Const.NVL(e.getMessage(), failure));
+ operation.fail(failure);
+ LogChannel.UI.logError(description + ": " + failure);
} finally {
String messageText = messages.toString();
host.asyncExec(
() -> {
- if (control.isDisposed() || resultsPanel.isDisposed()) {
+ if (control.isDisposed() || resultsPanel.isDisposed() ||
sash.isDisposed()) {
return;
}
resultsPanel.show(queryResults, messageText);
@@ -518,6 +527,59 @@ public class DatabaseSqlEditorTab implements
IHopFileTypeHandler {
return
DatabasePerspectiveConfigSingleton.getConfig().resolvedQueryRowLimit();
}
+ /**
+ * One line for the operations status and the log. The results panel keeps
{@code
+ * Throwable#getMessage()}, which includes the SQL. The root cause is the
database text ({@code
+ * ERROR: relation "..." does not exist}); the first line of that is enough
outside the panel.
+ */
+ static String sqlFailureText(Throwable error) {
+ if (error == null) {
+ return "";
+ }
+ Throwable root = error;
+ while (root.getCause() != null && root.getCause() != root) {
+ root = root.getCause();
+ }
+ String message = root.getMessage();
+ if (Utils.isEmpty(message)) {
+ message = error.getMessage();
+ }
+ if (Utils.isEmpty(message)) {
+ return root.toString();
+ }
+ int newline = message.indexOf('\n');
+ if (newline >= 0) {
+ message = message.substring(0, newline);
+ }
+ int cr = message.indexOf('\r');
+ if (cr >= 0) {
+ message = message.substring(0, cr);
+ }
+ return message.trim();
+ }
+
+ /**
+ * Display shutdown and a closed editor both surface as one of these. They
are not defects to log
+ * again while the session is already going away.
+ */
+ static boolean isShutdownRace(Throwable error) {
+ Throwable current = error;
+ while (current != null) {
+ String message = current.getMessage();
+ if (message != null
+ && (message.contains("Widget is disposed")
+ || message.contains("Already disposed")
+ || message.contains("Device is disposed"))) {
+ return true;
+ }
+ if (current.getCause() == current) {
+ break;
+ }
+ current = current.getCause();
+ }
+ return false;
+ }
+
static String queryResultMessage(int statementNr, int rowCount, int
rowLimit) {
if (rowLimit > 0 && rowCount >= rowLimit) {
return BaseMessages.getString(
@@ -556,10 +618,25 @@ public class DatabaseSqlEditorTab implements
IHopFileTypeHandler {
if (tabItem == null || tabItem.isDisposed()) {
return;
}
+ GuiResource resources = GuiResource.getInstance();
+ if (resources.isDisposed()) {
+ return;
+ }
tabItem.setText(Const.NVL(getName(), ""));
- tabItem.setImage(GuiResource.getInstance().getImageFile());
- tabItem.setFont(
- changed ? GuiResource.getInstance().getFontBold() :
tabItem.getParent().getFont());
+ Image image = resources.getImageFile();
+ if (image != null && !image.isDisposed()) {
+ tabItem.setImage(image);
+ }
+ Font font = tabItem.getParent() == null ? null :
tabItem.getParent().getFont();
+ if (changed) {
+ Font bold = resources.getFontBold();
+ if (bold != null && !bold.isDisposed()) {
+ font = bold;
+ }
+ }
+ if (font != null && !font.isDisposed()) {
+ tabItem.setFont(font);
+ }
}
public void setChanged() {
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java
index ca1224da80..57e4d3543f 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java
@@ -28,6 +28,7 @@ import org.apache.hop.history.AuditList;
import org.apache.hop.history.AuditManager;
import org.apache.hop.history.AuditState;
import org.apache.hop.history.AuditStateMap;
+import org.apache.hop.ui.core.gui.GuiResource;
import org.apache.hop.ui.core.gui.HopNamespace;
import org.apache.hop.ui.hopgui.HopGui;
import org.eclipse.swt.widgets.Display;
@@ -59,8 +60,21 @@ final class DatabaseSqlTabMemory {
private DatabaseSqlTabMemory() {}
+ /**
+ * False while the display is shutting down. {@code Display.isDisposed()} is
still false then: the
+ * disposed flag is set only after shells have been released.
+ */
+ private static boolean guiResourcesUsable() {
+ try {
+ GuiResource resources = GuiResource.getInstance();
+ return resources != null && !resources.isDisposed();
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
static void restore(DatabaseWorkbench workbench) {
- if (workbench == null || workbench.isDisposed()) {
+ if (workbench == null || workbench.isDisposed() || !guiResourcesUsable()) {
return;
}
register(workbench);
@@ -256,6 +270,11 @@ final class DatabaseSqlTabMemory {
}
static void restoreIntoRemaining(DatabaseWorkbench leaving) {
+ // Display.release() disposes GuiResource (SWT.Dispose) before it disposes
shells. Opening a
+ // tab from this dispose listener then asks for an icon that is already
gone (issue #9136).
+ if (!guiResourcesUsable()) {
+ return;
+ }
Session session = session(leaving);
if (session == null) {
return;
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTableInfoTab.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTableInfoTab.java
index 2397c0217e..cb7cd8168e 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTableInfoTab.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTableInfoTab.java
@@ -164,9 +164,13 @@ public class DatabaseTableInfoTab implements
IHopFileTypeHandler {
public void setTabItem(CTabItem tabItem) {
this.tabItem = tabItem;
- if (tabItem != null && !tabItem.isDisposed()) {
- tabItem.setText(getName());
- tabItem.setImage(tabImage());
+ if (tabItem == null || tabItem.isDisposed() ||
GuiResource.getInstance().isDisposed()) {
+ return;
+ }
+ tabItem.setText(getName());
+ Image image = tabImage();
+ if (image != null && !image.isDisposed()) {
+ tabItem.setImage(image);
}
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
index c6b7a8cf76..09695ab4a4 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
@@ -1622,17 +1622,50 @@ public class DatabaseWorkbench extends Composite
implements TabClosable {
BackgroundThreadFacade.start(
() -> {
try {
- work.run(operation);
- operation.complete();
- } catch (Exception e) {
- LogChannel.UI.logError(description, e);
- operation.fail(Const.NVL(e.getMessage(), e.toString()));
+ try {
+ work.run(operation);
+ operation.complete();
+ } catch (Exception e) {
+ // The SQL editor records a statement failure itself and does
not throw. Anything
+ // that still escapes is unexpected.
+ if (operation.getStatus() == DatabaseOperation.Status.RUNNING) {
+ LogChannel.UI.logError(description, e);
+ operation.fail(Const.NVL(e.getMessage(), e.toString()));
+ }
+ }
+ host.asyncExec(this::refreshOperationsQuietly);
+ } catch (RuntimeException e) {
+ if (operation.getStatus() == DatabaseOperation.Status.RUNNING) {
+ operation.fail(Const.NVL(e.getMessage(), e.toString()));
+ }
+ if (!DatabaseSqlEditorTab.isShutdownRace(e)) {
+ LogChannel.UI.logError(description, e);
+ }
}
- host.asyncExec(operationsPanel::refresh);
},
"hop-database-op");
}
+ private void refreshOperationsQuietly() {
+ if (operationsPanel.isDisposed()) {
+ return;
+ }
+ operationsPanel.refresh();
+ }
+
+ /** Runs a UI callback without letting a disposed widget or image kill the
Hop Web session. */
+ static void runSafely(Runnable runnable) {
+ try {
+ if (runnable != null) {
+ runnable.run();
+ }
+ } catch (RuntimeException e) {
+ if (!DatabaseSqlEditorTab.isShutdownRace(e)) {
+ LogChannel.UI.logError("Database workbench UI update failed", e);
+ }
+ }
+ }
+
public void refreshTab(DatabaseSqlEditorTab tab) {
tab.setTabItem(tab.getTabItem());
host.updateGui(tab);
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java
index a56492d9b1..ae015dc545 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java
@@ -94,10 +94,10 @@ public class HopGuiDatabaseWorkbenchHost implements
IDatabaseWorkbenchHost {
}
display.asyncExec(
() -> {
- if (alive != null && !alive.getAsBoolean()) {
+ if (display.isDisposed() || (alive != null &&
!alive.getAsBoolean())) {
return;
}
- runnable.run();
+ DatabaseWorkbench.runSafely(runnable);
});
}
diff --git a/ui/src/test/java/org/apache/hop/core/SwtUniversalImageTest.java
b/ui/src/test/java/org/apache/hop/core/SwtUniversalImageTest.java
index da2a413384..9e7ddd7f12 100644
--- a/ui/src/test/java/org/apache/hop/core/SwtUniversalImageTest.java
+++ b/ui/src/test/java/org/apache/hop/core/SwtUniversalImageTest.java
@@ -19,10 +19,14 @@ package org.apache.hop.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.awt.image.BufferedImage;
import java.lang.reflect.Method;
+import org.apache.hop.core.exception.HopRuntimeException;
import org.eclipse.swt.graphics.Device;
+import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.graphics.ImageData;
import org.junit.jupiter.api.Test;
@@ -37,6 +41,18 @@ class SwtUniversalImageTest {
assertEquals(1, SwtUniversalImage.pixelSize(0, 200));
}
+ @Test
+ void disposedImageReportsDisposedAndRefusesAnotherBitmap() {
+ SwtUniversalImage image = new UnusedImage();
+ assertFalse(image.isDisposed());
+ image.dispose();
+ assertTrue(image.isDisposed());
+ image.dispose();
+ HopRuntimeException thrown =
+ assertThrows(HopRuntimeException.class, () ->
image.getAsBitmapForSize(null, 16, 16));
+ assertEquals("Already disposed", thrown.getMessage());
+ }
+
@Test
void toImageDataKeepsSizeAndAlpha() {
BufferedImage src = new BufferedImage(16, 8, BufferedImage.TYPE_INT_ARGB);
@@ -59,4 +75,22 @@ class SwtUniversalImageTest {
"RAP class loading resolves method signatures; keep
ImageDataProvider off this API");
}
}
+
+ /** Never rendered. Dispose only clears the cache. */
+ private static final class UnusedImage extends SwtUniversalImage {
+ @Override
+ protected Image renderSimple(Device device) {
+ return null;
+ }
+
+ @Override
+ protected Image renderSimple(Device device, int width, int height) {
+ return null;
+ }
+
+ @Override
+ protected Image renderRotated(Device device, int width, int height, double
angleRadians) {
+ return null;
+ }
+ }
}
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanelTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanelTest.java
index 87d8df2e86..f10f4e0bd8 100644
---
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanelTest.java
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanelTest.java
@@ -69,6 +69,24 @@ class DatabaseOperationsPanelTest {
assertEquals("", DatabaseOperationsPanel.formatStatusLine(null));
}
+ @Test
+ void completeLeavesAFailureInPlace() {
+ DatabaseOperation operation = new DatabaseOperation("Execute SQL on CRM",
"CRM");
+ operation.fail("ERROR: relation \"order_shipment_tracking\" does not
exist");
+ operation.complete();
+ assertEquals(DatabaseOperation.Status.FAILED, operation.getStatus());
+ assertEquals(
+ "ERROR: relation \"order_shipment_tracking\" does not exist",
operation.getErrorMessage());
+ }
+
+ @Test
+ void completeOfACancelledRunIsCancelled() {
+ DatabaseOperation operation = new DatabaseOperation("Execute SQL on CRM",
"CRM");
+ operation.cancel();
+ operation.complete();
+ assertEquals(DatabaseOperation.Status.CANCELLED, operation.getStatus());
+ }
+
@Test
void failDetachesDatabase() {
DatabaseOperation operation = new DatabaseOperation("Connect", "shop");
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTabTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTabTest.java
index f82fc05f49..584f38ba55 100644
---
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTabTest.java
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTabTest.java
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.apache.hop.core.exception.HopDatabaseException;
import org.junit.jupiter.api.Test;
class DatabaseSqlEditorTabTest {
@@ -59,6 +60,38 @@ class DatabaseSqlEditorTabTest {
assertTrue(DatabaseSqlEditorTab.isModifiedFromClean("SELECT 1", ""));
}
+ @Test
+ void sqlFailureTextUsesTheDatabaseMessage() {
+ Exception cause =
+ new Exception("ERROR: relation \"order_shipment_tracking\" does not
exist\n Position: 64");
+ String text =
+ DatabaseSqlEditorTab.sqlFailureText(
+ new HopDatabaseException("An error occurred executing SQL:
\nSELECT 1", cause));
+ assertEquals("ERROR: relation \"order_shipment_tracking\" does not exist",
text);
+ }
+
+ @Test
+ void sqlFailureTextKeepsASingleLineAndSurvivesANull() {
+ assertEquals(
+ "relation missing",
+ DatabaseSqlEditorTab.sqlFailureText(new Exception("relation
missing\r\nmore")));
+ assertEquals("", DatabaseSqlEditorTab.sqlFailureText(null));
+ }
+
+ @Test
+ void shutdownRaceIsNotAQueryFailure() {
+ assertTrue(DatabaseSqlEditorTab.isShutdownRace(new
IllegalStateException("Already disposed")));
+ assertTrue(
+ DatabaseSqlEditorTab.isShutdownRace(new IllegalStateException("Widget
is disposed")));
+ assertTrue(
+ DatabaseSqlEditorTab.isShutdownRace(
+ new RuntimeException("wrapper", new IllegalStateException("Device
is disposed"))));
+ assertFalse(
+ DatabaseSqlEditorTab.isShutdownRace(
+ new HopDatabaseException("ERROR: relation \"t\" does not exist")));
+ assertFalse(DatabaseSqlEditorTab.isShutdownRace(null));
+ }
+
@Test
void generatedSqlCaretSitsOnTheLastStatement() {
String statement = "CREATE TABLE t (id INT);";