Explorar el Código

fix(R4.2): 排版配置写盘改原子写,消除杀进程丢配置的窗口

`ReadStyleRepository.save()` 原本是「先 FileUtils.delete,再
createFileIfNotExist().writeText()」——两步之间目标文件根本不存在,进程在此时
被杀,用户整套排版方案就没了;写到一半被杀则留下截断的 JSON。

新增 `FileUtils.writeTextAtomic()`:写同目录临时文件后 rename 覆盖。rename 在
同一文件系统上是原子的,目标要么是完整的旧内容、要么是完整的新内容。rename
失败(个别文件系统不覆盖已存在目标)时退回原地写。

**不做 fsync**,因此只保证进程被杀不丢,不保证掉电不丢——审计 R4.2 的验收条件
是「杀进程压力下配置零丢失」,fsync 的代价与收益留待有掉电诉求时再评估。

顺带解决两条保存通道(`ReadBookConfig.save()` 的 Coroutine.async + synchronized,
与 `ReadBookStyleConfigRepository` 的 ReadStyleSaveQueue)并发落到同一文件时
互相看到半个文件的问题。两条通道本身没有并一,那是更大的归属重构。

测试 4 条,其中「写入中途失败时目标保持旧内容」注入回归反证过会红
(把实现换回先删后写即失败)。全量单测 434 → 438。

已知同型未改(不在本项范围,各自独立):`ThemeConfigStore.save` 与
`Restore` 恢复配置文件的三处,都是同样的 delete + write/copy。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 hace 3 meses
padre
commit
67db72399f

+ 4 - 8
app/src/main/java/io/legado/app/data/repository/ReadStyleRepository.kt

@@ -58,14 +58,10 @@ class ReadStyleRepository(
         configs: List<ReadBookConfig.Config>,
         shareConfig: ReadBookConfig.Config
     ) {
-        GSON.toJson(configs).let {
-            FileUtils.delete(configFilePath)
-            FileUtils.createFileIfNotExist(configFilePath).writeText(it)
-        }
-        GSON.toJson(shareConfig).let {
-            FileUtils.delete(shareConfigFilePath)
-            FileUtils.createFileIfNotExist(shareConfigFilePath).writeText(it)
-        }
+        // 原子写:两条保存通道(ReadBookConfig.save 与 ReadStyleSaveQueue)会并发落到这里,
+        // 「删掉再写」既有杀进程丢配置的窗口,也会让两者互相看到对方写了一半的文件。
+        FileUtils.writeTextAtomic(configFilePath, GSON.toJson(configs))
+        FileUtils.writeTextAtomic(shareConfigFilePath, GSON.toJson(shareConfig))
     }
 
     fun getAllPicBgStr(configs: List<ReadBookConfig.Config>): ArrayList<String> {

+ 20 - 0
app/src/main/java/io/legado/app/utils/FileUtils.kt

@@ -13,6 +13,26 @@ import java.util.regex.Pattern
 @Suppress("unused", "MemberVisibilityCanBePrivate")
 object FileUtils {
 
+    /**
+     * 原子写入文本:先写同目录下的临时文件,再 rename 覆盖目标。
+     *
+     * 直接「删掉再写」有个进程被杀就丢数据的窗口——两步之间目标文件根本不存在,
+     * 写到一半被杀则留下截断的内容。rename 在同一文件系统上是原子的,
+     * 目标要么是完整的旧内容、要么是完整的新内容。
+     *
+     * 注意:不做 fsync,因此只保证**进程被杀**不丢,不保证掉电不丢。
+     */
+    fun writeTextAtomic(filePath: String, text: String) {
+        val target = File(filePath)
+        val temp = File("$filePath.tmp")
+        createFileIfNotExist(temp.absolutePath).writeText(text)
+        if (!temp.renameTo(target)) {
+            // 个别文件系统的 rename 不覆盖已存在的目标,退回原地写
+            createFileIfNotExist(filePath).writeText(text)
+            temp.delete()
+        }
+    }
+
     fun createFileIfNotExist(root: File, vararg subDirFiles: String): File {
         val filePath = getPath(root, *subDirFiles)
         return createFileIfNotExist(filePath)

+ 65 - 0
app/src/test/java/io/legado/app/utils/FileUtilsAtomicWriteTest.kt

@@ -0,0 +1,65 @@
+package io.legado.app.utils
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+
+/**
+ * R4.2 —— 排版配置的写盘必须原子。
+ *
+ * 原实现是「先 `FileUtils.delete`,再 `createFileIfNotExist().writeText()`」:两步之间目标
+ * 文件根本不存在,进程在此时被杀,用户的整套排版方案就没了。两条保存通道
+ * (`ReadBookConfig.save()` 与 `ReadStyleSaveQueue`)还会并发落到同一个文件上。
+ */
+class FileUtilsAtomicWriteTest {
+
+    @get:Rule
+    val folder = TemporaryFolder()
+
+    @Test
+    fun `写入新文件`() {
+        val path = File(folder.root, "readConfig.json").absolutePath
+
+        FileUtils.writeTextAtomic(path, "[1,2,3]")
+
+        assertEquals("[1,2,3]", File(path).readText())
+    }
+
+    @Test
+    fun `覆盖已有文件`() {
+        val target = folder.newFile("readConfig.json")
+        target.writeText("旧内容")
+
+        FileUtils.writeTextAtomic(target.absolutePath, "新内容")
+
+        assertEquals("新内容", target.readText())
+    }
+
+    @Test
+    fun `不留下临时文件`() {
+        val path = File(folder.root, "readConfig.json").absolutePath
+
+        FileUtils.writeTextAtomic(path, "内容")
+
+        assertFalse("临时文件应已被 rename 掉", File("$path.tmp").exists())
+    }
+
+    @Test
+    fun `写入中途失败时目标文件保持旧内容`() {
+        val target = folder.newFile("readConfig.json")
+        target.writeText("旧内容")
+        // 把临时文件路径预先占成目录,逼 writeText 抛异常,模拟写到一半没写成
+        File("${target.absolutePath}.tmp").mkdirs()
+
+        runCatching { FileUtils.writeTextAtomic(target.absolutePath, "新内容") }
+
+        assertEquals(
+            "写失败后目标必须还是完整的旧内容——先删后写会在这里丢掉整套排版配置",
+            "旧内容",
+            target.readText(),
+        )
+    }
+}