Runloop.kt
src/jvmMain/kotlin/afield/runtime/lifecycle/Runloop.kt · 553 lines
package afield.runtime.lifecycle
import afield.app.server.app
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.document.graph.offline
import afield.document.workspace.metadata
import afield.runtime.execution.plus
import afield.tools.docs.screenshot
import afield.ui.notation.with
import afield.ui.output.history
import afield.audio.synth.*
import afield.data.table.get
import afield.document.workspace.RunloopSchedulingStatus
import afield.document.workspace.RunloopTimingStats
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.ui.output.update
import afield.runtime.lifecycle.Generator.Companion.isMain
import afield.graphics.vulkan.util.MapOfSequences
import afield.app.diagnostics.logging.lprintln
import kotlinx.coroutines.*
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.locks.LockSupport
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.math.ceil
import kotlin.math.max
import kotlin.coroutines.CoroutineContext
The main Field loop: Runloop ticks preRun, the Framer dispatcher and postRun once per frame on one thread.
Runloop.runLoop is the single instance and Runloop.frame its frame counter. inMain launches a
coroutine onto that loop, Runloop.miscPool is the shared pool for background work, and
AnimationClock is the time source that frame-based code reads, which becomes frame-indexed during
a frame-synchronous video capture. RateLimiter coalesces bursts of work to a fixed rate. The
Runloop is not the audio thread; see FieldAudioEngine for that.
@OptIn(DelicateCoroutinesApi::class, ExperimentalAtomicApi::class) fun inMain(block: suspend CoroutineScope.() -> Unit) = GlobalScope.launch(Runloop.runLoop.main, start = if (isMain.load()) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT, block = block)
fun inMain(block: suspend CoroutineScope.() -> Unit) = GlobalScope.launch
Launch block as a coroutine on the Runloop's Framer dispatcher.
@OptIn(DelicateCoroutinesApi::class, ExperimentalAtomicApi::class)
fun inMain(block: suspend CoroutineScope.() -> Unit) = GlobalScope.launch(
Runloop.runLoop.main,
start = if (isMain.load()) CoroutineStart.DEFAULT else CoroutineStart.DEFAULT,
block = block
)
fun inMain(context: CoroutineContext, block: suspend CoroutineScope.() -> Unit) =
Explicit inherited metadata for box-owned work, including its first dispatch.
@OptIn(DelicateCoroutinesApi::class)
fun inMain(context: CoroutineContext, block: suspend CoroutineScope.() -> Unit) =
GlobalScope.launch(Runloop.runLoop.main + context, block = block)
object AnimationClock
Time source for frame-based programs that also need a duration.
Ordinarily this is monotonic wall time. A frame-synchronous video capture installs
a token that advances by exactly 1 / fps only after a Field frame has been painted
and copied into the movie. Code using nowSeconds therefore becomes frame-indexed
during offline capture without changing its live, interactive pacing.
class FrameToken internal constructor(internal val state: FrameState)
internal class FrameState
internal class FrameState(
val startSeconds: Double,
val fps: Int,
val frame: AtomicLong = AtomicLong(0)
)
private val fixed = AtomicReference<FrameState?>(null)
fun nowSeconds(): Double
fun nowSeconds(): Double {
val state = fixed.get()
return if (state == null) {
System.nanoTime() / 1_000_000_000.0
} else {
state.startSeconds + state.frame.get().toDouble() / state.fps.toDouble()
}
}
fun isFrameIndexed(): Boolean = fixed.get() != null
internal fun beginFrameIndexed(fps: Int): FrameToken
internal fun beginFrameIndexed(fps: Int): FrameToken {
val state = FrameState(
startSeconds = 0.0,
fps = fps.coerceAtLeast(1)
)
check(fixed.compareAndSet(null, state)) { "A frame-synchronous capture is already active" }
return FrameToken(state)
}
internal fun advance(token: FrameToken)
internal fun advance(token: FrameToken) {
check(fixed.get() === token.state) { "Frame clock token is no longer active" }
token.state.frame.incrementAndGet()
}
internal fun endFrameIndexed(token: FrameToken)
internal fun endFrameIndexed(token: FrameToken) {
fixed.compareAndSet(token.state, null)
}
}
class Runloop
class Runloop {
private constructor() {
lprintln("info,runloop", "Runloop is being constructed")
lprintln("info,runloop", "main = ${System.identityHashCode(main)}")
}
companion object
val runLoop = Runloop()
private val miscPoolThreads = AtomicLong(0)
val miscPool
Shared pool for fire-and-forget background work (history screenshots, workspace saves, AI box naming, render-target file writes).
Daemon-threaded, with a draining shutdown hook. With the default thread factory these
workers were non-daemon, so a dozen idle pool-1-thread-N threads sat parked in
LinkedBlockingQueue.take forever and the JVM could never terminate on its own. That is
what made examples.agent-transcripts.materialize exit 3 under
AFIELD_JVM_MAIN_STRICT_EXIT — a pool that has finished all its work still pinned the
process. (It presents as ForkJoinPool.managedBlock frames, which is merely how AQS parks
and not evidence of a ForkJoinPool.)
Daemon threads alone would have quietly given up something real, though: today an
in-flight save keeps the JVM alive by accident, so it gets to finish. The shutdown hook
restores that on purpose and extends it — hooks also run on exitProcess, which is how
this process actually ends, and which previously waited for nothing at all.
val miscPool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() + 2) { runnable ->
Thread(runnable, "afield-misc-${miscPoolThreads.incrementAndGet()}").apply { isDaemon = true }
}.also { pool ->
// Throws if the JVM is already shutting down when the pool is first touched; that is
// exactly the case where there is nothing left to drain, so ignoring it is correct.
runCatching {
Runtime.getRuntime().addShutdownHook(
Thread({
pool.shutdown()
runCatching { pool.awaitTermination(5, TimeUnit.SECONDS) }
}, "afield-misc-drain")
)
}
}
private val scheduling = RunloopSchedulingConfig.fromEnvironment()
private val schedulingState = RunloopSchedulingState(scheduling)
var mainThread: Thread?
@Volatile
var mainThread: Thread? = null
val isMainThread: Boolean
val isMainThread: Boolean
get() = mainThread == Thread.currentThread()
}
var frame
Volatile: the stall watchdog and BoxInterrupt.poll read it from other threads / later frames.
@Volatile
var frame = 0L
val main = Framer()
val preRun = MapOfSequences<String>(LifecycleKinds.RUNLOOP_PRE_RUN)
val postRun = MapOfSequences<String>(LifecycleKinds.RUNLOOP_POST_RUN)
var shouldSleep = true
var limiter = RateLimiter(10.0, 0.2)
private val timingMonitor = RunloopTimingMonitor()
fun timingStats(): RunloopTimingStats = timingMonitor.snapshot(frame)
fun schedulingStatus(): RunloopSchedulingStatus = schedulingState.status()
fun start()
body — 285 lines, from line 187
fun start() {
if (mainThread == null) {
mainThread = Thread.currentThread()
}
schedulingState.install(Thread.currentThread())
lprintln("info,startup", "entering run loop")
BoxInterrupt.Watchdog.start()
while (true) {
val frameStartNs = System.nanoTime()
preRun.update()
main.frame()
postRun.update()
val workNs = System.nanoTime() - frameStartNs
frame++
// First completed frame = Field is up. Announce ready and collapse the startup row.
if (frame == 1L) afield.app.diagnostics.logging.PinnedConsole.finishStartup()
timingMonitor.record(frame, frameStartNs, workNs)
if (shouldSleep) {
// lprintln("debug,runloop", "framer has run out of work thread is ${Thread.currentThread().name}")
schedulingState.idle()
}
shouldSleep = true
}
}
}
private enum class RunloopIdleStrategy {
YIELD,
SPIN,
PARK,
SPIN_THEN_PARK
}
private data class RunloopSchedulingConfig(
val idleStrategy: RunloopIdleStrategy = RunloopIdleStrategy.SPIN_THEN_PARK,
val spinIterations: Int = 64,
val parkNanos: Long = 50_000L,
val affinityCore: Int? = null,
val affinityEnabled: Boolean = affinityCore != null
) {
companion object {
fun fromEnvironment(): RunloopSchedulingConfig {
fun envName(name: String): String = "AFIELD_RUNLOOP_" + name
.replace(Regex("([a-z])([A-Z])"), "\$1_\$2")
.uppercase()
.replace('.', '_')
fun setting(name: String): String? {
return System.getProperty("afield.runloop.$name")
?: System.getenv(envName(name))
}
val idle = when (setting("idle")?.trim()?.lowercase()) {
null, "", "spin-then-park", "spin_then_park" -> RunloopIdleStrategy.SPIN_THEN_PARK
"yield" -> RunloopIdleStrategy.YIELD
"spin" -> RunloopIdleStrategy.SPIN
"park" -> RunloopIdleStrategy.PARK
else -> RunloopIdleStrategy.SPIN_THEN_PARK
}
val spinIterations = setting("spinIterations")?.trim()?.toIntOrNull()?.coerceIn(0, 1_000_000) ?: 64
val parkNanos = setting("parkNanos")?.trim()?.toLongOrNull()?.coerceIn(0L, 10_000_000L) ?: 50_000L
val affinityText = setting("affinityCore")?.trim()
?: System.getenv("AFIELD_RUNLOOP_AFFINITY")?.trim()
?: System.getProperty("afield.runloop.affinity")?.trim()
val affinityCore = affinityText
?.takeUnless { it.isBlank() || it.equals("off", ignoreCase = true) || it.equals("false", ignoreCase = true) }
?.toIntOrNull()
?.takeIf { it >= 0 }
return RunloopSchedulingConfig(
idleStrategy = idle,
spinIterations = spinIterations,
parkNanos = parkNanos,
affinityCore = affinityCore,
affinityEnabled = affinityCore != null
)
}
}
}
private class RunloopSchedulingState(private val config: RunloopSchedulingConfig) {
@Volatile private var installed = false
@Volatile private var applied = false
@Volatile private var linuxThreadId = -1L
@Volatile private var message = if (config.affinityEnabled) "pending" else "off"
fun install(thread: Thread) {
if (installed) return
installed = true
thread.name = "field-runloop-main"
if (!config.affinityEnabled) {
message = "affinity disabled"
lprintln("info,runloop", "Runloop scheduling: ${status().idleStrategy}, affinity disabled")
return
}
val os = System.getProperty("os.name", "").lowercase()
if (!os.contains("linux")) {
message = "affinity requested, but this is not Linux"
return
}
val core = config.affinityCore ?: run {
message = "affinity disabled"
return
}
val tid = currentLinuxThreadId(thread.name)
linuxThreadId = tid
if (tid <= 0) {
message = "could not locate Linux thread id for ${thread.name}"
return
}
val taskset = findTaskset()
if (taskset == null) {
message = "taskset not found; cannot apply experimental Linux affinity"
return
}
runCatching {
val process = ProcessBuilder(taskset, "-pc", core.toString(), tid.toString())
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText().trim()
val finished = process.waitFor(2, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
message = "taskset timed out"
} else if (process.exitValue() == 0) {
applied = true
message = output.ifBlank { "pinned Linux tid $tid to core $core" }
} else {
message = output.ifBlank { "taskset failed with exit ${process.exitValue()}" }
}
}.onFailure {
message = it.message ?: it.javaClass.name
}
lprintln("info,runloop", "Runloop scheduling: ${status().idleStrategy}, affinity=$message")
}
fun idle() {
when (config.idleStrategy) {
RunloopIdleStrategy.YIELD -> Thread.yield()
RunloopIdleStrategy.SPIN -> spin(config.spinIterations)
RunloopIdleStrategy.PARK -> park(config.parkNanos)
RunloopIdleStrategy.SPIN_THEN_PARK -> {
spin(config.spinIterations)
park(config.parkNanos)
}
}
}
fun status(): RunloopSchedulingStatus = RunloopSchedulingStatus(
idleStrategy = config.idleStrategy.name.lowercase().replace('_', '-'),
spinIterations = config.spinIterations,
parkNanos = config.parkNanos,
affinityRequested = config.affinityEnabled,
affinityCore = config.affinityCore ?: -1,
affinityApplied = applied,
affinityThreadId = linuxThreadId,
affinityMessage = message
)
private fun spin(iterations: Int) {
repeat(iterations) { Thread.onSpinWait() }
}
private fun park(nanos: Long) {
if (nanos > 0L) LockSupport.parkNanos(nanos)
}
private fun currentLinuxThreadId(threadName: String): Long {
val tasks = File("/proc/self/task").listFiles() ?: return -1L
val matches = tasks.mapNotNull { task ->
val tid = task.name.toLongOrNull() ?: return@mapNotNull null
val comm = runCatching { task.resolve("comm").readText().trim() }.getOrNull()
if (comm == threadName.take(15)) tid else null
}
return when {
matches.size == 1 -> matches.single()
matches.isNotEmpty() -> matches.minOrNull() ?: -1L
else -> -1L
}
}
private fun findTaskset(): String? {
val candidates = listOf("/usr/bin/taskset", "/bin/taskset", "taskset")
return candidates.firstOrNull { candidate ->
candidate.contains('/') && File(candidate).canExecute() || !candidate.contains('/')
}
}
}
private class RunloopTimingMonitor(private val sampleCapacity: Int = 8192) {
private val intervalSamplesNs = LongArray(sampleCapacity)
private val workSamplesNs = LongArray(sampleCapacity)
private val totalSamples = AtomicLong(0)
@Volatile
private var lastStartNs = 0L
@Volatile
private var latestIntervalNs = 0L
@Volatile
private var latestWorkNs = 0L
fun record(frame: Long, startNs: Long, workNs: Long) {
val previousStartNs = lastStartNs
lastStartNs = startNs
latestWorkNs = workNs
if (previousStartNs == 0L) return
val intervalNs = (startNs - previousStartNs).coerceAtLeast(0L)
latestIntervalNs = intervalNs
val index = (totalSamples.getAndIncrement() % sampleCapacity).toInt()
intervalSamplesNs[index] = intervalNs
workSamplesNs[index] = workNs
}
fun snapshot(frame: Long): RunloopTimingStats {
val total = totalSamples.get()
val count = minOf(total, sampleCapacity.toLong()).toInt()
if (count == 0) {
return RunloopTimingStats(
running = lastStartNs != 0L,
frame = frame,
totalSamples = total,
sampleCapacity = sampleCapacity
)
}
val intervals = LongArray(count)
val work = LongArray(count)
val start = (total - count).coerceAtLeast(0L)
for (offset in 0 until count) {
val source = ((start + offset) % sampleCapacity).toInt()
intervals[offset] = intervalSamplesNs[source]
work[offset] = workSamplesNs[source]
}
intervals.sort()
work.sort()
fun Long.nsToMs(): Double = this / 1_000_000.0
fun percentile(sorted: LongArray, percent: Double): Double {
val index = ceil(percent * sorted.size).toInt().coerceIn(1, sorted.size) - 1
return sorted[index].nsToMs()
}
fun median(sorted: LongArray): Double {
val middle = sorted.size / 2
return if (sorted.size % 2 == 0) {
(sorted[middle - 1].nsToMs() + sorted[middle].nsToMs()) / 2.0
} else {
sorted[middle].nsToMs()
}
}
val intervalSumNs = intervals.sum()
val ticksPerSecond = if (intervalSumNs > 0L) {
count * 1_000_000_000.0 / intervalSumNs.toDouble()
} else {
0.0
}
return RunloopTimingStats(
running = true,
frame = frame,
totalSamples = total,
sampleCount = count,
sampleCapacity = sampleCapacity,
ticksPerSecond = ticksPerSecond,
latestIntervalMs = latestIntervalNs.nsToMs(),
intervalMedianMs = median(intervals),
intervalP95Ms = percentile(intervals, 0.95),
intervalP99Ms = percentile(intervals, 0.99),
intervalMaxMs = intervals.last().nsToMs(),
latestWorkMs = latestWorkNs.nsToMs(),
workMedianMs = median(work),
workP95Ms = percentile(work, 0.95),
workP99Ms = percentile(work, 0.99),
workMaxMs = work.last().nsToMs(),
intervalsOver1Ms = intervals.count { it > 1_000_000L },
intervalsOver4Ms = intervals.count { it > 4_000_000L },
intervalsOver8Ms = intervals.count { it > 8_000_000L },
intervalsOver16Ms = intervals.count { it > 16_000_000L }
)
}
}
class RateLimiter
class RateLimiter(
rate: Double, private val maxLatency: Double
) {
private val intervalMillis = (1000.0 / rate).toLong()
private var lastExecution = 0L // timestamp of last execution
private var hasExecuted = false
private var pending: (() -> Unit)? = null // the most recent block waiting
private var job: Job? = null // scheduled coroutine
private val lock = Any()
fun submit(block: () -> Unit) {
fun submit(block: () -> Unit) {
if (AnimationClock.isFrameIndexed()) {
// Offline capture must not race a zero-delay coroutine against the screenshot at this
// runloop boundary. Rate-limit against virtual movie time and publish synchronously;
// intermediate submissions are still dropped exactly as a rate limiter promises.
val now = (AnimationClock.nowSeconds() * 1000.0).toLong()
var ready: (() -> Unit)? = null
synchronized(lock) {
if (!hasExecuted || now < lastExecution || now >= lastExecution + intervalMillis) {
lastExecution = now
hasExecuted = true
pending = null
ready = block
} else {
pending = block
}
}
ready?.invoke()
return
}
val now = System.currentTimeMillis()
synchronized(lock) {
// compute next allowed execution time
val nextTime = max(lastExecution + intervalMillis, now)
val latency = nextTime - now
if (latency > maxLatency * 1000) {
return
}
if (job == null) {
schedule(block, nextTime)
} else {
pending = block
}
}
}
operator fun invoke(block: () -> Unit) = submit(block)
@OptIn(DelicateCoroutinesApi::class)
private fun schedule(block: () -> Unit, executeAt: Long) {
val delayMs = max(0, executeAt - System.currentTimeMillis())
job = GlobalScope.launch {
delay(delayMs)
var blockToRun: (() -> Unit)? = null
synchronized(lock) {
// If there’s a newer pending block, schedule it now.
pending?.also { next ->
pending = null
val now = System.currentTimeMillis()
val nextExec = max(lastExecution + intervalMillis, now)
if (nextExec - now <= maxLatency * 1000) {
schedule(next, nextExec)
} else {
job = null
}
} ?: run {
lastExecution = System.currentTimeMillis()
hasExecuted = true
job = null
blockToRun = block
}
}
blockToRun?.let { ready ->
ready()
}
}
}
}