Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add leak detection API and module #2191

Merged
merged 1 commit into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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