-
Notifications
You must be signed in to change notification settings - Fork 113
Fix launchMolecule startup cancellation race #761
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from 5 commits
6ec6833
54a47a8
3e82207
ed2e853
d22e552
e9e70b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -231,34 +231,39 @@ public fun <T> CoroutineScope.launchMolecule( | |
| val composition = Composition(UnitApplier, recomposer) | ||
|
|
||
| var snapshotHandle: ObserverHandle? = null | ||
| launch(finalContext, start = UNDISPATCHED) { | ||
| try { | ||
| recomposer.runRecomposeAndApplyChanges() | ||
| } finally { | ||
| composition.dispose() | ||
| snapshotHandle?.dispose() | ||
| } | ||
| val recomposerJob = launch(finalContext, start = UNDISPATCHED) { | ||
| recomposer.runRecomposeAndApplyChanges() | ||
| } | ||
|
|
||
| when (snapshotNotifier) { | ||
| SnapshotNotifier.External -> {} | ||
| try { | ||
| when (snapshotNotifier) { | ||
| SnapshotNotifier.External -> {} | ||
|
|
||
| SnapshotNotifier.WhileActive -> { | ||
| var applyScheduled = false | ||
| snapshotHandle = Snapshot.registerGlobalWriteObserver { | ||
| if (!applyScheduled) { | ||
| applyScheduled = true | ||
| launch(finalContext) { | ||
| applyScheduled = false | ||
| Snapshot.sendApplyNotifications() | ||
| SnapshotNotifier.WhileActive -> { | ||
| var applyScheduled = false | ||
| snapshotHandle = Snapshot.registerGlobalWriteObserver { | ||
| if (!applyScheduled) { | ||
| applyScheduled = true | ||
| launch(finalContext) { | ||
| applyScheduled = false | ||
| Snapshot.sendApplyNotifications() | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| composition.setContent { | ||
| emitter(body()) | ||
| composition.setContent { | ||
| emitter(body()) | ||
| } | ||
| } catch (throwable: Throwable) { | ||
| recomposer.cancel() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| throw throwable | ||
| } finally { | ||
| recomposerJob.invokeOnCompletion { | ||
| composition.dispose() | ||
| snapshotHandle?.dispose() | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ import app.cash.molecule.RecompositionMode.ContextClock | |
| import app.cash.molecule.RecompositionMode.Immediate | ||
| import assertk.assertFailure | ||
| import assertk.assertThat | ||
| import assertk.assertions.isEmpty | ||
| import assertk.assertions.isEqualTo | ||
| import assertk.assertions.isSameInstanceAs | ||
| import assertk.assertions.isTrue | ||
|
|
@@ -99,10 +100,33 @@ class MoleculeStateFlowTest { | |
| } | ||
| }.isSameInstanceAs(runtimeException) | ||
|
|
||
| // This exception is processed in `composeInitial` and not `runRecomposeAndApplyChanges`, so the job is still active. | ||
| runCurrent() | ||
| assertThat(job.children.toList()).isEmpty() | ||
| job.cancelAndJoin() | ||
| } | ||
|
|
||
| @Test fun cancelledContextComposesInitialValueBeforeStopping() = runTest { | ||
| for (mode in listOf(ContextClock, Immediate)) { | ||
| val job = Job() | ||
| val scope = CoroutineScope(coroutineContext + BroadcastFrameClock()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! |
||
| var effectRan = false | ||
|
|
||
| job.cancel() | ||
|
|
||
| val flow = scope.launchMolecule<Int>(mode, context = job) { | ||
| LaunchedEffect(Unit) { | ||
| effectRan = true | ||
| } | ||
| 1 | ||
| } | ||
| runCurrent() | ||
|
|
||
| assertThat(flow.value).isEqualTo(1) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Move this before
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated! |
||
| assertThat(effectRan).isEqualTo(false) | ||
| assertThat(job.children.toList()).isEmpty() | ||
| } | ||
| } | ||
|
|
||
| @Test fun errorDelayed() = runTest { | ||
| val job = Job() | ||
| val clock = BroadcastFrameClock() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ import app.cash.molecule.SnapshotNotifier.External | |
| import app.cash.molecule.SnapshotNotifier.WhileActive | ||
| import assertk.assertFailure | ||
| import assertk.assertThat | ||
| import assertk.assertions.isEmpty | ||
| import assertk.assertions.isEqualTo | ||
| import assertk.assertions.isNotSameInstanceAs | ||
| import assertk.assertions.isSameInstanceAs | ||
|
|
@@ -110,10 +111,29 @@ class MoleculeTest { | |
| } | ||
| }.isSameInstanceAs(runtimeException) | ||
|
|
||
| // This exception is processed in `composeInitial` and not `runRecomposeAndApplyChanges`, so the job is still active. | ||
| runCurrent() | ||
| assertThat(job.children.toList()).isEmpty() | ||
| job.cancelAndJoin() | ||
| } | ||
|
|
||
| @Test fun cancelledContextComposesInitialValueBeforeStopping() = runTest { | ||
| for (mode in listOf(ContextClock, Immediate)) { | ||
| val job = Job() | ||
| val scope = CoroutineScope(coroutineContext + BroadcastFrameClock()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same feedback here. |
||
| var value = 0 | ||
|
|
||
| job.cancel() | ||
|
|
||
| scope.launchMolecule<Int>(mode, emitter = { value = it }, context = job) { | ||
| 1 | ||
| } | ||
| runCurrent() | ||
|
|
||
| assertThat(value).isEqualTo(1) | ||
| assertThat(job.children.toList()).isEmpty() | ||
| } | ||
| } | ||
|
|
||
| @Test fun errorDelayed() = runTest { | ||
| val job = Job() | ||
| val clock = BroadcastFrameClock() | ||
|
|
@@ -184,7 +204,8 @@ class MoleculeTest { | |
| } | ||
| }.isSameInstanceAs(runtimeException) | ||
|
|
||
| // This exception is processed in `composeInitial` and not `runRecomposeAndApplyChanges`, so the job is still active. | ||
| runCurrent() | ||
| assertThat(job.children.toList()).isEmpty() | ||
| job.cancelAndJoin() | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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 thewhen (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.
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
isActiveand returning early would be idiomatic with how other launch methods behave on cancelled scopes. That is what I would do.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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?
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.
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!