├── .github └── workflows │ └── build.yaml ├── .gitignore ├── README.md ├── pom.xml └── src ├── main └── java │ └── me │ └── escoffier │ └── loom │ └── loomunit │ ├── Collector.java │ ├── InternalEvents.java │ ├── LoomUnitExtension.java │ ├── ShouldNotPin.java │ ├── ShouldPin.java │ └── ThreadPinnedEvents.java └── test └── java └── me └── escoffier └── loom └── loomunit └── snippets ├── CodeUnderTest.java ├── LoomUnitExampleOnClassTest.java └── LoomUnitExampleTest.java /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up JDK 20 15 | uses: actions/setup-java@v3 16 | with: 17 | java-version: '20' 18 | distribution: 'temurin' 19 | - name: Build with Maven 20 | run: mvn --batch-mode verify -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | 6 | ### IntelliJ IDEA ### 7 | .idea/modules.xml 8 | .idea/jarRepositories.xml 9 | .idea/compiler.xml 10 | .idea/libraries/ 11 | *.iws 12 | *.iml 13 | *.ipr 14 | .idea/ 15 | 16 | ### Eclipse ### 17 | .apt_generated 18 | .classpath 19 | .factorypath 20 | .project 21 | .settings 22 | .springBeans 23 | .sts4-cache 24 | 25 | ### NetBeans ### 26 | /nbproject/private/ 27 | /nbbuild/ 28 | /dist/ 29 | /nbdist/ 30 | /.nb-gradle/ 31 | build/ 32 | !**/src/main/**/build/ 33 | !**/src/test/**/build/ 34 | 35 | ### VS Code ### 36 | .vscode/ 37 | 38 | ### Mac OS ### 39 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Loom-Unit 2 | 3 | A Junit 5 extension capturing weather a virtual threads _pins_ the carrier thread during the execution of the test. 4 | 5 | 6 | ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/cescoffier/loom-unit/Build?style=for-the-badge) ![Maven Central](https://img.shields.io/maven-central/v/me.escoffier.loom/loom-unit?style=for-the-badge) 7 | 8 | ## Usage 9 | 10 | 1. Add the following dependency to your project: 11 | 12 | ```xml 13 | 14 | me.escoffier.loom 15 | loom-unit 16 | VERSION 17 | test 18 | 19 | ``` 20 | 21 | **IMPORTANT**: You need to use Java 19+. 22 | 23 | 2. Extends your test class with the `me.escoffier.loom.loomunit.LoomUnitExtension` extension: 24 | 25 | ```java 26 | @ExtendWith(LoomUnitExtension.class) 27 | public class LoomUnitExampleTest { 28 | // ... 29 | } 30 | ``` 31 | 32 | 3. Use the `me.escoffier.loom.loomunit.ShouldNotPin` or `me.escoffier.loom.loomunit.ShouldPin` annotation on your test. 33 | 34 | ## Complete example 35 | 36 | ```java 37 | package me.escoffier.loom.loomunit.snippets; 38 | import me.escoffier.loom.loomunit.LoomUnitExtension; 39 | import me.escoffier.loom.loomunit.ThreadPinnedEvents; 40 | import me.escoffier.loom.loomunit.ShouldNotPin; 41 | import me.escoffier.loom.loomunit.ShouldPin; 42 | import org.junit.jupiter.api.Assertions; 43 | import org.junit.jupiter.api.Test; 44 | import org.junit.jupiter.api.extension.ExtendWith; 45 | 46 | import static org.awaitility.Awaitility.await; 47 | 48 | 49 | @ExtendWith(LoomUnitExtension.class) // Use the extension 50 | public class LoomUnitExampleTest { 51 | 52 | CodeUnderTest codeUnderTest = new CodeUnderTest(); 53 | 54 | @Test 55 | @ShouldNotPin 56 | public void testThatShouldNotPin() { 57 | // ... 58 | } 59 | 60 | @Test 61 | @ShouldPin(atMost = 1) 62 | public void testThatShouldPinAtMostOnce() { 63 | codeUnderTest.pin(); 64 | } 65 | 66 | @Test 67 | public void testThatShouldNotPin(ThreadPinnedEvents events) { // Inject an object to check the pin events 68 | Assertions.assertTrue(events.getEvents().isEmpty()); 69 | codeUnderTest.pin(); 70 | await().until(() -> events.getEvents().size() > 0); 71 | Assertions.assertEquals(events.getEvents().size(), 1); 72 | } 73 | 74 | } 75 | ``` 76 | 77 | You can also use the `@ShouldPin` and `@ShouldNotPin` annotations on the class: 78 | 79 | ```java 80 | @ExtendWith(LoomUnitExtension.class) // Use the extension 81 | @ShouldNotPin // You can use @ShouldNotPin or @ShouldPin on the class itself, it's applied to each method. 82 | public class LoomUnitExampleOnClassTest { 83 | 84 | CodeUnderTest codeUnderTest = new CodeUnderTest(); 85 | 86 | @Test 87 | public void testThatShouldNotPin() { 88 | // ... 89 | } 90 | 91 | @Test 92 | @ShouldPin(atMost = 1) // Method annotation overrides the class annotation 93 | public void testThatShouldPinAtMostOnce() { 94 | codeUnderTest.pin(); 95 | } 96 | 97 | } 98 | ``` 99 | 100 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | me.escoffier.loom 6 | loom-unit 7 | 0.3.1-SNAPSHOT 8 | 9 | Loom-Unit 10 | A Junit 5 Extension to check if your code pins the carrier thread 11 | https://github.com/cescoffier/loom-unit 12 | 13 | 14 | Apache License 2.0 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | 18 | 19 | 20 | 20 21 | 20 22 | UTF-8 23 | 24 | 25 | 26 | 27 | ossrh 28 | https://oss.sonatype.org/content/repositories/snapshots 29 | 30 | 31 | 32 | 33 | 34 | org.junit.jupiter 35 | junit-jupiter-api 36 | 5.9.0 37 | 38 | 39 | org.junit.jupiter 40 | junit-jupiter-engine 41 | 5.9.0 42 | test 43 | 44 | 45 | org.assertj 46 | assertj-core 47 | 3.23.1 48 | test 49 | 50 | 51 | io.smallrye.common 52 | smallrye-common-constraint 53 | 2.0.0 54 | 55 | 56 | org.awaitility 57 | awaitility 58 | 4.2.0 59 | test 60 | 61 | 62 | 63 | 64 | 65 | 66 | org.apache.maven.plugins 67 | maven-surefire-plugin 68 | 3.0.0-M7 69 | 70 | --enable-preview 71 | 72 | 73 | 74 | org.sonatype.plugins 75 | nexus-staging-maven-plugin 76 | 1.6.13 77 | true 78 | 79 | ossrh 80 | https://oss.sonatype.org/ 81 | true 82 | 83 | 84 | 85 | org.apache.maven.plugins 86 | maven-compiler-plugin 87 | 3.10.1 88 | 89 | --enable-preview 90 | 91 | 92 | 93 | org.apache.maven.plugins 94 | maven-release-plugin 95 | 3.0.0-M6 96 | 97 | true 98 | false 99 | release 100 | deploy 101 | 102 | 103 | 104 | 105 | 106 | 107 | release 108 | 109 | 110 | 111 | org.apache.maven.plugins 112 | maven-source-plugin 113 | 3.0.0 114 | 115 | 116 | attach-sources 117 | 118 | jar-no-fork 119 | 120 | 121 | 122 | 123 | 124 | org.apache.maven.plugins 125 | maven-javadoc-plugin 126 | 3.4.1 127 | 128 | 129 | attach-javadocs 130 | 131 | jar 132 | 133 | 134 | 135 | 136 | 137 | 138 | --snippet-path=${basedir}/src/test/java 139 | 140 | 141 | 142 | 143 | 144 | org.apache.maven.plugins 145 | maven-gpg-plugin 146 | 3.0.1 147 | 148 | 149 | sign-artifacts 150 | verify 151 | 152 | sign 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | https://github.com/cescoffier/loom-unit 163 | https://github.com/cescoffier/loom-unit.git 164 | scm:git:https://github.com/cescoffier/loom-unit.git 165 | HEAD 166 | 167 | 168 | 2022 169 | 170 | github 171 | https://github.com/cescoffier/vertx-completable-future/issues 172 | 173 | 174 | escoffier.me 175 | http://escoffier.me 176 | 177 | 178 | 179 | cescoffier 180 | Clement Escoffier 181 | clement[AT]apache[DOT]org 182 | https://github.com/cescoffier 183 | 184 | 185 | 186 | -------------------------------------------------------------------------------- /src/main/java/me/escoffier/loom/loomunit/Collector.java: -------------------------------------------------------------------------------- 1 | package me.escoffier.loom.loomunit; 2 | 3 | import jdk.jfr.consumer.RecordedEvent; 4 | import jdk.jfr.consumer.RecordingStream; 5 | import org.junit.jupiter.api.extension.ExtensionContext; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | import java.util.concurrent.CopyOnWriteArrayList; 10 | import java.util.concurrent.CountDownLatch; 11 | import java.util.concurrent.TimeUnit; 12 | import java.util.function.Consumer; 13 | import java.util.function.Function; 14 | import java.util.logging.Level; 15 | import java.util.logging.Logger; 16 | 17 | 18 | public class Collector implements Consumer { 19 | public static final String CARRIER_PINNED_EVENT_NAME = "jdk.VirtualThreadPinned"; 20 | private static final Logger LOGGER = Logger.getLogger(Collector.class.getName()); 21 | 22 | private final List> observers = new CopyOnWriteArrayList<>(); 23 | 24 | private final List events = new CopyOnWriteArrayList<>(); 25 | 26 | private final RecordingStream stream; 27 | 28 | volatile State state = State.INIT; 29 | 30 | public Collector() { 31 | LOGGER.log(Level.FINE, "Creating collector"); 32 | try { 33 | stream = new RecordingStream(); 34 | stream.enable(CARRIER_PINNED_EVENT_NAME).withStackTrace(); 35 | stream.enable(InternalEvents.InitializationEvent.class).withoutStackTrace(); 36 | stream.enable(InternalEvents.ShutdownEvent.class).withoutStackTrace(); 37 | stream.enable(InternalEvents.CapturingStartedEvent.class).withoutStackTrace(); 38 | stream.enable(InternalEvents.CapturingStoppedEvent.class).withoutStackTrace(); 39 | stream.setMaxSize(100); 40 | stream.setReuse(true); 41 | stream.setOrdered(true); 42 | stream.onEvent(this); 43 | } catch (Exception e) { 44 | throw new RuntimeException(e); 45 | } 46 | } 47 | 48 | public void init() throws InterruptedException { 49 | long begin = System.nanoTime(); 50 | CountDownLatch latch = new CountDownLatch(1); 51 | observers.add(re -> { 52 | if (re.getEventType().getName().equals(InternalEvents.INITIALIZATION_EVENT_NAME)) { 53 | latch.countDown(); 54 | return true; 55 | } 56 | return false; 57 | }); 58 | stream.startAsync(); 59 | new InternalEvents.InitializationEvent().commit(); 60 | if (latch.await(10, TimeUnit.SECONDS)) { 61 | long end = System.nanoTime(); 62 | state = State.STARTED; 63 | LOGGER.log(Level.FINE, "Event collection started in {0}s", (end - begin) / 1000000); 64 | } else { 65 | throw new IllegalStateException("Unable to start JFR collection, RecordingStartedEvent event not received after 10s"); 66 | } 67 | } 68 | 69 | public void start(ExtensionContext context) throws InterruptedException { 70 | CountDownLatch latch = new CountDownLatch(1); 71 | String id = context.getUniqueId(); 72 | long begin = System.nanoTime(); 73 | observers.add(re -> { 74 | if (re.getEventType().getName().equals(InternalEvents.CAPTURING_STARTED_EVENT_NAME)) { 75 | if (id.equals(re.getString("id"))) { 76 | events.clear(); 77 | state = State.COLLECTING; 78 | latch.countDown(); 79 | return true; 80 | } 81 | } 82 | return false; 83 | }); 84 | 85 | new InternalEvents.CapturingStartedEvent(id).commit(); 86 | 87 | if (!latch.await(10, TimeUnit.SECONDS)) { 88 | throw new IllegalStateException("Unable to start JFR collection, START_EVENT event not received after 10s"); 89 | } 90 | long end = System.nanoTime(); 91 | LOGGER.log(Level.FINE, "Event capturing started in {0}s", (end - begin) / 1000000); 92 | } 93 | 94 | public List stop(ExtensionContext context) throws InterruptedException { 95 | CountDownLatch latch = new CountDownLatch(1); 96 | String id = context.getUniqueId(); 97 | var begin = System.nanoTime(); 98 | observers.add(re -> { 99 | if (re.getEventType().getName().equals(InternalEvents.CAPTURING_STOPPED_EVENT_NAME)) { 100 | state = State.STARTED; 101 | latch.countDown(); 102 | return true; 103 | } 104 | return false; 105 | }); 106 | 107 | new InternalEvents.CapturingStoppedEvent(id).commit(); 108 | 109 | if (!latch.await(10, TimeUnit.SECONDS)) { 110 | throw new IllegalStateException("Unable to start JFR collection, STOP_EVENT event not received after 10s"); 111 | } 112 | var end = System.nanoTime(); 113 | LOGGER.log(Level.FINE, "Event collection stopped in {0}s", (end - begin) / 1000000); 114 | 115 | 116 | return new ArrayList<>(events); 117 | } 118 | 119 | public void shutdown() throws InterruptedException { 120 | CountDownLatch latch = new CountDownLatch(1); 121 | var begin = System.nanoTime(); 122 | observers.add(re -> { 123 | if (re.getEventType().getName().equals(InternalEvents.SHUTDOWN_EVENT_NAME)) { 124 | latch.countDown(); 125 | return true; 126 | } 127 | return false; 128 | }); 129 | InternalEvents.ShutdownEvent event = new InternalEvents.ShutdownEvent(); 130 | event.commit(); 131 | if (latch.await(10, TimeUnit.SECONDS)) { 132 | state = State.INIT; 133 | var end = System.nanoTime(); 134 | LOGGER.log(Level.FINE, "Event collector shutdown in {0}s", (end - begin) / 1000000); 135 | stream.stop(); 136 | } else { 137 | throw new IllegalStateException("Unable to stop JFR collection, RecordingStoppedEvent event not received at 10s"); 138 | } 139 | } 140 | 141 | @Override 142 | public void accept(RecordedEvent re) { 143 | if (state == State.COLLECTING) { 144 | events.add(re); 145 | } 146 | List> toBeRemoved = new ArrayList<>(); 147 | observers.forEach(c -> { 148 | if (c.apply(re)) { 149 | toBeRemoved.add(c); 150 | } 151 | }); 152 | observers.removeAll(toBeRemoved); 153 | } 154 | 155 | public List getEvents() { 156 | return new ArrayList<>(events); 157 | } 158 | 159 | enum State { 160 | INIT, 161 | STARTED, 162 | COLLECTING 163 | } 164 | 165 | 166 | } 167 | -------------------------------------------------------------------------------- /src/main/java/me/escoffier/loom/loomunit/InternalEvents.java: -------------------------------------------------------------------------------- 1 | package me.escoffier.loom.loomunit; 2 | 3 | import jdk.jfr.Category; 4 | import jdk.jfr.Event; 5 | import jdk.jfr.Label; 6 | import jdk.jfr.Name; 7 | import jdk.jfr.SettingDefinition; 8 | import jdk.jfr.StackTrace; 9 | 10 | 11 | public interface InternalEvents { 12 | 13 | String INITIALIZATION_EVENT_NAME = "me.escoffier.loom.loomunit.InternalEvents.InitializationEvent"; 14 | String SHUTDOWN_EVENT_NAME = "me.escoffier.loom.loomunit.InternalEvents.ShutdownEvent"; 15 | 16 | String CAPTURING_STARTED_EVENT_NAME = "me.escoffier.loom.loomunit.InternalEvents.CapturingStartedEvent"; 17 | String CAPTURING_STOPPED_EVENT_NAME = "me.escoffier.loom.loomunit.InternalEvents.CapturingStoppedEvent"; 18 | 19 | 20 | 21 | @Name(INITIALIZATION_EVENT_NAME) 22 | @Category("loom-unit") 23 | @StackTrace(value = false) 24 | class InitializationEvent extends Event { 25 | // Marker event 26 | } 27 | 28 | @Name(SHUTDOWN_EVENT_NAME) 29 | @Category("loom-unit") 30 | @StackTrace(value = false) 31 | class ShutdownEvent extends Event { 32 | // Marker event 33 | } 34 | 35 | @Name(CAPTURING_STARTED_EVENT_NAME) 36 | @Category("loom-unit") 37 | @StackTrace(value = false) 38 | class CapturingStartedEvent extends Event { 39 | 40 | @Name("id") 41 | @Label("id") 42 | public final String id; 43 | 44 | public CapturingStartedEvent(String id) { 45 | this.id = id; 46 | } 47 | } 48 | 49 | @Name(CAPTURING_STOPPED_EVENT_NAME) 50 | @Category("loom-unit") 51 | @StackTrace(value = false) 52 | class CapturingStoppedEvent extends Event { 53 | 54 | 55 | @Name("id") 56 | @Label("id") 57 | public final String id; 58 | 59 | public CapturingStoppedEvent(String id) { 60 | this.id = id; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/me/escoffier/loom/loomunit/LoomUnitExtension.java: -------------------------------------------------------------------------------- 1 | package me.escoffier.loom.loomunit; 2 | 3 | import jdk.jfr.consumer.RecordedEvent; 4 | import jdk.jfr.consumer.RecordedFrame; 5 | import org.junit.jupiter.api.extension.AfterAllCallback; 6 | import org.junit.jupiter.api.extension.AfterEachCallback; 7 | import org.junit.jupiter.api.extension.BeforeAllCallback; 8 | import org.junit.jupiter.api.extension.BeforeEachCallback; 9 | import org.junit.jupiter.api.extension.ExtensionContext; 10 | import org.junit.jupiter.api.extension.ParameterContext; 11 | import org.junit.jupiter.api.extension.ParameterResolutionException; 12 | import org.junit.jupiter.api.extension.ParameterResolver; 13 | import org.junit.jupiter.api.extension.TestInstantiationException; 14 | 15 | import java.lang.reflect.Method; 16 | import java.util.Arrays; 17 | import java.util.List; 18 | import java.util.stream.Collectors; 19 | 20 | import static me.escoffier.loom.loomunit.Collector.CARRIER_PINNED_EVENT_NAME; 21 | 22 | /** 23 | * A Junit 5 Extension that allows checking if virtual threads used in tests are pinning or not the carrier thread. 24 | * The detection is based on JFR events. 25 | *

26 | * Example of usage: 27 | * {@snippet class = "me.escoffier.loom.loomunit.snippets.LoomUnitExampleTest" region = "example"} 28 | *

29 | * {@snippet class = "me.escoffier.loom.loomunit.snippets.LoomUnitExampleOnClassTest" region = "example"} 30 | */ 31 | public class LoomUnitExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback, ParameterResolver { 32 | 33 | 34 | private ExtensionContext.Namespace namespace; 35 | 36 | 37 | @Override 38 | public void beforeAll(ExtensionContext extensionContext) throws Exception { 39 | Collector collector = new Collector(); 40 | namespace = ExtensionContext.Namespace.create("loom-unit"); 41 | var store = extensionContext.getStore(namespace); 42 | store.put("collector", collector); 43 | collector.init(); 44 | } 45 | 46 | @Override 47 | public void afterAll(ExtensionContext extensionContext) throws Exception { 48 | var store = extensionContext.getStore(namespace); 49 | store.get("collector", Collector.class).shutdown(); 50 | } 51 | 52 | @Override 53 | public void beforeEach(ExtensionContext extensionContext) throws InterruptedException { 54 | if (requiresRecording(extensionContext.getRequiredTestClass(), extensionContext.getRequiredTestMethod())) { 55 | var store = extensionContext.getStore(namespace); 56 | store.get("collector", Collector.class).start(extensionContext); 57 | 58 | if (getShouldPin(extensionContext.getRequiredTestClass(), extensionContext.getRequiredTestMethod()) != null 59 | && getShouldNotPin(extensionContext.getRequiredTestClass(), extensionContext.getRequiredTestMethod()) != null) { 60 | throw new TestInstantiationException("Cannot execute test " + extensionContext.getDisplayName() + ": @ShouldPin and @ShouldNotPin are used on the method or class"); 61 | } 62 | } 63 | } 64 | 65 | private boolean requiresRecording(Class clazz, Method method) { 66 | if (clazz.isAnnotationPresent(ShouldNotPin.class) || clazz.isAnnotationPresent(ShouldPin.class) 67 | || method.isAnnotationPresent(ShouldNotPin.class) || method.isAnnotationPresent(ShouldPin.class)) { 68 | return true; 69 | } 70 | return Arrays.asList(method.getParameterTypes()).contains(ThreadPinnedEvents.class); 71 | } 72 | 73 | private ShouldPin getShouldPin(Class clazz, Method method) { 74 | if (method.isAnnotationPresent(ShouldPin.class)) { 75 | return method.getAnnotation(ShouldPin.class); 76 | } 77 | 78 | if (method.isAnnotationPresent(ShouldNotPin.class)) { 79 | // If the method overrides the class annotation. 80 | return null; 81 | } 82 | 83 | if (clazz.isAnnotationPresent(ShouldPin.class)) { 84 | return clazz.getAnnotation(ShouldPin.class); 85 | } 86 | 87 | return null; 88 | } 89 | 90 | private ShouldNotPin getShouldNotPin(Class clazz, Method method) { 91 | if (method.isAnnotationPresent(ShouldNotPin.class)) { 92 | return method.getAnnotation(ShouldNotPin.class); 93 | } 94 | 95 | if (method.isAnnotationPresent(ShouldPin.class)) { 96 | // If the method overrides the class annotation. 97 | return null; 98 | } 99 | 100 | if (clazz.isAnnotationPresent(ShouldNotPin.class)) { 101 | return clazz.getAnnotation(ShouldNotPin.class); 102 | } 103 | 104 | return null; 105 | } 106 | 107 | @Override 108 | public void afterEach(ExtensionContext extensionContext) throws InterruptedException { 109 | Method method = extensionContext.getRequiredTestMethod(); 110 | Class clazz = extensionContext.getRequiredTestClass(); 111 | if (!requiresRecording(clazz, method)) { 112 | return; 113 | } 114 | var store = extensionContext.getStore(namespace); 115 | List captured = store.get("collector", Collector.class).stop(extensionContext); 116 | List pinEvents = captured.stream().filter(re -> re.getEventType().getName().equals(CARRIER_PINNED_EVENT_NAME)).collect(Collectors.toList()); 117 | 118 | ShouldPin pin = getShouldPin(clazz, method); 119 | ShouldNotPin notpin = getShouldNotPin(clazz, method); 120 | 121 | if (pin != null) { 122 | if (pinEvents.isEmpty()) { 123 | throw new AssertionError("The test " + extensionContext.getDisplayName() + " was expected to pin the carrier thread, it didn't"); 124 | } 125 | if (pin.atMost() != Integer.MAX_VALUE && pinEvents.size() > pin.atMost()) { 126 | throw new AssertionError("The test " + extensionContext.getDisplayName() + " was expected to pin the carrier thread at most " + pin.atMost() 127 | + ", but we collected " + pinEvents.size() + " events\n" + dump(pinEvents)); 128 | } 129 | } 130 | 131 | if (notpin != null) { 132 | if (!pinEvents.isEmpty()) { 133 | throw new AssertionError("The test " + extensionContext.getDisplayName() + " was expected to NOT pin the carrier thread" 134 | + ", but we collected " + pinEvents.size() + " event(s)\n" + dump(pinEvents)); 135 | } 136 | } 137 | 138 | } 139 | 140 | private static final String STACK_TRACE_TEMPLATE = "\t%s.%s(%s.java:%d)\n"; 141 | 142 | private String dump(List pinEvents) { 143 | StringBuilder builder = new StringBuilder(); 144 | for (RecordedEvent pinEvent : pinEvents) { 145 | builder.append("* Pinning event captured: \n"); 146 | for (RecordedFrame recordedFrame : pinEvent.getStackTrace().getFrames()) { 147 | builder.append(STACK_TRACE_TEMPLATE.formatted(recordedFrame.getMethod().getType().getName(), 148 | recordedFrame.getMethod().getName(), recordedFrame.getMethod().getType().getName(), recordedFrame.getLineNumber())); 149 | } 150 | } 151 | return builder.toString(); 152 | } 153 | 154 | 155 | @Override 156 | public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { 157 | return parameterContext.getParameter().getType().equals(ThreadPinnedEvents.class); 158 | } 159 | 160 | @Override 161 | public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { 162 | return (ThreadPinnedEvents) () -> { 163 | var store = extensionContext.getStore(namespace); 164 | return store.get("collector", Collector.class).getEvents(); 165 | }; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/main/java/me/escoffier/loom/loomunit/ShouldNotPin.java: -------------------------------------------------------------------------------- 1 | package me.escoffier.loom.loomunit; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | /** 9 | * Marker indicating that the test method should not pin the carrier thread. 10 | * If, during the execution of the test, a virtual thread pins the carrier thread, the test fails. 11 | */ 12 | @Retention(RetentionPolicy.RUNTIME) 13 | @Target({ElementType.METHOD, ElementType.TYPE}) 14 | public @interface ShouldNotPin { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/me/escoffier/loom/loomunit/ShouldPin.java: -------------------------------------------------------------------------------- 1 | package me.escoffier.loom.loomunit; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | /** 9 | * Indicates that the method can pin. At most can be set to indicate the maximum number of events. 10 | * If, during the execution of the test, a virtual thread does not pin the carrier thread, or pins it more than 11 | * the given {@code atMost} value, the test fails. 12 | */ 13 | @Retention(RetentionPolicy.RUNTIME) 14 | @Target({ElementType.METHOD, ElementType.TYPE}) 15 | public @interface ShouldPin { 16 | 17 | int atMost() default Integer.MAX_VALUE; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/me/escoffier/loom/loomunit/ThreadPinnedEvents.java: -------------------------------------------------------------------------------- 1 | package me.escoffier.loom.loomunit; 2 | 3 | import jdk.jfr.consumer.RecordedEvent; 4 | 5 | import java.util.List; 6 | 7 | /** 8 | * Object that can be injected in a test method. 9 | * It gives controlled on the captured events, and so let you do manual checks. 10 | *

11 | * The returned list is a copy of the list of captured events. 12 | */ 13 | public interface ThreadPinnedEvents { 14 | 15 | List getEvents(); 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/me/escoffier/loom/loomunit/snippets/CodeUnderTest.java: -------------------------------------------------------------------------------- 1 | package me.escoffier.loom.loomunit.snippets; 2 | 3 | import java.util.concurrent.CountDownLatch; 4 | 5 | public class CodeUnderTest { 6 | 7 | void pin() { 8 | CountDownLatch latch = new CountDownLatch(1); 9 | CodeUnderTest pinning = new CodeUnderTest(); 10 | Thread.ofVirtual().start(() -> pinning.callSynchronizedMethod(latch)); 11 | try { 12 | latch.await(); 13 | } catch (InterruptedException e) { 14 | Thread.currentThread().interrupt(); 15 | throw new RuntimeException(e); 16 | } 17 | } 18 | 19 | 20 | public void callSynchronizedMethod(CountDownLatch latch) { 21 | synchronized (this) { 22 | try { 23 | Thread.sleep(1); 24 | } catch (InterruptedException e) { 25 | throw new RuntimeException(e); 26 | } 27 | } 28 | latch.countDown(); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/me/escoffier/loom/loomunit/snippets/LoomUnitExampleOnClassTest.java: -------------------------------------------------------------------------------- 1 | package me.escoffier.loom.loomunit.snippets; 2 | // @start region="example" 3 | import me.escoffier.loom.loomunit.LoomUnitExtension; 4 | import me.escoffier.loom.loomunit.ThreadPinnedEvents; 5 | import me.escoffier.loom.loomunit.ShouldNotPin; 6 | import me.escoffier.loom.loomunit.ShouldPin; 7 | import org.junit.jupiter.api.Assertions; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | 11 | import static org.awaitility.Awaitility.await; 12 | 13 | 14 | @ExtendWith(LoomUnitExtension.class) // Use the extension 15 | @ShouldNotPin // You can use @ShouldNotPin or @ShouldPin on the class itself, it's applied to each method. 16 | public class LoomUnitExampleOnClassTest { 17 | 18 | CodeUnderTest codeUnderTest = new CodeUnderTest(); 19 | 20 | @Test 21 | public void testThatShouldNotPin() { 22 | // ... 23 | } 24 | 25 | @Test 26 | @ShouldPin(atMost = 1) // Method annotation overrides the class annotation 27 | public void testThatShouldPinAtMostOnce() { 28 | codeUnderTest.pin(); 29 | } 30 | 31 | } 32 | // @end -------------------------------------------------------------------------------- /src/test/java/me/escoffier/loom/loomunit/snippets/LoomUnitExampleTest.java: -------------------------------------------------------------------------------- 1 | package me.escoffier.loom.loomunit.snippets; 2 | // @start region="example" 3 | import me.escoffier.loom.loomunit.LoomUnitExtension; 4 | import me.escoffier.loom.loomunit.ThreadPinnedEvents; 5 | import me.escoffier.loom.loomunit.ShouldNotPin; 6 | import me.escoffier.loom.loomunit.ShouldPin; 7 | import org.junit.jupiter.api.Assertions; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | 11 | import static org.awaitility.Awaitility.await; 12 | 13 | 14 | @ExtendWith(LoomUnitExtension.class) // Use the extension 15 | public class LoomUnitExampleTest { 16 | 17 | CodeUnderTest codeUnderTest = new CodeUnderTest(); 18 | 19 | @Test 20 | @ShouldNotPin 21 | public void testThatShouldNotPin() { 22 | // ... 23 | } 24 | 25 | @Test 26 | @ShouldPin(atMost = 1) 27 | public void testThatShouldPinAtMostOnce() { 28 | codeUnderTest.pin(); 29 | } 30 | 31 | @Test 32 | public void testThatShouldPin(ThreadPinnedEvents events) { // Inject an object to check the pin events 33 | Assertions.assertTrue(events.getEvents().isEmpty()); 34 | codeUnderTest.pin(); 35 | await().until(() -> events.getEvents().size() > 0); 36 | Assertions.assertEquals(events.getEvents().size(), 1); 37 | } 38 | 39 | } 40 | // @end 41 | --------------------------------------------------------------------------------