From e78d99d0543b1bd79bb093a673a3686481f8dd74 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Mon, 17 Aug 2026 10:54:54 +0100 Subject: [PATCH 1/2] fix(core): resolve dependencies and change classes across classloader boundaries Fixes #951. Under Spring Boot DevTools restart, application classes are reloaded through a separate RestartClassLoader while Flamingock's own classes stay on the base loader. Two Class objects with the same fully-qualified name but different defining classloaders are never equal, so Flamingock failed to resolve dependencies and change/param types it had just registered, surfacing as a misleading "Dependency not found" error. - CodeLoadedChangeBuilder: resolve change classes via a classloader fallback chain (thread context classloader, then this class's own loader, then the system loader) instead of relying solely on the implicit default of Class.forName, so it picks up the same "live" app classes the framework is actually using. - SimpleContext: fall back to matching dependencies by fully-qualified class name when exact-type and assignability lookups miss, covering manually registered dependencies split across classloaders. Logs a warning when this fallback is used so a genuine duplicate-class issue on the classpath doesn't get silently masked. - Add ClassloaderMismatchReproTest, simulating the dual-classloader scenario to guard against regressions. --- .../loaded/CodeLoadedChangeBuilder.java | 29 ++++- .../internal/core/context/SimpleContext.java | 29 ++++- .../context/ClassloaderMismatchReproTest.java | 110 ++++++++++++++++++ .../context/repro/MigrationConfiguration.java | 28 +++++ 4 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 core/flamingock-core/src/test/java/io/flamingock/internal/core/context/ClassloaderMismatchReproTest.java create mode 100644 core/flamingock-core/src/test/java/io/flamingock/internal/core/context/repro/MigrationConfiguration.java diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java index 677ab904a..505aadb70 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java @@ -30,6 +30,7 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Method; +import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -229,13 +230,31 @@ private void setRecoveryFromClass(Class sourceClass) { } } + // Tried in order: the thread's context classloader is what frameworks like Spring Boot DevTools + // point at the "live" app classes (e.g. its RestartClassLoader), so it must win when present. + // This class's own loader is the pre-existing fallback, kept for environments (CLI, plain Java) + // where no context classloader is set. The system loader is a last resort. private Class getClassForName(String clazzName) { - try { - return Class.forName(clazzName); - } - catch (ClassNotFoundException e) { - throw new RuntimeException(e); + ClassNotFoundException lastException = null; + for (ClassLoader candidate : candidateClassLoaders()) { + if (candidate == null) { + continue; + } + try { + return Class.forName(clazzName, true, candidate); + } catch (ClassNotFoundException e) { + lastException = e; + } } + throw new RuntimeException(lastException); + } + + private List candidateClassLoaders() { + return Arrays.asList( + Thread.currentThread().getContextClassLoader(), + getClass().getClassLoader(), + ClassLoader.getSystemClassLoader() + ); } private Constructor getConstructorFromPreview(CodePreviewChange preview) { diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/context/SimpleContext.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/context/SimpleContext.java index 7bd0fb2d3..9436e8f52 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/context/SimpleContext.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/context/SimpleContext.java @@ -18,6 +18,8 @@ import io.flamingock.internal.common.core.context.Context; import io.flamingock.internal.common.core.context.Dependency; import io.flamingock.internal.util.Property; +import io.flamingock.internal.util.log.FlamingockLoggerFactory; +import org.slf4j.Logger; import java.io.File; import java.net.InetAddress; @@ -46,6 +48,8 @@ public class SimpleContext extends AbstractSimpleContextResolver implements Context { + private static final Logger logger = FlamingockLoggerFactory.getLogger("SimpleContext"); + private final Map dependenciesByName; private final Map, Dependency> dependenciesByExactType; @@ -64,9 +68,23 @@ protected Optional getByType(Class type) { Optional dependencyByExactClass = Optional.ofNullable(dependenciesByExactType.get(type)); if (dependencyByExactClass.isPresent()) { return dependencyByExactClass; - } else { - return getFirstAssignableDependency(type); } + Optional assignableDependency = getFirstAssignableDependency(type); + if (assignableDependency.isPresent()) { + return assignableDependency; + } + // Fallback for types with the same fully-qualified name loaded by different classloaders + // (e.g. Spring Boot DevTools' restart classloader), where Class identity/assignability + // checks above never match even though it's logically the same application type. + Optional sameNameDependency = getFirstSameNameDependency(type); + if (sameNameDependency.isPresent()) { + logger.warn("Dependency[{}] resolved by class name across a classloader boundary " + + "(requested type and registered type share the name but are different Class instances). " + + "This usually happens under a hot-reload classloader (e.g. Spring Boot DevTools). " + + "If this is unexpected, check for duplicate classes on the classpath.", + type.getName()); + } + return sameNameDependency; } private Optional getFirstAssignableDependency(Class type) { @@ -76,6 +94,13 @@ private Optional getFirstAssignableDependency(Class type) { .findFirst(); } + private Optional getFirstSameNameDependency(Class type) { + return dependenciesByExactType.entrySet().stream() + .filter(entry -> type.getName().equals(entry.getKey().getName())) + .map(Map.Entry::getValue) + .findFirst(); + } + @Override public void addDependency(Dependency dependency) { if (!dependency.isDefaultNamed()) { diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/ClassloaderMismatchReproTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/ClassloaderMismatchReproTest.java new file mode 100644 index 000000000..c7c22fde1 --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/ClassloaderMismatchReproTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2023 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.internal.core.context; + +import io.flamingock.internal.common.core.context.Dependency; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.net.URL; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Reproduces https://github.com/flamingock/flamingock-java/issues/951 + *

+ * Simulates what Spring Boot DevTools' RestartClassLoader does: the same + * fully-qualified class is loaded twice by two different classloaders, + * producing two distinct {@code Class} instances with the same name. + * SimpleContext keys its dependency map by exact {@code Class} identity, + * so a dependency registered under one loader's Class instance is not found + * when looked up with the other loader's Class instance. + */ +class ClassloaderMismatchReproTest { + + private static final String TARGET_CLASS = "io.flamingock.internal.core.context.repro.MigrationConfiguration"; + + @Test + void dependencyRegisteredUnderOneClassloaderIsNotFoundUnderAnother() throws Exception { + ClassLoader appLoader = ClassloaderMismatchReproTest.class.getClassLoader(); + Class typeFromRegistration = loadIsolated(appLoader).loadClass(TARGET_CLASS); + Class typeFromLookup = loadIsolated(appLoader).loadClass(TARGET_CLASS); + + assertFalse(typeFromRegistration.equals(typeFromLookup), + "precondition: the two loaders must produce distinct Class instances"); + + SimpleContext context = new SimpleContext(); + Object instance = typeFromRegistration.getConstructor(String.class) + .newInstance("some-config"); + context.addDependency(new Dependency(typeFromRegistration, instance)); + + boolean found = context.getDependency(typeFromLookup).isPresent(); + + assertTrue(found, "dependency should be found by name across classloader boundaries"); + } + + private static IsolatedClassLoader loadIsolated(ClassLoader parent) { + return new IsolatedClassLoader(parent); + } + + /** Loads only classes under the repro package in isolation; delegates everything else to parent. */ + private static class IsolatedClassLoader extends ClassLoader { + private final ClassLoader resourceLoader; + + IsolatedClassLoader(ClassLoader resourceLoader) { + super(null); + this.resourceLoader = resourceLoader; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals(TARGET_CLASS)) { + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) { + String path = name.replace('.', '/') + ".class"; + try (InputStream is = resourceLoader.getResourceAsStream(path)) { + if (is == null) { + throw new ClassNotFoundException(name); + } + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = is.read(buf)) != -1) { + baos.write(buf, 0, n); + } + byte[] bytes = baos.toByteArray(); + loaded = defineClass(name, bytes, 0, bytes.length); + } catch (Exception e) { + throw new ClassNotFoundException(name, e); + } + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + return Class.forName(name, resolve, resourceLoader); + } + + @Override + public URL getResource(String name) { + return resourceLoader.getResource(name); + } + } +} diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/repro/MigrationConfiguration.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/repro/MigrationConfiguration.java new file mode 100644 index 000000000..01838cd90 --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/repro/MigrationConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.internal.core.context.repro; + +public class MigrationConfiguration { + private final String configCollection; + + public MigrationConfiguration(String configCollection) { + this.configCollection = configCollection; + } + + public String getConfigCollection() { + return configCollection; + } +} From 78ed4c606d0eb84e7523dfa6fae5dc8388089c69 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Thu, 20 Aug 2026 07:01:49 +0100 Subject: [PATCH 2/2] fix(core): resolve dependencies and change classes across classloader --- .../core/change/loaded/CodeLoadedChangeBuilder.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java index 505aadb70..ef2dea64f 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java @@ -31,8 +31,10 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.util.Arrays; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; +import java.util.Set; public class CodeLoadedChangeBuilder implements LoadedChangeBuilder { @@ -249,12 +251,12 @@ private Class getClassForName(String clazzName) { throw new RuntimeException(lastException); } - private List candidateClassLoaders() { - return Arrays.asList( + private Set candidateClassLoaders() { + return new LinkedHashSet<>(Arrays.asList( Thread.currentThread().getContextClassLoader(), getClass().getClassLoader(), ClassLoader.getSystemClassLoader() - ); + )); } private Constructor getConstructorFromPreview(CodePreviewChange preview) {