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