Scripting invalidate compiled cache if a dependency is missing

#KT-48303 fixed
This commit is contained in:
Ilya Chernikov
2021-08-18 12:58:44 +02:00
committed by TeamCityServer
parent e60e80f19a
commit 61e5f68b8d
4 changed files with 146 additions and 63 deletions
@@ -7,13 +7,13 @@ package kotlin.script.experimental.jvmhost.test
import junit.framework.TestCase import junit.framework.TestCase
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.jetbrains.kotlin.cli.common.ExitCode
import org.jetbrains.kotlin.cli.jvm.K2JVMCompiler
import org.junit.Assert import org.junit.Assert
import org.junit.Ignore
import org.junit.Test import org.junit.Test
import java.io.File import java.io.*
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.security.MessageDigest import java.security.MessageDigest
import java.util.ArrayList
import kotlin.script.experimental.api.* import kotlin.script.experimental.api.*
import kotlin.script.experimental.host.toScriptSource import kotlin.script.experimental.host.toScriptSource
import kotlin.script.experimental.host.with import kotlin.script.experimental.host.with
@@ -24,13 +24,14 @@ import kotlin.script.experimental.jvm.util.classpathFromClass
import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost
import kotlin.script.experimental.jvmhost.CompiledScriptJarsCache import kotlin.script.experimental.jvmhost.CompiledScriptJarsCache
import kotlin.script.experimental.jvmhost.JvmScriptCompiler import kotlin.script.experimental.jvmhost.JvmScriptCompiler
import kotlin.script.experimental.jvmhost.loadScriptFromJar
class CachingTest : TestCase() { class CachingTest : TestCase() {
val simpleScript = "val x = 1\nprintln(\"x = \$x\")" val simpleScript = "val x = 1\nprintln(\"x = \$x\")".toScriptSource()
val simpleScriptExpectedOutput = listOf("x = 1") val simpleScriptExpectedOutput = listOf("x = 1")
val scriptWithImport = "println(\"Hello from imported \$helloScriptName script!\")" val scriptWithImport = "println(\"Hello from imported \$helloScriptName script!\")".toScriptSource()
val scriptWithImportExpectedOutput = listOf("Hello from helloWithVal script!", "Hello from imported helloWithVal script!") val scriptWithImportExpectedOutput = listOf("Hello from helloWithVal script!", "Hello from imported helloWithVal script!")
@Test @Test
@@ -117,16 +118,78 @@ class CachingTest : TestCase() {
compilationConfiguration = { compilationConfiguration = {
updateClasspath(classpathFromClass<ScriptingHostTest>()) // the class defined here should be in the classpath updateClasspath(classpathFromClass<ScriptingHostTest>()) // the class defined here should be in the classpath
implicitReceivers(Implicit::class) implicitReceivers(Implicit::class)
},
evaluationConfiguration = {
implicitReceivers(Implicit)
} }
) {
implicitReceivers(Implicit)
}
}
}
@Test
@Ignore // does not work reliably probably due to file cashing TODO: rewrite to more reliable variant
fun ignoredTestLocalDependencyWithJarCacheInvalidation() {
withTempDir("scriptingTestDepDir") { depDir ->
val standardJars = KotlinJars.kotlinScriptStandardJars
val outJar = File(depDir, "dependency.jar")
val inKt = File(depDir, "Dependency.kt").apply { writeText("class Dependency(val v: Int)") }
val outStream = ByteArrayOutputStream()
val compileExitCode = K2JVMCompiler().exec(
PrintStream(outStream),
"-d", outJar.path, "-no-stdlib", "-cp", standardJars.joinToString(File.pathSeparator), inKt.path
) )
assertTrue(
"Compilation Failed:\n$outStream",
outStream.size() == 0 && compileExitCode == ExitCode.OK && outJar.exists()
)
withTempDir("scriptingTestJarChacheWithDep") { cacheDir ->
val cache = TestCompiledScriptJarsCache(cacheDir)
Assert.assertTrue(cache.baseDir.listFiles()!!.isEmpty())
val hostConfiguration = defaultJvmScriptingHostConfiguration.with {
jvm {
baseClassLoader.replaceOnlyDefault(null)
compilationCache(cache)
}
}
val host = BasicJvmScriptingHost(compiler = JvmScriptCompiler(hostConfiguration), evaluator = BasicJvmScriptEvaluator())
val scriptCompilationConfiguration = ScriptCompilationConfiguration {
updateClasspath(standardJars +outJar)
this.hostConfiguration.update { hostConfiguration }
}
val script = "Dependency(42).v".toScriptSource()
val res0 = host.eval(script, scriptCompilationConfiguration, null).valueOrThrow().returnValue
assertEquals(42, (res0 as? ResultValue.Value)?.value)
Assert.assertEquals(1, cache.storedScripts)
Assert.assertEquals(0, cache.retrievedScripts)
val res1 = host.eval(script, scriptCompilationConfiguration, null).valueOrThrow().returnValue
assertEquals(42, (res1 as? ResultValue.Value)?.value)
Assert.assertEquals(1, cache.storedScripts)
Assert.assertEquals(1, cache.retrievedScripts)
val outJar2 = File(depDir, "dependency2.jar")
outJar.renameTo(outJar2)
val cachedScriptJar = cache.baseDir.listFiles().single()
val loadedScript = cachedScriptJar.loadScriptFromJar(checkMissingDependencies = false)
val res2 = runBlocking {
BasicJvmScriptEvaluator().invoke(loadedScript!!)
}.valueOrThrow().returnValue
assertEquals("Dependency", (res2 as? ResultValue.Error)?.error?.message)
assertNull(cachedScriptJar.loadScriptFromJar(checkMissingDependencies = true))
assertNull(cache.get(script, scriptCompilationConfiguration))
assertEquals(0, cacheDir.listFiles().size)
}
} }
} }
private fun checkWithCache( private fun checkWithCache(
cache: ScriptingCacheWithCounters, script: String, expectedOutput: List<String>, checkDirectEval: Boolean = true, cache: ScriptingCacheWithCounters, script: SourceCode, expectedOutput: List<String>, checkDirectEval: Boolean = true,
compilationConfiguration: ScriptCompilationConfiguration.Builder.() -> Unit = {}, compilationConfiguration: ScriptCompilationConfiguration.Builder.() -> Unit = {},
evaluationConfiguration: ScriptEvaluationConfiguration.Builder.() -> Unit = {} evaluationConfiguration: ScriptEvaluationConfiguration.Builder.() -> Unit = {}
) { ) {
@@ -151,7 +214,7 @@ class CachingTest : TestCase() {
var compiledScript: CompiledScript? = null var compiledScript: CompiledScript? = null
val output = captureOut { val output = captureOut {
runBlocking { runBlocking {
compiler(script.toScriptSource(), scriptCompilationConfiguration).onSuccess { compiler(script, scriptCompilationConfiguration).onSuccess {
compiledScript = it compiledScript = it
evaluator(it, scriptEvaluationConfiguration) evaluator(it, scriptEvaluationConfiguration)
}.throwOnFailure() }.throwOnFailure()
@@ -162,7 +225,7 @@ class CachingTest : TestCase() {
Assert.assertEquals(0, cache.retrievedScripts) Assert.assertEquals(0, cache.retrievedScripts)
if (checkDirectEval) { if (checkDirectEval) {
val cachedScript = cache.get(script.toScriptSource(), scriptCompilationConfiguration) val cachedScript = cache.get(script, scriptCompilationConfiguration)
Assert.assertNotNull(cachedScript) Assert.assertNotNull(cachedScript)
Assert.assertEquals(1, cache.retrievedScripts) Assert.assertEquals(1, cache.retrievedScripts)
@@ -184,7 +247,7 @@ class CachingTest : TestCase() {
} }
val output3 = captureOut { val output3 = captureOut {
host.eval(script.toScriptSource(), scriptCompilationConfiguration, scriptEvaluationConfiguration).throwOnFailure() host.eval(script, scriptCompilationConfiguration, scriptEvaluationConfiguration).throwOnFailure()
}.lines() }.lines()
Assert.assertEquals(if (checkDirectEval) 2 else 1, cache.retrievedScripts) Assert.assertEquals(if (checkDirectEval) 2 else 1, cache.retrievedScripts)
Assert.assertEquals(output, output3) Assert.assertEquals(output, output3)
@@ -6,15 +6,11 @@
package kotlin.script.experimental.jvmhost package kotlin.script.experimental.jvmhost
import java.io.File import java.io.File
import java.net.URLClassLoader import kotlin.script.experimental.api.CompiledScript
import java.util.jar.JarInputStream import kotlin.script.experimental.api.ScriptCompilationConfiguration
import kotlin.reflect.KClass import kotlin.script.experimental.api.SourceCode
import kotlin.script.experimental.api.*
import kotlin.script.experimental.jvm.CompiledJvmScriptsCache import kotlin.script.experimental.jvm.CompiledJvmScriptsCache
import kotlin.script.experimental.jvm.baseClassLoader
import kotlin.script.experimental.jvm.impl.KJvmCompiledScript import kotlin.script.experimental.jvm.impl.KJvmCompiledScript
import kotlin.script.experimental.jvm.impl.createScriptFromClassLoader
import kotlin.script.experimental.jvm.jvm
open class CompiledScriptJarsCache(val scriptToFile: (SourceCode, ScriptCompilationConfiguration) -> File?) : open class CompiledScriptJarsCache(val scriptToFile: (SourceCode, ScriptCompilationConfiguration) -> File?) :
CompiledJvmScriptsCache { CompiledJvmScriptsCache {
@@ -25,12 +21,11 @@ open class CompiledScriptJarsCache(val scriptToFile: (SourceCode, ScriptCompilat
if (!file.exists()) return null if (!file.exists()) return null
val className = file.inputStream().use { ostr -> return file.loadScriptFromJar() ?: run {
JarInputStream(ostr).use { // invalidate cache if the script cannot be loaded
it.manifest.mainAttributes.getValue("Main-Class") file.delete()
} null
} }
return KJvmCompiledScriptLazilyLoadedFromClasspath(className, listOf(file))
} }
override fun store( override fun store(
@@ -48,38 +43,3 @@ open class CompiledScriptJarsCache(val scriptToFile: (SourceCode, ScriptCompilat
} }
} }
private class KJvmCompiledScriptLazilyLoadedFromClasspath(
private val scriptClassFQName: String,
private val classPath: List<File>
) : CompiledScript {
private var loadedScript: KJvmCompiledScript? = null
fun getScriptOrError(): KJvmCompiledScript = loadedScript ?: throw RuntimeException("Compiled script is not loaded yet")
override suspend fun getClass(scriptEvaluationConfiguration: ScriptEvaluationConfiguration?): ResultWithDiagnostics<KClass<*>> {
if (loadedScript == null) {
val actualEvaluationConfiguration = scriptEvaluationConfiguration ?: ScriptEvaluationConfiguration()
val baseClassLoader = actualEvaluationConfiguration[ScriptEvaluationConfiguration.jvm.baseClassLoader]
val classLoader = URLClassLoader(
classPath.map { it.toURI().toURL() }.toTypedArray(),
baseClassLoader
)
loadedScript = createScriptFromClassLoader(scriptClassFQName, classLoader)
}
return getScriptOrError().getClass(scriptEvaluationConfiguration)
}
override val compilationConfiguration: ScriptCompilationConfiguration
get() = getScriptOrError().compilationConfiguration
override val sourceLocationId: String?
get() = getScriptOrError().sourceLocationId
override val otherScripts: List<CompiledScript>
get() = getScriptOrError().otherScripts
override val resultField: Pair<String, KotlinType>?
get() = getScriptOrError().resultField
}
@@ -8,12 +8,18 @@ package kotlin.script.experimental.jvmhost
import org.jetbrains.kotlin.utils.KotlinPaths import org.jetbrains.kotlin.utils.KotlinPaths
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.URI
import java.net.URLClassLoader
import java.util.jar.JarEntry import java.util.jar.JarEntry
import java.util.jar.JarInputStream
import java.util.jar.JarOutputStream import java.util.jar.JarOutputStream
import java.util.jar.Manifest import java.util.jar.Manifest
import kotlin.reflect.KClass
import kotlin.script.experimental.api.* import kotlin.script.experimental.api.*
import kotlin.script.experimental.jvm.JvmDependency import kotlin.script.experimental.jvm.JvmDependency
import kotlin.script.experimental.jvm.baseClassLoader
import kotlin.script.experimental.jvm.impl.* import kotlin.script.experimental.jvm.impl.*
import kotlin.script.experimental.jvm.jvm
import kotlin.script.experimental.jvm.util.scriptCompilationClasspathFromContextOrNull import kotlin.script.experimental.jvm.util.scriptCompilationClasspathFromContextOrNull
// TODO: generate execution code (main) // TODO: generate execution code (main)
@@ -58,7 +64,8 @@ fun KJvmCompiledScript.saveToJar(outputJar: File) {
classLoader = this::class.java.classLoader, classLoader = this::class.java.classLoader,
wholeClasspath = false wholeClasspath = false
) ?: emptyList() ) ?: emptyList()
val dependencies = (dependenciesFromScript + dependenciesForMain).distinct() // saving only existing files, so the check for the existence in the loadScriptFromJar is meaningful
val dependencies = (dependenciesFromScript + dependenciesForMain).distinct().filter { it.exists() }
FileOutputStream(outputJar).use { fileStream -> FileOutputStream(outputJar).use { fileStream ->
val manifest = Manifest() val manifest = Manifest()
manifest.mainAttributes.apply { manifest.mainAttributes.apply {
@@ -86,6 +93,26 @@ fun KJvmCompiledScript.saveToJar(outputJar: File) {
} }
} }
fun File.loadScriptFromJar(checkMissingDependencies: Boolean = true): CompiledScript? {
val (className: String?, classPathUrls) = this.inputStream().use { ostr ->
JarInputStream(ostr).use {
it.manifest.mainAttributes.getValue("Main-Class") to
(it.manifest.mainAttributes.getValue("Class-Path")?.split(" ") ?: emptyList())
}
}
if (className == null) return null
val classPath = classPathUrls.mapNotNullTo(mutableListOf(this)) { cpEntry ->
File(URI(cpEntry)).takeIf { it.exists() } ?: File(cpEntry).takeIf { it.exists() }
}
if (!checkMissingDependencies || classPathUrls.size + 1 == classPath.size) {
return KJvmCompiledScriptLazilyLoadedFromClasspath(className, classPath)
} else {
// Assuming that some script dependencies are not accessible anymore so the script is not valid and should be recompiled to reresolve dependencies
return null
}
}
open class BasicJvmScriptJarGenerator(val outputJar: File) : ScriptEvaluator { open class BasicJvmScriptJarGenerator(val outputJar: File) : ScriptEvaluator {
override suspend operator fun invoke( override suspend operator fun invoke(
@@ -105,6 +132,41 @@ open class BasicJvmScriptJarGenerator(val outputJar: File) : ScriptEvaluator {
} }
} }
private class KJvmCompiledScriptLazilyLoadedFromClasspath(
private val scriptClassFQName: String,
private val classPath: List<File>
) : CompiledScript {
private var loadedScript: KJvmCompiledScript? = null
fun getScriptOrError(): KJvmCompiledScript = loadedScript ?: throw RuntimeException("Compiled script is not loaded yet")
override suspend fun getClass(scriptEvaluationConfiguration: ScriptEvaluationConfiguration?): ResultWithDiagnostics<KClass<*>> {
if (loadedScript == null) {
val actualEvaluationConfiguration = scriptEvaluationConfiguration ?: ScriptEvaluationConfiguration()
val baseClassLoader = actualEvaluationConfiguration[ScriptEvaluationConfiguration.jvm.baseClassLoader]
val classLoader = URLClassLoader(
classPath.map { it.toURI().toURL() }.toTypedArray(),
baseClassLoader
)
loadedScript = createScriptFromClassLoader(scriptClassFQName, classLoader)
}
return getScriptOrError().getClass(scriptEvaluationConfiguration)
}
override val compilationConfiguration: ScriptCompilationConfiguration
get() = getScriptOrError().compilationConfiguration
override val sourceLocationId: String?
get() = getScriptOrError().sourceLocationId
override val otherScripts: List<CompiledScript>
get() = getScriptOrError().otherScripts
override val resultField: Pair<String, KotlinType>?
get() = getScriptOrError().resultField
}
private fun failure(msg: String) = private fun failure(msg: String) =
ResultWithDiagnostics.Failure(msg.asErrorDiagnostics()) ResultWithDiagnostics.Failure(msg.asErrorDiagnostics())
@@ -5,7 +5,6 @@
package kotlin.script.experimental.jvm.impl package kotlin.script.experimental.jvm.impl
import org.jetbrains.annotations.TestOnly
import java.io.* import java.io.*
import java.net.URL import java.net.URL
import java.net.URLClassLoader import java.net.URLClassLoader
@@ -94,7 +93,6 @@ open class KJvmCompiledScript internal constructor(
) )
} }
@TestOnly
fun getCompiledModule() = compiledModule fun getCompiledModule() = compiledModule
private fun writeObject(outputStream: ObjectOutputStream) { private fun writeObject(outputStream: ObjectOutputStream) {