postgresql-jdbc.git / summary / log / commit / refs

commit    bdd930bf458e3d490d93edc4dc24a67e3a2d5964
Author:   Vladimir Sitnikov <sitnikov.vladimir@gmail.com>
Date:     Wed Dec 03 07:37:35 2025 +0000

    fix: avoid memory leaks caused by Thread.inheritedAccessControlContext
    
    Thread.inheritedAccessControlContext inherits the caller ProtectionDomain, thus
    it effectively holds its classloader.
    There's no way to "unset inheritedAccessControlContext".
    
    For Java 11+ use java.lang.ref.Cleaner-based approach via multi-release jar
    
    We workaround that by going for ForkJoinPool + ManagedBlocker so it is JDK
    that creates a thread, thus it should have no inheritedAccessControlContext.
    
    The logic is as follows:
    1) ForkJoinPool task runs on CPU and it processes the queue
    2) If the queue is empty, the blocking call delegates to ManagedBlocker
    
    It fits nice with FJP: CPU tasks do not create extra threads, and blocking
    queue operations do not starve FJP CPU threads.
    
    See https://stackoverflow.com/questions/78163385/does-postgres-jdbc-lazycleaner-cause-a-classloader-memory-leak/79835942


.github/workflows/main.yml | 2 +- .../kotlin/build-logic.checkerframework.gradle.kts | 28 +-- pgjdbc/build.gradle.kts | 38 ++++ .../java/org/postgresql/jdbc/PgConnection.java | 3 +- .../main/java/org/postgresql/util/LazyCleaner.java | 37 ++++ .../java/org/postgresql/util/LazyCleanerImpl.java | 202 ++++++++++++--------- .../main/java/org/postgresql/util/SharedTimer.java | 2 +- .../java/org/postgresql/util/StreamWrapper.java | 2 +- .../org/postgresql/util/LazyCleanerImpl.java | 151 +++++++++++++++ .../WindowsSecurityContextImpl.astub | 0 .../org/postgresql/test/jdbc2/StatementTest.java | 3 +- .../java/org/postgresql/util/LazyCleanerTest.java | 162 +++++++++++------ 12 files changed, 474 insertions(+), 156 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 80d4ff34..888ebf0f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -79,7 +79,7 @@ jobs: with: read-only: false job-id: checker-jdk21 - arguments: --scan --no-parallel --no-daemon -PenableCheckerframework classes + arguments: --scan --no-parallel --no-daemon -PenableCheckerframework classes :postgresql:compileJava11 source-distribution-check: name: Source distribution diff --git a/build-logic/verification/src/main/kotlin/build-logic.checkerframework.gradle.kts b/build-logic/verification/src/main/kotlin/build-logic.checkerframework.gradle.kts index 6d133910..2a0db7d7 100644 --- a/build-logic/verification/src/main/kotlin/build-logic.checkerframework.gradle.kts +++ b/build-logic/verification/src/main/kotlin/build-logic.checkerframework.gradle.kts @@ -3,6 +3,7 @@ import org.gradle.kotlin.dsl.dependencies plugins { id("build-logic.build-params") id("org.checkerframework") + id("java") } dependencies { @@ -33,21 +34,26 @@ checkerFramework { checkers.add("org.checkerframework.checker.optional.OptionalChecker") // checkers.add("org.checkerframework.checker.index.IndexChecker") checkers.add("org.checkerframework.checker.regex.RegexChecker") - extraJavacArgs.add("-Astubs=" + - files( + // Translation classes are autogenerated + extraJavacArgs.add("-AskipDefs=^org\\.postgresql\\.translation\\.") + // The below produces too many warnings :( + // extraJavacArgs.add("-Alint=redundantNullComparison") +} + +sourceSets.all { + val sourceSetName = name + // Every sourceset + tasks.named<JavaCompile>(compileJavaTaskName) { + options.compilerArgs.add( + "-Astubs=" + files( // Global stubs fileTree("$rootDir/config/checkerframework") { include("*.astub") }, - // Project-local stubs - fileTree(layout.projectDirectory.dir("src/main/checkerframework")) { + fileTree(layout.projectDirectory.dir("src/${sourceSetName}Checkerframework")) { include("*.astub") }, - ) - .asPath - ) - // Translation classes are autogenerated, and they - extraJavacArgs.add("-AskipDefs=^org\\.postgresql\\.translation\\.") - // The below produces too many warnings :( - // extraJavacArgs.add("-Alint=redundantNullComparison") + ).asPath + ) + } } diff --git a/pgjdbc/build.gradle.kts b/pgjdbc/build.gradle.kts index 65838f88..2f8b8a73 100644 --- a/pgjdbc/build.gradle.kts +++ b/pgjdbc/build.gradle.kts @@ -46,6 +46,41 @@ java { } } +// Create a separate source set for Java 11+ specific code (e.g., java.lang.ref.Cleaner) +val java11 by sourceSets.creating { + java { + srcDir("src/main/java11") + } + // Make java11 source set depend on main source set (to access LazyCleaner base class) + compileClasspath += sourceSets.main.get().output +} + +if (buildParameters.testJdkVersion >= 11) { + // By default, Gradle uses "test classes" dir for classpath, so multi-release jar is not used there + // So we explicitly prepend the classpath with Java 11 classes + tasks.test { + classpath = java11.output + classpath + } +} + +// Configure the java11 source set to compile with Java 11 +tasks.named<JavaCompile>(java11.compileJavaTaskName) { + options.release.set(11) + // Ensure main classes are compiled before java11 classes + dependsOn(tasks.compileJava) +} + +fun CopySpec.addMultiReleaseContents() { + into("META-INF/versions/11") { + from(java11.output) + } +} + +// Add java11 compiled classes to the main JAR +tasks.jar { + addMultiReleaseContents() +} + val knows by tasks.existing { group = null // Hide the task from `./gradlew tasks` output description = "This is a dummy task, unfortunately the author refuses to remove it: https://github.com/johnrengelman/shadow/issues/122" @@ -109,6 +144,7 @@ dependencies { shaded("com.ongres.scram:scram-client:3.2") implementation("org.checkerframework:checker-qual:3.52.0") + java11.implementationConfigurationName("org.checkerframework:checker-qual:3.52.0") testKitSourcesWithoutAnnotations(projects.testkit) @@ -227,6 +263,7 @@ tasks.configureEach<Jar> { manifest { attributes["Main-Class"] = "org.postgresql.util.PGJDBCMain" attributes["Automatic-Module-Name"] = "org.postgresql.jdbc" + attributes["Multi-Release"] = "true" } } @@ -243,6 +280,7 @@ tasks.shadowJar { exclude("META-INF/NOTICE*") exclude("LICENSE") exclude("NOTICE") + addMultiReleaseContents() listOf( "com.ongres" ).forEach { diff --git a/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java b/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java index 34159c84..4ff26540 100644 --- a/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java +++ b/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java @@ -43,6 +43,7 @@ import org.postgresql.util.DriverInfo; import org.postgresql.util.GT; import org.postgresql.util.HostSpec; import org.postgresql.util.LazyCleaner; +import org.postgresql.util.LazyCleanerImpl; import org.postgresql.util.LruCache; import org.postgresql.util.PGBinaryObject; import org.postgresql.util.PGInterval; @@ -398,7 +399,7 @@ public class PgConnection implements BaseConnection { replicationConnection = PGProperty.REPLICATION.getOrDefault(info) != null; xmlFactoryFactoryClass = PGProperty.XML_FACTORY_FACTORY.getOrDefault(info); - cleanable = LazyCleaner.getInstance().register(leakHandle, finalizeAction); + cleanable = LazyCleanerImpl.getInstance().register(leakHandle, finalizeAction); } private static ReadOnlyBehavior getReadOnlyBehavior(@Nullable String property) { diff --git a/pgjdbc/src/main/java/org/postgresql/util/LazyCleaner.java b/pgjdbc/src/main/java/org/postgresql/util/LazyCleaner.java new file mode 100644 index 00000000..24b9b468 --- /dev/null +++ b/pgjdbc/src/main/java/org/postgresql/util/LazyCleaner.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +package org.postgresql.util; + +public interface LazyCleaner { + /** + * Cleanable interface for objects that can be manually cleaned. + * + * @param <T> the type of exception that can be thrown during cleanup + */ + interface Cleanable<T extends Throwable> { + void clean() throws T; + } + + /** + * CleaningAction interface for cleanup actions that are notified whether cleanup + * occurred due to a leak (automatic) or manual cleanup. + * + * @param <T> the type of exception that can be thrown during cleanup + */ + interface CleaningAction<T extends Throwable> { + void onClean(boolean leak) throws T; + } + + /** + * Registers an object for cleanup when it becomes phantom reachable. + * + * @param obj the object to monitor for cleanup (should not be the same as action) + * @param action the action to perform when the object becomes unreachable + * @param <T> the type of exception that can be thrown during cleanup + * @return a Cleanable that can be used to manually trigger cleanup + */ + <T extends Throwable> Cleanable<T> register(Object obj, CleaningAction<T> action); +} diff --git a/pgjdbc/src/main/java/org/postgresql/util/LazyCleanerImpl.java b/pgjdbc/src/main/java/org/postgresql/util/LazyCleanerImpl.java index 6c29a9e5..48d55541 100644 --- a/pgjdbc/src/main/java/org/postgresql/util/LazyCleanerImpl.java +++ b/pgjdbc/src/main/java/org/postgresql/util/LazyCleanerImpl.java @@ -25,73 +25,71 @@ package org.postgresql.util; import org.checkerframework.checker.nullness.qual.Nullable; import java.lang.ref.PhantomReference; +import java.lang.ref.Reference; import java.lang.ref.ReferenceQueue; import java.time.Duration; -import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ForkJoinPool; +import java.util.function.BooleanSupplier; import java.util.logging.Level; import java.util.logging.Logger; /** * LazyCleaner is a utility class that allows to register objects for deferred cleanup. * + * <p>This is the Java 8 compatible implementation that uses PhantomReferences + * and ForkJoinPool for deferred cleanup operations.</p> + * + * <p>On Java 11+, this class is replaced by a version that uses the native + * {@link java.lang.ref.Cleaner} API via the multi-release JAR mechanism.</p> + * * <p>Note: this is a driver-internal class</p> */ -public class LazyCleaner { - private static final Logger LOGGER = Logger.getLogger(LazyCleaner.class.getName()); - private static final LazyCleaner instance = - new LazyCleaner( - Duration.ofMillis(Long.getLong("pgjdbc.config.cleanup.thread.ttl", 30000)), - "PostgreSQL-JDBC-Cleaner" - ); - - public interface Cleanable<T extends Throwable> { - void clean() throws T; - } - - public interface CleaningAction<T extends Throwable> { - void onClean(boolean leak) throws T; - } +public class LazyCleanerImpl implements LazyCleaner { + private static final Logger LOGGER = Logger.getLogger(LazyCleanerImpl.class.getName()); + private static final LazyCleanerImpl instance = new LazyCleanerImpl( + "PostgreSQL-JDBC-Cleaner", + Duration.ofMillis(Long.getLong("pgjdbc.config.cleanup.thread.ttl", 30000)) + ); private final ReferenceQueue<Object> queue = new ReferenceQueue<>(); - private final long threadTtl; - private final ThreadFactory threadFactory; + private final String threadName; + private final Duration threadTtl; private boolean threadRunning; - private int watchedCount; private @Nullable Node<?> first; + /** + * Creates a LazyCleaner with the specified configuration. + * + * @param threadName the name for the cleanup thread + * @param threadTtl the maximum time the cleanup thread will wait for references + */ + public LazyCleanerImpl(String threadName, Duration threadTtl) { + this.threadName = threadName; + this.threadTtl = threadTtl; + } + /** * Returns a default cleaner instance. * * <p>Note: this is driver-internal API.</p> * @return the instance of LazyCleaner */ - public static LazyCleaner getInstance() { + public static LazyCleanerImpl getInstance() { return instance; } - public LazyCleaner(Duration threadTtl, final String threadName) { - this(threadTtl, runnable -> { - Thread thread = new Thread(runnable, threadName); - thread.setDaemon(true); - return thread; - }); - } - - private LazyCleaner(Duration threadTtl, ThreadFactory threadFactory) { - this.threadTtl = threadTtl.toMillis(); - this.threadFactory = threadFactory; - } - + @Override public <T extends Throwable> Cleanable<T> register(Object obj, CleaningAction<T> action) { assert obj != action : "object handle should not be the same as cleaning action, otherwise" + " the object will never become phantom reachable, so the action will never trigger"; - return add(new Node<T>(obj, action)); - } - - public synchronized int getWatchedCount() { - return watchedCount; + return add(new Node<>(obj, action)); } + /** + * Returns whether the cleanup thread is currently running. + * + * @return true if cleanup operations are active + */ public synchronized boolean isThreadRunning() { return threadRunning; } @@ -110,7 +108,6 @@ public class LazyCleaner { first.prev = node; } first = node; - watchedCount++; if (!threadRunning) { threadRunning = startThread(); @@ -118,56 +115,92 @@ public class LazyCleaner { return node; } + /** + * RefQueueBlocker retrieves references from the reference queue without blocking ForkJoinPool + * CPU threads. + * + * @param <T> the type of the objects referenced by the {@link Reference}s in the queue + */ + private static class RefQueueBlocker<T> implements ForkJoinPool.ManagedBlocker { + private final ReferenceQueue<T> queue; + private final String threadName; + private @Nullable Reference<? extends T> ref; + private final long blockTimeoutMillis; + private final BooleanSupplier shouldTerminate; + + RefQueueBlocker(ReferenceQueue<T> queue, String threadName, Duration blockTimeout, BooleanSupplier shouldTerminate) { + this.queue = queue; + this.threadName = threadName; + this.blockTimeoutMillis = blockTimeout.toMillis(); + this.shouldTerminate = shouldTerminate; + } + + @Override + public boolean isReleasable() { + if (ref != null || shouldTerminate.getAsBoolean()) { + return true; // already have a ref from a previous call + } + // non-blocking check + ref = queue.poll(); + // no need to block if we already have a ref from a previous call + return ref != null; + } + + @Override + public boolean block() throws InterruptedException { + if (isReleasable()) { + return true; + } + Thread currentThread = Thread.currentThread(); + // ForkJoinPool reuses threads, so we set the thread name just for the blocking operation + String oldName = currentThread.getName(); + try { + currentThread.setName(threadName); + // Perform blocking operation + ref = queue.remove(blockTimeoutMillis); + } finally { + currentThread.setName(oldName); + } + return false; + } + + public @Nullable Reference<? extends T> drainOne() { + Reference<? extends T> ref = this.ref; + this.ref = null; + return ref; + } + } + private boolean startThread() { - Thread thread = threadFactory.newThread(new Runnable() { - @Override - public void run() { - while (true) { - try { - // Clear setContextClassLoader to avoid leaking the classloader - Thread.currentThread().setContextClassLoader(null); - Thread.currentThread().setUncaughtExceptionHandler(null); - // Node extends PhantomReference, so this cast is safe - Node<?> ref = (Node<?>) queue.remove(threadTtl); - if (ref == null) { - if (checkEmpty()) { + // We use ForkJoinPool to work around Thread.inheritedAccessControlContext memory leak + // Java creates FJP threads without caller's access control context, thus we reduce + // the surface for the leak. + ForkJoinPool.commonPool().execute( + () -> { + // Clear setContextClassLoader to avoid leaking the classloader + Thread.currentThread().setContextClassLoader(null); + RefQueueBlocker<Object> blocker = + new RefQueueBlocker<>(queue, threadName, threadTtl, this::checkEmpty); + while (!checkEmpty()) { + try { + ForkJoinPool.managedBlock(blocker); + Node<?> ref = (Node<?>) blocker.drainOne(); + if (ref != null) { + ref.onClean(true); + } + } catch (InterruptedException e) { + if (!blocker.isReleasable()) { + LOGGER.log(Level.FINE, "Got interrupt and the cleanup queue is empty, will terminate the cleanup thread"); break; } - continue; - } - try { - ref.onClean(true); + LOGGER.log(Level.FINE, "Got interrupt and the cleanup queue is NOT empty. Will ignore the interrupt"); } catch (Throwable e) { - if (e instanceof InterruptedException) { - // This could happen if onClean uses sneaky-throws - LOGGER.log(Level.WARNING, "Unexpected interrupt while executing onClean", e); - throw e; - } - // Should not happen if cleaners are well-behaved LOGGER.log(Level.WARNING, "Unexpected exception while executing onClean", e); } - } catch (InterruptedException e) { - if (LazyCleaner.this.checkEmpty()) { - LOGGER.log( - Level.FINE, - "Cleanup queue is empty, and got interrupt, will terminate the cleanup thread" - ); - break; - } - LOGGER.log(Level.FINE, "Ignoring interrupt since the cleanup queue is non-empty"); - } catch (Throwable e) { - // Ignore exceptions from the cleanup action - LOGGER.log(Level.WARNING, "Unexpected exception in cleaner thread main loop", e); } } - } - }); - if (thread != null) { - thread.start(); - return true; - } - LOGGER.log(Level.WARNING, "Unable to create cleanup thread"); - return false; + ); + return true; } private synchronized boolean remove(Node<?> node) { @@ -191,13 +224,12 @@ public class LazyCleaner { node.next = node; node.prev = node; - watchedCount--; return true; } private class Node<T extends Throwable> extends PhantomReference<Object> implements Cleanable<T>, CleaningAction<T> { - private final @Nullable CleaningAction<T> action; + private final CleaningAction<T> action; private @Nullable Node<?> prev; private @Nullable Node<?> next; @@ -217,9 +249,7 @@ public class LazyCleaner { if (!remove(this)) { return; } - if (action != null) { - action.onClean(leak); - } + action.onClean(leak); } } } diff --git a/pgjdbc/src/main/java/org/postgresql/util/SharedTimer.java b/pgjdbc/src/main/java/org/postgresql/util/SharedTimer.java index 27b45fbc..ee793a13 100644 --- a/pgjdbc/src/main/java/org/postgresql/util/SharedTimer.java +++ b/pgjdbc/src/main/java/org/postgresql/util/SharedTimer.java @@ -62,7 +62,7 @@ public class SharedTimer { Thread.currentThread().setContextClassLoader(null); this.timer = timer = new Timer("PostgreSQL-JDBC-SharedTimer-" + index, true); - this.timerCleanup = LazyCleaner.getInstance().register(refCount, new TimerCleanup(timer)); + this.timerCleanup = LazyCleanerImpl.getInstance().register(refCount, new TimerCleanup(timer)); } finally { Thread.currentThread().setContextClassLoader(prevContextCL); } diff --git a/pgjdbc/src/main/java/org/postgresql/util/StreamWrapper.java b/pgjdbc/src/main/java/org/postgresql/util/StreamWrapper.java index 03072dfc..8c1190d1 100644 --- a/pgjdbc/src/main/java/org/postgresql/util/StreamWrapper.java +++ b/pgjdbc/src/main/java/org/postgresql/util/StreamWrapper.java @@ -75,7 +75,7 @@ public final class StreamWrapper implements Closeable { this.stream = null; // The stream is opened on demand TempFileHolder tempFileHolder = new TempFileHolder(tempFile); this.tempFileHolder = tempFileHolder; - cleaner = LazyCleaner.getInstance().register(leakHandle, tempFileHolder); + cleaner = LazyCleanerImpl.getInstance().register(leakHandle, tempFileHolder); } else { this.rawData = rawData; this.stream = null; diff --git a/pgjdbc/src/main/java11/org/postgresql/util/LazyCleanerImpl.java b/pgjdbc/src/main/java11/org/postgresql/util/LazyCleanerImpl.java new file mode 100644 index 00000000..a6296242 --- /dev/null +++ b/pgjdbc/src/main/java11/org/postgresql/util/LazyCleanerImpl.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2023, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +package org.postgresql.util; + +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.lang.ref.Cleaner; +import java.time.Duration; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * LazyCleaner is a utility class that allows to register objects for deferred cleanup. + * + * <p>This is the Java 11+ implementation that uses the native {@link Cleaner} API + * introduced in Java 9+ for deferred cleanup operations.</p> + * + * <p>This class replaces the Java 8 PhantomReference-based implementation via the + * multi-release JAR mechanism when running on Java 11+.</p> + * + * <p>Note: this is a driver-internal class</p> + */ +public class LazyCleanerImpl implements LazyCleaner { + private static final Logger LOGGER = Logger.getLogger(LazyCleanerImpl.class.getName()); + private static final LazyCleanerImpl instance = new LazyCleanerImpl( + "PostgreSQL-JDBC-Cleaner", + Duration.ofMillis(Long.getLong("pgjdbc.config.cleanup.thread.ttl", 30000)) + ); + + private final Cleaner cleaner; + + /** + * Creates a LazyCleaner with the specified configuration. + * + * <p>Note: The {@code threadName} and {@code threadTtl} parameters are ignored + * in this implementation since the JVM manages Cleaner threads internally.</p> + * + * @param threadName the name for the cleanup thread (ignored, for API compatibility) + * @param threadTtl the maximum time the cleanup thread will wait (ignored, for API compatibility) + */ + public LazyCleanerImpl(String threadName, Duration threadTtl) { + // threadName and threadTtl are ignored in Java 11+ since Cleaner manages its own threads + this.cleaner = Cleaner.create(); + } + + /** + * Returns a default cleaner instance. + * + * <p>Note: this is driver-internal API.</p> + * @return the instance of LazyCleaner + */ + public static LazyCleanerImpl getInstance() { + return instance; + } + + @Override + public <T extends Throwable> Cleanable<T> register(Object obj, CleaningAction<T> action) { + assert obj != action : "object handle should not be the same as cleaning action, otherwise" + + " the object will never become phantom reachable, so the action will never trigger"; + + // Wrapper is needed to prevent double cleanup (e.g. user-managed cleanups and automatic cleanup) + CleanableWrapper<T> wrapper = new CleanableWrapper<>(action); + + // Register with the native Cleaner, using the wrapper's cleanup method + Cleaner.Cleanable nativeCleanable = cleaner.register(obj, wrapper::leakDetected); + + // Add the reference so the cleanable could be deregistered when user calls clean() + wrapper.setNativeCleanable(nativeCleanable); + return wrapper; + } + + /** + * Returns whether the cleanup thread is currently running. + * + * <p>For the Java 11+ implementation, this always returns false since the Cleaner + * manages its own thread pool internally and there is no dedicated cleanup thread.</p> + * + * @return false, since the Cleaner manages threads internally + */ + public boolean isThreadRunning() { + // The Cleaner manages its own thread pool, so we return false meaning "there's no extra cleanup thread running" + return false; + } + + /** + * Wrapper that adapts the native Cleaner.Cleanable to our Cleanable interface. + */ + private static class CleanableWrapper<T extends Throwable> implements Cleanable<T> { + private Cleaner.@Nullable Cleanable nativeCleanable; + private volatile @Nullable CleaningAction<T> action; + + CleanableWrapper(CleaningAction<T> action) { + this.action = action; + } + + void setNativeCleanable(Cleaner.Cleanable nativeCleanable) { + this.nativeCleanable = nativeCleanable; + } + + private synchronized @Nullable CleaningAction<T> getCleaningAction() { + CleaningAction<T> action = this.action; + this.action = null; + return action; + } + + /** + * Performs the actual cleanup if it hasn't been done yet. + * This method is called both from manual clean() and from the Cleaner thread. + */ + void leakDetected() { + CleaningAction<T> cleaningAction = getCleaningAction(); + if (cleaningAction == null) { + return; + } + try { + cleaningAction.onClean(true); + } catch (Throwable e) { + if (e instanceof InterruptedException) { + LOGGER.log(Level.WARNING, "Unexpected interrupt while executing onClean", e); + } else { + // Should not happen if cleaners are well-behaved + LOGGER.log(Level.WARNING, "Unexpected exception while executing onClean", e); + } + } + } + + @Override + public void clean() throws T { + CleaningAction<T> cleaningAction = getCleaningAction(); + if (cleaningAction == null) { + return; + } + + // Deregister from native Cleaner to prevent automatic cleanup + // This is safe to call even if cleanup already happened + Cleaner.Cleanable nativeCleanable = this.nativeCleanable; + if (nativeCleanable != null) { + // just in case + this.nativeCleanable = null; + nativeCleanable.clean(); + } + + // Call performCleanup with leak=false for manual cleanup + // We rethrow the exceptions so callers can handle them + cleaningAction.onClean(false); + } + } +} diff --git a/pgjdbc/src/main/checkerframework/WindowsSecurityContextImpl.astub b/pgjdbc/src/mainCheckerframework/WindowsSecurityContextImpl.astub similarity index 100% rename from pgjdbc/src/main/checkerframework/WindowsSecurityContextImpl.astub rename to pgjdbc/src/mainCheckerframework/WindowsSecurityContextImpl.astub diff --git a/pgjdbc/src/test/java/org/postgresql/test/jdbc2/StatementTest.java b/pgjdbc/src/test/java/org/postgresql/test/jdbc2/StatementTest.java index bfc3f74d..f587970b 100644 --- a/pgjdbc/src/test/java/org/postgresql/test/jdbc2/StatementTest.java +++ b/pgjdbc/src/test/java/org/postgresql/test/jdbc2/StatementTest.java @@ -21,6 +21,7 @@ import org.postgresql.jdbc.PgStatement; import org.postgresql.test.TestUtil; import org.postgresql.test.util.StrangeProxyServer; import org.postgresql.util.LazyCleaner; +import org.postgresql.util.LazyCleanerImpl; import org.postgresql.util.PSQLState; import org.postgresql.util.SharedTimer; @@ -1067,7 +1068,7 @@ class StatementTest { // Create several cleaners, so they can clean leaks concurrently List<LazyCleaner> cleaners = new ArrayList<>(); for (int i = 0; i < 16; i++) { - cleaners.add(new LazyCleaner(Duration.ofSeconds(2), "pgjdbc-test-cleaner-" + i)); + cleaners.add(new LazyCleanerImpl("pgjdbc-test-cleaner-" + i, Duration.ofSeconds(2))); } for (int q = 0; System.nanoTime() < deadline || leaks.get() < 10000; q++) { diff --git a/pgjdbc/src/test/java/org/postgresql/util/LazyCleanerTest.java b/pgjdbc/src/test/java/org/postgresql/util/LazyCleanerTest.java index fb97b36b..92b18ea4 100644 --- a/pgjdbc/src/test/java/org/postgresql/util/LazyCleanerTest.java +++ b/pgjdbc/src/test/java/org/postgresql/util/LazyCleanerTest.java @@ -24,15 +24,19 @@ package org.postgresql.util; import static java.time.Duration.ofSeconds; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.postgresql.core.JavaVersion; + import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; public class LazyCleanerTest { @Test @@ -40,7 +44,7 @@ public class LazyCleanerTest { List<Object> list = new ArrayList<>(Arrays.asList( new Object(), new Object(), new Object())); - LazyCleaner t = new LazyCleaner(ofSeconds(5), "Cleaner"); + LazyCleanerImpl t = new LazyCleanerImpl("Cleaner", ofSeconds(5)); String[] collected = new String[list.size()]; List<LazyCleaner.Cleanable<RuntimeException>> cleaners = new ArrayList<>(); @@ -60,42 +64,32 @@ public class LazyCleanerTest { ) ); } - assertEquals( - list.size(), - t.getWatchedCount(), - "All objects are strongly-reachable, so getWatchedCount should reflect it" - ); - assertTrue(t.isThreadRunning(), - "cleanup thread should be running, and it should wait for the leaks"); + if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) { + assertTrue(t.isThreadRunning(), + "cleanup thread should be running, and it should wait for the leaks"); + } cleaners.get(1).clean(); - assertEquals( - list.size() - 1, - t.getWatchedCount(), - "One object has been released properly, so getWatchedCount should reflect it" - ); - list.set(0, null); System.gc(); System.gc(); - Await.until( - "One object was released, and another one has leaked, so getWatchedCount should reflect it", - ofSeconds(5), - () -> t.getWatchedCount() == list.size() - 2 - ); - list.clear(); System.gc(); System.gc(); - Await.until( - "The cleanup thread should detect leaks and terminate within 5-10 seconds after GC", - ofSeconds(10), - () -> !t.isThreadRunning() - ); + if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) { + Await.until( + "The cleanup thread should detect leaks and terminate within 5-10 seconds after GC", + ofSeconds(10), + () -> !t.isThreadRunning() + ); + } else { + // Allow some room for Java's Cleaner to clean the refs + Thread.sleep(1000); + } assertEquals( Arrays.asList("LEAK", "NO LEAK", "LEAK").toString(), @@ -105,46 +99,106 @@ public class LazyCleanerTest { } @Test - void getThread() throws InterruptedException { + void cleanupCompletesAfterManualClean() throws InterruptedException { String threadName = UUID.randomUUID().toString(); - LazyCleaner t = new LazyCleaner(ofSeconds(5), threadName); + LazyCleanerImpl t = new LazyCleanerImpl(threadName, ofSeconds(5)); + + AtomicBoolean cleaned = new AtomicBoolean(); List<Object> list = new ArrayList<>(); list.add(new Object()); - LazyCleaner.Cleanable<IllegalStateException> cleanable = + + LazyCleaner.Cleanable<RuntimeException> cleanable = t.register( list.get(0), - leak -> { - throw new IllegalStateException("test exception from CleaningAction"); - } + leak -> cleaned.set(true) ); - assertTrue(t.isThreadRunning(), - "cleanup thread should be running, and it should wait for the leaks"); - Thread thread = getThreadByName(threadName); - thread.interrupt(); - Await.until( - "The cleanup thread should ignore the interrupt since there's one object to monitor", - ofSeconds(10), - () -> !thread.isInterrupted() + + if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) { + assertTrue(t.isThreadRunning(), + "cleanup thread should be running when there are objects to monitor"); + } + + // Manually clean the object + cleanable.clean(); + + // Verify it was cleaned + assertTrue(cleaned.get(), "Object should be cleaned after manual clean"); + + // Clear the reference and verify cleanup thread eventually stops + list.clear(); + System.gc(); + System.gc(); + + if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) { + Await.until( + "Cleanup thread should stop when no objects remain", + ofSeconds(10), + () -> !t.isThreadRunning() + ); + } + } + + @Test + void exceptionsDuringCleanupAreHandled() throws InterruptedException { + LazyCleanerImpl t = new LazyCleanerImpl("test-cleaner", ofSeconds(5)); + + java.util.concurrent.atomic.AtomicInteger cleanupCount = new java.util.concurrent.atomic.AtomicInteger(0); + List<Object> list = new ArrayList<>(); + + // Register object that throws during cleanup + list.add(new Object()); + t.register( + list.get(0), + leak -> { + cleanupCount.incrementAndGet(); + throw new IllegalStateException("test exception from CleaningAction"); + } ); - assertThrows( - IllegalStateException.class, - cleanable::clean, - "Exception from cleanable.clean() should be rethrown" + + // Register another object that should still be cleaned + list.add(new Object()); + AtomicBoolean secondCleaned = new AtomicBoolean(false); + t.register( + list.get(1), + leak -> secondCleaned.set(true) ); - thread.interrupt(); + + if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) { + assertTrue(t.isThreadRunning(), + "cleanup thread should be running when there are objects to monitor"); + } + + // Trigger cleanup + list.clear(); + System.gc(); + System.gc(); + Await.until( - "The cleanup thread should exit shortly after interrupt as there's no leaks to monitor", - ofSeconds(1), - () -> !t.isThreadRunning() + "Both cleanups should complete despite exception", + ofSeconds(10), + () -> cleanupCount.get() == 1 && secondCleaned.get() ); - } - public static Thread getThreadByName(String threadName) { - for (Thread t : Thread.getAllStackTraces().keySet()) { - if (t.getName().equals(threadName)) { - return t; - } + if (JavaVersion.getRuntimeVersion() == JavaVersion.v1_8) { + Await.until( + "Cleanup thread should stop after all objects are cleaned", + ofSeconds(10), + () -> !t.isThreadRunning() + ); } - throw new IllegalStateException("Cleanup thread " + threadName + " not found"); + } + + @Test + void exceptionOnCleanRethrowsToCaller() { + LazyCleanerImpl t = new LazyCleanerImpl("test-cleaner", ofSeconds(5)); + Object obj = new Object(); + RuntimeException expectedException = new RuntimeException("test exception"); + + LazyCleaner.Cleanable<RuntimeException> cleanable = t.register(obj, leak -> { + throw expectedException; + }); + + RuntimeException thrownException = assertThrows(RuntimeException.class, cleanable::clean); + assertSame(expectedException, thrownException, "Expected same exception instance to be thrown"); } } [parent: 76e8a6539068]