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