Skip to content

ReturnValueRegistry.kt

src/jvmMain/kotlin/afield/runtime/execution/ReturnValueRegistry.kt · 336 lines

package afield.runtime.execution

What happens to the value a box's code returns: ReturnValueRegistry offers it to each registered ReturnValueHandler in turn, and the first one that accepts it keeps it running.

The handlers registered here run a returned Sequence one element per frame (registerSequenceReturnValue) and a returned frames { } block (registerFramesReturnValue). Several values can be returned together with launches or +, which builds a LaunchBundle. Each launch carries a per-strand generation, so relaunching or stopping a strand ends what it started before.

import afield.data.table.div
import afield.data.table.minus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.box
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.rem

import afield.app.diagnostics.ProfileOrigin
import afield.app.notifications.notify
import afield.app.protocol.IsRunning
import afield.app.protocol.Message
import afield.app.protocol.computeIfAbsent
import afield.app.protocol.handlers
import afield.app.protocol.payload
import afield.app.protocol.send
import afield.audio.synth.*
import afield.data.table.get
import afield.document.graph.Box
import afield.document.graph.RunningState
import afield.document.graph.captureLaunchEpoch
import afield.document.graph.isLaunchEpochCurrent
import afield.document.graph.registerEndPhaseJob
import afield.document.graph.registerLaunchJob
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.Frames
import afield.runtime.lifecycle.RunScope
import afield.runtime.lifecycle.RunsAt
import afield.runtime.lifecycle.inMain
import afield.runtime.lifecycle.returnValueRegistration
import afield.runtime.lifecycle.runAll
import afield.runtime.lifecycle.withCurrent
import afield.app.diagnostics.logging.lprintln
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.yield
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger

typealias ReturnValueHandler

A handler that can launch a return value from box execution.

Return true if this handler consumed the value, false to let the next handler try.

typealias ReturnValueHandler = (
    inside: Box,
    returnValue: Any,
    strand: String,
    generation: Int,
    launchEpoch: Int,
    notify: (running: Boolean, strand: String, watermark: Int) -> Unit
) -> Boolean

class LaunchBundle internal constructor(val items: List<Any?>)

override fun toString() = "launchBundle[of ${items.size}]"

fun launches(vararg items: Any?): LaunchBundle = LaunchBundle(items.toList())

operator fun LaunchBundle.plus(other: Any?): LaunchBundle

operator fun LaunchBundle.plus(other: Any?): LaunchBundle =
    LaunchBundle(buildList {
        addAll(items.flatMap(::flattenLaunchItems))
        addAll(flattenLaunchItems(other))
    })

operator fun Frames.plus(other: Any?): LaunchBundle = launches(this, other)

object ReturnValueRegistry

object ReturnValueRegistry {

val handlers = mutableListOf<ReturnValueHandler>()

init

    init {
        runAll(returnValueRegistration)
    }

fun launch

    fun launch(
        inside: Box,
        returnValue: Any?,
        strand: String = "default",
        notify: (running: Boolean, strand: String, watermark: Int) -> Unit
    ): Boolean {
        val launchEpoch = inside.captureLaunchEpoch()
        return launchPrepared(inside, returnValue, strand, launchEpoch, notify)
    }

internal fun launchPrepared

    internal fun launchPrepared(
        inside: Box,
        returnValue: Any?,
        strand: String,
        launchEpoch: Int,
        notify: (running: Boolean, strand: String, watermark: Int) -> Unit
    ): Boolean {
        if (!inside.isLaunchEpochCurrent(launchEpoch)) return false
        val generation = inside.runGeneration.incrementAndGet(strand)
        return launchWithGeneration(inside, returnValue, strand, generation, launchEpoch, notify)
    }

internal fun launchWithGeneration

    internal fun launchWithGeneration(
        inside: Box,
        returnValue: Any?,
        strand: String,
        generation: Int,
        launchEpoch: Int = inside.captureLaunchEpoch(),
        notify: (running: Boolean, strand: String, watermark: Int) -> Unit
    ): Boolean {
        if (!inside.isLaunchEpochCurrent(launchEpoch)) return false
        if (returnValue == null) {
            notify(false, strand, generation)
            return true
        }
        // Handlers find the run that owns what they register through RunScope.current(): the launch's
        // scope when execution installed one, otherwise the box's ambient run (other languages).
        val scope = RunScope.currentFor(inside)
        return RunScope.withCurrent(scope) {
            if (launchCompositeIfPresent(inside, returnValue, strand, generation, launchEpoch, notify)) return@withCurrent true
            if (dispatch(inside, returnValue, strand, generation, launchEpoch, notify)) return@withCurrent true
            // no handler matched
            notify(false, strand, generation)
            false
        }
    }

internal fun currentRunElement(): kotlin.coroutines.CoroutineContext

The coroutine context element of the run a handler is dispatched for, if any.

    internal fun currentRunElement(): kotlin.coroutines.CoroutineContext =
        RunScope.current()?.element ?: kotlin.coroutines.EmptyCoroutineContext

internal fun dispatch

body — 86 lines, from line 143
    internal fun dispatch(
        inside: Box,
        returnValue: Any,
        strand: String,
        generation: Int,
        launchEpoch: Int,
        notify: (running: Boolean, strand: String, watermark: Int) -> Unit
    ): Boolean {
        for (handler in handlers) {
            if (handler(inside, returnValue, strand, generation, launchEpoch, notify)) return true
        }
        return false
    }

    private fun launchCompositeIfPresent(
        inside: Box,
        returnValue: Any,
        strand: String,
        generation: Int,
        launchEpoch: Int,
        notify: (running: Boolean, strand: String, watermark: Int) -> Unit
    ): Boolean {
        val items = when (returnValue) {
            is LaunchBundle -> flattenLaunchItems(returnValue)
            is Iterable<*> -> flattenLaunchItems(returnValue)
            else -> return false
        }.filterNotNull()

        if (items.isEmpty()) {
            notify(false, strand, generation)
            return true
        }

        val activeChildren = AtomicInteger(0)
        val launchedAny = AtomicBoolean(false)
        val dispatchComplete = AtomicBoolean(false)
        val runningNotified = AtomicBoolean(false)
        val compositeNotify: (Boolean, String, Int) -> Unit = composite@{ running: Boolean, _: String, _: Int ->
            if (running) return@composite
            activeChildren.updateAndGet { current -> if (current > 0) current - 1 else 0 }
            if (
                dispatchComplete.get() &&
                activeChildren.get() == 0 &&
                launchedAny.get() &&
                runningNotified.compareAndSet(true, false)
            ) {
                notify(false, strand, generation)
            }
        }

        for (item in items) {
            activeChildren.incrementAndGet()
            val consumed = dispatch(inside, item, strand, generation, launchEpoch, compositeNotify)
            if (consumed) {
                launchedAny.set(true)
            } else {
                activeChildren.decrementAndGet()
                lprintln(
                    "warn,execution",
                    "No return-value handler for composite launch item `${item::class.qualifiedName ?: item::class.simpleName ?: item}`"
                )
            }
        }

        dispatchComplete.set(true)
        if (launchedAny.get() && activeChildren.get() > 0) {
            if (runningNotified.compareAndSet(false, true)) {
                notify(true, strand, generation)
            }
        } else {
            notify(false, strand, generation)
        }
        return true
    }
}

private fun flattenLaunchItems(value: Any?): List<Any?> = when (value) {
    is LaunchBundle -> value.items.flatMap(::flattenLaunchItems)
    is Iterable<*> -> value.flatMap(::flattenLaunchItems)
    else -> listOf(value)
}

private fun MutableMap<String, Int>.incrementAndGet(name: String): Int {
    this[name] = computeIfAbsent(name) { 0 } + 1
    return this[name]!!
}

fun registerSequenceReturnValue()

@RunsAt(returnValueRegistration, priority = 0)
fun registerSequenceReturnValue() {
    ReturnValueRegistry.handlers += handler@{ inside, returnValue, strand, generation, launchEpoch, notify ->
        if (returnValue !is Sequence<*>) return@handler false
        val a = returnValue.iterator()
        inMain(Box.boxContextElement(inside) + ProfileOrigin(inside, strand) + ReturnValueRegistry.currentRunElement()) {
            if (!inside.isLaunchEpochCurrent(launchEpoch)) {
                notify(false, strand, generation)
                return@inMain
            }
            notify(true, strand, generation)
            while (inside.isLaunchEpochCurrent(launchEpoch) && inside.runGeneration[strand] == generation && a.hasNext()) {
                a.next()
                yield()
            }
            notify(false, strand, generation)
        }
        true
    }
}

fun registerFramesReturnValue()

body — 85 lines, from line 251
@RunsAt(returnValueRegistration, priority = 1)
fun registerFramesReturnValue() {
    ReturnValueRegistry.handlers += handler@{ inside, returnValue, strand, generation, launchEpoch, notify ->
        if (returnValue !is Frames) return@handler false
        val scope = RunScope.currentFor(inside)
        returnValue.shouldend = {
            inside.runGeneration[strand] != generation
        }
        val original = returnValue.atEnd
        returnValue.atEnd = {
            original()
        }
        // `a + b` forwards up to three end phases to these hooks: report "ending" once per stop and
        // drop the end fence only when the last of them has finished.
        val activeEndPhases = HashSet<Job>()
        returnValue.onAtEndStart = onAtEndStart@{ endJob ->
            // The end phase registers against the epoch *now*, not `launchEpoch`: stop bumps the
            // epoch and it is stop's cancellation that creates this job. See registerEndPhaseJob.
            if (!inside.registerEndPhaseJob(endJob)) return@onAtEndStart
            val first = synchronized(activeEndPhases) { activeEndPhases.isEmpty().also { activeEndPhases.add(endJob) } }
            if (!first) return@onAtEndStart
            val endingGeneration = inside.runGeneration[strand] ?: (generation + 1)
            returnValue.shouldCancelAtEnd = {
                inside.runGeneration[strand] != endingGeneration
            }
            // Only claim the running state if a newer launch has not already taken this strand. A
            // stop followed immediately by a relaunch — what the docs capture's `restartTarget`
            // does — runs this end phase alongside the new launch, and reporting the *old*
            // generation as "ending" would leave the strand looking stopped while it runs, because
            // the matching removal below keys on that older generation.
            val holder = inside.currentRunningStates[strand]
            if (holder == null || holder.watermark <= generation) {
                inside.currentRunningStates[strand] = RunningState(generation, "ending")
                send(Message("is-running").payload(IsRunning(inside.id, true, generation, strand, "ending")))
            }
        }
        returnValue.onAtEndFinish = { endJob ->
            inside.jobs.remove(endJob)
            val last = synchronized(activeEndPhases) { activeEndPhases.remove(endJob) && activeEndPhases.isEmpty() }
            if (last) returnValue.shouldCancelAtEnd = { false }
        }
        inMain(Box.boxContextElement(inside) + ProfileOrigin(inside, strand) + ReturnValueRegistry.currentRunElement()) {
            Box.withBoxContext(inside) {
                val job = kotlin.coroutines.coroutineContext[Job]!!
                returnValue.job = job
                if (!inside.registerLaunchJob(job, launchEpoch)) {
                    notify(false, strand, generation)
                    return@withBoxContext
                }
                // A replaced run must cancel waits too, not only the next frame() boundary.
                scope?.ownJob(job)
                notify(true, strand, generation)
                try {
                    // Supersession closes the old scope after scheduling us. Give its cancelled
                    // body a turn to run synchronous finally cleanup before touching shared state.
                    kotlinx.coroutines.yield()
                    returnValue.body()
                } catch (e: CancellationException) {
                } catch (t: Throwable) {
                    lprintln("error,execution", t.stackTraceToString())
                    var fallbackFirstLine = -1
                    var firstLineInThisBox = -1
                    t.stackTrace.forEach { e ->
                        if (fallbackFirstLine == -1) {
                            fallbackFirstLine = e.lineNumber
                        }
                        if (e.fileName?.contains(inside.id) == true && firstLineInThisBox == -1) {
                            firstLineInThisBox = e.lineNumber
                        }
                    }
                    val firstLine = if (firstLineInThisBox != -1) firstLineInThisBox else fallbackFirstLine
                    val message = t.message?.takeIf { it.isNotBlank() } ?: t.javaClass.name
                    inside.displayError(message, line = firstLine)
                } finally {
                    inside.jobs.remove(job)
                    if (inside.currentRunningStates[strand]?.watermark == generation) {
                        inside.currentRunningStates.remove(strand)
                    }
                    notify(false, strand, generation)
                }
            }
        }
        true
    }
}