From f0a2207daa0a49973c0834fd7aaea76a6ae8163e Mon Sep 17 00:00:00 2001 From: Vladimir Sitnikov Date: Sun, 24 Oct 2021 15:03:58 +0300 Subject: [PATCH] Stop forked exec and javaexec on build termination Terminating a single process does not terminate its children automatically, so when Gradle terminates .bat file, it leaves the forked application running. The problem is often visible as "process exec process keeps running after build termination". The root cause is that in Windows exec uses cmd -> process.exe sequence, so aborting the build terminates "cmd.exe", and "process.exe" keeps running. The workaround is to use taskkill /PID $pid /T /F command that terminates the process tree For other operating systems, alternative solutions are needed. This commit uses Process#descendants if Gradle runs with Java 9+ Otherwise it resorts to the previous "process.destroy" sequence. fixes #18716 #7603 #6114 #3093 #1128 #1109 Signed-off-by: Vladimir Sitnikov --- .../process/internal/ExecHandleRunner.java | 114 +++++++++++++++++- .../internal/DefaultExecHandleSpec.groovy | 23 +++- 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/subprojects/core/src/main/java/org/gradle/process/internal/ExecHandleRunner.java b/subprojects/core/src/main/java/org/gradle/process/internal/ExecHandleRunner.java index cc810b0afbb0..479211553e23 100755 --- a/subprojects/core/src/main/java/org/gradle/process/internal/ExecHandleRunner.java +++ b/subprojects/core/src/main/java/org/gradle/process/internal/ExecHandleRunner.java @@ -17,12 +17,20 @@ package org.gradle.process.internal; import net.rubygrapefruit.platform.ProcessLauncher; +import org.apache.commons.lang.StringUtils; import org.gradle.api.logging.Logger; import org.gradle.api.logging.Logging; +import org.gradle.internal.os.OperatingSystem; +import javax.annotation.Nullable; +import java.lang.management.ManagementFactory; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Stream; public class ExecHandleRunner implements Runnable { private static final Logger LOGGER = Logging.getLogger(ExecHandleRunner.class); @@ -57,14 +65,116 @@ public void abortProcess() { aborted = true; if (process != null) { streamsHandler.disconnect(); - LOGGER.debug("Abort requested. Destroying process: {}.", execHandle.getDisplayName()); - process.destroy(); + LOGGER.info("Abort requested. Destroying process: {}.", execHandle.getDisplayName()); + if (!OperatingSystem.current().isWindows()) { + destroyProcessTree(); + } else { + Long pid = getProcessPid(); + if (pid == null) { + LOGGER.info("Aborting {}", execHandle.getDisplayName()); + process.destroy(); + } else { + // taskkill requires pid + destroyWindowsProcessTree(pid); + } + } } } finally { lock.unlock(); } } + private @Nullable Long getProcessPid() { + long pid; + try { + // Java 9+ + Method pidMethod = Process.class.getMethod("pid"); + pid = (Long) pidMethod.invoke(process); + } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + LOGGER.debug("Process#pid() is not available, so will try deduce process id from RuntimeMXBean.name", e); + String runtimeMXBeanName = ManagementFactory.getRuntimeMXBean().getName(); + try { + pid = Long.parseLong(StringUtils.substringBefore(runtimeMXBeanName, "@")); + } catch (NumberFormatException nfe) { + LOGGER.info("Native-platform process: failed to parse PID from Runtime MX bean name: {} " + + " (expecting pid@.. format) when terminating {}", runtimeMXBeanName, execHandle.getDisplayName()); + return null; + } + } + return pid; + } + + /** + * By default, {@link Process#destroy()} does not terminate children processes in Windows, + * so it causes non-terminated processes when user cancels the build (e.g. via Ctrl+C). + * {@code taskkill} utility allows to terminate all the processes in the tree. + */ + private void destroyWindowsProcessTree(long pid) { + // TODO: is this ok? Should it use ExecActionFactory somehow? + ProcessBuilder taskkillProcessBuilder = new ProcessBuilder("cmd", "/K", "taskkill", "/PID", Long.toString(pid), "/T", "/F"); + Process taskkill = processLauncher.start(taskkillProcessBuilder); + streamsHandler.connectStreams(taskkill, "terminate " + execHandle.getDisplayName() + " via taskkill " + pid, executor); + try { + // taskkill should be fast, however, do not allow it to hang Gradle + taskkill.waitFor(15, TimeUnit.SECONDS); + } catch (InterruptedException e) { + LOGGER.info("taskkill took more than 15 seconds to terminate {}", execHandle.getDisplayName()); + process.destroy(); + } + } + + /** + * By default, {@link Process#destroy()} does not terminate children processes in Windows, + * so it causes non-terminated processes when user cancels the build (e.g. via Ctrl+C). + * The method destroys all descendants first, and then destroys the process itself. + */ + @SuppressWarnings("unchecked") + private void destroyProcessTree() { + // Destroy descendants when running with Java 9+ + // TODO: remove reflection when code is Java 9+ + @SuppressWarnings("rawtypes") + Stream/**/ descendantsProcesses = Stream.empty(); + + try { + Method descendantsMethod = Process.class.getMethod("descendants"); + //noinspection rawtypes + descendantsProcesses = (Stream) descendantsMethod.invoke(process); + } catch (NoSuchMethodException ignore) { + LOGGER.info("Can't call terminate process tree for {} since Process#descendants method does not exist. Running with Java 8?", execHandle.getDisplayName()); + } catch (InvocationTargetException e) { + LOGGER.info("Can't query descendants process tree for {}", execHandle.getDisplayName(), e.getCause()); + } catch (IllegalAccessException e) { + LOGGER.info("Can't query descendants process tree for {}", execHandle.getDisplayName(), e); + } + + //noinspection unchecked + descendantsProcesses.forEach(processHandle -> { // ProcessHandle is Java 9+ + Class processHandleClass = processHandle.getClass(); + Long childPid; + try { + Method pidMethod = processHandleClass.getMethod("pid"); + childPid = (Long) pidMethod.invoke(processHandle); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ignore) { + return; + } + + try { + Method destroyMethod = processHandleClass.getMethod("destroy"); + LOGGER.info("Destroying process {}", childPid); + destroyMethod.invoke(processHandle); + } catch (NoSuchMethodException ignore) { + // can't happen + } catch (InvocationTargetException e) { + LOGGER.info("Error while destroying process {}", childPid, e.getCause()); + } catch (IllegalAccessException e) { + LOGGER.info("Error while destroying process {}", childPid, e); + } + }); + + LOGGER.info("Aborting {}", execHandle.getDisplayName()); + process.destroy(); + } + @Override public void run() { try { diff --git a/subprojects/core/src/test/groovy/org/gradle/process/internal/DefaultExecHandleSpec.groovy b/subprojects/core/src/test/groovy/org/gradle/process/internal/DefaultExecHandleSpec.groovy index 892b0013d39d..7bc3fe15f4f2 100644 --- a/subprojects/core/src/test/groovy/org/gradle/process/internal/DefaultExecHandleSpec.groovy +++ b/subprojects/core/src/test/groovy/org/gradle/process/internal/DefaultExecHandleSpec.groovy @@ -294,7 +294,27 @@ class DefaultExecHandleSpec extends ConcurrentSpec { execHandle.abort() } - @Ignore //not yet implemented + void "aborts process tree"() { + def output = new ByteArrayOutputStream() + def execHandle = handle().setStandardOutput(output).setTimeout(5000).args(args(SlowDaemonApp.class)).build() + + when: + execHandle.start() + // 2.5 sec should be enough for the process to print "'m the daemon" + Thread.sleep(2500) + // Now we abort it while it still runs + execHandle.abort() + execHandle.waitForFinish() + + then: + output.toString().contains "I'm the daemon" + execHandle.state == ExecHandleState.ABORTED + + cleanup: + execHandle.abort() + } + + @Ignore //not yet implemented: see "when" comment void "aborts daemon"() { def output = new ByteArrayOutputStream() def execHandle = handle().setDaemon(true).setStandardOutput(output).args(args(SlowDaemonApp.class)).build() @@ -308,6 +328,7 @@ class DefaultExecHandleSpec extends ConcurrentSpec { when: execHandle.abort() + // .abort() does destroy the process, however, DETACHED is treated as "terminal" state, so the state is not updated to ABORTED def result = execHandle.waitForFinish() then: