Skip to content

Commit ac97849

Browse files
authored
Graceful shutdown of the JVMs executing the unit tests, in case of a wait time out. This way, JVMs are properly closed and operations like flushing test coverage to disk are properly finished. (#25)
1 parent 3bc823c commit ac97849

File tree

5 files changed

+289
-2
lines changed

5 files changed

+289
-2
lines changed

pom.xml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<groupId>com.clevertap</groupId>
88
<artifactId>supertest-maven-plugin</artifactId>
99
<packaging>maven-plugin</packaging>
10-
<version>1.12</version>
10+
<version>1.13</version>
1111
<description>A wrapper for Maven's Surefire Plugin, with advanced re-run capabilities.
1212
</description>
1313
<name>supertest-maven-plugin</name>
@@ -60,6 +60,13 @@
6060
<version>4.12</version>
6161
<scope>test</scope>
6262
</dependency>
63+
64+
<dependency>
65+
<groupId>org.mockito</groupId>
66+
<artifactId>mockito-core</artifactId>
67+
<version>4.11.0</version>
68+
<scope>test</scope>
69+
</dependency>
6370
</dependencies>
6471

6572
<distributionManagement>

src/main/java/com/clevertap/maven/plugins/supertest/SuperTestMavenPlugin.java

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.clevertap.maven.plugins.supertest;
22

3+
import com.clevertap.maven.plugins.supertest.util.ProcessHelper;
34
import java.io.BufferedReader;
45
import java.io.File;
56
import java.io.IOException;
@@ -38,6 +39,8 @@ public class SuperTestMavenPlugin extends AbstractMojo {
3839
private static final String TEST_REGEX = "-Dtest=(.*?)(\\s|$)";
3940
private static final Pattern TEST_REGEX_PATTERN = Pattern.compile(TEST_REGEX);
4041

42+
private final ProcessHelper processHelper = new ProcessHelper();
43+
4144
private ExecutorService pool;
4245

4346
@Parameter(defaultValue = "${project}", readonly = true)
@@ -56,6 +59,9 @@ public class SuperTestMavenPlugin extends AbstractMojo {
5659
@Parameter(property = "shellNoActivityTimeout", readonly = true, defaultValue = "300")
5760
Integer shellNoActivityTimeout;
5861

62+
@Parameter(property = "gracefulShutdownTimeout", readonly = true, defaultValue = "60")
63+
Integer gracefulShutdownTimeout;
64+
5965
@Parameter(property = "includes" )
6066
List<String> includes;
6167

@@ -190,11 +196,51 @@ public int runShellCommand(final String command, final String commandDescriptor)
190196
if (exited) {
191197
return proc.exitValue();
192198
} else {
193-
proc.destroyForcibly();
199+
shutdown(proc);
200+
194201
return 1;
195202
}
196203
}
197204

205+
private void shutdown(Process proc) throws InterruptedException {
206+
// tries to gracefully shutdown first
207+
gracefullyShutdown(proc);
208+
209+
if (!proc.waitFor(gracefulShutdownTimeout, TimeUnit.SECONDS)) {
210+
// if the process is still alive after the graceful period is over,
211+
// kill it forcibly; this may have some undesired effects (e.g. test coverage
212+
// info might not be flushed to disk), but we better kill it, than waiting forever
213+
getLog().info("Process did not shutdown within " + gracefulShutdownTimeout
214+
+ " sec, killing it forcibly now...");
215+
proc.destroyForcibly();
216+
} else {
217+
getLog().info("Process shutdown successfully upon time out.");
218+
}
219+
}
220+
221+
private void gracefullyShutdown(Process proc) {
222+
if (processHelper.isUnixProcess(proc)) {
223+
// Calling proc.destroy() does not seem to properly propagate SIGTERM to
224+
// the child processes. As a result killing the maven process this way does not
225+
// make the forked JVMs write coverage data to disk. This is the reason why
226+
// we find the process leaves and send them a SIGTERM signal.
227+
try {
228+
List<Long> leaves = processHelper.getLeaves(processHelper.getPid(proc));
229+
230+
for (long pid : leaves) {
231+
processHelper.sendSIGTERM(pid);
232+
}
233+
} catch (Exception e) {
234+
getLog().error("Error while gracefully shutting down.", e);
235+
proc.destroy();
236+
}
237+
} else {
238+
// getLeaves(...) is not supported on non-Unix systems, that's why we just destroy
239+
// with all trade-offs coming from that (see above)
240+
proc.destroy();
241+
}
242+
}
243+
198244
private String[] getShellCommandAsArray(String command) {
199245
// this is what Runtime.getRuntime().exec(...) is doing internally
200246
StringTokenizer tokenizer = new StringTokenizer(command);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.clevertap.maven.plugins.supertest.util;
2+
3+
import java.util.Locale;
4+
5+
public class OSUtil {
6+
private static final String OS = System.getProperty("os.name").toLowerCase(Locale.ENGLISH);
7+
8+
private OSUtil() {
9+
// prevents instance creation
10+
}
11+
12+
public static boolean isUnix() {
13+
return (OS.contains("nix") || OS.contains("nux") || OS.contains("aix")
14+
|| OS.contains("mac"));
15+
}
16+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.clevertap.maven.plugins.supertest.util;
2+
3+
import java.io.BufferedReader;
4+
import java.io.IOException;
5+
import java.io.InputStreamReader;
6+
import java.lang.reflect.Field;
7+
import java.util.ArrayList;
8+
import java.util.Arrays;
9+
import java.util.List;
10+
import java.util.stream.Collectors;
11+
12+
public class ProcessHelper {
13+
private final Runtime runtime;
14+
15+
public ProcessHelper() {
16+
this(Runtime.getRuntime());
17+
}
18+
19+
ProcessHelper(Runtime runtime) {
20+
this.runtime = runtime;
21+
}
22+
23+
/**
24+
* Gets the process id of a java process.
25+
*
26+
* @return the pid found or -1 in case of an error on unsupported OS
27+
*/
28+
@SuppressWarnings("java:S3011")
29+
public long getPid(Process process) {
30+
// java 8 does not have a handy util for getting pid, that's why reflection is needed
31+
// (since java 9, process management is much better)
32+
long pid = -1;
33+
34+
try {
35+
if (isUnixProcess(process)) {
36+
Field field = process.getClass().getDeclaredField("pid");
37+
field.setAccessible(true);
38+
39+
try {
40+
pid = field.getLong(process);
41+
} finally {
42+
field.setAccessible(false);
43+
}
44+
}
45+
} catch (Exception e) {
46+
pid = -1;
47+
}
48+
49+
return pid;
50+
}
51+
52+
@SuppressWarnings({"java:S1872"})
53+
public boolean isUnixProcess(Process process) {
54+
return process.getClass().getName().equals("java.lang.UNIXProcess");
55+
}
56+
57+
public Process sendSIGINT(Process process) throws IOException {
58+
return sendSIGINT(getPid(process));
59+
}
60+
61+
public Process sendSIGINT(long pid) throws IOException {
62+
return runtime.exec("kill -SIGINT " + pid);
63+
}
64+
65+
public Process sendSIGTERM(Process process) throws IOException {
66+
return sendSIGTERM(getPid(process));
67+
}
68+
69+
public Process sendSIGTERM(long pid) throws IOException {
70+
return runtime.exec("kill " + pid);
71+
}
72+
73+
// depends on the presence of pgrep command
74+
public List<Long> getChildren(long pid) {
75+
if (!OSUtil.isUnix()) {
76+
throw new UnsupportedOperationException("Not supported on non-Unix OS.");
77+
}
78+
79+
String children = runCommand("pgrep -P " + pid);
80+
return Arrays.stream(children.split("\n")).filter(c -> !c.isEmpty()).map(Long::parseLong)
81+
.collect(Collectors.toList());
82+
}
83+
84+
/**
85+
* Gets the process ids of all leaves in the process tree of the provided pid.
86+
*/
87+
public List<Long> getLeaves(long pid) {
88+
List<Long> leaves = new ArrayList<>();
89+
List<Long> children = getChildren(pid);
90+
91+
for (long child : children) {
92+
List<Long> childLeaves = getLeaves(child);
93+
94+
if (childLeaves.isEmpty()) {
95+
leaves.add(child);
96+
} else {
97+
leaves.addAll(childLeaves);
98+
}
99+
}
100+
101+
return leaves;
102+
}
103+
104+
private String runCommand(String command) {
105+
try {
106+
Process proc = runtime.exec(command);
107+
StringBuilder output = new StringBuilder();
108+
109+
try (BufferedReader reader =
110+
new BufferedReader(new InputStreamReader(proc.getInputStream()))) {
111+
String line;
112+
113+
while ((line = reader.readLine()) != null) {
114+
output.append(line).append('\n');
115+
}
116+
}
117+
118+
return output.toString();
119+
} catch (IOException e) {
120+
throw new RuntimeException(e);
121+
}
122+
}
123+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.clevertap.maven.plugins.supertest.util;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertTrue;
5+
import static org.junit.Assume.assumeTrue;
6+
import static org.mockito.Mockito.doReturn;
7+
import static org.mockito.Mockito.mock;
8+
import static org.mockito.Mockito.when;
9+
10+
import java.io.ByteArrayInputStream;
11+
import java.io.IOException;
12+
import java.util.Arrays;
13+
import java.util.HashSet;
14+
import java.util.List;
15+
import java.util.Set;
16+
import org.junit.Before;
17+
import org.junit.Test;
18+
import org.mockito.internal.util.collections.Sets;
19+
20+
public class ProcessHelperTest {
21+
private Runtime runtime;
22+
private ProcessHelper processHelper;
23+
24+
@Before
25+
public void setUp() {
26+
runtime = mock(Runtime.class);
27+
processHelper = new ProcessHelper(runtime);
28+
}
29+
30+
@Test
31+
public void testGetPid() throws IOException {
32+
assumeTrue(OSUtil.isUnix());
33+
34+
Process process = Runtime.getRuntime().exec("ls");
35+
long pid = processHelper.getPid(process);
36+
assertTrue(pid > 0);
37+
}
38+
39+
@Test
40+
public void testIsUnixProcess() throws IOException {
41+
assertTrue(OSUtil.isUnix());
42+
assertTrue(processHelper.isUnixProcess(Runtime.getRuntime().exec("ls")));
43+
}
44+
45+
@Test
46+
public void testGetChildren() throws IOException {
47+
assumeTrue(OSUtil.isUnix());
48+
49+
doReturn(mockProcess("54321\n65432\n")).when(runtime).exec("pgrep -P 12345");
50+
List<Long> children = processHelper.getChildren(12345L);
51+
assertEquals(Arrays.asList(54321L, 65432L), children);
52+
}
53+
54+
@Test
55+
public void testGetLeaves() throws IOException {
56+
assumeTrue(OSUtil.isUnix());
57+
58+
doReturn(mockProcess("54321\n65432\n")).when(runtime).exec("pgrep -P 12345");
59+
doReturn(mockProcess("111\n222\n")).when(runtime).exec("pgrep -P 54321");
60+
doReturn(mockProcess("")).when(runtime).exec("pgrep -P 65432");
61+
doReturn(mockProcess("")).when(runtime).exec("pgrep -P 111");
62+
doReturn(mockProcess("")).when(runtime).exec("pgrep -P 222");
63+
64+
Set<Long> expectedLeaves = Sets.newSet(222L, 111L, 65432L);
65+
Set<Long> actualLeaves = new HashSet<>(processHelper.getLeaves(12345L));
66+
67+
assertEquals(expectedLeaves, actualLeaves);
68+
}
69+
70+
@Test
71+
public void testSendSIGINT() throws Exception {
72+
Process killProcess = mock(Process.class);
73+
when(runtime.exec("kill -SIGINT 12345")).thenReturn(killProcess);
74+
75+
Process process = processHelper.sendSIGINT(12345L);
76+
assertEquals(killProcess, process);
77+
}
78+
79+
@Test
80+
public void testSendSIGTERM() throws Exception {
81+
Process killProcess = mock(Process.class);
82+
when(runtime.exec("kill 12345")).thenReturn(killProcess);
83+
84+
Process process = processHelper.sendSIGTERM(12345L);
85+
assertEquals(killProcess, process);
86+
}
87+
88+
private Process mockProcess(String output) {
89+
Process process = mock(Process.class);
90+
when(process.getInputStream())
91+
.thenReturn(new ByteArrayInputStream(output.getBytes()));
92+
93+
return process;
94+
}
95+
}

0 commit comments

Comments
 (0)