Skip to content

R8 removes the job GC-anchor field from ReadonlySharedFlow/ReadonlyStateFlow #4646

Description

@solevic

The fix for #2557 (fbac730) added a private val job: Job? field to ReadonlySharedFlow and ReadonlyStateFlow to prevent the sharing coroutine from being garbage collected. The field is annotated @Suppress("unused"), which silences the Kotlin compiler but has no effect on bytecode-level optimizers.

R8's full-mode optimizer removes fields that are written but never read. Since job is never read, R8 strips the field and its constructor assignment, making the sharing coroutine eligible for GC again.

This will affect all Android projects once AGP 9.0 is adopted, because it drops support for proguard-android.txt, which had -dontoptimize.

Reproduction

Tested with:

  • kotlinx-coroutines 1.10.2
  • R8 8.13.19
  • Kotlin 2.3.20
  • JDK 17

The reproduction code is adapted from the original issue. A callbackFlow with a WeakReference-based callback (mimicking Android APIs like SharedPreferences or MediaPlayer) is shared via stateIn. Without the job GC anchor, the sharing coroutine is collected and emissions stop.

Here are the files to reproduce using a Gradle JVM project that runs R8 standalone, without requiring AGP:

settings.gradle.kts

rootProject.name = "coroutines-r8-repro"

build.gradle.kts

import java.util.jar.JarFile
import java.net.URI

plugins {
    kotlin("jvm") version "2.3.20"
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}

application {
    mainClass.set("MainKt")
}

val r8Version = "8.13.19"
val r8Jar = layout.buildDirectory.file("r8/r8.jar")

tasks.register("downloadR8") {
    val dest = r8Jar.get().asFile
    outputs.file(dest)
    doLast {
        dest.parentFile.mkdirs()
        URI("https://storage.googleapis.com/r8-releases/raw/$r8Version/r8.jar").toURL()
            .openStream().use { it.copyTo(dest.outputStream()) }
    }
}

tasks.register<Jar>("fatJar") {
    archiveClassifier.set("all")
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
    manifest { attributes["Main-Class"] = "MainKt" }
    from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) })
    with(tasks.jar.get())
}

tasks.register<JavaExec>("r8") {
    dependsOn("fatJar", "downloadR8")

    val fatJar = tasks.named<Jar>("fatJar").get().archiveFile.get().asFile
    val outputDir = layout.buildDirectory.dir("r8out").get().asFile
    val rulesFile = layout.projectDirectory.file("r8-rules.pro").asFile
    val extractedRules = layout.buildDirectory.file("coroutines-r8.pro").get().asFile

    doFirst {
        val jarFile = JarFile(fatJar)
        val entry = jarFile.getJarEntry("META-INF/com.android.tools/r8/coroutines.pro")
        extractedRules.writeText(jarFile.getInputStream(entry).bufferedReader().readText())
        jarFile.close()
        outputDir.deleteRecursively()
        outputDir.mkdirs()
    }

    classpath = files(r8Jar)
    mainClass.set("com.android.tools.r8.R8")
    args = listOf(
        "--classfile",
        "--release",
        "--no-minification",
        "--pg-conf", rulesFile.absolutePath,
        "--pg-conf", extractedRules.absolutePath,
        "--lib", System.getProperty("java.home"),
        "--output", outputDir.absolutePath,
        fatJar.absolutePath
    )
}

tasks.register<Jar>("r8Jar") {
    dependsOn("r8")
    archiveClassifier.set("r8")
    manifest { attributes["Main-Class"] = "MainKt" }
    from(layout.buildDirectory.dir("r8out"))
}

tasks.register<JavaExec>("runBaseline") {
    dependsOn("fatJar")
    classpath = files(tasks.named<Jar>("fatJar").get().archiveFile)
}

tasks.register<JavaExec>("runOptimized") {
    dependsOn("r8Jar")
    classpath = files(tasks.named<Jar>("r8Jar").get().archiveFile)
}

r8-rules.pro: bundled coroutines R8 rules are extracted from the jar and provided to the R8 pass

-dontwarn **

# App entry point
-keep class MainKt { *; }

src/main/kotlin/Main.kt: similar to original snippet

@file:OptIn(ExperimentalCoroutinesApi::class)

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.channels.*
import java.lang.ref.WeakReference
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

fun main(): Unit = runBlocking {
    val jobToCancel = launch {
        someFlow.collect()
    }
    delay(100.milliseconds)
    repeat(5) {
        System.gc()
        System.gc()
        delay(1.seconds)
    }
    jobToCancel.cancel()
}

val someFlow = flow<Int> {
    iterationsFlow(1.seconds).collect {
        println("Got $it")
        currentCoroutineContext().ensureActive()
    }
}.stateIn(
    scope = CoroutineScope(EmptyCoroutineContext),
    started = SharingStarted.Eagerly,
    initialValue = 0
)

fun iterationsFlow(interval: Duration): Flow<Int> = callbackFlow {
    val listener = Listener {
        trySend(it)
    }
    val cb = CallbackRepeatCaller(interval, listener)
    awaitClose {
        cb.forget()
        listener.call(-1)
    }
}

fun interface Listener {
    fun call(iterations: Int)
}

class CallbackRepeatCaller(interval: Duration, listener: Listener) {
    private val ref = WeakReference(listener)

    private val caller = CoroutineScope(EmptyCoroutineContext).launch {
        var iterations = 0
        while (true) {
            ensureActive()
            ref.get()?.call(++iterations)
            delay(interval)
        }
    }

    fun forget() {
        caller.cancel()
        ref.clear()
    }
}

1. Baseline (no R8): sharing coroutine survives GC:

./gradlew runBaseline
Got 1
Got 2
Got 3
Got 4
Got 5
Got 6

2. After R8: sharing coroutine is GC'd:

./gradlew runOptimized
Got 1

R8 strips the job field because none of the bundled rules protect write-only private fields. You can verify with:

javap -private -cp build/libs/coroutines-r8-repro-r8.jar kotlinx.coroutines.flow.ReadonlyStateFlow

Before R8:

private final kotlinx.coroutines.flow.StateFlow<T> $$delegate_0;
private final kotlinx.coroutines.Job job;                          // GC anchor present

After R8:

public final kotlinx.coroutines.flow.StateFlow $$delegate_0;      // job is gone

ProGuard 7.9.0 does not exhibit this behavior with -optimizations field/removal/writeonly explicitly enabled, it preserves the field. It is unclear to me whether this is an intentional optimization difference in R8 or a bug.

Regardless, the job field should be protected with a -keep rule to prevent any optimizer from stripping it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions