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