Skip to content

PersonalLayer.kt

src/jvmMain/kotlin/afield/app/settings/PersonalLayer.kt · 257 lines

package afield.app.settings

import afield.app.server.Server
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.runtime.execution.plus
import afield.runtime.kotlin.KotlinInterface2
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.with

import afield.audio.synth.*
import afield.data.table.get
import afield.data.table.ref
import afield.document.graph.layers
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.map
import afield.ui.output.templates.filter
import afield.app.diagnostics.logging.lprintln
import java.io.File
import java.lang.ref.WeakReference
import java.net.URLClassLoader
import java.util.IdentityHashMap

Loads the gitignored src/personal jar in its own classloader between core Field and the REPL, so box code can use personal classes and the layer can be replaced without restarting.

PersonalLayer owns the loader; PersonalReload.kt handles the objects an old layer leaves behind after a reload.

object PersonalLayer

The classloader layer that holds src/personal.

src/personal is gitignored, local-only code. It compiles as its own JVM compilation (see the personal compilation on the jvm target in build.gradle.kts) into a single jar, and that jar is deliberately kept off the launch -cp. Instead the launch args carry -Dafield.personal.classpath=<jar> and the app loads it here, in a URLClassLoader slotted between the app classloader and KotlinInterface2.sharedClassLoader:

  app loader          core Field, LWJGL, ktor, the JNI-bearing deps   [stable]
    └─ personal       afield-personal.jar                            [swappable]
         └─ KI2.sharedClassLoader (empty URLClassLoader)
              └─ per-snippet REPL loaders (K2ReplEvaluator chain)

Nothing in core references a personal package, so nothing above the boundary needs personal classes; everything that does — box code, and the REPL compiler resolving it — reaches them by ordinary parent delegation from below. The compile side needs no configuration: KI2's dependenciesFromClassloader(wholeClasspath = true) walks the parent chain collecting each loader's URLs, so the personal jar lands in the REPL compile classpath automatically.

The point of the indirection is that the layer is droppable: with personal reachable only through one loader, the layer can be retired (advance) and rebuilt without restarting. See reloadPersonal in PersonalReload.kt for what that costs on the far side — the engines holding stale classes, and how they are found.

Note that personal's third-party dependencies stay on the main classpath, in the stable layer, rather than moving up with it. Several of them (ortools, jolt-jni) load JNI natives, and a native library can only ever be loaded by one classloader per JVM — a swappable layer that loaded them would die on the second reload with "Native Library ... already loaded in another classloader".

object PersonalLayer {

const val CLASSPATH_PROPERTY = "afield.personal.classpath"

Set by requiredRunJvmArgs in build.gradle.kts; File.pathSeparator-separated.

val roots: List<File> by lazy

The roots making up the layer, or empty when there is no personal code — which is the normal state for anyone but Marc, and also what you get before the first ./gradlew personalJar.

    val roots: List<File> by lazy {
        val declared = System.getProperty(CLASSPATH_PROPERTY).orEmpty()
            .split(File.pathSeparatorChar)
            .map(String::trim)
            .filter(String::isNotEmpty)
            .map(::File)
            .distinct()
        val (present, missing) = declared.partition { it.exists() }
        if (missing.isNotEmpty()) {
            // Worth shouting about: a silently-absent personal layer degrades quietly (box code
            // stops resolving, completion stops offering) rather than failing loudly.
            lprintln(
                "warning,startup",
                "personal layer: ${missing.size} declared root(s) do not exist and were skipped " +
                    "(${missing.joinToString { it.path }}) — run ./gradlew personalJar"
            )
        }
        present
    }

val isPresent: Boolean get() = roots.isNotEmpty()

var generation: Int

How many times advance has run. Stamped into each layer's name and into the files it reads, so a stale object can be attributed to a specific reload in an error message.

    var generation: Int = 0
        private set

    private val layers = IdentityHashMap<ClassLoader, ClassLoader>()

    private var stagedRoots: List<File>? = null

val activeRoots: List<File>

The files the live layer actually reads: a per-generation copy of roots.

The copy is not paranoia. A URLClassLoader holds its jars open and caches their entries, and the only way to release them is close() — which we must never do to a retired layer, since engines still rooted above it would start failing to resolve. So rebuilding afield-personal.jar in place and pointing a new loader at the same path can serve the old bytes: the reload appears to succeed and changes nothing. Staging each generation at its own path sidesteps the whole question.

The path is per process as well as per generation. Every Field started from one checkout shares build/personal, and with a bare gen-N a second Field (a backdoor-managed server, a test run) staging its own gen-0 overwrote the jar a running Field held open: that Field could still use the classes it had loaded but no longer read their bytes (getResourceAsStream gave null), which held every house panel build at its identity check until a restart.

Directories are passed through — a directory root is re-read from the filesystem on each lookup, so it has no staleness problem to solve. A process's own old generations are left behind deliberately, since their layers may still be alive; whole directories of processes that have exited are removed the next time any Field stages.

    val activeRoots: List<File>
        @Synchronized get() = stagedRoots ?: stage().also { stagedRoots = it }

internal fun stagedPath(root: File, generation: Int, pid: Long = ProcessHandle.current().pid()): File

Where process pid stages root for generation.

    internal fun stagedPath(root: File, generation: Int, pid: Long = ProcessHandle.current().pid()): File =
        File(root.parentFile, "generations/pid-$pid/gen-$generation/${root.name}")

internal fun removeExitedStaging

Removes the pid-* staging directories of exited processes; self, live and unparsable names stay.

    internal fun removeExitedStaging(
        generations: File,
        self: Long = ProcessHandle.current().pid(),
        alive: (Long) -> Boolean = { pid -> ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false) },
    ) {
        generations.listFiles { f -> f.isDirectory && f.name.startsWith("pid-") }?.forEach { dir ->
            val pid = dir.name.removePrefix("pid-").toLongOrNull() ?: return@forEach
            if (pid != self && !alive(pid)) dir.deleteRecursively()
        }
    }

    private fun stage(): List<File> = roots.map { root ->
        if (root.isDirectory) return@map root
        // Kit JARs already live at immutable, content-addressed paths. Staging beside them would
        // write generations into the package snapshot and invalidate its integrity on the next build.
        val packages = File(afield.document.workspace.KitPackages.directory, "packages").canonicalFile.toPath()
        if (root.canonicalFile.toPath().startsWith(packages)) return@map root
        runCatching {
            runCatching { removeExitedStaging(File(root.parentFile, "generations")) }
            val staged = stagedPath(root, generation)
            staged.parentFile.mkdirs()
            root.copyTo(staged, overwrite = true)
        }.getOrElse {
            lprintln("warning,startup", "personal layer: could not stage ${root.name} (${it.message}); using it in place")
            root
        }
    }

fun wrap(base: ClassLoader): ClassLoader

The layer built on top of base, creating it on first use.

Idempotent in the way callers need: base may itself be a personal layer (Server.main installs one as the thread context classloader), in which case this resolves to the current layer over the same stable base rather than stacking a second one on top. That matters after advance — stacking gen-1 above gen-0 would leave gen-0's classes winning parent-first delegation and make the old layer uncollectable, which is precisely the reload silently not happening.

    @Synchronized
    fun wrap(base: ClassLoader): ClassLoader {
        if (roots.isEmpty()) return base
        val stable = generateSequence(base) { it.parent }.firstOrNull { !isOurs(it) } ?: base
        return layers.getOrPut(stable) {
            URLClassLoader(
                "afield-personal-gen$generation",
                activeRoots.map { it.toURI().toURL() }.toTypedArray(),
                stable
            ).also {
                lprintln(
                    "info,startup",
                    "personal layer: gen$generation loaded ${roots.joinToString { it.name }} above ${stable.name ?: stable.javaClass.simpleName}"
                )
            }
        }
    }

    private fun isOurs(loader: ClassLoader): Boolean =
        layers.values.any { it === loader } || retired.any { it.owns(loader) }

fun advance(): Int?

Retire every live layer and move to the next generation. The next wrap/current builds a fresh layer over a fresh copy of the jar; callers are responsible for disposing the engines that were using the old one (see reloadPersonal).

The retired loaders are deliberately not closed. Anything still holding one — an engine we chose not to reset, a live object, a registered callback — must keep resolving; a closed loader would turn a stale-class problem into a NoClassDefFoundError storm. They go instead into retired, weakly, so a class can later be attributed to the reload that orphaned it, and so they can be collected once nothing references them.

@return the generation just retired, or null if there was nothing live to retire.

    @Synchronized
    fun advance(): Int? {
        if (roots.isEmpty()) return null
        val retiring = layers.values.distinct()
        val was = generation
        layers.values.forEach { retired += Retired(was, it) }
        layers.clear()
        stagedRoots = null
        generation++
        retired.removeAll { it.collected }
        lprintln("info,personal", "personal layer: retired gen$was (${retiring.size} loader(s)), now on gen$generation")
        return if (retiring.isEmpty()) null else was
    }

    private class Retired(val generation: Int, loader: ClassLoader) {
        private val ref = WeakReference(loader)
        val collected: Boolean get() = ref.get() == null
        fun owns(loader: ClassLoader?): Boolean = loader != null && ref.get() === loader
    }

    private val retired = mutableListOf<Retired>()

val hasRetiredLayers: Boolean @Synchronized get() = retired.isNotEmpty()

Whether advance has retired a layer that is still listed. PersonalReload.kt's staleness checks return early when it is false, so nothing walks the retired list on the normal path.

fun retiredGenerationOf(type: Class<*>?): Int?

The generation that owned type, when type came from a layer a later reload retired, which is what makes an object holding it stale. Null for everything else, including classes from the live layer.

    @Synchronized
    fun retiredGenerationOf(type: Class<*>?): Int? {
        val loader = type?.classLoader ?: return null
        return retired.firstOrNull { it.owns(loader) }?.generation
    }

val current: ClassLoader

The layer over the loader that loaded core Field.

    val current: ClassLoader
        get() = wrap(PersonalLayer::class.java.classLoader ?: ClassLoader.getSystemClassLoader())
}