From 37b9647aa845308648fa1450cc1a763f9bb52c94 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Fri, 31 Jul 2026 17:01:46 +0000
Subject: [PATCH] [#797] Retry the deletion of the embedded server temporary directory on close() (#798)

---
 opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/ConfigTest.java                |  124 ++++++++
 opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/Config.java                    |   98 ++++++
 opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJTest.java        |   94 ++++--
 opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJ.java            |  299 +++++++++++++++++---
 opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJCleanupTest.java |  181 ++++++++++++
 5 files changed, 711 insertions(+), 85 deletions(-)

diff --git a/opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/Config.java b/opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/Config.java
index 143c867..fe314c8 100644
--- a/opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/Config.java
+++ b/opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/Config.java
@@ -11,7 +11,7 @@
  * Header, with the fields enclosed by brackets [] replaced by your own identifying
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
- * Copyright 2024 3A Systems LLC.
+ * Copyright 2024-2026 3A Systems LLC.
  */
 
 package org.openidentityplatform.opendj.embedded;
@@ -23,9 +23,9 @@
 public class Config {
 
     private final String CONFIG_PREFIX = Config.class.getPackage().getName();
-    private int port = Integer.parseInt(System.getProperty(CONFIG_PREFIX + ".port", "1389"));
+    private int port = intProperty(CONFIG_PREFIX + ".port", "1389");
 
-    private int adminPort = Integer.parseInt(System.getProperty(CONFIG_PREFIX + ".admin_port", "4444"));
+    private int adminPort = intProperty(CONFIG_PREFIX + ".admin_port", "4444");
 
     private String adminPassword = System.getProperty(CONFIG_PREFIX + ".password", "passw0rd");
 
@@ -33,7 +33,7 @@
 
     private String backendType = System.getProperty(CONFIG_PREFIX + ".backend", "je");
 
-    private int jmxPort = Integer.parseInt(System.getProperty(CONFIG_PREFIX + ".jmx_port", "1689"));
+    private int jmxPort = intProperty(CONFIG_PREFIX + ".jmx_port", "1689");
 
     private String ldifSchema = System.getProperty(CONFIG_PREFIX + ".ldif.schema");
 
@@ -41,6 +41,64 @@
 
     private Set<String> skipSet = new HashSet<>(Arrays.asList(System.getProperty(CONFIG_PREFIX + ".skip", ",ou=sample-skip-group,").toLowerCase().split(";")));
 
+    private long deleteTimeout = timeoutProperty(CONFIG_PREFIX + ".delete_timeout", "10000");
+
+    /**
+     * Returns the value of a system property holding a number.
+     * <p>
+     * These fields are initialized when the instance is created, so an unhandled
+     * {@link NumberFormatException} would surface from the constructor of
+     * {@link EmbeddedOpenDJ} saying only which string was rejected, without naming the
+     * property that carried it.
+     *
+     * @param name
+     *            the name of the system property
+     * @param defaultValue
+     *            the value used when the property is not set
+     * @return the value of the property, or {@code defaultValue} when it is not set
+     * @throws IllegalArgumentException
+     *             If the value of the property is not a number.
+     */
+    private static int intProperty(String name, String defaultValue) {
+        final String value = System.getProperty(name, defaultValue);
+        try {
+            return Integer.parseInt(value);
+        } catch (NumberFormatException e) {
+            throw invalidProperty(name, value, e);
+        }
+    }
+
+    /**
+     * Returns the value of a system property holding a duration in milliseconds.
+     *
+     * @param name
+     *            the name of the system property
+     * @param defaultValue
+     *            the value used when the property is not set
+     * @return the value of the property, or {@code defaultValue} when it is not set
+     * @throws IllegalArgumentException
+     *             If the value of the property is not a number, or is negative.
+     */
+    private static long timeoutProperty(String name, String defaultValue) {
+        final String value = System.getProperty(name, defaultValue);
+        final long timeout;
+        try {
+            timeout = Long.parseLong(value);
+        } catch (NumberFormatException e) {
+            throw invalidProperty(name, value, e);
+        }
+        if (timeout < 0) {
+            throw new IllegalArgumentException("Invalid value \"" + value + "\" for the system property "
+                    + name + ": a timeout in milliseconds cannot be negative");
+        }
+        return timeout;
+    }
+
+    private static IllegalArgumentException invalidProperty(String name, String value, NumberFormatException cause) {
+        return new IllegalArgumentException("Invalid value \"" + value + "\" for the system property "
+                + name + ": expected a number", cause);
+    }
+
     public int getPort() {
         return port;
     }
@@ -115,6 +173,37 @@
         this.skipSet = skipSet;
     }
 
+    /**
+     * Returns how long {@link EmbeddedOpenDJ#close()} retries the deletion of the temporary
+     * directory of the instance, in milliseconds.
+     * <p>
+     * The deletion has to be retried because a file cannot be deleted on Windows while a
+     * handle to it is still open, and the server threads release their handles shortly after
+     * the server has been stopped. Whatever is still locked when this expires is scheduled for
+     * deletion on JVM exit. Set it to {@code 0} to delete once and never wait.
+     *
+     * @return the deletion timeout in milliseconds
+     */
+    public long getDeleteTimeout() {
+        return deleteTimeout;
+    }
+
+    /**
+     * Sets how long {@link EmbeddedOpenDJ#close()} retries the deletion of the temporary
+     * directory of the instance.
+     *
+     * @param deleteTimeout
+     *            the deletion timeout in milliseconds, {@code 0} to delete once and never wait
+     * @throws IllegalArgumentException
+     *             If the timeout is negative.
+     */
+    public void setDeleteTimeout(long deleteTimeout) {
+        if (deleteTimeout < 0) {
+            throw new IllegalArgumentException("The delete timeout cannot be negative, but was " + deleteTimeout);
+        }
+        this.deleteTimeout = deleteTimeout;
+    }
+
     @Override
     public String toString() {
         return "Config {" +
@@ -127,6 +216,7 @@
                 ", ldifSchema='" + ldifSchema + '\'' +
                 ", file='" + file + '\'' +
                 ", skipSet=" + skipSet +
+                ", deleteTimeout=" + deleteTimeout +
                 '}';
     }
 }
diff --git a/opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJ.java b/opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJ.java
index d2ef004..b9e336c 100644
--- a/opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJ.java
+++ b/opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJ.java
@@ -51,10 +51,30 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
 
 public class EmbeddedOpenDJ implements Runnable, Closeable {
     private static final String JAR_SCHEMA_DIRECTORY = "opendj/config/schema/";
 
+    private static final String ARCHIVE_NAME = "opendj.zip";
+
+    /**
+     * How long the deletion of the instance directory is retried when there is no point in
+     * waiting for the full {@link Config#getDeleteTimeout() configured timeout}: from the
+     * shutdown hook, where a longer wait only makes Control-C look hung, and from the
+     * constructor, where an initialization failure has to be reported promptly. What is still
+     * locked when this expires is deleted on JVM exit anyway.
+     */
+    private static final long SHORT_DELETE_TIMEOUT_MS = 1_000L;
+
+    private static final long INITIAL_DELETE_RETRY_DELAY_MS = 50L;
+
+    private static final long MAX_DELETE_RETRY_DELAY_MS = 500L;
+
+    /** Upper bound on the number of leftover paths reported when the deletion fails. */
+    private static final int MAX_REPORTED_REMAINING_PATHS = 20;
+
     final static Logger logger = LoggerFactory.getLogger(EmbeddedOpenDJ.class.getName());
     final EmbeddedDirectoryServer server;
 
@@ -62,6 +82,10 @@
 
     private final File instanceDirectory;
     private final File rootDirectory;
+    private final Thread shutdownHook;
+
+    /** Written under {@code this}, read without locking so that a running {@link #close()} blocks nothing. */
+    private volatile boolean closed;
 
     public EmbeddedOpenDJ() {
         this(new Config());
@@ -82,7 +106,10 @@
             // deleted on close().
             instanceDirectory = Files.createTempDirectory("opendj").toFile();
             File rootDirectory = new File(instanceDirectory, "opendj");
-            rootDirectory.mkdir();
+            if (!rootDirectory.mkdir()) {
+                // the parent has just been created, so this only fails on a real filesystem error
+                throw new IOException("Cannot create the server root directory " + rootDirectory);
+            }
             logger.info("OpenDJ server root: {}", rootDirectory);
 
             File configDirectory = new File(rootDirectory, "config");
@@ -100,8 +127,15 @@
                     System.out,
                     System.err);
 
-            copyFilesFromJar(Collections.singletonList("opendj.zip"),"embedded-opendj/",rootDirectory);
-            server.extractArchiveForSetup(new File(rootDirectory,"opendj.zip"));
+            copyFilesFromJar(Collections.singletonList(ARCHIVE_NAME),"embedded-opendj/",rootDirectory);
+            final File archive = new File(rootDirectory, ARCHIVE_NAME);
+            server.extractArchiveForSetup(archive);
+            // The archive is only needed for the extraction above. Keeping it would leave a
+            // full copy of the distribution in the temporary directory and one more file to
+            // delete on close().
+            if (!archive.delete()) {
+                logger.warn("Cannot delete {} after extracting it", archive);
+            }
 
             server.setup(
                     SetupParameters.setupParams()
@@ -121,32 +155,35 @@
             this.rootDirectory = rootDirectory;
         }catch (Exception e) {
             logger.error("Error initializing OpenDJ");
-            FileUtils.deleteQuietly(instanceDirectory);
+            deleteInstanceDirectory(instanceDirectory, shortDeleteTimeout(config));
             throw new RuntimeException(e);
         }
-        Runtime.getRuntime().addShutdownHook(new Thread(this::close));
+        shutdownHook = new Thread(this::close, "EmbeddedOpenDJ shutdown hook");
+        Runtime.getRuntime().addShutdownHook(shutdownHook);
     }
 
     /**
      * Returns the server root directory of this embedded instance.
      *
      * @return the server root directory
+     * @throws IllegalStateException
+     *             If this instance has been closed, and the directory therefore deleted.
      */
     public File getServerRootDirectory() {
+        checkNotClosed();
         return rootDirectory;
     }
 
     @Override
     public void run() {
+        checkNotClosed();
         try {
             final DN baseDN = DN.valueOf(config.getBaseDN());
-            try {
-                ManagementContext config = server.getConfiguration();
-                BackendCfgClient userRoot = config.getRootConfiguration().getBackend("userRoot");
+            try (ManagementContext managementContext = server.getConfiguration()) {
+                BackendCfgClient userRoot = managementContext.getRootConfiguration().getBackend("userRoot");
                 userRoot.setBaseDN((Collections.singletonList(baseDN)));
                 userRoot.setEnabled(true);
                 userRoot.commit();
-                config.close();
             } catch (Exception e) {
                 throw new RuntimeException(e);
             }
@@ -163,18 +200,167 @@
         }
     }
 
+    /**
+     * Stops this instance, if it is still running, and deletes its temporary directory.
+     * <p>
+     * This method is idempotent: it is also registered as a JVM shutdown hook.
+     * <p>
+     * When the server cannot be stopped, this instance stays open and keeps its shutdown hook
+     * registered, so that a later call - or the hook at JVM exit - retries the stop.
+     */
     @Override
-    public void close()  {
+    public synchronized void close()  {
+        if (closed) {
+            return;
+        }
+        final boolean fromShutdownHook = Thread.currentThread() == shutdownHook;
         if (server.isRunning()) {
             try {
                 logger.info("Shutting down OpenDJ ...");
                 server.stop(this.getClass().getName(), LocalizableMessage.raw("Stopped after receiving Control-C"));
             }catch (Throwable e) {
                 logger.error("Error stopping OpenDJ", e);
+                if (!fromShutdownHook) {
+                    // The server is still running: deleting its directory now would destroy a
+                    // live installation. Leave this instance open, with its shutdown hook still
+                    // registered, so that the stop can be retried.
+                    return;
+                }
+                // The JVM is going down and there will be no later attempt, so the temporary
+                // directory is removed even though the server has not stopped cleanly.
             }
         }
-        // close() is also registered as a shutdown hook, so deletion must stay idempotent
-        FileUtils.deleteQuietly(instanceDirectory);
+        closed = true;
+        unregisterShutdownHook();
+        deleteInstanceDirectory(instanceDirectory,
+                fromShutdownHook ? shortDeleteTimeout(config) : config.getDeleteTimeout());
+    }
+
+    private void checkNotClosed() {
+        if (closed) {
+            throw new IllegalStateException("this embedded OpenDJ instance is closed");
+        }
+    }
+
+    private static long shortDeleteTimeout(Config config) {
+        return Math.min(config.getDeleteTimeout(), SHORT_DELETE_TIMEOUT_MS);
+    }
+
+    private void unregisterShutdownHook() {
+        try {
+            Runtime.getRuntime().removeShutdownHook(shutdownHook);
+        } catch (IllegalStateException e) {
+            // close() was reached from the shutdown hook itself: nothing to unregister
+        }
+    }
+
+    /**
+     * Deletes the temporary directory of an instance, retrying for a bounded period and
+     * falling back to a deletion on JVM exit.
+     * <p>
+     * Deleting once is not enough. On Windows a file cannot be deleted while a handle to it
+     * is still open, and {@code server.stop()} does not wait for the server threads to
+     * terminate: an embedded server runs them as daemon threads, which the shutdown monitor
+     * of the directory server ignores. Some handles are therefore released shortly after
+     * {@code stop()} has returned, and a single best-effort deletion loses that race and
+     * silently leaks the whole directory, backend data included. Retrying is a way to wait
+     * for those handles to be released - a single attempt already removes everything that is
+     * not in use at that moment.
+     *
+     * @param directory
+     *            the directory to delete, may be {@code null} when the instance failed to
+     *            initialize before creating it
+     * @param timeoutMs
+     *            how long the deletion is retried, in milliseconds
+     */
+    static void deleteInstanceDirectory(File directory, long timeoutMs) {
+        deleteInstanceDirectory(directory, timeoutMs, FileUtils::deleteQuietly);
+    }
+
+    /**
+     * Implements {@link #deleteInstanceDirectory(File, long)} with an injectable deletion, so
+     * that tests can drive the retries without depending on a genuinely undeletable file.
+     *
+     * @param deleteAttempt
+     *            performs one deletion attempt and reports whether the directory is gone
+     */
+    static void deleteInstanceDirectory(File directory, long timeoutMs, Predicate<File> deleteAttempt) {
+        if (directory == null || !directory.exists()) {
+            return;
+        }
+        final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs);
+        long retryDelay = INITIAL_DELETE_RETRY_DELAY_MS;
+        while (true) {
+            // The second test covers a directory removed by someone else in the meantime:
+            // deleteQuietly() reports a failure for a directory that is already gone.
+            if (deleteAttempt.test(directory) || !directory.exists()) {
+                return;
+            }
+            if (System.nanoTime() >= deadline) {
+                logger.warn("Cannot delete {} within {} ms, some files are still in use. "
+                        + "They are now scheduled for deletion on JVM exit:{}",
+                        directory, timeoutMs, remainingPaths(directory));
+                break;
+            }
+            try {
+                Thread.sleep(retryDelay);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                logger.warn("Interrupted while deleting {}. What is left is now scheduled "
+                        + "for deletion on JVM exit:{}", directory, remainingPaths(directory));
+                break;
+            }
+            retryDelay = Math.min(retryDelay * 2, MAX_DELETE_RETRY_DELAY_MS);
+        }
+        deleteTreeOnExit(directory);
+    }
+
+    /**
+     * Describes what is left in the given directory: one path per line, at most
+     * {@link #MAX_REPORTED_REMAINING_PATHS} of them, followed by the number of paths left out.
+     */
+    static String remainingPaths(File directory) {
+        final List<String> reported = new ArrayList<>();
+        final int total = collectRemainingPaths(directory, reported);
+        final StringBuilder description = new StringBuilder();
+        for (String path : reported) {
+            description.append("\n  ").append(path);
+        }
+        if (total > reported.size()) {
+            description.append("\n  ... and ").append(total - reported.size()).append(" more");
+        }
+        return description.toString();
+    }
+
+    /**
+     * Adds at most {@link #MAX_REPORTED_REMAINING_PATHS} paths of the given tree to
+     * {@code reported} and returns how many paths it holds in total.
+     */
+    private static int collectRemainingPaths(File file, List<String> reported) {
+        final File[] children = file.listFiles();
+        if (children == null || children.length == 0) {
+            if (reported.size() < MAX_REPORTED_REMAINING_PATHS) {
+                reported.add(file.getPath());
+            }
+            return 1;
+        }
+        int total = 0;
+        for (File child : children) {
+            total += collectRemainingPaths(child, reported);
+        }
+        return total;
+    }
+
+    private static void deleteTreeOnExit(File file) {
+        // File.deleteOnExit() deletes in reverse order of registration, so a directory has to
+        // be registered before its content for the content to be removed first.
+        file.deleteOnExit();
+        final File[] children = file.listFiles();
+        if (children != null) {
+            for (File child : children) {
+                deleteTreeOnExit(child);
+            }
+        }
     }
 
     private void copyFilesFromJar(List<String> jarFiles, String jarDirectory, File outputDirectory) throws IOException{
@@ -183,65 +369,80 @@
             final String resourcePath = !jarFile.contains("/")
                     ? "/"+jarDirectory + jarFile
                     : jarFile;
-            InputStream in = new File(jarFile).exists()
+            try (InputStream in = new File(jarFile).exists()
                     ? Files.newInputStream(new File(jarFile).toPath())
-                    : MemoryBackend.class.getResourceAsStream(resourcePath);
-            if (in == null) {
-                throw new IOException("cannot find " + resourcePath);
+                    : MemoryBackend.class.getResourceAsStream(resourcePath)) {
+                if (in == null) {
+                    throw new IOException("cannot find " + resourcePath);
+                }
+                FileUtils.copyInputStreamToFile(in, outputFile);
             }
-            FileUtils.copyInputStreamToFile(in, outputFile);
-            in.close();
         }
     }
 
+    /**
+     * Imports the LDIF read from the given stream.
+     * <p>
+     * The stream is closed by this method, whether the import succeeds or not.
+     *
+     * @param inputStream
+     *            the LDIF to import
+     * @throws IllegalStateException
+     *             If this instance has been closed.
+     */
     public void importData(InputStream inputStream) throws EmbeddedDirectoryServerException, IOException {
+        checkNotClosed();
         logger.info("start import ldif from stream");
 
-        EntryReader reader;
-        try {
-            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
-            reader = new LDIFEntryReader(bufferedReader);
-        } catch (Exception e) {
-            logger.error("import ldif : {}", e, e);
-            throw e;
-        }
         org.forgerock.opendj.ldap.Entry  entryBefore;
-        final Connection connection = server.getInternalConnection();
         long recordCount = 0;
-        while (reader.hasNext() && (entryBefore = reader.readEntry()) != null) {
-            recordCount++;
-            try {
-                connection.add(entryBefore);
-                logger.info("import ldif : {}",entryBefore.getName());
-            }catch (LdapException e) {
-                logger.error("import ldif : {} {}",entryBefore.getName(),e.toString());
+        try (EntryReader reader = new LDIFEntryReader(new BufferedReader(new InputStreamReader(inputStream)));
+             Connection connection = server.getInternalConnection()) {
+            while (reader.hasNext() && (entryBefore = reader.readEntry()) != null) {
+                recordCount++;
+                try {
+                    connection.add(entryBefore);
+                    logger.info("import ldif : {}",entryBefore.getName());
+                }catch (LdapException e) {
+                    logger.error("import ldif : {} {}",entryBefore.getName(),e.toString());
+                }
             }
         }
         if(recordCount == 0) {
             logger.error("no records were imported, check file contents and permissions");
             throw new RuntimeException("no records were imported");
         }
-        reader.close();
-        connection.close();
     }
 
+    /**
+     * Writes the entries below the given base DN to the given stream, as LDIF.
+     * <p>
+     * The stream is flushed and closed by this method, whether the export succeeds or not.
+     *
+     * @param baseDN
+     *            the base DN of the subtree to export
+     * @param out
+     *            where the LDIF is written
+     * @throws IllegalStateException
+     *             If this instance has been closed.
+     */
     public void getData(String baseDN, OutputStream out) throws IOException, EmbeddedDirectoryServerException {
-        LDIFEntryWriter ldifWriter = new LDIFEntryWriter(out);
-        final Connection connection = server.getInternalConnection();
-
-        ConnectionEntryReader reader = connection.search(baseDN, SearchScope.WHOLE_SUBTREE, "(objectClass=*)");
-        while(reader.hasNext()) {
-            if (!reader.isReference()) {
-                SearchResultEntry se = reader.readEntry();
-                if (!skipEntry(se)) {
-                    ldifWriter.writeEntry(se);
-                    logger.info("export {}", se.toString());
+        checkNotClosed();
+        // resources are closed in reverse order, so the writer is flushed and closed last
+        try (LDIFEntryWriter ldifWriter = new LDIFEntryWriter(out);
+             Connection connection = server.getInternalConnection();
+             ConnectionEntryReader reader =
+                     connection.search(baseDN, SearchScope.WHOLE_SUBTREE, "(objectClass=*)")) {
+            while (reader.hasNext()) {
+                if (!reader.isReference()) {
+                    SearchResultEntry se = reader.readEntry();
+                    if (!skipEntry(se)) {
+                        ldifWriter.writeEntry(se);
+                        logger.info("export {}", se.toString());
+                    }
                 }
             }
         }
-        reader.close();
-        ldifWriter.close();
-        connection.close();
     }
 
     private boolean skipEntry(SearchResultEntry se) {
diff --git a/opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/ConfigTest.java b/opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/ConfigTest.java
new file mode 100644
index 0000000..8d8e442
--- /dev/null
+++ b/opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/ConfigTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.openidentityplatform.opendj.embedded;
+
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
+
+/**
+ * Tests how the numeric configuration properties are read: they are parsed while the instance
+ * is being created, so a value that cannot be parsed has to name the property that carried it
+ * instead of surfacing as a bare {@link NumberFormatException} from a constructor.
+ */
+public class ConfigTest {
+
+    private static final String PREFIX = Config.class.getPackage().getName();
+
+    private final List<String> propertiesSet = new ArrayList<>();
+
+    @AfterMethod
+    public void clearProperties() {
+        for (String name : propertiesSet) {
+            System.clearProperty(name);
+        }
+        propertiesSet.clear();
+    }
+
+    @DataProvider
+    public Object[][] numericProperties() {
+        return new Object[][] { { ".port" }, { ".admin_port" }, { ".jmx_port" }, { ".delete_timeout" } };
+    }
+
+    @Test
+    public void readsTheDefaultsWhenNoPropertyIsSet() {
+        final Config config = new Config();
+
+        assertEquals(config.getPort(), 1389);
+        assertEquals(config.getAdminPort(), 4444);
+        assertEquals(config.getJmxPort(), 1689);
+        assertEquals(config.getDeleteTimeout(), 10_000L);
+    }
+
+    @Test
+    public void readsTheNumbersFromTheSystemProperties() {
+        setProperty(".port", "11389");
+        setProperty(".admin_port", "14444");
+        setProperty(".jmx_port", "11689");
+        setProperty(".delete_timeout", "0");
+
+        final Config config = new Config();
+
+        assertEquals(config.getPort(), 11389);
+        assertEquals(config.getAdminPort(), 14444);
+        assertEquals(config.getJmxPort(), 11689);
+        assertEquals(config.getDeleteTimeout(), 0L);
+    }
+
+    @Test(dataProvider = "numericProperties")
+    public void namesThePropertyWhoseValueIsNotANumber(String property) {
+        setProperty(property, "not a number");
+
+        try {
+            new Config();
+            fail("a value that is not a number should have been rejected");
+        } catch (IllegalArgumentException e) {
+            assertTrue(e.getMessage().contains(PREFIX + property), e.getMessage());
+            assertTrue(e.getMessage().contains("not a number"), e.getMessage());
+            assertTrue(e.getCause() instanceof NumberFormatException, "unexpected cause: " + e.getCause());
+        }
+    }
+
+    @Test
+    public void rejectsANegativeDeleteTimeoutProperty() {
+        setProperty(".delete_timeout", "-1");
+
+        try {
+            new Config();
+            fail("a negative timeout should have been rejected");
+        } catch (IllegalArgumentException e) {
+            assertTrue(e.getMessage().contains(PREFIX + ".delete_timeout"), e.getMessage());
+            assertTrue(e.getMessage().contains("negative"), e.getMessage());
+        }
+    }
+
+    @Test
+    public void rejectsANegativeDeleteTimeout() {
+        final Config config = new Config();
+
+        try {
+            config.setDeleteTimeout(-1);
+            fail("a negative timeout should have been rejected");
+        } catch (IllegalArgumentException e) {
+            assertTrue(e.getMessage().contains("-1"), e.getMessage());
+        }
+        assertEquals(config.getDeleteTimeout(), 10_000L, "the timeout has been changed");
+    }
+
+    private void setProperty(String property, String value) {
+        final String name = PREFIX + property;
+        System.setProperty(name, value);
+        propertiesSet.add(name);
+    }
+}
diff --git a/opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJCleanupTest.java b/opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJCleanupTest.java
new file mode 100644
index 0000000..f959c29
--- /dev/null
+++ b/opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJCleanupTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.openidentityplatform.opendj.embedded;
+
+import org.apache.commons.io.FileUtils;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Tests the deletion of the temporary directory of an embedded instance, which has to survive
+ * the files that the server threads are still holding when {@code stop()} returns.
+ * <p>
+ * The deletion itself is injected, so that the retries can be driven without depending on a
+ * genuinely undeletable file, which no platform offers in a portable way.
+ */
+public class EmbeddedOpenDJCleanupTest {
+
+    private final List<File> temporaryDirectories = new ArrayList<>();
+
+    @AfterMethod
+    public void deleteTemporaryDirectories() {
+        for (File directory : temporaryDirectories) {
+            FileUtils.deleteQuietly(directory);
+        }
+        temporaryDirectories.clear();
+    }
+
+    @Test
+    public void deletesTheWholeTree() throws Exception {
+        final File directory = createTree(3);
+
+        EmbeddedOpenDJ.deleteInstanceDirectory(directory, 0);
+
+        assertFalse(directory.exists(), directory + " has not been deleted");
+    }
+
+    @Test
+    public void ignoresADirectoryThatDoesNotExist() throws Exception {
+        final File directory = createTree(0);
+        assertTrue(FileUtils.deleteQuietly(directory));
+
+        //neither of these must throw
+        EmbeddedOpenDJ.deleteInstanceDirectory(directory, 10_000);
+        EmbeddedOpenDJ.deleteInstanceDirectory(null, 10_000);
+    }
+
+    @Test
+    public void stopsRetryingAsSoonAsTheDeletionSucceeds() throws Exception {
+        final File directory = createTree(1);
+        final AtomicInteger attempts = new AtomicInteger();
+
+        final long elapsedMs = timed(() ->
+                EmbeddedOpenDJ.deleteInstanceDirectory(directory, 10_000, file -> attempts.incrementAndGet() >= 3));
+
+        assertEquals(attempts.get(), 3, "the deletion should have been retried until it succeeded");
+        assertTrue(elapsedMs < 10_000, "it waited " + elapsedMs + " ms instead of returning on success");
+    }
+
+    @Test
+    public void triesOnlyOnceWithoutATimeout() throws Exception {
+        final File directory = createTree(1);
+        final AtomicInteger attempts = new AtomicInteger();
+
+        EmbeddedOpenDJ.deleteInstanceDirectory(directory, 0, file -> {
+            attempts.incrementAndGet();
+            return false;
+        });
+
+        assertEquals(attempts.get(), 1, "a zero timeout must not retry");
+        assertTrue(directory.exists(), "the injected deletion did not delete anything");
+    }
+
+    @Test
+    public void givesUpWhenTheTimeoutExpires() throws Exception {
+        final File directory = createTree(1);
+        final AtomicInteger attempts = new AtomicInteger();
+        final long timeoutMs = 300;
+
+        final long elapsedMs = timed(() -> EmbeddedOpenDJ.deleteInstanceDirectory(directory, timeoutMs, file -> {
+            attempts.incrementAndGet();
+            return false;
+        }));
+
+        assertTrue(attempts.get() > 1, "the deletion should have been retried, attempts: " + attempts.get());
+        assertTrue(elapsedMs >= timeoutMs, "it gave up after " + elapsedMs + " ms, before the timeout");
+        assertTrue(elapsedMs < TimeUnit.SECONDS.toMillis(30), "it did not give up, " + elapsedMs + " ms elapsed");
+        //the leftovers are registered for deletion on JVM exit, so this must return normally
+        assertTrue(directory.exists());
+    }
+
+    @Test
+    public void givesUpWhenTheThreadIsInterrupted() throws Exception {
+        final File directory = createTree(1);
+        final AtomicInteger attempts = new AtomicInteger();
+
+        final long elapsedMs;
+        Thread.currentThread().interrupt();
+        try {
+            elapsedMs = timed(() -> EmbeddedOpenDJ.deleteInstanceDirectory(directory, 60_000, file -> {
+                attempts.incrementAndGet();
+                return false;
+            }));
+            assertTrue(Thread.currentThread().isInterrupted(), "the interrupted status has been swallowed");
+        } finally {
+            Thread.interrupted();
+        }
+
+        assertEquals(attempts.get(), 1, "an interrupted deletion must not be retried");
+        assertTrue(elapsedMs < TimeUnit.SECONDS.toMillis(30), "it kept retrying for " + elapsedMs + " ms");
+    }
+
+    @Test
+    public void reportsTheRemainingPathsAndHowManyAreLeftOut() throws Exception {
+        final File directory = createTree(25);
+
+        final String remaining = EmbeddedOpenDJ.remainingPaths(directory);
+
+        final String[] lines = remaining.split("\n");
+        //one empty leading token before the first line separator, 20 paths and the summary
+        assertEquals(lines.length, 22, "unexpected report: " + remaining);
+        for (int i = 1; i < lines.length - 1; i++) {
+            assertTrue(lines[i].startsWith("  " + directory.getPath()), "unexpected line: " + lines[i]);
+        }
+        assertTrue(remaining.endsWith("... and 5 more"), remaining);
+    }
+
+    @Test
+    public void reportsAllTheRemainingPathsWhenThereAreFewOfThem() throws Exception {
+        final File directory = createTree(2);
+
+        final String remaining = EmbeddedOpenDJ.remainingPaths(directory);
+
+        assertEquals(remaining.split("\n").length, 3, "unexpected report: " + remaining);
+        assertFalse(remaining.contains("more"), remaining);
+    }
+
+    /** Creates a temporary directory holding the given number of files, one of them nested. */
+    private File createTree(int fileCount) throws IOException {
+        final File directory = Files.createTempDirectory("opendj-cleanup-test").toFile();
+        temporaryDirectories.add(directory);
+        final File subDirectory = new File(directory, "nested");
+        assertTrue(subDirectory.mkdir());
+        for (int i = 0; i < fileCount; i++) {
+            final File parent = i == 0 ? subDirectory : directory;
+            Files.write(new File(parent, "file" + i + ".txt").toPath(), new byte[] { 'x' });
+        }
+        return directory;
+    }
+
+    private static long timed(Runnable runnable) {
+        final long start = System.nanoTime();
+        runnable.run();
+        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+    }
+}
diff --git a/opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJTest.java b/opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJTest.java
index d776d1e..a434fd8 100644
--- a/opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJTest.java
+++ b/opendj-embedded/src/test/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJTest.java
@@ -26,16 +26,19 @@
 import org.forgerock.opendj.ldif.ConnectionEntryReader;
 import org.testng.annotations.Test;
 
-import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
+import java.io.IOException;
 import java.io.InputStream;
 import java.net.URI;
-import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.stream.Stream;
 
+import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
 
 public class EmbeddedOpenDJTest {
@@ -51,44 +54,71 @@
 
         //start embedded OpenDJ server
         EmbeddedOpenDJ embeddedOpenDJ = new EmbeddedOpenDJ(config);
-        embeddedOpenDJ.run();
-        assertTrue(embeddedOpenDJ.isRunning());
-
         File serverRoot = embeddedOpenDJ.getServerRootDirectory();
-        assertTrue(serverRoot.isDirectory());
+        try {
+            embeddedOpenDJ.run();
+            assertTrue(embeddedOpenDJ.isRunning());
+            assertTrue(serverRoot.isDirectory());
 
-        //import ldif data from an input stream
-        URI resUri = getClass().getClassLoader().getResource("opendj/data.ldif").toURI();
-        byte[] bytes = Files.readAllBytes(Paths.get(resUri));
-        String newBytes = new String(bytes);
-        InputStream is = new ByteArrayInputStream(newBytes.getBytes(StandardCharsets.UTF_8));
-        embeddedOpenDJ.importData(is);
+            //import ldif data from an input stream
+            URI resUri = getClass().getClassLoader().getResource("opendj/data.ldif").toURI();
+            try (InputStream is = Files.newInputStream(Paths.get(resUri))) {
+                embeddedOpenDJ.importData(is);
+            }
 
-        //export OpenDJ data
-        ByteArrayOutputStream bos = new ByteArrayOutputStream();
-        embeddedOpenDJ.getData("dc=openidentityplatform,dc=org", bos);
-        String imported = bos.toString();
-        assertTrue(imported.contains("dn: uid=jdoe,ou=people,dc=openidentityplatform,dc=org"));
+            //export OpenDJ data
+            ByteArrayOutputStream bos = new ByteArrayOutputStream();
+            embeddedOpenDJ.getData(config.getBaseDN(), bos);
+            //getData() writes LDIF in the platform default charset, so it has to be read back
+            //in the same one: LDIFEntryWriter(OutputStream) wraps it in a plain OutputStreamWriter
+            String imported = bos.toString();
+            assertTrue(imported.contains("dn: uid=jdoe,ou=people," + config.getBaseDN()));
 
-        //test search in the imported data
-        try(LDAPConnectionFactory factory = new LDAPConnectionFactory("localhost", 1389);
-            Connection connection = factory.getConnection()) {
-            BindResult result = connection.bind("cn=Directory Manager", "passw0rd".toCharArray());
-            assertTrue(result.isSuccess());
+            //test search in the imported data
+            try (LDAPConnectionFactory factory = new LDAPConnectionFactory("localhost", config.getPort());
+                 Connection connection = factory.getConnection()) {
+                BindResult result = connection.bind("cn=Directory Manager", config.getAdminPassword().toCharArray());
+                assertTrue(result.isSuccess());
 
-            SearchRequest request = Requests.newSearchRequest("dc=openidentityplatform,dc=org",
-                    SearchScope.WHOLE_SUBTREE, "(uid=jdoe)", "uid");
-            ConnectionEntryReader reader = connection.search(request);
-            SearchResultEntry entry = reader.readEntry();
-            entry.getAllAttributes();
+                SearchRequest request = Requests.newSearchRequest(config.getBaseDN(),
+                        SearchScope.WHOLE_SUBTREE, "(uid=jdoe)", "uid");
+                try (ConnectionEntryReader reader = connection.search(request)) {
+                    SearchResultEntry entry = reader.readEntry();
+                    assertEquals(entry.getName().toString(), "uid=jdoe,ou=people," + config.getBaseDN());
+                    assertEquals(entry.parseAttribute("uid").asString(), "jdoe");
+                    assertFalse(reader.hasNext(), "the search returned more than one entry");
+                }
+            }
+        } finally {
+            //stop OpenDJ
+            embeddedOpenDJ.close();
         }
-
-        //stop OpenDJ
-        embeddedOpenDJ.close();
         assertFalse(embeddedOpenDJ.isRunning());
 
         //the per-instance temporary directory is deleted on close
-        assertFalse(serverRoot.exists());
-        assertFalse(serverRoot.getParentFile().exists());
+        assertFalse(serverRoot.exists(), leftovers(serverRoot));
+        assertFalse(serverRoot.getParentFile().exists(), leftovers(serverRoot.getParentFile()));
+
+        //a closed instance cannot be used any more: its directory is gone
+        assertThrows(IllegalStateException.class, embeddedOpenDJ::run);
+        assertThrows(IllegalStateException.class, embeddedOpenDJ::getServerRootDirectory);
+        assertThrows(IllegalStateException.class, () -> embeddedOpenDJ.getData(config.getBaseDN(), new ByteArrayOutputStream()));
+
+        //close() is idempotent, it is also registered as a shutdown hook
+        embeddedOpenDJ.close();
+    }
+
+    /** Describes what is left in the given directory, to make an assertion failure diagnosable. */
+    private static String leftovers(File directory) {
+        if (!directory.exists()) {
+            return "";
+        }
+        final StringBuilder message = new StringBuilder(directory + " still exists and contains:");
+        try (Stream<Path> paths = Files.walk(directory.toPath())) {
+            paths.forEach(path -> message.append("\n  ").append(path));
+        } catch (IOException e) {
+            message.append(" <cannot be listed: ").append(e).append('>');
+        }
+        return message.toString();
     }
 }

--
Gitblit v1.10.0