RerunCompileCache.kt
src/jvmMain/kotlin/afield/runtime/kotlin/RerunCompileCache.kt · 181 lines
package afield.runtime.kotlin
import org.jetbrains.kotlin.psi.KtProperty
import org.jetbrains.org.objectweb.asm.ClassReader
import org.jetbrains.org.objectweb.asm.ClassVisitor
import org.jetbrains.org.objectweb.asm.FieldVisitor
import org.jetbrains.org.objectweb.asm.MethodVisitor
import org.jetbrains.org.objectweb.asm.Opcodes
import kotlin.script.experimental.api.CompiledScript
import kotlin.script.experimental.jvm.impl.KJvmCompiledModuleInMemory
import kotlin.script.experimental.jvm.impl.KJvmCompiledScript
import kotlin.script.experimental.util.LinkedSnippet
Compile once, re-evaluate on rerun: one K2 engine's record of the snippets it has compiled.
A box that reruns with exactly the same expanded source (a propDown reader re-launched by its
writer, the regathered preamble in front of it) can evaluate the snippet it already has instead
of compiling it again.
On Kotlin 2.5.0-Beta1's K2ReplEvaluator, re-evaluating an old LinkedSnippet is not the same
as recompiling its source, in two ways, and the cache refuses both:
- The body does not run when the snippet is re-evaluated straight after its own previous
evaluation: the evaluator reuses the class it already loaded, and a K2 snippet's body runs
once per loaded class. (
lst.add(1); lst.sizegave 1 twice and the list kept one element.) With any other snippet evaluated in between the class is reloaded and the body runs. So a hit is refused when the cached snippet is the evaluator's last one (KotlinInterface2 checks). - Stale bindings. A snippet binds each name it uses to the snippet class that declared it
when it was compiled. If a later snippet redeclares that name, a recompile binds the new
declaration and a re-evaluation keeps the old one (
q * 2stayed 10 afterval q = 6). So every entry records which earlier snippet classes it references, and is refused once any name those classes declare has been redeclared by a later compile.
Mutable declarations and their readers also compile afresh: cached evaluation can reload historical backing classes, leaving captured mutable slots outside the evaluator's current history.
Also refused, conservatively: sources that carry @file: annotations (DependsOn refines the
compile configuration) — and a compiled import invalidates every entry, since imports may
change how already-compiled names would resolve now.
Everything here runs under the engine's compile mutex.
internal class RerunCompileCache(private val maxEntries: Int = 64)
One engine's record of compiled snippets, so an unchanged rerun evaluates instead of compiling.
internal class RerunCompileCache(private val maxEntries: Int = 64) {
class Entry
class Entry(
val snippet: LinkedSnippet<CompiledScript>,
val referencedSnippetClasses: Set<String>,
val importGeneration: Int,
val requiresFreshEvaluation: Boolean,
)
Snippet class internal name -> names that snippet declares.
private val declaredBy = HashMap<String, Set<String>>()
Declared name -> internal name of the latest snippet class that declared it.
private val latestDeclarer = HashMap<String, String>()
Snippet class internal names in compile order.
private val snippetClasses = ArrayList<String>()
Mutable declarations and snippets that depend on them cannot fork their backing classes.
private val statefulSnippetClasses = HashSet<String>()
private var importGeneration = 0
private val entries = object : LinkedHashMap<String, Entry>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Entry>?) = size > maxEntries
}
var hits
var hits = 0
private set
var misses
var misses = 0
private set
var lastMissReason: String
Why the last lookup missed, for the log.
var lastMissReason: String = ""
private set
fun cacheable(source: String): Boolean = !source.contains("@file:")
fun lookup(key: String): Entry?
fun lookup(key: String): Entry? {
val e = entries[key]
val reason = when {
e == null -> "not compiled before"
e.requiresFreshEvaluation -> "mutable bindings need their existing backing classes"
e.importGeneration != importGeneration -> "an import was compiled since"
else -> e.referencedSnippetClasses.firstNotNullOfOrNull { r ->
declaredBy[r]?.firstOrNull { latestDeclarer[it] != r }?.let { "'$it' was redeclared since" }
}
}
if (reason != null) {
if (e != null) entries.remove(key)
lastMissReason = reason
misses++
return null
}
hits++
return e
}
fun refuse(key: String, reason: String)
Count a hit KotlinInterface2 refused after lookup (see point 1 above) as a miss.
fun refuse(key: String, reason: String) {
entries.remove(key)
hits--
misses++
lastMissReason = reason
}
fun recordCompile(key: String?, source: String, snippet: LinkedSnippet<CompiledScript>)
Register a snippet that was really compiled, and cache it under key when non-null.
fun recordCompile(key: String?, source: String, snippet: LinkedSnippet<CompiledScript>) {
val script = snippet.get() as? KJvmCompiledScript
val files = (script?.getCompiledModule() as? KJvmCompiledModuleInMemory)?.compilerOutputFiles
if (script == null || files == null) {
// Unknown shape: nothing can be proven about it, so it may have shadowed anything.
importGeneration++
return
}
val own = script.scriptClassFQName.replace('.', '/')
val classFiles = files.filterKeys { it.endsWith(".class") }
// References are taken before this snippet's own declarations are registered.
val text = classFiles.values.joinToString("\u0000") { String(it, Charsets.ISO_8859_1) }
val refs = snippetClasses.filterTo(HashSet()) { it != own && text.contains(it) }
val declared = HashSet<String>()
classFiles.forEach { (path, bytes) ->
val internal = path.removeSuffix(".class")
if (internal == own) declared += declaredNames(bytes)
else if (internal.startsWith("$own$")) declared += internal.substringAfterLast('$')
}
declaredBy[own] = declared
declared.forEach { latestDeclarer[it] = own }
snippetClasses += own
if (source.lineSequence().any { it.trimStart().startsWith("import ") }) importGeneration++
val mutableState = declaresMutableState(source)
val requiresFreshEvaluation = mutableState || refs.any { it in statefulSnippetClasses }
if (requiresFreshEvaluation) statefulSnippetClasses += own
if (key != null) entries[key] = Entry(snippet, refs, importGeneration, requiresFreshEvaluation)
}
Re-evaluating a cached declaration can clone historical classes in a new loader. Closures then point at slots absent from the current REPL history. Fresh compilation preserves the existing classes, so a declaration reset can reach every older captured mutable slot.
private fun declaresMutableState(source: String): Boolean = try {
val statements = FieldKtParser.parse(source).script?.blockExpression?.statements
statements == null || statements.filterIsInstance<KtProperty>().any { it.isVar }
} catch (_: Exception) {
// Unknown source shape: preserve evaluation semantics by compiling afresh.
true
}
private fun declaredNames(bytes: ByteArray): Set<String> {
val names = HashSet<String>()
fun add(n: String) {
if (n.startsWith("<") || n.startsWith("$$") || n == "INSTANCE") return
names += n
for (p in listOf("get", "set", "is")) {
if (n.length > p.length && n.startsWith(p) && n[p.length].isUpperCase()) {
names += n.substring(p.length).replaceFirstChar { it.lowercase() }
}
}
names += n.substringBefore('$')
}
ClassReader(bytes).accept(object : ClassVisitor(Opcodes.ASM9) {
override fun visitField(a: Int, name: String, d: String?, s: String?, v: Any?): FieldVisitor? {
add(name); return null
}
override fun visitMethod(a: Int, name: String, d: String?, s: String?, e: Array<out String>?): MethodVisitor? {
add(name); return null
}
}, ClassReader.SKIP_CODE)
return names
}
}