View Javadoc
1   package org.apache.maven.plugins.clean;
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 java.io.File;
23  import java.io.IOException;
24  import java.lang.reflect.InvocationHandler;
25  import java.lang.reflect.Method;
26  import java.lang.reflect.Proxy;
27  import java.nio.file.Files;
28  import java.nio.file.Path;
29  import java.nio.file.StandardCopyOption;
30  import java.util.ArrayDeque;
31  import java.util.Deque;
32  
33  import org.apache.maven.execution.ExecutionListener;
34  import org.apache.maven.execution.MavenSession;
35  import org.apache.maven.plugin.logging.Log;
36  import org.apache.maven.shared.utils.Os;
37  import org.eclipse.aether.SessionData;
38  
39  import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_BACKGROUND;
40  import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_DEFER;
41  
42  /**
43   * Cleans directories.
44   * 
45   * @author Benjamin Bentmann
46   */
47  class Cleaner
48  {
49  
50      private static final boolean ON_WINDOWS = Os.isFamily( Os.FAMILY_WINDOWS );
51  
52      private static final String LAST_DIRECTORY_TO_DELETE = Cleaner.class.getName() + ".lastDirectoryToDelete";
53  
54      /**
55       * The maven session.  This is typically non-null in a real run, but it can be during unit tests.
56       */
57      private final MavenSession session;
58  
59      private final Logger logDebug;
60  
61      private final Logger logInfo;
62  
63      private final Logger logVerbose;
64  
65      private final Logger logWarn;
66  
67      private final File fastDir;
68  
69      private final String fastMode;
70  
71      /**
72       * Creates a new cleaner.
73       * @param log The logger to use, may be <code>null</code> to disable logging.
74       * @param verbose Whether to perform verbose logging.
75       * @param fastMode The fast deletion mode
76       */
77      Cleaner( MavenSession session, final Log log, boolean verbose, File fastDir, String fastMode )
78      {
79          logDebug = ( log == null || !log.isDebugEnabled() ) ? null : log::debug;
80  
81          logInfo = ( log == null || !log.isInfoEnabled() ) ? null : log::info;
82  
83          logWarn = ( log == null || !log.isWarnEnabled() ) ? null : log::warn;
84  
85          logVerbose = verbose ? logInfo : logDebug;
86  
87          this.session = session;
88          this.fastDir = fastDir;
89          this.fastMode = fastMode;
90      }
91  
92      /**
93       * Deletes the specified directories and its contents.
94       * 
95       * @param basedir The directory to delete, must not be <code>null</code>. Non-existing directories will be silently
96       *            ignored.
97       * @param selector The selector used to determine what contents to delete, may be <code>null</code> to delete
98       *            everything.
99       * @param followSymlinks Whether to follow symlinks.
100      * @param failOnError Whether to abort with an exception in case a selected file/directory could not be deleted.
101      * @param retryOnError Whether to undertake additional delete attempts in case the first attempt failed.
102      * @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
103      */
104     public void delete( File basedir, Selector selector, boolean followSymlinks, boolean failOnError,
105                         boolean retryOnError )
106         throws IOException
107     {
108         if ( !basedir.isDirectory() )
109         {
110             if ( !basedir.exists() )
111             {
112                 if ( logDebug != null )
113                 {
114                     logDebug.log( "Skipping non-existing directory " + basedir );
115                 }
116                 return;
117             }
118             throw new IOException( "Invalid base directory " + basedir );
119         }
120 
121         if ( logInfo != null )
122         {
123             logInfo.log( "Deleting " + basedir + ( selector != null ? " (" + selector + ")" : "" ) );
124         }
125 
126         File file = followSymlinks ? basedir : basedir.getCanonicalFile();
127 
128         if ( selector == null && !followSymlinks && fastDir != null && session != null )
129         {
130             // If anything wrong happens, we'll just use the usual deletion mechanism
131             if ( fastDelete( file ) )
132             {
133                 return;
134             }
135         }
136 
137         delete( file, "", selector, followSymlinks, failOnError, retryOnError );
138     }
139 
140     private boolean fastDelete( File baseDirFile )
141     {
142         Path baseDir = baseDirFile.toPath();
143         Path fastDir = this.fastDir.toPath();
144         // Handle the case where we use ${maven.multiModuleProjectDirectory}/target/.clean for example
145         if ( fastDir.toAbsolutePath().startsWith( baseDir.toAbsolutePath() ) )
146         {
147             try
148             {
149                 String prefix = baseDir.getFileName().toString() + ".";
150                 Path tmpDir = Files.createTempDirectory( baseDir.getParent(), prefix );
151                 try
152                 {
153                     Files.move( baseDir, tmpDir, StandardCopyOption.REPLACE_EXISTING );
154                     if ( session != null )
155                     {
156                         session.getRepositorySession().getData().set( LAST_DIRECTORY_TO_DELETE, baseDir.toFile() );
157                     }
158                     baseDir = tmpDir;
159                 }
160                 catch ( IOException e )
161                 {
162                     Files.delete( tmpDir );
163                     throw e;
164                 }
165             }
166             catch ( IOException e )
167             {
168                 if ( logDebug != null )
169                 {
170                     // TODO: this Logger interface cannot log exceptions and needs refactoring
171                     logDebug.log( "Unable to fast delete directory: " + e );
172                 }
173                 return false;
174             }
175         }
176         // Create fastDir and the needed parents if needed
177         try
178         {
179             if ( !Files.isDirectory( fastDir ) )
180             {
181                 Files.createDirectories( fastDir );
182             }
183         }
184         catch ( IOException e )
185         {
186             if ( logDebug != null )
187             {
188                 // TODO: this Logger interface cannot log exceptions and needs refactoring
189                 logDebug.log( "Unable to fast delete directory as the path "
190                         + fastDir + " does not point to a directory or cannot be created: " + e );
191             }
192             return false;
193         }
194 
195         try
196         {
197             Path tmpDir = Files.createTempDirectory( fastDir, "" );
198             Path dstDir = tmpDir.resolve( baseDir.getFileName() );
199             // Note that by specifying the ATOMIC_MOVE, we expect an exception to be thrown
200             // if the path leads to a directory on another mountpoint.  If this is the case
201             // or any other exception occurs, an exception will be thrown in which case
202             // the method will return false and the usual deletion will be performed.
203             Files.move( baseDir, dstDir, StandardCopyOption.ATOMIC_MOVE );
204             BackgroundCleaner.delete( this, tmpDir.toFile(), fastMode );
205             return true;
206         }
207         catch ( IOException e )
208         {
209             if ( logDebug != null )
210             {
211                 // TODO: this Logger interface cannot log exceptions and needs refactoring
212                 logDebug.log( "Unable to fast delete directory: " + e );
213             }
214             return false;
215         }
216     }
217 
218     /**
219      * Deletes the specified file or directory.
220      * 
221      * @param file The file/directory to delete, must not be <code>null</code>. If <code>followSymlinks</code> is
222      *            <code>false</code>, it is assumed that the parent file is canonical.
223      * @param pathname The relative pathname of the file, using {@link File#separatorChar}, must not be
224      *            <code>null</code>.
225      * @param selector The selector used to determine what contents to delete, may be <code>null</code> to delete
226      *            everything.
227      * @param followSymlinks Whether to follow symlinks.
228      * @param failOnError Whether to abort with an exception in case a selected file/directory could not be deleted.
229      * @param retryOnError Whether to undertake additional delete attempts in case the first attempt failed.
230      * @return The result of the cleaning, never <code>null</code>.
231      * @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
232      */
233     private Result delete( File file, String pathname, Selector selector, boolean followSymlinks, boolean failOnError,
234                            boolean retryOnError )
235         throws IOException
236     {
237         Result result = new Result();
238 
239         boolean isDirectory = file.isDirectory();
240 
241         if ( isDirectory )
242         {
243             if ( selector == null || selector.couldHoldSelected( pathname ) )
244             {
245                 final boolean isSymlink = Files.isSymbolicLink( file.toPath() );
246                 File canonical = followSymlinks ? file : file.getCanonicalFile();
247                 if ( followSymlinks || !isSymlink )
248                 {
249                     String[] filenames = canonical.list();
250                     if ( filenames != null )
251                     {
252                         String prefix = pathname.length() > 0 ? pathname + File.separatorChar : "";
253                         for ( int i = filenames.length - 1; i >= 0; i-- )
254                         {
255                             String filename = filenames[i];
256                             File child = new File( canonical, filename );
257                             result.update( delete( child, prefix + filename, selector, followSymlinks, failOnError,
258                                                    retryOnError ) );
259                         }
260                     }
261                 }
262                 else if ( logDebug != null )
263                 {
264                     logDebug.log( "Not recursing into symlink " + file );
265                 }
266             }
267             else if ( logDebug != null )
268             {
269                 logDebug.log( "Not recursing into directory without included files " + file );
270             }
271         }
272 
273         if ( !result.excluded && ( selector == null || selector.isSelected( pathname ) ) )
274         {
275             if ( logVerbose != null )
276             {
277                 if ( isDirectory )
278                 {
279                     logVerbose.log( "Deleting directory " + file );
280                 }
281                 else if ( file.exists() )
282                 {
283                     logVerbose.log( "Deleting file " + file );
284                 }
285                 else
286                 {
287                     logVerbose.log( "Deleting dangling symlink " + file );
288                 }
289             }
290             result.failures += delete( file, failOnError, retryOnError );
291         }
292         else
293         {
294             result.excluded = true;
295         }
296 
297         return result;
298     }
299 
300     /**
301      * Deletes the specified file, directory. If the path denotes a symlink, only the link is removed, its target is
302      * left untouched.
303      * 
304      * @param file The file/directory to delete, must not be <code>null</code>.
305      * @param failOnError Whether to abort with an exception in case the file/directory could not be deleted.
306      * @param retryOnError Whether to undertake additional delete attempts in case the first attempt failed.
307      * @return <code>0</code> if the file was deleted, <code>1</code> otherwise.
308      * @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
309      */
310     private int delete( File file, boolean failOnError, boolean retryOnError )
311         throws IOException
312     {
313         if ( !file.delete() )
314         {
315             boolean deleted = false;
316 
317             if ( retryOnError )
318             {
319                 if ( ON_WINDOWS )
320                 {
321                     // try to release any locks held by non-closed files
322                     System.gc();
323                 }
324 
325                 final int[] delays = { 50, 250, 750 };
326                 for ( int i = 0; !deleted && i < delays.length; i++ )
327                 {
328                     try
329                     {
330                         Thread.sleep( delays[i] );
331                     }
332                     catch ( InterruptedException e )
333                     {
334                         // ignore
335                     }
336                     deleted = file.delete() || !file.exists();
337                 }
338             }
339             else
340             {
341                 deleted = !file.exists();
342             }
343 
344             if ( !deleted )
345             {
346                 if ( failOnError )
347                 {
348                     throw new IOException( "Failed to delete " + file );
349                 }
350                 else
351                 {
352                     if ( logWarn != null )
353                     {
354                         logWarn.log( "Failed to delete " + file );
355                     }
356                     return 1;
357                 }
358             }
359         }
360 
361         return 0;
362     }
363 
364     private static class Result
365     {
366 
367         private int failures;
368 
369         private boolean excluded;
370 
371         public void update( Result result )
372         {
373             failures += result.failures;
374             excluded |= result.excluded;
375         }
376 
377     }
378 
379     private interface Logger
380     {
381 
382         void log( CharSequence message );
383 
384     }
385 
386     private static class BackgroundCleaner extends Thread
387     {
388 
389         private static BackgroundCleaner instance;
390 
391         private final Deque<File> filesToDelete = new ArrayDeque<>();
392 
393         private final Cleaner cleaner;
394 
395         private final String fastMode;
396 
397         private static final int NEW = 0;
398         private static final int RUNNING = 1;
399         private static final int STOPPED = 2;
400 
401         private int status = NEW;
402 
403         public static void delete( Cleaner cleaner, File dir, String fastMode )
404         {
405             synchronized ( BackgroundCleaner.class )
406             {
407                 if ( instance == null || !instance.doDelete( dir ) )
408                 {
409                     instance = new BackgroundCleaner( cleaner, dir, fastMode );
410                 }
411             }
412         }
413 
414         static void sessionEnd()
415         {
416             synchronized ( BackgroundCleaner.class )
417             {
418                 if ( instance != null )
419                 {
420                     instance.doSessionEnd();
421                 }
422             }
423         }
424 
425         private BackgroundCleaner( Cleaner cleaner, File dir, String fastMode )
426         {
427             super( "mvn-background-cleaner" );
428             this.cleaner = cleaner;
429             this.fastMode = fastMode;
430             init( cleaner.fastDir, dir );
431         }
432 
433         public void run()
434         {
435             while ( true )
436             {
437                 File basedir = pollNext();
438                 if ( basedir == null )
439                 {
440                     break;
441                 }
442                 try
443                 {
444                     cleaner.delete( basedir, "", null, false, false, true );
445                 }
446                 catch ( IOException e )
447                 {
448                     // do not display errors
449                 }
450             }
451         }
452 
453         synchronized void init( File fastDir, File dir )
454         {
455             if ( fastDir.isDirectory() )
456             {
457                 File[] children = fastDir.listFiles();
458                 if ( children != null && children.length > 0 )
459                 {
460                     for ( File child : children )
461                     {
462                         doDelete( child );
463                     }
464                 }
465             }
466             doDelete( dir );
467         }
468 
469         synchronized File pollNext()
470         {
471             File basedir = filesToDelete.poll();
472             if ( basedir == null )
473             {
474                 if ( cleaner.session != null )
475                 {
476                     SessionData data = cleaner.session.getRepositorySession().getData();
477                     File lastDir = ( File ) data.get( LAST_DIRECTORY_TO_DELETE );
478                     if ( lastDir != null )
479                     {
480                         data.set( LAST_DIRECTORY_TO_DELETE, null );
481                         return lastDir;
482                     }
483                 }
484                 status = STOPPED;
485                 notifyAll();
486             }
487             return basedir;
488         }
489 
490         synchronized boolean doDelete( File dir )
491         {
492             if ( status == STOPPED )
493             {
494                 return false;
495             }
496             filesToDelete.add( dir );
497             if ( status == NEW && FAST_MODE_BACKGROUND.equals( fastMode ) )
498             {
499                 status = RUNNING;
500                 notifyAll();
501                 start();
502             }
503             wrapExecutionListener();
504             return true;
505         }
506 
507         /**
508          * If this has not been done already, we wrap the ExecutionListener inside a proxy
509          * which simply delegates call to the previous listener.  When the session ends, it will
510          * also call {@link BackgroundCleaner#sessionEnd()}.
511          * There's no clean API to do that properly as this is a very unusual use case for a plugin
512          * to outlive its main execution.
513          */
514         private void wrapExecutionListener()
515         {
516             ExecutionListener executionListener = cleaner.session.getRequest().getExecutionListener();
517             if ( executionListener == null
518                     || !Proxy.isProxyClass( executionListener.getClass() )
519                     || !( Proxy.getInvocationHandler( executionListener ) instanceof SpyInvocationHandler ) )
520             {
521                 ExecutionListener listener = ( ExecutionListener ) Proxy.newProxyInstance(
522                         ExecutionListener.class.getClassLoader(),
523                         new Class[] { ExecutionListener.class },
524                         new SpyInvocationHandler( executionListener ) );
525                 cleaner.session.getRequest().setExecutionListener( listener );
526             }
527         }
528 
529         synchronized void doSessionEnd()
530         {
531             if ( status != STOPPED )
532             {
533                 if ( status == NEW )
534                 {
535                     start();
536                 }
537                 if ( !FAST_MODE_DEFER.equals( fastMode ) )
538                 {
539                     try
540                     {
541                         cleaner.logInfo.log( "Waiting for background file deletion" );
542                         while ( status != STOPPED )
543                         {
544                             wait();
545                         }
546                     }
547                     catch ( InterruptedException e )
548                     {
549                         // ignore
550                     }
551                 }
552             }
553         }
554 
555     }
556 
557     static class SpyInvocationHandler implements InvocationHandler
558     {
559         private final ExecutionListener delegate;
560 
561         SpyInvocationHandler( ExecutionListener delegate )
562         {
563             this.delegate = delegate;
564         }
565 
566         @Override
567         public Object invoke( Object proxy, Method method, Object[] args ) throws Throwable
568         {
569             if ( "sessionEnded".equals( method.getName() ) )
570             {
571                 BackgroundCleaner.sessionEnd();
572             }
573             if ( delegate != null )
574             {
575                 return method.invoke( delegate, args );
576             }
577             return null;
578         }
579 
580     }
581 
582 }