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

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


The following commit(s) were added to refs/heads/main by this push:
     new 02b72d78af add graceful shutdown to hop server, fixes #6403 (#7480)
02b72d78af is described below

commit 02b72d78af3f03839a0cdf4aeba3cc2fae8c6064
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Jul 10 16:27:50 2026 +0200

    add graceful shutdown to hop server, fixes #6403 (#7480)
---
 .../modules/ROOT/pages/docker-container.adoc       |   4 +
 .../modules/ROOT/pages/hop-server/index.adoc       |  34 +++++++
 .../java/org/apache/hop/www/AddExportServlet.java  |   3 +
 .../org/apache/hop/www/AddPipelineServlet.java     |   3 +
 .../org/apache/hop/www/AddWorkflowServlet.java     |   3 +
 .../java/org/apache/hop/www/BaseHttpServlet.java   |  21 ++++
 .../java/org/apache/hop/www/BodyHttpServlet.java   |   5 +
 .../java/org/apache/hop/www/GetStatusServlet.java  |   4 +-
 .../main/java/org/apache/hop/www/HopServer.java    |  87 ++++++++++++++++
 .../org/apache/hop/www/HopServerSingleton.java     |  19 ++++
 .../java/org/apache/hop/www/HopServerStatus.java   |   6 ++
 .../hop/www/PrepareExecutionPipelineServlet.java   |   3 +
 .../hop/www/StartExecutionPipelineServlet.java     |   3 +
 .../org/apache/hop/www/StartPipelineServlet.java   |   3 +
 .../org/apache/hop/www/StartWorkflowServlet.java   |   3 +
 .../org/apache/hop/www/GracefulShutdownTest.java   | 109 +++++++++++++++++++++
 .../org/apache/hop/www/HopServerStatusTest.java    |  15 +++
 .../org/apache/hop/www/ShutdownServletTest.java    |   2 +-
 18 files changed, 325 insertions(+), 2 deletions(-)

diff --git a/docs/hop-tech-manual/modules/ROOT/pages/docker-container.adoc 
b/docs/hop-tech-manual/modules/ROOT/pages/docker-container.adoc
index 0a9ad3580a..49bd5d46e6 100644
--- a/docs/hop-tech-manual/modules/ROOT/pages/docker-container.adoc
+++ b/docs/hop-tech-manual/modules/ROOT/pages/docker-container.adoc
@@ -188,6 +188,10 @@ Below are the variables you can use for a **long-lived** 
container, running Hop
 | `8080`
 | The port the server will listen to.
 
+|```HOP_SERVER_SHUTDOWN_TIMEOUT```
+| `0`
+| The maximum number of seconds to wait for running pipelines and workflows to 
finish when the server shuts down (for example on a `SIGTERM` from the 
container runtime). `0` shuts down immediately.
+
 |```HOP_SERVER_AUTH```
 | `true`
 | Disable/Enable the authentication used for hop server
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-server/index.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-server/index.adoc
index d49a33a60a..15ccfc759a 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-server/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-server/index.adoc
@@ -138,6 +138,11 @@ This is provided for by the 'projects' plugin.
 |
 |Specify the ID of the pipeline or workflow to query
 
+|-swt
+|--shutdown-timeout
+|Default: 0 +
+The maximum number of seconds to wait for running pipelines and workflows to 
finish when the server shuts down (0 shuts down immediately). Can also be set 
with the `HOP_SERVER_SHUTDOWN_TIMEOUT` environment variable.
+
 |===
 
 =====
@@ -459,6 +464,35 @@ Linux, macOS::
 --
 ====
 
+==== Graceful shutdown
+
+Whichever way a shutdown is triggered (`CTRL-C`, `SIGTERM` or the `/shutdown` 
servlet), Hop Server shuts down gracefully:
+
+* it immediately stops accepting new work: requests that add or start 
pipelines, workflows or packages are refused with an HTTP `503 Service 
Unavailable`, while status requests keep working,
+* the server status reports `Shutting down` (the status XML/JSON contains 
`shutting_down=Y`), which is useful as a Kubernetes readiness signal,
+* it waits for the running pipelines and workflows to finish before the 
process exits.
+
+By default the server waits `0` seconds, i.e. it shuts down immediately.
+Use the `--shutdown-timeout` option (or the `HOP_SERVER_SHUTDOWN_TIMEOUT` 
environment variable) to allow running executions to finish within a maximum 
number of seconds.
+When the timeout elapses while executions are still running, the server shuts 
down anyway.
+
+[tabs]
+====
+Windows::
++
+--
+[source,shell]
+hop-server.bat 127.0.0.1 8080 --shutdown-timeout 120
+--
+
+Linux, macOS::
++
+--
+[source,shell]
+ ./hop-server.sh 127.0.0.1 8080 --shutdown-timeout 120
+--
+====
+
 == Verify startup
 
 Starting a Hop Server on the local machine e.g. on port 8081 will only take 1 
or 2 seconds.
diff --git a/engine/src/main/java/org/apache/hop/www/AddExportServlet.java 
b/engine/src/main/java/org/apache/hop/www/AddExportServlet.java
index f1fb64a4a7..fcd9220c71 100644
--- a/engine/src/main/java/org/apache/hop/www/AddExportServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/AddExportServlet.java
@@ -83,6 +83,9 @@ public class AddExportServlet extends BaseHttpServlet 
implements IHopServerPlugi
     if (isJettyMode() && !request.getRequestURI().startsWith(CONTEXT_PATH)) {
       return;
     }
+    if (refuseIfShuttingDown(response)) {
+      return;
+    }
 
     if (log.isDebug()) {
       logDebug("Addition of export requested");
diff --git a/engine/src/main/java/org/apache/hop/www/AddPipelineServlet.java 
b/engine/src/main/java/org/apache/hop/www/AddPipelineServlet.java
index 988599f3fa..008cc348f4 100644
--- a/engine/src/main/java/org/apache/hop/www/AddPipelineServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/AddPipelineServlet.java
@@ -66,6 +66,9 @@ public class AddPipelineServlet extends BaseHttpServlet 
implements IHopServerPlu
     if (isJettyMode() && !request.getRequestURI().startsWith(CONTEXT_PATH)) {
       return;
     }
+    if (refuseIfShuttingDown(response)) {
+      return;
+    }
 
     if (log.isDebug()) {
       logDebug("Addition of pipeline requested");
diff --git a/engine/src/main/java/org/apache/hop/www/AddWorkflowServlet.java 
b/engine/src/main/java/org/apache/hop/www/AddWorkflowServlet.java
index 12ceb0ebd2..f835721b53 100644
--- a/engine/src/main/java/org/apache/hop/www/AddWorkflowServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/AddWorkflowServlet.java
@@ -66,6 +66,9 @@ public class AddWorkflowServlet extends BaseHttpServlet 
implements IHopServerPlu
     if (isJettyMode() && !request.getRequestURI().startsWith(CONTEXT_PATH)) {
       return;
     }
+    if (refuseIfShuttingDown(response)) {
+      return;
+    }
 
     if (log.isDebug()) {
       logDebug("Addition of workflow requested");
diff --git a/engine/src/main/java/org/apache/hop/www/BaseHttpServlet.java 
b/engine/src/main/java/org/apache/hop/www/BaseHttpServlet.java
index 97fb89a4ea..85650eee28 100644
--- a/engine/src/main/java/org/apache/hop/www/BaseHttpServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/BaseHttpServlet.java
@@ -224,6 +224,27 @@ public class BaseHttpServlet extends HttpServlet {
     }
   }
 
+  /**
+   * Refuse a request with HTTP 503 (Service Unavailable) while the server is 
performing a graceful
+   * shutdown. Servlets that accept new work (register/add/start/execute 
pipelines and workflows)
+   * should call this at the top of their handler and return immediately when 
it returns true.
+   * Status and reporting servlets do not call it and keep working during 
shutdown.
+   *
+   * @return true when the server is shutting down and an error response has 
been sent; the caller
+   *     must return immediately without performing any work.
+   */
+  protected boolean refuseIfShuttingDown(HttpServletResponse response) {
+    if (!HopServerSingleton.isServerShuttingDown()) {
+      return false;
+    }
+    logBasic("Refused a request: the Hop server is shutting down.");
+    sendSafeError(
+        response,
+        HttpServletResponse.SC_SERVICE_UNAVAILABLE,
+        "The Hop server is shutting down and is not accepting new work.");
+    return true;
+  }
+
   /**
    * Log server-side and return a {@link WebResult} error to the client in the 
requested XML or JSON
    * shape. Use {@code writer} when the response writer is already acquired 
for this request;
diff --git a/engine/src/main/java/org/apache/hop/www/BodyHttpServlet.java 
b/engine/src/main/java/org/apache/hop/www/BodyHttpServlet.java
index e1a2d3721c..6035d7c002 100644
--- a/engine/src/main/java/org/apache/hop/www/BodyHttpServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/BodyHttpServlet.java
@@ -48,6 +48,11 @@ public abstract class BodyHttpServlet extends 
BaseHttpServlet implements IHopSer
       return;
     }
 
+    // Register/add servlets accept new work, so refuse them while the server 
is shutting down.
+    if (refuseIfShuttingDown(response)) {
+      return;
+    }
+
     if (log.isDebug()) {
       logDebug(messages.getString("Log.Execute"));
     }
diff --git a/engine/src/main/java/org/apache/hop/www/GetStatusServlet.java 
b/engine/src/main/java/org/apache/hop/www/GetStatusServlet.java
index 8f91b29576..ab46bb3574 100644
--- a/engine/src/main/java/org/apache/hop/www/GetStatusServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/GetStatusServlet.java
@@ -113,8 +113,10 @@ public class GetStatusServlet extends BaseHttpServlet 
implements IHopServerPlugi
 
     if (useXml || useJson) {
       try {
+        boolean shuttingDown = HopServerSingleton.isServerShuttingDown();
         HopServerStatus serverStatus = new HopServerStatus();
-        serverStatus.setStatusDescription("Online");
+        serverStatus.setStatusDescription(shuttingDown ? "Shutting down" : 
"Online");
+        serverStatus.setShuttingDown(shuttingDown);
 
         getSystemInfo(serverStatus);
 
diff --git a/engine/src/main/java/org/apache/hop/www/HopServer.java 
b/engine/src/main/java/org/apache/hop/www/HopServer.java
index 08365612d2..504ae34487 100644
--- a/engine/src/main/java/org/apache/hop/www/HopServer.java
+++ b/engine/src/main/java/org/apache/hop/www/HopServer.java
@@ -56,8 +56,12 @@ import 
org.apache.hop.metadata.serializer.json.JsonMetadataProvider;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
 import org.apache.hop.metadata.util.HopMetadataInstance;
 import org.apache.hop.metadata.util.HopMetadataUtil;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engine.IPipelineEngine;
 import org.apache.hop.pipeline.transform.TransformStatus;
 import org.apache.hop.server.HopServerMeta;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.engine.IWorkflowEngine;
 import org.w3c.dom.Document;
 import org.w3c.dom.Node;
 import picocli.CommandLine;
@@ -135,6 +139,15 @@ public class HopServer implements Runnable, 
IHasHopMetadataProvider, IHopCommand
       description = "Does the Hop web server have authentication enabled")
   private Boolean enableAuth;
 
+  @CommandLine.Option(
+      names = {"-swt", "--shutdown-timeout"},
+      description =
+          "The maximum number of seconds to wait for running pipelines and 
workflows to finish "
+              + "when the server shuts down. 0 (the default) shuts down 
immediately without "
+              + "waiting. Can also be set with the HOP_SERVER_SHUTDOWN_TIMEOUT 
environment variable.",
+      defaultValue = "${env:HOP_SERVER_SHUTDOWN_TIMEOUT:-0}")
+  private long shutdownTimeout;
+
   private WebServer webServer;
   private HopServerConfig config;
   private boolean allOK;
@@ -267,6 +280,10 @@ public class HopServer implements Runnable, 
IHasHopMetadataProvider, IHopCommand
   /** Gracefully shut down the running Hop web server. */
   public void shutdown() {
     if (webServer != null) {
+      // From now on the server refuses new work (adding/running pipelines, 
workflows, ...) but
+      // keeps serving status requests.
+      HopServerSingleton.setServerShuttingDown(true);
+
       // Right before the Hop server will shut down
       try {
         ExtensionPointHandler.callExtensionPoint(
@@ -277,10 +294,80 @@ public class HopServer implements Runnable, 
IHasHopMetadataProvider, IHopCommand
         log.logError("Error calling extension point HopServerShutdown", e);
       }
 
+      // Wait for running pipelines and workflows to finish, up to the 
configured timeout.
+      awaitRunningExecutions();
+
       webServer.stop();
     }
   }
 
+  /**
+   * Wait until all running pipelines and workflows on this server have 
finished, or until the
+   * configured shutdown timeout (in seconds) has elapsed. A timeout of 0 (the 
default) shuts down
+   * immediately without waiting.
+   */
+  private void awaitRunningExecutions() {
+    if (shutdownTimeout <= 0) {
+      log.logBasic(
+          "Shutdown timeout is 0; stopping the server immediately without 
waiting for running "
+              + "pipelines and workflows to finish. Set --shutdown-timeout (or 
"
+              + "HOP_SERVER_SHUTDOWN_TIMEOUT) to a number of seconds to wait 
for them.");
+      return;
+    }
+
+    HopServerSingleton singleton = HopServerSingleton.getInstance();
+    PipelineMap pipelineMap = singleton.getPipelineMap();
+    WorkflowMap workflowMap = singleton.getWorkflowMap();
+
+    long deadline = System.currentTimeMillis() + shutdownTimeout * 1000L;
+    log.logBasic(
+        "Waiting up to "
+            + shutdownTimeout
+            + " seconds for running pipelines and workflows to finish before 
shutting down.");
+
+    int running;
+    while ((running = countRunningExecutions(pipelineMap, workflowMap)) > 0
+        && System.currentTimeMillis() < deadline) {
+      log.logBasic(running + " pipeline(s)/workflow(s) still running, waiting 
for completion...");
+      try {
+        Thread.sleep(5000L);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        break;
+      }
+    }
+
+    running = countRunningExecutions(pipelineMap, workflowMap);
+    if (running > 0) {
+      log.logBasic(
+          "Shutdown wait time elapsed while "
+              + running
+              + " pipeline(s)/workflow(s) are still running; stopping the 
server now.");
+    } else {
+      log.logBasic("All pipelines and workflows have finished; stopping the 
server.");
+    }
+  }
+
+  /**
+   * @return the number of pipelines and workflows that are currently running 
on this server
+   */
+  private int countRunningExecutions(PipelineMap pipelineMap, WorkflowMap 
workflowMap) {
+    int running = 0;
+    for (HopServerObjectEntry entry : pipelineMap.getPipelineObjects()) {
+      IPipelineEngine<PipelineMeta> pipeline = pipelineMap.getPipeline(entry);
+      if (pipeline != null && !pipeline.isFinished() && !pipeline.isStopped()) 
{
+        running++;
+      }
+    }
+    for (HopServerObjectEntry entry : workflowMap.getWorkflowObjects()) {
+      IWorkflowEngine<WorkflowMeta> workflow = workflowMap.getWorkflow(entry);
+      if (workflow != null && !workflow.isFinished() && !workflow.isStopped()) 
{
+        running++;
+      }
+    }
+    return running;
+  }
+
   /** Virtual-machine shutdown hook that stops the Hop server gracefully (e.g. 
on Ctrl-C). */
   private class ShutdownHook extends Thread {
     @Override
diff --git a/engine/src/main/java/org/apache/hop/www/HopServerSingleton.java 
b/engine/src/main/java/org/apache/hop/www/HopServerSingleton.java
index db3ea4deec..8df83f13d7 100644
--- a/engine/src/main/java/org/apache/hop/www/HopServerSingleton.java
+++ b/engine/src/main/java/org/apache/hop/www/HopServerSingleton.java
@@ -55,6 +55,13 @@ public class HopServerSingleton {
   private PipelineMap pipelineMap;
   private WorkflowMap workflowMap;
 
+  /**
+   * Set to true once a graceful shutdown has been initiated. While the server 
is shutting down it
+   * refuses new work (adding/running pipelines, workflows, ...) but keeps 
serving status requests.
+   * It is static because there is a single Hop server per JVM.
+   */
+  private static final AtomicBoolean shuttingDown = new AtomicBoolean(false);
+
   private HopServerSingleton(HopServerConfig config) throws HopException {
     HopEnvironment.init();
     HopLogStore.init();
@@ -239,6 +246,18 @@ public class HopServerSingleton {
     }
   }
 
+  /**
+   * @return true when a graceful shutdown of the server has been initiated. 
Safe to call from
+   *     servlets at any time.
+   */
+  public static boolean isServerShuttingDown() {
+    return shuttingDown.get();
+  }
+
+  public static void setServerShuttingDown(boolean value) {
+    shuttingDown.set(value);
+  }
+
   public PipelineMap getPipelineMap() {
     return pipelineMap;
   }
diff --git a/engine/src/main/java/org/apache/hop/www/HopServerStatus.java 
b/engine/src/main/java/org/apache/hop/www/HopServerStatus.java
index 4cc5fd49ce..a3e03877c5 100644
--- a/engine/src/main/java/org/apache/hop/www/HopServerStatus.java
+++ b/engine/src/main/java/org/apache/hop/www/HopServerStatus.java
@@ -34,6 +34,10 @@ public class HopServerStatus {
 
   @Getter @Setter private String statusDescription;
   @Getter @Setter private String errorDescription;
+
+  /** True when the server has started a graceful shutdown and is refusing new 
work. */
+  @Getter @Setter private boolean shuttingDown;
+
   @Getter @Setter private List<HopServerPipelineStatus> pipelineStatusList;
   @Getter @Setter private List<HopServerWorkflowStatus> workflowStatusList;
   @Getter @Setter private long memoryFree;
@@ -77,6 +81,7 @@ public class HopServerStatus {
 
     xml.append("<" + XML_TAG + ">").append(Const.CR);
     xml.append(XmlHandler.addTagValue("statusdesc", statusDescription));
+    xml.append(XmlHandler.addTagValue("shutting_down", shuttingDown));
 
     xml.append(XmlHandler.addTagValue("memory_free", memoryFree));
     xml.append(XmlHandler.addTagValue("memory_total", memoryTotal));
@@ -113,6 +118,7 @@ public class HopServerStatus {
   public HopServerStatus(Node statusNode) throws HopException {
     this();
     statusDescription = XmlHandler.getTagValue(statusNode, "statusdesc");
+    shuttingDown = "Y".equalsIgnoreCase(XmlHandler.getTagValue(statusNode, 
"shutting_down"));
 
     memoryFree = Const.toLong(XmlHandler.getTagValue(statusNode, 
"memory_free"), -1L);
     memoryTotal = Const.toLong(XmlHandler.getTagValue(statusNode, 
"memory_total"), -1L);
diff --git 
a/engine/src/main/java/org/apache/hop/www/PrepareExecutionPipelineServlet.java 
b/engine/src/main/java/org/apache/hop/www/PrepareExecutionPipelineServlet.java
index e4dd239cbc..d9616c96f7 100644
--- 
a/engine/src/main/java/org/apache/hop/www/PrepareExecutionPipelineServlet.java
+++ 
b/engine/src/main/java/org/apache/hop/www/PrepareExecutionPipelineServlet.java
@@ -62,6 +62,9 @@ public class PrepareExecutionPipelineServlet extends 
BaseHttpServlet implements
     if (isJettyMode() && !request.getContextPath().startsWith(CONTEXT_PATH)) {
       return;
     }
+    if (refuseIfShuttingDown(response)) {
+      return;
+    }
 
     if (log.isDebug()) {
       logDebug(
diff --git 
a/engine/src/main/java/org/apache/hop/www/StartExecutionPipelineServlet.java 
b/engine/src/main/java/org/apache/hop/www/StartExecutionPipelineServlet.java
index 3c6611f96d..4d9b1a4f00 100644
--- a/engine/src/main/java/org/apache/hop/www/StartExecutionPipelineServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/StartExecutionPipelineServlet.java
@@ -56,6 +56,9 @@ public class StartExecutionPipelineServlet extends 
BaseHttpServlet implements IH
     if (isJettyMode() && !request.getContextPath().startsWith(CONTEXT_PATH)) {
       return;
     }
+    if (refuseIfShuttingDown(response)) {
+      return;
+    }
 
     if (log.isDebug()) {
       logDebug("Start execution of pipeline requested");
diff --git a/engine/src/main/java/org/apache/hop/www/StartPipelineServlet.java 
b/engine/src/main/java/org/apache/hop/www/StartPipelineServlet.java
index bf980f5e4f..d222cca473 100644
--- a/engine/src/main/java/org/apache/hop/www/StartPipelineServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/StartPipelineServlet.java
@@ -62,6 +62,9 @@ public class StartPipelineServlet extends BaseHttpServlet 
implements IHopServerP
     if (isJettyMode() && !request.getContextPath().startsWith(CONTEXT_PATH)) {
       return;
     }
+    if (refuseIfShuttingDown(response)) {
+      return;
+    }
 
     if (log.isDebug()) {
       logDebug(BaseMessages.getString(PKG, 
"StartPipelineServlet.Log.PipelineStartRequested"));
diff --git a/engine/src/main/java/org/apache/hop/www/StartWorkflowServlet.java 
b/engine/src/main/java/org/apache/hop/www/StartWorkflowServlet.java
index f2005b08b8..cf0a9e50ed 100644
--- a/engine/src/main/java/org/apache/hop/www/StartWorkflowServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/StartWorkflowServlet.java
@@ -64,6 +64,9 @@ public class StartWorkflowServlet extends BaseHttpServlet 
implements IHopServerP
     if (isJettyMode() && !request.getContextPath().startsWith(CONTEXT_PATH)) {
       return;
     }
+    if (refuseIfShuttingDown(response)) {
+      return;
+    }
 
     if (log.isDebug()) {
       logDebug(BaseMessages.getString(PKG, 
"StartWorkflowServlet.Log.StartWorkflowRequested"));
diff --git a/engine/src/test/java/org/apache/hop/www/GracefulShutdownTest.java 
b/engine/src/test/java/org/apache/hop/www/GracefulShutdownTest.java
new file mode 100644
index 0000000000..15304dd528
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/GracefulShutdownTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.www;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Collections;
+import org.apache.hop.core.logging.HopLogStore;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests the graceful-shutdown behaviour: refusing new work and reporting the 
shutting-down state.
+ */
+class GracefulShutdownTest {
+
+  @BeforeEach
+  void setup() {
+    HopLogStore.init();
+    HopServerSingleton.setServerShuttingDown(false);
+  }
+
+  @AfterEach
+  void tearDown() {
+    HopServerSingleton.setServerShuttingDown(false);
+  }
+
+  @Test
+  void workServletIsRefusedWithServiceUnavailableWhileShuttingDown() throws 
Exception {
+    HopServerSingleton.setServerShuttingDown(true);
+
+    StartPipelineServlet servlet = new 
StartPipelineServlet(mock(PipelineMap.class));
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    
when(request.getContextPath()).thenReturn(StartPipelineServlet.CONTEXT_PATH);
+
+    servlet.doGet(request, response);
+
+    verify(response).sendError(eq(HttpServletResponse.SC_SERVICE_UNAVAILABLE), 
anyString());
+  }
+
+  @Test
+  void workServletIsNotRefusedWhenNotShuttingDown() throws Exception {
+    // Not shutting down: the refusal path must not fire (the servlet is free 
to do its own work).
+    assertFalse(HopServerSingleton.isServerShuttingDown());
+
+    AddPipelineServlet servlet = new 
AddPipelineServlet(mock(PipelineMap.class));
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    // Not the right URI in jetty mode, so the servlet returns early without 
doing any real work.
+    when(request.getRequestURI()).thenReturn("/some/other/path");
+
+    servlet.doGet(request, response);
+
+    verify(response, never())
+        .sendError(eq(HttpServletResponse.SC_SERVICE_UNAVAILABLE), 
anyString());
+  }
+
+  @Test
+  void statusServletReportsShuttingDownState() throws Exception {
+    HopServerSingleton.setServerShuttingDown(true);
+
+    PipelineMap pipelineMap = mock(PipelineMap.class);
+    when(pipelineMap.getPipelineObjects()).thenReturn(Collections.emptyList());
+    WorkflowMap workflowMap = mock(WorkflowMap.class);
+    when(workflowMap.getWorkflowObjects()).thenReturn(Collections.emptyList());
+
+    GetStatusServlet servlet = new GetStatusServlet(pipelineMap, workflowMap);
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    StringWriter out = new StringWriter();
+    when(response.getWriter()).thenReturn(new PrintWriter(out));
+    when(request.getContextPath()).thenReturn(GetStatusServlet.CONTEXT_PATH);
+    when(request.getParameter("xml")).thenReturn("Y");
+
+    servlet.doGet(request, response);
+
+    String xml = out.toString();
+    assertTrue(xml.contains("Shutting down"), xml);
+    assertTrue(xml.contains("<shutting_down>Y</shutting_down>"), xml);
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/www/HopServerStatusTest.java 
b/engine/src/test/java/org/apache/hop/www/HopServerStatusTest.java
index 081c0e63b0..723871e200 100644
--- a/engine/src/test/java/org/apache/hop/www/HopServerStatusTest.java
+++ b/engine/src/test/java/org/apache/hop/www/HopServerStatusTest.java
@@ -18,6 +18,7 @@
 package org.apache.hop.www;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -52,4 +53,18 @@ class HopServerStatusTest {
     assertTrue(xml.contains("pipeline_status_list"));
     assertTrue(xml.contains("workflow_status_list"));
   }
+
+  @Test
+  void shuttingDownRoundTripsThroughXml() throws HopException {
+    HopServerStatus status = new HopServerStatus("Shutting down");
+    status.setShuttingDown(true);
+
+    HopServerStatus parsed = HopServerStatus.fromXml(status.getXml());
+    assertTrue(parsed.isShuttingDown());
+    assertEquals("Shutting down", parsed.getStatusDescription());
+
+    // Default is false and it round-trips as false as well.
+    HopServerStatus online = HopServerStatus.fromXml(new 
HopServerStatus("Online").getXml());
+    assertFalse(online.isShuttingDown());
+  }
 }
diff --git a/engine/src/test/java/org/apache/hop/www/ShutdownServletTest.java 
b/engine/src/test/java/org/apache/hop/www/ShutdownServletTest.java
index c0c40988bd..ba9131c5ef 100644
--- a/engine/src/test/java/org/apache/hop/www/ShutdownServletTest.java
+++ b/engine/src/test/java/org/apache/hop/www/ShutdownServletTest.java
@@ -56,7 +56,7 @@ class ShutdownServletTest {
 
     // The servlet responds immediately and schedules the shutdown on a short 
delayed timer.
     verify(response).setStatus(HttpServletResponse.SC_OK);
-    verify(server, timeout(6000)).shutdown();
+    verify(server, timeout(15000)).shutdown();
   }
 
   @Test

Reply via email to