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 |
--------------------------------------------------------------------------------