From 816fb6a3b6fc1cda81be913f1df33231da0a4e6a Mon Sep 17 00:00:00 2001 From: Stainless Bot Date: Mon, 28 Oct 2024 22:47:41 +0000 Subject: [PATCH] feat(client): clean up resource leaks when the resource becomes phantom reachable --- .../com/lithic/api/core/ClientOptions.kt | 13 ++++-- .../com/lithic/api/core/PhantomReachable.kt | 46 +++++++++++++++++++ .../http/PhantomReachableClosingHttpClient.kt | 21 +++++++++ .../lithic/api/core/PhantomReachableTest.kt | 27 +++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 lithic-java-core/src/main/kotlin/com/lithic/api/core/PhantomReachable.kt create mode 100644 lithic-java-core/src/main/kotlin/com/lithic/api/core/http/PhantomReachableClosingHttpClient.kt create mode 100644 lithic-java-core/src/test/kotlin/com/lithic/api/core/PhantomReachableTest.kt diff --git a/lithic-java-core/src/main/kotlin/com/lithic/api/core/ClientOptions.kt b/lithic-java-core/src/main/kotlin/com/lithic/api/core/ClientOptions.kt index bf264d1d1..05104954d 100644 --- a/lithic-java-core/src/main/kotlin/com/lithic/api/core/ClientOptions.kt +++ b/lithic-java-core/src/main/kotlin/com/lithic/api/core/ClientOptions.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.json.JsonMapper import com.google.common.collect.ArrayListMultimap import com.google.common.collect.ListMultimap import com.lithic.api.core.http.HttpClient +import com.lithic.api.core.http.PhantomReachableClosingHttpClient import com.lithic.api.core.http.RetryingHttpClient import java.time.Clock @@ -153,11 +154,13 @@ private constructor( return ClientOptions( httpClient!!, - RetryingHttpClient.builder() - .httpClient(httpClient!!) - .clock(clock) - .maxRetries(maxRetries) - .build(), + PhantomReachableClosingHttpClient( + RetryingHttpClient.builder() + .httpClient(httpClient!!) + .clock(clock) + .maxRetries(maxRetries) + .build() + ), jsonMapper ?: jsonMapper(), clock, baseUrl, diff --git a/lithic-java-core/src/main/kotlin/com/lithic/api/core/PhantomReachable.kt b/lithic-java-core/src/main/kotlin/com/lithic/api/core/PhantomReachable.kt new file mode 100644 index 000000000..d3a660c20 --- /dev/null +++ b/lithic-java-core/src/main/kotlin/com/lithic/api/core/PhantomReachable.kt @@ -0,0 +1,46 @@ +@file:JvmName("PhantomReachable") + +package com.lithic.api.core + +import com.lithic.api.errors.LithicException +import java.lang.reflect.InvocationTargetException + +/** + * Closes [closeable] when [observed] becomes only phantom reachable. + * + * This is a wrapper around a Java 9+ [java.lang.ref.Cleaner], or a no-op in older Java versions. + */ +@JvmSynthetic +internal fun closeWhenPhantomReachable(observed: Any, closeable: AutoCloseable) { + check(observed !== closeable) { + "`observed` cannot be the same object as `closeable` because it would never become phantom reachable" + } + closeWhenPhantomReachable?.let { it(observed, closeable::close) } +} + +private val closeWhenPhantomReachable: ((Any, AutoCloseable) -> Unit)? by lazy { + try { + val cleanerClass = Class.forName("java.lang.ref.Cleaner") + val cleanerCreate = cleanerClass.getMethod("create") + val cleanerRegister = + cleanerClass.getMethod("register", Any::class.java, Runnable::class.java) + val cleanerObject = cleanerCreate.invoke(null); + + { observed, closeable -> + try { + cleanerRegister.invoke(cleanerObject, observed, Runnable { closeable.close() }) + } catch (e: ReflectiveOperationException) { + if (e is InvocationTargetException) { + when (val cause = e.cause) { + is RuntimeException, + is Error -> throw cause + } + } + throw LithicException("Unexpected reflective invocation failure", e) + } + } + } catch (e: ReflectiveOperationException) { + // We're running Java 8, which has no Cleaner. + null + } +} diff --git a/lithic-java-core/src/main/kotlin/com/lithic/api/core/http/PhantomReachableClosingHttpClient.kt b/lithic-java-core/src/main/kotlin/com/lithic/api/core/http/PhantomReachableClosingHttpClient.kt new file mode 100644 index 000000000..8cea2fb2b --- /dev/null +++ b/lithic-java-core/src/main/kotlin/com/lithic/api/core/http/PhantomReachableClosingHttpClient.kt @@ -0,0 +1,21 @@ +package com.lithic.api.core.http + +import com.lithic.api.core.RequestOptions +import com.lithic.api.core.closeWhenPhantomReachable +import java.util.concurrent.CompletableFuture + +internal class PhantomReachableClosingHttpClient(private val httpClient: HttpClient) : HttpClient { + init { + closeWhenPhantomReachable(this, httpClient) + } + + override fun execute(request: HttpRequest, requestOptions: RequestOptions): HttpResponse = + httpClient.execute(request, requestOptions) + + override fun executeAsync( + request: HttpRequest, + requestOptions: RequestOptions + ): CompletableFuture = httpClient.executeAsync(request, requestOptions) + + override fun close() = httpClient.close() +} diff --git a/lithic-java-core/src/test/kotlin/com/lithic/api/core/PhantomReachableTest.kt b/lithic-java-core/src/test/kotlin/com/lithic/api/core/PhantomReachableTest.kt new file mode 100644 index 000000000..8860d72e5 --- /dev/null +++ b/lithic-java-core/src/test/kotlin/com/lithic/api/core/PhantomReachableTest.kt @@ -0,0 +1,27 @@ +package com.lithic.api.core + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class PhantomReachableTest { + + @Test + fun closeWhenPhantomReachable_whenObservedIsGarbageCollected_closesCloseable() { + var closed = false + val closeable = AutoCloseable { closed = true } + + closeWhenPhantomReachable( + // Pass an inline object for the object to observe so that it becomes immediately + // unreachable. + Any(), + closeable + ) + + assertThat(closed).isFalse() + + System.gc() + Thread.sleep(3000) + + assertThat(closed).isTrue() + } +}