Resolve FileNotFoundException issue in OkioStorage at startup.
This change introduces a second attempt at reading a file in OkioStorage `readData()`, in case the initial read attempt failed due to a race condition caused by in the file being created by a different process.
Bug: 337870543
Test: Tested locally via aosp/3496411.
Change-Id: I43b3fb565cc76344bc14c7dc3964ff76b2319358
diff --git a/datastore/datastore-core-okio/src/commonMain/kotlin/androidx/datastore/core/okio/OkioStorage.kt b/datastore/datastore-core-okio/src/commonMain/kotlin/androidx/datastore/core/okio/OkioStorage.kt
index b1aea46..381dbe6 100644
--- a/datastore/datastore-core-okio/src/commonMain/kotlin/androidx/datastore/core/okio/OkioStorage.kt
+++ b/datastore/datastore-core-okio/src/commonMain/kotlin/androidx/datastore/core/okio/OkioStorage.kt
@@ -165,9 +165,14 @@
fileSystem.read(file = path) { serializer.readFrom(this) }
} catch (ex: FileNotFoundException) {
if (fileSystem.exists(path)) {
- throw ex
+ // Attempt a second read in case a race condition resulted in the file being created
+ // by a different process. If we can't read again, a FileNotFoundException is
+ // thrown.
+ fileSystem.read(file = path) { serializer.readFrom(this) }
+ } else {
+ // File does not exist, return default value.
+ serializer.defaultValue
}
- serializer.defaultValue
}
}
diff --git a/datastore/datastore-core-okio/src/commonTest/kotlin/androidx/datastore/core/okio/OkioFakeFileSystem.kt b/datastore/datastore-core-okio/src/commonTest/kotlin/androidx/datastore/core/okio/OkioFakeFileSystem.kt
new file mode 100644
index 0000000..44ddae6
--- /dev/null
+++ b/datastore/datastore-core-okio/src/commonTest/kotlin/androidx/datastore/core/okio/OkioFakeFileSystem.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.datastore.core.okio
+
+import kotlin.jvm.JvmName
+import okio.FileHandle
+import okio.FileMetadata
+import okio.FileNotFoundException
+import okio.FileSystem
+import okio.Path
+import okio.Sink
+import okio.Source
+
+/**
+ * Used to test the race condition that may occur during [OkioReadScope.readData] when a file does
+ * not exist during a call to read but is actually being created in different process slightly
+ * later.
+ *
+ * This class wraps a [FileSystem] and overrides the [source] function to throw a
+ * [FileNotFoundException] at the first read attempt, but to return the read value as expected in
+ * the second read attempt.
+ */
+class OkioFakeFileSystem(@get:JvmName("delegate") val delegate: FileSystem) : FileSystem() {
+ private var fileReadAttempt = 0
+
+ override fun appendingSink(file: Path, mustExist: Boolean): Sink {
+ return delegate.appendingSink(file, mustExist)
+ }
+
+ override fun atomicMove(source: Path, target: Path) {
+ delegate.atomicMove(source, target)
+ }
+
+ override fun canonicalize(path: Path): Path {
+ return delegate.canonicalize(path)
+ }
+
+ override fun createDirectory(dir: Path, mustCreate: Boolean) {
+ delegate.createDirectory(dir, mustCreate)
+ }
+
+ override fun createSymlink(source: Path, target: Path) {
+ delegate.createSymlink(source, target)
+ }
+
+ override fun delete(path: Path, mustExist: Boolean) {
+ delegate.delete(path, mustExist)
+ }
+
+ override fun list(dir: Path): List {
+ return delegate.list(dir)
+ }
+
+ override fun listOrNull(dir: Path): List? {
+ return delegate.listOrNull(dir)
+ }
+
+ override fun metadataOrNull(path: Path): FileMetadata? {
+ return delegate.metadataOrNull(path)
+ }
+
+ override fun openReadOnly(file: Path): FileHandle {
+ return delegate.openReadOnly(file)
+ }
+
+ override fun openReadWrite(file: Path, mustCreate: Boolean, mustExist: Boolean): FileHandle {
+ return delegate.openReadWrite(file, mustCreate, mustExist)
+ }
+
+ override fun sink(file: Path, mustCreate: Boolean): Sink {
+ return delegate.sink(file, mustCreate)
+ }
+
+ override fun source(file: Path): Source {
+ // This function is invoked during fileSystem.read(). We change the behaviour to throw
+ // an exception in the first read attempt, and return the correct value in the second to
+ // mimic the race condition.
+ fileReadAttempt++
+ return if (fileReadAttempt <= 1) {
+ // First attempt should throw.
+ throw FileNotFoundException("Intentional failure to mimic race condition.")
+ } else {
+ // Second attempt onwards returns read value as expected, if the file exists. Throws an
+ // [IOException] otherwise.
+ delegate.source(file)
+ }
+ }
+}
diff --git a/datastore/datastore-core-okio/src/commonTest/kotlin/androidx/datastore/core/okio/OkioStorageTest.kt b/datastore/datastore-core-okio/src/commonTest/kotlin/androidx/datastore/core/okio/OkioStorageTest.kt
index ccadc83..a729c1f 100644
--- a/datastore/datastore-core-okio/src/commonTest/kotlin/androidx/datastore/core/okio/OkioStorageTest.kt
+++ b/datastore/datastore-core-okio/src/commonTest/kotlin/androidx/datastore/core/okio/OkioStorageTest.kt
@@ -347,4 +347,18 @@
testConnection.writeData(1)
assertThat(testConnection.readData()).isEqualTo(1)
}
+
+ @Test
+ fun handleConcurrentNewDatastores() =
+ testScope.runTest {
+ val fakeFileSystem = OkioFakeFileSystem(fileSystem)
+ testIO = OkioTestIO(fakeFileSystem)
+ val fakeTestPath = testIO.newTempFile().path
+ val fakeTestStorage = OkioStorage(fakeFileSystem, testingSerializer) { fakeTestPath }
+ val fakeTestConnection = fakeTestStorage.createConnection()
+ fakeTestConnection.writeData(5.toByte())
+
+ // Succeeds without throwing "FileNotFoundException", confirm we read the value written.
+ assertThat(fakeTestConnection.readData()).isEqualTo(5.toByte())
+ }
}
diff --git a/datastore/datastore-core/src/jvmAndroidMain/kotlin/androidx/datastore/core/FileStorage.kt b/datastore/datastore-core/src/jvmAndroidMain/kotlin/androidx/datastore/core/FileStorage.kt
index 249610f..66ef285 100644
--- a/datastore/datastore-core/src/jvmAndroidMain/kotlin/androidx/datastore/core/FileStorage.kt
+++ b/datastore/datastore-core/src/jvmAndroidMain/kotlin/androidx/datastore/core/FileStorage.kt
@@ -165,8 +165,9 @@
// is called. Otherwise file exists but we can't read it; throw
// FileNotFoundException because something is wrong.
FileInputStream(file).use { stream -> serializer.readFrom(stream) }
+ } else {
+ serializer.defaultValue
}
- serializer.defaultValue
}
}
}