diff --git a/src/main/java/com/password4j/Argon2Function.java b/src/main/java/com/password4j/Argon2Function.java index 40e1cee..576209b 100755 --- a/src/main/java/com/password4j/Argon2Function.java +++ b/src/main/java/com/password4j/Argon2Function.java @@ -166,6 +166,11 @@ protected static String getUID(int memory, int iterations, int parallelism, int return memory + "|" + iterations + "|" + parallelism + "|" + outputLength + "|" + type.ordinal() + "|" + version; } + static void clearInstances() + { + INSTANCES.clear(); + } + private static byte[] getInitialHashLong(byte[] initialHash, byte[] appendix) { byte[] initialHashLong = new byte[ARGON2_INITIAL_SEED_LENGTH]; diff --git a/src/main/java/com/password4j/BalloonHashingFunction.java b/src/main/java/com/password4j/BalloonHashingFunction.java index 785161e..11dc9c8 100644 --- a/src/main/java/com/password4j/BalloonHashingFunction.java +++ b/src/main/java/com/password4j/BalloonHashingFunction.java @@ -88,6 +88,11 @@ private static String getUID(String algorithm, int spaceCost, int timeCost, int return algorithm + '|' + spaceCost + '|' + timeCost + '|' + parallelism + '|' + delta; } + static void clearInstances() + { + INSTANCES.clear(); + } + protected static String toString(String algorithm, int spaceCost, int timeCost, int parallelism, int delta) { return "a=" + algorithm + ", s=" + spaceCost + ", t=" + timeCost + ", p=" + parallelism + ", d=" + delta; diff --git a/src/main/java/com/password4j/Password.java b/src/main/java/com/password4j/Password.java index cc0a508..79c7d69 100755 --- a/src/main/java/com/password4j/Password.java +++ b/src/main/java/com/password4j/Password.java @@ -42,6 +42,24 @@ private Password() // } + /** + * Shuts down all internal worker thread pools used by parallel hashing functions + * (Argon2, Balloon) and clears their cached instances. This is intended for + * environments with a lifecycle shorter than the JVM — typically servlet + * containers, where it should be invoked from {@code ServletContextListener.contextDestroyed} + * to prevent thread/classloader leaks on webapp undeploy. + *

+ * After this call, subsequent hashing requests will lazily re-create the workers. + * In a plain-JVM application it is not necessary to invoke this method: worker + * threads are daemons and are shut down automatically when the JVM exits. + * + * @since 1.8.5 + */ + public static void shutdown() + { + Utils.shutdownExecutors(); + } + /** * Starts to hash the given plain text password. *

diff --git a/src/main/java/com/password4j/Utils.java b/src/main/java/com/password4j/Utils.java index 6be8026..e24e477 100755 --- a/src/main/java/com/password4j/Utils.java +++ b/src/main/java/com/password4j/Utils.java @@ -29,9 +29,12 @@ import java.nio.charset.StandardCharsets; import java.security.*; import java.util.Arrays; +import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Random; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; @@ -55,6 +58,10 @@ class Utils private static final ThreadGroup THREAD_GROUP = new ThreadGroup("Password4j Workers"); + private static final Set EXECUTORS = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + private static volatile Thread shutdownHook; + static { Arrays.fill(FROM_BASE64, -1); @@ -669,12 +676,55 @@ static ExecutorService createExecutorService() return thread; }); - addShutdownHook(executorService); + EXECUTORS.add(executorService); + ensureShutdownHook(); return executorService; } - static void addShutdownHook(ExecutorService executorService) + private static synchronized void ensureShutdownHook() { - Runtime.getRuntime().addShutdownHook(new Thread(executorService::shutdownNow, "password4j-shutdownhook")); + if (shutdownHook != null) + { + return; + } + Thread hook = new Thread(() -> { + for (ExecutorService service : EXECUTORS) + { + service.shutdownNow(); + } + EXECUTORS.clear(); + }, "password4j-shutdownhook"); + try + { + Runtime.getRuntime().addShutdownHook(hook); + shutdownHook = hook; + } + catch (IllegalStateException e) + { + // JVM is already shutting down; no hook needed. + } + } + + static synchronized void shutdownExecutors() + { + for (ExecutorService service : EXECUTORS) + { + service.shutdownNow(); + } + EXECUTORS.clear(); + Argon2Function.clearInstances(); + BalloonHashingFunction.clearInstances(); + if (shutdownHook != null) + { + try + { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } + catch (IllegalStateException e) + { + // JVM is shutting down; the hook is running or finished. Nothing to do. + } + shutdownHook = null; + } } } diff --git a/src/test/com/password4j/PasswordTest.java b/src/test/com/password4j/PasswordTest.java index 56d8872..017ff9d 100755 --- a/src/test/com/password4j/PasswordTest.java +++ b/src/test/com/password4j/PasswordTest.java @@ -1272,4 +1272,51 @@ public PermissionCollection getPermissions(CodeSource codesource) return; } } + + @Test + public void shutdownStopsWorkerThreadsAndPermitsReuse() + { + // Prime the pool so worker threads exist. + Argon2Function argon2 = Argon2Function.getInstance(256, 1, 2, 32, Argon2.ID); + Password.hash("password").with(argon2); + assertTrue("worker threads should exist before shutdown", workerThreadCount() > 0); + + Password.shutdown(); + + // After shutdown the workers should wind down quickly (daemon interrupt). + long deadline = System.currentTimeMillis() + 2000; + while (workerThreadCount() > 0 && System.currentTimeMillis() < deadline) + { + try + { + Thread.sleep(20); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + break; + } + } + assertEquals("worker threads must be terminated after shutdown", 0, workerThreadCount()); + + // Hashing must still work afterwards: a fresh pool is created lazily. + Argon2Function argon2Again = Argon2Function.getInstance(256, 1, 2, 32, Argon2.ID); + Hash hash = Password.hash("password").with(argon2Again); + assertNotNull(hash.getResult()); + assertTrue("worker threads should exist again after reuse", workerThreadCount() > 0); + Password.shutdown(); + } + + private static int workerThreadCount() + { + int count = 0; + for (Thread thread : Thread.getAllStackTraces().keySet()) + { + if (thread.isAlive() && thread.getName().startsWith("password4j-worker-")) + { + count++; + } + } + return count; + } }