Implemented InstrumentationModuleClassLoader for invokedynamic Advice dispatching (#9177)

Co-authored-by: Trask Stalnaker <trask.stalnaker@gmail.com>
This commit is contained in:
Jonas Kunz 2023-08-21 14:11:52 +02:00 committed by GitHub
parent dd2bccdd3e
commit 5abba34ade
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 678 additions and 0 deletions

View File

@ -0,0 +1,135 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.javaagent.tooling.instrumentation.indy;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import net.bytebuddy.utility.StreamDrainer;
/**
* Provides the bytecode and the original resource URL for loaded and not-yet loaded classes. The
* implementation is based on {@link net.bytebuddy.dynamic.ClassFileLocator.ForClassLoader}, with
* the difference that it preserves the original classfile resource URL.
*/
abstract class ClassCopySource {
private ClassCopySource() {}
/**
* Provides a URL pointing to the specific classfile.
*
* @return the URL
*/
public abstract URL getUrl();
/**
* Provides the bytecode of the class. The result is the same as calling {@link URL#openStream()}
* on {@link #getUrl()} and draining that stream.
*
* @return the bytecode of the class.
*/
public abstract byte[] getBytecode();
/**
* Creates a cached copy of this {@link ClassCopySource}. The cached copy eagerly loads the
* bytecode, so that {@link #getBytecode()} is guaranteed to not cause any IO. This comes at the
* cost of a higher heap consumption, as the bytecode is kept in memory.
*
* @return an ClassFileSource implementing the described caching behaviour.
*/
public abstract ClassCopySource cached();
/**
* Creates a {@link ClassCopySource} for the class with the provided fully qualified name. The
* .class file for the provided classname must be available as a resource in the provided
* classloader. The class is guaranteed to not be loaded during this process.
*
* @param className the fully qualified name of the class to copy
* @param classLoader the classloader
* @return the ClassCopySource which can be used to copy the provided class to other classloaders.
*/
public static ClassCopySource create(String className, ClassLoader classLoader) {
if (classLoader == null) {
throw new IllegalArgumentException(
"Copying classes from the bootstrap classloader is not supported!");
}
String classFileName = className.replace('.', '/') + ".class";
return new Lazy(classLoader, classFileName);
}
/**
* Same as {@link #create(String, ClassLoader)}, but easier to use for already loaded classes.
*
* @param loadedClass the class to copy
* @return the ClassCopySource which can be used to copy the provided class to other classloaders.
*/
public static ClassCopySource create(Class<?> loadedClass) {
return create(loadedClass.getName(), loadedClass.getClassLoader());
}
private static class Lazy extends ClassCopySource {
private final ClassLoader classLoader;
private final String resourceName;
private Lazy(ClassLoader classLoader, String resourceName) {
this.classLoader = classLoader;
this.resourceName = resourceName;
}
@Override
public URL getUrl() {
URL url = classLoader.getResource(resourceName);
if (url == null) {
throw new IllegalStateException(
"Classfile " + resourceName + " does not exist in the provided classloader!");
}
return url;
}
@Override
public byte[] getBytecode() {
try (InputStream bytecodeStream = getUrl().openStream()) {
return StreamDrainer.DEFAULT.drain(bytecodeStream);
} catch (IOException e) {
throw new IllegalStateException("Failed to read classfile URL", e);
}
}
@Override
public ClassCopySource cached() {
return new Cached(this);
}
}
private static class Cached extends ClassCopySource {
private final URL classFileUrl;
private final byte[] cachedByteCode;
private Cached(ClassCopySource.Lazy from) {
classFileUrl = from.getUrl();
cachedByteCode = from.getBytecode();
}
@Override
public URL getUrl() {
return classFileUrl;
}
@Override
public byte[] getBytecode() {
return cachedByteCode;
}
@Override
public ClassCopySource cached() {
return this;
}
}
}

View File

@ -0,0 +1,255 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.javaagent.tooling.instrumentation.indy;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.net.URL;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.security.ProtectionDomain;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
/**
* Classloader used to load the helper classes from {@link
* io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule}s, so that those
* classes have access to both the agent/extension classes and the instrumented application classes.
*
* <p>This classloader implements the following classloading delegation strategy:
*
* <ul>
* <li>First, injected classes are considered (usually the helper classes from the
* InstrumentationModule)
* <li>Next, the classloader looks in the agent or extension classloader, depending on where the
* InstrumentationModule comes from
* <li>Finally, the instrumented application classloader is checked for the class
* </ul>
*
* <p>In addition, this classloader ensures that the lookup of corresponding .class resources follow
* the same delegation strategy, so that bytecode inspection tools work correctly.
*/
class InstrumentationModuleClassLoader extends ClassLoader {
static {
ClassLoader.registerAsParallelCapable();
}
private static final Map<String, ClassCopySource> ALWAYS_INJECTED_CLASSES =
Collections.singletonMap(
LookupExposer.class.getName(), ClassCopySource.create(LookupExposer.class).cached());
private static final ProtectionDomain PROTECTION_DOMAIN = getProtectionDomain();
private static final MethodHandle FIND_PACKAGE_METHOD = getFindPackageMethod();
private final Map<String, ClassCopySource> additionalInjectedClasses;
private final ClassLoader agentOrExtensionCl;
private final ClassLoader instrumentedCl;
private volatile MethodHandles.Lookup cachedLookup;
public InstrumentationModuleClassLoader(
ClassLoader instrumentedCl,
ClassLoader agentOrExtensionCl,
Map<String, ClassCopySource> injectedClasses) {
// agent/extension-classloader is "main"-parent, but class lookup is overridden
super(agentOrExtensionCl);
additionalInjectedClasses = injectedClasses;
this.agentOrExtensionCl = agentOrExtensionCl;
this.instrumentedCl = instrumentedCl;
}
/**
* Provides a Lookup within this classloader. See {@link LookupExposer} for the details.
*
* @return a lookup capable of accessing public types in this classloader
*/
public MethodHandles.Lookup getLookup() {
if (cachedLookup == null) {
// Load the injected copy of LookupExposer and invoke it
try {
// we don't mind the race condition causing the initialization to run multiple times here
Class<?> lookupExposer = loadClass(LookupExposer.class.getName());
cachedLookup = (MethodHandles.Lookup) lookupExposer.getMethod("getLookup").invoke(null);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
return cachedLookup;
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> result = findLoadedClass(name);
// This CL is self-first: Injected class are loaded BEFORE a parent lookup
if (result == null) {
ClassCopySource injected = getInjectedClass(name);
if (injected != null) {
byte[] bytecode = injected.getBytecode();
if (System.getSecurityManager() == null) {
result = defineClassWithPackage(name, bytecode);
} else {
result =
AccessController.doPrivileged(
(PrivilegedAction<Class<?>>) () -> defineClassWithPackage(name, bytecode));
}
}
}
if (result == null) {
result = tryLoad(agentOrExtensionCl, name);
}
if (result == null) {
result = tryLoad(instrumentedCl, name);
}
if (result != null) {
if (resolve) {
resolveClass(result);
}
return result;
} else {
throw new ClassNotFoundException(name);
}
}
}
private static Class<?> tryLoad(ClassLoader cl, String name) {
try {
return cl.loadClass(name);
} catch (ClassNotFoundException e) {
return null;
}
}
@Override
public URL getResource(String resourceName) {
String className = resourceToClassName(resourceName);
if (className == null) {
// delegate to just the default parent (the agent classloader)
return super.getResource(resourceName);
}
// for classes use the same precedence as in loadClass
ClassCopySource injected = getInjectedClass(className);
if (injected != null) {
return injected.getUrl();
}
URL fromAgentCl = agentOrExtensionCl.getResource(resourceName);
if (fromAgentCl != null) {
return fromAgentCl;
}
return instrumentedCl.getResource(resourceName);
}
@Override
public Enumeration<URL> getResources(String resourceName) throws IOException {
String className = resourceToClassName(resourceName);
if (className == null) {
return super.getResources(resourceName);
}
URL resource = getResource(resourceName);
List<URL> result =
resource != null ? Collections.singletonList(resource) : Collections.emptyList();
return Collections.enumeration(result);
}
@Nullable
private static String resourceToClassName(String resourceName) {
if (!resourceName.endsWith(".class")) {
return null;
}
String className = resourceName;
if (className.startsWith("/")) {
className = className.substring(1);
}
className = className.replace('/', '.');
className = className.substring(0, className.length() - ".class".length());
return className;
}
@Nullable
private ClassCopySource getInjectedClass(String name) {
ClassCopySource alwaysInjected = ALWAYS_INJECTED_CLASSES.get(name);
if (alwaysInjected != null) {
return alwaysInjected;
}
return additionalInjectedClasses.get(name);
}
private Class<?> defineClassWithPackage(String name, byte[] bytecode) {
int lastDotIndex = name.lastIndexOf('.');
if (lastDotIndex != -1) {
String packageName = name.substring(0, lastDotIndex);
safeDefinePackage(packageName);
}
return defineClass(name, bytecode, 0, bytecode.length, PROTECTION_DOMAIN);
}
private void safeDefinePackage(String packageName) {
if (findPackage(packageName) == null) {
try {
definePackage(packageName, null, null, null, null, null, null, null);
} catch (IllegalArgumentException e) {
// Can happen if two classes from the same package are loaded concurrently
if (findPackage(packageName) == null) {
// package still doesn't exist, the IllegalArgumentException must be for a different
// reason than a race condition
throw e;
}
}
}
}
/**
* Invokes {@link #getPackage(String)} for Java 8 and {@link #getDefinedPackage(String)} for Java
* 9+.
*
* <p>Package-private for testing.
*
* @param name the name of the package find
* @return the found package or null if it was not found.
*/
@SuppressWarnings({"deprecation", "InvalidLink"})
Package findPackage(String name) {
try {
return (Package) FIND_PACKAGE_METHOD.invoke(this, name);
} catch (Throwable t) {
throw new IllegalStateException(t);
}
}
private static ProtectionDomain getProtectionDomain() {
if (System.getSecurityManager() == null) {
return InstrumentationModuleClassLoader.class.getProtectionDomain();
}
return AccessController.doPrivileged(
(PrivilegedAction<ProtectionDomain>)
((Class<?>) InstrumentationModuleClassLoader.class)::getProtectionDomain);
}
private static MethodHandle getFindPackageMethod() {
MethodType methodType = MethodType.methodType(Package.class, String.class);
MethodHandles.Lookup lookup = MethodHandles.lookup();
try {
try {
return lookup.findVirtual(ClassLoader.class, "getDefinedPackage", methodType);
} catch (NoSuchMethodException e) {
// Java 8 case
try {
return lookup.findVirtual(ClassLoader.class, "getPackage", methodType);
} catch (NoSuchMethodException ex) {
throw new IllegalStateException("expected method to always exist!", ex);
}
}
} catch (IllegalAccessException e) {
throw new IllegalStateException("Method should be accessible from here", e);
}
}
}

View File

@ -0,0 +1,27 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.javaagent.tooling.instrumentation.indy;
import java.lang.invoke.MethodHandles;
/**
* This class is injected into every {@link InstrumentationModuleClassLoader} so that the bootstrap
* can use a {@link MethodHandles.Lookup} with a lookup class from within the {@link
* InstrumentationModuleClassLoader}, instead of calling {@link MethodHandles#lookup()} which uses
* the caller class as the lookup class.
*
* <p>This circumvents a nasty JVM bug that's described <a
* href="https://github.com/elastic/apm-agent-java/issues/1450">here</a>. The error is reproduced in
* {@code InstrumentationModuleClassLoaderTest}
*/
public class LookupExposer {
private LookupExposer() {}
public static MethodHandles.Lookup getLookup() {
return MethodHandles.lookup();
}
}

View File

@ -0,0 +1,239 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.javaagent.tooling.instrumentation.indy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import io.opentelemetry.javaagent.tooling.instrumentation.indy.dummies.Bar;
import io.opentelemetry.javaagent.tooling.instrumentation.indy.dummies.Foo;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.dynamic.ClassFileLocator;
import net.bytebuddy.implementation.FixedValue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@SuppressWarnings("ClassNamedLikeTypeParameter")
class InstrumentationModuleClassLoaderTest {
@Test
void checkLookup() throws Throwable {
Map<String, ClassCopySource> toInject = new HashMap<>();
toInject.put(Foo.class.getName(), ClassCopySource.create(Foo.class));
toInject.put(Bar.class.getName(), ClassCopySource.create(Bar.class));
ClassLoader dummyParent = new URLClassLoader(new URL[] {}, null);
InstrumentationModuleClassLoader m1 =
new InstrumentationModuleClassLoader(dummyParent, dummyParent, toInject);
InstrumentationModuleClassLoader m2 =
new InstrumentationModuleClassLoader(dummyParent, dummyParent, toInject);
// MethodHandles.publicLookup() always succeeds on the first invocation
lookupAndInvokeFoo(m1);
// MethodHandles.publicLookup() fails on the second invocation,
// even though the classes are loaded from an isolated class loader hierarchy
lookupAndInvokeFoo(m2);
}
private static void lookupAndInvokeFoo(InstrumentationModuleClassLoader classLoader)
throws Throwable {
Class<?> fooClass = classLoader.loadClass(Foo.class.getName());
MethodHandles.Lookup lookup;
// using public lookup fails with LinkageError on second invocation - is this a (known) JVM bug?
// The failure only occurs on certain JVM versions, e.g. Java 8 and 11
// lookup = MethodHandles.publicLookup();
lookup = classLoader.getLookup();
MethodHandle methodHandle =
lookup.findStatic(
fooClass,
"foo",
MethodType.methodType(String.class, classLoader.loadClass(Bar.class.getName())));
assertThat(methodHandle.invoke((Bar) null)).isEqualTo("foo");
}
@Test
void checkInjectedClassesHavePackage() throws Throwable {
Map<String, ClassCopySource> toInject = new HashMap<>();
toInject.put(A.class.getName(), ClassCopySource.create(A.class));
toInject.put(B.class.getName(), ClassCopySource.create(B.class));
String packageName = A.class.getName().substring(0, A.class.getName().lastIndexOf('.'));
ClassLoader dummyParent = new URLClassLoader(new URL[] {}, null);
InstrumentationModuleClassLoader m1 =
new InstrumentationModuleClassLoader(dummyParent, dummyParent, toInject);
Class<?> injected = Class.forName(A.class.getName(), true, m1);
// inject two classes from the same package to trigger errors if we try to redefine the package
Class.forName(B.class.getName(), true, m1);
assertThat(injected.getClassLoader()).isSameAs(m1);
Package clPackage = m1.findPackage(packageName);
Package classPackage = injected.getPackage();
assertThat(classPackage).isNotNull();
assertThat(clPackage).isNotNull();
assertThat(classPackage).isSameAs(clPackage);
}
@Test
void checkClassLookupPrecedence(@TempDir Path tempDir) throws Exception {
Map<String, byte[]> appClasses = copyClassesWithMarker("app-cl", A.class, B.class, C.class);
Map<String, byte[]> agentClasses = copyClassesWithMarker("agent-cl", B.class, C.class);
Map<String, byte[]> moduleClasses =
copyClassesWithMarker("module-cl", A.class, B.class, C.class, D.class);
Path appJar = tempDir.resolve("dummy-app.jar");
createJar(appClasses, appJar);
Path agentJar = tempDir.resolve("dummy-agent.jar");
createJar(agentClasses, agentJar);
Path moduleJar = tempDir.resolve("instrumentation-module.jar");
createJar(moduleClasses, moduleJar);
URLClassLoader appCl = new URLClassLoader(new URL[] {appJar.toUri().toURL()}, null);
URLClassLoader agentCl = new URLClassLoader(new URL[] {agentJar.toUri().toURL()}, null);
URLClassLoader moduleSourceCl = new URLClassLoader(new URL[] {moduleJar.toUri().toURL()}, null);
try {
Map<String, ClassCopySource> toInject = new HashMap<>();
toInject.put(C.class.getName(), ClassCopySource.create(C.class.getName(), moduleSourceCl));
InstrumentationModuleClassLoader moduleCl =
new InstrumentationModuleClassLoader(appCl, agentCl, toInject);
// Verify precedence for classloading
Class<?> clA = moduleCl.loadClass(A.class.getName());
assertThat(getMarkerValue(clA)).isEqualTo("app-cl");
assertThat(clA.getClassLoader()).isSameAs(appCl);
Class<?> clB = moduleCl.loadClass(B.class.getName());
assertThat(getMarkerValue(clB)).isEqualTo("agent-cl");
assertThat(clB.getClassLoader()).isSameAs(agentCl);
Class<?> clC = moduleCl.loadClass(C.class.getName());
assertThat(getMarkerValue(clC)).isEqualTo("module-cl");
assertThat(clC.getClassLoader())
.isSameAs(moduleCl); // class must be copied, therefore moduleCL
assertThatThrownBy(() -> moduleCl.loadClass(D.class.getName()))
.isInstanceOf(ClassNotFoundException.class);
// Verify precedence for looking up .class resources
URL resourceA = moduleCl.getResource(getClassFile(A.class));
assertThat(resourceA.toString()).startsWith("jar:file:" + appJar);
assertThat(Collections.list(moduleCl.getResources(getClassFile(A.class))))
.containsExactly(resourceA);
assertThat(moduleCl.getResourceAsStream(getClassFile(A.class)))
.hasBinaryContent(appClasses.get(A.class.getName()));
URL resourceB = moduleCl.getResource(getClassFile(B.class));
assertThat(resourceB.toString()).startsWith("jar:file:" + agentJar);
assertThat(Collections.list(moduleCl.getResources(getClassFile(B.class))))
.containsExactly(resourceB);
assertThat(moduleCl.getResourceAsStream(getClassFile(B.class)))
.hasBinaryContent(agentClasses.get(B.class.getName()));
URL resourceC = moduleCl.getResource(getClassFile(C.class));
assertThat(resourceC.toString()).startsWith("jar:file:" + moduleJar);
assertThat(Collections.list(moduleCl.getResources(getClassFile(C.class))))
.containsExactly(resourceC);
assertThat(moduleCl.getResourceAsStream(getClassFile(C.class)))
.hasBinaryContent(moduleClasses.get(C.class.getName()));
assertThat(moduleCl.getResource("/" + getClassFile(C.class))).isEqualTo(resourceC);
assertThat(moduleCl.getResource(D.class.getName())).isNull();
assertThat(moduleCl.getResourceAsStream(D.class.getName())).isNull();
assertThat(Collections.list(moduleCl.getResources(D.class.getName()))).isEmpty();
// And finally verify that our resource handling does what it is supposed to do:
// Provide the correct bytecode sources when looking up bytecode with bytebuddy (or similar
// tools)
assertThat(ClassFileLocator.ForClassLoader.read(clA))
.isEqualTo(appClasses.get(A.class.getName()));
assertThat(ClassFileLocator.ForClassLoader.read(clB))
.isEqualTo(agentClasses.get(B.class.getName()));
assertThat(ClassFileLocator.ForClassLoader.read(clC))
.isEqualTo(moduleClasses.get(C.class.getName()));
} finally {
appCl.close();
agentCl.close();
moduleSourceCl.close();
}
}
private static String getClassFile(Class<?> cl) {
return cl.getName().replace('.', '/') + ".class";
}
private static Map<String, byte[]> copyClassesWithMarker(String marker, Class<?>... toCopy) {
Map<String, byte[]> classes = new HashMap<>();
for (Class<?> clazz : toCopy) {
classes.put(clazz.getName(), copyClassWithMarker(clazz, marker));
}
return classes;
}
private static byte[] copyClassWithMarker(Class<?> original, String markerValue) {
return new ByteBuddy()
.redefine(original)
.defineMethod("marker", String.class, Modifier.PUBLIC | Modifier.STATIC)
.intercept(FixedValue.value(markerValue))
.make()
.getBytes();
}
private static String getMarkerValue(Class<?> clazz) {
try {
return (String) clazz.getMethod("marker").invoke(null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static void createJar(Map<String, byte[]> classNameToBytecode, Path jarFilePath) {
try (OutputStream fileOut = Files.newOutputStream(jarFilePath)) {
try (JarOutputStream jarOut = new JarOutputStream(fileOut)) {
for (String clName : classNameToBytecode.keySet()) {
String classFile = clName.replace('.', '/') + ".class";
jarOut.putNextEntry(new JarEntry(classFile));
jarOut.write(classNameToBytecode.get(clName));
jarOut.closeEntry();
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static class A {}
public static class B {}
public static class C {}
public static class D {}
}

View File

@ -0,0 +1,8 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.javaagent.tooling.instrumentation.indy.dummies;
public class Bar {}

View File

@ -0,0 +1,14 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.javaagent.tooling.instrumentation.indy.dummies;
public class Foo {
private Foo() {}
public static String foo(Bar bar) {
return "foo";
}
}