SafeSaveAtomicSmokeTest.kt
src/jvmMain/kotlin/afield/document/persistence/SafeSaveAtomicSmokeTest.kt · 166 lines
package afield.document.persistence
import afield.app.protocol.payload
import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.box
import afield.runtime.execution.plus
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.frames
import afield.runtime.lifecycle.loop
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.tools.automation.OperationTranscript
import afield.ui.notation.with
import afield.ui.output.transcript
import afield.audio.synth.*
import afield.data.table.get
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.map
import afield.ui.output.templates.filter
import org.yaml.snakeyaml.Yaml
import java.io.File
import java.nio.file.Files
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicReference
FIELD_TEST id: kotlin.safe-save-atomic.smoke runner: jvm-main entrypoint: afield.document.persistence.SafeSaveAtomicSmokeTest tags: [smoke, saving, transcript] timeoutSec: 120 expect: exitCode: 0 stdoutContains: - "SafeSave atomic smoke passed"
Smoke for File.safeSave's atomic = true path, which is what keeps a file readable while it is
being rewritten. The operation-transcript writer (OperationTranscript.checkpoint and friends)
uses it for operations.yaml: the text goes to a temp file in the same directory and is then
renamed into place, so a concurrent reader — another thread, or another process sharing the
workspace — only ever sees a complete file.
concurrentAtomicWritesNeverTear is the case that exercises that. Six writer threads replace one
file 120 times each, with payloads of very different lengths, while a reader parses it in
a tight loop. A torn or interleaved write splices one payload into another and SnakeYAML reports
expected <block end>; under atomic writes every read parses to a list.
object SafeSaveAtomicSmokeTest
object SafeSaveAtomicSmokeTest {
fun runTests(): Boolean
fun runTests(): Boolean {
val dir = Files.createTempDirectory("safe-save-atomic").toFile()
return try {
println("Running SafeSave atomic smoke...")
atomicWriteRoundTrips(dir)
atomicReplaceBacksUpAndLeavesNoTemp(dir)
concurrentAtomicWritesNeverTear(dir)
println("✅ SafeSave atomic smoke passed")
true
} catch (t: Throwable) {
println("❌ SafeSave atomic smoke failed: ${t.message}")
t.printStackTrace()
false
} finally {
dir.deleteRecursively()
}
}
fun main(args: Array<String>)
@JvmStatic
fun main(args: Array<String>) {
check(runTests()) { "SafeSave atomic smoke failed" }
}
private fun require(cond: Boolean, lazyMessage: () -> String) {
if (!cond) throw AssertionError(lazyMessage())
}
private fun tempFiles(dir: File): List<File> =
dir.listFiles()?.filter { it.isFile && it.name.endsWith(".tmp") } ?: emptyList()
An atomic write lands the exact content and leaves no temp file behind.
private fun atomicWriteRoundTrips(dir: File) {
val file = File(dir, "round-trip.yaml")
val yaml = "- move:\n box: \"db367ae3-471e-4af6-93c9-07c795413735\"\n to: { x: 1.0 }\n"
require(file.safeSave(yaml, createBackup = false, atomic = true)) { "first write should report a change" }
require(file.readText() == yaml) { "content must round-trip exactly" }
require(Yaml().load<Any?>(file.readText()) is List<*>) { "written YAML must parse to a list" }
require(tempFiles(dir).isEmpty()) { "no .tmp file should be left behind: ${tempFiles(dir)}" }
}
Replacing existing content backs up the old file and still leaves no temp behind.
private fun atomicReplaceBacksUpAndLeavesNoTemp(dir: File) {
val file = File(dir, "replace.yaml")
val v1 = "- wait:\n frames: 1\n"
val v2 = "- wait:\n frames: 2\n"
file.safeSave(v1, createBackup = true, maxBackups = 10, atomic = true)
file.safeSave(v2, createBackup = true, maxBackups = 10, atomic = true)
require(file.readText() == v2) { "file must hold the latest content" }
val backups = File(dir, "backups").listFiles()?.filter { it.isFile } ?: emptyList()
require(backups.any { it.readText() == v1 }) { "the previous version must be backed up: $backups" }
require(tempFiles(dir).isEmpty()) { "no .tmp file should be left behind: ${tempFiles(dir)}" }
}
THE case: concurrent atomic writers must never leave the file in an unparseable state.
source — 51 lines, from line 115
private fun concurrentAtomicWritesNeverTear(dir: File) {
val file = File(dir, "concurrent.yaml")
// Distinct-but-valid payloads of very different lengths — a torn/interleaved write would
// splice one into another and fail to parse (the exact bug being guarded).
val payloads = (0 until 6).map { i ->
buildString {
repeat(20 + i * 40) { n -> append("- move:\n box: \"box-$i-$n\"\n to: { x: ${i}.$n }\n") }
}
}
file.safeSave(payloads[0], createBackup = false, atomic = true)
val writers = 6
val itersPerWriter = 120
val readerErr = AtomicReference<Throwable?>(null)
val stop = java.util.concurrent.atomic.AtomicBoolean(false)
val start = CountDownLatch(1)
val reader = Thread {
start.await()
val yaml = Yaml()
while (!stop.get()) {
try {
val parsed = yaml.load<Any?>(file.readText())
if (parsed !is List<*>) error("parsed to non-list: ${parsed?.javaClass}")
} catch (t: Throwable) {
readerErr.compareAndSet(null, t); return@Thread
}
}
}
reader.start()
val writerThreads = (0 until writers).map { w ->
Thread {
start.await()
repeat(itersPerWriter) { file.safeSave(payloads[(w + it) % payloads.size], createBackup = false, atomic = true) }
}
}
writerThreads.forEach { it.start() }
start.countDown()
writerThreads.forEach { it.join() }
stop.set(true)
reader.join()
require(readerErr.get() == null) {
"a concurrent read observed a torn/unparseable file — atomic write regressed: ${readerErr.get()}"
}
require(Yaml().load<Any?>(file.readText()) in payloads.map { Yaml().load<Any?>(it) }) {
"final file must be one complete payload, not a splice"
}
}
}