Skip to content

Replace unstructured coroutine scopes with applicationScope and lifecycle scopes - #4517

Open
rvandermeulen wants to merge 6 commits into
mozilla-mobile:masterfrom
rvandermeulen:globalscope-cleanup
Open

Replace unstructured coroutine scopes with applicationScope and lifecycle scopes#4517
rvandermeulen wants to merge 6 commits into
mozilla-mobile:masterfrom
rvandermeulen:globalscope-cleanup

Conversation

@rvandermeulen

Copy link
Copy Markdown
Contributor

Follow-up to the applicationScope introduced in #4515. That change added the scope to satisfy Bug 1990613's new android-components constructor parameters; this branch finishes the job by removing the remaining unstructured coroutine usage in the app.

Six commits, each independently buildable:

  1. Use applicationScope instead of GlobalScope. The two remaining GlobalScope.launch sites in BrowserApplication plus CrashIntegration.sendCrashReport, and a stale @OptIn in BrowserFragment.
  2. Use applicationScope for application-lived work. Seven inline CoroutineScope(...)/MainScope() launches across BackgroundServices, Services, PushFxaIntegration and WebPushEngineIntegration. Same defect as GlobalScope, spelled differently.
  3. Bind UI work to lifecycle scopes. Six sites in AccountSettingsFragment. The three SyncStatusObserver callbacks use the fragment lifecycleScope rather than viewLifecycleOwner because they arrive on the FxaAccountManager thread, where getViewLifecycleOwner is unsafe; the commit message has the full trace. IntentReceiverActivity's coroutine is removed outright since IntentProcessor.process is not suspend.
  4. Cancel retained scopes on teardown. ToolbarIntegration held a BrowserStore collector launched from init and never cancelled, retaining the fragment view via the application-lived store. AddonsFragment and InstalledAddonDetailsActivity move to lifecycle scopes.
  5. Handle add-on load failures. Pre-existing: InstalledAddonDetailsActivity threw inside a nested coroutine its own catch could not see. Both add-on catch blocks now also log the exception.
  6. Load the add-on list once per start. Pre-existing double getAddons() on first display.

Behavior note for reviewers: everything moved onto applicationScope now routes uncaught exceptions to its CoroutineExceptionHandler (log) instead of crashing. This matches Fenix, including for crash report submission.

Verified locally after clean: compileDebugKotlin, ktfmtCheck, detekt, lintDebug, buildHealth, assembleDebug, assembleAndroidTest.

Bug 1990613 introduced an application-provided CoroutineScope in
android-components, and BrowserApplication gained an applicationScope to
satisfy it. Switch the remaining GlobalScope.launch call sites over to it,
which also drops the @OptIn(DelicateCoroutinesApi::class) annotations they
required:

  - BrowserApplication, two sites. The explicit Dispatchers.Main in
    restoreBrowserState is redundant because applicationScope already
    carries it.
  - CrashIntegration.sendCrashReport, which reaches the scope through
    context.components. Crash reports should outlive the observer's
    lifecycle, so the application scope is the appropriate one.

BrowserFragment.deleteHistorySuggestion carried the same @OptIn but uses
lifecycleScope, so that annotation was already dead and is simply removed.

Removing the @OptIn changes sendCrashReport's detekt baseline ID, so its
UndocumentedPublicFunction entry is updated to the new signature.
These call sites created a throwaway CoroutineScope or MainScope, launched
into it and dropped the reference, leaving the job orphaned with no way to
cancel it. That is the same defect as the GlobalScope usage, just spelled
differently.

All of the work involved is push and account handling that has to survive UI
teardown, so the application scope is the correct one rather than any
lifecycle scope. Services and both push integrations get it threaded in;
BackgroundServices already had it as a constructor parameter.
AccountSettingsFragment launched six coroutines on inline
CoroutineScope(Dispatchers.Main) instances that were never cancelled, while
touching findPreference, getString and requireContext from inside them.

The three driven by click and preference-change listeners now use
viewLifecycleOwner.lifecycleScope, matching the call already present in this
file. The three SyncStatusObserver callbacks use the fragment's own
lifecycleScope instead, because they do not arrive on the main thread:
FxaAccountManager defaults to a single-threaded background executor, and
ObserverRegistry.notifyObservers invokes observers directly on the calling
thread. Fragment.getViewLifecycleOwner is not safe to call from there and
throws once the view is gone, whereas Lifecycle.coroutineScope is safe. In
both cases Dispatchers.Main stays explicit to preserve the previous
non-immediate dispatch.

IntentReceiverActivity needed no coroutine at all: IntentProcessor.process is
not a suspend function, so the MainScope().launch only served to defer the
work by one main-loop pass while leaking an uncancellable job. Inline it.
Three classes held a long-lived CoroutineScope that was never cancelled.

ToolbarIntegration launched a BrowserStore collector from init and left it
running. Because the store is application-lived, that subscription retained
the feature, and with it the fragment's view and context, well past view
destruction. Move the collector into start() and cancel it in stop() so it
follows the LifecycleAwareFeature contract. Cancelling the scope in stop()
alone would not have worked, since nothing would restart the collector.

InstalledAddonDetailsActivity and AddonsFragment each hand-rolled a
CoroutineScope(Dispatchers.IO) with no cancellation. Both already have a
lifecycle to hang off, so use lifecycleScope and viewLifecycleOwner's
lifecycleScope respectively, keeping the IO dispatcher where the work is
off the main thread. AddonsFragment uses the view lifecycle because its
main-thread continuations touch the RecyclerView.
bindAddon in InstalledAddonDetailsActivity threw AddonManagerException from
inside a nested lifecycleScope.launch(Dispatchers.Main), a separate coroutine
from the try meant to handle it. The catch never saw it, so an add-on missing
from the manager's list crashed instead of showing the failure toast. Collapse
the nested launches into a single main-dispatched coroutine with withContext
around the blocking getAddons call, which puts the throw and the catch in the
same coroutine.

Both this catch and the one in AddonsFragment also dropped the exception on
the floor. Log it so a provider or storage failure leaves a trace beyond the
generic toast.
AddonsFragment called bindRecyclerView from both onViewCreated and onStart.
Since onStart always follows onViewCreated, every first display issued two
concurrent getAddons calls and built two adapters racing to set
recyclerView.adapter. Keep the onStart call, which also serves as the refresh
after returning from the details activity, and drop the redundant one.
@mergify

mergify Bot commented Sep 1, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@rvandermeulen rvandermeulen added the needs landing Auto lands approved and green PRs. label Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs landing Auto lands approved and green PRs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant