ursus
08/21/2025, 9:40 PM@SingleIn(UserScope::class)
class MyDependency(
@QualifierFor(AppScope::class) private val scope: CoroutineScope
) {
fun foo() {
scope.launch {
// access other fields
}
}
}
since this compiles but is a memory leakZach Klippenstein (he/him) [MOD]
08/21/2025, 10:02 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:03 PMursus
08/21/2025, 10:08 PMursus
08/21/2025, 10:10 PMrunTest { .. }
seems to want to push me in the direction of
runTest {
val x = MyDependency(this)
}
such to get the cancellation out of the boxZach Klippenstein (he/him) [MOD]
08/21/2025, 10:11 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:14 PM@SingleIn(UserScope::class)
class MyDependency(
@QualifierFor(AppScope::class) private val context: CoroutineContext
) : Scoped {
fun foo() {
asCoroutineScope().launch(context) {
// access other fields
}
}
}
Where Scoped
is an interface that our system wires up to start/stop callbacks, and has an extension method asCoroutineScope()
that creates a CoroutineScope
with a job that gets cancelled when the scope is disposed.ursus
08/21/2025, 10:15 PMclass MyDependency(private val dispatcher) {
private val scope = CoroutineScope(disptacher + SupervisorJob())
// ...
fun stop() {
scope.cancel()
}
}
i.e. a private val scope
?Zach Klippenstein (he/him) [MOD]
08/21/2025, 10:16 PMursus
08/21/2025, 10:16 PMrunTest
ursus
08/21/2025, 10:17 PMursus
08/21/2025, 10:18 PMstop
explciitly, which nobody does..Zach Klippenstein (he/him) [MOD]
08/21/2025, 10:18 PMursus
08/21/2025, 10:19 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:19 PMCircuit
or Square's Workflow
Zach Klippenstein (he/him) [MOD]
08/21/2025, 10:20 PMursus
08/21/2025, 10:20 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:21 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:21 PMursus
08/21/2025, 10:22 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:24 PMursus
08/21/2025, 10:24 PMursus
08/21/2025, 10:27 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:27 PMclass MyDependency(private val context: CoroutineContext) {
private val scope = CoroutineScope(context + SupervisorJob(parent = context[Job]))
// ...
fun stop() {
scope.cancel()
}
}
Then in your test you can pass in a context with a job, but all the coroutines in MyDependency
are still scoped to the lifetime of that class via your own mechanism.Zach Klippenstein (he/him) [MOD]
08/21/2025, 10:27 PMDispatcher
or whateverZach Klippenstein (he/him) [MOD]
08/21/2025, 10:28 PMstop
method is called, so there's kind of two sources of truth for lifetime.Zach Klippenstein (he/him) [MOD]
08/21/2025, 10:29 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:29 PMstop
other than cancelling a coroutine jobursus
08/21/2025, 10:30 PMtest helper
, how would that look like? pop in Scoped references into it in @Before
?Zach Klippenstein (he/him) [MOD]
08/21/2025, 10:31 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:31 PMstop()
?ursus
08/21/2025, 10:31 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:31 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:32 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:32 PMstop()
ursus
08/21/2025, 10:32 PMScopes.remove(UserScope::class)
like an explicit call to it, last line of suspend logout
ursus
08/21/2025, 10:33 PMursus
08/21/2025, 10:33 PMScopes
is just a map of dagger components, thats all
and on remove, scope gets all its Scoped
instances (multibinding) and calls stop on eachZach Klippenstein (he/him) [MOD]
08/21/2025, 10:35 PMMyDependency
directly?ursus
08/21/2025, 10:36 PMursus
08/21/2025, 10:37 PMclass FooSyncer(private val dispatcher) {
private val scope = CoroutineScope(disptacher + SupervisorJob())
fun sync() {
scope.launch {
doSync()
}
}
private suspend fun doSync() {
//
}
fun stop() {
scope.cancel()
}
}
one such test would be to test sync()
hereursus
08/21/2025, 10:38 PMrunTest {
val syncer = FooSyncer(this if it were to take scope)
syncer.sync()
advanceUntilIdle()
assertThat(db).isTheWayIWantIt()
}
Zach Klippenstein (he/him) [MOD]
08/21/2025, 10:39 PMinline fun <T: Scoped> T.use(block: (T) -> Unit) {
try {
block()
} finally {
stop()
}
}
and then write your tests like
@Test fun thingsWork() {
MyDependency(…).use { dependency ->
// test code
}
}
of course throwing runTest
in there when you need coroutinesursus
08/21/2025, 10:40 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:41 PMfun CoroutineScope.stopOnCompletion(scoped: Scoped) {
coroutineContext.job.invokeOnCompletion { scoped.stop() }
}
and write tests like
@Test fun thingsWork() = runTest {
val dependency = MyDependency(…)
backgroundScope.stopOnCompletion(dependency)
}
I think that's a little more gross though, since it is more coupled to coroutines specificallyZach Klippenstein (he/him) [MOD]
08/21/2025, 10:42 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:43 PMursus
08/21/2025, 10:43 PMrunTest {
val x = MyDependency(GlobalScope)
}
this?ursus
08/21/2025, 10:45 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:45 PMCoroutineScope()
, or EmptyCoroutineContext
Zach Klippenstein (he/him) [MOD]
08/21/2025, 10:46 PMAssistedInject
ursus
08/21/2025, 10:46 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:46 PMursus
08/21/2025, 10:47 PMyea, the docs for Circuit have an example specifically using daggerokay but then notion of scopes is not from dagger, but rather from Circuit?AssistedInject
Zach Klippenstein (he/him) [MOD]
08/21/2025, 10:47 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:48 PMpresent
function in compositionursus
08/21/2025, 10:49 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 10:50 PMursus
08/21/2025, 10:54 PMStandardTestDispatcher
? i.e. all the references (io, default, main) point to that single instance , thus making is effecivelly single threaded?ursus
08/21/2025, 10:55 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:10 PMursus
08/21/2025, 11:11 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:13 PMursus
08/21/2025, 11:13 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:14 PMursus
08/21/2025, 11:14 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:16 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:17 PMursus
08/21/2025, 11:18 PMfun foo(): SomeResult {
return coroutineScope {
val x = async { .. }
val y = async { .. }
x.await() + y.await()
}
}
say its something trivial like this
isnt there benefit to have the asyncs actually be concurrent at test time?Zach Klippenstein (he/him) [MOD]
08/21/2025, 11:19 PMursus
08/21/2025, 11:19 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:19 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:20 PMursus
08/21/2025, 11:20 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:21 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:21 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:21 PMursus
08/21/2025, 11:22 PMDispatchers.Unconfined
everywhere?Zach Klippenstein (he/him) [MOD]
08/21/2025, 11:22 PMursus
08/21/2025, 11:23 PMUnconfinedTestDispatcher
as well,
okay I probably dont know what unconfined actually doesZach Klippenstein (he/him) [MOD]
08/21/2025, 11:23 PMursus
08/21/2025, 11:24 PMStandardTestDispatcher
take in the actual test thread or have it's own backing it? that would make senseursus
08/21/2025, 11:24 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:25 PMursus
08/21/2025, 11:25 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:25 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 11:27 PMursus
08/21/2025, 11:28 PMsvenjacobs
08/22/2025, 6:05 AMsvenjacobs
08/22/2025, 6:12 AMsvenjacobs
08/22/2025, 6:13 AMCoroutineScope(appContext).launch { }
Lukasz Kalnik
08/22/2025, 9:45 AMCoroutineScope
to classes because then you could pass an ApplicationScope
where actually you should have e.g. ViewModelScope
.Lukasz Kalnik
08/22/2025, 9:49 AMclass MyViewModel(
providedCoroutineScope: CoroutineScope? = null
) {
private val coroutineScope = providedCoroutineScope ?: viewModelScope
}
For repositories we set a default argument (and also only overwrite the scope for tests):
class MyRepository(
private val coroutineScope: CoroutineScope = ApplicationScope.instance
)
And then replace the scopes in tests with backgroundScope
.Lukasz Kalnik
08/22/2025, 9:54 AMLukasz Kalnik
08/22/2025, 9:54 AMApplicationScope.instance
)ursus
08/22/2025, 10:18 AMLukasz Kalnik
08/22/2025, 10:20 AMWould this then be okay? Just creating a new scope where needed?
CoroutineScope(appContext).launch { }To guarantee structured concurrency you would need to store this CoroutineScope in class, so all methods use common instance. Also you need to cancel it in
onCleared()
(for ViewModels).svenjacobs
08/22/2025, 10:21 AMursus
08/22/2025, 10:21 AMLukasz Kalnik
08/22/2025, 10:21 AMLukasz Kalnik
08/22/2025, 10:22 AMursus
08/22/2025, 10:23 AMthis
Lukasz Kalnik
08/22/2025, 10:25 AMclass MyViewModel {
fun sendFeedback() {
FeedbackRepository.instance.sendFeedback() // no coroutine scope, not a suspending function
}
}
class FeedbackRepository(
private val coroutineScope: CoroutineScope = ApplicationScope.instance
) {
fun sendFeedback() {
coroutineScope.launch {
apiClient.sendFeedback() // suspending function
}
}
}
svenjacobs
08/22/2025, 10:26 AMLukasz Kalnik
08/22/2025, 10:27 AMLukasz Kalnik
08/22/2025, 10:28 AMLukasz Kalnik
08/22/2025, 10:28 AMLukasz Kalnik
08/22/2025, 10:29 AMsvenjacobs
08/22/2025, 10:30 AMursus
08/22/2025, 10:31 AMLukasz Kalnik
08/22/2025, 10:33 AMursus
08/22/2025, 10:34 AMsvenjacobs
08/22/2025, 11:48 AMlaunch
lambda doesn't reference any properties or methods of the outer class there should be no leaking? I think it's a subtle issue since once you add a reference to an outside property/method you introduce a memory leak. But it's not really obvious that this is going to happen and it might not even be noticeable if the coroutine just runs for a short period of time.ursus
08/22/2025, 11:49 AMsvenjacobs
08/22/2025, 11:50 AMsvenjacobs
08/22/2025, 12:05 PMExample
because the lambda of launch
doesn't reference any property or method of Example
?
class Example(val appScope: CoroutineScope) {
fun execute() {
appScope.launch {
while (true) {
println("Hello world")
}
}
}
}
Assuming that appScope
is a singleton and there's no reference to Example
anymore after Example(appScope).execute()
.
But this would leak:
class Example(val appScope: CoroutineScope) {
val message = "Hello world"
fun execute() {
appScope.launch {
while (true) {
println(message)
}
}
}
}
ursus
08/22/2025, 12:06 PMthis.message
technically, so yes it captures this
if this was swift and you could tell it to capture only message
directly, then it would not be an issue
but this is how java/kotlin does itursus
08/22/2025, 12:07 PM