summaryrefslogtreecommitdiff
path: root/platform-paper/src/test
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-08-05 00:42:16 +0900
committerSho Sakuma <me@m1sk9.dev>2026-08-05 00:42:25 +0900
commit8870ca5de9dd091830a6c3b80a5fcc4f8f035360 (patch)
treeae164b30d72ddf3f7a7b17990396080a74406f84 /platform-paper/src/test
parenteb5cb6960b4f914e9c7b819974c91408ad259f9f (diff)
downloadLunaticChat-8870ca5de9dd091830a6c3b80a5fcc4f8f035360.tar.gz
LunaticChat-8870ca5de9dd091830a6c3b80a5fcc4f8f035360.tar.bz2
LunaticChat-8870ca5de9dd091830a6c3b80a5fcc4f8f035360.zip
fix: give every data file the same atomic write
Writing to a fixed sibling only moved the interleaving from the destination to the temporary file: two saves racing there published mixed content, and the losing move then failed with the temporary file already gone. Each write now gets a unique temporary file, and falls back to a non-atomic replace on the network mounts that refuse an atomic rename. settings.yml and the conversion cache were still written in place. Both are discarded wholesale when they do not parse, so a torn file silently costs every player's settings or the whole accumulated cache. Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'platform-paper/src/test')
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt76
1 files changed, 76 insertions, 0 deletions
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt
new file mode 100644
index 0000000..ea0c35f
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt
@@ -0,0 +1,76 @@
+package dev.m1sk9.lunaticChat.paper
+
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.concurrent.ConcurrentLinkedQueue
+import java.util.concurrent.CyclicBarrier
+import kotlin.io.path.exists
+import kotlin.io.path.listDirectoryEntries
+import kotlin.io.path.readText
+import kotlin.io.path.writeText
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class AtomicWriteTest {
+ private fun withTemporaryDirectory(block: (Path) -> Unit) {
+ val directory = Files.createTempDirectory("atomic-write-test")
+ try {
+ block(directory)
+ } finally {
+ directory.toFile().deleteRecursively()
+ }
+ }
+
+ @Test
+ fun `writes the content and leaves no temporary file behind`() =
+ withTemporaryDirectory { directory ->
+ val target = directory.resolve("channels.json")
+
+ target.writeTextAtomically("""{"channels":[]}""")
+
+ assertEquals("""{"channels":[]}""", target.readText())
+ assertEquals(listOf(target), directory.listDirectoryEntries())
+ }
+
+ @Test
+ fun `replaces existing content`() =
+ withTemporaryDirectory { directory ->
+ val target = directory.resolve("settings.yml")
+ target.writeText("version: 1")
+
+ target.writeTextAtomically("version: 2")
+
+ assertEquals("version: 2", target.readText())
+ }
+
+ @Test
+ fun `concurrent writers each publish a whole file rather than colliding`() =
+ withTemporaryDirectory { directory ->
+ val target = directory.resolve("channels.json")
+ val writerCount = 8
+ val contents = (1..writerCount).map { "content-$it".repeat(4_000) }
+ val failures = ConcurrentLinkedQueue<Throwable>()
+ val barrier = CyclicBarrier(writerCount)
+
+ // A shutdown save and a still-pending debounced save can reach the same file at once.
+ // With a shared temporary path they interleave there instead, and the losing move fails
+ // with the temporary file already gone.
+ val writers =
+ contents.map { content ->
+ Thread {
+ barrier.await()
+ runCatching { target.writeTextAtomically(content) }
+ .onFailure { failures.add(it) }
+ }
+ }
+ writers.forEach { it.start() }
+ writers.forEach { it.join() }
+
+ assertTrue(failures.isEmpty(), "writes failed: ${failures.map { it.toString() }}")
+ assertContains(contents, target.readText())
+ assertEquals(listOf(target), directory.listDirectoryEntries())
+ assertTrue(target.exists())
+ }
+}