Skip to content

Commit 7178ead

Browse files
authored
fix(core): resolve dependencies and change classes across classloader… (#952)
* fix(core): resolve dependencies and change classes across classloader boundaries
1 parent e7a073e commit 7178ead

4 files changed

Lines changed: 191 additions & 7 deletions

File tree

core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@
3030

3131
import java.lang.reflect.Constructor;
3232
import java.lang.reflect.Method;
33+
import java.util.Arrays;
34+
import java.util.LinkedHashSet;
3335
import java.util.List;
3436
import java.util.Optional;
37+
import java.util.Set;
3538

3639
public class CodeLoadedChangeBuilder implements LoadedChangeBuilder<CodeLoadedChange> {
3740

@@ -229,13 +232,31 @@ private void setRecoveryFromClass(Class<?> sourceClass) {
229232
}
230233
}
231234

235+
// Tried in order: the thread's context classloader is what frameworks like Spring Boot DevTools
236+
// point at the "live" app classes (e.g. its RestartClassLoader), so it must win when present.
237+
// This class's own loader is the pre-existing fallback, kept for environments (CLI, plain Java)
238+
// where no context classloader is set. The system loader is a last resort.
232239
private Class<?> getClassForName(String clazzName) {
233-
try {
234-
return Class.forName(clazzName);
235-
}
236-
catch (ClassNotFoundException e) {
237-
throw new RuntimeException(e);
240+
ClassNotFoundException lastException = null;
241+
for (ClassLoader candidate : candidateClassLoaders()) {
242+
if (candidate == null) {
243+
continue;
244+
}
245+
try {
246+
return Class.forName(clazzName, true, candidate);
247+
} catch (ClassNotFoundException e) {
248+
lastException = e;
249+
}
238250
}
251+
throw new RuntimeException(lastException);
252+
}
253+
254+
private Set<ClassLoader> candidateClassLoaders() {
255+
return new LinkedHashSet<>(Arrays.asList(
256+
Thread.currentThread().getContextClassLoader(),
257+
getClass().getClassLoader(),
258+
ClassLoader.getSystemClassLoader()
259+
));
239260
}
240261

241262
private Constructor<?> getConstructorFromPreview(CodePreviewChange preview) {

core/flamingock-core/src/main/java/io/flamingock/internal/core/context/SimpleContext.java

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import io.flamingock.internal.common.core.context.Context;
1919
import io.flamingock.internal.common.core.context.Dependency;
2020
import io.flamingock.internal.util.Property;
21+
import io.flamingock.internal.util.log.FlamingockLoggerFactory;
22+
import org.slf4j.Logger;
2123

2224
import java.io.File;
2325
import java.net.InetAddress;
@@ -46,6 +48,8 @@
4648

4749
public class SimpleContext extends AbstractSimpleContextResolver implements Context {
4850

51+
private static final Logger logger = FlamingockLoggerFactory.getLogger("SimpleContext");
52+
4953
private final Map<String, Dependency> dependenciesByName;
5054
private final Map<Class<?>, Dependency> dependenciesByExactType;
5155

@@ -64,9 +68,23 @@ protected Optional<Dependency> getByType(Class<?> type) {
6468
Optional<Dependency> dependencyByExactClass = Optional.ofNullable(dependenciesByExactType.get(type));
6569
if (dependencyByExactClass.isPresent()) {
6670
return dependencyByExactClass;
67-
} else {
68-
return getFirstAssignableDependency(type);
6971
}
72+
Optional<Dependency> assignableDependency = getFirstAssignableDependency(type);
73+
if (assignableDependency.isPresent()) {
74+
return assignableDependency;
75+
}
76+
// Fallback for types with the same fully-qualified name loaded by different classloaders
77+
// (e.g. Spring Boot DevTools' restart classloader), where Class<?> identity/assignability
78+
// checks above never match even though it's logically the same application type.
79+
Optional<Dependency> sameNameDependency = getFirstSameNameDependency(type);
80+
if (sameNameDependency.isPresent()) {
81+
logger.warn("Dependency[{}] resolved by class name across a classloader boundary " +
82+
"(requested type and registered type share the name but are different Class instances). " +
83+
"This usually happens under a hot-reload classloader (e.g. Spring Boot DevTools). " +
84+
"If this is unexpected, check for duplicate classes on the classpath.",
85+
type.getName());
86+
}
87+
return sameNameDependency;
7088
}
7189

7290
private Optional<Dependency> getFirstAssignableDependency(Class<?> type) {
@@ -76,6 +94,13 @@ private Optional<Dependency> getFirstAssignableDependency(Class<?> type) {
7694
.findFirst();
7795
}
7896

97+
private Optional<Dependency> getFirstSameNameDependency(Class<?> type) {
98+
return dependenciesByExactType.entrySet().stream()
99+
.filter(entry -> type.getName().equals(entry.getKey().getName()))
100+
.map(Map.Entry::getValue)
101+
.findFirst();
102+
}
103+
79104
@Override
80105
public void addDependency(Dependency dependency) {
81106
if (!dependency.isDefaultNamed()) {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright 2023 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.internal.core.context;
17+
18+
import io.flamingock.internal.common.core.context.Dependency;
19+
import org.junit.jupiter.api.Test;
20+
21+
import java.io.InputStream;
22+
import java.net.URL;
23+
24+
import static org.junit.jupiter.api.Assertions.assertFalse;
25+
import static org.junit.jupiter.api.Assertions.assertTrue;
26+
27+
/**
28+
* Reproduces https://github.com/flamingock/flamingock-java/issues/951
29+
* <p>
30+
* Simulates what Spring Boot DevTools' RestartClassLoader does: the same
31+
* fully-qualified class is loaded twice by two different classloaders,
32+
* producing two distinct {@code Class<?>} instances with the same name.
33+
* SimpleContext keys its dependency map by exact {@code Class<?>} identity,
34+
* so a dependency registered under one loader's Class instance is not found
35+
* when looked up with the other loader's Class instance.
36+
*/
37+
class ClassloaderMismatchReproTest {
38+
39+
private static final String TARGET_CLASS = "io.flamingock.internal.core.context.repro.MigrationConfiguration";
40+
41+
@Test
42+
void dependencyRegisteredUnderOneClassloaderIsNotFoundUnderAnother() throws Exception {
43+
ClassLoader appLoader = ClassloaderMismatchReproTest.class.getClassLoader();
44+
Class<?> typeFromRegistration = loadIsolated(appLoader).loadClass(TARGET_CLASS);
45+
Class<?> typeFromLookup = loadIsolated(appLoader).loadClass(TARGET_CLASS);
46+
47+
assertFalse(typeFromRegistration.equals(typeFromLookup),
48+
"precondition: the two loaders must produce distinct Class instances");
49+
50+
SimpleContext context = new SimpleContext();
51+
Object instance = typeFromRegistration.getConstructor(String.class)
52+
.newInstance("some-config");
53+
context.addDependency(new Dependency(typeFromRegistration, instance));
54+
55+
boolean found = context.getDependency(typeFromLookup).isPresent();
56+
57+
assertTrue(found, "dependency should be found by name across classloader boundaries");
58+
}
59+
60+
private static IsolatedClassLoader loadIsolated(ClassLoader parent) {
61+
return new IsolatedClassLoader(parent);
62+
}
63+
64+
/** Loads only classes under the repro package in isolation; delegates everything else to parent. */
65+
private static class IsolatedClassLoader extends ClassLoader {
66+
private final ClassLoader resourceLoader;
67+
68+
IsolatedClassLoader(ClassLoader resourceLoader) {
69+
super(null);
70+
this.resourceLoader = resourceLoader;
71+
}
72+
73+
@Override
74+
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
75+
if (name.equals(TARGET_CLASS)) {
76+
synchronized (getClassLoadingLock(name)) {
77+
Class<?> loaded = findLoadedClass(name);
78+
if (loaded == null) {
79+
String path = name.replace('.', '/') + ".class";
80+
try (InputStream is = resourceLoader.getResourceAsStream(path)) {
81+
if (is == null) {
82+
throw new ClassNotFoundException(name);
83+
}
84+
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
85+
byte[] buf = new byte[4096];
86+
int n;
87+
while ((n = is.read(buf)) != -1) {
88+
baos.write(buf, 0, n);
89+
}
90+
byte[] bytes = baos.toByteArray();
91+
loaded = defineClass(name, bytes, 0, bytes.length);
92+
} catch (Exception e) {
93+
throw new ClassNotFoundException(name, e);
94+
}
95+
}
96+
if (resolve) {
97+
resolveClass(loaded);
98+
}
99+
return loaded;
100+
}
101+
}
102+
return Class.forName(name, resolve, resourceLoader);
103+
}
104+
105+
@Override
106+
public URL getResource(String name) {
107+
return resourceLoader.getResource(name);
108+
}
109+
}
110+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2023 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.internal.core.context.repro;
17+
18+
public class MigrationConfiguration {
19+
private final String configCollection;
20+
21+
public MigrationConfiguration(String configCollection) {
22+
this.configCollection = configCollection;
23+
}
24+
25+
public String getConfigCollection() {
26+
return configCollection;
27+
}
28+
}

0 commit comments

Comments
 (0)