question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

[Proposal] New "reusable" scope

See original GitHub issue

Use case (the problem)

  1. Some components, e.g. Android Fragments can be reused after it’s “destruction”. Unlike Activities that after onDestroy() become really dead and garbage collected, fragments have a different lifecycle and can stay physically alive after onDestroy()/onDetach(), for example cached for future use.
class MyFragment : Fragment(), CoroutineScope by MainScope() {
    // ...
    override fun onDestroy() {
        super.onDestroy()
        cancelCoroutineScope()
        // ...
    }
}

So, when we get an instance of such fragment that survived after destruction, we can no longer launch any coroutine in because the parent job is already canceled.


  1. Those who use MVP pattern in their apps, often inject Presenters as singletons that live as long as the entire app lives.
class MyPresenter<V> {
    // ...
    fun takeView(v: V) { /*...*/ }
    fun dropView(v: V) { /*...*/ }
}

Naturally, presenters can be considered as coroutine scopes too.

class MyPresenter<MyView>: CoroutineScope by MainScope() {
    //...
    fun takeView(v: V) { /*...*/ } // enter scope
    fun dropView(v: V) { cancelCoroutineScope() } // exit scope
}

But in practice, a presenter can be used with different views, and its scope is likely determined by the lifecycle of the view attached to it - a lifetime in the interval between the presenter’s takeView() and dropView(). Presenter drops view when a user rotates device or starts another activity etc. At the same time, the presenter should stop all running tasks. If it cancels coroutine scope once, we become unable to launch coroutines again.


  1. The same goes for ViewModel, and I’m sure you can come up with your own examples.

Workarounds

  1. Do not implement CoroutineScope itself. One can create the coroutine scope inside a fragment/presenter and manage it explicitly by hands.
class MyPresenter<MyView> {
    private var myScope: CoroutineScope? = null
    fun takeView(v: V) { buildCoroutineScope() } // enter scope
    fun dropView(v: V) { cancelCoroutineScope() } // exit scope
}

This might be error prone and requires writing some boilerplate code. But we all know that less code == less bugs.


  1. Do not cancel the parent job, but it’s children.
class MyPresenter<MyView>: CoroutineScope by MainScope() {
    //...
    fun takeView(v: V) { /*...*/ } // enter scope
    fun dropView(v: V) { cancelChildrenJobs() } // exit scope
}

The use of coroutineScope[Job]?.cancelChildren() instead of coroutineScope[Job]?.cancel() partially solves the issue: we get all jobs canceled except the parent.

Solution

Introduce new ReusableCoroutineScope.

internal class ReusableContextScope(
    context: CoroutineContext,
    private val newJob: () -> Job
) : CoroutineScope {
    private var reusableContext: CoroutineContext = context

    override val coroutineContext: CoroutineContext
        get() {
            if (reusableContext[Job]?.isCancelled == true) {
                reusableContext += newJob()
            }
            return reusableContext
        }
}

@Suppress("FunctionName")
fun ReusableCoroutineScope(
    context: CoroutineContext,
    newJob: () -> Job = { SupervisorJob() }
): CoroutineScope =
    ReusableContextScope(context, newJob)

One defines a newJob() function that’s going to be used as a generator of new jobs in cases when the coroutineContext’s job became canceled. Also, inspired by #829, we can also add an alternative ReusableMainScope() factory function that will create a scope with Dispatchers.Main and re-inject new SupervisorJob to the context.

@Suppress("FunctionName")
fun ReusableMainScope(): CoroutineScope =
    ReusableContextScope(Dispatchers.Main) { SupervisorJob() }

Benefits

  1. With this reusable scope, we can have a shiny nice short record for sensitive components.
class MyFragment : Fragment(), CoroutineScope by ReusableMainScope() {
    // ...
    override fun onDestroy() {
        super.onDestroy()
        cancelCoroutineScope()
    }
}
  1. We don’t care about managing jobs
  2. We have safe code
  3. Our coroutines still don’t leak
  4. If we need to reuse some canceled/destroyed component, we just take and use it like if it were a fresh new object.

@elizarov, @qwwdfsad, what do you think?

Issue Analytics

  • State:closed
  • Created 5 years ago
  • Reactions:6
  • Comments:11 (10 by maintainers)

github_iconTop GitHub Comments

1reaction
qwwdfsadcommented, Dec 12, 2018

I don’t think that reusable scope is a good fit for kotlinx.coroutines library:

  1. Reusability is always a source of heisenbugs which occur in random places due to concurrent nature of the reusability. And it is not something that can be fully validated from within a reusable object itself. E.g. even the proposed PR contains multiple concurrency-related bugs.
  2. “Performant” primitives are always acting like a flag “use me”, especially when they come from the core library and we definitely don’t want to encourage its usage.
  3. Scope mutation is not a practice we’d recommend to use (it is trickier than it seems)
  4. The proposed solution is a very project/architecture-specific and requires a lot of changes to have a clear semantics and invariants (e.g. now cancel cancels all children, then restart mutates context and cancelling children from a previous use can observe it).
  5. An architecture-specific solution can be implemented outside of the library as it does not use any internal API.

This change is not worth it and carries more risks than value.

1reaction
fvascocommented, Dec 7, 2018

Hi @DmitriyZaitsev I try to response to your requests

I’m a bit confused here. Whose this isActive?

It is CoroutineScope::isActive, if myFragment isCoroutineScope so both myFragment.cancelCoroutineScope() and myFragment.isActive are valid.

The problem may be that in the initial proposal we can’t distinguish reusable scope from non-reusable

I consider this a secondary problem.

Yes, in this case the job will be recreated, but how bad is this if it’s empty, very cheap and does nothing?

This can be tricky to understand, especially if you are looking for a bug.

if the job inside was recreated at least once, we should know about it

Why this is useful?

since we provide a factory function for the job, we should be able to invoke it

Is the close method enough?

Finally I consider preferable the “workarounds” than your “solution”.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Proposal for an Innovative Disposable Endoscope in ...
Disposable endoscopes are a new product with significant market ... surgeons, who have experience with reusable and disposable endoscopes.
Read more >
Single-use flexible ureteroscopes: update and perspective in ...
The first single-use flexible ureteroscope was launched in 2011 and new models ... When handled by a single surgeon, a reusable scope can...
Read more >
New proposals to make sustainable products the norm
It sets new requirements to make products more durable, reliable, reusable, upgradable, reparable, easier to maintain, refurbish and recycle, ...
Read more >
PepsiCo to double use of reusable packaging to 20% by 2030
PepsiCo plans to double the percentage of all beverage servings it sells delivered through reusable models from a current 10% to 20% by...
Read more >
Single-Use vs. Reusable Digital Flexible Ureteroscope to ...
Methods: Four hundred forty patients with reusable digital flexible ... Classification of surgical complications: a new proposal with evaluation in a cohort ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found