Przeglądaj źródła

Handle providers of optional services in ubermodule classloader (#91217)

* When scanning a jar bundle for services to add to the
ubermodule descriptor, we must make sure that any optional
dependencies match up to services that are defined in the
code. If we naively declare that our module uses any service
that is implemented by one of its jars, we might fail to load
the plugin because the original implementation is not included
in our bundle.

Here we add a fairly detailed test scenario for required and
optional services defined outside the bundle, as well as for
services defined within the bundle, either by module declaration
or by META-INF/services entry.
William Brafford 2 lat temu
rodzic
commit
e14472223c

+ 37 - 15
server/src/main/java/org/elasticsearch/plugins/ModuleSupport.java

@@ -45,10 +45,19 @@ public class ModuleSupport {
         throw new AssertionError("Utility class, should not be instantiated");
     }
 
-    static ModuleFinder ofSyntheticPluginModule(String name, Path[] jarPaths, Set<String> requires, Set<String> uses) {
+    static ModuleFinder ofSyntheticPluginModule(
+        String name,
+        Path[] jarPaths,
+        Set<String> requires,
+        Set<String> uses,
+        Predicate<String> isPackageInParentLayers
+    ) {
         try {
             return new InMemoryModuleFinder(
-                new InMemoryModuleReference(createModuleDescriptor(name, jarPaths, requires, uses), URI.create("module:/" + name))
+                new InMemoryModuleReference(
+                    createModuleDescriptor(name, jarPaths, requires, uses, isPackageInParentLayers),
+                    URI.create("module:/" + name)
+                )
             );
         } catch (IOException e) {
             throw new UncheckedIOException(e);
@@ -56,8 +65,13 @@ public class ModuleSupport {
     }
 
     @SuppressForbidden(reason = "need access to the jar file")
-    static ModuleDescriptor createModuleDescriptor(String name, Path[] jarPaths, Set<String> requires, Set<String> uses)
-        throws IOException {
+    static ModuleDescriptor createModuleDescriptor(
+        String name,
+        Path[] jarPaths,
+        Set<String> requires,
+        Set<String> uses,
+        Predicate<String> isPackageInParentLayers
+    ) throws IOException {
         var builder = ModuleDescriptor.newOpenModule(name); // open module, for now
         requires.stream().forEach(builder::requires);
         uses.stream().forEach(builder::uses);
@@ -65,7 +79,7 @@ public class ModuleSupport {
         // scan the names of the entries in the JARs
         Set<String> pkgs = new HashSet<>();
         Map<String, List<String>> allBundledProviders = new HashMap<>();
-        Set<String> allBundledServices = new HashSet<>();
+        Set<String> servicesUsedInBundle = new HashSet<>();
         for (Path path : jarPaths) {
             assert path.getFileName().toString().endsWith(".jar") : "expected jars suffix, in path: " + path;
             try (JarFile jf = new JarFile(path.toFile(), true, ZipFile.OPEN_READ, Runtime.version())) {
@@ -74,13 +88,13 @@ public class ModuleSupport {
                 if (moduleInfo != null) {
                     var descriptor = getDescriptorForModularJar(path);
                     pkgs.addAll(descriptor.packages());
-                    allBundledServices.addAll(descriptor.uses());
+                    servicesUsedInBundle.addAll(descriptor.uses());
                     for (ModuleDescriptor.Provides p : descriptor.provides()) {
                         String serviceName = p.service();
                         List<String> providersInModule = p.providers();
 
                         allBundledProviders.compute(serviceName, (k, v) -> createListOrAppend(v, providersInModule));
-                        allBundledServices.add(serviceName);
+                        servicesUsedInBundle.add(serviceName);
                     }
                 } else {
                     var scan = scan(jf);
@@ -92,7 +106,7 @@ public class ModuleSupport {
                         List<String> providersInJar = getProvidersFromServiceFile(jf, serviceFileName);
 
                         allBundledProviders.compute(serviceName, (k, v) -> createListOrAppend(v, providersInJar));
-                        allBundledServices.add(serviceName);
+                        servicesUsedInBundle.add(serviceName);
                     }
                 }
             }
@@ -100,13 +114,21 @@ public class ModuleSupport {
 
         builder.packages(pkgs);
 
-        // the module needs to use all services it provides, for the case of internal use
-        allBundledServices.addAll(allBundledProviders.keySet());
-        // but we don't want to add any services we already got from the parent layer
-        allBundledServices.removeAll(uses);
+        // we don't want to add any services we already got from the parent layer
+        servicesUsedInBundle.removeAll(uses);
 
-        allBundledServices.forEach(builder::uses);
-        allBundledProviders.forEach(builder::provides);
+        // Services that aren't exported in the parent layer or defined in our
+        // bundle. This can happen for optional (compile-time) dependencies
+        Set<String> missingServices = servicesUsedInBundle.stream()
+            .filter(s -> isPackageInParentLayers.test(toPackageName(s, ".").orElseThrow()) == false)
+            .filter(s -> pkgs.contains(toPackageName(s, ".").orElseThrow()) == false)
+            .collect(Collectors.toSet());
+
+        servicesUsedInBundle.stream().filter(s -> missingServices.contains(s) == false).forEach(builder::uses);
+        allBundledProviders.entrySet()
+            .stream()
+            .filter(e -> missingServices.contains(e.getKey()) == false)
+            .forEach(e -> builder.provides(e.getKey(), e.getValue()));
         return builder.build();
     }
 
@@ -246,7 +268,7 @@ public class ModuleSupport {
     @SuppressForbidden(reason = "need access to the jar file")
     private static List<String> getProvidersFromServiceFile(JarFile jf, String sf) throws IOException {
         try (BufferedReader bf = new BufferedReader(new InputStreamReader(jf.getInputStream(jf.getEntry(sf)), StandardCharsets.UTF_8))) {
-            return bf.lines().toList();
+            return bf.lines().filter(Predicate.not(l -> l.startsWith("#"))).filter(Predicate.not(String::isEmpty)).toList();
         }
     }
 

+ 33 - 17
server/src/main/java/org/elasticsearch/plugins/UberModuleClassLoader.java

@@ -57,21 +57,16 @@ public class UberModuleClassLoader extends SecureClassLoader implements AutoClos
     private final ModuleLayer.Controller moduleController;
     private final Set<String> packageNames;
 
-    private static final Map<String, Set<String>> platformModulesToServices;
-
-    static {
-        Set<String> unqualifiedExports = ModuleLayer.boot()
-            .modules()
+    private static Map<String, Set<String>> getModuleToServiceMap(ModuleLayer moduleLayer) {
+        Set<String> unqualifiedExports = moduleLayer.modules()
             .stream()
             .flatMap(module -> module.getDescriptor().exports().stream())
             .filter(Predicate.not(ModuleDescriptor.Exports::isQualified))
             .map(ModuleDescriptor.Exports::source)
             .collect(Collectors.toSet());
-        platformModulesToServices = ModuleLayer.boot()
-            .modules()
+        return moduleLayer.modules()
             .stream()
             .map(Module::getDescriptor)
-            .filter(ModuleSupport::isJavaPlatformModule)
             .filter(ModuleSupport::hasAtLeastOneUnqualifiedExport)
             .collect(
                 Collectors.toMap(
@@ -86,27 +81,38 @@ public class UberModuleClassLoader extends SecureClassLoader implements AutoClos
     }
 
     static UberModuleClassLoader getInstance(ClassLoader parent, String moduleName, Set<URL> jarUrls) {
-        return getInstance(parent, moduleName, jarUrls, Set.of());
+        return getInstance(parent, ModuleLayer.boot(), moduleName, jarUrls, Set.of());
     }
 
     @SuppressWarnings("removal")
-    static UberModuleClassLoader getInstance(ClassLoader parent, String moduleName, Set<URL> jarUrls, Set<String> moduleDenyList) {
+    static UberModuleClassLoader getInstance(
+        ClassLoader parent,
+        ModuleLayer parentLayer,
+        String moduleName,
+        Set<URL> jarUrls,
+        Set<String> moduleDenyList
+    ) {
         Path[] jarPaths = jarUrls.stream().map(UberModuleClassLoader::urlToPathUnchecked).toArray(Path[]::new);
-
-        Set<String> requires = platformModulesToServices.keySet()
+        var parentLayerModuleToServiceMap = getModuleToServiceMap(parentLayer);
+        Set<String> requires = parentLayerModuleToServiceMap.keySet()
             .stream()
             .filter(Predicate.not(moduleDenyList::contains))
             .collect(Collectors.toSet());
-        Set<String> uses = platformModulesToServices.entrySet()
+        Set<String> uses = parentLayerModuleToServiceMap.entrySet()
             .stream()
             .filter(Predicate.not(entry -> moduleDenyList.contains(entry.getKey())))
             .flatMap(entry -> entry.getValue().stream())
             .collect(Collectors.toSet());
 
-        ModuleFinder finder = ModuleSupport.ofSyntheticPluginModule(moduleName, jarPaths, requires, uses);
-        ModuleLayer mparent = ModuleLayer.boot();
+        ModuleFinder finder = ModuleSupport.ofSyntheticPluginModule(
+            moduleName,
+            jarPaths,
+            requires,
+            uses,
+            s -> isPackageInLayers(s, parentLayer)
+        );
         // TODO: check that denied modules are not brought as transitive dependencies (or switch to allow-list?)
-        Configuration cf = mparent.configuration().resolve(finder, ModuleFinder.of(), Set.of(moduleName));
+        Configuration cf = parentLayer.configuration().resolve(finder, ModuleFinder.of(), Set.of(moduleName));
 
         Set<String> packageNames = finder.find(moduleName).map(ModuleReference::descriptor).map(ModuleDescriptor::packages).orElseThrow();
 
@@ -115,12 +121,22 @@ public class UberModuleClassLoader extends SecureClassLoader implements AutoClos
             moduleName,
             jarUrls.toArray(new URL[0]),
             cf,
-            mparent,
+            parentLayer,
             packageNames
         );
         return AccessController.doPrivileged(pa);
     }
 
+    private static boolean isPackageInLayers(String packageName, ModuleLayer moduleLayer) {
+        if (moduleLayer.modules().stream().map(Module::getPackages).anyMatch(p -> p.contains(packageName))) {
+            return true;
+        }
+        if (moduleLayer.parents().equals(List.of(ModuleLayer.empty()))) {
+            return false;
+        }
+        return moduleLayer.parents().stream().anyMatch(ml -> isPackageInLayers(packageName, ml));
+    }
+
     /**
      * Constructor
      */

+ 338 - 4
server/src/test/java/org/elasticsearch/plugins/UberModuleClassLoaderTests.java

@@ -14,6 +14,8 @@ import org.elasticsearch.test.compiler.InMemoryJavaCompiler;
 import org.elasticsearch.test.jar.JarUtils;
 
 import java.io.IOException;
+import java.lang.module.Configuration;
+import java.lang.module.ModuleFinder;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.net.URLClassLoader;
@@ -21,6 +23,7 @@ import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
 import java.util.Enumeration;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -301,6 +304,7 @@ public class UberModuleClassLoaderTests extends ESTestCase {
         try (
             UberModuleClassLoader denyListLoader = UberModuleClassLoader.getInstance(
                 UberModuleClassLoaderTests.class.getClassLoader(),
+                ModuleLayer.boot(),
                 "synthetic",
                 Set.of(toUrl(jar)),
                 Set.of("java.sql", "java.sql.rowset") // if present, java.sql.rowset requires java.sql transitively
@@ -337,7 +341,7 @@ public class UberModuleClassLoaderTests extends ESTestCase {
         Path topLevelDir = createTempDir(getTestName());
         Path jar = topLevelDir.resolve("my-service-jar.jar");
 
-        createServiceTestJar(jar, false, true);
+        createServiceTestSingleJar(jar, false, true);
 
         try (
             UberModuleClassLoader cl = UberModuleClassLoader.getInstance(
@@ -357,7 +361,7 @@ public class UberModuleClassLoaderTests extends ESTestCase {
         Path topLevelDir = createTempDir(getTestName());
         Path jar = topLevelDir.resolve("my-service-jar.jar");
 
-        createServiceTestJar(jar, true, false);
+        createServiceTestSingleJar(jar, true, false);
 
         try (
             UberModuleClassLoader cl = UberModuleClassLoader.getInstance(
@@ -377,7 +381,7 @@ public class UberModuleClassLoaderTests extends ESTestCase {
         Path topLevelDir = createTempDir(getTestName());
         Path jar = topLevelDir.resolve("my-service-jar.jar");
 
-        createServiceTestJar(jar, true, true);
+        createServiceTestSingleJar(jar, true, true);
 
         try (
             UberModuleClassLoader cl = UberModuleClassLoader.getInstance(
@@ -393,7 +397,7 @@ public class UberModuleClassLoaderTests extends ESTestCase {
         }
     }
 
-    private static void createServiceTestJar(Path jar, boolean modularize, boolean addMetaInfService) throws IOException {
+    private static void createServiceTestSingleJar(Path jar, boolean modularize, boolean addMetaInfService) throws IOException {
         String serviceInterface = """
             package p;
 
@@ -461,6 +465,336 @@ public class UberModuleClassLoaderTests extends ESTestCase {
         JarUtils.createJarWithEntries(jar, jarEntries);
     }
 
+    public void testServiceLoadingWithOptionalDependencies() throws Exception {
+
+        try (UberModuleClassLoader loader = getServiceTestLoader(true)) {
+
+            Class<?> serviceCallerClass = loader.loadClass("q.caller.ServiceCaller");
+            Object instance = serviceCallerClass.getConstructor().newInstance();
+
+            var requiredParent = serviceCallerClass.getMethod("callServiceFromRequiredParent");
+            assertThat(requiredParent.invoke(instance), equalTo("AB"));
+            var optionalParent = serviceCallerClass.getMethod("callServiceFromOptionalParent");
+            assertThat(optionalParent.invoke(instance), equalTo("catdog"));
+            var modular = serviceCallerClass.getMethod("callServiceFromModularJar");
+            assertThat(modular.invoke(instance), equalTo("12"));
+            var nonModular = serviceCallerClass.getMethod("callServiceFromNonModularJar");
+            assertThat(nonModular.invoke(instance), equalTo("foo"));
+        }
+    }
+
+    public void testServiceLoadingWithoutOptionalDependencies() throws Exception {
+
+        try (UberModuleClassLoader loader = getServiceTestLoader(false)) {
+
+            Class<?> serviceCallerClass = loader.loadClass("q.caller.ServiceCaller");
+            Object instance = serviceCallerClass.getConstructor().newInstance();
+
+            var requiredParent = serviceCallerClass.getMethod("callServiceFromRequiredParent");
+            assertThat(requiredParent.invoke(instance), equalTo("AB"));
+            var optionalParent = serviceCallerClass.getMethod("callServiceFromOptionalParent");
+            assertThat(optionalParent.invoke(instance), equalTo("Optional AnimalService dependency not present at runtime."));
+            var modular = serviceCallerClass.getMethod("callServiceFromModularJar");
+            assertThat(modular.invoke(instance), equalTo("12"));
+            var nonModular = serviceCallerClass.getMethod("callServiceFromNonModularJar");
+            assertThat(nonModular.invoke(instance), equalTo("foo"));
+        }
+    }
+
+    /**
+     * We need to create a test scenario that covers four service loading situations:
+     * 1. Service defined in package exported in parent layer.
+     * 2. Service defined in a compile-time dependency, optionally present at runtime.
+     * 3. Service defined in modular jar in uberjar
+     * 4. Service defined in non-modular jar in uberjar
+     *
+     * We create a jar for each scenario, plus "service caller" jar with a demo class, then
+     * create an UberModuleClassLoader for the relevant jars.
+     */
+    private static UberModuleClassLoader getServiceTestLoader(boolean includeOptionalDeps) throws IOException {
+        Path libDir = createTempDir("libs");
+        Path parentJar = createRequiredJarForParentLayer(libDir);
+        Path optionalJar = createOptionalJarForParentLayer(libDir);
+
+        Set<String> moduleNames = includeOptionalDeps ? Set.of("p.required", "p.optional") : Set.of("p.required");
+        ModuleFinder parentModuleFinder = includeOptionalDeps ? ModuleFinder.of(parentJar, optionalJar) : ModuleFinder.of(parentJar);
+        Configuration parentLayerConfiguration = ModuleLayer.boot()
+            .configuration()
+            .resolve(parentModuleFinder, ModuleFinder.of(), moduleNames);
+
+        ModuleLayer parentLayer = ModuleLayer.defineModulesWithOneLoader(
+            parentLayerConfiguration,
+            List.of(ModuleLayer.boot()),
+            UberModuleClassLoaderTests.class.getClassLoader()
+        ).layer();
+
+        // jars for the ubermodule
+        Path modularJar = createModularizedJarForBundle(libDir);
+        Path nonModularJar = createNonModularizedJarForBundle(libDir, parentJar, optionalJar, modularJar);
+        Path serviceCallerJar = createServiceCallingJarForBundle(libDir, parentJar, optionalJar, modularJar, nonModularJar);
+
+        Set<Path> jarPaths = new HashSet<>(Set.of(modularJar, nonModularJar, serviceCallerJar));
+        return UberModuleClassLoader.getInstance(
+            parentLayer.findLoader("p.required"),
+            parentLayer,
+            "synthetic",
+            jarPaths.stream().map(UberModuleClassLoaderTests::pathToUrlUnchecked).collect(Collectors.toSet()),
+            Set.of()
+        );
+    }
+
+    private static Path createServiceCallingJarForBundle(Path libDir, Path parentJar, Path optionalJar, Path modularJar, Path nonModularJar)
+        throws IOException {
+
+        String serviceCaller = """
+            package q.caller;
+
+            import p.optional.AnimalService;
+            import p.required.LetterService;
+            import q.jar.one.NumberService;
+            import q.jar.two.FooBarService;
+
+            import java.util.ServiceLoader;
+            import java.util.stream.Collectors;
+
+            public class ServiceCaller {
+
+                public ServiceCaller() { }
+
+                public String callServiceFromRequiredParent() {
+                    return ServiceLoader.load(LetterService.class, ServiceCaller.class.getClassLoader()).stream()
+                            .map(ServiceLoader.Provider::get)
+                            .map(LetterService::getLetter)
+                            .sorted()
+                            .collect(Collectors.joining());
+                }
+
+                public String callServiceFromOptionalParent() {
+                    Class<?> animalServiceClass;
+                    try {
+                        animalServiceClass = ServiceCaller.class.getClassLoader().loadClass("p.optional.AnimalService");
+                    } catch (ClassNotFoundException e) {
+                        return "Optional AnimalService dependency not present at runtime.";
+                    }
+                    return ServiceLoader.load(animalServiceClass, ServiceCaller.class.getClassLoader()).stream()
+                            .map(ServiceLoader.Provider::get)
+                            .filter(instance -> instance instanceof AnimalService)
+                            .map(AnimalService.class::cast)
+                            .map(AnimalService::getAnimal)
+                            .sorted()
+                            .collect(Collectors.joining());
+                }
+
+                public String callServiceFromModularJar() {
+                    return ServiceLoader.load(NumberService.class, ServiceCaller.class.getClassLoader()).stream()
+                            .map(ServiceLoader.Provider::get)
+                            .map(NumberService::getNumber)
+                            .sorted()
+                            .collect(Collectors.joining());
+                }
+
+                public String callServiceFromNonModularJar() {
+                    return ServiceLoader.load(FooBarService.class, ServiceCaller.class.getClassLoader()).stream()
+                            .map(ServiceLoader.Provider::get)
+                            .map(FooBarService::getFoo)
+                            .sorted()
+                            .collect(Collectors.joining());
+                }
+            }
+            """;
+
+        Map<String, CharSequence> serviceCallerJarSources = new HashMap<>();
+        serviceCallerJarSources.put("q.caller.ServiceCaller", serviceCaller);
+        var serviceCallerJarCompiled = InMemoryJavaCompiler.compile(
+            serviceCallerJarSources,
+            "--class-path",
+            String.join(
+                System.getProperty("path.separator"),
+                parentJar.toString(),
+                optionalJar.toString(),
+                modularJar.toString(),
+                nonModularJar.toString()
+            )
+        );
+
+        assertThat(serviceCallerJarCompiled, notNullValue());
+
+        Path serviceCallerJar = libDir.resolve("service-caller.jar");
+        JarUtils.createJarWithEntries(
+            serviceCallerJar,
+            serviceCallerJarCompiled.entrySet()
+                .stream()
+                .collect(Collectors.toMap(e -> e.getKey().replace(".", "/") + ".class", Map.Entry::getValue))
+        );
+        return serviceCallerJar;
+    }
+
+    private static Path createNonModularizedJarForBundle(Path libDir, Path parentJar, Path optionalJar, Path modularJar)
+        throws IOException {
+        String serviceFromNonModularJar = """
+            package q.jar.two;
+            public interface FooBarService {
+                String getFoo();
+            }
+            """;
+        String providerInNonModularJar = """
+            package q.jar.two;
+            import q.jar.one.NumberService;
+            import p.required.LetterService;
+
+            public class JarTwoProvider implements FooBarService, LetterService, NumberService {
+                @Override public String getFoo() { return "foo"; }
+                @Override public String getLetter() { return "B"; }
+                @Override public String getNumber() { return "2"; }
+            }
+            """;
+        String providerOfOptionalInNonModularJar = """
+            package q.jar.two;
+            import p.optional.AnimalService;
+
+            public class JarTwoOptionalProvider implements AnimalService {
+                @Override public String getAnimal() { return "cat"; }
+            }
+
+            """;
+
+        Map<String, CharSequence> nonModularJarSources = new HashMap<>();
+        nonModularJarSources.put("q.jar.two.FooBarService", serviceFromNonModularJar);
+        nonModularJarSources.put("q.jar.two.JarTwoProvider", providerInNonModularJar);
+        nonModularJarSources.put("q.jar.two.JarTwoOptionalProvider", providerOfOptionalInNonModularJar);
+        var nonModularJarCompiled = InMemoryJavaCompiler.compile(
+            nonModularJarSources,
+            "--class-path",
+            String.join(System.getProperty("path.separator"), parentJar.toString(), optionalJar.toString(), modularJar.toString())
+        );
+
+        assertThat(nonModularJarCompiled, notNullValue());
+
+        Path nonModularJar = libDir.resolve("non-modular.jar");
+        var nonModularJarEntries = new HashMap<String, byte[]>();
+        nonModularJarEntries.put("q/jar/two/FooBarService.class", nonModularJarCompiled.get("q.jar.two.FooBarService"));
+        nonModularJarEntries.put("q/jar/two/JarTwoProvider.class", nonModularJarCompiled.get("q.jar.two.JarTwoProvider"));
+        nonModularJarEntries.put("q/jar/two/JarTwoOptionalProvider.class", nonModularJarCompiled.get("q.jar.two.JarTwoOptionalProvider"));
+        nonModularJarEntries.put("META-INF/services/p.required.LetterService", "q.jar.two.JarTwoProvider".getBytes(StandardCharsets.UTF_8));
+        nonModularJarEntries.put(
+            "META-INF/services/p.optional.AnimalService",
+            "q.jar.two.JarTwoOptionalProvider".getBytes(StandardCharsets.UTF_8)
+        );
+        nonModularJarEntries.put("META-INF/services/q.jar.one.NumberService", "q.jar.two.JarTwoProvider".getBytes(StandardCharsets.UTF_8));
+        nonModularJarEntries.put("META-INF/services/q.jar.two.FooBarService", "q.jar.two.JarTwoProvider".getBytes(StandardCharsets.UTF_8));
+        JarUtils.createJarWithEntries(nonModularJar, nonModularJarEntries);
+        return nonModularJar;
+    }
+
+    private static Path createModularizedJarForBundle(Path libDir) throws IOException {
+        String serviceFromModularJar = """
+            package q.jar.one;
+            public interface NumberService {
+                String getNumber();
+            }
+            """;
+        String providerInModularJar = """
+            package q.jar.one;
+            import p.required.LetterService;
+
+            public class JarOneProvider implements LetterService, NumberService {
+                @Override public String getLetter() { return "A"; }
+                @Override public String getNumber() { return "1"; }
+            }
+            """;
+        String providerOfOptionalInModularJar = """
+            package q.jar.one;
+            import p.optional.AnimalService;
+
+            public class JarOneOptionalProvider implements AnimalService {
+                @Override public String getAnimal() { return "dog"; }
+            }
+            """;
+        String moduleInfo = """
+            module q.jar.one {
+                exports q.jar.one;
+
+                requires p.required;
+                requires static p.optional;
+
+                provides p.optional.AnimalService with q.jar.one.JarOneOptionalProvider;
+                provides p.required.LetterService with q.jar.one.JarOneProvider;
+                provides q.jar.one.NumberService with q.jar.one.JarOneProvider;
+            }
+            """;
+
+        Map<String, CharSequence> modularizedJarSources = new HashMap<>();
+        modularizedJarSources.put("q.jar.one.NumberService", serviceFromModularJar);
+        modularizedJarSources.put("q.jar.one.JarOneProvider", providerInModularJar);
+        modularizedJarSources.put("q.jar.one.JarOneOptionalProvider", providerOfOptionalInModularJar);
+        modularizedJarSources.put("module-info", moduleInfo);
+        var modularJarCompiled = InMemoryJavaCompiler.compile(modularizedJarSources, "--module-path", libDir.toString());
+
+        assertThat(modularJarCompiled, notNullValue());
+
+        Path modularJar = libDir.resolve("modular.jar");
+        JarUtils.createJarWithEntries(
+            modularJar,
+            modularJarCompiled.entrySet()
+                .stream()
+                .collect(Collectors.toMap(e -> e.getKey().replace(".", "/") + ".class", Map.Entry::getValue))
+        );
+        return modularJar;
+    }
+
+    private static Path createOptionalJarForParentLayer(Path libDir) throws IOException {
+        String serviceFromOptionalParent = """
+            package p.optional;
+            public interface AnimalService {
+                String getAnimal();
+            }
+            """;
+        String optionalParentModuleInfo = """
+            module p.optional { exports p.optional; }
+            """;
+        Map<String, CharSequence> optionalModuleSources = new HashMap<>();
+        optionalModuleSources.put("p.optional.AnimalService", serviceFromOptionalParent);
+        optionalModuleSources.put("module-info", optionalParentModuleInfo);
+        var optionalModuleCompiled = InMemoryJavaCompiler.compile(optionalModuleSources);
+        assertThat(optionalModuleCompiled, notNullValue());
+
+        Path optionalJar = libDir.resolve("optional.jar");
+        JarUtils.createJarWithEntries(
+            optionalJar,
+            optionalModuleCompiled.entrySet()
+                .stream()
+                .collect(Collectors.toMap(e -> e.getKey().replace(".", "/") + ".class", Map.Entry::getValue))
+        );
+        return optionalJar;
+    }
+
+    private static Path createRequiredJarForParentLayer(Path libDir) throws IOException {
+        String serviceFromRequiredParent = """
+            package p.required;
+            public interface LetterService {
+                String getLetter();
+            }
+            """;
+        String requiredParentModuleInfo = """
+            module p.required { exports p.required; }
+            """;
+        Map<String, CharSequence> requiredModuleSources = new HashMap<>();
+        requiredModuleSources.put("p.required.LetterService", serviceFromRequiredParent);
+        requiredModuleSources.put("module-info", requiredParentModuleInfo);
+        var requiredModuleCompiled = InMemoryJavaCompiler.compile(requiredModuleSources);
+
+        assertThat(requiredModuleCompiled, notNullValue());
+        Path parentJar = libDir.resolve("parent.jar");
+
+        JarUtils.createJarWithEntries(
+            parentJar,
+            requiredModuleCompiled.entrySet()
+                .stream()
+                .collect(Collectors.toMap(e -> e.getKey().replace(".", "/") + ".class", Map.Entry::getValue))
+        );
+        return parentJar;
+    }
+
     private static UberModuleClassLoader getLoader(Path jar) {
         return getLoader(List.of(jar));
     }