From e62be86681fbe8aba9423021daeeb3054227c84e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 05:40:24 +0300 Subject: [PATCH] feat: add opt-in resource leak detector to sdk-core Add a log-only detector that warns when a closeable resource (e.g. a response body) becomes phantom-reachable without having been closed. The detector is off by default and never changes program behavior: it only observes. Auto-closing a caller-owned resource from a GC callback is unsafe, so detection is strictly diagnostic. - LeakDetector tracks resources via a PhantomReference per resource plus a ReferenceQueue drained by a single daemon reaper thread. This works on every JDK from 8 up and needs no java.lang.ref.Cleaner (9+). - A tracked resource shares an AtomicBoolean with its LeakTracker; LeakTracker.closed() flips the flag and de-registers the phantom, so a cleanly-closed resource is never reported. - trackCloseable wraps an AutoCloseable so close() auto-marks the tracker, the by-hand integration point a response-body factory uses today. - systemDefault reads dexpace.sdk.leakDetection (and .captureStack); a Builder lets applications enable it explicitly and supply a custom LeakListener (default logs each leak at WARN via SLF4J). - drainManually() processes already-enqueued leaks synchronously, giving tests a race-free trigger after a GC-poll loop. Closes #45 --- sdk-core/api/sdk-core.api | 42 +++ .../sdk/core/lifecycle/LeakDetector.kt | 346 ++++++++++++++++++ .../sdk/core/lifecycle/LeakListener.kt | 31 ++ .../dexpace/sdk/core/lifecycle/LeakReport.kt | 42 +++ .../dexpace/sdk/core/lifecycle/LeakTracker.kt | 35 ++ .../sdk/core/lifecycle/LeakDetectorTest.kt | 262 +++++++++++++ 6 files changed, 758 insertions(+) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakDetector.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakListener.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakReport.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakTracker.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/lifecycle/LeakDetectorTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..a76a1d22 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -1977,6 +1977,48 @@ public abstract interface class org/dexpace/sdk/core/io/Source : java/io/Closeab public abstract fun read (Lorg/dexpace/sdk/core/io/Buffer;J)J } +public final class org/dexpace/sdk/core/lifecycle/LeakDetector { + public static final field CAPTURE_STACK_PROPERTY Ljava/lang/String; + public static final field Companion Lorg/dexpace/sdk/core/lifecycle/LeakDetector$Companion; + public static final field DEFAULT_THREAD_NAME Ljava/lang/String; + public static final field ENABLE_PROPERTY Ljava/lang/String; + public static final field systemDefault Lorg/dexpace/sdk/core/lifecycle/LeakDetector; + public synthetic fun (ZZLorg/dexpace/sdk/core/lifecycle/LeakListener;Ljava/lang/String;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun drainManually ()I + public static final fun loggingListener ()Lorg/dexpace/sdk/core/lifecycle/LeakListener; + public final fun track (Ljava/lang/Object;Ljava/lang/String;)Lorg/dexpace/sdk/core/lifecycle/LeakTracker; + public final fun trackCloseable (Ljava/lang/AutoCloseable;Ljava/lang/String;)Ljava/lang/AutoCloseable; +} + +public final class org/dexpace/sdk/core/lifecycle/LeakDetector$Builder { + public fun ()V + public final fun build ()Lorg/dexpace/sdk/core/lifecycle/LeakDetector; + public final fun captureCreationStack (Z)Lorg/dexpace/sdk/core/lifecycle/LeakDetector$Builder; + public final fun enabled (Z)Lorg/dexpace/sdk/core/lifecycle/LeakDetector$Builder; + public final fun listener (Lorg/dexpace/sdk/core/lifecycle/LeakListener;)Lorg/dexpace/sdk/core/lifecycle/LeakDetector$Builder; + public final fun startReaperThread (Z)Lorg/dexpace/sdk/core/lifecycle/LeakDetector$Builder; + public final fun threadName (Ljava/lang/String;)Lorg/dexpace/sdk/core/lifecycle/LeakDetector$Builder; +} + +public final class org/dexpace/sdk/core/lifecycle/LeakDetector$Companion { + public final fun loggingListener ()Lorg/dexpace/sdk/core/lifecycle/LeakListener; +} + +public abstract interface class org/dexpace/sdk/core/lifecycle/LeakListener { + public abstract fun onLeak (Lorg/dexpace/sdk/core/lifecycle/LeakReport;)V +} + +public final class org/dexpace/sdk/core/lifecycle/LeakReport { + public synthetic fun (Ljava/lang/String;[Ljava/lang/StackTraceElement;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getCreationStack ()[Ljava/lang/StackTraceElement; + public final fun getDescription ()Ljava/lang/String; + public final fun summary ()Ljava/lang/String; +} + +public abstract interface class org/dexpace/sdk/core/lifecycle/LeakTracker { + public abstract fun closed ()V +} + public final class org/dexpace/sdk/core/pagination/CursorPaginationStrategy : org/dexpace/sdk/core/pagination/PaginationStrategy { public fun (Lkotlin/jvm/functions/Function1;)V public fun (Lkotlin/jvm/functions/Function1;Ljava/lang/String;)V diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakDetector.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakDetector.kt new file mode 100644 index 00000000..5d3593da --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakDetector.kt @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.lifecycle + +import org.dexpace.sdk.core.instrumentation.ClientLogger +import java.lang.ref.PhantomReference +import java.lang.ref.ReferenceQueue +import java.util.Collections +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** + * Opt-in, log-only detector for closeable resources that are never closed. + * + * The detector watches resources (response bodies, sources, any [AutoCloseable]) for the + * pattern where a caller forgets to `close()` and the object is reclaimed by the garbage + * collector while still "open". When the JVM makes such a resource phantom-reachable, the + * detector emits a single leak [LeakReport] — by default a `WARN` log line — optionally with + * the stack trace of where the resource was created. + * + * ## What it does **not** do + * + * It never closes anything. Auto-closing a caller-owned resource from a GC callback is unsafe + * (the close could run on an arbitrary thread, at an arbitrary time, against transport state + * the caller may still expect to own) so the detector is strictly observational. Behaviour of + * a program is identical whether the detector is on or off; only diagnostics change. + * + * ## Off by default + * + * [systemDefault] reads the `dexpace.sdk.leakDetection` system property and is **disabled** + * unless that property is set to `true`. A [Builder] always lets an application enable it + * explicitly regardless of the property. + * + * ## Mechanism (Java 8 compatible) + * + * Detection uses a [PhantomReference] per tracked resource plus a [ReferenceQueue] drained by + * a single daemon thread. This works identically on every JDK from 8 up and needs no + * `java.lang.ref.Cleaner` (which is 9+). A tracked resource shares an [AtomicBoolean] with its + * [LeakTracker]; [LeakTracker.closed] flips the flag and de-registers the phantom reference so + * a cleanly-closed resource is never reported. + * + * ## Determinism for tests + * + * The reaper thread is started lazily and only when [Builder.startReaperThread] is `true` + * (the default for [systemDefault]). Tests can construct a detector with the thread disabled + * and call [drainManually] after forcing a GC to get a deterministic, race-free check that + * registration and detection fire. + * + * ## Thread-safety + * + * All public operations are thread-safe. + */ +public class LeakDetector private constructor( + private val enabled: Boolean, + private val captureCreationStack: Boolean, + private val listener: LeakListener, + private val threadName: String, + startReaperThread: Boolean, +) { + private val queue = ReferenceQueue() + + /** Strong refs keep each [TrackedRef] alive until it is enqueued (phantoms are not self-rooted). */ + private val live: MutableSet = Collections.synchronizedSet(HashSet()) + + private val reaperLock = ReentrantLock() + private var reaper: Thread? = null + + init { + if (enabled && startReaperThread) { + startReaper() + } + } + + /** + * Begins tracking [resource]. The returned [LeakTracker] must have [LeakTracker.closed] + * called from the resource's `close()` to mark a clean shutdown; otherwise the resource is + * reported as a leak once it is reclaimed. + * + * When the detector is disabled this returns a shared no-op tracker and registers nothing, + * so the overhead on the disabled path is a single field read and no allocation of detector + * state. + * + * @param resource the object whose lifecycle is watched. The detector holds **no** strong + * reference to it, so tracking never keeps the resource alive. + * @param description a non-blank label used in the leak report, e.g. `"ResponseBody"`. + */ + public fun track( + resource: Any, + description: String, + ): LeakTracker { + if (!enabled) { + return NoopTracker + } + val stack = if (captureCreationStack) Throwable(STACK_PROBE_MESSAGE).stackTrace else null + val closedFlag = AtomicBoolean(false) + val ref = TrackedRef(resource, queue, description, stack, closedFlag) + live.add(ref) + return Handle(ref, closedFlag) + } + + /** + * Wraps [closeable] so that the SDK detects when it is reclaimed without `close()` having + * been called. The returned [AutoCloseable] delegates `close()` to [closeable] and, on the + * first close, marks the tracker so no leak is reported. Callers use the returned wrapper in + * place of the original (e.g. hand it to a caller via `try`-with-resources / `use {}`). + * + * This is the by-hand integration point a response-body or stream factory would use today: + * create the underlying closeable, return `detector.trackCloseable(it, "ResponseBody")`, and + * an unclosed wrapper surfaces as a `WARN` when the JVM reclaims it. Behaviour is unchanged + * whether the detector is enabled or not — the wrapper only ever observes, never auto-closes. + * + * When the detector is disabled this returns [closeable] unchanged (no wrapper allocation). + * + * @param closeable the resource to delegate to and watch. + * @param description a non-blank label used in the leak report. + */ + public fun trackCloseable( + closeable: AutoCloseable, + description: String, + ): AutoCloseable { + if (!enabled) { + return closeable + } + return TrackedCloseable(closeable, track(closeable, description)) + } + + /** + * Drains every leak that has already been enqueued by the GC and reports each through the + * configured [LeakListener], returning how many leaks were reported. + * + * This is the deterministic entry point for tests: force a GC (e.g. with a poll loop that + * allocates pressure), then call `drainManually()` to process whatever the collector has + * enqueued so far. It is safe to call even when the reaper thread is running, though in + * production the thread normally drains the queue first. + */ + public fun drainManually(): Int { + var reported = 0 + while (true) { + val ref = queue.poll() as? TrackedRef ?: break + if (report(ref)) { + reported++ + } + } + return reported + } + + /** Reports [ref] if it was not closed; always de-registers it. Returns `true` if reported. */ + private fun report(ref: TrackedRef): Boolean { + live.remove(ref) + ref.clear() + if (ref.closed.get()) { + return false + } + try { + listener.onLeak(LeakReport.create(ref.description, ref.creationStack)) + } catch (t: Throwable) { + // A listener must never kill the reaper thread; swallow and keep draining. + REAPER_LOGGER.atWarning() + .event("leak.listener.error") + .cause(t) + .log("leak listener threw") + } + return true + } + + private fun startReaper() { + reaperLock.withLock { + if (reaper != null) { + return + } + val t = + Thread({ + while (true) { + try { + val ref = queue.remove() as? TrackedRef ?: continue + report(ref) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return@Thread + } + } + }, threadName) + t.isDaemon = true + reaper = t + t.start() + } + } + + /** + * Builder for a [LeakDetector]. Use this to enable detection programmatically (independent + * of the `dexpace.sdk.leakDetection` system property) or to plug in a custom listener. + */ + public class Builder { + private var enabled: Boolean = false + private var captureCreationStack: Boolean = false + private var listener: LeakListener = loggingListener() + private var threadName: String = DEFAULT_THREAD_NAME + private var startReaperThread: Boolean = true + + /** Enables or disables detection. Disabled detectors allocate nothing per [track] call. */ + public fun enabled(enabled: Boolean): Builder = + apply { + this.enabled = enabled + } + + /** + * When `true`, captures the stack trace at each [track] call and attaches it to the + * [LeakReport] so leaks can be traced to their creation site. Off by default because + * stack capture allocates on every tracked resource. + */ + public fun captureCreationStack(capture: Boolean): Builder = + apply { + this.captureCreationStack = capture + } + + /** Sets the sink for leak reports. Defaults to a SLF4J `WARN` logger. */ + public fun listener(listener: LeakListener): Builder = + apply { + this.listener = listener + } + + /** Overrides the name of the daemon reaper thread. */ + public fun threadName(name: String): Builder = + apply { + this.threadName = name + } + + /** + * Controls whether the background reaper daemon thread is started. Set to `false` for + * deterministic tests that drive detection through [drainManually] only. + */ + public fun startReaperThread(start: Boolean): Builder = + apply { + this.startReaperThread = start + } + + /** Builds the detector. */ + public fun build(): LeakDetector = + LeakDetector( + enabled = enabled, + captureCreationStack = captureCreationStack, + listener = listener, + threadName = threadName, + startReaperThread = startReaperThread, + ) + } + + public companion object { + /** System property that enables the [systemDefault] detector when set to `true`. */ + public const val ENABLE_PROPERTY: String = "dexpace.sdk.leakDetection" + + /** System property that enables creation-stack capture for the [systemDefault] detector. */ + public const val CAPTURE_STACK_PROPERTY: String = "dexpace.sdk.leakDetection.captureStack" + + /** Default name of the background reaper daemon thread. */ + public const val DEFAULT_THREAD_NAME: String = "dexpace-leak-detector" + + private val REAPER_LOGGER = ClientLogger("org.dexpace.sdk.core.lifecycle.LeakDetector") + + private const val STACK_PROBE_MESSAGE = "leak-detector creation-stack probe" + + /** + * The process-wide detector configured from system properties. It is enabled only when + * `-Ddexpace.sdk.leakDetection=true` is set, and captures creation stacks only when + * `-Ddexpace.sdk.leakDetection.captureStack=true` is also set. Off by default, so the + * SDK never spends cycles on leak tracking unless an operator opts in. + */ + @JvmField + public val systemDefault: LeakDetector = + Builder() + .enabled("true".equals(System.getProperty(ENABLE_PROPERTY), ignoreCase = true)) + .captureCreationStack("true".equals(System.getProperty(CAPTURE_STACK_PROPERTY), ignoreCase = true)) + .build() + + /** Returns a [LeakListener] that logs each report at SLF4J `WARN`, with creation stack if present. */ + @JvmStatic + public fun loggingListener(): LeakListener = + LeakListener { report -> + val event = + REAPER_LOGGER.atWarning() + .event("resource.leak") + .field("resource", report.description) + val stack = report.creationStack + if (stack != null) { + event.cause(creationTrace(report.description, stack)) + } + event.log(report.summary()) + } + + /** Wraps a captured creation stack in a throwable so loggers render it as a "cause". */ + private fun creationTrace( + description: String, + stack: Array, + ): Throwable = + Throwable("creation site of leaked resource: $description").apply { + stackTrace = stack + } + + private val NoopTracker: LeakTracker = LeakTracker { } + } + + /** + * Phantom reference to a tracked resource. Carries the metadata needed to report a leak + * without ever dereferencing the (already-collected) resource. + */ + private class TrackedRef( + referent: Any, + queue: ReferenceQueue, + val description: String, + val creationStack: Array?, + val closed: AtomicBoolean, + ) : PhantomReference(referent, queue) + + /** Delegating [AutoCloseable] that marks its [LeakTracker] closed on the first `close()`. */ + private class TrackedCloseable( + private val delegate: AutoCloseable, + private val tracker: LeakTracker, + ) : AutoCloseable { + override fun close() { + try { + delegate.close() + } finally { + tracker.closed() + } + } + } + + /** Caller-facing handle: flips the shared flag and de-registers on [closed]. */ + private inner class Handle( + private val ref: TrackedRef, + private val closedFlag: AtomicBoolean, + ) : LeakTracker { + override fun closed() { + if (closedFlag.compareAndSet(false, true)) { + live.remove(ref) + ref.clear() + } + } + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakListener.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakListener.kt new file mode 100644 index 00000000..8698a12a --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakListener.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.lifecycle + +/** + * Sink for leak reports produced by a [LeakDetector]. + * + * The default detector logs each report at `WARN` through SLF4J (see + * [LeakDetector.loggingListener]). Applications that want to forward leaks to metrics, fail a + * test suite, or aggregate counts can supply their own implementation via + * [LeakDetector.Builder.listener]. + * + * ## Threading + * + * [onLeak] is invoked from the detector's internal reaper thread (or from the calling thread + * when [LeakDetector.drainManually] is used in tests). Implementations must be thread-safe and + * should not block; a slow listener stalls detection of subsequent leaks. Exceptions thrown + * from [onLeak] are caught by the detector and never propagate to the JVM. + */ +public fun interface LeakListener { + /** + * Called once per detected leak. Must not throw; any thrown exception is swallowed by the + * detector to keep the reaper alive. + */ + public fun onLeak(report: LeakReport) +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakReport.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakReport.kt new file mode 100644 index 00000000..52240bf4 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakReport.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.lifecycle + +/** + * A single detected leak: a tracked resource that became phantom-reachable without ever being + * closed. + * + * The report is the payload handed to a [LeakListener]. It intentionally carries no reference + * to the leaked object itself (by the time a leak is detected the object is already gone); + * instead it carries the human-readable [description] that was supplied at registration and, + * when stack capture was enabled, the [creationStack] showing where the resource was created. + * + * @property description the label supplied to [LeakDetector.track], e.g. `"ResponseBody"` or + * a more specific `"ResponseBody(GET /v1/widgets)"`. Never blank. + * @property creationStack the stack trace captured at registration time, or `null` when stack + * capture was disabled (the default). Capturing a stack on every registration has a real + * allocation cost, so it is opt-in via [LeakDetector.Builder.captureCreationStack]. + */ +public class LeakReport private constructor( + public val description: String, + public val creationStack: Array?, +) { + /** + * Renders a single-line summary suitable for a log message. When a [creationStack] is + * present the caller is responsible for rendering it separately (e.g. as a `Throwable` + * cause); this method only returns the headline. + */ + public fun summary(): String = "Resource leak detected: $description was not closed before being reclaimed" + + internal companion object { + internal fun create( + description: String, + creationStack: Array?, + ): LeakReport = LeakReport(description, creationStack) + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakTracker.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakTracker.kt new file mode 100644 index 00000000..ad4a7510 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/lifecycle/LeakTracker.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.lifecycle + +/** + * Handle returned by [LeakDetector.track] for a single tracked resource. + * + * A `LeakTracker` is the caller's link to the detector's bookkeeping for one resource. The + * owning resource calls [closed] from its `close()` method to mark itself cleanly released; + * that single call is what distinguishes a clean shutdown from a leak when the resource later + * becomes phantom-reachable. + * + * Calling [closed] is idempotent and cheap (a single atomic flag flip plus a map removal). + * Resources that never close (e.g. a body the caller forgot to release) simply never call it, + * and the detector reports them when the JVM reclaims the resource. + * + * ## Thread-safety + * + * [closed] is safe to call from any thread and any number of times. + */ +public fun interface LeakTracker { + /** + * Marks the tracked resource as cleanly closed. + * + * Idempotent: extra calls are no-ops. After this returns, the resource will not be + * reported as a leak even once it becomes phantom-reachable. Resources should invoke + * this from their `close()` implementation. + */ + public fun closed() +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/lifecycle/LeakDetectorTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/lifecycle/LeakDetectorTest.kt new file mode 100644 index 00000000..98bafb0c --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/lifecycle/LeakDetectorTest.kt @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.lifecycle + +import java.lang.ref.WeakReference +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Detection is driven through [LeakDetector.drainManually] with the background reaper thread + * disabled so the assertions are race-free. The only non-determinism is whether the GC has + * reclaimed the resource; that is bounded by [forceCollection], which polls a [WeakReference] + * sentinel through repeated `System.gc()` calls and fails fast (rather than flaking) if the + * collector never runs. + */ +class LeakDetectorTest { + private val captured = CopyOnWriteArrayList() + + private fun detector(captureStack: Boolean = false): LeakDetector = + LeakDetector.Builder() + .enabled(true) + .startReaperThread(false) + .captureCreationStack(captureStack) + .listener { captured.add(it) } + .build() + + @Test + fun `reports an unclosed resource once it is reclaimed`() { + val det = detector() + // Allocate, track, and immediately drop the only strong reference — all inside a helper + // so no local on this frame keeps the resource alive. + val sentinel = trackThenDrop(det, "ResponseBody") + + forceCollection(sentinel) + val reported = det.drainManually() + + assertEquals(1, reported) + assertEquals(1, captured.size) + assertEquals("ResponseBody", captured[0].description) + assertTrue(captured[0].summary().contains("ResponseBody")) + } + + @Test + fun `does not report a resource that was closed`() { + val det = detector() + val sentinel = trackCloseThenDrop(det, "ClosedBody") + + forceCollection(sentinel) + val reported = det.drainManually() + + assertEquals(0, reported) + assertTrue(captured.isEmpty()) + } + + @Test + fun `disabled detector returns a no-op tracker and never reports`() { + val det = LeakDetector.Builder().enabled(false).build() + val tracker = det.track(Any(), "Ignored") + tracker.closed() // must not throw + + // Even after the (already-dropped) resource is gone there is nothing to drain. + forceCollectionBestEffort() + assertEquals(0, det.drainManually()) + assertTrue(captured.isEmpty()) + } + + @Test + fun `closed is idempotent`() { + val det = detector() + val tracker = det.track(Any(), "Body") + tracker.closed() + tracker.closed() + tracker.closed() + // No exception; nothing reported because the resource was marked closed. + assertEquals(0, det.drainManually()) + } + + @Test + fun `captures a creation stack when enabled`() { + val det = detector(captureStack = true) + val sentinel = trackThenDrop(det, "StackBody") + + forceCollection(sentinel) + det.drainManually() + + assertEquals(1, captured.size) + val stack = captured[0].creationStack + assertNotNull(stack) + assertTrue(stack.isNotEmpty()) + } + + @Test + fun `omits the creation stack by default`() { + val det = detector(captureStack = false) + val sentinel = trackThenDrop(det, "NoStackBody") + + forceCollection(sentinel) + det.drainManually() + + assertEquals(1, captured.size) + assertNull(captured[0].creationStack) + } + + @Test + fun `a throwing listener does not stop subsequent draining`() { + var calls = 0 + val det = + LeakDetector.Builder() + .enabled(true) + .startReaperThread(false) + .listener { + calls++ + error("boom") + } + .build() + val sentinel = trackThenDrop(det, "Boom") + + forceCollection(sentinel) + // drainManually must swallow the listener exception and count the leak. + val reported = det.drainManually() + assertEquals(1, reported) + assertEquals(1, calls) + } + + @Test + fun `system default detector is off unless the property is set`() { + // The property is not set in the test JVM, so the process-wide detector is disabled. + assertFalse(System.getProperty(LeakDetector.ENABLE_PROPERTY).equals("true", ignoreCase = true)) + val tracker = LeakDetector.systemDefault.track(Any(), "X") + tracker.closed() + assertEquals(0, LeakDetector.systemDefault.drainManually()) + } + + @Test + fun `trackCloseable reports a wrapper that is never closed`() { + val det = detector() + val sentinel = wrapThenDrop(det, close = false) + + forceCollection(sentinel) + val reported = det.drainManually() + + assertEquals(1, reported) + assertEquals("CloseableBody", captured.single().description) + } + + @Test + fun `trackCloseable does not report a wrapper that is closed and delegates close`() { + val det = detector() + val closed = booleanArrayOf(false) + val wrapper = det.trackCloseable({ closed[0] = true }, "CloseableBody") + wrapper.close() + + assertTrue(closed[0], "delegate close must run") + forceCollectionBestEffort() + assertEquals(0, det.drainManually()) + } + + @Test + fun `trackCloseable returns the original instance when disabled`() { + val det = LeakDetector.Builder().enabled(false).build() + val original = AutoCloseable { } + assertTrue(det.trackCloseable(original, "X") === original) + } + + @Test + fun `loggingListener tolerates reports with and without a stack`() { + val listener = LeakDetector.loggingListener() + // Should not throw for either shape. + listener.onLeak(LeakReport.create("NoStack", null)) + listener.onLeak(LeakReport.create("WithStack", Throwable().stackTrace)) + } + + // --- helpers ------------------------------------------------------------------------- + + /** Tracks a fresh resource, returns a [WeakReference] sentinel, and lets the resource die. */ + private fun trackThenDrop( + det: LeakDetector, + description: String, + ): WeakReference { + val resource = Any() + det.track(resource, description) + return WeakReference(resource) + } + + private fun trackCloseThenDrop( + det: LeakDetector, + description: String, + ): WeakReference { + val resource = Any() + val tracker = det.track(resource, description) + tracker.closed() + return WeakReference(resource) + } + + /** Wraps a fresh closeable via [LeakDetector.trackCloseable], optionally closing it, then drops it. */ + private fun wrapThenDrop( + det: LeakDetector, + close: Boolean, + ): WeakReference { + // A fresh instance per call (not a no-capture lambda, which Kotlin would intern as a + // singleton and never collect). + val delegate = NoopCloseable() + val wrapper = det.trackCloseable(delegate, "CloseableBody") + if (close) { + wrapper.close() + } + // The sentinel watches the delegate, which is the phantom referent the detector tracks. + return WeakReference(delegate) + } + + /** Polls `System.gc()` until [sentinel] is cleared, or fails the test if the GC never runs. */ + private fun forceCollection(sentinel: WeakReference) { + repeat(MAX_GC_ATTEMPTS) { + if (sentinel.get() == null) return + System.gc() + allocatePressure() + Thread.sleep(GC_POLL_MILLIS) + } + if (sentinel.get() != null) { + fail("resource was not collected after $MAX_GC_ATTEMPTS GC attempts") + } + } + + /** Best-effort GC nudge for the disabled-path test, which does not assert on collection. */ + private fun forceCollectionBestEffort() { + repeat(3) { + System.gc() + allocatePressure() + } + } + + private fun allocatePressure() { + // Allocate and discard to encourage the collector to act. The sink keeps the + // allocation from being elided without leaking a strong reference past this frame. + sink = ByteArray(PRESSURE_BYTES) + sink = null + } + + @Volatile + private var sink: ByteArray? = null + + private class NoopCloseable : AutoCloseable { + override fun close() = Unit + } + + private companion object { + private const val MAX_GC_ATTEMPTS = 100 + private const val GC_POLL_MILLIS = 10L + private const val PRESSURE_BYTES = 1 shl 20 + } +}