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:
Got 1
Got 2
Got 3
Got 4
Got 5
Got 6
2. After R8: sharing coroutine is GC'd:
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.
The fix for #2557 (fbac730) added a
private val job: Job?field toReadonlySharedFlowandReadonlyStateFlowto 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
jobis 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:
The reproduction code is adapted from the original issue. A
callbackFlowwith aWeakReference-based callback (mimicking Android APIs likeSharedPreferencesorMediaPlayer) is shared viastateIn. Without thejobGC 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.ktsbuild.gradle.ktsr8-rules.pro: bundled coroutines R8 rules are extracted from the jar and provided to the R8 passsrc/main/kotlin/Main.kt: similar to original snippet1. Baseline (no R8): sharing coroutine survives GC:
2. After R8: sharing coroutine is GC'd:
R8 strips the
jobfield because none of the bundled rules protect write-only private fields. You can verify with:Before R8:
After R8:
ProGuard 7.9.0 does not exhibit this behavior with
-optimizations field/removal/writeonlyexplicitly enabled, it preserves the field. It is unclear to me whether this is an intentional optimization difference in R8 or a bug.Regardless, the
jobfield should be protected with a-keeprule to prevent any optimizer from stripping it.