Skip to content

Commit

Permalink
Add leak detection API and module (#2191)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
JakeWharton authored Jul 22, 2024
1 parent 16dc069 commit d5defde
Show file tree
Hide file tree
Showing 35 changed files with 1,224 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ class RedwoodBuildPlugin : Plugin<Project> {
// 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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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())
Expand All @@ -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
Expand Down
59 changes: 59 additions & 0 deletions redwood-leak-detector-zipline-test/build.gradle
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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<LeakDetectorTestService>(SERVICE_NAME, LeakDetectorTestServiceImpl())
}
Original file line number Diff line number Diff line change
@@ -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<Pair<String, String>>,
) = 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()
}
}
24 changes: 24 additions & 0 deletions redwood-leak-detector/api/redwood-leak-detector.api
Original file line number Diff line number Diff line change
@@ -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 {
}

30 changes: 30 additions & 0 deletions redwood-leak-detector/api/redwood-leak-detector.klib.api
Original file line number Diff line number Diff line change
@@ -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: <app.cash.redwood:redwood-leak-detector>
open annotation class app.cash.redwood.leaks/RedwoodLeakApi : kotlin/Annotation { // app.cash.redwood.leaks/RedwoodLeakApi|null[0]
constructor <init>() // app.cash.redwood.leaks/RedwoodLeakApi.<init>|<init>(){}[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]
}
53 changes: 53 additions & 0 deletions redwood-leak-detector/build.gradle
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<T>

internal expect inline fun <T> concurrentMutableListOf(): ConcurrentMutableList<T>

internal expect inline operator fun <T> ConcurrentMutableList<T>.plusAssign(element: T)

internal expect inline fun <T> ConcurrentMutableList<T>.removeIf(predicate: (element: T) -> Boolean)
Loading

0 comments on commit d5defde

Please sign in to comment.