Skip to content

Stop the scope editor stalling on a cold app list - #927

Merged
JingMatrix merged 2 commits into
masterfrom
scope-editor-cold-list
Aug 20, 2026
Merged

Stop the scope editor stalling on a cold app list#927
JingMatrix merged 2 commits into
masterfrom
scope-editor-cold-list

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Reading the installed applications is not the cheap lookup its call site looks like: it goes out as getInstalledPackagesFromAllUsers with filterNoProcess set, and the daemon answers by asking the package manager for the full component list of every installed package. AppRepository caches it for that reason, but had no way to say a read was already running, so two callers arriving together both went to the daemon. The scope editor is two such callers by construction — load reads the list, and the module-package set it filters by reads it again. Cold is the common case: every install, update and uninstall drops the cache, a package event arrives twice, and the Modules panel's own scan does not refill it.

Behind that, ScopeUiState starts with loading set and the state built on the last line of load was the only other thing that wrote it, so any exit skipping that line left the spinner up for the life of the view model.

Fixes #917

Asking the daemon for the installed applications is not the cheap lookup its call site reads as. It
goes out as getInstalledPackagesFromAllUsers with filterNoProcess set, and the daemon answers that by
querying the package manager for the full component list of every package on the device -- activities,
services, receivers and providers, several hundred times over, with a four-call fallback each time a
binder buffer overflows on the way back. AppRepository caches the answer for exactly that reason, and
the splash prefetch pays for it once where nobody is waiting.

What the cache did not have was a way to say that a read was already running. It checked a volatile
field and, finding it empty, went to the daemon; two callers arriving together both found it empty and
both went. The scope editor is two such callers by construction -- its load reads the list, and the
module-package set it filters by reads it again -- so on a cold cache it ran two of those enumerations
against each other, one racing the other for the same threads, while the screen it was opening waited
on the first. Cold is not the rare case either: every install, update and uninstall drops the cache,
and a package event arrives twice, once from the platform and once from the daemon's re-broadcast.

So the fetch becomes a job the repository holds rather than work each caller starts. A caller that
finds one running joins it, and the mutex guarding that field is never held across the fetch itself,
only across the decision. The job runs on the application scope, not the caller's: leaving a screen
part-way through a read now leaves the answer behind for the next visit instead of throwing away the
several hundred queries it had already paid for. A finished job is retired by the next reader rather
than by itself, which is what lets a failed read be retried instead of joined for the life of the
process -- a successful one has filled the cache and is never consulted again.

A generation counter goes in beside it. A read already in flight when a package event lands has by
definition missed what that event carried, and the old code cached its answer regardless, holding a
list known to be wrong until the next event -- on a device where nothing else changes, forever. The
counter is sampled when a read starts and again before it publishes: the caller that asked still gets
the answer, because it is the best that read can offer, but nobody else inherits it.

forceRefresh goes. It had no callers, and sharing a running job leaves nothing for it to mean.
ScopeUiState starts with loading set, and the state built on the last line of load was the only other
thing that ever wrote it. Every exit that skipped that line therefore left the flag standing, and the
screen is drawn entirely behind it: a throw from one of the reads, or the reader leaving while they
were still running, and the spinner was there for as long as the view model was. There is no second
load to correct it -- load runs once, from init.

A try around the body and the flag cleared in the finally, only when it is still set, so the success
path stays the single writer of the state it builds. What a failure now shows is the empty list, which
is wrong but says so, rather than a wait that never ends.

The one read in that body that could throw is the manifest inspection, which opens the module's APK.
The package info fetched immediately above it is already guarded for the same hazard -- a package
removed or replaced between one call and the next -- and this one was not, so an APK that went away
mid-load took the whole manager down from a viewModelScope coroutine. It recovers the way its
neighbour does, as no recommended scope, which costs the reader a few rows that would have arrived
pre-ticked.
@JingMatrix JingMatrix mentioned this pull request Aug 20, 2026
1 task
@JingMatrix
JingMatrix merged commit 0ee0f53 into master Aug 20, 2026
1 check passed
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.

Module settings don't load

1 participant