Skip to content

[Android] DataStore created per KeychainModule instance — IllegalStateException on a second React instance (plus a scope-lifetime issue in #793) #801

Description

@Prajwaltechversant

Description

On 10.0.0, DataStorePrefsStorage creates a new Jetpack DataStore for every KeychainModule instance, so a second React instance in the same process trips DataStore's active-file guard:

java.lang.IllegalStateException: There are multiple DataStores active for the same file:
/data/user/0/<app>/files/datastore/RN_KEYCHAIN.preferences_pb.
You should either maintain your DataStore as a singleton or confirm that there is no two
DataStore's active on the same file (by confirming that the scope is cancelled).

This overlaps #784 and #777. I'm opening a separate issue because I have two things those threads don't cover: evidence on what actually triggers it (and why the call-site workarounds suggested there can't work), and a defect in the proposed fix in #793 that I think should block merging it as-is. Happy for a maintainer to close this as a duplicate and fold the content into either thread.

Root cause

DataStorePrefsStorage declares the store as an instance property:

class DataStorePrefsStorage(
  reactContext: ReactApplicationContext,
  private val coroutineScope: CoroutineScope,
) : PrefsStorageBase {
  private val Context.prefs: DataStore<Preferences> by preferencesDataStore(
    name = KEYCHAIN_DATA,
    produceMigrations = ::sharedPreferencesMigration,
    scope = coroutineScope,
  )
  private val prefs: DataStore<Preferences> = reactContext.prefs

preferencesDataStore(...) returns a delegate that memoises its own instance. Declared per class instance, each DataStorePrefsStorage therefore builds a separate DataStore over the same file. KeychainModule.init constructs one per module, so the number of active DataStores equals the number of live KeychainModule instances.

What triggers it

A second React instance in the process — not a second call. Anything that creates one while the first is still alive, or before its scope has finished cancelling:

Why it's process-wide (and why call-site workarounds don't help)

We observed two unrelated keychain call sites failing with this exception ~2 seconds apart in the same session — one during startup, one on a different screen. Once a second DataStore exists for the file, every subsequent keychain read and write in that process fails.

That matters for the mitigation suggested in #784: an async-mutex / call-once guard around getGenericPassword won't help, because the failure isn't caused by concurrent access. It's caused by module instantiation, which happens before any call is made.

Worth flagging the severity for apps that treat a failed keychain read as "credentials are gone": the natural handling is to sign the user out, so this tends to surface as spurious forced logouts at cold launch rather than as a visible crash.

Note on the proposed fix (#793)

#793 makes the DataStore a companion-object singleton, which is the right direction, but as written the cached INSTANCE captures the first module's coroutineScope — and KeychainModule.invalidate() cancels that scope unconditionally:

override fun invalidate() {
  super.invalidate()
  if (coroutineScope.isActive) {
    coroutineScope.cancel("$KEYCHAIN_MODULE has been destroyed.")
  }
  ...
}

So:

  1. React instance A → module A → DataStore created with scopeA, cached in INSTANCE
  2. A torn down → invalidate()scopeA cancelled
  3. React instance B → module B → receives the cached INSTANCE, backed by a cancelled scope

That converts an intermittent IllegalStateException into a permanent keychain failure for the remaining life of the process — worse than the bug, especially given the forced-logout handling above.

Suggested shape: give the singleton its own application-lifetime scope in the same companion object (CoroutineScope(Dispatchers.IO + SupervisorJob())), never the per-module scope, and never cancel it in invalidate(). The per-module coroutineScope can stay exactly as it is for cipher work — callSuspendable uses runBlocking(coroutineScope.coroutineContext) and each module gets a fresh one.

Two further constraints worth making explicit in that patch, since both fail silently rather than loudly:

  • Keep produceMigrations / SharedPreferencesMigration(context, KEYCHAIN_DATA) wired, or users who predate the DataStore migration are orphaned.
  • The hand-rolled filesDir.resolve("datastore/$KEYCHAIN_DATA.preferences_pb") must keep resolving to exactly what preferencesDataStore(name = ...) produced. It matches the path in the reported stack traces, but a one-segment drift would silently orphan every stored credential.

Environment

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions