diff --git a/core/src/main/java/io/micronaut/core/util/NativeImageUtils.java b/core/src/main/java/io/micronaut/core/util/NativeImageUtils.java
new file mode 100644
index 0000000000..9c5f98f191
--- /dev/null
+++ b/core/src/main/java/io/micronaut/core/util/NativeImageUtils.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2017-2024 original authors
+ *
+ * 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
+ *
+ * https://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.micronaut.core.util;
+
+import io.micronaut.core.annotation.Internal;
+
+/**
+ * Utility class to retrieve information about the context in which code gets executed.
+ * Partial fork of {@code org.graalvm.nativeimage.ImageInfo} to avoid a dependency on {@code org.graalvm.sdk:nativeimage}.
+ *
+ * @since 4.8.0
+ */
+@Internal
+public final class NativeImageUtils {
+ /**
+ * Holds the string that is the name of the system property providing information about the
+ * context in which code is currently executing. If the property returns the string given by
+ * {@link #PROPERTY_IMAGE_CODE_VALUE_BUILDTIME} the code is executing in the context of image
+ * building (e.g. in a static initializer of a class that will be contained in the image). If
+ * the property returns the string given by {@link #PROPERTY_IMAGE_CODE_VALUE_RUNTIME} the code
+ * is executing at image runtime. Otherwise, the property is not set.
+ */
+ public static final String PROPERTY_IMAGE_CODE_KEY = "org.graalvm.nativeimage.imagecode";
+
+ /**
+ * Holds the string that will be returned by the system property for
+ * {@link NativeImageUtils#PROPERTY_IMAGE_CODE_KEY} if code is executing in the context of image
+ * building (e.g. in a static initializer of class that will be contained in the image).
+ */
+ public static final String PROPERTY_IMAGE_CODE_VALUE_BUILDTIME = "buildtime";
+
+ /**
+ * Holds the string that will be returned by the system property for
+ * {@link NativeImageUtils#PROPERTY_IMAGE_CODE_KEY} if code is executing at image runtime.
+ */
+ public static final String PROPERTY_IMAGE_CODE_VALUE_RUNTIME = "runtime";
+
+ private NativeImageUtils() {
+ }
+
+ /**
+ * Returns true if (at the time of the call) code is executing in the context of image building
+ * or during image runtime, else false. This method will be const-folded so that it can be used
+ * to hide parts of an application that only work when running on the JVM. For example:
+ * {@code if (!ImageInfo.inImageCode()) { ... JVM specific code here ... }}
+ * @return true if (at the time of the call) code is executing in the context of image building or during image runtime, else false
+ */
+ public static boolean inImageCode() {
+ return inImageBuildtimeCode() || inImageRuntimeCode();
+ }
+
+ /**
+ * Returns true if (at the time of the call) code is executing at image runtime. This method
+ * will be const-folded. It can be used to hide parts of an application that only work when
+ * running as native image.
+ * @return true if (at the time of the call) code is executing at image runtime.
+ */
+ public static boolean inImageRuntimeCode() {
+ return PROPERTY_IMAGE_CODE_VALUE_RUNTIME.equals(System.getProperty(PROPERTY_IMAGE_CODE_KEY));
+ }
+
+ /**
+ * Returns true if (at the time of the call) code is executing in the context of image building
+ * (e.g. in a static initializer of class that will be contained in the image).
+ * @return true if (at the time of the call) code is executing in the context of image building
+ */
+ public static boolean inImageBuildtimeCode() {
+ return PROPERTY_IMAGE_CODE_VALUE_BUILDTIME.equals(System.getProperty(PROPERTY_IMAGE_CODE_KEY));
+ }
+}
diff --git a/core/src/test/java/io/micronaut/core/util/NativeImageUtilsInNativeImageTest.java b/core/src/test/java/io/micronaut/core/util/NativeImageUtilsInNativeImageTest.java
new file mode 100644
index 0000000000..fc07b1fba6
--- /dev/null
+++ b/core/src/test/java/io/micronaut/core/util/NativeImageUtilsInNativeImageTest.java
@@ -0,0 +1,17 @@
+package io.micronaut.core.util;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledInNativeImage;
+import org.junit.jupiter.api.condition.EnabledInNativeImage;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class NativeImageUtilsInNativeImageTest {
+
+ @EnabledInNativeImage
+ @Test
+ void testInImageCode() {
+ assertTrue(NativeImageUtils.inImageCode());
+ }
+}
\ No newline at end of file
diff --git a/core/src/test/java/io/micronaut/core/util/NativeImageUtilsTest.java b/core/src/test/java/io/micronaut/core/util/NativeImageUtilsTest.java
new file mode 100644
index 0000000000..acfcdfee50
--- /dev/null
+++ b/core/src/test/java/io/micronaut/core/util/NativeImageUtilsTest.java
@@ -0,0 +1,17 @@
+package io.micronaut.core.util;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledInNativeImage;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+class NativeImageUtilsTest {
+
+ @DisabledInNativeImage
+ @Test
+ void testInImageCode() {
+ assertFalse(NativeImageUtils.inImageCode());
+ assertFalse(NativeImageUtils.inImageRuntimeCode());
+ assertFalse(NativeImageUtils.inImageBuildtimeCode());
+ }
+}
\ No newline at end of file
diff --git a/inject/src/main/java/io/micronaut/context/condition/NotInNativeImage.java b/inject/src/main/java/io/micronaut/context/condition/NotInNativeImage.java
new file mode 100644
index 0000000000..e0d4807cf2
--- /dev/null
+++ b/inject/src/main/java/io/micronaut/context/condition/NotInNativeImage.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2017-2024 original authors
+ *
+ * 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
+ *
+ * https://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.micronaut.context.condition;
+
+import io.micronaut.core.util.NativeImageUtils;
+
+/**
+ * Condition to hide parts of an application that only work when running on the JVM.
+ * Internal implementation is identical to {@code if (!ImageInfo.inImageCode()).
+ * @author Sergio del Amo
+ * @since 4.8.0
+ */
+public class NotInNativeImage implements Condition {
+ @Override
+ public boolean matches(ConditionContext context) {
+ return !NativeImageUtils.inImageCode();
+ }
+}
diff --git a/management/build.gradle.kts b/management/build.gradle.kts
index bf8d32778f..de7a28b082 100644
--- a/management/build.gradle.kts
+++ b/management/build.gradle.kts
@@ -4,6 +4,7 @@ plugins {
dependencies {
annotationProcessor(projects.micronautInjectJava)
+ testAnnotationProcessor(projects.micronautInjectJava)
annotationProcessor(projects.micronautGraal)
api(projects.micronautRouter)
@@ -19,7 +20,6 @@ dependencies {
exclude(group = "io.micronaut")
}
compileOnly(projects.micronautHttpClientCore)
-
implementation(libs.managed.reactor)
testImplementation(projects.micronautHttpClient)
@@ -38,5 +38,5 @@ dependencies {
compileOnly(libs.logback.classic)
compileOnly(libs.log4j)
-
+ testImplementation(libs.awaitility)
}
diff --git a/management/src/main/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicator.java b/management/src/main/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicator.java
new file mode 100644
index 0000000000..592a1cddac
--- /dev/null
+++ b/management/src/main/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicator.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2017-2024 original authors
+ *
+ * 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
+ *
+ * https://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.micronaut.management.health.indicator.threads;
+
+import io.micronaut.context.annotation.Requires;
+import io.micronaut.context.condition.NotInNativeImage;
+import io.micronaut.core.annotation.Internal;
+import io.micronaut.core.util.StringUtils;
+import io.micronaut.health.HealthStatus;
+import io.micronaut.management.endpoint.health.HealthEndpoint;
+import io.micronaut.management.health.indicator.AbstractHealthIndicator;
+import io.micronaut.management.health.indicator.HealthIndicator;
+import io.micronaut.management.health.indicator.annotation.Liveness;
+import jakarta.inject.Singleton;
+
+import java.lang.management.ManagementFactory;
+import java.lang.management.MonitorInfo;
+import java.lang.management.ThreadInfo;
+import java.lang.management.ThreadMXBean;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ *
A {@link HealthIndicator} that uses the {@link ThreadMXBean} to check for deadlocked threads.
+ * Returns {@link HealthStatus#DOWN} if any are found and their {@link ThreadInfo} in the details.
+ *
+ * @author Andreas Brenk
+ * @since 4.8.0
+ */
+@Singleton
+@Liveness
+@Requires(condition = NotInNativeImage.class)
+@Requires(property = HealthEndpoint.PREFIX + ".deadlocked-threads.enabled", notEquals = StringUtils.FALSE)
+@Requires(beans = HealthEndpoint.class)
+@Internal
+class DeadlockedThreadsHealthIndicator extends AbstractHealthIndicator {
+
+ private static final String NAME = "deadlockedThreads";
+ private static final String KEY_THREAD_ID = "threadId";
+ private static final String KEY_THREAD_NAME = "threadName";
+ private static final String KEY_THREAD_STATE = "threadState";
+ private static final String KEY_DAEMON = "daemon";
+ private static final String KEY_PRIORITY = "priority";
+ private static final String KEY_SUSPENDED = "suspended";
+ private static final String KEY_IN_NATIVE = "inNative";
+ private static final String KEY_LOCK_NAME = "lockName";
+ private static final String KEY_LOCK_OWNER_NAME = "lockOwnerName";
+ private static final String KEY_LOCK_OWNER_ID = "lockOwnerId";
+ private static final String KEY_LOCKED_SYNCHRONIZERS = "lockedSynchronizers";
+ private static final String KEY_STACK_TRACE = "stackTrace";
+
+ @Override
+ protected Object getHealthInformation() {
+ ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
+ long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
+ if (deadlockedThreads == null) {
+ this.healthStatus = HealthStatus.UP;
+ return null;
+ }
+ this.healthStatus = HealthStatus.DOWN;
+ return Arrays.stream(threadMXBean.getThreadInfo(deadlockedThreads, true, true, Integer.MAX_VALUE))
+ .map(DeadlockedThreadsHealthIndicator::getDetails)
+ .toList();
+ }
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ private static Map getDetails(ThreadInfo threadInfo) {
+ Map details = new LinkedHashMap<>();
+ details.put(KEY_THREAD_ID, String.valueOf(threadInfo.getThreadId()));
+ details.put(KEY_THREAD_NAME, threadInfo.getThreadName());
+ details.put(KEY_THREAD_STATE, threadInfo.getThreadState().name());
+ details.put(KEY_DAEMON, String.valueOf(threadInfo.isDaemon()));
+ details.put(KEY_PRIORITY, String.valueOf(threadInfo.getPriority()));
+ details.put(KEY_SUSPENDED, String.valueOf(threadInfo.isSuspended()));
+ details.put(KEY_IN_NATIVE, String.valueOf(threadInfo.isInNative()));
+ details.put(KEY_LOCK_NAME, threadInfo.getLockName());
+ details.put(KEY_LOCK_OWNER_NAME, threadInfo.getLockOwnerName());
+ details.put(KEY_LOCK_OWNER_ID, String.valueOf(threadInfo.getLockOwnerId()));
+ details.put(KEY_LOCKED_SYNCHRONIZERS, Arrays.stream(threadInfo.getLockedSynchronizers()).map(String::valueOf).toList());
+ details.put(KEY_STACK_TRACE, formatStackTrace(threadInfo));
+ return details;
+ }
+
+ private static String formatStackTrace(ThreadInfo threadInfo) {
+ StringBuilder sb = new StringBuilder();
+
+ int i = 0;
+ StackTraceElement[] stackTrace = threadInfo.getStackTrace();
+ for (; i < stackTrace.length; i++) {
+ StackTraceElement ste = stackTrace[i];
+ sb.append(ste.toString());
+ sb.append('\n');
+
+ if (i == 0 && threadInfo.getLockInfo() != null) {
+ switch (threadInfo.getThreadState()) {
+ case BLOCKED:
+ sb.append("- blocked on ");
+ sb.append(threadInfo.getLockInfo());
+ sb.append('\n');
+ break;
+ case WAITING, TIMED_WAITING:
+ sb.append("- waiting on ");
+ sb.append(threadInfo.getLockInfo());
+ sb.append('\n');
+ break;
+ default:
+ }
+ }
+
+ for (MonitorInfo mi : threadInfo.getLockedMonitors()) {
+ if (mi.getLockedStackDepth() == i) {
+ sb.append("- locked ");
+ sb.append(mi);
+ sb.append('\n');
+ }
+ }
+ }
+
+ return sb.toString();
+ }
+}
diff --git a/management/src/main/java/io/micronaut/management/health/indicator/threads/package-info.java b/management/src/main/java/io/micronaut/management/health/indicator/threads/package-info.java
new file mode 100644
index 0000000000..262aecb795
--- /dev/null
+++ b/management/src/main/java/io/micronaut/management/health/indicator/threads/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2017-2024 original authors
+ *
+ * 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
+ *
+ * https://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.
+ */
+/**
+ * Thread deadlock indicator.
+ *
+ * @author Andreas Brenk
+ * @since 4.8.0
+ */
+package io.micronaut.management.health.indicator.threads;
diff --git a/management/src/test/groovy/io/micronaut/management/health/aggregator/HealthAggregatorSpec.groovy b/management/src/test/groovy/io/micronaut/management/health/aggregator/HealthAggregatorSpec.groovy
index 64701dedb3..d842891df7 100644
--- a/management/src/test/groovy/io/micronaut/management/health/aggregator/HealthAggregatorSpec.groovy
+++ b/management/src/test/groovy/io/micronaut/management/health/aggregator/HealthAggregatorSpec.groovy
@@ -43,24 +43,26 @@ class HealthAggregatorSpec extends Specification {
assert appender.events.isEmpty()
break
case Level.DEBUG:
- assert appender.events.size() == 7
+ assert appender.events.size() == 8
assert appender.events[0] == 'Health result for compositeDiscoveryClient(): status UP'
- assert appender.events[1] == 'Health result for diskSpace: status UP'
- assert appender.events[2] == 'Health result for jdbc: status UP'
- assert appender.events[3] == 'Health result for jdbc:h2:mem:oneDb: status UP'
- assert appender.events[4] == 'Health result for liveness: status UP'
- assert appender.events[5] == 'Health result for readiness: status UP'
- assert appender.events[6] == 'Health result for service: status UP'
+ assert appender.events[1] == 'Health result for deadlockedThreads: status UP'
+ assert appender.events[2] == 'Health result for diskSpace: status UP'
+ assert appender.events[3] == 'Health result for jdbc: status UP'
+ assert appender.events[4] == 'Health result for jdbc:h2:mem:oneDb: status UP'
+ assert appender.events[5] == 'Health result for liveness: status UP'
+ assert appender.events[6] == 'Health result for readiness: status UP'
+ assert appender.events[7] == 'Health result for service: status UP'
break
case Level.TRACE:
- assert appender.events.size() == 7
+ assert appender.events.size() == 8
assert appender.events[0].contains('Health result for compositeDiscoveryClient(): status UP, details {')
- assert appender.events[1].contains('Health result for diskSpace: status UP, details {')
- assert appender.events[2].contains('Health result for jdbc: status UP, details {')
- assert appender.events[3].contains('Health result for jdbc:h2:mem:oneDb: status UP, details {')
- assert appender.events[4] == 'Health result for liveness: status UP, details {}'
- assert appender.events[5] == 'Health result for readiness: status UP, details {}'
- assert appender.events[6] == 'Health result for service: status UP, details {}'
+ assert appender.events[1] == 'Health result for deadlockedThreads: status UP, details {}'
+ assert appender.events[2].contains('Health result for diskSpace: status UP, details {')
+ assert appender.events[3].contains('Health result for jdbc: status UP, details {')
+ assert appender.events[4].contains('Health result for jdbc:h2:mem:oneDb: status UP, details {')
+ assert appender.events[5] == 'Health result for liveness: status UP, details {}'
+ assert appender.events[6] == 'Health result for readiness: status UP, details {}'
+ assert appender.events[7] == 'Health result for service: status UP, details {}'
break
}
diff --git a/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorConfigurationSpec.groovy b/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorConfigurationSpec.groovy
new file mode 100644
index 0000000000..2967446473
--- /dev/null
+++ b/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorConfigurationSpec.groovy
@@ -0,0 +1,22 @@
+package io.micronaut.management.health.indicator.threads
+
+import io.micronaut.context.ApplicationContext
+import io.micronaut.core.util.StringUtils
+import io.micronaut.management.health.indicator.discovery.DiscoveryClientHealthIndicator
+import io.micronaut.management.health.indicator.discovery.DiscoveryClientHealthIndicatorConfiguration
+import spock.lang.Specification
+
+class DeadlockedThreadsHealthIndicatorConfigurationSpec extends Specification {
+
+ void "bean of type DeadlockedThreadsHealthIndicator does not exist if you set endpoints.health.deadlocked-threads.enabled=false"() {
+ given:
+ Map conf = ['endpoints.health.deadlocked-threads.enabled': StringUtils.FALSE]
+ ApplicationContext applicationContext = ApplicationContext.run(conf)
+
+ expect:
+ !applicationContext.containsBean(DeadlockedThreadsHealthIndicator)
+
+ cleanup:
+ applicationContext.close()
+ }
+}
diff --git a/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorSpec.groovy b/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorSpec.groovy
new file mode 100644
index 0000000000..ad3e3e1658
--- /dev/null
+++ b/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorSpec.groovy
@@ -0,0 +1,79 @@
+package io.micronaut.management.health.indicator.threads
+
+import io.micronaut.context.ApplicationContext
+import io.micronaut.health.HealthStatus
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import reactor.core.publisher.Mono
+import spock.lang.Specification
+
+import static java.lang.Thread.sleep
+
+class DeadlockedThreadsHealthIndicatorSpec extends Specification {
+
+ Logger log = LoggerFactory.getLogger(DeadlockedThreadsHealthIndicatorSpec)
+
+ def "No deadlocked threads so status is UP"() {
+ given:
+ ApplicationContext applicationContext = ApplicationContext.run()
+ Thread thread1 = new Thread()
+ Thread thread2 = new Thread()
+ DeadlockedThreadsHealthIndicator healthIndicator = applicationContext.getBean(DeadlockedThreadsHealthIndicator)
+ when:
+ thread1.start()
+ thread2.start()
+ def result = Mono.from(healthIndicator.getResult()).block()
+
+ then:
+ HealthStatus.UP == result.status
+ null == result.details
+
+ cleanup:
+ applicationContext.close()
+ }
+
+ def "Deadlocked threads found so status is DOWN"() {
+ given:
+ ApplicationContext applicationContext = ApplicationContext.run()
+ Object lock1 = new Object()
+ Object lock2 = new Object()
+ Thread thread1 = new Thread(() -> {
+ synchronized (lock1) {
+ log.debug "Thread 1: Holding lock 1"
+
+ sleep 200
+
+ synchronized (lock2) {
+ log.debug "Thread 1: Holding lock 1 and lock 2"
+ }
+ }
+ })
+ Thread thread2 = new Thread(() -> {
+ synchronized (lock2) {
+ log.debug "Thread 2: Holding lock 2"
+
+ sleep 100
+
+ synchronized (lock1) {
+ log.debug "Thread 2: Holding lock 2 and lock 1"
+ }
+ }
+ })
+ DeadlockedThreadsHealthIndicator healthIndicator = applicationContext.getBean(DeadlockedThreadsHealthIndicator)
+
+ when:
+ thread1.start()
+ thread2.start()
+
+ Thread.sleep(300)
+
+ def result = Mono.from(healthIndicator.getResult()).block()
+
+ then:
+ HealthStatus.DOWN == result.status
+ null != result.details
+
+ cleanup:
+ applicationContext.close()
+ }
+}
diff --git a/management/src/test/groovy/io/micronaut/management/health/monitor/HealthMonitorTaskSpec.groovy b/management/src/test/groovy/io/micronaut/management/health/monitor/HealthMonitorTaskSpec.groovy
index 43af8aad63..bebe663ff2 100644
--- a/management/src/test/groovy/io/micronaut/management/health/monitor/HealthMonitorTaskSpec.groovy
+++ b/management/src/test/groovy/io/micronaut/management/health/monitor/HealthMonitorTaskSpec.groovy
@@ -6,6 +6,7 @@ import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.AppenderBase
import io.micronaut.context.ApplicationContext
import io.micronaut.context.annotation.Requires
+import io.micronaut.core.util.StringUtils
import io.micronaut.runtime.server.EmbeddedServer
import org.slf4j.LoggerFactory
import spock.lang.Specification
@@ -29,7 +30,8 @@ class HealthMonitorTaskSpec extends Specification {
'spec.name' : 'HealthMonitorTask',
'micronaut.application.name': 'foo',
'micronaut.health.monitor.enabled': true,
- 'endpoints.health.sensitive': false
+ 'endpoints.health.sensitive': false,
+ 'endpoints.health.deadlocked-threads.enabled': StringUtils.FALSE
])
PollingConditions conditions = new PollingConditions(timeout: 7)
diff --git a/management/src/test/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorTest.java b/management/src/test/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorTest.java
new file mode 100644
index 0000000000..697ba806e7
--- /dev/null
+++ b/management/src/test/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorTest.java
@@ -0,0 +1,125 @@
+package io.micronaut.management.health.indicator.threads;
+
+import io.micronaut.context.ApplicationContext;
+import io.micronaut.context.annotation.Context;
+import io.micronaut.context.annotation.Requires;
+import io.micronaut.context.event.StartupEvent;
+import io.micronaut.core.type.Argument;
+import io.micronaut.health.HealthStatus;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpStatus;
+import io.micronaut.http.client.BlockingHttpClient;
+import io.micronaut.http.client.HttpClient;
+import io.micronaut.http.client.exceptions.HttpClientResponseException;
+import io.micronaut.management.endpoint.health.DetailsVisibility;
+import io.micronaut.management.endpoint.health.HealthLevelOfDetail;
+import io.micronaut.runtime.server.EmbeddedServer;
+import jakarta.inject.Singleton;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.*;
+
+class DeadlockedThreadsHealthIndicatorTest {
+
+ @Test
+ void testDeadlockedThreadsHealthIndicator() {
+ Map configuration = Map.of(
+ "spec.name", "DeadlockedThreadsHealthIndicatorTest",
+ "endpoints.health.details-visible", DetailsVisibility.ANONYMOUS
+ );
+ try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, configuration)) {
+ try (HttpClient httpClient = server.getApplicationContext().createBean(HttpClient.class, server.getURL())) {
+ BlockingHttpClient client = httpClient.toBlocking();
+ await().until(() -> isDown(client));
+ Argument ok = Argument.of(Map.class);
+ Argument notOk = Argument.of(Map.class);
+ HttpClientResponseException ex = assertThrows(HttpClientResponseException.class,
+ () -> client.exchange(HttpRequest.GET("/health"), ok, notOk));
+ assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatus());
+ Optional