cashapp / cashapp/molecule

launchMolecule can call setContent on a disposed Composition during startup cancellation

Open
#760 1 comment 1 reaction 0 assignees View on GitHub
Dominant language
Kotlin
Stars
2.2k
Forks
116
Avg merge
1h 36m
Merged PRs (30d)
8

Description

### Description

`launchMolecule` can throw `IllegalStateException("The composition is disposed")` when the supplied coroutine context is cancelled during startup.

The current implementation creates a `Recomposer` and `Composition`, launches `recomposer.runRecomposeAndApplyChanges()` with `CoroutineStart.UNDISPATCHED`, disposes the composition in that coroutine's `finally`, and only then calls `composition.setContent { ... }`.

If the recomposer coroutine completes due to cancellation before `setContent` runs, `setContent` is invoked on an already-disposed composition.

I originally saw this in production Android crash reports with stacks like:

```text
java.lang.IllegalStateException: The composition is disposed
at androidx.compose.runtime.CompositionImpl.ensureRunning(Composition.kt:1470)
at androidx.compose.runtime.CompositionImpl.setContent(Composition.kt:634)
at app.cash.molecule.MoleculeKt.launchMolecule(Molecule.kt:259)
at app.cash.molecule.MoleculeKt.launchMolecule$default(Molecule.kt:217)
at app.cash.molecule.MoleculeKt$immediateClockFlow$1$1$1.invokeSuspend(molecule.kt:85)
```

### Repro

This test demonstrates the failure on current `trunk` and also matches the same ordering in `2.2.0`:

```kotlin
@Test fun cancelledContextClockLaunch() = runTest {
val job = Job()
val clock = BroadcastFrameClock()
val scope = CoroutineScope(coroutineContext + clock)

job.cancel()

assertFailure {
scope.launchMolecule(ContextClock, emitter = { fail("emitter should not be called") }, context = job) {
fail("body should not be composed")
}
}.hasMessage("The composition is disposed")
}
```

### Expected behavior

Launching with an already-cancelled context, or one that is cancelled during startup, should fail with normal coroutine cancellation semantics (`CancellationException`) and should not attempt `Composition.setContent` after disposal.

### Possible fix direction
A small fix might be to check `finalContext.ensureActive()` before creating/launching the composition, and/or reorder startup so `setContent` happens before the recomposer coroutine can dispose the composition. If reordering, startup failures before the recomposer coroutine is launched should still dispose the composition and snapshot observer before rethrowing.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start in Molecule.kt around launchMolecule, especially the startup ordering described in the stack trace. Reproduce the issue with cancelledContextClockLaunch and inspect cancellation before and during startup. Done means cancellation produces a CancellationException and setContent is never called after the composition is disposed, while startup failures still clean up resources.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, kotlin
Domain
mobile
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.