Skip to content

Commit 4279668

Browse files
stephanjclaude
andcommitted
fix: stop bundling platform coroutines jar; restore plugin build (#1054)
Inline completion crashed on first request with: ClassCastException: kotlinx.coroutines.flow.internal.SafeCollector cannot be cast to kotlinx.coroutines.flow.FlowCollector The published distribution bundled its own kotlinx-coroutines-core-jvm jar, so the plugin classloader loaded FlowCollector while the platform classloader loaded SafeCollector, failing the cross-classloader cast when the platform inline-completion API handed a Flow to DevoxxGenieInlineCompletionProvider. Chat mode does not use the platform Flow machinery, so it was unaffected. stripBinaryIncompatibleRuntimeJars only cleaned the runIde/test sandboxes, not the buildPlugin distribution that ships to the Marketplace, so the bug was invisible in development. Changes: - Exclude kotlinx-coroutines-core(-jvm) from the base multiplatform-markdown -renderer-jvm dependency (the leak source; the -code-jvm variant already did). - Filter the platform-provided runtime jars directly out of the buildPlugin Zip as defense-in-depth against future transitive reintroduction. - Add PluginDistributionPackagingTest as a regression guard on the built ZIP. Also restore the build: since 2025.3 (build 253) IntelliJ IDEA is a single unified product and the separate Community (idea:ideaIC) Maven artifact is no longer published, so create("IC", ...) could not resolve. Switch to the unified intellijIdea(...) accessor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent af456e1 commit 4279668

2 files changed

Lines changed: 141 additions & 2 deletions

File tree

build.gradle.kts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,16 @@ tasks.register("updateProperties") {
115115
}
116116
}
117117

118-
tasks.named("buildPlugin") {
118+
tasks.named<Zip>("buildPlugin") {
119119
dependsOn("updateProperties")
120+
121+
// Defense-in-depth for issue #1054: never ship runtime jars the IntelliJ Platform already
122+
// provides. stripBinaryIncompatibleRuntimeJars only cleans the runIde/test sandboxes; the
123+
// published distribution is produced by this Zip task, so filter the same jars out here
124+
// regardless of which transitive dependency reintroduces them.
125+
binaryIncompatibleRuntimeJarPatterns.forEach { pattern ->
126+
exclude("**/lib/$pattern")
127+
}
120128
}
121129

122130
// Diagnostic CLI for the RAG store. Talks to the same ChromaDB + Ollama the plugin uses.
@@ -219,8 +227,11 @@ tasks.named("processResources") {
219227

220228
dependencies {
221229
intellijPlatform {
230+
// Starting with 2025.3 (build 253) IntelliJ IDEA is a single unified product: the
231+
// separate Community (IC) Maven artifact is no longer published, so create("IC", ...)
232+
// can no longer resolve. Use the unified intellijIdea(...) accessor instead.
222233
// Allow overriding IDE version via property: ./gradlew runIde -PideVersion=2026.1
223-
create("IC", providers.gradleProperty("ideVersion").orElse("2025.3.3")) {}
234+
intellijIdea(providers.gradleProperty("ideVersion").orElse("2025.3.3"))
224235
bundledPlugin("com.intellij.java")
225236
bundledPlugin("org.intellij.plugins.markdown") // Required by markdown renderer
226237
composeUI()
@@ -287,6 +298,11 @@ dependencies {
287298
implementation("io.netty:netty-all:$nettyVersion")
288299
// Compose Markdown Renderer aligned with the 251 Compose/Kotlin toolchain
289300
implementation("com.mikepenz:multiplatform-markdown-renderer-jvm:$markdownRendererVersion") {
301+
// The IntelliJ Platform provides coroutines at runtime. Bundling our own copy makes the
302+
// plugin classloader load kotlinx.coroutines.flow.FlowCollector while the platform loads
303+
// SafeCollector, causing a cross-classloader ClassCastException (issue #1054).
304+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
305+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core-jvm")
290306
exclude(group = "org.jetbrains", module = "markdown")
291307
}
292308
implementation("com.mikepenz:multiplatform-markdown-renderer-code-jvm:$markdownRendererVersion") {
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.devoxx.genie.packaging;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.io.File;
6+
import java.nio.file.Path;
7+
import java.nio.file.Paths;
8+
import java.util.ArrayList;
9+
import java.util.Arrays;
10+
import java.util.Comparator;
11+
import java.util.List;
12+
import java.util.Optional;
13+
import java.util.regex.Pattern;
14+
import java.util.zip.ZipEntry;
15+
import java.util.zip.ZipFile;
16+
17+
import static org.junit.jupiter.api.Assertions.fail;
18+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
19+
20+
/**
21+
* Regression test for issue #1054.
22+
*
23+
* <p>The IntelliJ Platform ships its own Kotlin coroutines runtime. When the plugin
24+
* distribution bundles its <em>own</em> copy of {@code kotlinx-coroutines-core(-jvm)} the
25+
* plugin's {@code PluginClassLoader} loads classes such as
26+
* {@code kotlinx.coroutines.flow.FlowCollector} while the platform's {@code PathClassLoader}
27+
* loads {@code kotlinx.coroutines.flow.internal.SafeCollector}. When the platform inline
28+
* completion API hands a {@code SafeCollector} to the plugin's compiled Kotlin
29+
* ({@code DevoxxGenieInlineCompletionProvider.getSuggestionDebounced}) the cross-classloader
30+
* cast fails with:
31+
*
32+
* <pre>
33+
* java.lang.ClassCastException: class kotlinx.coroutines.flow.internal.SafeCollector
34+
* cannot be cast to class kotlinx.coroutines.flow.FlowCollector
35+
* (SafeCollector is in unnamed module of loader PathClassLoader;
36+
* FlowCollector is in unnamed module of loader PluginClassLoader)
37+
* </pre>
38+
*
39+
* <p>{@code stripBinaryIncompatibleRuntimeJars} already removes these jars from the
40+
* {@code prepareSandbox}/{@code prepareTestSandbox} outputs (so {@code runIde} and the
41+
* test sandbox are clean and the bug is invisible during development), but it was NOT applied
42+
* to the {@code buildPlugin} distribution that is actually published. This test inspects the
43+
* built distribution ZIP and fails if any forbidden coroutines jar is present in the plugin
44+
* {@code lib/} directory.
45+
*/
46+
class PluginDistributionPackagingTest {
47+
48+
/**
49+
* Jars that must never be bundled in the plugin distribution because the IntelliJ Platform
50+
* provides them at runtime. Mirrors {@code binaryIncompatibleRuntimeJarPatterns} in
51+
* {@code build.gradle.kts} (the coroutines entries).
52+
*/
53+
private static final List<Pattern> FORBIDDEN_LIB_JARS = Arrays.asList(
54+
Pattern.compile("kotlinx-coroutines-core-.*\\.jar"),
55+
Pattern.compile("kotlinx-coroutines-core-jvm-.*\\.jar")
56+
);
57+
58+
@Test
59+
void distributionMustNotBundlePlatformCoroutinesJars() throws Exception {
60+
Optional<File> distribution = findLatestDistributionZip();
61+
62+
// The distribution is only produced by `./gradlew buildPlugin`. When it is absent
63+
// (e.g. a plain `./gradlew test` on a clean checkout) there is nothing to verify, so
64+
// skip rather than fail spuriously. Run `./gradlew buildPlugin` to exercise this test.
65+
assumeTrue(distribution.isPresent(),
66+
"No plugin distribution ZIP found under build/distributions/ - run ./gradlew buildPlugin first");
67+
68+
File zip = distribution.get();
69+
List<String> offending = new ArrayList<>();
70+
71+
try (ZipFile zipFile = new ZipFile(zip)) {
72+
var entries = zipFile.entries();
73+
while (entries.hasMoreElements()) {
74+
ZipEntry entry = entries.nextElement();
75+
String name = entry.getName();
76+
if (entry.isDirectory()) {
77+
continue;
78+
}
79+
// Only inspect jars that live in the packaged plugin's lib/ directory,
80+
// e.g. "DevoxxGenie/lib/kotlinx-coroutines-core-jvm-1.8.0.jar".
81+
int libIdx = name.indexOf("/lib/");
82+
if (libIdx < 0) {
83+
continue;
84+
}
85+
String fileName = name.substring(name.lastIndexOf('/') + 1);
86+
for (Pattern forbidden : FORBIDDEN_LIB_JARS) {
87+
if (forbidden.matcher(fileName).matches()) {
88+
offending.add(name);
89+
break;
90+
}
91+
}
92+
}
93+
}
94+
95+
if (!offending.isEmpty()) {
96+
fail("Plugin distribution '" + zip.getName() + "' bundles coroutines jars that conflict "
97+
+ "with the IntelliJ Platform classloader (issue #1054). "
98+
+ "These must be excluded from the published plugin:\n "
99+
+ String.join("\n ", offending));
100+
}
101+
}
102+
103+
/**
104+
* Locates the most recently modified plugin distribution ZIP produced by buildPlugin.
105+
* Walks up from the working directory so the test works regardless of the module the
106+
* test runner is launched from.
107+
*/
108+
private static Optional<File> findLatestDistributionZip() {
109+
Path dir = Paths.get("").toAbsolutePath();
110+
for (int depth = 0; depth < 4 && dir != null; depth++) {
111+
File distributions = dir.resolve("build").resolve("distributions").toFile();
112+
if (distributions.isDirectory()) {
113+
File[] zips = distributions.listFiles(
114+
(d, fileName) -> fileName.endsWith(".zip") && fileName.startsWith("DevoxxGenie"));
115+
if (zips != null && zips.length > 0) {
116+
return Arrays.stream(zips).max(Comparator.comparingLong(File::lastModified));
117+
}
118+
}
119+
dir = dir.getParent();
120+
}
121+
return Optional.empty();
122+
}
123+
}

0 commit comments

Comments
 (0)