View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.surefire.booter;
20  
21  import javax.annotation.Nonnull;
22  
23  import java.io.File;
24  import java.io.IOException;
25  import java.io.InterruptedIOException;
26  import java.nio.file.Path;
27  import java.text.SimpleDateFormat;
28  import java.util.Queue;
29  import java.util.Scanner;
30  import java.util.TimeZone;
31  import java.util.concurrent.ConcurrentLinkedQueue;
32  import java.util.regex.Matcher;
33  import java.util.regex.Pattern;
34  
35  import org.apache.maven.surefire.api.booter.DumpErrorSingleton;
36  import org.apache.maven.surefire.api.util.SureFireFileManager;
37  
38  import static java.lang.Integer.parseInt;
39  import static java.lang.Long.parseLong;
40  import static java.lang.String.join;
41  import static java.nio.file.Files.delete;
42  import static java.nio.file.Files.readAllBytes;
43  import static java.util.concurrent.TimeUnit.DAYS;
44  import static java.util.concurrent.TimeUnit.HOURS;
45  import static java.util.concurrent.TimeUnit.MINUTES;
46  import static java.util.regex.Pattern.compile;
47  import static org.apache.maven.surefire.api.util.internal.StringUtils.NL;
48  import static org.apache.maven.surefire.booter.ProcessInfo.ERR_PROCESS_INFO;
49  import static org.apache.maven.surefire.booter.ProcessInfo.INVALID_PROCESS_INFO;
50  import static org.apache.maven.surefire.booter.ProcessInfo.unixProcessInfo;
51  import static org.apache.maven.surefire.booter.ProcessInfo.windowsProcessInfo;
52  import static org.apache.maven.surefire.shared.lang3.StringUtils.isNotBlank;
53  import static org.apache.maven.surefire.shared.lang3.SystemUtils.IS_OS_HP_UX;
54  import static org.apache.maven.surefire.shared.lang3.SystemUtils.IS_OS_LINUX;
55  import static org.apache.maven.surefire.shared.lang3.SystemUtils.IS_OS_UNIX;
56  import static org.apache.maven.surefire.shared.lang3.SystemUtils.IS_OS_WINDOWS;
57  
58  /**
59   * Recognizes PID of Plugin process and determines lifetime.
60   *
61   * @author <a href="mailto:tibordigana@apache.org">Tibor Digana (tibor17)</a>
62   * @since 2.20.1
63   */
64  final class PpidChecker {
65      private static final long MINUTES_TO_MILLIS = 60L * 1000L;
66      // 25 chars https://superuser.com/questions/937380/get-creation-time-of-file-in-milliseconds/937401#937401
67      private static final int WMIC_CREATION_DATE_VALUE_LENGTH = 25;
68      private static final int WMIC_CREATION_DATE_TIMESTAMP_LENGTH = 18;
69      private static final SimpleDateFormat WMIC_CREATION_DATE_FORMAT =
70              IS_OS_WINDOWS ? createWindowsCreationDateFormat() : null;
71      private static final String WMIC_CREATION_DATE = "CreationDate";
72      private static final String WINDOWS_SYSTEM_ROOT_ENV = "SystemRoot";
73      private static final String RELATIVE_PATH_TO_WMIC = "System32\\Wbem";
74      private static final String SYSTEM_PATH_TO_WMIC =
75              "%" + WINDOWS_SYSTEM_ROOT_ENV + "%\\" + RELATIVE_PATH_TO_WMIC + "\\";
76      private static final String PS_ETIME_HEADER = "ELAPSED";
77      private static final String PS_PID_HEADER = "PID";
78  
79      private final Queue<Process> destroyableCommands = new ConcurrentLinkedQueue<>();
80  
81      /**
82       * The etime is in the form of [[dd-]hh:]mm:ss on Unix like systems.
83       * See the workaround https://issues.apache.org/jira/browse/SUREFIRE-1451.
84       */
85      static final Pattern UNIX_CMD_OUT_PATTERN = compile("^(((\\d+)-)?(\\d{1,2}):)?(\\d{1,2}):(\\d{1,2})\\s+(\\d+)$");
86  
87      static final Pattern BUSYBOX_CMD_OUT_PATTERN = compile("^(\\d+)[hH](\\d{1,2})\\s+(\\d+)$");
88  
89      private final String ppid;
90  
91      private volatile ProcessInfo parentProcessInfo;
92      private volatile boolean stopped;
93  
94      PpidChecker(@Nonnull String ppid) {
95          this.ppid = ppid;
96      }
97  
98      boolean canUse() {
99          if (isStopped()) {
100             return false;
101         }
102         final ProcessInfo ppi = parentProcessInfo;
103         return ppi == null ? IS_OS_WINDOWS || IS_OS_UNIX && canExecuteUnixPs() : ppi.canUse();
104     }
105 
106     /**
107      * This method can be called only after {@link #canUse()} has returned {@code true}.
108      *
109      * @return {@code true} if parent process is alive; {@code false} otherwise
110      * @throws IllegalStateException if {@link #canUse()} returns {@code false}, error to read process
111      *                               or this object has been {@link #destroyActiveCommands() destroyed}
112      * @throws NullPointerException if extracted e-time is null
113      */
114     boolean isProcessAlive() {
115         if (!canUse()) {
116             throw new IllegalStateException("irrelevant to call isProcessAlive()");
117         }
118 
119         final ProcessInfo previousInfo = parentProcessInfo;
120         if (IS_OS_WINDOWS) {
121             parentProcessInfo = windows();
122             checkProcessInfo();
123 
124             // let's compare creation time, should be same unless killed or PID is reused by OS into another process
125             return !parentProcessInfo.isInvalid()
126                     && (previousInfo == null || parentProcessInfo.isTimeEqualTo(previousInfo));
127         } else if (IS_OS_UNIX) {
128             parentProcessInfo = unix();
129             checkProcessInfo();
130 
131             // let's compare elapsed time, should be greater or equal if parent process is the same and still alive
132             return !parentProcessInfo.isInvalid()
133                     && (previousInfo == null || !parentProcessInfo.isTimeBefore(previousInfo));
134         }
135         parentProcessInfo = ERR_PROCESS_INFO;
136         throw new IllegalStateException("unknown platform or you did not call canUse() before isProcessAlive()");
137     }
138 
139     private void checkProcessInfo() {
140         if (isStopped()) {
141             throw new IllegalStateException("error [STOPPED] to read process " + ppid);
142         }
143 
144         if (!parentProcessInfo.canUse()) {
145             throw new IllegalStateException(
146                     "Cannot use PPID " + ppid + " process information. " + "Going to use NOOP events.");
147         }
148     }
149 
150     // https://www.freebsd.org/cgi/man.cgi?ps(1)
151     // etimes elapsed running time, in decimal integer seconds
152 
153     // http://manpages.ubuntu.com/manpages/xenial/man1/ps.1.html
154     // etimes elapsed time since the process was started, in seconds.
155 
156     // http://hg.openjdk.java.net/jdk7/jdk7/jdk/file/9b8c96f96a0f/test/java/lang/ProcessBuilder/Basic.java#L167
157     ProcessInfo unix() {
158         String charset = System.getProperty("native.encoding", System.getProperty("file.encoding", "UTF-8"));
159         ProcessInfoConsumer reader = new ProcessInfoConsumer(charset) {
160             @Override
161             @Nonnull
162             ProcessInfo consumeLine(String line, ProcessInfo previousOutputLine) {
163                 if (previousOutputLine.isInvalid()) {
164                     if (hasHeader) {
165                         Matcher matcher = UNIX_CMD_OUT_PATTERN.matcher(line);
166                         if (matcher.matches() && ppid.equals(fromPID(matcher))) {
167                             long pidUptime = fromDays(matcher)
168                                     + fromHours(matcher)
169                                     + fromMinutes(matcher)
170                                     + fromSeconds(matcher);
171                             return unixProcessInfo(ppid, pidUptime);
172                         }
173                         matcher = BUSYBOX_CMD_OUT_PATTERN.matcher(line);
174                         if (matcher.matches() && ppid.equals(fromBusyboxPID(matcher))) {
175                             long pidUptime = fromBusyboxHours(matcher) + fromBusyboxMinutes(matcher);
176                             return unixProcessInfo(ppid, pidUptime);
177                         }
178                     } else {
179                         hasHeader = line.contains(PS_ETIME_HEADER) && line.contains(PS_PID_HEADER);
180                     }
181                 }
182                 return previousOutputLine;
183             }
184         };
185         String cmd = unixPathToPS() + " -o etime,pid " + (IS_OS_LINUX ? "" : "-p ") + ppid;
186         return reader.execute("/bin/sh", "-c", cmd);
187     }
188 
189     ProcessInfo windows() {
190         ProcessInfoConsumer reader = new ProcessInfoConsumer("US-ASCII") {
191             @Override
192             @Nonnull
193             ProcessInfo consumeLine(String line, ProcessInfo previousProcessInfo) throws Exception {
194                 if (previousProcessInfo.isInvalid() && !line.isEmpty()) {
195                     if (hasHeader) {
196                         // now the line is CreationDate, e.g. 20180406142327.741074+120
197                         if (line.length() != WMIC_CREATION_DATE_VALUE_LENGTH) {
198                             throw new IllegalStateException("WMIC CreationDate should have 25 characters " + line);
199                         }
200                         String startTimestamp = line.substring(0, WMIC_CREATION_DATE_TIMESTAMP_LENGTH);
201                         int indexOfTimeZone = WMIC_CREATION_DATE_VALUE_LENGTH - 4;
202                         long startTimestampMillisUTC =
203                                 WMIC_CREATION_DATE_FORMAT.parse(startTimestamp).getTime()
204                                         - parseInt(line.substring(indexOfTimeZone)) * MINUTES_TO_MILLIS;
205                         return windowsProcessInfo(ppid, startTimestampMillisUTC);
206                     } else {
207                         hasHeader = WMIC_CREATION_DATE.equals(line);
208                     }
209                 }
210                 return previousProcessInfo;
211             }
212         };
213         String wmicPath = hasWmicStandardSystemPath() ? SYSTEM_PATH_TO_WMIC : "";
214         return reader.execute(
215                 "CMD",
216                 "/A",
217                 "/X",
218                 "/C",
219                 wmicPath + "wmic process where (ProcessId=" + ppid + ") get " + WMIC_CREATION_DATE);
220     }
221 
222     void destroyActiveCommands() {
223         stopped = true;
224         for (Process p = destroyableCommands.poll(); p != null; p = destroyableCommands.poll()) {
225             p.destroy();
226         }
227     }
228 
229     boolean isStopped() {
230         return stopped;
231     }
232 
233     private static String unixPathToPS() {
234         return canExecuteLocalUnixPs() ? "/usr/bin/ps" : "/bin/ps";
235     }
236 
237     static boolean canExecuteUnixPs() {
238         return canExecuteLocalUnixPs() || canExecuteStandardUnixPs();
239     }
240 
241     private static boolean canExecuteLocalUnixPs() {
242         try {
243             return new File("/usr/bin/ps").canExecute();
244         } catch (SecurityException e) {
245             return false;
246         }
247     }
248 
249     private static boolean canExecuteStandardUnixPs() {
250         try {
251             return new File("/bin/ps").canExecute();
252         } catch (SecurityException e) {
253             return false;
254         }
255     }
256 
257     private static boolean hasWmicStandardSystemPath() {
258         String systemRoot = System.getenv(WINDOWS_SYSTEM_ROOT_ENV);
259         return isNotBlank(systemRoot) && new File(systemRoot, RELATIVE_PATH_TO_WMIC + "\\wmic.exe").isFile();
260     }
261 
262     static long fromDays(Matcher matcher) {
263         String s = matcher.group(3);
264         return s == null ? 0L : DAYS.toSeconds(parseLong(s));
265     }
266 
267     static long fromHours(Matcher matcher) {
268         String s = matcher.group(4);
269         return s == null ? 0L : HOURS.toSeconds(parseLong(s));
270     }
271 
272     static long fromMinutes(Matcher matcher) {
273         String s = matcher.group(5);
274         return s == null ? 0L : MINUTES.toSeconds(parseLong(s));
275     }
276 
277     static long fromSeconds(Matcher matcher) {
278         String s = matcher.group(6);
279         return s == null ? 0L : parseLong(s);
280     }
281 
282     static String fromPID(Matcher matcher) {
283         return matcher.group(7);
284     }
285 
286     static long fromBusyboxHours(Matcher matcher) {
287         String s = matcher.group(1);
288         return s == null ? 0L : HOURS.toSeconds(parseLong(s));
289     }
290 
291     static long fromBusyboxMinutes(Matcher matcher) {
292         String s = matcher.group(2);
293         return s == null ? 0L : MINUTES.toSeconds(parseLong(s));
294     }
295 
296     static String fromBusyboxPID(Matcher matcher) {
297         return matcher.group(3);
298     }
299 
300     private static void checkValid(Scanner scanner) throws IOException {
301         IOException exception = scanner.ioException();
302         if (exception != null) {
303             throw exception;
304         }
305     }
306 
307     /**
308      * The beginning part of Windows WMIC format yyyymmddHHMMSS.xxx <br>
309      * https://technet.microsoft.com/en-us/library/ee198928.aspx <br>
310      * We use UTC time zone which avoids DST changes, see SUREFIRE-1512.
311      *
312      * @return Windows WMIC format yyyymmddHHMMSS.xxx
313      */
314     private static SimpleDateFormat createWindowsCreationDateFormat() {
315         SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMddHHmmss'.'SSS");
316         formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
317         return formatter;
318     }
319 
320     public void stop() {
321         stopped = true;
322     }
323 
324     /**
325      * Reads standard output from {@link Process}.
326      * <br>
327      * The artifact maven-shared-utils has non-daemon Threads which is an issue in Surefire to satisfy System.exit.
328      * This implementation is taylor made without using any Thread.
329      * It's easy to destroy Process from other Thread.
330      */
331     abstract class ProcessInfoConsumer {
332         private final String charset;
333 
334         boolean hasHeader;
335 
336         ProcessInfoConsumer(String charset) {
337             this.charset = charset;
338         }
339 
340         abstract @Nonnull ProcessInfo consumeLine(String line, ProcessInfo previousProcessInfo) throws Exception;
341 
342         ProcessInfo execute(String... command) {
343             ProcessBuilder processBuilder = new ProcessBuilder(command);
344             Process process = null;
345             ProcessInfo processInfo = INVALID_PROCESS_INFO;
346             StringBuilder out = new StringBuilder(64);
347             out.append(join(" ", command)).append(NL);
348             Path stdErr = null;
349             try {
350                 stdErr = SureFireFileManager.createTempFile("surefire", null).toPath();
351 
352                 processBuilder.redirectError(stdErr.toFile());
353                 if (IS_OS_HP_UX) // force to run shell commands in UNIX Standard mode on HP-UX
354                 {
355                     processBuilder.environment().put("UNIX95", "1");
356                 }
357                 process = processBuilder.start();
358                 destroyableCommands.add(process);
359                 Scanner scanner = new Scanner(process.getInputStream(), charset);
360                 while (scanner.hasNextLine()) {
361                     String line = scanner.nextLine();
362                     out.append(line).append(NL);
363                     processInfo = consumeLine(line.trim(), processInfo);
364                 }
365                 checkValid(scanner);
366                 int exitCode = process.waitFor();
367                 boolean isError = Thread.interrupted() || isStopped();
368                 if (exitCode != 0 || isError) {
369                     out.append("<<exit>> <<")
370                             .append(exitCode)
371                             .append(">>")
372                             .append(NL)
373                             .append("<<stopped>> <<")
374                             .append(isStopped())
375                             .append(">>");
376                     DumpErrorSingleton.getSingleton().dumpText(out.toString());
377                 }
378 
379                 return isError ? ERR_PROCESS_INFO : (exitCode == 0 ? processInfo : INVALID_PROCESS_INFO);
380             } catch (Exception e) {
381                 if (!(e instanceof InterruptedException
382                         || e instanceof InterruptedIOException
383                         || e.getCause() instanceof InterruptedException)) {
384                     DumpErrorSingleton.getSingleton().dumpText(out.toString());
385 
386                     DumpErrorSingleton.getSingleton().dumpException(e);
387                 }
388 
389                 //noinspection ResultOfMethodCallIgnored
390                 Thread.interrupted();
391 
392                 return ERR_PROCESS_INFO;
393             } finally {
394                 if (process != null) {
395                     destroyableCommands.remove(process);
396                     closeQuietly(process.getInputStream());
397                     closeQuietly(process.getErrorStream());
398                     closeQuietly(process.getOutputStream());
399                 }
400 
401                 if (stdErr != null) {
402                     try {
403                         String error = new String(readAllBytes(stdErr)).trim();
404                         if (!error.isEmpty()) {
405                             DumpErrorSingleton.getSingleton().dumpText(error);
406                         }
407                         delete(stdErr);
408                     } catch (IOException e) {
409                         // cannot do anything about it, the dump file writes would fail as well
410                     }
411                 }
412             }
413         }
414 
415         private void closeQuietly(AutoCloseable autoCloseable) {
416             if (autoCloseable != null) {
417                 try {
418                     autoCloseable.close();
419                 } catch (Exception e) {
420                     // ignore
421                 }
422             }
423         }
424     }
425 
426     @Override
427     public String toString() {
428         String args = "ppid=" + ppid + ", stopped=" + stopped;
429 
430         ProcessInfo processInfo = parentProcessInfo;
431         if (processInfo != null) {
432             args += ", invalid=" + processInfo.isInvalid() + ", error=" + processInfo.isError();
433         }
434 
435         if (IS_OS_UNIX) {
436             args += ", canExecuteLocalUnixPs=" + canExecuteLocalUnixPs() + ", canExecuteStandardUnixPs="
437                     + canExecuteStandardUnixPs();
438         }
439 
440         return "PpidChecker{" + args + '}';
441     }
442 }