Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Capture all classloaders loading mutant for roboelectric and quarkus #1067

Merged
merged 5 commits into from
Nov 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Read all about it at http://pitest.org

## Releases

## 1.10.0 (unreleased)

* #1067 Improved Quarkus and Roboelectric support

As a result of #1067 it is important that mutations are only created for a single class for each JVM. The `MutationGrouper` extension point has therefore been removed as this allowed this constraint to be violated. Any third party plugins using this extension are no longer supported.

### 1.9.11

* #1105 Aggregator resolves wrong file for out of package kotlin files with same name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ public List<List<MutationDetails>> groupMutations(
final Map<ClassName, Collection<MutationDetails>> bucketed = FCollection
.bucket(mutations, MutationDetails::getClassName);
final List<List<MutationDetails>> chunked = new ArrayList<>();
for (final Collection<MutationDetails> each : bucketed.values()) {
shrinkToMaximumUnitSize(chunked, each);
for (final Map.Entry<ClassName,Collection<MutationDetails>> each : bucketed.entrySet()) {
shrinkToMaximumUnitSize(chunked, each.getValue());
}

return chunked;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,11 @@ public JavaExecutableLocator getJavaExecutable() {
}

public MutationGrouperFactory getMutationGrouper() {
final Collection<? extends MutationGrouperFactory> groupers = this.plugins
.findGroupers();
return firstOrDefault(groupers, new DefaultMutationGrouperFactory());
// Grouping behaviour is important. We cannot have more than 1 class mutated within
// a JVM or else the last mutation will poison the next. This restriction can only
// be removed if the hotswap functionality is reworked.
// Grouping behaviour is therefore hard coded for now.
return new DefaultMutationGrouperFactory();
}

public void describeFeatures(Consumer<Feature> enabled, Consumer<Feature> disabled) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.example.classloaders;

import java.util.function.IntSupplier;

public class MuteeInOtherClassloader implements IntSupplier {

@Override
public int getAsInt() {
pointless();
System.out.println("Can't kill me");
return 42;
}

public void pointless() {
System.out.println("Can't kill me either");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.classloaders;

import org.junit.Test;
import org.pitest.classpath.ClassPath;
import org.pitest.mutationtest.execute.DefaultPITClassloader;

import java.util.function.IntSupplier;

import static org.junit.Assert.assertEquals;

public class MuteeInOtherClassloaderPooledTest {

// will persist between mutants
static DefaultPITClassloader otherLoader = new DefaultPITClassloader(new ClassPath(), null);

@Test
public void returns42() throws Exception {
Class<?> clz = otherLoader.loadClass(MuteeInOtherClassloader.class.getName());
IntSupplier underTest = (IntSupplier) clz.getConstructor().newInstance();
assertEquals(42, underTest.getAsInt());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.example.classloaders;

import org.junit.Test;
import org.pitest.classpath.ClassPath;
import org.pitest.mutationtest.execute.DefaultPITClassloader;

import java.util.function.IntSupplier;

import static org.junit.Assert.assertEquals;

public class MuteeInOtherClassloaderTest {

@Test
public void returns42() throws Exception {
DefaultPITClassloader otherLoader = new DefaultPITClassloader(new ClassPath(), null);
Class<?> clz = otherLoader.loadClass(MuteeInOtherClassloader.class.getName());
IntSupplier underTest = (IntSupplier) clz.getConstructor().newInstance();
assertEquals(42, underTest.getAsInt());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
import java.util.Collections;
import java.util.List;

import com.example.classloaders.MuteeInOtherClassloader;
import com.example.classloaders.MuteeInOtherClassloaderPooledTest;
import com.example.classloaders.MuteeInOtherClassloaderTest;
import org.junit.Before;
import org.junit.Test;
import org.junit.experimental.categories.Category;
Expand Down Expand Up @@ -427,6 +430,34 @@ public void shouldRecoverFromMinionThatDoesNotStart() {
// just running without a hang then erroring is success
}

@Test
public void handlesMutantsInOtherClassLoaders() {
setMutators("PRIMITIVE_RETURNS", "VOID_METHOD_CALLS");

this.data
.setTargetClasses(asGlobs(MuteeInOtherClassloader.class));
this.data
.setTargetTests(predicateFor(MuteeInOtherClassloaderTest.class));

createAndRun();

verifyResults(KILLED, SURVIVED, SURVIVED, SURVIVED);
}

@Test
public void handlesMutantsInPooledClassLoaders() {
setMutators("PRIMITIVE_RETURNS", "VOID_METHOD_CALLS");

this.data
.setTargetClasses(asGlobs(MuteeInOtherClassloader.class));
this.data
.setTargetTests(predicateFor(MuteeInOtherClassloaderPooledTest.class));

createAndRun();

verifyResults(KILLED, SURVIVED, SURVIVED, SURVIVED);
}

@Generated
public static class AnnotatedToAvoidAtClassLevel {
public int mutateMe() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ private CoverageOptions createCoverageOptions(TestPluginArguments configuration)
configuration, this.data.getVerbosity());
}

protected void setMutators(final String mutator) {
protected void setMutators(final String... mutator) {
this.data.setMutators(Arrays.asList(mutator));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ public void shouldWorkWithQuarkus() throws Exception {
//assertThat(actual)
// .contains(
// "<mutation detected='false' status='SURVIVED' numberOfTestsRun='2'>" +
// "<sourceFile>ExampleController.java</sourceFile>");
// "<sourceFile>ExampleController.java</sourceFile>");

assertThat(actual)
.contains(
Expand Down
2 changes: 2 additions & 0 deletions pitest/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@
<artifactId>equalsverifier</artifactId>
<scope>test</scope>
</dependency>


</dependencies>

</project>
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.WeakHashMap;

/**
* Pitest mainly inserts mutants by calling Instrumentation.redefineClasses using
Expand All @@ -22,83 +22,41 @@
* the byte array (modifying by rerunning the mutator might result in a different mutant for
* the same id if the input has changed).
*
* So, sometimes we want to transform the class, sometimes we don't.
*
* Not found a way to reliably work out which we need to do, so need to maintain
* a list. As mutants unexpectedly surviving are far more likely to be reported than
* ones unexpectedly killed, we list the loaders we should mutate for (currently just
* quarkus, but others likely exist).
* This transformer identifies loaders that try to load the target class, and stores
* a weak reference to them to ensure that all copies of the class are mutated.
*
*/
public class CatchNewClassLoadersTransformer implements ClassFileTransformer {

private static String targetClass;
private static byte[] currentMutant;
private static String currentClass;

// The context classloader at the point pitest started.
// we do not want to transform classes from this as they are already handled
// by the primary mechanism
private static ClassLoader ignore;

// Map of loaders we have transformed the current class in, so we can restore them
static final Map<ClassLoader, byte[]> ORIGINAL_LOADER_CLASSES = new ConcurrentHashMap<>();

public static synchronized void setLoader(ClassLoader loader) {
ignore = loader;
}
// What we want is a ConcurrentWeakHasSet, since that doesn't exist without writing one ourselves
// we'll abuse a WeakHashMap and live with the synchronization
static final Map<ClassLoader, Object> CLASS_LOADERS = Collections.synchronizedMap(new WeakHashMap<>());

public static synchronized void setMutant(String className, byte[] mutant) {
String toRestore = currentClass;
targetClass = className;
currentMutant = mutant;

// prevent transforming again when we restore if the same class is mutated twice
currentClass = null;

restoreClasses(toRestore);

currentClass = className;
}

private static void restoreClasses(String toRestore) {
for (Map.Entry<ClassLoader, byte[]> each : ORIGINAL_LOADER_CLASSES.entrySet()) {
final Class<?> clazz = checkClassForLoader(each.getKey(), toRestore);
for (ClassLoader each : CLASS_LOADERS.keySet()) {
final Class<?> clazz = checkClassForLoader(each, className);
if (clazz != null) {
HotSwapAgent.hotSwap(clazz, each.getValue());
HotSwapAgent.hotSwap(clazz, mutant);
}
}
ORIGINAL_LOADER_CLASSES.clear();
}

@Override
public byte[] transform(final ClassLoader loader, final String className,
final Class<?> classBeingRedefined,
final ProtectionDomain protectionDomain, final byte[] classfileBuffer) {
if (loader == null || ignore == loader) {
return null;
}

// Only loader identified so far where mutants must be inserted is Quarkus, but
// others likely exist. At least one loader (gwtmockito) results incorrectly
// killed mutants if we insert.
if (!loader.getClass().getName().startsWith("io.quarkus.bootstrap.classloading")) {
return null;
}

if (className.equals(currentClass)) {
// skip if class already loaded
if (classBeingRedefined != null) {
return null;
}

// avoid restoring an already mutated class
// Not clear if this situation is possible, but check left in
// out of fear.
if (!Arrays.equals(classfileBuffer, currentMutant)) {
ORIGINAL_LOADER_CLASSES.put(loader, classfileBuffer);
}

if (className.equals(targetClass) && shouldTransform(loader)) {
CLASS_LOADERS.put(loader, null);
// we might be mid-mutation so return the mutated bytes
return currentMutant;
}

return null;
}

Expand All @@ -119,4 +77,10 @@ private static Class<?> checkClassForLoader(ClassLoader loader, String className

}

private boolean shouldTransform(ClassLoader loader) {
// Only gwtmockito has been identified so far as a loader not to transform
// but there will be others
return !loader.getClass().getName().startsWith("com.google.gwtmockito.");
}

}
60 changes: 15 additions & 45 deletions pitest/src/main/java/org/pitest/mutationtest/execute/HotSwap.java
Original file line number Diff line number Diff line change
@@ -1,62 +1,32 @@
package org.pitest.mutationtest.execute;

import org.pitest.boot.HotSwapAgent;
import org.pitest.classinfo.ClassByteArraySource;
import org.pitest.classinfo.ClassName;
import org.pitest.functional.F3;
import org.pitest.util.Unchecked;

class HotSwap implements F3<ClassName, ClassLoader, byte[], Boolean> {
/**
* Since pitest 1.9.4 there is an implicit assumption that pitest will never mutate
* more than once class within the same jvm. If this assumption is ever broken, mutants
* from the previous class would remain active and invalidate results.
*/
class HotSwap {

private final ClassByteArraySource byteSource;
private byte[] lastClassPreMutation;
private ClassName lastMutatedClass;
private ClassLoader lastUsedLoader;

HotSwap(final ClassByteArraySource byteSource) {
this.byteSource = byteSource;
}

@Override
public Boolean apply(final ClassName clazzName, final ClassLoader loader,
final byte[] b) {
public Boolean insertClass(final ClassName clazzName, ClassLoader loader, final byte[] mutantBytes) {
try {
// Some frameworks (eg quarkus) run tests in non delegating
// classloaders. Need to make sure these are transformed too
CatchNewClassLoadersTransformer.setMutant(clazzName.asInternalName(), mutantBytes);

System.out.println("Hotswap loader " + loader);

restoreLastClass(this.byteSource, clazzName, loader);
this.lastUsedLoader = loader;
// trigger loading for the current loader
Class<?> clazz = Class.forName(clazzName.asJavaName(), false, loader);
return HotSwapAgent.hotSwap(clazz, b);
} catch (final ClassNotFoundException e) {
throw Unchecked.translateCheckedException(e);
}

}

private void restoreLastClass(final ClassByteArraySource byteSource,
final ClassName clazzName, final ClassLoader loader)
throws ClassNotFoundException {
if ((this.lastMutatedClass != null)
&& !this.lastMutatedClass.equals(clazzName)) {
restoreForLoader(this.lastUsedLoader);
restoreForLoader(loader);
}
// will still need to explicitly swap it... not clear why the transformed does not do this
return HotSwapAgent.hotSwap(clazz, mutantBytes);

if ((this.lastMutatedClass == null)
|| !this.lastMutatedClass.equals(clazzName)) {
this.lastClassPreMutation = byteSource.getBytes(clazzName.asJavaName())
.get();
} catch (final ClassNotFoundException e) {
throw Unchecked.translateCheckedException(e);
}

this.lastMutatedClass = clazzName;
}

private void restoreForLoader(ClassLoader loader)
throws ClassNotFoundException {
final Class<?> clazz = Class.forName(this.lastMutatedClass.asJavaName(), false,
loader);
HotSwapAgent.hotSwap(clazz, this.lastClassPreMutation);
}

}