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