From d5defdea39837cb2ff9f38e9a9c9b35350018b88 Mon Sep 17 00:00:00 2001 From: Jake Wharton Date: Mon, 22 Jul 2024 14:34:19 -0400 Subject: [PATCH] Add leak detection API and module (#2191) This can be used to watch a reference when it is known to no longer be used, and to check whether or not it was collected by the Kotlin garbage collector or not. --- .../buildsupport/RedwoodBuildPlugin.kt | 1 + .../buildsupport/ZiplineAppEmbedTask.kt | 9 +- .../build.gradle | 59 +++++++++ .../leaks/zipline/LeakDetectorTestService.kt | 31 +++++ .../zipline/LeakDetectorTestServiceImpl.kt | 47 ++++++++ .../leaks/zipline/LeakDetectorZiplineTest.kt | 79 ++++++++++++ .../api/redwood-leak-detector.api | 24 ++++ .../api/redwood-leak-detector.klib.api | 30 +++++ redwood-leak-detector/build.gradle | 53 +++++++++ .../redwood/leaks/ConcurrentMutableList.kt | 25 ++++ .../kotlin/app/cash/redwood/leaks/Gc.kt | 27 +++++ .../app/cash/redwood/leaks/LeakDetector.kt | 112 ++++++++++++++++++ .../app/cash/redwood/leaks/RedwoodLeakApi.kt | 24 ++++ .../app/cash/redwood/leaks/WeakReference.kt | 23 ++++ .../cash/redwood/leaks/LeakDetectorTest.kt | 104 ++++++++++++++++ .../redwood/leaks/RecordingLeakListener.kt | 30 +++++ .../app/cash/redwood/leaks/delayForGc.kt | 20 ++++ .../redwood/leaks/ConcurrentMutableList.kt | 48 ++++++++ .../kotlin/app/cash/redwood/leaks/Gc.kt | 29 +++++ .../app/cash/redwood/leaks/WeakReference.kt | 26 ++++ .../app/cash/redwood/leaks/globalThis.kt | 18 +++ .../app/cash/redwood/leaks/delayForGc.kt | 28 +++++ .../redwood/leaks/ConcurrentMutableList.kt | 39 ++++++ .../kotlin/app/cash/redwood/leaks/Gc.kt | 30 +++++ .../app/cash/redwood/leaks/WeakReference.kt | 24 ++++ .../app/cash/redwood/leaks/delayForGc.kt | 23 ++++ .../redwood/leaks/ConcurrentMutableList.kt | 46 +++++++ .../kotlin/app/cash/redwood/leaks/Gc.kt | 28 +++++ .../app/cash/redwood/leaks/WeakReference.kt | 25 ++++ .../app/cash/redwood/leaks/delayForGc.kt | 23 ++++ .../redwood/leaks/ConcurrentMutableList.kt | 39 ++++++ .../kotlin/app/cash/redwood/leaks/Gc.kt | 36 ++++++ .../app/cash/redwood/leaks/WeakReference.kt | 37 ++++++ .../app/cash/redwood/leaks/delayForGc.kt | 28 +++++ settings.gradle | 2 + 35 files changed, 1224 insertions(+), 3 deletions(-) create mode 100644 redwood-leak-detector-zipline-test/build.gradle create mode 100644 redwood-leak-detector-zipline-test/src/commonMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestService.kt create mode 100644 redwood-leak-detector-zipline-test/src/guestMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestServiceImpl.kt create mode 100644 redwood-leak-detector-zipline-test/src/hostTest/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorZiplineTest.kt create mode 100644 redwood-leak-detector/api/redwood-leak-detector.api create mode 100644 redwood-leak-detector/api/redwood-leak-detector.klib.api create mode 100644 redwood-leak-detector/build.gradle create mode 100644 redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt create mode 100644 redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/Gc.kt create mode 100644 redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/LeakDetector.kt create mode 100644 redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/RedwoodLeakApi.kt create mode 100644 redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/WeakReference.kt create mode 100644 redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/LeakDetectorTest.kt create mode 100644 redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/RecordingLeakListener.kt create mode 100644 redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/delayForGc.kt create mode 100644 redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt create mode 100644 redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/Gc.kt create mode 100644 redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/WeakReference.kt create mode 100644 redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/globalThis.kt create mode 100644 redwood-leak-detector/src/jsTest/kotlin/app/cash/redwood/leaks/delayForGc.kt create mode 100644 redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt create mode 100644 redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/Gc.kt create mode 100644 redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/WeakReference.kt create mode 100644 redwood-leak-detector/src/jvmTest/kotlin/app/cash/redwood/leaks/delayForGc.kt create mode 100644 redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt create mode 100644 redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/Gc.kt create mode 100644 redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/WeakReference.kt create mode 100644 redwood-leak-detector/src/nativeTest/kotlin/app/cash/redwood/leaks/delayForGc.kt create mode 100644 redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt create mode 100644 redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/Gc.kt create mode 100644 redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/WeakReference.kt create mode 100644 redwood-leak-detector/src/wasmJsTest/kotlin/app/cash/redwood/leaks/delayForGc.kt diff --git a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildPlugin.kt b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildPlugin.kt index 529574ae5d..c63e2b1489 100644 --- a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildPlugin.kt +++ b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildPlugin.kt @@ -235,6 +235,7 @@ class RedwoodBuildPlugin : Plugin { // generated code isn't relying on them (without also generating appropriate opt-ins). if (!path.startsWith(":test-app:schema:")) { sourceSets.configureEach { + it.languageSettings.optIn("app.cash.redwood.leaks.RedwoodLeakApi") it.languageSettings.optIn("app.cash.redwood.yoga.RedwoodYogaApi") it.languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") it.languageSettings.optIn("kotlinx.cinterop.BetaInteropApi") diff --git a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ZiplineAppEmbedTask.kt b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ZiplineAppEmbedTask.kt index e9da0018f5..d9c1f004ac 100644 --- a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ZiplineAppEmbedTask.kt +++ b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ZiplineAppEmbedTask.kt @@ -27,7 +27,7 @@ import org.gradle.api.tasks.TaskAction private const val ZIPLINE_MANIFEST_JSON = "manifest.zipline.json" -internal abstract class ZiplineAppEmbedTask : DefaultTask() { +abstract class ZiplineAppEmbedTask : DefaultTask() { @get:InputFiles abstract val files: ConfigurableFileCollection @@ -48,7 +48,7 @@ internal abstract class ZiplineAppEmbedTask : DefaultTask() { val fileMap = files.asFileTree.files .associateByTo(mutableMapOf()) { it.name } - val inputManifestFile = checkNotNull(fileMap.remove(ZIPLINE_MANIFEST_JSON)) { + val inputManifestFile = checkNotNull(fileMap[ZIPLINE_MANIFEST_JSON]) { "No zipline.manifest.json file found in input files ${fileMap.keys}" } val inputManifest = ZiplineManifest.decodeJson(inputManifestFile.readText()) @@ -59,12 +59,15 @@ internal abstract class ZiplineAppEmbedTask : DefaultTask() { freshAtEpochMs = System.currentTimeMillis(), ), ) + + // Rename the manifest to be prefixed by its application name which is required for Zipline to + // load it as an embedded app. val outputManifestFile = outputDirectory.file("${appName.get()}.$ZIPLINE_MANIFEST_JSON").asFile outputManifestFile.writeText(outputManifest.encodeJson()) // Rewrite all .zipline files to their SHA-256 hashes which is how Zipline loads when embedded. for (module in outputManifest.modules.values) { - val inputFile = checkNotNull(fileMap.remove(module.url)) { + val inputFile = checkNotNull(fileMap[module.url]) { "No ${module.url} file found in input files as specified by the manifest" } val outputFile = outputDirectory.file(module.sha256.hex()).asFile diff --git a/redwood-leak-detector-zipline-test/build.gradle b/redwood-leak-detector-zipline-test/build.gradle new file mode 100644 index 0000000000..5ad46c01c6 --- /dev/null +++ b/redwood-leak-detector-zipline-test/build.gradle @@ -0,0 +1,59 @@ +import app.cash.redwood.buildsupport.ZiplineAppEmbedTask + +apply plugin: 'org.jetbrains.kotlin.multiplatform' +apply plugin: 'app.cash.zipline' + +zipline { + // This is a test-only contract. + apiTracking = false +} + +kotlin { + js("guest") { + nodejs() + binaries.executable() + } + jvm("host") + + sourceSets { + commonMain { + dependencies { + implementation libs.zipline + } + } + commonTest { + dependencies { + implementation libs.kotlin.test + } + } + guestMain { + dependencies { + implementation projects.redwoodLeakDetector + implementation libs.assertk + } + } + hostTest { + dependencies { + implementation libs.okio + implementation libs.zipline.loader + } + } + } +} + +// Compile JS tests into a Zipline application so we can test inside QuickJS VM in the JVM tests. +def ziplineAppName = 'leaks' +def ziplineCompile = tasks.named('compileDevelopmentExecutableKotlinGuestZipline') +def ziplineEmbed = tasks.register('ziplineEmbed', ZiplineAppEmbedTask) { + files.setFrom(ziplineCompile) + appName = ziplineAppName + outputDirectory = layout.buildDirectory.dir('generated/ziplineEmbed') +} +tasks.named('hostTest', Test) { + // Explicit dependsOn and input because systemProperty doesn't accept providers. + dependsOn(ziplineEmbed) + inputs.dir(ziplineEmbed.get().outputDirectory) + + systemProperty('ziplineDir', ziplineEmbed.get().outputDirectory.get().asFile.absolutePath) + systemProperty('ziplineAppName', ziplineAppName) +} 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 new file mode 100644 index 0000000000..7348b9a855 --- /dev/null +++ b/redwood-leak-detector-zipline-test/src/commonMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestService.kt @@ -0,0 +1,31 @@ +/* + * 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.zipline + +import app.cash.zipline.ZiplineService + +interface LeakDetectorTestService : ZiplineService { + fun leakDetectorDisabled() + + // TODO Once QuickJS supports WeakRef, enable regular tests: + // suspend fun detectImmediateCollection() + // suspend fun detectDelayedCollection() + // suspend fun detectLeak() + + companion object { + const val SERVICE_NAME = "leakDetectorTest" + } +} 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 new file mode 100644 index 0000000000..c71ab1debb --- /dev/null +++ b/redwood-leak-detector-zipline-test/src/guestMain/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorTestServiceImpl.kt @@ -0,0 +1,47 @@ +/* + * 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.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 + +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, + ) + // 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) + } +} + +fun main() { + val zipline = Zipline.get() + zipline.bind(SERVICE_NAME, LeakDetectorTestServiceImpl()) +} 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 new file mode 100644 index 0000000000..bc6368d3e1 --- /dev/null +++ b/redwood-leak-detector-zipline-test/src/hostTest/kotlin/app/cash/redwood/leaks/zipline/LeakDetectorZiplineTest.kt @@ -0,0 +1,79 @@ +/* + * 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.zipline + +import app.cash.redwood.leaks.zipline.LeakDetectorTestService.Companion.SERVICE_NAME +import app.cash.zipline.Zipline +import app.cash.zipline.ZiplineManifest +import app.cash.zipline.loader.FreshnessChecker +import app.cash.zipline.loader.LoadResult.Failure +import app.cash.zipline.loader.LoadResult.Success +import app.cash.zipline.loader.ManifestVerifier.Companion.NO_SIGNATURE_CHECKS +import app.cash.zipline.loader.ZiplineHttpClient +import app.cash.zipline.loader.ZiplineLoader +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.Dispatchers.Unconfined +import kotlinx.coroutines.runBlocking +import okio.FileSystem.Companion.SYSTEM +import okio.Path.Companion.toPath + +private val ziplineDir = System.getProperty("ziplineDir")!!.toPath() +private val ziplineAppName = System.getProperty("ziplineAppName")!! + +class LeakDetectorZiplineTest { + private val loader = ZiplineLoader( + dispatcher = Unconfined, + manifestVerifier = NO_SIGNATURE_CHECKS, + httpClient = object : ZiplineHttpClient() { + override suspend fun download( + url: String, + requestHeaders: List>, + ) = throw UnsupportedOperationException() + }, + ).withEmbedded(SYSTEM, ziplineDir) + + private lateinit var zipline: Zipline + private lateinit var service: LeakDetectorTestService + + @BeforeTest fun before() = runBlocking { + val result = loader.loadOnce( + applicationName = ziplineAppName, + freshnessChecker = object : FreshnessChecker { + override fun isFresh(manifest: ZiplineManifest, freshAtEpochMs: Long) = true + }, + manifestUrl = "http://0.0.0.0:0", + ) + + when (result) { + is Failure -> throw result.exception + is Success -> { + zipline = result.zipline + } + } + + service = zipline.take(SERVICE_NAME) + } + + @AfterTest fun after() { + zipline.close() + } + + @Test fun leakDetectorDisabled() { + service.leakDetectorDisabled() + } +} diff --git a/redwood-leak-detector/api/redwood-leak-detector.api b/redwood-leak-detector/api/redwood-leak-detector.api new file mode 100644 index 0000000000..c7fbbce40d --- /dev/null +++ b/redwood-leak-detector/api/redwood-leak-detector.api @@ -0,0 +1,24 @@ +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 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/LeakListener { + public abstract fun onReferenceCollected (Ljava/lang/String;)V + public abstract fun onReferenceLeaked-HG0u8IE (Ljava/lang/String;J)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 new file mode 100644 index 0000000000..0efecb4448 --- /dev/null +++ b/redwood-leak-detector/api/redwood-leak-detector.klib.api @@ -0,0 +1,30 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, wasmJs] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +open annotation class app.cash.redwood.leaks/RedwoodLeakApi : kotlin/Annotation { // app.cash.redwood.leaks/RedwoodLeakApi|null[0] + constructor () // app.cash.redwood.leaks/RedwoodLeakApi.|(){}[0] +} + +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 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 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] + } +} + +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/build.gradle b/redwood-leak-detector/build.gradle new file mode 100644 index 0000000000..48ffb35864 --- /dev/null +++ b/redwood-leak-detector/build.gradle @@ -0,0 +1,53 @@ +import static app.cash.redwood.buildsupport.TargetGroup.Common + +redwoodBuild { + targets(Common) + publishing() +} + +kotlin { + js { + nodejs { + testTask { + useMocha { + // We use up to 10s of wall clock time to test leaks. + timeout = '15s' + // Required for access to V8 GC function. + nodeJsArgs.add('--expose-gc') + } + } + } + } + wasmJs { + nodejs { + testTask { + useMocha { + // We use up to 10s of wall clock time to test leaks. + timeout = '15s' + // Required for access to V8 GC function. + nodeJsArgs.add('--expose-gc') + } + } + } + } + + sourceSets { + commonMain { + dependencies { + implementation libs.kotlinx.coroutines.core + } + } + commonTest { + dependencies { + implementation libs.kotlin.test + implementation libs.kotlinx.coroutines.test + implementation libs.assertk + } + } + } +} + +tasks.named('dokkaHtmlPartial') { + // Don't document this module as it's entirely implementation detail. + enabled = false +} 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 new file mode 100644 index 0000000000..ae7219c0ab --- /dev/null +++ b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt @@ -0,0 +1,25 @@ +/* + * 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 new file mode 100644 index 0000000000..8f69b907fe --- /dev/null +++ b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/Gc.kt @@ -0,0 +1,27 @@ +/* + * 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 + +internal expect fun detectGc(): Gc + +internal interface Gc { + suspend fun collect() + + object None : Gc { + override suspend fun collect() { + } + } +} 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 new file mode 100644 index 0000000000..a98b61e6af --- /dev/null +++ b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/LeakDetector.kt @@ -0,0 +1,112 @@ +/* + * 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 kotlin.time.Duration +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** Watch references and detect when they leak. */ +@RedwoodLeakApi +public interface LeakDetector { + /** + * Add [reference] as a watched instance that is expected to be garbage collected soon. + * + * This function is safe to call from any thread. + */ + public fun watchReference(reference: Any, name: 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. + */ + public suspend fun checkLeaks() + + public object None : LeakDetector { + override fun watchReference(reference: Any, name: String) {} + override suspend fun checkLeaks() {} + } + + public companion object { + /** + * Create a time-based [LeakDetector] which reports leaks to [listener] 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. + */ + public fun timeBased( + listener: LeakListener, + timeSource: TimeSource, + leakThreshold: Duration, + ): LeakDetector { + if (hasWeakReference()) { + return TimeBasedLeakDetector(listener, timeSource, leakThreshold) + } + return None + } + } +} + +@RedwoodLeakApi +public interface LeakListener { + public fun onReferenceCollected(name: String) + public fun onReferenceLeaked(name: String, alive: Duration) +} + +internal class TimeBasedLeakDetector( + private val listener: LeakListener, + private val timeSource: TimeSource, + private val leakThreshold: Duration, +) : 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(), + ) + } + + override suspend fun checkLeaks() { + gc.collect() + + watchedReferences.removeIf { watchedReference -> + if (watchedReference.weakReference.get() == null) { + listener.onReferenceCollected(watchedReference.name) + return@removeIf true + } + + val alive = watchedReference.watchedAt.elapsedNow() + if (alive >= leakThreshold) { + listener.onReferenceLeaked(watchedReference.name, alive) + return@removeIf true + } + + false + } + } + + private class WatchedReference( + val name: String, + val weakReference: WeakReference, + val watchedAt: TimeMark, + ) +} diff --git a/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/RedwoodLeakApi.kt b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/RedwoodLeakApi.kt new file mode 100644 index 0000000000..71e7a9a29d --- /dev/null +++ b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/RedwoodLeakApi.kt @@ -0,0 +1,24 @@ +/* + * 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 + +/** + * Denote an API which should only be used by Redwood implementation details and is not considered + * stable across any version. + */ +@RedwoodLeakApi +@RequiresOptIn("This API is unstable and for Redwood internal use only") +public annotation class RedwoodLeakApi diff --git a/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/WeakReference.kt b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/WeakReference.kt new file mode 100644 index 0000000000..1bedd0bac4 --- /dev/null +++ b/redwood-leak-detector/src/commonMain/kotlin/app/cash/redwood/leaks/WeakReference.kt @@ -0,0 +1,23 @@ +/* + * 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 + +internal expect fun hasWeakReference(): Boolean + +internal expect class WeakReference { + constructor(referred: T) + fun get(): T? +} 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 new file mode 100644 index 0000000000..bc4e2b6143 --- /dev/null +++ b/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/LeakDetectorTest.kt @@ -0,0 +1,104 @@ +/* + * 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 assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotSameInstanceAs +import assertk.assertions.prop +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +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() + + @BeforeTest fun before() { + assertThat(leakDetector) + .isInstanceOf() + .prop(TimeBasedLeakDetector::gc) + .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) + } + + @Test fun detectImmediateCollection() = runTest { + leakDetector.watchReference(ref!!, "ref") + + ref = null + wait(10.seconds) + + leakDetector.checkLeaks() + assertThat(listener.events).containsExactly("collected: ref") + } + + @Test fun detectDelayedCollection() = runTest { + leakDetector.watchReference(ref!!, "ref") + + wait(5.seconds) + + leakDetector.checkLeaks() + assertThat(listener.events).isEmpty() + + ref = null + + wait(5.seconds) + + leakDetector.checkLeaks() + assertThat(listener.events).containsExactly("collected: ref") + } + + @Test fun detectLeak() = runTest { + 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") + } + + @Test fun concurrencyStressTest() = runTest { + coroutineScope { + repeat(10_000) { i -> + launch(Dispatchers.Default) { + leakDetector.watchReference(Any(), "$i") + } + } + repeat(100) { + launch(Dispatchers.Default) { + leakDetector.checkLeaks() + } + } + } + } +} 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 new file mode 100644 index 0000000000..62b4558077 --- /dev/null +++ b/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/RecordingLeakListener.kt @@ -0,0 +1,30 @@ +/* + * 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 kotlin.time.Duration + +class RecordingLeakListener : LeakListener { + val events = mutableListOf() + + override fun onReferenceCollected(name: String) { + events += "collected: $name" + } + + override fun onReferenceLeaked(name: String, alive: Duration) { + events += "leaked @ $alive: $name" + } +} diff --git a/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/delayForGc.kt b/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/delayForGc.kt new file mode 100644 index 0000000000..6731b3eba3 --- /dev/null +++ b/redwood-leak-detector/src/commonTest/kotlin/app/cash/redwood/leaks/delayForGc.kt @@ -0,0 +1,20 @@ +/* + * 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 kotlin.time.Duration + +internal expect suspend fun delayForGc(duration: Duration) 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 new file mode 100644 index 0000000000..8c76bf7140 --- /dev/null +++ b/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt @@ -0,0 +1,48 @@ +/* + * 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/jsMain/kotlin/app/cash/redwood/leaks/Gc.kt b/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/Gc.kt new file mode 100644 index 0000000000..aff5bdc6fb --- /dev/null +++ b/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/Gc.kt @@ -0,0 +1,29 @@ +/* + * 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 + +internal actual fun detectGc(): Gc { + if (globalThis.hasOwnProperty("gc")) { + return GlobalThisGc() + } + return Gc.None +} + +private class GlobalThisGc : Gc { + override suspend fun collect() { + globalThis.gc() + } +} diff --git a/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/WeakReference.kt b/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/WeakReference.kt new file mode 100644 index 0000000000..07699cf294 --- /dev/null +++ b/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/WeakReference.kt @@ -0,0 +1,26 @@ +/* + * 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 + +internal actual fun hasWeakReference(): Boolean { + return globalThis["WeakRef"] != null +} + +@JsName("WeakRef") +internal actual external class WeakReference actual constructor(referred: T) { + @JsName("deref") + actual fun get(): T? +} diff --git a/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/globalThis.kt b/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/globalThis.kt new file mode 100644 index 0000000000..bcc708b161 --- /dev/null +++ b/redwood-leak-detector/src/jsMain/kotlin/app/cash/redwood/leaks/globalThis.kt @@ -0,0 +1,18 @@ +/* + * 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 + +internal external val globalThis: dynamic diff --git a/redwood-leak-detector/src/jsTest/kotlin/app/cash/redwood/leaks/delayForGc.kt b/redwood-leak-detector/src/jsTest/kotlin/app/cash/redwood/leaks/delayForGc.kt new file mode 100644 index 0000000000..ff2742f3be --- /dev/null +++ b/redwood-leak-detector/src/jsTest/kotlin/app/cash/redwood/leaks/delayForGc.kt @@ -0,0 +1,28 @@ +/* + * 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 kotlin.time.Duration +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +internal actual suspend fun delayForGc(duration: Duration) { + // Force a wall clock delay for JS Gc. + withContext(Dispatchers.Default) { + delay(duration) + } +} 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 new file mode 100644 index 0000000000..b7222580d6 --- /dev/null +++ b/redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt @@ -0,0 +1,39 @@ +/* + * 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/jvmMain/kotlin/app/cash/redwood/leaks/Gc.kt b/redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/Gc.kt new file mode 100644 index 0000000000..900da563ae --- /dev/null +++ b/redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/Gc.kt @@ -0,0 +1,30 @@ +/* + * 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 kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay + +internal actual fun detectGc(): Gc = JvmGc() + +private class JvmGc : Gc { + override suspend fun collect() { + System.gc() + delay(100.milliseconds) + System.runFinalization() + System.gc() + } +} diff --git a/redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/WeakReference.kt b/redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/WeakReference.kt new file mode 100644 index 0000000000..7911427d4c --- /dev/null +++ b/redwood-leak-detector/src/jvmMain/kotlin/app/cash/redwood/leaks/WeakReference.kt @@ -0,0 +1,24 @@ +/* + * 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 inline fun hasWeakReference(): Boolean { + return true +} + +internal actual typealias WeakReference = java.lang.ref.WeakReference diff --git a/redwood-leak-detector/src/jvmTest/kotlin/app/cash/redwood/leaks/delayForGc.kt b/redwood-leak-detector/src/jvmTest/kotlin/app/cash/redwood/leaks/delayForGc.kt new file mode 100644 index 0000000000..e4c0f37ff2 --- /dev/null +++ b/redwood-leak-detector/src/jvmTest/kotlin/app/cash/redwood/leaks/delayForGc.kt @@ -0,0 +1,23 @@ +/* + * 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 kotlin.time.Duration +import kotlinx.coroutines.delay + +internal actual suspend fun delayForGc(duration: Duration) { + delay(duration) +} 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 new file mode 100644 index 0000000000..b14c44f477 --- /dev/null +++ b/redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt @@ -0,0 +1,46 @@ +/* + * 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/nativeMain/kotlin/app/cash/redwood/leaks/Gc.kt b/redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/Gc.kt new file mode 100644 index 0000000000..6e33a74f2c --- /dev/null +++ b/redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/Gc.kt @@ -0,0 +1,28 @@ +/* + * 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 kotlin.native.runtime.GC +import kotlin.native.runtime.NativeRuntimeApi + +internal actual fun detectGc(): Gc = KotlinNativeGc() + +@OptIn(NativeRuntimeApi::class) +private class KotlinNativeGc : Gc { + override suspend fun collect() { + GC.collect() + } +} diff --git a/redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/WeakReference.kt b/redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/WeakReference.kt new file mode 100644 index 0000000000..fde4639c8b --- /dev/null +++ b/redwood-leak-detector/src/nativeMain/kotlin/app/cash/redwood/leaks/WeakReference.kt @@ -0,0 +1,25 @@ +/* + * 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 kotlin.experimental.ExperimentalNativeApi + +internal actual inline fun hasWeakReference(): Boolean { + return true +} + +@OptIn(ExperimentalNativeApi::class) +internal actual typealias WeakReference = kotlin.native.ref.WeakReference diff --git a/redwood-leak-detector/src/nativeTest/kotlin/app/cash/redwood/leaks/delayForGc.kt b/redwood-leak-detector/src/nativeTest/kotlin/app/cash/redwood/leaks/delayForGc.kt new file mode 100644 index 0000000000..e4c0f37ff2 --- /dev/null +++ b/redwood-leak-detector/src/nativeTest/kotlin/app/cash/redwood/leaks/delayForGc.kt @@ -0,0 +1,23 @@ +/* + * 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 kotlin.time.Duration +import kotlinx.coroutines.delay + +internal actual suspend fun delayForGc(duration: Duration) { + delay(duration) +} 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 new file mode 100644 index 0000000000..2a3a43a794 --- /dev/null +++ b/redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/ConcurrentMutableList.kt @@ -0,0 +1,39 @@ +/* + * 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-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/Gc.kt b/redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/Gc.kt new file mode 100644 index 0000000000..678760833e --- /dev/null +++ b/redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/Gc.kt @@ -0,0 +1,36 @@ +/* + * 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 + +private external val globalThis: GlobalThis + +private external class GlobalThis { + fun hasOwnProperty(name: String): Boolean + fun gc() +} + +internal actual fun detectGc(): Gc { + if (globalThis.hasOwnProperty("gc")) { + return GlobalThisGc() + } + return Gc.None +} + +private class GlobalThisGc : Gc { + override suspend fun collect() { + globalThis.gc() + } +} diff --git a/redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/WeakReference.kt b/redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/WeakReference.kt new file mode 100644 index 0000000000..c4e6bf2fb2 --- /dev/null +++ b/redwood-leak-detector/src/wasmJsMain/kotlin/app/cash/redwood/leaks/WeakReference.kt @@ -0,0 +1,37 @@ +/* + * 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 inline fun hasWeakReference(): Boolean { + return true +} + +internal actual class WeakReference +private constructor( + private val real: WeakRef>, +) { + actual constructor(referred: T) : this(WeakRef(referred.toJsReference())) + + actual fun get(): T? { + return real.deref()?.get() + } +} + +private external class WeakRef(reference: T) { + fun deref(): T? +} diff --git a/redwood-leak-detector/src/wasmJsTest/kotlin/app/cash/redwood/leaks/delayForGc.kt b/redwood-leak-detector/src/wasmJsTest/kotlin/app/cash/redwood/leaks/delayForGc.kt new file mode 100644 index 0000000000..ff2742f3be --- /dev/null +++ b/redwood-leak-detector/src/wasmJsTest/kotlin/app/cash/redwood/leaks/delayForGc.kt @@ -0,0 +1,28 @@ +/* + * 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 kotlin.time.Duration +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +internal actual suspend fun delayForGc(duration: Duration) { + // Force a wall clock delay for JS Gc. + withContext(Dispatchers.Default) { + delay(duration) + } +} diff --git a/settings.gradle b/settings.gradle index a1dc4eddc1..0384335b9e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -67,6 +67,8 @@ include ':redwood-lazylayout-testing' include ':redwood-lazylayout-uiview' include ':redwood-lazylayout-view' include ':redwood-lazylayout-widget' +include ':redwood-leak-detector' +include ':redwood-leak-detector-zipline-test' include ':redwood-protocol' include ':redwood-protocol-guest' include ':redwood-protocol-host'