Skip to content

Commit d2a51e3

Browse files
authored
Fix ServiceLoader queries using ModuleClassLoader & add related tests (#52)
The fix is just to call the package-private `ModuleLayer.bindToLoader(ClassLoader)` method on the parent layers when creating the `ModuleClassLoader`. The rest of the PR is a complicated testing setup, to make sure that we can test with both CP-loaded and SJH-loaded source sets. Closes McModLauncher/modlauncher#100.
1 parent 529e8f1 commit d2a51e3

13 files changed

Lines changed: 380 additions & 10 deletions

File tree

build.gradle

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ dependencyUpdates {
8484
}
8585
}
8686

87+
sourceSets {
88+
// Additional test JARs, to be loaded via SecureJar.from(...)
89+
testjar1 {}
90+
testjar2 {}
91+
// Test classpath code, to make sure that ModuleClassLoader is properly isolated from the classpath
92+
testjar_cp {}
93+
test {
94+
runtimeClasspath += sourceSets.testjar_cp.output
95+
}
96+
}
97+
8798
// We can't use toolchains because we need --add-export
8899
//java.toolchain.languageVersion = JavaLanguageVersion.of(16)
89100
compileJava {
@@ -95,20 +106,12 @@ compileJava {
95106
]
96107
}
97108

98-
test {
99-
//exclude '**/*'
100-
useJUnitPlatform()
101-
jvmArgs += [
102-
'--add-opens=java.base/java.lang.invoke=ALL-UNNAMED'
103-
]
104-
}
105-
106109
compileTestJava {
107110
sourceCompatibility = JavaVersion.VERSION_16
108111
targetCompatibility = JavaVersion.VERSION_16
109112
options.compilerArgs += [
110113
'--add-modules=jdk.zipfs',
111-
'--add-exports=jdk.zipfs/jdk.nio.zipfs=ALL-UNNAMED'
114+
'--add-exports=jdk.zipfs/jdk.nio.zipfs=cpw.mods.securejarhandler'
112115
]
113116
}
114117

@@ -126,6 +129,49 @@ dependencies {
126129
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.8.+')
127130
}
128131

132+
test {
133+
//exclude '**/*'
134+
useJUnitPlatform()
135+
jvmArgs += [
136+
// Add test sourceset to the SJH module
137+
'--patch-module=cpw.mods.securejarhandler=' + sourceSets.test.output.classesDirs.asPath,
138+
// SJH needs this for UnionFileSystem
139+
'--add-opens=java.base/java.lang.invoke=cpw.mods.securejarhandler',
140+
// Allow JUnit to access the tests
141+
'--add-opens=cpw.mods.securejarhandler/cpw.mods.cl.test=ALL-UNNAMED',
142+
'--add-opens=cpw.mods.securejarhandler/cpw.mods.jarhandling.impl=ALL-UNNAMED',
143+
'--add-opens=cpw.mods.securejarhandler/cpw.mods.niofs.union=ALL-UNNAMED',
144+
// To test reading from the classpath
145+
'--add-reads=cpw.mods.securejarhandler=ALL-UNNAMED',
146+
]
147+
148+
dependsOn tasks.compileTestjar1Java
149+
dependsOn tasks.processTestjar1Resources
150+
dependsOn tasks.compileTestjar2Java
151+
dependsOn tasks.processTestjar2Resources
152+
environment "sjh.testjar1", sourceSets.testjar1.output.classesDirs.asPath + File.pathSeparator + sourceSets.testjar1.output.resourcesDir.absolutePath
153+
environment "sjh.testjar2", sourceSets.testjar2.output.classesDirs.asPath + File.pathSeparator + sourceSets.testjar2.output.resourcesDir.absolutePath
154+
155+
// Add SJH and its dependencies to the module path
156+
jvmArgumentProviders.add(new CommandLineArgumentProvider() {
157+
@Override
158+
Iterable<String> asArguments() {
159+
// Dependencies
160+
def modulePath = test.classpath.filter {
161+
it.name.contains("asm")
162+
}.join(File.pathSeparator)
163+
// SJH itself
164+
modulePath += File.pathSeparator + sourceSets.main.output.classesDirs.asPath
165+
166+
return [
167+
"--module-path",
168+
modulePath,
169+
"--add-modules=ALL-MODULE-PATH",
170+
]
171+
}
172+
})
173+
}
174+
129175
changelog {
130176
from '0.9'
131177
}

src/main/java/cpw/mods/cl/ModuleClassLoader.java

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@
66
import java.io.IOException;
77
import java.io.InputStream;
88
import java.io.UncheckedIOException;
9+
import java.lang.invoke.MethodHandle;
10+
import java.lang.invoke.MethodHandles;
11+
import java.lang.invoke.MethodType;
912
import java.lang.module.*;
1013
import java.net.MalformedURLException;
1114
import java.net.URI;
1215
import java.net.URL;
1316
import java.util.*;
1417
import java.util.function.BiFunction;
18+
import java.util.function.Consumer;
1519
import java.util.function.Function;
16-
import java.util.function.Supplier;
1720
import java.util.stream.Collectors;
1821
import java.util.stream.Stream;
1922

@@ -24,6 +27,39 @@ public class ModuleClassLoader extends ClassLoader {
2427
ModularURLHandler.initFrom(ModuleClassLoader.class.getModule().getLayer());
2528
}
2629

30+
// Reflect into JVM internals to associate each ModuleClassLoader with all of its parent layers.
31+
// This is necessary to let ServiceProvider find service implementations in parent module layers.
32+
// At the moment, this does not work for providers in the bootstrap or platform class loaders,
33+
// but any other provider (defined by the application class loader or child layers) should work.
34+
//
35+
// The only mechanism the JVM has for this is to also look for layers defined by the parent class loader.
36+
// We don't want to set a parent because we explicitly do not want to delegate to a parent class loader,
37+
// and that wouldn't even handle the case of multiple parent layers anyway.
38+
private static final MethodHandle LAYER_BIND_TO_LOADER;
39+
40+
static {
41+
try {
42+
var hackfield = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
43+
hackfield.setAccessible(true);
44+
MethodHandles.Lookup hack = (MethodHandles.Lookup) hackfield.get(null);
45+
46+
LAYER_BIND_TO_LOADER = hack.findSpecial(ModuleLayer.class, "bindToLoader", MethodType.methodType(void.class, ClassLoader.class), ModuleLayer.class);
47+
} catch (NoSuchFieldException | IllegalAccessException | NoSuchMethodException e) {
48+
throw new RuntimeException(e);
49+
}
50+
}
51+
52+
/**
53+
* Invokes {@code ModuleLayer.bindToLoader(ClassLoader)}.
54+
*/
55+
private static void bindToLayer(ModuleClassLoader classLoader, ModuleLayer layer) {
56+
try {
57+
LAYER_BIND_TO_LOADER.invokeExact(layer, (ClassLoader) classLoader);
58+
} catch (Throwable t) {
59+
throw new RuntimeException(t);
60+
}
61+
}
62+
2763
private final Configuration configuration;
2864
private final Map<String, JarModuleFinder.JarModuleReference> resolvedRoots;
2965
private final Map<String, ResolvedModule> packageLookup;
@@ -78,6 +114,20 @@ public ModuleClassLoader(final String name, final Configuration configuration, f
78114
}
79115
}
80116
}
117+
// Bind this classloader to all parent layers recursively,
118+
// to make sure ServiceLoader can find providers defined in parent layers
119+
Set<ModuleLayer> visitedLayers = new HashSet<>();
120+
parentLayers.forEach(p -> forLayerAndParents(p, visitedLayers, l -> bindToLayer(this, l)));
121+
}
122+
123+
private static void forLayerAndParents(ModuleLayer layer, Set<ModuleLayer> visited, Consumer<ModuleLayer> operation) {
124+
if (visited.contains(layer)) return;
125+
visited.add(layer);
126+
operation.accept(layer);
127+
128+
if (layer != ModuleLayer.boot()) {
129+
layer.parents().forEach(l -> forLayerAndParents(l, visited, operation));
130+
}
81131
}
82132

83133
private URL readerToURL(final ModuleReader reader, final ModuleReference ref, final String name) {

src/test/java/cpw/mods/cl/test/TestClassLoader.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
import java.util.ServiceLoader;
1515
import java.util.function.Consumer;
1616

17+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
18+
import static org.junit.jupiter.api.Assertions.assertThrows;
19+
1720
public class TestClassLoader {
1821
public static void main(String[] args) {
1922
new TestClassLoader().testCL();
@@ -41,4 +44,17 @@ void testCL() {
4144
var c = sl.stream().map(ServiceLoader.Provider::get).toList();
4245
c.get(0).accept(new String[0]);
4346
}
47+
48+
@Test
49+
public void testCpIsolation() throws Exception {
50+
// Make sure that classes that would normally be accessible via classpath...
51+
assertDoesNotThrow(() -> Class.forName("cpw.mods.testjar_cp.SomeClass"));
52+
53+
// ...cannot be loaded via a ModuleClassLoader
54+
TestjarUtil.withTestjar1Setup(cl -> {
55+
assertThrows(ClassNotFoundException.class, () -> {
56+
Class.forName("cpw.mods.testjar_cp.SomeClass", true, cl);
57+
});
58+
});
59+
}
4460
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cpw.mods.cl.test;
2+
3+
import cpw.mods.cl.ModuleClassLoader;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.net.spi.URLStreamHandlerProvider;
7+
import java.nio.file.spi.FileSystemProvider;
8+
import java.util.ServiceLoader;
9+
10+
import static org.junit.jupiter.api.Assertions.assertFalse;
11+
import static org.junit.jupiter.api.Assertions.assertTrue;
12+
13+
public class TestServiceLoader {
14+
/**
15+
* Tests that we can load services from modules that are part of the boot layer.
16+
* In principle this also tests that services correctly get loaded from parent module layers too.
17+
*/
18+
@Test
19+
public void testLoadServiceFromBootLayer() throws Exception {
20+
TestjarUtil.withTestjar1Setup(cl -> {
21+
// We expect to find at least the unionfs provider
22+
ServiceLoader<FileSystemProvider> sl = TestjarUtil.loadTestjar1(cl, FileSystemProvider.class);
23+
boolean foundUnionFsProvider = sl.stream().map(ServiceLoader.Provider::get).anyMatch(p -> p.getScheme().equals("union"));
24+
25+
assertTrue(foundUnionFsProvider, "Expected to be able to find the UFS provider");
26+
});
27+
}
28+
29+
@Test
30+
public void testLoadServiceFromBootLayerNested() throws Exception {
31+
TestjarUtil.withTestjar2Setup(cl -> {
32+
// Try to find service from boot layer
33+
// We expect to find at least the unionfs provider
34+
ServiceLoader<FileSystemProvider> sl = TestjarUtil.loadTestjar2(cl, FileSystemProvider.class);
35+
boolean foundUnionFsProvider = sl.stream().map(ServiceLoader.Provider::get).anyMatch(p -> p.getScheme().equals("union"));
36+
37+
assertTrue(foundUnionFsProvider, "Expected to be able to find the UFS provider");
38+
39+
// Try to find service from testjar1 layer
40+
var foundService = TestjarUtil.loadTestjar2(cl, URLStreamHandlerProvider.class)
41+
.stream()
42+
.anyMatch(p -> p.type().getName().startsWith("cpw.mods.cl.testjar1"));
43+
44+
assertTrue(foundService, "Expected to be able to find the provider in testjar1 layer");
45+
});
46+
}
47+
48+
/**
49+
* Tests that services that would normally be loaded from the classpath
50+
* do not get loaded by {@link ModuleClassLoader}.
51+
* In other words, test that our class loader isolation also works with services.
52+
*/
53+
@Test
54+
public void testClassPathServiceDoesNotLeak() throws Exception {
55+
// Test that the DummyURLStreamHandlerProvider service provider can be loaded from the classpath
56+
var foundService = TestjarUtil.loadClasspath(TestServiceLoader.class.getClassLoader(), URLStreamHandlerProvider.class)
57+
.stream()
58+
.anyMatch(p -> p.type().getName().startsWith("cpw.mods.testjar_cp"));
59+
60+
assertTrue(foundService, "Could not find service in classpath using application class loader!");
61+
62+
TestjarUtil.withTestjar1Setup(cl -> {
63+
// Test that the DummyURLStreamHandlerProvider service provider cannot be loaded
64+
// from the classpath via ModuleClassLoader
65+
var foundServiceMCL = TestjarUtil.loadTestjar1(cl, URLStreamHandlerProvider.class)
66+
.stream()
67+
.anyMatch(p -> p.type().getName().startsWith("cpw.mods.testjar_cp"));
68+
69+
assertFalse(foundServiceMCL, "Could find service in classpath using application class loader!");
70+
});
71+
}
72+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package cpw.mods.cl.test;
2+
3+
import cpw.mods.cl.JarModuleFinder;
4+
import cpw.mods.cl.ModuleClassLoader;
5+
import cpw.mods.jarhandling.SecureJar;
6+
7+
import java.io.File;
8+
import java.lang.module.Configuration;
9+
import java.lang.module.ModuleFinder;
10+
import java.nio.file.Path;
11+
import java.nio.file.Paths;
12+
import java.util.List;
13+
import java.util.ServiceLoader;
14+
import java.util.stream.Stream;
15+
16+
public class TestjarUtil {
17+
private record BuiltLayer(ModuleClassLoader cl, ModuleLayer layer) {}
18+
19+
/**
20+
* Build a layer for a {@code testjarX} source set.
21+
*/
22+
private static BuiltLayer buildTestjarLayer(int testjar, List<ModuleLayer> parentLayers) {
23+
var paths = Stream.of(System.getenv("sjh.testjar" + testjar).split(File.pathSeparator))
24+
.map(Paths::get)
25+
.toArray(Path[]::new);
26+
var jar = SecureJar.from(paths);
27+
28+
var roots = List.of(jar.name());
29+
var jf = JarModuleFinder.of(jar);
30+
var conf = Configuration.resolveAndBind(
31+
jf,
32+
parentLayers.stream().map(ModuleLayer::configuration).toList(),
33+
ModuleFinder.of(),
34+
roots);
35+
var cl = new ModuleClassLoader("testjar2-layer", conf, parentLayers);
36+
var layer = ModuleLayer.defineModules(conf, parentLayers, m -> cl).layer();
37+
return new BuiltLayer(cl, layer);
38+
}
39+
40+
private static void withClassLoader(ClassLoader cl, TestCallback callback) throws Exception {
41+
// Replace context classloader during the callback
42+
var previousCl = Thread.currentThread().getContextClassLoader();
43+
Thread.currentThread().setContextClassLoader(cl);
44+
45+
try {
46+
callback.test(cl);
47+
} finally {
48+
Thread.currentThread().setContextClassLoader(previousCl);
49+
}
50+
}
51+
52+
/**
53+
* Load the {@code testjar1} source set as new module into a new layer,
54+
* and run the callback with the new layer's classloader.
55+
*/
56+
public static void withTestjar1Setup(TestCallback callback) throws Exception {
57+
var built = buildTestjarLayer(1, List.of(ModuleLayer.boot()));
58+
59+
withClassLoader(built.cl, callback);
60+
}
61+
62+
/**
63+
* Load the {@code testjar2} source set as new module into a new layer,
64+
* whose parent is a layer loaded from the {@code testjar1} source set.
65+
*/
66+
public static void withTestjar2Setup(TestCallback callback) throws Exception {
67+
var built1 = buildTestjarLayer(1, List.of(ModuleLayer.boot()));
68+
var built2 = buildTestjarLayer(2, List.of(built1.layer));
69+
70+
withClassLoader(built2.cl, callback);
71+
}
72+
73+
@FunctionalInterface
74+
public interface TestCallback {
75+
void test(ClassLoader cl) throws Exception;
76+
}
77+
78+
/**
79+
* Instantiates a {@link ServiceLoader} within the testjar1 module.
80+
*/
81+
public static <S> ServiceLoader<S> loadTestjar1(ClassLoader cl, Class<S> clazz) throws Exception {
82+
// Use the `load` method from the testjar sourceset.
83+
var testClass = cl.loadClass("cpw.mods.cl.testjar1.ServiceLoaderTest");
84+
var loadMethod = testClass.getMethod("load", Class.class);
85+
//noinspection unchecked
86+
return (ServiceLoader<S>) loadMethod.invoke(null, clazz);
87+
}
88+
89+
/**
90+
* Instantiates a {@link ServiceLoader} within the testjar2 module.
91+
*/
92+
public static <S> ServiceLoader<S> loadTestjar2(ClassLoader cl, Class<S> clazz) throws Exception {
93+
// Use the `load` method from the testjar sourceset.
94+
var testClass = cl.loadClass("cpw.mods.cl.testjar2.ServiceLoaderTest");
95+
var loadMethod = testClass.getMethod("load", Class.class);
96+
//noinspection unchecked
97+
return (ServiceLoader<S>) loadMethod.invoke(null, clazz);
98+
}
99+
100+
/**
101+
* Instantiates a {@link ServiceLoader} within the classpath source set.
102+
*/
103+
public static <S> ServiceLoader<S> loadClasspath(ClassLoader cl, Class<S> clazz) throws Exception {
104+
// Use the `load` method from the testjar sourceset.
105+
var testClass = cl.loadClass("cpw.mods.testjar_cp.ServiceLoaderTest");
106+
var loadMethod = testClass.getMethod("load", Class.class);
107+
//noinspection unchecked
108+
return (ServiceLoader<S>) loadMethod.invoke(null, clazz);
109+
}
110+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package cpw.mods.cl.testjar1;
2+
3+
import java.net.URLStreamHandler;
4+
import java.net.spi.URLStreamHandlerProvider;
5+
6+
/**
7+
* Referenced by {@code TestServiceLoader}.
8+
*/
9+
public class DummyURLStreamHandlerProvider extends URLStreamHandlerProvider {
10+
@Override
11+
public URLStreamHandler createURLStreamHandler(String protocol) {
12+
return null;
13+
}
14+
}

0 commit comments

Comments
 (0)