├── .github
└── workflows
│ └── build.yaml
├── src
├── main
│ └── java
│ │ └── me
│ │ └── escoffier
│ │ └── loom
│ │ └── loomunit
│ │ ├── ThreadPinnedEvents.java
│ │ ├── ShouldNotPin.java
│ │ ├── ShouldPin.java
│ │ ├── InternalEvents.java
│ │ ├── Collector.java
│ │ └── LoomUnitExtension.java
└── test
│ └── java
│ └── me
│ └── escoffier
│ └── loom
│ └── loomunit
│ └── snippets
│ ├── CodeUnderTest.java
│ ├── LoomUnitExampleOnClassTest.java
│ └── LoomUnitExampleTest.java
├── .gitignore
├── README.md
└── pom.xml
/.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
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |  
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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------