diff --git a/bom/pom.xml b/bom/pom.xml index 3db507a7651..c6cc615fae6 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -1088,6 +1088,11 @@ ${helidon.version} + + io.helidon.common.testing + helidon-common-testing-virtual-threads + ${helidon.version} + io.helidon.microprofile.testing helidon-microprofile-testing-junit5 diff --git a/common/testing/pom.xml b/common/testing/pom.xml index 2341db93845..0f6dffc279b 100644 --- a/common/testing/pom.xml +++ b/common/testing/pom.xml @@ -34,5 +34,6 @@ junit5 http-junit5 + virtual-threads diff --git a/common/testing/virtual-threads/pom.xml b/common/testing/virtual-threads/pom.xml new file mode 100644 index 00000000000..b33591411cf --- /dev/null +++ b/common/testing/virtual-threads/pom.xml @@ -0,0 +1,32 @@ + + + + + 4.0.0 + + io.helidon.common.testing + helidon-common-testing-project + 4.2.0-SNAPSHOT + ../pom.xml + + helidon-common-testing-virtual-threads + + Helidon Virtual Threads Testing Utilities + + diff --git a/common/testing/virtual-threads/src/main/java/io/helidon/common/testing/virtualthreads/PinningAssertionError.java b/common/testing/virtual-threads/src/main/java/io/helidon/common/testing/virtualthreads/PinningAssertionError.java new file mode 100644 index 00000000000..0c709ec2bef --- /dev/null +++ b/common/testing/virtual-threads/src/main/java/io/helidon/common/testing/virtualthreads/PinningAssertionError.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.common.testing.virtualthreads; + +import jdk.jfr.consumer.RecordedEvent; + +/** + * Assertion error used for reporting of Virtual Thread pinning detected during test. + */ +public class PinningAssertionError extends AssertionError { + + /** + * Pinning JFR event. + */ + private final RecordedEvent recordedEvent; + + /** + * Create new pinning exception. + * + * @param recordedEvent pinning JFR event + */ + PinningAssertionError(RecordedEvent recordedEvent) { + this.recordedEvent = recordedEvent; + if (recordedEvent.getStackTrace() != null) { + StackTraceElement[] stackTraceElements = recordedEvent.getStackTrace().getFrames().stream() + .map(f -> new StackTraceElement(f.getMethod().getType().getName(), + f.getMethod().getName(), + f.getMethod().getType().getName() + ".java", + f.getLineNumber())) + .toArray(StackTraceElement[]::new); + super.setStackTrace(stackTraceElements); + } + } + + @Override + public String getMessage() { + return "Pinned virtual threads were detected:\n" + + recordedEvent.toString(); + } +} diff --git a/common/testing/virtual-threads/src/main/java/io/helidon/common/testing/virtualthreads/PinningRecorder.java b/common/testing/virtual-threads/src/main/java/io/helidon/common/testing/virtualthreads/PinningRecorder.java new file mode 100644 index 00000000000..e85e25e6175 --- /dev/null +++ b/common/testing/virtual-threads/src/main/java/io/helidon/common/testing/virtualthreads/PinningRecorder.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.common.testing.virtualthreads; + +import java.time.Duration; + +import jdk.jfr.consumer.RecordedEvent; +import jdk.jfr.consumer.RecordingStream; + +/** + * Record pinned thread events and throw exception when detected. + */ +public class PinningRecorder implements AutoCloseable { + + /** + * Default threshold for considering carrier thread blocking as pinning. + */ + public static final long DEFAULT_THRESHOLD = 20; + private static final String JFR_EVENT_VIRTUAL_THREAD_PINNED = "jdk.VirtualThreadPinned"; + private final RecordingStream recordingStream = new RecordingStream(); + private volatile PinningAssertionError pinningAssertionError; + + private PinningRecorder() { + //noop + } + + /** + * Create new pinning JFR event recorder. + * + * @return new pinning recorder + */ + public static PinningRecorder create() { + return new PinningRecorder(); + } + + /** + * Start async recording of {@code jdk.VirtualThreadPinned} JFR event. + * + * @param threshold time threshold for carrier thread blocking to be considered as pinning + */ + public void record(Duration threshold) { + recordingStream.enable(JFR_EVENT_VIRTUAL_THREAD_PINNED) + .withThreshold(threshold) + .withStackTrace(); + recordingStream.onEvent(JFR_EVENT_VIRTUAL_THREAD_PINNED, this::record); + recordingStream.startAsync(); + } + + @Override + public void close() { + try { + // Flush ending events + recordingStream.stop(); + } finally { + recordingStream.close(); + } + checkAndThrow(); + } + + void checkAndThrow() { + if (pinningAssertionError != null) { + throw pinningAssertionError; + } + } + + private void record(RecordedEvent event) { + PinningAssertionError e = new PinningAssertionError(event); + if (pinningAssertionError == null) { + pinningAssertionError = e; + } else { + pinningAssertionError.addSuppressed(e); + } + } +} diff --git a/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/PinnedThreadValidation.java b/common/testing/virtual-threads/src/main/java/io/helidon/common/testing/virtualthreads/package-info.java similarity index 57% rename from microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/PinnedThreadValidation.java rename to common/testing/virtual-threads/src/main/java/io/helidon/common/testing/virtualthreads/package-info.java index e3be0b51783..07d21132096 100644 --- a/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/PinnedThreadValidation.java +++ b/common/testing/virtual-threads/src/main/java/io/helidon/common/testing/virtualthreads/package-info.java @@ -13,19 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.helidon.microprofile.testing.testng; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; /** - * An annotation making this test class to fail at the end if a pinned virtual thread was detected. + * Virtual Threads testing features. */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -@Inherited -public @interface PinnedThreadValidation { -} +package io.helidon.common.testing.virtualthreads; diff --git a/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/PinnedThreadValidation.java b/common/testing/virtual-threads/src/main/java/module-info.java similarity index 51% rename from webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/PinnedThreadValidation.java rename to common/testing/virtual-threads/src/main/java/module-info.java index 3c2f343f5f6..246c15c45b2 100644 --- a/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/PinnedThreadValidation.java +++ b/common/testing/virtual-threads/src/main/java/module-info.java @@ -13,22 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.helidon.webserver.testing.junit5; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import org.junit.jupiter.api.extension.ExtendWith; /** - * An annotation making this test class to fail at the end if a pinned virtual thread was detected. + * Virtual Thread testing features for Helidon test extensions. */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -@ExtendWith(HelidonPinnedThreadValidationJunitExtension.class) -@Inherited -public @interface PinnedThreadValidation { -} +module io.helidon.common.testing.vitualthreads { + requires jdk.jfr; + exports io.helidon.common.testing.virtualthreads to + io.helidon.microprofile.testing.junit5, + io.helidon.microprofile.testing.testng, + io.helidon.webserver.testing.junit5; +} \ No newline at end of file diff --git a/dependencies/pom.xml b/dependencies/pom.xml index 212b35a1465..7ab3a7d54de 100644 --- a/dependencies/pom.xml +++ b/dependencies/pom.xml @@ -95,6 +95,7 @@ 3.1.9 6.7.0.202309050840-r 5.9.3 + 1.11.3 3.8.1 2.21.1 1.4.14 @@ -1284,6 +1285,11 @@ hamcrest-core ${version.lib.hamcrest} + + org.junit.platform + junit-platform-testkit + ${version.lib.junit-platform-testkit} + org.testcontainers junit-jupiter diff --git a/docs/src/main/asciidoc/mp/testing/testing-ng.adoc b/docs/src/main/asciidoc/mp/testing/testing-ng.adoc index f21f7869bf1..62ddbd62fd3 100644 --- a/docs/src/main/asciidoc/mp/testing/testing-ng.adoc +++ b/docs/src/main/asciidoc/mp/testing/testing-ng.adoc @@ -31,6 +31,7 @@ include::{rootdir}/includes/mp.adoc[] - <> - <> - <> +- <> - <> == Overview @@ -150,6 +151,34 @@ include::{sourcedir}/mp/testing/TestingNgSnippets.java[tag=snippet_2, indent=0] <3> Add JaxRs support to the current test class. <4> Define a `RequestScoped` bean. + +== Virtual Threads +Helidon tests are able to detect Virtual Threads pinning. A situation when carrier thread is blocked +in a way, that virtual thread scheduler can't use it for scheduling of other virtual threads. +This can happen for example when blocking native code is invoked, or prior to the JDK 24 when +blocking IO operation happens in a synchronized block. +Pinning can in some cases negatively affect application performance. + +[source,java] +.Enable pinning detection +---- +include::{sourcedir}/mp/testing/TestingNgSnippets.java[tag=snippet_3, indent=0] +---- + +Pinning is considered as harmful when it takes longer than 20 milliseconds, +that is also the default when detecting it within Helidon tests. + +Pinning threshold can be changed with: + +[source,java] +.Configure pinning threshold +---- +include::{sourcedir}/mp/testing/TestingNgSnippets.java[tag=snippet_4, indent=0] +---- +<1> Change pinning threshold from default(20) to 50 milliseconds. + +When pinning is detected, test fails with stacktrace pointing to the line of code causing it. + == Reference * https://testng.org[TestNG Documentation] diff --git a/docs/src/main/asciidoc/mp/testing/testing.adoc b/docs/src/main/asciidoc/mp/testing/testing.adoc index da0895b3fb7..fab51290a0d 100644 --- a/docs/src/main/asciidoc/mp/testing/testing.adoc +++ b/docs/src/main/asciidoc/mp/testing/testing.adoc @@ -33,6 +33,7 @@ include::{rootdir}/includes/mp.adoc[] - <> - <> - <> +- <> - <> - <> @@ -224,6 +225,33 @@ include::{sourcedir}/mp/testing/CDIMockingSnippets.java[tag=snippet_2, indent=0] <4> Test that the mock is injected with modified behavior. <5> Test the real method behavior. +== Virtual Threads +Helidon tests are able to detect Virtual Threads pinning. A situation when carrier thread is blocked +in a way, that virtual thread scheduler can't use it for scheduling of other virtual threads. +This can happen for example when blocking native code is invoked, or prior to the JDK 24 when +blocking IO operation happens in a synchronized block. +Pinning can in some cases negatively affect application performance. + +[source,java] +.Enable pinning detection +---- +include::{sourcedir}/mp/testing/TestingSnippets.java[tag=snippet_3, indent=0] +---- + +Pinning is considered as harmful when it takes longer than 20 milliseconds, +that is also the default when detecting it within Helidon tests. + +Pinning threshold can be changed with: + +[source,java] +.Configure pinning threshold +---- +include::{sourcedir}/mp/testing/TestingSnippets.java[tag=snippet_4, indent=0] +---- +<1> Change pinning threshold from default(20) to 50 milliseconds. + +When pinning is detected, test fails with stacktrace pointing to the line of code causing it. + == Additional Information * https://medium.com/helidon/testing-helidon-9df2ea14e22[Official blog article about Helidon and JUnit usage] diff --git a/docs/src/main/asciidoc/se/testing.adoc b/docs/src/main/asciidoc/se/testing.adoc index a1cdeef75e2..b5c2cb1988a 100644 --- a/docs/src/main/asciidoc/se/testing.adoc +++ b/docs/src/main/asciidoc/se/testing.adoc @@ -32,6 +32,7 @@ include::{rootdir}/includes/se.adoc[] - <> - <> - <> +- <> - <> - <> @@ -207,6 +208,33 @@ It is required to annotate the test class with the `@RoutingTest` annotation to Routing is configured the same way as in full server testing using the `@SetUpRoute` annotation. +== Virtual Threads +Helidon tests are able to detect Virtual Threads pinning. A situation when carrier thread is blocked +in a way, that virtual thread scheduler can't use it for scheduling of other virtual threads. +This can happen for example when blocking native code is invoked, or prior to the JDK 24 when +blocking IO operation happens in a synchronized block. +Pinning can in some cases negatively affect application performance. + +[source,java] +.Enable pinning detection +---- +include::{sourcedir}/se/TestingSnippets.java[tag=snippet_6, indent=0] +---- + +Pinning is considered as harmful when it takes longer than 20 milliseconds, +that is also the default when detecting it within Helidon tests. + +Pinning threshold can be changed with: + +[source,java] +.Configure pinning threshold +---- +include::{sourcedir}/se/TestingSnippets.java[tag=snippet_7, indent=0] +---- +<1> Change pinning threshold from default(20) to 50 milliseconds. + +When pinning is detected, test fails with stacktrace pointing to the line of code causing it. + == Additional Information === WebSocket Testing diff --git a/docs/src/main/java/io/helidon/docs/mp/testing/TestingNgSnippets.java b/docs/src/main/java/io/helidon/docs/mp/testing/TestingNgSnippets.java index d696e44cade..5f07d7c26ab 100644 --- a/docs/src/main/java/io/helidon/docs/mp/testing/TestingNgSnippets.java +++ b/docs/src/main/java/io/helidon/docs/mp/testing/TestingNgSnippets.java @@ -91,4 +91,16 @@ public Response get() { } // end::snippet_2[] + // tag::snippet_3[] + @HelidonTest(pinningDetection = true) + // end::snippet_3[] + class TestDetectionExample1 { + } + + // tag::snippet_4[] + @HelidonTest(pinningDetection = true, pinningThreshold = 50)// <1> + // end::snippet_4[] + class TestDetectionExample2 { + } + } diff --git a/docs/src/main/java/io/helidon/docs/mp/testing/TestingSnippets.java b/docs/src/main/java/io/helidon/docs/mp/testing/TestingSnippets.java index 919d2062a2c..dea7121d3b2 100644 --- a/docs/src/main/java/io/helidon/docs/mp/testing/TestingSnippets.java +++ b/docs/src/main/java/io/helidon/docs/mp/testing/TestingSnippets.java @@ -91,4 +91,16 @@ public Response get() { } // end::snippet_2[] + // tag::snippet_3[] + @HelidonTest(pinningDetection = true) + // end::snippet_3[] + class TestDetectionExample1 { + } + + // tag::snippet_4[] + @HelidonTest(pinningDetection = true, pinningThreshold = 50)// <1> + // end::snippet_4[] + class TestDetectionExample2 { + } + } diff --git a/docs/src/main/java/io/helidon/docs/se/TestingSnippets.java b/docs/src/main/java/io/helidon/docs/se/TestingSnippets.java index a3c9eef2bfb..3690ca88594 100644 --- a/docs/src/main/java/io/helidon/docs/se/TestingSnippets.java +++ b/docs/src/main/java/io/helidon/docs/se/TestingSnippets.java @@ -16,6 +16,7 @@ package io.helidon.docs.se; import io.helidon.http.Status; +import io.helidon.microprofile.testing.junit5.HelidonTest; import io.helidon.webclient.http1.Http1Client; import io.helidon.webclient.http1.Http1ClientResponse; import io.helidon.webclient.websocket.WsClient; @@ -156,4 +157,16 @@ public void onMessage(WsSession session, String text, boolean last) { // <1> } } // end::snippet_5[] + + // tag::snippet_6[] + @ServerTest(pinningDetection = true) + // end::snippet_6[] + class TestDetectionExample1 { + } + + // tag::snippet_7[] + @ServerTest(pinningDetection = true, pinningThreshold = 50)// <1> + // end::snippet_7[] + class TestDetectionExample2 { + } } diff --git a/microprofile/testing/junit5/pom.xml b/microprofile/testing/junit5/pom.xml index 40fe01c0dad..b8c6daf35d8 100644 --- a/microprofile/testing/junit5/pom.xml +++ b/microprofile/testing/junit5/pom.xml @@ -38,6 +38,10 @@ helidon-microprofile-server true + + io.helidon.common.testing + helidon-common-testing-virtual-threads + io.helidon.microprofile.cdi helidon-microprofile-cdi diff --git a/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonJunitExtension.java b/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonJunitExtension.java index a046abd318c..9632943ab6c 100644 --- a/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonJunitExtension.java +++ b/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonJunitExtension.java @@ -28,6 +28,7 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.net.URL; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -38,6 +39,7 @@ import java.util.Set; import java.util.stream.Collectors; +import io.helidon.common.testing.virtualthreads.PinningRecorder; import io.helidon.config.mp.MpConfigSources; import io.helidon.microprofile.server.JaxRsCdiExtension; import io.helidon.microprofile.server.ServerCdiExtension; @@ -111,7 +113,7 @@ class HelidonJunitExtension implements BeforeAllCallback, private ConfigProviderResolver configProviderResolver; private Config config; private SeContainer container; - + private PinningRecorder pinningRecorder; @SuppressWarnings("unchecked") @Override @@ -135,8 +137,13 @@ public void beforeAll(ExtensionContext context) { HelidonTest testAnnot = testClass.getAnnotation(HelidonTest.class); if (testAnnot != null) { resetPerTest = testAnnot.resetPerTest(); + if (testAnnot.pinningDetection()) { + pinningRecorder = PinningRecorder.create(); + pinningRecorder.record(Duration.ofMillis(testAnnot.pinningThreshold())); + } } + DisableDiscovery discovery = getAnnotation(testClass, DisableDiscovery.class, metaAnnotations); if (discovery != null) { classLevelDisableDiscovery = discovery.value(); @@ -174,7 +181,7 @@ private List extractMetaAnnotations(Class testClass) { for (Annotation testAnnotation : testAnnotations) { List annotations = List.of(testAnnotation.annotationType().getAnnotations()); List> annotationsClass = annotations.stream() - .map(a -> a.annotationType()).collect(Collectors.toList()); + .map(Annotation::annotationType).collect(Collectors.toList()); if (!Collections.disjoint(HELIDON_TEST_ANNOTATIONS, annotationsClass)) { // Contains at least one of HELIDON_TEST_ANNOTATIONS return annotations; @@ -268,6 +275,7 @@ public void afterEach(ExtensionContext context) throws Exception { releaseConfig(); stopContainer(); } +// pinningRecorder.checkAndThrow(); } private void validatePerClass() { @@ -427,6 +435,10 @@ public void afterAll(ExtensionContext context) { stopContainer(); releaseConfig(); callAfterStop(); + if (pinningRecorder != null) { + pinningRecorder.close(); + pinningRecorder = null; + } } @Override diff --git a/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonPinnedThreadValidationJunitExtension.java b/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonPinnedThreadValidationJunitExtension.java deleted file mode 100644 index 0a0b42593df..00000000000 --- a/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonPinnedThreadValidationJunitExtension.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (c) 2024 Oracle and/or its affiliates. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.helidon.microprofile.testing.junit5; - -import jdk.jfr.consumer.RecordedEvent; -import jdk.jfr.consumer.RecordingStream; -import org.junit.jupiter.api.extension.AfterAllCallback; -import org.junit.jupiter.api.extension.BeforeAllCallback; -import org.junit.jupiter.api.extension.ExtensionContext; - -/** - * JUnit5 extension to support pinned threads validation. - */ -class HelidonPinnedThreadValidationJunitExtension implements BeforeAllCallback, AfterAllCallback { - - private RecordingStream recordingStream; - private boolean pinnedThreadValidation; - private PinningException pinningException; - - @Override - public void beforeAll(ExtensionContext context) throws Exception { - Class testClass = context.getRequiredTestClass(); - pinnedThreadValidation = testClass.getAnnotation(PinnedThreadValidation.class) != null; - if (pinnedThreadValidation) { - recordingStream = new RecordingStream(); - recordingStream.enable("jdk.VirtualThreadPinned").withStackTrace(); - recordingStream.onEvent("jdk.VirtualThreadPinned", this::record); - recordingStream.startAsync(); - } - } - - void record(RecordedEvent event) { - PinningException e = new PinningException(event); - if (pinningException == null) { - pinningException = e; - } else { - pinningException.addSuppressed(e); - } - } - - @Override - public void afterAll(ExtensionContext context) { - if (pinnedThreadValidation) { - try { - // Flush ending events - recordingStream.stop(); - if (pinningException != null) { - throw pinningException; - } - } finally { - recordingStream.close(); - } - } - } - - private static class PinningException extends AssertionError { - private final RecordedEvent recordedEvent; - - PinningException(RecordedEvent recordedEvent) { - this.recordedEvent = recordedEvent; - if (recordedEvent.getStackTrace() != null) { - StackTraceElement[] stackTraceElements = recordedEvent.getStackTrace().getFrames().stream() - .map(f -> new StackTraceElement(f.getMethod().getType().getName(), - f.getMethod().getName(), - f.getMethod().getType().getName() + ".java", - f.getLineNumber())) - .toArray(StackTraceElement[]::new); - super.setStackTrace(stackTraceElements); - } - } - - @Override - public String getMessage() { - return "Pinned virtual threads were detected:\n" - + recordedEvent.toString(); - } - } -} diff --git a/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonTest.java b/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonTest.java index 983494f66bd..cb85395b11a 100644 --- a/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonTest.java +++ b/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2023 Oracle and/or its affiliates. + * Copyright (c) 2020, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,8 @@ import org.junit.jupiter.api.extension.ExtendWith; +import static io.helidon.common.testing.virtualthreads.PinningRecorder.DEFAULT_THRESHOLD; + /** * An annotation making this test class a CDI bean with support for injection. *

@@ -52,4 +54,18 @@ * @return whether to reset container per test method */ boolean resetPerTest() default false; + + /** + * Time threshold for carrier thread blocking to be considered as pinning. + * + * @return threshold in milliseconds, {@code 20} is default + */ + long pinningThreshold() default DEFAULT_THRESHOLD; + + /** + * Whether to turn on pinning detection during {@code @HelidonTest}. + * + * @return true for turning detection on, {@code false} is default + */ + boolean pinningDetection() default false; } diff --git a/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/PinnedThreadValidation.java b/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/PinnedThreadValidation.java deleted file mode 100644 index 49f3a2f6fde..00000000000 --- a/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/PinnedThreadValidation.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2024 Oracle and/or its affiliates. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.helidon.microprofile.testing.junit5; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import org.junit.jupiter.api.extension.ExtendWith; - -/** - * An annotation making this test class to fail at the end if a pinned virtual thread was detected. - */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -@ExtendWith(HelidonPinnedThreadValidationJunitExtension.class) -@Inherited -public @interface PinnedThreadValidation { -} diff --git a/microprofile/testing/junit5/src/main/java/module-info.java b/microprofile/testing/junit5/src/main/java/module-info.java index e20192d5d8a..398fc045b50 100644 --- a/microprofile/testing/junit5/src/main/java/module-info.java +++ b/microprofile/testing/junit5/src/main/java/module-info.java @@ -24,7 +24,6 @@ requires io.helidon.microprofile.cdi; requires jakarta.inject; requires org.junit.jupiter.api; - requires jdk.jfr; requires transitive jakarta.cdi; requires transitive jakarta.ws.rs; @@ -32,6 +31,7 @@ requires static io.helidon.microprofile.server; requires static jersey.cdi1x; requires static jersey.weld2.se; + requires io.helidon.common.testing.vitualthreads; exports io.helidon.microprofile.testing.junit5; diff --git a/microprofile/testing/testng/pom.xml b/microprofile/testing/testng/pom.xml index 9f781af4fc2..f33097c9eba 100644 --- a/microprofile/testing/testng/pom.xml +++ b/microprofile/testing/testng/pom.xml @@ -38,6 +38,10 @@ helidon-microprofile-server true + + io.helidon.common.testing + helidon-common-testing-virtual-threads + io.helidon.microprofile.cdi helidon-microprofile-cdi diff --git a/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTest.java b/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTest.java index 8f86fb6f7a7..2f3ef14400f 100644 --- a/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTest.java +++ b/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,8 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import static io.helidon.common.testing.virtualthreads.PinningRecorder.DEFAULT_THRESHOLD; + /** * An annotation making this test class a CDI bean with support for injection. *

@@ -48,4 +50,18 @@ * @return whether to reset container per test method */ boolean resetPerTest() default false; + + /** + * Time threshold for carrier thread blocking to be considered as pinning. + * + * @return threshold in milliseconds, {@code 20} is default + */ + long pinningThreshold() default DEFAULT_THRESHOLD; + + /** + * Whether to turn on pinning detection during {@code @HelidonTest}. + * + * @return true for turning detection on, {@code false} is default + */ + boolean pinningDetection() default false; } diff --git a/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListener.java b/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListener.java index 66248244198..27f6049ec89 100644 --- a/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListener.java +++ b/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListener.java @@ -26,6 +26,7 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.net.URL; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -36,6 +37,7 @@ import java.util.Set; import java.util.stream.Collectors; +import io.helidon.common.testing.virtualthreads.PinningRecorder; import io.helidon.config.mp.MpConfigSources; import io.helidon.microprofile.server.JaxRsCdiExtension; import io.helidon.microprofile.server.ServerCdiExtension; @@ -59,8 +61,6 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import jakarta.ws.rs.client.ClientBuilder; -import jdk.jfr.consumer.RecordedEvent; -import jdk.jfr.consumer.RecordingStream; import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.spi.ConfigBuilder; import org.eclipse.microprofile.config.spi.ConfigProviderResolver; @@ -90,29 +90,31 @@ public class HelidonTestNgListener implements IClassListener, ITestListener { private List classLevelExtensions = new ArrayList<>(); private List classLevelBeans = new ArrayList<>(); private ConfigMeta classLevelConfigMeta = new ConfigMeta(); - private RecordingStream recordingStream; private boolean classLevelDisableDiscovery = false; + private boolean helidonTest; private boolean resetPerTest; - private boolean pinnedThreadValidation; private Class testClass; private Object testInstance; private ConfigProviderResolver configProviderResolver; private Config config; private SeContainer container; - private PinningException pinningException; + private PinningRecorder pinningRecorder; @Override public void onBeforeClass(ITestClass iTestClass) { testClass = iTestClass.getRealClass(); + HelidonTest testAnnot = testClass.getAnnotation(HelidonTest.class); + if (testAnnot == null) { + return; + } + helidonTest = true; + resetPerTest = testAnnot.resetPerTest(); List metaAnnotations = extractMetaAnnotations(testClass); AddConfig[] configs = getAnnotations(testClass, AddConfig.class, metaAnnotations); - pinnedThreadValidation = testClass.getAnnotation(PinnedThreadValidation.class) != null; - startRecordingStream(); - AddConfig[] configs = getAnnotations(testClass, AddConfig.class); classLevelConfigMeta.addConfig(configs); classLevelConfigMeta.configuration(getAnnotation(testClass, Configuration.class, metaAnnotations)); classLevelConfigMeta.addConfigBlock(getAnnotation(testClass, AddConfigBlock.class, metaAnnotations)); @@ -124,9 +126,9 @@ public void onBeforeClass(ITestClass iTestClass) { AddBean[] beans = getAnnotations(testClass, AddBean.class, metaAnnotations); classLevelBeans.addAll(Arrays.asList(beans)); - HelidonTest testAnnot = testClass.getAnnotation(HelidonTest.class); - if (testAnnot != null) { - resetPerTest = testAnnot.resetPerTest(); + if (testAnnot.pinningDetection()) { + pinningRecorder = PinningRecorder.create(); + pinningRecorder.record(Duration.ofMillis(testAnnot.pinningThreshold())); } DisableDiscovery discovery = getAnnotation(testClass, DisableDiscovery.class, metaAnnotations); @@ -165,15 +167,25 @@ public void onBeforeClass(ITestClass iTestClass) { @Override public void onAfterClass(ITestClass testClass) { + if (!helidonTest) { + return; + } + if (!resetPerTest) { releaseConfig(); stopContainer(); } - closeRecordingStream(); + if (pinningRecorder != null) { + pinningRecorder.close(); + pinningRecorder = null; + } } @Override public void onTestStart(ITestResult result) { + if (!helidonTest) { + return; + } if (resetPerTest) { Method method = result.getMethod().getConstructorOrMethod().getMethod(); @@ -206,6 +218,10 @@ public void onTestStart(ITestResult result) { @Override public void onTestFailure(ITestResult iTestResult) { + if (!helidonTest) { + return; + } + if (resetPerTest) { releaseConfig(); stopContainer(); @@ -214,6 +230,10 @@ public void onTestFailure(ITestResult iTestResult) { @Override public void onTestSuccess(ITestResult iTestResult) { + if (!helidonTest) { + return; + } + if (resetPerTest) { releaseConfig(); stopContainer(); @@ -336,7 +356,7 @@ private List extractMetaAnnotations(Class testClass) { for (Annotation testAnnotation : testAnnotations) { List annotations = List.of(testAnnotation.annotationType().getAnnotations()); List> annotationsClass = annotations.stream() - .map(a -> a.annotationType()).collect(Collectors.toList()); + .map(Annotation::annotationType).collect(Collectors.toList()); if (!Collections.disjoint(TEST_ANNOTATIONS, annotationsClass)) { // Contains at least one of HELIDON_TEST_ANNOTATIONS return annotations; @@ -367,30 +387,6 @@ private T getAnnotation(Class testClass, Class anno return annotation; } - private void startRecordingStream() { - if (pinnedThreadValidation) { - pinningException = null; - recordingStream = new RecordingStream(); - recordingStream.enable("jdk.VirtualThreadPinned").withStackTrace(); - recordingStream.onEvent("jdk.VirtualThreadPinned", this::record); - recordingStream.startAsync(); - } - } - - private void closeRecordingStream() { - if (pinnedThreadValidation) { - try { - // Flush ending events - recordingStream.stop(); - if (pinningException != null) { - throw pinningException; - } - } finally { - recordingStream.close(); - } - } - } - @SuppressWarnings("unchecked") private T[] getAnnotations(Class testClass, Class annotClass, List metaAnnotations) { @@ -464,15 +460,6 @@ private static boolean hasAnnotation(AnnotatedElement element, Set testClass) implements Extension { @@ -688,27 +675,4 @@ private static final class SingletonLiteral extends AnnotationLiteral static final SingletonLiteral INSTANCE = new SingletonLiteral(); } - private static class PinningException extends AssertionError { - private final RecordedEvent recordedEvent; - - PinningException(RecordedEvent recordedEvent) { - this.recordedEvent = recordedEvent; - if (recordedEvent.getStackTrace() != null) { - StackTraceElement[] stackTraceElements = recordedEvent.getStackTrace().getFrames().stream() - .map(f -> new StackTraceElement(f.getMethod().getType().getName(), - f.getMethod().getName(), - f.getMethod().getType().getName() + ".java", - f.getLineNumber())) - .toArray(StackTraceElement[]::new); - super.setStackTrace(stackTraceElements); - } - } - - @Override - public String getMessage() { - return "Pinned virtual threads were detected:\n" - + recordedEvent.toString(); - } - } - } diff --git a/microprofile/testing/testng/src/main/java/module-info.java b/microprofile/testing/testng/src/main/java/module-info.java index 6a5d76347fe..02ddeb59bf3 100644 --- a/microprofile/testing/testng/src/main/java/module-info.java +++ b/microprofile/testing/testng/src/main/java/module-info.java @@ -14,8 +14,6 @@ * limitations under the License. */ -import io.helidon.microprofile.testing.testng.HelidonTestNgListener; - /** * TestNG extension module to run CDI tests. */ @@ -23,11 +21,11 @@ requires io.helidon.config.mp; requires io.helidon.config.yaml.mp; + requires io.helidon.common.testing.vitualthreads; requires io.helidon.microprofile.cdi; requires jakarta.cdi; requires jakarta.inject; requires jakarta.ws.rs; - requires jdk.jfr; requires microprofile.config.api; requires org.testng; @@ -37,6 +35,6 @@ exports io.helidon.microprofile.testing.testng; - provides org.testng.ITestNGListener with HelidonTestNgListener; + provides org.testng.ITestNGListener with io.helidon.microprofile.testing.testng.HelidonTestNgListener; } \ No newline at end of file diff --git a/microprofile/tests/testing/junit5/pom.xml b/microprofile/tests/testing/junit5/pom.xml index 3c48ca8a3e8..b34d857a815 100644 --- a/microprofile/tests/testing/junit5/pom.xml +++ b/microprofile/tests/testing/junit5/pom.xml @@ -64,5 +64,11 @@ mockito-core test + + org.junit.platform + junit-platform-testkit + test + + diff --git a/microprofile/tests/testing/junit5/src/test/java/io/helidon/microprofile/tests/testing/junit5/TestPinnedThread.java b/microprofile/tests/testing/junit5/src/test/java/io/helidon/microprofile/tests/testing/junit5/TestPinnedThread.java index 2e5ea48e6db..398bd0c4424 100644 --- a/microprofile/tests/testing/junit5/src/test/java/io/helidon/microprofile/tests/testing/junit5/TestPinnedThread.java +++ b/microprofile/tests/testing/junit5/src/test/java/io/helidon/microprofile/tests/testing/junit5/TestPinnedThread.java @@ -16,25 +16,172 @@ package io.helidon.microprofile.tests.testing.junit5; -import io.helidon.microprofile.testing.junit5.PinnedThreadValidation; +import java.util.Arrays; -import org.junit.jupiter.api.Disabled; +import io.helidon.common.testing.virtualthreads.PinningAssertionError; +import io.helidon.microprofile.testing.junit5.AddBean; +import io.helidon.microprofile.testing.junit5.HelidonTest; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.client.WebTarget; +import org.assertj.core.api.Condition; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.platform.testkit.engine.EngineTestKit; +import org.junit.platform.testkit.engine.Event; +import org.junit.platform.testkit.engine.Events; + +import static org.junit.platform.commons.util.FunctionUtils.where; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.junit.platform.testkit.engine.EventConditions.displayName; +import static org.junit.platform.testkit.engine.EventConditions.event; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; -@PinnedThreadValidation class TestPinnedThread { @Test - @Disabled("Enable to verify pinned threads fails") - void test() throws InterruptedException { - Thread.ofVirtual().start(() -> { - synchronized (this) { - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - Thread.interrupted(); + void engineTest() { + Events events = EngineTestKit.engine("junit-jupiter") + .selectors( + selectClass(PinningTestCase.class), + selectClass(PinningExtraThreadTestCase.class), + selectClass(NoPinningTestCase.class), + selectClass(NoPinningExtraThreadTestCase.class) + ) + .execute() + .containerEvents() + .assertStatistics(stats -> stats + .failed(2) + .succeeded(3)); + + events.failed() + .assertEventsMatchExactly( + event(displayClass(PinningTestCase.class), failedWithPinningException("pinningInResourceMethod")), + event(displayClass(PinningExtraThreadTestCase.class), failedWithPinningException("lambda$pinningTest$0")) + ); + } + + private Condition failedWithPinningException(String expectedPinningMethodName) { + return finishedWithFailure( + instanceOf(PinningAssertionError.class), + message(m -> m.startsWith("Pinned virtual threads were detected")) + , new Condition<>(where( + t -> Arrays.stream(t.getStackTrace()), + s -> s + .anyMatch(e -> e.getMethodName() + .equals(expectedPinningMethodName))), + "Method with pinning is missing from stack strace.") + ); + } + + private Condition displayClass(Class clazz) { + return displayName(Arrays.stream(clazz.getName().split("\\.")).toList().getLast()); + } + + @HelidonTest(pinningDetection = true) + @AddBean(PinningTestCase.TestResource.class) + static class PinningTestCase { + + @Path("/pinning") + public static class TestResource { + @GET + public String pinningInResourceMethod() { + synchronized (this) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.interrupted(); + } } + return "pinning"; } - }).join(); + } + + @Test + void pinningTest(WebTarget target) { + target.path("/pinning") + .request() + .get(String.class); + } + } + + @HelidonTest(pinningDetection = true) + static class PinningExtraThreadTestCase { + + @Test + void pinningTest() throws InterruptedException { + Thread.ofVirtual().start(() -> { + synchronized (this) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + }).join(); + } + } + + @HelidonTest(pinningDetection = false) + static class PinningDisabledExtraThreadTestCase { + + @Test + void pinningTest() throws InterruptedException { + Thread.ofVirtual().start(() -> { + synchronized (this) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + }).join(); + } + } + + @HelidonTest(pinningDetection = true) + static class NoPinningTestCase { + + @Path("/pinning") + public static class TestResource { + @GET + public String pinningInResourceMethod() { + synchronized (this) { + try { + Thread.sleep(5); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + return "NO PINNING!"; + } + } + + @Test + void pinningTest(WebTarget target) { + Assertions.assertEquals("NO PINNING!", target.path("/pinning") + .request() + .get(String.class)); + } + } + + @HelidonTest(pinningDetection = true) + static class NoPinningExtraThreadTestCase { + + @Test + void pinningTest() throws InterruptedException { + Thread.ofVirtual().start(() -> { + synchronized (this) { + try { + Thread.sleep(5); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + }).join(); + } } } diff --git a/microprofile/tests/testing/testng/pom.xml b/microprofile/tests/testing/testng/pom.xml index 8b439c66b58..00f5459ca8d 100644 --- a/microprofile/tests/testing/testng/pom.xml +++ b/microprofile/tests/testing/testng/pom.xml @@ -15,8 +15,8 @@ limitations under the License. --> + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 io.helidon.microprofile.tests.testing @@ -28,8 +28,7 @@ Helidon Microprofile Tests TestNG unit tests - Test for TestNG integration to prevent cyclic dependencies, - so the module can be used in MP config implementation + Test for TestNG integration to prevent cyclic dependencies, so the module can be used in MP config implementation @@ -64,4 +63,19 @@ test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + test-suite.xml + + + + + diff --git a/microprofile/tests/testing/testng/src/test/java/io/helidon/microprofile/tests/testing/testng/TestPinnedThread.java b/microprofile/tests/testing/testng/src/test/java/io/helidon/microprofile/tests/testing/testng/TestPinnedThread.java index 6660b31425a..8fdd6bda3bc 100644 --- a/microprofile/tests/testing/testng/src/test/java/io/helidon/microprofile/tests/testing/testng/TestPinnedThread.java +++ b/microprofile/tests/testing/testng/src/test/java/io/helidon/microprofile/tests/testing/testng/TestPinnedThread.java @@ -16,25 +16,61 @@ package io.helidon.microprofile.tests.testing.testng; -import io.helidon.microprofile.testing.testng.PinnedThreadValidation; +import java.util.Arrays; -import org.testng.annotations.Ignore; +import io.helidon.common.testing.virtualthreads.PinningAssertionError; +import io.helidon.microprofile.tests.testing.testng.programmatic.PinningExtraThreadTest; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.testng.Assert; +import org.testng.TestListenerAdapter; +import org.testng.TestNG; import org.testng.annotations.Test; -@PinnedThreadValidation -class TestPinnedThread { +import static org.hamcrest.CoreMatchers.startsWith; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsCollectionContaining.hasItem; + +public class TestPinnedThread { + + private static final String EXPECTED_PINNING_METHOD_NAME = "lambda$testPinningExtraThread$0"; @Test - @Ignore("Enable to verify pinned threads fails") - void test() throws InterruptedException { - Thread.ofVirtual().start(() -> { - synchronized (this) { - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - Thread.interrupted(); - } - } - }).join(); + void testListener() { + TestNG testng = new TestNG(); + testng.setTestClasses(new Class[] {PinningExtraThreadTest.class}); + TestListenerAdapter tla = new TestListenerAdapter(); + testng.addListener(tla); + PinningAssertionError pinningAssertionError = Assert.expectThrows(PinningAssertionError.class, testng::run); + assertThat(pinningAssertionError.getMessage(), startsWith("Pinned virtual threads were detected:")); + assertThat("Method with pinning is missing from stack strace.", Arrays.asList(pinningAssertionError.getStackTrace()), + hasItem(new StackTraceElementMatcher(EXPECTED_PINNING_METHOD_NAME))); + } + + private static class StackTraceElementMatcher extends BaseMatcher { + + private final String methodName; + + StackTraceElementMatcher(String methodName) { + this.methodName = methodName; + } + + @Override + public boolean matches(Object o) { + return methodName.equals(((StackTraceElement) o).getMethodName()); + } + + @Override + public void describeMismatch(Object o, Description description) { + description.appendText("method ").appendValue(methodName) + .appendText(" does not match stack trace element ") + .appendValue(o); + } + + @Override + public void describeTo(Description description) { + + } } } diff --git a/microprofile/tests/testing/testng/src/test/java/io/helidon/microprofile/tests/testing/testng/programmatic/PinningExtraThreadTest.java b/microprofile/tests/testing/testng/src/test/java/io/helidon/microprofile/tests/testing/testng/programmatic/PinningExtraThreadTest.java new file mode 100644 index 00000000000..924106a43ff --- /dev/null +++ b/microprofile/tests/testing/testng/src/test/java/io/helidon/microprofile/tests/testing/testng/programmatic/PinningExtraThreadTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.tests.testing.testng.programmatic; + +import io.helidon.microprofile.testing.testng.HelidonTest; + +import org.testng.annotations.Test; + +/** + * Test executed programmatically from TestPinnedThread Test. + */ +@HelidonTest(pinningDetection = true) +public class PinningExtraThreadTest { + + @Test + void testPinningExtraThread() throws InterruptedException { + Thread.ofVirtual().start(() -> { + synchronized (this) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + }).join(); + } +} diff --git a/microprofile/tests/testing/testng/test-suite.xml b/microprofile/tests/testing/testng/test-suite.xml index a42203a85eb..a60e4f2eed7 100644 --- a/microprofile/tests/testing/testng/test-suite.xml +++ b/microprofile/tests/testing/testng/test-suite.xml @@ -1,7 +1,7 @@ + + \ No newline at end of file diff --git a/webserver/testing/junit5/junit5/pom.xml b/webserver/testing/junit5/junit5/pom.xml index 35895677f8b..96442d4532c 100644 --- a/webserver/testing/junit5/junit5/pom.xml +++ b/webserver/testing/junit5/junit5/pom.xml @@ -46,6 +46,10 @@ io.helidon.common.testing helidon-common-testing-http-junit5 + + io.helidon.common.testing + helidon-common-testing-virtual-threads + io.helidon.config helidon-config-yaml @@ -58,5 +62,10 @@ org.hamcrest hamcrest-all + + org.junit.platform + junit-platform-testkit + test + diff --git a/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/HelidonPinnedThreadValidationJunitExtension.java b/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/HelidonPinnedThreadValidationJunitExtension.java deleted file mode 100644 index a45e9de5656..00000000000 --- a/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/HelidonPinnedThreadValidationJunitExtension.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2024 Oracle and/or its affiliates. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.helidon.webserver.testing.junit5; - -import java.util.ArrayList; -import java.util.List; - -import jdk.jfr.consumer.RecordedEvent; -import jdk.jfr.consumer.RecordedFrame; -import jdk.jfr.consumer.RecordingStream; -import org.junit.jupiter.api.extension.AfterAllCallback; -import org.junit.jupiter.api.extension.BeforeAllCallback; -import org.junit.jupiter.api.extension.ExtensionContext; - -import static org.junit.jupiter.api.Assertions.fail; - -/** - * JUnit5 extension to support pinned threads validation. - */ -class HelidonPinnedThreadValidationJunitExtension implements BeforeAllCallback, AfterAllCallback { - - private List jfrVTPinned; - private RecordingStream recordingStream; - private boolean pinnedThreadValidation; - - @Override - public void beforeAll(ExtensionContext context) throws Exception { - Class testClass = context.getRequiredTestClass(); - pinnedThreadValidation = testClass.getAnnotation(PinnedThreadValidation.class) != null; - if (pinnedThreadValidation) { - jfrVTPinned = new ArrayList<>(); - recordingStream = new RecordingStream(); - recordingStream.enable("jdk.VirtualThreadPinned").withStackTrace(); - recordingStream.onEvent("jdk.VirtualThreadPinned", event -> { - jfrVTPinned.add(new EventWrapper(event)); - }); - recordingStream.startAsync(); - } - } - - @Override - public void afterAll(ExtensionContext context) { - if (pinnedThreadValidation) { - try { - // Flush ending events - recordingStream.stop(); - if (!jfrVTPinned.isEmpty()) { - fail("Some pinned virtual threads were detected:\n" + jfrVTPinned); - } - } finally { - recordingStream.close(); - } - } - } - - private static class EventWrapper { - - private final RecordedEvent recordedEvent; - - private EventWrapper(RecordedEvent recordedEvent) { - this.recordedEvent = recordedEvent; - } - - @Override - public String toString() { - StringBuilder builder = new StringBuilder(recordedEvent.toString()); - if (recordedEvent.getStackTrace() != null) { - builder.append("full-stackTrace = ["); - List frames = recordedEvent.getStackTrace().getFrames(); - for (RecordedFrame frame : frames) { - builder.append("\n\t").append(frame.getMethod().getType().getName()) - .append("#").append(frame.getMethod().getName()) - .append("(").append(frame.getLineNumber()).append(")"); - } - builder.append("\n]"); - } - return builder.toString(); - } - } -} diff --git a/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/HelidonServerJunitExtension.java b/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/HelidonServerJunitExtension.java index 68dcf78f356..360c351a556 100644 --- a/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/HelidonServerJunitExtension.java +++ b/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/HelidonServerJunitExtension.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.net.URI; +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -33,6 +34,7 @@ import io.helidon.common.config.GlobalConfig; import io.helidon.common.context.Context; import io.helidon.common.context.Contexts; +import io.helidon.common.testing.virtualthreads.PinningRecorder; import io.helidon.logging.common.LogConfig; import io.helidon.webserver.ListenerConfig; import io.helidon.webserver.Router; @@ -66,6 +68,7 @@ class HelidonServerJunitExtension extends JunitExtensionBase private final List extensions; private WebServer server; + private PinningRecorder pinningRecorder; HelidonServerJunitExtension() { this.extensions = HelidonServiceLoader.create(ServiceLoader.load(ServerJunitExtension.class)).asList(); @@ -82,6 +85,11 @@ public void beforeAll(ExtensionContext context) { throw new IllegalStateException("Invalid test class for this extension: " + testClass); } + if (testAnnot.pinningDetection()) { + pinningRecorder = PinningRecorder.create(); + pinningRecorder.record(Duration.ofMillis(testAnnot.pinningThreshold())); + } + WebServerConfig.Builder builder = WebServer.builder() .config(GlobalConfig.config().get("server")) .host("localhost"); @@ -113,6 +121,11 @@ public void afterAll(ExtensionContext extensionContext) { } super.afterAll(extensionContext); + + if (pinningRecorder != null) { + pinningRecorder.close(); + pinningRecorder = null; + } } @Override diff --git a/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/ServerTest.java b/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/ServerTest.java index 5611b0cb7f3..7e78be3fdaf 100644 --- a/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/ServerTest.java +++ b/webserver/testing/junit5/junit5/src/main/java/io/helidon/webserver/testing/junit5/ServerTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,8 @@ import org.junit.jupiter.api.extension.ExtendWith; +import static io.helidon.common.testing.virtualthreads.PinningRecorder.DEFAULT_THRESHOLD; + /** * Test of server that opens a socket (for integration tests). * Can be used together with: @@ -37,4 +39,17 @@ @ExtendWith(HelidonServerJunitExtension.class) @Inherited public @interface ServerTest { + /** + * Time threshold for carrier thread blocking to be considered as pinning. + * + * @return threshold in milliseconds, {@code 20} is default + */ + long pinningThreshold() default DEFAULT_THRESHOLD; + + /** + * Whether to turn on pinning detection during test. + * + * @return true for turning detection on, {@code false} is default + */ + boolean pinningDetection() default false; } diff --git a/webserver/testing/junit5/junit5/src/main/java/module-info.java b/webserver/testing/junit5/junit5/src/main/java/module-info.java index f2ec443a9f7..9729909931f 100644 --- a/webserver/testing/junit5/junit5/src/main/java/module-info.java +++ b/webserver/testing/junit5/junit5/src/main/java/module-info.java @@ -24,7 +24,7 @@ module io.helidon.webserver.testing.junit5 { requires io.helidon.logging.common; - requires jdk.jfr; + requires io.helidon.common.testing.vitualthreads; requires transitive hamcrest.all; requires transitive io.helidon.common.testing.http.junit5; diff --git a/webserver/testing/junit5/junit5/src/test/java/io/helidon/webserver/testing/junit5/TestPinnedThread.java b/webserver/testing/junit5/junit5/src/test/java/io/helidon/webserver/testing/junit5/TestPinnedThread.java index a39937d0b31..1098d8e01b4 100644 --- a/webserver/testing/junit5/junit5/src/test/java/io/helidon/webserver/testing/junit5/TestPinnedThread.java +++ b/webserver/testing/junit5/junit5/src/test/java/io/helidon/webserver/testing/junit5/TestPinnedThread.java @@ -16,23 +16,166 @@ package io.helidon.webserver.testing.junit5; -import org.junit.jupiter.api.Disabled; +import java.util.Arrays; + +import io.helidon.common.testing.virtualthreads.PinningAssertionError; +import io.helidon.webclient.api.WebClient; +import io.helidon.webserver.http.HttpRouting; + +import org.assertj.core.api.Condition; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.platform.testkit.engine.EngineTestKit; +import org.junit.platform.testkit.engine.Event; +import org.junit.platform.testkit.engine.Events; + +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.junit.platform.testkit.engine.EventConditions.displayName; +import static org.junit.platform.testkit.engine.EventConditions.event; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; -@PinnedThreadValidation class TestPinnedThread { @Test - @Disabled("Enable to verify pinned threads fails") - void test() throws InterruptedException { - Thread.ofVirtual().start(() -> { - synchronized (this) { - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - Thread.interrupted(); + void engineTest() { + Events events = EngineTestKit.engine("junit-jupiter") + .selectors( + selectClass(PinningTestCase.class), + selectClass(PinningExtraThreadTestCase.class), + selectClass(NoPinningTestCase.class), + selectClass(NoPinningExtraThreadTestCase.class) + ) + .execute() + .containerEvents() + .assertStatistics(stats -> stats + .failed(2) + .succeeded(3)); + + events.failed() + .assertEventsMatchExactly( + event(displayClass(PinningTestCase.class), failedWithPinningException("lambda$routing$0")), + event(displayClass(PinningExtraThreadTestCase.class), failedWithPinningException("lambda$pinningTest$0")) + ); + } + + private Condition failedWithPinningException(String expectedPinningMethodName) { + return finishedWithFailure( + instanceOf(PinningAssertionError.class), + message(m -> m.startsWith("Pinned virtual threads were detected")) + , new Condition<>( + t -> Arrays.stream(t.getStackTrace()) + .anyMatch(e -> e.getMethodName() + .equals(expectedPinningMethodName)), + "Method with pinning is missing from stack strace.") + ); + } + + private Condition displayClass(Class clazz) { + return displayName(Arrays.stream(clazz.getName().split("\\.")).toList().getLast()); + } + + @ServerTest(pinningDetection = true) + static class PinningTestCase { + + static final Object monitor = new Object(); + + @SetUpRoute + static void routing(HttpRouting.Builder router) { + router.get("/pinning", (req, res) -> { + synchronized (monitor) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + res.send("pinning"); + }); + } + + @Test + void pinningTest(WebClient target) { + target.get("/pinning") + .request(String.class); + } + } + + @ServerTest(pinningDetection = true) + static class PinningExtraThreadTestCase { + + @Test + void pinningTest() throws InterruptedException { + Thread.ofVirtual().start(() -> { + synchronized (this) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + }).join(); + } + } + + @ServerTest(pinningDetection = true) + static class PinningDisabledExtraThreadTestCase { + + @Test + void pinningTest() throws InterruptedException { + Thread.ofVirtual().start(() -> { + synchronized (this) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + }).join(); + } + } + + @ServerTest(pinningDetection = true) + static class NoPinningTestCase { + + static final Object monitor = new Object(); + + @SetUpRoute + static void routing(HttpRouting.Builder router) { + router.get("/pinning", (req, res) -> { + synchronized (monitor) { + try { + Thread.sleep(5); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + res.send("NO PINNING!"); + }); + } + + @Test + void pinningTest(WebClient target) { + Assertions.assertEquals("NO PINNING!", target.get("/pinning") + .request(String.class)); + } + } + + @ServerTest(pinningDetection = true) + static class NoPinningExtraThreadTestCase { + + @Test + void pinningTest() throws InterruptedException { + Thread.ofVirtual().start(() -> { + synchronized (this) { + try { + Thread.sleep(5); + } catch (InterruptedException e) { + Thread.interrupted(); + } } - } - }).join(); + }).join(); + } } }