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:
- React instance A → module A → DataStore created with
scopeA, cached in INSTANCE
- A torn down →
invalidate() → scopeA cancelled
- 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
Description
On 10.0.0,
DataStorePrefsStoragecreates a new JetpackDataStorefor everyKeychainModuleinstance, so a second React instance in the same process trips DataStore's active-file guard: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
DataStorePrefsStoragedeclares the store as an instance property:preferencesDataStore(...)returns a delegate that memoises its own instance. Declared per class instance, eachDataStorePrefsStoragetherefore builds a separateDataStoreover the same file.KeychainModule.initconstructs one per module, so the number of active DataStores equals the number of liveKeychainModuleinstances.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:
restartApp()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
DataStoreexists 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 aroundgetGenericPasswordwon'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
INSTANCEcaptures the first module'scoroutineScope— andKeychainModule.invalidate()cancels that scope unconditionally:So:
scopeA, cached inINSTANCEinvalidate()→scopeAcancelledINSTANCE, backed by a cancelled scopeThat converts an intermittent
IllegalStateExceptioninto 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 ininvalidate(). The per-modulecoroutineScopecan stay exactly as it is for cipher work —callSuspendableusesrunBlocking(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:
produceMigrations/SharedPreferencesMigration(context, KEYCHAIN_DATA)wired, or users who predate the DataStore migration are orphaned.filesDir.resolve("datastore/$KEYCHAIN_DATA.preferences_pb")must keep resolving to exactly whatpreferencesDataStore(name = ...)produced. It matches the path in the reported stack traces, but a one-segment drift would silently orphan every stored credential.Environment
Related