DocumentShutdownFlush.kt
src/jvmMain/kotlin/afield/document/persistence/DocumentShutdownFlush.kt · 163 lines
package afield.document.persistence
import afield.app.diagnostics.logging.lprintln
import afield.app.server.InterruptSignal
import afield.app.server.Server
import afield.data.table.div
import afield.data.table.get
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.document.workspace.metadata
import afield.runtime.execution.plus
import afield.runtime.lifecycle.loop
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.ui.notation.with
import afield.ui.output.md
import afield.app.server.documents
import afield.app.server.SessionInformation
import afield.audio.synth.*
import afield.document.properties.PropertyDependencies
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import java.util.concurrent.TimeUnit
Saves pending document edits when Field shuts down, so a Ctrl-C does not lose the last few seconds of work.
object DocumentShutdownFlush
Saves, on the way out, the document edits the autosave loop has not reached yet.
The autosave loop in Server.server() visits documents every ~5 s, so an edit made in the last
few seconds before Field stops was simply lost — on SIGTERM always, and on SIGINT once Field
started honouring it (see InterruptSignal). Measured on 2026-09-29: a box created and its code
set through the backdoor, then SIGINT as soon as the set was acknowledged, left the document
folder with no metadata.edn and no sidecar in 3 of 3 trials; with the SIGINT 12 s later it was
all there. dev_docs/SIGINT_SHUTDOWN.md has the numbers.
This is the same save the loop would have made — same predicate (writesToDisk, not loading,
something pending), same box set — run inline on a shutdown-hook thread, because the misc pool
the loop normally hands the write to is being shut down by its own hook at the same moment.
Bounded: a save that wedges (a stuck lock, a slow disk) must not turn a Ctrl-C back into a process that will not die, so the flush gets BUDGET_MS and the JVM exits regardless. A second SIGINT halts at once in any case.
object DocumentShutdownFlush {
const val BUDGET_MS = 4_000L
@Volatile
private var installed = false
fun install()
fun install() {
if (installed) return
installed = true
runCatching {
Runtime.getRuntime().addShutdownHook(Thread({ flushWithinBudget() }, "afield-document-flush"))
}
}
private fun flushWithinBudget() {
val worker = Thread({ flushPendingDocumentSaves() }, "afield-document-flush-worker").apply { isDaemon = true }
worker.start()
worker.join(BUDGET_MS)
if (worker.isAlive) System.err.println("afield: document flush still running after ${BUDGET_MS} ms; exiting anyway")
}
fun flushPendingDocumentSaves(): Int
Saves every document with pending changes, on this thread. Returns how many it saved.
fun flushPendingDocumentSaves(): Int {
var saved = 0
documents.forEach { (_, info) ->
runCatching { PropertyDependencies.flush(info, force = true) }
if (saveIfPending(info, inline = true, reason = "shutdown")) saved++
}
return saved
}
fun saveIfPending(info: SessionInformation, inline: Boolean, reason: String, log: Boolean = true): Boolean
The autosave loop's save of info, if it needs one: writesToDisk, not loading, and either
something marked pending or a PersistedFingerprint that differs from the last one matching
disk. inline runs the write on this thread; otherwise it goes to the misc pool. log prints
a line on stderr (the shutdown and detach saves do; the 5 s loop does not). Returns whether a
save was made.
fun saveIfPending(info: SessionInformation, inline: Boolean, reason: String, log: Boolean = true): Boolean {
if (!info.writesToDisk || info.loading) return false
// A fingerprint that cannot be taken (a store mutated under us on the shutdown thread) counts as changed.
val entries = runCatching { PersistedFingerprint.entries(info) }.getOrNull()
val fingerprint = entries?.values?.sum()
val unmarked = info.savedFingerprint != null && fingerprint != info.savedFingerprint
if (info.pendingSidecarSaves.isEmpty() && !unmarked) return false
if (info.savedFingerprint != null && fingerprint == info.savedFingerprint && marksAreNoOps(info)) {
// N1020-7: every marked edit put back a value disk already holds (a frame re-sent
// unchanged, a rename to the same name); rewriting metadata.edn and a backup is churn.
info.clearSavedSidecars(info.pendingSidecarSnapshot())
noOpSkips.incrementAndGet()
return false
}
reportUnmarked(info, entries, reason)
if (unmarked && log) System.err.println("afield: ${info.filename} changed without a marked edit; saving on $reason")
val started = System.nanoTime()
return try {
val allBoxes = info.boxes.values.toMutableSet()
allBoxes.add(info.root)
val pendingSnapshot = info.pendingSidecarSnapshot()
// The snapshot below is taken on this thread before save returns, so this is what it writes.
PersistedFingerprint.record(info, entries)
SavingContext().save(info.filename, allBoxes, inline = inline, onComplete = {
info.clearSavedSidecars(pendingSnapshot)
})
if (log) System.err.println(
"afield: saved ${info.filename} on $reason (${TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)} ms)"
)
true
} catch (t: Throwable) {
PersistedFingerprint.record(info, null)
if (log) System.err.println("afield: could not save ${info.filename} on $reason: $t")
else lprintln("error,server", "trouble saving ${info.filename}: $t")
false
}
}
val noOpSkips = java.util.concurrent.atomic.AtomicLong()
How many saves saveIfPending skipped because the marked edits changed nothing; for tests and measurement.
True when the fingerprint can vouch for every marked property: its current value hashes by
content, so an unchanged hash means an unchanged value. A value hashed by identity (a class
with no hashCode of its own) could have changed in place under the same hash, so it saves.
private fun marksAreNoOps(info: SessionInformation): Boolean = info.pendingSidecarSaves.all { (uid, property) ->
val box = info.boxes[uid] ?: info.root.takeIf { it.id == uid } ?: return@all true
PersistedFingerprint.hashesByContent(box.properties[property])
}
const val UNMARKED_CHANNEL = "warning,persistence"
Channel of the unmarked-edit tripwire's lines; one per (box, property) a mutator changed without marking.
var lastUnmarked: List<Pair<String, String>>
The last tripwire report, for tests: (box id, property) pairs.
@Volatile
var lastUnmarked: List<Pair<String, String>> = emptyList()
internal set
private fun reportUnmarked(info: SessionInformation, entries: Map<Pair<String, String>, Long>?, reason: String) {
val saved = info.savedFingerprintEntries ?: return
if (entries == null) return
val unmarked = PersistedFingerprint.unmarkedChanges(saved, entries, info.pendingSidecarSaves.toSet())
lastUnmarked = unmarked
for ((uid, property) in unmarked) {
val box = info.boxes[uid] ?: info.root.takeIf { it.id == uid }
val label = box?.let { runCatching { it.name }.getOrNull() } ?: "?"
lprintln("warning,persistence", "unmarked edit: box $uid ($label) property '$property' changed without notePersistentChange; saved on $reason")
}
}
}