diff --git a/redwood-compose/src/commonTest/kotlin/app/cash/redwood/compose/ChangeListenerTest.kt b/redwood-compose/src/commonTest/kotlin/app/cash/redwood/compose/ChangeListenerTest.kt index 68bd2a3de5..50319c1429 100644 --- a/redwood-compose/src/commonTest/kotlin/app/cash/redwood/compose/ChangeListenerTest.kt +++ b/redwood-compose/src/commonTest/kotlin/app/cash/redwood/compose/ChangeListenerTest.kt @@ -22,6 +22,7 @@ import app.cash.redwood.Modifier import app.cash.redwood.RedwoodCodegenApi import app.cash.redwood.layout.testing.RedwoodLayoutTestingWidgetFactory import app.cash.redwood.lazylayout.testing.RedwoodLazyLayoutTestingWidgetFactory +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.protocol.guest.DefaultGuestProtocolAdapter import app.cash.redwood.protocol.guest.guestRedwoodVersion import app.cash.redwood.protocol.host.HostProtocolAdapter @@ -67,6 +68,7 @@ class ProtocolChangeListenerTest : AbstractChangeListenerTest() { container = MutableListChildren(), factory = TestSchemaProtocolFactory(widgetSystem), eventSink = { throw AssertionError() }, + leakDetector = LeakDetector.none(), ) guestAdapter.initChangesSink(hostAdapter) return TestRedwoodComposition(this, guestAdapter.widgetSystem, guestAdapter.root) { diff --git a/redwood-gradle-plugin/build.gradle b/redwood-gradle-plugin/build.gradle index 3519d0b5ac..bdf1b8c2a0 100644 --- a/redwood-gradle-plugin/build.gradle +++ b/redwood-gradle-plugin/build.gradle @@ -113,6 +113,7 @@ gradlePlugin { test { dependsOn(':redwood-compose:publishAllPublicationsToLocalMavenRepository') dependsOn(':redwood-gradle-plugin:publishAllPublicationsToLocalMavenRepository') + dependsOn(':redwood-leak-detector:publishAllPublicationsToLocalMavenRepository') dependsOn(':redwood-protocol:publishAllPublicationsToLocalMavenRepository') dependsOn(':redwood-protocol-guest:publishAllPublicationsToLocalMavenRepository') dependsOn(':redwood-protocol-host:publishAllPublicationsToLocalMavenRepository') diff --git a/redwood-leak-detector-zipline-test/src/commonMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestService.kt b/redwood-leak-detector-zipline-test/src/commonMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestService.kt index 7348b9a855..468beaa6e6 100644 --- a/redwood-leak-detector-zipline-test/src/commonMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestService.kt +++ b/redwood-leak-detector-zipline-test/src/commonMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestService.kt @@ -18,7 +18,7 @@ package app.cash.redwood.leaks.zipline import app.cash.zipline.ZiplineService interface LeakDetectorTestService : ZiplineService { - fun leakDetectorDisabled() + suspend fun leakDetectorDisabled() // TODO Once QuickJS supports WeakRef, enable regular tests: // suspend fun detectImmediateCollection() diff --git a/redwood-leak-detector-zipline-test/src/guestMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestServiceImpl.kt b/redwood-leak-detector-zipline-test/src/guestMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestServiceImpl.kt index c71ab1debb..c540f570f2 100644 --- a/redwood-leak-detector-zipline-test/src/guestMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestServiceImpl.kt +++ b/redwood-leak-detector-zipline-test/src/guestMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestServiceImpl.kt @@ -16,28 +16,26 @@ package app.cash.redwood.leaks.zipline import app.cash.redwood.leaks.LeakDetector -import app.cash.redwood.leaks.LeakListener import app.cash.redwood.leaks.zipline.LeakDetectorTestService.Companion.SERVICE_NAME import app.cash.zipline.Zipline -import assertk.assertThat -import assertk.assertions.isSameInstanceAs -import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource +import kotlinx.coroutines.coroutineScope class LeakDetectorTestServiceImpl : LeakDetectorTestService { - override fun leakDetectorDisabled() { - val leakDetector = LeakDetector.timeBased( - listener = object : LeakListener { - override fun onReferenceCollected(name: String) {} - override fun onReferenceLeaked(name: String, alive: Duration) {} - }, + override suspend fun leakDetectorDisabled() = coroutineScope { + val leakDetector = LeakDetector.timeBasedIn( + scope = this, timeSource = TimeSource.Monotonic, - leakThreshold = 2.seconds, + leakThreshold = 0.seconds, + callback = { _, _ -> throw AssertionError() }, ) + // QuickJS does not support WeakRef which is required for the leak detection to work correctly. // Once WeakRef is supported and this test starts failing, enable bridging of the real tests. - assertThat(leakDetector).isSameInstanceAs(LeakDetector.None) + leakDetector.watchReference(this@LeakDetectorTestServiceImpl, "") + + leakDetector.awaitClose() } } diff --git a/redwood-leak-detector-zipline-test/src/hostTest/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorZiplineTest.kt b/redwood-leak-detector-zipline-test/src/hostTest/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorZiplineTest.kt index bc6368d3e1..1152f240d1 100644 --- a/redwood-leak-detector-zipline-test/src/hostTest/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorZiplineTest.kt +++ b/redwood-leak-detector-zipline-test/src/hostTest/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorZiplineTest.kt @@ -73,7 +73,7 @@ class LeakDetectorZiplineTest { zipline.close() } - @Test fun leakDetectorDisabled() { + @Test fun leakDetectorDisabled() = runBlocking { service.leakDetectorDisabled() } } diff --git a/redwood-leak-detector/api/redwood-leak-detector.api b/redwood-leak-detector/api/redwood-leak-detector.api index c7fbbce40d..78675bcbcb 100644 --- a/redwood-leak-detector/api/redwood-leak-detector.api +++ b/redwood-leak-detector/api/redwood-leak-detector.api @@ -1,22 +1,17 @@ -public abstract interface class app/cash/redwood/leaks/LeakDetector { +public abstract interface class app/cash/redwood/leaks/LeakDetector : java/lang/AutoCloseable { public static final field Companion Lapp/cash/redwood/leaks/LeakDetector$Companion; - public abstract fun checkLeaks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun awaitClose (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun close ()V public abstract fun watchReference (Ljava/lang/Object;Ljava/lang/String;)V } -public final class app/cash/redwood/leaks/LeakDetector$Companion { - public final fun timeBased-SxA4cEA (Lapp/cash/redwood/leaks/LeakListener;Lkotlin/time/TimeSource;J)Lapp/cash/redwood/leaks/LeakDetector; -} - -public final class app/cash/redwood/leaks/LeakDetector$None : app/cash/redwood/leaks/LeakDetector { - public static final field INSTANCE Lapp/cash/redwood/leaks/LeakDetector$None; - public fun checkLeaks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun watchReference (Ljava/lang/Object;Ljava/lang/String;)V +public abstract interface class app/cash/redwood/leaks/LeakDetector$Callback { + public abstract fun onReferenceLeaked (Ljava/lang/Object;Ljava/lang/String;)V } -public abstract interface class app/cash/redwood/leaks/LeakListener { - public abstract fun onReferenceCollected (Ljava/lang/String;)V - public abstract fun onReferenceLeaked-HG0u8IE (Ljava/lang/String;J)V +public final class app/cash/redwood/leaks/LeakDetector$Companion { + public final fun none ()Lapp/cash/redwood/leaks/LeakDetector; + public final fun timeBasedIn-exY8QGI (Lkotlinx/coroutines/CoroutineScope;Lkotlin/time/TimeSource;JLapp/cash/redwood/leaks/LeakDetector$Callback;)Lapp/cash/redwood/leaks/LeakDetector; } public abstract interface annotation class app/cash/redwood/leaks/RedwoodLeakApi : java/lang/annotation/Annotation { diff --git a/redwood-leak-detector/api/redwood-leak-detector.klib.api b/redwood-leak-detector/api/redwood-leak-detector.klib.api index 0efecb4448..6254f086d0 100644 --- a/redwood-leak-detector/api/redwood-leak-detector.klib.api +++ b/redwood-leak-detector/api/redwood-leak-detector.klib.api @@ -10,21 +10,17 @@ open annotation class app.cash.redwood.leaks/RedwoodLeakApi : kotlin/Annotation constructor () // app.cash.redwood.leaks/RedwoodLeakApi.|(){}[0] } -abstract interface app.cash.redwood.leaks/LeakDetector { // app.cash.redwood.leaks/LeakDetector|null[0] +abstract interface app.cash.redwood.leaks/LeakDetector : kotlin/AutoCloseable { // app.cash.redwood.leaks/LeakDetector|null[0] + abstract fun close() // app.cash.redwood.leaks/LeakDetector.close|close(){}[0] abstract fun watchReference(kotlin/Any, kotlin/String) // app.cash.redwood.leaks/LeakDetector.watchReference|watchReference(kotlin.Any;kotlin.String){}[0] - abstract suspend fun checkLeaks() // app.cash.redwood.leaks/LeakDetector.checkLeaks|checkLeaks(){}[0] + abstract suspend fun awaitClose() // app.cash.redwood.leaks/LeakDetector.awaitClose|awaitClose(){}[0] - final object Companion { // app.cash.redwood.leaks/LeakDetector.Companion|null[0] - final fun timeBased(app.cash.redwood.leaks/LeakListener, kotlin.time/TimeSource, kotlin.time/Duration): app.cash.redwood.leaks/LeakDetector // app.cash.redwood.leaks/LeakDetector.Companion.timeBased|timeBased(app.cash.redwood.leaks.LeakListener;kotlin.time.TimeSource;kotlin.time.Duration){}[0] + abstract fun interface Callback { // app.cash.redwood.leaks/LeakDetector.Callback|null[0] + abstract fun onReferenceLeaked(kotlin/Any, kotlin/String) // app.cash.redwood.leaks/LeakDetector.Callback.onReferenceLeaked|onReferenceLeaked(kotlin.Any;kotlin.String){}[0] } - final object None : app.cash.redwood.leaks/LeakDetector { // app.cash.redwood.leaks/LeakDetector.None|null[0] - final fun watchReference(kotlin/Any, kotlin/String) // app.cash.redwood.leaks/LeakDetector.None.watchReference|watchReference(kotlin.Any;kotlin.String){}[0] - final suspend fun checkLeaks() // app.cash.redwood.leaks/LeakDetector.None.checkLeaks|checkLeaks(){}[0] + final object Companion { // app.cash.redwood.leaks/LeakDetector.Companion|null[0] + final fun none(): app.cash.redwood.leaks/LeakDetector // app.cash.redwood.leaks/LeakDetector.Companion.none|none(){}[0] + final fun timeBasedIn(kotlinx.coroutines/CoroutineScope, kotlin.time/TimeSource, kotlin.time/Duration, app.cash.redwood.leaks/LeakDetector.Callback): app.cash.redwood.leaks/LeakDetector // app.cash.redwood.leaks/LeakDetector.Companion.timeBasedIn|timeBasedIn(kotlinx.coroutines.CoroutineScope;kotlin.time.TimeSource;kotlin.time.Duration;app.cash.redwood.leaks.LeakDetector.Callback){}[0] } } - -abstract interface app.cash.redwood.leaks/LeakListener { // app.cash.redwood.leaks/LeakListener|null[0] - abstract fun onReferenceCollected(kotlin/String) // app.cash.redwood.leaks/LeakListener.onReferenceCollected|onReferenceCollected(kotlin.String){}[0] - abstract fun onReferenceLeaked(kotlin/String, kotlin.time/Duration) // app.cash.redwood.leaks/LeakListener.onReferenceLeaked|onReferenceLeaked(kotlin.String;kotlin.time.Duration){}[0] -} diff --git a/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt deleted file mode 100644 index ae7219c0ab..0000000000 --- a/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2024 Square, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package app.cash.redwood.leaks - -@Suppress("unused") // Used by extension functions. -internal expect class ConcurrentMutableList - -internal expect inline fun concurrentMutableListOf(): ConcurrentMutableList - -internal expect inline operator fun ConcurrentMutableList.plusAssign(element: T) - -internal expect inline fun ConcurrentMutableList.removeIf(predicate: (element: T) -> Boolean) diff --git a/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/Gc.kt b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/Gc.kt index 8f69b907fe..c201f47c55 100644 --- a/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/Gc.kt +++ b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/Gc.kt @@ -17,7 +17,7 @@ package app.cash.redwood.leaks internal expect fun detectGc(): Gc -internal interface Gc { +internal fun interface Gc { suspend fun collect() object None : Gc { diff --git a/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/LeakDetector.kt b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/LeakDetector.kt index a98b61e6af..a0eee4cd12 100644 --- a/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/LeakDetector.kt +++ b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/LeakDetector.kt @@ -15,98 +15,140 @@ */ package app.cash.redwood.leaks +import app.cash.redwood.leaks.LeakDetector.Callback import kotlin.time.Duration -import kotlin.time.TimeMark import kotlin.time.TimeSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch /** Watch references and detect when they leak. */ @RedwoodLeakApi -public interface LeakDetector { +public interface LeakDetector : AutoCloseable { /** * Add [reference] as a watched instance that is expected to be garbage collected soon. * * This function is safe to call from any thread. + * + * @param note Information about why [reference] is being watched. */ - public fun watchReference(reference: Any, name: String) + public fun watchReference(reference: Any, note: String) /** - * Trigger garbage collection and determine if any watched references have leaked per this - * instance's leak policy. - * - * This function is safe to call from any thread. + * Suspend until all current watched references are either collected or detected as a leak, and + * throw an exception for any new calls to [watchReference]. */ - public suspend fun checkLeaks() + public suspend fun awaitClose() - public object None : LeakDetector { - override fun watchReference(reference: Any, name: String) {} - override suspend fun checkLeaks() {} - } + /** + * Immediately stop all leak detection of current watched references, and throw an exception for + * any new calls to [watchReference]. + */ + override fun close() public companion object { /** - * Create a time-based [LeakDetector] which reports leaks to [listener] if watched references + * Create a time-based [LeakDetector] which reports leaks if watched references * have not been garbage collected before the [leakThreshold]. * - * This function may return [None] if the platform does not support weak references, in which - * case [listener] will never be invoked. + * This function may return a [no-op detector][none] if the platform does not support weak references. */ - public fun timeBased( - listener: LeakListener, + public fun timeBasedIn( + scope: CoroutineScope, timeSource: TimeSource, leakThreshold: Duration, + callback: Callback, ): LeakDetector { if (hasWeakReference()) { - return TimeBasedLeakDetector(listener, timeSource, leakThreshold) + return TimeBasedLeakDetector(scope, detectGc(), timeSource, leakThreshold, callback) } - return None + return none() } + + /** A [LeakDetector] that does not watch references for leaks. */ + public fun none(): LeakDetector = NoOpLeakDetector() } -} -@RedwoodLeakApi -public interface LeakListener { - public fun onReferenceCollected(name: String) - public fun onReferenceLeaked(name: String, alive: Duration) + public fun interface Callback { + public fun onReferenceLeaked(reference: Any, note: String) + } } internal class TimeBasedLeakDetector( - private val listener: LeakListener, + private val scope: CoroutineScope, + internal val gc: Gc, private val timeSource: TimeSource, private val leakThreshold: Duration, + private val callback: Callback, ) : LeakDetector { - internal val gc = detectGc() - private val watchedReferences = concurrentMutableListOf() - - override fun watchReference(reference: Any, name: String) { - watchedReferences += WatchedReference( - name = name, - weakReference = WeakReference(reference), - watchedAt = timeSource.markNow(), - ) - } + private var closed = false - override suspend fun checkLeaks() { - gc.collect() + private val gcJob = Job() + private val gcNotifications = flow { + val checkPeriod = leakThreshold / 2 + while (true) { + delay(checkPeriod) + gc.collect() + emit(Unit) + } + }.shareIn( + scope = CoroutineScope(scope.coroutineContext + gcJob), + started = SharingStarted.WhileSubscribed(), + ) - watchedReferences.removeIf { watchedReference -> - if (watchedReference.weakReference.get() == null) { - listener.onReferenceCollected(watchedReference.name) - return@removeIf true - } + private val watchJob = Job() - val alive = watchedReference.watchedAt.elapsedNow() - if (alive >= leakThreshold) { - listener.onReferenceLeaked(watchedReference.name, alive) - return@removeIf true - } + override fun watchReference(reference: Any, note: String) { + check(!closed) { "closed" } + internalWatch(WeakReference(reference), note) + } - false + private fun internalWatch(weakReference: WeakReference, reason: String) { + scope.launch(watchJob, start = UNDISPATCHED) { + val watchedAt = timeSource.markNow() + gcNotifications.collect { + val reference = weakReference.get() ?: cancel() + if (watchedAt.elapsedNow() >= leakThreshold) { + callback.onReferenceLeaked(reference, reason) + cancel() + } + } } } - private class WatchedReference( - val name: String, - val weakReference: WeakReference, - val watchedAt: TimeMark, - ) + override suspend fun awaitClose() { + closed = true + // Wait for all active watches to complete. + watchJob.children.forEach { it.join() } + watchJob.cancel() + gcJob.cancel() + } + + override fun close() { + closed = true + watchJob.cancel() + gcJob.cancel() + } +} + +private class NoOpLeakDetector : LeakDetector { + private var closed = false + + override fun watchReference(reference: Any, note: String) { + check(!closed) { "closed" } + } + + override suspend fun awaitClose() { + closed = true + } + + override fun close() { + closed = true + } } diff --git a/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/LeakDetectorTest.kt b/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/LeakDetectorTest.kt index bc4e2b6143..fbc9709e23 100644 --- a/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/LeakDetectorTest.kt +++ b/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/LeakDetectorTest.kt @@ -17,25 +17,36 @@ package app.cash.redwood.leaks import assertk.assertThat import assertk.assertions.containsExactly +import assertk.assertions.hasSize import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import assertk.assertions.isNotSameInstanceAs import assertk.assertions.prop +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -import kotlin.time.TestTimeSource +import kotlin.time.TimeSource +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.testTimeSource +@OptIn(ExperimentalCoroutinesApi::class) class LeakDetectorTest { - private val timeSource = TestTimeSource() - private val listener = RecordingLeakListener() - private val leakDetector = LeakDetector.timeBased(listener, timeSource, 10.seconds) - private var ref: Any? = Any() + private val scope = CoroutineScope(Dispatchers.Default) + private val callback = RecordingLeakCallback() + private val leakDetector = LeakDetector.timeBasedIn(scope, TimeSource.Monotonic, 10.seconds, callback) + private var ref: Any? = object : Any() { + override fun toString() = "ref" + } @BeforeTest fun before() { assertThat(leakDetector) @@ -44,61 +55,57 @@ class LeakDetectorTest { .isNotSameInstanceAs(Gc.None) } - private suspend fun wait(duration: Duration) { - // Advance the virtual time against which leak thresholds are compared. - timeSource += duration - // Use a delay indirection that might use wall clock time where required. - delayForGc(duration) + @AfterTest fun after() { + scope.cancel() } - @Test fun detectImmediateCollection() = runTest { - leakDetector.watchReference(ref!!, "ref") - + @Test fun detectCollection() = runTest { + leakDetector.watchReference(ref!!, "note") ref = null - wait(10.seconds) - - leakDetector.checkLeaks() - assertThat(listener.events).containsExactly("collected: ref") + leakDetector.awaitClose() + assertThat(callback.events).isEmpty() } - @Test fun detectDelayedCollection() = runTest { - leakDetector.watchReference(ref!!, "ref") + @Test fun detectLeak() = runTest { + leakDetector.watchReference(ref!!, "note") + leakDetector.awaitClose() + assertThat(callback.events).containsExactly("leaked ref note") + } - wait(5.seconds) + @Test fun gcOnlyRunsWhenWatchingReferences() = runTest { + var gcRuns = 0 + val gc = Gc { gcRuns += 1 } + val leakDetector = TimeBasedLeakDetector(this, gc, testTimeSource, 10.seconds, callback) - leakDetector.checkLeaks() - assertThat(listener.events).isEmpty() + // No refs? No GC. + advanceTimeByAndRunCurrent(30.seconds) + assertThat(gcRuns).isEqualTo(0) - ref = null + leakDetector.watchReference(ref!!, "note") - wait(5.seconds) + advanceTimeByAndRunCurrent(5.seconds) + assertThat(gcRuns).isEqualTo(1) + advanceTimeByAndRunCurrent(5.seconds) + assertThat(gcRuns).isEqualTo(2) + assertThat(callback.events).hasSize(1) - leakDetector.checkLeaks() - assertThat(listener.events).containsExactly("collected: ref") - } + // No refs? No GC. + advanceTimeByAndRunCurrent(30.seconds) + assertThat(gcRuns).isEqualTo(2) - @Test fun detectLeak() = runTest { - leakDetector.watchReference(ref!!, "ref") + leakDetector.watchReference(ref!!, "note") - // Only advance virtual time for checking the event message. - timeSource += 15.seconds + advanceTimeByAndRunCurrent(5.seconds) + assertThat(gcRuns).isEqualTo(3) + advanceTimeByAndRunCurrent(5.seconds) + assertThat(gcRuns).isEqualTo(4) + assertThat(callback.events).hasSize(2) - leakDetector.checkLeaks() - assertThat(listener.events).containsExactly("leaked @ 15s: ref") + leakDetector.close() } - @Test fun concurrencyStressTest() = runTest { - coroutineScope { - repeat(10_000) { i -> - launch(Dispatchers.Default) { - leakDetector.watchReference(Any(), "$i") - } - } - repeat(100) { - launch(Dispatchers.Default) { - leakDetector.checkLeaks() - } - } - } + private fun TestScope.advanceTimeByAndRunCurrent(duration: Duration) { + advanceTimeBy(duration) + runCurrent() } } diff --git a/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/RecordingLeakListener.kt b/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/RecordingLeakCallback.kt similarity index 71% rename from redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/RecordingLeakListener.kt rename to redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/RecordingLeakCallback.kt index 62b4558077..3b192d6560 100644 --- a/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/RecordingLeakListener.kt +++ b/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/RecordingLeakCallback.kt @@ -15,16 +15,10 @@ */ package app.cash.redwood.leaks -import kotlin.time.Duration - -class RecordingLeakListener : LeakListener { +class RecordingLeakCallback : LeakDetector.Callback { val events = mutableListOf() - override fun onReferenceCollected(name: String) { - events += "collected: $name" - } - - override fun onReferenceLeaked(name: String, alive: Duration) { - events += "leaked @ $alive: $name" + override fun onReferenceLeaked(reference: Any, note: String) { + events += "leaked $reference $note" } } diff --git a/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt b/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt deleted file mode 100644 index 8c76bf7140..0000000000 --- a/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (C) 2024 Square, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@file:Suppress("NOTHING_TO_INLINE") - -package app.cash.redwood.leaks - -@JsName("Array") -internal external class JsArray { - val length: Int - fun at(index: Int): T? - fun push(element: T) - fun splice(start: Int, deleteCount: Int) -} - -internal actual typealias ConcurrentMutableList = JsArray - -internal actual inline fun concurrentMutableListOf(): ConcurrentMutableList { - return JsArray() -} - -internal actual inline operator fun ConcurrentMutableList.plusAssign(element: T) { - push(element) -} - -internal actual inline fun ConcurrentMutableList.removeIf(predicate: (element: T) -> Boolean) { - var i = 0 - while (i < length) { - // We know the index is safe and will return a T. - if (predicate(at(i).unsafeCast())) { - splice(i, 1) - } else { - i++ - } - } -} diff --git a/redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt b/redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt deleted file mode 100644 index b7222580d6..0000000000 --- a/redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2024 Square, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@file:Suppress("NOTHING_TO_INLINE") - -package app.cash.redwood.leaks - -import java.util.concurrent.ConcurrentLinkedQueue - -internal actual typealias ConcurrentMutableList = ConcurrentLinkedQueue - -internal actual inline fun concurrentMutableListOf(): ConcurrentMutableList { - return ConcurrentLinkedQueue() -} - -internal actual inline operator fun ConcurrentMutableList.plusAssign(element: T) { - add(element) -} - -internal actual inline fun ConcurrentMutableList.removeIf(predicate: (element: T) -> Boolean) { - val iterator = iterator() - while (iterator.hasNext()) { - if (predicate(iterator.next())) { - iterator.remove() - } - } -} diff --git a/redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt b/redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt deleted file mode 100644 index b14c44f477..0000000000 --- a/redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (C) 2024 Square, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package app.cash.redwood.leaks - -import kotlinx.atomicfu.locks.SynchronizedObject -import kotlinx.atomicfu.locks.synchronized - -internal actual class ConcurrentMutableList : SynchronizedObject() { - val list = mutableListOf() -} - -internal actual inline fun concurrentMutableListOf(): ConcurrentMutableList { - return ConcurrentMutableList() -} - -internal actual inline operator fun ConcurrentMutableList.plusAssign(element: T) { - synchronized(this) { - list += element - } -} - -internal actual inline fun ConcurrentMutableList.removeIf(predicate: (element: T) -> Boolean) { - synchronized(this) { - var i = 0 - while (i < list.size) { - if (predicate(list[i])) { - list.removeAt(i) - } else { - i++ - } - } - } -} diff --git a/redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt b/redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt deleted file mode 100644 index 2a3a43a794..0000000000 --- a/redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2024 Square, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@file:Suppress("NOTHING_TO_INLINE") - -package app.cash.redwood.leaks - -internal actual typealias ConcurrentMutableList = ArrayList - -internal actual inline fun concurrentMutableListOf(): ConcurrentMutableList { - return arrayListOf() -} - -internal actual inline operator fun ConcurrentMutableList.plusAssign(element: T) { - add(element) -} - -internal actual inline fun ConcurrentMutableList.removeIf(predicate: (element: T) -> Boolean) { - var i = 0 - while (i < size) { - if (predicate(get(i))) { - removeAt(i) - } else { - i++ - } - } -} diff --git a/redwood-protocol-host/api/redwood-protocol-host.api b/redwood-protocol-host/api/redwood-protocol-host.api index 23909902e7..a0dd1737e3 100644 --- a/redwood-protocol-host/api/redwood-protocol-host.api +++ b/redwood-protocol-host/api/redwood-protocol-host.api @@ -5,7 +5,7 @@ public abstract interface class app/cash/redwood/protocol/host/GeneratedProtocol } public final class app/cash/redwood/protocol/host/HostProtocolAdapter : app/cash/redwood/protocol/ChangesSink { - public synthetic fun (Ljava/lang/String;Lapp/cash/redwood/widget/Widget$Children;Lapp/cash/redwood/protocol/host/ProtocolFactory;Lapp/cash/redwood/protocol/host/UiEventSink;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lapp/cash/redwood/widget/Widget$Children;Lapp/cash/redwood/protocol/host/ProtocolFactory;Lapp/cash/redwood/protocol/host/UiEventSink;Lapp/cash/redwood/leaks/LeakDetector;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun close ()V public fun sendChanges (Ljava/util/List;)V } @@ -42,6 +42,7 @@ public abstract class app/cash/redwood/protocol/host/ProtocolNode { public abstract fun getWidget ()Lapp/cash/redwood/widget/Widget; public final fun getWidgetTag-BlhN7y0 ()I public final fun setId-ou3jOuA (I)V + public abstract fun toString ()Ljava/lang/String; public final fun updateModifier (Lapp/cash/redwood/Modifier;)V public abstract fun visitIds (Lkotlin/jvm/functions/Function1;)V } diff --git a/redwood-protocol-host/api/redwood-protocol-host.klib.api b/redwood-protocol-host/api/redwood-protocol-host.klib.api index 98ca783282..f38fab7e9d 100644 --- a/redwood-protocol-host/api/redwood-protocol-host.klib.api +++ b/redwood-protocol-host/api/redwood-protocol-host.klib.api @@ -48,12 +48,13 @@ abstract class <#A: kotlin/Any> app.cash.redwood.protocol.host/ProtocolNode { // abstract fun apply(app.cash.redwood.protocol/PropertyChange, app.cash.redwood.protocol.host/UiEventSink) // app.cash.redwood.protocol.host/ProtocolNode.apply|apply(app.cash.redwood.protocol.PropertyChange;app.cash.redwood.protocol.host.UiEventSink){}[0] abstract fun children(app.cash.redwood.protocol/ChildrenTag): app.cash.redwood.protocol.host/ProtocolChildren<#A>? // app.cash.redwood.protocol.host/ProtocolNode.children|children(app.cash.redwood.protocol.ChildrenTag){}[0] abstract fun detach() // app.cash.redwood.protocol.host/ProtocolNode.detach|detach(){}[0] + abstract fun toString(): kotlin/String // app.cash.redwood.protocol.host/ProtocolNode.toString|toString(){}[0] abstract fun visitIds(kotlin/Function1) // app.cash.redwood.protocol.host/ProtocolNode.visitIds|visitIds(kotlin.Function1){}[0] final fun updateModifier(app.cash.redwood/Modifier) // app.cash.redwood.protocol.host/ProtocolNode.updateModifier|updateModifier(app.cash.redwood.Modifier){}[0] } final class <#A: kotlin/Any> app.cash.redwood.protocol.host/HostProtocolAdapter : app.cash.redwood.protocol/ChangesSink { // app.cash.redwood.protocol.host/HostProtocolAdapter|null[0] - constructor (app.cash.redwood.protocol/RedwoodVersion, app.cash.redwood.widget/Widget.Children<#A>, app.cash.redwood.protocol.host/ProtocolFactory<#A>, app.cash.redwood.protocol.host/UiEventSink) // app.cash.redwood.protocol.host/HostProtocolAdapter.|(app.cash.redwood.protocol.RedwoodVersion;app.cash.redwood.widget.Widget.Children<1:0>;app.cash.redwood.protocol.host.ProtocolFactory<1:0>;app.cash.redwood.protocol.host.UiEventSink){}[0] + constructor (app.cash.redwood.protocol/RedwoodVersion, app.cash.redwood.widget/Widget.Children<#A>, app.cash.redwood.protocol.host/ProtocolFactory<#A>, app.cash.redwood.protocol.host/UiEventSink, app.cash.redwood.leaks/LeakDetector) // app.cash.redwood.protocol.host/HostProtocolAdapter.|(app.cash.redwood.protocol.RedwoodVersion;app.cash.redwood.widget.Widget.Children<1:0>;app.cash.redwood.protocol.host.ProtocolFactory<1:0>;app.cash.redwood.protocol.host.UiEventSink;app.cash.redwood.leaks.LeakDetector){}[0] final fun close() // app.cash.redwood.protocol.host/HostProtocolAdapter.close|close(){}[0] final fun sendChanges(kotlin.collections/List) // app.cash.redwood.protocol.host/HostProtocolAdapter.sendChanges|sendChanges(kotlin.collections.List){}[0] diff --git a/redwood-protocol-host/build.gradle b/redwood-protocol-host/build.gradle index 4615fde7ec..fea08442e3 100644 --- a/redwood-protocol-host/build.gradle +++ b/redwood-protocol-host/build.gradle @@ -15,6 +15,7 @@ kotlin { dependencies { api projects.redwoodProtocol api projects.redwoodWidget + api projects.redwoodLeakDetector } } commonTest { diff --git a/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapter.kt b/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapter.kt index eaa304b2e6..c68f0276bd 100644 --- a/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapter.kt +++ b/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapter.kt @@ -17,6 +17,7 @@ package app.cash.redwood.protocol.host import app.cash.redwood.Modifier import app.cash.redwood.RedwoodCodegenApi +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.protocol.Change import app.cash.redwood.protocol.ChangesSink import app.cash.redwood.protocol.ChildrenChange @@ -50,6 +51,7 @@ public class HostProtocolAdapter( container: Widget.Children, factory: ProtocolFactory, private val eventSink: UiEventSink, + private val leakDetector: LeakDetector, ) : ChangesSink { private val factory = when (factory) { is GeneratedProtocolFactory -> factory @@ -182,13 +184,22 @@ public class HostProtocolAdapter( pool.addFirst(removedNode) if (pool.size > POOL_SIZE) { val evicted = pool.removeLast() // Evict the least-recently added element. - evicted.detach() + watchForLeaksAndDetach(evicted, "evicted from reuse pool") } } else { - removedNode.detach() + watchForLeaksAndDetach(removedNode, "not eligible for reuse") } } + private fun watchForLeaksAndDetach(node: ProtocolNode, note: String) { + leakDetector.watchReference(node.widget.value, note) + leakDetector.watchReference(node.widget, note) + leakDetector.watchReference(node, note) + + // Detaching frees the node's reference to the widget, so this must be done last. + node.detach() + } + /** * Implements widget reuse (view recycling). * @@ -406,6 +417,8 @@ private class RootProtocolNode( override fun detach() { children.detach() } + + override fun toString() = "RootProtocolNode" } private const val REUSE_MODIFIER_TAG = -4_543_827 diff --git a/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/ProtocolNode.kt b/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/ProtocolNode.kt index 319dc5b23c..67adb3a290 100644 --- a/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/ProtocolNode.kt +++ b/redwood-protocol-host/src/commonMain/kotlin/app/cash/redwood/protocol/host/ProtocolNode.kt @@ -67,8 +67,15 @@ public abstract class ProtocolNode( /** Recursively visit IDs in this widget's tree, starting with this widget's [id]. */ public abstract fun visitIds(block: (Id) -> Unit) - /** Detach all child widgets recursively, then clear direct references to them. */ + /** + * Detach all child widgets recursively, then clear direct references to them. + * + * After this is called there will be no further calls to this node. + */ public abstract fun detach() + + /** Human-readable name of this node along with [id] and [widgetTag]. */ + public abstract override fun toString(): String } /** diff --git a/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ChildrenNodeIndexTest.kt b/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ChildrenNodeIndexTest.kt index 067e86b0a0..8140278e01 100644 --- a/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ChildrenNodeIndexTest.kt +++ b/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/ChildrenNodeIndexTest.kt @@ -140,6 +140,8 @@ private class WidgetNode(override val widget: StringWidget) : ProtocolNode { diff --git a/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapterTest.kt b/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapterTest.kt index cc7f222783..b54266f631 100644 --- a/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapterTest.kt +++ b/redwood-protocol-host/src/commonTest/kotlin/app/cash/redwood/protocol/host/HostProtocolAdapterTest.kt @@ -18,6 +18,7 @@ package app.cash.redwood.protocol.host import app.cash.redwood.RedwoodCodegenApi import app.cash.redwood.layout.testing.RedwoodLayoutTestingWidgetFactory import app.cash.redwood.lazylayout.testing.RedwoodLazyLayoutTestingWidgetFactory +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.protocol.ChildrenChange.Add import app.cash.redwood.protocol.ChildrenChange.Remove import app.cash.redwood.protocol.ChildrenTag @@ -56,6 +57,7 @@ class HostProtocolAdapterTest { ), ), eventSink = ::error, + leakDetector = LeakDetector.none(), ) val changes = listOf( Create( @@ -82,6 +84,7 @@ class HostProtocolAdapterTest { ), ), eventSink = ::error, + leakDetector = LeakDetector.none(), ) val changes = listOf( Create( @@ -109,6 +112,7 @@ class HostProtocolAdapterTest { ), ), eventSink = ::error, + leakDetector = LeakDetector.none(), ) // Add a button. @@ -119,6 +123,12 @@ class HostProtocolAdapterTest { // Button tag = WidgetTag(4), ), + // Set Button's required color property. + PropertyChange( + id = Id(1), + tag = PropertyTag(3), + value = JsonPrimitive(0), + ), Add( id = Id.Root, tag = ChildrenTag.Root, @@ -169,6 +179,7 @@ class HostProtocolAdapterTest { ), ), eventSink = ::error, + leakDetector = LeakDetector.none(), ) // Initial Button add does not trigger update callback (it's implicit because of insert). @@ -203,6 +214,7 @@ class HostProtocolAdapterTest { ), ), eventSink = ::error, + leakDetector = LeakDetector.none(), ) // TestRow { diff --git a/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewRecyclingTester.kt b/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewRecyclingTester.kt index 026120e27f..d2f81e62da 100644 --- a/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewRecyclingTester.kt +++ b/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewRecyclingTester.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import app.cash.redwood.RedwoodCodegenApi import app.cash.redwood.layout.testing.RedwoodLayoutTestingWidgetFactory import app.cash.redwood.lazylayout.testing.RedwoodLazyLayoutTestingWidgetFactory +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.protocol.guest.DefaultGuestProtocolAdapter import app.cash.redwood.protocol.guest.GuestProtocolAdapter import app.cash.redwood.protocol.guest.guestRedwoodVersion @@ -67,6 +68,7 @@ class ViewRecyclingTester( eventSink = { event -> guestAdapter.sendEvent(event.toProtocol(Json.Default)) }, + leakDetector = LeakDetector.none(), ) private val guestAdapter: GuestProtocolAdapter = DefaultGuestProtocolAdapter( diff --git a/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt b/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt index 06fba23e60..6a4c94b1e0 100644 --- a/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt +++ b/redwood-testing/src/commonTest/kotlin/app/cash/redwood/testing/ViewTreesTest.kt @@ -21,6 +21,7 @@ import app.cash.redwood.RedwoodCodegenApi import app.cash.redwood.compose.current import app.cash.redwood.layout.testing.RedwoodLayoutTestingWidgetFactory import app.cash.redwood.lazylayout.testing.RedwoodLazyLayoutTestingWidgetFactory +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.protocol.Change import app.cash.redwood.protocol.ChildrenChange.Add import app.cash.redwood.protocol.ChildrenTag @@ -152,6 +153,7 @@ class ViewTreesTest { container = widgetContainer, factory = protocolNodes, eventSink = { throw AssertionError() }, + leakDetector = LeakDetector.none(), ) hostAdapter.sendChanges(expected) diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolHostGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolHostGeneration.kt index a7a8f813dc..537fbb8a0e 100644 --- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolHostGeneration.kt +++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/protocolHostGeneration.kt @@ -42,6 +42,7 @@ import com.squareup.kotlinpoet.MemberName import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.UNIT @@ -287,6 +288,8 @@ internal class ProtocolButton( public override fun detach() { _widget = null } + + public override fun toString() = "ProtocolButton(id=$id, tag=12) } */ internal fun generateProtocolNode( @@ -510,6 +513,22 @@ internal fun generateProtocolNode( .addStatement("_widget = null") .build(), ) + .addFunction( + FunSpec.builder("toString") + .addModifiers(OVERRIDE) + .returns(STRING) + // This explicit string builder usage allows sharing of strings in dex. + // See https://jakewharton.com/the-economics-of-generated-code/#string-duplication. + .beginControlFlow("return buildString") + .addStatement("append(%S)", type.simpleName) + .addStatement("""append("(id=")""") + .addStatement("append(id.value)") + .addStatement("""append(", tag=")""") + .addStatement("append(widgetTag.value)") + .addStatement("append(')')") + .endControlFlow() + .build(), + ) .build(), ) } diff --git a/redwood-treehouse-host/api/android/redwood-treehouse-host.api b/redwood-treehouse-host/api/android/redwood-treehouse-host.api index fa21b50dcb..1f04c9b433 100644 --- a/redwood-treehouse-host/api/android/redwood-treehouse-host.api +++ b/redwood-treehouse-host/api/android/redwood-treehouse-host.api @@ -109,8 +109,8 @@ public abstract class app/cash/redwood/treehouse/TreehouseApp$Spec { } public final class app/cash/redwood/treehouse/TreehouseAppFactoryAndroidKt { - public static final fun TreehouseAppFactory (Landroid/content/Context;Lokhttp3/OkHttpClient;Lapp/cash/zipline/loader/ManifestVerifier;Lokio/FileSystem;Lokio/Path;Ljava/lang/String;JLapp/cash/zipline/loader/LoaderEventListener;ILapp/cash/redwood/treehouse/StateStore;)Lapp/cash/redwood/treehouse/TreehouseApp$Factory; - public static synthetic fun TreehouseAppFactory$default (Landroid/content/Context;Lokhttp3/OkHttpClient;Lapp/cash/zipline/loader/ManifestVerifier;Lokio/FileSystem;Lokio/Path;Ljava/lang/String;JLapp/cash/zipline/loader/LoaderEventListener;ILapp/cash/redwood/treehouse/StateStore;ILjava/lang/Object;)Lapp/cash/redwood/treehouse/TreehouseApp$Factory; + public static final fun TreehouseAppFactory (Landroid/content/Context;Lokhttp3/OkHttpClient;Lapp/cash/zipline/loader/ManifestVerifier;Lokio/FileSystem;Lokio/Path;Ljava/lang/String;JLapp/cash/zipline/loader/LoaderEventListener;ILapp/cash/redwood/treehouse/StateStore;Lapp/cash/redwood/leaks/LeakDetector;)Lapp/cash/redwood/treehouse/TreehouseApp$Factory; + public static synthetic fun TreehouseAppFactory$default (Landroid/content/Context;Lokhttp3/OkHttpClient;Lapp/cash/zipline/loader/ManifestVerifier;Lokio/FileSystem;Lokio/Path;Ljava/lang/String;JLapp/cash/zipline/loader/LoaderEventListener;ILapp/cash/redwood/treehouse/StateStore;Lapp/cash/redwood/leaks/LeakDetector;ILjava/lang/Object;)Lapp/cash/redwood/treehouse/TreehouseApp$Factory; } public abstract interface class app/cash/redwood/treehouse/TreehouseContentSource { diff --git a/redwood-treehouse-host/api/redwood-treehouse-host.klib.api b/redwood-treehouse-host/api/redwood-treehouse-host.klib.api index f34c4a81f2..2a01e4bdca 100644 --- a/redwood-treehouse-host/api/redwood-treehouse-host.klib.api +++ b/redwood-treehouse-host/api/redwood-treehouse-host.klib.api @@ -187,4 +187,4 @@ open class app.cash.redwood.treehouse/EventListener { // app.cash.redwood.treeho final fun <#A: app.cash.redwood.treehouse/AppService, #B: kotlin/Any> (app.cash.redwood.treehouse/TreehouseContentSource<#A>).app.cash.redwood.treehouse/bindWhenReady(app.cash.redwood.treehouse/TreehouseView<#B>, app.cash.redwood.treehouse/TreehouseApp<#A>, app.cash.redwood.treehouse/CodeListener = ...): okio/Closeable // app.cash.redwood.treehouse/bindWhenReady|bindWhenReady@app.cash.redwood.treehouse.TreehouseContentSource<0:0>(app.cash.redwood.treehouse.TreehouseView<0:1>;app.cash.redwood.treehouse.TreehouseApp<0:0>;app.cash.redwood.treehouse.CodeListener){0§;1§}[0] final fun <#A: kotlin/Any> (app.cash.redwood.treehouse/Content).app.cash.redwood.treehouse/bindWhenReady(app.cash.redwood.treehouse/TreehouseView<#A>): okio/Closeable // app.cash.redwood.treehouse/bindWhenReady|bindWhenReady@app.cash.redwood.treehouse.Content(app.cash.redwood.treehouse.TreehouseView<0:0>){0§}[0] -final fun app.cash.redwood.treehouse/TreehouseAppFactory(app.cash.zipline.loader/ZiplineHttpClient, app.cash.zipline.loader/ManifestVerifier, okio/FileSystem? = ..., okio/Path? = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/Int = ..., app.cash.zipline.loader/LoaderEventListener = ..., app.cash.redwood.treehouse/StateStore = ...): app.cash.redwood.treehouse/TreehouseApp.Factory // app.cash.redwood.treehouse/TreehouseAppFactory|TreehouseAppFactory(app.cash.zipline.loader.ZiplineHttpClient;app.cash.zipline.loader.ManifestVerifier;okio.FileSystem?;okio.Path?;kotlin.String;kotlin.Long;kotlin.Int;app.cash.zipline.loader.LoaderEventListener;app.cash.redwood.treehouse.StateStore){}[0] +final fun app.cash.redwood.treehouse/TreehouseAppFactory(app.cash.zipline.loader/ZiplineHttpClient, app.cash.zipline.loader/ManifestVerifier, okio/FileSystem? = ..., okio/Path? = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/Int = ..., app.cash.zipline.loader/LoaderEventListener = ..., app.cash.redwood.treehouse/StateStore = ..., app.cash.redwood.leaks/LeakDetector = ...): app.cash.redwood.treehouse/TreehouseApp.Factory // app.cash.redwood.treehouse/TreehouseAppFactory|TreehouseAppFactory(app.cash.zipline.loader.ZiplineHttpClient;app.cash.zipline.loader.ManifestVerifier;okio.FileSystem?;okio.Path?;kotlin.String;kotlin.Long;kotlin.Int;app.cash.zipline.loader.LoaderEventListener;app.cash.redwood.treehouse.StateStore;app.cash.redwood.leaks.LeakDetector){}[0] diff --git a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryAndroid.kt b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryAndroid.kt index 3ff2245684..cb275fb5ae 100644 --- a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryAndroid.kt +++ b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryAndroid.kt @@ -16,6 +16,7 @@ package app.cash.redwood.treehouse import android.content.Context +import app.cash.redwood.leaks.LeakDetector import app.cash.zipline.loader.LoaderEventListener import app.cash.zipline.loader.ManifestVerifier import app.cash.zipline.loader.asZiplineHttpClient @@ -36,6 +37,7 @@ public fun TreehouseAppFactory( loaderEventListener: LoaderEventListener = LoaderEventListener.None, concurrentDownloads: Int = 8, stateStore: StateStore = MemoryStateStore(), + leakDetector: LeakDetector = LeakDetector.none(), ): TreehouseApp.Factory = RealTreehouseApp.Factory( platform = AndroidTreehousePlatform(context), httpClient = httpClient.asZiplineHttpClient(), @@ -49,4 +51,5 @@ public fun TreehouseAppFactory( loaderEventListener = loaderEventListener, concurrentDownloads = concurrentDownloads, stateStore = stateStore, + leakDetector = leakDetector, ) diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt index 7ea0a288d3..fff3c8ff5a 100644 --- a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.treehouse +import app.cash.redwood.leaks.LeakDetector import app.cash.zipline.Zipline import app.cash.zipline.loader.LoaderEventListener import app.cash.zipline.loader.ManifestVerifier @@ -107,6 +108,7 @@ internal class TreehouseTester( concurrentDownloads = 1, loaderEventListener = LoaderEventListener.None, stateStore = MemoryStateStore(), + leakDetector = LeakDetector.none(), ) val openTreehouseDispatchersCount: Int diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeap.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeap.kt index 83cd4be2f7..5ba29a68ed 100644 --- a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeap.kt +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeap.kt @@ -16,6 +16,7 @@ package app.cash.redwood.treehouse.leaks import app.cash.redwood.treehouse.EventLog +import java.lang.ref.WeakReference import java.lang.reflect.Field import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job @@ -78,6 +79,7 @@ internal object JvmHeap : Heap { instance is KSerializer<*> -> listOf() instance is SerializersModule -> listOf() instance is String -> listOf() + instance is WeakReference<*> -> listOf() // Explore everything else by reflecting on its fields. javaPackageName.isDescendant( diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt index 9dcba60150..66802b3380 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.treehouse +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.protocol.SnapshotChangeList import app.cash.redwood.protocol.host.HostProtocolAdapter import app.cash.redwood.protocol.host.ProtocolMismatchHandler @@ -49,6 +50,7 @@ public class ChangeListRenderer( ProtocolMismatchHandler.Throwing, ), eventSink = refuseAllEvents, + leakDetector = LeakDetector.none(), ) hostAdapter.sendChanges(changeList.changes) } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt index cb54c684d5..fe329714f1 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.treehouse +import app.cash.redwood.leaks.LeakDetector import app.cash.zipline.EventListener as ZiplineEventListener import app.cash.zipline.Zipline import app.cash.zipline.loader.LoadResult @@ -37,6 +38,7 @@ internal class RealTreehouseApp private constructor( spec: Spec, override val dispatchers: TreehouseDispatchers, eventListenerFactory: EventListener.Factory, + private val leakDetector: LeakDetector, ) : TreehouseApp() { /** This property is confined to [TreehouseDispatchers.ui]. */ private var closed = false @@ -85,6 +87,7 @@ internal class RealTreehouseApp private constructor( dispatchers = dispatchers, codeEventPublisher = RealCodeEventPublisher(codeListener, this), source = source, + leakDetector = leakDetector, ) } @@ -191,6 +194,7 @@ internal class RealTreehouseApp private constructor( private val loaderEventListener: LoaderEventListener, internal val concurrentDownloads: Int, internal val stateStore: StateStore, + private val leakDetector: LeakDetector, ) : TreehouseApp.Factory { /** This is lazy to avoid initializing the cache on the thread that creates this launcher. */ internal val cache = lazy { @@ -211,6 +215,7 @@ internal class RealTreehouseApp private constructor( spec = spec, dispatchers = platform.newDispatchers(spec.name), eventListenerFactory = eventListenerFactory, + leakDetector = leakDetector, ) override fun close() { diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt index 82f642c467..d6f1704862 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.treehouse +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.protocol.Change import app.cash.redwood.protocol.EventSink import app.cash.redwood.protocol.host.HostProtocolAdapter @@ -84,6 +85,7 @@ internal class TreehouseAppContent( private val dispatchers: TreehouseDispatchers, private val codeEventPublisher: CodeEventPublisher, private val source: TreehouseContentSource, + private val leakDetector: LeakDetector, ) : Content, CodeHost.Listener, CodeSession.Listener { @@ -278,6 +280,7 @@ internal class TreehouseAppContent( isInitialLaunch = isInitialLaunch, onBackPressedDispatcher = onBackPressedDispatcher, firstUiConfiguration = firstUiConfiguration, + leakDetector = leakDetector, ).apply { start() } @@ -307,6 +310,7 @@ private class ViewContentCodeBinding( private val isInitialLaunch: Boolean, private val onBackPressedDispatcher: OnBackPressedDispatcher, firstUiConfiguration: StateFlow, + private val leakDetector: LeakDetector, ) : ChangesSinkService, TreehouseView.SaveCallback, ZiplineTreehouseUi.Host { @@ -404,6 +408,7 @@ private class ViewContentCodeBinding( protocolMismatchHandler = eventPublisher.widgetProtocolMismatchHandler, ) as ProtocolFactory, eventSink = eventBridge, + leakDetector = leakDetector, ) hostAdapterOrNull = hostAdapter } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt index b6381be9ae..f63decc18d 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.treehouse +import app.cash.redwood.leaks.LeakDetector import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.AfterTest import kotlin.test.Test @@ -299,6 +300,7 @@ class CodeHostTest { dispatchers = dispatchers, codeEventPublisher = codeEventPublisher, source = { app -> app.newUi() }, + leakDetector = LeakDetector.none(), ) } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeProtocolNode.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeProtocolNode.kt index 98022259c6..6e3e1946bd 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeProtocolNode.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeProtocolNode.kt @@ -54,4 +54,6 @@ internal class FakeProtocolNode( override fun detach() { } + + override fun toString() = "FakeProtocolNode(id=${id.value}, tag=${widgetTag.value})" } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt index 95a5d306f5..9b57a154f1 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.treehouse +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.ui.UiConfiguration import assertk.assertThat import assertk.assertions.isEmpty @@ -507,6 +508,7 @@ class TreehouseAppContentTest { dispatchers = dispatchers, codeEventPublisher = codeEventPublisher, source = { app -> app.newUi() }, + leakDetector = LeakDetector.none(), ) } diff --git a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryIos.kt b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryIos.kt index c4074f7142..15021e8802 100644 --- a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryIos.kt +++ b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryIos.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.treehouse +import app.cash.redwood.leaks.LeakDetector import app.cash.zipline.loader.LoaderEventListener import app.cash.zipline.loader.ManifestVerifier import app.cash.zipline.loader.ZiplineHttpClient @@ -34,6 +35,7 @@ public fun TreehouseAppFactory( concurrentDownloads: Int = 8, loaderEventListener: LoaderEventListener = LoaderEventListener.None, stateStore: StateStore = MemoryStateStore(), + leakDetector: LeakDetector = LeakDetector.none(), ): TreehouseApp.Factory = RealTreehouseApp.Factory( platform = IosTreehousePlatform(), httpClient = httpClient, @@ -47,4 +49,5 @@ public fun TreehouseAppFactory( loaderEventListener = loaderEventListener, concurrentDownloads = concurrentDownloads, stateStore = stateStore, + leakDetector = leakDetector, ) diff --git a/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt b/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt index 19ec388d0f..3083484970 100644 --- a/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt +++ b/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt @@ -33,6 +33,7 @@ import androidx.core.view.WindowCompat import app.cash.redwood.compose.AndroidUiDispatcher.Companion.Main import app.cash.redwood.layout.composeui.ComposeUiRedwoodLayoutWidgetFactory import app.cash.redwood.lazylayout.composeui.ComposeUiRedwoodLazyLayoutWidgetFactory +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.treehouse.EventListener import app.cash.redwood.treehouse.TreehouseApp import app.cash.redwood.treehouse.TreehouseAppFactory @@ -53,6 +54,8 @@ import com.example.redwood.emojisearch.launcher.EmojiSearchAppSpec import com.example.redwood.emojisearch.protocol.host.EmojiSearchProtocolFactory import com.example.redwood.emojisearch.treehouse.EmojiSearchPresenter import com.example.redwood.emojisearch.widget.EmojiSearchWidgetSystem +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel @@ -67,6 +70,15 @@ class EmojiSearchActivity : ComponentActivity() { private val scope: CoroutineScope = CoroutineScope(Main) private val snackbarHostState = SnackbarHostState() + private val leakDetector = LeakDetector.timeBasedIn( + scope = scope, + timeSource = TimeSource.Monotonic, + leakThreshold = 10.seconds, + callback = { reference, note -> + Log.e("LEAK", "Leak detected! $reference $note") + }, + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -143,6 +155,7 @@ class EmojiSearchActivity : ComponentActivity() { manifestVerifier = ManifestVerifier.Companion.NO_SIGNATURE_CHECKS, embeddedFileSystem = applicationContext.assets.asFileSystem(), embeddedDir = "/".toPath(), + leakDetector = leakDetector, ) val manifestUrlFlow = flowOf("http://10.0.2.2:8080/manifest.zipline.json") diff --git a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt index 37a5ece8ca..8199384644 100644 --- a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt +++ b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt @@ -25,6 +25,7 @@ import androidx.core.view.WindowCompat import app.cash.redwood.compose.AndroidUiDispatcher.Companion.Main import app.cash.redwood.layout.view.ViewRedwoodLayoutWidgetFactory import app.cash.redwood.lazylayout.view.ViewRedwoodLazyLayoutWidgetFactory +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.treehouse.CodeListener import app.cash.redwood.treehouse.EventListener import app.cash.redwood.treehouse.TreehouseApp @@ -45,6 +46,8 @@ import com.example.redwood.emojisearch.treehouse.emojiSearchSerializersModule import com.example.redwood.emojisearch.widget.EmojiSearchWidgetSystem import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar.LENGTH_INDEFINITE +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.flowOf @@ -59,6 +62,15 @@ class EmojiSearchActivity : ComponentActivity() { private val scope: CoroutineScope = CoroutineScope(Main) private lateinit var treehouseLayout: TreehouseLayout + private val leakDetector = LeakDetector.timeBasedIn( + scope = scope, + timeSource = TimeSource.Monotonic, + leakThreshold = 10.seconds, + callback = { reference, note -> + Log.e("LEAK", "Leak detected! $reference $note") + }, + ) + @SuppressLint("ResourceType") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -154,6 +166,7 @@ class EmojiSearchActivity : ComponentActivity() { fileSystem = FileSystem.SYSTEM, directory = applicationContext.getDir("TreehouseState", MODE_PRIVATE).toOkioPath(), ), + leakDetector = leakDetector, ) val manifestUrlFlow = flowOf("http://10.0.2.2:8080/manifest.zipline.json") diff --git a/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/EmojiSearchLauncher.kt b/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/EmojiSearchLauncher.kt index d105308cdc..445c373161 100644 --- a/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/EmojiSearchLauncher.kt +++ b/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/EmojiSearchLauncher.kt @@ -15,6 +15,7 @@ */ package com.example.redwood.emojisearch.ios +import app.cash.redwood.leaks.LeakDetector import app.cash.redwood.treehouse.EventListener import app.cash.redwood.treehouse.TreehouseApp import app.cash.redwood.treehouse.TreehouseAppFactory @@ -26,6 +27,8 @@ import app.cash.zipline.loader.withDevelopmentServerPush import com.example.redwood.emojisearch.launcher.EmojiSearchAppSpec import com.example.redwood.emojisearch.treehouse.EmojiSearchPresenter import com.example.redwood.emojisearch.treehouse.HostApi +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.flowOf @@ -63,6 +66,14 @@ class EmojiSearchLauncher( val treehouseAppFactory = TreehouseAppFactory( httpClient = ziplineHttpClient, manifestVerifier = ManifestVerifier.Companion.NO_SIGNATURE_CHECKS, + leakDetector = LeakDetector.timeBasedIn( + scope = coroutineScope, + timeSource = TimeSource.Monotonic, + leakThreshold = 10.seconds, + callback = { reference, note -> + NSLog("Leak detected! $reference $note") + }, + ), ) val manifestUrlFlow = flowOf(manifestUrl)