diff --git a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java index 676e9cdc6c..fefc6938b5 100644 --- a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java +++ b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/AbstractSurefireMojo.java @@ -882,6 +882,33 @@ public abstract class AbstractSurefireMojo extends AbstractMojo implements Suref @Parameter private Map jdkToolchain; + /** + * Configuration map passed to every registered + * {@link org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension} + * via + * {@link org.apache.maven.surefire.extensions.ForkedProcessTimeoutContext#getExtensionContext()}. + *
+ * Extension implementations read implementation-specific keys from this + * map. For instance the built-in jstack extension honors the + * {@code jstack.output.location} key (a directory path where the + * {@code surefire-timeout-jstack-*.txt} files are written). + *
+ * Example: + *
+     * {@code
+     *    
+     *        
+     *            ${project.build.directory}/jstacks
+     *        
+     *    
+     *    }
+     * 
+ * + * @since 3.6.0 + */ + @Parameter + private Map forkedProcessTimeoutExtensionContext; + @Inject private ToolchainManager toolchainManager; @@ -2371,7 +2398,8 @@ private ForkStarter createForkStarter( forkConfiguration, getForkedProcessTimeoutInSeconds(), startupReportConfiguration, - log); + log, + forkedProcessTimeoutExtensionContext); } private InPluginVMSurefireStarter createInprocessStarter( diff --git a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ForkStarter.java b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ForkStarter.java index f79d5cc1e3..4c5100f85d 100644 --- a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ForkStarter.java +++ b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ForkStarter.java @@ -24,6 +24,8 @@ import java.io.File; import java.io.IOException; import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; import java.util.Map; import java.util.Queue; import java.util.Set; @@ -53,6 +55,8 @@ import org.apache.maven.plugin.surefire.booterclient.output.InPluginProcessDumpSingleton; import org.apache.maven.plugin.surefire.booterclient.output.NativeStdErrStreamConsumer; import org.apache.maven.plugin.surefire.booterclient.output.ThreadedStreamConsumer; +import org.apache.maven.plugin.surefire.extensions.timeout.DefaultForkedProcessTimeoutContext; +import org.apache.maven.plugin.surefire.extensions.timeout.TimeoutExtensionDispatcher; import org.apache.maven.plugin.surefire.log.api.ConsoleLogger; import org.apache.maven.plugin.surefire.report.DefaultReporterFactory; import org.apache.maven.plugin.surefire.report.ReportsMerger; @@ -72,6 +76,7 @@ import org.apache.maven.surefire.extensions.EventHandler; import org.apache.maven.surefire.extensions.ForkChannel; import org.apache.maven.surefire.extensions.ForkNodeFactory; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutContext; import org.apache.maven.surefire.extensions.Stoppable; import org.apache.maven.surefire.extensions.util.CommandlineExecutor; import org.apache.maven.surefire.extensions.util.CommandlineStreams; @@ -103,6 +108,7 @@ import static org.apache.maven.surefire.api.util.internal.DaemonThreadFactory.newDaemonThreadFactory; import static org.apache.maven.surefire.api.util.internal.StringUtils.NL; import static org.apache.maven.surefire.booter.SystemPropertyManager.writePropertiesFile; +import static org.apache.maven.surefire.booter.SystemUtils.pidOf; import static org.apache.maven.surefire.shared.utils.cli.ShutdownHookUtils.addShutDownHook; import static org.apache.maven.surefire.shared.utils.cli.ShutdownHookUtils.removeShutdownHook; @@ -145,6 +151,10 @@ public class ForkStarter { private final int forkedProcessTimeoutInSeconds; + private final TimeoutExtensionDispatcher timeoutExtensionDispatcher; + + private final Map forkedProcessTimeoutExtensionContext; + private final ProviderConfiguration providerConfiguration; private final StartupConfiguration startupConfiguration; @@ -227,17 +237,39 @@ public ForkStarter( int forkedProcessTimeoutInSeconds, StartupReportConfiguration startupReportConfiguration, ConsoleLogger log) { + this( + providerConfiguration, + startupConfiguration, + forkConfiguration, + forkedProcessTimeoutInSeconds, + startupReportConfiguration, + log, + null); + } + + public ForkStarter( + ProviderConfiguration providerConfiguration, + StartupConfiguration startupConfiguration, + ForkConfiguration forkConfiguration, + int forkedProcessTimeoutInSeconds, + StartupReportConfiguration startupReportConfiguration, + ConsoleLogger log, + Map forkedProcessTimeoutExtensionContext) { this.forkConfiguration = forkConfiguration; this.providerConfiguration = providerConfiguration; this.forkedProcessTimeoutInSeconds = forkedProcessTimeoutInSeconds; this.startupConfiguration = startupConfiguration; this.startupReportConfiguration = startupReportConfiguration; this.log = log; + this.forkedProcessTimeoutExtensionContext = forkedProcessTimeoutExtensionContext == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(forkedProcessTimeoutExtensionContext)); reportMerger = new DefaultReporterFactory(startupReportConfiguration, log); reportMerger.runStarting(); defaultReporterFactories = new ConcurrentLinkedQueue<>(); currentForkClients = new ConcurrentLinkedQueue<>(); timeoutCheckScheduler = createTimeoutCheckScheduler(); + timeoutExtensionDispatcher = new TimeoutExtensionDispatcher(log); triggerTimeoutCheck(); } @@ -252,6 +284,7 @@ public RunResult run(@Nonnull SurefireProperties effectiveSystemProperties, @Non reportMerger.close(); pingThreadScheduler.shutdownNow(); timeoutCheckScheduler.shutdownNow(); + timeoutExtensionDispatcher.close(); for (String line : logsAtEnd) { log.warning(line); } @@ -549,6 +582,7 @@ private RunResult fork( currentForkClients.add(forkClient); CountdownCloseable countdownCloseable = new CountdownCloseable(eventConsumer, forkChannel.getCountdownCloseablePermits()); + final ForkedProcessTimeoutContext[] timeoutContextHolder = new ForkedProcessTimeoutContext[1]; try (CommandlineExecutor exec = new CommandlineExecutor(cli, countdownCloseable)) { forkChannel.tryConnectToClient(); CommandlineStreams streams = exec.execute(); @@ -562,6 +596,21 @@ private RunResult fork( log.debug("Fork Channel [" + forkNumber + "] connected to the client."); + if (timeoutExtensionDispatcher.hasExtensions() && forkedProcessTimeoutInSeconds > 0) { + Long pid = pidOf(exec.getProcess()); + File javaExecutable = cli.getExecutable() == null ? null : new File(cli.getExecutable()); + final ForkedProcessTimeoutContext context = new DefaultForkedProcessTimeoutContext( + pid == null ? -1L : pid, + forkNumber, + javaExecutable, + startupReportConfiguration.getReportsDirectory(), + forkedProcessTimeoutInSeconds, + log, + forkedProcessTimeoutExtensionContext); + timeoutContextHolder[0] = context; + forkClient.setTimeoutDetectedListener(() -> timeoutExtensionDispatcher.fireTimeoutDetected(context)); + } + result = exec.awaitExit(); if (forkClient.hadTimeout()) { @@ -595,6 +644,10 @@ private RunResult fork( } forkClient.close(runResult.isTimeout()); + if (runResult.isTimeout() && timeoutContextHolder[0] != null) { + timeoutExtensionDispatcher.fireForkExited(timeoutContextHolder[0], runResult); + } + if (!runResult.isTimeout()) { Throwable cause = booterForkException == null ? null : booterForkException.getCause(); String detail = booterForkException == null ? "" : "\n" + booterForkException.getMessage(); diff --git a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/output/ForkClient.java b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/output/ForkClient.java index 34af87db04..b164e4f641 100644 --- a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/output/ForkClient.java +++ b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/output/ForkClient.java @@ -87,6 +87,14 @@ public final class ForkClient implements EventHandler { private volatile StackTraceWriter errorInFork; + /** + * Optional callback invoked once from {@link #tryToTimeout} when the + * forked-process timeout has been reached, immediately before the KILL + * shutdown command is sent. Used by Surefire to dispatch + * {@code ForkedProcessTimeoutExtension#onTimeoutDetected} callbacks. + */ + private volatile Runnable timeoutDetectedListener; + public ForkClient( DefaultReporterFactory defaultReporterFactory, NotifiableTestStream notifiableTestStream, int forkNumber) { this.defaultReporterFactory = defaultReporterFactory; @@ -116,6 +124,19 @@ public void setStopOnNextTestListener(ForkedProcessEventListener listener) { notifier.setStopOnNextTestListener(listener); } + /** + * Registers a one-shot callback invoked synchronously when the forked + * process timeout has been reached, immediately before the KILL command is + * sent. The forked JVM is still alive when the callback runs, so it may + * collect live diagnostics (for example a {@code jstack} dump). + * + * @param listener the callback to run, or {@code null} to clear; callback + * must not throw checked exceptions + */ + public void setTimeoutDetectedListener(Runnable listener) { + this.timeoutDetectedListener = listener; + } + private final class TestSetStartingListener implements ForkedProcessReportEventListener { @Override public void handle(TestSetReportEntry reportEntry) { @@ -288,8 +309,17 @@ public void tryToTimeout(long currentTimeMillis, int forkedProcessTimeoutInSecon final long forkedProcessTimeoutInMillis = 1000L * forkedProcessTimeoutInSeconds; final long startedAt = testSetStartedAt.get(); if (startedAt > START_TIME_ZERO && currentTimeMillis - startedAt >= forkedProcessTimeoutInMillis) { - testSetStartedAt.set(START_TIME_NEGATIVE_TIMEOUT); - notifiableTestStream.shutdown(KILL); + if (testSetStartedAt.compareAndSet(startedAt, START_TIME_NEGATIVE_TIMEOUT)) { + Runnable listener = timeoutDetectedListener; + if (listener != null) { + try { + listener.run(); + } catch (RuntimeException ignored) { + // listener failures must never prevent the kill + } + } + notifiableTestStream.shutdown(KILL); + } } } } diff --git a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/extensions/timeout/DefaultForkedProcessTimeoutContext.java b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/extensions/timeout/DefaultForkedProcessTimeoutContext.java new file mode 100644 index 0000000000..2979a01eb6 --- /dev/null +++ b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/extensions/timeout/DefaultForkedProcessTimeoutContext.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.plugin.surefire.extensions.timeout; + +import java.io.File; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.apache.maven.plugin.surefire.log.api.ConsoleLogger; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutContext; + +/** + * Default immutable {@link ForkedProcessTimeoutContext} implementation. + */ +public final class DefaultForkedProcessTimeoutContext implements ForkedProcessTimeoutContext { + + private final long pid; + private final int forkNumber; + private final File javaExecutable; + private final File reportsDirectory; + private final int timeoutSeconds; + private final ConsoleLogger logger; + private final Map extensionContext; + + public DefaultForkedProcessTimeoutContext( + long pid, + int forkNumber, + File javaExecutable, + File reportsDirectory, + int timeoutSeconds, + ConsoleLogger logger) { + this(pid, forkNumber, javaExecutable, reportsDirectory, timeoutSeconds, logger, null); + } + + public DefaultForkedProcessTimeoutContext( + long pid, + int forkNumber, + File javaExecutable, + File reportsDirectory, + int timeoutSeconds, + ConsoleLogger logger, + Map extensionContext) { + this.pid = pid; + this.forkNumber = forkNumber; + this.javaExecutable = javaExecutable; + this.reportsDirectory = reportsDirectory; + this.timeoutSeconds = timeoutSeconds; + this.logger = logger; + this.extensionContext = extensionContext == null || extensionContext.isEmpty() + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(extensionContext)); + } + + @Override + public long getPid() { + return pid; + } + + @Override + public int getForkNumber() { + return forkNumber; + } + + @Override + public File getJavaExecutable() { + return javaExecutable; + } + + @Override + public File getReportsDirectory() { + return reportsDirectory; + } + + @Override + public int getTimeoutSeconds() { + return timeoutSeconds; + } + + @Override + public ConsoleLogger getConsoleLogger() { + return logger; + } + + @Override + public Map getExtensionContext() { + return extensionContext; + } +} diff --git a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/extensions/timeout/JstackTimeoutExtension.java b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/extensions/timeout/JstackTimeoutExtension.java new file mode 100644 index 0000000000..7417b66684 --- /dev/null +++ b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/extensions/timeout/JstackTimeoutExtension.java @@ -0,0 +1,202 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.plugin.surefire.extensions.timeout; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.concurrent.TimeUnit; + +import org.apache.maven.plugin.surefire.log.api.ConsoleLogger; +import org.apache.maven.surefire.api.suite.RunResult; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutContext; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension; +import org.apache.maven.surefire.shared.lang3.SystemUtils; + +/** + * Built-in {@link ForkedProcessTimeoutExtension} that captures a {@code jstack} + * thread dump of the forked test JVM just before it is killed because of + * {@code forkedProcessTimeoutInSeconds}. + *

+ * The output is written to + * {@code /surefire-timeout-jstack--.txt}. + *

+ * Activation: this extension is registered via + * {@code META-INF/services} but is disabled by default. To enable it + * set the system property {@code surefire.timeout.jstack.enabled=true} on the + * Maven process (for example with {@code MAVEN_OPTS} or + * {@code -Dsurefire.timeout.jstack.enabled=true}). The {@code jstack} binary + * is resolved from {@code ${java.home}/bin/jstack}, the parent JDK {@code bin} + * (Java 8 layout), {@code $JAVA_HOME/bin/jstack}, and finally from {@code PATH}. + * + * @since 3.6.0 + */ +public class JstackTimeoutExtension implements ForkedProcessTimeoutExtension { + + /** System property that enables this extension. */ + public static final String ENABLED_PROPERTY = "surefire.timeout.jstack.enabled"; + + /** + * Extension-context key that enables this extension from the POM via the + * {@code forkedProcessTimeoutExtensionContext} Mojo parameter. Set to + * {@code true} to enable. When either this key or {@link #ENABLED_PROPERTY} + * is set to {@code true}, the extension runs. + */ + public static final String ENABLED_KEY = "jstack.enabled"; + + /** + * Extension context key that overrides the directory where the + * {@code surefire-timeout-jstack-*.txt} files are written. When the key is + * missing or blank, the Surefire reports directory is used. + */ + public static final String OUTPUT_LOCATION_KEY = "jstack.output.location"; + + /** Wall-clock timeout for the jstack subprocess. */ + static final int JSTACK_TIMEOUT_SECONDS = 20; + + @Override + public void onTimeoutDetected(ForkedProcessTimeoutContext context) { + ConsoleLogger logger = context.getConsoleLogger(); + if (!isEnabled(context)) { + logger.debug("JstackTimeoutExtension disabled (set -D" + ENABLED_PROPERTY + "=true or POM key " + + ENABLED_KEY + "=true to enable)"); + return; + } + long pid = context.getPid(); + if (pid <= 0L) { + logger.warning("JstackTimeoutExtension: PID of forked JVM unknown (Java 8 or unsupported platform); " + + "skipping jstack for fork " + context.getForkNumber()); + return; + } + File jstack = resolveJstackBinary(); + if (jstack == null) { + logger.warning("JstackTimeoutExtension: cannot find jstack in java.home, JAVA_HOME or PATH; " + + "skipping jstack for fork " + context.getForkNumber() + " (pid=" + pid + ")"); + return; + } + File outputDirectory = resolveOutputDirectory(context, logger); + if (outputDirectory == null) { + return; + } + File output = + new File(outputDirectory, "surefire-timeout-jstack-" + context.getForkNumber() + "-" + pid + ".txt"); + runJstack(jstack, pid, output, logger, context.getForkNumber()); + } + + private static boolean isEnabled(ForkedProcessTimeoutContext context) { + if (Boolean.getBoolean(ENABLED_PROPERTY)) { + return true; + } + String fromCtx = context.getExtensionContext().get(ENABLED_KEY); + return fromCtx != null && Boolean.parseBoolean(fromCtx.trim()); + } + + private static File resolveOutputDirectory(ForkedProcessTimeoutContext context, ConsoleLogger logger) { + String configured = context.getExtensionContext().get(OUTPUT_LOCATION_KEY); + File target; + if (configured != null && !configured.trim().isEmpty()) { + target = new File(configured.trim()); + } else { + target = context.getReportsDirectory(); + } + if (!target.isDirectory() && !target.mkdirs()) { + logger.warning("JstackTimeoutExtension: cannot create output directory " + target.getAbsolutePath()); + return null; + } + return target; + } + + @Override + public void onForkExited(ForkedProcessTimeoutContext context, RunResult runResult) { + // No-op: the diagnostic is captured before the kill. + } + + private void runJstack(File jstack, long pid, File output, ConsoleLogger logger, int forkNumber) { + ProcessBuilder builder = new ProcessBuilder(jstack.getAbsolutePath(), Long.toString(pid)) + .redirectErrorStream(true) + .redirectOutput(output); + Process process = null; + try { + process = builder.start(); + if (!process.waitFor(JSTACK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly(); + logger.warning("JstackTimeoutExtension: jstack for fork " + forkNumber + " (pid=" + pid + + ") did not complete within " + JSTACK_TIMEOUT_SECONDS + "s"); + return; + } + int exit = process.exitValue(); + if (exit != 0) { + logger.warning("JstackTimeoutExtension: jstack exited with code " + exit + " for fork " + forkNumber + + " (pid=" + pid + "); see " + output.getAbsolutePath()); + } else { + logger.info("JstackTimeoutExtension: wrote thread dump for fork " + forkNumber + " (pid=" + pid + + ") to " + output.getAbsolutePath()); + } + } catch (IOException e) { + logger.warning("JstackTimeoutExtension: failed to run jstack for fork " + forkNumber + ": " + e); + } catch (InterruptedException e) { + if (process != null) { + process.destroyForcibly(); + } + Thread.currentThread().interrupt(); + } + } + + static File resolveJstackBinary() { + String exe = SystemUtils.IS_OS_WINDOWS ? "jstack.exe" : "jstack"; + String javaHome = System.getProperty("java.home"); + if (javaHome != null) { + // java.home may point to JRE (Java 8) or JDK (Java 9+); jstack lives in /bin + File candidate = new File(javaHome, "bin/" + exe); + if (isExecutable(candidate)) { + return candidate; + } + // Java 8: /jre/bin/java -> jstack at /bin/jstack + File parent = new File(javaHome).getParentFile(); + if (parent != null) { + File candidate2 = new File(parent, "bin/" + exe); + if (isExecutable(candidate2)) { + return candidate2; + } + } + } + String envJavaHome = System.getenv("JAVA_HOME"); + if (envJavaHome != null) { + File candidate = new File(envJavaHome, "bin/" + exe); + if (isExecutable(candidate)) { + return candidate; + } + } + // Fall back to PATH lookup + String path = System.getenv("PATH"); + if (path != null) { + for (String dir : path.split(File.pathSeparator)) { + File candidate = new File(dir, exe); + if (isExecutable(candidate)) { + return candidate; + } + } + } + return null; + } + + private static boolean isExecutable(File f) { + return f != null && f.isFile() && Files.isExecutable(f.toPath()); + } +} diff --git a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/extensions/timeout/TimeoutExtensionDispatcher.java b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/extensions/timeout/TimeoutExtensionDispatcher.java new file mode 100644 index 0000000000..3827b417ba --- /dev/null +++ b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/extensions/timeout/TimeoutExtensionDispatcher.java @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.plugin.surefire.extensions.timeout; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.ServiceConfigurationError; +import java.util.ServiceLoader; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.maven.plugin.surefire.log.api.ConsoleLogger; +import org.apache.maven.surefire.api.suite.RunResult; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutContext; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension; + +/** + * Discovers and invokes {@link ForkedProcessTimeoutExtension} instances when a + * forked test JVM is killed due to {@code forkedProcessTimeoutInSeconds}. + *

+ * Extensions are loaded once per Surefire run via {@link ServiceLoader} from + * the plugin classloader (the same classloader that loaded the + * {@link ForkedProcessTimeoutExtension} interface). + *

+ * Each extension callback is invoked on an internal executor and capped at + * {@link #PER_CALLBACK_TIMEOUT_SECONDS} seconds; any thrown {@link Throwable} + * or timeout is logged at warn level and never affects the test result. + * + * @since 3.6.0 + */ +public final class TimeoutExtensionDispatcher { + + /** Maximum wall-clock time allowed per extension callback. */ + public static final int PER_CALLBACK_TIMEOUT_SECONDS = 30; + + private final ConsoleLogger logger; + private final List extensions; + private final ExecutorService executor; + + public TimeoutExtensionDispatcher(ConsoleLogger logger) { + this(logger, loadExtensions(logger)); + } + + TimeoutExtensionDispatcher(ConsoleLogger logger, List extensions) { + this.logger = logger; + this.extensions = Collections.unmodifiableList(new ArrayList<>(extensions)); + this.executor = this.extensions.isEmpty() ? null : Executors.newCachedThreadPool(daemonThreadFactory()); + } + + /** + * @return {@code true} when at least one extension is registered + */ + public boolean hasExtensions() { + return !extensions.isEmpty(); + } + + /** + * Synchronously invokes {@link ForkedProcessTimeoutExtension#onTimeoutDetected} + * on every registered extension. Should be called immediately after + * Surefire detects the timeout and before the kill is sent. + */ + public void fireTimeoutDetected(final ForkedProcessTimeoutContext context) { + if (extensions.isEmpty()) { + return; + } + for (final ForkedProcessTimeoutExtension extension : extensions) { + invokeWithTimeout(extension, "onTimeoutDetected", () -> { + extension.onTimeoutDetected(context); + return null; + }); + } + } + + /** + * Synchronously invokes {@link ForkedProcessTimeoutExtension#onForkExited} + * on every registered extension, after the forked JVM exited. + */ + public void fireForkExited(final ForkedProcessTimeoutContext context, final RunResult result) { + if (extensions.isEmpty()) { + return; + } + for (final ForkedProcessTimeoutExtension extension : extensions) { + invokeWithTimeout(extension, "onForkExited", () -> { + extension.onForkExited(context, result); + return null; + }); + } + } + + /** + * Shuts down the internal executor. Safe to call multiple times. + */ + public void close() { + if (executor != null) { + executor.shutdownNow(); + } + } + + private void invokeWithTimeout(ForkedProcessTimeoutExtension extension, String callback, Callable task) { + Future future = executor.submit(task); + try { + future.get(PER_CALLBACK_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + logger.warning("Timeout extension " + extension.getClass().getName() + "#" + callback + " exceeded " + + PER_CALLBACK_TIMEOUT_SECONDS + "s and was cancelled"); + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + logger.warning( + "Timeout extension " + extension.getClass().getName() + "#" + callback + " failed: " + cause); + } catch (InterruptedException e) { + future.cancel(true); + Thread.currentThread().interrupt(); + } + } + + private static List loadExtensions(ConsoleLogger logger) { + List result = new ArrayList<>(); + try { + ServiceLoader loader = ServiceLoader.load( + ForkedProcessTimeoutExtension.class, ForkedProcessTimeoutExtension.class.getClassLoader()); + for (ForkedProcessTimeoutExtension extension : loader) { + result.add(extension); + } + } catch (ServiceConfigurationError | RuntimeException e) { + logger.warning("Failed to load ForkedProcessTimeoutExtension implementations: " + e); + } + return result; + } + + private static ThreadFactory daemonThreadFactory() { + return new ThreadFactory() { + private final AtomicInteger counter = new AtomicInteger(); + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "surefire-timeout-extension-" + counter.incrementAndGet()); + t.setDaemon(true); + return t; + } + }; + } +} diff --git a/maven-surefire-common/src/main/resources/META-INF/services/org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension b/maven-surefire-common/src/main/resources/META-INF/services/org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension new file mode 100644 index 0000000000..f29c46c8d6 --- /dev/null +++ b/maven-surefire-common/src/main/resources/META-INF/services/org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension @@ -0,0 +1 @@ +org.apache.maven.plugin.surefire.extensions.timeout.JstackTimeoutExtension diff --git a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/output/ForkClientTest.java b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/output/ForkClientTest.java index dc98bf5ac6..1c36807cd0 100644 --- a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/output/ForkClientTest.java +++ b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/output/ForkClientTest.java @@ -24,10 +24,14 @@ import java.io.Closeable; import java.io.File; import java.nio.channels.ReadableByteChannel; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.maven.plugin.surefire.booterclient.MockReporter; import org.apache.maven.plugin.surefire.booterclient.lazytestprovider.NotifiableTestStream; @@ -65,6 +69,7 @@ import org.apache.maven.surefire.extensions.EventHandler; import org.apache.maven.surefire.extensions.util.CountdownCloseable; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import static java.nio.channels.Channels.newChannel; import static org.apache.maven.plugin.surefire.booterclient.MockReporter.CONSOLE_DEBUG; @@ -1593,4 +1598,44 @@ synchronized boolean isCalled() { return called; } } + + @Test + public void timeoutListenerInvokedBeforeKillAndOnlyOnce() { + DefaultReporterFactory factory = mock(DefaultReporterFactory.class); + when(factory.getReportsDirectory()).thenReturn(new File(".")); + when(factory.createTestReportListener()).thenReturn(new MockReporter()); + NotifiableTestStream notifiableTestStream = mock(NotifiableTestStream.class); + + TestSetReportEntry reportEntry = mock(TestSetReportEntry.class); + when(reportEntry.getRunMode()).thenReturn(NORMAL_RUN); + when(reportEntry.getTestRunId()).thenReturn(1L); + when(reportEntry.getElapsed()).thenReturn(ELAPSED_TIME); + when(reportEntry.getName()).thenReturn("my test"); + when(reportEntry.getSourceName()).thenReturn("pkg.MyTest"); + + ForkClient client = new ForkClient(factory, notifiableTestStream, 0); + final AtomicInteger listenerCalls = new AtomicInteger(); + final List order = Collections.synchronizedList(new ArrayList<>()); + client.setTimeoutDetectedListener(() -> { + listenerCalls.incrementAndGet(); + order.add("listener"); + }); + Mockito.doAnswer(invocation -> { + order.add("shutdown"); + return null; + }) + .when(notifiableTestStream) + .shutdown(Shutdown.KILL); + + client.handleEvent(new TestsetStartingEvent(reportEntry)); + + client.tryToTimeout(System.currentTimeMillis() + 1000L, 1); + client.tryToTimeout(System.currentTimeMillis() + 2000L, 1); + + assertThat(listenerCalls).hasValue(1); + verify(notifiableTestStream).shutdown(Shutdown.KILL); + verifyNoMoreInteractions(notifiableTestStream); + assertThat(order).containsExactly("listener", "shutdown"); + assertThat(client.hadTimeout()).isTrue(); + } } diff --git a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/extensions/timeout/JstackTimeoutExtensionTest.java b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/extensions/timeout/JstackTimeoutExtensionTest.java new file mode 100644 index 0000000000..2943671a25 --- /dev/null +++ b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/extensions/timeout/JstackTimeoutExtensionTest.java @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.plugin.surefire.extensions.timeout; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.apache.maven.plugin.surefire.log.api.NullConsoleLogger; +import org.apache.maven.surefire.api.suite.RunResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +class JstackTimeoutExtensionTest { + + @TempDir + Path tmp; + + private final NullConsoleLogger logger = new NullConsoleLogger(); + private String previousEnabled; + + @BeforeEach + void clearProperty() { + previousEnabled = System.getProperty(JstackTimeoutExtension.ENABLED_PROPERTY); + System.clearProperty(JstackTimeoutExtension.ENABLED_PROPERTY); + } + + @AfterEach + void restoreProperty() { + if (previousEnabled == null) { + System.clearProperty(JstackTimeoutExtension.ENABLED_PROPERTY); + } else { + System.setProperty(JstackTimeoutExtension.ENABLED_PROPERTY, previousEnabled); + } + } + + @Test + void disabledByDefaultDoesNothing() { + JstackTimeoutExtension ext = new JstackTimeoutExtension(); + DefaultForkedProcessTimeoutContext ctx = + new DefaultForkedProcessTimeoutContext(currentPidBestEffort(), 1, null, tmp.toFile(), 60, logger); + ext.onTimeoutDetected(ctx); + assertThat(tmp.toFile().listFiles()).isNullOrEmpty(); + } + + @Test + void unknownPidIsSkippedWhenEnabled() { + System.setProperty(JstackTimeoutExtension.ENABLED_PROPERTY, "true"); + JstackTimeoutExtension ext = new JstackTimeoutExtension(); + DefaultForkedProcessTimeoutContext ctx = + new DefaultForkedProcessTimeoutContext(-1L, 1, null, tmp.toFile(), 60, logger); + ext.onTimeoutDetected(ctx); + assertThat(tmp.toFile().listFiles()).isNullOrEmpty(); + } + + @Test + void onForkExitedIsNoop() { + new JstackTimeoutExtension() + .onForkExited( + new DefaultForkedProcessTimeoutContext(123L, 1, null, tmp.toFile(), 60, logger), + new RunResult(0, 0, 0, 0)); + assertThat(tmp.toFile().listFiles()).isNullOrEmpty(); + } + + @Test + void resolveJstackBinaryReturnsExecutableOrNull() { + File jstack = JstackTimeoutExtension.resolveJstackBinary(); + if (jstack != null) { + assertThat(jstack.isFile()).isTrue(); + assertThat(Files.isExecutable(jstack.toPath())).isTrue(); + } + } + + @Test + void enabledViaExtensionContextKey() { + // Property NOT set; but context map contains jstack.enabled=true → still enabled. + Map ctxMap = new HashMap<>(); + ctxMap.put(JstackTimeoutExtension.ENABLED_KEY, "true"); + DefaultForkedProcessTimeoutContext ctx = + new DefaultForkedProcessTimeoutContext(-1L, 1, null, tmp.toFile(), 60, logger, ctxMap); + // PID is -1 → extension exits early after the enable check; this asserts no NPE / no early + // "disabled" short-circuit (covered indirectly by the absence of a thrown exception). + new JstackTimeoutExtension().onTimeoutDetected(ctx); + } + + @Test + void contextKeyFalseDoesNotEnable() { + Map ctxMap = Collections.singletonMap(JstackTimeoutExtension.ENABLED_KEY, "false"); + DefaultForkedProcessTimeoutContext ctx = new DefaultForkedProcessTimeoutContext( + currentPidBestEffort(), 1, null, tmp.toFile(), 60, logger, ctxMap); + new JstackTimeoutExtension().onTimeoutDetected(ctx); + assertThat(tmp.toFile().listFiles()).isNullOrEmpty(); + } + + @Test + void outputLocationFromExtensionContextIsHonoredWhenJstackAvailable() throws Exception { + System.setProperty(JstackTimeoutExtension.ENABLED_PROPERTY, "true"); + File jstack = JstackTimeoutExtension.resolveJstackBinary(); + if (jstack == null) { + return; + } + long pid = currentPidBestEffort(); + if (pid <= 0L) { + return; + } + Path customDir = tmp.resolve("jstacks"); + Map ctxMap = + Collections.singletonMap(JstackTimeoutExtension.OUTPUT_LOCATION_KEY, customDir.toString()); + DefaultForkedProcessTimeoutContext ctx = + new DefaultForkedProcessTimeoutContext(pid, 1, null, tmp.toFile(), 60, logger, ctxMap); + new JstackTimeoutExtension().onTimeoutDetected(ctx); + // file written under the custom location, reports dir stays empty + assertThat(customDir.toFile().listFiles()).isNotNull().isNotEmpty(); + } + + @Test + void blankOutputLocationFallsBackToReportsDirectory() { + Map ctxMap = Collections.singletonMap(JstackTimeoutExtension.OUTPUT_LOCATION_KEY, " "); + DefaultForkedProcessTimeoutContext ctx = + new DefaultForkedProcessTimeoutContext(-1L, 1, null, tmp.toFile(), 60, logger, ctxMap); + // PID is -1 so we exit early, but the resolution path was exercised in tests above; + // here we just make sure no NPE / IllegalArgumentException leaks out for blank values. + System.setProperty(JstackTimeoutExtension.ENABLED_PROPERTY, "true"); + new JstackTimeoutExtension().onTimeoutDetected(ctx); + } + + private static long currentPidBestEffort() { + try { + String name = + java.lang.management.ManagementFactory.getRuntimeMXBean().getName(); + int at = name.indexOf('@'); + return at > 0 ? Long.parseLong(name.substring(0, at)) : -1L; + } catch (RuntimeException e) { + return -1L; + } + } +} diff --git a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/extensions/timeout/TimeoutExtensionDispatcherTest.java b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/extensions/timeout/TimeoutExtensionDispatcherTest.java new file mode 100644 index 0000000000..5c3770269c --- /dev/null +++ b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/extensions/timeout/TimeoutExtensionDispatcherTest.java @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.plugin.surefire.extensions.timeout; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.maven.plugin.surefire.log.api.NullConsoleLogger; +import org.apache.maven.surefire.api.suite.RunResult; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutContext; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TimeoutExtensionDispatcherTest { + + private final NullConsoleLogger logger = new NullConsoleLogger(); + + private ForkedProcessTimeoutContext context() { + return new DefaultForkedProcessTimeoutContext(123L, 2, new File("/java"), new File("."), 60, logger); + } + + @Test + void hasExtensionsReportsEmptyList() { + TimeoutExtensionDispatcher d = + new TimeoutExtensionDispatcher(logger, Collections.emptyList()); + assertThat(d.hasExtensions()).isFalse(); + d.fireTimeoutDetected(context()); + d.fireForkExited(context(), new RunResult(0, 0, 0, 0)); + d.close(); + } + + @Test + void invokesAllExtensionsForBothCallbacks() { + AtomicInteger detected = new AtomicInteger(); + AtomicInteger exited = new AtomicInteger(); + ForkedProcessTimeoutExtension ext = new ForkedProcessTimeoutExtension() { + @Override + public void onTimeoutDetected(ForkedProcessTimeoutContext ctx) { + detected.incrementAndGet(); + } + + @Override + public void onForkExited(ForkedProcessTimeoutContext ctx, RunResult runResult) { + exited.incrementAndGet(); + } + }; + List exts = Arrays.asList(ext, ext); + TimeoutExtensionDispatcher d = new TimeoutExtensionDispatcher(logger, exts); + try { + d.fireTimeoutDetected(context()); + d.fireForkExited(context(), new RunResult(0, 0, 0, 0)); + } finally { + d.close(); + } + assertThat(detected).hasValue(2); + assertThat(exited).hasValue(2); + } + + @Test + void exceptionInOneExtensionDoesNotPreventOthers() { + final AtomicBoolean secondCalled = new AtomicBoolean(); + ForkedProcessTimeoutExtension throwing = new ForkedProcessTimeoutExtension() { + @Override + public void onTimeoutDetected(ForkedProcessTimeoutContext ctx) { + throw new RuntimeException("boom"); + } + + @Override + public void onForkExited(ForkedProcessTimeoutContext ctx, RunResult runResult) {} + }; + ForkedProcessTimeoutExtension ok = new ForkedProcessTimeoutExtension() { + @Override + public void onTimeoutDetected(ForkedProcessTimeoutContext ctx) { + secondCalled.set(true); + } + + @Override + public void onForkExited(ForkedProcessTimeoutContext ctx, RunResult runResult) {} + }; + TimeoutExtensionDispatcher d = new TimeoutExtensionDispatcher(logger, Arrays.asList(throwing, ok)); + try { + d.fireTimeoutDetected(context()); + } finally { + d.close(); + } + assertThat(secondCalled).isTrue(); + } + + @Test + void contextCarriesAllValues() { + AtomicReference received = new AtomicReference<>(); + ForkedProcessTimeoutExtension ext = new ForkedProcessTimeoutExtension() { + @Override + public void onTimeoutDetected(ForkedProcessTimeoutContext ctx) { + received.set(ctx); + } + + @Override + public void onForkExited(ForkedProcessTimeoutContext ctx, RunResult runResult) {} + }; + TimeoutExtensionDispatcher d = + new TimeoutExtensionDispatcher(logger, Collections.singletonList(ext)); + try { + d.fireTimeoutDetected(context()); + } finally { + d.close(); + } + ForkedProcessTimeoutContext got = received.get(); + assertThat(got).isNotNull(); + assertThat(got.getPid()).isEqualTo(123L); + assertThat(got.getForkNumber()).isEqualTo(2); + assertThat(got.getJavaExecutable()).isEqualTo(new File("/java")); + assertThat(got.getReportsDirectory()).isEqualTo(new File(".")); + assertThat(got.getTimeoutSeconds()).isEqualTo(60); + } + + @Test + void blockingExtensionDoesNotLeakAfterClose() throws Exception { + final CountDownLatch entered = new CountDownLatch(1); + ForkedProcessTimeoutExtension blocker = new ForkedProcessTimeoutExtension() { + @Override + public void onTimeoutDetected(ForkedProcessTimeoutContext ctx) { + entered.countDown(); + try { + Thread.sleep(TimeUnit.MINUTES.toMillis(5)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void onForkExited(ForkedProcessTimeoutContext ctx, RunResult runResult) {} + }; + TimeoutExtensionDispatcher d = new TimeoutExtensionDispatcher( + logger, Collections.singletonList(blocker)); + try { + Thread t = new Thread(() -> d.fireTimeoutDetected(context())); + t.setDaemon(true); + t.start(); + assertThat(entered.await(10, TimeUnit.SECONDS)).isTrue(); + } finally { + d.close(); + } + } + + @Test + void extensionContextMapFlowsThroughContext() { + AtomicReference> seen = new AtomicReference<>(); + ForkedProcessTimeoutExtension ext = new ForkedProcessTimeoutExtension() { + @Override + public void onTimeoutDetected(ForkedProcessTimeoutContext ctx) { + seen.set(ctx.getExtensionContext()); + } + + @Override + public void onForkExited(ForkedProcessTimeoutContext ctx, RunResult runResult) {} + }; + Map config = Collections.singletonMap("jstack.output.location", "/tmp/dumps"); + ForkedProcessTimeoutContext ctx = + new DefaultForkedProcessTimeoutContext(123L, 2, new File("/java"), new File("."), 60, logger, config); + TimeoutExtensionDispatcher d = + new TimeoutExtensionDispatcher(logger, Collections.singletonList(ext)); + try { + d.fireTimeoutDetected(ctx); + } finally { + d.close(); + } + assertThat(seen.get()).isNotNull().containsEntry("jstack.output.location", "/tmp/dumps"); + } + + @Test + void extensionContextDefaultsToEmptyMap() { + ForkedProcessTimeoutContext ctx = + new DefaultForkedProcessTimeoutContext(1L, 1, null, new File("."), 60, logger); + assertThat(ctx.getExtensionContext()).isNotNull().isEmpty(); + } +} diff --git a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/report/StatelessXmlReporterTest.java b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/report/StatelessXmlReporterTest.java index 9945133398..e50f38c186 100644 --- a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/report/StatelessXmlReporterTest.java +++ b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/report/StatelessXmlReporterTest.java @@ -29,11 +29,14 @@ import java.nio.Buffer; import java.nio.ByteBuffer; import java.nio.file.Path; +import java.util.Arrays; import java.util.Collections; import java.util.Deque; import java.util.HashMap; +import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import org.apache.maven.plugin.surefire.booterclient.output.DeserializedStacktraceWriter; import org.apache.maven.surefire.api.report.ReportEntry; @@ -229,7 +232,7 @@ public void testAllFieldsSerialized() throws IOException { null, false, 0, - new ConcurrentHashMap>(), + new ConcurrentHashMap<>(), XSD, "3.0.2", false, @@ -248,6 +251,11 @@ public void testAllFieldsSerialized() throws IOException { } assertEquals("testsuite", testSuite.getName()); Xpp3Dom properties = testSuite.getChild("properties"); + // + Properties props = new Properties(); + props.putAll(Arrays.stream(properties.getChildren()) + .collect(Collectors.toMap(p -> p.getAttribute("name"), p -> p.getAttribute("value")))); + assertEquals(System.getProperties(), props); assertEquals(System.getProperties().size(), properties.getChildCount()); Xpp3Dom child = properties.getChild(1); assertFalse(isEmpty(child.getAttribute("value"))); @@ -364,6 +372,10 @@ public void testOutputRerunFlakyFailure() throws IOException { assertEquals("testsuite", testSuite.getName()); assertEquals("0.012", testSuite.getAttribute("time")); Xpp3Dom properties = testSuite.getChild("properties"); + Properties props = new Properties(); + props.putAll(Arrays.stream(properties.getChildren()) + .collect(Collectors.toMap(p -> p.getAttribute("name"), p -> p.getAttribute("value")))); + assertEquals(System.getProperties(), props); assertEquals(System.getProperties().size(), properties.getChildCount()); Xpp3Dom child = properties.getChild(1); assertFalse(isEmpty(child.getAttribute("value"))); diff --git a/maven-surefire-plugin/src/site/markdown/examples/timeout-extension.md b/maven-surefire-plugin/src/site/markdown/examples/timeout-extension.md new file mode 100644 index 0000000000..f5c628aa9f --- /dev/null +++ b/maven-surefire-plugin/src/site/markdown/examples/timeout-extension.md @@ -0,0 +1,149 @@ + + +# Forked Process Timeout Extension + +Since **3.6.0**, Surefire and Failsafe expose an extension point that is +invoked when a forked test JVM is killed because it exceeded +`forkedProcessTimeoutInSeconds`. The primary motivation is to allow capturing +diagnostic information (such as a `jstack` thread dump) about a hung test +process before it is destroyed. + +## SPI + +Implement +`org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension` from the +`surefire-extensions-api` artifact: + +```java +public interface ForkedProcessTimeoutExtension { + // Called before the KILL signal — the forked JVM is still alive. + void onTimeoutDetected(ForkedProcessTimeoutContext context) throws Exception; + + // Called after the forked JVM has exited. + void onForkExited(ForkedProcessTimeoutContext context, RunResult runResult) throws Exception; +} +``` + +The `ForkedProcessTimeoutContext` exposes: + +| Method | Description | +|-----------------------|-----------------------------------------------------------| +| `getPid()` | OS PID of the forked JVM, or `-1` if unavailable (Java 8) | +| `getForkNumber()` | 1-based fork number assigned by Surefire | +| `getJavaExecutable()` | Path to the `java` binary used by the fork | +| `getReportsDirectory()` | Surefire reports directory (good place to write dumps) | +| `getTimeoutSeconds()` | Configured `forkedProcessTimeoutInSeconds` | +| `getConsoleLogger()` | Logger to write to the Maven console | +| `getExtensionContext()` | User-supplied `Map` from the Mojo (see below) | + +Callback failures (any `Throwable`) are caught and logged by Surefire — they +never affect the test result. Each callback is bounded by an internal +30-second time limit so a misbehaving extension cannot stall test execution. + +## Passing configuration to extensions + +Extensions often need user-supplied configuration (output directory, +toggles, etc.). Surefire and Failsafe expose a single Mojo parameter +`forkedProcessTimeoutExtensionContext` (a `Map`) that is +made available to every registered extension via +`ForkedProcessTimeoutContext.getExtensionContext()`: + +```xml + + org.apache.maven.plugins + maven-surefire-plugin + + 600 + + ${project.build.directory}/jstacks + my-value + + + +``` + +Keys are implementation-specific; pick a unique prefix for your extension. + +## Registration + +Extensions are discovered via the standard `ServiceLoader` mechanism from the +**plugin classpath**. Two steps: + +1. Add a file + `META-INF/services/org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension` + to your extension JAR, containing the fully qualified class name of your + implementation. +2. Declare the extension JAR as a `` of `maven-surefire-plugin` + (or `maven-failsafe-plugin`) in the project POM: + +```xml + + org.apache.maven.plugins + maven-surefire-plugin + + + com.example + my-timeout-extension + 1.0.0 + + + +``` + +## Built-in `jstack` extension + +Surefire ships with a built-in `JstackTimeoutExtension` that captures a +`jstack` thread dump of the forked JVM and writes it to +`/surefire-timeout-jstack--.txt` just +before the JVM is killed. + +It is **disabled by default**. Enable it either via system property: + +```bash +mvn test -Dsurefire.timeout.jstack.enabled=true +``` + +…or directly in the POM through the `forkedProcessTimeoutExtensionContext` +map (no command-line property needed): + +```xml + + org.apache.maven.plugins + maven-surefire-plugin + + 600 + + true + + + +``` + +### Supported extension-context keys + +| Key | Description | +|---------------------------|----------------------------------------------------------------------| +| `jstack.enabled` | Set to `true` to enable the extension from the POM. | +| `jstack.output.location` | Directory where the `surefire-timeout-jstack-*.txt` files are written. When unset, defaults to the Surefire reports directory. | + +`jstack` is resolved from `JAVA_HOME/bin/jstack`, falling back to the +`jstack` binary on `PATH`. The thread dump is best-effort: if `jstack` is +missing, the PID is unknown (Java 8), or the call fails, a warning is logged +and the test result is unaffected. diff --git a/maven-surefire-plugin/src/site/markdown/timeout-extension-guide.md b/maven-surefire-plugin/src/site/markdown/timeout-extension-guide.md new file mode 100644 index 0000000000..b3d55aa761 --- /dev/null +++ b/maven-surefire-plugin/src/site/markdown/timeout-extension-guide.md @@ -0,0 +1,319 @@ + + +# Forked Process Timeout Extension Guide + +Since **3.6.0**, Surefire and Failsafe expose an extension point that fires +when a forked test JVM is killed because it exceeded +`forkedProcessTimeoutInSeconds`. Use it to capture diagnostic information +(thread dumps, heap dumps, JFR recordings, notifications, …) about a hung +test process *before* it is destroyed. + +This page walks through: + +1. [Enabling the built-in `jstack` extension](#built-in-jstack-extension) +2. [Writing your own extension](#writing-your-own-extension) +3. [Passing configuration to extensions](#passing-configuration-to-extensions) +4. [Lifecycle, threading and error handling](#lifecycle-threading-and-error-handling) + +A concise API reference is also available at +[examples/timeout-extension.html](examples/timeout-extension.html). + +--- + +## Built-in `jstack` extension + +Surefire ships with a built-in `JstackTimeoutExtension`. When a forked JVM +hits the timeout it captures a `jstack` thread dump of that process and +writes it to a `.txt` file just **before** the kill signal is sent — so the +dump reflects the actual hung state. + +### Step 1 — set a sensible timeout + +The extension only runs when the kill path is triggered, so make sure a +timeout is configured: + +```xml + + org.apache.maven.plugins + maven-surefire-plugin + + 600 + + +``` + +### Step 2 — enable it + +The extension is registered via `META-INF/services` but is **disabled by +default**. Enable it either through the POM… + +```xml + + org.apache.maven.plugins + maven-surefire-plugin + + 600 + + true + + + +``` + +…or per-build via a system property: + +```bash +mvn verify -Dsurefire.timeout.jstack.enabled=true +``` + +Either source enables the extension; if both are set, the property still +wins (it is processed first). + +### Step 3 — (optional) choose where dumps land + +By default the file is written under the configured reports directory as + +``` +target/surefire-reports/surefire-timeout-jstack--.txt +``` + +To override the destination, add the `jstack.output.location` key to the +same map: + +```xml + + org.apache.maven.plugins + maven-surefire-plugin + + 600 + + true + ${project.build.directory}/jstacks + + + +``` + +### Requirements + +* `jstack` must be available — Surefire looks for it in `${java.home}/bin/`, + the parent JDK `bin/`, `$JAVA_HOME/bin/` and finally on `PATH`. Building + with a JRE (Java 8) will fail to find it. +* The PID of the forked JVM must be resolvable; on **Java 8** there is no + public `Process.pid()` API and the extension will log a warning and skip + the dump. +* When the call fails for any reason (timeout > 20 s, non-zero exit, etc.) + a warning is logged and the test result is unaffected. + +--- + +## Writing your own extension + +The SPI is a single interface in the `surefire-extensions-api` artifact: + +```java +package org.apache.maven.surefire.extensions; + +public interface ForkedProcessTimeoutExtension { + + // Called BEFORE the KILL signal — the forked JVM is still alive, + // ideal place to run jstack, jcmd, capture a JFR snapshot, … + void onTimeoutDetected(ForkedProcessTimeoutContext context) throws Exception; + + // Called AFTER the forked JVM has exited — good place to upload + // artifacts, send notifications, etc. + void onForkExited(ForkedProcessTimeoutContext context, RunResult runResult) throws Exception; +} +``` + +### Step 1 — create the extension module + +A standalone Maven module is the simplest distribution unit: + +```xml + + 4.0.0 + + com.example.surefire + my-timeout-extension + 1.0.0 + + + + org.apache.maven.surefire + surefire-extensions-api + 3.6.0 + provided + + + +``` + +`scope=provided` keeps the dependency out of the published JAR — the SPI +classes are always supplied by the running Surefire plugin. + +### Step 2 — implement the interface + +```java +package com.example.surefire; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.maven.plugin.surefire.log.api.ConsoleLogger; +import org.apache.maven.surefire.api.suite.RunResult; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutContext; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension; + +public class HeapDumpTimeoutExtension implements ForkedProcessTimeoutExtension { + + private static final String OUTPUT_KEY = "heapdump.output.location"; + + @Override + public void onTimeoutDetected(ForkedProcessTimeoutContext context) throws Exception { + ConsoleLogger log = context.getConsoleLogger(); + long pid = context.getPid(); + if (pid <= 0L) { + log.warning("HeapDumpTimeoutExtension: unknown PID, skipping fork " + context.getForkNumber()); + return; + } + String dir = context.getExtensionContext().getOrDefault( + OUTPUT_KEY, context.getReportsDirectory().getAbsolutePath()); + Path out = Path.of(dir, "heap-fork-" + context.getForkNumber() + "-" + pid + ".hprof"); + Files.createDirectories(out.getParent()); + + // Spawn jcmd ${pid} GC.heap_dump + new ProcessBuilder("jcmd", Long.toString(pid), "GC.heap_dump", out.toString()) + .redirectErrorStream(true) + .inheritIO() + .start() + .waitFor(); + + log.info("HeapDumpTimeoutExtension: wrote " + out); + } + + @Override + public void onForkExited(ForkedProcessTimeoutContext context, RunResult runResult) { + // optional: upload to S3, post to Slack, etc. + } +} +``` + +### Step 3 — register via `ServiceLoader` + +Create the file +`src/main/resources/META-INF/services/org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension` +with one fully-qualified class name per line: + +``` +com.example.surefire.HeapDumpTimeoutExtension +``` + +### Step 4 — declare the extension in the consumer project + +Add the extension JAR as a `` of `maven-surefire-plugin` (or +`maven-failsafe-plugin`) — **not** as a project test dependency. Surefire +discovers extensions on the *plugin* classpath: + +```xml + + org.apache.maven.plugins + maven-surefire-plugin + + 600 + + ${project.build.directory}/heap-dumps + + + + + com.example.surefire + my-timeout-extension + 1.0.0 + + + +``` + +That's it — when a fork is killed for timeout, both your extension and any +other registered extension (including the built-in `jstack` one if enabled) +are invoked. + +--- + +## Passing configuration to extensions + +Surefire and Failsafe expose a single Mojo parameter for **all** timeout +extensions: + +```xml + + ${project.build.directory}/jstacks + ${project.build.directory}/heap-dumps + https://hooks.slack.com/services/… + +``` + +The map is exposed to every extension via +`ForkedProcessTimeoutContext.getExtensionContext()`. Keys are +implementation-specific — pick a unique prefix for each extension to avoid +collisions. + +### Context API + +| Method | Description | +|-------------------------|----------------------------------------------------------------------| +| `getPid()` | OS PID of the forked JVM, or `-1` if unavailable (Java 8) | +| `getForkNumber()` | 1-based fork number assigned by Surefire | +| `getJavaExecutable()` | Path to the `java` binary used by the fork (may be `null`) | +| `getReportsDirectory()` | Surefire reports directory | +| `getTimeoutSeconds()` | Configured `forkedProcessTimeoutInSeconds` | +| `getConsoleLogger()` | Logger writing to the Maven console | +| `getExtensionContext()` | User-supplied `Map` from the Mojo parameter | + +### Supported built-in keys + +| Key | Used by | Description | +|---------------------------|-------------------------------|-----------------------------------------------------------------------------| +| `jstack.enabled` | `JstackTimeoutExtension` | Set to `true` to enable the built-in jstack extension from the POM. | +| `jstack.output.location` | `JstackTimeoutExtension` | Directory for `surefire-timeout-jstack-*.txt`. Defaults to the reports dir. | + +--- + +## Lifecycle, threading and error handling + +* **Order of callbacks** — `onTimeoutDetected` is invoked synchronously + the first time Surefire decides to kill a fork, *before* the KILL + signal is dispatched. `onForkExited` is invoked once the OS has reaped + the process. +* **Single fire per fork** — both callbacks fire at most once per forked + JVM, even if the timeout poll observes the condition repeatedly. +* **Bounded execution** — each callback is invoked on an internal cached + thread pool and cancelled after **30 seconds**. A misbehaving extension + cannot stall test execution. +* **Isolation** — any `Throwable` thrown by an extension is logged at + warn-level and never affects the test result. +* **Classpath** — extensions are loaded with + `ServiceLoader.load(ForkedProcessTimeoutExtension.class, ForkedProcessTimeoutExtension.class.getClassLoader())`, + i.e. the **plugin classloader**, not the project test classpath. +* **Failsafe** — the SPI works identically for `maven-failsafe-plugin`. + Register the extension as a dependency of the failsafe plugin and use + the same `forkedProcessTimeoutExtensionContext` parameter. diff --git a/maven-surefire-plugin/src/site/site.xml b/maven-surefire-plugin/src/site/site.xml index 84e4deee01..f1d46735d3 100644 --- a/maven-surefire-plugin/src/site/site.xml +++ b/maven-surefire-plugin/src/site/site.xml @@ -64,6 +64,8 @@ + + diff --git a/pom.xml b/pom.xml index 9df4d01d75..371bfa103e 100644 --- a/pom.xml +++ b/pom.xml @@ -427,6 +427,7 @@ src/test/resources/**/*.css **/*.jj src/main/resources/META-INF/services/org.apache.maven.surefire.api.provider.SurefireProvider + src/main/resources/META-INF/services/org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension DEPENDENCIES .m2/** .m2 diff --git a/surefire-booter/src/main/java/org/apache/maven/surefire/booter/SystemUtils.java b/surefire-booter/src/main/java/org/apache/maven/surefire/booter/SystemUtils.java index 5eecda6edf..0cd8278ed7 100644 --- a/surefire-booter/src/main/java/org/apache/maven/surefire/booter/SystemUtils.java +++ b/surefire-booter/src/main/java/org/apache/maven/surefire/booter/SystemUtils.java @@ -198,6 +198,32 @@ public static Long pid() { return pidOnJMX(); } + /** + * Returns the OS PID of the given child {@link Process} using the Java 9+ + * {@code Process.pid()} method via reflection. + * + * @param process a {@link Process} returned by {@code ProcessBuilder} / + * {@code Runtime.exec}; may be {@code null} + * @return the PID, or {@code null} when it cannot be determined (e.g. + * running on Java 8 or the reflective call fails) + * @since 3.6.0 + */ + public static Long pidOf(Process process) { + if (process == null) { + return null; + } + Method pidMethod = ReflectionUtils.tryGetMethod(Process.class, "pid"); + if (pidMethod == null) { + return null; + } + try { + Object value = pidMethod.invoke(process); + return value instanceof Long ? (Long) value : null; + } catch (ReflectiveOperationException | RuntimeException e) { + return null; + } + } + static Long pidOnJMX() { String processName = ManagementFactory.getRuntimeMXBean().getName(); if (processName.contains("@")) { diff --git a/surefire-extensions-api/src/main/java/org/apache/maven/surefire/extensions/ForkedProcessTimeoutContext.java b/surefire-extensions-api/src/main/java/org/apache/maven/surefire/extensions/ForkedProcessTimeoutContext.java new file mode 100644 index 0000000000..ffb10bafcb --- /dev/null +++ b/surefire-extensions-api/src/main/java/org/apache/maven/surefire/extensions/ForkedProcessTimeoutContext.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.surefire.extensions; + +import java.io.File; +import java.util.Collections; +import java.util.Map; + +import org.apache.maven.plugin.surefire.log.api.ConsoleLogger; + +/** + * Information about a forked Surefire test JVM that has reached its configured + * timeout, passed to {@link ForkedProcessTimeoutExtension} callbacks. + * + * @since 3.6.0 + */ +public interface ForkedProcessTimeoutContext { + + /** + * The operating-system PID of the forked JVM, or {@code -1} when the PID + * cannot be determined (for instance on Java 8 where the + * {@code ProcessHandle} API is unavailable). + * + * @return the forked process PID, or {@code -1} if unknown + */ + long getPid(); + + /** + * The fork channel id (1-based) assigned by Surefire to this forked JVM. + * + * @return the fork number + */ + int getForkNumber(); + + /** + * The {@code java} executable used to launch the forked JVM, or + * {@code null} when it cannot be determined. + * + * @return the java executable used by the fork, or {@code null} + */ + File getJavaExecutable(); + + /** + * The Surefire reports directory configured for the current run. Extensions + * may use this directory to write diagnostic output (thread dumps, etc.). + * + * @return the reports directory; never {@code null} + */ + File getReportsDirectory(); + + /** + * The configured {@code forkedProcessTimeoutInSeconds}. + * + * @return the configured timeout in seconds (always greater than 0) + */ + int getTimeoutSeconds(); + + /** + * Logger that extensions should use for diagnostic output to the Maven + * console. + * + * @return the console logger; never {@code null} + */ + ConsoleLogger getConsoleLogger(); + + /** + * User-supplied extension configuration, as provided by the + * {@code forkedProcessTimeoutExtensionContext} Mojo parameter. Extensions + * may read implementation-specific keys from this map (for instance the + * built-in jstack extension reads {@code jstack.output.location}). + *

+ * The map is never {@code null} but may be empty. Implementations should + * treat it as read-only. + * + * @return user-supplied extension configuration; never {@code null} + */ + default Map getExtensionContext() { + return Collections.emptyMap(); + } +} diff --git a/surefire-extensions-api/src/main/java/org/apache/maven/surefire/extensions/ForkedProcessTimeoutExtension.java b/surefire-extensions-api/src/main/java/org/apache/maven/surefire/extensions/ForkedProcessTimeoutExtension.java new file mode 100644 index 0000000000..390ee41621 --- /dev/null +++ b/surefire-extensions-api/src/main/java/org/apache/maven/surefire/extensions/ForkedProcessTimeoutExtension.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.surefire.extensions; + +import org.apache.maven.surefire.api.suite.RunResult; + +/** + * Extension point invoked when a forked Surefire test JVM exceeds its + * configured {@code forkedProcessTimeoutInSeconds}. + *

+ * Implementations are discovered via the standard {@link java.util.ServiceLoader} + * mechanism from the plugin classpath. To register an extension, add a + * file + * {@code META-INF/services/org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension} + * to your extension JAR and declare it as a {@code } of + * {@code maven-surefire-plugin} (or {@code maven-failsafe-plugin}) in the + * project POM. + *

+ * The primary motivation is to capture diagnostic information (for example, a + * {@code jstack} thread dump) about a test JVM that is about to be killed so + * the root cause of a deadlock or hang can be investigated. + *

+ * Lifecycle: + *

    + *
  1. {@link #onTimeoutDetected(ForkedProcessTimeoutContext)} is invoked + * synchronously immediately after Surefire detects the timeout but + * before the {@code KILL} shutdown command is sent. The forked JVM + * is therefore still alive at this point, which makes utilities such as + * {@code jstack} usable.
  2. + *
  3. {@link #onForkExited(ForkedProcessTimeoutContext, RunResult)} is + * invoked from the plugin after the forked JVM has actually exited.
  4. + *
+ * Any {@link Throwable} raised by a callback is caught by Surefire, logged at warn level, and does + * not affect the test result. + *

+ * Callbacks are executed on Surefire-managed daemon threads and are subject to an internal time + * limit; implementations should complete quickly and must not block indefinitely. + * + * @since 3.6.0 + */ +public interface ForkedProcessTimeoutExtension { + + /** + * Invoked when Surefire detects that a forked JVM has exceeded its + * timeout, before the JVM is killed. + *

+ * At this point the forked JVM is still running; tools that require a + * live target process (such as {@code jstack}) may be invoked here. + * + * @param context diagnostic information about the forked process; never + * {@code null} + * @throws Exception any failure is logged by Surefire and suppressed + */ + void onTimeoutDetected(ForkedProcessTimeoutContext context) throws Exception; + + /** + * Invoked after the forked JVM has exited following a timeout. + *

+ * Useful for cleanup, archival of dumps, or notifying external systems. + * The forked JVM is no longer alive when this method is invoked. + * + * @param context diagnostic information about the forked process; never + * {@code null} + * @param runResult the final {@link RunResult} for the fork; never + * {@code null} + * @throws Exception any failure is logged by Surefire and suppressed + */ + void onForkExited(ForkedProcessTimeoutContext context, RunResult runResult) throws Exception; +} diff --git a/surefire-extensions-api/src/main/java/org/apache/maven/surefire/extensions/util/CommandlineExecutor.java b/surefire-extensions-api/src/main/java/org/apache/maven/surefire/extensions/util/CommandlineExecutor.java index 9f517c8ab8..d2ea07e57d 100644 --- a/surefire-extensions-api/src/main/java/org/apache/maven/surefire/extensions/util/CommandlineExecutor.java +++ b/surefire-extensions-api/src/main/java/org/apache/maven/surefire/extensions/util/CommandlineExecutor.java @@ -87,6 +87,15 @@ public int awaitExit() throws InterruptedException { } } + /** + * @return the underlying {@link Process}, or {@code null} when + * {@link #execute()} has not yet been called or has failed + * @since 3.6.0 + */ + public Process getProcess() { + return process; + } + @Override public void close() { if (shutdownHook != null) { diff --git a/surefire-its/src/test/java/org/apache/maven/surefire/its/jiras/Surefire838TimeoutExtensionIT.java b/surefire-its/src/test/java/org/apache/maven/surefire/its/jiras/Surefire838TimeoutExtensionIT.java new file mode 100644 index 0000000000..ada9ae17ee --- /dev/null +++ b/surefire-its/src/test/java/org/apache/maven/surefire/its/jiras/Surefire838TimeoutExtensionIT.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.surefire.its.jiras; + +import java.io.File; + +import org.apache.maven.surefire.its.fixture.OutputValidator; +import org.apache.maven.surefire.its.fixture.SurefireJUnit4IntegrationTestCase; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * SUREFIRE-838: ForkedProcessTimeoutExtension is invoked on forked process + * timeout (both {@code onTimeoutDetected} before kill and {@code onForkExited} + * after the fork exits). + */ +public class Surefire838TimeoutExtensionIT extends SurefireJUnit4IntegrationTestCase { + + @BeforeAll + public static void installExtension() { + unpack(Surefire838TimeoutExtensionIT.class, "surefire-838-timeout-extension-ext", "ext") + .executeInstall(); + } + + @Test + public void extensionInvokedOnTimeout() { + OutputValidator validator = unpack("surefire-838-timeout-extension") + .maven() + .withFailure() + .executeTest() + .verifyTextInLog("There was a timeout in the fork"); + + assertThat(validator.getSurefireReportsFile("timeout-detected.txt").isFile()) + .as("timeout-detected.txt marker must be written by MarkerTimeoutExtension#onTimeoutDetected") + .isTrue(); + validator.getSurefireReportsFile("timeout-detected.txt").assertContainsText("timeoutSeconds=5"); + + assertThat(validator.getSurefireReportsFile("fork-exited.txt").isFile()) + .as("fork-exited.txt marker must be written by MarkerTimeoutExtension#onForkExited") + .isTrue(); + validator.getSurefireReportsFile("fork-exited.txt").assertContainsText("isTimeout=true"); + + // Verify the built-in JstackTimeoutExtension was enabled from the POM via + // true... + // The dump file is best-effort: it requires a JDK with `jstack` and a resolvable + // PID (Java 9+). Skip the assertion when those preconditions are not met. + if (isJava9OrLater() && jstackAvailable()) { + File jstackDir = new File(validator.getBaseDir(), "target/jstacks"); + assertThat(jstackDir) + .as("custom jstack.output.location directory must exist") + .isDirectory(); + File[] dumps = jstackDir.listFiles( + (dir, name) -> name.startsWith("surefire-timeout-jstack-") && name.endsWith(".txt")); + assertThat(dumps) + .as("JstackTimeoutExtension must have written at least one thread-dump file in " + jstackDir) + .isNotNull() + .isNotEmpty(); + } + } + + private static boolean isJava9OrLater() { + String spec = System.getProperty("java.specification.version", "1.8"); + try { + // "1.8" -> 1.8 -> < 9; "11", "17", "21" -> >= 9 + return !spec.startsWith("1.") && Integer.parseInt(spec) >= 9; + } catch (NumberFormatException e) { + return false; + } + } + + private static boolean jstackAvailable() { + String javaHome = System.getProperty("java.home"); + if (javaHome == null) { + return false; + } + String exe = System.getProperty("os.name", "").toLowerCase().contains("win") ? "jstack.exe" : "jstack"; + if (new File(javaHome, "bin/" + exe).canExecute()) { + return true; + } + File parent = new File(javaHome).getParentFile(); + return parent != null && new File(parent, "bin/" + exe).canExecute(); + } +} diff --git a/surefire-its/src/test/resources/surefire-838-timeout-extension-ext/pom.xml b/surefire-its/src/test/resources/surefire-838-timeout-extension-ext/pom.xml new file mode 100644 index 0000000000..4aad156cc3 --- /dev/null +++ b/surefire-its/src/test/resources/surefire-838-timeout-extension-ext/pom.xml @@ -0,0 +1,52 @@ + + + + 4.0.0 + + org.apache.maven.plugins.surefire + surefire-838-timeout-extension + 1.0-SNAPSHOT + surefire-838-timeout-extension + + + 1.8 + 1.8 + + + + + org.apache.maven.surefire + surefire-extensions-api + ${surefire.version} + + + org.apache.maven.surefire + surefire-api + ${surefire.version} + + + org.apache.maven.surefire + surefire-logger-api + ${surefire.version} + + + diff --git a/surefire-its/src/test/resources/surefire-838-timeout-extension-ext/src/main/java/org/apache/maven/surefire/its/extension/MarkerTimeoutExtension.java b/surefire-its/src/test/resources/surefire-838-timeout-extension-ext/src/main/java/org/apache/maven/surefire/its/extension/MarkerTimeoutExtension.java new file mode 100644 index 0000000000..a6f2353479 --- /dev/null +++ b/surefire-its/src/test/resources/surefire-838-timeout-extension-ext/src/main/java/org/apache/maven/surefire/its/extension/MarkerTimeoutExtension.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.surefire.its.extension; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Writer; + +import org.apache.maven.surefire.api.suite.RunResult; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutContext; +import org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension; + +/** + * Test extension that writes a marker file under the reports directory when + * the forked test JVM times out. + */ +public class MarkerTimeoutExtension implements ForkedProcessTimeoutExtension { + + @Override + public void onTimeoutDetected(ForkedProcessTimeoutContext context) throws IOException { + File reports = context.getReportsDirectory(); + if (!reports.isDirectory()) { + reports.mkdirs(); + } + File marker = new File(reports, "timeout-detected.txt"); + try (Writer w = new FileWriter(marker)) { + w.write("fork=" + context.getForkNumber() + + " pid=" + context.getPid() + + " timeoutSeconds=" + context.getTimeoutSeconds() + + " javaExecutable=" + context.getJavaExecutable()); + } + } + + @Override + public void onForkExited(ForkedProcessTimeoutContext context, RunResult runResult) throws IOException { + File marker = new File(context.getReportsDirectory(), "fork-exited.txt"); + try (Writer w = new FileWriter(marker)) { + w.write("isTimeout=" + runResult.isTimeout()); + } + } +} diff --git a/surefire-its/src/test/resources/surefire-838-timeout-extension-ext/src/main/resources/META-INF/services/org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension b/surefire-its/src/test/resources/surefire-838-timeout-extension-ext/src/main/resources/META-INF/services/org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension new file mode 100644 index 0000000000..724721643f --- /dev/null +++ b/surefire-its/src/test/resources/surefire-838-timeout-extension-ext/src/main/resources/META-INF/services/org.apache.maven.surefire.extensions.ForkedProcessTimeoutExtension @@ -0,0 +1 @@ +org.apache.maven.surefire.its.extension.MarkerTimeoutExtension diff --git a/surefire-its/src/test/resources/surefire-838-timeout-extension/pom.xml b/surefire-its/src/test/resources/surefire-838-timeout-extension/pom.xml new file mode 100644 index 0000000000..cfbe65335b --- /dev/null +++ b/surefire-its/src/test/resources/surefire-838-timeout-extension/pom.xml @@ -0,0 +1,69 @@ + + + + 4.0.0 + + org.apache.maven.plugins.surefire + surefire-838-timeout-extension-consumer + 1.0-SNAPSHOT + surefire-838-timeout-extension-consumer + + + 1.8 + 1.8 + + + + + org.junit.jupiter + junit-jupiter + 5.9.1 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + 1 + true + 5 + + true + ${project.build.directory}/jstacks + + + + + org.apache.maven.plugins.surefire + surefire-838-timeout-extension + 1.0-SNAPSHOT + + + + + + diff --git a/surefire-its/src/test/resources/surefire-838-timeout-extension/src/test/java/surefire838/SleepingTest.java b/surefire-its/src/test/resources/surefire-838-timeout-extension/src/test/java/surefire838/SleepingTest.java new file mode 100644 index 0000000000..47fbf8e361 --- /dev/null +++ b/surefire-its/src/test/resources/surefire-838-timeout-extension/src/test/java/surefire838/SleepingTest.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 surefire838; + +import org.junit.jupiter.api.Test; + +public class SleepingTest { + + @Test + public void sleepsLongerThanTimeout() throws Exception { + Thread.sleep(120_000L); + } +}