Skip to content

Commit e985144

Browse files
committed
refactor: streamline KotlinExecutor classloader implementation and improve error handling
1 parent f542371 commit e985144

4 files changed

Lines changed: 95 additions & 135 deletions

File tree

.idea/gradle.xml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

build.gradle.kts

Lines changed: 13 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
2-
import java.io.File
32

43
plugins {
54
id("org.jetbrains.kotlin.jvm")
@@ -45,34 +44,22 @@ dependencies {
4544
// and the bridge then throws "Result type X is not serializable".
4645
compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
4746

48-
// Phase 2: Kotlin runtime execution via embedded K2 compiler — same approach as
49-
// LivePlugin. We bundle kotlin-compiler-embeddable + scripting jars in the plugin zip,
50-
// but a post-prepareSandbox task (see below) MOVES them out of `lib/` into a sibling
51-
// `kotlin-compiler/` folder so the IntelliJ PluginClassLoader never sees them. At
52-
// runtime our KotlinExecutor builds a dedicated UrlClassLoader over those jars and
53-
// drives K2JVMCompiler.exec() through reflection — exactly like LivePlugin.
47+
// Phase 2: Kotlin runtime execution.
5448
//
55-
// `runtimeOnly` keeps these off the test classpath (they bundle older IntelliJ resources
56-
// that shadow the IDE's modern ones during BasePlatformTestCase). The
57-
// testRuntimeClasspath excludes below are defensive duplicates of the same intent.
58-
runtimeOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21")
59-
runtimeOnly("org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21")
60-
runtimeOnly("org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21")
61-
runtimeOnly("org.jetbrains.kotlin:kotlin-scripting-common:2.3.21")
62-
runtimeOnly("org.jetbrains.kotlin:kotlin-stdlib:2.3.21")
63-
runtimeOnly("org.jetbrains.kotlin:kotlin-reflect:2.3.21")
64-
// Our wrapper subproject — its jar carries EmbeddedCompiler.kt and is relocated
65-
// alongside the kotlin-* jars into `kotlin-compiler/` post-prepareSandbox.
49+
// We do NOT bundle kotlin-compiler-embeddable (≈57 MB) anymore. Instead, at runtime
50+
// KotlinExecutor reaches into the IDE's bundled Kotlin plugin's classloader, which
51+
// already has the full kotlin compiler available (the Kotlin plugin uses it for its
52+
// own kts-script support). Our 8-KB wrapper jar (the `:kotlin-compiler-wrapper`
53+
// subproject) is loaded into a child URL classloader whose parent IS the Kotlin
54+
// plugin's classloader — so K2JVMCompiler resolves there without a duplicate
55+
// ApplicationEnvironment.
56+
//
57+
// Trade-off: exec.execute_kotlin_in_ide requires the user's IDE to ship the
58+
// org.jetbrains.kotlin plugin. IntelliJ IDEA, Android Studio, PyCharm Pro, GoLand,
59+
// WebStorm, RubyMine all do; DataGrip, Rider, RustRover, CLion don't. Phase 2 of this
60+
// plan adds a Maven-Central lazy-download fallback for those IDEs.
6661
runtimeOnly(project(":kotlin-compiler-wrapper"))
6762

68-
// `@KotlinScript` annotation lives in kotlin-scripting-common; we need it at compile
69-
// time on our `IntrospectorScript` template class. The annotation class isn't on the
70-
// runtime classpath after the relocate task — but the JVM doesn't require annotation
71-
// classes to be loaded for instances to exist (RuntimeRetention only matters when
72-
// reflection tries to read it). The script compiler reads the annotation via its own
73-
// classpath in kotlin-compiler/, so no conflict.
74-
compileOnly("org.jetbrains.kotlin:kotlin-scripting-common:2.3.21")
75-
7663
// docs/MCP_TOOLS.md generator — runs as part of compileKotlin; see doc-processor/.
7764
ksp(project(":doc-processor"))
7865

@@ -88,77 +75,6 @@ dependencies {
8875
}
8976
}
9077

91-
// `runtimeOnly` puts the deps on production runtime AND testRuntimeClasspath (the latter
92-
// extends the former). kotlin-compiler-embeddable bundles an older
93-
// `messages/JavaPsiBundle.properties` (and other IntelliJ platform resources) that shadow
94-
// the IDE's modern ones during tests, triggering missing-resource errors during the
95-
// platform's FileTypeManager preload. Removing the scripting stack from test runtime is
96-
// safe because tests don't exec Kotlin scripts.
97-
configurations.testRuntimeClasspath {
98-
exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-embeddable")
99-
exclude(group = "org.jetbrains.kotlin", module = "kotlin-scripting-compiler-embeddable")
100-
exclude(group = "org.jetbrains.kotlin", module = "kotlin-scripting-compiler-impl-embeddable")
101-
exclude(group = "org.jetbrains.kotlin", module = "kotlin-scripting-jvm")
102-
exclude(group = "org.jetbrains.kotlin", module = "kotlin-scripting-common")
103-
exclude(group = "org.jetbrains.kotlin", module = "kotlin-script-runtime")
104-
exclude(group = "org.jetbrains.kotlin", module = "kotlin-daemon-embeddable")
105-
}
106-
107-
// Move kotlin-compiler-embeddable + scripting + kotlin-stdlib/reflect + our wrapper jar
108-
// from `<sandbox>/plugins/ide-introspector/lib/` into a sibling `kotlin-compiler/` after
109-
// prepareSandbox. The IntelliJ PluginClassLoader only walks `lib/` — relocating these
110-
// keeps them OFF the plugin classpath, so there are no kotlin class duplicates between
111-
// our bundled compiler and the IDE's bundled Kotlin plugin. KotlinExecutor reads them via
112-
// `pluginPath/kotlin-compiler/` at runtime into a dedicated UrlClassLoader.
113-
//
114-
// Same approach as LivePlugin's "Move kotlin compiler jars from plugin classpath into a
115-
// separate folder so that there are no conflicts" task in its build.gradle.
116-
// The path is computed inside doLast purely with java.io.File ops (no Project script
117-
// references) but Gradle's configuration cache still flags closure capture. Opting out
118-
// for this task only is the pragmatic move — relocation is fast enough that recomputing
119-
// on every build is not a problem.
120-
val sandboxRootPath: String = layout.projectDirectory.dir(".intellijPlatform/sandbox").asFile.absolutePath
121-
val relocateKotlinCompilerJars = tasks.register("relocateKotlinCompilerJars") {
122-
notCompatibleWithConfigurationCache("Walks live sandbox plugin tree")
123-
dependsOn("prepareSandbox")
124-
doLast {
125-
val root = File(sandboxRootPath)
126-
val pluginDir: File = root.takeIf { it.isDirectory }
127-
?.walkTopDown()
128-
?.firstOrNull { it.name == "ide-introspector" && it.parentFile?.name == "plugins" }
129-
?: error("relocateKotlinCompilerJars: no sandbox plugin dir found under $sandboxRootPath")
130-
val libDir = pluginDir.resolve("lib")
131-
val targetDir = pluginDir.resolve("kotlin-compiler").apply { mkdirs() }
132-
val relocatablePrefixes = listOf(
133-
"kotlin-compiler-embeddable",
134-
"kotlin-scripting-compiler",
135-
"kotlin-scripting-common",
136-
"kotlin-scripting-jvm",
137-
"kotlin-daemon-embeddable",
138-
"kotlin-script-runtime",
139-
"kotlin-stdlib",
140-
"kotlin-reflect",
141-
"kotlinx-coroutines",
142-
"kotlin-compiler-wrapper",
143-
// org.jetbrains.annotations — required by Kotlin compiler's AnnotationCodegen
144-
// to emit @Nullable on generated bytecode; bundled as a Kotlin compiler
145-
// transitive but must live next to the compiler, NOT in plugin classpath.
146-
"annotations-",
147-
)
148-
val moved = mutableListOf<String>()
149-
libDir.listFiles()?.forEach { f ->
150-
if (f.isFile && f.name.endsWith(".jar") && relocatablePrefixes.any { f.name.startsWith(it) }) {
151-
val dest = targetDir.resolve(f.name)
152-
if (dest.exists()) dest.delete()
153-
check(f.renameTo(dest)) { "Failed to move ${f.name} to kotlin-compiler/" }
154-
moved += f.name
155-
}
156-
}
157-
logger.lifecycle("Relocated ${moved.size} kotlin-* jars to ${targetDir.relativeTo(pluginDir)}/: ${moved.joinToString()}")
158-
}
159-
}
160-
tasks.named("prepareSandbox").configure { finalizedBy(relocateKotlinCompilerJars) }
161-
16278
intellijPlatform {
16379
pluginConfiguration {
16480
ideaVersion {

kotlin-compiler-wrapper/build.gradle.kts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ plugins {
33
}
44

55
dependencies {
6-
// The K2 compiler + scripting infrastructure used by EmbeddedCompiler.compile().
7-
// `implementation` here so the symbols resolve at compile time inside this subproject
8-
// ONLY. The main `:` project never depends on these — it loads this jar (and the
9-
// bundled kotlin-* jars) into a separate UrlClassLoader at runtime.
10-
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21")
11-
implementation("org.jetbrains.kotlin:kotlin-scripting-common:2.3.21")
12-
implementation("org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21")
13-
implementation("org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21")
6+
// `compileOnly` — these resolve K2JVMCompiler / MessageCollector / Services symbols at
7+
// compile time but are NOT carried into the main plugin's runtime classpath. At runtime
8+
// this jar (only ~8 KB) is loaded into a UrlClassLoader whose parent is the IDE's
9+
// Kotlin-plugin classloader, which provides all of these classes via its own jars in
10+
// <Kotlin plugin>/kotlinc/lib/. Avoids bundling 57 MB of kotlin-compiler-embeddable in
11+
// the plugin distribution.
12+
compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21")
13+
compileOnly("org.jetbrains.kotlin:kotlin-scripting-common:2.3.21")
14+
compileOnly("org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21")
15+
compileOnly("org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21")
1416
}
1517

1618
kotlin {

src/main/kotlin/com/github/xepozz/ide/introspector/exec/KotlinExecutor.kt

Lines changed: 71 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import java.io.File
1414
import java.io.PrintStream
1515
import java.net.URLClassLoader
1616
import java.nio.file.Files
17-
import java.nio.file.Path
1817
import java.util.concurrent.Callable
1918
import java.util.concurrent.Executors
2019
import java.util.concurrent.Future
@@ -141,10 +140,14 @@ object KotlinExecutor {
141140
elapsedMs(startNs), warnings)
142141
}
143142
} catch (t: Throwable) {
143+
// Unwrap InvocationTargetException + chained causes — Method.invoke wraps every
144+
// failure, often with an inner cause whose message is the real diagnostic.
145+
val root = generateSequence<Throwable>(t) { it.cause }.lastOrNull() ?: t
146+
val rootMessage = root.message ?: root.javaClass.simpleName
144147
return ExecutionResult(false, null, null,
145148
stdoutBuf.toString().ifEmpty { null },
146149
stderrBuf.toString().ifEmpty { null },
147-
"Execution setup failed: ${t.javaClass.simpleName}: ${t.message}",
150+
"Execution setup failed: ${root.javaClass.simpleName}: $rootMessage\n${root.stackTraceToString().take(3000)}",
148151
elapsedMs(startNs), warnings)
149152
} finally {
150153
if (args.captureStdout) System.setOut(originalOut)
@@ -179,74 +182,112 @@ object KotlinExecutor {
179182
) as List<String>
180183
}
181184

182-
/** Isolated classloader for the embedded Kotlin compiler — must NOT chain into the IDE. */
185+
/**
186+
* Classloader for `EmbeddedCompilerKt.compile()`. We build a **fresh, isolated**
187+
* UrlClassLoader over (a) the IDE Kotlin plugin's `kotlinc/lib` jars (full
188+
* kotlin-compiler + scripting + stdlib) plus (b) our 8-KB wrapper jar plus (c) the
189+
* JDK class roots. Parent is the JDK bootstrap loader — NOT the IDE's PluginClassLoader.
190+
*
191+
* Why isolated and not just `kotlinPlugin.classLoader` as parent: the Kotlin plugin
192+
* exposes its public API via PluginClassLoader but **does NOT export CLI compiler
193+
* internals** (`org.jetbrains.kotlin.cli.common.messages.MessageRenderer`, etc.) — they
194+
* live in the Kotlin plugin's separate `kotlinc/lib/kotlin-compiler.jar` and are
195+
* loaded only when the plugin internally needs them.
196+
*
197+
* Why parent = null and not IDE classloader: kotlin-compiler.jar carries duplicate
198+
* `com.intellij.openapi.*` classes (un-shaded). Mixing them with the IDE's own
199+
* com.intellij.* in one classloader chain leads to "class X seen from two loaders"
200+
* errors and `ApplicationManager.ourApplication` being initialised twice.
201+
*
202+
* Trade-off: requires the org.jetbrains.kotlin plugin to be installed. Most IntelliJ
203+
* IDEs ship it (IDEA, Android Studio, PyCharm Pro, GoLand, WebStorm, RubyMine);
204+
* DataGrip, Rider, RustRover, CLion don't. Phase 2 adds a Maven-download fallback.
205+
*/
183206
private val compilerClassLoader: ClassLoader by lazy {
184-
val jars = pluginKotlinCompilerJars()
185-
check(jars.isNotEmpty()) {
186-
"No kotlin-compiler jars found in <plugin>/kotlin-compiler — was the prepareSandbox relocate task run?"
207+
val wrapperJar = findWrapperJar()
208+
?: error("kotlin-compiler-wrapper jar not found in plugin's lib/ — the build is broken.")
209+
val kotlincJars = kotlincJars()
210+
check(kotlincJars.isNotEmpty()) {
211+
"Couldn't find the Kotlin plugin's kotlinc/lib jars in this IDE. " +
212+
"exec.execute_kotlin_in_ide requires the org.jetbrains.kotlin plugin. " +
213+
"Install it from Settings → Plugins → Marketplace and restart."
187214
}
188215
UrlClassLoader.build()
189-
.files(jdkClassRoots() + jars.map(File::toPath))
216+
.files((listOf(wrapperJar) + kotlincJars).map(File::toPath) + jdkClassRoots())
190217
.noPreload()
191218
.allowBootstrapResources()
192219
.useCache()
193220
.get()
194221
}
195222

196-
private fun pluginKotlinCompilerJars(): List<File> {
223+
private fun findWrapperJar(): File? {
197224
val ourDescriptor = PluginLookup.findPlugin(PluginId.getId("com.github.xepozz.ide.introspector"))
198-
?: return emptyList()
199-
val dir = ourDescriptor.pluginPath?.toFile()?.resolve("kotlin-compiler") ?: return emptyList()
200-
if (!dir.isDirectory) return emptyList()
201-
return dir.listFiles { f -> f.isFile && f.name.endsWith(".jar") }?.toList().orEmpty()
225+
?: return null
226+
val libDir = ourDescriptor.pluginPath?.toFile()?.resolve("lib") ?: return null
227+
return libDir.listFiles { f -> f.isFile && f.name.startsWith("kotlin-compiler-wrapper") && f.name.endsWith(".jar") }
228+
?.firstOrNull()
229+
}
230+
231+
/**
232+
* The Kotlin IDE plugin ships its full compiler under `<plugin>/kotlinc/lib/`. We pull
233+
* every jar from there except the `*-sources.jar` files (no need at runtime) and the
234+
* Kotlin **compiler plugins** (which target IntelliJ APIs and clash with our isolated
235+
* loader). LivePlugin uses the same filter rule.
236+
*/
237+
private fun kotlincJars(): List<File> {
238+
val kotlinPlugin = PluginLookup.findPlugin(PluginId.getId("org.jetbrains.kotlin")) ?: return emptyList()
239+
val kotlincLib = kotlinPlugin.pluginPath?.toFile()?.resolve("kotlinc/lib") ?: return emptyList()
240+
if (!kotlincLib.isDirectory) return emptyList()
241+
return kotlincLib.listFiles { f ->
242+
f.isFile && f.name.endsWith(".jar") &&
243+
!f.name.endsWith("-sources.jar") &&
244+
!f.name.contains("compiler-plugin")
245+
}?.toList().orEmpty()
202246
}
203247

204248
/**
205-
* Best-effort discovery of JDK class roots (rt.jar / java.base modules) that the
206-
* embedded compiler needs to resolve `java.*` types. Falls back to scanning
207-
* `${java.home}/lib/modules` and a few well-known directories.
249+
* Best-effort discovery of JDK class roots that the compiler needs to resolve `java.*`.
250+
* Tries IntelliJ's `JavaSdkUtil.getJdkClassesRoots` reflectively first, falls back to
251+
* the `${java.home}/lib/modules` jimage file (works on JDK 9+).
208252
*/
209-
private fun jdkClassRoots(): List<Path> {
253+
private fun jdkClassRoots(): List<java.nio.file.Path> {
210254
val jdkRoot = File(System.getProperty("java.home"))
211-
// Try IntelliJ's JavaSdkUtil reflectively — present in jps-model.jar in modern IDEs.
212255
try {
213256
val cls = Class.forName("org.jetbrains.jps.model.java.impl.JavaSdkUtil")
214257
val m = cls.methods.firstOrNull { it.name == "getJdkClassesRoots" && it.parameterCount == 2 }
215258
if (m != null) {
216259
@Suppress("UNCHECKED_CAST")
217-
return m.invoke(null, jdkRoot.toPath(), true) as List<Path>
260+
return m.invoke(null, jdkRoot.toPath(), true) as List<java.nio.file.Path>
218261
}
219262
} catch (_: Throwable) { /* fall through */ }
220-
// Fallback: just the jrt-fs JDK image — works on JDK 9+.
221263
val jrtModules = jdkRoot.resolve("lib/modules")
222264
return if (jrtModules.isFile) listOf(jrtModules.toPath()) else emptyList()
223265
}
224266

225267
private fun buildCompilerClasspath(): List<File> {
226268
val out = LinkedHashSet<File>()
227269

228-
// 1) Every jar in the IDE's main lib/ — gives us com.intellij.* platform classes.
270+
// 1) Every jar in the IDE's main lib/ — com.intellij.* platform classes.
229271
val ideLib = File(PathManager.getLibPath())
230272
if (ideLib.isDirectory) {
231273
ideLib.listFiles { f -> f.isFile && (f.name.endsWith(".jar") || f.name.endsWith(".zip")) }
232274
?.let { out.addAll(it) }
233275
}
234276

235-
// 2) Our own plugin's lib/ — so user code can reference com.github.xepozz.* types.
277+
// 2) Our own plugin's lib/ — user code can reference com.github.xepozz.* types.
236278
val ourDescriptor = PluginLookup.findPlugin(PluginId.getId("com.github.xepozz.ide.introspector"))
237279
ourDescriptor?.pluginPath?.toFile()?.resolve("lib")?.listFiles { f ->
238280
f.isFile && f.name.endsWith(".jar")
239281
}?.let { out.addAll(it) }
240282

241-
// 3) kotlin-stdlib + kotlin-reflect from OUR isolated kotlin-compiler/ — provides
242-
// kotlin.* / kotlin.collections.* symbols at compile time. We deliberately do
243-
// NOT pull in the IDE Kotlin plugin's kotlinc/lib/kotlin-compiler.jar — that's
244-
// a 156 MB "fat" jar with duplicate copies of many compiler-internal classes
245-
// that confuse K2's codegen ("Exception while generating code for run …").
246-
// At runtime the JVM resolves kotlin.* via the IDE classloader chain (Kotlin
247-
// plugin's classloader), which sees its own bundled stdlib — versions match in
248-
// practice because we pin our bundled stdlib (2.3.21) close to the IDE's.
249-
ourDescriptor?.pluginPath?.toFile()?.resolve("kotlin-compiler")?.listFiles { f ->
283+
// 3) Kotlin plugin's bundled `kotlinc/lib/kotlin-stdlib*.jar` + `kotlin-reflect.jar` +
284+
// coroutines — kotlin.* / kotlin.collections.* symbols at compile time. Skip the
285+
// bigger `kotlin-compiler.jar` (156 MB) — it carries duplicate IntelliJ classes
286+
// that confuse K2 codegen (the user code's compile classpath should NOT include
287+
// the compiler itself).
288+
val kotlinPlugin = PluginLookup.findPlugin(PluginId.getId("org.jetbrains.kotlin"))
289+
val kotlinPluginRoot = kotlinPlugin?.pluginPath?.toFile()
290+
kotlinPluginRoot?.resolve("kotlinc/lib")?.listFiles { f ->
250291
f.isFile && f.name.endsWith(".jar") && (
251292
f.name.startsWith("kotlin-stdlib") ||
252293
f.name.startsWith("kotlin-reflect") ||

0 commit comments

Comments
 (0)