From 6e6cbc7128ebc845f968ae95be36be29a0a05ef8 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Mon, 05 Oct 2026 12:41:43 +0000
Subject: [PATCH] [#1160] Report what start-ds printed when the server fails to start, and dump the quicksetup test instance's logs on a failed build (#1169)

---
 opendj-server-legacy/src/test/java/org/opends/quicksetup/util/ServerControllerStartFailureTest.java |  275 +++++++++++++++++++++++++++++++++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/quicksetup/util/ServerController.java                 |   71 ++++++++++-
 .github/workflows/build.yml                                                                         |    6 
 opendj-server-legacy/src/messages/org/opends/messages/quickSetup.properties                         |    1 
 4 files changed, 343 insertions(+), 10 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 97972d8..ecab9ef 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -437,12 +437,14 @@
 
     # A test step that fails leaves its instances behind. The server-side story of a
     # failed start lives in logs/server.out and logs/errors, and nothing else prints it
-    # (setup only has the client-side view, see issue #1030).
+    # (setup only has the client-side view, see issue #1030). The quicksetup tests run
+    # their own instance under build/unit-tests/quicksetup (issue #1160).
     - name: Dump the server logs of a failed test
       if: failure()
       shell: bash
       run: |
-        for f in opendj-server-legacy/target/package/opendj*/logs/server.out opendj-server-legacy/target/package/opendj*/logs/errors; do
+        for f in opendj-server-legacy/target/package/opendj*/logs/server.out opendj-server-legacy/target/package/opendj*/logs/errors \
+                 opendj-server-legacy/build/unit-tests/quicksetup/OpenDS/logs/server.out opendj-server-legacy/build/unit-tests/quicksetup/OpenDS/logs/errors; do
           [ -f "$f" ] || continue
           echo "===== $f"
           cat "$f"
diff --git a/opendj-server-legacy/src/main/java/org/opends/quicksetup/util/ServerController.java b/opendj-server-legacy/src/main/java/org/opends/quicksetup/util/ServerController.java
index aa808db..acb6ff6 100644
--- a/opendj-server-legacy/src/main/java/org/opends/quicksetup/util/ServerController.java
+++ b/opendj-server-legacy/src/main/java/org/opends/quicksetup/util/ServerController.java
@@ -21,7 +21,9 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Deque;
 import java.util.List;
 import java.util.Map;
 
@@ -58,6 +60,12 @@
 
   private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
 
+  /** How many of the last lines printed by the start command a failed start reports. */
+  private static final int MAX_REPORTED_START_LINES = 100;
+
+  /** How long a failed start waits for the readers to drain what the start command printed. */
+  private static final long START_OUTPUT_DRAIN_TIMEOUT_MS = 5000;
+
   private Application application;
 
   private Installation installation;
@@ -331,7 +339,8 @@
 
       try
       {
-        startServerViaAnotherProcess();
+        // Without suppression the readers have already shown every line to the listeners.
+        startServerViaAnotherProcess(suppressOutput || application == null);
 
         if (verifyCanConnect)
         {
@@ -358,7 +367,8 @@
     }
   }
 
-  private void startServerViaAnotherProcess() throws IOException, InterruptedException, ApplicationException
+  private void startServerViaAnotherProcess(boolean reportOutput)
+      throws IOException, InterruptedException, ApplicationException
   {
     logger.info(LocalizableMessage.raw("starting server"));
 
@@ -381,8 +391,12 @@
     String startedId = getStartedId();
     Process process = pb.start();
 
-    StartReader errReader = new StartReader(process.getErrorStream(), startedId, true);
-    StartReader outputReader = new StartReader(process.getInputStream(), startedId, false);
+    // When the start fails, what start-ds printed (the server.out of a server that stopped
+    // during its initialization, or the reason it was not launched) is the only account of
+    // the cause that reaches the caller, so both readers keep its last lines (issue #1160).
+    Deque<String> outputTail = new ArrayDeque<>();
+    StartReader errReader = new StartReader(process.getErrorStream(), startedId, true, outputTail);
+    StartReader outputReader = new StartReader(process.getInputStream(), startedId, false, outputTail);
 
     int returnValue = process.waitFor();
 
@@ -390,7 +404,12 @@
 
     if (returnValue != 0)
     {
-      throw new ApplicationException(ReturnCode.START_ERROR, INFO_ERROR_STARTING_SERVER_CODE.get(returnValue), null);
+      // The readers may still be draining the lines start-ds printed just before it exited.
+      errReader.join(START_OUTPUT_DRAIN_TIMEOUT_MS);
+      outputReader.join(START_OUTPUT_DRAIN_TIMEOUT_MS);
+      throw new ApplicationException(ReturnCode.START_ERROR, reportOutput
+          ? getStartFailedMessage(returnValue, outputTail)
+          : INFO_ERROR_STARTING_SERVER_CODE.get(returnValue), null);
     }
     if (outputReader.isFinished())
     {
@@ -596,6 +615,20 @@
     return helper.getStartedId();
   }
 
+  private static LocalizableMessage getStartFailedMessage(int returnValue, Deque<String> outputTail)
+  {
+    // The header is the message a failed start has always reported, which is translated.
+    LocalizableMessageBuilder mb = new LocalizableMessageBuilder(INFO_ERROR_STARTING_SERVER_CODE.get(returnValue));
+    synchronized (outputTail)
+    {
+      if (!outputTail.isEmpty())
+      {
+        mb.append(INFO_ERROR_STARTING_SERVER_OUTPUT.get(String.join(System.lineSeparator(), outputTail)));
+      }
+    }
+    return mb.toMessage();
+  }
+
   /**
    * This class is used to read the standard error and standard output of the
    * Start process.
@@ -613,6 +646,8 @@
 
     private boolean isFirstLine;
 
+    private final Thread thread;
+
     /**
      * The protected constructor.
      * @param stream the stream of the start process to read.
@@ -620,9 +655,11 @@
      * the start is over or not.
      * @param isError a boolean indicating whether the stream
      * corresponds to the standard error or to the standard output.
+     * @param outputTail the last lines read from both streams of the start process,
+     * which this reader appends to; it is guarded by its own monitor.
      */
     public StartReader(final InputStream stream, final String startedId,
-        final boolean isError)
+        final boolean isError, final Deque<String> outputTail)
     {
       final LocalizableMessage errorTag =
               isError ?
@@ -631,7 +668,7 @@
 
       isFirstLine = true;
 
-      Thread t = new Thread(new Runnable()
+      thread = new Thread(new Runnable()
       {
         @Override
         public void run()
@@ -662,6 +699,14 @@
                 isFirstLine = false;
               }
               logger.info(LocalizableMessage.raw("server: " + line));
+              synchronized (outputTail)
+              {
+                if (outputTail.size() == MAX_REPORTED_START_LINES)
+                {
+                  outputTail.removeFirst();
+                }
+                outputTail.addLast(line);
+              }
               if (line.toLowerCase().contains("=" + startedId))
               {
                 isFinished = true;
@@ -680,7 +725,17 @@
           isFinished = true;
         }
       });
-      t.start();
+      thread.start();
+    }
+
+    /**
+     * Waits for this reader to reach the end of its stream.
+     * @param timeoutMillis the longest time to wait, in milliseconds.
+     * @throws InterruptedException if the current thread is interrupted while waiting.
+     */
+    public void join(long timeoutMillis) throws InterruptedException
+    {
+      thread.join(timeoutMillis);
     }
 
     /**
diff --git a/opendj-server-legacy/src/messages/org/opends/messages/quickSetup.properties b/opendj-server-legacy/src/messages/org/opends/messages/quickSetup.properties
index ccc934d..098a273 100644
--- a/opendj-server-legacy/src/messages/org/opends/messages/quickSetup.properties
+++ b/opendj-server-legacy/src/messages/org/opends/messages/quickSetup.properties
@@ -341,6 +341,7 @@
 INFO_ERROR_STARTING_SERVER=Error Starting Directory Server.
 INFO_ERROR_STARTING_SERVER_CODE=Error Starting Directory Server. Error code: \
  %s.
+INFO_ERROR_STARTING_SERVER_OUTPUT=%nLast lines of the start command output:%n%s
 INFO_ERROR_STARTING_SERVER_IN_UNIX=Could not connect to the server after \
  requesting start. Verify that the server has access rights to port %s.
 INFO_ERROR_STARTING_SERVER_IN_WINDOWS=Could not connect to the server after \
diff --git a/opendj-server-legacy/src/test/java/org/opends/quicksetup/util/ServerControllerStartFailureTest.java b/opendj-server-legacy/src/test/java/org/opends/quicksetup/util/ServerControllerStartFailureTest.java
new file mode 100644
index 0000000..b38a6bd
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/quicksetup/util/ServerControllerStartFailureTest.java
@@ -0,0 +1,275 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.opends.quicksetup.util;
+
+import static com.forgerock.opendj.util.OperatingSystem.*;
+import static org.opends.messages.QuickSetupMessages.INFO_ERROR_STARTING_SERVER_CODE;
+import static org.testng.Assert.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+import org.forgerock.i18n.LocalizableMessage;
+import org.opends.quicksetup.Application;
+import org.opends.quicksetup.ApplicationException;
+import org.opends.quicksetup.Installation;
+import org.opends.quicksetup.ProgressStep;
+import org.opends.quicksetup.ReturnCode;
+import org.opends.server.DirectoryServerTestCase;
+import org.testng.SkipException;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * A failed start reports what the start command printed (issue #1160), unless the listeners of
+ * the application have already shown it. The installation is a bare directory whose start command
+ * is a stand-in script that prints and exits with 1.
+ */
+@SuppressWarnings("javadoc")
+@Test(sequential = true)
+public class ServerControllerStartFailureTest extends DirectoryServerTestCase
+{
+  private File root;
+
+  @BeforeMethod
+  public void createInstallation() throws IOException
+  {
+    root = Files.createTempDirectory("server-controller-start-failure").toFile();
+  }
+
+  @AfterMethod(alwaysRun = true)
+  public void deleteInstallation() throws ApplicationException
+  {
+    new FileManager().deleteRecursively(root);
+  }
+
+  @Test
+  public void failedStartReportsBothOutputStreams() throws Exception
+  {
+    writeStartCommandPrintingToBothStreams();
+
+    String message = startAndExpectFailure(null, false);
+
+    assertTrue(message.startsWith(exitCodeMessage()), message);
+    assertTrue(message.contains("cause on stdout"), message);
+    assertTrue(message.contains("cause on stderr"), message);
+  }
+
+  @Test
+  public void failedStartReportsOnlyTheLastLines() throws Exception
+  {
+    if (isWindows())
+    {
+      writeStartCommand("@for /L %%i in (1,1,150) do @echo L%%iL", "@exit /b 1");
+    }
+    else
+    {
+      writeStartCommand("#!/bin/sh", "i=1", "while [ $i -le 150 ]; do echo \"L${i}L\"; i=$((i+1)); done", "exit 1");
+    }
+
+    String message = startAndExpectFailure(null, false);
+
+    assertFalse(message.contains("L50L"), message);
+    assertTrue(message.contains("L51L"), message);
+    assertTrue(message.contains("L150L"), message);
+  }
+
+  @Test
+  public void failedStartReportsWhatIsPrintedAfterTheExit() throws Exception
+  {
+    if (isWindows())
+    {
+      throw new SkipException("needs a child process that outlives the start command");
+    }
+    // The child holds the inherited pipes after sh exits, so its line arrives after waitFor().
+    // When the process exits, the JDK drains what is already in a pipe and closes it, unless a
+    // reader is blocked in read() on that stream, which the drain then waits for. The first
+    // line and the pause before the exit leave the stdout reader blocked there, so that the
+    // line of the child is read rather than cut off by the drain.
+    writeStartCommand("#!/bin/sh", "echo 'printed before the exit'", "sleep 1",
+        "(sleep 1; echo 'printed after the exit') &", "exit 1");
+
+    String message = startAndExpectFailure(null, false);
+
+    assertTrue(message.contains("printed before the exit"), message);
+    assertTrue(message.contains("printed after the exit"), message);
+  }
+
+  @Test
+  public void silentFailedStartKeepsTheExitCodeMessage() throws Exception
+  {
+    if (isWindows())
+    {
+      writeStartCommand("@exit /b 1");
+    }
+    else
+    {
+      writeStartCommand("#!/bin/sh", "exit 1");
+    }
+
+    assertEquals(startAndExpectFailure(null, false), exitCodeMessage());
+  }
+
+  @Test
+  public void failedStartShownToTheListenersKeepsTheExitCodeMessage() throws Exception
+  {
+    writeStartCommandPrintingToBothStreams();
+    ListeningApplication application = new ListeningApplication();
+
+    assertEquals(startAndExpectFailure(application, false), exitCodeMessage());
+    assertTrue(application.getShown().contains("cause on stdout"), application.getShown());
+    assertTrue(application.getShown().contains("cause on stderr"), application.getShown());
+  }
+
+  @Test
+  public void failedStartHiddenFromTheListenersReportsBothOutputStreams() throws Exception
+  {
+    writeStartCommandPrintingToBothStreams();
+    ListeningApplication application = new ListeningApplication();
+
+    String message = startAndExpectFailure(application, true);
+
+    assertTrue(message.startsWith(exitCodeMessage()), message);
+    assertTrue(message.contains("cause on stdout"), message);
+    assertTrue(message.contains("cause on stderr"), message);
+    assertEquals(application.getShown(), "");
+  }
+
+  private static String exitCodeMessage()
+  {
+    return INFO_ERROR_STARTING_SERVER_CODE.get(1).toString();
+  }
+
+  private void writeStartCommandPrintingToBothStreams() throws IOException
+  {
+    if (isWindows())
+    {
+      writeStartCommand("@echo cause on stdout", "@echo cause on stderr 1>&2", "@exit /b 1");
+    }
+    else
+    {
+      writeStartCommand("#!/bin/sh", "echo 'cause on stdout'", "echo 'cause on stderr' >&2", "exit 1");
+    }
+  }
+
+  private void writeStartCommand(String... lines) throws IOException
+  {
+    Installation installation = new Installation(root, root);
+    File command = installation.getServerStartCommandFile();
+    assertTrue(command.getParentFile().mkdirs());
+    Files.write(command.toPath(), String.join(isWindows() ? "\r\n" : "\n", lines).concat("\n")
+        .getBytes(StandardCharsets.US_ASCII));
+    assertTrue(command.setExecutable(true));
+  }
+
+  private String startAndExpectFailure(Application application, boolean suppressOutput)
+  {
+    try
+    {
+      new ServerController(application, new Installation(root, root)).startServer(suppressOutput);
+      fail("The start command exited with 1, but the start did not fail");
+      return null;
+    }
+    catch (ApplicationException e)
+    {
+      assertEquals(e.getType(), ReturnCode.START_ERROR);
+      return e.getMessage();
+    }
+  }
+
+  /** An application whose listener keeps the log details it is shown. */
+  private static final class ListeningApplication extends Application
+  {
+    private final StringBuilder shown = new StringBuilder();
+
+    ListeningApplication()
+    {
+      setProgressMessageFormatter(new PlainTextProgressMessageFormatter());
+      addProgressUpdateListener(ev ->
+      {
+        synchronized (shown)
+        {
+          shown.append(ev.getNewLogs());
+        }
+      });
+    }
+
+    String getShown()
+    {
+      synchronized (shown)
+      {
+        return shown.toString();
+      }
+    }
+
+    @Override
+    public String getInstallationPath()
+    {
+      return null;
+    }
+
+    @Override
+    public String getInstancePath()
+    {
+      return null;
+    }
+
+    @Override
+    public ProgressStep getCurrentProgressStep()
+    {
+      return null;
+    }
+
+    @Override
+    public Integer getRatio(ProgressStep step)
+    {
+      return null;
+    }
+
+    @Override
+    public LocalizableMessage getSummary(ProgressStep step)
+    {
+      return null;
+    }
+
+    @Override
+    public boolean isFinished()
+    {
+      return false;
+    }
+
+    @Override
+    public boolean isCancellable()
+    {
+      return false;
+    }
+
+    @Override
+    public void cancel()
+    {
+      // Nothing to cancel.
+    }
+
+    @Override
+    public void run()
+    {
+      // Never run: the test drives the ServerController directly.
+    }
+  }
+}

--
Gitblit v1.10.0