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