From 0624f9657e2877af5fce8c4b19e94075ce254bcf Mon Sep 17 00:00:00 2001 From: Laurens Date: Tue, 21 Apr 2026 10:58:01 +0200 Subject: [PATCH] Fix webapp worker thread and classloader leak on undeploy Three issues prevented clean shutdown of Argon2Function and BalloonHashingFunction executors in a servlet container lifecycle: 1. createExecutorService() registered a new Runtime shutdown hook on every call. Webapps that redeploy accumulated these hooks indefinitely; each hook pinned the undeployed webapp's classloader via its lambda, preventing garbage collection. 2. No public API existed to stop the workers. Runtime shutdown hooks only fire on JVM exit, not servlet contextDestroyed, so Tomcat's clearReferencesThreads consistently warned about password4j-worker threads on undeploy. 3. Even if a caller shut the executor down via reflection, the Argon2Function/BalloonHashingFunction INSTANCES caches still held references to instances whose executors had terminated, breaking subsequent hash calls. Changes: - Utils: single managed shutdown hook plus a static registry of executors. Registration is idempotent (guarded by a volatile hook reference). - Password.shutdown(): public static entry point to be called from ServletContextListener.contextDestroyed. Shuts down every tracked executor, clears the function-instance caches so future calls rebuild cleanly, and removes the JVM shutdown hook to free the classloader. - Argon2Function/BalloonHashingFunction: package-private clearInstances() used by the shutdown path. - PasswordTest: regression test covering thread termination and reuse after shutdown. Fixes #171 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/com/password4j/Argon2Function.java | 5 ++ .../password4j/BalloonHashingFunction.java | 5 ++ src/main/java/com/password4j/Password.java | 18 ++++++ src/main/java/com/password4j/Utils.java | 56 ++++++++++++++++++- src/test/com/password4j/PasswordTest.java | 47 ++++++++++++++++ 5 files changed, 128 insertions(+), 3 deletions(-) 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; + } }