Skip to content

Fix launchMolecule startup cancellation race#761

Open
jmalpasuto wants to merge 6 commits into
cashapp:trunkfrom
jmalpasuto:jack--fix-molecule-startup-disposal-race
Open

Fix launchMolecule startup cancellation race#761
jmalpasuto wants to merge 6 commits into
cashapp:trunkfrom
jmalpasuto:jack--fix-molecule-startup-disposal-race

Conversation

@jmalpasuto

@jmalpasuto jmalpasuto commented May 21, 2026

Copy link
Copy Markdown

Fixes #760

Ensures launchMolecule completes its synchronous initial composition and produces one value even when its launch context is already cancelled. Cancellation still prevents launched effects and future recompositions.

Keeps composition and snapshot observer cleanup tied to recomposer completion, and cancels the recomposer when initial composition or emitter code fails so startup resources do not leak.

Comment on lines -234 to -240
launch(finalContext, start = UNDISPATCHED) {
try {
recomposer.runRecomposeAndApplyChanges()
} finally {
composition.dispose()
snapshotHandle?.dispose()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's been a while since I looked through the Recomposer code, but I think this try/finally block needs to wrap recomposer.runRecomposeAndApplyChanges(). With this change if something throws in the when (snapshotNotifier) block, the snapshotHandle would leak and the composition wouldn't be disposed.

I think all you need to do is add the finalContext.ensureActive().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I dislike silent failure as a failure mode, checking isActive and returning early would be idiomatic with how other launch methods behave on cancelled scopes. That is what I would do.

@FletchMcKee FletchMcKee May 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, looking at some of the tests I wonder if there’s another issue. I remember discovering this snapshotHandle leak when working on #596 as I would see the errorDelayed tests handles leaking into other tests. That’s why I added this check to any of the tests that threw an exception:

// Verify `runRecomposeAndApplyChanges` is no longer active.
assertThat(job.isCompleted).isTrue()

But this didn’t work for the errorImmediately tests and I thought it wasn’t an issue because the exception was thrown in the initial composition so it never had a recomposition. For these tests I added job.cancelAndJoin() at the end and thought it was enough but I wonder if this is wrong.

If an exception occurs in the initial composition, I don’t know if anything ever cleans up the snapshotHandle and runRecomposeAndApplyChanges ends up hanging. Maybe something like this is also needed?

if (!finalContext.isActive) return 
try {
  composition.setContent {
    emitter(body())
  }
} catch (exception: Exception) {
  finalContext.cancel()
  throw exception 
}

There’s gotta be a cleaner way and I may be overthinking this, maybe the job completes on the next frame clock tick, but some food for thought.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reviewing! I put up a follow up fix that hopefully addresses these issues... Let me know what you think!

)

return flow!!
return flow ?: throw CancellationException("launchMolecule was cancelled before producing an initial value")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure what to return if the flow is null now... CancellationException seemed to make sense?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... What does stateIn do if you call it on a canceled scope? We should do whatever they do.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point... I'll take a peek

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay so looking at stateIn, the non-suspending initial value overload can always return MutableStateFlow(initialValue), even if the scope is cancelled.

the suspending overload (with no initial value) waits for the first value via a CompletableDeferred parented to the scope job, so a cancelled scope throws CancellationException.

since Molecule has no initial value to return, I think the StateFlow overload should probably match the suspending stateIn behavior by throwing the CancellationException?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that we aren't suspending, so we're not within a coroutine where that exception makes sense. Our function can be called synchronously on the main thread.

What if we wrap ourselves in NonCancelable and then cancel all of the children once we have our initial value?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion here is that we can always complete the initial recomposition to produce a single value, and then only then do we prevent the recomposer and effects from running and producing subsequent values.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good call. updated! if this isn't exactly what you're thinking lmk and / or feel free to make changes as you see fit

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simplified this a bit, should be good for another pass

@jmalpasuto
jmalpasuto force-pushed the jack--fix-molecule-startup-disposal-race branch from 5d7cab5 to d22e552 Compare July 14, 2026 18:03
emitter(body())
}
} catch (throwable: Throwable) {
recomposer.cancel()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recomposer, or the recomposerJob?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recomposer. Canceling only the job could leave effects running whereas canceling the recomposer shuts down both.

@Test fun cancelledContextComposesInitialValueBeforeStopping() = runTest {
for (mode in listOf(ContextClock, Immediate)) {
val job = Job()
val scope = CoroutineScope(coroutineContext + BroadcastFrameClock())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaks structured concurrency, which is bad — breaking SC can lead to unowned jobs, and concurrency leaks.

Instead, do something like:

(this + BroadcastFrameClock()).launchMolecule<Int>(mode, context = job)

If that hangs, well — good! More coverage from the test.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(This is contra the style used in other tests, I now realize, so - Jake will probably be fine with it)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call checking this. The scope keeps runTest’s existing job, so no new unowned job is created.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, yeah, I missed that. I just automatically scan for CoroutineScope as being problematic. You are right!

@Test fun cancelledContextComposesInitialValueBeforeStopping() = runTest {
for (mode in listOf(ContextClock, Immediate)) {
val job = Job()
val scope = CoroutineScope(coroutineContext + BroadcastFrameClock())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same feedback here.

}
runCurrent()

assertThat(flow.value).isEqualTo(1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this before runCurrent() - this should synchronously be the case immediately after launchMolecule.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated!

@Test fun cancelledContextComposesInitialValueBeforeStopping() = runTest {
for (mode in listOf(ContextClock, Immediate)) {
val job = Job()
val scope = CoroutineScope(coroutineContext + BroadcastFrameClock())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(This is contra the style used in other tests, I now realize, so - Jake will probably be fine with it)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

launchMolecule can call setContent on a disposed Composition during startup cancellation

4 participants