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..c943756416 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/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..4fef4b9767 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 @@ -21,19 +21,15 @@ import app.cash.redwood.leaks.zipline.LeakDetectorTestService.Companion.SERVICE_ 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 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) {} - }, timeSource = TimeSource.Monotonic, leakThreshold = 2.seconds, + listener = object : LeakListener() {}, ) // 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. diff --git a/redwood-leak-detector/api/redwood-leak-detector.api b/redwood-leak-detector/api/redwood-leak-detector.api index c7fbbce40d..5060504e6d 100644 --- a/redwood-leak-detector/api/redwood-leak-detector.api +++ b/redwood-leak-detector/api/redwood-leak-detector.api @@ -1,22 +1,23 @@ public abstract interface class app/cash/redwood/leaks/LeakDetector { public static final field Companion Lapp/cash/redwood/leaks/LeakDetector$Companion; public abstract fun checkLeaks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun watchReference (Ljava/lang/Object;Ljava/lang/String;)V + public abstract fun watchReference (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)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 fun timeBased-8Mi8wO0 (Lkotlin/time/TimeSource;JLapp/cash/redwood/leaks/LeakListener;)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 fun watchReference (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)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 abstract class app/cash/redwood/leaks/LeakListener { + public fun ()V + public fun onReferenceCollected (Lkotlin/jvm/functions/Function0;)V + public fun onReferenceLeaked-8Mi8wO0 (Ljava/lang/Object;JLkotlin/jvm/functions/Function0;)V } 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..19186dd584 100644 --- a/redwood-leak-detector/api/redwood-leak-detector.klib.api +++ b/redwood-leak-detector/api/redwood-leak-detector.klib.api @@ -11,20 +11,22 @@ open annotation class app.cash.redwood.leaks/RedwoodLeakApi : kotlin/Annotation } abstract interface app.cash.redwood.leaks/LeakDetector { // app.cash.redwood.leaks/LeakDetector|null[0] - abstract fun watchReference(kotlin/Any, kotlin/String) // app.cash.redwood.leaks/LeakDetector.watchReference|watchReference(kotlin.Any;kotlin.String){}[0] + abstract fun watchReference(kotlin/Any, kotlin/Function0) // app.cash.redwood.leaks/LeakDetector.watchReference|watchReference(kotlin.Any;kotlin.Function0){}[0] abstract suspend fun checkLeaks() // app.cash.redwood.leaks/LeakDetector.checkLeaks|checkLeaks(){}[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] + final fun timeBased(kotlin.time/TimeSource, kotlin.time/Duration, app.cash.redwood.leaks/LeakListener): app.cash.redwood.leaks/LeakDetector // app.cash.redwood.leaks/LeakDetector.Companion.timeBased|timeBased(kotlin.time.TimeSource;kotlin.time.Duration;app.cash.redwood.leaks.LeakListener){}[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 fun watchReference(kotlin/Any, kotlin/Function0) // app.cash.redwood.leaks/LeakDetector.None.watchReference|watchReference(kotlin.Any;kotlin.Function0){}[0] final suspend fun checkLeaks() // app.cash.redwood.leaks/LeakDetector.None.checkLeaks|checkLeaks(){}[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] +abstract class app.cash.redwood.leaks/LeakListener { // app.cash.redwood.leaks/LeakListener|null[0] + constructor () // app.cash.redwood.leaks/LeakListener.|(){}[0] + + open fun onReferenceCollected(kotlin/Function0) // app.cash.redwood.leaks/LeakListener.onReferenceCollected|onReferenceCollected(kotlin.Function0){}[0] + open fun onReferenceLeaked(kotlin/Any, kotlin.time/Duration, kotlin/Function0) // app.cash.redwood.leaks/LeakListener.onReferenceLeaked|onReferenceLeaked(kotlin.Any;kotlin.time.Duration;kotlin.Function0){}[0] } 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..e6bcd82281 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 @@ -27,7 +27,7 @@ public interface LeakDetector { * * This function is safe to call from any thread. */ - public fun watchReference(reference: Any, name: String) + public fun watchReference(reference: Any, description: () -> String) /** * Trigger garbage collection and determine if any watched references have leaked per this @@ -38,7 +38,7 @@ public interface LeakDetector { public suspend fun checkLeaks() public object None : LeakDetector { - override fun watchReference(reference: Any, name: String) {} + override fun watchReference(reference: Any, description: () -> String) {} override suspend fun checkLeaks() {} } @@ -51,9 +51,9 @@ public interface LeakDetector { * case [listener] will never be invoked. */ public fun timeBased( - listener: LeakListener, timeSource: TimeSource, leakThreshold: Duration, + listener: LeakListener, ): LeakDetector { if (hasWeakReference()) { return TimeBasedLeakDetector(listener, timeSource, leakThreshold) @@ -64,9 +64,9 @@ public interface LeakDetector { } @RedwoodLeakApi -public interface LeakListener { - public fun onReferenceCollected(name: String) - public fun onReferenceLeaked(name: String, alive: Duration) +public abstract class LeakListener { + public open fun onReferenceCollected(description: () -> String) {} + public open fun onReferenceLeaked(reference: Any, alive: Duration, description: () -> String) {} } internal class TimeBasedLeakDetector( @@ -77,11 +77,11 @@ internal class TimeBasedLeakDetector( internal val gc = detectGc() private val watchedReferences = concurrentMutableListOf() - override fun watchReference(reference: Any, name: String) { + override fun watchReference(reference: Any, description: () -> String) { watchedReferences += WatchedReference( - name = name, weakReference = WeakReference(reference), watchedAt = timeSource.markNow(), + description = description, ) } @@ -89,14 +89,15 @@ internal class TimeBasedLeakDetector( gc.collect() watchedReferences.removeIf { watchedReference -> - if (watchedReference.weakReference.get() == null) { - listener.onReferenceCollected(watchedReference.name) + val reference = watchedReference.weakReference.get() + if (reference == null) { + listener.onReferenceCollected(watchedReference.description) return@removeIf true } val alive = watchedReference.watchedAt.elapsedNow() if (alive >= leakThreshold) { - listener.onReferenceLeaked(watchedReference.name, alive) + listener.onReferenceLeaked(reference, alive, watchedReference.description) return@removeIf true } @@ -105,8 +106,8 @@ internal class TimeBasedLeakDetector( } private class WatchedReference( - val name: String, val weakReference: WeakReference, val watchedAt: TimeMark, + val description: () -> String, ) } 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..6bf315efa8 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 @@ -34,8 +34,10 @@ import kotlinx.coroutines.test.runTest 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 leakDetector = LeakDetector.timeBased(timeSource, 10.seconds, listener) + private var ref: Any? = object : Any() { + override fun toString() = "anAny" + } @BeforeTest fun before() { assertThat(leakDetector) @@ -52,7 +54,7 @@ class LeakDetectorTest { } @Test fun detectImmediateCollection() = runTest { - leakDetector.watchReference(ref!!, "ref") + leakDetector.watchReference(ref!!) { "ref" } ref = null wait(10.seconds) @@ -62,7 +64,7 @@ class LeakDetectorTest { } @Test fun detectDelayedCollection() = runTest { - leakDetector.watchReference(ref!!, "ref") + leakDetector.watchReference(ref!!) { "ref" } wait(5.seconds) @@ -78,20 +80,20 @@ class LeakDetectorTest { } @Test fun detectLeak() = runTest { - leakDetector.watchReference(ref!!, "ref") + leakDetector.watchReference(ref!!) { "ref" } // Only advance virtual time for checking the event message. timeSource += 15.seconds leakDetector.checkLeaks() - assertThat(listener.events).containsExactly("leaked @ 15s: ref") + assertThat(listener.events).containsExactly("leaked anAny @ 15s: ref") } @Test fun concurrencyStressTest() = runTest { coroutineScope { repeat(10_000) { i -> launch(Dispatchers.Default) { - leakDetector.watchReference(Any(), "$i") + leakDetector.watchReference(Any()) { "$i" } } } repeat(100) { 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/RecordingLeakListener.kt index 62b4558077..feea9f9491 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/RecordingLeakListener.kt @@ -17,14 +17,14 @@ package app.cash.redwood.leaks import kotlin.time.Duration -class RecordingLeakListener : LeakListener { +class RecordingLeakListener : LeakListener() { val events = mutableListOf() - override fun onReferenceCollected(name: String) { - events += "collected: $name" + override fun onReferenceCollected(description: () -> String) { + events += "collected: ${description()}" } - override fun onReferenceLeaked(name: String, alive: Duration) { - events += "leaked @ $alive: $name" + override fun onReferenceLeaked(reference: Any, alive: Duration, description: () -> String) { + events += "leaked $reference @ $alive: ${description()}" } } diff --git a/redwood-protocol-host/api/redwood-protocol-host.api b/redwood-protocol-host/api/redwood-protocol-host.api index dd61a2e8f5..bade6e9567 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/EventSink;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/EventSink;Lapp/cash/redwood/leaks/LeakDetector;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun close ()V public fun sendChanges (Ljava/util/List;)V } @@ -41,6 +41,7 @@ public abstract class app/cash/redwood/protocol/host/ProtocolNode { public final fun getId-0HhLjSo ()I public abstract fun getWidget ()Lapp/cash/redwood/widget/Widget; public final fun getWidgetTag-BlhN7y0 ()I + 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 23ad9f283b..9e2ed68fd4 100644 --- a/redwood-protocol-host/api/redwood-protocol-host.klib.api +++ b/redwood-protocol-host/api/redwood-protocol-host.klib.api @@ -42,12 +42,13 @@ abstract class <#A: kotlin/Any> app.cash.redwood.protocol.host/ProtocolNode { // abstract fun apply(app.cash.redwood.protocol/PropertyChange, app.cash.redwood.protocol/EventSink) // app.cash.redwood.protocol.host/ProtocolNode.apply|apply(app.cash.redwood.protocol.PropertyChange;app.cash.redwood.protocol.EventSink){}[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/EventSink) // 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.EventSink){}[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/EventSink, 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.EventSink;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 e23a94e89b..d7020f6137 100644 --- a/redwood-protocol-host/build.gradle +++ b/redwood-protocol-host/build.gradle @@ -14,6 +14,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 e7a01e1434..3557238815 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 @@ -51,6 +52,7 @@ public class HostProtocolAdapter( container: Widget.Children, factory: ProtocolFactory, private val eventSink: EventSink, + private val leakDetector: LeakDetector, ) : ChangesSink { private val factory = requireNotNull(factory as? GeneratedProtocolFactory) { "Factory ${factory::class} was not generated by Redwood or is out of date" @@ -183,13 +185,28 @@ 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, cause: String) { + leakDetector.watchReference(node.widget.value) { + "Widget's native UI view when $cause" + } + leakDetector.watchReference(node.widget) { + "Widget when $cause" + } + leakDetector.watchReference(node) { + "Redwood-internal widget protocol node when $cause" + } + + // Detaching frees the node's reference to the widget, so this must be done last. + node.detach() + } + /** * Implements widget reuse (view recycling). * @@ -404,6 +421,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 6451fc2c33..ffdae99b1c 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 88ee0146f6..e758267ddf 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 @@ -141,6 +141,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..3d674bfeb8 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 4a95440387..63ec5aa451 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.guestRedwoodVersion import app.cash.redwood.protocol.host.HostProtocolAdapter @@ -63,6 +64,7 @@ class ViewRecyclingTester( container = widgetContainer, factory = widgetProtocolFactory, eventSink = { throw AssertionError() }, + leakDetector = LeakDetector.None, ) private val guestAdapter = 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..aaa1ad2ca3 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 68885f0f64..8cde1b4e01 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 @@ -41,6 +41,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 @@ -286,6 +287,8 @@ internal class ProtocolButton( public override fun detach() { _widget = null } + + public override fun toString() = "ProtocolButton(id=$id, tag=12) } */ internal fun generateProtocolNode( @@ -509,6 +512,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 f5885a9f65..7061262670 100644 --- a/redwood-treehouse-host/api/android/redwood-treehouse-host.api +++ b/redwood-treehouse-host/api/android/redwood-treehouse-host.api @@ -39,6 +39,7 @@ public class app/cash/redwood/treehouse/EventListener { public fun downloadFailed (Ljava/lang/String;Ljava/lang/Exception;Ljava/lang/Object;)V public fun downloadStart (Ljava/lang/String;)Ljava/lang/Object; public fun downloadSuccess (Ljava/lang/String;Ljava/lang/Object;)V + public fun hostReferenceLeaked (Ljava/lang/Object;JLkotlin/jvm/functions/Function0;)V public fun initializerEnd (Ljava/lang/String;Ljava/lang/Object;)V public fun initializerStart (Ljava/lang/String;)Ljava/lang/Object; public fun mainFunctionEnd (Ljava/lang/String;Ljava/lang/Object;)V @@ -109,8 +110,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/jvm/redwood-treehouse-host.api b/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api index b94988a6e2..002c2dc8c7 100644 --- a/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api +++ b/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api @@ -39,6 +39,7 @@ public class app/cash/redwood/treehouse/EventListener { public fun downloadFailed (Ljava/lang/String;Ljava/lang/Exception;Ljava/lang/Object;)V public fun downloadStart (Ljava/lang/String;)Ljava/lang/Object; public fun downloadSuccess (Ljava/lang/String;Ljava/lang/Object;)V + public fun hostReferenceLeaked (Ljava/lang/Object;JLkotlin/jvm/functions/Function0;)V public fun initializerEnd (Ljava/lang/String;Ljava/lang/Object;)V public fun initializerStart (Ljava/lang/String;)Ljava/lang/Object; public fun mainFunctionEnd (Ljava/lang/String;Ljava/lang/Object;)V diff --git a/redwood-treehouse-host/api/redwood-treehouse-host.klib.api b/redwood-treehouse-host/api/redwood-treehouse-host.klib.api index 6caee59121..8db8b7cb69 100644 --- a/redwood-treehouse-host/api/redwood-treehouse-host.klib.api +++ b/redwood-treehouse-host/api/redwood-treehouse-host.klib.api @@ -155,6 +155,7 @@ open class app.cash.redwood.treehouse/EventListener { // app.cash.redwood.treeho open fun downloadFailed(kotlin/String, kotlin/Exception, kotlin/Any?) // app.cash.redwood.treehouse/EventListener.downloadFailed|downloadFailed(kotlin.String;kotlin.Exception;kotlin.Any?){}[0] open fun downloadStart(kotlin/String): kotlin/Any? // app.cash.redwood.treehouse/EventListener.downloadStart|downloadStart(kotlin.String){}[0] open fun downloadSuccess(kotlin/String, kotlin/Any?) // app.cash.redwood.treehouse/EventListener.downloadSuccess|downloadSuccess(kotlin.String;kotlin.Any?){}[0] + open fun hostReferenceLeaked(kotlin/Any, kotlin/Long, kotlin/Function0) // app.cash.redwood.treehouse/EventListener.hostReferenceLeaked|hostReferenceLeaked(kotlin.Any;kotlin.Long;kotlin.Function0){}[0] open fun initializerEnd(kotlin/String, kotlin/Any?) // app.cash.redwood.treehouse/EventListener.initializerEnd|initializerEnd(kotlin.String;kotlin.Any?){}[0] open fun initializerStart(kotlin/String): kotlin/Any? // app.cash.redwood.treehouse/EventListener.initializerStart|initializerStart(kotlin.String){}[0] open fun mainFunctionEnd(kotlin/String, kotlin/Any?) // app.cash.redwood.treehouse/EventListener.mainFunctionEnd|mainFunctionEnd(kotlin.String;kotlin.Any?){}[0] @@ -187,4 +188,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..87dcf07036 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 3ecf7a9771..9a96dfe45f 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 c267c2d2e1..d07efb3001 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.EventSink import app.cash.redwood.protocol.SnapshotChangeList import app.cash.redwood.protocol.host.HostProtocolAdapter @@ -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/EventListener.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventListener.kt index 9910a2aaa2..7a6930a204 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventListener.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventListener.kt @@ -352,6 +352,24 @@ public open class EventListener { ) { } + /** + * Invoked when Redwood has detected a host-side reference leak. This could be a native UI + * element, a widget wrapper, an internal protocol wrapper, etc. + * + * This function will be invoked only once per [reference], but multiple invocations may be + * connected. For example, a leaking widget wrapper might also leak its native UI element. + * + * Both `reference.toString()` and the result of invoking `description` should be logged. + * + * @param aliveMillis Time since [reference] was available for GC but not collected. + */ + public open fun hostReferenceLeaked( + reference: Any, + aliveMillis: Long, + description: () -> String, + ) { + } + /** * Invoked when [app] has thrown an uncaught exception. * diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt index 2af1b3715f..4183e6c0fc 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt @@ -20,6 +20,7 @@ import app.cash.redwood.protocol.Id import app.cash.redwood.protocol.WidgetTag import app.cash.redwood.protocol.host.ProtocolMismatchHandler import app.cash.zipline.EventListener as ZiplineEventListener +import kotlin.time.Duration internal interface EventPublisher { val ziplineEventListener: ZiplineEventListener @@ -32,5 +33,11 @@ internal interface EventPublisher { fun onUncaughtException(exception: Throwable) + fun onLeakDetected( + reference: Any, + alive: Duration, + description: () -> String, + ) + fun close() } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealEventPublisher.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealEventPublisher.kt index 97f15b3bcc..9f9209bd46 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealEventPublisher.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealEventPublisher.kt @@ -27,6 +27,7 @@ import app.cash.zipline.CallResult import app.cash.zipline.Zipline import app.cash.zipline.ZiplineManifest import app.cash.zipline.ZiplineService +import kotlin.time.Duration internal class RealEventPublisher( listener: EventListener, @@ -214,6 +215,10 @@ internal class RealEventPublisher( listener!!.uncaughtException(exception) } + override fun onLeakDetected(reference: Any, alive: Duration, description: () -> String) { + listener!!.hostReferenceLeaked(reference, alive.inWholeMilliseconds, description) + } + override fun close() { listener = null } 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 3520e54481..e1324d86e1 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( override val 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 @@ -81,6 +83,7 @@ internal class RealTreehouseApp private constructor( dispatchers = dispatchers, codeEventPublisher = RealCodeEventPublisher(codeListener, this), source = source, + leakDetector = leakDetector, ) } @@ -184,6 +187,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 { @@ -204,6 +208,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 7907437c00..2f9711de78 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.Event import app.cash.redwood.protocol.EventSink @@ -26,6 +27,7 @@ import app.cash.redwood.ui.UiConfiguration import app.cash.redwood.widget.Widget import app.cash.zipline.ZiplineApiMismatchException import app.cash.zipline.ZiplineScope +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -33,6 +35,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first @@ -82,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 { @@ -276,6 +280,7 @@ internal class TreehouseAppContent( isInitialLaunch = isInitialLaunch, onBackPressedDispatcher = onBackPressedDispatcher, firstUiConfiguration = firstUiConfiguration, + leakDetector = leakDetector, ).apply { start() } @@ -305,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 { @@ -402,6 +408,7 @@ private class ViewContentCodeBinding( protocolMismatchHandler = eventPublisher.widgetProtocolMismatchHandler, ) as ProtocolFactory, eventSink = eventBridge, + leakDetector = leakDetector, ) hostAdapterOrNull = hostAdapter } @@ -453,6 +460,13 @@ private class ViewContentCodeBinding( ) } } + + bindingScope.launch { + while (true) { + delay(5.seconds) + leakDetector.checkLeaks() + } + } } override fun addOnBackPressedCallback( 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..7085854868 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/FakeEventPublisher.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeEventPublisher.kt index decfb7ae48..6a06ded4ef 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeEventPublisher.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeEventPublisher.kt @@ -20,6 +20,7 @@ import app.cash.redwood.protocol.Id import app.cash.redwood.protocol.WidgetTag import app.cash.redwood.protocol.host.ProtocolMismatchHandler import app.cash.zipline.EventListener +import kotlin.time.Duration class FakeEventPublisher : EventPublisher { override val ziplineEventListener = EventListener.NONE @@ -35,6 +36,9 @@ class FakeEventPublisher : EventPublisher { override fun onUncaughtException(exception: Throwable) { } + override fun onLeakDetected(reference: Any, alive: Duration, description: () -> String) { + } + override fun close() { } } 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 ae82646700..0a5835e360 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..8642df7049 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..38d9a66e76 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..b170a822a1 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,8 @@ 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.leaks.LeakListener import app.cash.redwood.treehouse.EventListener import app.cash.redwood.treehouse.TreehouseApp import app.cash.redwood.treehouse.TreehouseAppFactory @@ -53,6 +55,9 @@ 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 +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel @@ -143,6 +148,27 @@ class EmojiSearchActivity : ComponentActivity() { manifestVerifier = ManifestVerifier.Companion.NO_SIGNATURE_CHECKS, embeddedFileSystem = applicationContext.assets.asFileSystem(), embeddedDir = "/".toPath(), + leakDetector = LeakDetector.timeBased( + timeSource = TimeSource.Monotonic, + leakThreshold = 10.seconds, + listener = object : LeakListener() { + override fun onReferenceLeaked( + reference: Any, + alive: Duration, + description: () -> String, + ) { + Log.e( + "LEAK", + """ + |Host reference leak detected! + | Ref: $reference + | Alive: $alive + | Description: ${description()} + """.trimMargin(), + ) + } + }, + ), ) 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..6b7eb6bcbd 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,8 @@ 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.leaks.LeakListener import app.cash.redwood.treehouse.CodeListener import app.cash.redwood.treehouse.EventListener import app.cash.redwood.treehouse.TreehouseApp @@ -45,6 +47,9 @@ 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 +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.flowOf @@ -154,6 +159,27 @@ class EmojiSearchActivity : ComponentActivity() { fileSystem = FileSystem.SYSTEM, directory = applicationContext.getDir("TreehouseState", MODE_PRIVATE).toOkioPath(), ), + leakDetector = LeakDetector.timeBased( + timeSource = TimeSource.Monotonic, + leakThreshold = 10.seconds, + listener = object : LeakListener() { + override fun onReferenceLeaked( + reference: Any, + alive: Duration, + description: () -> String, + ) { + Log.e( + "LEAK", + """ + |Host reference leak detected! + | Ref: $reference + | Alive: $alive + | Description: ${description()} + """.trimMargin(), + ) + } + }, + ), ) 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..75e0f0b60b 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,8 @@ */ package com.example.redwood.emojisearch.ios +import app.cash.redwood.leaks.LeakDetector +import app.cash.redwood.leaks.LeakListener import app.cash.redwood.treehouse.EventListener import app.cash.redwood.treehouse.TreehouseApp import app.cash.redwood.treehouse.TreehouseAppFactory @@ -26,6 +28,9 @@ 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 +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 +68,26 @@ class EmojiSearchLauncher( val treehouseAppFactory = TreehouseAppFactory( httpClient = ziplineHttpClient, manifestVerifier = ManifestVerifier.Companion.NO_SIGNATURE_CHECKS, + leakDetector = LeakDetector.timeBased( + timeSource = TimeSource.Monotonic, + leakThreshold = 10.seconds, + listener = object : LeakListener() { + override fun onReferenceLeaked( + reference: Any, + alive: Duration, + description: () -> String, + ) { + NSLog( + """ + |Host reference leak detected! + | Ref: $reference + | Alive: $alive + | Description: ${description()} + """.trimMargin(), + ) + } + }, + ), ) val manifestUrlFlow = flowOf(manifestUrl)