Fix launchMolecule startup cancellation race#761
Conversation
| launch(finalContext, start = UNDISPATCHED) { | ||
| try { | ||
| recomposer.runRecomposeAndApplyChanges() | ||
| } finally { | ||
| composition.dispose() | ||
| snapshotHandle?.dispose() | ||
| } |
There was a problem hiding this comment.
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().
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
I wasn't sure what to return if the flow is null now... CancellationException seemed to make sense?
There was a problem hiding this comment.
Hmm... What does stateIn do if you call it on a canceled scope? We should do whatever they do.
There was a problem hiding this comment.
Good point... I'll take a peek
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
good call. updated! if this isn't exactly what you're thinking lmk and / or feel free to make changes as you see fit
There was a problem hiding this comment.
simplified this a bit, should be good for another pass
5d7cab5 to
d22e552
Compare
| emitter(body()) | ||
| } | ||
| } catch (throwable: Throwable) { | ||
| recomposer.cancel() |
There was a problem hiding this comment.
The recomposer, or the recomposerJob?
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
(This is contra the style used in other tests, I now realize, so - Jake will probably be fine with it)
There was a problem hiding this comment.
Good call checking this. The scope keeps runTest’s existing job, so no new unowned job is created.
There was a problem hiding this comment.
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()) |
| } | ||
| runCurrent() | ||
|
|
||
| assertThat(flow.value).isEqualTo(1) |
There was a problem hiding this comment.
Move this before runCurrent() - this should synchronously be the case immediately after launchMolecule.
| @Test fun cancelledContextComposesInitialValueBeforeStopping() = runTest { | ||
| for (mode in listOf(ContextClock, Immediate)) { | ||
| val job = Job() | ||
| val scope = CoroutineScope(coroutineContext + BroadcastFrameClock()) |
There was a problem hiding this comment.
(This is contra the style used in other tests, I now realize, so - Jake will probably be fine with it)
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.