actors.kt
src/jvmMain/kotlin/afield/runtime/execution/actors.kt · 487 lines
@file:afield.runtime.completion.CompletionCategory("Internal")
package afield.runtime.execution
Typed messages between boxes: a box registers a handler with onMessage, and other code delivers
to it with send (fire-and-forget) or sendAndAwait (suspend for a reply).
Messages are queued per box and delivered on the next RunLoop frame. Handlers run as coroutines on the RunLoop's dispatcher, so one can call MessageScope.frame to wait a frame or MessageScope.reply to answer. A box with no handler for a payload type passes it to its ancestors. go launches a suspendable block owned by a box, so stopping the box cancels it and its errors appear in the box's editor.
onMessage<String> { reply(payload.length) }
go { println(thisBox.sendAndAwait<String, Int>("hello")) }
package and imports — 79 lines, from line 22
import afield.data.collections.invoke
import afield.data.collections.s
import afield.data.collections.times
import afield.data.graph.main
import afield.graphics.render.rangeTo
import afield.io.osc.main
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.control.main
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.statistics.main
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.simulation.automata.main
import afield.animation.keyframes.h
import afield.app.commands.action
import afield.data.collections.get
import afield.geometry.mesh.contains
import afield.graphics.vulkan.buffers.add
import afield.lang.stacksl.h
import afield.ui.output.h
import afield.ai.agent.*
import afield.ai.conversation.*
import afield.ai.tools.*
import afield.animation.gesture.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.app.diagnostics.*
import afield.app.notifications.*
import afield.app.protocol.*
import afield.app.server.*
import afield.app.settings.*
import afield.audio.synth.*
import afield.data.table.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.history.*
import afield.document.persistence.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.graphics.color.*
import afield.graphics.paint.*
import afield.graphics.vulkan.*
import afield.math.functions.*
import afield.media.capture.*
import afield.media.stream.*
import afield.runtime.completion.*
import afield.runtime.execution.*
import afield.runtime.kotlin.*
import afield.runtime.lifecycle.*
import afield.tools.automation.*
import afield.tools.docs.*
import afield.ui.controls.*
import afield.ui.layout.*
import afield.ui.notation.*
import afield.ui.output.*
import afield.ui.output.templates.*
import afield.ui.slides.*
import afield.ui.visualization.*
import afield.app.diagnostics.logging.lprintln
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.yield
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.coroutines.coroutineContext
import kotlin.reflect.KClass
data class Envelope<T : Any>
Envelope wrapping a user payload with sender/receiver metadata.
data class Envelope<T : Any>(
val payload: T,
val from: Box?,
val to: Box,
val time: Long = System.currentTimeMillis()
)
class MessageScope<T : Any>(val envelope: Envelope<T>, private val deferred: CompletableDeferred<Any?>?, internal val handlerBox: Box?)
Receiver for suspend message handlers. Provides frame to suspend until the next RunLoop tick and reply to send a value back to a waiting sendAndAwait caller.
class MessageScope<T : Any>(val envelope: Envelope<T>, private val deferred: CompletableDeferred<Any?>?, internal val handlerBox: Box?) {
val payload: T get() = envelope.payload
val from: Box? get() = envelope.from
val to: Box get() = envelope.to
val time: Long get() = envelope.time
suspend fun frame()
Suspend until the next RunLoop frame. Because the handler coroutine
is dispatched on Runloop.runLoop.main (the afield.runtime.lifecycle.Framer),
calling kotlinx.coroutines.yield parks it until the Framer is
pumped again.
suspend fun frame() {
yield()
}
fun reply(value: Any?)
Provide a return value to a sendAndAwait caller.
If the message was sent with plain send, this is a no-op.
Can be called at most once; subsequent calls are ignored.
fun reply(value: Any?) {
deferred?.complete(value)
// emit reply arc: handler → original sender
val senderBox = envelope.from
if (senderBox != null && handlerBox != null) {
val arcPayload = ActorArc(
senderUid = senderBox.id,
targetUid = senderBox.id,
handlerUid = handlerBox.id,
isReply = true
)
lprintln("actors", "actor-arc reply: handler=${arcPayload.handlerUid} -> sender=${arcPayload.senderUid}")
afield.app.protocol.send(Message("actor-arc").payload(arcPayload))
} else {
lprintln("actors", "actor-arc reply skipped: senderBox=${senderBox?.id} handlerBox=${handlerBox?.id}")
}
}
}
private const val ACTOR_HANDLERS_PROP = "_actorHandlers"
private const val ACTOR_MAILBOX_PROP = "_actorMailbox"
private const val RUNLOOP_HOOK_KEY = "afield.stdlib.actors"
internal envelope with optional completion slot
internal class PendingMessage
internal class PendingMessage(
val envelope: Envelope<*>,
val deferred: CompletableDeferred<Any?>? // null for fire-and-forget send()
)
per-box handler storage
@Suppress("UNCHECKED_CAST")
private fun Box.handlers(): MutableMap<KClass<*>, suspend MessageScope<*>.() -> Unit> {
var map = getPropertyRaw(ACTOR_HANDLERS_PROP) as? MutableMap<KClass<*>, suspend MessageScope<*>.() -> Unit>
if (map == null) {
map = mutableMapOf()
setPropertyRaw(ACTOR_HANDLERS_PROP, map, doResync = false)
}
return map
}
@Suppress("UNCHECKED_CAST")
private fun Box.mailbox(): ConcurrentLinkedQueue<PendingMessage> {
var q = getPropertyRaw(ACTOR_MAILBOX_PROP) as? ConcurrentLinkedQueue<PendingMessage>
if (q == null) {
q = ConcurrentLinkedQueue()
setPropertyRaw(ACTOR_MAILBOX_PROP, q, doResync = false)
}
return q
}
public API: register a handler
inline fun <reified T : Any> Box.onMessage(noinline handler: suspend MessageScope<T>.() -> Unit) {
Register a typed, suspendable message handler on this box.
onMessage<Foo> {
// `this` is MessageScope<Foo>
// payload, from, to, time are available directly
println("got $payload")
frame() // suspend until the next RunLoop tick
reply(42) // send a value back to sendAndAwait callers
}
The handler runs as a coroutine on the RunLoop's afield.runtime.lifecycle.Framer dispatcher, so it can call MessageScope.frame to yield across frames.
Handler lookup walks breadth-first up the parent graph: if the target box has no handler for a type, ancestors are checked.
inline fun <reified T : Any> Box.onMessage(noinline handler: suspend MessageScope<T>.() -> Unit) {
onMessage(T::class, handler)
}
fun <T : Any> Box.onMessage(type: KClass<T>, handler: suspend MessageScope<T>.() -> Unit) {
@Suppress("UNCHECKED_CAST")
fun <T : Any> Box.onMessage(type: KClass<T>, handler: suspend MessageScope<T>.() -> Unit) {
handlers()[type] = handler as suspend MessageScope<*>.() -> Unit
ensureActorPump()
}
public API: send (fire-and-forget)
fun <T : Any> Box.send(payload: T)
Enqueue payload for delivery to this box on the next RunLoop frame.
The sender is inferred from Box.context (may be null if called outside
a box execution context).
fun <T : Any> Box.send(payload: T) {
val sender = runCatching { Box.context.get() }.getOrNull()
mailbox().add(PendingMessage(Envelope(payload, from = sender, to = this), deferred = null))
ensureActorPump()
}
public API: go (coroutine with error reporting)
fun Box.go(block: suspend CoroutineScope.() -> Unit) {
Launch a suspendable block on the RunLoop's Framer dispatcher, owned by this box. Stopping the box cancels the block; uncaught errors appear in the editor.
Use this instead of bare inMain { } when you want exceptions to appear as
red marks and inline errors in the box, just like normal execution errors.
go {
val result = otherBox.sendAndAwait<MyRequest, Int>(MyRequest("hi"))
println(result)
}
@OptIn(DelicateCoroutinesApi::class)
fun Box.go(block: suspend CoroutineScope.() -> Unit) {
val box = this
val epoch = box.captureLaunchEpoch()
// The run that called go: its strand travels with the job, alongside Box.context.
val strand = RunScope.currentFor(box)?.strand ?: "default"
inMain {
val job = coroutineContext[Job]!!
// Marked as a go job before registerLaunchJob, so the box never flashes as fully launched.
box.goJobs[job] = strand
job.invokeOnCompletion {
box.goJobs.remove(job)
box.publishGoJobs()
}
if (!box.registerLaunchJob(job, epoch)) return@inMain
box.publishGoJobs()
try {
Box.withBoxContext(box) {
try {
block()
} catch (_: CancellationException) {
} catch (e: Throwable) {
lprintln("error,actors", "go {} threw on box '${box.name}': ${e.message}")
lprintln("error,actors", e.stackTraceToString())
reportBoxError(box, e)
}
}
} finally {
box.jobs.remove(job)
}
}
}
suspend fun delay(ms: Number) = kotlinx.coroutines.delay(ms.toLong())
Wait ms milliseconds inside go { } (or any coroutine) without holding the runloop.
Box code does not import kotlinx.coroutines.delay, so without this a bare delay(500) in a
go block found some other delay — once the synth effect now called afield.audio.synth.delayEffect,
which returned at once and let a while (true) around it hold the runloop.
public API: send and await handler completion
sealed class SendResult<out T>
sealed class SendResult<out T> {
data class Ok<T>(val value: T?) : SendResult<T>()
Handler ran to completion. value is whatever MessageScope.reply provided (null if reply was never called).
data object NoHandler : SendResult<Nothing>()
No handler was found for this message type on the target box or any of its ancestors.
data class Error(val exception: Throwable) : SendResult<Nothing>()
The handler threw an exception.
inline fun <R> map(transform: (T?) -> R): SendResult<R> = when (this) {
Transform the value if Ok, otherwise pass through the failure unchanged.
inline fun <R> map(transform: (T?) -> R): SendResult<R> = when (this) {
is Ok -> Ok(transform(value))
is NoHandler -> NoHandler
is Error -> Error(exception)
}
fun getOrNull(): T?
Return the value if Ok, otherwise null.
fun getOrNull(): T? = when (this) {
is Ok -> value
else -> null
}
inline fun getOrElse(default: () -> @UnsafeVariance T?): T? = when (this) {
Return the value if Ok, otherwise default.
inline fun getOrElse(default: () -> @UnsafeVariance T?): T? = when (this) {
is Ok -> value
else -> default()
}
inline fun onOk(action: (T?) -> Unit): SendResult<T> {
Run action if Ok, return this for chaining.
inline fun onOk(action: (T?) -> Unit): SendResult<T> {
if (this is Ok) action(value)
return this
}
inline fun onFailure(action: (SendResult<@UnsafeVariance T>) -> Unit): SendResult<T> {
Run action if not Ok, return this for chaining.
inline fun onFailure(action: (SendResult<@UnsafeVariance T>) -> Unit): SendResult<T> {
if (this !is Ok) action(this)
return this
}
}
suspend fun <T : Any, R> Box.sendAndAwait(payload: T): SendResult<R>
Send payload and suspend until the handler finishes (or is not found).
The handler can call MessageScope.reply to provide a typed return value.
If the handler completes without calling reply, the result value is null.
Must be called from a coroutine (e.g. inside inMain { }, a frames { } block,
or another onMessage handler).
@Suppress("UNCHECKED_CAST")
suspend fun <T : Any, R> Box.sendAndAwait(payload: T): SendResult<R> {
val sender = runCatching { Box.context.get() }.getOrNull()
val envelope = Envelope(payload, from = sender, to = this)
// check for handler existence eagerly so we can return NoHandler without
// waiting a frame
if (findHandler(payload::class) == null) {
return SendResult.NoHandler
}
val deferred = CompletableDeferred<Any?>()
mailbox().add(PendingMessage(envelope, deferred))
ensureActorPump()
return try {
val value = deferred.await()
SendResult.Ok(value as? R)
} catch (e: Throwable) {
SendResult.Error(e)
}
}
dispatch
Report a runtime exception to the editor as a red mark + inline error, extracting the source line from the stack trace.
private fun reportBoxError(box: Box, e: Throwable) {
var fallbackFirstLine = -1
var firstLineInThisBox = -1
e.stackTrace.forEach { frame ->
if (fallbackFirstLine == -1) {
fallbackFirstLine = frame.lineNumber
}
if (frame.fileName?.contains(box.id) == true && firstLineInThisBox == -1) {
firstLineInThisBox = frame.lineNumber
}
}
val firstLine = if (firstLineInThisBox != -1) firstLineInThisBox else fallbackFirstLine
val message = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.name
box.displayError(message, line = firstLine)
}
Find a handler for type by walking breadth-first up the parent graph,
starting at this box. Returns the owning box and handler, or null if
no handler is registered anywhere in the ancestry.
@Suppress("UNCHECKED_CAST")
private fun Box.findHandler(type: KClass<*>): Pair<Box, suspend MessageScope<*>.() -> Unit>? {
val seen = mutableSetOf<Box>()
var fringe = setOf(this)
while (fringe.isNotEmpty()) {
for (box in fringe) {
val h = box.handlers()[type]
if (h != null) return box to h
}
seen.addAll(fringe)
fringe = fringe.flatMap { it.parents }.toSet() - seen
}
return null
}
Drain this box's mailbox, launching each handler as a coroutine on the RunLoop's Framer dispatcher so it can call MessageScope.frame.
source — 46 lines, from line 415
@OptIn(DelicateCoroutinesApi::class)
private fun Box.drainMailbox() {
val mbox = mailbox()
var budget = 1000 // safety cap per frame
while (budget-- > 0) {
val pending = mbox.poll() ?: break
val result = findHandler(pending.envelope.payload::class)
if (result == null) {
lprintln("warning,actors", "no handler for ${pending.envelope.payload::class.simpleName} on box '${name}' (${id})")
pending.deferred?.complete(null)
continue
}
val (handlerBox, handler) = result
val targetBox = this
// emit send arc: sender → handler (and handler → target if different)
val arcPayload = ActorArc(
senderUid = pending.envelope.from?.id,
targetUid = targetBox.id,
handlerUid = handlerBox.id,
isReply = false
)
lprintln("actors", "actor-arc send: sender=${arcPayload.senderUid} target=${arcPayload.targetUid} handler=${arcPayload.handlerUid}")
afield.app.protocol.send(Message("actor-arc").payload(arcPayload))
// launch on the Framer so the handler can call frame() / yield()
inMain {
Box.withBoxContext(targetBox) {
@Suppress("UNCHECKED_CAST")
val scope = MessageScope(pending.envelope as Envelope<Any>, pending.deferred, handlerBox)
try {
scope.handler()
// if handler never called reply(), complete with null
pending.deferred?.complete(null)
} catch (e: CancellationException) {
// coroutine cancellation is normal, don't report
} catch (e: Throwable) {
lprintln("error,actors", "actor handler threw on box '${targetBox.name}': ${e.message}")
lprintln("error,actors", e.stackTraceToString())
pending.deferred?.completeExceptionally(e)
reportBoxError(targetBox, e)
}
}
}
}
}
RunLoop integration
private val registeredBoxes = ConcurrentLinkedQueue<Box>()
private fun ensureActorPump() {
Runloop.runLoop.preRun.setUnowned(RUNLOOP_HOOK_KEY, sequence {
while (true) {
val snapshot = registeredBoxes.toList()
for (box in snapshot) {
box.drainMailbox()
}
yield(Unit)
}
})
}
Make sure this box is in the pump set so its mailbox gets drained each frame.
private fun Box.ensureActorPump() {
if (!registeredBoxes.contains(this)) {
registeredBoxes.add(this)
}
afield.runtime.execution.ensureActorPump()
}