blob: 4e6e9d2812267ab3a609729e1f758209840906b1 [file] [log] [blame]
package org.apache.maven.plugins.clean;
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayDeque;
import java.util.Deque;
import org.apache.maven.execution.ExecutionListener;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.logging.Log;
import org.apache.maven.shared.utils.Os;
import org.eclipse.aether.SessionData;
import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_BACKGROUND;
import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_DEFER;
/**
* Cleans directories.
*
* @author Benjamin Bentmann
*/
class Cleaner
{
private static final boolean ON_WINDOWS = Os.isFamily( Os.FAMILY_WINDOWS );
private static final String LAST_DIRECTORY_TO_DELETE = Cleaner.class.getName() + ".lastDirectoryToDelete";
/**
* The maven session. This is typically non-null in a real run, but it can be during unit tests.
*/
private final MavenSession session;
private final Logger logDebug;
private final Logger logInfo;
private final Logger logVerbose;
private final Logger logWarn;
private final File fastDir;
private final String fastMode;
/**
* Creates a new cleaner.
* @param log The logger to use, may be <code>null</code> to disable logging.
* @param verbose Whether to perform verbose logging.
* @param fastMode The fast deletion mode
*/
Cleaner( MavenSession session, final Log log, boolean verbose, File fastDir, String fastMode )
{
logDebug = ( log == null || !log.isDebugEnabled() ) ? null : log::debug;
logInfo = ( log == null || !log.isInfoEnabled() ) ? null : log::info;
logWarn = ( log == null || !log.isWarnEnabled() ) ? null : log::warn;
logVerbose = verbose ? logInfo : logDebug;
this.session = session;
this.fastDir = fastDir;
this.fastMode = fastMode;
}
/**
* Deletes the specified directories and its contents.
*
* @param basedir The directory to delete, must not be <code>null</code>. Non-existing directories will be silently
* ignored.
* @param selector The selector used to determine what contents to delete, may be <code>null</code> to delete
* everything.
* @param followSymlinks Whether to follow symlinks.
* @param failOnError Whether to abort with an exception in case a selected file/directory could not be deleted.
* @param retryOnError Whether to undertake additional delete attempts in case the first attempt failed.
* @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
*/
public void delete( File basedir, Selector selector, boolean followSymlinks, boolean failOnError,
boolean retryOnError )
throws IOException
{
if ( !basedir.isDirectory() )
{
if ( !basedir.exists() )
{
if ( logDebug != null )
{
logDebug.log( "Skipping non-existing directory " + basedir );
}
return;
}
throw new IOException( "Invalid base directory " + basedir );
}
if ( logInfo != null )
{
logInfo.log( "Deleting " + basedir + ( selector != null ? " (" + selector + ")" : "" ) );
}
File file = followSymlinks ? basedir : basedir.getCanonicalFile();
if ( selector == null && !followSymlinks && fastDir != null && session != null )
{
// If anything wrong happens, we'll just use the usual deletion mechanism
if ( fastDelete( file ) )
{
return;
}
}
delete( file, "", selector, followSymlinks, failOnError, retryOnError );
}
private boolean fastDelete( File baseDirFile )
{
Path baseDir = baseDirFile.toPath();
Path fastDir = this.fastDir.toPath();
// Handle the case where we use ${maven.multiModuleProjectDirectory}/target/.clean for example
if ( fastDir.toAbsolutePath().startsWith( baseDir.toAbsolutePath() ) )
{
try
{
String prefix = baseDir.getFileName().toString() + ".";
Path tmpDir = Files.createTempDirectory( baseDir.getParent(), prefix );
try
{
Files.move( baseDir, tmpDir, StandardCopyOption.REPLACE_EXISTING );
if ( session != null )
{
session.getRepositorySession().getData().set( LAST_DIRECTORY_TO_DELETE, baseDir.toFile() );
}
baseDir = tmpDir;
}
catch ( IOException e )
{
Files.delete( tmpDir );
throw e;
}
}
catch ( IOException e )
{
if ( logDebug != null )
{
// TODO: this Logger interface cannot log exceptions and needs refactoring
logDebug.log( "Unable to fast delete directory: " + e );
}
return false;
}
}
// Create fastDir and the needed parents if needed
try
{
if ( !Files.isDirectory( fastDir ) )
{
Files.createDirectories( fastDir );
}
}
catch ( IOException e )
{
if ( logDebug != null )
{
// TODO: this Logger interface cannot log exceptions and needs refactoring
logDebug.log( "Unable to fast delete directory as the path "
+ fastDir + " does not point to a directory or cannot be created: " + e );
}
return false;
}
try
{
Path tmpDir = Files.createTempDirectory( fastDir, "" );
Path dstDir = tmpDir.resolve( baseDir.getFileName() );
// Note that by specifying the ATOMIC_MOVE, we expect an exception to be thrown
// if the path leads to a directory on another mountpoint. If this is the case
// or any other exception occurs, an exception will be thrown in which case
// the method will return false and the usual deletion will be performed.
Files.move( baseDir, dstDir, StandardCopyOption.ATOMIC_MOVE );
BackgroundCleaner.delete( this, tmpDir.toFile(), fastMode );
return true;
}
catch ( IOException e )
{
if ( logDebug != null )
{
// TODO: this Logger interface cannot log exceptions and needs refactoring
logDebug.log( "Unable to fast delete directory: " + e );
}
return false;
}
}
/**
* Deletes the specified file or directory.
*
* @param file The file/directory to delete, must not be <code>null</code>. If <code>followSymlinks</code> is
* <code>false</code>, it is assumed that the parent file is canonical.
* @param pathname The relative pathname of the file, using {@link File#separatorChar}, must not be
* <code>null</code>.
* @param selector The selector used to determine what contents to delete, may be <code>null</code> to delete
* everything.
* @param followSymlinks Whether to follow symlinks.
* @param failOnError Whether to abort with an exception in case a selected file/directory could not be deleted.
* @param retryOnError Whether to undertake additional delete attempts in case the first attempt failed.
* @return The result of the cleaning, never <code>null</code>.
* @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
*/
private Result delete( File file, String pathname, Selector selector, boolean followSymlinks, boolean failOnError,
boolean retryOnError )
throws IOException
{
Result result = new Result();
boolean isDirectory = file.isDirectory();
if ( isDirectory )
{
if ( selector == null || selector.couldHoldSelected( pathname ) )
{
final boolean isSymlink = Files.isSymbolicLink( file.toPath() );
File canonical = followSymlinks ? file : file.getCanonicalFile();
if ( followSymlinks || !isSymlink )
{
String[] filenames = canonical.list();
if ( filenames != null )
{
String prefix = pathname.length() > 0 ? pathname + File.separatorChar : "";
for ( int i = filenames.length - 1; i >= 0; i-- )
{
String filename = filenames[i];
File child = new File( canonical, filename );
result.update( delete( child, prefix + filename, selector, followSymlinks, failOnError,
retryOnError ) );
}
}
}
else if ( logDebug != null )
{
logDebug.log( "Not recursing into symlink " + file );
}
}
else if ( logDebug != null )
{
logDebug.log( "Not recursing into directory without included files " + file );
}
}
if ( !result.excluded && ( selector == null || selector.isSelected( pathname ) ) )
{
if ( logVerbose != null )
{
if ( isDirectory )
{
logVerbose.log( "Deleting directory " + file );
}
else if ( file.exists() )
{
logVerbose.log( "Deleting file " + file );
}
else
{
logVerbose.log( "Deleting dangling symlink " + file );
}
}
result.failures += delete( file, failOnError, retryOnError );
}
else
{
result.excluded = true;
}
return result;
}
/**
* Deletes the specified file, directory. If the path denotes a symlink, only the link is removed, its target is
* left untouched.
*
* @param file The file/directory to delete, must not be <code>null</code>.
* @param failOnError Whether to abort with an exception in case the file/directory could not be deleted.
* @param retryOnError Whether to undertake additional delete attempts in case the first attempt failed.
* @return <code>0</code> if the file was deleted, <code>1</code> otherwise.
* @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
*/
private int delete( File file, boolean failOnError, boolean retryOnError )
throws IOException
{
if ( !file.delete() )
{
boolean deleted = false;
if ( retryOnError )
{
if ( ON_WINDOWS )
{
// try to release any locks held by non-closed files
System.gc();
}
final int[] delays = { 50, 250, 750 };
for ( int i = 0; !deleted && i < delays.length; i++ )
{
try
{
Thread.sleep( delays[i] );
}
catch ( InterruptedException e )
{
// ignore
}
deleted = file.delete() || !file.exists();
}
}
else
{
deleted = !file.exists();
}
if ( !deleted )
{
if ( failOnError )
{
throw new IOException( "Failed to delete " + file );
}
else
{
if ( logWarn != null )
{
logWarn.log( "Failed to delete " + file );
}
return 1;
}
}
}
return 0;
}
private static class Result
{
private int failures;
private boolean excluded;
public void update( Result result )
{
failures += result.failures;
excluded |= result.excluded;
}
}
private interface Logger
{
void log( CharSequence message );
}
private static class BackgroundCleaner extends Thread
{
private static BackgroundCleaner instance;
private final Deque<File> filesToDelete = new ArrayDeque<>();
private final Cleaner cleaner;
private final String fastMode;
private static final int NEW = 0;
private static final int RUNNING = 1;
private static final int STOPPED = 2;
private int status = NEW;
public static void delete( Cleaner cleaner, File dir, String fastMode )
{
synchronized ( BackgroundCleaner.class )
{
if ( instance == null || !instance.doDelete( dir ) )
{
instance = new BackgroundCleaner( cleaner, dir, fastMode );
}
}
}
static void sessionEnd()
{
synchronized ( BackgroundCleaner.class )
{
if ( instance != null )
{
instance.doSessionEnd();
}
}
}
private BackgroundCleaner( Cleaner cleaner, File dir, String fastMode )
{
super( "mvn-background-cleaner" );
this.cleaner = cleaner;
this.fastMode = fastMode;
init( cleaner.fastDir, dir );
}
public void run()
{
while ( true )
{
File basedir = pollNext();
if ( basedir == null )
{
break;
}
try
{
cleaner.delete( basedir, "", null, false, false, true );
}
catch ( IOException e )
{
// do not display errors
}
}
}
synchronized void init( File fastDir, File dir )
{
if ( fastDir.isDirectory() )
{
File[] children = fastDir.listFiles();
if ( children != null && children.length > 0 )
{
for ( File child : children )
{
doDelete( child );
}
}
}
doDelete( dir );
}
synchronized File pollNext()
{
File basedir = filesToDelete.poll();
if ( basedir == null )
{
if ( cleaner.session != null )
{
SessionData data = cleaner.session.getRepositorySession().getData();
File lastDir = ( File ) data.get( LAST_DIRECTORY_TO_DELETE );
if ( lastDir != null )
{
data.set( LAST_DIRECTORY_TO_DELETE, null );
return lastDir;
}
}
status = STOPPED;
notifyAll();
}
return basedir;
}
synchronized boolean doDelete( File dir )
{
if ( status == STOPPED )
{
return false;
}
filesToDelete.add( dir );
if ( status == NEW && FAST_MODE_BACKGROUND.equals( fastMode ) )
{
status = RUNNING;
notifyAll();
start();
}
wrapExecutionListener();
return true;
}
/**
* If this has not been done already, we wrap the ExecutionListener inside a proxy
* which simply delegates call to the previous listener. When the session ends, it will
* also call {@link BackgroundCleaner#sessionEnd()}.
* There's no clean API to do that properly as this is a very unusual use case for a plugin
* to outlive its main execution.
*/
private void wrapExecutionListener()
{
ExecutionListener executionListener = cleaner.session.getRequest().getExecutionListener();
if ( executionListener == null
|| !Proxy.isProxyClass( executionListener.getClass() )
|| !( Proxy.getInvocationHandler( executionListener ) instanceof SpyInvocationHandler ) )
{
ExecutionListener listener = ( ExecutionListener ) Proxy.newProxyInstance(
ExecutionListener.class.getClassLoader(),
new Class[] { ExecutionListener.class },
new SpyInvocationHandler( executionListener ) );
cleaner.session.getRequest().setExecutionListener( listener );
}
}
synchronized void doSessionEnd()
{
if ( status != STOPPED )
{
if ( status == NEW )
{
start();
}
if ( !FAST_MODE_DEFER.equals( fastMode ) )
{
try
{
cleaner.logInfo.log( "Waiting for background file deletion" );
while ( status != STOPPED )
{
wait();
}
}
catch ( InterruptedException e )
{
// ignore
}
}
}
}
}
static class SpyInvocationHandler implements InvocationHandler
{
private final ExecutionListener delegate;
SpyInvocationHandler( ExecutionListener delegate )
{
this.delegate = delegate;
}
@Override
public Object invoke( Object proxy, Method method, Object[] args ) throws Throwable
{
if ( "sessionEnded".equals( method.getName() ) )
{
BackgroundCleaner.sessionEnd();
}
if ( delegate != null )
{
return method.invoke( delegate, args );
}
return null;
}
}
}