Skip to content

KitRuntime.kt

src/jvmMain/kotlin/afield/document/workspace/KitRuntime.kt · 226 lines

package afield.document.workspace

import afield.app.server.SessionInformation
import afield.app.settings.PersonalLayer
import afield.document.graph.Box
import afield.app.diagnostics.logging.lprintln
import java.io.File
import java.util.concurrent.ConcurrentHashMap

Loads each installed kit runtime once per host, then attaches and detaches documents against it. KitRuntime.start reads the package directories the launcher captured in afield.kit.packages, instantiates each manifest's runtime.lifecycle class from the personal class layer and calls KitLifecycle.startup. A kit whose startup throws is cleaned up, recorded in KitRuntime.errors and shown as an error in Welcome rather than failing the host. Opening a document that carries a KitInstanceRecord calls KitRuntime.activate, which resolves the document's WorkspaceProjectScope and runs KitLifecycle.activate inside its root box context; tearing that session down calls KitLifecycle.deactivate.

Those package directories come from the profile Gradle read at launch, not from the registry as it stands now, so a kit installed or updated since this host started is invisible here until a restart. KitRuntime.activate enforces the matching condition directly: a document records the runtime revision it was created against, and activation refuses when this host loaded a different one, naming both.

KitRuntime.launchBoxes answers what a starting point asks Field to run. It reads launch from the revision the document recorded rather than from the newer compatible one this host may hold, so a kit that has since renamed or reordered its boxes cannot change what an existing document launches, and it insists on exactly one live box per name instead of guessing. A shutdown hook deactivates every live document and calls KitLifecycle.cleanup in reverse load order, with four seconds to finish.

Reloading the personal layer (reloadPersonal()) goes through suspendForReload and resumeAfterReload: every kit is deactivated and cleaned up, the layer is replaced, and the kits start again from the new classes, reactivating the documents that were open. The package directories themselves are still the launch profile's, so installing or removing a kit needs a restart, as does a native library a kit has already loaded.

interface KitLifecycle

Implement in a kit's Kotlin extension; startup/cleanup belong to the host, activation to a document.

interface KitLifecycle {

fun startup(context: KitHostContext) {}

Called once when the host loads this kit, before any of its documents open. Throwing here cleans the kit up and leaves it reporting an error in Welcome, rather than failing the host.

fun activate(context: KitDocumentContext) {}

Called as each of the kit's documents opens, inside that document's root box context, so a box API that reads the current box works. Throwing deactivates the document again and leaves it inactive.

fun deactivate(context: KitDocumentContext) {}

Called as a document's session goes away. A failure here is logged and does not stop the teardown.

fun cleanup(context: KitHostContext) {}

Called as the host shuts down or reloads the personal layer, after every document has been deactivated, and in reverse load order across kits. Shutdown allows four seconds for all of them.

data class KitHostContext(val kit: InstalledKit, val directory: File)

The immutable runtime revision loaded by this process.

data class KitDocumentContext(val host: KitHostContext, val document: SessionInformation, val project: WorkspaceProjectScope)

The ordinary Field document and its project's shared parent/resources/preferences.

data class KitDocumentContext(val host: KitHostContext, val document: SessionInformation, val project: WorkspaceProjectScope)

object KitRuntime

Runtime lifecycle for the exact package paths captured by the launcher, independent of pending registry changes.

object KitRuntime {
    private data class Loaded(val context: KitHostContext, val lifecycle: KitLifecycle)
    private val loaded = linkedMapOf<String, Loaded>()
    private val activeDocuments = ConcurrentHashMap<String, KitDocumentContext>()
    private val problems = ConcurrentHashMap<String, String>()
    private var started = false
    private var shutdownHookInstalled = false

val errors: Map<String, String> get() = problems.toMap()

A snapshot of why each kit is not running, keyed by kit id, and by the package directory instead for a kit whose manifest would not read far enough to have one. KitPackages.statuses takes this to turn a registered kit into an error row in Welcome.

fun start()

Loads every kit the launcher captured in afield.kit.packages and calls KitLifecycle.startup on each, in the order the launch profile lists them. Returns at once if the kits are already started, skips a dedicated-host kit, and records a kit that throws in errors instead of propagating it.

    @Synchronized
    fun start() {
        if (started) return
        started = true
        problems.clear()
        val directories = System.getProperty("afield.kit.packages").orEmpty().split(File.pathSeparator).filter { it.isNotBlank() }
        directories.forEach { path ->
            var id = path
            runCatching {
                val directory = File(path)
                val manifest = KitPackages.read(directory); id = manifest.id
                if (manifest.host != null) return@runCatching
                val revision = KitPackages.active()[id] ?: error("Runtime profile is missing revision for $id")
                val kit = InstalledKit(id, revision, KitPackages.runtimeRevision(directory), path, path)
                val context = KitHostContext(kit, directory)
                val lifecycle = if (manifest.runtime.lifecycle.isEmpty()) object : KitLifecycle {} else {
                    Class.forName(manifest.runtime.lifecycle, true, PersonalLayer.current).getDeclaredConstructor().newInstance() as KitLifecycle
                }
                try { lifecycle.startup(context) }
                catch (error: Throwable) { runCatching { lifecycle.cleanup(context) }; throw error }
                loaded[id] = Loaded(context, lifecycle)
            }.onFailure { problems[id] = it.message ?: it.javaClass.simpleName; lprintln("error,kits", "$id startup failed: ${problems[id]}") }
        }
        // A reload calls start again; the hook is the host's, so it is added once.
        if (directories.isNotEmpty() && !shutdownHookInstalled) Runtime.getRuntime().addShutdownHook(Thread({
            val worker = Thread({ shutdown() }, "field-kit-cleanup-worker").apply { isDaemon = true }
            worker.start(); worker.join(4000)
            if (worker.isAlive) System.err.println("Field kit cleanup exceeded 4 seconds; exiting.")
        }, "field-kit-cleanup")).also { shutdownHookInstalled = true }
    }

fun suspendForReload(): List<SessionInformation>?

The documents active now, for resumeAfterReload; deactivates them and cleans up every kit. Null when the kits were never started, so there is nothing to resume.

    @Synchronized
    fun suspendForReload(): List<SessionInformation>? {
        if (!started) return null
        val documents = activeDocuments.values.map { it.document }
        shutdown()
        return documents
    }

fun resumeAfterReload(documents: List<SessionInformation>?)

Starts the kits again, from the personal layer as it is now, and reactivates documents. A document that will not reactivate is reported in errors and left inactive.

    @Synchronized
    fun resumeAfterReload(documents: List<SessionInformation>?) {
        if (documents == null) return
        start()
        documents.forEach { info ->
            runCatching { activate(info) }
                .onFailure { lprintln("error,kits", "${info.documentName} did not reactivate after the reload: ${it.message}") }
        }
    }

fun activate(info: SessionInformation)

Runs KitLifecycle.activate for a document that carries a KitInstanceRecord, inside its root box context. Does nothing for an ephemeral document, for one already active, or for one with no record, and throws when the kit is not loaded or when this host holds a different runtime revision from the one the document recorded, naming both. A failed activation is deactivated again and recorded in errors before the error is rethrown.

    @Synchronized
    fun activate(info: SessionInformation) {
        if (info.ephemeral || activeDocuments.containsKey(info.filename)) return
        val record = KitInstances.record(File(info.filename)) ?: return
        val runtime = loaded[record.kit] ?: error(problems[record.kit] ?: "Restart Field to load kit ${record.kit}")
        require(runtime.context.kit.runtimeRevision == record.runtimeRevision) {
            "Document ${info.documentName} needs kit runtime ${record.runtimeRevision.take(8)}; this host loaded ${runtime.context.kit.runtimeRevision.take(8)}. Inspect its kit update or restore its recorded kit revision."
        }
        val scope = WorkspaceProjectScopes.get(File(info.filename).parentFile, record.project)
        val context = KitDocumentContext(runtime.context, info, scope)
        try {
            Box.withBoxContextNow(info.root) { runtime.lifecycle.activate(context) }
            activeDocuments[info.filename] = context
        } catch (error: Exception) {
            runCatching { runtime.lifecycle.deactivate(context) }
            problems[record.kit] = "${info.documentName}: ${error.message}"
            throw error
        }
    }

fun deactivate(info: SessionInformation)

Runs KitLifecycle.deactivate for a document activate made active, and drops it from the active set. A document that is not active is ignored, and a lifecycle that throws is logged rather than raised, so one kit cannot hold up closing a document.

    @Synchronized
    fun deactivate(info: SessionInformation) {
        val context = activeDocuments.remove(info.filename) ?: return
        runCatching { loaded[context.host.kit.id]?.lifecycle?.deactivate(context) }
            .onFailure { lprintln("error,kits", "${context.host.kit.id} deactivate failed: ${it.message}") }
    }

fun shutdown()

Deactivates every live document, then calls KitLifecycle.cleanup on each kit in reverse load order, logging rather than raising a failure so one kit cannot strand the rest. Leaves the kits unstarted, so a later start loads them again from the classes as they then are.

    @Synchronized
    fun shutdown() {
        activeDocuments.values.toList().forEach { deactivate(it.document) }
        loaded.values.toList().asReversed().forEach { kit ->
            runCatching { kit.lifecycle.cleanup(kit.context) }.onFailure { lprintln("error,kits", "${kit.context.kit.id} cleanup failed: ${it.message}") }
        }
        loaded.clear()
        started = false
    }

fun launchBoxes(info: SessionInformation): List<Box>?

The boxes a kit document's starting point asks Field to run, in the order it names them, and null for a document with no KitInstanceRecord. The names come from the revision the document recorded rather than from the newer compatible one this host may hold, and a name matched by anything other than exactly one live box throws rather than guessing. scheduleAutorunBoxes takes this list to keep the ordinary autorun sweep off these boxes; what launches them is the first open of a copy and Welcome's explicit activation.

    @Synchronized
    fun launchBoxes(info: SessionInformation): List<Box>? {
        val record = KitInstances.record(File(info.filename)) ?: return null
        val runtime = loaded[record.kit] ?: error("Kit runtime is not active")
        val manifest = KitPackages.read(runtime.context.directory)
        // The seed must come from the instance's revision, even if this host's compatible runtime is newer.
        val seedManifest = KitPackages.read(File(KitPackages.directory, "packages/${record.kit}/${record.revision}"))
        val point = seedManifest.startingPoints[record.startingPoint] ?: error("Missing recorded starting point")
        require(manifest.id == record.kit)
        return point.launch.map { name ->
            info.boxes.values.filter { it.name == name && it.getPropertyRaw("_tombstone") != true }.singleOrNull()
                ?: error("Kit launch needs exactly one box named '$name'")
        }
    }
}