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_POWERSHELL = "System32\\WindowsPowerShell\\v1.0";
74      private static final String SYSTEM_PATH_TO_POWERSHELL =
75              System.getenv(WINDOWS_SYSTEM_ROOT_ENV) + "\\" + RELATIVE_PATH_TO_POWERSHELL + "\\";
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             if (!checkProcessInfo()) {
123                 return false;
124             }
125             // let's compare creation time, should be same unless killed or PID is reused by OS into another process
126             return !parentProcessInfo.isInvalid()
127                     && (previousInfo == null || parentProcessInfo.isTimeEqualTo(previousInfo));
128         } else if (IS_OS_UNIX) {
129             parentProcessInfo = unix();
130             checkProcessInfo();
131 
132             // let's compare elapsed time, should be greater or equal if parent process is the same and still alive
133             return !parentProcessInfo.isInvalid()
134                     && (previousInfo == null || !parentProcessInfo.isTimeBefore(previousInfo));
135         }
136         parentProcessInfo = ERR_PROCESS_INFO;
137         throw new IllegalStateException("unknown platform or you did not call canUse() before isProcessAlive()");
138     }
139 
140     /**
141      *
142      * @return true if process info is valid to use; false if this object has been {@link #destroyActiveCommands() destroyed}
143      * @throws IllegalStateException if process info cannot be used
144      */
145     private boolean checkProcessInfo() {
146         if (isStopped()) {
147             //            throw new IllegalStateException("error [STOPPED] to read process " + this);
148             return false;
149         }
150 
151         if (!parentProcessInfo.canUse()) {
152             throw new IllegalStateException(
153                     "Cannot use PPID " + ppid + " process information. " + "Going to use NOOP events.");
154         }
155         return true;
156     }
157 
158     // https://www.freebsd.org/cgi/man.cgi?ps(1)
159     // etimes elapsed running time, in decimal integer seconds
160 
161     // http://manpages.ubuntu.com/manpages/xenial/man1/ps.1.html
162     // etimes elapsed time since the process was started, in seconds.
163 
164     // http://hg.openjdk.java.net/jdk7/jdk7/jdk/file/9b8c96f96a0f/test/java/lang/ProcessBuilder/Basic.java#L167
165     ProcessInfo unix() {
166         String charset = System.getProperty("native.encoding", System.getProperty("file.encoding", "UTF-8"));
167         ProcessInfoConsumer reader = new ProcessInfoConsumer(charset) {
168             @Override
169             @Nonnull
170             ProcessInfo consumeLine(String line, ProcessInfo previousOutputLine) {
171                 if (previousOutputLine.isInvalid()) {
172                     if (hasHeader) {
173                         Matcher matcher = UNIX_CMD_OUT_PATTERN.matcher(line);
174                         if (matcher.matches() && ppid.equals(fromPID(matcher))) {
175                             long pidUptime = fromDays(matcher)
176                                     + fromHours(matcher)
177                                     + fromMinutes(matcher)
178                                     + fromSeconds(matcher);
179                             return unixProcessInfo(ppid, pidUptime);
180                         }
181                         matcher = BUSYBOX_CMD_OUT_PATTERN.matcher(line);
182                         if (matcher.matches() && ppid.equals(fromBusyboxPID(matcher))) {
183                             long pidUptime = fromBusyboxHours(matcher) + fromBusyboxMinutes(matcher);
184                             return unixProcessInfo(ppid, pidUptime);
185                         }
186                     } else {
187                         hasHeader = line.contains(PS_ETIME_HEADER) && line.contains(PS_PID_HEADER);
188                     }
189                 }
190                 return previousOutputLine;
191             }
192         };
193         String cmd = unixPathToPS() + " -o etime,pid " + (IS_OS_LINUX ? "" : "-p ") + ppid;
194         return reader.execute("/bin/sh", "-c", cmd);
195     }
196 
197     ProcessInfo windows() {
198         ProcessInfoConsumer reader = new ProcessInfoConsumer("US-ASCII") {
199             @Override
200             @Nonnull
201             ProcessInfo consumeLine(String line, ProcessInfo previousProcessInfo) throws Exception {
202                 if (previousProcessInfo.isInvalid() && !line.isEmpty()) {
203                     // we still use WMIC output format even though we now use PowerShell to produce it
204                     if (hasHeader) {
205                         // now the line is CreationDate, e.g. 20180406142327.741074+120
206                         if (line.length() != WMIC_CREATION_DATE_VALUE_LENGTH) {
207                             throw new IllegalStateException("WMIC CreationDate should have 25 characters " + line);
208                         }
209                         String startTimestamp = line.substring(0, WMIC_CREATION_DATE_TIMESTAMP_LENGTH);
210                         int indexOfTimeZone = WMIC_CREATION_DATE_VALUE_LENGTH - 4;
211                         long startTimestampMillisUTC =
212                                 WMIC_CREATION_DATE_FORMAT.parse(startTimestamp).getTime()
213                                         - parseInt(line.substring(indexOfTimeZone)) * MINUTES_TO_MILLIS;
214                         return windowsProcessInfo(ppid, startTimestampMillisUTC);
215                     } else {
216                         hasHeader = WMIC_CREATION_DATE.equals(line);
217                     }
218                 }
219                 return previousProcessInfo;
220             }
221         };
222 
223         String psPath = hasPowerShellStandardSystemPath() ? SYSTEM_PATH_TO_POWERSHELL : "";
224         // mimic output format of the original check:
225         // wmic process where (ProcessId=<ppid>) get CreationDate
226         String psCommand = String.format(
227                 "Add-Type -AssemblyName System.Management; "
228                         + "$p = Get-CimInstance Win32_Process -Filter 'ProcessId=%2$s'; "
229                         + "if ($p) { "
230                         + "    Write-Output '%1$s'; "
231                         + "    [System.Management.ManagementDateTimeConverter]::ToDmtfDateTime($p.CreationDate) "
232                         + "}",
233                 WMIC_CREATION_DATE, ppid);
234         return reader.execute(psPath + "powershell", "-NoProfile", "-NonInteractive", "-Command", psCommand);
235     }
236 
237     void destroyActiveCommands() {
238         stopped = true;
239         for (Process p = destroyableCommands.poll(); p != null; p = destroyableCommands.poll()) {
240             p.destroy();
241         }
242     }
243 
244     boolean isStopped() {
245         return stopped;
246     }
247 
248     private static String unixPathToPS() {
249         return canExecuteLocalUnixPs() ? "/usr/bin/ps" : "/bin/ps";
250     }
251 
252     static boolean canExecuteUnixPs() {
253         return canExecuteLocalUnixPs() || canExecuteStandardUnixPs();
254     }
255 
256     private static boolean canExecuteLocalUnixPs() {
257         try {
258             return new File("/usr/bin/ps").canExecute();
259         } catch (SecurityException e) {
260             return false;
261         }
262     }
263 
264     private static boolean canExecuteStandardUnixPs() {
265         try {
266             return new File("/bin/ps").canExecute();
267         } catch (SecurityException e) {
268             return false;
269         }
270     }
271 
272     private static boolean hasPowerShellStandardSystemPath() {
273         String systemRoot = System.getenv(WINDOWS_SYSTEM_ROOT_ENV);
274         return isNotBlank(systemRoot)
275                 && new File(systemRoot, RELATIVE_PATH_TO_POWERSHELL + "\\powershell.exe").isFile();
276     }
277 
278     static long fromDays(Matcher matcher) {
279         String s = matcher.group(3);
280         return s == null ? 0L : DAYS.toSeconds(parseLong(s));
281     }
282 
283     static long fromHours(Matcher matcher) {
284         String s = matcher.group(4);
285         return s == null ? 0L : HOURS.toSeconds(parseLong(s));
286     }
287 
288     static long fromMinutes(Matcher matcher) {
289         String s = matcher.group(5);
290         return s == null ? 0L : MINUTES.toSeconds(parseLong(s));
291     }
292 
293     static long fromSeconds(Matcher matcher) {
294         String s = matcher.group(6);
295         return s == null ? 0L : parseLong(s);
296     }
297 
298     static String fromPID(Matcher matcher) {
299         return matcher.group(7);
300     }
301 
302     static long fromBusyboxHours(Matcher matcher) {
303         String s = matcher.group(1);
304         return s == null ? 0L : HOURS.toSeconds(parseLong(s));
305     }
306 
307     static long fromBusyboxMinutes(Matcher matcher) {
308         String s = matcher.group(2);
309         return s == null ? 0L : MINUTES.toSeconds(parseLong(s));
310     }
311 
312     static String fromBusyboxPID(Matcher matcher) {
313         return matcher.group(3);
314     }
315 
316     private static void checkValid(Scanner scanner) throws IOException {
317         IOException exception = scanner.ioException();
318         if (exception != null) {
319             throw exception;
320         }
321     }
322 
323     /**
324      * The beginning part of Windows WMIC format yyyymmddHHMMSS.xxx <br>
325      * https://technet.microsoft.com/en-us/library/ee198928.aspx <br>
326      * We use UTC time zone which avoids DST changes, see SUREFIRE-1512.
327      *
328      * @return Windows WMIC format yyyymmddHHMMSS.xxx
329      */
330     private static SimpleDateFormat createWindowsCreationDateFormat() {
331         SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMddHHmmss'.'SSS");
332         formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
333         return formatter;
334     }
335 
336     public void stop() {
337         stopped = true;
338     }
339 
340     /**
341      * Reads standard output from {@link Process}.
342      * <br>
343      * The artifact maven-shared-utils has non-daemon Threads which is an issue in Surefire to satisfy System.exit.
344      * This implementation is taylor made without using any Thread.
345      * It's easy to destroy Process from other Thread.
346      */
347     abstract class ProcessInfoConsumer {
348         private final String charset;
349 
350         boolean hasHeader;
351 
352         ProcessInfoConsumer(String charset) {
353             this.charset = charset;
354         }
355 
356         abstract @Nonnull ProcessInfo consumeLine(String line, ProcessInfo previousProcessInfo) throws Exception;
357 
358         ProcessInfo execute(String... command) {
359             ProcessBuilder processBuilder = new ProcessBuilder(command);
360             Process process = null;
361             ProcessInfo processInfo = INVALID_PROCESS_INFO;
362             StringBuilder out = new StringBuilder(64);
363             out.append(join(" ", command)).append(NL);
364             Path stdErr = null;
365             try {
366                 stdErr = SureFireFileManager.createTempFile("surefire", null).toPath();
367 
368                 processBuilder.redirectError(stdErr.toFile());
369                 if (IS_OS_HP_UX) // force to run shell commands in UNIX Standard mode on HP-UX
370                 {
371                     processBuilder.environment().put("UNIX95", "1");
372                 }
373                 process = processBuilder.start();
374                 destroyableCommands.add(process);
375                 Scanner scanner = new Scanner(process.getInputStream(), charset);
376                 while (scanner.hasNextLine()) {
377                     String line = scanner.nextLine();
378                     out.append(line).append(NL);
379                     processInfo = consumeLine(line.trim(), processInfo);
380                 }
381                 checkValid(scanner);
382                 int exitCode = process.waitFor();
383                 boolean isError = Thread.interrupted() || isStopped();
384                 if (exitCode != 0 || isError) {
385                     out.append("<<exit>> <<")
386                             .append(exitCode)
387                             .append(">>")
388                             .append(NL)
389                             .append("<<stopped>> <<")
390                             .append(isStopped())
391                             .append(">>");
392                     DumpErrorSingleton.getSingleton().dumpText(out.toString());
393                 }
394 
395                 return isError ? ERR_PROCESS_INFO : (exitCode == 0 ? processInfo : INVALID_PROCESS_INFO);
396             } catch (Exception e) {
397                 if (!(e instanceof InterruptedException
398                         || e instanceof InterruptedIOException
399                         || e.getCause() instanceof InterruptedException)) {
400                     DumpErrorSingleton.getSingleton().dumpText(out.toString());
401 
402                     DumpErrorSingleton.getSingleton().dumpException(e);
403                 }
404 
405                 //noinspection ResultOfMethodCallIgnored
406                 Thread.interrupted();
407 
408                 return ERR_PROCESS_INFO;
409             } finally {
410                 if (process != null) {
411                     destroyableCommands.remove(process);
412                     closeQuietly(process.getInputStream());
413                     closeQuietly(process.getErrorStream());
414                     closeQuietly(process.getOutputStream());
415                 }
416 
417                 if (stdErr != null) {
418                     try {
419                         String error = new String(readAllBytes(stdErr)).trim();
420                         if (!error.isEmpty()) {
421                             DumpErrorSingleton.getSingleton().dumpText(error);
422                         }
423                         delete(stdErr);
424                     } catch (IOException e) {
425                         // cannot do anything about it, the dump file writes would fail as well
426                     }
427                 }
428             }
429         }
430 
431         private void closeQuietly(AutoCloseable autoCloseable) {
432             if (autoCloseable != null) {
433                 try {
434                     autoCloseable.close();
435                 } catch (Exception e) {
436                     // ignore
437                 }
438             }
439         }
440     }
441 
442     @Override
443     public String toString() {
444         String args = "ppid=" + ppid + ", stopped=" + stopped;
445 
446         ProcessInfo processInfo = parentProcessInfo;
447         if (processInfo != null) {
448             args += ", invalid=" + processInfo.isInvalid() + ", error=" + processInfo.isError();
449         }
450 
451         if (IS_OS_UNIX) {
452             args += ", canExecuteLocalUnixPs=" + canExecuteLocalUnixPs() + ", canExecuteStandardUnixPs="
453                     + canExecuteStandardUnixPs();
454         }
455 
456         return "PpidChecker{" + args + '}';
457     }
458 }