Skip to content

Commit 6ba497b

Browse files
authored
fix: Prevent stale JAR cache in ReflectionsClassFinder under Gradle daemon (CP: 24.9) (#23969)
Close `URLClassLoader` on cleanup to release JAR file handles, and disable JVM-level JAR caching in `getResource()` by wrapping `jar:` URLs with a `URLStreamHandler` that sets `useCaches(false)`. The Gradle daemon reuses JVMs across builds. When a sibling module's JAR is rewritten, two independent caching layers can hold stale file handles: 1. `URLClassLoader` internal cache (`URLClassPath` → `JarLoader`) 2. `JarFileFactory` static HashMap (populated via `JarURLConnection`) The `URLClassLoader.close()` call addresses layer 1, but layer 2 is JVM-global and independent of the class loader. Setting `useCaches(false)` on `jar:` URL connections prevents `JarFileFactory` from caching `JarFile` instances, matching the approach used by Spring's `PathMatchingResourcePatternResolver` (SPR-4639). Fixes #15458
1 parent 65590b2 commit 6ba497b

File tree

5 files changed

+175
-57
lines changed

5 files changed

+175
-57
lines changed

flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/GradlePluginAdapter.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ internal class GradlePluginAdapter private constructor(
112112
return _classFinder
113113
}
114114

115+
fun closeClassFinder() {
116+
if (::_classFinder.isInitialized && _classFinder is AutoCloseable) {
117+
try {
118+
(_classFinder as AutoCloseable).close()
119+
} catch (e: Exception) {
120+
logger.debug("Error closing ClassFinder", e)
121+
}
122+
}
123+
}
124+
115125
private fun createClassFinderClasspath(
116126
project: Project,
117127
dependencyConfiguration: Configuration?

flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/VaadinBuildFrontendTask.kt

Lines changed: 52 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -79,58 +79,62 @@ public abstract class VaadinBuildFrontendTask : DefaultTask() {
7979

8080
@TaskAction
8181
public fun vaadinBuildFrontend() {
82-
val config = adapter.get().config
83-
logger.info("Running the vaadinBuildFrontend task with effective configuration $config")
84-
// sanity check
85-
val tokenFile = BuildFrontendUtil.getTokenFile(adapter.get())
86-
check(tokenFile.exists()) { "token file $tokenFile doesn't exist!" }
87-
88-
val options = Options(null, adapter.get().classFinder, config.npmFolder.get())
89-
.withFrontendDirectory(BuildFrontendUtil.getFrontendDirectory(adapter.get()))
90-
.withFrontendGeneratedFolder(config.generatedTsFolder.get())
91-
val cleanTask = TaskCleanFrontendFiles(options)
92-
93-
val reactEnabled: Boolean = adapter.get().isReactEnabled()
94-
&& FrontendUtils.isReactRouterRequired(
95-
BuildFrontendUtil.getFrontendDirectory(adapter.get())
96-
)
97-
val featureFlags: FeatureFlags = FeatureFlags(
98-
adapter.get().createLookup(adapter.get().getClassFinder())
99-
)
100-
if (adapter.get().javaResourceFolder() != null) {
101-
featureFlags.setPropertiesLocation(adapter.get().javaResourceFolder())
102-
}
103-
val frontendDependencies: FrontendDependenciesScanner = FrontendDependenciesScannerFactory()
104-
.createScanner(
105-
!adapter.get().optimizeBundle(), adapter.get().getClassFinder(),
106-
adapter.get().generateEmbeddableWebComponents(), featureFlags,
107-
reactEnabled
82+
try {
83+
val config = adapter.get().config
84+
logger.info("Running the vaadinBuildFrontend task with effective configuration $config")
85+
// sanity check
86+
val tokenFile = BuildFrontendUtil.getTokenFile(adapter.get())
87+
check(tokenFile.exists()) { "token file $tokenFile doesn't exist!" }
88+
89+
val options = Options(null, adapter.get().classFinder, config.npmFolder.get())
90+
.withFrontendDirectory(BuildFrontendUtil.getFrontendDirectory(adapter.get()))
91+
.withFrontendGeneratedFolder(config.generatedTsFolder.get())
92+
val cleanTask = TaskCleanFrontendFiles(options)
93+
94+
val reactEnabled: Boolean = adapter.get().isReactEnabled()
95+
&& FrontendUtils.isReactRouterRequired(
96+
BuildFrontendUtil.getFrontendDirectory(adapter.get())
10897
)
109-
110-
BuildFrontendUtil.runNodeUpdater(adapter.get(), frontendDependencies)
111-
112-
if (adapter.get().generateBundle() && BundleValidationUtil.needsBundleBuild
113-
(adapter.get().servletResourceOutputDirectory())) {
114-
BuildFrontendUtil.runFrontendBuild(adapter.get())
115-
if (cleanFrontendFiles()) {
116-
cleanTask.execute()
98+
val featureFlags: FeatureFlags = FeatureFlags(
99+
adapter.get().createLookup(adapter.get().getClassFinder())
100+
)
101+
if (adapter.get().javaResourceFolder() != null) {
102+
featureFlags.setPropertiesLocation(adapter.get().javaResourceFolder())
117103
}
118-
}
119-
LicenseChecker.setStrictOffline(true)
120-
val (licenseRequired: Boolean, commercialBannerRequired: Boolean) = try {
121-
Pair(
122-
BuildFrontendUtil.validateLicenses(
123-
adapter.get(),
124-
frontendDependencies
125-
), false
104+
val frontendDependencies: FrontendDependenciesScanner = FrontendDependenciesScannerFactory()
105+
.createScanner(
106+
!adapter.get().optimizeBundle(), adapter.get().getClassFinder(),
107+
adapter.get().generateEmbeddableWebComponents(), featureFlags,
108+
reactEnabled
109+
)
110+
111+
BuildFrontendUtil.runNodeUpdater(adapter.get(), frontendDependencies)
112+
113+
if (adapter.get().generateBundle() && BundleValidationUtil.needsBundleBuild
114+
(adapter.get().servletResourceOutputDirectory())) {
115+
BuildFrontendUtil.runFrontendBuild(adapter.get())
116+
if (cleanFrontendFiles()) {
117+
cleanTask.execute()
118+
}
119+
}
120+
LicenseChecker.setStrictOffline(true)
121+
val (licenseRequired: Boolean, commercialBannerRequired: Boolean) = try {
122+
Pair(
123+
BuildFrontendUtil.validateLicenses(
124+
adapter.get(),
125+
frontendDependencies
126+
), false
127+
)
128+
} catch (e: MissingLicenseKeyException) {
129+
logger.info(e.message)
130+
Pair(true, true)
131+
}
132+
133+
BuildFrontendUtil.updateBuildFile(adapter.get(), licenseRequired, commercialBannerRequired
126134
)
127-
} catch (e: MissingLicenseKeyException) {
128-
logger.info(e.message)
129-
Pair(true, true)
135+
} finally {
136+
adapter.get().closeClassFinder()
130137
}
131-
132-
BuildFrontendUtil.updateBuildFile(adapter.get(), licenseRequired, commercialBannerRequired
133-
)
134138
}
135139

136140

flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/VaadinPrepareFrontendTask.kt

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,16 @@ public abstract class VaadinPrepareFrontendTask : DefaultTask() {
7676

7777
@TaskAction
7878
public fun vaadinPrepareFrontend() {
79-
//val adapter = GradlePluginAdapter(this, config, true)
80-
// Remove Frontend/generated folder to get clean files copied/generated
81-
logger.debug("Running the vaadinPrepareFrontend task with effective configuration ${adapter.get().config}")
82-
val tokenFile = BuildFrontendUtil.propagateBuildInfo(adapter.get())
79+
try {
80+
// Remove Frontend/generated folder to get clean files copied/generated
81+
logger.debug("Running the vaadinPrepareFrontend task with effective configuration ${adapter.get().config}")
82+
val tokenFile = BuildFrontendUtil.propagateBuildInfo(adapter.get())
8383

84-
logger.info("Generated token file $tokenFile")
85-
check(tokenFile.exists()) { "token file $tokenFile doesn't exist!" }
86-
BuildFrontendUtil.prepareFrontend(adapter.get())
84+
logger.info("Generated token file $tokenFile")
85+
check(tokenFile.exists()) { "token file $tokenFile doesn't exist!" }
86+
BuildFrontendUtil.prepareFrontend(adapter.get())
87+
} finally {
88+
adapter.get().closeClassFinder()
89+
}
8790
}
8891
}

flow-plugins/flow-plugin-base/src/main/java/com/vaadin/flow/server/scanner/ReflectionsClassFinder.java

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@
1515
*/
1616
package com.vaadin.flow.server.scanner;
1717

18+
import java.io.IOException;
1819
import java.lang.annotation.Annotation;
1920
import java.lang.annotation.Repeatable;
2021
import java.lang.reflect.AnnotatedElement;
22+
import java.net.MalformedURLException;
2123
import java.net.URL;
2224
import java.net.URLClassLoader;
25+
import java.net.URLConnection;
26+
import java.net.URLStreamHandler;
2327
import java.util.Collections;
2428
import java.util.Comparator;
2529
import java.util.LinkedHashSet;
@@ -47,7 +51,7 @@
4751
*
4852
* @since 2.0
4953
*/
50-
public class ReflectionsClassFinder implements ClassFinder {
54+
public class ReflectionsClassFinder implements ClassFinder, AutoCloseable {
5155
private static final Logger LOGGER = LoggerFactory
5256
.getLogger(ReflectionsClassFinder.class);
5357
private final transient ClassLoader classLoader;
@@ -115,7 +119,28 @@ private Set<Class<?>> getAnnotatedByRepeatedAnnotation(
115119

116120
@Override
117121
public URL getResource(String name) {
118-
return classLoader.getResource(name);
122+
URL url = classLoader.getResource(name);
123+
if (url == null || !"jar".equals(url.getProtocol())) {
124+
return url;
125+
}
126+
// Wrap jar: URLs with a handler that disables JVM-level JAR caching.
127+
// Without this, JarFileFactory keeps a static cache of JarFile
128+
// instances that become stale when JARs are rewritten between Gradle
129+
// daemon builds, causing ZipException ("invalid LOC header").
130+
try {
131+
return new URL(null, url.toExternalForm(), new URLStreamHandler() {
132+
@Override
133+
protected URLConnection openConnection(URL u)
134+
throws IOException {
135+
URLConnection conn = new URL(u.toExternalForm())
136+
.openConnection();
137+
conn.setUseCaches(false);
138+
return conn;
139+
}
140+
});
141+
} catch (MalformedURLException e) {
142+
return url;
143+
}
119144
}
120145

121146
@Override
@@ -146,6 +171,14 @@ public ClassLoader getClassLoader() {
146171
return classLoader;
147172
}
148173

174+
@Override
175+
public void close() throws IOException {
176+
if (classLoader instanceof URLClassLoader) {
177+
LOGGER.debug("Closing URLClassLoader to release file handles");
178+
((URLClassLoader) classLoader).close();
179+
}
180+
}
181+
149182
private <T> Set<Class<? extends T>> sortedByClassName(
150183
Set<Class<? extends T>> source) {
151184
return source.stream().sorted(Comparator.comparing(Class::getName))

flow-plugins/flow-plugin-base/src/test/java/com/vaadin/flow/server/scanner/ReflectionsClassFinderTest.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,19 @@
2020
import javax.tools.ToolProvider;
2121

2222
import java.io.File;
23+
import java.io.FileOutputStream;
2324
import java.io.IOException;
25+
import java.io.InputStream;
2426
import java.net.URL;
2527
import java.net.URLClassLoader;
28+
import java.net.URLConnection;
2629
import java.nio.charset.StandardCharsets;
2730
import java.nio.file.Files;
2831
import java.nio.file.Path;
2932
import java.util.List;
3033
import java.util.Set;
34+
import java.util.jar.JarEntry;
35+
import java.util.jar.JarOutputStream;
3136
import java.util.stream.Collectors;
3237

3338
import org.junit.Assert;
@@ -168,6 +173,69 @@ private URL createTestModule(String moduleName, String pkg,
168173
return buildDir.toURI().toURL();
169174
}
170175

176+
// See https://github.com/vaadin/flow/issues/15458
177+
@Test
178+
public void getResource_jarUrlDisablesCaching() throws Exception {
179+
String pkg = "com.vaadin.flow.test.jar";
180+
String className = "TestComponent";
181+
182+
File jarFile = externalModules.newFile("test-component.jar");
183+
createTestJar(jarFile, "jar-v1", pkg, className, "1.0.0");
184+
185+
try (ReflectionsClassFinder finder = new ReflectionsClassFinder(
186+
jarFile.toURI().toURL())) {
187+
URL resource = finder.getResource(
188+
pkg.replace('.', '/') + "/" + className + ".class");
189+
Assert.assertNotNull("Resource should be found in JAR", resource);
190+
Assert.assertEquals("jar", resource.getProtocol());
191+
192+
URLConnection conn = resource.openConnection();
193+
Assert.assertFalse(
194+
"jar: URL connections should have caching disabled "
195+
+ "to prevent stale JarFileFactory entries "
196+
+ "under Gradle daemon",
197+
conn.getUseCaches());
198+
// Verify the resource is still readable
199+
try (InputStream is = conn.getInputStream()) {
200+
Assert.assertTrue("Should be able to read class bytes",
201+
is.read() != -1);
202+
}
203+
}
204+
}
205+
206+
private void createTestJar(File jarFile, String moduleName, String pkg,
207+
String className, String npmPackageVersion) throws IOException {
208+
// Compile the class to a temp directory
209+
File sources = externalModules.newFolder(moduleName + "/src");
210+
File sourcePkg = externalModules
211+
.newFolder(moduleName + "/src/" + pkg.replace('.', '/'));
212+
File buildDir = externalModules.newFolder(moduleName + "/target");
213+
214+
Path sourceFile = sourcePkg.toPath().resolve(className + ".java");
215+
Files.writeString(sourceFile, String.format(CLASS_TEMPLATE, pkg,
216+
npmPackageVersion, className), StandardCharsets.UTF_8);
217+
compile(sourceFile.toFile(), sources, buildDir);
218+
219+
// Package compiled classes into a JAR
220+
try (JarOutputStream jos = new JarOutputStream(
221+
new FileOutputStream(jarFile))) {
222+
Path classesRoot = buildDir.toPath();
223+
try (var walker = Files.walk(classesRoot)) {
224+
walker.filter(Files::isRegularFile).forEach(classFile -> {
225+
String entryName = classesRoot.relativize(classFile)
226+
.toString().replace(File.separatorChar, '/');
227+
try {
228+
jos.putNextEntry(new JarEntry(entryName));
229+
jos.write(Files.readAllBytes(classFile));
230+
jos.closeEntry();
231+
} catch (IOException e) {
232+
throw new RuntimeException(e);
233+
}
234+
});
235+
}
236+
}
237+
}
238+
171239
private void compile(File sourceFile, File sourcePath, File outputPath) {
172240
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
173241
int result = compiler.run(null, null, null, "-d", outputPath.getPath(),

0 commit comments

Comments
 (0)